From 294ccfa902d5a8ff79baa1c9b287a9c39abb8245 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 28 Feb 2026 03:21:55 -0500 Subject: [PATCH] Add emit initial delay option for widget audio emit --- client/public/version.js | 2 +- client/src/audio/itemEmitRuntime.ts | 15 +++++++++++++++ docs/item-schema.md | 2 ++ docs/item-types.md | 3 +++ docs/protocol-notes.md | 2 +- server/app/item_catalog.py | 6 ++++++ server/app/items/types/widget/definition.py | 8 ++++++++ server/app/items/types/widget/validator.py | 8 ++++++++ 8 files changed, 44 insertions(+), 2 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 3465bd8..51d5b14 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.28 R309"; +window.CHGRID_WEB_VERSION = "2026.02.28 R310"; // 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 b925d0a..b486cb1 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -15,6 +15,7 @@ type EmitOutput = { effectRuntime: EffectRuntime | null; effect: EffectId; effectValue: number; + initialDelaySeconds: number; loopDelaySeconds: number; gain: GainNode; panner: StereoPannerNode | null; @@ -83,6 +84,14 @@ function resolveEmitLoopDelaySeconds(item: WorldItem): number { return Math.round(clamped * 10) / 10; } +/** Resolves the optional emit initial delay in seconds from item params. */ +function resolveEmitInitialDelaySeconds(item: WorldItem): number { + const globals = getItemTypeGlobalProperties(item.type); + const delaySeconds = Number(item.params.emitInitialDelay ?? globals.emitInitialDelay ?? 0); + const clamped = Number.isFinite(delaySeconds) ? Math.max(0, Math.min(300, delaySeconds)) : 0; + return Math.round(clamped * 10) / 10; +} + export class ItemEmitRuntime { private readonly outputs = new Map(); private readonly resumeStateByItemId = new Map(); @@ -216,6 +225,7 @@ export class ItemEmitRuntime { const initialRates = resolveEmitRates(item); setElementPreservesPitch(element, initialRates.preservePitch); element.playbackRate = initialRates.playbackRate; + const initialDelaySeconds = resolveEmitInitialDelaySeconds(item); const loopDelaySeconds = resolveEmitLoopDelaySeconds(item); const onEnded = () => { const delaySeconds = this.outputs.get(item.id)?.loopDelaySeconds ?? 0; @@ -314,10 +324,14 @@ export class ItemEmitRuntime { effectRuntime, effect, effectValue, + initialDelaySeconds, loopDelaySeconds, gain, panner, }); + if (!resumeState && !this.nextEmitStartAtMs.has(item.id) && initialDelaySeconds > 0) { + this.nextEmitStartAtMs.set(item.id, Date.now() + initialDelaySeconds * 1000); + } this.resumeStateByItemId.delete(item.id); this.tryStartEmitPlayback(item.id, element); } @@ -361,6 +375,7 @@ export class ItemEmitRuntime { output.effectValue = effectValue; } const nextRates = resolveEmitRates(item); + output.initialDelaySeconds = resolveEmitInitialDelaySeconds(item); output.loopDelaySeconds = resolveEmitLoopDelaySeconds(item); setElementPreservesPitch(output.element, nextRates.preservePitch); const nextPlaybackRate = nextRates.playbackRate; diff --git a/docs/item-schema.md b/docs/item-schema.md index 31bab96..d1322a1 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -161,6 +161,7 @@ "emitVolume": 100, "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitInitialDelay": 0, "emitLoopDelay": 0, "emitEffect": "off", "emitEffectValue": 50, @@ -177,6 +178,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`). +- `emitInitialDelay`: number, range `0-300`, precision `0.1`, default `0`; delay in seconds before emitted audio starts after enable. - `emitLoopDelay`: number, range `0-300`, precision `0.1`, default `0`; delay in seconds between each emitted playback. - `emitEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `emitEffectValue`: number, range `0-100`, precision `0.1`, default `50`. diff --git a/docs/item-types.md b/docs/item-types.md index f8c9ec1..7bef89a 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -141,6 +141,7 @@ This is behavior-focused documentation for item types and their defaults. - `emitVolume=100` - `emitSoundSpeed=50` - `emitSoundTempo=50` + - `emitInitialDelay=0` - `emitLoopDelay=0` - `emitEffect="off"` - `emitEffectValue=50` @@ -154,6 +155,7 @@ This is behavior-focused documentation for item types and their defaults. - `directional=false` - `emitSoundSpeed=50` - `emitSoundTempo=50` + - `emitInitialDelay=0` - `emitLoopDelay=0` ### Use @@ -167,6 +169,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 +- `emitInitialDelay`: number `0..300` with `0.1` step/precision; delay in seconds before emitted audio starts after enable - `emitLoopDelay`: number `0..300` with `0.1` step/precision; delay in seconds between each emitted loop playback - `emitEffect`: `reverb | echo | flanger | high_pass | low_pass | off` - `emitEffectValue`: number `0..100` with `0.1` precision diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 92fcd9b..44a6ddf 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -100,7 +100,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `itemTypes[].capabilities`: server-declared actions supported by the type - `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `label`, optional `range`, optional `tooltip`, optional `maxLength`, optional `options`, optional `visibleWhen`) - - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`, `emitLoopDelay`) + - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`, `emitInitialDelay`, `emitLoopDelay`) - `adminMenu.actions`: server-authored admin root menu labels/ordering for the authenticated user. - Client item UI requires this metadata from the server; there is no fallback item definition map. - Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 584a7c6..6e0caff 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -113,6 +113,11 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = { "tooltip": "Global emitted sound tempo percent. 50 is normal.", "range": {"min": 0, "max": 100, "step": 0.1}, }, + "emitInitialDelay": { + "valueType": "number", + "tooltip": "Delay in seconds before emitted audio starts after this sound is enabled.", + "range": {"min": 0, "max": 300, "step": 0.1}, + }, "emitLoopDelay": { "valueType": "number", "tooltip": "Delay in seconds between each emitted playback.", @@ -159,5 +164,6 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | boo "directional": bool(definition.directional), "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitInitialDelay": 0, "emitLoopDelay": 0, } diff --git a/server/app/items/types/widget/definition.py b/server/app/items/types/widget/definition.py index 5d39207..b362095 100644 --- a/server/app/items/types/widget/definition.py +++ b/server/app/items/types/widget/definition.py @@ -13,6 +13,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = ( "emitVolume", "emitSoundSpeed", "emitSoundTempo", + "emitInitialDelay", "emitLoopDelay", "emitEffect", "emitEffectValue", @@ -34,6 +35,7 @@ DEFAULT_PARAMS: dict = { "emitVolume": 100, "emitSoundSpeed": 50, "emitSoundTempo": 50, + "emitInitialDelay": 0, "emitLoopDelay": 0, "emitEffect": "off", "emitEffectValue": 50, @@ -48,6 +50,7 @@ PARAM_KEYS: tuple[str, ...] = ( "emitVolume", "emitSoundSpeed", "emitSoundTempo", + "emitInitialDelay", "emitLoopDelay", "emitEffect", "emitEffectValue", @@ -86,6 +89,11 @@ 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": 0.1}, }, + "emitInitialDelay": { + "valueType": "number", + "tooltip": "Delay in seconds before emitted audio starts after this sound is enabled.", + "range": {"min": 0, "max": 300, "step": 0.1}, + }, "emitLoopDelay": { "valueType": "number", "tooltip": "Delay in seconds between each playing of this audio.", diff --git a/server/app/items/types/widget/validator.py b/server/app/items/types/widget/validator.py index d9daa69..db0d913 100644 --- a/server/app/items/types/widget/validator.py +++ b/server/app/items/types/widget/validator.py @@ -56,6 +56,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: raise ValueError("emitSoundTempo must be between 0 and 100.") next_params["emitSoundTempo"] = round(emit_tempo, 1) + try: + emit_initial_delay = float(next_params.get("emitInitialDelay", item.params.get("emitInitialDelay", 0))) + except (TypeError, ValueError) as exc: + raise ValueError("emitInitialDelay must be a number between 0 and 300.") from exc + if not (0 <= emit_initial_delay <= 300): + raise ValueError("emitInitialDelay must be between 0 and 300.") + next_params["emitInitialDelay"] = round(emit_initial_delay, 1) + try: emit_loop_delay = float(next_params.get("emitLoopDelay", item.params.get("emitLoopDelay", 0))) except (TypeError, ValueError) as exc: