| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241 |
- // ─── CHURCH ACOUSTIC GEOMETRY ENGINE ─────────────────────────────────────────
- // Pure functions — no React dependencies. All dimensions in mm unless noted.
- // Coordinate origin = centre floor datum (lowest point of the dish).
- //
- // KEY RULES:
- // • Ceiling is FLAT at hall.ceilFlat above the centre datum (main seating zone).
- // • Floor rises linearly from 0 mm at row1Radius outward to hall.dishRise at row8Back.
- // The communion table zone (r < row1Radius) is flat at 0 mm.
- // • Corner zone ceiling is structurally lower; absolute height =
- // hall.cornerCeilClearance + floorHeightAtRadius(r)
- // • Speaker height (absolute) = ceilAbsoluteAt(spk.radius) − spk.dropRod
- // • Ear height (absolute) = hall.earHeight + floorHeightAtRadius(row.radius)
- // ─── FLOOR ────────────────────────────────────────────────────────────────────
- /**
- * Floor height above centre datum at radius r.
- * Flat at 0 mm inside row1Radius (communion table zone).
- * Linear rise from 0 mm at row1Radius to dishRise at row8Back, extrapolated beyond.
- */
- export function floorHeightAtRadius(r, hall) {
- if (r <= hall.row1Radius) return 0;
- const span = hall.row8Back - hall.row1Radius; // 6201 mm for confirmed hall
- return Math.round(((r - hall.row1Radius) / span) * hall.dishRise);
- }
- // ─── CEILING ──────────────────────────────────────────────────────────────────
- /**
- * Boundary radius where the ceiling transitions from the flat main zone
- * to the structurally lower corner zone.
- */
- function cornerZoneStartR(hall) {
- // Corner row 1 front is cornerFrontOffset mm inside the building half-width
- return hall.hallWidth / 2 - hall.cornerFrontOffset; // ≈ 9837 mm
- }
- /**
- * Absolute ceiling height above centre datum at radius r.
- * Main zone → hall.ceilFlat (constant — it's a flat ceiling)
- * Corner zone → hall.cornerCeilClearance + floorHeightAtRadius(r)
- * (the soffit drops structurally at the perimeter)
- */
- export function ceilAbsoluteAt(r, hall) {
- if (r >= cornerZoneStartR(hall)) {
- return hall.cornerCeilClearance + floorHeightAtRadius(r, hall);
- }
- return hall.ceilFlat;
- }
- /**
- * Floor-to-ceiling clearance at radius r (what a listener experiences as room height).
- * Decreases toward the perimeter because the floor rises while the ceiling stays flat.
- */
- export function floorToCeilingAt(r, hall) {
- return ceilAbsoluteAt(r, hall) - floorHeightAtRadius(r, hall);
- }
- // ─── ROW GEOMETRY ─────────────────────────────────────────────────────────────
- /**
- * Build per-row geometry for all main rows and corner rows.
- * Each entry: { row, radius, frontEdge, floorRise, ceilHeight, earAFF, earToCell, isCorner }
- * radius — mid-bench ear position (radial)
- * ceilHeight — absolute ceiling above centre datum at that radius
- * earAFF — absolute ear height above centre datum
- * earToCell — ear-to-ceiling clearance (critical for flutter and ARTA window)
- */
- export function computeHallGeometry(hall) {
- const { rows, row1Radius, rowPitch, earHeight, hasCornerRows, cornerRows, cornerFrontOffset, hallWidth } = hall;
- const halfW = hallWidth / 2;
- const rowData = [];
- // Main circular rows 1–N
- for (let r = 1; r <= rows; r++) {
- const frontEdge = row1Radius + (r - 1) * rowPitch;
- const radius = frontEdge + Math.round(rowPitch / 2); // mid-bench
- const floorRise = floorHeightAtRadius(radius, hall);
- const ceilH = ceilAbsoluteAt(radius, hall);
- const earAFF = earHeight + floorRise;
- const earToCell = ceilH - earAFF;
- rowData.push({ row: r, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: false });
- }
- // Corner rows (face inward from building perimeter)
- // Front of corner row 1 is cornerFrontOffset mm inside the half-width wall
- if (hasCornerRows) {
- const cRow1Front = halfW - cornerFrontOffset;
- for (let cr = 1; cr <= cornerRows; cr++) {
- const frontEdge = cRow1Front + (cr - 1) * rowPitch;
- const radius = frontEdge + Math.round(rowPitch / 2);
- const floorRise = floorHeightAtRadius(radius, hall);
- const ceilH = ceilAbsoluteAt(radius, hall);
- const earAFF = earHeight + floorRise;
- const earToCell = ceilH - earAFF;
- rowData.push({ row: `C${cr}`, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: true, cornerRow: cr });
- }
- }
- return rowData;
- }
- // ─── COVERAGE ─────────────────────────────────────────────────────────────────
- const RINGS = ['centre', 'inner', 'outer', 'corner'];
- /**
- * For every row × speaker ring combination, compute:
- * slantM — 3-D slant distance speaker→ear (metres)
- * delay — propagation delay (ms)
- * haasExcess — ms beyond the nearest speaker to this row (Haas reference)
- * offAxisDeg — angle from directly below the speaker (0° = on-axis)
- * splAtEar — estimated SPL at ear using inverse-square + sensitivity + power
- * inCoverage — whether offAxisDeg ≤ 55° (MS6 rated coverage angle)
- * haasStatus — 'primary' | 'ok' | 'warn' | 'danger'
- */
- export function computeCoverage(hall, speakers, rowData) {
- const c = hall.speedOfSound;
- const results = [];
- for (const row of rowData) {
- // 1. Find minimum delay to this row (Haas reference = nearest speaker)
- let minDelay = Infinity;
- for (const ring of RINGS) {
- const spk = speakers[ring];
- if (!spk?.enabled) continue;
- const spkH = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
- const vertDiff = spkH - row.earAFF;
- const horiz = Math.abs(row.radius - spk.radius);
- const delay = Math.sqrt(vertDiff ** 2 + horiz ** 2) / 1000 / c * 1000;
- if (delay < minDelay) minDelay = delay;
- }
- // 2. Per-ring metrics
- const rowResults = {
- row: row.row, radius: row.radius,
- earAFF: row.earAFF, ceilHeight: row.ceilHeight, earToCell: row.earToCell,
- speakers: {},
- };
- for (const ring of RINGS) {
- const spk = speakers[ring];
- if (!spk?.enabled) { rowResults.speakers[ring] = null; continue; }
- const spkH = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
- const vertDiff = spkH - row.earAFF;
- const horiz = Math.abs(row.radius - spk.radius);
- const slant = Math.sqrt(vertDiff ** 2 + horiz ** 2);
- const slantM = slant / 1000;
- const delay = slantM / c * 1000;
- const haasExcess = Math.max(0, delay - minDelay);
- // 0° = directly below speaker; increases toward horizontal
- const offAxisDeg = Math.round(Math.atan2(horiz, Math.max(vertDiff, 1)) * 180 / Math.PI);
- // SPL = sensitivity + 20·log10(1/d) + 10·log10(W)
- const splAtEar = spk.sensitivity
- + 20 * Math.log10(1 / Math.max(slantM, 0.1))
- + 10 * Math.log10(Math.max(spk.power, 0.01));
- const inCoverage = offAxisDeg <= 55;
- let haasStatus = 'primary';
- if (haasExcess > 30) haasStatus = 'danger';
- else if (haasExcess > 10) haasStatus = 'warn';
- else if (haasExcess > 0) haasStatus = 'ok';
- rowResults.speakers[ring] = {
- slantM, delay, haasExcess, offAxisDeg,
- splAtEar: Math.round(splAtEar * 10) / 10,
- inCoverage, haasStatus, spkH, vertDiff,
- };
- }
- results.push(rowResults);
- }
- return results;
- }
- // ─── FLUTTER ECHO ─────────────────────────────────────────────────────────────
- /**
- * Flutter frequency = c / (2 × clearance).
- * Clearance = ceiling absolute height − ear absolute height.
- * As the floor rises toward the perimeter, ear moves closer to the flat ceiling
- * → higher flutter frequencies → harmonics land in speech band.
- *
- * Returns fundamentals at three positions plus harmonic series and tile diffraction.
- * (Dudley Wilkin: "standing waves from the flat floor in the centre of the dish and the ceiling")
- */
- export function computeFlutter(hall) {
- const hz = (clearMm) => Math.round((hall.speedOfSound * 1000) / (2 * Math.max(clearMm, 50)));
- // Centre zone: floor = 0, ear at earHeight above centre datum
- const centreClearMm = hall.ceilFlat - hall.earHeight;
- // Row 8 mid-bench: floor extrapolated slightly beyond row8Back
- const row8MidR = hall.row8Back - Math.round(hall.rowPitch / 2);
- const row8FloorRise = floorHeightAtRadius(row8MidR, hall);
- const row8ClearMm = hall.ceilFlat - (hall.earHeight + row8FloorRise);
- // Last corner row mid
- const halfW = hall.hallWidth / 2;
- const cRow1Front = halfW - hall.cornerFrontOffset;
- const cLastMidR = cRow1Front + (hall.cornerRows - 0.5) * hall.rowPitch;
- const cFloorRise = floorHeightAtRadius(cLastMidR, hall);
- const cCeilAbs = ceilAbsoluteAt(cLastMidR, hall);
- const cornerClearMm = cCeilAbs - (hall.earHeight + cFloorRise);
- const fundamentalCentre = hz(centreClearMm);
- // Harmonics 1–5 at centre (reference position cited by Dudley Wilkin)
- const harmonics = [1, 2, 3, 4, 5].map(n => Math.round(fundamentalCentre * n));
- // Tile diffraction grating: wavelength = tileGrid → f = c/λ
- const tileDiffraction = Math.round((hall.speedOfSound * 1000) / hall.tileGrid);
- return {
- fundamentalCentre,
- fundamentalRow8: hz(row8ClearMm),
- fundamentalCorner: hz(cornerClearMm),
- harmonics,
- tileDiffraction,
- centreClearMm,
- row8ClearMm,
- cornerClearMm,
- };
- }
- // ─── HAAS DISPLAY HELPERS ─────────────────────────────────────────────────────
- export const haasColor = (s) => ({
- primary: '#34d399',
- ok: '#4f9eff',
- warn: '#f0b429',
- danger: '#f87171',
- }[s] ?? '#475569');
- export const haasLabel = (s) => ({
- primary: 'Primary',
- ok: 'OK <10ms',
- warn: 'Warn 10–30ms',
- danger: 'Echo >30ms',
- }[s] ?? '—');
|