diff --git a/app/pages/pm.vue b/app/pages/pm.vue index 503bb98..de6527a 100644 --- a/app/pages/pm.vue +++ b/app/pages/pm.vue @@ -992,6 +992,7 @@ import { useApi } from '~/composables/useApi' 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 type { CandidateTraceElimination, CandidateTraceEntry, @@ -1526,8 +1527,9 @@ const airportFrequencies = ref([]) const airportFrequencyLoading = ref(false) const frequencySources = ref({ vatsim: false, openaip: false }) const atisPlaybackLoading = ref(false) -const atisLoopAudio = ref(null) const atisLoopKey = ref(null) +let atisAudioLoop: AtisAudioLoop | null = null +let atisLoopActive = false let atisLoopSeq = 0 onMounted(async () => { @@ -2667,32 +2669,46 @@ const resolveAtisEpoch = (entry: AirportFrequencyEntry): number => { return Date.now() } +const ensureAtisAudioLoop = (): AtisAudioLoop => { + if (!atisAudioLoop) { + atisAudioLoop = createAtisAudioLoop() + } + return atisAudioLoop +} + const stopAtisLoop = () => { - const audio = atisLoopAudio.value - if (audio) { + atisLoopSeq += 1 + atisLoopActive = false + atisLoopKey.value = null + if (atisAudioLoop) { try { - audio.pause() - audio.removeAttribute('src') - audio.load() + atisAudioLoop.stop() } 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) { + 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(flightContext.value.dep) @@ -2703,6 +2719,7 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => { if (!content) { setLastTransmission('ATIS: No current information available') + // Lass den Carrier laufen — Pilot hört dass die Frequenz aktiv ist, nur keine Ansage return } @@ -2727,29 +2744,26 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => { 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) + 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?)') + return + } + + pmLog.info('ATIS loop start', { + icao: flightContext.value.dep, + atisCode: entry.atisCode, + duration: result.duration, + requestedOffset: result.requestedOffset, + epochAgeSec: (Date.now() - epoch) / 1000, }) - atisLoopAudio.value = audio - atisLoopKey.value = desiredKey setLastTransmission(`ATIS: ${announcement}`) } catch (err) { pmLog.error('ATIS loop TTS failed', err) @@ -3117,10 +3131,10 @@ watch( }, (next, prev) => { if (!next) { - if (atisLoopAudio.value) stopAtisLoop() + if (atisLoopActive) stopAtisLoop() return } - if (prev && prev.key === next.key && atisLoopAudio.value) return + if (prev && prev.key === next.key && atisLoopActive) return startAtisLoop(next.entry) }, { immediate: true } diff --git a/shared/utils/atisAudioLoop.ts b/shared/utils/atisAudioLoop.ts new file mode 100644 index 0000000..8663f6a --- /dev/null +++ b/shared/utils/atisAudioLoop.ts @@ -0,0 +1,260 @@ +/** + * ATIS audio loop with carrier-noise bed. Web Audio API based so the loop + * seek is sample-accurate (`source.start(when, offset)`), unlike + * HTMLAudioElement.currentTime which browsers can quantize. + * + * Two BufferSources fed through gain nodes into a shared AudioContext: + * 1. Carrier noise (1s buffer, looped, bandpass-filtered) — starts on + * tune, stays running. Loud gain while ATIS is loading, quiet "bed" + * gain while ATIS plays. + * 2. ATIS audio — decoded from TTS base64, looped, started at the + * virtual-clock offset. + */ + +export type AtisLoopPhase = 'idle' | 'loading' | 'playing' + +export interface AtisLoopState { + phase: AtisLoopPhase + requestedOffset?: number + duration?: number + startedAt?: number + startedAtCtx?: number + epochMs?: number +} + +export interface AtisAudioLoop { + startLoading(): void + startBroadcast(opts: { + audioBase64: string + mime?: string + epochMs: number + }): Promise<{ requestedOffset: number; duration: number } | null> + stop(): void + getState(): AtisLoopState +} + +const CARRIER_GAIN_LOUD = 0.45 +const CARRIER_GAIN_BED = 0.12 +const CARRIER_FADE_S = 0.5 +const CARRIER_BANDPASS_HZ = 1500 +const CARRIER_BANDPASS_Q = 1.0 + +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return bytes.buffer +} + +export function createAtisAudioLoop(): AtisAudioLoop { + if (typeof window === 'undefined') { + // SSR safety — return a no-op stub + return { + startLoading() {}, + async startBroadcast() { return null }, + stop() {}, + getState() { return { phase: 'idle' } } + } + } + + let ctx: AudioContext | null = null + let carrierSource: AudioBufferSourceNode | null = null + let carrierGain: GainNode | null = null + let atisSource: AudioBufferSourceNode | null = null + let atisGain: GainNode | null = null + + const state: AtisLoopState = { phase: 'idle' } + + const ensureCtx = (): AudioContext => { + if (ctx && ctx.state !== 'closed') { + // Some browsers auto-suspend the context — resume on demand. + if (ctx.state === 'suspended') ctx.resume().catch(() => {}) + return ctx + } + const Ctor: typeof AudioContext = window.AudioContext + // @ts-expect-error: webkitAudioContext for older Safari + ?? window.webkitAudioContext + ctx = new Ctor() + return ctx + } + + const buildCarrierBuffer = (): AudioBuffer => { + const c = ensureCtx() + // 1-second buffer, looped. White noise, light high-pass tilt. + const length = c.sampleRate + const buffer = c.createBuffer(1, length, c.sampleRate) + const ch = buffer.getChannelData(0) + let prev = 0 + for (let i = 0; i < length; i++) { + const white = Math.random() * 2 - 1 + // simple 1-sample tilt to take off the bass weight + const sample = white * 0.9 - prev * 0.5 + prev = white + ch[i] = sample + } + return buffer + } + + const stopCarrier = () => { + if (carrierSource) { + try { carrierSource.stop() } catch { /* already stopped */ } + try { carrierSource.disconnect() } catch {} + carrierSource = null + } + if (carrierGain) { + try { carrierGain.disconnect() } catch {} + carrierGain = null + } + } + + const stopAtis = () => { + if (atisSource) { + try { atisSource.stop() } catch {} + try { atisSource.disconnect() } catch {} + atisSource = null + } + if (atisGain) { + try { atisGain.disconnect() } catch {} + atisGain = null + } + } + + const exposeDebug = () => { + if (typeof window !== 'undefined') { + ;(window as any).__atisDebug = { + ctx, + carrierSource, + carrierGain, + atisSource, + atisGain, + state: { ...state } + } + } + } + + const startLoading = () => { + const c = ensureCtx() + if (carrierSource) { + // Already running. Ramp back up to loud in case we were in bed mode. + if (carrierGain) { + const now = c.currentTime + carrierGain.gain.cancelScheduledValues(now) + carrierGain.gain.setValueAtTime(carrierGain.gain.value, now) + carrierGain.gain.linearRampToValueAtTime(CARRIER_GAIN_LOUD, now + CARRIER_FADE_S) + } + state.phase = state.phase === 'playing' ? 'playing' : 'loading' + exposeDebug() + return + } + + const buffer = buildCarrierBuffer() + const source = c.createBufferSource() + source.buffer = buffer + source.loop = true + + const bandpass = c.createBiquadFilter() + bandpass.type = 'bandpass' + bandpass.frequency.value = CARRIER_BANDPASS_HZ + bandpass.Q.value = CARRIER_BANDPASS_Q + + const gain = c.createGain() + gain.gain.value = 0 + const now = c.currentTime + gain.gain.linearRampToValueAtTime(CARRIER_GAIN_LOUD, now + CARRIER_FADE_S) + + source.connect(bandpass) + bandpass.connect(gain) + gain.connect(c.destination) + source.start() + + carrierSource = source + carrierGain = gain + state.phase = 'loading' + exposeDebug() + } + + const startBroadcast = async ({ audioBase64, mime: _mime, epochMs }: { + audioBase64: string + mime?: string + epochMs: number + }) => { + const c = ensureCtx() + let audioBuffer: AudioBuffer + try { + const arrayBuffer = base64ToArrayBuffer(audioBase64) + audioBuffer = await c.decodeAudioData(arrayBuffer) + } catch (err) { + console.warn('[ATIS] decodeAudioData failed', err) + return null + } + + // Stop any previous broadcast — we're switching ATIS sources. + stopAtis() + + const duration = audioBuffer.duration + const elapsed = (Date.now() - epochMs) / 1000 + const requestedOffset = ((elapsed % duration) + duration) % duration + + const source = c.createBufferSource() + source.buffer = audioBuffer + source.loop = true + + const gain = c.createGain() + gain.gain.value = 1.0 + + source.connect(gain) + gain.connect(c.destination) + + const startedAtCtx = c.currentTime + source.start(0, requestedOffset) + + // Fade carrier down to "bed" level + if (carrierGain) { + const now = c.currentTime + carrierGain.gain.cancelScheduledValues(now) + carrierGain.gain.setValueAtTime(carrierGain.gain.value, now) + carrierGain.gain.linearRampToValueAtTime(CARRIER_GAIN_BED, now + CARRIER_FADE_S) + } + + atisSource = source + atisGain = gain + state.phase = 'playing' + state.requestedOffset = requestedOffset + state.duration = duration + state.startedAt = Date.now() + state.startedAtCtx = startedAtCtx + state.epochMs = epochMs + + exposeDebug() + return { requestedOffset, duration } + } + + const stop = () => { + // Quick fade out the carrier then stop both sources + if (ctx && carrierGain) { + const now = ctx.currentTime + try { + carrierGain.gain.cancelScheduledValues(now) + carrierGain.gain.setValueAtTime(carrierGain.gain.value, now) + carrierGain.gain.linearRampToValueAtTime(0, now + 0.15) + } catch {} + } + // Schedule stops slightly in the future so the fade completes audibly + const stopDelay = 200 + setTimeout(() => { + stopAtis() + stopCarrier() + state.phase = 'idle' + state.requestedOffset = undefined + state.duration = undefined + state.startedAt = undefined + state.startedAtCtx = undefined + state.epochMs = undefined + exposeDebug() + }, stopDelay) + } + + const getState = (): AtisLoopState => ({ ...state }) + + return { startLoading, startBroadcast, stop, getState } +}