diff --git a/client/public/version.js b/client/public/version.js index c86a7ab..d3311c6 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,3 +1,3 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.20 R58"; +window.CHGRID_WEB_VERSION = "2026.02.20 R59"; diff --git a/client/src/audio/audioEngine.ts b/client/src/audio/audioEngine.ts index 5db0095..32a59be 100644 --- a/client/src/audio/audioEngine.ts +++ b/client/src/audio/audioEngine.ts @@ -1,4 +1,12 @@ import { HEARING_RADIUS } from '../state/gameState'; +import { + EFFECT_SEQUENCE, + clampEffectLevel, + connectEffectChain, + disconnectEffectRuntime, + type EffectId, + type EffectRuntime, +} from './effects'; export type SpatialPeerRuntime = { nickname: string; @@ -18,20 +26,8 @@ type SoundSpec = { delay?: number; }; -type EffectId = 'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off'; type OutputMode = 'stereo' | 'mono'; -type EffectPreset = { id: EffectId; label: string; defaultValue: number }; - -const EFFECT_SEQUENCE: EffectPreset[] = [ - { id: 'reverb', label: 'Reverb', defaultValue: 50 }, - { id: 'echo', label: 'Echo', defaultValue: 50 }, - { id: 'flanger', label: 'Flanger', defaultValue: 50 }, - { id: 'high_pass', label: 'High Pass', defaultValue: 50 }, - { id: 'low_pass', label: 'Low Pass', defaultValue: 50 }, - { id: 'off', label: 'Off', defaultValue: 0 }, -]; - export class AudioEngine { private audioCtx: AudioContext | null = null; private sfxGainNode: GainNode | null = null; @@ -41,9 +37,7 @@ export class AudioEngine { private outboundSource: MediaStreamAudioSourceNode | null = null; private outboundInputGain: GainNode | null = null; private outboundDestination: MediaStreamAudioDestinationNode | null = null; - private outboundEffectNodes: AudioNode[] = []; - private flangerLfo: OscillatorNode | null = null; - private flangerLfoGain: GainNode | null = null; + private outboundEffectRuntime: EffectRuntime | null = null; private outputMode: OutputMode = 'stereo'; private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off'); private readonly effectValues: Record = { @@ -315,140 +309,22 @@ export class AudioEngine { return; } - this.cleanupEffectNodes(); + disconnectEffectRuntime(this.outboundEffectRuntime); + this.outboundEffectRuntime = null; this.outboundInputGain.disconnect(); const effect = EFFECT_SEQUENCE[this.effectIndex].id; - const effectMix = this.effectValues[effect] / 100; - - if (effect === 'off') { - this.outboundInputGain.connect(this.outboundDestination); - return; - } - - if (effect === 'high_pass' || effect === 'low_pass') { - const filter = this.audioCtx.createBiquadFilter(); - filter.type = effect === 'high_pass' ? 'highpass' : 'lowpass'; - if (effect === 'high_pass') { - filter.frequency.value = 120 + effectMix * 7000; - } else { - filter.frequency.value = 7800 - effectMix * 7600; - } - filter.Q.value = 0.7 + effectMix * 8; - this.outboundInputGain.connect(filter); - filter.connect(this.outboundDestination); - this.outboundEffectNodes.push(filter); - return; - } - - if (effect === 'echo') { - const delay = this.audioCtx.createDelay(1); - delay.delayTime.value = 0.04 + effectMix * 0.76; - const feedback = this.audioCtx.createGain(); - feedback.gain.value = 0.04 + effectMix * 0.88; - const wetGain = this.audioCtx.createGain(); - wetGain.gain.value = 0.08 + effectMix * 0.92; - const dryGain = this.audioCtx.createGain(); - dryGain.gain.value = 1 - effectMix * 0.85; - - this.outboundInputGain.connect(dryGain); - dryGain.connect(this.outboundDestination); - this.outboundInputGain.connect(delay); - delay.connect(wetGain); - wetGain.connect(this.outboundDestination); - delay.connect(feedback); - feedback.connect(delay); - - this.outboundEffectNodes.push(delay, feedback, wetGain, dryGain); - return; - } - - if (effect === 'reverb') { - const convolver = this.audioCtx.createConvolver(); - convolver.buffer = this.createImpulseResponse(0.4 + effectMix * 4.2, 1 + effectMix * 3.6); - const wetGain = this.audioCtx.createGain(); - wetGain.gain.value = 0.06 + effectMix * 0.94; - const dryGain = this.audioCtx.createGain(); - dryGain.gain.value = 1 - effectMix * 0.8; - - this.outboundInputGain.connect(dryGain); - dryGain.connect(this.outboundDestination); - this.outboundInputGain.connect(convolver); - convolver.connect(wetGain); - wetGain.connect(this.outboundDestination); - - this.outboundEffectNodes.push(convolver, wetGain, dryGain); - return; - } - - const delay = this.audioCtx.createDelay(0.05); - delay.delayTime.value = 0.0005 + effectMix * 0.012; - const feedback = this.audioCtx.createGain(); - feedback.gain.value = 0.04 + effectMix * 0.9; - const wetGain = this.audioCtx.createGain(); - wetGain.gain.value = 0.05 + effectMix * 0.95; - const dryGain = this.audioCtx.createGain(); - dryGain.gain.value = 1 - effectMix * 0.82; - - const lfo = this.audioCtx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.value = 0.05 + effectMix * 1.8; - const lfoGain = this.audioCtx.createGain(); - lfoGain.gain.value = 0.0002 + effectMix * 0.015; - - lfo.connect(lfoGain); - lfoGain.connect(delay.delayTime); - lfo.start(); - - this.outboundInputGain.connect(dryGain); - dryGain.connect(this.outboundDestination); - this.outboundInputGain.connect(delay); - delay.connect(wetGain); - wetGain.connect(this.outboundDestination); - delay.connect(feedback); - feedback.connect(delay); - - this.flangerLfo = lfo; - this.flangerLfoGain = lfoGain; - this.outboundEffectNodes.push(delay, feedback, wetGain, lfoGain, dryGain); - } - - private cleanupEffectNodes(): void { - for (const node of this.outboundEffectNodes) { - node.disconnect(); - } - this.outboundEffectNodes = []; - - if (this.flangerLfo) { - this.flangerLfo.stop(); - this.flangerLfo.disconnect(); - this.flangerLfo = null; - } - if (this.flangerLfoGain) { - this.flangerLfoGain.disconnect(); - this.flangerLfoGain = null; - } - } - - private createImpulseResponse(duration: number, decay: number): AudioBuffer { - if (!this.audioCtx) { - throw new Error('Audio context not initialized'); - } - const length = Math.floor(this.audioCtx.sampleRate * duration); - const impulse = this.audioCtx.createBuffer(2, length, this.audioCtx.sampleRate); - for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) { - const data = impulse.getChannelData(channel); - for (let i = 0; i < length; i += 1) { - const noise = Math.random() * 2 - 1; - data[i] = noise * Math.pow(1 - i / length, decay); - } - } - return impulse; + this.outboundEffectRuntime = connectEffectChain( + this.audioCtx, + this.outboundInputGain, + this.outboundDestination, + effect, + this.effectValues[effect], + ); } private clampLevel(value: number): number { - const clamped = Math.max(0, Math.min(100, value)); - return Math.round(clamped / 5) * 5; + return clampEffectLevel(value); } private playSound(spec: SoundSpec): void { diff --git a/client/src/audio/effects.ts b/client/src/audio/effects.ts new file mode 100644 index 0000000..91131a8 --- /dev/null +++ b/client/src/audio/effects.ts @@ -0,0 +1,157 @@ +export type EffectId = 'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off'; + +export type EffectPreset = { id: EffectId; label: string; defaultValue: number }; + +export const EFFECT_SEQUENCE: EffectPreset[] = [ + { id: 'reverb', label: 'Reverb', defaultValue: 50 }, + { id: 'echo', label: 'Echo', defaultValue: 50 }, + { id: 'flanger', label: 'Flanger', defaultValue: 50 }, + { id: 'high_pass', label: 'High Pass', defaultValue: 50 }, + { id: 'low_pass', label: 'Low Pass', defaultValue: 50 }, + { id: 'off', label: 'Off', defaultValue: 0 }, +]; + +export const EFFECT_IDS = new Set(EFFECT_SEQUENCE.map((effect) => effect.id)); + +export type EffectRuntime = { + nodes: AudioNode[]; + flangerLfo: OscillatorNode | null; + flangerLfoGain: GainNode | null; +}; + +export function clampEffectLevel(value: number): number { + const clamped = Math.max(0, Math.min(100, value)); + return Math.round(clamped / 5) * 5; +} + +export function disconnectEffectRuntime(runtime: EffectRuntime | null): void { + if (!runtime) return; + for (const node of runtime.nodes) { + node.disconnect(); + } + if (runtime.flangerLfo) { + runtime.flangerLfo.stop(); + runtime.flangerLfo.disconnect(); + } + runtime.flangerLfoGain?.disconnect(); +} + +export function connectEffectChain( + audioCtx: AudioContext, + input: AudioNode, + destination: AudioNode, + effect: EffectId, + effectValue: number, +): EffectRuntime { + const runtime: EffectRuntime = { + nodes: [], + flangerLfo: null, + flangerLfoGain: null, + }; + const effectMix = clampEffectLevel(effectValue) / 100; + + if (effect === 'off') { + input.connect(destination); + return runtime; + } + + if (effect === 'high_pass' || effect === 'low_pass') { + const filter = audioCtx.createBiquadFilter(); + filter.type = effect === 'high_pass' ? 'highpass' : 'lowpass'; + if (effect === 'high_pass') { + filter.frequency.value = 120 + effectMix * 7000; + } else { + filter.frequency.value = 7800 - effectMix * 7600; + } + filter.Q.value = 0.7 + effectMix * 8; + input.connect(filter); + filter.connect(destination); + runtime.nodes.push(filter); + return runtime; + } + + if (effect === 'echo') { + const delay = audioCtx.createDelay(1); + delay.delayTime.value = 0.04 + effectMix * 0.76; + const feedback = audioCtx.createGain(); + feedback.gain.value = 0.04 + effectMix * 0.88; + const wetGain = audioCtx.createGain(); + wetGain.gain.value = 0.08 + effectMix * 0.92; + const dryGain = audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.85; + + input.connect(dryGain); + dryGain.connect(destination); + input.connect(delay); + delay.connect(wetGain); + wetGain.connect(destination); + delay.connect(feedback); + feedback.connect(delay); + + runtime.nodes.push(delay, feedback, wetGain, dryGain); + return runtime; + } + + if (effect === 'reverb') { + const convolver = audioCtx.createConvolver(); + convolver.buffer = createImpulseResponse(audioCtx, 0.4 + effectMix * 4.2, 1 + effectMix * 3.6); + const wetGain = audioCtx.createGain(); + wetGain.gain.value = 0.06 + effectMix * 0.94; + const dryGain = audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.8; + + input.connect(dryGain); + dryGain.connect(destination); + input.connect(convolver); + convolver.connect(wetGain); + wetGain.connect(destination); + + runtime.nodes.push(convolver, wetGain, dryGain); + return runtime; + } + + const delay = audioCtx.createDelay(0.05); + delay.delayTime.value = 0.0005 + effectMix * 0.012; + const feedback = audioCtx.createGain(); + feedback.gain.value = 0.04 + effectMix * 0.9; + const wetGain = audioCtx.createGain(); + wetGain.gain.value = 0.05 + effectMix * 0.95; + const dryGain = audioCtx.createGain(); + dryGain.gain.value = 1 - effectMix * 0.82; + + const lfo = audioCtx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.value = 0.05 + effectMix * 1.8; + const lfoGain = audioCtx.createGain(); + lfoGain.gain.value = 0.0002 + effectMix * 0.015; + + lfo.connect(lfoGain); + lfoGain.connect(delay.delayTime); + lfo.start(); + + input.connect(dryGain); + dryGain.connect(destination); + input.connect(delay); + delay.connect(wetGain); + wetGain.connect(destination); + delay.connect(feedback); + feedback.connect(delay); + + runtime.flangerLfo = lfo; + runtime.flangerLfoGain = lfoGain; + runtime.nodes.push(delay, feedback, wetGain, lfoGain, dryGain); + return runtime; +} + +function createImpulseResponse(audioCtx: AudioContext, duration: number, decay: number): AudioBuffer { + const length = Math.floor(audioCtx.sampleRate * duration); + const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate); + for (let channel = 0; channel < impulse.numberOfChannels; channel += 1) { + const data = impulse.getChannelData(channel); + for (let i = 0; i < length; i += 1) { + const noise = Math.random() * 2 - 1; + data[i] = noise * Math.pow(1 - i / length, decay); + } + } + return impulse; +} diff --git a/client/src/main.ts b/client/src/main.ts index 0abff58..b0b3c97 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,5 +1,14 @@ import './styles.css'; import { AudioEngine } from './audio/audioEngine'; +import { + EFFECT_IDS, + EFFECT_SEQUENCE, + clampEffectLevel, + connectEffectChain, + disconnectEffectRuntime, + type EffectId, + type EffectRuntime, +} from './audio/effects'; import { applyTextInput } from './input/textInput'; import { type IncomingMessage, type OutgoingMessage } from './network/protocol'; import { SignalingClient } from './network/signalingClient'; @@ -112,6 +121,10 @@ type SharedRadioSource = { }; type ItemRadioOutput = { streamUrl: string; + effectInput: GainNode; + effectRuntime: EffectRuntime | null; + effect: EffectId; + effectValue: number; gain: GainNode; panner: StereoPannerNode | null; }; @@ -317,7 +330,7 @@ function beginItemProperties(item: WorldItem): void { state.mode = 'itemProperties'; state.itemPropertyKeys = ['title']; if (item.type === 'radio_station') { - state.itemPropertyKeys.push('streamUrl', 'enabled', 'volume'); + state.itemPropertyKeys.push('streamUrl', 'enabled', 'volume', 'effect', 'effectValue'); } else if (item.type === 'dice') { state.itemPropertyKeys.push('sides', 'number'); } @@ -370,12 +383,43 @@ function getOrCreateSharedRadioSource(streamUrl: string): SharedRadioSource | nu function cleanupRadioRuntime(itemId: string): void { const output = itemRadioOutputs.get(itemId); if (!output) return; + 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 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); @@ -405,7 +449,11 @@ async function ensureRadioRuntime(item: WorldItem): Promise { const gain = audioCtx.createGain(); gain.gain.value = 0; - shared.source.connect(gain); + const effectInput = audioCtx.createGain(); + shared.source.connect(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(); @@ -413,7 +461,7 @@ async function ensureRadioRuntime(item: WorldItem): Promise { } else { gain.connect(audioCtx.destination); } - itemRadioOutputs.set(item.id, { streamUrl, gain, panner }); + itemRadioOutputs.set(item.id, { streamUrl, effectInput, effectRuntime, effect, effectValue, gain, panner }); } async function syncRadioStationPlayback(): Promise { @@ -443,6 +491,9 @@ function updateRadioStationSpatialAudio(): void { 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; @@ -497,6 +548,8 @@ function describeCharacter(ch: string): string { function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'title') return item.title; if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; + if (key === 'effect') return normalizeRadioEffect(item.params.effect); + if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue)); return String(item.params[key] ?? ''); } @@ -1476,6 +1529,22 @@ function handleItemPropertyEditModeInput(code: string, key: string): void { return; } signaling.send({ type: 'item_update', itemId, params: { volume: parsed } }); + } else if (propertyKey === 'effect') { + const normalized = value.trim().toLowerCase() as EffectId; + if (!EFFECT_IDS.has(normalized)) { + updateStatus(`effect must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { effect: normalized } }); + } else if (propertyKey === 'effectValue') { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + updateStatus('effectValue must be an integer between 0 and 100.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } }); } else if (propertyKey === 'sides' || propertyKey === 'number') { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index bb4aedb..f61edf3 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -19,7 +19,7 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { default_title="radio", capabilities=("editable", "carryable", "deletable"), use_sound=None, - default_params={"streamUrl": "", "enabled": True, "volume": 50}, + default_params={"streamUrl": "", "enabled": True, "volume": 50, "effect": "off", "effectValue": 50}, ), "dice": ItemDefinition( default_title="Dice", diff --git a/server/app/server.py b/server/app/server.py index b2f81c1..d7bc319 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -47,6 +47,7 @@ from .models import ( LOGGER = logging.getLogger("chgrid.server") PACKET_LOGGER = logging.getLogger("chgrid.server.packet") CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) +RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"} class SignalingServer: @@ -491,6 +492,30 @@ class SignalingServer: ) return next_params["volume"] = volume + + effect = str(next_params.get("effect", "off")).strip().lower() + if effect not in RADIO_EFFECT_IDS: + await self._send_item_result( + client, + False, + "update", + "effect must be one of reverb, echo, flanger, high_pass, low_pass, off.", + item.id, + ) + return + next_params["effect"] = effect + + try: + effect_value = int(next_params.get("effectValue", 50)) + except (TypeError, ValueError): + await self._send_item_result(client, False, "update", "effectValue must be a number.", item.id) + return + if not (0 <= effect_value <= 100): + await self._send_item_result( + client, False, "update", "effectValue must be between 0 and 100.", item.id + ) + return + next_params["effectValue"] = round(effect_value / 5) * 5 item.params = next_params item.updatedAt = self.item_service.now_ms() item.version += 1