From 869fee08ba4a9c06d78943a7e2caa593cfc8ddf9 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 10 Jul 2026 09:33:03 +0200 Subject: [PATCH] refactor(live-atc): extract ATIS playback/loop scheduling to useAtisPlayback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves ATIS carrier/broadcast loop, TTS audio caching, METAR fallback, and the background airport-data refresh scheduler out of live-atc.vue. Also extracts the pmLog debug logger to shared/utils/pmLog.ts since it has no Vue/component dependency and multiple composables need it — threading it through every composable's parameter list would've been worse than giving it one shared home. Verified via typecheck plus a browser mount/redirect smoke test. Co-Authored-By: Claude Sonnet 5 --- app/composables/useAtisPlayback.ts | 368 +++++++++++++++++++++++++++ app/pages/live-atc.vue | 382 ++--------------------------- shared/utils/pmLog.ts | 26 ++ 3 files changed, 408 insertions(+), 368 deletions(-) create mode 100644 app/composables/useAtisPlayback.ts create mode 100644 shared/utils/pmLog.ts diff --git a/app/composables/useAtisPlayback.ts b/app/composables/useAtisPlayback.ts new file mode 100644 index 0000000..14f1bd0 --- /dev/null +++ b/app/composables/useAtisPlayback.ts @@ -0,0 +1,368 @@ +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, + normalizedFrequencyValue, + atisVariantLabel, + type AirportFrequencyEntry, + type useFrequencyPresets, +} from '~/composables/useFrequencyPresets' + +export function useAtisPlayback( + engine: ReturnType, + freq: ReturnType, + currentScreen: Ref, + signalStrength: Ref, + 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(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() + // 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 | 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 => { + 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>() + + /** Request (or reuse) the TTS audio for an ATIS broadcast. Deduped by content. */ + const requestAtisAudio = (entry: AirportFrequencyEntry, content: string): { announcement: string; promise: Promise } => { + 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, + } +} diff --git a/app/pages/live-atc.vue b/app/pages/live-atc.vue index 2815a5c..f8dfb9b 100644 --- a/app/pages/live-atc.vue +++ b/app/pages/live-atc.vue @@ -1515,17 +1515,17 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { useRouter, useRoute } from 'vue-router' import useCommunicationsEngine from "../../shared/utils/communicationsEngine"; -import { normalizeRadioPhrase, normalizeAtisForSpeech, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech'; +import { normalizeRadioPhrase, DEFAULT_AIRLINE_TELEPHONY } from '../../shared/utils/radioSpeech'; import { useAuthStore } from '~/stores/auth' import { useApi } from '~/composables/useApi' import { usePmTheme } from '~/composables/usePmTheme' import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite' import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite' import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects' -import { createAtisAudioLoop, type AtisAudioLoop } from '../../shared/utils/atisAudioLoop' import { normalizeManualFreq } from '../../shared/utils/frequency' import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry' import { encodeWav } from '../../shared/utils/wavEncoder' +import { pmLog } from '../../shared/utils/pmLog' import { useFrequencyPresets, FREQUENCY_PLACEHOLDER, @@ -1533,6 +1533,7 @@ import { normalizedFrequencyValue, type AirportFrequencyEntry, } from '~/composables/useFrequencyPresets' +import { useAtisPlayback } from '~/composables/useAtisPlayback' import type { CandidateTraceElimination, CandidateTraceEntry, @@ -1542,33 +1543,6 @@ import type { LLMDecisionTrace, } from '../../shared/types/llm' -// --------------------------------------------------------------------------- -// Debug logger — prefix [PM] so it's easy to filter in DevTools. -// Disable at runtime: localStorage.setItem('PM_DEBUG', '0') -// Re-enable: localStorage.setItem('PM_DEBUG', '1') -// Show trace detail: localStorage.setItem('PM_DEBUG', 'verbose') -// --------------------------------------------------------------------------- -const pmLog = (() => { - const level = () => { - if (typeof localStorage === 'undefined') return 'on' - return localStorage.getItem('PM_DEBUG') ?? 'on' - } - const enabled = () => level() !== '0' - const verbose = () => level() === 'verbose' - return { - info: (...a: any[]) => enabled() && console.log ('[PM]', ...a), - warn: (...a: any[]) => enabled() && console.warn ('[PM]', ...a), - error: (...a: any[]) => enabled() && console.error ('[PM]', ...a), - debug: (...a: any[]) => verbose() && console.debug ('[PM:v]', ...a), - group: (label: string, fn: () => void) => { - if (!enabled()) { fn(); return } - console.groupCollapsed(`[PM] ${label}`) - fn() - console.groupEnd() - }, - } -})() - // Core State const engine = useCommunicationsEngine() const auth = useAuthStore() @@ -2605,65 +2579,6 @@ const simulationRunning = ref(false) const simulationTrace = ref([]) const simulationError = ref('') -const atisPlaybackLoading = ref(false) -const atisLoopKey = ref(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() -// 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 | 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) -} - // Send unauthenticated visitors to login while preserving where they were // headed (e.g. /live-atc?token=… so the bridge link survives the round-trip), // instead of dropping them on the classroom fallback after sign-in. @@ -2949,6 +2864,7 @@ const stopCurrentSpeech = () => { speechQueue = Promise.resolve() } +const freq = useFrequencyPresets(engine, stopCurrentSpeech) const { frequencies, airportFrequencies, @@ -2976,7 +2892,16 @@ const { onPresetSelectActive, onPresetSelectStandby, applyManualFrequency, -} = useFrequencyPresets(engine, stopCurrentSpeech) +} = freq + +const { + atisPlaybackLoading, + atisLoopKey, + cancelAirportDataRefresh, + stopAtisLoop, + playAtisBroadcast, + prefetchAtisAudio, +} = useAtisPlayback(engine, freq, currentScreen, signalStrength, setLastTransmission) const enqueueSpeech = (task: () => Promise) => { const generation = _speechGeneration @@ -4175,250 +4100,6 @@ const sendPilotText = async () => { } -const fetchMetarText = async (icao: string | undefined): Promise => { - 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 -} - -/** "Arrival" / "Departure" variant from the station callsign (EDDF_A_ATIS / EDDF_D_ATIS). */ -const atisVariantLabel = (entry: AirportFrequencyEntry): string => { - const callsign = (entry.callsign || '').toUpperCase() - if (callsign.includes('_A_')) return 'Arrival ' - if (callsign.includes('_D_')) return 'Departure ' - // Fallback: published station name (OpenAIP) sometimes carries the variant. - const label = (entry.label || '').toLowerCase() - if (label.includes('arr')) return 'Arrival ' - if (label.includes('dep')) return 'Departure ' - return '' -} - -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>() - -/** Request (or reuse) the TTS audio for an ATIS broadcast. Deduped by content. */ -const requestAtisAudio = (entry: AirportFrequencyEntry, content: string): { announcement: string; promise: Promise } => { - 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) -} - const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i // Pilot asking ATC to repeat its last transmission. @@ -4757,41 +4438,6 @@ watch(() => activeFrequency.value, (newFreq) => { } }) -// 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 = 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 } -) - // Pre-recording: manage continuous mic capture lifecycle watch([inputMode, micPermission, prerecEnabled], ([mode, mic, enabled]) => { if (mode === 'voice' && mic && enabled) { diff --git a/shared/utils/pmLog.ts b/shared/utils/pmLog.ts new file mode 100644 index 0000000..9421b61 --- /dev/null +++ b/shared/utils/pmLog.ts @@ -0,0 +1,26 @@ +// --------------------------------------------------------------------------- +// Debug logger — prefix [PM] so it's easy to filter in DevTools. +// Disable at runtime: localStorage.setItem('PM_DEBUG', '0') +// Re-enable: localStorage.setItem('PM_DEBUG', '1') +// Show trace detail: localStorage.setItem('PM_DEBUG', 'verbose') +// --------------------------------------------------------------------------- +export const pmLog = (() => { + const level = () => { + if (typeof localStorage === 'undefined') return 'on' + return localStorage.getItem('PM_DEBUG') ?? 'on' + } + const enabled = () => level() !== '0' + const verbose = () => level() === 'verbose' + return { + info: (...a: any[]) => enabled() && console.log ('[PM]', ...a), + warn: (...a: any[]) => enabled() && console.warn ('[PM]', ...a), + error: (...a: any[]) => enabled() && console.error ('[PM]', ...a), + debug: (...a: any[]) => verbose() && console.debug ('[PM:v]', ...a), + group: (label: string, fn: () => void) => { + if (!enabled()) { fn(); return } + console.groupCollapsed(`[PM] ${label}`) + fn() + console.groupEnd() + }, + } +})()