Add emit loop delay control for item emit audio

This commit is contained in:
Jage9
2026-02-28 02:30:10 -05:00
parent 1b2c7cdc56
commit 887aad9435
8 changed files with 63 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.27 R302"; window.CHGRID_WEB_VERSION = "2026.02.28 R303";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -9,11 +9,13 @@ import { volumePercentToGain } from './volume';
type EmitOutput = { type EmitOutput = {
soundUrl: string; soundUrl: string;
element: HTMLAudioElement; element: HTMLAudioElement;
onEnded: () => void;
source: MediaElementAudioSourceNode; source: MediaElementAudioSourceNode;
effectInput: GainNode; effectInput: GainNode;
effectRuntime: EffectRuntime | null; effectRuntime: EffectRuntime | null;
effect: EffectId; effect: EffectId;
effectValue: number; effectValue: number;
loopDelaySeconds: number;
gain: GainNode; gain: GainNode;
panner: StereoPannerNode | null; panner: StereoPannerNode | null;
}; };
@@ -63,6 +65,14 @@ function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitc
return { playbackRate, preservePitch }; return { playbackRate, preservePitch };
} }
/** Resolves the optional emit loop delay in seconds from item params. */
function resolveEmitLoopDelaySeconds(item: WorldItem): number {
const globals = getItemTypeGlobalProperties(item.type);
const delaySeconds = Number(item.params.emitLoopDelay ?? globals.emitLoopDelay ?? 0);
const clamped = Number.isFinite(delaySeconds) ? Math.max(0, Math.min(300, delaySeconds)) : 0;
return Math.round(clamped * 10) / 10;
}
export class ItemEmitRuntime { export class ItemEmitRuntime {
private readonly outputs = new Map<string, EmitOutput>(); private readonly outputs = new Map<string, EmitOutput>();
private readonly pendingEmitStarts = new Set<string>(); private readonly pendingEmitStarts = new Set<string>();
@@ -81,6 +91,7 @@ export class ItemEmitRuntime {
const output = this.outputs.get(itemId); const output = this.outputs.get(itemId);
if (!output) return; if (!output) return;
output.element.pause(); output.element.pause();
output.element.removeEventListener('ended', output.onEnded);
output.element.src = ''; output.element.src = '';
output.source.disconnect(); output.source.disconnect();
output.effectInput.disconnect(); output.effectInput.disconnect();
@@ -154,7 +165,7 @@ export class ItemEmitRuntime {
continue; continue;
} }
const element = new Audio(soundUrl); const element = new Audio(soundUrl);
element.loop = true; element.loop = false;
element.preload = 'none'; element.preload = 'none';
element.crossOrigin = 'anonymous'; element.crossOrigin = 'anonymous';
const source = audioCtx.createMediaElementSource(element); const source = audioCtx.createMediaElementSource(element);
@@ -169,6 +180,12 @@ export class ItemEmitRuntime {
const initialRates = resolveEmitRates(item); const initialRates = resolveEmitRates(item);
setElementPreservesPitch(element, initialRates.preservePitch); setElementPreservesPitch(element, initialRates.preservePitch);
element.playbackRate = initialRates.playbackRate; element.playbackRate = initialRates.playbackRate;
const loopDelaySeconds = resolveEmitLoopDelaySeconds(item);
const onEnded = () => {
const delaySeconds = this.outputs.get(item.id)?.loopDelaySeconds ?? 0;
this.nextEmitStartAtMs.set(item.id, Date.now() + delaySeconds * 1000);
};
element.addEventListener('ended', onEnded);
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination; const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
if (this.audio.supportsStereoPanner()) { if (this.audio.supportsStereoPanner()) {
panner = audioCtx.createStereoPanner(); panner = audioCtx.createStereoPanner();
@@ -176,7 +193,19 @@ export class ItemEmitRuntime {
} else { } else {
gain.connect(destination); gain.connect(destination);
} }
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner }); this.outputs.set(item.id, {
soundUrl,
element,
onEnded,
source,
effectInput,
effectRuntime,
effect,
effectValue,
loopDelaySeconds,
gain,
panner,
});
this.tryStartEmitPlayback(item.id, element); this.tryStartEmitPlayback(item.id, element);
} }
@@ -208,6 +237,10 @@ export class ItemEmitRuntime {
output.effectValue = effectValue; output.effectValue = effectValue;
} }
const nextRates = resolveEmitRates(item); const nextRates = resolveEmitRates(item);
output.loopDelaySeconds = resolveEmitLoopDelaySeconds(item);
if (output.element.paused && output.element.ended) {
this.nextEmitStartAtMs.set(itemId, Date.now() + output.loopDelaySeconds * 1000);
}
setElementPreservesPitch(output.element, nextRates.preservePitch); setElementPreservesPitch(output.element, nextRates.preservePitch);
const nextPlaybackRate = nextRates.playbackRate; const nextPlaybackRate = nextRates.playbackRate;
if (Math.abs(output.element.playbackRate - nextPlaybackRate) > 0.001) { if (Math.abs(output.element.playbackRate - nextPlaybackRate) > 0.001) {

View File

@@ -161,6 +161,7 @@
"emitVolume": 100, "emitVolume": 100,
"emitSoundSpeed": 50, "emitSoundSpeed": 50,
"emitSoundTempo": 50, "emitSoundTempo": 50,
"emitLoopDelay": 0,
"emitEffect": "off", "emitEffect": "off",
"emitEffectValue": 50, "emitEffectValue": 50,
"useSound": "", "useSound": "",
@@ -176,6 +177,7 @@
- `emitVolume`: integer, range `0-100`, default `100`. - `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`). - `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`). - `emitSoundTempo`: integer, range `0-100`, default `50`; controls emitted sound tempo (`0=0.5x`, `50=1.0x`, `100=2.0x`).
- `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`. - `emitEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
- `emitEffectValue`: number, range `0-100`, precision `0.1`, default `50`. - `emitEffectValue`: number, range `0-100`, precision `0.1`, default `50`.
- `useSound`: empty, filename (assumed under `sounds/`), or full URL. - `useSound`: empty, filename (assumed under `sounds/`), or full URL.

View File

@@ -141,6 +141,7 @@ This is behavior-focused documentation for item types and their defaults.
- `emitVolume=100` - `emitVolume=100`
- `emitSoundSpeed=50` - `emitSoundSpeed=50`
- `emitSoundTempo=50` - `emitSoundTempo=50`
- `emitLoopDelay=0`
- `emitEffect="off"` - `emitEffect="off"`
- `emitEffectValue=50` - `emitEffectValue=50`
- `useSound=""` - `useSound=""`
@@ -165,6 +166,7 @@ This is behavior-focused documentation for item types and their defaults.
- `emitVolume`: integer `0..100` - `emitVolume`: integer `0..100`
- `emitSoundSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for speed/pitch - `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 - `emitSoundTempo`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`) for tempo
- `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` - `emitEffect`: `reverb | echo | flanger | high_pass | low_pass | off`
- `emitEffectValue`: number `0..100` with `0.1` precision - `emitEffectValue`: number `0..100` with `0.1` precision
- `useSound`: empty, filename (assumed under `sounds/`), or full URL - `useSound`: empty, filename (assumed under `sounds/`), or full URL

