Unify radio and user effects with shared effect model
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// 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";
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { HEARING_RADIUS } from '../state/gameState';
|
import { HEARING_RADIUS } from '../state/gameState';
|
||||||
|
import {
|
||||||
|
EFFECT_SEQUENCE,
|
||||||
|
clampEffectLevel,
|
||||||
|
connectEffectChain,
|
||||||
|
disconnectEffectRuntime,
|
||||||
|
type EffectId,
|
||||||
|
type EffectRuntime,
|
||||||
|
} from './effects';
|
||||||
|
|
||||||
export type SpatialPeerRuntime = {
|
export type SpatialPeerRuntime = {
|
||||||
nickname: string;
|
nickname: string;
|
||||||
@@ -18,20 +26,8 @@ type SoundSpec = {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EffectId = 'reverb' | 'echo' | 'flanger' | 'high_pass' | 'low_pass' | 'off';
|
|
||||||
type OutputMode = 'stereo' | 'mono';
|
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 {
|
export class AudioEngine {
|
||||||
private audioCtx: AudioContext | null = null;
|
private audioCtx: AudioContext | null = null;
|
||||||
private sfxGainNode: GainNode | null = null;
|
private sfxGainNode: GainNode | null = null;
|
||||||
@@ -41,9 +37,7 @@ export class AudioEngine {
|
|||||||
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
private outboundSource: MediaStreamAudioSourceNode | null = null;
|
||||||
private outboundInputGain: GainNode | null = null;
|
private outboundInputGain: GainNode | null = null;
|
||||||
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
private outboundDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
private outboundEffectNodes: AudioNode[] = [];
|
private outboundEffectRuntime: EffectRuntime | null = null;
|
||||||
private flangerLfo: OscillatorNode | null = null;
|
|
||||||
private flangerLfoGain: GainNode | null = null;
|
|
||||||
private outputMode: OutputMode = 'stereo';
|
private outputMode: OutputMode = 'stereo';
|
||||||
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
private effectIndex = EFFECT_SEQUENCE.findIndex((effect) => effect.id === 'off');
|
||||||
private readonly effectValues: Record<EffectId, number> = {
|
private readonly effectValues: Record<EffectId, number> = {
|
||||||
@@ -315,140 +309,22 @@ export class AudioEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cleanupEffectNodes();
|
disconnectEffectRuntime(this.outboundEffectRuntime);
|
||||||
|
this.outboundEffectRuntime = null;
|
||||||
this.outboundInputGain.disconnect();
|
this.outboundInputGain.disconnect();
|
||||||
|
|
||||||
const effect = EFFECT_SEQUENCE[this.effectIndex].id;
|
const effect = EFFECT_SEQUENCE[this.effectIndex].id;
|
||||||
const effectMix = this.effectValues[effect] / 100;
|
this.outboundEffectRuntime = connectEffectChain(
|
||||||
|
this.audioCtx,
|
||||||
if (effect === 'off') {
|
this.outboundInputGain,
|
||||||
this.outboundInputGain.connect(this.outboundDestination);
|
this.outboundDestination,
|
||||||
return;
|
effect,
|
||||||
}
|
this.effectValues[effect],
|
||||||
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private clampLevel(value: number): number {
|
private clampLevel(value: number): number {
|
||||||
const clamped = Math.max(0, Math.min(100, value));
|
return clampEffectLevel(value);
|
||||||
return Math.round(clamped / 5) * 5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private playSound(spec: SoundSpec): void {
|
private playSound(spec: SoundSpec): void {
|
||||||
|
|||||||
157
client/src/audio/effects.ts
Normal file
157
client/src/audio/effects.ts
Normal file
@@ -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<EffectId>(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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { AudioEngine } from './audio/audioEngine';
|
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 { applyTextInput } from './input/textInput';
|
||||||
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
import { type IncomingMessage, type OutgoingMessage } from './network/protocol';
|
||||||
import { SignalingClient } from './network/signalingClient';
|
import { SignalingClient } from './network/signalingClient';
|
||||||
@@ -112,6 +121,10 @@ type SharedRadioSource = {
|
|||||||
};
|
};
|
||||||
type ItemRadioOutput = {
|
type ItemRadioOutput = {
|
||||||
streamUrl: string;
|
streamUrl: string;
|
||||||
|
effectInput: GainNode;
|
||||||
|
effectRuntime: EffectRuntime | null;
|
||||||
|
effect: EffectId;
|
||||||
|
effectValue: number;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
@@ -317,7 +330,7 @@ function beginItemProperties(item: WorldItem): void {
|
|||||||
state.mode = 'itemProperties';
|
state.mode = 'itemProperties';
|
||||||
state.itemPropertyKeys = ['title'];
|
state.itemPropertyKeys = ['title'];
|
||||||
if (item.type === 'radio_station') {
|
if (item.type === 'radio_station') {
|
||||||
state.itemPropertyKeys.push('streamUrl', 'enabled', 'volume');
|
state.itemPropertyKeys.push('streamUrl', 'enabled', 'volume', 'effect', 'effectValue');
|
||||||
} else if (item.type === 'dice') {
|
} else if (item.type === 'dice') {
|
||||||
state.itemPropertyKeys.push('sides', 'number');
|
state.itemPropertyKeys.push('sides', 'number');
|
||||||
}
|
}
|
||||||
@@ -370,12 +383,43 @@ function getOrCreateSharedRadioSource(streamUrl: string): SharedRadioSource | nu
|
|||||||
function cleanupRadioRuntime(itemId: string): void {
|
function cleanupRadioRuntime(itemId: string): void {
|
||||||
const output = itemRadioOutputs.get(itemId);
|
const output = itemRadioOutputs.get(itemId);
|
||||||
if (!output) return;
|
if (!output) return;
|
||||||
|
output.effectInput.disconnect();
|
||||||
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
output.panner?.disconnect();
|
output.panner?.disconnect();
|
||||||
itemRadioOutputs.delete(itemId);
|
itemRadioOutputs.delete(itemId);
|
||||||
releaseSharedRadioSource(output.streamUrl);
|
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 {
|
function cleanupAllRadioRuntimes(): void {
|
||||||
for (const id of Array.from(itemRadioOutputs.keys())) {
|
for (const id of Array.from(itemRadioOutputs.keys())) {
|
||||||
cleanupRadioRuntime(id);
|
cleanupRadioRuntime(id);
|
||||||
@@ -405,7 +449,11 @@ async function ensureRadioRuntime(item: WorldItem): Promise<void> {
|
|||||||
|
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
gain.gain.value = 0;
|
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;
|
let panner: StereoPannerNode | null = null;
|
||||||
if (audio.supportsStereoPanner()) {
|
if (audio.supportsStereoPanner()) {
|
||||||
panner = audioCtx.createStereoPanner();
|
panner = audioCtx.createStereoPanner();
|
||||||
@@ -413,7 +461,7 @@ async function ensureRadioRuntime(item: WorldItem): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
gain.connect(audioCtx.destination);
|
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<void> {
|
async function syncRadioStationPlayback(): Promise<void> {
|
||||||
@@ -443,6 +491,9 @@ function updateRadioStationSpatialAudio(): void {
|
|||||||
const enabled = item.params.enabled !== false;
|
const enabled = item.params.enabled !== false;
|
||||||
const volume = Number(item.params.volume ?? 50);
|
const volume = Number(item.params.volume ?? 50);
|
||||||
const normalizedVolume = Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) / 100 : 0.5;
|
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) {
|
if (!streamUrl || !enabled) {
|
||||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||||
continue;
|
continue;
|
||||||
@@ -497,6 +548,8 @@ function describeCharacter(ch: string): string {
|
|||||||
function getItemPropertyValue(item: WorldItem, key: string): string {
|
function getItemPropertyValue(item: WorldItem, key: string): string {
|
||||||
if (key === 'title') return item.title;
|
if (key === 'title') return item.title;
|
||||||
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
|
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] ?? '');
|
return String(item.params[key] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1476,6 +1529,22 @@ function handleItemPropertyEditModeInput(code: string, key: string): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { volume: parsed } });
|
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') {
|
} else if (propertyKey === 'sides' || propertyKey === 'number') {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
default_title="radio",
|
default_title="radio",
|
||||||
capabilities=("editable", "carryable", "deletable"),
|
capabilities=("editable", "carryable", "deletable"),
|
||||||
use_sound=None,
|
use_sound=None,
|
||||||
default_params={"streamUrl": "", "enabled": True, "volume": 50},
|
default_params={"streamUrl": "", "enabled": True, "volume": 50, "effect": "off", "effectValue": 50},
|
||||||
),
|
),
|
||||||
"dice": ItemDefinition(
|
"dice": ItemDefinition(
|
||||||
default_title="Dice",
|
default_title="Dice",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from .models import (
|
|||||||
LOGGER = logging.getLogger("chgrid.server")
|
LOGGER = logging.getLogger("chgrid.server")
|
||||||
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
||||||
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
||||||
|
RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"}
|
||||||
|
|
||||||
|
|
||||||
class SignalingServer:
|
class SignalingServer:
|
||||||
@@ -491,6 +492,30 @@ class SignalingServer:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
next_params["volume"] = volume
|
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.params = next_params
|
||||||
item.updatedAt = self.item_service.now_ms()
|
item.updatedAt = self.item_service.now_ms()
|
||||||
item.version += 1
|
item.version += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user