Add emit reverse option and retune echo/dice output

This commit is contained in:
Jage9
2026-02-22 01:57:52 -05:00
parent c162e6dc3c
commit 93bb778cd7
12 changed files with 92 additions and 19 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 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";

View File

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

View File

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

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', '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<ItemType, Record<string, string | number | boolean>> = {
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';

View File

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