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>
This commit is contained in:
leubeem
2026-06-10 23:10:35 +02:00
parent d758873f55
commit 1f4fed6955
3 changed files with 393 additions and 90 deletions

View File

@@ -62,9 +62,19 @@ export function createAtisAudioLoop(): AtisAudioLoop {
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.
@@ -133,6 +143,11 @@ export function createAtisAudioLoop(): AtisAudioLoop {
}
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.
@@ -142,7 +157,7 @@ export function createAtisAudioLoop(): AtisAudioLoop {
carrierGain.gain.setValueAtTime(carrierGain.gain.value, now)
carrierGain.gain.linearRampToValueAtTime(CARRIER_GAIN_LOUD, now + CARRIER_FADE_S)
}
state.phase = state.phase === 'playing' ? 'playing' : 'loading'
state.phase = 'loading'
exposeDebug()
return
}
@@ -178,6 +193,7 @@ export function createAtisAudioLoop(): AtisAudioLoop {
mime?: string
epochMs: number
}) => {
cancelScheduledStop()
const c = ensureCtx()
let audioBuffer: AudioBuffer
try {
@@ -230,6 +246,7 @@ export function createAtisAudioLoop(): AtisAudioLoop {
}
const stop = () => {
cancelScheduledStop()
// Quick fade out the carrier then stop both sources
if (ctx && carrierGain) {
const now = ctx.currentTime
@@ -239,9 +256,11 @@ export function createAtisAudioLoop(): AtisAudioLoop {
carrierGain.gain.linearRampToValueAtTime(0, now + 0.15)
} catch {}
}
// Schedule stops slightly in the future so the fade completes audibly
// 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
setTimeout(() => {
stopTimer = setTimeout(() => {
stopTimer = null
stopAtis()
stopCarrier()
state.phase = 'idle'