View File

@@ -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[].capabilities`: server-declared actions supported by the type
- `itemTypes[].editableProperties`: editable property keys by item 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[].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`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`, `emitLoopDelay`)
- `adminMenu.actions`: server-authored admin root menu labels/ordering for the authenticated user. - `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 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. - Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics.

View File

@@ -113,6 +113,11 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = {
"tooltip": "Global emitted sound tempo percent. 50 is normal.", "tooltip": "Global emitted sound tempo percent. 50 is normal.",
"range": {"min": 0, "max": 100, "step": 0.1}, "range": {"min": 0, "max": 100, "step": 0.1},
}, },
"emitLoopDelay": {
"valueType": "number",
"tooltip": "Delay in seconds between each emitted playback.",
"range": {"min": 0, "max": 300, "step": 0.1},
},
} }
ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = { ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = {
@@ -154,4 +159,5 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | boo
"directional": bool(definition.directional), "directional": bool(definition.directional),
"emitSoundSpeed": 50, "emitSoundSpeed": 50,
"emitSoundTempo": 50, "emitSoundTempo": 50,
"emitLoopDelay": 0,
} }

View File

@@ -13,6 +13,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = (
"emitVolume", "emitVolume",
"emitSoundSpeed", "emitSoundSpeed",
"emitSoundTempo", "emitSoundTempo",
"emitLoopDelay",
"emitEffect", "emitEffect",
"emitEffectValue", "emitEffectValue",
"useSound", "useSound",
@@ -33,6 +34,7 @@ DEFAULT_PARAMS: dict = {
"emitVolume": 100, "emitVolume": 100,
"emitSoundSpeed": 50, "emitSoundSpeed": 50,
"emitSoundTempo": 50, "emitSoundTempo": 50,
"emitLoopDelay": 0,
"emitEffect": "off", "emitEffect": "off",
"emitEffectValue": 50, "emitEffectValue": 50,
"useSound": "", "useSound": "",
@@ -46,6 +48,7 @@ PARAM_KEYS: tuple[str, ...] = (
"emitVolume", "emitVolume",
"emitSoundSpeed", "emitSoundSpeed",
"emitSoundTempo", "emitSoundTempo",
"emitLoopDelay",
"emitEffect", "emitEffect",
"emitEffectValue", "emitEffectValue",
"useSound", "useSound",
@@ -83,6 +86,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.", "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}, "range": {"min": 0, "max": 100, "step": 0.1},
}, },
"emitLoopDelay": {
"valueType": "number",
"tooltip": "Delay in seconds between each playing of this audio.",
"range": {"min": 0, "max": 300, "step": 0.1},
},
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)}, "emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)},
"emitEffectValue": { "emitEffectValue": {
"valueType": "number", "valueType": "number",

View File

@@ -56,6 +56,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
raise ValueError("emitSoundTempo must be between 0 and 100.") raise ValueError("emitSoundTempo must be between 0 and 100.")
next_params["emitSoundTempo"] = round(emit_tempo, 1) next_params["emitSoundTempo"] = round(emit_tempo, 1)
try:
emit_loop_delay = float(next_params.get("emitLoopDelay", item.params.get("emitLoopDelay", 0)))
except (TypeError, ValueError) as exc:
raise ValueError("emitLoopDelay must be a number between 0 and 300.") from exc
if not (0 <= emit_loop_delay <= 300):
raise ValueError("emitLoopDelay must be between 0 and 300.")
next_params["emitLoopDelay"] = round(emit_loop_delay, 1)
emit_effect = str(next_params.get("emitEffect", item.params.get("emitEffect", "off"))).strip().lower() emit_effect = str(next_params.get("emitEffect", item.params.get("emitEffect", "off"))).strip().lower()
if emit_effect not in EFFECT_OPTIONS: if emit_effect not in EFFECT_OPTIONS:
raise ValueError("emitEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.") raise ValueError("emitEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")