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 (
);
}
// ─── 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 (
);
}
// ─── 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 => (
| {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) => (
| 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]) => (
))}
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.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]) => (
))}
);
}
// ─── 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]) => (
))}
{[["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 => (
| {h} |
))}
{coverage.flatMap((row, ri) =>
Object.entries(row.speakers).filter(([, d]) => d).map(([ring, d], si) => (
| {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 ; }
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:
)}
{(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
set("maxTokens", v)} min={256} max={8192} step={256} />
set("temperature", Math.min(2, Math.max(0, v)))} step={0.05} />
{ setFlash(true); setTimeout(() => setFlash(false), 1600); }} color={flash ? C.good : C.accent} style={{ width: "100%", padding: 13, fontSize: 14 }}>
{flash ? "✓ Settings Applied" : "Save Settings"}
);
}
// ─── APP ─────────────────────────────────────────────────────────────────────
export default function App() {
const [hall, setHall] = useState(HALL_DEFAULTS);
const [speakers, setSpeakers] = useState(SPEAKER_DEFAULTS);
const [settings, setSettings] = useState(SETTINGS_DEFAULTS);
const [activeTab, setActiveTab] = useState("hall");
const [log, setLog] = useState([]);
const logRef = useRef([]);
const addLog = useCallback((msg, type = "info") => {
const entry = { msg, type, t: new Date().toLocaleTimeString() };
logRef.current = [...logRef.current.slice(-99), entry];
setLog([...logRef.current]);
}, []);
const TABS = [
{ id: "hall", icon: "⬡", label: "Hall" },
{ id: "speakers", icon: "◎", label: "Speakers" },
{ id: "coverage", icon: "⊞", label: "Coverage" },
{ id: "analysis", icon: "◈", label: "Analysis" },
{ id: "settings", icon: "⚙", label: "Settings" },
{ id: "log", icon: "▤", label: "Log" },
];
const tabStyle = (active) => ({
background: active ? C.accent : "transparent",
color: active ? C.bg : C.muted,
border: `1px solid ${active ? C.accent : C.border}`,
borderRadius: 6, padding: "7px 14px", fontSize: 11, fontWeight: 700,
letterSpacing: 0.8, textTransform: "uppercase", cursor: "pointer",
fontFamily: "inherit", transition: "all 0.15s",
});
const provLabel = settings.provider === "ollama"
? `🦙 ${settings.ollamaModel || "Ollama"}`
: `✦ Claude`;
return (
{/* header */}
PBCC Universal Hall · MS6 Speaker System
Church Acoustic Analysis Suite
{hall.hallWidth / 1000}m × {hall.hallWidth / 1000}m
{hall.rows} rows
{provLabel}
{/* tabs */}
{TABS.map(t => )}
{activeTab === "hall" &&
}
{activeTab === "speakers" &&
}
{activeTab === "coverage" &&
}
{activeTab === "analysis" &&
}
{activeTab === "settings" &&
}
{activeTab === "log" && (
Activity Log
{ logRef.current = []; setLog([]); }} color={C.muted} outline style={{ padding: "3px 10px", fontSize: 10 }}>Clear
{log.length === 0 && No activity yet.
}
{[...log].reverse().map((e, i) => (
{e.t}
{e.msg}
))}
)}
);
}