mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-09 19:16:02 +08:00
refactor(live-atc): extract ATIS playback/loop scheduling to useAtisPlayback
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 <noreply@anthropic.com>
This commit is contained in:
368
app/composables/useAtisPlayback.ts
Normal file
368
app/composables/useAtisPlayback.ts
Normal file
@@ -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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1515,17 +1515,17 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import useCommunicationsEngine from "../../shared/utils/communicationsEngine";
|
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 { useAuthStore } from '~/stores/auth'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { usePmTheme } from '~/composables/usePmTheme'
|
import { usePmTheme } from '~/composables/usePmTheme'
|
||||||
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
import { loadPizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||||
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
import type { PizzicatoLite } from '../../shared/utils/pizzicatoLite'
|
||||||
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
|
import { createNoiseGenerators, getReadabilityProfile } from '../../shared/utils/radioEffects'
|
||||||
import { createAtisAudioLoop, type AtisAudioLoop } from '../../shared/utils/atisAudioLoop'
|
|
||||||
import { normalizeManualFreq } from '../../shared/utils/frequency'
|
import { normalizeManualFreq } from '../../shared/utils/frequency'
|
||||||
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
import { normalizeSimFreq, normalizeBridgeTelemetry, telemetrySignature } from '../../shared/utils/bridgeTelemetry'
|
||||||
import { encodeWav } from '../../shared/utils/wavEncoder'
|
import { encodeWav } from '../../shared/utils/wavEncoder'
|
||||||
|
import { pmLog } from '../../shared/utils/pmLog'
|
||||||
import {
|
import {
|
||||||
useFrequencyPresets,
|
useFrequencyPresets,
|
||||||
FREQUENCY_PLACEHOLDER,
|
FREQUENCY_PLACEHOLDER,
|
||||||
@@ -1533,6 +1533,7 @@ import {
|
|||||||
normalizedFrequencyValue,
|
normalizedFrequencyValue,
|
||||||
type AirportFrequencyEntry,
|
type AirportFrequencyEntry,
|
||||||
} from '~/composables/useFrequencyPresets'
|
} from '~/composables/useFrequencyPresets'
|
||||||
|
import { useAtisPlayback } from '~/composables/useAtisPlayback'
|
||||||
import type {
|
import type {
|
||||||
CandidateTraceElimination,
|
CandidateTraceElimination,
|
||||||
CandidateTraceEntry,
|
CandidateTraceEntry,
|
||||||
@@ -1542,33 +1543,6 @@ import type {
|
|||||||
LLMDecisionTrace,
|
LLMDecisionTrace,
|
||||||
} from '../../shared/types/llm'
|
} 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
|
// Core State
|
||||||
const engine = useCommunicationsEngine()
|
const engine = useCommunicationsEngine()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -2605,65 +2579,6 @@ const simulationRunning = ref(false)
|
|||||||
const simulationTrace = ref<SimulationTraceEntry[]>([])
|
const simulationTrace = ref<SimulationTraceEntry[]>([])
|
||||||
const simulationError = ref('')
|
const simulationError = ref('')
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send unauthenticated visitors to login while preserving where they were
|
// Send unauthenticated visitors to login while preserving where they were
|
||||||
// headed (e.g. /live-atc?token=… so the bridge link survives the round-trip),
|
// 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.
|
// instead of dropping them on the classroom fallback after sign-in.
|
||||||
@@ -2949,6 +2864,7 @@ const stopCurrentSpeech = () => {
|
|||||||
speechQueue = Promise.resolve()
|
speechQueue = Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const freq = useFrequencyPresets(engine, stopCurrentSpeech)
|
||||||
const {
|
const {
|
||||||
frequencies,
|
frequencies,
|
||||||
airportFrequencies,
|
airportFrequencies,
|
||||||
@@ -2976,7 +2892,16 @@ const {
|
|||||||
onPresetSelectActive,
|
onPresetSelectActive,
|
||||||
onPresetSelectStandby,
|
onPresetSelectStandby,
|
||||||
applyManualFrequency,
|
applyManualFrequency,
|
||||||
} = useFrequencyPresets(engine, stopCurrentSpeech)
|
} = freq
|
||||||
|
|
||||||
|
const {
|
||||||
|
atisPlaybackLoading,
|
||||||
|
atisLoopKey,
|
||||||
|
cancelAirportDataRefresh,
|
||||||
|
stopAtisLoop,
|
||||||
|
playAtisBroadcast,
|
||||||
|
prefetchAtisAudio,
|
||||||
|
} = useAtisPlayback(engine, freq, currentScreen, signalStrength, setLastTransmission)
|
||||||
|
|
||||||
const enqueueSpeech = (task: () => Promise<void>) => {
|
const enqueueSpeech = (task: () => Promise<void>) => {
|
||||||
const generation = _speechGeneration
|
const generation = _speechGeneration
|
||||||
@@ -4175,250 +4100,6 @@ const sendPilotText = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "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<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i
|
const RADIO_CHECK_RE = /\b(radio|signal)\s*check\b|how do you read/i
|
||||||
|
|
||||||
// Pilot asking ATC to repeat its last transmission.
|
// 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
|
// Pre-recording: manage continuous mic capture lifecycle
|
||||||
watch([inputMode, micPermission, prerecEnabled], ([mode, mic, enabled]) => {
|
watch([inputMode, micPermission, prerecEnabled], ([mode, mic, enabled]) => {
|
||||||
if (mode === 'voice' && mic && enabled) {
|
if (mode === 'voice' && mic && enabled) {
|
||||||
|
|||||||
26
shared/utils/pmLog.ts
Normal file
26
shared/utils/pmLog.ts
Normal file
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
Reference in New Issue
Block a user