Add directional emit model and per-type emit range defaults

This commit is contained in:
Jage9
2026-02-21 19:37:08 -05:00
parent 14a382ab40
commit 7952324633
10 changed files with 151 additions and 14 deletions

View File

@@ -10,6 +10,12 @@ type EmitOutput = {
panner: StereoPannerNode | null;
};
type EmitSpatialConfig = {
range: number;
directional: boolean;
facingDeg: number;
};
const ITEM_EMIT_BASE_GAIN = 0.3;
export class ItemEmitRuntime {
@@ -19,6 +25,7 @@ export class ItemEmitRuntime {
constructor(
private readonly audio: AudioEngine,
private readonly resolveSoundUrl: (soundPath: string) => string,
private readonly getSpatialConfig: (item: WorldItem) => EmitSpatialConfig,
) {}
cleanup(itemId: string): void {
@@ -108,14 +115,21 @@ export class ItemEmitRuntime {
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
continue;
}
const spatialConfig = this.getSpatialConfig(item);
const mix = resolveSpatialMix({
dx: item.x - playerPosition.x,
dy: item.y - playerPosition.y,
range: HEARING_RADIUS,
range: Math.max(1, spatialConfig.range || HEARING_RADIUS),
baseGain: ITEM_EMIT_BASE_GAIN,
nearFieldDistance: 1,
nearFieldGain: 1,
nearFieldCenterPan: true,
directional: {
enabled: spatialConfig.directional,
facingDeg: spatialConfig.facingDeg,
coneDeg: 120,
rearGain: 0.5,
},
});
const gainValue = mix?.gain ?? 0;
const panValue = mix?.pan ?? 0;

View File

@@ -113,12 +113,21 @@ function freshStreamUrl(streamUrl: string): string {
return `${streamUrl}${separator}chgrid_start=${Date.now()}`;
}
type RadioSpatialConfig = {
range: number;
directional: boolean;
facingDeg: number;
};
export class RadioStationRuntime {
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
private layerEnabled = true;
constructor(private readonly audio: AudioEngine) {}
constructor(
private readonly audio: AudioEngine,
private readonly getSpatialConfig: (item: WorldItem) => RadioSpatialConfig,
) {}
cleanup(itemId: string): void {
const output = this.itemRadioOutputs.get(itemId);
@@ -203,14 +212,21 @@ export class RadioStationRuntime {
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
continue;
}
const spatialConfig = this.getSpatialConfig(item);
const mix = resolveSpatialMix({
dx: item.x - playerPosition.x,
dy: item.y - playerPosition.y,
range: HEARING_RADIUS,
range: Math.max(1, spatialConfig.range || HEARING_RADIUS),
baseGain: normalizedVolume,
nearFieldDistance: 1,
nearFieldGain: 1,
nearFieldCenterPan: true,
directional: {
enabled: spatialConfig.directional,
facingDeg: spatialConfig.facingDeg,
coneDeg: 120,
rearGain: 0.5,
},
});
const gainValue = mix?.gain ?? 0;
const panValue = mix?.pan ?? 0;

View File

@@ -6,6 +6,12 @@ export type SpatialMixOptions = {
nearFieldDistance?: number;
nearFieldGain?: number;
nearFieldCenterPan?: boolean;
directional?: {
enabled: boolean;
facingDeg: number;
coneDeg?: number;
rearGain?: number;
};
};
export type SpatialMixResult = {
@@ -45,5 +51,37 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult
}
}
if (options.directional?.enabled) {
const coneDeg = Math.max(1, Math.min(359, options.directional.coneDeg ?? 120));
const rearGain = Math.max(0, Math.min(1, options.directional.rearGain ?? 0.5));
const facingDeg = normalizeDegrees(options.directional.facingDeg);
const bearingDeg = bearingFromSourceToListener(dx, dy);
const diff = angularDifferenceDeg(facingDeg, bearingDeg);
const halfCone = coneDeg / 2;
if (diff > halfCone) {
const span = Math.max(1, 180 - halfCone);
const t = Math.max(0, Math.min(1, (diff - halfCone) / span));
const directionalGain = 1 - t * (1 - rearGain);
gain *= directionalGain;
}
}
return { distance, gain, pan };
}
export function normalizeDegrees(value: number): number {
if (!Number.isFinite(value)) return 0;
const wrapped = value % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
}
function bearingFromSourceToListener(dx: number, dy: number): number {
// 0 degrees is north (+y), 90 is east (+x), matching screen-reader compass wording.
const degrees = Math.atan2(dx, dy) * (180 / Math.PI);
return normalizeDegrees(degrees);
}
function angularDifferenceDeg(a: number, b: number): number {
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
return raw > 180 ? 360 - raw : raw;
}

View File

@@ -48,17 +48,17 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'],
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing'],
dice: ['title', 'sides', 'number'],
wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'],
};
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
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 },
};
type UiDefinitionsPayload = {

View File

@@ -8,6 +8,7 @@ import {
} from './audio/effects';
import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
import { normalizeDegrees } from './audio/spatial';
import {
applyPastedText,
applyTextInput,
@@ -175,8 +176,8 @@ let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono'
let connecting = false;
const messageBuffer: string[] = [];
let messageCursor = -1;
const radioRuntime = new RadioStationRuntime(audio);
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl);
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig);
let internalClipboardText = '';
let replaceTextOnNextType = false;
let pendingEscapeDisconnect = false;
@@ -522,6 +523,16 @@ function itemLabel(item: WorldItem): string {
return `${item.title} (${itemTypeLabel(item.type)})`;
}
function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } {
const global = getItemTypeGlobalProperties(item.type);
const rawRange = Number(global.emitRange);
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15;
const directional = global.directional === true;
const rawFacing = Number(item.params.facing ?? 0);
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
return { range, directional, facingDeg };
}
function openHelpViewer(): void {
if (helpViewerLines.length === 0) {
updateStatus('Help unavailable.');
@@ -697,6 +708,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
if (key === 'facing') {
const parsed = Number(item.params.facing ?? 0);
if (!Number.isFinite(parsed)) return '0';
return String(Math.round(normalizeDegrees(parsed) * 10) / 10);
}
const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
if (globalValue !== undefined) return String(globalValue);
return String(item.params[key] ?? '');
@@ -1984,6 +2000,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
return;
}
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } });
} else if (propertyKey === 'facing') {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 360) {
updateStatus('facing must be a number between 0 and 360.');
audio.sfxUiCancel();
return;
}
signaling.send({ type: 'item_update', itemId, params: { facing: Math.round(parsed * 10) / 10 } });
} else if (propertyKey === 'spaces') {
const spaces = value
.split(',')