mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-06-27 19:05:48 +08:00
- 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>
280 lines
9.0 KiB
TypeScript
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 }
|
|
}
|