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:
itsrubberduck
2026-05-27 14:43:48 +02:00
parent 4ac8c1104a
commit 22f50aa403
2 changed files with 145 additions and 27 deletions

View File

@@ -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>

View File

@@ -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,