From d070cc6504e76940c8af6f7501ffd116f6b1eb43 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 02:37:29 -0500 Subject: [PATCH] Refactor radio station audio runtime out of main --- client/src/audio/radioStationRuntime.ts | 308 +++++++++++++++++++++++ client/src/main.ts | 313 +----------------------- 2 files changed, 315 insertions(+), 306 deletions(-) create mode 100644 client/src/audio/radioStationRuntime.ts diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts new file mode 100644 index 0000000..9d7e9f6 --- /dev/null +++ b/client/src/audio/radioStationRuntime.ts @@ -0,0 +1,308 @@ +import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; +import { EFFECT_IDS, clampEffectLevel, connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects'; +import { AudioEngine } from './audioEngine'; + +export const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const; +export type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number]; + +type SharedRadioSource = { + streamUrl: string; + element: HTMLAudioElement; + source: MediaElementAudioSourceNode; + refCount: number; +}; + +type ItemRadioOutput = { + streamUrl: string; + channel: RadioChannelMode; + sharedSource: MediaElementAudioSourceNode; + sourceInput: GainNode; + channelSplitter: ChannelSplitterNode | null; + channelMerger: ChannelMergerNode | null; + channelLeftGain: GainNode | null; + channelRightGain: GainNode | null; + effectInput: GainNode; + effectRuntime: EffectRuntime | null; + effect: EffectId; + effectValue: number; + gain: GainNode; + panner: StereoPannerNode | null; +}; + +export function normalizeRadioEffect(effect: unknown): EffectId { + if (typeof effect !== 'string') return 'off'; + const normalized = effect.trim().toLowerCase() as EffectId; + return EFFECT_IDS.has(normalized) ? normalized : 'off'; +} + +export function normalizeRadioEffectValue(effectValue: unknown): number { + if (typeof effectValue !== 'number' || !Number.isFinite(effectValue)) { + return 50; + } + return clampEffectLevel(effectValue); +} + +export function normalizeRadioChannel(channel: unknown): RadioChannelMode { + if (typeof channel !== 'string') return 'stereo'; + const normalized = channel.trim().toLowerCase() as RadioChannelMode; + return (RADIO_CHANNEL_OPTIONS as readonly string[]).includes(normalized) ? normalized : 'stereo'; +} + +function connectRadioChannelSource( + audioCtx: AudioContext, + sharedSource: MediaElementAudioSourceNode, + channel: RadioChannelMode, + destination: GainNode, +): { + sourceInput: GainNode; + channelSplitter: ChannelSplitterNode | null; + channelMerger: ChannelMergerNode | null; + channelLeftGain: GainNode | null; + channelRightGain: GainNode | null; +} { + const sourceInput = audioCtx.createGain(); + sourceInput.gain.value = 1; + + if (channel === 'stereo') { + sharedSource.connect(sourceInput); + sourceInput.connect(destination); + return { + sourceInput, + channelSplitter: null, + channelMerger: null, + channelLeftGain: null, + channelRightGain: null, + }; + } + + const splitter = audioCtx.createChannelSplitter(2); + const merger = audioCtx.createChannelMerger(1); + sharedSource.connect(splitter); + + let leftGain: GainNode | null = null; + let rightGain: GainNode | null = null; + if (channel === 'mono') { + leftGain = audioCtx.createGain(); + rightGain = audioCtx.createGain(); + leftGain.gain.value = 0.5; + rightGain.gain.value = 0.5; + splitter.connect(leftGain, 0); + splitter.connect(rightGain, 1); + leftGain.connect(merger, 0, 0); + rightGain.connect(merger, 0, 0); + } else if (channel === 'left') { + splitter.connect(merger, 0, 0); + } else { + splitter.connect(merger, 1, 0); + } + + merger.connect(sourceInput); + sourceInput.connect(destination); + return { + sourceInput, + channelSplitter: splitter, + channelMerger: merger, + channelLeftGain: leftGain, + channelRightGain: rightGain, + }; +} + +export class RadioStationRuntime { + private readonly sharedRadioSources = new Map(); + private readonly itemRadioOutputs = new Map(); + + constructor(private readonly audio: AudioEngine) {} + + cleanup(itemId: string): void { + const output = this.itemRadioOutputs.get(itemId); + if (!output) return; + if (output.channelSplitter) { + try { + output.sharedSource.disconnect(output.channelSplitter); + } catch { + // Ignore stale graph disconnects. + } + } else { + try { + output.sharedSource.disconnect(output.sourceInput); + } catch { + // Ignore stale graph disconnects. + } + } + output.channelLeftGain?.disconnect(); + output.channelRightGain?.disconnect(); + output.channelSplitter?.disconnect(); + output.channelMerger?.disconnect(); + output.sourceInput.disconnect(); + output.effectInput.disconnect(); + disconnectEffectRuntime(output.effectRuntime); + output.gain.disconnect(); + output.panner?.disconnect(); + this.itemRadioOutputs.delete(itemId); + this.releaseSharedSource(output.streamUrl); + } + + cleanupAll(): void { + for (const id of Array.from(this.itemRadioOutputs.keys())) { + this.cleanup(id); + } + } + + async sync(items: Iterable): Promise { + const validIds = new Set(); + for (const item of items) { + if (item.type !== 'radio_station') continue; + validIds.add(item.id); + await this.ensureRuntime(item); + } + for (const id of Array.from(this.itemRadioOutputs.keys())) { + if (!validIds.has(id)) { + this.cleanup(id); + } + } + } + + updateSpatialAudio(items: Map, playerPosition: { x: number; y: number }): void { + const audioCtx = this.audio.context; + if (!audioCtx) return; + for (const [itemId, output] of this.itemRadioOutputs.entries()) { + const item = items.get(itemId); + if (!item || item.type !== 'radio_station') { + this.cleanup(itemId); + continue; + } + const streamUrl = String(item.params.streamUrl ?? '').trim(); + const enabled = item.params.enabled !== false; + const volume = Number(item.params.volume ?? 50); + const normalizedVolume = Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) / 100 : 0.5; + const effect = normalizeRadioEffect(item.params.effect); + const effectValue = normalizeRadioEffectValue(item.params.effectValue); + this.applyEffect(output, audioCtx, effect, effectValue); + if (!streamUrl || !enabled) { + output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); + continue; + } + const dist = Math.hypot(item.x - playerPosition.x, item.y - playerPosition.y); + let gainValue = 0; + let panValue = 0; + if (dist < HEARING_RADIUS) { + gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); + panValue = Math.sin(((item.x - playerPosition.x) / HEARING_RADIUS) * (Math.PI / 2)); + } + if (dist <= 1) { + gainValue = 1; + panValue = 0; + } + output.gain.gain.linearRampToValueAtTime(gainValue * normalizedVolume, audioCtx.currentTime + 0.1); + if (output.panner) { + const resolvedPan = Math.max(-1, Math.min(1, panValue)); + output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); + } + } + } + + private applyEffect( + output: ItemRadioOutput, + audioCtx: AudioContext, + effect: EffectId, + effectValue: number, + ): void { + if (output.effect === effect && output.effectValue === effectValue) { + return; + } + output.effectInput.disconnect(); + disconnectEffectRuntime(output.effectRuntime); + output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.gain, effect, effectValue); + output.effect = effect; + output.effectValue = effectValue; + } + + private releaseSharedSource(streamUrl: string): void { + const shared = this.sharedRadioSources.get(streamUrl); + if (!shared) return; + shared.refCount -= 1; + if (shared.refCount > 0) return; + shared.element.pause(); + shared.element.src = ''; + shared.source.disconnect(); + this.sharedRadioSources.delete(streamUrl); + } + + private getOrCreateSharedSource(streamUrl: string): SharedRadioSource | null { + const existing = this.sharedRadioSources.get(streamUrl); + if (existing) { + existing.refCount += 1; + return existing; + } + const audioCtx = this.audio.context; + if (!audioCtx) return null; + const element = new Audio(streamUrl); + element.crossOrigin = 'anonymous'; + element.loop = true; + element.preload = 'none'; + const source = audioCtx.createMediaElementSource(element); + void element.play().catch(() => undefined); + const shared: SharedRadioSource = { + streamUrl, + element, + source, + refCount: 1, + }; + this.sharedRadioSources.set(streamUrl, shared); + return shared; + } + + private async ensureRuntime(item: WorldItem): Promise { + const streamUrl = String(item.params.streamUrl ?? '').trim(); + if (!streamUrl) { + this.cleanup(item.id); + return; + } + await this.audio.ensureContext(); + const audioCtx = this.audio.context; + if (!audioCtx) return; + + const channel = normalizeRadioChannel(item.params.channel); + const existing = this.itemRadioOutputs.get(item.id); + if (existing && existing.streamUrl === streamUrl && existing.channel === channel) { + return; + } + if (existing) { + this.cleanup(item.id); + } + + const shared = this.getOrCreateSharedSource(streamUrl); + if (!shared) return; + + const gain = audioCtx.createGain(); + gain.gain.value = 0; + const effectInput = audioCtx.createGain(); + const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput); + const effect = normalizeRadioEffect(item.params.effect); + const effectValue = normalizeRadioEffectValue(item.params.effectValue); + const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); + let panner: StereoPannerNode | null = null; + if (this.audio.supportsStereoPanner()) { + panner = audioCtx.createStereoPanner(); + gain.connect(panner).connect(audioCtx.destination); + } else { + gain.connect(audioCtx.destination); + } + this.itemRadioOutputs.set(item.id, { + streamUrl, + channel, + sharedSource: shared.source, + sourceInput: channelSource.sourceInput, + channelSplitter: channelSource.channelSplitter, + channelMerger: channelSource.channelMerger, + channelLeftGain: channelSource.channelLeftGain, + channelRightGain: channelSource.channelRightGain, + effectInput, + effectRuntime, + effect, + effectValue, + gain, + panner, + }); + } +} diff --git a/client/src/main.ts b/client/src/main.ts index 43e1a13..6430646 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -4,18 +4,15 @@ import { EFFECT_IDS, EFFECT_SEQUENCE, clampEffectLevel, - connectEffectChain, - disconnectEffectRuntime, type EffectId, - type EffectRuntime, } from './audio/effects'; +import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime'; import { applyTextInput } from './input/textInput'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; import { SignalingClient } from './network/signalingClient'; import { CanvasRenderer } from './render/canvasRenderer'; import { GRID_SIZE, - HEARING_RADIUS, MOVE_COOLDOWN_MS, createInitialState, getDirection, @@ -116,8 +113,6 @@ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ 'sides', 'number', ]); -const RADIO_CHANNEL_OPTIONS = ['stereo', 'mono', 'left', 'right'] as const; -type RadioChannelMode = (typeof RADIO_CHANNEL_OPTIONS)[number]; const OPTION_ITEM_PROPERTY_VALUES: Partial> = { effect: EFFECT_SEQUENCE.map((effect) => effect.id), channel: [...RADIO_CHANNEL_OPTIONS], @@ -154,30 +149,7 @@ let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' let connecting = false; const messageBuffer: string[] = []; let messageCursor = -1; -type SharedRadioSource = { - streamUrl: string; - element: HTMLAudioElement; - source: MediaElementAudioSourceNode; - refCount: number; -}; -type ItemRadioOutput = { - streamUrl: string; - channel: RadioChannelMode; - sharedSource: MediaElementAudioSourceNode; - sourceInput: GainNode; - channelSplitter: ChannelSplitterNode | null; - channelMerger: ChannelMergerNode | null; - channelLeftGain: GainNode | null; - channelRightGain: GainNode | null; - effectInput: GainNode; - effectRuntime: EffectRuntime | null; - effect: EffectId; - effectValue: number; - gain: GainNode; - panner: StereoPannerNode | null; -}; -const sharedRadioSources = new Map(); -const itemRadioOutputs = new Map(); +const radioRuntime = new RadioStationRuntime(audio); let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; @@ -501,277 +473,6 @@ function openItemPropertyOptionSelect(item: WorldItem, key: string): void { audio.sfxUiBlip(); } -function releaseSharedRadioSource(streamUrl: string): void { - const shared = sharedRadioSources.get(streamUrl); - if (!shared) return; - shared.refCount -= 1; - if (shared.refCount > 0) return; - shared.element.pause(); - shared.element.src = ''; - shared.source.disconnect(); - sharedRadioSources.delete(streamUrl); -} - -function getOrCreateSharedRadioSource(streamUrl: string): SharedRadioSource | null { - const existing = sharedRadioSources.get(streamUrl); - if (existing) { - existing.refCount += 1; - return existing; - } - const audioCtx = audio.context; - if (!audioCtx) return null; - const element = new Audio(streamUrl); - element.crossOrigin = 'anonymous'; - element.loop = true; - element.preload = 'none'; - const source = audioCtx.createMediaElementSource(element); - void element.play().catch(() => undefined); - const shared: SharedRadioSource = { - streamUrl, - element, - source, - refCount: 1, - }; - sharedRadioSources.set(streamUrl, shared); - return shared; -} - -function cleanupRadioRuntime(itemId: string): void { - const output = itemRadioOutputs.get(itemId); - if (!output) return; - if (output.channelSplitter) { - try { - output.sharedSource.disconnect(output.channelSplitter); - } catch { - // Ignore stale graph disconnects. - } - } else { - try { - output.sharedSource.disconnect(output.sourceInput); - } catch { - // Ignore stale graph disconnects. - } - } - output.channelLeftGain?.disconnect(); - output.channelRightGain?.disconnect(); - output.channelSplitter?.disconnect(); - output.channelMerger?.disconnect(); - output.sourceInput.disconnect(); - output.effectInput.disconnect(); - disconnectEffectRuntime(output.effectRuntime); - output.gain.disconnect(); - output.panner?.disconnect(); - itemRadioOutputs.delete(itemId); - releaseSharedRadioSource(output.streamUrl); -} - -function normalizeRadioEffect(effect: unknown): EffectId { - if (typeof effect !== 'string') return 'off'; - const normalized = effect.trim().toLowerCase() as EffectId; - return EFFECT_IDS.has(normalized) ? normalized : 'off'; -} - -function normalizeRadioEffectValue(effectValue: unknown): number { - if (typeof effectValue !== 'number' || !Number.isFinite(effectValue)) { - return 50; - } - return clampEffectLevel(effectValue); -} - -function normalizeRadioChannel(channel: unknown): RadioChannelMode { - if (typeof channel !== 'string') return 'stereo'; - const normalized = channel.trim().toLowerCase() as RadioChannelMode; - return (RADIO_CHANNEL_OPTIONS as readonly string[]).includes(normalized) ? normalized : 'stereo'; -} - -function connectRadioChannelSource( - audioCtx: AudioContext, - sharedSource: MediaElementAudioSourceNode, - channel: RadioChannelMode, - destination: GainNode, -): { - sourceInput: GainNode; - channelSplitter: ChannelSplitterNode | null; - channelMerger: ChannelMergerNode | null; - channelLeftGain: GainNode | null; - channelRightGain: GainNode | null; -} { - const sourceInput = audioCtx.createGain(); - sourceInput.gain.value = 1; - - if (channel === 'stereo') { - sharedSource.connect(sourceInput); - sourceInput.connect(destination); - return { - sourceInput, - channelSplitter: null, - channelMerger: null, - channelLeftGain: null, - channelRightGain: null, - }; - } - - const splitter = audioCtx.createChannelSplitter(2); - const merger = audioCtx.createChannelMerger(1); - sharedSource.connect(splitter); - - let leftGain: GainNode | null = null; - let rightGain: GainNode | null = null; - if (channel === 'mono') { - leftGain = audioCtx.createGain(); - rightGain = audioCtx.createGain(); - leftGain.gain.value = 0.5; - rightGain.gain.value = 0.5; - splitter.connect(leftGain, 0); - splitter.connect(rightGain, 1); - leftGain.connect(merger, 0, 0); - rightGain.connect(merger, 0, 0); - } else if (channel === 'left') { - splitter.connect(merger, 0, 0); - } else { - splitter.connect(merger, 1, 0); - } - - merger.connect(sourceInput); - sourceInput.connect(destination); - return { - sourceInput, - channelSplitter: splitter, - channelMerger: merger, - channelLeftGain: leftGain, - channelRightGain: rightGain, - }; -} - -function applyRadioEffect( - output: ItemRadioOutput, - audioCtx: AudioContext, - effect: EffectId, - effectValue: number, -): void { - if (output.effect === effect && output.effectValue === effectValue) { - return; - } - output.effectInput.disconnect(); - disconnectEffectRuntime(output.effectRuntime); - output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.gain, effect, effectValue); - output.effect = effect; - output.effectValue = effectValue; -} - -function cleanupAllRadioRuntimes(): void { - for (const id of Array.from(itemRadioOutputs.keys())) { - cleanupRadioRuntime(id); - } -} - -async function ensureRadioRuntime(item: WorldItem): Promise { - const streamUrl = String(item.params.streamUrl ?? '').trim(); - if (!streamUrl) { - cleanupRadioRuntime(item.id); - return; - } - await audio.ensureContext(); - const audioCtx = audio.context; - if (!audioCtx) return; - - const existing = itemRadioOutputs.get(item.id); - const channel = normalizeRadioChannel(item.params.channel); - if (existing && existing.streamUrl === streamUrl && existing.channel === channel) { - return; - } - if (existing) { - cleanupRadioRuntime(item.id); - } - - const shared = getOrCreateSharedRadioSource(streamUrl); - if (!shared) return; - - const gain = audioCtx.createGain(); - gain.gain.value = 0; - const effectInput = audioCtx.createGain(); - const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput); - const effect = normalizeRadioEffect(item.params.effect); - const effectValue = normalizeRadioEffectValue(item.params.effectValue); - const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); - let panner: StereoPannerNode | null = null; - if (audio.supportsStereoPanner()) { - panner = audioCtx.createStereoPanner(); - gain.connect(panner).connect(audioCtx.destination); - } else { - gain.connect(audioCtx.destination); - } - itemRadioOutputs.set(item.id, { - streamUrl, - channel, - sharedSource: shared.source, - sourceInput: channelSource.sourceInput, - channelSplitter: channelSource.channelSplitter, - channelMerger: channelSource.channelMerger, - channelLeftGain: channelSource.channelLeftGain, - channelRightGain: channelSource.channelRightGain, - effectInput, - effectRuntime, - effect, - effectValue, - gain, - panner, - }); -} - -async function syncRadioStationPlayback(): Promise { - const validIds = new Set(); - for (const item of state.items.values()) { - if (item.type !== 'radio_station') continue; - validIds.add(item.id); - await ensureRadioRuntime(item); - } - for (const id of Array.from(itemRadioOutputs.keys())) { - if (!validIds.has(id)) { - cleanupRadioRuntime(id); - } - } -} - -function updateRadioStationSpatialAudio(): void { - const audioCtx = audio.context; - if (!audioCtx) return; - for (const [itemId, output] of itemRadioOutputs.entries()) { - const item = state.items.get(itemId); - if (!item || item.type !== 'radio_station') { - cleanupRadioRuntime(itemId); - continue; - } - const streamUrl = String(item.params.streamUrl ?? '').trim(); - const enabled = item.params.enabled !== false; - const volume = Number(item.params.volume ?? 50); - const normalizedVolume = Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) / 100 : 0.5; - const effect = normalizeRadioEffect(item.params.effect); - const effectValue = normalizeRadioEffectValue(item.params.effectValue); - applyRadioEffect(output, audioCtx, effect, effectValue); - if (!streamUrl || !enabled) { - output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); - continue; - } - const dist = Math.hypot(item.x - state.player.x, item.y - state.player.y); - let gainValue = 0; - let panValue = 0; - if (dist < HEARING_RADIUS) { - gainValue = Math.pow(1 - dist / HEARING_RADIUS, 2); - panValue = Math.sin(((item.x - state.player.x) / HEARING_RADIUS) * (Math.PI / 2)); - } - if (dist <= 1) { - gainValue = 1; - panValue = 0; - } - output.gain.gain.linearRampToValueAtTime(gainValue * normalizedVolume, audioCtx.currentTime + 0.1); - if (output.panner) { - const resolvedPan = Math.max(-1, Math.min(1, panValue)); - output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); - } - } -} - function shouldReplaceCurrentText(code: string, key: string): boolean { if (!replaceTextOnNextType) return false; if (code === 'ArrowLeft' || code === 'ArrowRight' || code === 'Home' || code === 'End') { @@ -950,7 +651,7 @@ function gameLoop(): void { if (!state.running) return; handleMovement(); audio.updateSpatialAudio(peerManager.getPeers(), { x: state.player.x, y: state.player.y }); - updateRadioStationSpatialAudio(); + radioRuntime.updateSpatialAudio(state.items, { x: state.player.x, y: state.player.y }); state.cursorVisible = Math.floor(Date.now() / 500) % 2 === 0; renderer.draw(state); requestAnimationFrame(gameLoop); @@ -1137,7 +838,7 @@ function disconnect(): void { stopLocalMedia(); peerManager.cleanupAll(); - cleanupAllRadioRuntimes(); + radioRuntime.cleanupAll(); state.running = false; state.keysPressed = {}; state.peers.clear(); @@ -1201,7 +902,7 @@ async function onMessage(message: IncomingMessage): Promise { carrierId: item.carrierId ?? null, }); } - await syncRadioStationPlayback(); + await radioRuntime.sync(state.items.values()); gameLoop(); break; @@ -1304,14 +1005,14 @@ async function onMessage(message: IncomingMessage): Promise { updateStatus(`${key}: ${getItemPropertyValue(message.item, key)}`); } } - await syncRadioStationPlayback(); + await radioRuntime.sync(state.items.values()); break; } case 'item_remove': { state.items.delete(message.itemId); state.carriedItemId = getCarriedItem()?.id ?? null; - cleanupRadioRuntime(message.itemId); + radioRuntime.cleanup(message.itemId); break; }