mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 03:45:33 +08:00
feat(atis): carrier-noise bed and Web-Audio loop seek
Tuning to the ATIS frequency now plays carrier noise immediately so the pilot gets feedback before the TTS comes back (synthesis takes a moment on cold cache). When the ATIS audio is ready, the carrier ducks down to a subtle bed level and stays underneath the announcement — mimicking how a real radio channel always carries some noise floor. Switches the loop from HTMLAudioElement (whose seek on data: URLs gets quantized by some browsers) to a Web-Audio AudioBufferSourceNode. `source.start(0, offset)` is sample-accurate per spec, so the virtual-clock entry point lands exactly where computed. `window.__atisDebug` exposes ctx/source/state for manual inspection, and pm.vue logs the requestedOffset/duration/epochAge on each loop start. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -992,6 +992,7 @@ import { useApi } from '~/composables/useApi'
|
|||||||
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 type {
|
import type {
|
||||||
CandidateTraceElimination,
|
CandidateTraceElimination,
|
||||||
CandidateTraceEntry,
|
CandidateTraceEntry,
|
||||||
@@ -1526,8 +1527,9 @@ const airportFrequencies = ref<AirportFrequencyEntry[]>([])
|
|||||||
const airportFrequencyLoading = ref(false)
|
const airportFrequencyLoading = ref(false)
|
||||||
const frequencySources = ref({ vatsim: false, openaip: false })
|
const frequencySources = ref({ vatsim: false, openaip: false })
|
||||||
const atisPlaybackLoading = ref(false)
|
const atisPlaybackLoading = ref(false)
|
||||||
const atisLoopAudio = ref<HTMLAudioElement | null>(null)
|
|
||||||
const atisLoopKey = ref<string | null>(null)
|
const atisLoopKey = ref<string | null>(null)
|
||||||
|
let atisAudioLoop: AtisAudioLoop | null = null
|
||||||
|
let atisLoopActive = false
|
||||||
let atisLoopSeq = 0
|
let atisLoopSeq = 0
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -2667,32 +2669,46 @@ const resolveAtisEpoch = (entry: AirportFrequencyEntry): number => {
|
|||||||
return Date.now()
|
return Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ensureAtisAudioLoop = (): AtisAudioLoop => {
|
||||||
|
if (!atisAudioLoop) {
|
||||||
|
atisAudioLoop = createAtisAudioLoop()
|
||||||
|
}
|
||||||
|
return atisAudioLoop
|
||||||
|
}
|
||||||
|
|
||||||
const stopAtisLoop = () => {
|
const stopAtisLoop = () => {
|
||||||
const audio = atisLoopAudio.value
|
atisLoopSeq += 1
|
||||||
if (audio) {
|
atisLoopActive = false
|
||||||
|
atisLoopKey.value = null
|
||||||
|
if (atisAudioLoop) {
|
||||||
try {
|
try {
|
||||||
audio.pause()
|
atisAudioLoop.stop()
|
||||||
audio.removeAttribute('src')
|
|
||||||
audio.load()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pmLog.warn('ATIS loop stop failed', err)
|
pmLog.warn('ATIS loop stop failed', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
atisLoopAudio.value = null
|
|
||||||
atisLoopKey.value = null
|
|
||||||
atisLoopSeq += 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAtisLoop = async (entry: AirportFrequencyEntry) => {
|
const startAtisLoop = async (entry: AirportFrequencyEntry) => {
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
|
|
||||||
const desiredKey = buildAtisLoopKey(entry)
|
const desiredKey = buildAtisLoopKey(entry)
|
||||||
if (atisLoopAudio.value && atisLoopKey.value === desiredKey) {
|
if (atisLoopActive && atisLoopKey.value === desiredKey) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAtisLoop()
|
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()
|
let content = (entry.atisText || '').trim()
|
||||||
if (!content) {
|
if (!content) {
|
||||||
const metar = await fetchMetarText(flightContext.value.dep)
|
const metar = await fetchMetarText(flightContext.value.dep)
|
||||||
@@ -2703,6 +2719,7 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => {
|
|||||||
|
|
||||||
if (!content) {
|
if (!content) {
|
||||||
setLastTransmission('ATIS: No current information available')
|
setLastTransmission('ATIS: No current information available')
|
||||||
|
// Lass den Carrier laufen — Pilot hört dass die Frequenz aktiv ist, nur keine Ansage
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2727,29 +2744,26 @@ const startAtisLoop = async (entry: AirportFrequencyEntry) => {
|
|||||||
if (requestSeq !== atisLoopSeq) return
|
if (requestSeq !== atisLoopSeq) return
|
||||||
if (!response?.success || !response.audio) return
|
if (!response?.success || !response.audio) return
|
||||||
|
|
||||||
const dataUrl = `data:${response.audio.mime || 'audio/wav'};base64,${response.audio.base64}`
|
const result = await loop.startBroadcast({
|
||||||
const audio = new Audio(dataUrl)
|
audioBase64: response.audio.base64,
|
||||||
audio.loop = true
|
mime: response.audio.mime,
|
||||||
audio.preload = 'auto'
|
epochMs: epoch,
|
||||||
|
})
|
||||||
audio.addEventListener('loadedmetadata', () => {
|
|
||||||
if (requestSeq !== atisLoopSeq) return
|
if (requestSeq !== atisLoopSeq) return
|
||||||
const duration = audio.duration
|
if (!result) {
|
||||||
if (!Number.isFinite(duration) || duration <= 0) {
|
pmLog.warn('ATIS loop broadcast start returned null (decode failed?)')
|
||||||
audio.play().catch(err => pmLog.warn('ATIS loop play failed', err))
|
return
|
||||||
return
|
}
|
||||||
}
|
|
||||||
const offset = ((Date.now() - epoch) / 1000) % duration
|
pmLog.info('ATIS loop start', {
|
||||||
audio.currentTime = offset < 0 ? offset + duration : offset
|
icao: flightContext.value.dep,
|
||||||
audio.play().catch(err => pmLog.warn('ATIS loop play failed', err))
|
atisCode: entry.atisCode,
|
||||||
}, { once: true })
|
duration: result.duration,
|
||||||
|
requestedOffset: result.requestedOffset,
|
||||||
audio.addEventListener('error', (err) => {
|
epochAgeSec: (Date.now() - epoch) / 1000,
|
||||||
pmLog.warn('ATIS loop audio error', err)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
atisLoopAudio.value = audio
|
|
||||||
atisLoopKey.value = desiredKey
|
|
||||||
setLastTransmission(`ATIS: ${announcement}`)
|
setLastTransmission(`ATIS: ${announcement}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pmLog.error('ATIS loop TTS failed', err)
|
pmLog.error('ATIS loop TTS failed', err)
|
||||||
@@ -3117,10 +3131,10 @@ watch(
|
|||||||
},
|
},
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
if (atisLoopAudio.value) stopAtisLoop()
|
if (atisLoopActive) stopAtisLoop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (prev && prev.key === next.key && atisLoopAudio.value) return
|
if (prev && prev.key === next.key && atisLoopActive) return
|
||||||
startAtisLoop(next.entry)
|
startAtisLoop(next.entry)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
|
|||||||
260
shared/utils/atisAudioLoop.ts
Normal file
260
shared/utils/atisAudioLoop.ts
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user