diff --git a/client/public/version.js b/client/public/version.js index ecb2ef5..379ffbe 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 R135"; +window.CHGRID_WEB_VERSION = "2026.02.22 R136"; // 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/effects.ts b/client/src/audio/effects.ts index d85f02f..bfc09bd 100644 --- a/client/src/audio/effects.ts +++ b/client/src/audio/effects.ts @@ -71,14 +71,16 @@ export function connectEffectChain( } if (effect === 'echo') { + // Retune echo curve so 100 ~= previous 80, then increase decay (more fade) by reducing feedback. + const tunedMix = effectMix * 0.8; const delay = audioCtx.createDelay(1); - delay.delayTime.value = 0.04 + effectMix * 0.76; + delay.delayTime.value = 0.04 + tunedMix * 0.76; const feedback = audioCtx.createGain(); - feedback.gain.value = 0.04 + effectMix * 0.88; + feedback.gain.value = 0.02 + tunedMix * 0.44; const wetGain = audioCtx.createGain(); - wetGain.gain.value = 0.08 + effectMix * 0.92; + wetGain.gain.value = 0.08 + tunedMix * 0.92; const dryGain = audioCtx.createGain(); - dryGain.gain.value = 1 - effectMix * 0.85; + dryGain.gain.value = 1 - tunedMix * 0.85; input.connect(dryGain); dryGain.connect(destination); diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 947433d..e244c51 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -45,13 +45,23 @@ function setElementPreservesPitch(element: HTMLAudioElement, enabled: boolean): if ('webkitPreservesPitch' in target) target.webkitPreservesPitch = enabled; } -function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean } { +function setElementPlaybackRate(element: HTMLAudioElement, playbackRate: number, reverse: boolean): void { + const targetRate = reverse ? -Math.abs(playbackRate) : Math.abs(playbackRate); + element.playbackRate = targetRate; + // Most browsers reject negative playbackRate for media elements; fall back gracefully. + if (reverse && element.playbackRate >= 0) { + element.playbackRate = Math.abs(playbackRate); + } +} + +function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitch: boolean; reverse: 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 reverse = item.params.emitSoundReverse === true || (item.params.emitSoundReverse === undefined && globals.emitSoundReverse === true); const playbackRate = Math.max(0.25, Math.min(4, speed * tempo)); const preservePitch = Math.abs(speed - 1) < 0.001; - return { playbackRate, preservePitch }; + return { playbackRate, preservePitch, reverse }; } export class ItemEmitRuntime { @@ -133,7 +143,7 @@ export class ItemEmitRuntime { const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); const initialRates = resolveEmitRates(item); setElementPreservesPitch(element, initialRates.preservePitch); - element.playbackRate = initialRates.playbackRate; + setElementPlaybackRate(element, initialRates.playbackRate, initialRates.reverse); if (this.audio.supportsStereoPanner()) { panner = audioCtx.createStereoPanner(); gain.connect(panner).connect(audioCtx.destination); @@ -174,8 +184,11 @@ export class ItemEmitRuntime { 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; + const absCurrentRate = Math.abs(output.element.playbackRate); + const shouldReverse = nextRates.reverse; + const isReverseNow = output.element.playbackRate < 0; + if (Math.abs(absCurrentRate - nextPlaybackRate) > 0.001 || isReverseNow !== shouldReverse) { + setElementPlaybackRate(output.element, nextPlaybackRate, shouldReverse); } const spatialConfig = this.getSpatialConfig(item); const mix = resolveSpatialMix({ diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 2f7f1c7..fa31af7 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', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], + widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitSoundReverse', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], }; const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { - 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 }, + radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true, emitSoundSpeed: 50, emitSoundTempo: 50, emitSoundReverse: false }, + dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50, emitSoundReverse: false }, + wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50, emitSoundReverse: false }, + clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50, emitSoundReverse: false }, + widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false, emitSoundSpeed: 50, emitSoundTempo: 50, emitSoundReverse: false }, }; export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound'; @@ -198,6 +198,7 @@ export function itemPropertyLabel(key: string): string { if (key === 'emitVolume') return 'emit volume'; if (key === 'emitSoundSpeed') return 'emit sound speed'; if (key === 'emitSoundTempo') return 'emit sound tempo'; + if (key === 'emitSoundReverse') return 'emit sound reverse'; 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 7819fe7..49eea3d 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -715,6 +715,12 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound); if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound); if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; + if (key === 'emitSoundReverse') { + if (typeof item.params.emitSoundReverse === 'boolean') { + return item.params.emitSoundReverse ? 'on' : 'off'; + } + return getItemTypeGlobalProperties(item.type).emitSoundReverse === true ? 'on' : 'off'; + } if (key === 'directional') { if (typeof item.params.directional === 'boolean') { return item.params.directional ? 'on' : 'off'; @@ -747,7 +753,7 @@ 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 === 'enabled' || key === 'use24Hour' || key === 'directional' || key === 'emitSoundReverse') return 'boolean'; if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone') return 'list'; if ( key === 'x' || @@ -2062,6 +2068,13 @@ function handleItemPropertiesModeInput(code: string, key: string): void { audio.sfxUiBlip(); return; } + if (key === 'emitSoundReverse') { + const nextEmitSoundReverse = item.params.emitSoundReverse !== true; + signaling.send({ type: 'item_update', itemId, params: { emitSoundReverse: nextEmitSoundReverse } }); + updateStatus(`emit sound reverse: ${nextEmitSoundReverse ? 'on' : 'off'}`); + audio.sfxUiBlip(); + return; + } if (key === 'use24Hour') { const nextUse24Hour = item.params.use24Hour !== true; signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); @@ -2146,6 +2159,15 @@ 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 === 'emitSoundReverse') { + const normalized = value.toLowerCase(); + if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) { + updateStatus('emit sound reverse must be on or off.'); + audio.sfxUiCancel(); + return; + } + const emitSoundReverse = ['on', 'true', '1', 'yes'].includes(normalized); + signaling.send({ type: 'item_update', itemId, params: { emitSoundReverse } }); } else if (propertyKey === 'mediaVolume') { const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); if (!parsed.ok) { diff --git a/docs/item-schema.md b/docs/item-schema.md index e8ef520..698de36 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -139,6 +139,7 @@ "emitVolume": 100, "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitSoundReverse": false, "emitEffect": "off", "emitEffectValue": 50, "useSound": "", @@ -153,6 +154,7 @@ - `emitVolume`: integer, range `0-100`, default `100`. - `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`). +- `emitSoundReverse`: boolean (or `on/off` in updates), default `false`. - `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 b9c7ce6..788947c 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -122,6 +122,7 @@ This is behavior-focused documentation for item types and their defaults. - `emitVolume=100` - `emitSoundSpeed=50` - `emitSoundTempo=50` + - `emitSoundReverse=false` - `emitEffect="off"` - `emitEffectValue=50` - `useSound=""` @@ -134,6 +135,7 @@ This is behavior-focused documentation for item types and their defaults. - `directional=false` - `emitSoundSpeed=50` - `emitSoundTempo=50` + - `emitSoundReverse=false` ### Use - `use` toggles `enabled` on/off and plays `useSound` when configured. @@ -146,6 +148,7 @@ This is behavior-focused documentation for item types and their defaults. - `emitVolume`: integer `0..100` - `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 +- `emitSoundReverse`: boolean/on-off - `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/docs/protocol-notes.md b/docs/protocol-notes.md index a6f39ec..b2e47e9 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -44,7 +44,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`) - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`) - - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`) + - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`, `emitSoundReverse`) - Clients keep local fallback defaults but should prefer server-provided metadata when present. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index f183e51..200ecc4 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -101,6 +101,10 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "Global emitted sound tempo percent. 50 is normal.", "range": {"min": 0, "max": 100, "step": 1}, }, + "emitSoundReverse": { + "valueType": "boolean", + "tooltip": "Global emitted sound reverse flag.", + }, } ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = { @@ -136,4 +140,5 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | boo "directional": bool(definition.directional), "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitSoundReverse": False, } diff --git a/server/app/items/dice.py b/server/app/items/dice.py index fc3ee17..d2be7cd 100644 --- a/server/app/items/dice.py +++ b/server/app/items/dice.py @@ -62,8 +62,12 @@ def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], rolls = [random.randint(1, sides) for _ in range(number)] total = sum(rolls) rolls_text = ", ".join(str(value) for value in rolls) + if number == 1: + return ItemUseResult( + self_message=f"You rolled {item.title}: {rolls_text}.", + others_message=f"{nickname} rolled {item.title}: {rolls_text}.", + ) return ItemUseResult( self_message=f"You rolled {item.title}: {rolls_text} (total {total}).", others_message=f"{nickname} rolled {item.title}: {rolls_text} (total {total}).", ) - diff --git a/server/app/items/widget.py b/server/app/items/widget.py index 04448fb..2df34eb 100644 --- a/server/app/items/widget.py +++ b/server/app/items/widget.py @@ -19,6 +19,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "emitVolume", "emitSoundSpeed", "emitSoundTempo", + "emitSoundReverse", "emitEffect", "emitEffectValue", "useSound", @@ -39,6 +40,7 @@ DEFAULT_PARAMS: dict = { "emitVolume": 100, "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitSoundReverse": False, "emitEffect": "off", "emitEffectValue": 50, "useSound": "", @@ -75,6 +77,10 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.", "range": {"min": 0, "max": 100, "step": 1}, }, + "emitSoundReverse": { + "valueType": "boolean", + "tooltip": "Play emitted sound in reverse.", + }, "emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."}, "emitEffectValue": { "valueType": "number", @@ -154,6 +160,12 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: raise ValueError("emitSoundTempo must be between 0 and 100.") next_params["emitSoundTempo"] = emit_tempo + emit_reverse = parse_bool_like( + next_params.get("emitSoundReverse", item.params.get("emitSoundReverse", False)), + default=False, + ) + next_params["emitSoundReverse"] = emit_reverse + 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 813d267..4480ad6 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -294,6 +294,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: "emitVolume": 42, "emitSoundSpeed": 25, "emitSoundTempo": 60, + "emitSoundReverse": True, "emitEffect": "reverb", "emitEffectValue": 63.2, "useSound": "ping.ogg", @@ -309,6 +310,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: assert item.params.get("emitVolume") == 42 assert item.params.get("emitSoundSpeed") == 25 assert item.params.get("emitSoundTempo") == 60 + assert item.params.get("emitSoundReverse") is True assert item.params.get("emitEffect") == "reverb" assert item.params.get("emitEffectValue") == 63.2 assert item.params.get("useSound") == "sounds/ping.ogg" @@ -339,3 +341,10 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None: ) assert send_payloads[-1].ok is False assert "emitsoundtempo 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": {"emitSoundReverse": "off"}}), + ) + assert send_payloads[-1].ok is True + assert item.params.get("emitSoundReverse") is False