import { useState, useCallback, useRef, useEffect } from "react"; // ─── PALETTE ────────────────────────────────────────────────────────────────── const C = { bg: "#07090f", bgAlt: "#0c0f18", panel: "#0f1420", panelAlt: "#111827", border: "#1a2236", borderHi: "#243050", accent: "#4f9eff", accentDim: "#2a6abd", accentBg: "#4f9eff12", gold: "#f0b429", goldBg: "#f0b42912", good: "#34d399", goodBg: "#34d39912", warn: "#fb923c", warnBg: "#fb923c12", danger: "#f87171", dangerBg: "#f8717112", purple: "#a78bfa", purpleBg: "#a78bfa12", text: "#cbd5e1", muted: "#475569", heading: "#f1f5f9", }; // ─── HALL DEFAULTS ──────────────────────────────────────────────────────────── // Confirmed geometry from SK-303 Version B + site measurements // // PLAN (square seating zone): // Overall seating plan: 20914 x 20914mm (half-width = 10457mm) // Row 1 front edge: 2210mm radius from centre // Row pitch: 883mm FFL to FFL (8 main rows) // Row 8 back face: 8411mm radius (per SK-303 drawing) // Back aisle: 2046mm (10457 - 8411 = 2046 ✓) // Corner seating: 4 rows, same 883mm pitch, front of row 1 = 620mm from aisle wall // Corner row 1 front radius ≈ 10457 + 620 = beyond main seating — in corner zone // // SECTION (flat ceiling — "1 in 8 slope"): // Ceiling at row 1 front (r=2210): 4338mm (3600 + 738) // Ceiling at row 8 back (r=8411): 3600mm (confirmed) // Ceiling perimeter aisle: 3600mm (confirmed) // Ceiling behind last corner row: 2950mm // // FLOOR (dished — rises from centre outward): // Centre floor: 0mm datum (lowest) // Row 8 back: +738mm rise (SK-303) // Slope ≈ 1:8.4 // // CORNER SPEAKERS: // Radius: 10310mm from centre // 1500mm from side walls (wall at 10457mm → coord = 8957mm on one axis) const HALL_DEFAULTS = { hallWidth: 20914, // mm — confirmed seating plan dimension rows: 8, // main circular rows row1Radius: 2210, // mm — centre to front edge of row 1 rowPitch: 883, // mm — FFL to FFL row8Back: 8411, // mm — centre to back face of row 8 aisleWidth: 2046, // mm — back aisle width // Corner seating cornerRows: 4, cornerFrontOffset: 620, // mm — front of corner row 1 from aisle back wall hasCornerRows: true, // Ceiling — SLOPED, higher at centre, lower at perimeter ceilAtRow1Front: 4338, // mm — ceiling at r=2210mm (row 1 front) ceilAtRow8Back: 3600, // mm — ceiling at r=8411mm (back of row 8 / aisle) ceilAtCornerBack: 2950, // mm — ceiling behind last corner row // Floor — DISHED, rises from centre outward dishRise: 700, // mm — floor rise from centre to row 8 back // Acoustic earHeight: 1100, // mm — seated ear above local floor speedOfSound: 343, // m/s tileGrid: 600, // mm — ceiling tile grid }; // Confirmed speaker layout — all flush with flat ceiling (no drop rods) // Ceiling at 4300mm above centre floor datum throughout // Inner ring: 8× at r=5720mm // Outer ring: 8× at r=8600mm // Corner: 8× (2 per corner) at r=10310mm const SPEAKER_DEFAULTS = { centre: { enabled: true, count: 1, radius: 0, dropRod: 0, power: 6, sensitivity: 88, phase: 1, label: "Centre (×1)", }, inner: { enabled: true, count: 8, radius: 5720, dropRod: 0, power: 8, sensitivity: 88, phase: 1, label: "Inner Ring (×8)", }, outer: { enabled: true, count: 8, radius: 8600, dropRod: 0, power: 8, sensitivity: 88, phase: 1, label: "Outer Ring (×8)", }, corner: { enabled: true, count: 8, radius: 10310, dropRod: 0, power: 4, sensitivity: 88, phase: 1, label: "Corner (×8, 2/corner)", }, }; const SETTINGS_DEFAULTS = { provider: "ollama", ollamaHost: "http://127.0.0.1", ollamaPort: "11434", ollamaModel: "", anthropicKey: "", anthropicModel: "claude-sonnet-4-20250514", rewHost: "http://127.0.0.1", rewPort: "4735", maxTokens: 2048, temperature: 0.3, systemPrompt: "You are an expert acoustic consultant specialising in distributed ceiling speaker systems for large assembly halls. You have deep knowledge of the Brethren meeting hall (PBCC Universal hall) speaker system design, including the MS6 speaker with Visaton FRS8M and G20SC drivers, the MIDAS MR12 active crossover system, anti-phasing techniques, and the Haas effect in multi-speaker environments. Analyse the provided coverage data and give specific, technical, actionable recommendations.", }; // ─── GEOMETRY ENGINE ────────────────────────────────────────────────────────── // Ceiling is FLAT at ceilFlat mm above centre floor datum (lowest point). // Floor rises linearly from 0 at centre to dishRise at row 8 outer radius. // earToCell = ceilFlat - (earHeight + floorRise) — gets smaller toward perimeter. // speakerHeightAboveCentreDatum = ceilFlat - dropRod (flat ceiling mount). // ── Ceiling height at any radius (linear interpolation / extrapolation) ────── // Seating zone: r=row1Radius(2210) → r=row8Back(8411): 4338mm → 3600mm // Corner zone: r=row8Back(8411) → r=cornerBackR: 3600mm → 2950mm function ceilHeightAtRadius(r, hall) { const { row1Radius, row8Back, ceilAtRow1Front, ceilAtRow8Back, ceilAtCornerBack, cornerRows, cornerFrontOffset, aisleWidth, rowPitch } = hall; const halfW = hall.hallWidth / 2; // Corner back wall radius (along axis, not diagonal) const cornerFrontR = halfW + cornerFrontOffset; // ≈10457+620=11077 — wait, // Actually corners are IN the square beyond the circular aisle. // Corner row 1 front is 620mm PAST the aisle back wall (halfW = 10457mm) // so corner row 1 front = halfW - aisleWidth ... no. // The aisle sits between row8Back(8411) and halfW(10457). // Corner seating is in the SQUARE CORNERS beyond the circular arrangement. // For ceiling purposes we use radius from centre as proxy: // Corner row mid positions along the diagonal axis ≈ halfW + 620 + pitch*n/2 // But for section analysis we treat them on a radial axis: const cornerRow1Front = halfW + cornerFrontOffset; // beyond the back wall... // CORRECTION: corners are WITHIN the building. The aisle back wall IS the building // back wall at halfW=10457mm. Corner rows face inward FROM that wall: // Corner row 1 front = halfW - cornerFrontOffset = 10457 - 620 = 9837mm from centre const cFront = halfW - cornerFrontOffset; // 9837mm — front of corner row 1 const cBack = cFront + (cornerRows) * rowPitch; // back of last corner row if (r <= row8Back) { // Main seating zone: interpolate between row1Front ceiling and row8Back ceiling const t = Math.max(0, (r - row1Radius) / (row8Back - row1Radius)); return Math.round(ceilAtRow1Front - t * (ceilAtRow1Front - ceilAtRow8Back)); } else { // Aisle + corner zone: interpolate from row8Back ceiling to cornerBack ceiling const t = Math.min(1, (r - row8Back) / (cBack - row8Back)); return Math.round(ceilAtRow8Back - t * (ceilAtRow8Back - ceilAtCornerBack)); } } // ── Floor height at radius (dish — rises from 0 at centre to dishRise at row8Back) ─ function floorHeightAtRadius(r, hall) { const ref = hall.row8Back; if (r <= ref) return Math.round((r / ref) * hall.dishRise); // Beyond row8Back: floor continues to rise (conservative — same slope) return Math.round((r / ref) * hall.dishRise); } function computeHallGeometry(hall) { const { rows, row1Radius, rowPitch, earHeight, aisleWidth, hasCornerRows, cornerRows, cornerFrontOffset, hallWidth } = hall; const halfW = hallWidth / 2; // 10457mm const dishRef = hall.row8Back; const rowData = []; // ── Main circular rows 1–8 ────────────────────────────────────────────────── for (let r = 1; r <= rows; r++) { const frontEdge = row1Radius + (r - 1) * rowPitch; const radius = frontEdge + Math.round(rowPitch / 2); // mid-bench ear const floorRise = floorHeightAtRadius(radius, hall); const ceilH = ceilHeightAtRadius(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 ───────────────────────────────────────────────────────────── // Corner rows face inward from the back wall. // Front of corner row 1 = halfW - cornerFrontOffset from centre (along axis) // BUT corners are at 45° diagonals — for acoustic section we use the radial // distance along the corner row axis (approx = distance from centre along diagonal) if (hasCornerRows) { const cRow1Front = halfW - cornerFrontOffset; // 9837mm — front of corner row 1 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 = ceilHeightAtRadius(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; } function computeCoverage(hall, speakers, rowData) { const c = hall.speedOfSound; const rings = ["centre", "inner", "outer", "corner"]; const results = []; for (const row of rowData) { const rowResults = { row: row.row, radius: row.radius, earAFF: row.earAFF, speakers: {} }; // Speaker flush at sloped ceiling — height = ceilHeightAtRadius(spk.radius) - dropRod let minDelay = Infinity; for (const ring of rings) { const spk = speakers[ring]; if (!spk.enabled) continue; const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod; const vertDiff = spkH - row.earAFF; const horizDist= Math.abs(row.radius - spk.radius); const slant = Math.sqrt(vertDiff ** 2 + horizDist ** 2); const delay = (slant / 1000) / c * 1000; if (delay < minDelay) minDelay = delay; } for (const ring of rings) { const spk = speakers[ring]; if (!spk.enabled) { rowResults.speakers[ring] = null; continue; } const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod; const vertDiff = spkH - row.earAFF; const horizDist = Math.abs(row.radius - spk.radius); const slant = Math.sqrt(vertDiff ** 2 + horizDist ** 2); const slantM = slant / 1000; const delay = slantM / c * 1000; const haasExcess= delay - minDelay; // off-axis angle: 0°=straight down from speaker, increases toward horizontal const offAxisDeg= Math.round(Math.atan2(horizDist, Math.max(vertDiff, 1)) * 180 / Math.PI); // SPL: 88dB/1W/1m + 20log(1/d) + 10log(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: flat ceiling vs rising floor — clearance decreases toward perimeter function computeFlutter(hall) { const dishRef = hall.row8Back; // not used directly below but kept for reference // Centre: floor=0, ear=earHeight // Centre: use row1 front radius as proxy for centre-zone ceiling const centreR = hall.row1Radius; const centreCeil = ceilHeightAtRadius(centreR, hall); const centreClear = centreCeil - hall.earHeight; // Row 8 mid-bench const row8Mid = hall.row8Back - Math.round(hall.rowPitch / 2); const row8Floor = floorHeightAtRadius(row8Mid, hall); const row8Ceil = ceilHeightAtRadius(row8Mid, hall); const row8Clear = row8Ceil - (hall.earHeight + row8Floor); // Last corner row mid const halfW = hall.hallWidth / 2; const cFront = halfW - hall.cornerFrontOffset; const cLastMid = cFront + (hall.cornerRows - 0.5) * hall.rowPitch; const cFloor = floorHeightAtRadius(cLastMid, hall); const cCeil = ceilHeightAtRadius(cLastMid, hall); const cornerClear = cCeil - (hall.earHeight + cFloor); const f = (mm) => Math.round((hall.speedOfSound * 1000) / (2 * Math.max(mm, 100))); const fundamentalCentre = f(centreClear); const harmonics = [1,2,3,4,5].map(n => Math.round(fundamentalCentre * n)); // Tile diffraction: c/tileGrid const tileDiffraction = Math.round((hall.speedOfSound * 1000) / hall.tileGrid); return { fundamentalCentre, fundamentalRow8: f(row8Clear), fundamentalCorner: f(cornerClear), harmonics, tileDiffraction, centreClearMm: centreClear, row8ClearMm: row8Clear, cornerClearMm: cornerClear, }; } // ─── PRIMITIVES ─────────────────────────────────────────────────────────────── const Lbl = ({ children, sub }) => (
{sub && {sub}}
); const Field = ({ label, sub, children }) => (
{label} {children}
); const numStyle = { background: "#080b12", border: `1px solid ${C.border}`, borderRadius: 6, color: C.heading, padding: "7px 10px", width: "100%", fontSize: 13, outline: "none", fontFamily: "inherit", boxSizing: "border-box" }; const NumIn = ({ value, onChange, min, max, step = 1, unit, style: sx }) => (
onChange(+e.target.value || 0)} style={{ ...numStyle, ...sx }} /> {unit && {unit}}
); const TxtIn = ({ value, onChange, type = "text", placeholder, style: sx }) => ( onChange(e.target.value)} style={{ ...numStyle, ...sx }} /> ); const Btn = ({ onClick, disabled, children, color = C.accent, outline, style: sx }) => ( ); const Tag = ({ color = C.accent, children, size = "sm" }) => ( {children} ); const Dot = ({ color }) => ; const Panel = ({ children, style: sx, accent }) => (
{children}
); const SectionHead = ({ children, sub }) => (

{children}

{sub &&

{sub}

}
); const Divider = () =>
; // ─── HAAS STATUS HELPERS ────────────────────────────────────────────────────── const haasColor = (s) => ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[s] || C.muted); const haasLabel = (s) => ({ primary: "Primary", ok: "OK <10ms", warn: "Warn 10–30ms", danger: "Echo >30ms" }[s] || "—"); // ─── SECTION DIAGRAM ────────────────────────────────────────────────────────── function SectionDiagram({ hall, speakers, rowData }) { const W = 640, H = 220, PAD = 40; const outerR = hall.row1Radius + (hall.rows - 1) * hall.rowPitch; const totalW = outerR + hall.aisleWidth; const scaleX = (W - PAD * 2) / totalW; const maxCeil = ceilHeightAtRadius(hall.row1Radius, hall); const scaleY = (H - PAD * 2) / maxCeil; const cx = (r) => PAD + r * scaleX; const cy = (h) => H - PAD - h * scaleY; return ( {/* flat ceiling */} {/* Sloped ceiling line */} {(() => { const cpts = rowData.map(r => `${cx(r.radius)},${cy(r.ceilHeight)}`).join(' '); return ; })()} Ceiling (sloped {hall.ceilAtRow1Front}→{hall.ceilAtRow8Back}→{hall.ceilAtCornerBack}mm) {/* dish floor — rises from centre to perimeter */} `${cx(r.radius)},${cy(r.floorRise)}`).join(" ")} fill="none" stroke={C.borderHi} strokeWidth={1.5} strokeDasharray="4,3" /> Floor (+{hall.dishRise}mm rise) {/* rows — vertical line from floor to ear height */} {rowData.map((r) => ( {r.isCorner ? "C" : r.row} ))} {/* speakers */} {Object.entries(speakers).map(([ring, spk]) => { if (!spk.enabled) return null; const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod; const spkY = cy(spkH); const spkX = cx(spk.radius); const color = ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent; return ( {spk.radius === 0 ? "CTR" : spk.radius >= 10000 ? "CNR" : spk.radius >= 8000 ? "OUT" : "INN"} {/* rays to each row */} {rowData.map(r => { const res = computeCoverage(hall, { [ring]: spk }, [r])[0]?.speakers[ring]; if (!res) return null; const rayColor = res.inCoverage ? ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[res.haasStatus] || C.muted) : "#444"; return ; })} ); })} {/* legend */} {[["●", C.accent, "Ear pos"], ["▬", C.gold, "Centre"], ["▬", C.good, "Main ring"], ["▬", C.purple, "Outer ring"], ["—", C.good, "Haas OK"], ["—", C.gold, "Warn"], ["—", C.danger, "Echo/OOB"]].map(([sym, col, lbl], i) => ( {sym} {lbl} ))} ); } // ─── COVERAGE HEATMAP ──────────────────────────────────────────────────────── function CoverageHeatmap({ coverage, speakers }) { const rings = Object.entries(speakers).filter(([, v]) => v.enabled).map(([k, v]) => [k, v]); const cellW = 110, cellH = 52, lblW = 50; const W = lblW + rings.length * cellW + 10; const H = 28 + coverage.length * cellH + 10; return (
{/* column headers */} {rings.map(([ring, spk], ci) => ( {spk.label} ))} {coverage.map((row, ri) => ( {/* row label */} {row.row === "C" ? "Corner" : `Row ${row.row}`} {rings.map(([ring], ci) => { const d = row.speakers[ring]; if (!d) return null; const bg = d.inCoverage ? d.haasStatus === "primary" ? C.goodBg : d.haasStatus === "ok" ? C.accentBg : d.haasStatus === "warn" ? C.goldBg : C.dangerBg : C.dangerBg; const borderCol = d.inCoverage ? haasColor(d.haasStatus) : C.danger; return ( {d.slantM.toFixed(2)}m · {d.delay.toFixed(1)}ms {haasLabel(d.haasStatus)} {d.offAxisDeg}° off-ax · {d.splAtEar.toFixed(1)}dB ); })} ))}
); } // ─── HALL GEOMETRY TAB ──────────────────────────────────────────────────────── function HallTab({ hall, setHall }) { const rowData = computeHallGeometry(hall); const flutter = computeFlutter(hall); const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch; return (
Hall Dimensions
setHall(h => ({ ...h, hallWidth: v }))} unit="mm" /> setHall(h => ({ ...h, rows: Math.max(1, v) }))} min={1} max={12} /> setHall(h => ({ ...h, row1Radius: v }))} unit="mm" /> setHall(h => ({ ...h, rowPitch: v }))} unit="mm" /> setHall(h => ({ ...h, aisleWidth: v }))} unit="mm" /> setHall(h => ({ ...h, earHeight: v }))} unit="mm" />
Ceiling & Dish Floor
setHall(h => ({ ...h, ceilAtRow1Front: v }))} unit="mm" /> setHall(h => ({ ...h, ceilAtRow8Back: v }))} unit="mm" /> setHall(h => ({ ...h, ceilAtCornerBack: v }))} unit="mm" /> setHall(h => ({ ...h, dishRise: v }))} unit="mm" /> setHall(h => ({ ...h, cornerRows: Math.max(1,v) }))} min={1} max={8} /> setHall(h => ({ ...h, cornerFrontOffset: v }))} unit="mm" />
Overall plan: {(hall.hallWidth / 1000).toFixed(3)}m sq Half-width: {(hall.hallWidth / 2).toLocaleString()}mm Tile grid: {hall.tileGrid}mm Dish slope: 1:{Math.round(hall.row8Back / hall.dishRise)} Rise/row ≈ {Math.round(hall.dishRise / hall.rows)}mm Ceil @ row 1: {hall.ceilAtRow1Front}mm Ceil @ row 8: {hall.ceilAtRow8Back}mm Ceil @ corner: {hall.ceilAtCornerBack}mm
Section Diagram Row Geometry Table
{["Row", "Front Edge", "Ear Radius", "Floor Rise", "Ceiling", "Ear AFF", "Ear→Ceiling"].map(h => ( ))} {rowData.map((r, i) => ( {[ r.isCorner ? "Corner" : `Row ${r.row}`, r.frontEdge ? `${r.frontEdge.toLocaleString()}mm` : "—", `${r.radius.toLocaleString()}mm`, `+${r.floorRise}mm`, `${r.ceilHeight.toLocaleString()}mm`, `${r.earAFF.toLocaleString()}mm`, `${r.earToCell.toLocaleString()}mm`, ].map((v, j) => ( ))} ))}
{h}
0 ? "monospace" : "inherit" }}>{v}
Flutter Echo Analysis
{[ ["Row 1 ear→ceiling", `${flutter.centreClearMm.toLocaleString()}mm`, C.text], ["Centre flutter fund.", `${flutter.fundamentalCentre} Hz`, C.warn], ["Row 8 flutter fund.", `${flutter.fundamentalRow8} Hz`, C.danger], ].map(([lbl, val, col]) => (
{lbl}
{val}
))}
Centre harmonics: {flutter.harmonics.map((f, i) => {f}Hz).reduce((a, e) => [...a, " ", e], [])}
⚠ Row 8 mid-bench ear-to-ceiling = {flutter.row8ClearMm}mm → flutter fundamental {flutter.fundamentalRow8}Hz. Harmonics: {flutter.harmonics[1]}Hz and {flutter.harmonics[2]}Hz land in the 100–300Hz critical speech band. Tile diffraction grating: {flutter.tileDiffraction}Hz (λ = {hall.tileGrid}mm).
); } // ─── SPEAKER LAYOUT TAB ────────────────────────────────────────────────────── function SpeakerTab({ hall, speakers, setSpeakers }) { const rowData = computeHallGeometry(hall); const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch; const RingEditor = ({ ringKey, color }) => { const spk = speakers[ringKey]; const set = (k, v) => setSpeakers(s => ({ ...s, [ringKey]: { ...s[ringKey], [k]: v } })); const spkHeightAFF = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod; return (

