diff --git a/client/public/version.js b/client/public/version.js index 4664228..eac818b 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // 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. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 9042c1b..94360c2 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -1,11 +1,17 @@ import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; import { AudioEngine } from './audioEngine'; +import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects'; +import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime'; import { resolveSpatialMix } from './spatial'; type EmitOutput = { soundUrl: string; element: HTMLAudioElement; source: MediaElementAudioSourceNode; + effectInput: GainNode; + effectRuntime: EffectRuntime | null; + effect: EffectId; + effectValue: number; gain: GainNode; panner: StereoPannerNode | null; }; @@ -34,6 +40,8 @@ export class ItemEmitRuntime { output.element.pause(); output.element.src = ''; output.source.disconnect(); + output.effectInput.disconnect(); + disconnectEffectRuntime(output.effectRuntime); output.gain.disconnect(); output.panner?.disconnect(); this.outputs.delete(itemId); @@ -85,17 +93,21 @@ export class ItemEmitRuntime { element.preload = 'none'; element.crossOrigin = 'anonymous'; const source = audioCtx.createMediaElementSource(element); + const effectInput = audioCtx.createGain(); const gain = audioCtx.createGain(); gain.gain.value = 0; 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()) { panner = audioCtx.createStereoPanner(); gain.connect(panner).connect(audioCtx.destination); } else { 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); } @@ -117,6 +129,15 @@ export class ItemEmitRuntime { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); 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 mix = resolveSpatialMix({ dx: item.x - playerPosition.x, diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index 93bd0f5..8a1da9e 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -205,8 +205,8 @@ export class RadioStationRuntime { const enabled = item.params.enabled !== false; const mediaVolume = Number(item.params.mediaVolume ?? 50); const normalizedVolume = Number.isFinite(mediaVolume) ? Math.max(0, Math.min(100, mediaVolume)) / 100 : 0.5; - const effect = normalizeRadioEffect(item.params.effect); - const effectValue = normalizeRadioEffectValue(item.params.effectValue); + const effect = normalizeRadioEffect(item.params.mediaEffect); + const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue); this.applyEffect(output, audioCtx, effect, effectValue); if (!streamUrl || !enabled) { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); @@ -299,7 +299,7 @@ export class RadioStationRuntime { const audioCtx = this.audio.context; if (!audioCtx) return; - const channel = normalizeRadioChannel(item.params.channel); + const channel = normalizeRadioChannel(item.params.mediaChannel); const existing = this.itemRadioOutputs.get(item.id); if (existing && existing.streamUrl === streamUrl && existing.channel === channel) { return; @@ -315,8 +315,8 @@ export class RadioStationRuntime { 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 effect = normalizeRadioEffect(item.params.mediaEffect); + const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue); const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); let panner: StereoPannerNode | null = null; if (this.audio.supportsStereoPanner()) { diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index ed4d1dd..208244c 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -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_EDITABLE_PROPERTIES: Record = { - 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'], wheel: ['title', 'spaces'], 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> = { @@ -112,8 +112,9 @@ let itemTypeGlobalProperties: Record> = { - effect: EFFECT_SEQUENCE.map((effect) => effect.id), - channel: [...RADIO_CHANNEL_OPTIONS], + mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id), + emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id), + mediaChannel: [...RADIO_CHANNEL_OPTIONS], timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], }; let itemTypePropertyMetadata: Partial>> = {}; @@ -195,6 +196,11 @@ export function itemPropertyLabel(key: string): string { if (key === 'emitRange') return 'emit range'; if (key === 'mediaVolume') return 'media 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 === 'emitSound') return 'emit sound'; return key; diff --git a/client/src/main.ts b/client/src/main.ts index d90ee7d..528af7f 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -723,9 +723,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string { } if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone()); if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off'; - if (key === 'channel') return normalizeRadioChannel(item.params.channel); - if (key === 'effect') return normalizeRadioEffect(item.params.effect); - if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue)); + if (key === 'mediaChannel') return normalizeRadioChannel(item.params.mediaChannel); + if (key === 'mediaEffect') return normalizeRadioEffect(item.params.mediaEffect); + 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') { const parsed = Number(item.params.facing ?? 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 { if (key === 'useSound' || key === 'emitSound') return 'sound'; 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 ( key === 'x' || key === 'y' || key === 'version' || key === 'mediaVolume' || key === 'emitVolume' || - key === 'effectValue' || + key === 'mediaEffectValue' || + key === 'emitEffectValue' || key === 'facing' || key === 'emitRange' || key === 'sides' || @@ -2155,22 +2158,22 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo return; } 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; 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(); return; } - signaling.send({ type: 'item_update', itemId, params: { effect: normalized } }); - } else if (propertyKey === 'effectValue') { + signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: normalized } }); + } else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') { const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false); if (!parsed.ok) { updateStatus(parsed.message); audio.sfxUiCancel(); 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') { const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false); if (!parsed.ok) { diff --git a/docs/item-schema.md b/docs/item-schema.md index 2802fb6..7a02557 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -61,10 +61,10 @@ { "streamUrl": "", "enabled": true, - "channel": "stereo", + "mediaChannel": "stereo", "mediaVolume": 50, - "effect": "off", - "effectValue": 50, + "mediaEffect": "off", + "mediaEffectValue": 50, "facing": 0, "emitRange": 20 } @@ -74,9 +74,9 @@ - `enabled`: boolean on/off flag. - UI behavior: in property menu, `Enter` toggles on/off directly. - `mediaVolume`: integer, range `0-100`, default `50`. -- `channel`: one of `stereo | mono | left | right`, default `stereo`. -- `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. -- `effectValue`: number, range `0-100`, precision `0.1`. +- `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`. +- `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. +- `mediaEffectValue`: number, range `0-100`, precision `0.1`. - `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`). - `emitRange`: integer, range `5-20`, default `20`. @@ -137,6 +137,8 @@ "facing": 0, "emitRange": 15, "emitVolume": 100, + "emitEffect": "off", + "emitEffectValue": 50, "useSound": "", "emitSound": "" } @@ -147,6 +149,8 @@ - `facing`: number, range `0-360`, precision `0.1`. - `emitRange`: integer, range `1-20`, default `15`. - `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. - `emitSound`: empty, filename (assumed under `sounds/`), or full URL. diff --git a/docs/item-types.md b/docs/item-types.md index e4f04a2..b2e9edb 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -21,10 +21,10 @@ This is behavior-focused documentation for item types and their defaults. - Params: - `streamUrl=""` - `enabled=true` - - `channel="stereo"` + - `mediaChannel="stereo"` - `mediaVolume=50` - - `effect="off"` - - `effectValue=50` + - `mediaEffect="off"` + - `mediaEffectValue=50` - `facing=0` - `emitRange=20` - 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. ### Validation -- `channel`: `stereo | mono | left | right` +- `mediaChannel`: `stereo | mono | left | right` - `mediaVolume`: integer `0..100` -- `effect`: `reverb | echo | flanger | high_pass | low_pass | off` -- `effectValue`: number `0..100` with `0.1` precision +- `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off` +- `mediaEffectValue`: number `0..100` with `0.1` precision - `facing`: number `0..360` with `0.1` precision - `emitRange`: integer `5..20` @@ -120,6 +120,8 @@ This is behavior-focused documentation for item types and their defaults. - `facing=0` - `emitRange=15` - `emitVolume=100` + - `emitEffect="off"` + - `emitEffectValue=50` - `useSound=""` - `emitSound=""` - Global: @@ -138,6 +140,8 @@ This is behavior-focused documentation for item types and their defaults. - `facing`: number `0..360` with `0.1` precision - `emitRange`: integer `1..20` - `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 - `emitSound`: empty, filename (assumed under `sounds/`), or full URL diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 1f6f88e..5169c0e 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -75,8 +75,9 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { } ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = { - "effect": RADIO_EFFECT_OPTIONS, - "channel": RADIO_CHANNEL_OPTIONS, + "mediaEffect": RADIO_EFFECT_OPTIONS, + "emitEffect": RADIO_EFFECT_OPTIONS, + "mediaChannel": RADIO_CHANNEL_OPTIONS, "timeZone": CLOCK_TIME_ZONE_OPTIONS, } diff --git a/server/app/items/radio.py b/server/app/items/radio.py index 949b507..8de3b10 100644 --- a/server/app/items/radio.py +++ b/server/app/items/radio.py @@ -14,10 +14,10 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "title", "streamUrl", "enabled", - "channel", "mediaVolume", - "effect", - "effectValue", + "mediaChannel", + "mediaEffect", + "mediaEffectValue", "facing", "emitRange", ) @@ -31,10 +31,10 @@ DEFAULT_TITLE = "radio" DEFAULT_PARAMS: dict = { "streamUrl": "", "enabled": True, - "channel": "stereo", "mediaVolume": 50, - "effect": "off", - "effectValue": 50, + "mediaChannel": "stereo", + "mediaEffect": "off", + "mediaEffectValue": 50, "facing": 0, "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."}, "streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by 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": { "valueType": "number", "tooltip": "Playback media volume percent for this radio.", "range": {"min": 0, "max": 100, "step": 1}, }, - "effect": {"valueType": "list", "tooltip": "Select the active radio effect."}, - "effectValue": { + "mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."}, + "mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."}, + "mediaEffectValue": { "valueType": "number", "tooltip": "Amount for the selected effect.", "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.") 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: - raise ValueError("effect must be one of reverb, echo, flanger, high_pass, low_pass, off.") - next_params["effect"] = effect + raise ValueError("mediaEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.") + 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: - raise ValueError("channel must be one of stereo, mono, left, right.") - next_params["channel"] = channel + raise ValueError("mediaChannel must be one of stereo, mono, left, right.") + next_params["mediaChannel"] = channel try: - effect_value = float(next_params.get("effectValue", 50)) + effect_value = float(next_params.get("mediaEffectValue", 50)) 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): - raise ValueError("effectValue must be between 0 and 100.") - next_params["effectValue"] = round(effect_value, 1) + raise ValueError("mediaEffectValue must be between 0 and 100.") + next_params["mediaEffectValue"] = round(effect_value, 1) try: facing = float(next_params.get("facing", item.params.get("facing", 0))) diff --git a/server/app/items/widget.py b/server/app/items/widget.py index 0a49dbf..4cfa5f0 100644 --- a/server/app/items/widget.py +++ b/server/app/items/widget.py @@ -17,6 +17,8 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "facing", "emitRange", "emitVolume", + "emitEffect", + "emitEffectValue", "useSound", "emitSound", ) @@ -33,9 +35,12 @@ DEFAULT_PARAMS: dict = { "facing": 0, "emitRange": 15, "emitVolume": 100, + "emitEffect": "off", + "emitEffectValue": 50, "useSound": "", "emitSound": "", } +EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off") PROPERTY_METADATA: dict[str, dict[str, object]] = { "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.", "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."}, "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.") 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["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", ""))) return next_params diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 1b03f0b..3e6c2e6 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -85,7 +85,7 @@ async def test_radio_use_toggles_enabled(monkeypatch: pytest.MonkeyPatch) -> Non @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) ws = _fake_ws() 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( 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 item.params.get("channel") == "left" + assert item.params.get("mediaChannel") == "left" await server._handle_message( 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 "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( client, @@ -139,6 +139,13 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) - assert send_payloads[-1].ok is True 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( client, 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, "emitRange": 7, "emitVolume": 42, + "emitEffect": "reverb", + "emitEffectValue": 63.2, "useSound": "ping.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("emitRange") == 7 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("emitSound") == "https://example.com/ambient.ogg"