// ─── 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] ?? '—');