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>