church-acoustic-suite.jsx 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149
  1. import { useState, useCallback, useRef, useEffect } from "react";
  2. // ─── PALETTE ──────────────────────────────────────────────────────────────────
  3. const C = {
  4. bg: "#07090f",
  5. bgAlt: "#0c0f18",
  6. panel: "#0f1420",
  7. panelAlt: "#111827",
  8. border: "#1a2236",
  9. borderHi: "#243050",
  10. accent: "#4f9eff",
  11. accentDim: "#2a6abd",
  12. accentBg: "#4f9eff12",
  13. gold: "#f0b429",
  14. goldBg: "#f0b42912",
  15. good: "#34d399",
  16. goodBg: "#34d39912",
  17. warn: "#fb923c",
  18. warnBg: "#fb923c12",
  19. danger: "#f87171",
  20. dangerBg: "#f8717112",
  21. purple: "#a78bfa",
  22. purpleBg: "#a78bfa12",
  23. text: "#cbd5e1",
  24. muted: "#475569",
  25. heading: "#f1f5f9",
  26. };
  27. // ─── HALL DEFAULTS ────────────────────────────────────────────────────────────
  28. // Confirmed geometry from SK-303 Version B + site measurements
  29. //
  30. // PLAN (square seating zone):
  31. // Overall seating plan: 20914 x 20914mm (half-width = 10457mm)
  32. // Row 1 front edge: 2210mm radius from centre
  33. // Row pitch: 883mm FFL to FFL (8 main rows)
  34. // Row 8 back face: 8411mm radius (per SK-303 drawing)
  35. // Back aisle: 2046mm (10457 - 8411 = 2046 ✓)
  36. // Corner seating: 4 rows, same 883mm pitch, front of row 1 = 620mm from aisle wall
  37. // Corner row 1 front radius ≈ 10457 + 620 = beyond main seating — in corner zone
  38. //
  39. // SECTION (flat ceiling — "1 in 8 slope"):
  40. // Ceiling at row 1 front (r=2210): 4338mm (3600 + 738)
  41. // Ceiling at row 8 back (r=8411): 3600mm (confirmed)
  42. // Ceiling perimeter aisle: 3600mm (confirmed)
  43. // Ceiling behind last corner row: 2950mm
  44. //
  45. // FLOOR (dished — rises from centre outward):
  46. // Centre floor: 0mm datum (lowest)
  47. // Row 8 back: +738mm rise (SK-303)
  48. // Slope ≈ 1:8.4
  49. //
  50. // CORNER SPEAKERS:
  51. // Radius: 10310mm from centre
  52. // 1500mm from side walls (wall at 10457mm → coord = 8957mm on one axis)
  53. const HALL_DEFAULTS = {
  54. hallWidth: 20914, // mm — confirmed seating plan dimension
  55. rows: 8, // main circular rows
  56. row1Radius: 2210, // mm — centre to front edge of row 1
  57. rowPitch: 883, // mm — FFL to FFL
  58. row8Back: 8411, // mm — centre to back face of row 8
  59. aisleWidth: 2046, // mm — back aisle width
  60. // Corner seating
  61. cornerRows: 4,
  62. cornerFrontOffset: 620, // mm — front of corner row 1 from aisle back wall
  63. hasCornerRows: true,
  64. // Ceiling — SLOPED, higher at centre, lower at perimeter
  65. ceilAtRow1Front: 4338, // mm — ceiling at r=2210mm (row 1 front)
  66. ceilAtRow8Back: 3600, // mm — ceiling at r=8411mm (back of row 8 / aisle)
  67. ceilAtCornerBack: 2950, // mm — ceiling behind last corner row
  68. // Floor — DISHED, rises from centre outward
  69. dishRise: 700, // mm — floor rise from centre to row 8 back
  70. // Acoustic
  71. earHeight: 1100, // mm — seated ear above local floor
  72. speedOfSound: 343, // m/s
  73. tileGrid: 600, // mm — ceiling tile grid
  74. };
  75. // Confirmed speaker layout — all flush with flat ceiling (no drop rods)
  76. // Ceiling at 4300mm above centre floor datum throughout
  77. // Inner ring: 8× at r=5720mm
  78. // Outer ring: 8× at r=8600mm
  79. // Corner: 8× (2 per corner) at r=10310mm
  80. const SPEAKER_DEFAULTS = {
  81. centre: {
  82. enabled: true, count: 1, radius: 0, dropRod: 0,
  83. power: 6, sensitivity: 88, phase: 1, label: "Centre (×1)",
  84. },
  85. inner: {
  86. enabled: true, count: 8, radius: 5720, dropRod: 0,
  87. power: 8, sensitivity: 88, phase: 1, label: "Inner Ring (×8)",
  88. },
  89. outer: {
  90. enabled: true, count: 8, radius: 8600, dropRod: 0,
  91. power: 8, sensitivity: 88, phase: 1, label: "Outer Ring (×8)",
  92. },
  93. corner: {
  94. enabled: true, count: 8, radius: 10310, dropRod: 0,
  95. power: 4, sensitivity: 88, phase: 1, label: "Corner (×8, 2/corner)",
  96. },
  97. };
  98. const SETTINGS_DEFAULTS = {
  99. provider: "ollama",
  100. ollamaHost: "http://127.0.0.1", ollamaPort: "11434", ollamaModel: "",
  101. anthropicKey: "", anthropicModel: "claude-sonnet-4-20250514",
  102. rewHost: "http://127.0.0.1", rewPort: "4735",
  103. maxTokens: 2048, temperature: 0.3,
  104. 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.",
  105. };
  106. // ─── GEOMETRY ENGINE ──────────────────────────────────────────────────────────
  107. // Ceiling is FLAT at ceilFlat mm above centre floor datum (lowest point).
  108. // Floor rises linearly from 0 at centre to dishRise at row 8 outer radius.
  109. // earToCell = ceilFlat - (earHeight + floorRise) — gets smaller toward perimeter.
  110. // speakerHeightAboveCentreDatum = ceilFlat - dropRod (flat ceiling mount).
  111. // ── Ceiling height at any radius (linear interpolation / extrapolation) ──────
  112. // Seating zone: r=row1Radius(2210) → r=row8Back(8411): 4338mm → 3600mm
  113. // Corner zone: r=row8Back(8411) → r=cornerBackR: 3600mm → 2950mm
  114. function ceilHeightAtRadius(r, hall) {
  115. const { row1Radius, row8Back, ceilAtRow1Front, ceilAtRow8Back, ceilAtCornerBack,
  116. cornerRows, cornerFrontOffset, aisleWidth, rowPitch } = hall;
  117. const halfW = hall.hallWidth / 2;
  118. // Corner back wall radius (along axis, not diagonal)
  119. const cornerFrontR = halfW + cornerFrontOffset; // ≈10457+620=11077 — wait,
  120. // Actually corners are IN the square beyond the circular aisle.
  121. // Corner row 1 front is 620mm PAST the aisle back wall (halfW = 10457mm)
  122. // so corner row 1 front = halfW - aisleWidth ... no.
  123. // The aisle sits between row8Back(8411) and halfW(10457).
  124. // Corner seating is in the SQUARE CORNERS beyond the circular arrangement.
  125. // For ceiling purposes we use radius from centre as proxy:
  126. // Corner row mid positions along the diagonal axis ≈ halfW + 620 + pitch*n/2
  127. // But for section analysis we treat them on a radial axis:
  128. const cornerRow1Front = halfW + cornerFrontOffset; // beyond the back wall...
  129. // CORRECTION: corners are WITHIN the building. The aisle back wall IS the building
  130. // back wall at halfW=10457mm. Corner rows face inward FROM that wall:
  131. // Corner row 1 front = halfW - cornerFrontOffset = 10457 - 620 = 9837mm from centre
  132. const cFront = halfW - cornerFrontOffset; // 9837mm — front of corner row 1
  133. const cBack = cFront + (cornerRows) * rowPitch; // back of last corner row
  134. if (r <= row8Back) {
  135. // Main seating zone: interpolate between row1Front ceiling and row8Back ceiling
  136. const t = Math.max(0, (r - row1Radius) / (row8Back - row1Radius));
  137. return Math.round(ceilAtRow1Front - t * (ceilAtRow1Front - ceilAtRow8Back));
  138. } else {
  139. // Aisle + corner zone: interpolate from row8Back ceiling to cornerBack ceiling
  140. const t = Math.min(1, (r - row8Back) / (cBack - row8Back));
  141. return Math.round(ceilAtRow8Back - t * (ceilAtRow8Back - ceilAtCornerBack));
  142. }
  143. }
  144. // ── Floor height at radius (dish — rises from 0 at centre to dishRise at row8Back) ─
  145. function floorHeightAtRadius(r, hall) {
  146. const ref = hall.row8Back;
  147. if (r <= ref) return Math.round((r / ref) * hall.dishRise);
  148. // Beyond row8Back: floor continues to rise (conservative — same slope)
  149. return Math.round((r / ref) * hall.dishRise);
  150. }
  151. function computeHallGeometry(hall) {
  152. const { rows, row1Radius, rowPitch, earHeight, aisleWidth,
  153. hasCornerRows, cornerRows, cornerFrontOffset, hallWidth } = hall;
  154. const halfW = hallWidth / 2; // 10457mm
  155. const dishRef = hall.row8Back;
  156. const rowData = [];
  157. // ── Main circular rows 1–8 ──────────────────────────────────────────────────
  158. for (let r = 1; r <= rows; r++) {
  159. const frontEdge = row1Radius + (r - 1) * rowPitch;
  160. const radius = frontEdge + Math.round(rowPitch / 2); // mid-bench ear
  161. const floorRise = floorHeightAtRadius(radius, hall);
  162. const ceilH = ceilHeightAtRadius(radius, hall);
  163. const earAFF = earHeight + floorRise;
  164. const earToCell = ceilH - earAFF;
  165. rowData.push({ row: r, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: false });
  166. }
  167. // ── Corner rows ─────────────────────────────────────────────────────────────
  168. // Corner rows face inward from the back wall.
  169. // Front of corner row 1 = halfW - cornerFrontOffset from centre (along axis)
  170. // BUT corners are at 45° diagonals — for acoustic section we use the radial
  171. // distance along the corner row axis (approx = distance from centre along diagonal)
  172. if (hasCornerRows) {
  173. const cRow1Front = halfW - cornerFrontOffset; // 9837mm — front of corner row 1
  174. for (let cr = 1; cr <= cornerRows; cr++) {
  175. const frontEdge = cRow1Front + (cr - 1) * rowPitch;
  176. const radius = frontEdge + Math.round(rowPitch / 2);
  177. const floorRise = floorHeightAtRadius(radius, hall);
  178. const ceilH = ceilHeightAtRadius(radius, hall);
  179. const earAFF = earHeight + floorRise;
  180. const earToCell = ceilH - earAFF;
  181. rowData.push({ row: `C${cr}`, radius, frontEdge, floorRise, ceilHeight: ceilH, earAFF, earToCell, isCorner: true, cornerRow: cr });
  182. }
  183. }
  184. return rowData;
  185. }
  186. function computeCoverage(hall, speakers, rowData) {
  187. const c = hall.speedOfSound;
  188. const rings = ["centre", "inner", "outer", "corner"];
  189. const results = [];
  190. for (const row of rowData) {
  191. const rowResults = { row: row.row, radius: row.radius, earAFF: row.earAFF, speakers: {} };
  192. // Speaker flush at sloped ceiling — height = ceilHeightAtRadius(spk.radius) - dropRod
  193. let minDelay = Infinity;
  194. for (const ring of rings) {
  195. const spk = speakers[ring];
  196. if (!spk.enabled) continue;
  197. const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod;
  198. const vertDiff = spkH - row.earAFF;
  199. const horizDist= Math.abs(row.radius - spk.radius);
  200. const slant = Math.sqrt(vertDiff ** 2 + horizDist ** 2);
  201. const delay = (slant / 1000) / c * 1000;
  202. if (delay < minDelay) minDelay = delay;
  203. }
  204. for (const ring of rings) {
  205. const spk = speakers[ring];
  206. if (!spk.enabled) { rowResults.speakers[ring] = null; continue; }
  207. const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod;
  208. const vertDiff = spkH - row.earAFF;
  209. const horizDist = Math.abs(row.radius - spk.radius);
  210. const slant = Math.sqrt(vertDiff ** 2 + horizDist ** 2);
  211. const slantM = slant / 1000;
  212. const delay = slantM / c * 1000;
  213. const haasExcess= delay - minDelay;
  214. // off-axis angle: 0°=straight down from speaker, increases toward horizontal
  215. const offAxisDeg= Math.round(Math.atan2(horizDist, Math.max(vertDiff, 1)) * 180 / Math.PI);
  216. // SPL: 88dB/1W/1m + 20log(1/d) + 10log(W)
  217. const splAtEar = spk.sensitivity + 20 * Math.log10(1 / Math.max(slantM, 0.1)) + 10 * Math.log10(Math.max(spk.power, 0.01));
  218. const inCoverage= offAxisDeg <= 55;
  219. let haasStatus = "primary";
  220. if (haasExcess > 30) haasStatus = "danger";
  221. else if (haasExcess > 10) haasStatus = "warn";
  222. else if (haasExcess > 0) haasStatus = "ok";
  223. rowResults.speakers[ring] = {
  224. slantM, delay, haasExcess, offAxisDeg,
  225. splAtEar: Math.round(splAtEar * 10) / 10,
  226. inCoverage, haasStatus, spkH, vertDiff,
  227. };
  228. }
  229. results.push(rowResults);
  230. }
  231. return results;
  232. }
  233. // Flutter: flat ceiling vs rising floor — clearance decreases toward perimeter
  234. function computeFlutter(hall) {
  235. const dishRef = hall.row8Back; // not used directly below but kept for reference
  236. // Centre: floor=0, ear=earHeight
  237. // Centre: use row1 front radius as proxy for centre-zone ceiling
  238. const centreR = hall.row1Radius;
  239. const centreCeil = ceilHeightAtRadius(centreR, hall);
  240. const centreClear = centreCeil - hall.earHeight;
  241. // Row 8 mid-bench
  242. const row8Mid = hall.row8Back - Math.round(hall.rowPitch / 2);
  243. const row8Floor = floorHeightAtRadius(row8Mid, hall);
  244. const row8Ceil = ceilHeightAtRadius(row8Mid, hall);
  245. const row8Clear = row8Ceil - (hall.earHeight + row8Floor);
  246. // Last corner row mid
  247. const halfW = hall.hallWidth / 2;
  248. const cFront = halfW - hall.cornerFrontOffset;
  249. const cLastMid = cFront + (hall.cornerRows - 0.5) * hall.rowPitch;
  250. const cFloor = floorHeightAtRadius(cLastMid, hall);
  251. const cCeil = ceilHeightAtRadius(cLastMid, hall);
  252. const cornerClear = cCeil - (hall.earHeight + cFloor);
  253. const f = (mm) => Math.round((hall.speedOfSound * 1000) / (2 * Math.max(mm, 100)));
  254. const fundamentalCentre = f(centreClear);
  255. const harmonics = [1,2,3,4,5].map(n => Math.round(fundamentalCentre * n));
  256. // Tile diffraction: c/tileGrid
  257. const tileDiffraction = Math.round((hall.speedOfSound * 1000) / hall.tileGrid);
  258. return {
  259. fundamentalCentre, fundamentalRow8: f(row8Clear), fundamentalCorner: f(cornerClear),
  260. harmonics, tileDiffraction,
  261. centreClearMm: centreClear, row8ClearMm: row8Clear, cornerClearMm: cornerClear,
  262. };
  263. }
  264. // ─── PRIMITIVES ───────────────────────────────────────────────────────────────
  265. const Lbl = ({ children, sub }) => (
  266. <div style={{ marginBottom: 5 }}>
  267. <label style={{ display: "block", fontSize: 10, letterSpacing: 1.3, textTransform: "uppercase", color: C.muted }}>{children}</label>
  268. {sub && <span style={{ fontSize: 10, color: C.muted, opacity: 0.7 }}>{sub}</span>}
  269. </div>
  270. );
  271. const Field = ({ label, sub, children }) => (
  272. <div style={{ marginBottom: 14 }}>
  273. <Lbl sub={sub}>{label}</Lbl>
  274. {children}
  275. </div>
  276. );
  277. 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" };
  278. const NumIn = ({ value, onChange, min, max, step = 1, unit, style: sx }) => (
  279. <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
  280. <input type="number" value={value} min={min} max={max} step={step} onChange={e => onChange(+e.target.value || 0)} style={{ ...numStyle, ...sx }} />
  281. {unit && <span style={{ fontSize: 11, color: C.muted, whiteSpace: "nowrap" }}>{unit}</span>}
  282. </div>
  283. );
  284. const TxtIn = ({ value, onChange, type = "text", placeholder, style: sx }) => (
  285. <input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
  286. style={{ ...numStyle, ...sx }} />
  287. );
  288. const Btn = ({ onClick, disabled, children, color = C.accent, outline, style: sx }) => (
  289. <button onClick={onClick} disabled={disabled} style={{
  290. background: outline ? "transparent" : disabled ? C.border : color,
  291. color: outline ? color : disabled ? C.muted : C.bg,
  292. border: `1px solid ${outline ? color : disabled ? C.border : color}`,
  293. borderRadius: 7, padding: "9px 18px", fontSize: 12, fontWeight: 700,
  294. letterSpacing: 0.5, cursor: disabled ? "not-allowed" : "pointer",
  295. fontFamily: "inherit", transition: "all 0.15s", ...sx,
  296. }}>{children}</button>
  297. );
  298. const Tag = ({ color = C.accent, children, size = "sm" }) => (
  299. <span style={{ background: color + "20", border: `1px solid ${color}40`, color, borderRadius: 4, padding: size === "sm" ? "2px 8px" : "4px 12px", fontSize: size === "sm" ? 10 : 12, letterSpacing: 0.5, whiteSpace: "nowrap" }}>{children}</span>
  300. );
  301. const Dot = ({ color }) => <span style={{ display: "inline-block", width: 7, height: 7, borderRadius: "50%", background: color, flexShrink: 0 }} />;
  302. const Panel = ({ children, style: sx, accent }) => (
  303. <div style={{ background: C.panel, border: `1px solid ${accent ? accent + "40" : C.border}`, borderRadius: 12, padding: 20, marginBottom: 14, ...sx }}>{children}</div>
  304. );
  305. const SectionHead = ({ children, sub }) => (
  306. <div style={{ marginBottom: 16 }}>
  307. <h2 style={{ margin: 0, color: C.heading, fontSize: 14, fontWeight: 700, letterSpacing: 0.3 }}>{children}</h2>
  308. {sub && <p style={{ margin: "4px 0 0", fontSize: 11, color: C.muted }}>{sub}</p>}
  309. </div>
  310. );
  311. const Divider = () => <div style={{ height: 1, background: C.border, margin: "16px 0" }} />;
  312. // ─── HAAS STATUS HELPERS ──────────────────────────────────────────────────────
  313. const haasColor = (s) => ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[s] || C.muted);
  314. const haasLabel = (s) => ({ primary: "Primary", ok: "OK <10ms", warn: "Warn 10–30ms", danger: "Echo >30ms" }[s] || "—");
  315. // ─── SECTION DIAGRAM ──────────────────────────────────────────────────────────
  316. function SectionDiagram({ hall, speakers, rowData }) {
  317. const W = 640, H = 220, PAD = 40;
  318. const outerR = hall.row1Radius + (hall.rows - 1) * hall.rowPitch;
  319. const totalW = outerR + hall.aisleWidth;
  320. const scaleX = (W - PAD * 2) / totalW;
  321. const maxCeil = ceilHeightAtRadius(hall.row1Radius, hall);
  322. const scaleY = (H - PAD * 2) / maxCeil;
  323. const cx = (r) => PAD + r * scaleX;
  324. const cy = (h) => H - PAD - h * scaleY;
  325. return (
  326. <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", background: "#07090f", borderRadius: 8, border: `1px solid ${C.border}` }}>
  327. {/* flat ceiling */}
  328. {/* Sloped ceiling line */}
  329. {(() => { const cpts = rowData.map(r => `${cx(r.radius)},${cy(r.ceilHeight)}`).join(' '); return <polyline points={`${cx(0)},${cy(ceilHeightAtRadius(0,hall))} ${cpts} ${cx(totalW)},${cy(hall.ceilAtCornerBack)}`} fill="none" stroke={C.borderHi} strokeWidth={2} />; })()}
  330. <text x={cx(hall.row1Radius)+4} y={cy(ceilHeightAtRadius(hall.row1Radius,hall))-4} fill={C.muted} fontSize={9}>Ceiling (sloped {hall.ceilAtRow1Front}→{hall.ceilAtRow8Back}→{hall.ceilAtCornerBack}mm)</text>
  331. {/* dish floor — rises from centre to perimeter */}
  332. <polyline
  333. points={`${cx(0)},${cy(0)} ` + rowData.map(r => `${cx(r.radius)},${cy(r.floorRise)}`).join(" ")}
  334. fill="none" stroke={C.borderHi} strokeWidth={1.5} strokeDasharray="4,3" />
  335. <text x={cx(outerR) + 4} y={cy(hall.dishRise) - 4} fill={C.muted} fontSize={9}>Floor (+{hall.dishRise}mm rise)</text>
  336. {/* rows — vertical line from floor to ear height */}
  337. {rowData.map((r) => (
  338. <g key={r.row}>
  339. <line x1={cx(r.radius)} y1={cy(r.floorRise)} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={C.border} strokeWidth={1} />
  340. <circle cx={cx(r.radius)} cy={cy(r.earAFF)} r={3} fill={C.accent} opacity={0.8} />
  341. <text x={cx(r.radius)} y={cy(r.floorRise) + 12} fill={C.muted} fontSize={8} textAnchor="middle">{r.isCorner ? "C" : r.row}</text>
  342. </g>
  343. ))}
  344. {/* speakers */}
  345. {Object.entries(speakers).map(([ring, spk]) => {
  346. if (!spk.enabled) return null;
  347. const spkH = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod;
  348. const spkY = cy(spkH);
  349. const spkX = cx(spk.radius);
  350. const color = ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent;
  351. return (
  352. <g key={ring}>
  353. <line x1={spkX} y1={cy(ceilHeightAtRadius(spk.radius,hall))} x2={spkX} y2={spkY} stroke={color} strokeWidth={1} strokeDasharray="2,2" opacity={0.5} />
  354. <rect x={spkX - 7} y={spkY - 4} width={14} height={8} fill={color} rx={2} opacity={0.9} />
  355. <text x={spkX} y={spkY - 7} fill={color} fontSize={7} textAnchor="middle">{spk.radius === 0 ? "CTR" : spk.radius >= 10000 ? "CNR" : spk.radius >= 8000 ? "OUT" : "INN"}</text>
  356. {/* rays to each row */}
  357. {rowData.map(r => {
  358. const res = computeCoverage(hall, { [ring]: spk }, [r])[0]?.speakers[ring];
  359. if (!res) return null;
  360. const rayColor = res.inCoverage
  361. ? ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[res.haasStatus] || C.muted)
  362. : "#444";
  363. return <line key={r.row} x1={spkX} y1={spkY} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={rayColor} strokeWidth={0.7} opacity={0.3} />;
  364. })}
  365. </g>
  366. );
  367. })}
  368. {/* legend */}
  369. {[["●", 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) => (
  370. <text key={i} x={PAD + i * 82} y={H - 6} fill={col} fontSize={7}>{sym} {lbl}</text>
  371. ))}
  372. </svg>
  373. );
  374. }
  375. // ─── COVERAGE HEATMAP ────────────────────────────────────────────────────────
  376. function CoverageHeatmap({ coverage, speakers }) {
  377. const rings = Object.entries(speakers).filter(([, v]) => v.enabled).map(([k, v]) => [k, v]);
  378. const cellW = 110, cellH = 52, lblW = 50;
  379. const W = lblW + rings.length * cellW + 10;
  380. const H = 28 + coverage.length * cellH + 10;
  381. return (
  382. <div style={{ overflowX: "auto" }}>
  383. <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", minWidth: W }}>
  384. {/* column headers */}
  385. {rings.map(([ring, spk], ci) => (
  386. <text key={ring} x={lblW + ci * cellW + cellW / 2} y={18} fill={ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent} fontSize={10} textAnchor="middle" fontWeight="700">{spk.label}</text>
  387. ))}
  388. {coverage.map((row, ri) => (
  389. <g key={row.row}>
  390. {/* row label */}
  391. <text x={lblW - 6} y={28 + ri * cellH + cellH / 2 + 4} fill={C.muted} fontSize={10} textAnchor="end">
  392. {row.row === "C" ? "Corner" : `Row ${row.row}`}
  393. </text>
  394. {rings.map(([ring], ci) => {
  395. const d = row.speakers[ring];
  396. if (!d) return null;
  397. const bg = d.inCoverage
  398. ? d.haasStatus === "primary" ? C.goodBg : d.haasStatus === "ok" ? C.accentBg : d.haasStatus === "warn" ? C.goldBg : C.dangerBg
  399. : C.dangerBg;
  400. const borderCol = d.inCoverage
  401. ? haasColor(d.haasStatus)
  402. : C.danger;
  403. return (
  404. <g key={ring}>
  405. <rect x={lblW + ci * cellW + 2} y={28 + ri * cellH + 2} width={cellW - 4} height={cellH - 4} fill={bg} stroke={borderCol} strokeWidth={1} rx={4} opacity={0.9} />
  406. <text x={lblW + ci * cellW + cellW / 2} y={28 + ri * cellH + 17} fill={C.heading} fontSize={9} textAnchor="middle">{d.slantM.toFixed(2)}m · {d.delay.toFixed(1)}ms</text>
  407. <text x={lblW + ci * cellW + cellW / 2} y={28 + ri * cellH + 29} fill={haasColor(d.haasStatus)} fontSize={9} textAnchor="middle">{haasLabel(d.haasStatus)}</text>
  408. <text x={lblW + ci * cellW + cellW / 2} y={28 + ri * cellH + 41} fill={d.inCoverage ? C.text : C.danger} fontSize={9} textAnchor="middle">{d.offAxisDeg}° off-ax · {d.splAtEar.toFixed(1)}dB</text>
  409. </g>
  410. );
  411. })}
  412. </g>
  413. ))}
  414. </svg>
  415. </div>
  416. );
  417. }
  418. // ─── HALL GEOMETRY TAB ────────────────────────────────────────────────────────
  419. function HallTab({ hall, setHall }) {
  420. const rowData = computeHallGeometry(hall);
  421. const flutter = computeFlutter(hall);
  422. const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch;
  423. return (
  424. <div>
  425. <Panel>
  426. <SectionHead sub="22.2m × 22.2m square — adjust per hall">Hall Dimensions</SectionHead>
  427. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
  428. <Field label="Hall Width" sub="square plan"><NumIn value={hall.hallWidth} onChange={v => setHall(h => ({ ...h, hallWidth: v }))} unit="mm" /></Field>
  429. <Field label="Number of Rows"><NumIn value={hall.rows} onChange={v => setHall(h => ({ ...h, rows: Math.max(1, v) }))} min={1} max={12} /></Field>
  430. <Field label="Row 1 Radius" sub="centre to row 1 inner edge"><NumIn value={hall.row1Radius} onChange={v => setHall(h => ({ ...h, row1Radius: v }))} unit="mm" /></Field>
  431. <Field label="Row Pitch" sub="FFL to FFL"><NumIn value={hall.rowPitch} onChange={v => setHall(h => ({ ...h, rowPitch: v }))} unit="mm" /></Field>
  432. <Field label="Corner Aisle Width" sub="aisle before corner rows"><NumIn value={hall.aisleWidth} onChange={v => setHall(h => ({ ...h, aisleWidth: v }))} unit="mm" /></Field>
  433. <Field label="Ear Height AFF" sub="seated listener"><NumIn value={hall.earHeight} onChange={v => setHall(h => ({ ...h, earHeight: v }))} unit="mm" /></Field>
  434. </div>
  435. <Divider />
  436. <SectionHead sub="Ceiling slopes down toward perimeter · floor dishes upward · 1-in-8 slope">Ceiling & Dish Floor</SectionHead>
  437. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
  438. <Field label="Ceiling at Row 1 Front" sub="highest point — r=2210mm">
  439. <NumIn value={hall.ceilAtRow1Front} onChange={v => setHall(h => ({ ...h, ceilAtRow1Front: v }))} unit="mm" />
  440. </Field>
  441. <Field label="Ceiling at Row 8 Back" sub="r=8411mm · aisle level">
  442. <NumIn value={hall.ceilAtRow8Back} onChange={v => setHall(h => ({ ...h, ceilAtRow8Back: v }))} unit="mm" />
  443. </Field>
  444. <Field label="Ceiling at Corner Back" sub="behind last corner row">
  445. <NumIn value={hall.ceilAtCornerBack} onChange={v => setHall(h => ({ ...h, ceilAtCornerBack: v }))} unit="mm" />
  446. </Field>
  447. <Field label="Floor Dish Rise" sub="centre=0 → row 8 back">
  448. <NumIn value={hall.dishRise} onChange={v => setHall(h => ({ ...h, dishRise: v }))} unit="mm" />
  449. </Field>
  450. <Field label="Corner Rows" sub="rows in each corner">
  451. <NumIn value={hall.cornerRows} onChange={v => setHall(h => ({ ...h, cornerRows: Math.max(1,v) }))} min={1} max={8} />
  452. </Field>
  453. <Field label="Corner Row 1 Offset" sub="from aisle back wall inward">
  454. <NumIn value={hall.cornerFrontOffset} onChange={v => setHall(h => ({ ...h, cornerFrontOffset: v }))} unit="mm" />
  455. </Field>
  456. </div>
  457. <div style={{ padding: "10px 14px", background: C.bgAlt, borderRadius: 8, display: "flex", gap: 20, flexWrap: "wrap", fontSize: 11, color: C.muted }}>
  458. <span>Overall plan: <strong style={{ color: C.heading }}>{(hall.hallWidth / 1000).toFixed(3)}m sq</strong></span>
  459. <span>Half-width: <strong style={{ color: C.heading }}>{(hall.hallWidth / 2).toLocaleString()}mm</strong></span>
  460. <span>Tile grid: <strong style={{ color: C.heading }}>{hall.tileGrid}mm</strong></span>
  461. <span>Dish slope: <strong style={{ color: C.gold }}>1:{Math.round(hall.row8Back / hall.dishRise)}</strong></span>
  462. <span>Rise/row ≈ <strong style={{ color: C.heading }}>{Math.round(hall.dishRise / hall.rows)}mm</strong></span>
  463. <span>Ceil @ row 1: <strong style={{ color: C.accent }}>{hall.ceilAtRow1Front}mm</strong></span>
  464. <span>Ceil @ row 8: <strong style={{ color: C.accent }}>{hall.ceilAtRow8Back}mm</strong></span>
  465. <span>Ceil @ corner: <strong style={{ color: (hall.ceilAtCornerBack - hall.earHeight - hall.dishRise) < 1200 ? C.warn : C.accent }}>{hall.ceilAtCornerBack}mm</strong></span>
  466. </div>
  467. </Panel>
  468. <Panel>
  469. <SectionHead>Section Diagram</SectionHead>
  470. <SectionDiagram hall={hall} speakers={SPEAKER_DEFAULTS} rowData={rowData} />
  471. </Panel>
  472. <Panel>
  473. <SectionHead sub="Flat ceiling at 4300mm — ear-to-ceiling decreases as floor rises toward perimeter">Row Geometry Table</SectionHead>
  474. <div style={{ overflowX: "auto" }}>
  475. <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11 }}>
  476. <thead>
  477. <tr>{["Row", "Front Edge", "Ear Radius", "Floor Rise", "Ceiling", "Ear AFF", "Ear→Ceiling"].map(h => (
  478. <th key={h} style={{ padding: "6px 10px", textAlign: "left", color: C.muted, fontWeight: 600, borderBottom: `1px solid ${C.border}`, letterSpacing: 0.5, whiteSpace: "nowrap" }}>{h}</th>
  479. ))}</tr>
  480. </thead>
  481. <tbody>
  482. {rowData.map((r, i) => (
  483. <tr key={r.row} style={{ background: i % 2 === 0 ? C.panelAlt : "transparent" }}>
  484. {[
  485. r.isCorner ? "Corner" : `Row ${r.row}`,
  486. r.frontEdge ? `${r.frontEdge.toLocaleString()}mm` : "—",
  487. `${r.radius.toLocaleString()}mm`,
  488. `+${r.floorRise}mm`,
  489. `${r.ceilHeight.toLocaleString()}mm`,
  490. `${r.earAFF.toLocaleString()}mm`,
  491. `${r.earToCell.toLocaleString()}mm`,
  492. ].map((v, j) => (
  493. <td key={j} style={{ padding: "6px 10px", color: j === 0 ? C.gold : j === 5 ? (r.earToCell < 1500 ? C.warn : C.good) : C.text, fontFamily: j > 0 ? "monospace" : "inherit" }}>{v}</td>
  494. ))}
  495. </tr>
  496. ))}
  497. </tbody>
  498. </table>
  499. </div>
  500. </Panel>
  501. <Panel accent={C.warn}>
  502. <SectionHead sub="Flat ceiling + dished floor — flutter risk increases toward perimeter">Flutter Echo Analysis</SectionHead>
  503. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14, marginBottom: 14 }}>
  504. {[
  505. ["Row 1 ear→ceiling", `${flutter.centreClearMm.toLocaleString()}mm`, C.text],
  506. ["Centre flutter fund.", `${flutter.fundamentalCentre} Hz`, C.warn],
  507. ["Row 8 flutter fund.", `${flutter.fundamentalRow8} Hz`, C.danger],
  508. ].map(([lbl, val, col]) => (
  509. <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "12px 14px" }}>
  510. <div style={{ fontSize: 10, color: C.muted, marginBottom: 4, letterSpacing: 1, textTransform: "uppercase" }}>{lbl}</div>
  511. <div style={{ fontSize: 20, fontWeight: 700, color: col, fontFamily: "monospace" }}>{val}</div>
  512. </div>
  513. ))}
  514. </div>
  515. <div style={{ fontSize: 11, color: C.muted, lineHeight: 1.7 }}>
  516. Centre harmonics: {flutter.harmonics.map((f, i) => <Tag key={i} color={i === 0 ? C.warn : i < 3 ? C.gold : C.muted} size="sm">{f}Hz</Tag>).reduce((a, e) => [...a, " ", e], [])}<br />
  517. <span style={{ color: C.warn }}>⚠ 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).</span>
  518. </div>
  519. </Panel>
  520. </div>
  521. );
  522. }
  523. // ─── SPEAKER LAYOUT TAB ──────────────────────────────────────────────────────
  524. function SpeakerTab({ hall, speakers, setSpeakers }) {
  525. const rowData = computeHallGeometry(hall);
  526. const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch;
  527. const RingEditor = ({ ringKey, color }) => {
  528. const spk = speakers[ringKey];
  529. const set = (k, v) => setSpeakers(s => ({ ...s, [ringKey]: { ...s[ringKey], [k]: v } }));
  530. const spkHeightAFF = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod;
  531. return (
  532. <Panel accent={color} style={{ marginBottom: 10 }}>
  533. <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 14 }}>
  534. <div style={{ width: 10, height: 10, borderRadius: "50%", background: color }} />
  535. <h3 style={{ margin: 0, color: C.heading, fontSize: 14 }}>{spk.label}</h3>
  536. <label style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: C.muted, cursor: "pointer" }}>
  537. <input type="checkbox" checked={spk.enabled} onChange={e => set("enabled", e.target.checked)} style={{ accentColor: color }} />
  538. Enabled
  539. </label>
  540. </div>
  541. {spk.enabled && (
  542. <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
  543. <Field label="Count"><NumIn value={spk.count} onChange={v => set("count", v)} min={1} max={16} /></Field>
  544. <Field label="Radius from Centre" sub="plan position"><NumIn value={spk.radius} onChange={v => set("radius", v)} unit="mm" /></Field>
  545. <Field label="Drop Rod Length" sub="0 = flush with ceiling"><NumIn value={spk.dropRod} onChange={v => set("dropRod", Math.max(0, v))} unit="mm" /></Field>
  546. <Field label="Power per speaker"><NumIn value={spk.power} onChange={v => set("power", v)} step={0.5} unit="W" /></Field>
  547. <Field label="Sensitivity" sub="dB/1W/1m"><NumIn value={spk.sensitivity} onChange={v => set("sensitivity", v)} unit="dB" /></Field>
  548. <Field label="Phase">
  549. <select value={spk.phase} onChange={e => set("phase", +e.target.value)}
  550. style={{ ...numStyle }}>
  551. <option value={1}>Normal (+)</option>
  552. <option value={-1}>Anti-phase (−)</option>
  553. </select>
  554. </Field>
  555. <Field label="Speaker Acoustic Centre" sub={spk.dropRod === 0 ? "Flush ceiling ✓" : `${spk.dropRod}mm drop rod`}>
  556. <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.gold, fontFamily: "monospace" }}>
  557. {spkHeightAFF.toLocaleString()} mm
  558. </div>
  559. </Field>
  560. <Field label="Radial position" sub="nearest row">
  561. <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 12, color: C.text }}>
  562. {(() => {
  563. const nearest = rowData.reduce((best, r) => Math.abs(r.radius - spk.radius) < Math.abs(best.radius - spk.radius) ? r : best, rowData[0]);
  564. return `Row ${nearest.row} (r=${nearest.radius}mm)`;
  565. })()}
  566. </div>
  567. </Field>
  568. <Field label="Total power" sub="ring total">
  569. <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.text, fontFamily: "monospace" }}>
  570. {(spk.power * spk.count).toFixed(1)} W
  571. </div>
  572. </Field>
  573. </div>
  574. )}
  575. </Panel>
  576. );
  577. };
  578. const totalPower = Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.power * s.count, 0);
  579. return (
  580. <div>
  581. <Panel style={{ marginBottom: 10 }}>
  582. <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
  583. {[
  584. ["Total speakers", Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.count, 0) + " (flush)", C.accent],
  585. ["Total power", `${totalPower.toFixed(1)}W`, C.gold],
  586. ["Crossover", "MS6 / MR12 · 3kHz gap", C.good],
  587. ["Gap", "433Hz @ 3kHz", C.purple],
  588. ].map(([lbl, val, col]) => (
  589. <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "10px 16px", flex: 1, minWidth: 120 }}>
  590. <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: "uppercase", marginBottom: 3 }}>{lbl}</div>
  591. <div style={{ fontSize: 16, fontWeight: 700, color: col }}>{val}</div>
  592. </div>
  593. ))}
  594. </div>
  595. </Panel>
  596. <RingEditor ringKey="centre" color={C.gold} />
  597. <RingEditor ringKey="inner" color={C.good} />
  598. <RingEditor ringKey="outer" color={C.purple} />
  599. <RingEditor ringKey="corner" color={C.accent} />
  600. </div>
  601. );
  602. }
  603. // ─── COVERAGE TAB ────────────────────────────────────────────────────────────
  604. function CoverageTab({ hall, speakers }) {
  605. const rowData = computeHallGeometry(hall);
  606. const coverage = computeCoverage(hall, speakers, rowData);
  607. // Summary stats
  608. const allCells = coverage.flatMap(r => Object.values(r.speakers).filter(Boolean));
  609. const issues = allCells.filter(c => !c.inCoverage || c.haasStatus === "danger");
  610. const warnings = allCells.filter(c => c.haasStatus === "warn");
  611. return (
  612. <div>
  613. <Panel>
  614. <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 4 }}>
  615. {[
  616. ["Coverage cells", allCells.length, C.accent],
  617. ["Issues", issues.length, issues.length > 0 ? C.danger : C.good],
  618. ["Warnings", warnings.length, warnings.length > 0 ? C.gold : C.good],
  619. ["Haas limit", "30ms", C.text],
  620. ["MS6 max off-axis", "55°", C.text],
  621. ].map(([lbl, val, col]) => (
  622. <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "10px 14px", flex: 1, minWidth: 100 }}>
  623. <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: "uppercase", marginBottom: 3 }}>{lbl}</div>
  624. <div style={{ fontSize: 18, fontWeight: 700, color: col }}>{val}</div>
  625. </div>
  626. ))}
  627. </div>
  628. <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
  629. {[["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]) => (
  630. <Tag key={lbl} color={col}>{lbl}</Tag>
  631. ))}
  632. </div>
  633. </Panel>
  634. <Panel>
  635. <SectionHead sub="Slant distance · propagation delay · Haas status · off-axis angle · SPL at ear">Coverage Heatmap — All Rows × All Speaker Rings</SectionHead>
  636. <CoverageHeatmap coverage={coverage} speakers={speakers} />
  637. </Panel>
  638. <Panel>
  639. <SectionHead sub="Full data table">Per-Row Coverage Detail</SectionHead>
  640. <div style={{ overflowX: "auto" }}>
  641. <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11 }}>
  642. <thead>
  643. <tr>
  644. {["Row", "Radius", "Ring", "Slant (m)", "Delay (ms)", "Haas +Δms", "Off-axis °", "SPL (dB)", "In cone", "Status"].map(h => (
  645. <th key={h} style={{ padding: "6px 8px", textAlign: "left", color: C.muted, fontWeight: 600, borderBottom: `1px solid ${C.border}`, fontSize: 10, letterSpacing: 0.5, whiteSpace: "nowrap" }}>{h}</th>
  646. ))}
  647. </tr>
  648. </thead>
  649. <tbody>
  650. {coverage.flatMap((row, ri) =>
  651. Object.entries(row.speakers).filter(([, d]) => d).map(([ring, d], si) => (
  652. <tr key={`${ri}-${si}`} style={{ background: ri % 2 === 0 && si === 0 ? C.panelAlt : "transparent" }}>
  653. <td style={{ padding: "5px 8px", color: C.gold }}>{row.row === "C" ? "Corner" : `Row ${row.row}`}</td>
  654. <td style={{ padding: "5px 8px", color: C.muted, fontFamily: "monospace" }}>{(row.radius / 1000).toFixed(3)}m</td>
  655. <td style={{ padding: "5px 8px", color: ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent }}>{speakers[ring].label}</td>
  656. <td style={{ padding: "5px 8px", fontFamily: "monospace" }}>{d.slantM.toFixed(3)}</td>
  657. <td style={{ padding: "5px 8px", fontFamily: "monospace", color: d.delay > 30 ? C.danger : C.text }}>{d.delay.toFixed(2)}</td>
  658. <td style={{ padding: "5px 8px", fontFamily: "monospace", color: d.haasExcess > 30 ? C.danger : d.haasExcess > 10 ? C.gold : C.text }}>{d.haasExcess > 0 ? `+${d.haasExcess.toFixed(2)}` : "—"}</td>
  659. <td style={{ padding: "5px 8px", fontFamily: "monospace", color: d.offAxisDeg > 55 ? C.danger : C.text }}>{d.offAxisDeg}°</td>
  660. <td style={{ padding: "5px 8px", fontFamily: "monospace" }}>{d.splAtEar}</td>
  661. <td style={{ padding: "5px 8px" }}><Dot color={d.inCoverage ? C.good : C.danger} /></td>
  662. <td style={{ padding: "5px 8px" }}><Tag color={haasColor(d.haasStatus)}>{haasLabel(d.haasStatus)}</Tag></td>
  663. </tr>
  664. ))
  665. )}
  666. </tbody>
  667. </table>
  668. </div>
  669. </Panel>
  670. </div>
  671. );
  672. }
  673. // ─── REW & ANALYSIS TAB ───────────────────────────────────────────────────────
  674. function AnalysisTab({ hall, speakers, settings, addLog }) {
  675. const [rewStatus, setRewStatus] = useState("idle");
  676. const [rewData, setRewData] = useState(null);
  677. const [analysisState, setAnalysisState] = useState("idle");
  678. const [streamText, setStreamText] = useState("");
  679. const [analysis, setAnalysis] = useState(null);
  680. const rewBase = `${settings.rewHost}:${settings.rewPort}`;
  681. const rowData = computeHallGeometry(hall);
  682. const coverage = computeCoverage(hall, speakers, rowData);
  683. const flutter = computeFlutter(hall);
  684. async function connectREW() {
  685. setRewStatus("checking");
  686. addLog(`Connecting to REW at ${rewBase}…`);
  687. try {
  688. const r = await fetch(`${rewBase}/application`);
  689. if (!r.ok) throw new Error(`HTTP ${r.status}`);
  690. addLog("REW API connected ✓", "good");
  691. // Push room size
  692. await fetch(`${rewBase}/roomsim/room-size`, {
  693. method: "PUT", headers: { "Content-Type": "application/json" },
  694. body: JSON.stringify({ length: hall.hallWidth / 1000, width: hall.hallWidth / 1000, height: hall.ceilAtRow1Front / 1000 }),
  695. });
  696. addLog("Room dimensions pushed to REW ✓", "good");
  697. // Fetch frequency response
  698. const freqResp = await fetch(`${rewBase}/roomsim/frequency-response?micposition=Main&ppo=24`).then(r => r.json());
  699. addLog("Frequency response retrieved ✓", "good");
  700. const magnitudes = (() => {
  701. try {
  702. const bin = atob(freqResp.magnitudes || "");
  703. const buf = new Uint8Array(bin.length);
  704. for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
  705. const view = new DataView(buf.buffer);
  706. const out = [];
  707. for (let i = 0; i < buf.length; i += 4) out.push(view.getFloat32(i, false));
  708. return out;
  709. } catch { return []; }
  710. })();
  711. const startFreq = freqResp.startFreq || 20, ppo = freqResp.pointsPerOctave || 24;
  712. const freqPoints = magnitudes.map((mag, i) => ({ freq: Math.round(startFreq * Math.pow(2, i / ppo)), spl: Math.round(mag * 10) / 10 }));
  713. const bandData = [63, 125, 250, 500, 1000, 2000, 4000, 8000].map(f => {
  714. const near = freqPoints.reduce((b, p) => Math.abs(p.freq - f) < Math.abs(b.freq - f) ? p : b, freqPoints[0] || { freq: f, spl: 0 });
  715. return { freq: f, spl: near?.spl ?? 0 };
  716. });
  717. setRewData({ bandData, freqPoints: freqPoints.filter((_, i) => i % 4 === 0).slice(0, 80) });
  718. setRewStatus("connected");
  719. addLog("REW data ready ✓", "good");
  720. } catch (e) {
  721. setRewStatus("error");
  722. addLog(`REW error: ${e.message}`, "error");
  723. }
  724. }
  725. function buildPrompt() {
  726. const outerRadius = hall.row1Radius + (hall.rows - 1) * hall.rowPitch;
  727. const flutter = computeFlutter(hall);
  728. return `${settings.systemPrompt}
  729. ## Hall Specification (SK-303 Version B confirmed)
  730. - Seating plan: ${hall.hallWidth}mm × ${hall.hallWidth}mm (${(hall.hallWidth/1000).toFixed(3)}m sq)
  731. - Half-width centre to back wall: ${hall.hallWidth/2}mm
  732. - 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
  733. - Rows: ${hall.rows} circular rows + corner rows
  734. - Row 1 front edge: ${hall.row1Radius}mm from centre
  735. - Row pitch (FFL–FFL): ${hall.rowPitch}mm
  736. - Row 8 back face: ${hall.row8Back}mm from centre (per drawing)
  737. - Back aisle: ${hall.aisleWidth}mm (${hall.hallWidth/2} − ${hall.row8Back} = ${hall.hallWidth/2 - hall.row8Back}mm ✓)
  738. ## Dish Floor Geometry
  739. - Centre floor: 0mm (lowest) | Row 8 floor: +${hall.dishRise}mm rise | Slope: 1:${Math.round(outerRadius / hall.dishRise)}
  740. - 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))})
  741. - As floor rises toward perimeter, each row's seated ear moves closer to the flat ceiling
  742. ## Speaker System — MS6 (Visaton FRS8M + G20SC, MIDAS MR12 active crossover)
  743. - All speakers FLUSH mounted in sloped ceiling (no drop rods) — height varies by position
  744. - Crossover gap: 2792–3225Hz (433Hz gap, zero driver overlap — eliminates horizontal lobes)
  745. - Max coverage: 55° off-axis (-3dB), near-circular polar response
  746. - Sensitivity: 88dB/1W/1m
  747. ${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")}
  748. - Total installed power: ${Object.values(speakers).filter(v=>v.enabled).reduce((s,v)=>s+v.power*v.count,0).toFixed(1)}W
  749. ## Coverage Analysis (per row)
  750. ${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")}
  751. ## Flutter Echo Risk (flat ceiling + rising floor)
  752. - Centre: ${flutter.centreClearMm}mm clearance → fundamental ${flutter.fundamentalCentre}Hz, harmonics: ${flutter.harmonics.join(", ")}Hz
  753. - Row 8: ${flutter.row8ClearMm}mm clearance → fundamental ${flutter.fundamentalRow8}Hz
  754. - 600mm tile grid → diffraction grating at ~571Hz
  755. ${rewData ? `## REW Frequency Response\n${rewData.bandData.map(b=>`- ${b.freq}Hz: ${b.spl}dB`).join("\n")}` : "## REW: Not connected (geometry analysis only)"}
  756. ---
  757. Provide structured analysis:
  758. ### 🏛 Hall Assessment
  759. Speech intelligibility assessment for 360° circular seating with dished floor.
  760. ### ⚠️ Coverage Issues
  761. Rows outside 55° MS6 cone, Haas violations (>10ms excess = colouration, >30ms = echo), SPL variation across rows.
  762. ### 🔊 Speaker Position Optimisation
  763. Recommended ring radii and drop rod lengths. Note: ceiling tile grid constrains positions to 600mm increments.
  764. ### ⏱ Delay & Haas Analysis
  765. Identify which speaker combinations cause Haas window problems per row. Recommend DSP delay compensation values in ms.
  766. ### 🔇 Anti-Phasing
  767. Assess viability given 8+8 speaker count and MS6 system. Reference Dudley Wilkin's 2010 analysis.
  768. ### 🎛 MR12 Acoustic EQ
  769. Channel 1 TEQ/PEQ recommendations given hall geometry, including flutter frequencies to notch.
  770. ### 📐 REW / ARTA Measurement Protocol
  771. 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.`;
  772. }
  773. async function runAnalysis() {
  774. setAnalysisState("loading"); setAnalysis(null); setStreamText("");
  775. const prompt = buildPrompt();
  776. const { provider, ollamaHost, ollamaPort, ollamaModel, anthropicKey, anthropicModel, maxTokens, temperature } = settings;
  777. if (provider === "ollama") {
  778. const base = `${ollamaHost}:${ollamaPort}`;
  779. addLog(`Sending to Ollama (${ollamaModel})…`);
  780. try {
  781. const r = await fetch(`${base}/api/chat`, {
  782. method: "POST", headers: { "Content-Type": "application/json" },
  783. body: JSON.stringify({ model: ollamaModel, stream: true, options: { temperature, num_predict: maxTokens }, messages: [{ role: "user", content: prompt }] }),
  784. });
  785. if (!r.ok) throw new Error(`Ollama ${r.status}`);
  786. const reader = r.body.getReader(); const dec = new TextDecoder(); let full = "";
  787. setAnalysisState("streaming");
  788. while (true) {
  789. const { done, value } = await reader.read(); if (done) break;
  790. for (const line of dec.decode(value).split("\n").filter(Boolean)) {
  791. try { const obj = JSON.parse(line); full += obj.message?.content || obj.response || ""; setStreamText(full); } catch { }
  792. }
  793. }
  794. setAnalysis(full); setAnalysisState("done"); addLog("Analysis complete ✓", "good");
  795. } catch (e) { setAnalysisState("error"); addLog(`Ollama error: ${e.message}`, "error"); }
  796. } else {
  797. if (!anthropicKey) { addLog("No API key — check Settings", "error"); setAnalysisState("error"); return; }
  798. addLog(`Sending to Claude (${anthropicModel})…`);
  799. try {
  800. const r = await fetch("https://api.anthropic.com/v1/messages", {
  801. method: "POST", headers: { "Content-Type": "application/json" },
  802. body: JSON.stringify({ model: anthropicModel, max_tokens: maxTokens, messages: [{ role: "user", content: prompt }] }),
  803. });
  804. const data = await r.json();
  805. if (data.error) throw new Error(data.error.message);
  806. const text = data.content?.filter(b => b.type === "text").map(b => b.text).join("") || "";
  807. setAnalysis(text); setAnalysisState("done"); addLog("Analysis complete ✓", "good");
  808. } catch (e) { setAnalysisState("error"); addLog(`Claude error: ${e.message}`, "error"); }
  809. }
  810. }
  811. function renderMD(text) {
  812. return text.split("\n").map((line, i) => {
  813. if (line.startsWith("### ")) return <h3 key={i} style={{ color: C.accent, fontSize: 13, margin: "18px 0 6px", borderBottom: `1px solid ${C.border}`, paddingBottom: 4 }}>{line.slice(4)}</h3>;
  814. if (line.startsWith("## ")) return <h2 key={i} style={{ color: C.heading, fontSize: 15, margin: "20px 0 7px" }}>{line.slice(3)}</h2>;
  815. if (/^\d+\.\s\*\*/.test(line)) { const m = line.match(/^(\d+)\.\s\*\*(.+?)\*\*(.*)$/); if (m) return <div key={i} style={{ display: "flex", gap: 8, margin: "5px 0" }}><span style={{ color: C.accent, fontWeight: 700, minWidth: 16 }}>{m[1]}.</span><div><strong style={{ color: C.heading }}>{m[2]}</strong><span style={{ color: C.text }}>{m[3]}</span></div></div>; }
  816. if (line.startsWith("- ")) return <div key={i} style={{ display: "flex", gap: 8, margin: "3px 0" }}><span style={{ color: C.accentDim }}>▸</span><span style={{ color: C.text, fontSize: 13 }}>{line.slice(2)}</span></div>;
  817. if (!line.trim()) return <div key={i} style={{ height: 6 }} />;
  818. const parts = line.split(/(\*\*[^*]+\*\*)/g);
  819. return <p key={i} style={{ color: C.text, margin: "3px 0", lineHeight: 1.65, fontSize: 13 }}>{parts.map((p, j) => p.startsWith("**") ? <strong key={j} style={{ color: C.heading }}>{p.slice(2, -2)}</strong> : p)}</p>;
  820. });
  821. }
  822. const displayText = (analysisState === "streaming" ? streamText : analysis) || "";
  823. const providerLabel = settings.provider === "ollama" ? `🦙 ${settings.ollamaModel || "Ollama"}` : `✦ Claude`;
  824. return (
  825. <div>
  826. <Panel>
  827. <SectionHead sub="Optional — connect to REW for measured frequency data">REW Room Simulator</SectionHead>
  828. <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
  829. <Btn onClick={connectREW} disabled={rewStatus === "checking"} color={C.accentDim}>
  830. {rewStatus === "checking" ? "Connecting…" : "Connect to REW"}
  831. </Btn>
  832. {rewStatus !== "idle" && (
  833. <Tag color={rewStatus === "connected" ? C.good : rewStatus === "error" ? C.danger : C.accent}>
  834. {rewStatus === "connected" ? "REW connected ✓" : rewStatus === "error" ? "Connection failed" : "Connecting…"}
  835. </Tag>
  836. )}
  837. <span style={{ fontSize: 11, color: C.muted }}>REW at {rewBase} — configure in ⚙ Settings</span>
  838. </div>
  839. {rewData && (
  840. <div style={{ marginTop: 14 }}>
  841. <div style={{ fontSize: 11, color: C.muted, marginBottom: 8 }}>Octave band response from REW simulator:</div>
  842. <svg width="100%" viewBox="0 0 500 80" style={{ display: "block" }}>
  843. {rewData.bandData.map((b, i) => {
  844. const vals = rewData.bandData.map(d => d.spl);
  845. const mn = Math.min(...vals) - 3, mx = Math.max(...vals) + 3;
  846. const x = 30 + i * (440 / (rewData.bandData.length - 1));
  847. const y = 70 - ((b.spl - mn) / (mx - mn)) * 55;
  848. return <g key={i}><circle cx={x} cy={y} r={3} fill={C.accent} /><text x={x} y={78} textAnchor="middle" fontSize={8} fill={C.muted}>{b.freq >= 1000 ? `${b.freq / 1000}k` : b.freq}</text></g>;
  849. })}
  850. <polyline points={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 `${x},${y}`; }).join(" ")} fill="none" stroke={C.accent} strokeWidth={2} />
  851. </svg>
  852. </div>
  853. )}
  854. </Panel>
  855. <Btn onClick={runAnalysis} disabled={analysisState === "loading" || analysisState === "streaming"}
  856. color={C.good} style={{ width: "100%", padding: 14, fontSize: 14, marginBottom: 14 }}>
  857. {(analysisState === "loading" || analysisState === "streaming") ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`}
  858. </Btn>
  859. {analysisState === "loading" && (
  860. <Panel><div style={{ textAlign: "center", padding: 30, color: C.muted }}>Sending geometry and coverage data to {providerLabel}…</div></Panel>
  861. )}
  862. {(analysisState === "streaming" || analysisState === "done") && displayText && (
  863. <Panel>
  864. <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
  865. <SectionHead>Acoustic Analysis Report</SectionHead>
  866. <div style={{ display: "flex", gap: 8 }}>
  867. {analysisState === "streaming" && <Tag color={C.accent}>● Streaming</Tag>}
  868. <Tag color={settings.provider === "ollama" ? C.purple : C.accent}>{providerLabel}</Tag>
  869. </div>
  870. </div>
  871. <div>{renderMD(displayText)}</div>
  872. </Panel>
  873. )}
  874. {analysisState === "error" && (
  875. <Panel accent={C.danger}><div style={{ color: C.danger }}>⚠ Analysis failed — check Log tab</div></Panel>
  876. )}
  877. </div>
  878. );
  879. }
  880. // ─── SETTINGS TAB ────────────────────────────────────────────────────────────
  881. function SettingsTab({ settings, setSettings, addLog }) {
  882. const [ollamaModels, setOllamaModels] = useState([]);
  883. const [ollamaStatus, setOllamaStatus] = useState("idle");
  884. const [rewTestStatus, setRewTestStatus] = useState("idle");
  885. const [flash, setFlash] = useState(false);
  886. const set = (k, v) => setSettings(p => ({ ...p, [k]: v }));
  887. const selectStyle = { ...numStyle };
  888. async function fetchModels() {
  889. setOllamaStatus("fetching");
  890. try {
  891. const r = await fetch(`${settings.ollamaHost}:${settings.ollamaPort}/api/tags`);
  892. const data = await r.json();
  893. const models = (data.models || []).map(m => m.name);
  894. setOllamaModels(models);
  895. setOllamaStatus("ok");
  896. if (models.length > 0 && !settings.ollamaModel) set("ollamaModel", models[0]);
  897. addLog(`Ollama: ${models.join(", ")}`, "good");
  898. } catch (e) { setOllamaStatus("error"); addLog(`Ollama: ${e.message}`, "error"); }
  899. }
  900. async function testREW() {
  901. setRewTestStatus("checking");
  902. try {
  903. await fetch(`${settings.rewHost}:${settings.rewPort}/application`);
  904. setRewTestStatus("connected"); addLog("REW reachable ✓", "good");
  905. } catch (e) { setRewTestStatus("error"); addLog(`REW: ${e.message}`, "error"); }
  906. }
  907. const ProvBtn = ({ id, icon, title, sub }) => (
  908. <button onClick={() => set("provider", id)} style={{
  909. flex: 1, padding: "14px 10px", borderRadius: 10, cursor: "pointer", fontFamily: "inherit",
  910. border: `2px solid ${settings.provider === id ? C.accent : C.border}`,
  911. background: settings.provider === id ? C.accentBg : C.panelAlt,
  912. color: settings.provider === id ? C.accent : C.muted, textAlign: "center", transition: "all 0.15s",
  913. }}>
  914. <div style={{ fontSize: 20, marginBottom: 4 }}>{icon}</div>
  915. <div style={{ fontWeight: 700, fontSize: 13 }}>{title}</div>
  916. <div style={{ fontSize: 10, opacity: 0.7, marginTop: 2 }}>{sub}</div>
  917. </button>
  918. );
  919. return (
  920. <div>
  921. <Panel>
  922. <SectionHead>LLM Provider</SectionHead>
  923. <div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
  924. <ProvBtn id="ollama" icon="🦙" title="Ollama" sub="Local / Private" />
  925. <ProvBtn id="anthropic" icon="✦" title="Anthropic" sub="Claude API" />
  926. </div>
  927. {settings.provider === "ollama" && <>
  928. <div style={{ display: "grid", gridTemplateColumns: "1fr 100px", gap: 10, marginBottom: 12 }}>
  929. <Field label="Ollama Host"><TxtIn value={settings.ollamaHost} onChange={v => set("ollamaHost", v)} placeholder="http://127.0.0.1" /></Field>
  930. <Field label="Port"><TxtIn value={settings.ollamaPort} onChange={v => set("ollamaPort", v)} /></Field>
  931. </div>
  932. <Field label="Model">
  933. <div style={{ display: "flex", gap: 10 }}>
  934. {ollamaModels.length > 0
  935. ? <select value={settings.ollamaModel} onChange={e => set("ollamaModel", e.target.value)} style={selectStyle}>{ollamaModels.map(m => <option key={m} value={m}>{m}</option>)}</select>
  936. : <TxtIn value={settings.ollamaModel} onChange={v => set("ollamaModel", v)} placeholder="e.g. llama3.2, mistral, qwen2.5…" />}
  937. <Btn onClick={fetchModels} disabled={ollamaStatus === "fetching"} color={C.accentDim}>
  938. {ollamaStatus === "fetching" ? "…" : "Fetch"}
  939. </Btn>
  940. </div>
  941. </Field>
  942. {ollamaStatus !== "idle" && <div style={{ fontSize: 11, color: ollamaStatus === "ok" ? C.good : ollamaStatus === "error" ? C.danger : C.accent, marginBottom: 8 }}>
  943. {ollamaStatus === "ok" ? `✓ ${ollamaModels.length} model(s)` : ollamaStatus === "error" ? "Cannot reach Ollama — OLLAMA_ORIGINS=* ollama serve" : "Fetching…"}
  944. </div>}
  945. </>}
  946. {settings.provider === "anthropic" && <>
  947. <Field label="API Key"><TxtIn value={settings.anthropicKey} onChange={v => set("anthropicKey", v)} type="password" placeholder="sk-ant-api03-…" /></Field>
  948. <Field label="Model">
  949. <select value={settings.anthropicModel} onChange={e => set("anthropicModel", e.target.value)} style={selectStyle}>
  950. {["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001"].map(m => <option key={m} value={m}>{m}</option>)}
  951. </select>
  952. </Field>
  953. </>}
  954. </Panel>
  955. <Panel>
  956. <SectionHead>REW API</SectionHead>
  957. <div style={{ display: "grid", gridTemplateColumns: "1fr 100px", gap: 10, marginBottom: 10 }}>
  958. <Field label="REW Host"><TxtIn value={settings.rewHost} onChange={v => set("rewHost", v)} /></Field>
  959. <Field label="Port"><TxtIn value={settings.rewPort} onChange={v => set("rewPort", v)} /></Field>
  960. </div>
  961. <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
  962. <Btn onClick={testREW} disabled={rewTestStatus === "checking"} color={C.accentDim}>Test Connection</Btn>
  963. {rewTestStatus !== "idle" && <Tag color={rewTestStatus === "connected" ? C.good : rewTestStatus === "error" ? C.danger : C.accent}>
  964. {rewTestStatus === "connected" ? "REW reachable ✓" : rewTestStatus === "error" ? "Failed" : "Testing…"}
  965. </Tag>}
  966. </div>
  967. </Panel>
  968. <Panel>
  969. <SectionHead>LLM Behaviour</SectionHead>
  970. <Field label="System Prompt">
  971. <textarea value={settings.systemPrompt} onChange={e => set("systemPrompt", e.target.value)} rows={5}
  972. style={{ ...numStyle, resize: "vertical", lineHeight: 1.6, fontSize: 12 }} />
  973. </Field>
  974. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
  975. <Field label="Max Tokens"><NumIn value={settings.maxTokens} onChange={v => set("maxTokens", v)} min={256} max={8192} step={256} /></Field>
  976. <Field label="Temperature"><NumIn value={settings.temperature} onChange={v => set("temperature", Math.min(2, Math.max(0, v)))} step={0.05} /></Field>
  977. </div>
  978. </Panel>
  979. <Btn onClick={() => { setFlash(true); setTimeout(() => setFlash(false), 1600); }} color={flash ? C.good : C.accent} style={{ width: "100%", padding: 13, fontSize: 14 }}>
  980. {flash ? "✓ Settings Applied" : "Save Settings"}
  981. </Btn>
  982. </div>
  983. );
  984. }
  985. // ─── APP ─────────────────────────────────────────────────────────────────────
  986. export default function App() {
  987. const [hall, setHall] = useState(HALL_DEFAULTS);
  988. const [speakers, setSpeakers] = useState(SPEAKER_DEFAULTS);
  989. const [settings, setSettings] = useState(SETTINGS_DEFAULTS);
  990. const [activeTab, setActiveTab] = useState("hall");
  991. const [log, setLog] = useState([]);
  992. const logRef = useRef([]);
  993. const addLog = useCallback((msg, type = "info") => {
  994. const entry = { msg, type, t: new Date().toLocaleTimeString() };
  995. logRef.current = [...logRef.current.slice(-99), entry];
  996. setLog([...logRef.current]);
  997. }, []);
  998. const TABS = [
  999. { id: "hall", icon: "⬡", label: "Hall" },
  1000. { id: "speakers", icon: "◎", label: "Speakers" },
  1001. { id: "coverage", icon: "⊞", label: "Coverage" },
  1002. { id: "analysis", icon: "◈", label: "Analysis" },
  1003. { id: "settings", icon: "⚙", label: "Settings" },
  1004. { id: "log", icon: "▤", label: "Log" },
  1005. ];
  1006. const tabStyle = (active) => ({
  1007. background: active ? C.accent : "transparent",
  1008. color: active ? C.bg : C.muted,
  1009. border: `1px solid ${active ? C.accent : C.border}`,
  1010. borderRadius: 6, padding: "7px 14px", fontSize: 11, fontWeight: 700,
  1011. letterSpacing: 0.8, textTransform: "uppercase", cursor: "pointer",
  1012. fontFamily: "inherit", transition: "all 0.15s",
  1013. });
  1014. const provLabel = settings.provider === "ollama"
  1015. ? `🦙 ${settings.ollamaModel || "Ollama"}`
  1016. : `✦ Claude`;
  1017. return (
  1018. <div style={{ minHeight: "100vh", background: C.bg, color: C.text, fontFamily: "'IBM Plex Mono', 'Courier New', monospace", paddingBottom: 60 }}>
  1019. {/* header */}
  1020. <div style={{ borderBottom: `1px solid ${C.border}`, padding: "14px 24px", display: "flex", alignItems: "center", justifyContent: "space-between", background: C.bgAlt, position: "sticky", top: 0, zIndex: 10 }}>
  1021. <div>
  1022. <div style={{ fontSize: 9, letterSpacing: 2.5, color: C.accentDim, textTransform: "uppercase", marginBottom: 2, fontFamily: "inherit" }}>PBCC Universal Hall · MS6 Speaker System</div>
  1023. <h1 style={{ margin: 0, fontSize: 16, color: C.heading, fontWeight: 700, letterSpacing: 0.5 }}>Church Acoustic Analysis Suite</h1>
  1024. </div>
  1025. <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
  1026. <Tag color={C.gold}>{hall.hallWidth / 1000}m × {hall.hallWidth / 1000}m</Tag>
  1027. <Tag color={C.good}>{hall.rows} rows</Tag>
  1028. <Tag color={settings.provider === "ollama" ? C.purple : C.accent}>{provLabel}</Tag>
  1029. </div>
  1030. </div>
  1031. <div style={{ maxWidth: 900, margin: "0 auto", padding: "20px 16px" }}>
  1032. {/* tabs */}
  1033. <div style={{ display: "flex", gap: 6, marginBottom: 20, flexWrap: "wrap" }}>
  1034. {TABS.map(t => <button key={t.id} style={tabStyle(activeTab === t.id)} onClick={() => setActiveTab(t.id)}>{t.icon} {t.label}</button>)}
  1035. </div>
  1036. {activeTab === "hall" && <HallTab hall={hall} setHall={setHall} />}
  1037. {activeTab === "speakers" && <SpeakerTab hall={hall} speakers={speakers} setSpeakers={setSpeakers} />}
  1038. {activeTab === "coverage" && <CoverageTab hall={hall} speakers={speakers} />}
  1039. {activeTab === "analysis" && <AnalysisTab hall={hall} speakers={speakers} settings={settings} addLog={addLog} />}
  1040. {activeTab === "settings" && <SettingsTab settings={settings} setSettings={setSettings} addLog={addLog} />}
  1041. {activeTab === "log" && (
  1042. <Panel style={{ fontFamily: "inherit" }}>
  1043. <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
  1044. <span style={{ fontSize: 11, letterSpacing: 1.5, color: C.muted, textTransform: "uppercase" }}>Activity Log</span>
  1045. <Btn onClick={() => { logRef.current = []; setLog([]); }} color={C.muted} outline style={{ padding: "3px 10px", fontSize: 10 }}>Clear</Btn>
  1046. </div>
  1047. {log.length === 0 && <div style={{ color: C.muted, fontSize: 11 }}>No activity yet.</div>}
  1048. {[...log].reverse().map((e, i) => (
  1049. <div key={i} style={{ display: "flex", gap: 10, marginBottom: 4, fontSize: 11, borderBottom: `1px solid ${C.border}22`, paddingBottom: 3 }}>
  1050. <span style={{ color: C.muted, minWidth: 68, flexShrink: 0 }}>{e.t}</span>
  1051. <span style={{ color: e.type === "error" ? C.danger : e.type === "good" ? C.good : e.type === "warn" ? C.gold : C.text }}>{e.msg}</span>
  1052. </div>
  1053. ))}
  1054. </Panel>
  1055. )}
  1056. </div>
  1057. </div>
  1058. );
  1059. }