Files
OpenSquawk/shared/utils/atisAudioLoop.ts
leubeem 1f4fed6955 feat(pm): live ATIS broadcast loop with METAR-slot refresh and multi-station support
- per-airport ATIS loop keyed by station with a virtual start epoch, so
  re-tuning resumes where the broadcast would be instead of restarting
- refetch airport data at :23/:53 to follow VATSIM ATIS regeneration from
  real-world METAR publication, with faster retries while no ATIS is on
  the feed; prefetch audio when the info letter changes
- support separate arrival/departure ATIS stations on different frequencies
- cancel the deferred audio teardown on retune so a fresh broadcast is not
  killed by the previous stop()'s fade-out timer (atisAudioLoop)
- comm log shows newest entries first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:10:35 +02:00

280 lines
9.0 KiB
TypeScript

/**
* 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
// Pending deferred teardown from stop() — must be cancelled when a new
// start comes in before it fires, otherwise it kills the fresh sources.
let stopTimer: ReturnType<typeof setTimeout> | null = null
const state: AtisLoopState = { phase: 'idle' }
const cancelScheduledStop = () => {
if (stopTimer) {
clearTimeout(stopTimer)
stopTimer = null
}
}
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 = () => {
cancelScheduledStop()
// Cut any previous broadcast immediately — when switching between ATIS
// stations only the carrier noise should be audible until the new
// audio is ready, never the old station bleeding through.
stopAtis()
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 = '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
}) => {
cancelScheduledStop()
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 = () => {
cancelScheduledStop()
// 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.
// Kept in stopTimer so a retune can cancel it before it fires.
const stopDelay = 200
stopTimer = setTimeout(() => {
stopTimer = null
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 }
}