Files
OpenSquawk/app/composables/useAtisPlayback.ts
itsrubberduck 91e124f54d feat(live-atc): optional auto-tune after a frequency handoff
Tuning is manual, so after a handoff nothing the pilot says goes through until
they dial the new frequency in. With the setting on, the radio does it for them
three seconds after the handoff was accepted, announcing "OpenSquawk changing
frequency to …" first so it is never a surprise.

Whether a change is due is decided from state rather than from an event, which
is what makes the two must-not-tune cases safe without a special case: a
frequency readback that was wrong and one not yet given both leave the session
on a state that still expects the frequency already dialled in, so nothing is
due. A pending change is dropped if the session ends or the pilot reaches for
the radio themselves — theirs wins.

Off by default: working the radio is part of what is being practised, so
handing it to the aircraft has to be a deliberate choice. The decision itself
is a pure function and covered by tests; the announcement goes through the same
speech and comm-log path as everything else, so the browser sim and the bridge
both see the tuned frequency the way they already do for a manual change.

Also folds the two copies of normalizedFrequencyValue into one in shared/, so
the auto-tune logic can compare frequencies without a composable import.
2026-07-27 00:51:53 +02:00

369 lines
12 KiB
TypeScript

import { ref, watch, type Ref } from 'vue'
import { useApi } from '~/composables/useApi'
import useCommunicationsEngine from '../../shared/utils/communicationsEngine'
import { normalizeAtisForSpeech } from '../../shared/utils/radioSpeech'
import { createAtisAudioLoop, type AtisAudioLoop } from '../../shared/utils/atisAudioLoop'
import { pmLog } from '../../shared/utils/pmLog'
import {
FREQUENCY_PLACEHOLDER,
atisVariantLabel,
type AirportFrequencyEntry,
type useFrequencyPresets,
} from '~/composables/useFrequencyPresets'
import { normalizedFrequencyValue } from '~~/shared/utils/frequency'
export function useAtisPlayback(
engine: ReturnType<typeof useCommunicationsEngine>,
freq: ReturnType<typeof useFrequencyPresets>,
currentScreen: Ref<string>,
signalStrength: Ref<number>,
setLastTransmission: (text: string) => void,
) {
const { flightContext, sessionId: engineSessionId, flags } = engine
const {
activeAirportIcao, airportName, atisEntries, atisFrequencyEntry,
setActiveFrequencyFromList, fetchAirportFrequencies,
} = freq
const api = useApi()
const atisPlaybackLoading = ref(false)
const atisLoopKey = ref<string | null>(null)
let atisAudioLoop: AtisAudioLoop | null = null
let atisLoopActive = false
let atisLoopSeq = 0
// Virtual ATIS start times per loop key. Keeps the broadcast position stable
// across tune-out/tune-in: re-tuning resumes where the loop would be if it
// had been playing continuously.
const atisEpochByKey = new Map<string, number>()
// Airport-data refresh scheduling. VATSIM ATIS regenerates from real-world
// METARs, which publish at :20/:50 — the new info letter shows up on the
// datafeed a couple of minutes later, so refetch at :23 and :53. When no
// ATIS is on the feed yet (station may log on mid-session), poll more often.
let airportDataRefreshTimer: ReturnType<typeof setTimeout> | null = null
const ATIS_REFRESH_MINUTES = [23, 53]
const ATIS_RETRY_NO_DATA_MS = 5 * 60 * 1000
const cancelAirportDataRefresh = () => {
if (airportDataRefreshTimer) {
clearTimeout(airportDataRefreshTimer)
airportDataRefreshTimer = null
}
}
const msUntilNextAtisSlot = (): number => {
const now = new Date()
let best = Infinity
for (const hourOffset of [0, 1]) {
for (const minute of ATIS_REFRESH_MINUTES) {
const candidate = new Date(now)
candidate.setHours(now.getHours() + hourOffset, minute, 0, 0)
const delta = candidate.getTime() - now.getTime()
if (delta > 0 && delta < best) best = delta
}
}
return best
}
const scheduleAirportDataRefresh = () => {
cancelAirportDataRefresh()
const hasAtisData = Boolean(atisFrequencyEntry.value?.atisText)
const slotDelay = msUntilNextAtisSlot()
// With live ATIS data wait for the next METAR slot; without it, also retry
// sooner in case the ATIS station logs on mid-session.
const delay = hasAtisData ? slotDelay : Math.min(slotDelay, ATIS_RETRY_NO_DATA_MS)
airportDataRefreshTimer = setTimeout(async () => {
airportDataRefreshTimer = null
if (currentScreen.value === 'monitor' && activeAirportIcao.value) {
await fetchAirportFrequencies(activeAirportIcao.value, { silent: true })
// Pre-generate audio for a changed info letter so the loop restart
// (triggered by the key change) plays without a generation gap.
prefetchAtisAudio()
}
if (currentScreen.value === 'monitor') {
scheduleAirportDataRefresh()
}
}, delay)
}
const fetchMetarText = async (icao: string | undefined): Promise<string | null> => {
if (!icao) return null
try {
const response = await api.get('/api/vatsim/metar', { query: { id: icao } })
if (typeof response === 'string') {
const trimmed = response.trim()
return trimmed.length ? trimmed : null
}
} catch (err) {
console.error('Failed to fetch METAR:', err)
}
return null
}
const buildAtisAnnouncement = (entry: AirportFrequencyEntry, fallback?: string): string => {
const parts: string[] = []
const icao = activeAirportIcao.value || flightContext.value.dep
const variant = atisVariantLabel(entry)
const location = icao ? `${icao} ${variant}ATIS` : `${variant}ATIS`
parts.push(location)
if (entry.atisCode) {
parts.push(`Information ${entry.atisCode}`)
}
if (entry.atisText) {
parts.push(entry.atisText)
} else if (fallback) {
parts.push(fallback)
}
if (!entry.frequency || entry.frequency === FREQUENCY_PLACEHOLDER) {
parts.push('Frequency unavailable')
} else {
parts.push(`Frequency ${entry.frequency}`)
}
return parts
.map(segment => segment.trim())
.filter(Boolean)
.join('. ')
.replace(/\s+/g, ' ')
}
const buildAtisLoopKey = (entry: AirportFrequencyEntry): string => {
const icao = (activeAirportIcao.value || flightContext.value.dep || 'XXXX').toUpperCase()
// Station identity: with separate Arrival/Departure ATIS, each station
// gets its own loop (own content, own virtual-clock epoch).
const station = (entry.callsign || normalizedFrequencyValue(entry.frequency) || '-').toUpperCase()
const code = entry.atisCode || '-'
const text = (entry.atisText || '').trim()
return `${icao}|${station}|${code}|${text.length}`
}
const resolveAtisEpoch = (entry: AirportFrequencyEntry): number => {
if (entry.lastUpdated) {
const parsed = Date.parse(entry.lastUpdated)
if (Number.isFinite(parsed)) return parsed
}
// No timestamp from VATSIM — pin a stable epoch per ATIS content so that
// tuning away and back resumes mid-broadcast instead of restarting at zero.
const key = buildAtisLoopKey(entry)
const cached = atisEpochByKey.get(key)
if (cached) return cached
const epoch = Date.now()
atisEpochByKey.set(key, epoch)
return epoch
}
const ensureAtisAudioLoop = (): AtisAudioLoop => {
if (!atisAudioLoop) {
atisAudioLoop = createAtisAudioLoop()
}
return atisAudioLoop
}
const stopAtisLoop = () => {
atisLoopSeq += 1
atisLoopActive = false
atisLoopKey.value = null
if (atisAudioLoop) {
try {
atisAudioLoop.stop()
} catch (err) {
pmLog.warn('ATIS loop stop failed', err)
}
}
}
// TTS audio cache per ATIS broadcast (keyed by loop key + signal level) so
// tune-in is instant when the audio was prefetched or heard before.
const atisAudioRequestCache = new Map<string, Promise<any | null>>()
/** Request (or reuse) the TTS audio for an ATIS broadcast. Deduped by content. */
const requestAtisAudio = (entry: AirportFrequencyEntry, content: string): { announcement: string; promise: Promise<any | null> } => {
const announcement = buildAtisAnnouncement({ ...entry, atisText: content })
const cacheKey = `${buildAtisLoopKey(entry)}|${signalStrength.value}`
let promise = atisAudioRequestCache.get(cacheKey)
if (!promise) {
const spokenAnnouncement = normalizeAtisForSpeech(announcement, {
airportIcao: activeAirportIcao.value || flightContext.value.dep,
airportName: airportName.value,
})
promise = api.post('/api/atc/say', {
text: spokenAnnouncement,
preNormalized: true,
level: signalStrength.value,
voice: 'verse',
speed: 0.9,
moduleId: 'pilot-monitoring',
lessonId: 'atis',
tag: 'atis',
sessionId: engineSessionId.value || flags.value.session_id || undefined,
}).catch((err: any) => {
// Drop failed requests from the cache so a retune retries.
atisAudioRequestCache.delete(cacheKey)
pmLog.error('ATIS TTS failed', err)
return null
})
atisAudioRequestCache.set(cacheKey, promise)
}
return { announcement, promise }
}
/** Pre-generate TTS for all ATIS broadcasts so the first tune-in plays instantly. */
const prefetchAtisAudio = () => {
for (const entry of atisEntries.value) {
const content = (entry.atisText || '').trim()
if (!content) continue
requestAtisAudio(entry, content)
}
}
const startAtisLoop = async (entry: AirportFrequencyEntry) => {
if (!entry) return
const desiredKey = buildAtisLoopKey(entry)
if (atisLoopActive && atisLoopKey.value === desiredKey) {
return
}
stopAtisLoop()
// Carrier-Noise SOFORT — user gets feedback before TTS is back
const loop = ensureAtisAudioLoop()
try {
loop.startLoading()
} catch (err) {
pmLog.warn('ATIS carrier start failed', err)
}
atisLoopActive = true
atisLoopKey.value = desiredKey
let content = (entry.atisText || '').trim()
if (!content) {
const metar = await fetchMetarText(activeAirportIcao.value || flightContext.value.dep)
if (metar) {
content = `METAR ${metar}`
}
}
if (!content) {
setLastTransmission('ATIS: No current information available')
// Lass den Carrier laufen — Pilot hört dass die Frequenz aktiv ist, nur keine Ansage
return
}
const { announcement, promise } = requestAtisAudio(entry, content)
const epoch = resolveAtisEpoch(entry)
const requestSeq = ++atisLoopSeq
atisPlaybackLoading.value = true
try {
const response = await promise
if (requestSeq !== atisLoopSeq) return
if (!response?.success || !response.audio) {
pmLog.warn('ATIS loop TTS returned no audio')
atisLoopActive = false
atisLoopKey.value = null
return
}
const result = await loop.startBroadcast({
audioBase64: response.audio.base64,
mime: response.audio.mime,
epochMs: epoch,
})
if (requestSeq !== atisLoopSeq) return
if (!result) {
pmLog.warn('ATIS loop broadcast start returned null (decode failed?)')
// Mark inactive so a retune triggers a fresh attempt instead of being
// swallowed by the "already active on this key" early-return.
atisLoopActive = false
atisLoopKey.value = null
return
}
pmLog.info('ATIS loop start', {
icao: activeAirportIcao.value || flightContext.value.dep,
atisCode: entry.atisCode,
duration: result.duration,
requestedOffset: result.requestedOffset,
epochAgeSec: (Date.now() - epoch) / 1000,
})
setLastTransmission(`ATIS: ${announcement}`)
} catch (err) {
pmLog.error('ATIS loop start failed', err)
if (requestSeq === atisLoopSeq) {
atisLoopActive = false
atisLoopKey.value = null
}
} finally {
if (requestSeq === atisLoopSeq) {
atisPlaybackLoading.value = false
}
}
}
const playAtisBroadcast = async () => {
const atisEntry = atisFrequencyEntry.value
if (!atisEntry) return
setActiveFrequencyFromList(atisEntry)
await startAtisLoop(atisEntry)
}
// Background airport-data refresh while monitoring, aligned to METAR
// publication slots (:23/:53 — see ATIS_REFRESH_MINUTES). The silent fetch
// never blanks the list or retunes the radio; when the info letter changes,
// the ATIS-loop watcher below sees a new key and restarts the broadcast.
watch(currentScreen, (screen) => {
if (screen === 'monitor') {
scheduleAirportDataRefresh()
} else {
cancelAirportDataRefresh()
}
}, { immediate: true })
// ATIS loop: start when any ATIS frequency is tuned (arrival/departure ATIS
// are separate stations with own content), stop when tuned away, restart on
// info-letter change or when switching between ATIS stations.
watch(
() => {
const entry = freq.tunedAtisEntry.value
if (!entry) return null
return {
entry,
key: buildAtisLoopKey(entry),
}
},
(next, prev) => {
if (!next) {
if (atisLoopActive) stopAtisLoop()
return
}
if (prev && prev.key === next.key && atisLoopActive) return
startAtisLoop(next.entry)
},
{ immediate: true },
)
return {
atisPlaybackLoading,
atisLoopKey,
cancelAirportDataRefresh,
scheduleAirportDataRefresh,
fetchMetarText,
buildAtisAnnouncement,
buildAtisLoopKey,
stopAtisLoop,
startAtisLoop,
playAtisBroadcast,
prefetchAtisAudio,
}
}