Rename radio media params and add widget emit effects
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// 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.21 R127";
|
window.CHGRID_WEB_VERSION = "2026.02.21 R128";
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
||||||
import { AudioEngine } from './audioEngine';
|
import { AudioEngine } from './audioEngine';
|
||||||
|
import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
||||||
|
import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime';
|
||||||
import { resolveSpatialMix } from './spatial';
|
import { resolveSpatialMix } from './spatial';
|
||||||
|
|
||||||
type EmitOutput = {
|
type EmitOutput = {
|
||||||
soundUrl: string;
|
soundUrl: string;
|
||||||
element: HTMLAudioElement;
|
element: HTMLAudioElement;
|
||||||
source: MediaElementAudioSourceNode;
|
source: MediaElementAudioSourceNode;
|
||||||
|
effectInput: GainNode;
|
||||||
|
effectRuntime: EffectRuntime | null;
|
||||||
|
effect: EffectId;
|
||||||
|
effectValue: number;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
@@ -34,6 +40,8 @@ export class ItemEmitRuntime {
|
|||||||
output.element.pause();
|
output.element.pause();
|
||||||
output.element.src = '';
|
output.element.src = '';
|
||||||
output.source.disconnect();
|
output.source.disconnect();
|
||||||
|
output.effectInput.disconnect();
|
||||||
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
output.panner?.disconnect();
|
output.panner?.disconnect();
|
||||||
this.outputs.delete(itemId);
|
this.outputs.delete(itemId);
|
||||||
@@ -85,17 +93,21 @@ export class ItemEmitRuntime {
|
|||||||
element.preload = 'none';
|
element.preload = 'none';
|
||||||
element.crossOrigin = 'anonymous';
|
element.crossOrigin = 'anonymous';
|
||||||
const source = audioCtx.createMediaElementSource(element);
|
const source = audioCtx.createMediaElementSource(element);
|
||||||
|
const effectInput = audioCtx.createGain();
|
||||||
const gain = audioCtx.createGain();
|
const gain = audioCtx.createGain();
|
||||||
gain.gain.value = 0;
|
gain.gain.value = 0;
|
||||||
let panner: StereoPannerNode | null = null;
|
let panner: StereoPannerNode | null = null;
|
||||||
source.connect(gain);
|
source.connect(effectInput);
|
||||||
|
const effect = normalizeRadioEffect(item.params.emitEffect);
|
||||||
|
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
||||||
|
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||||
if (this.audio.supportsStereoPanner()) {
|
if (this.audio.supportsStereoPanner()) {
|
||||||
panner = audioCtx.createStereoPanner();
|
panner = audioCtx.createStereoPanner();
|
||||||
gain.connect(panner).connect(audioCtx.destination);
|
gain.connect(panner).connect(audioCtx.destination);
|
||||||
} else {
|
} else {
|
||||||
gain.connect(audioCtx.destination);
|
gain.connect(audioCtx.destination);
|
||||||
}
|
}
|
||||||
this.outputs.set(item.id, { soundUrl, element, source, gain, panner });
|
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner });
|
||||||
void element.play().catch(() => undefined);
|
void element.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +129,15 @@ export class ItemEmitRuntime {
|
|||||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const effect = normalizeRadioEffect(item.params.emitEffect);
|
||||||
|
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
||||||
|
if (output.effect !== effect || output.effectValue !== effectValue) {
|
||||||
|
output.effectInput.disconnect();
|
||||||
|
disconnectEffectRuntime(output.effectRuntime);
|
||||||
|
output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.gain, effect, effectValue);
|
||||||
|
output.effect = effect;
|
||||||
|
output.effectValue = effectValue;
|
||||||
|
}
|
||||||
const spatialConfig = this.getSpatialConfig(item);
|
const spatialConfig = this.getSpatialConfig(item);
|
||||||
const mix = resolveSpatialMix({
|
const mix = resolveSpatialMix({
|
||||||
dx: item.x - playerPosition.x,
|
dx: item.x - playerPosition.x,
|
||||||
|
|||||||
@@ -205,8 +205,8 @@ export class RadioStationRuntime {
|
|||||||
const enabled = item.params.enabled !== false;
|
const enabled = item.params.enabled !== false;
|
||||||
const mediaVolume = Number(item.params.mediaVolume ?? 50);
|
const mediaVolume = Number(item.params.mediaVolume ?? 50);
|
||||||
const normalizedVolume = Number.isFinite(mediaVolume) ? Math.max(0, Math.min(100, mediaVolume)) / 100 : 0.5;
|
const normalizedVolume = Number.isFinite(mediaVolume) ? Math.max(0, Math.min(100, mediaVolume)) / 100 : 0.5;
|
||||||
const effect = normalizeRadioEffect(item.params.effect);
|
const effect = normalizeRadioEffect(item.params.mediaEffect);
|
||||||
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||||
this.applyEffect(output, audioCtx, effect, effectValue);
|
this.applyEffect(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);
|
||||||
@@ -299,7 +299,7 @@ export class RadioStationRuntime {
|
|||||||
const audioCtx = this.audio.context;
|
const audioCtx = this.audio.context;
|
||||||
if (!audioCtx) return;
|
if (!audioCtx) return;
|
||||||
|
|
||||||
const channel = normalizeRadioChannel(item.params.channel);
|
const channel = normalizeRadioChannel(item.params.mediaChannel);
|
||||||
const existing = this.itemRadioOutputs.get(item.id);
|
const existing = this.itemRadioOutputs.get(item.id);
|
||||||
if (existing && existing.streamUrl === streamUrl && existing.channel === channel) {
|
if (existing && existing.streamUrl === streamUrl && existing.channel === channel) {
|
||||||
return;
|
return;
|
||||||
@@ -315,8 +315,8 @@ export class RadioStationRuntime {
|
|||||||
gain.gain.value = 0;
|
gain.gain.value = 0;
|
||||||
const effectInput = audioCtx.createGain();
|
const effectInput = audioCtx.createGain();
|
||||||
const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput);
|
const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput);
|
||||||
const effect = normalizeRadioEffect(item.params.effect);
|
const effect = normalizeRadioEffect(item.params.mediaEffect);
|
||||||
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||||
let panner: StereoPannerNode | null = null;
|
let panner: StereoPannerNode | null = null;
|
||||||
if (this.audio.supportsStereoPanner()) {
|
if (this.audio.supportsStereoPanner()) {
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
|
|||||||
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget'];
|
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget'];
|
||||||
|
|
||||||
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||||
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'mediaVolume', 'effect', 'effectValue', 'facing', 'emitRange'],
|
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
|
||||||
dice: ['title', 'sides', 'number'],
|
dice: ['title', 'sides', 'number'],
|
||||||
wheel: ['title', 'spaces'],
|
wheel: ['title', 'spaces'],
|
||||||
clock: ['title', 'timeZone', 'use24Hour'],
|
clock: ['title', 'timeZone', 'use24Hour'],
|
||||||
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'useSound', 'emitSound'],
|
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||||
@@ -112,8 +112,9 @@ let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number |
|
|||||||
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
||||||
};
|
};
|
||||||
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
||||||
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||||
channel: [...RADIO_CHANNEL_OPTIONS],
|
emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||||
|
mediaChannel: [...RADIO_CHANNEL_OPTIONS],
|
||||||
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
||||||
};
|
};
|
||||||
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
||||||
@@ -195,6 +196,11 @@ export function itemPropertyLabel(key: string): string {
|
|||||||
if (key === 'emitRange') return 'emit range';
|
if (key === 'emitRange') return 'emit range';
|
||||||
if (key === 'mediaVolume') return 'media volume';
|
if (key === 'mediaVolume') return 'media volume';
|
||||||
if (key === 'emitVolume') return 'emit volume';
|
if (key === 'emitVolume') return 'emit volume';
|
||||||
|
if (key === 'mediaChannel') return 'media channel';
|
||||||
|
if (key === 'mediaEffect') return 'media effect';
|
||||||
|
if (key === 'mediaEffectValue') return 'media effect value';
|
||||||
|
if (key === 'emitEffect') return 'emit effect';
|
||||||
|
if (key === 'emitEffectValue') return 'emit effect value';
|
||||||
if (key === 'useSound') return 'use sound';
|
if (key === 'useSound') return 'use sound';
|
||||||
if (key === 'emitSound') return 'emit sound';
|
if (key === 'emitSound') return 'emit sound';
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
@@ -723,9 +723,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
|||||||
}
|
}
|
||||||
if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
|
if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
|
||||||
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
||||||
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
if (key === 'mediaChannel') return normalizeRadioChannel(item.params.mediaChannel);
|
||||||
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
|
if (key === 'mediaEffect') return normalizeRadioEffect(item.params.mediaEffect);
|
||||||
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
|
if (key === 'mediaEffectValue') return String(normalizeRadioEffectValue(item.params.mediaEffectValue));
|
||||||
|
if (key === 'emitEffect') return normalizeRadioEffect(item.params.emitEffect);
|
||||||
|
if (key === 'emitEffectValue') return String(normalizeRadioEffectValue(item.params.emitEffectValue));
|
||||||
if (key === 'facing') {
|
if (key === 'facing') {
|
||||||
const parsed = Number(item.params.facing ?? 0);
|
const parsed = Number(item.params.facing ?? 0);
|
||||||
if (!Number.isFinite(parsed)) return '0';
|
if (!Number.isFinite(parsed)) return '0';
|
||||||
@@ -744,14 +746,15 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
|||||||
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
||||||
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
||||||
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
|
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
|
||||||
if (key === 'channel' || key === 'effect' || key === 'timeZone') return 'list';
|
if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone') return 'list';
|
||||||
if (
|
if (
|
||||||
key === 'x' ||
|
key === 'x' ||
|
||||||
key === 'y' ||
|
key === 'y' ||
|
||||||
key === 'version' ||
|
key === 'version' ||
|
||||||
key === 'mediaVolume' ||
|
key === 'mediaVolume' ||
|
||||||
key === 'emitVolume' ||
|
key === 'emitVolume' ||
|
||||||
key === 'effectValue' ||
|
key === 'mediaEffectValue' ||
|
||||||
|
key === 'emitEffectValue' ||
|
||||||
key === 'facing' ||
|
key === 'facing' ||
|
||||||
key === 'emitRange' ||
|
key === 'emitRange' ||
|
||||||
key === 'sides' ||
|
key === 'sides' ||
|
||||||
@@ -2155,22 +2158,22 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } });
|
signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } });
|
||||||
} else if (propertyKey === 'effect') {
|
} else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') {
|
||||||
const normalized = value.trim().toLowerCase() as EffectId;
|
const normalized = value.trim().toLowerCase() as EffectId;
|
||||||
if (!EFFECT_IDS.has(normalized)) {
|
if (!EFFECT_IDS.has(normalized)) {
|
||||||
updateStatus(`effect must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`);
|
updateStatus(`${itemPropertyLabel(propertyKey)} must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`);
|
||||||
audio.sfxUiCancel();
|
audio.sfxUiCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { effect: normalized } });
|
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: normalized } });
|
||||||
} else if (propertyKey === 'effectValue') {
|
} else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') {
|
||||||
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
updateStatus(parsed.message);
|
updateStatus(parsed.message);
|
||||||
audio.sfxUiCancel();
|
audio.sfxUiCancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed.value) } });
|
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: clampEffectLevel(parsed.value) } });
|
||||||
} else if (propertyKey === 'facing') {
|
} else if (propertyKey === 'facing') {
|
||||||
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
|
|||||||
@@ -61,10 +61,10 @@
|
|||||||
{
|
{
|
||||||
"streamUrl": "",
|
"streamUrl": "",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"channel": "stereo",
|
"mediaChannel": "stereo",
|
||||||
"mediaVolume": 50,
|
"mediaVolume": 50,
|
||||||
"effect": "off",
|
"mediaEffect": "off",
|
||||||
"effectValue": 50,
|
"mediaEffectValue": 50,
|
||||||
"facing": 0,
|
"facing": 0,
|
||||||
"emitRange": 20
|
"emitRange": 20
|
||||||
}
|
}
|
||||||
@@ -74,9 +74,9 @@
|
|||||||
- `enabled`: boolean on/off flag.
|
- `enabled`: boolean on/off flag.
|
||||||
- UI behavior: in property menu, `Enter` toggles on/off directly.
|
- UI behavior: in property menu, `Enter` toggles on/off directly.
|
||||||
- `mediaVolume`: integer, range `0-100`, default `50`.
|
- `mediaVolume`: integer, range `0-100`, default `50`.
|
||||||
- `channel`: one of `stereo | mono | left | right`, default `stereo`.
|
- `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`.
|
||||||
- `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
|
- `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
|
||||||
- `effectValue`: number, range `0-100`, precision `0.1`.
|
- `mediaEffectValue`: number, range `0-100`, precision `0.1`.
|
||||||
- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`).
|
- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`).
|
||||||
- `emitRange`: integer, range `5-20`, default `20`.
|
- `emitRange`: integer, range `5-20`, default `20`.
|
||||||
|
|
||||||
@@ -137,6 +137,8 @@
|
|||||||
"facing": 0,
|
"facing": 0,
|
||||||
"emitRange": 15,
|
"emitRange": 15,
|
||||||
"emitVolume": 100,
|
"emitVolume": 100,
|
||||||
|
"emitEffect": "off",
|
||||||
|
"emitEffectValue": 50,
|
||||||
"useSound": "",
|
"useSound": "",
|
||||||
"emitSound": ""
|
"emitSound": ""
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,8 @@
|
|||||||
- `facing`: number, range `0-360`, precision `0.1`.
|
- `facing`: number, range `0-360`, precision `0.1`.
|
||||||
- `emitRange`: integer, range `1-20`, default `15`.
|
- `emitRange`: integer, range `1-20`, default `15`.
|
||||||
- `emitVolume`: integer, range `0-100`, default `100`.
|
- `emitVolume`: integer, range `0-100`, default `100`.
|
||||||
|
- `emitEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
|
||||||
|
- `emitEffectValue`: number, range `0-100`, precision `0.1`, default `50`.
|
||||||
- `useSound`: empty, filename (assumed under `sounds/`), or full URL.
|
- `useSound`: empty, filename (assumed under `sounds/`), or full URL.
|
||||||
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL.
|
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL.
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- Params:
|
- Params:
|
||||||
- `streamUrl=""`
|
- `streamUrl=""`
|
||||||
- `enabled=true`
|
- `enabled=true`
|
||||||
- `channel="stereo"`
|
- `mediaChannel="stereo"`
|
||||||
- `mediaVolume=50`
|
- `mediaVolume=50`
|
||||||
- `effect="off"`
|
- `mediaEffect="off"`
|
||||||
- `effectValue=50`
|
- `mediaEffectValue=50`
|
||||||
- `facing=0`
|
- `facing=0`
|
||||||
- `emitRange=20`
|
- `emitRange=20`
|
||||||
- Global:
|
- Global:
|
||||||
@@ -38,10 +38,10 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `use` toggles `enabled` on/off and broadcasts chat status.
|
- `use` toggles `enabled` on/off and broadcasts chat status.
|
||||||
|
|
||||||
### Validation
|
### Validation
|
||||||
- `channel`: `stereo | mono | left | right`
|
- `mediaChannel`: `stereo | mono | left | right`
|
||||||
- `mediaVolume`: integer `0..100`
|
- `mediaVolume`: integer `0..100`
|
||||||
- `effect`: `reverb | echo | flanger | high_pass | low_pass | off`
|
- `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off`
|
||||||
- `effectValue`: number `0..100` with `0.1` precision
|
- `mediaEffectValue`: number `0..100` with `0.1` precision
|
||||||
- `facing`: number `0..360` with `0.1` precision
|
- `facing`: number `0..360` with `0.1` precision
|
||||||
- `emitRange`: integer `5..20`
|
- `emitRange`: integer `5..20`
|
||||||
|
|
||||||
@@ -120,6 +120,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `facing=0`
|
- `facing=0`
|
||||||
- `emitRange=15`
|
- `emitRange=15`
|
||||||
- `emitVolume=100`
|
- `emitVolume=100`
|
||||||
|
- `emitEffect="off"`
|
||||||
|
- `emitEffectValue=50`
|
||||||
- `useSound=""`
|
- `useSound=""`
|
||||||
- `emitSound=""`
|
- `emitSound=""`
|
||||||
- Global:
|
- Global:
|
||||||
@@ -138,6 +140,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `facing`: number `0..360` with `0.1` precision
|
- `facing`: number `0..360` with `0.1` precision
|
||||||
- `emitRange`: integer `1..20`
|
- `emitRange`: integer `1..20`
|
||||||
- `emitVolume`: integer `0..100`
|
- `emitVolume`: integer `0..100`
|
||||||
|
- `emitEffect`: `reverb | echo | flanger | high_pass | low_pass | off`
|
||||||
|
- `emitEffectValue`: number `0..100` with `0.1` precision
|
||||||
- `useSound`: empty, filename (assumed under `sounds/`), or full URL
|
- `useSound`: empty, filename (assumed under `sounds/`), or full URL
|
||||||
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL
|
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL
|
||||||
|
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
||||||
"effect": RADIO_EFFECT_OPTIONS,
|
"mediaEffect": RADIO_EFFECT_OPTIONS,
|
||||||
"channel": RADIO_CHANNEL_OPTIONS,
|
"emitEffect": RADIO_EFFECT_OPTIONS,
|
||||||
|
"mediaChannel": RADIO_CHANNEL_OPTIONS,
|
||||||
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ EDITABLE_PROPERTIES: tuple[str, ...] = (
|
|||||||
"title",
|
"title",
|
||||||
"streamUrl",
|
"streamUrl",
|
||||||
"enabled",
|
"enabled",
|
||||||
"channel",
|
|
||||||
"mediaVolume",
|
"mediaVolume",
|
||||||
"effect",
|
"mediaChannel",
|
||||||
"effectValue",
|
"mediaEffect",
|
||||||
|
"mediaEffectValue",
|
||||||
"facing",
|
"facing",
|
||||||
"emitRange",
|
"emitRange",
|
||||||
)
|
)
|
||||||
@@ -31,10 +31,10 @@ DEFAULT_TITLE = "radio"
|
|||||||
DEFAULT_PARAMS: dict = {
|
DEFAULT_PARAMS: dict = {
|
||||||
"streamUrl": "",
|
"streamUrl": "",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"channel": "stereo",
|
|
||||||
"mediaVolume": 50,
|
"mediaVolume": 50,
|
||||||
"effect": "off",
|
"mediaChannel": "stereo",
|
||||||
"effectValue": 50,
|
"mediaEffect": "off",
|
||||||
|
"mediaEffectValue": 50,
|
||||||
"facing": 0,
|
"facing": 0,
|
||||||
"emitRange": 20,
|
"emitRange": 20,
|
||||||
}
|
}
|
||||||
@@ -46,14 +46,14 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio."},
|
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio."},
|
||||||
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
||||||
"channel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
|
||||||
"mediaVolume": {
|
"mediaVolume": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Playback media volume percent for this radio.",
|
"tooltip": "Playback media volume percent for this radio.",
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
"effect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
||||||
"effectValue": {
|
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
||||||
|
"mediaEffectValue": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Amount for the selected effect.",
|
"tooltip": "Amount for the selected effect.",
|
||||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||||
@@ -107,23 +107,23 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
|
|||||||
raise ValueError("mediaVolume must be between 0 and 100.")
|
raise ValueError("mediaVolume must be between 0 and 100.")
|
||||||
next_params["mediaVolume"] = media_volume
|
next_params["mediaVolume"] = media_volume
|
||||||
|
|
||||||
effect = str(next_params.get("effect", "off")).strip().lower()
|
effect = str(next_params.get("mediaEffect", "off")).strip().lower()
|
||||||
if effect not in EFFECT_OPTIONS:
|
if effect not in EFFECT_OPTIONS:
|
||||||
raise ValueError("effect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
raise ValueError("mediaEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
||||||
next_params["effect"] = effect
|
next_params["mediaEffect"] = effect
|
||||||
|
|
||||||
channel = str(next_params.get("channel", "stereo")).strip().lower()
|
channel = str(next_params.get("mediaChannel", "stereo")).strip().lower()
|
||||||
if channel not in CHANNEL_OPTIONS:
|
if channel not in CHANNEL_OPTIONS:
|
||||||
raise ValueError("channel must be one of stereo, mono, left, right.")
|
raise ValueError("mediaChannel must be one of stereo, mono, left, right.")
|
||||||
next_params["channel"] = channel
|
next_params["mediaChannel"] = channel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
effect_value = float(next_params.get("effectValue", 50))
|
effect_value = float(next_params.get("mediaEffectValue", 50))
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("effectValue must be a number.") from exc
|
raise ValueError("mediaEffectValue must be a number.") from exc
|
||||||
if not (0 <= effect_value <= 100):
|
if not (0 <= effect_value <= 100):
|
||||||
raise ValueError("effectValue must be between 0 and 100.")
|
raise ValueError("mediaEffectValue must be between 0 and 100.")
|
||||||
next_params["effectValue"] = round(effect_value, 1)
|
next_params["mediaEffectValue"] = round(effect_value, 1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
facing = float(next_params.get("facing", item.params.get("facing", 0)))
|
facing = float(next_params.get("facing", item.params.get("facing", 0)))
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ EDITABLE_PROPERTIES: tuple[str, ...] = (
|
|||||||
"facing",
|
"facing",
|
||||||
"emitRange",
|
"emitRange",
|
||||||
"emitVolume",
|
"emitVolume",
|
||||||
|
"emitEffect",
|
||||||
|
"emitEffectValue",
|
||||||
"useSound",
|
"useSound",
|
||||||
"emitSound",
|
"emitSound",
|
||||||
)
|
)
|
||||||
@@ -33,9 +35,12 @@ DEFAULT_PARAMS: dict = {
|
|||||||
"facing": 0,
|
"facing": 0,
|
||||||
"emitRange": 15,
|
"emitRange": 15,
|
||||||
"emitVolume": 100,
|
"emitVolume": 100,
|
||||||
|
"emitEffect": "off",
|
||||||
|
"emitEffectValue": 50,
|
||||||
"useSound": "",
|
"useSound": "",
|
||||||
"emitSound": "",
|
"emitSound": "",
|
||||||
}
|
}
|
||||||
|
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
@@ -56,6 +61,12 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"tooltip": "Emitted sound volume percent.",
|
"tooltip": "Emitted sound volume percent.",
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
|
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
|
||||||
|
"emitEffectValue": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Amount for emit effect.",
|
||||||
|
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||||
|
},
|
||||||
"useSound": {"valueType": "sound", "tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL."},
|
"useSound": {"valueType": "sound", "tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL."},
|
||||||
"emitSound": {"valueType": "sound", "tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL."},
|
"emitSound": {"valueType": "sound", "tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL."},
|
||||||
}
|
}
|
||||||
@@ -113,6 +124,19 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
|
|||||||
raise ValueError("emitVolume must be between 0 and 100.")
|
raise ValueError("emitVolume must be between 0 and 100.")
|
||||||
next_params["emitVolume"] = emit_volume
|
next_params["emitVolume"] = emit_volume
|
||||||
|
|
||||||
|
emit_effect = str(next_params.get("emitEffect", item.params.get("emitEffect", "off"))).strip().lower()
|
||||||
|
if emit_effect not in EFFECT_OPTIONS:
|
||||||
|
raise ValueError("emitEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
||||||
|
next_params["emitEffect"] = emit_effect
|
||||||
|
|
||||||
|
try:
|
||||||
|
emit_effect_value = float(next_params.get("emitEffectValue", item.params.get("emitEffectValue", 50)))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("emitEffectValue must be a number.") from exc
|
||||||
|
if not (0 <= emit_effect_value <= 100):
|
||||||
|
raise ValueError("emitEffectValue must be between 0 and 100.")
|
||||||
|
next_params["emitEffectValue"] = round(emit_effect_value, 1)
|
||||||
|
|
||||||
next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", "")))
|
next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", "")))
|
||||||
next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", "")))
|
next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", "")))
|
||||||
return next_params
|
return next_params
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async def test_radio_use_toggles_enabled(monkeypatch: pytest.MonkeyPatch) -> Non
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_radio_media_fields_update_validate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
server = SignalingServer("127.0.0.1", 8765, None, None)
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
ws = _fake_ws()
|
ws = _fake_ws()
|
||||||
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
|
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
|
||||||
@@ -106,17 +106,17 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
|
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
client,
|
client,
|
||||||
json.dumps({"type": "item_update", "itemId": item.id, "params": {"channel": "left"}}),
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"mediaChannel": "left"}}),
|
||||||
)
|
)
|
||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
assert item.params.get("channel") == "left"
|
assert item.params.get("mediaChannel") == "left"
|
||||||
|
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
client,
|
client,
|
||||||
json.dumps({"type": "item_update", "itemId": item.id, "params": {"channel": "invalid"}}),
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"mediaChannel": "invalid"}}),
|
||||||
)
|
)
|
||||||
assert send_payloads[-1].ok is False
|
assert send_payloads[-1].ok is False
|
||||||
assert "channel must be one of" in send_payloads[-1].message.lower()
|
assert "mediachannel must be one of" in send_payloads[-1].message.lower()
|
||||||
|
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
client,
|
client,
|
||||||
@@ -139,6 +139,13 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
assert item.params.get("mediaVolume") == 12
|
assert item.params.get("mediaVolume") == 12
|
||||||
|
|
||||||
|
await server._handle_message(
|
||||||
|
client,
|
||||||
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"mediaEffect": "echo"}}),
|
||||||
|
)
|
||||||
|
assert send_payloads[-1].ok is True
|
||||||
|
assert item.params.get("mediaEffect") == "echo"
|
||||||
|
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
client,
|
client,
|
||||||
json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 12}}),
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 12}}),
|
||||||
@@ -285,6 +292,8 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"facing": 123.4,
|
"facing": 123.4,
|
||||||
"emitRange": 7,
|
"emitRange": 7,
|
||||||
"emitVolume": 42,
|
"emitVolume": 42,
|
||||||
|
"emitEffect": "reverb",
|
||||||
|
"emitEffectValue": 63.2,
|
||||||
"useSound": "ping.ogg",
|
"useSound": "ping.ogg",
|
||||||
"emitSound": "https://example.com/ambient.ogg",
|
"emitSound": "https://example.com/ambient.ogg",
|
||||||
},
|
},
|
||||||
@@ -296,6 +305,8 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert item.params.get("facing") == 123.4
|
assert item.params.get("facing") == 123.4
|
||||||
assert item.params.get("emitRange") == 7
|
assert item.params.get("emitRange") == 7
|
||||||
assert item.params.get("emitVolume") == 42
|
assert item.params.get("emitVolume") == 42
|
||||||
|
assert item.params.get("emitEffect") == "reverb"
|
||||||
|
assert item.params.get("emitEffectValue") == 63.2
|
||||||
assert item.params.get("useSound") == "sounds/ping.ogg"
|
assert item.params.get("useSound") == "sounds/ping.ogg"
|
||||||
assert item.params.get("emitSound") == "https://example.com/ambient.ogg"
|
assert item.params.get("emitSound") == "https://example.com/ambient.ogg"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user