{spk.label}

{spk.enabled && (
set("count", v)} min={1} max={16} /> set("radius", v)} unit="mm" /> set("dropRod", Math.max(0, v))} unit="mm" /> set("power", v)} step={0.5} unit="W" /> set("sensitivity", v)} unit="dB" />
{spkHeightAFF.toLocaleString()} mm
{(() => { const nearest = rowData.reduce((best, r) => Math.abs(r.radius - spk.radius) < Math.abs(best.radius - spk.radius) ? r : best, rowData[0]); return `Row ${nearest.row} (r=${nearest.radius}mm)`; })()}
{(spk.power * spk.count).toFixed(1)} W
)} ); }; const totalPower = Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.power * s.count, 0); return (
{[ ["Total speakers", Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.count, 0) + " (flush)", C.accent], ["Total power", `${totalPower.toFixed(1)}W`, C.gold], ["Crossover", "MS6 / MR12 · 3kHz gap", C.good], ["Gap", "433Hz @ 3kHz", C.purple], ].map(([lbl, val, col]) => (
{lbl}
{val}
))}
); } // ─── COVERAGE TAB ──────────────────────────────────────────────────────────── function CoverageTab({ hall, speakers }) { const rowData = computeHallGeometry(hall); const coverage = computeCoverage(hall, speakers, rowData); // Summary stats const allCells = coverage.flatMap(r => Object.values(r.speakers).filter(Boolean)); const issues = allCells.filter(c => !c.inCoverage || c.haasStatus === "danger"); const warnings = allCells.filter(c => c.haasStatus === "warn"); return (
{[ ["Coverage cells", allCells.length, C.accent], ["Issues", issues.length, issues.length > 0 ? C.danger : C.good], ["Warnings", warnings.length, warnings.length > 0 ? C.gold : C.good], ["Haas limit", "30ms", C.text], ["MS6 max off-axis", "55°", C.text], ].map(([lbl, val, col]) => (
{lbl}
{val}
))}
{[["Primary source", C.good], ["OK (0–10ms)", C.accent], ["Warn (10–30ms)", C.gold], ["Echo (>30ms)", C.danger], ["Outside 55° cone", C.danger]].map(([lbl, col]) => ( {lbl} ))}
Coverage Heatmap — All Rows × All Speaker Rings Per-Row Coverage Detail
{["Row", "Radius", "Ring", "Slant (m)", "Delay (ms)", "Haas +Δms", "Off-axis °", "SPL (dB)", "In cone", "Status"].map(h => ( ))} {coverage.flatMap((row, ri) => Object.entries(row.speakers).filter(([, d]) => d).map(([ring, d], si) => ( )) )}
{h}
{row.row === "C" ? "Corner" : `Row ${row.row}`} {(row.radius / 1000).toFixed(3)}m {speakers[ring].label} {d.slantM.toFixed(3)} 30 ? C.danger : C.text }}>{d.delay.toFixed(2)} 30 ? C.danger : d.haasExcess > 10 ? C.gold : C.text }}>{d.haasExcess > 0 ? `+${d.haasExcess.toFixed(2)}` : "—"} 55 ? C.danger : C.text }}>{d.offAxisDeg}° {d.splAtEar} {haasLabel(d.haasStatus)}
); } // ─── REW & ANALYSIS TAB ─────────────────────────────────────────────────────── function AnalysisTab({ hall, speakers, settings, addLog }) { const [rewStatus, setRewStatus] = useState("idle"); const [rewData, setRewData] = useState(null); const [analysisState, setAnalysisState] = useState("idle"); const [streamText, setStreamText] = useState(""); const [analysis, setAnalysis] = useState(null); const rewBase = `${settings.rewHost}:${settings.rewPort}`; const rowData = computeHallGeometry(hall); const coverage = computeCoverage(hall, speakers, rowData); const flutter = computeFlutter(hall); async function connectREW() { setRewStatus("checking"); addLog(`Connecting to REW at ${rewBase}…`); try { const r = await fetch(`${rewBase}/application`); if (!r.ok) throw new Error(`HTTP ${r.status}`); addLog("REW API connected ✓", "good"); // Push room size await fetch(`${rewBase}/roomsim/room-size`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ length: hall.hallWidth / 1000, width: hall.hallWidth / 1000, height: hall.ceilAtRow1Front / 1000 }), }); addLog("Room dimensions pushed to REW ✓", "good"); // Fetch frequency response const freqResp = await fetch(`${rewBase}/roomsim/frequency-response?micposition=Main&ppo=24`).then(r => r.json()); addLog("Frequency response retrieved ✓", "good"); const magnitudes = (() => { try { const bin = atob(freqResp.magnitudes || ""); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const view = new DataView(buf.buffer); const out = []; for (let i = 0; i < buf.length; i += 4) out.push(view.getFloat32(i, false)); return out; } catch { return []; } })(); const startFreq = freqResp.startFreq || 20, ppo = freqResp.pointsPerOctave || 24; const freqPoints = magnitudes.map((mag, i) => ({ freq: Math.round(startFreq * Math.pow(2, i / ppo)), spl: Math.round(mag * 10) / 10 })); const bandData = [63, 125, 250, 500, 1000, 2000, 4000, 8000].map(f => { const near = freqPoints.reduce((b, p) => Math.abs(p.freq - f) < Math.abs(b.freq - f) ? p : b, freqPoints[0] || { freq: f, spl: 0 }); return { freq: f, spl: near?.spl ?? 0 }; }); setRewData({ bandData, freqPoints: freqPoints.filter((_, i) => i % 4 === 0).slice(0, 80) }); setRewStatus("connected"); addLog("REW data ready ✓", "good"); } catch (e) { setRewStatus("error"); addLog(`REW error: ${e.message}`, "error"); } } function buildPrompt() { const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch; const flutter = computeFlutter(hall); return `${settings.systemPrompt} ## Hall Specification (SK-303 Version B confirmed) - Seating plan: ${hall.hallWidth}mm × ${hall.hallWidth}mm (${(hall.hallWidth/1000).toFixed(3)}m sq) - Half-width centre to back wall: ${hall.hallWidth/2}mm - Ceiling: SLOPED — ${hall.ceilAtRow1Front}mm at row 1 front → ${hall.ceilAtRow8Back}mm at row 8 back → ${hall.ceilAtCornerBack}mm behind last corner row · ${hall.tileGrid}mm tile grid - Rows: ${hall.rows} circular rows + corner rows - Row 1 front edge: ${hall.row1Radius}mm from centre - Row pitch (FFL–FFL): ${hall.rowPitch}mm - Row 8 back face: ${hall.row8Back}mm from centre (per drawing) - Back aisle: ${hall.aisleWidth}mm (${hall.hallWidth/2} − ${hall.row8Back} = ${hall.hallWidth/2 - hall.row8Back}mm ✓) ## Dish Floor Geometry - Centre floor: 0mm (lowest) | Row 8 floor: +${hall.dishRise}mm rise | Slope: 1:${Math.round(outerRadius / hall.dishRise)} - Ceiling slope: ${hall.ceilAtRow1Front - hall.ceilAtRow8Back}mm drop over ${hall.row8Back - hall.row1Radius}mm (≈1:${Math.round((hall.row8Back-hall.row1Radius)/(hall.ceilAtRow1Front-hall.ceilAtRow8Back))}) - As floor rises toward perimeter, each row's seated ear moves closer to the flat ceiling ## Speaker System — MS6 (Visaton FRS8M + G20SC, MIDAS MR12 active crossover) - All speakers FLUSH mounted in sloped ceiling (no drop rods) — height varies by position - Crossover gap: 2792–3225Hz (433Hz gap, zero driver overlap — eliminates horizontal lobes) - Max coverage: 55° off-axis (-3dB), near-circular polar response - Sensitivity: 88dB/1W/1m ${Object.entries(speakers).filter(([,v])=>v.enabled).map(([k,v])=>`- ${v.label}: ${v.count}× at r=${v.radius}mm flush, ${v.power}W each (${(v.power*v.count).toFixed(1)}W total), phase ${v.phase > 0 ? "normal" : "anti-phase"}`).join("\n")} - Total installed power: ${Object.values(speakers).filter(v=>v.enabled).reduce((s,v)=>s+v.power*v.count,0).toFixed(1)}W ## Coverage Analysis (per row) ${coverage.map(row => `Row ${row.row} (r=${row.radius}mm, ear ${row.earAFF}mm AFF, ceil ${row.ceilHeight}mm, ear→ceil ${row.earToCell}mm):\n${Object.entries(row.speakers).filter(([,d])=>d).map(([ring,d])=>` ${speakers[ring].label}: ${d.slantM.toFixed(2)}m slant | ${d.delay.toFixed(1)}ms delay | +${d.haasExcess.toFixed(1)}ms Haas excess | ${d.offAxisDeg}° off-axis | ${d.splAtEar}dB SPL at ear | ${d.inCoverage ? "within 55° cone ✓" : "OUTSIDE 55° CONE ✗"} | ${haasLabel(d.haasStatus)}`).join("\n")}`).join("\n\n")} ## Flutter Echo Risk (flat ceiling + rising floor) - Centre: ${flutter.centreClearMm}mm clearance → fundamental ${flutter.fundamentalCentre}Hz, harmonics: ${flutter.harmonics.join(", ")}Hz - Row 8: ${flutter.row8ClearMm}mm clearance → fundamental ${flutter.fundamentalRow8}Hz - 600mm tile grid → diffraction grating at ~571Hz ${rewData ? `## REW Frequency Response\n${rewData.bandData.map(b=>`- ${b.freq}Hz: ${b.spl}dB`).join("\n")}` : "## REW: Not connected (geometry analysis only)"} --- Provide structured analysis: ### 🏛 Hall Assessment Speech intelligibility assessment for 360° circular seating with dished floor. ### ⚠️ Coverage Issues Rows outside 55° MS6 cone, Haas violations (>10ms excess = colouration, >30ms = echo), SPL variation across rows. ### 🔊 Speaker Position Optimisation Recommended ring radii and drop rod lengths. Note: ceiling tile grid constrains positions to 600mm increments. ### ⏱ Delay & Haas Analysis Identify which speaker combinations cause Haas window problems per row. Recommend DSP delay compensation values in ms. ### 🔇 Anti-Phasing Assess viability given 8+8 speaker count and MS6 system. Reference Dudley Wilkin's 2010 analysis. ### 🎛 MR12 Acoustic EQ Channel 1 TEQ/PEQ recommendations given hall geometry, including flutter frequencies to notch. ### 📐 REW / ARTA Measurement Protocol Optimal measurement positions, mic height above dished floor, ARTA window size — ceiling reflection times: ${(2*(hall.ceilAtRow1Front-hall.earHeight)/1000/hall.speedOfSound*1000).toFixed(1)}ms at row 1, ${(2*(hall.ceilAtRow8Back-hall.earHeight)/1000/hall.speedOfSound*1000).toFixed(1)}ms at row 8 — limits usable ARTA window.`; } async function runAnalysis() { setAnalysisState("loading"); setAnalysis(null); setStreamText(""); const prompt = buildPrompt(); const { provider, ollamaHost, ollamaPort, ollamaModel, anthropicKey, anthropicModel, maxTokens, temperature } = settings; if (provider === "ollama") { const base = `${ollamaHost}:${ollamaPort}`; addLog(`Sending to Ollama (${ollamaModel})…`); try { const r = await fetch(`${base}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: ollamaModel, stream: true, options: { temperature, num_predict: maxTokens }, messages: [{ role: "user", content: prompt }] }), }); if (!r.ok) throw new Error(`Ollama ${r.status}`); const reader = r.body.getReader(); const dec = new TextDecoder(); let full = ""; setAnalysisState("streaming"); while (true) { const { done, value } = await reader.read(); if (done) break; for (const line of dec.decode(value).split("\n").filter(Boolean)) { try { const obj = JSON.parse(line); full += obj.message?.content || obj.response || ""; setStreamText(full); } catch { } } } setAnalysis(full); setAnalysisState("done"); addLog("Analysis complete ✓", "good"); } catch (e) { setAnalysisState("error"); addLog(`Ollama error: ${e.message}`, "error"); } } else { if (!anthropicKey) { addLog("No API key — check Settings", "error"); setAnalysisState("error"); return; } addLog(`Sending to Claude (${anthropicModel})…`); try { const r = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: anthropicModel, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }), }); const data = await r.json(); if (data.error) throw new Error(data.error.message); const text = data.content?.filter(b => b.type === "text").map(b => b.text).join("") || ""; setAnalysis(text); setAnalysisState("done"); addLog("Analysis complete ✓", "good"); } catch (e) { setAnalysisState("error"); addLog(`Claude error: ${e.message}`, "error"); } } } function renderMD(text) { return text.split("\n").map((line, i) => { if (line.startsWith("### ")) return

