Add widget emit speed control for emitted audio

This commit is contained in:
Jage9
2026-02-21 23:07:37 -05:00
parent a747046dfe
commit dd06d882e7
8 changed files with 54 additions and 2 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.21 R128"; window.CHGRID_WEB_VERSION = "2026.02.21 R129";
// 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

@@ -24,6 +24,15 @@ type EmitSpatialConfig = {
const ITEM_EMIT_BASE_GAIN = 0.3; const ITEM_EMIT_BASE_GAIN = 0.3;
function resolveEmitPlaybackRate(raw: unknown): number {
const speed = Number(raw);
const clamped = Number.isFinite(speed) ? Math.max(0, Math.min(100, speed)) : 50;
if (clamped <= 50) {
return 0.5 + (clamped / 50) * 0.5;
}
return 1 + ((clamped - 50) / 50) * 1;
}
export class ItemEmitRuntime { export class ItemEmitRuntime {
private readonly outputs = new Map<string, EmitOutput>(); private readonly outputs = new Map<string, EmitOutput>();
private layerEnabled = true; private layerEnabled = true;
@@ -101,6 +110,7 @@ export class ItemEmitRuntime {
const effect = normalizeRadioEffect(item.params.emitEffect); const effect = normalizeRadioEffect(item.params.emitEffect);
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue); const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue); const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
element.playbackRate = resolveEmitPlaybackRate(item.params.emitSpeed);
if (this.audio.supportsStereoPanner()) { if (this.audio.supportsStereoPanner()) {
panner = audioCtx.createStereoPanner(); panner = audioCtx.createStereoPanner();
gain.connect(panner).connect(audioCtx.destination); gain.connect(panner).connect(audioCtx.destination);
@@ -138,6 +148,10 @@ export class ItemEmitRuntime {
output.effect = effect; output.effect = effect;
output.effectValue = effectValue; output.effectValue = effectValue;
} }
const nextPlaybackRate = resolveEmitPlaybackRate(item.params.emitSpeed);
if (Math.abs(output.element.playbackRate - nextPlaybackRate) > 0.001) {
output.element.playbackRate = nextPlaybackRate;
}
const spatialConfig = this.getSpatialConfig(item); const spatialConfig = this.getSpatialConfig(item);
const mix = resolveSpatialMix({ const mix = resolveSpatialMix({
dx: item.x - playerPosition.x, dx: item.x - playerPosition.x,

View File

@@ -52,7 +52,7 @@ const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
dice: ['title', 'sides', 'number'], dice: ['title', 'sides', 'number'],
wheel: ['title', 'spaces'], wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'], clock: ['title', 'timeZone', 'use24Hour'],
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'], widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSpeed', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
}; };
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = { const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
@@ -196,6 +196,7 @@ export function itemPropertyLabel(key: string): string {
if (key === 'emitRange') return 'emit range'; if (key === 'emitRange') return 'emit range';
if (key === 'mediaVolume') return 'media volume'; if (key === 'mediaVolume') return 'media volume';
if (key === 'emitVolume') return 'emit volume'; if (key === 'emitVolume') return 'emit volume';
if (key === 'emitSpeed') return 'emit speed';
if (key === 'mediaChannel') return 'media channel'; if (key === 'mediaChannel') return 'media channel';
if (key === 'mediaEffect') return 'media effect'; if (key === 'mediaEffect') return 'media effect';
if (key === 'mediaEffectValue') return 'media effect value'; if (key === 'mediaEffectValue') return 'media effect value';

View File

@@ -753,6 +753,7 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
key === 'version' || key === 'version' ||
key === 'mediaVolume' || key === 'mediaVolume' ||
key === 'emitVolume' || key === 'emitVolume' ||
key === 'emitSpeed' ||
key === 'mediaEffectValue' || key === 'mediaEffectValue' ||
key === 'emitEffectValue' || key === 'emitEffectValue' ||
key === 'facing' || key === 'facing' ||
@@ -2158,6 +2159,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
return; return;
} }
signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } }); signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } });
} else if (propertyKey === 'emitSpeed') {
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!parsed.ok) {
updateStatus(parsed.message);
audio.sfxUiCancel();
return;
}
signaling.send({ type: 'item_update', itemId, params: { emitSpeed: parsed.value } });
} else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { } else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') {
const normalized = value.trim().toLowerCase() as EffectId; const normalized = value.trim().toLowerCase() as EffectId;
if (!EFFECT_IDS.has(normalized)) { if (!EFFECT_IDS.has(normalized)) {

View File

@@ -137,6 +137,7 @@
"facing": 0, "facing": 0,
"emitRange": 15, "emitRange": 15,
"emitVolume": 100, "emitVolume": 100,
"emitSpeed": 50,
"emitEffect": "off", "emitEffect": "off",
"emitEffectValue": 50, "emitEffectValue": 50,
"useSound": "", "useSound": "",
@@ -149,6 +150,7 @@
- `facing`: number, range `0-360`, precision `0.1`. - `facing`: number, range `0-360`, precision `0.1`.
- `emitRange`: integer, range `1-20`, default `15`. - `emitRange`: integer, range `1-20`, default `15`.
- `emitVolume`: integer, range `0-100`, default `100`. - `emitVolume`: integer, range `0-100`, default `100`.
- `emitSpeed`: integer, range `0-100`, default `50`; maps to playback rate (`0=0.5x`, `50=1.0x`, `100=2.0x`).
- `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

@@ -120,6 +120,7 @@ This is behavior-focused documentation for item types and their defaults.
- `facing=0` - `facing=0`
- `emitRange=15` - `emitRange=15`
- `emitVolume=100` - `emitVolume=100`
- `emitSpeed=50`
- `emitEffect="off"` - `emitEffect="off"`
- `emitEffectValue=50` - `emitEffectValue=50`
- `useSound=""` - `useSound=""`
@@ -140,6 +141,7 @@ This is behavior-focused documentation for item types and their defaults.
- `facing`: number `0..360` with `0.1` precision - `facing`: number `0..360` with `0.1` precision
- `emitRange`: integer `1..20` - `emitRange`: integer `1..20`
- `emitVolume`: integer `0..100` - `emitVolume`: integer `0..100`
- `emitSpeed`: integer `0..100` (`0=0.5x`, `50=1.0x`, `100=2.0x`)
- `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

@@ -17,6 +17,7 @@ EDITABLE_PROPERTIES: tuple[str, ...] = (
"facing", "facing",
"emitRange", "emitRange",
"emitVolume", "emitVolume",
"emitSpeed",
"emitEffect", "emitEffect",
"emitEffectValue", "emitEffectValue",
"useSound", "useSound",
@@ -35,6 +36,7 @@ DEFAULT_PARAMS: dict = {
"facing": 0, "facing": 0,
"emitRange": 15, "emitRange": 15,
"emitVolume": 100, "emitVolume": 100,
"emitSpeed": 50,
"emitEffect": "off", "emitEffect": "off",
"emitEffectValue": 50, "emitEffectValue": 50,
"useSound": "", "useSound": "",
@@ -61,6 +63,11 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"tooltip": "Emitted sound volume percent.", "tooltip": "Emitted sound volume percent.",
"range": {"min": 0, "max": 100, "step": 1}, "range": {"min": 0, "max": 100, "step": 1},
}, },
"emitSpeed": {
"valueType": "number",
"tooltip": "Playback speed/pitch 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."}, "emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
"emitEffectValue": { "emitEffectValue": {
"valueType": "number", "valueType": "number",
@@ -124,6 +131,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
raise ValueError("emitVolume must be between 0 and 100.") raise ValueError("emitVolume must be between 0 and 100.")
next_params["emitVolume"] = emit_volume next_params["emitVolume"] = emit_volume
try:
emit_speed = int(next_params.get("emitSpeed", item.params.get("emitSpeed", 50)))
except (TypeError, ValueError) as exc:
raise ValueError("emitSpeed must be an integer between 0 and 100.") from exc
if not (0 <= emit_speed <= 100):
raise ValueError("emitSpeed must be between 0 and 100.")
next_params["emitSpeed"] = emit_speed
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.")

View File

@@ -292,6 +292,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
"facing": 123.4, "facing": 123.4,
"emitRange": 7, "emitRange": 7,
"emitVolume": 42, "emitVolume": 42,
"emitSpeed": 25,
"emitEffect": "reverb", "emitEffect": "reverb",
"emitEffectValue": 63.2, "emitEffectValue": 63.2,
"useSound": "ping.ogg", "useSound": "ping.ogg",
@@ -305,6 +306,7 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
assert item.params.get("facing") == 123.4 assert item.params.get("facing") == 123.4
assert item.params.get("emitRange") == 7 assert item.params.get("emitRange") == 7
assert item.params.get("emitVolume") == 42 assert item.params.get("emitVolume") == 42
assert item.params.get("emitSpeed") == 25
assert item.params.get("emitEffect") == "reverb" assert item.params.get("emitEffect") == "reverb"
assert item.params.get("emitEffectValue") == 63.2 assert item.params.get("emitEffectValue") == 63.2
assert item.params.get("useSound") == "sounds/ping.ogg" assert item.params.get("useSound") == "sounds/ping.ogg"
@@ -321,3 +323,10 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
) )
assert send_payloads[-1].ok is False assert send_payloads[-1].ok is False
assert "emitrange must be between 1 and 20" in send_payloads[-1].message.lower() assert "emitrange must be between 1 and 20" in send_payloads[-1].message.lower()
await server._handle_message(
client,
json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitSpeed": 101}}),
)
assert send_payloads[-1].ok is False
assert "emitspeed must be between 0 and 100" in send_payloads[-1].message.lower()