Files
threejs-sound-sim/src/ui/signalPath.ts
T
ddidderr 95d5125d3c feat(audio): account for source directivity in diffuse reverb calculations
Previously, diffuse field reverberant gain and critical distance calculations
assumed an omnidirectional sound source (Q = 1), regardless of cone settings.
Directional sources concentrate sound energy in specific directions, reducing
the total acoustic power injected into the room and lowering the reverberant
field level relative to the direct sound on-axis.

Incorporate source directivity factor Q into the acoustics model:
- Compute closed-form directivity factor Q in src/physics/cone.ts by
  integrating squared cone gain over the unit sphere.
- Scale reverberant field gain by 1 / sqrt(Q) (-10·log10(Q) dB) and adjust
  critical distance by sqrt(Q) in src/audio/reverb.ts.
- Update AudioEngine and signal path UI to present directivity index (DI)
  and adjust room dominance evaluations.
- Add unit tests verifying Q against brute-force numerical quadrature and
  confirming critical distance behavior.

Test Plan:
- `npm run typecheck` -- passed clean
- `npm test` -- 125/125 tests passed (6 test suites)
- `git diff --cached --check` -- no whitespace errors
2026-07-26 18:51:27 +02:00

132 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Telemetry } from '../audio';
import { el } from './controls';
import { fmt } from './format';
import { dbToFill } from './meters';
interface Row {
tr: HTMLTableRowElement;
detail: HTMLTableCellElement;
db: HTMLTableCellElement;
fill: HTMLElement | null;
}
function row(label: string, segment: string | null, isTotal = false): Row {
const detail = el('td', { class: 'detail' });
const db = el('td', { class: 'db' });
const cells: HTMLElement[] = [el('th', { scope: 'row', text: label }), detail, db];
let fill: HTMLElement | null = null;
if (segment) {
fill = el('i', { 'data-seg': segment });
cells.push(el('td', { class: 'bar' }, [el('div', { class: 'meter' }, [fill])]));
} else {
cells.push(el('td', { class: 'bar' }));
}
const tr = el('tr', { class: isTotal ? 'total' : '' }, cells) as HTMLTableRowElement;
return { tr, detail, db, fill };
}
/**
* The gain budget: every stage between the source and the ear, with its loss in
* dB on one shared 60…0 dB scale so the bars are directly comparable.
*
* This is the answer to "why does it sound like that". It is a real DOM table
* rather than a canvas so the most important telemetry is not trapped in pixels
* where a screen reader cannot reach it.
*/
export class SignalPath {
readonly root: HTMLElement;
private readonly source = row('Source', 'survive');
private readonly distance = row('Distance', 'distance');
private readonly directivity = row('Directivity', 'directivity');
private readonly occlusion = row('Occlusion', 'occlusion');
private readonly air = row('Air', null);
private readonly direct = row('At your ear', 'survive', true);
private readonly room = row('Room (wet)', 'room');
private readonly ratio = row('Direct / room', null);
private cache = new Map<HTMLElement, string>();
constructor(private readonly presetLabel: () => string, private readonly volume: () => number) {
const body = el('tbody', {}, [
this.source.tr,
this.distance.tr,
this.directivity.tr,
this.occlusion.tr,
this.air.tr,
this.direct.tr,
this.room.tr,
this.ratio.tr,
]);
this.root = el('table', { class: 'path' }, [
el('caption', { class: 'visually-hidden', text: 'Signal path: gain at each stage' }),
body,
]);
}
update(t: Telemetry): void {
this.text(this.source.detail, this.presetLabel());
this.text(this.source.db, fmt.db(20 * Math.log10(Math.max(this.volume(), 1e-4))));
this.bar(this.source.fill, 1);
this.text(this.distance.detail, fmt.metres(t.distance));
this.text(this.distance.db, fmt.db(t.distanceGainDb));
this.bar(this.distance.fill, dbToFill(t.distanceGainDb));
this.text(this.directivity.detail, `${fmt.degrees(t.offAxisAngle)} off-axis`);
this.text(this.directivity.db, fmt.db(t.coneGainDb));
this.bar(this.directivity.fill, dbToFill(t.coneGainDb));
const blocked = t.occlusion > 0.01;
this.text(
this.occlusion.detail,
blocked ? `${fmt.percent(t.occlusion)} blocked · LP ${fmt.hz(t.occlusionCutoff)}` : 'clear'
);
this.text(this.occlusion.db, fmt.db(t.occlusionGainDb));
this.bar(this.occlusion.fill, dbToFill(t.occlusionGainDb));
this.occlusion.tr.dataset.flag = blocked ? 'warn' : '';
// Air absorption only shapes tone; it has no broadband dB to report, so
// printing one would break the guarantee that the stages sum to the total.
this.text(this.air.detail, `tone only · LP ${fmt.hz(t.airCutoff)}`);
this.text(this.air.db, '—');
this.text(this.direct.detail, `${fmt.ms(t.timeOfFlightMs)} flight`);
this.text(this.direct.db, fmt.db(t.directGainDb));
this.bar(this.direct.fill, dbToFill(t.directGainDb));
// The room level is set by t60 and by how much power the cone actually
// throws into the room, so both belong on the row. Without the directivity
// index the wet level looks arbitrarily low next to a loud direct path.
this.text(
this.room.detail,
t.directivityIndexDb > 0.05
? `t60 ${fmt.seconds(t.t60)} · directivity ${fmt.db(t.directivityIndexDb)}`
: `t60 ${fmt.seconds(t.t60)}`
);
this.text(this.room.db, fmt.db(t.reverbGainDb));
this.bar(this.room.fill, dbToFill(t.reverbGainDb));
this.text(
this.ratio.detail,
t.reverbDominant ? 'room-dominant' : `direct-dominant · rc ${fmt.metres(t.criticalDistance)}`
);
this.text(this.ratio.db, fmt.db(t.directToReverbDb));
this.ratio.tr.dataset.flag = t.reverbDominant ? 'warn' : '';
}
/** Writes only when the rendered string actually changed. */
private text(node: HTMLElement, value: string): void {
if (this.cache.get(node) === value) return;
this.cache.set(node, value);
node.textContent = value;
}
private bar(node: HTMLElement | null, fraction: number): void {
if (!node) return;
node.style.width = `${(fraction * 100).toFixed(1)}%`;
}
}