{line.slice(4)}

; if (line.startsWith("## ")) return

{line.slice(3)}

; if (/^\d+\.\s\*\*/.test(line)) { const m = line.match(/^(\d+)\.\s\*\*(.+?)\*\*(.*)$/); if (m) return
{m[1]}.
{m[2]}{m[3]}
; } if (line.startsWith("- ")) return
{line.slice(2)}
; if (!line.trim()) return
; const parts = line.split(/(\*\*[^*]+\*\*)/g); return

{parts.map((p, j) => p.startsWith("**") ? {p.slice(2, -2)} : p)}

; }); } const displayText = (analysisState === "streaming" ? streamText : analysis) || ""; const providerLabel = settings.provider === "ollama" ? `🦙 ${settings.ollamaModel || "Ollama"}` : `✦ Claude`; return (
REW Room Simulator
{rewStatus === "checking" ? "Connecting…" : "Connect to REW"} {rewStatus !== "idle" && ( {rewStatus === "connected" ? "REW connected ✓" : rewStatus === "error" ? "Connection failed" : "Connecting…"} )} REW at {rewBase} — configure in ⚙ Settings
{rewData && (
Octave band response from REW simulator:
{rewData.bandData.map((b, i) => { const vals = rewData.bandData.map(d => d.spl); const mn = Math.min(...vals) - 3, mx = Math.max(...vals) + 3; const x = 30 + i * (440 / (rewData.bandData.length - 1)); const y = 70 - ((b.spl - mn) / (mx - mn)) * 55; return {b.freq >= 1000 ? `${b.freq / 1000}k` : b.freq}; })} { const vals = rewData.bandData.map(d => d.spl); const mn = Math.min(...vals) - 3, mx = Math.max(...vals) + 3; const x = 30 + i * (440 / (rewData.bandData.length - 1)); const y = 70 - ((b.spl - mn) / (mx - mn)) * 55; return `${x},${y}`; }).join(" ")} fill="none" stroke={C.accent} strokeWidth={2} />
)}
{(analysisState === "loading" || analysisState === "streaming") ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`} {analysisState === "loading" && (
Sending geometry and coverage data to {providerLabel}…
)} {(analysisState === "streaming" || analysisState === "done") && displayText && (
Acoustic Analysis Report
{analysisState === "streaming" && ● Streaming} {providerLabel}
{renderMD(displayText)}
)} {analysisState === "error" && (
⚠ Analysis failed — check Log tab
)}
); } // ─── SETTINGS TAB ──────────────────────────────────────────────────────────── function SettingsTab({ settings, setSettings, addLog }) { const [ollamaModels, setOllamaModels] = useState([]); const [ollamaStatus, setOllamaStatus] = useState("idle"); const [rewTestStatus, setRewTestStatus] = useState("idle"); const [flash, setFlash] = useState(false); const set = (k, v) => setSettings(p => ({ ...p, [k]: v })); const selectStyle = { ...numStyle }; async function fetchModels() { setOllamaStatus("fetching"); try { const r = await fetch(`${settings.ollamaHost}:${settings.ollamaPort}/api/tags`); const data = await r.json(); const models = (data.models || []).map(m => m.name); setOllamaModels(models); setOllamaStatus("ok"); if (models.length > 0 && !settings.ollamaModel) set("ollamaModel", models[0]); addLog(`Ollama: ${models.join(", ")}`, "good"); } catch (e) { setOllamaStatus("error"); addLog(`Ollama: ${e.message}`, "error"); } } async function testREW() { setRewTestStatus("checking"); try { await fetch(`${settings.rewHost}:${settings.rewPort}/application`); setRewTestStatus("connected"); addLog("REW reachable ✓", "good"); } catch (e) { setRewTestStatus("error"); addLog(`REW: ${e.message}`, "error"); } } const ProvBtn = ({ id, icon, title, sub }) => ( ); return (
LLM Provider
{settings.provider === "ollama" && <>
set("ollamaHost", v)} placeholder="http://127.0.0.1" /> set("ollamaPort", v)} />
{ollamaModels.length > 0 ? : set("ollamaModel", v)} placeholder="e.g. llama3.2, mistral, qwen2.5…" />} {ollamaStatus === "fetching" ? "…" : "Fetch"}
{ollamaStatus !== "idle" &&
{ollamaStatus === "ok" ? `✓ ${ollamaModels.length} model(s)` : ollamaStatus === "error" ? "Cannot reach Ollama — OLLAMA_ORIGINS=* ollama serve" : "Fetching…"}
} } {settings.provider === "anthropic" && <> set("anthropicKey", v)} type="password" placeholder="sk-ant-api03-…" /> }
REW API
set("rewHost", v)} /> set("rewPort", v)} />
Test Connection {rewTestStatus !== "idle" && {rewTestStatus === "connected" ? "REW reachable ✓" : rewTestStatus === "error" ? "Failed" : "Testing…"} }
LLM Behaviour