From a2c1306b46c25e0f35f03467088cf21279253769 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 22:38:48 -0500 Subject: [PATCH] Split media vs emit volume for radio and widget --- client/public/version.js | 2 +- client/src/audio/itemEmitRuntime.ts | 4 +++- client/src/audio/radioStationRuntime.ts | 4 ++-- client/src/items/itemRegistry.ts | 6 ++++-- client/src/main.ts | 15 ++++++++++++--- docs/item-schema.md | 6 ++++-- docs/item-types.md | 6 ++++-- server/app/items/radio.py | 19 +++++++++---------- server/app/items/widget.py | 25 ++++++++++++++++++++++++- server/tests/test_item_use_cooldown.py | 9 +++++++++ 10 files changed, 72 insertions(+), 24 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 1a68559..4664228 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 R126"; +window.CHGRID_WEB_VERSION = "2026.02.21 R127"; // 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 1dbce2e..9042c1b 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -135,7 +135,9 @@ export class ItemEmitRuntime { }); const gainValue = mix?.gain ?? 0; const panValue = mix?.pan ?? 0; - output.gain.gain.linearRampToValueAtTime(gainValue, audioCtx.currentTime + 0.1); + const emitVolumeRaw = Number(item.params.emitVolume ?? 100); + const emitVolume = Number.isFinite(emitVolumeRaw) ? Math.max(0, Math.min(100, emitVolumeRaw)) / 100 : 1; + output.gain.gain.linearRampToValueAtTime(gainValue * emitVolume, audioCtx.currentTime + 0.1); if (output.panner) { const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue)); output.panner.pan.linearRampToValueAtTime(resolvedPan, audioCtx.currentTime + 0.1); diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index 42cf35f..93bd0f5 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -203,8 +203,8 @@ export class RadioStationRuntime { } const streamUrl = String(item.params.streamUrl ?? '').trim(); const enabled = item.params.enabled !== false; - const volume = Number(item.params.volume ?? 50); - const normalizedVolume = Number.isFinite(volume) ? Math.max(0, Math.min(100, volume)) / 100 : 0.5; + const 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); this.applyEffect(output, audioCtx, effect, effectValue); diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 4b4e653..ed4d1dd 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', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'], + radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'mediaVolume', 'effect', 'effectValue', 'facing', 'emitRange'], dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], - widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'useSound', 'emitSound'], + widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'useSound', 'emitSound'], }; const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { @@ -193,6 +193,8 @@ export function itemTypeLabel(type: ItemType): string { export function itemPropertyLabel(key: string): string { if (key === 'use24Hour') return 'use 24 hour format'; if (key === 'emitRange') return 'emit range'; + if (key === 'mediaVolume') return 'media volume'; + if (key === 'emitVolume') return 'emit volume'; 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 23b87d5..d90ee7d 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -749,7 +749,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde key === 'x' || key === 'y' || key === 'version' || - key === 'volume' || + key === 'mediaVolume' || + key === 'emitVolume' || key === 'effectValue' || key === 'facing' || key === 'emitRange' || @@ -2138,14 +2139,22 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo } const directional = ['on', 'true', '1', 'yes'].includes(normalized); signaling.send({ type: 'item_update', itemId, params: { directional } }); - } else if (propertyKey === 'volume') { + } else if (propertyKey === 'mediaVolume') { const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); if (!parsed.ok) { updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { volume: parsed.value } }); + signaling.send({ type: 'item_update', itemId, params: { mediaVolume: parsed.value } }); + } else if (propertyKey === 'emitVolume') { + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); + if (!parsed.ok) { + updateStatus(parsed.message); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } }); } else if (propertyKey === 'effect') { const normalized = value.trim().toLowerCase() as EffectId; if (!EFFECT_IDS.has(normalized)) { diff --git a/docs/item-schema.md b/docs/item-schema.md index 36ef725..2802fb6 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -62,7 +62,7 @@ "streamUrl": "", "enabled": true, "channel": "stereo", - "volume": 50, + "mediaVolume": 50, "effect": "off", "effectValue": 50, "facing": 0, @@ -73,7 +73,7 @@ - `streamUrl`: string, empty allowed until configured. - `enabled`: boolean on/off flag. - UI behavior: in property menu, `Enter` toggles on/off directly. -- `volume`: integer, range `0-100`, default `50`. +- `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`. @@ -136,6 +136,7 @@ "directional": false, "facing": 0, "emitRange": 15, + "emitVolume": 100, "useSound": "", "emitSound": "" } @@ -145,6 +146,7 @@ - `directional`: boolean (or `on/off` in updates), default `false`. - `facing`: number, range `0-360`, precision `0.1`. - `emitRange`: integer, range `1-20`, default `15`. +- `emitVolume`: integer, range `0-100`, default `100`. - `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 1af3820..e4f04a2 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -22,7 +22,7 @@ This is behavior-focused documentation for item types and their defaults. - `streamUrl=""` - `enabled=true` - `channel="stereo"` - - `volume=50` + - `mediaVolume=50` - `effect="off"` - `effectValue=50` - `facing=0` @@ -39,7 +39,7 @@ This is behavior-focused documentation for item types and their defaults. ### Validation - `channel`: `stereo | mono | left | right` -- `volume`: integer `0..100` +- `mediaVolume`: integer `0..100` - `effect`: `reverb | echo | flanger | high_pass | low_pass | off` - `effectValue`: number `0..100` with `0.1` precision - `facing`: number `0..360` with `0.1` precision @@ -119,6 +119,7 @@ This is behavior-focused documentation for item types and their defaults. - `directional=false` - `facing=0` - `emitRange=15` + - `emitVolume=100` - `useSound=""` - `emitSound=""` - Global: @@ -136,6 +137,7 @@ This is behavior-focused documentation for item types and their defaults. - `directional`: boolean or on/off style input - `facing`: number `0..360` with `0.1` precision - `emitRange`: integer `1..20` +- `emitVolume`: integer `0..100` - `useSound`: empty, filename (assumed under `sounds/`), or full URL - `emitSound`: empty, filename (assumed under `sounds/`), or full URL diff --git a/server/app/items/radio.py b/server/app/items/radio.py index c1bf59f..949b507 100644 --- a/server/app/items/radio.py +++ b/server/app/items/radio.py @@ -15,7 +15,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "streamUrl", "enabled", "channel", - "volume", + "mediaVolume", "effect", "effectValue", "facing", @@ -32,7 +32,7 @@ DEFAULT_PARAMS: dict = { "streamUrl": "", "enabled": True, "channel": "stereo", - "volume": 50, + "mediaVolume": 50, "effect": "off", "effectValue": 50, "facing": 0, @@ -47,9 +47,9 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "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."}, - "volume": { + "mediaVolume": { "valueType": "number", - "tooltip": "Playback volume percent for this radio.", + "tooltip": "Playback media volume percent for this radio.", "range": {"min": 0, "max": 100, "step": 1}, }, "effect": {"valueType": "list", "tooltip": "Select the active radio effect."}, @@ -100,12 +100,12 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: next_params["enabled"] = enabled try: - volume = int(next_params.get("volume", 50)) + media_volume = int(next_params.get("mediaVolume", 50)) except (TypeError, ValueError) as exc: - raise ValueError("volume must be a number.") from exc - if not (0 <= volume <= 100): - raise ValueError("volume must be between 0 and 100.") - next_params["volume"] = volume + raise ValueError("mediaVolume must be a number.") from exc + if not (0 <= media_volume <= 100): + raise ValueError("mediaVolume must be between 0 and 100.") + next_params["mediaVolume"] = media_volume effect = str(next_params.get("effect", "off")).strip().lower() if effect not in EFFECT_OPTIONS: @@ -153,4 +153,3 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], others_message=f"{nickname} turns {state_text} {item.title}.", updated_params={**item.params, "enabled": next_enabled}, ) - diff --git a/server/app/items/widget.py b/server/app/items/widget.py index 3013c0d..0a49dbf 100644 --- a/server/app/items/widget.py +++ b/server/app/items/widget.py @@ -10,7 +10,16 @@ from .helpers import parse_bool_like, toggle_bool_param LABEL = "widget" TOOLTIP = "A basic item. Make it a beacon or whatever you want." -EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "enabled", "directional", "facing", "emitRange", "useSound", "emitSound") +EDITABLE_PROPERTIES: tuple[str, ...] = ( + "title", + "enabled", + "directional", + "facing", + "emitRange", + "emitVolume", + "useSound", + "emitSound", +) CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") USE_SOUND: str | None = None EMIT_SOUND: str | None = None @@ -23,6 +32,7 @@ DEFAULT_PARAMS: dict = { "directional": False, "facing": 0, "emitRange": 15, + "emitVolume": 100, "useSound": "", "emitSound": "", } @@ -41,6 +51,11 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "Maximum distance in squares for emitted sound.", "range": {"min": 1, "max": 20, "step": 1}, }, + "emitVolume": { + "valueType": "number", + "tooltip": "Emitted sound volume percent.", + "range": {"min": 0, "max": 100, "step": 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."}, } @@ -90,6 +105,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: raise ValueError("emitRange must be between 1 and 20.") next_params["emitRange"] = emit_range + try: + emit_volume = int(next_params.get("emitVolume", item.params.get("emitVolume", 100))) + except (TypeError, ValueError) as exc: + raise ValueError("emitVolume must be an integer between 0 and 100.") from exc + if not (0 <= emit_volume <= 100): + raise ValueError("emitVolume must be between 0 and 100.") + next_params["emitVolume"] = emit_volume + 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 7b54136..1b03f0b 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -132,6 +132,13 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) - assert send_payloads[-1].ok is False assert "facing must be between 0 and 360" in send_payloads[-1].message.lower() + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"mediaVolume": 12}}), + ) + 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": {"emitRange": 12}}), @@ -277,6 +284,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: "directional": True, "facing": 123.4, "emitRange": 7, + "emitVolume": 42, "useSound": "ping.ogg", "emitSound": "https://example.com/ambient.ogg", }, @@ -287,6 +295,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: assert item.params.get("directional") is True assert item.params.get("facing") == 123.4 assert item.params.get("emitRange") == 7 + assert item.params.get("emitVolume") == 42 assert item.params.get("useSound") == "sounds/ping.ogg" assert item.params.get("emitSound") == "https://example.com/ambient.ogg"