diff --git a/client/public/version.js b/client/public/version.js index 420dbc7..4a67ac3 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.22 R130"; +window.CHGRID_WEB_VERSION = "2026.02.22 R131"; // 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 ddf4e43..947433d 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -1,4 +1,5 @@ import { HEARING_RADIUS, type WorldItem } from '../state/gameState'; +import { getItemTypeGlobalProperties } from '../items/itemRegistry'; import { AudioEngine } from './audioEngine'; import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects'; import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime'; @@ -33,6 +34,26 @@ function resolveEmitPlaybackRate(raw: unknown): number { return 1 + ((clamped - 50) / 50) * 1; } +function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean): void { + const target = element as HTMLAudioElement & { + preservesPitch?: boolean; + mozPreservesPitch?: boolean; + webkitPreservesPitch?: boolean; + }; + if ('preservesPitch' in target) target.preservesPitch = enabled; + if ('mozPreservesPitch' in target) target.mozPreservesPitch = enabled; + if ('webkitPreservesPitch' in target) target.webkitPreservesPitch = enabled; +} + +function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean } { + const globals = getItemTypeGlobalProperties(item.type); + const speed = resolveEmitPlaybackRate(item.params.emitSoundSpeed ?? globals.emitSoundSpeed ?? 50); + const tempo = resolveEmitPlaybackRate(item.params.emitSoundTempo ?? globals.emitSoundTempo ?? 50); + const playbackRate = Math.max(0.25, Math.min(4, speed * tempo)); + const preservePitch = Math.abs(speed - 1) < 0.001; + return { playbackRate, preservePitch }; +} + export class ItemEmitRuntime { private readonly outputs = new Map(); private layerEnabled = true; @@ -110,7 +131,9 @@ export class ItemEmitRuntime { const effect = normalizeRadioEffect(item.params.emitEffect); const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue); const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); - element.playbackRate = resolveEmitPlaybackRate(item.params.emitSoundSpeed); + const initialRates = resolveEmitRates(item); + setElementPreservesPitch(element, initialRates.preservePitch); + element.playbackRate = initialRates.playbackRate; if (this.audio.supportsStereoPanner()) { panner = audioCtx.createStereoPanner(); gain.connect(panner).connect(audioCtx.destination); @@ -148,7 +171,9 @@ export class ItemEmitRuntime { output.effect = effect; output.effectValue = effectValue; } - const nextPlaybackRate = resolveEmitPlaybackRate(item.params.emitSoundSpeed); + const nextRates = resolveEmitRates(item); + setElementPreservesPitch(output.element, nextRates.preservePitch); + const nextPlaybackRate = nextRates.playbackRate; if (Math.abs(output.element.playbackRate - nextPlaybackRate) > 0.001) { output.element.playbackRate = nextPlaybackRate; } diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index c13489a..2f7f1c7 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -52,15 +52,15 @@ const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], - widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], + widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], }; const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { - radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true }, - dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false }, - wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false }, - clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false }, - widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false }, + radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true, emitSoundSpeed: 50, emitSoundTempo: 50 }, + dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, + wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, + clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, + widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50 }, }; export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound'; @@ -197,6 +197,7 @@ export function itemPropertyLabel(key: string): string { if (key === 'mediaVolume') return 'media volume'; if (key === 'emitVolume') return 'emit volume'; if (key === 'emitSoundSpeed') return 'emit sound speed'; + if (key === 'emitSoundTempo') return 'emit sound tempo'; if (key === 'mediaChannel') return 'media channel'; if (key === 'mediaEffect') return 'media effect'; if (key === 'mediaEffectValue') return 'media effect value'; diff --git a/client/src/main.ts b/client/src/main.ts index 996329a..0cee372 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -754,6 +754,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde key === 'mediaVolume' || key === 'emitVolume' || key === 'emitSoundSpeed' || + key === 'emitSoundTempo' || key === 'mediaEffectValue' || key === 'emitEffectValue' || key === 'facing' || @@ -2167,6 +2168,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo return; } signaling.send({ type: 'item_update', itemId, params: { emitSoundSpeed: parsed.value } }); + } else if (propertyKey === 'emitSoundTempo') { + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); + if (!parsed.ok) { + updateStatus(parsed.message); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { emitSoundTempo: parsed.value } }); } else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { 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 0d01375..e8ef520 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -138,6 +138,7 @@ "emitRange": 15, "emitVolume": 100, "emitSoundSpeed": 50, + "emitSoundTempo": 50, "emitEffect": "off", "emitEffectValue": 50, "useSound": "", @@ -150,7 +151,8 @@ - `facing`: number, range `0-360`, precision `0.1`. - `emitRange`: integer, range `1-20`, default `15`. - `emitVolume`: integer, range `0-100`, default `100`. -- `emitSoundSpeed`: integer, range `0-100`, default `50`; maps to playback rate (`0=0.5x`, `50=1.0x`, `100=2.0x`). +- `emitSoundSpeed`: integer, range `0-100`, default `50`; controls emitted sound speed/pitch (`0=0.5x`, `50=1.0x`, `100=2.0x`). +- `emitSoundTempo`: integer, range `0-100`, default `50`; controls emitted sound tempo (`0=0.5x`, `50=1.0x`, `100=2.0x`). - `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. diff --git a/docs/item-types.md b/docs/item-types.md index 8199da2..b9c7ce6 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -121,6 +121,7 @@ This is behavior-focused documentation for item types and their defaults. - `emitRange=15` - `emitVolume=100` - `emitSoundSpeed=50` + - `emitSoundTempo=50` - `emitEffect="off"` - `emitEffectValue=50` - `useSound=""` @@ -131,6 +132,8 @@ This is behavior-focused documentation for item types and their defaults. - `useCooldownMs=1000` - `emitRange=15` - `directional=false` + - `emitSoundSpeed=50` + - `emitSoundTempo=50` ### Use - `use` toggles `enabled` on/off and plays `useSound` when configured. @@ -141,7 +144,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` -- `emitSoundSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) +- `emitSoundSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for speed/pitch +- `emitSoundTempo`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for tempo - `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 diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 5169c0e..f183e51 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -91,6 +91,16 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = { "useCooldownMs": {"valueType": "number", "tooltip": "Global cooldown in milliseconds between uses for this item type."}, "emitRange": {"valueType": "number", "tooltip": "Maximum distance in squares where emitted audio can be heard."}, "directional": {"valueType": "boolean", "tooltip": "Whether emitted audio favors the item's facing direction."}, + "emitSoundSpeed": { + "valueType": "number", + "tooltip": "Global emitted sound speed/pitch percent. 50 is normal.", + "range": {"min": 0, "max": 100, "step": 1}, + }, + "emitSoundTempo": { + "valueType": "number", + "tooltip": "Global emitted sound tempo percent. 50 is normal.", + "range": {"min": 0, "max": 100, "step": 1}, + }, } ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = { @@ -124,4 +134,6 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | boo "useCooldownMs": get_item_use_cooldown_ms(item_type), "emitRange": definition.emit_range if isinstance(definition.emit_range, int) and definition.emit_range > 0 else 15, "directional": bool(definition.directional), + "emitSoundSpeed": 50, + "emitSoundTempo": 50, } diff --git a/server/app/items/widget.py b/server/app/items/widget.py index cc9a5ac..e5becfb 100644 --- a/server/app/items/widget.py +++ b/server/app/items/widget.py @@ -18,6 +18,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "emitRange", "emitVolume", "emitSoundSpeed", + "emitSoundTempo", "emitEffect", "emitEffectValue", "useSound", @@ -37,6 +38,7 @@ DEFAULT_PARAMS: dict = { "emitRange": 15, "emitVolume": 100, "emitSoundSpeed": 50, + "emitSoundTempo": 50, "emitEffect": "off", "emitEffectValue": 50, "useSound": "", @@ -68,6 +70,11 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "Playback speed/pitch percent for emitted sound. 50 is normal, 0 is half, 100 is double.", "range": {"min": 0, "max": 100, "step": 1}, }, + "emitSoundTempo": { + "valueType": "number", + "tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double.", + "range": {"min": 0, "max": 100, "step": 1}, + }, "emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."}, "emitEffectValue": { "valueType": "number", @@ -139,6 +146,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: raise ValueError("emitSoundSpeed must be between 0 and 100.") next_params["emitSoundSpeed"] = emit_speed + try: + emit_tempo = int(next_params.get("emitSoundTempo", item.params.get("emitSoundTempo", 50))) + except (TypeError, ValueError) as exc: + raise ValueError("emitSoundTempo must be an integer between 0 and 100.") from exc + if not (0 <= emit_tempo <= 100): + raise ValueError("emitSoundTempo must be between 0 and 100.") + next_params["emitSoundTempo"] = emit_tempo + 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.") diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 98d06b6..813d267 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -293,6 +293,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: "emitRange": 7, "emitVolume": 42, "emitSoundSpeed": 25, + "emitSoundTempo": 60, "emitEffect": "reverb", "emitEffectValue": 63.2, "useSound": "ping.ogg", @@ -307,6 +308,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: assert item.params.get("emitRange") == 7 assert item.params.get("emitVolume") == 42 assert item.params.get("emitSoundSpeed") == 25 + assert item.params.get("emitSoundTempo") == 60 assert item.params.get("emitEffect") == "reverb" assert item.params.get("emitEffectValue") == 63.2 assert item.params.get("useSound") == "sounds/ping.ogg" @@ -330,3 +332,10 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: ) assert send_payloads[-1].ok is False assert "emitsoundspeed must be between 0 and 100" in send_payloads[-1].message.lower() + + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitSoundTempo": 101}}), + ) + assert send_payloads[-1].ok is False + assert "emitsoundtempo must be between 0 and 100" in send_payloads[-1].message.lower()