Add emit sound tempo and global emit speed/tempo defaults

This commit is contained in:
Jage9
2026-02-21 23:17:18 -05:00
parent d3a98ef1ea
commit 9571a3c14d
9 changed files with 88 additions and 11 deletions

View File

@@ -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";

View File

@@ -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<string, EmitOutput>();
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;
}

View File

@@ -52,15 +52,15 @@ const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
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<ItemType, Record<string, string | number | boolean>> = {
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';

View File

@@ -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)) {

View File

@@ -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.

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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.")

View File

@@ -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()