mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 17:05:53 +08:00
feat(atis): clock-locked ATIS loop tied to tuned frequency
Real ATIS broadcasts run continuously; tuning in mid-broadcast should drop the pilot into the current spoken position, then loop. The frontend now generates the full announcement once via TTS, plays it as a looping HTMLAudioElement, and seeks to ((Date.now() - lastUpdated) / duration) on metadata-load so all clients tuned to the same ATIS hear it phase- synced. The loop starts/stops automatically with frequency tuning and restarts on info-letter change. say.post.ts now caches tag=atis like tag=flightlab to avoid re-synthesizing identical announcements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
157
app/pages/pm.vue
157
app/pages/pm.vue
@@ -983,7 +983,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
||||
import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech';
|
||||
@@ -1526,6 +1526,9 @@ const airportFrequencies = ref<AirportFrequencyEntry[]>([])
|
||||
const airportFrequencyLoading = ref(false)
|
||||
const frequencySources = ref({ vatsim: false, openaip: false })
|
||||
const atisPlaybackLoading = ref(false)
|
||||
const atisLoopAudio = ref<HTMLAudioElement | null>(null)
|
||||
const atisLoopKey = ref<string | null>(null)
|
||||
let atisLoopSeq = 0
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -2649,42 +2652,120 @@ const buildAtisAnnouncement = (entry: AirportFrequencyEntry, fallback?: string):
|
||||
.replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
const playAtisBroadcast = async () => {
|
||||
const atisEntry = atisFrequencyEntry.value
|
||||
if (!atisEntry) return
|
||||
const buildAtisLoopKey = (entry: AirportFrequencyEntry): string => {
|
||||
const icao = (flightContext.value.dep || 'XXXX').toUpperCase()
|
||||
const code = entry.atisCode || '-'
|
||||
const text = (entry.atisText || '').trim()
|
||||
return `${icao}|${code}|${text.length}`
|
||||
}
|
||||
|
||||
setActiveFrequencyFromList(atisEntry)
|
||||
const resolveAtisEpoch = (entry: AirportFrequencyEntry): number => {
|
||||
if (entry.lastUpdated) {
|
||||
const parsed = Date.parse(entry.lastUpdated)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
const stopAtisLoop = () => {
|
||||
const audio = atisLoopAudio.value
|
||||
if (audio) {
|
||||
try {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
} catch (err) {
|
||||
pmLog.warn('ATIS loop stop failed', err)
|
||||
}
|
||||
}
|
||||
atisLoopAudio.value = null
|
||||
atisLoopKey.value = null
|
||||
atisLoopSeq += 1
|
||||
}
|
||||
|
||||
const startAtisLoop = async (entry: AirportFrequencyEntry) => {
|
||||
if (!entry) return
|
||||
|
||||
const desiredKey = buildAtisLoopKey(entry)
|
||||
if (atisLoopAudio.value && atisLoopKey.value === desiredKey) {
|
||||
return
|
||||
}
|
||||
|
||||
stopAtisLoop()
|
||||
|
||||
let content = (entry.atisText || '').trim()
|
||||
if (!content) {
|
||||
const metar = await fetchMetarText(flightContext.value.dep)
|
||||
if (metar) {
|
||||
content = `METAR ${metar}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
setLastTransmission('ATIS: No current information available')
|
||||
return
|
||||
}
|
||||
|
||||
const announcement = buildAtisAnnouncement({ ...entry, atisText: content })
|
||||
const epoch = resolveAtisEpoch(entry)
|
||||
const requestSeq = ++atisLoopSeq
|
||||
atisPlaybackLoading.value = true
|
||||
|
||||
try {
|
||||
let content = atisEntry.atisText || ''
|
||||
if (!content) {
|
||||
const metar = await fetchMetarText(flightContext.value.dep)
|
||||
if (metar) {
|
||||
content = `METAR ${metar}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
setLastTransmission('ATIS: No current information available')
|
||||
return
|
||||
}
|
||||
|
||||
const announcement = buildAtisAnnouncement({ ...atisEntry, atisText: content })
|
||||
await speakPlainText(announcement, {
|
||||
const response = await api.post('/api/atc/say', {
|
||||
text: announcement,
|
||||
level: signalStrength.value,
|
||||
voice: 'verse',
|
||||
speed: 0.9,
|
||||
tag: 'atis-broadcast',
|
||||
moduleId: 'pilot-monitoring',
|
||||
lessonId: 'atis',
|
||||
lastTransmissionLabel: `ATIS: ${announcement}`
|
||||
tag: 'atis',
|
||||
sessionId: engineSessionId.value || flags.value.session_id || undefined,
|
||||
})
|
||||
|
||||
if (requestSeq !== atisLoopSeq) return
|
||||
if (!response?.success || !response.audio) return
|
||||
|
||||
const dataUrl = `data:${response.audio.mime || 'audio/wav'};base64,${response.audio.base64}`
|
||||
const audio = new Audio(dataUrl)
|
||||
audio.loop = true
|
||||
audio.preload = 'auto'
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (requestSeq !== atisLoopSeq) return
|
||||
const duration = audio.duration
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
audio.play().catch(err => pmLog.warn('ATIS loop play failed', err))
|
||||
return
|
||||
}
|
||||
const offset = ((Date.now() - epoch) / 1000) % duration
|
||||
audio.currentTime = offset < 0 ? offset + duration : offset
|
||||
audio.play().catch(err => pmLog.warn('ATIS loop play failed', err))
|
||||
}, { once: true })
|
||||
|
||||
audio.addEventListener('error', (err) => {
|
||||
pmLog.warn('ATIS loop audio error', err)
|
||||
})
|
||||
|
||||
atisLoopAudio.value = audio
|
||||
atisLoopKey.value = desiredKey
|
||||
setLastTransmission(`ATIS: ${announcement}`)
|
||||
} catch (err) {
|
||||
console.error('ATIS playback failed:', err)
|
||||
pmLog.error('ATIS loop TTS failed', err)
|
||||
} finally {
|
||||
atisPlaybackLoading.value = false
|
||||
if (requestSeq === atisLoopSeq) {
|
||||
atisPlaybackLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const playAtisBroadcast = async () => {
|
||||
const atisEntry = atisFrequencyEntry.value
|
||||
if (!atisEntry) return
|
||||
setActiveFrequencyFromList(atisEntry)
|
||||
await startAtisLoop(atisEntry)
|
||||
}
|
||||
|
||||
const performRadioCheck = async () => {
|
||||
if (!flightContext.value.callsign) return
|
||||
|
||||
@@ -3019,6 +3100,34 @@ watch(() => activeFrequency.value, (newFreq) => {
|
||||
frequencies.value.active = newFreq
|
||||
}
|
||||
})
|
||||
|
||||
// ATIS loop: start when ATIS frequency is tuned, stop otherwise, restart on info-letter change
|
||||
watch(
|
||||
() => {
|
||||
const entry = atisFrequencyEntry.value
|
||||
if (!entry?.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) return null
|
||||
const active = normalizedFrequencyValue(frequencies.value.active)
|
||||
const atisFreq = normalizedFrequencyValue(entry.frequency)
|
||||
if (!active || active !== atisFreq) return null
|
||||
return {
|
||||
entry,
|
||||
key: buildAtisLoopKey(entry),
|
||||
}
|
||||
},
|
||||
(next, prev) => {
|
||||
if (!next) {
|
||||
if (atisLoopAudio.value) stopAtisLoop()
|
||||
return
|
||||
}
|
||||
if (prev && prev.key === next.key && atisLoopAudio.value) return
|
||||
startAtisLoop(next.entry)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAtisLoop()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
51
docs/plans/2026-05-27-atis-virtual-clock-loop-design.md
Normal file
51
docs/plans/2026-05-27-atis-virtual-clock-loop-design.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# ATIS Virtual-Clock Loop
|
||||
|
||||
## Problem
|
||||
Heute spielt der "Play ATIS"-Button die VATSIM-ATIS-Ansage genau einmal ab. In der Realität läuft ATIS dauerhaft; wer auf die Frequenz tuned, steigt mitten im Satz ein und hört dann durch bis zum Wiederanfang. Das wollen wir nachbilden, ohne den TTS-Stack komplexer zu machen.
|
||||
|
||||
## Lösung
|
||||
Vollständige ATIS-Ansage einmal synthetisieren, im Frontend als `<audio>`-Element loopen, und beim Tunen den `currentTime`-Offset aus einer virtuellen Uhr ableiten.
|
||||
|
||||
```
|
||||
offset_seconds = ((Date.now() - epoch_ms) / 1000) % audio.duration
|
||||
```
|
||||
|
||||
`epoch_ms` = `Date.parse(entry.lastUpdated)` von der VATSIM-ATIS (Fallback `Date.now()`). Damit sind alle Clients, die gleichzeitig tunen, phasensynchron — und beim ATIS-Update (neuer Info-Letter) startet die Phase neu, was sich richtig anfühlt.
|
||||
|
||||
## Verworfene Alternativen
|
||||
- **Server schneidet Audio passend** — bricht TTS-Caching, geht nicht mit Black-Box-TTS (OpenAI), unnötiger Komplexitätszuwachs.
|
||||
- **Client-Epoch statt VATSIM-Epoch** — funktioniert, verliert aber Multi-Client-Sync ohne Gewinn.
|
||||
|
||||
## Backend
|
||||
- `server/api/atc/say.post.ts`: `tag: 'atis'` aktiviert dieselbe Disk-Cache-Logik wie `tag: 'flightlab'`. Cache-Key = sha256 über normalisiertem Text + Voice/Speed/Provider. So wird identische ATIS-Ansage nicht jedes Mal neu synthetisiert.
|
||||
- Kein neuer Endpoint. Der ATIS-Text kommt weiterhin aus `GET /api/airports/[icao]/frequencies`.
|
||||
|
||||
## Frontend (`app/pages/pm.vue`)
|
||||
**Neue State-Refs**
|
||||
- `atisLoopAudio: Ref<HTMLAudioElement | null>` — aktives Loop-Element
|
||||
- `atisLoopKey: Ref<string | null>` — `${icao}:${atisCode}` zum Erkennen, ob Restart nötig
|
||||
- `atisLoopSeq` — Monotoner Counter gegen Race-Conditions (TTS-Response trifft nach Weg-Tunen ein)
|
||||
|
||||
**Neue Funktionen**
|
||||
- `startAtisLoop(entry)` — Idempotent. Wenn schon der gleiche Key läuft, nichts tun. Sonst: POST `/api/atc/say` mit `tag: 'atis'`, Audio-Element bauen, im `loadedmetadata`-Handler Offset setzen, `play()`.
|
||||
- `stopAtisLoop()` — Pause + `src` lösen + Refs nullen.
|
||||
|
||||
**Trigger**
|
||||
- Watcher: wenn aktive Frequenz ATIS ist (`atisFrequencyEntry` matched aktive Freq), `startAtisLoop`. Sonst `stopAtisLoop`.
|
||||
- Watcher auf `atisFrequencyEntry.atisCode` — bei Wechsel Restart.
|
||||
- Bestehender Button (`playAtisBroadcast`) ruft `startAtisLoop` (idempotent, falls Auto-Trigger das schon gemacht hat).
|
||||
|
||||
**Keine Radio-FX**
|
||||
Der Pizzicato-Effekt-Chain ist auf One-Shot-Wiedergabe ausgelegt und passt nicht zum Loop. ATIS klingt im echten Funk sowieso anders (stationäre Sendung statt komprimierter ATC-TX). Radio-FX im Loop wäre ein separates Follow-up.
|
||||
|
||||
## Edge Cases
|
||||
- Kein `atisText` → Loop startet nicht.
|
||||
- TTS-Fehler → Loop startet nicht, keine UI-Eskalation.
|
||||
- Weg-Tunen während TTS-Request läuft → `atisLoopSeq`-Check verwirft die Response.
|
||||
- ATIS-Code-Wechsel → Stop & Restart mit neuem Audio.
|
||||
- Tab-Background / Browser-Throttling → kein Sondercode; `audio.loop = true` läuft weiter.
|
||||
|
||||
## Out of Scope
|
||||
- Radio-FX im Loop
|
||||
- ATIS-Lautstärke unabhängig vom ATC-TX
|
||||
- Cross-fade beim ATIS-Update
|
||||
@@ -25,6 +25,14 @@ function isFlightLabTag(tag?: string) {
|
||||
return (tag || "").trim().toLowerCase() === "flightlab";
|
||||
}
|
||||
|
||||
function isAtisTag(tag?: string) {
|
||||
return (tag || "").trim().toLowerCase() === "atis";
|
||||
}
|
||||
|
||||
function isCacheableTag(tag?: string) {
|
||||
return isFlightLabTag(tag) || isAtisTag(tag);
|
||||
}
|
||||
|
||||
type TTSProvider = "openai" | "speaches" | "piper";
|
||||
|
||||
function resolveTtsProvider(useSpeaches: boolean, usePiper: boolean): TTSProvider {
|
||||
@@ -196,7 +204,8 @@ export default defineEventHandler(async (event) => {
|
||||
const ext = fmtToExt(fmt);
|
||||
const outputExt = (provider === "openai" || provider === "piper") ? "wav" : ext;
|
||||
const flightlabRequest = isFlightLabTag(body?.tag);
|
||||
const flightlabCacheKey = flightlabRequest
|
||||
const cacheableRequest = isCacheableTag(body?.tag);
|
||||
const flightlabCacheKey = cacheableRequest
|
||||
? buildFlightLabCacheKey({
|
||||
normalized,
|
||||
level,
|
||||
@@ -207,7 +216,7 @@ export default defineEventHandler(async (event) => {
|
||||
model: providerModel
|
||||
})
|
||||
: null;
|
||||
const flightlabCacheBaseDir = flightlabRequest ? flightLabCacheDir() : null;
|
||||
const flightlabCacheBaseDir = cacheableRequest ? flightLabCacheDir() : null;
|
||||
const flightlabCachedAudioPath = (flightlabCacheBaseDir && flightlabCacheKey)
|
||||
? join(flightlabCacheBaseDir, `${flightlabCacheKey}.${outputExt}`)
|
||||
: null;
|
||||
@@ -286,7 +295,7 @@ export default defineEventHandler(async (event) => {
|
||||
{
|
||||
key: flightlabCacheKey,
|
||||
createdAt: timestamp,
|
||||
tag: "flightlab",
|
||||
tag: (body?.tag || "").trim().toLowerCase() || "flightlab",
|
||||
voice,
|
||||
speed,
|
||||
level,
|
||||
|
||||
Reference in New Issue
Block a user