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
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
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)}%`;
|
||
}
|
||
}
|