Benjamin Harris hace 1 mes
padre
commit
e10dac2a24
Se han modificado 18 ficheros con 3346 adiciones y 1066 borrados
  1. 8 0
      .claude/settings.local.json
  2. 12 0
      .dockerignore
  3. 6 0
      .gitignore
  4. 232 1066
      CLAUDE.md
  5. 16 0
      Dockerfile
  6. 195 0
      README.md
  7. 1149 0
      church-acoustic-suite.jsx
  8. 6 0
      docker-compose.yml
  9. 16 0
      index.html
  10. 22 0
      nginx.conf
  11. 26 0
      package.json
  12. 924 0
      src/App.jsx
  13. 322 0
      src/__tests__/geometry.test.js
  14. 126 0
      src/constants.js
  15. 241 0
      src/geometry.js
  16. 5 0
      src/main.jsx
  17. 24 0
      src/palette.js
  18. 16 0
      vite.config.js

+ 8 - 0
.claude/settings.local.json

@@ -0,0 +1,8 @@
+{
+  "permissions": {
+    "allow": [
+      "PowerShell(Get-Command node, npm, npx -ErrorAction SilentlyContinue)",
+      "PowerShell(docker --version 2>&1)"
+    ]
+  }
+}

+ 12 - 0
.dockerignore

@@ -0,0 +1,12 @@
+node_modules
+dist
+coverage
+.vite
+.git
+*.md
+docker-compose.yml
+Dockerfile
+nginx.conf
+church-acoustic-suite.jsx
+CLAUDE_CHAT.md
+documents

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+node_modules
+dist
+coverage
+.vite
+documents
+CLAUDE_CHAT.md

+ 232 - 1066
CLAUDE.md

@@ -1,1113 +1,279 @@
----
-title: "Room EQ Wizard analysis with LLM integration"
-date: 2026-05-11
-url: "https://claude.ai/chat/9a80c0e9-7228-4550-93ee-0840f4acb1e6"
----
-
-# Room EQ Wizard analysis with LLM integration
-
-#### User
-
-I am wanting to create a website page that the user can enter room sizes and then use room eq wizard REW to assess results using LLM e.g. ollama to analyse results and suggest improvements. using REW API. [https://www.roomeqwizard.com/help/help\_en-GB/html/api.html](https://www.roomeqwizard.com/help/help_en-GB/html/api.html)
-
-#### Assistant
-
-Good — I have the full REW API spec. Now I'll design a polished React app that:
-
-1.  Lets the user input room dimensions + surface absorptions
-2.  Connects to REW's local API (port 4735) to push those into the Room Simulator
-3.  Fetches measurements / frequency response data from REW
-4.  Sends that data to Claude (via Anthropic API) for acoustic analysis
-5.  Displays the LLM analysis with actionable recommendations
-
-Here's the full app. Here's what it does and how each piece connects:
-
-* * *
-
-### Architecture
-
-**Room Config → REW API → Claude AI → Recommendations**
-
-#### Tab 1 — 🏠 Room
-
-Enter dimensions (L × W × H). The app instantly computes:
-
-*   Volume, L:W and L:H ratios (colour-coded against ideal ranges)
-*   Schroeder frequency (where room modes dominate)
-*   All axial/tangential/oblique room modes up to 500 Hz
-
-#### Tab 2 — 🧱 Surfaces
-
-Set absorption coefficients per surface (0–1) with material presets like _Carpet_, _Acoustic panels_, _Glass_, etc. A visual bar shows absorption level.
-
-#### REW Integration (Connect button)
-
-Calls the REW API at `localhost:4735` in sequence:
-
-1.  `PUT /roomsim/room-size` — pushes your dimensions
-2.  `PUT /roomsim/absorptions` — pushes surface absorptions
-3.  `GET /roomsim/frequency-response?micposition=Main` — fetches the simulated response, decoding the Base64 big-endian float arrays REW uses
-4.  Optionally fetches RT60 from any existing measurements via `POST /measurements/:id/command` (Generate RT60)
-
-#### Tab 3 — 🧠 AI Analysis
-
-Sends all collected data to Claude Sonnet including room modes, octave band SPL, RT60 if available, and surface absorptions. Claude returns structured analysis covering:
-
-*   Room assessment
-*   Problem frequencies with exact Hz
-*   Dimension ratio evaluation (Bolt/EBU criteria)
-*   Prioritised treatment recommendations with locations
-*   Suggested parametric EQ corrections
-*   Next REW measurements to take
-
-* * *
-
-### To run it
-
-1.  Start REW, go to **Preferences → API** and enable the API server (or launch with `-api`)
-2.  Open this app — it connects to `localhost:4735`
-3.  Fill in room dimensions and surface materials, hit **Connect**
-4.  Switch to Analysis tab and hit **Run AI Acoustic Analysis**
-
-> **Note:** REW's API is localhost-only by design, so this needs to run in the same browser on the same machine as REW.
-
-Rew acoustic analyser
-
-Code · JSX
-
-#### User
-
-For a start we would like to use Ollama if you can build in a settings page to put this data into and select the available llm.
-
-#### Assistant
-
-Good, I have the full picture. Now I'll rebuild the app with a proper Settings tab covering Ollama host/port/model selection, REW connection settings, and an Anthropic API key fallback — all persisted to localStorage within the artifact.
-
-The ⚙ Settings tab is fully built in. Here's what's new:
-
-* * *
-
-### Settings tab breakdown
-
-**LLM Provider** — toggle between two cards:
-
-*   **🦙 Ollama (local)** — configure host + port, then hit **Fetch Models** to query `/api/tags` and populate a dropdown of whatever models you have pulled. Falls back to a free-text field if Ollama isn't reachable yet. Shows connection status inline.
-*   **✦ Anthropic** — paste your API key (masked input) and pick from a Claude model dropdown.
-
-**REW API Connection** — set host and port independently from the default `127.0.0.1:4735`, with a **Test Connection** button that pings `/application` and reports pass/fail.
-
-**LLM Behaviour** — editable system prompt, max tokens, and temperature sliders, so you can tune how the model responds without touching code.
-
-* * *
-
-### Ollama setup reminder
-
-For the browser fetch to reach Ollama you need CORS enabled:
-
-```
-OLLAMA_ORIGINS=* ollama serve
-```
-
-And a model pulled, e.g.:
-
-```
-ollama pull llama3.2
-ollama pull mistral
-ollama pull qwen2.5
-```
-
-The active provider and model are shown in the header tag at all times so you always know what's configured before hitting Analyse.
-
-Rew acoustic analyser
-
-Code · JSX
-
-#### User
-
-So we are building this for a unique case. Using the same principles as [https://measurely.uk](https://measurely.uk) but for a church. Where everyone sits in a circle. See attached floor plan. In the next message I will add some of the documents that we have had over the years. This should give better understanding. The floor is also dished so that brings in another dimension.
-
-#### Assistant
-
-What a fascinating and genuinely complex acoustic challenge. Let me make sure I understand what I'm looking at before you share the documents.
-
-From the floor plan I can see:
-
-**Layout observations:**
-
-*   Large square building with a central circular/octagonal seating arrangement
-*   Seating radiates outward in concentric arcs from a central void (altar/communion table area?)
-*   Diagonal seating sections filling the corners — so it's essentially 360° seating with no clear "front"
-*   Multiple **PA** symbols distributed around the perimeter and mid-zones — suggesting a distributed speaker system is already specified or installed
-*   The **filled black dots** clustered heavily in the upper-left quadrant look like measurement positions (or perhaps existing mic/speaker points) — notably absent from the other three quadrants, which is interesting
-*   Cross-shaped structural or acoustic elements dividing the quadrants
-
-**The dished floor** adds significant complexity — it means:
-
-*   Every seat has a different height relative to the speaker sources
-*   Ray paths to each listener position vary in both horizontal and vertical planes
-*   REW room simulation would need to account for the rake/dish geometry
-
-**Key acoustic challenges I'm anticipating:**
-
-*   No dominant "front wall" — modal behaviour will be more radially symmetric
-*   High risk of flutter echo and comb filtering across the circular space
-*   The central void creates a focal point that can concentrate reflections
-*   360° coverage with consistent SPL and intelligibility is the core engineering problem — exactly what Measurely addresses for similar venues
-
-Before the documents arrive — a few questions that will shape how we configure the tool:
-
-1.  **What's the approximate diameter** of the seating area, and the ceiling height at centre vs perimeter?
-2.  **Is there an existing speaker system** (those PA symbols suggest yes) — or is this a design/specification phase?
-3.  **What's the primary use** — speech intelligibility (sermons), music worship, or both equally?
-4.  **Do you have any existing REW measurements** from the space, or are we starting from simulation?
-
-Send the documents when ready — this is going to be a great test case for the tool.
-
-#### User
-
-pdf
-
-pdf
-
-Here is some information. In the next msg I will show some section through and diagrams we have drawn.
-
-#### Assistant
-
-This is extremely rich context. Let me digest what I'm seeing across all four documents before you share the sections.
-
-* * *
-
-### What I've gathered so far
-
-**The organisation & hall type** This is a Brethren meeting hall — specifically the "Universal" (or "9-row") hall format, a standardised square building used globally by the Plymouth Brethren Christian Church. The floor plan from your first message is a large hall (likely the MS6 / Aberdeen scale — possibly 60×60 ft or similar metric equivalent) with concentric circular seating around a central communion table, with a dished/raked floor.
-
-**The speaker system history (Dudley Wilkin's documents, 2009–2010)** This is a remarkably well-documented internal engineering history:
-
-*   **1970s:** Four corner column speakers — poor coverage, triggered every room echo
-*   **1982:** First ceiling speaker ring — 8 speakers at 22.5° positions over row 5, plus 4 diagonal speakers over row 2, plus corner staging speakers
-*   **1986:** Anti-phasing introduced in quadrants → then eighths → creates null lines along aisle centrelines, meaning each listener only receives sound from **one** speaker. This was the key intelligibility breakthrough
-*   **Centre speaker:** Added for "presence" on front rows, later discovered it could cover rows 1–3 on its own — leading to the 2010 proposed new layout
-
-**The 2010 proposed new layout (Aberdeen 8-row hall)**
-
-*   Centre speaker (speaker 13) at 6W — covers rows 1–3
-*   Main ring of 8 moved further out — speakers 1–8 at 8W — covers rows 4+
-*   Outer ring speakers 9–12 at 4W — fill back rows
-*   Reduces total speaker count from 21 → 13
-*   Anti-phasing potentially still viable because centre speaker "fills in" the null
-
-**The continuous sound warning document** Dudley's note is essentially a pre-emptive argument for using **impulse-based measurement (REW-style)** over continuous pink noise — he explicitly identifies why STI/RT60 via impulse through the live system is the only valid method. This aligns perfectly with REW's methodology. He even mentions the dished floor creating standing waves between the flat centre and the ceiling.
-
-* * *
-
-### Key acoustic peculiarities of this space
-
-1.  **No front wall** — 360° seating means no dominant early reflection axis
-2.  **Dished floor** — the central flat area (communion table) acts as a parallel reflective surface to the ceiling, creating a flutter echo cavity right at the geometric centre
-3.  **Anti-phasing nulls** — the system deliberately creates acoustic null zones along aisle lines; these interact with REW measurements in unusual ways depending on mic placement
-4.  **Multiple delayed sources** — even with anti-phasing, a mic at any position still "sees" at least one speaker directly plus leakage from adjacent segments
-5.  **The Haas window** — Dudley explicitly references 30ms; for a 60×60ft hall the far speakers are ~18m away = ~52ms delay, well outside the Haas window without DSP delay compensation
-6.  **The measurement dot pattern** in your first floor plan — those black dots in the upper-left quadrant look very much like a systematic grid measurement campaign, possibly the RT60 or SPL mapping exercise he references
-
-* * *
-
-### What this means for the REW tool
-
-The standard room simulator assumptions (shoebox, uniform absorption) **won't apply directly** here. We'll need to think about:
-
-*   Modelling each speaker as a **point source with known position, power, and phase** relative to the seating grid
-*   The dished floor geometry affecting direct-to-reverberant ratio differently at each row
-*   Using **STI (Speech Transmission Index)** as the primary metric rather than just frequency response flatness
-*   The measurement positions needing to avoid anti-phase null lines
-
-Send the section drawings — those will tell us the ceiling height profile, the dish geometry, and likely the speaker mounting heights, which are critical for the delay calculations.
-
-#### User
-
-pdf
-
-Here are some section views
-
-#### Assistant
-
-Excellent — now I have the critical geometry. Let me extract the hard numbers from these drawings.
-
-* * *
-
-### Key dimensions extracted
-
-**From the section drawing (HDW, 6.02.09):**
+# Church Acoustic Analysis Suite — Project Reference
 
-*   **Ceiling height: 3600mm** (flat, horizontal ceiling)
-*   **Ear height: 1100mm above feet** (seated ear level)
-*   **Floor dish:** Progressive slope from row 1 (lowest, outermost) rising toward the centre — the dish rises as you move _inward_ toward the communion table
-*   **Speaker positions** (numbered 1–13 in section):
-    *   Speaker 13 = **centre** of ceiling
-    *   Speakers 9–12 = inner ring, closer to centre, mounted lower on drop rods
-    *   Speakers 1–8 = main outer ring, also on drop rods at intermediate height
-    *   The drop rod lengths vary — inner speakers hang **lower** than outer speakers, which is a deliberate coverage angle calculation
+## Project Purpose
 
-**From the angular coverage diagram (upper sketch):**
+A React single-file web application for acoustic analysis of a Plymouth Brethren Christian Church (PBCC) "Universal Hall" — specifically the MS6 ceiling speaker system. The tool models the hall geometry, computes speaker coverage per row, checks Haas window compliance, analyses flutter echo risk, connects to REW (Room EQ Wizard) API for measurements, and sends all data to a local Ollama LLM (or Claude API) for structured acoustic recommendations.
 
-*   Coverage angles from a corner-mounted speaker position: **50°, 55°, 60°, 65°**
-*   Each angle corresponds to a row (rows 1–13 labelled on the ear-distance axis)
-*   This is Dudley calculating the **off-axis angle** from each speaker to each row's ear position — confirming these are wide-dispersion ceiling speakers aimed at maintaining on-axis coverage across the full row depth
-
-**The seating section PDF (6655-SK-303):**
-
-*   1-in-8 slope version — so the dish gradient is **1:8** (125mm rise per 1000mm horizontal run)
-*   This is a significant rake — across a 9-row hall (~9m radius from centre) that's **over 1100mm of floor level change** from outermost row to centre flat
-*   The flat central area (communion table zone) sits at the high point
-
-* * *
-
-### What this tells us acoustically
-
-**Speaker-to-ear distances per row** (approximate, using 3600mm ceiling, 1100mm ear height, 1:8 dish):
-
-| Row | Horiz. dist from centre | Floor rise | Ear height AFF | Ear-to-ceiling | Speaker drop | Net speaker-to-ear |
-| --- | --- | --- | --- | --- | --- | --- |
-| 1 (outer) | ~9m | 0mm | 1100mm | ~2500mm | ~400mm | ~2100mm |
-| 5 (mid) | ~4.5m | ~560mm | 1660mm | ~1940mm | ~600mm | ~1650mm |
-| 9 (inner) | ~1.5m | ~870mm | 1970mm | ~1630mm | ~900mm | ~1300mm |
-
-The **drop rods compensating for the dish** is critical — without them the inner speakers would be further from inner-row ears and closer to ceiling. Dudley's design intentionally lowers the inner speakers to maintain consistent throw distance and coverage angle across all rows. This is elegant engineering.
-
-**Delay implications:** At 343m/s, the difference between a close speaker (~1.3m) and a far speaker (~2.1m) is only ~2.3ms — well within the Haas window for the _nearest_ speaker. The problem Dudley identified is the **main ring speakers** being audible to front-row listeners at ~3–5m distance = 9–15ms delay on top of the centre speaker's direct sound. That's the interference/colouration problem he's solving with the 2010 layout revision.
-
-**The dished floor + flat ceiling flutter echo:** The flat centre area of the dish directly faces the flat ceiling at roughly 1.6m separation — this creates a **parallel surface flutter cavity** right where the stand microphone and communion table sit. Fundamental flutter frequency ≈ 343/(2×1.6) = **~107Hz** — solidly in the lower-mid speech band. This would be clearly visible in a REW waterfall plot as a comb-filtered decay.
-
-* * *
-
-### How this changes the tool design
-
-Now that I have real geometry, the tool needs significant adaptation from the generic shoebox model:
-
-**What we should build next:**
-
-1.  **Church-specific geometry input** — replace generic L×W×H with:
-    *   Hall width (square, so one dimension)
-    *   Number of rows
-    *   Dish slope ratio (1:8 default)
-    *   Ceiling height
-    *   Drop rod lengths per speaker ring
-2.  **Speaker position calculator** — from the geometry, auto-calculate:
-    *   Radial position of each speaker ring
-    *   Speaker-to-ear distance per row
-    *   Off-axis angle per row (Dudley's angular coverage diagram, automated)
-    *   Propagation delay from each speaker to each row position
-3.  **Delay/Haas analysis** — show which speakers at which rows fall inside/outside the 30ms Haas window, flagging intelligibility risk zones
-4.  **Anti-phase null line mapper** — show where null lines fall relative to aisle positions and mic positions
-5.  **REW measurement protocol generator** — based on the layout, output a recommended measurement grid that avoids null lines and accounts for the dish geometry
-
-Would you like me to rebuild the app now with this church-specific geometry engine? It would be a much more purpose-built tool for this exact hall type — essentially a digital twin of the Universal hall acoustic system. Given the standardised nature of these halls globally, it could be genuinely useful across many installations.
-
-Also — do you have the actual hall dimensions (the square footage or metre equivalent)? The drawings suggest ~60×60ft but I want to confirm before hardcoding the geometry.
-
-#### User
-
-The floor dish is lowest in the centre with a ceiling height 4300mm the top floor level e.g e.g row 8 is 3600mm
-
-#### Assistant
-
-Ah, that's the **inverse** of what I assumed — let me correct my mental model completely.
-
-* * *
-
-### Corrected geometry
-
-**The dish is a bowl — lowest at centre, highest at perimeter.**
-
-So:
-
-*   **Centre (communion table / row 1):** Floor at lowest point → ceiling clearance = **4300mm**
-*   **Row 8 (outermost):** Floor raised by dish → ceiling clearance = **3600mm**
-*   **Dish drop from perimeter to centre:** 4300 - 3600 = **700mm total**
-
-With a 1:8 slope ratio across ~9 rows, that tracks well — the floor progressively **drops** as you move inward from the outer rows toward the communion table.
-
-* * *
-
-### What this changes acoustically
-
-**Seated ear heights relative to ceiling (at 1100mm ear height AFF):**
-
-| Position | Floor-to-ceiling | Ear-to-ceiling |
-| --- | --- | --- |
-| Row 1 (outermost) | 3600mm | **2500mm** |
-| Row 5 (mid) | ~3900mm | **2800mm** |
-| Row 9 / centre | 4300mm | **3200mm** |
-
-So the **front/inner rows have the most ceiling clearance** and the outer rows are tightest. This is actually more acoustically challenging than I first thought because:
-
-1.  The centre speaker serving rows 1–3 has a **longer throw** than I calculated — ~3200mm to ear vs ~2100mm in my earlier estimate
-2.  The outer ring speakers serving rows 6–9 are **closer to those listeners** — ~2500mm ceiling to ear, minus drop rod
-3.  The parallel flutter cavity I identified (flat dish centre vs flat ceiling) is now **700mm deeper** — flutter fundamental drops to ~343/(2×2.0) ≈ **~86Hz**, right in the critical speech fundamental range
-4.  The communion table/stand mic sits at the **lowest point** of the dish — maximum distance from ceiling — which actually helps reduce early ceiling reflection pickup at the mic
-
-* * *
-
-### Revised speaker-to-ear geometry
-
-With the ceiling at 3600mm at the perimeter and speakers on drop rods:
-
-**Outer ring (rows 6–9 coverage):**
-
-*   Ceiling at ~3600mm, ear at ~2500mm from ceiling
-*   Speaker drop rod say 300mm → speaker at 3300mm AFF
-*   Speaker-to-ear vertical = 3300 - 1100 = **2200mm**
-*   Horizontal offset to row 7 ≈ ~2m
-*   Slant distance ≈ **√(2.2² + 2.0²) ≈ 2.97m** → delay **~8.7ms**
-
-**Centre speaker (rows 1–3 coverage):**
-
-*   Ceiling at 4300mm, ear at ~3200mm from ceiling
-*   Speaker drop rod say 600mm → speaker at 3700mm AFF
-*   Speaker-to-ear vertical = 3700 - 1100 = **2600mm**
-*   Horizontal offset to row 2 ≈ ~1.5m
-*   Slant distance ≈ **√(2.6² + 1.5²) ≈ 3.0m** → delay **~8.7ms**
-
-Interestingly the delays nearly equalise — which may be intentional in Dudley's design. The centre speaker and outer ring are both approximately **3m slant distance** from their intended coverage rows. Clean.
-
-**The cross-coverage problem:** The outer ring speakers are visible to row 1–3 listeners at roughly **5–7m slant** = **15–20ms** after the centre speaker direct sound. That's inside the Haas window but will cause colouration. This is exactly what the 2010 layout was trying to solve by **power-differentiating** the centre vs outer ring.
-
-* * *
-
-### Ready to rebuild
-
-I now have enough to build a proper **church acoustic geometry engine**. Before I do — a few quick confirmations:
-
-1.  **Hall width** — is it the standard ~18.3m (60ft) square, or do you have a metric dimension from the drawings?
-2.  **Number of rows** — the documents reference both 8-row and 9-row halls; which is this specific installation?
-3.  **Drop rod lengths** — do you have the actual specified lengths for each speaker ring from the MS6 ceiling speaker document?
-4.  **The MS6 document** — the `.docx` upload didn't extract text — can you confirm if it has the current speaker schedule/positions for this specific hall?
-
-#### User
-
-The hall is 22.2m square for the 8 row hall. I will upload a text version of MS6 document.
-
-#### Assistant
-
-Perfect — **22.2m square, 8-row hall**. That gives us solid numbers to work with.
-
-While you grab the MS6 text, let me lock in the geometry so it's ready to code:
-
-* * *
-
-### Confirmed geometry for this hall
-
-**Plan:** 22.2m × 22.2m square **Centre-to-outer-wall:** 11.1m **Row spacing** (approximate, 8 rows across ~9m radius): **~1.1m per row**
-
-| Row | Radial dist from centre | Floor level (1:8 dish) | Ceiling height | Ear-to-ceiling |
-| --- | --- | --- | --- | --- |
-| 1 (inner) | ~1.1m | −700mm (lowest) | 4300mm | 3200mm |
-| 2 | ~2.2m | −613mm | 4213mm | 3113mm |
-| 3 | ~3.3m | −525mm | 4125mm | 3025mm |
-| 4 | ~4.4m | −438mm | 4038mm | 2938mm |
-| 5 | ~5.5m | −350mm | 3950mm | 2850mm |
-| 6 | ~6.6m | −263mm | 3863mm | 2763mm |
-| 7 | ~7.7m | −175mm | 3775mm | 2675mm |
-| 8 (outer) | ~8.8m | 0mm (ref) | 3600mm | 2500mm |
-
-**Dish drop total: 700mm over ~8.8m radius → slope = 1:12.6** (slightly shallower than the 1:8 document — worth confirming with the SK-303 drawing dimensions once we have them)
-
-* * *
-
-### Speaker ring positions (from Dudley's 2010 layout, scaled to 22.2m)
-
-Based on the Aberdeen 8-row proposal scaled to this hall:
-
-| Speaker group | Count | Radial position | Coverage target |
-| --- | --- | --- | --- |
-| Centre (13) | 1 | 0m | Rows 1–3 |
-| Main ring (1–8) | 8 | ~6.5–7m | Rows 4–7 |
-| Outer ring (9–12) | 4 | ~9–10m | Row 8 + corners |
-
-**Haas window check at 22.2m scale:**
-
-*   Speed of sound = 343 m/s → 1ms per 343mm
-*   Outer ring to row 1 centre = ~10m slant → **~29ms** — right at the Haas limit
-*   This will be a critical flagging point in the tool
-
-* * *
-
-### What the rebuilt tool will include
-
-**Church Geometry Tab** (replacing generic room dimensions):
-
-*   Hall width (locked at 22.2m for this hall, adjustable)
-*   Row count (8)
-*   Dish parameters (centre depth, perimeter ceiling height)
-*   Speaker ring radii and drop rod lengths
-
-**Coverage Calculator:**
-
-*   Auto-computed slant distances speaker→ear per row
-*   Propagation delay per speaker-to-row combination
-*   Haas window compliance map (green/amber/red per zone)
-*   Off-axis angle per row (Dudley's coverage angle diagram, automated)
-
-**Anti-phase null line mapper:**
+---
 
-*   Shows null line positions relative to aisle centres
-*   Flags mic position risk zones
+## Hall Geometry — Confirmed Measurements
+
+### Critical geometry rule — read carefully
+
+**The CEILING IS FLAT.** It is one horizontal plane at a constant absolute height of **4300mm above the centre floor datum** across the entire main seating zone.
+
+**The FLOOR IS DISHED** (bowl shape). It is lowest at the centre (communion table area) and rises outward toward the perimeter.
+
+The apparent "ceiling height" (floor-to-ceiling clearance) decreases toward the perimeter solely because the floor rises. There is no slope in the ceiling itself over the main seating area.
+
+### Plan dimensions
+
+| Parameter | Value | Source |
+|---|---|---|
+| Seating plan | 20,914mm × 20,914mm | SK-303 + confirmed |
+| Half-width (centre to wall) | 10,457mm | 20914 / 2 |
+| Row 1 front edge (inner radius) | 2,210mm from centre | Confirmed |
+| Row pitch (FFL to FFL) | 883mm | Confirmed |
+| Number of main rows | 8 | Confirmed |
+| Row 8 back face | 8,411mm from centre | SK-303 drawing |
+| Back aisle width | 2,046mm | 10457 − 8411 = 2046 ✓ |
+| Corner rows per corner | 4 rows | Confirmed |
+| Corner row pitch | 883mm (same as main) | Confirmed |
+| Corner row 1 front | ~620mm from edge of circular seating | Confirmed |
+
+**Row mid-bench radii (ear position):**
+
+| Row | Front edge (mm) | Mid-bench ear (mm) |
+|---|---|---|
+| 1 | 2,210 | 2,651 |
+| 2 | 3,093 | 3,534 |
+| 3 | 3,976 | 4,418 |
+| 4 | 4,859 | 5,301 |
+| 5 | 5,742 | 6,184 |
+| 6 | 6,625 | 7,067 |
+| 7 | 7,508 | 7,950 |
+| 8 | 8,391 | 8,833 → back face 8,411 |
+
+### Section — ceiling and floor
+
+**Flat ceiling (main seating zone):**
+- Absolute height: **4300mm** above centre floor datum (0mm reference)
+- This is constant across the entire main seating zone
+- Floor-to-ceiling clearance at any radius = `4300 − floorRise(r)`
+
+**Dished floor:**
+- Centre (communion table zone, r ≈ 0–2210mm): **0mm** (lowest, flat zone)
+- Rises linearly from row 1 outward to row 8
+- Total dish rise from centre to row 8 back (r=8411mm): **700mm**
+- Dish slope: 700mm over 8411mm ≈ **1:12** (approximately 1:8 per SK-303, measured from row 1)
+- Rise per row ≈ 87.5mm
+
+**Floor-to-ceiling clearance per row (flat ceiling @ 4300mm):**
+
+| Row | Floor rise (mm) | Floor-to-ceiling (mm) |
+|---|---|---|
+| Centre | 0 | 4,300 |
+| Row 1 mid | ~55 | ~4,245 |
+| Row 2 mid | ~140 | ~4,160 |
+| Row 3 mid | ~220 | ~4,080 |
+| Row 4 mid | ~310 | ~3,990 |
+| Row 5 mid | ~400 | ~3,900 |
+| Row 6 mid | ~480 | ~3,820 |
+| Row 7 mid | ~565 | ~3,735 |
+| Row 8 mid | ~650 | ~3,650 |
+| Row 8 back | 700 | 3,600 |
+
+**Corner area ceiling:**
+- The ceiling drops in the corner zones due to building structure (lower roof soffit)
+- Floor-to-ceiling behind last corner row: **2,950mm**
+- This is a structural change, not a continuation of the main flat ceiling
+
+**Ear height above local floor:** 1,100mm (seated listener, consistent throughout)
+
+**Ceiling tile grid:** 600mm × 600mm acoustic tiles — constrains speaker positions to 600mm increments from the grid
 
-**REW Integration** (carried over):
+---
 
-*   Push geometry to REW room simulator
-*   Fetch frequency response
-*   Ollama/Claude analysis
+## Speaker System
 
-Send the MS6 text when ready — that'll give us the actual specified drop rod lengths and power settings to complete the model.
+### Layout — all flush mounted, NO drop rods
 
-#### Assistant
+All speakers sit flush with the ceiling (acoustic face at ceiling level). There are no drop rods anywhere.
 
-pdf
+| Ring | Count | Radius from centre | Ceiling height at radius | Notes |
+|---|---|---|---|---|
+| Centre | 1 | 0mm | ~4,300mm | Over communion table |
+| Inner ring | 8 | 5,720mm | ~4,060mm | Between rows 4–5 |
+| Outer ring | 8 | 8,600mm | ~3,580mm | Just inside row 8 back (8,411mm) |
+| Corner | 8 (2 per corner) | 10,310mm | ~2,950mm | 1,500mm from side walls |
 
-#### Assistant
+**Total: 25 speakers, all flush with ceiling.**
 
-This is the complete picture now. The MS6 document is a major piece — let me extract everything critical before we build.
+Speaker height above centre floor datum = ceiling height at that radius (since drop rod = 0).
 
-* * *
+### Corner speaker position
 
-### MS6 Speaker System — Key Technical Data
+- Radius: 10,310mm from centre
+- 1,500mm from each side wall (wall at 10,457mm → one axis coordinate = 8,957mm)
+- Cartesian: approximately (8,957mm, 5,107mm) from centre (solving 10,310² = 8,957² + y²)
 
-**The speaker itself (ISS 1, June 2024 — Dudley Wilkin)**
+### MS6 Speaker — Technical Specification (ISS 1, June 2024, Dudley Wilkin)
 
 **Drivers:**
-
-*   **Low:** Visaton FRS8M — 8Ω, 30W, 88dB/1W/1m, resonant freq 125Hz, response 100Hz–20kHz
-*   **High:** Visaton G20SC — 8Ω, 20mm soft dome tweeter, 88dB/1W/1m, response 1200Hz–30kHz, resonant freq 1890Hz
-
-**Driver spacing: 90mm centre-to-centre** — this is the critical dimension that causes the horizontal lobe problem with conventional crossovers
-
-**The crossover innovation:**
-
-*   Conventional analogue crossovers leave both drivers active simultaneously around the crossover frequency — causes destructive interference lobes at wide dispersion angles, exactly at the frequencies (2–5kHz) needed for consonant intelligibility (P, T, F, S)
-*   MS6 solution: **hard frequency gap** using MIDAS MR12 digital mixer as active crossover
-*   Low driver: cut off at **2792Hz** (hard roll-off)
-*   High driver: starts at **3225Hz**
-*   **Gap: 433Hz** centred around 3kHz — less than 1/5 octave, inaudible
-*   Crossover point at **3044Hz at -15dB** — both drivers effectively silent in the gap zone
-*   Result: **zero horizontal lobes** confirmed by polar measurements at 30° and 55° off-axis
-
-**The polar results are remarkable:**
-
-*   At 30° off-axis: near-perfect circle across all frequencies 200Hz–10kHz
-*   At 55° off-axis: still excellent circular response — this is what enables the minimum speaker count
-*   No coupling between drivers confirmed (section 8)
+- LF: Visaton FRS8M — 8Ω, 30W, 88dB/1W/1m, resonant freq 125Hz, response 100Hz–20kHz
+- HF: Visaton G20SC — 8Ω, 20mm soft dome tweeter, 88dB/1W/1m, response 1,200Hz–30kHz
+- Driver spacing: 90mm centre-to-centre
+
+**Crossover innovation (key design element):**
+- Conventional crossovers cause destructive interference lobes at 2–5kHz — exactly the consonant intelligibility band (P, T, F, S)
+- MS6 uses MIDAS MR12 digital mixer as active crossover with a **hard frequency gap**
+- LF driver hard cut-off: **2,792Hz**
+- HF driver starts: **3,225Hz**
+- Gap: **433Hz** wide, centred around 3kHz — inaudible but eliminates driver overlap
+- Result: zero horizontal lobes, near-circular polar response to 55° off-axis
+
+**Polar response (measured):**
+- At 30° off-axis: near-perfect circular across 200Hz–10kHz
+- At 55° off-axis: still excellent circular response
+- **Maximum coverage angle: 55° off-axis from directly below**
 
 **Signal chain:**
-
 ```
-Audio system mono out
-    → MIDAS MR12 Input Ch1 (Acoustic TEQ insert — room EQ)
-    → Bus 1 → AUX1 → TEQ + PEQ → Low driver amplifier → 100V line → LF transformers
-    → Bus 2 → AUX2 → TEQ + PEQ → High driver amplifier → 100V line → HF transformers
+Mono audio source
+  → MIDAS MR12 Ch1 (Acoustic TEQ insert for room EQ)
+  → Bus 1 → AUX1 → LF PEQ (crossover cut) → LF amplifier → 100V line → LF transformers
+  → Bus 2 → AUX2 → HF PEQ (crossover cut) → HF amplifier → 100V line → HF transformers
 ```
 
-**AUX1 Low driver PEQ crossover settings:**
-
-*   Low: −15dB @ 3k09, Q=10
-*   Low2: −15dB @ 3k31, Q=10
-*   LoMid: −15dB @ 3k43, Q=10
-*   HiMid: +4dB @ 2k79, Q=10
-*   High/High2: flat
-
-**AUX2 High driver PEQ crossover settings:**
-
-*   Low: −15dB @ 2k79, Q=10
-*   Low2: −15dB @ 2k99, Q=10
-*   LoMid: +5.5dB @ 3k20, Q=10
-*   HiMid: +0.5dB @ 3k55, Q=10
-*   High shelves: flat to 20kHz
-
-**Acoustic EQ (Channel 1 TEQ insert — room correction):** This is applied to the full-range signal before the crossover split, correcting the room acoustics rather than the driver response. The TEQ settings shown reflect the specific hall tuning.
-
-**Measurement protocol specified:**
-
-*   ARTA software with impulse window
-*   Minimum **10ms window** needed (requires sufficient ceiling clearance)
-*   Mic at **2m minimum** from speaker (far field)
-*   **Never use TrueRTA** (continuous signal — exactly what Dudley's 2010 document warned against)
-*   Equalise at on-axis position at 90° horizontal (both drivers equidistant from mic)
-
-* * *
-
-### What this means for the REW tool — complete picture
-
-Now I understand the full system. The tool needs to handle something REW's room simulator was never designed for:
-
-**It's not a room acoustic problem in the traditional sense — it's a distributed source intelligibility problem.**
-
-The key metrics for this hall are:
-
-| Metric | Why it matters |
-| --- | --- |
-| **Direct-to-Reverberant ratio per seat** | Primary intelligibility driver |
-| **Haas window compliance** | Which speakers are within 30ms of nearest speaker per row |
-| **Off-axis angle per speaker per row** | Is listener within the MS6's 55° coverage cone? |
-| **Speaker-to-ear slant distance per row** | SPL inverse square law, delay calculation |
-| **Cross-coverage SPL from non-nearest speakers** | The "boominess" Dudley documents |
-| **Flutter echo risk** | Centre dish flat area vs ceiling |
-
-* * *
-
-### Proposed tool architecture — rebuilt from scratch
-
-I want to build this properly now. Here's what I'm planning:
-
-**Tab 1 — Hall Geometry**
-
-*   Hall width (22.2m default)
-*   Row count (8)
-*   Dish: centre ceiling height (4300mm), perimeter ceiling height (3600mm)
-*   Auto-calculates floor level per row, ear-to-ceiling per row, dish slope
-
-**Tab 2 — Speaker Layout**
-
-*   Three rings configurable: Centre, Main ring (8 speakers), Outer ring (4 speakers)
-*   Per ring: radial position, drop rod length, power (watts), phase (normal/anti-phase)
-*   Auto-calculates speaker height AFF per ring
+**MR12 AUX1 (LF driver) PEQ crossover settings:**
+- Low: −15dB @ 3k09, Q=10
+- Low2: −15dB @ 3k31, Q=10
+- LoMid: −15dB @ 3k43, Q=10
+- HiMid: +4dB @ 2k79, Q=10
 
-**Tab 3 — Coverage Analysis** _(the core engine)_
+**MR12 AUX2 (HF driver) PEQ crossover settings:**
+- Low: −15dB @ 2k79, Q=10
+- Low2: −15dB @ 2k99, Q=10
+- LoMid: +5.5dB @ 3k20, Q=10
+- HiMid: +0.5dB @ 3k55, Q=10
 
-*   For every combination of speaker ring × row:
-    *   Slant distance (m)
-    *   Propagation delay (ms)
-    *   Off-axis vertical angle (°)
-    *   SPL at ear (dB, inverse square law from sensitivity)
-    *   Haas compliance (✓ within 30ms of nearest / ⚠ 30–50ms / ✗ >50ms)
-    *   Within 55° MS6 coverage cone (✓/✗)
-*   Heat map grid: rows vs speaker rings, colour-coded
+**Acoustic EQ (Channel 1 TEQ insert):** Applied full-range before the crossover split, corrects room acoustics per installation.
 
-**Tab 4 — Flutter Echo Analysis**
+**Measurement protocol:**
+- Use ARTA software with impulse window (minimum 10ms window)
+- Never use TrueRTA or any continuous-signal method
+- Mic minimum 2m from speaker (far field), at 90° horizontal so both drivers are equidistant
 
-*   Centre dish flat zone dimensions
-*   Ceiling height at centre
-*   Flutter fundamental frequency (c/2d)
-*   Harmonic series
-*   Risk assessment
+### Historical system context (Dudley Wilkin documentation, 1970–2024)
 
-**Tab 5 — REW Integration**
+- **1970s:** 4 corner column speakers — poor coverage, excited all room echoes
+- **1982:** First ceiling ring — 8 speakers at row 5 radius + 4 diagonal + corner staging speakers
+- **1986:** Anti-phasing breakthrough — quadrant anti-phasing creates null lines along aisle centrelines; each listener hears only one speaker segment → major intelligibility improvement
+- **2010:** Proposed new layout — centre speaker (rows 1–3) + main ring (rows 4+) + outer ring fill; reduces count from 21 to 13
+- **MS6 (2024):** New bespoke two-driver speaker with gap crossover replacing earlier drivers
 
-*   Connect to REW, push geometry, fetch measurements
-*   Measurement position recommendations (avoiding anti-phase nulls)
-*   ARTA window size calculator (given ceiling height, recommends mic distance)
+**Anti-phasing principle:** Adjacent speaker segments operate in opposite polarity. Null lines form along aisle centrelines. A listener on either side of an aisle hears only the speaker from their segment. This eliminates cross-segment interference and dramatically improves STI (Speech Transmission Index).
 
-**Tab 6 — AI Analysis (Ollama/Claude)**
-
-*   Sends full geometry + coverage analysis + REW data to LLM
-*   Prompt specifically tuned to this hall type and the MS6 system
-
-**Tab 7 — Settings** (carried over)
-
-**Tab 8 — Log**
-
-* * *
-
-Shall I build this now? This will be a significantly more purposeful tool than the generic REW wrapper we started with — essentially a digital acoustic planning tool specifically for this hall type, informed by 40+ years of Dudley's engineering documentation.
-
-#### User
-
-Thanks let's get started with the build i will confirm some of the measurements e eg. The FFL to front row FFL is 883mm the radius of the front row which we refer to as row one is 2210mm and the row widths are 883mm for the 8row hall there is a 2m aisle before the corner rows.
-
-#### Assistant
-
-Perfect — now I have precise dimensions. Let me lock these in before building:
-
-* * *
-
-### Confirmed geometry
-
-**Row geometry:**
-
-*   Row pitch: **883mm**
-*   Row 1 radius (inner): **2210mm** from centre
-*   Row 8 radius: 2210 + (7 × 883) = **8391mm** from centre
-*   Aisle before corner rows: **2000mm** beyond row 8 = **10391mm** to corner seating
-
-**Floor dish (centre lowest):**
-
-*   Row 1 floor (centre): 0mm reference (lowest point)
-*   Row 8 floor: rises by dish slope
-*   Ceiling at centre: **4300mm**, at row 8 perimeter: **3600mm**
-*   Total dish rise over 8391mm radius: **700mm**
-*   Slope: 700/8391 = **1:11.99** (effectively 1:12)
-*   Floor rise per row: 700/7 = **100mm per row**
-
-**Floor level per row (above centre datum):**
-
-| Row | Radius (mm) | Floor level | Ceiling height | Ear height AFF | Ear-to-ceiling |
-| --- | --- | --- | --- | --- | --- |
-| 1 | 2210 | +0mm | 4300mm | 1100mm | 3200mm |
-| 2 | 3093 | +100mm | 4200mm | 1200mm | 3000mm |
-| 3 | 3976 | +200mm | 4100mm | 1300mm | 2800mm |
-| 4 | 4859 | +300mm | 4000mm | 1400mm | 2600mm |
-| 5 | 5742 | +400mm | 3900mm | 1500mm | 2400mm |
-| 6 | 6625 | +500mm | 3800mm | 1600mm | 2200mm |
-| 7 | 7508 | +600mm | 3700mm | 1700mm | 2000mm |
-| 8 | 8391 | +700mm | 3600mm | 1800mm | 1800mm |
-| Corner | 10391 | ~+870mm | ~3430mm | ~1970mm | ~1460mm |
-
-**Interesting observation — row 8:** ear-to-ceiling = exactly 1800mm, and the dish has raised the floor so the seated ear is actually **higher** than at row 1 relative to the centre floor datum. The ceiling speaker at row 8 is very close to the listener.
-
-Now building:
-
-The tool is built, pre-loaded with your exact hall geometry. Here's what's in each tab:
-
-* * *
-
-### ⬡ Hall Tab
-
-*   Locked to **22.2m × 22.2m**, row 1 radius **2210mm**, pitch **883mm**, 2m corner aisle
-*   **Section diagram** showing dished floor, ceiling slope, speaker rays to each row, colour-coded by Haas status
-*   **Row geometry table** — radius, floor rise, ceiling height, ear-to-ceiling for every row
-*   **Flutter echo analysis** — calculates the parallel cavity fundamental between the dish centre and ceiling, flags it against the speech band
-
-### ◎ Speakers Tab
-
-*   Three configurable rings: **Centre** (gold), **Main ring of 8** (green), **Outer ring of 4** (purple)
-*   Per ring: radius, drop rod length, power, count, sensitivity, phase (normal/anti)
-*   Auto-calculates speaker height AFF and nearest row
-
-### ⊞ Coverage Tab
-
-*   **Heatmap grid** — every row × every ring, colour-coded: green=primary, blue=OK <10ms, amber=warn 10–30ms, red=echo >30ms
-*   Each cell shows slant distance, delay, Haas status, off-axis angle, and SPL at ear
-*   Full sortable data table underneath
-*   Flags anything outside the **55° MS6 coverage cone**
-
-### ◈ Analysis Tab
-
-*   Optional REW connection for measured frequency data
-*   AI analysis prompt is **specifically tuned** for this hall type — references the MS6 crossover gap, Haas window, anti-phasing, MR12 EQ, and ARTA measurement protocol
-*   Streams from Ollama or Claude
-
-### Next steps
-
-Once you've confirmed/adjusted the speaker ring radii and drop rod lengths from your actual installation drawings, the coverage heatmap will show exactly where the Haas and cone issues are. What measurements can you confirm for the speaker positions?
-
-Church acoustic suite
-
-Code · JSX
-
-#### User
-
-Just to clarify the ceiling is a flat ceiling with 600mm square acoustic ceiling tile. The main ring of 8 speakers is 6m radius and the outer ring of 8 speakers is 9m
-
-#### Assistant
-
-Good corrections — let me update my understanding:
-
-* * *
-
-### Corrected geometry
-
-**Ceiling:** Flat at **4300mm** at centre — the dish is purely the **floor** rising toward the perimeter. The ceiling doesn't change height. This significantly changes all the ear-to-ceiling calculations.
-
-**Revised ear-to-ceiling per row (flat ceiling @ 4300mm):**
-
-| Row | Radius | Floor rise | Ear AFF (above centre datum) | Ear-to-ceiling |
-| --- | --- | --- | --- | --- |
-| 1 | 2210mm | 0mm | 1100mm | **3200mm** |
-| 2 | 3093mm | +100mm | 1200mm | **3100mm** |
-| 3 | 3976mm | +200mm | 1300mm | **3000mm** |
-| 4 | 4859mm | +300mm | 1400mm | **2900mm** |
-| 5 | 5742mm | +400mm | 1500mm | **2800mm** |
-| 6 | 6625mm | +500mm | 1600mm | **2700mm** |
-| 7 | 7508mm | +600mm | 1700mm | **2600mm** |
-| 8 | 8391mm | +700mm | 1800mm | **2500mm** |
-
-So the ceiling is always 4300mm above centre floor datum — the **ear gets progressively closer to the ceiling as you move outward** because the floor rises. Row 8 ear is only 2500mm below ceiling vs 3200mm at row 1. This makes the outer ring speaker drop rod length even more critical.
-
-**Speaker rings — corrected:**
-
-*   Main ring: **8 speakers** at **6000mm radius** (not 4 as previously assumed)
-*   Outer ring: **8 speakers** at **9000mm radius**
-*   Centre: **1 speaker** at 0mm
-
-**Speaker heights (flat ceiling @ 4300mm, drop rods TBC):**
-
-*   All speakers hang from the same flat 4300mm ceiling
-*   Speaker AFF = 4300mm − drop rod length (measured from centre floor datum)
-*   At row 8, floor is +700mm, so speaker clearance above row 8 floor = (4300 − drop rod) − 700mm
-
-**Acoustic ceiling tiles — 600mm square:**
-
-*   These are the mounting grid for the MS6 speakers
-*   600mm grid means speaker positions are constrained to 600mm increments from the grid
-*   A 6000mm radius ring fits cleanly on the 600mm grid (6000 ÷ 600 = 10 modules)
-*   A 9000mm radius ring also fits cleanly (9000 ÷ 600 = 15 modules)
-*   This explains Dudley's comment about speakers needing to fit "spare grid positions"
-
-**Flutter echo — revised:** With a flat ceiling at 4300mm and the floor dish rising, the flutter risk changes per zone:
-
-*   Centre: 4300 − 1100 = **3200mm** cavity → fundamental **~54Hz** (below speech band, less problematic)
-*   Row 8: 4300 − 700 − 1100 = **2500mm** cavity → fundamental **~69Hz**
-*   The concern shifts — these are sub-bass fundamentals but harmonics at 2× and 3× (108Hz, 162Hz) sit right in the speech fundamental range
-
-Now let me update the tool with all three corrections:
-
-Now fix the Hall tab UI and flutter panel to use the new field names:
-
-Now fix the SectionDiagram and SpeakerTab which still reference old field names:
-
-Now fix the SpeakerTab which references `hall.ceilCentre`:
-
-Now fix the analysis prompt builder which still references old fields:
-
-Updated with all three corrections. Here's a summary of what changed:
-
-* * *
-
-### What's corrected
-
-**Geometry engine — flat ceiling:**
-
-*   `ceilFlat = 4300mm` — constant across the entire hall, no slope
-*   `dishRise = 700mm` — floor rises from 0 at centre to 700mm at row 8
-*   Ear-to-ceiling now **decreases** row by row as the floor rises: 3200mm at row 1 → 2500mm at row 8 → gets tighter still at corners
-*   Speaker height AFF is now simply `4300 − dropRod` for every ring — no position-dependent ceiling calculation needed
-
-**Speaker layout:**
-
-*   Main ring: **8 speakers** at **6000mm radius**
-*   Outer ring: **8 speakers** at **9000mm radius** _(outer ring is now larger than the hall's row 8 radius of 8391mm — this means the outer ring sits beyond the last row, in the aisle zone — worth confirming physically)_
-*   600mm tile grid noted as a constraint on speaker positioning
-
-**Flutter analysis — revised:**
-
-*   Centre: 3200mm clearance → 54Hz fundamental (sub-bass, harmonics at 107Hz, 161Hz in speech band)
-*   Row 8: 2500mm clearance → 69Hz fundamental (harmonics at 137Hz, 206Hz — more problematic)
-*   Added note: 600mm tile grid creates a diffraction grating effect around 571Hz
-
-**Section diagram:** now correctly shows flat ceiling with the floor rising toward the outer rows, and uses the correct speaker height calculations
-
-One thing to confirm — with the outer ring at 9000mm radius and row 8 at only 8391mm, those speakers are sitting **609mm beyond the last seating row** toward the wall. Is that correct, or should the outer ring be inside row 8?
-
-Church acoustic suite
-
-Code · JSX
-
-#### User
-
-pdf
-
-This only shows the back of 8th row to be 8411mm is that your calculation?
-
-#### Assistant
-
-The PDF text didn't extract usefully — let me check my calculation against your 8411mm figure.
-
-Good — the PDF is a binary CAD export, no text to extract. But I can work directly from your figure.
-
-* * *
-
-### Checking my calculation vs your 8411mm
-
-My formula:
-
-```
-Row 8 radius = row1Radius + (rows - 1) × rowPitch
-             = 2210 + 7 × 883
-             = 2210 + 6181
-             = 8391mm
-```
-
-Your drawing shows **8411mm** — a difference of **20mm**.
-
-That's almost certainly the drawing dimension being measured to the **back face of the row 8 seating** rather than the row centreline, or it could be a rounding in the CAD model. The difference is acoustically negligible — 20mm at 9m radius is 0.2% — but I should match your drawing exactly.
-
-The most likely explanation is the row pitch is measured slightly differently — if the 8411mm is to the **back of row 8** and the 2210mm is to the **front of row 1**, then the seating band spans:
-
-```
-8411 - 2210 = 6201mm across 7 row gaps
-6201 / 7 = 885.9mm ≈ 886mm per row
-```
-
-Or alternatively the drawing measures to a **bench centreline** at row 1 rather than the inner edge, giving:
-
-```
-2210 + 7 × 883 = 8391mm  (inner edge basis)
-2210 + 443 + 6 × 883 + 440 = 8391mm  (roughly same)
-```
-
-The simplest fix is to just use **8411mm** as the outer radius directly in the defaults rather than computing it, and adjust row 1 radius slightly.
-
-Can you confirm — is the **2210mm** measured to the inner edge of row 1 seating, or to the centreline of the row 1 bench? That will tell us whether the 20mm discrepancy is a bench-width offset or a rounding difference. Either way I'll update the default to match your drawing exactly.
-
-#### User
-
-The 2210 is to the front edge of row 1 Rows are 883 spacings Back aisle is 2046. Which gives overall dimensions of 20.914m x 20.914m
-
-#### Assistant
-
-Excellent — now I can nail the geometry precisely.
-
-* * *
-
-### Corrected dimensions
-
-**Row geometry:**
-
-*   Row 1 front edge: **2210mm** from centre
-*   Row pitch (FFL to FFL): **883mm**
-*   Row 1 to row 8 back edge: 2210 + (8 × 883) = 2210 + 7064 = **9274mm** — wait, that's too large.
-
-Let me think carefully:
-
-*   Front of row 1: 2210mm
-*   Back of row 8 = front of row 1 + 8 × 883 = 2210 + 7064 = **9274mm**
-
-But overall dimension = 20914mm, so half = **10457mm** to wall from centre.
-
-Back aisle = 10457 − 9274 = **1183mm** — that doesn't match your 2046mm figure.
-
-Let me try the other interpretation:
-
-*   **Back of row 8** = 2210 + 7 × 883 = 2210 + 6181 = **8391mm** (7 gaps between 8 rows)
-*   Back aisle = 2046mm
-*   8391 + 2046 = **10437mm**
-*   Overall = 10437 × 2 = **20874mm** — close but not 20914mm
-
-One more try — if there's also a **front aisle** before row 1:
-
-*   Overall half = 10457mm
-*   Back of row 8 = 10457 − 2046 = **8411mm** ✓ matches your drawing exactly
-*   Front of row 1 = 2210mm
-*   Seating band = 8411 − 2210 = **6201mm** across 7 row spacings
-*   6201 / 7 = **885.9mm** — close to 883 but not exact
-
-So the **20mm discrepancy** is real — back of row 8 is 8411mm per the drawing, and 8 × 883 from the front of row 1 gives 8391mm. The drawing likely measures to the **back face of the bench** not the FFL, adding roughly one bench thickness (~20mm). Acoustically this is the seat position, which is what matters.
-
-Let me lock in the confirmed numbers:
-
-Good. Now update the geometry engine to use `row8Back` for the dish rise reference, and fix the hall width display:
-
-Now fix the flutter function and hall tab UI references to use `row8Back`:
-
-Now fix the Hall tab stats bar which references `hall.hallWidth` and dish slope calculation:
-
-Now fix the flutter panel to show tile diffraction and use correct field names:
-
-Now update the analysis prompt to use the confirmed dimensions:
-
-Also update the row geometry table to show both front edge and mid-bench radius:
-
-Updated. Here's a summary of what's now confirmed and locked in:
-
-* * *
-
-### Verified geometry — all cross-checked
-
-```
-Centre → Row 1 front:    2210mm   (front aisle)
-Row pitch FFL–FFL:        883mm   × 7 gaps = 6181mm
-Row 8 FFL:               8391mm
-Row 8 back face:         8411mm   (+20mm bench thickness, per SK-303 ✓)
-Back aisle:              2046mm
-Half-width total:       10457mm   (8411 + 2046)
-Overall seating plan:   20914mm × 20914mm ✓
-```
-
-**Three things worth confirming before the next session:**
-
-1.  **Outer ring at 9000mm** — this puts those 8 speakers 589mm into the back aisle (9000 − 8411). The coverage heatmap will now show the exact off-axis angles and SPL to corner rows from that position. Is 9000mm correct, or should they be positioned closer to row 8 (e.g. 8000mm, still within the seating zone)?
-2.  **Drop rod lengths** — do you have the actual specified lengths from the ceiling drawings? The defaults currently use 400mm (centre), 500mm (main), 300mm (outer) — these directly affect coverage angles and SPL at each row.
-3.  **The 22.2m figure** — was that the structural building width (wall to wall) rather than the seating plan? The 20914mm seating plan makes sense as the inner dimension if there's roughly 640mm of wall/structure each side (22200 − 20914 = 1286mm / 2 = 643mm).
-
-Church acoustic suite
-
-Code · JSX
-
-#### User
-
-So to confirm speakers the Inner ring is 5720mm radius Outer ring is 8600mm radius Corner speakers 10310mm Radius. (2 per cnr) They are flush with the ceiling no droppers on them.
-
-#### Assistant
-
-Perfect — that's a fundamentally different configuration than I had. Let me lock in all the corrections:
-
-* * *
-
-### Confirmed speaker layout
-
-**No drop rods — all speakers flush with the 4300mm flat ceiling.**
-
-So every speaker's acoustic centre is at **4300mm AFF** (centre floor datum). No drop rod calculation needed — `dropRod = 0` for all rings.
-
-**Three rings confirmed:**
-
-| Ring | Count | Radius | Height AFF | Notes |
-| --- | --- | --- | --- | --- |
-| Centre | 1 | 0mm | 4300mm | flush ceiling |
-| Inner ring | 8 | 5720mm | 4300mm | flush ceiling |
-| Outer ring | 8 | 8600mm | 4300mm | flush ceiling |
-| Corner | 8 (2×4 corners) | 10310mm | 4300mm | flush, outside seating back aisle |
-
-**Corner speakers at 10310mm** — let's check where that sits:
-
-*   Row 8 back face: 8411mm
-*   Back aisle: 2046mm → wall at 10457mm
-*   Corner speakers at 10310mm = **147mm from the wall** — essentially in the corners, which aligns perfectly with the corner seating arrangement
-
-**With flush ceiling, the geometry simplifies:**
-
-*   Speaker height above centre datum = **4300mm** constant
-*   Ear height varies by row due to dish:
-    *   Row 1 mid-bench (~2652mm): ear = 1100mm → speaker-to-ear vertical = **3200mm**
-    *   Row 8 mid-bench (~7950mm): ear ≈ 1100 + 656mm = **1756mm** → speaker-to-ear vertical = **2544mm**
-*   This means the outer rows have significantly less ceiling clearance — closer to the speaker, steeper off-axis angles from the inner ring
-
-Now update all the places in the code that reference `"main"` ring key — it's now `"inner"`:
-
-Now update the flush ceiling handling — with `dropRod = 0`, speaker height = `ceilFlat` exactly. Also update the SpeakerTab to show "flush ceiling" and remove the drop rod field as a primary input (keep it but default 0):
-
-Now update the analysis prompt to reflect the confirmed 4-ring flush layout:
-
-Now verify no orphaned references and do a final check:
-
-All clean. Present the final file:
-
-All confirmed and clean. Here's the complete verified geometry now embedded:
-
-* * *
-
-### Final confirmed layout
-
-**Hall:** 20914mm × 20914mm seating plan · flat ceiling 4300mm · dish floor +700mm centre→row 8
-
-**All 4 speaker rings — flush mounted, no drop rods:**
-
-| Ring | Count | Radius | Notes |
-| --- | --- | --- | --- |
-| Centre | 1 | 0mm | Communion table zone |
-| Inner ring | 8 | 5720mm | Between rows 4–5 |
-| Outer ring | 8 | 8600mm | Just inside row 8 back (8411mm) |
-| Corner | 8 (2×4) | 10310mm | 147mm from wall, corner seating |
-
-**Total: 25 speakers, all at 4300mm AFF**
-
-The coverage heatmap will now compute accurate off-axis angles and Haas delays. A few things worth noting that will show up clearly in the Coverage tab:
-
-*   **Inner ring at 5720mm** is between rows 4 and 5 (row 4 mid ≈ 5533mm, row 5 mid ≈ 6416mm) — good positioning
-*   **Outer ring at 8600mm** is 189mm inside row 8 back face — will have very steep off-axis angles to corner rows
-*   **Corner speakers at 10310mm** serve the corner seating — at 147mm from the wall their coverage toward the inner rows will be a long throw at high off-axis angle, likely outside the 55° MS6 limit for most rows
-
-Do you have the power settings (watts) per ring, or should we model those next?
-
-Church acoustic suite
-
-Code · JSX
-
-#### User
-
-To confirm the ceiling height at the asile behind row 8 is 3600mm so the ceiling height infront of row 1 is 3600+738mm the ceiling height behind the last corner row is 2950. There are 4 rows in the corners with the same spacing. The front of the first corner row is +620mm from the aisle. The corner speakers are 1500 off the side walls.
-
-#### Assistant
-
-Now we have the complete 3D picture. Let me work through this carefully.
-
-* * *
-
-### Corrected ceiling geometry
-
-The ceiling is **not flat** — it slopes! The ceiling rises from the perimeter inward, mirroring the dish floor but as a separate slope.
-
-**Known ceiling heights:**
-
-*   Behind row 8 (aisle at r=8411mm): **3600mm**
-*   In front of row 1 (r=2210mm): 3600 + 738 = **4338mm**
-*   Behind last corner row: **2950mm**
+---
 
-So the ceiling slopes **down** as you move outward past row 8 into the corners. And slopes **up** as you move inward from row 8 toward the centre.
+## Acoustic Analysis Objectives
 
-**Ceiling slope — seating zone (centre → row 8):**
+### Key metrics (in priority order)
+1. **Direct-to-Reverberant ratio** per seat — primary intelligibility driver
+2. **Haas window compliance** — speakers reaching a listener more than 30ms after the nearest speaker cause echo/colouration; 10–30ms causes tonal colouration
+3. **Off-axis angle** per speaker per row — must be within 55° MS6 coverage cone
+4. **SPL at ear** per row — from inverse square law using speaker sensitivity and power
+5. **Flutter echo** — flat ceiling + rising floor creates parallel surfaces at decreasing clearance toward perimeter
 
-*   At r=2210mm: 4338mm
-*   At r=8411mm: 3600mm
-*   Drop over 6201mm: **738mm**
-*   Slope: 738/6201 = **1:8.4** (close to 1:8 as per the SK-303 drawing title "1 in 8 slope")
+### Haas window rules applied in code
+- **Primary:** nearest speaker to that row (0ms excess) — green
+- **OK:** 0–10ms Haas excess — blue (slight colouration, acceptable)
+- **Warn:** 10–30ms Haas excess — amber (audible colouration)
+- **Danger:** >30ms Haas excess — red (distinct echo)
 
-That confirms the **1:8 slope is the CEILING slope**, not the floor slope — or rather both slope, creating the consistent seating rake geometry.
+### Flutter echo analysis
+- Flutter frequency at each zone = speed of sound / (2 × floor-to-ceiling clearance at ear level)
+- At row 8 (clearance ≈ 2,500mm ear-to-ceiling): flutter fundamental ≈ 69Hz, harmonics at 137Hz, 206Hz — in speech fundamental range
+- 600mm tile grid creates diffraction grating effect at ~571Hz
 
-**Ceiling height formula (linear interpolation):**
+---
 
+## Codebase
+
+### Files
+
+| File | Description |
+|---|---|
+| [church-acoustic-suite.jsx](church-acoustic-suite.jsx) | Complete single-file React application — all components, geometry engine, UI |
+| [CLAUDE.md](CLAUDE.md) | This project reference document |
+| [CLAUDE_CHAT.md](CLAUDE_CHAT.md) | Full transcript of the original design chat (historical reference) |
+| [README.md](README.md) | Repository readme |
+
+### App structure (church-acoustic-suite.jsx)
+
+**Constants:**
+- `C` — colour palette (dark theme)
+- `HALL_DEFAULTS` — confirmed hall geometry (20,914mm sq, 8 rows, dish floor, flat ceiling)
+- `SPEAKER_DEFAULTS` — 4 rings: centre, inner (×8, r=5720mm), outer (×8, r=8600mm), corner (×8, r=10310mm)
+- `SETTINGS_DEFAULTS` — Ollama/Claude API config, REW host/port, system prompt
+
+**Geometry engine:**
+- `ceilHeightAtRadius(r, hall)` — returns floor-to-ceiling clearance at radius r
+- `floorHeightAtRadius(r, hall)` — returns floor rise above centre datum at radius r
+- `computeHallGeometry(hall)` — builds rowData array with per-row geometry
+- `computeCoverage(hall, speakers, rowData)` — computes slant distance, delay, Haas status, off-axis angle, SPL per row×ring combination
+- `computeFlutter(hall)` — calculates flutter echo fundamentals and harmonics
+
+**Components:**
+- `SectionDiagram` — SVG cross-section showing ceiling, floor dish, speaker positions and coverage rays
+- `CoverageHeatmap` — SVG grid (rows × speaker rings) colour-coded by Haas status
+- `HallTab` — Hall dimension inputs, ceiling/dish inputs, row geometry table, flutter analysis
+- `SpeakerTab` — Per-ring editor (radius, power, sensitivity, phase, drop rod)
+- `CoverageTab` — Coverage heatmap + full data table
+- `AnalysisTab` — REW API connection + Ollama/Claude analysis with streaming
+- `SettingsTab` — LLM provider selection (Ollama or Anthropic), REW host/port, system prompt
+- `App` — Root with tab navigation and log
+
+### Known code issue to fix
+
+The `ceilHeightAtRadius()` function currently stores three separate ceiling heights (`ceilAtRow1Front: 4338`, `ceilAtRow8Back: 3600`, `ceilAtCornerBack: 2950`) and interpolates between them — this incorrectly models a **sloped ceiling**.
+
+**Correct model:** ceiling is flat at a single absolute height (`ceilFlat = 4300mm`) above centre datum. Floor-to-ceiling at any radius = `4300 − floorHeightAtRadius(r)`. The corner area ceiling (2950mm) is a separate structural change that should be applied only in the corner zone.
+
+The `HALL_DEFAULTS` and geometry engine need to be refactored to:
+```js
+ceilFlat: 4300,         // flat ceiling, mm above centre floor datum
+cornerCeilClearance: 2950,  // floor-to-ceiling in corner zone (structural drop)
+dishRise: 700,          // floor rise from centre to row 8 back face
 ```
-ceilHeight(r) = 4338 - (r - 2210) × (738/6201)   for r = 2210 to 8411mm
-              = 4338 - (r - 2210) × 0.1190
-```
-
-**Corner zone ceiling (r=8411mm outward → drops to 2950mm):**
-
-*   Corner rows: front of first corner row = 8411 + 2046 + 620 = **11077mm** from centre
-
-Wait — let me re-read. Back aisle = 2046mm, so aisle spans r=8411 to r=10457mm. Front of first corner row = \*\*10457 + 620 = ... \*\* no — the 620mm is from the aisle into the corner seating, so:
-
-*   Aisle back wall: r = 10457mm
-*   Front of first corner row: r = 10457 - 620 = **9837mm** from centre?
-
-Or more likely the corner rows are in the corner of the square plan — not on the same radial axis. The corner seating fills the triangular zones at 45° between the radial aisle lines. The "1500mm off the side walls" for corner speakers confirms this — they're positioned within the corner square zones.
-
-Let me reconsider the corner geometry:
-
-**Corner seating (square corners of the 20914mm hall):**
-
-*   The seating plan is 20914mm square
-*   Half-width = 10457mm
-*   Corner rows sit in the corner zones outside the circular back aisle
-*   Front of first corner row: **aisle back face + 620mm = 10457 + 620 = 11077mm** from centre along the diagonal...
-
-Actually the 620mm is likely measured **from the back aisle wall** (r=10457mm) inward to the front of corner row 1, so the corner rows are **outside** the main circular seating and occupy the square corners.
-
-**Corner rows (4 rows, 883mm pitch, from aisle wall inward):**
 
-*   Corner row 1 front: 10457 + 620 = not right either — they'd be outside the building.
-
-The most logical interpretation: the corner rows face **inward** from the corner walls, with front of row 1 at **620mm from the back aisle back wall**, measuring into the corner zone:
-
-*   Corner row 1 front edge from centre (along diagonal): the corner zone starts at the intersection of the back aisle and side aisle
-*   Corner speakers at r=10310mm, 1500mm off side walls
-
-**Let me nail this with the wall positions:**
-
-*   Hall half-width = 10457mm
-*   Side walls at ±10457mm on each axis
-*   Corner speakers "1500mm off side walls" = at **10457 - 1500 = 8957mm** from centre on each axis
-*   Corner speaker plan position: (8957, 8957) on the diagonal = radius = 8957 × √2 = **12670mm** — that's outside the building. So "1500mm off side walls" means measured **along the wall**, not radially.
-
-So corner speaker is at coordinates approximately **(10457-1500, 10457-something)** — I need the second dimension to pin it. The 10310mm radius given earlier likely refers to the **radial distance** from centre to the speaker position in plan.
-
-If radius = 10310mm and it's 1500mm from one side wall (10457mm), then the other coordinate is:
-
-```
-√(10310² - (10457-1500)²) = √(106296100 - 80213449) = √26082651 = 5107mm
-```
+---
 
-So corner speaker at approximately **(8957mm, 5107mm)** from centre — that places it roughly over the corner seating zone. This makes sense.
+## REW API Integration
 
-* * *
+- REW runs locally on port 4735 by default
+- Endpoint: `GET /application` — connection test
+- Endpoint: `PUT /roomsim/room-size` — push room dimensions
+- Endpoint: `GET /roomsim/frequency-response?micposition=Main&ppo=24` — fetch simulated frequency response (Base64 big-endian float32 array)
+- Full API spec: https://www.roomeqwizard.com/help/help_en-GB/html/api.html
+- REW must be started with API enabled (Preferences → API, or `-api` launch flag)
+- Browser must run on same machine as REW (localhost only by design)
 
-### Revised ceiling heights
+---
 
-**Ceiling at each key position:**
+## LLM Integration
 
-| Position | Radius | Ceiling height |
-| --- | --- | --- |
-| Row 1 front | 2210mm | **4338mm** |
-| Row 8 back | 8411mm | **3600mm** |
-| Aisle (mid) | ~9430mm | interpolating down |
-| Corner zone | varies | dropping to **2950mm** |
+**Ollama (preferred — local/private):**
+- Requires `OLLAMA_ORIGINS=* ollama serve` for CORS
+- Models: pull e.g. `ollama pull llama3.2`, `mistral`, `qwen2.5`
+- Endpoint: `POST /api/chat` with streaming
 
-**The ceiling slope continues past row 8 into the aisle and corner zone, dropping from 3600mm to 2950mm.**
+**Anthropic Claude (fallback):**
+- API key entered in Settings tab
+- Endpoint: `POST https://api.anthropic.com/v1/messages`
+- Default model: claude-sonnet-4-20250514
 
-Corner ceiling drop: 3600 → 2950 = **650mm** over the corner zone distance.
+**Analysis prompt:** Sends complete hall geometry, per-row coverage data (slant, delay, Haas, off-axis, SPL), flutter echo analysis, and optional REW frequency data. Requests structured sections: hall assessment, coverage issues, speaker position optimisation, Haas/delay analysis, anti-phasing assessment, MR12 EQ recommendations, ARTA measurement protocol.
 
-Now let me update the tool with all of this:
+---
 
-Now rewrite the geometry engine to handle the sloped ceiling and corner rows properly:
+## Build / Run
 
-Now I can see the exact lines to update. Let me do a targeted replacement of the whole file with a script:
+This is a standalone React JSX file intended for use in a CodeSandbox, Vite React project, or similar. It uses only React hooks (`useState`, `useCallback`, `useRef`, `useEffect`) with no external dependencies beyond React itself.
 
-Now update the Hall tab UI to show the new sloped ceiling inputs and fix the analysis prompt:
+To run locally:
+1. Create a Vite React project: `npm create vite@latest -- --template react`
+2. Replace `src/App.jsx` with `church-acoustic-suite.jsx`
+3. `npm run dev`
+4. Start REW on same machine with API enabled
+5. Start Ollama with CORS enabled: `OLLAMA_ORIGINS=* ollama serve`

+ 16 - 0
Dockerfile

@@ -0,0 +1,16 @@
+# Stage 1 — build
+FROM node:22-alpine AS build
+WORKDIR /app
+
+COPY package*.json ./
+RUN npm ci
+
+COPY . .
+RUN npm run test:run && npm run build
+
+# Stage 2 — serve
+FROM nginx:alpine AS serve
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
+CMD ["nginx", "-g", "daemon off;"]

+ 195 - 0
README.md

@@ -0,0 +1,195 @@
+# Church Acoustic Suite
+
+A purpose-built acoustic analysis tool for PBCC Universal Hall distributed speaker systems. Models the MS6 ceiling speaker layout, Haas window compliance, and off-axis coverage — then connects to Room EQ Wizard (REW) for live measurement analysis via a local LLM (Ollama) or Claude API.
+
+## What it does
+
+- **Hall geometry engine** — models the dished floor (bowl), flat ceiling, and all seating rows including corner zones; computes floor rise, ear-to-ceiling clearance, and absolute heights per row
+- **Coverage analysis** — slant distance, propagation delay, Haas window status, off-axis angle, and estimated SPL at ear for every speaker ring × seating row combination
+- **Flutter echo analysis** — calculates flutter fundamental frequencies and harmonics across the hall; flags speech-band risk zones
+- **REW integration** — connects to REW's local API (port 4735) to fetch frequency response and measurement data
+- **AI analysis** — sends geometry + coverage + REW data to Ollama (local) or Anthropic Claude for actionable acoustic recommendations tuned to this hall type
+
+## Prerequisites
+
+- **Room EQ Wizard** with API server enabled ([REW API docs](https://www.roomeqwizard.com/help/help_en-GB/html/api.html))
+- **Ollama** (recommended) or an Anthropic API key
+- **Docker** (recommended) — or Node.js ≥ 18 for local development
+
+## Docker (recommended)
+
+The easiest way to run the app. Docker handles the Node.js build inside the container — no local Node.js required.
+
+```bash
+# Build and start (runs tests during build)
+docker compose up --build
+
+# Open in browser
+# http://localhost:3000
+```
+
+REW (port 4735) and Ollama (port 11434) are called from your browser, not the container, so they reach your local machine as normal `localhost` addresses.
+
+To stop:
+
+```bash
+docker compose down
+```
+
+## Local development (Node.js)
+
+```bash
+npm install
+
+# Development server with hot reload
+npm run dev
+
+# Run tests
+npm test
+
+# Run tests once (CI)
+npm run test:run
+
+# Test coverage report
+npm run test:coverage
+
+# Production build
+npm run build
+```
+
+## REW setup
+
+1. Open REW → Preferences → API and enable the API server (default port 4735)
+2. Alternatively launch REW with the `-api` flag
+3. The app connects to `http://127.0.0.1:4735` by default — change in Settings if needed
+4. REW's API is localhost-only by design; the app and REW must run on the same machine
+
+## Ollama setup
+
+Ollama requires CORS to be enabled for the browser to reach it:
+
+```bash
+OLLAMA_ORIGINS=* ollama serve
+```
+
+Pull a model before starting (examples):
+
+```bash
+ollama pull llama3.2
+ollama pull mistral
+ollama pull qwen2.5
+```
+
+The Settings tab lets you configure the host, port, and fetch available models dynamically.
+
+## Hall geometry
+
+The geometry engine models the confirmed PBCC Universal Hall (8-row) dimensions:
+
+| Parameter | Value | Source |
+| --------- | ----- | ------ |
+| Seating plan | 20,914 × 20,914 mm | SK-303 drawing |
+| Row 1 front edge | 2,210 mm from centre | SK-303 |
+| Row pitch (FFL–FFL) | 883 mm | SK-303 |
+| Row 8 back face | 8,411 mm | SK-303 |
+| Back aisle | 2,046 mm | Derived: 10,457 − 8,411 |
+| Corner rows | 4 rows, 883 mm pitch | Site survey |
+| Ceiling | **FLAT** at 4,300 mm above centre datum | Survey |
+| Floor at centre | 0 mm (lowest point, communion table) | Survey |
+| Floor at row 8 back | +700 mm (dish rise) | Survey: 4,300 − 3,600 = 700 |
+| Floor-to-ceiling at row 8 | 3,600 mm | Confirmed survey |
+| Corner zone ceiling | 2,950 mm floor-to-ceiling (structural drop) | Survey |
+
+**Key rule:** The ceiling is a single flat horizontal plane. The floor rises (dished bowl) from 0 mm at the communion table zone outward to 700 mm at row 8. Floor-to-ceiling clearance therefore *decreases* toward the perimeter.
+
+## Speaker layout
+
+All speakers are flush-mounted in the 600 mm × 600 mm acoustic ceiling tile grid (no drop rods). Power settings per Dudley Wilkin's 2010 Aberdeen 8-row hall proposal:
+
+| Ring | Count | Radius | Power | Coverage target |
+| ---- | ----- | ------ | ----- | --------------- |
+| Centre | 1 | 0 mm | 6 W | Rows 1–3 |
+| Inner ring | 8 | 5,720 mm | 8 W | Rows 4–7 |
+| Outer ring | 8 | 8,600 mm | 4 W | Row 8 + back aisle |
+| Corner | 8 (2 per corner) | 10,310 mm | 4 W | Corner seating |
+
+## MS6 speaker
+
+The MS6 is a two-driver ceiling speaker designed by Dudley Wilkin for this hall type:
+
+- **LF driver**: Visaton FRS8M — 8 Ω, 30 W, 88 dB/1 W/1 m, response 100 Hz–20 kHz
+- **HF driver**: Visaton G20SC — 8 Ω, 20 mm soft dome, 88 dB/1 W/1 m, response 1,200 Hz–30 kHz
+- **Crossover**: MIDAS MR12 active crossover — LF cut at 2,792 Hz, HF starts at 3,225 Hz
+- **Gap**: 433 Hz gap centred at 3 kHz (less than 1/5 octave — inaudible)
+- **Purpose of gap**: Eliminates horizontal interference lobes that conventional crossovers produce at the 2–5 kHz consonant frequencies (P, T, F, S) critical for speech intelligibility
+- **Coverage**: 55° off-axis — near-perfect circular polar response at both 30° and 55°
+
+Signal chain: `Mono mix → MIDAS MR12 Ch1 (TEQ room EQ) → Bus 1/2 → AUX1 LF amp + AUX2 HF amp → 100 V line → transformers → speakers`
+
+## Anti-phasing
+
+Adjacent speaker segments are wired in anti-phase. This creates acoustic null lines along aisle centrelines, so each listener receives sound from only one speaker. This is the primary intelligibility mechanism — it dramatically improves gain-before-feedback and removes inter-speaker comb filtering.
+
+**Measurement note**: When setting EQ, select the main ring with anti-phase enabled and disconnect the centre speaker. A microphone placed under any main ring speaker will then pick up only that speaker. (Dudley Wilkin — GEQ setup method, September 2014)
+
+## EQ setup procedure (summary)
+
+Full method: Dudley Wilkin, September 2014
+
+1. Only one speaker active — main ring with anti-phase, centre disconnected
+2. Mic at 1,000 mm on-axis below the speaker
+3. Set all tone controls and EQ to flat
+4. **Coarse**: pink noise through system, TrueRTA at 1/3-octave, adjust EQ to flat
+5. **Fine**: frequency scan in TrueRTA at 1/24-octave, remove peaks, fill troughs
+6. Save and photograph all settings — never adjust EQ by ear
+7. Adjust tone controls by voice: reduce treble to tame "S" sibilance, minimal bass for natural sound
+
+## Measurement protocol
+
+**Only impulse-based measurement is valid** for this speaker system. Continuous sound (pink noise, SLM readings) is invalid because:
+
+- Each speaker covers only one zone (anti-phase nulls elsewhere)
+- A standard SLM picks up all speakers simultaneously — the result is meaningless
+- RT60 is only valid when measured through the live system with all speakers operating
+
+Use REW or ARTA with an impulse window. The minimum measurement window required is **10 ms** — this requires sufficient ceiling clearance (at minimum 2,000 mm microphone distance from the speaker).
+
+Reference: Dudley Wilkin — "Loudspeaker Positions and Continuous Sound"
+
+## Haas window
+
+| Status | Delay beyond primary | Meaning |
+| ------ | -------------------- | ------- |
+| Primary | 0 ms | Nearest speaker to this row |
+| OK | 0–10 ms | Acceptable — fused with primary |
+| Warn | 10–30 ms | Colouration risk |
+| Danger | > 30 ms | Distinct echo — intelligibility impact |
+
+## Project structure
+
+```text
+church-acoustic-suite/
+├── src/
+│   ├── main.jsx               # React entry point
+│   ├── App.jsx                # All tabs and UI components
+│   ├── geometry.js            # Pure acoustic geometry functions (testable)
+│   ├── constants.js           # Hall defaults, speaker defaults, settings defaults
+│   ├── palette.js             # Dark-theme colour constants
+│   └── __tests__/
+│       └── geometry.test.js   # 35+ unit and integration tests
+├── documents/                 # Reference documents (Dudley Wilkin)
+├── church-acoustic-suite.jsx  # Original monolithic prototype (reference)
+├── index.html
+├── vite.config.js
+├── package.json
+└── CLAUDE.md                  # Codebase reference for AI assistants
+```
+
+## Background
+
+This tool is informed by 40+ years of acoustic engineering documentation for the PBCC Universal Hall format by Dudley Wilkin, covering:
+
+- Speaker system evolution (1970s corner columns → 1982 ceiling ring → 1986 anti-phasing → 2010 MS6 layout)
+- The MS6 crossover innovation eliminating horizontal interference lobes
+- The Perth UK trial confirming the centre speaker alone covers rows 1–6
+- The case against continuous-sound measurement methods for this hall type

+ 1149 - 0
church-acoustic-suite.jsx

@@ -0,0 +1,1149 @@
+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 }) => (
+  <div style={{ marginBottom: 5 }}>
+    <label style={{ display: "block", fontSize: 10, letterSpacing: 1.3, textTransform: "uppercase", color: C.muted }}>{children}</label>
+    {sub && <span style={{ fontSize: 10, color: C.muted, opacity: 0.7 }}>{sub}</span>}
+  </div>
+);
+
+const Field = ({ label, sub, children }) => (
+  <div style={{ marginBottom: 14 }}>
+    <Lbl sub={sub}>{label}</Lbl>
+    {children}
+  </div>
+);
+
+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 }) => (
+  <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+    <input type="number" value={value} min={min} max={max} step={step} onChange={e => onChange(+e.target.value || 0)} style={{ ...numStyle, ...sx }} />
+    {unit && <span style={{ fontSize: 11, color: C.muted, whiteSpace: "nowrap" }}>{unit}</span>}
+  </div>
+);
+
+const TxtIn = ({ value, onChange, type = "text", placeholder, style: sx }) => (
+  <input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
+    style={{ ...numStyle, ...sx }} />
+);
+
+const Btn = ({ onClick, disabled, children, color = C.accent, outline, style: sx }) => (
+  <button onClick={onClick} disabled={disabled} style={{
+    background: outline ? "transparent" : disabled ? C.border : color,
+    color: outline ? color : disabled ? C.muted : C.bg,
+    border: `1px solid ${outline ? color : disabled ? C.border : color}`,
+    borderRadius: 7, padding: "9px 18px", fontSize: 12, fontWeight: 700,
+    letterSpacing: 0.5, cursor: disabled ? "not-allowed" : "pointer",
+    fontFamily: "inherit", transition: "all 0.15s", ...sx,
+  }}>{children}</button>
+);
+
+const Tag = ({ color = C.accent, children, size = "sm" }) => (
+  <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>
+);
+
+const Dot = ({ color }) => <span style={{ display: "inline-block", width: 7, height: 7, borderRadius: "50%", background: color, flexShrink: 0 }} />;
+
+const Panel = ({ children, style: sx, accent }) => (
+  <div style={{ background: C.panel, border: `1px solid ${accent ? accent + "40" : C.border}`, borderRadius: 12, padding: 20, marginBottom: 14, ...sx }}>{children}</div>
+);
+
+const SectionHead = ({ children, sub }) => (
+  <div style={{ marginBottom: 16 }}>
+    <h2 style={{ margin: 0, color: C.heading, fontSize: 14, fontWeight: 700, letterSpacing: 0.3 }}>{children}</h2>
+    {sub && <p style={{ margin: "4px 0 0", fontSize: 11, color: C.muted }}>{sub}</p>}
+  </div>
+);
+
+const Divider = () => <div style={{ height: 1, background: C.border, margin: "16px 0" }} />;
+
+// ─── 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 (
+    <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", background: "#07090f", borderRadius: 8, border: `1px solid ${C.border}` }}>
+      {/* flat ceiling */}
+      {/* Sloped ceiling line */}
+      {(() => { 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} />; })()}
+      <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>
+      {/* dish floor — rises from centre to perimeter */}
+      <polyline
+        points={`${cx(0)},${cy(0)} ` + rowData.map(r => `${cx(r.radius)},${cy(r.floorRise)}`).join(" ")}
+        fill="none" stroke={C.borderHi} strokeWidth={1.5} strokeDasharray="4,3" />
+      <text x={cx(outerR) + 4} y={cy(hall.dishRise) - 4} fill={C.muted} fontSize={9}>Floor (+{hall.dishRise}mm rise)</text>
+      {/* rows — vertical line from floor to ear height */}
+      {rowData.map((r) => (
+        <g key={r.row}>
+          <line x1={cx(r.radius)} y1={cy(r.floorRise)} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={C.border} strokeWidth={1} />
+          <circle cx={cx(r.radius)} cy={cy(r.earAFF)} r={3} fill={C.accent} opacity={0.8} />
+          <text x={cx(r.radius)} y={cy(r.floorRise) + 12} fill={C.muted} fontSize={8} textAnchor="middle">{r.isCorner ? "C" : r.row}</text>
+        </g>
+      ))}
+      {/* speakers */}
+      {Object.entries(speakers).map(([ring, spk]) => {
+        if (!spk.enabled) return null;
+        const spkH  = ceilHeightAtRadius(spk.radius, hall) - spk.dropRod;
+        const spkY  = cy(spkH);
+        const spkX  = cx(spk.radius);
+        const color = ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent;
+        return (
+          <g key={ring}>
+            <line x1={spkX} y1={cy(ceilHeightAtRadius(spk.radius,hall))} x2={spkX} y2={spkY} stroke={color} strokeWidth={1} strokeDasharray="2,2" opacity={0.5} />
+            <rect x={spkX - 7} y={spkY - 4} width={14} height={8} fill={color} rx={2} opacity={0.9} />
+            <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>
+            {/* rays to each row */}
+            {rowData.map(r => {
+              const res = computeCoverage(hall, { [ring]: spk }, [r])[0]?.speakers[ring];
+              if (!res) return null;
+              const rayColor = res.inCoverage
+                ? ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[res.haasStatus] || C.muted)
+                : "#444";
+              return <line key={r.row} x1={spkX} y1={spkY} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={rayColor} strokeWidth={0.7} opacity={0.3} />;
+            })}
+          </g>
+        );
+      })}
+      {/* legend */}
+      {[["●", C.accent, "Ear pos"], ["▬", C.gold, "Centre"], ["▬", C.good, "Main ring"], ["▬", C.purple, "Outer ring"], ["—", C.good, "Haas OK"], ["—", C.gold, "Warn"], ["—", C.danger, "Echo/OOB"]].map(([sym, col, lbl], i) => (
+        <text key={i} x={PAD + i * 82} y={H - 6} fill={col} fontSize={7}>{sym} {lbl}</text>
+      ))}
+    </svg>
+  );
+}
+
+// ─── 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 (
+    <div style={{ overflowX: "auto" }}>
+      <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: "block", minWidth: W }}>
+        {/* column headers */}
+        {rings.map(([ring, spk], ci) => (
+          <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>
+        ))}
+        {coverage.map((row, ri) => (
+          <g key={row.row}>
+            {/* row label */}
+            <text x={lblW - 6} y={28 + ri * cellH + cellH / 2 + 4} fill={C.muted} fontSize={10} textAnchor="end">
+              {row.row === "C" ? "Corner" : `Row ${row.row}`}
+            </text>
+            {rings.map(([ring], ci) => {
+              const d = row.speakers[ring];
+              if (!d) return null;
+              const bg = d.inCoverage
+                ? d.haasStatus === "primary" ? C.goodBg : d.haasStatus === "ok" ? C.accentBg : d.haasStatus === "warn" ? C.goldBg : C.dangerBg
+                : C.dangerBg;
+              const borderCol = d.inCoverage
+                ? haasColor(d.haasStatus)
+                : C.danger;
+              return (
+                <g key={ring}>
+                  <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} />
+                  <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>
+                  <text x={lblW + ci * cellW + cellW / 2} y={28 + ri * cellH + 29} fill={haasColor(d.haasStatus)} fontSize={9} textAnchor="middle">{haasLabel(d.haasStatus)}</text>
+                  <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>
+                </g>
+              );
+            })}
+          </g>
+        ))}
+      </svg>
+    </div>
+  );
+}
+
+// ─── 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 (
+    <div>
+      <Panel>
+        <SectionHead sub="22.2m × 22.2m square — adjust per hall">Hall Dimensions</SectionHead>
+        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
+          <Field label="Hall Width" sub="square plan"><NumIn value={hall.hallWidth} onChange={v => setHall(h => ({ ...h, hallWidth: v }))} unit="mm" /></Field>
+          <Field label="Number of Rows"><NumIn value={hall.rows} onChange={v => setHall(h => ({ ...h, rows: Math.max(1, v) }))} min={1} max={12} /></Field>
+          <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>
+          <Field label="Row Pitch" sub="FFL to FFL"><NumIn value={hall.rowPitch} onChange={v => setHall(h => ({ ...h, rowPitch: v }))} unit="mm" /></Field>
+          <Field label="Corner Aisle Width" sub="aisle before corner rows"><NumIn value={hall.aisleWidth} onChange={v => setHall(h => ({ ...h, aisleWidth: v }))} unit="mm" /></Field>
+          <Field label="Ear Height AFF" sub="seated listener"><NumIn value={hall.earHeight} onChange={v => setHall(h => ({ ...h, earHeight: v }))} unit="mm" /></Field>
+        </div>
+        <Divider />
+        <SectionHead sub="Ceiling slopes down toward perimeter · floor dishes upward · 1-in-8 slope">Ceiling & Dish Floor</SectionHead>
+        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14 }}>
+          <Field label="Ceiling at Row 1 Front" sub="highest point — r=2210mm">
+            <NumIn value={hall.ceilAtRow1Front} onChange={v => setHall(h => ({ ...h, ceilAtRow1Front: v }))} unit="mm" />
+          </Field>
+          <Field label="Ceiling at Row 8 Back" sub="r=8411mm · aisle level">
+            <NumIn value={hall.ceilAtRow8Back} onChange={v => setHall(h => ({ ...h, ceilAtRow8Back: v }))} unit="mm" />
+          </Field>
+          <Field label="Ceiling at Corner Back" sub="behind last corner row">
+            <NumIn value={hall.ceilAtCornerBack} onChange={v => setHall(h => ({ ...h, ceilAtCornerBack: v }))} unit="mm" />
+          </Field>
+          <Field label="Floor Dish Rise" sub="centre=0 → row 8 back">
+            <NumIn value={hall.dishRise} onChange={v => setHall(h => ({ ...h, dishRise: v }))} unit="mm" />
+          </Field>
+          <Field label="Corner Rows" sub="rows in each corner">
+            <NumIn value={hall.cornerRows} onChange={v => setHall(h => ({ ...h, cornerRows: Math.max(1,v) }))} min={1} max={8} />
+          </Field>
+          <Field label="Corner Row 1 Offset" sub="from aisle back wall inward">
+            <NumIn value={hall.cornerFrontOffset} onChange={v => setHall(h => ({ ...h, cornerFrontOffset: v }))} unit="mm" />
+          </Field>
+        </div>
+        <div style={{ padding: "10px 14px", background: C.bgAlt, borderRadius: 8, display: "flex", gap: 20, flexWrap: "wrap", fontSize: 11, color: C.muted }}>
+          <span>Overall plan: <strong style={{ color: C.heading }}>{(hall.hallWidth / 1000).toFixed(3)}m sq</strong></span>
+          <span>Half-width: <strong style={{ color: C.heading }}>{(hall.hallWidth / 2).toLocaleString()}mm</strong></span>
+          <span>Tile grid: <strong style={{ color: C.heading }}>{hall.tileGrid}mm</strong></span>
+          <span>Dish slope: <strong style={{ color: C.gold }}>1:{Math.round(hall.row8Back / hall.dishRise)}</strong></span>
+          <span>Rise/row ≈ <strong style={{ color: C.heading }}>{Math.round(hall.dishRise / hall.rows)}mm</strong></span>
+          <span>Ceil @ row 1: <strong style={{ color: C.accent }}>{hall.ceilAtRow1Front}mm</strong></span>
+          <span>Ceil @ row 8: <strong style={{ color: C.accent }}>{hall.ceilAtRow8Back}mm</strong></span>
+          <span>Ceil @ corner: <strong style={{ color: (hall.ceilAtCornerBack - hall.earHeight - hall.dishRise) < 1200 ? C.warn : C.accent }}>{hall.ceilAtCornerBack}mm</strong></span>
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead>Section Diagram</SectionHead>
+        <SectionDiagram hall={hall} speakers={SPEAKER_DEFAULTS} rowData={rowData} />
+      </Panel>
+
+      <Panel>
+        <SectionHead sub="Flat ceiling at 4300mm — ear-to-ceiling decreases as floor rises toward perimeter">Row Geometry Table</SectionHead>
+        <div style={{ overflowX: "auto" }}>
+          <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11 }}>
+            <thead>
+              <tr>{["Row", "Front Edge", "Ear Radius", "Floor Rise", "Ceiling", "Ear AFF", "Ear→Ceiling"].map(h => (
+                <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>
+              ))}</tr>
+            </thead>
+            <tbody>
+              {rowData.map((r, i) => (
+                <tr key={r.row} style={{ background: i % 2 === 0 ? C.panelAlt : "transparent" }}>
+                  {[
+                    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) => (
+                    <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>
+                  ))}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      </Panel>
+
+      <Panel accent={C.warn}>
+        <SectionHead sub="Flat ceiling + dished floor — flutter risk increases toward perimeter">Flutter Echo Analysis</SectionHead>
+        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14, marginBottom: 14 }}>
+          {[
+            ["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]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "12px 14px" }}>
+              <div style={{ fontSize: 10, color: C.muted, marginBottom: 4, letterSpacing: 1, textTransform: "uppercase" }}>{lbl}</div>
+              <div style={{ fontSize: 20, fontWeight: 700, color: col, fontFamily: "monospace" }}>{val}</div>
+            </div>
+          ))}
+        </div>
+        <div style={{ fontSize: 11, color: C.muted, lineHeight: 1.7 }}>
+          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 />
+          <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>
+        </div>
+      </Panel>
+    </div>
+  );
+}
+
+// ─── 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 (
+      <Panel accent={color} style={{ marginBottom: 10 }}>
+        <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 14 }}>
+          <div style={{ width: 10, height: 10, borderRadius: "50%", background: color }} />
+          <h3 style={{ margin: 0, color: C.heading, fontSize: 14 }}>{spk.label}</h3>
+          <label style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: C.muted, cursor: "pointer" }}>
+            <input type="checkbox" checked={spk.enabled} onChange={e => set("enabled", e.target.checked)} style={{ accentColor: color }} />
+            Enabled
+          </label>
+        </div>
+        {spk.enabled && (
+          <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
+            <Field label="Count"><NumIn value={spk.count} onChange={v => set("count", v)} min={1} max={16} /></Field>
+            <Field label="Radius from Centre" sub="plan position"><NumIn value={spk.radius} onChange={v => set("radius", v)} unit="mm" /></Field>
+            <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>
+            <Field label="Power per speaker"><NumIn value={spk.power} onChange={v => set("power", v)} step={0.5} unit="W" /></Field>
+            <Field label="Sensitivity" sub="dB/1W/1m"><NumIn value={spk.sensitivity} onChange={v => set("sensitivity", v)} unit="dB" /></Field>
+            <Field label="Phase">
+              <select value={spk.phase} onChange={e => set("phase", +e.target.value)}
+                style={{ ...numStyle }}>
+                <option value={1}>Normal (+)</option>
+                <option value={-1}>Anti-phase (−)</option>
+              </select>
+            </Field>
+            <Field label="Speaker Acoustic Centre" sub={spk.dropRod === 0 ? "Flush ceiling ✓" : `${spk.dropRod}mm drop rod`}>
+              <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.gold, fontFamily: "monospace" }}>
+                {spkHeightAFF.toLocaleString()} mm
+              </div>
+            </Field>
+            <Field label="Radial position" sub="nearest row">
+              <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 12, color: C.text }}>
+                {(() => {
+                  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)`;
+                })()}
+              </div>
+            </Field>
+            <Field label="Total power" sub="ring total">
+              <div style={{ padding: "7px 10px", background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.text, fontFamily: "monospace" }}>
+                {(spk.power * spk.count).toFixed(1)} W
+              </div>
+            </Field>
+          </div>
+        )}
+      </Panel>
+    );
+  };
+
+  const totalPower = Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.power * s.count, 0);
+
+  return (
+    <div>
+      <Panel style={{ marginBottom: 10 }}>
+        <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
+          {[
+            ["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]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "10px 16px", flex: 1, minWidth: 120 }}>
+              <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: "uppercase", marginBottom: 3 }}>{lbl}</div>
+              <div style={{ fontSize: 16, fontWeight: 700, color: col }}>{val}</div>
+            </div>
+          ))}
+        </div>
+      </Panel>
+      <RingEditor ringKey="centre" color={C.gold} />
+      <RingEditor ringKey="inner" color={C.good} />
+      <RingEditor ringKey="outer" color={C.purple} />
+      <RingEditor ringKey="corner" color={C.accent} />
+    </div>
+  );
+}
+
+// ─── 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 (
+    <div>
+      <Panel>
+        <div style={{ display: "flex", gap: 12, flexWrap: "wrap", marginBottom: 4 }}>
+          {[
+            ["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]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: "10px 14px", flex: 1, minWidth: 100 }}>
+              <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: "uppercase", marginBottom: 3 }}>{lbl}</div>
+              <div style={{ fontSize: 18, fontWeight: 700, color: col }}>{val}</div>
+            </div>
+          ))}
+        </div>
+        <div style={{ display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
+          {[["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]) => (
+            <Tag key={lbl} color={col}>{lbl}</Tag>
+          ))}
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead sub="Slant distance · propagation delay · Haas status · off-axis angle · SPL at ear">Coverage Heatmap — All Rows × All Speaker Rings</SectionHead>
+        <CoverageHeatmap coverage={coverage} speakers={speakers} />
+      </Panel>
+
+      <Panel>
+        <SectionHead sub="Full data table">Per-Row Coverage Detail</SectionHead>
+        <div style={{ overflowX: "auto" }}>
+          <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 11 }}>
+            <thead>
+              <tr>
+                {["Row", "Radius", "Ring", "Slant (m)", "Delay (ms)", "Haas +Δms", "Off-axis °", "SPL (dB)", "In cone", "Status"].map(h => (
+                  <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>
+                ))}
+              </tr>
+            </thead>
+            <tbody>
+              {coverage.flatMap((row, ri) =>
+                Object.entries(row.speakers).filter(([, d]) => d).map(([ring, d], si) => (
+                  <tr key={`${ri}-${si}`} style={{ background: ri % 2 === 0 && si === 0 ? C.panelAlt : "transparent" }}>
+                    <td style={{ padding: "5px 8px", color: C.gold }}>{row.row === "C" ? "Corner" : `Row ${row.row}`}</td>
+                    <td style={{ padding: "5px 8px", color: C.muted, fontFamily: "monospace" }}>{(row.radius / 1000).toFixed(3)}m</td>
+                    <td style={{ padding: "5px 8px", color: ring === "centre" ? C.gold : ring === "inner" ? C.good : ring === "outer" ? C.purple : C.accent }}>{speakers[ring].label}</td>
+                    <td style={{ padding: "5px 8px", fontFamily: "monospace" }}>{d.slantM.toFixed(3)}</td>
+                    <td style={{ padding: "5px 8px", fontFamily: "monospace", color: d.delay > 30 ? C.danger : C.text }}>{d.delay.toFixed(2)}</td>
+                    <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>
+                    <td style={{ padding: "5px 8px", fontFamily: "monospace", color: d.offAxisDeg > 55 ? C.danger : C.text }}>{d.offAxisDeg}°</td>
+                    <td style={{ padding: "5px 8px", fontFamily: "monospace" }}>{d.splAtEar}</td>
+                    <td style={{ padding: "5px 8px" }}><Dot color={d.inCoverage ? C.good : C.danger} /></td>
+                    <td style={{ padding: "5px 8px" }}><Tag color={haasColor(d.haasStatus)}>{haasLabel(d.haasStatus)}</Tag></td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </Panel>
+    </div>
+  );
+}
+
+// ─── 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 <h3 key={i} style={{ color: C.accent, fontSize: 13, margin: "18px 0 6px", borderBottom: `1px solid ${C.border}`, paddingBottom: 4 }}>{line.slice(4)}</h3>;
+      if (line.startsWith("## ")) return <h2 key={i} style={{ color: C.heading, fontSize: 15, margin: "20px 0 7px" }}>{line.slice(3)}</h2>;
+      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>; }
+      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>;
+      if (!line.trim()) return <div key={i} style={{ height: 6 }} />;
+      const parts = line.split(/(\*\*[^*]+\*\*)/g);
+      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>;
+    });
+  }
+
+  const displayText = (analysisState === "streaming" ? streamText : analysis) || "";
+  const providerLabel = settings.provider === "ollama" ? `🦙 ${settings.ollamaModel || "Ollama"}` : `✦ Claude`;
+
+  return (
+    <div>
+      <Panel>
+        <SectionHead sub="Optional — connect to REW for measured frequency data">REW Room Simulator</SectionHead>
+        <div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
+          <Btn onClick={connectREW} disabled={rewStatus === "checking"} color={C.accentDim}>
+            {rewStatus === "checking" ? "Connecting…" : "Connect to REW"}
+          </Btn>
+          {rewStatus !== "idle" && (
+            <Tag color={rewStatus === "connected" ? C.good : rewStatus === "error" ? C.danger : C.accent}>
+              {rewStatus === "connected" ? "REW connected ✓" : rewStatus === "error" ? "Connection failed" : "Connecting…"}
+            </Tag>
+          )}
+          <span style={{ fontSize: 11, color: C.muted }}>REW at {rewBase} — configure in ⚙ Settings</span>
+        </div>
+        {rewData && (
+          <div style={{ marginTop: 14 }}>
+            <div style={{ fontSize: 11, color: C.muted, marginBottom: 8 }}>Octave band response from REW simulator:</div>
+            <svg width="100%" viewBox="0 0 500 80" style={{ display: "block" }}>
+              {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 <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>;
+              })}
+              <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} />
+            </svg>
+          </div>
+        )}
+      </Panel>
+
+      <Btn onClick={runAnalysis} disabled={analysisState === "loading" || analysisState === "streaming"}
+        color={C.good} style={{ width: "100%", padding: 14, fontSize: 14, marginBottom: 14 }}>
+        {(analysisState === "loading" || analysisState === "streaming") ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`}
+      </Btn>
+
+      {analysisState === "loading" && (
+        <Panel><div style={{ textAlign: "center", padding: 30, color: C.muted }}>Sending geometry and coverage data to {providerLabel}…</div></Panel>
+      )}
+
+      {(analysisState === "streaming" || analysisState === "done") && displayText && (
+        <Panel>
+          <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 14 }}>
+            <SectionHead>Acoustic Analysis Report</SectionHead>
+            <div style={{ display: "flex", gap: 8 }}>
+              {analysisState === "streaming" && <Tag color={C.accent}>● Streaming</Tag>}
+              <Tag color={settings.provider === "ollama" ? C.purple : C.accent}>{providerLabel}</Tag>
+            </div>
+          </div>
+          <div>{renderMD(displayText)}</div>
+        </Panel>
+      )}
+
+      {analysisState === "error" && (
+        <Panel accent={C.danger}><div style={{ color: C.danger }}>⚠ Analysis failed — check Log tab</div></Panel>
+      )}
+    </div>
+  );
+}
+
+// ─── 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 }) => (
+    <button onClick={() => set("provider", id)} style={{
+      flex: 1, padding: "14px 10px", borderRadius: 10, cursor: "pointer", fontFamily: "inherit",
+      border: `2px solid ${settings.provider === id ? C.accent : C.border}`,
+      background: settings.provider === id ? C.accentBg : C.panelAlt,
+      color: settings.provider === id ? C.accent : C.muted, textAlign: "center", transition: "all 0.15s",
+    }}>
+      <div style={{ fontSize: 20, marginBottom: 4 }}>{icon}</div>
+      <div style={{ fontWeight: 700, fontSize: 13 }}>{title}</div>
+      <div style={{ fontSize: 10, opacity: 0.7, marginTop: 2 }}>{sub}</div>
+    </button>
+  );
+
+  return (
+    <div>
+      <Panel>
+        <SectionHead>LLM Provider</SectionHead>
+        <div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
+          <ProvBtn id="ollama" icon="🦙" title="Ollama" sub="Local / Private" />
+          <ProvBtn id="anthropic" icon="✦" title="Anthropic" sub="Claude API" />
+        </div>
+        {settings.provider === "ollama" && <>
+          <div style={{ display: "grid", gridTemplateColumns: "1fr 100px", gap: 10, marginBottom: 12 }}>
+            <Field label="Ollama Host"><TxtIn value={settings.ollamaHost} onChange={v => set("ollamaHost", v)} placeholder="http://127.0.0.1" /></Field>
+            <Field label="Port"><TxtIn value={settings.ollamaPort} onChange={v => set("ollamaPort", v)} /></Field>
+          </div>
+          <Field label="Model">
+            <div style={{ display: "flex", gap: 10 }}>
+              {ollamaModels.length > 0
+                ? <select value={settings.ollamaModel} onChange={e => set("ollamaModel", e.target.value)} style={selectStyle}>{ollamaModels.map(m => <option key={m} value={m}>{m}</option>)}</select>
+                : <TxtIn value={settings.ollamaModel} onChange={v => set("ollamaModel", v)} placeholder="e.g. llama3.2, mistral, qwen2.5…" />}
+              <Btn onClick={fetchModels} disabled={ollamaStatus === "fetching"} color={C.accentDim}>
+                {ollamaStatus === "fetching" ? "…" : "Fetch"}
+              </Btn>
+            </div>
+          </Field>
+          {ollamaStatus !== "idle" && <div style={{ fontSize: 11, color: ollamaStatus === "ok" ? C.good : ollamaStatus === "error" ? C.danger : C.accent, marginBottom: 8 }}>
+            {ollamaStatus === "ok" ? `✓ ${ollamaModels.length} model(s)` : ollamaStatus === "error" ? "Cannot reach Ollama — OLLAMA_ORIGINS=* ollama serve" : "Fetching…"}
+          </div>}
+        </>}
+        {settings.provider === "anthropic" && <>
+          <Field label="API Key"><TxtIn value={settings.anthropicKey} onChange={v => set("anthropicKey", v)} type="password" placeholder="sk-ant-api03-…" /></Field>
+          <Field label="Model">
+            <select value={settings.anthropicModel} onChange={e => set("anthropicModel", e.target.value)} style={selectStyle}>
+              {["claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-haiku-4-5-20251001"].map(m => <option key={m} value={m}>{m}</option>)}
+            </select>
+          </Field>
+        </>}
+      </Panel>
+
+      <Panel>
+        <SectionHead>REW API</SectionHead>
+        <div style={{ display: "grid", gridTemplateColumns: "1fr 100px", gap: 10, marginBottom: 10 }}>
+          <Field label="REW Host"><TxtIn value={settings.rewHost} onChange={v => set("rewHost", v)} /></Field>
+          <Field label="Port"><TxtIn value={settings.rewPort} onChange={v => set("rewPort", v)} /></Field>
+        </div>
+        <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
+          <Btn onClick={testREW} disabled={rewTestStatus === "checking"} color={C.accentDim}>Test Connection</Btn>
+          {rewTestStatus !== "idle" && <Tag color={rewTestStatus === "connected" ? C.good : rewTestStatus === "error" ? C.danger : C.accent}>
+            {rewTestStatus === "connected" ? "REW reachable ✓" : rewTestStatus === "error" ? "Failed" : "Testing…"}
+          </Tag>}
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead>LLM Behaviour</SectionHead>
+        <Field label="System Prompt">
+          <textarea value={settings.systemPrompt} onChange={e => set("systemPrompt", e.target.value)} rows={5}
+            style={{ ...numStyle, resize: "vertical", lineHeight: 1.6, fontSize: 12 }} />
+        </Field>
+        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 14 }}>
+          <Field label="Max Tokens"><NumIn value={settings.maxTokens} onChange={v => set("maxTokens", v)} min={256} max={8192} step={256} /></Field>
+          <Field label="Temperature"><NumIn value={settings.temperature} onChange={v => set("temperature", Math.min(2, Math.max(0, v)))} step={0.05} /></Field>
+        </div>
+      </Panel>
+
+      <Btn onClick={() => { setFlash(true); setTimeout(() => setFlash(false), 1600); }} color={flash ? C.good : C.accent} style={{ width: "100%", padding: 13, fontSize: 14 }}>
+        {flash ? "✓ Settings Applied" : "Save Settings"}
+      </Btn>
+    </div>
+  );
+}
+
+// ─── 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 (
+    <div style={{ minHeight: "100vh", background: C.bg, color: C.text, fontFamily: "'IBM Plex Mono', 'Courier New', monospace", paddingBottom: 60 }}>
+      {/* header */}
+      <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 }}>
+        <div>
+          <div style={{ fontSize: 9, letterSpacing: 2.5, color: C.accentDim, textTransform: "uppercase", marginBottom: 2, fontFamily: "inherit" }}>PBCC Universal Hall · MS6 Speaker System</div>
+          <h1 style={{ margin: 0, fontSize: 16, color: C.heading, fontWeight: 700, letterSpacing: 0.5 }}>Church Acoustic Analysis Suite</h1>
+        </div>
+        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", justifyContent: "flex-end" }}>
+          <Tag color={C.gold}>{hall.hallWidth / 1000}m × {hall.hallWidth / 1000}m</Tag>
+          <Tag color={C.good}>{hall.rows} rows</Tag>
+          <Tag color={settings.provider === "ollama" ? C.purple : C.accent}>{provLabel}</Tag>
+        </div>
+      </div>
+
+      <div style={{ maxWidth: 900, margin: "0 auto", padding: "20px 16px" }}>
+        {/* tabs */}
+        <div style={{ display: "flex", gap: 6, marginBottom: 20, flexWrap: "wrap" }}>
+          {TABS.map(t => <button key={t.id} style={tabStyle(activeTab === t.id)} onClick={() => setActiveTab(t.id)}>{t.icon} {t.label}</button>)}
+        </div>
+
+        {activeTab === "hall"     && <HallTab hall={hall} setHall={setHall} />}
+        {activeTab === "speakers" && <SpeakerTab hall={hall} speakers={speakers} setSpeakers={setSpeakers} />}
+        {activeTab === "coverage" && <CoverageTab hall={hall} speakers={speakers} />}
+        {activeTab === "analysis" && <AnalysisTab hall={hall} speakers={speakers} settings={settings} addLog={addLog} />}
+        {activeTab === "settings" && <SettingsTab settings={settings} setSettings={setSettings} addLog={addLog} />}
+        {activeTab === "log" && (
+          <Panel style={{ fontFamily: "inherit" }}>
+            <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
+              <span style={{ fontSize: 11, letterSpacing: 1.5, color: C.muted, textTransform: "uppercase" }}>Activity Log</span>
+              <Btn onClick={() => { logRef.current = []; setLog([]); }} color={C.muted} outline style={{ padding: "3px 10px", fontSize: 10 }}>Clear</Btn>
+            </div>
+            {log.length === 0 && <div style={{ color: C.muted, fontSize: 11 }}>No activity yet.</div>}
+            {[...log].reverse().map((e, i) => (
+              <div key={i} style={{ display: "flex", gap: 10, marginBottom: 4, fontSize: 11, borderBottom: `1px solid ${C.border}22`, paddingBottom: 3 }}>
+                <span style={{ color: C.muted, minWidth: 68, flexShrink: 0 }}>{e.t}</span>
+                <span style={{ color: e.type === "error" ? C.danger : e.type === "good" ? C.good : e.type === "warn" ? C.gold : C.text }}>{e.msg}</span>
+              </div>
+            ))}
+          </Panel>
+        )}
+      </div>
+    </div>
+  );
+}

+ 6 - 0
docker-compose.yml

@@ -0,0 +1,6 @@
+services:
+  church-acoustic-suite:
+    build: .
+    ports:
+      - "3000:80"
+    restart: unless-stopped

+ 16 - 0
index.html

@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Church Acoustic Suite — PBCC Universal Hall</title>
+    <style>
+      * { box-sizing: border-box; margin: 0; padding: 0; }
+      body { background: #07090f; }
+    </style>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.jsx"></script>
+  </body>
+</html>

+ 22 - 0
nginx.conf

@@ -0,0 +1,22 @@
+server {
+    listen 80;
+    server_name _;
+    root /usr/share/nginx/html;
+    index index.html;
+
+    # SPA fallback — serve index.html for all routes
+    location / {
+        try_files $uri $uri/ /index.html;
+    }
+
+    # Cache static assets
+    location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
+        expires 1y;
+        add_header Cache-Control "public, immutable";
+    }
+
+    # No cache for the HTML entry point
+    location = /index.html {
+        add_header Cache-Control "no-cache";
+    }
+}

+ 26 - 0
package.json

@@ -0,0 +1,26 @@
+{
+  "name": "church-acoustic-suite",
+  "version": "1.0.0",
+  "description": "Acoustic analysis tool for PBCC Universal Hall — MS6 ceiling speaker system with REW and LLM integration",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "test": "vitest",
+    "test:run": "vitest run",
+    "test:ui": "vitest --ui",
+    "test:coverage": "vitest run --coverage"
+  },
+  "dependencies": {
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-react": "^4.3.1",
+    "@vitest/coverage-v8": "^2.1.4",
+    "@vitest/ui": "^2.1.4",
+    "vite": "^5.4.10",
+    "vitest": "^2.1.4"
+  }
+}

+ 924 - 0
src/App.jsx

@@ -0,0 +1,924 @@
+import { useState, useCallback, useRef } from 'react';
+import { C } from './palette';
+import { HALL_DEFAULTS, SPEAKER_DEFAULTS, SETTINGS_DEFAULTS } from './constants';
+import {
+  ceilAbsoluteAt,
+  floorHeightAtRadius,
+  floorToCeilingAt,
+  computeHallGeometry,
+  computeCoverage,
+  computeFlutter,
+  haasColor,
+  haasLabel,
+} from './geometry';
+
+// ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
+
+const Lbl = ({ children, sub }) => (
+  <div style={{ marginBottom: 5 }}>
+    <label style={{ display: 'block', fontSize: 10, letterSpacing: 1.3, textTransform: 'uppercase', color: C.muted }}>{children}</label>
+    {sub && <span style={{ fontSize: 10, color: C.muted, opacity: 0.7 }}>{sub}</span>}
+  </div>
+);
+
+const Field = ({ label, sub, children }) => (
+  <div style={{ marginBottom: 14 }}>
+    <Lbl sub={sub}>{label}</Lbl>
+    {children}
+  </div>
+);
+
+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 }) => (
+  <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+    <input type="number" value={value} min={min} max={max} step={step} onChange={e => onChange(+e.target.value || 0)} style={{ ...numStyle, ...sx }} />
+    {unit && <span style={{ fontSize: 11, color: C.muted, whiteSpace: 'nowrap' }}>{unit}</span>}
+  </div>
+);
+
+const TxtIn = ({ value, onChange, type = 'text', placeholder, style: sx }) => (
+  <input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
+    style={{ ...numStyle, ...sx }} />
+);
+
+const Btn = ({ onClick, disabled, children, color = C.accent, outline, style: sx }) => (
+  <button onClick={onClick} disabled={disabled} style={{
+    background: outline ? 'transparent' : disabled ? C.border : color,
+    color: outline ? color : disabled ? C.muted : C.bg,
+    border: `1px solid ${outline ? color : disabled ? C.border : color}`,
+    borderRadius: 7, padding: '9px 18px', fontSize: 12, fontWeight: 700,
+    letterSpacing: 0.5, cursor: disabled ? 'not-allowed' : 'pointer',
+    fontFamily: 'inherit', transition: 'all 0.15s', ...sx,
+  }}>{children}</button>
+);
+
+const Tag = ({ color = C.accent, children, size = 'sm' }) => (
+  <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>
+);
+
+const Dot = ({ color }) => <span style={{ display: 'inline-block', width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 }} />;
+
+const Panel = ({ children, style: sx, accent }) => (
+  <div style={{ background: C.panel, border: `1px solid ${accent ? accent + '40' : C.border}`, borderRadius: 12, padding: 20, marginBottom: 14, ...sx }}>{children}</div>
+);
+
+const SectionHead = ({ children, sub }) => (
+  <div style={{ marginBottom: 16 }}>
+    <h2 style={{ margin: 0, color: C.heading, fontSize: 14, fontWeight: 700, letterSpacing: 0.3 }}>{children}</h2>
+    {sub && <p style={{ margin: '4px 0 0', fontSize: 11, color: C.muted }}>{sub}</p>}
+  </div>
+);
+
+const Divider = () => <div style={{ height: 1, background: C.border, margin: '16px 0' }} />;
+
+// ─── SECTION DIAGRAM ──────────────────────────────────────────────────────────
+
+function SectionDiagram({ hall, speakers, rowData }) {
+  const W = 640, H = 220, PAD = 40;
+  const totalW  = hall.hallWidth / 2;           // centre to wall
+  const scaleX  = (W - PAD * 2) / totalW;
+  const scaleY  = (H - PAD * 2) / hall.ceilFlat;
+  const cx = (r) => PAD + r * scaleX;
+  const cy = (h) => H - PAD - h * scaleY;
+
+  return (
+    <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', background: '#07090f', borderRadius: 8, border: `1px solid ${C.border}` }}>
+      {/* Flat ceiling line — main zone, then drops at corner zone */}
+      <polyline
+        points={rowData.map(r => `${cx(r.radius)},${cy(r.ceilHeight)}`).join(' ')}
+        fill="none" stroke={C.borderHi} strokeWidth={2} />
+      {/* Flat ceiling label */}
+      <text x={cx(hall.row1Radius) + 4} y={cy(hall.ceilFlat) - 4} fill={C.muted} fontSize={9}>
+        Flat ceiling {hall.ceilFlat} mm
+      </text>
+
+      {/* Dished floor — flat centre zone then rising */}
+      <polyline
+        points={`${cx(0)},${cy(0)} ` + rowData.map(r => `${cx(r.radius)},${cy(r.floorRise)}`).join(' ')}
+        fill="none" stroke={C.borderHi} strokeWidth={1.5} strokeDasharray="4,3" />
+      <text x={cx(hall.row8Back) + 4} y={cy(hall.dishRise) - 4} fill={C.muted} fontSize={9}>
+        Floor +{hall.dishRise} mm
+      </text>
+
+      {/* Ear positions per row */}
+      {rowData.map(r => (
+        <g key={r.row}>
+          <line x1={cx(r.radius)} y1={cy(r.floorRise)} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={C.border} strokeWidth={1} />
+          <circle cx={cx(r.radius)} cy={cy(r.earAFF)} r={3} fill={C.accent} opacity={0.8} />
+          <text x={cx(r.radius)} y={cy(r.floorRise) + 12} fill={C.muted} fontSize={8} textAnchor="middle">{r.isCorner ? 'C' : r.row}</text>
+        </g>
+      ))}
+
+      {/* Speakers */}
+      {Object.entries(speakers).map(([ring, spk]) => {
+        if (!spk.enabled) return null;
+        const spkH   = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
+        const spkY   = cy(spkH);
+        const spkX   = cx(spk.radius);
+        const color  = ring === 'centre' ? C.gold : ring === 'inner' ? C.good : ring === 'outer' ? C.purple : C.accent;
+        const label  = ring === 'centre' ? 'CTR' : ring === 'inner' ? 'INN' : ring === 'outer' ? 'OUT' : 'CNR';
+        return (
+          <g key={ring}>
+            <line x1={spkX} y1={cy(ceilAbsoluteAt(spk.radius, hall))} x2={spkX} y2={spkY} stroke={color} strokeWidth={1} strokeDasharray="2,2" opacity={0.5} />
+            <rect x={spkX - 7} y={spkY - 4} width={14} height={8} fill={color} rx={2} opacity={0.9} />
+            <text x={spkX} y={spkY - 7} fill={color} fontSize={7} textAnchor="middle">{label}</text>
+            {rowData.map(r => {
+              const res = computeCoverage(hall, { [ring]: spk }, [r])[0]?.speakers[ring];
+              if (!res) return null;
+              const rayColor = res.inCoverage
+                ? ({ primary: C.good, ok: C.accent, warn: C.gold, danger: C.danger }[res.haasStatus] ?? C.muted)
+                : '#444';
+              return <line key={r.row} x1={spkX} y1={spkY} x2={cx(r.radius)} y2={cy(r.earAFF)} stroke={rayColor} strokeWidth={0.7} opacity={0.3} />;
+            })}
+          </g>
+        );
+      })}
+
+      {/* Legend */}
+      {[['●', C.accent, 'Ear pos'], ['▬', C.gold, 'Centre'], ['▬', C.good, 'Inner'], ['▬', C.purple, 'Outer'], ['▬', C.accent, 'Corner'], ['—', C.good, 'Haas OK'], ['—', C.gold, 'Warn'], ['—', C.danger, 'Echo/OOB']].map(([sym, col, lbl], i) => (
+        <text key={i} x={PAD + i * 74} y={H - 6} fill={col} fontSize={7}>{sym} {lbl}</text>
+      ))}
+    </svg>
+  );
+}
+
+// ─── COVERAGE HEATMAP ────────────────────────────────────────────────────────
+
+function CoverageHeatmap({ coverage, speakers }) {
+  const rings  = Object.entries(speakers).filter(([, v]) => v.enabled);
+  const cellW  = 110, cellH = 52, lblW = 60;
+  const W = lblW + rings.length * cellW + 10;
+  const H = 28 + coverage.length * cellH + 10;
+
+  return (
+    <div style={{ overflowX: 'auto' }}>
+      <svg width="100%" viewBox={`0 0 ${W} ${H}`} style={{ display: 'block', minWidth: W }}>
+        {rings.map(([ring, spk], ci) => (
+          <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>
+        ))}
+        {coverage.map((row, ri) => (
+          <g key={row.row}>
+            <text x={lblW - 6} y={28 + ri * cellH + cellH / 2 + 4} fill={C.muted} fontSize={10} textAnchor="end">
+              {typeof row.row === 'string' ? row.row : `Row ${row.row}`}
+            </text>
+            {rings.map(([ring], ci) => {
+              const d = row.speakers[ring];
+              if (!d) return null;
+              const bg = d.inCoverage
+                ? d.haasStatus === 'primary' ? C.goodBg : d.haasStatus === 'ok' ? C.accentBg : d.haasStatus === 'warn' ? C.goldBg : C.dangerBg
+                : C.dangerBg;
+              return (
+                <g key={ring}>
+                  <rect x={lblW + ci * cellW + 2} y={28 + ri * cellH + 2} width={cellW - 4} height={cellH - 4}
+                    fill={bg} stroke={d.inCoverage ? haasColor(d.haasStatus) : C.danger} strokeWidth={1} rx={4} opacity={0.9} />
+                  <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>
+                  <text x={lblW + ci * cellW + cellW / 2} y={28 + ri * cellH + 29} fill={haasColor(d.haasStatus)} fontSize={9} textAnchor="middle">
+                    {haasLabel(d.haasStatus)}
+                  </text>
+                  <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}° · {d.splAtEar.toFixed(1)}dB
+                  </text>
+                </g>
+              );
+            })}
+          </g>
+        ))}
+      </svg>
+    </div>
+  );
+}
+
+// ─── HALL TAB ─────────────────────────────────────────────────────────────────
+
+function HallTab({ hall, setHall }) {
+  const rowData = computeHallGeometry(hall);
+  const flutter = computeFlutter(hall);
+  const set     = (k, v) => setHall(h => ({ ...h, [k]: v }));
+
+  // Derived values for the stats bar
+  const row8Clearance = hall.ceilFlat - hall.dishRise;
+
+  return (
+    <div>
+      <Panel>
+        <SectionHead sub="20.914 m × 20.914 m seating plan — adjust per installation">Hall Dimensions</SectionHead>
+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
+          <Field label="Hall Width" sub="square plan dimension"><NumIn value={hall.hallWidth} onChange={v => set('hallWidth', v)} unit="mm" /></Field>
+          <Field label="Number of Main Rows"><NumIn value={hall.rows} onChange={v => set('rows', Math.max(1, v))} min={1} max={12} /></Field>
+          <Field label="Row 1 Radius" sub="centre to front edge of row 1"><NumIn value={hall.row1Radius} onChange={v => set('row1Radius', v)} unit="mm" /></Field>
+          <Field label="Row Pitch" sub="FFL to FFL"><NumIn value={hall.rowPitch} onChange={v => set('rowPitch', v)} unit="mm" /></Field>
+          <Field label="Back Aisle Width" sub="behind row 8"><NumIn value={hall.aisleWidth} onChange={v => set('aisleWidth', v)} unit="mm" /></Field>
+          <Field label="Ear Height AFF" sub="seated listener above local floor"><NumIn value={hall.earHeight} onChange={v => set('earHeight', v)} unit="mm" /></Field>
+        </div>
+        <Divider />
+        <SectionHead sub="Ceiling is FLAT — one horizontal plane. Floor rises from centre outward.">Ceiling & Dish Floor</SectionHead>
+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 14 }}>
+          <Field label="Flat Ceiling Height" sub="absolute — above centre floor datum">
+            <NumIn value={hall.ceilFlat} onChange={v => set('ceilFlat', v)} unit="mm" />
+          </Field>
+          <Field label="Corner Ceiling Clearance" sub="floor-to-ceiling in corner zone (structural drop)">
+            <NumIn value={hall.cornerCeilClearance} onChange={v => set('cornerCeilClearance', v)} unit="mm" />
+          </Field>
+          <Field label="Floor Dish Rise" sub="0 mm at row 1 → max at row 8 back">
+            <NumIn value={hall.dishRise} onChange={v => set('dishRise', v)} unit="mm" />
+          </Field>
+          <Field label="Corner Rows" sub="rows per corner zone">
+            <NumIn value={hall.cornerRows} onChange={v => set('cornerRows', Math.max(1, v))} min={1} max={8} />
+          </Field>
+          <Field label="Corner Row 1 Offset" sub="from circular seating edge inward">
+            <NumIn value={hall.cornerFrontOffset} onChange={v => set('cornerFrontOffset', v)} unit="mm" />
+          </Field>
+          <Field label="Tile Grid" sub="ceiling tile size (constrains speaker positions)">
+            <NumIn value={hall.tileGrid} onChange={v => set('tileGrid', v)} unit="mm" />
+          </Field>
+        </div>
+        <div style={{ padding: '10px 14px', background: C.bgAlt, borderRadius: 8, display: 'flex', gap: 20, flexWrap: 'wrap', fontSize: 11, color: C.muted }}>
+          <span>Plan: <strong style={{ color: C.heading }}>{(hall.hallWidth / 1000).toFixed(3)} m sq</strong></span>
+          <span>Flat ceiling: <strong style={{ color: C.accent }}>{hall.ceilFlat} mm</strong></span>
+          <span>Clearance row 8: <strong style={{ color: C.accent }}>{row8Clearance} mm</strong></span>
+          <span>Corner clearance: <strong style={{ color: hall.cornerCeilClearance - hall.earHeight < 1200 ? C.warn : C.accent }}>{hall.cornerCeilClearance} mm</strong></span>
+          <span>Dish slope: <strong style={{ color: C.gold }}>1:{Math.round((hall.row8Back - hall.row1Radius) / hall.dishRise)}</strong></span>
+          <span>Rise/row ≈ <strong style={{ color: C.heading }}>{Math.round(hall.dishRise / hall.rows)} mm</strong></span>
+          <span>Tile grid: <strong style={{ color: C.heading }}>{hall.tileGrid} mm</strong></span>
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead>Section Diagram</SectionHead>
+        <SectionDiagram hall={hall} speakers={SPEAKER_DEFAULTS} rowData={rowData} />
+      </Panel>
+
+      <Panel>
+        <SectionHead sub={`Flat ceiling at ${hall.ceilFlat} mm — ear-to-ceiling decreases as floor rises toward perimeter`}>Row Geometry Table</SectionHead>
+        <div style={{ overflowX: 'auto' }}>
+          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
+            <thead>
+              <tr>{['Row', 'Front Edge', 'Ear Radius', 'Floor Rise', 'Ceiling (abs)', 'Ear AFF (abs)', 'Ear → Ceiling'].map(h => (
+                <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>
+              ))}</tr>
+            </thead>
+            <tbody>
+              {rowData.map((r, i) => (
+                <tr key={r.row} style={{ background: i % 2 === 0 ? C.panelAlt : 'transparent' }}>
+                  {[
+                    r.isCorner ? `${r.row} (corner)` : `Row ${r.row}`,
+                    `${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) => (
+                    <td key={j} style={{ padding: '6px 10px', color: j === 0 ? C.gold : j === 6 ? (r.earToCell < 1500 ? C.warn : C.good) : C.text, fontFamily: j > 0 ? 'monospace' : 'inherit' }}>{v}</td>
+                  ))}
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      </Panel>
+
+      <Panel accent={C.warn}>
+        <SectionHead sub="Flat ceiling + rising floor — flutter risk increases toward perimeter (Dudley Wilkin, 2010)">Flutter Echo Analysis</SectionHead>
+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 14, marginBottom: 14 }}>
+          {[
+            ['Centre clearance', `${flutter.centreClearMm} mm`, C.text],
+            ['Centre flutter fund.', `${flutter.fundamentalCentre} Hz`, C.warn],
+            ['Row 8 flutter fund.', `${flutter.fundamentalRow8} Hz`, C.danger],
+          ].map(([lbl, val, col]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: '12px 14px' }}>
+              <div style={{ fontSize: 10, color: C.muted, marginBottom: 4, letterSpacing: 1, textTransform: 'uppercase' }}>{lbl}</div>
+              <div style={{ fontSize: 20, fontWeight: 700, color: col, fontFamily: 'monospace' }}>{val}</div>
+            </div>
+          ))}
+        </div>
+        <div style={{ fontSize: 11, color: C.muted, lineHeight: 1.7 }}>
+          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 />
+          <span style={{ color: C.warn }}>⚠ Row 8 ear-to-ceiling = {flutter.row8ClearMm} mm → flutter fund. {flutter.fundamentalRow8} Hz.
+            Harmonics {Math.round(flutter.fundamentalRow8 * 2)} Hz &amp; {Math.round(flutter.fundamentalRow8 * 3)} Hz land in the 100–300 Hz speech band.
+            Tile diffraction grating: {flutter.tileDiffraction} Hz (λ = {hall.tileGrid} mm).
+          </span>
+        </div>
+      </Panel>
+    </div>
+  );
+}
+
+// ─── SPEAKER TAB ─────────────────────────────────────────────────────────────
+
+function SpeakerTab({ hall, speakers, setSpeakers }) {
+  const rowData = computeHallGeometry(hall);
+
+  const RingEditor = ({ ringKey, color }) => {
+    const spk = speakers[ringKey];
+    const set = (k, v) => setSpeakers(s => ({ ...s, [ringKey]: { ...s[ringKey], [k]: v } }));
+    const spkHeightAFF = ceilAbsoluteAt(spk.radius, hall) - spk.dropRod;
+
+    return (
+      <Panel accent={color} style={{ marginBottom: 10 }}>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
+          <div style={{ width: 10, height: 10, borderRadius: '50%', background: color }} />
+          <h3 style={{ margin: 0, color: C.heading, fontSize: 14 }}>{spk.label}</h3>
+          <label style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: C.muted, cursor: 'pointer' }}>
+            <input type="checkbox" checked={spk.enabled} onChange={e => set('enabled', e.target.checked)} style={{ accentColor: color }} />
+            Enabled
+          </label>
+        </div>
+        {spk.enabled && (
+          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
+            <Field label="Count"><NumIn value={spk.count} onChange={v => set('count', v)} min={1} max={16} /></Field>
+            <Field label="Radius from Centre"><NumIn value={spk.radius} onChange={v => set('radius', v)} unit="mm" /></Field>
+            <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>
+            <Field label="Power per speaker"><NumIn value={spk.power} onChange={v => set('power', v)} step={0.5} unit="W" /></Field>
+            <Field label="Sensitivity" sub="dB / 1 W / 1 m"><NumIn value={spk.sensitivity} onChange={v => set('sensitivity', v)} unit="dB" /></Field>
+            <Field label="Phase">
+              <select value={spk.phase} onChange={e => set('phase', +e.target.value)} style={{ ...numStyle }}>
+                <option value={1}>Normal (+)</option>
+                <option value={-1}>Anti-phase (−)</option>
+              </select>
+            </Field>
+            <Field label="Speaker Acoustic Centre" sub={spk.dropRod === 0 ? 'Flush ceiling ✓' : `${spk.dropRod} mm drop rod`}>
+              <div style={{ padding: '7px 10px', background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.gold, fontFamily: 'monospace' }}>
+                {spkHeightAFF.toLocaleString()} mm
+              </div>
+            </Field>
+            <Field label="Nearest row">
+              <div style={{ padding: '7px 10px', background: C.bgAlt, borderRadius: 6, fontSize: 12, color: C.text }}>
+                {(() => {
+                  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)`;
+                })()}
+              </div>
+            </Field>
+            <Field label="Ring total power">
+              <div style={{ padding: '7px 10px', background: C.bgAlt, borderRadius: 6, fontSize: 13, color: C.text, fontFamily: 'monospace' }}>
+                {(spk.power * spk.count).toFixed(1)} W
+              </div>
+            </Field>
+          </div>
+        )}
+      </Panel>
+    );
+  };
+
+  const totalPower = Object.values(speakers).filter(s => s.enabled).reduce((sum, s) => sum + s.power * s.count, 0);
+
+  return (
+    <div>
+      <Panel style={{ marginBottom: 10 }}>
+        <div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
+          {[
+            ['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],
+            ['MS6 crossover', 'MIDAS MR12 · 3 kHz gap', C.good],
+            ['Gap width', '433 Hz @ 3 kHz', C.purple],
+          ].map(([lbl, val, col]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: '10px 16px', flex: 1, minWidth: 120 }}>
+              <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 3 }}>{lbl}</div>
+              <div style={{ fontSize: 16, fontWeight: 700, color: col }}>{val}</div>
+            </div>
+          ))}
+        </div>
+      </Panel>
+      <RingEditor ringKey="centre" color={C.gold} />
+      <RingEditor ringKey="inner"  color={C.good} />
+      <RingEditor ringKey="outer"  color={C.purple} />
+      <RingEditor ringKey="corner" color={C.accent} />
+    </div>
+  );
+}
+
+// ─── COVERAGE TAB ─────────────────────────────────────────────────────────────
+
+function CoverageTab({ hall, speakers }) {
+  const rowData  = computeHallGeometry(hall);
+  const coverage = computeCoverage(hall, speakers, rowData);
+  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 (
+    <div>
+      <Panel>
+        <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 4 }}>
+          {[
+            ['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', '30 ms', C.text],
+            ['MS6 max off-axis', '55°', C.text],
+          ].map(([lbl, val, col]) => (
+            <div key={lbl} style={{ background: C.bgAlt, borderRadius: 8, padding: '10px 14px', flex: 1, minWidth: 100 }}>
+              <div style={{ fontSize: 10, color: C.muted, letterSpacing: 1, textTransform: 'uppercase', marginBottom: 3 }}>{lbl}</div>
+              <div style={{ fontSize: 18, fontWeight: 700, color: col }}>{val}</div>
+            </div>
+          ))}
+        </div>
+        <div style={{ display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap' }}>
+          {[['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]) => (
+            <Tag key={lbl} color={col}>{lbl}</Tag>
+          ))}
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead sub="Slant · delay · Haas status · off-axis · SPL">Coverage Heatmap — All Rows × All Speaker Rings</SectionHead>
+        <CoverageHeatmap coverage={coverage} speakers={speakers} />
+      </Panel>
+
+      <Panel>
+        <SectionHead sub="Full data table">Per-Row Coverage Detail</SectionHead>
+        <div style={{ overflowX: 'auto' }}>
+          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
+            <thead>
+              <tr>{['Row', 'Radius', 'Ring', 'Slant (m)', 'Delay (ms)', 'Haas +Δms', 'Off-axis °', 'SPL (dB)', 'In cone', 'Status'].map(h => (
+                <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>
+              ))}</tr>
+            </thead>
+            <tbody>
+              {coverage.flatMap((row, ri) =>
+                Object.entries(row.speakers).filter(([, d]) => d).map(([ring, d], si) => (
+                  <tr key={`${ri}-${si}`} style={{ background: ri % 2 === 0 && si === 0 ? C.panelAlt : 'transparent' }}>
+                    <td style={{ padding: '5px 8px', color: C.gold }}>{typeof row.row === 'string' ? row.row : `Row ${row.row}`}</td>
+                    <td style={{ padding: '5px 8px', color: C.muted, fontFamily: 'monospace' }}>{(row.radius / 1000).toFixed(3)} m</td>
+                    <td style={{ padding: '5px 8px', color: ring === 'centre' ? C.gold : ring === 'inner' ? C.good : ring === 'outer' ? C.purple : C.accent }}>{speakers[ring].label}</td>
+                    <td style={{ padding: '5px 8px', fontFamily: 'monospace' }}>{d.slantM.toFixed(3)}</td>
+                    <td style={{ padding: '5px 8px', fontFamily: 'monospace', color: d.delay > 30 ? C.danger : C.text }}>{d.delay.toFixed(2)}</td>
+                    <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>
+                    <td style={{ padding: '5px 8px', fontFamily: 'monospace', color: d.offAxisDeg > 55 ? C.danger : C.text }}>{d.offAxisDeg}°</td>
+                    <td style={{ padding: '5px 8px', fontFamily: 'monospace' }}>{d.splAtEar}</td>
+                    <td style={{ padding: '5px 8px' }}><Dot color={d.inCoverage ? C.good : C.danger} /></td>
+                    <td style={{ padding: '5px 8px' }}><Tag color={haasColor(d.haasStatus)}>{haasLabel(d.haasStatus)}</Tag></td>
+                  </tr>
+                ))
+              )}
+            </tbody>
+          </table>
+        </div>
+      </Panel>
+    </div>
+  );
+}
+
+// ─── 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');
+
+      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.ceilFlat / 1000 }),
+      });
+      addLog('Room dimensions pushed to REW ✓', 'good');
+
+      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;
+      const 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 flutter  = computeFlutter(hall);
+    const row8Clr  = hall.ceilFlat - hall.dishRise;
+    const artaRow1 = (2 * (hall.ceilFlat - hall.earHeight) / 1000 / hall.speedOfSound * 1000).toFixed(1);
+    const artaRow8 = (2 * row8Clr / 1000 / hall.speedOfSound * 1000).toFixed(1);
+
+    return `${settings.systemPrompt}
+
+## Hall Specification — SK-303 Version B (confirmed measurements)
+- 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: FLAT at ${hall.ceilFlat} mm above centre floor datum (constant throughout main seating zone)
+  Tile grid: ${hall.tileGrid} mm (constrains speaker positions to ${hall.tileGrid} mm increments)
+  Corner zone ceiling: ${hall.cornerCeilClearance} mm floor-to-ceiling (structural drop at perimeter)
+- Rows: ${hall.rows} circular rows + ${hall.cornerRows} corner rows per corner
+- Row 1 front edge: ${hall.row1Radius} mm from centre | Row pitch: ${hall.rowPitch} mm
+- Row 8 back face: ${hall.row8Back} mm | Back aisle: ${hall.aisleWidth} mm
+
+## Floor Dish Geometry
+- Communion table zone (r < ${hall.row1Radius} mm): FLAT at 0 mm datum
+- Seating zone (r = ${hall.row1Radius} → ${hall.row8Back} mm): rises ${hall.dishRise} mm linearly
+- Slope: 1:${Math.round((hall.row8Back - hall.row1Radius) / hall.dishRise)}
+- Floor-to-ceiling at row 8 back: ${row8Clr} mm (${hall.ceilFlat} − ${hall.dishRise} = ${row8Clr} ✓)
+- As floor rises outward, seated ear moves progressively closer to the flat ceiling
+
+## Speaker System — MS6 (Visaton FRS8M + G20SC, MIDAS MR12 active crossover)
+- All speakers FLUSH with sloped ceiling (no drop rods)
+- MS6 crossover gap: 2792–3225 Hz (433 Hz gap) — eliminates horizontal interference lobes
+- Max rated coverage: 55° off-axis (near-circular polar response to 55°)
+- Sensitivity: 88 dB / 1 W / 1 m
+${Object.entries(speakers).filter(([, v]) => v.enabled).map(([k, v]) =>
+  `- ${v.label}: ${v.count}× at r = ${v.radius} mm flush, ${v.power} W/spk (${(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 abs | ceil ${row.ceilHeight} mm abs | 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 | +${d.haasExcess.toFixed(1)} ms Haas excess | ${d.offAxisDeg}° off-axis | ${d.splAtEar} dB | ${d.inCoverage ? '✓ in 55° cone' : '✗ OUTSIDE cone'} | ${haasLabel(d.haasStatus)}`
+  ).join('\n')
+).join('\n\n')}
+
+## Flutter Echo Risk (flat ceiling + rising floor — Dudley Wilkin 2010)
+- Centre (ear→ceil ${flutter.centreClearMm} mm): fund. ${flutter.fundamentalCentre} Hz, harmonics ${flutter.harmonics.join(', ')} Hz
+- Row 8 (ear→ceil ${flutter.row8ClearMm} mm): fund. ${flutter.fundamentalRow8} Hz
+- ${hall.tileGrid} mm tile grid → diffraction grating at ${flutter.tileDiffraction} Hz
+- ARTA impulse window limit: ~${artaRow1} ms at row 1, ~${artaRow8} ms at row 8
+
+${rewData ? `## REW Frequency Response (octave bands)\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 and flat ceiling.
+
+### Coverage Issues
+Identify rows outside the 55° MS6 cone, Haas violations (>10 ms = colouration, >30 ms = echo), SPL variation.
+
+### Speaker Position Optimisation
+Recommend ring radii. Note: 600 mm tile grid constrains positions. No drop rods — flush only.
+
+### Delay & Haas Analysis
+Identify interference rows. Recommend DSP delay compensation in ms.
+
+### Anti-Phasing Assessment
+Assess viability given 8+8 flush speaker layout. Reference Dudley Wilkin's 1986–2010 analysis.
+Note: Perth UK trial showed centre speaker alone covered to row 6; main ring added "boominess" without anti-phasing.
+
+### MR12 Acoustic EQ
+Channel 1 TEQ/PEQ recommendations — room correction + flutter notch frequencies.
+
+### ARTA Measurement Protocol
+Optimal mic positions (avoid anti-phase null lines along aisle centrelines), mic height above dished floor,
+ARTA window size per row (limited by ceiling reflection time quoted above).`;
+  }
+
+  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 Anthropic 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', 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01' },
+          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 <h3 key={i} style={{ color: C.accent, fontSize: 13, margin: '18px 0 6px', borderBottom: `1px solid ${C.border}`, paddingBottom: 4 }}>{line.slice(4)}</h3>;
+      if (line.startsWith('## '))  return <h2 key={i} style={{ color: C.heading, fontSize: 15, margin: '20px 0 7px' }}>{line.slice(3)}</h2>;
+      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>;
+      if (!line.trim())             return <div key={i} style={{ height: 6 }} />;
+      const parts = line.split(/(\*\*[^*]+\*\*)/g);
+      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>;
+    });
+  }
+
+  const displayText   = (analysisState === 'streaming' ? streamText : analysis) ?? '';
+  const providerLabel = settings.provider === 'ollama' ? `🦙 ${settings.ollamaModel || 'Ollama'}` : `✦ Claude`;
+
+  return (
+    <div>
+      <Panel>
+        <SectionHead sub="Optional — connect to REW for measured frequency data">REW Room Simulator</SectionHead>
+        <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
+          <Btn onClick={connectREW} disabled={rewStatus === 'checking'} color={C.accentDim}>
+            {rewStatus === 'checking' ? 'Connecting…' : 'Connect to REW'}
+          </Btn>
+          {rewStatus !== 'idle' && (
+            <Tag color={rewStatus === 'connected' ? C.good : rewStatus === 'error' ? C.danger : C.accent}>
+              {rewStatus === 'connected' ? 'REW connected ✓' : rewStatus === 'error' ? 'Connection failed' : 'Connecting…'}
+            </Tag>
+          )}
+          <span style={{ fontSize: 11, color: C.muted }}>REW at {rewBase} — configure in ⚙ Settings</span>
+        </div>
+        {rewData && (
+          <div style={{ marginTop: 14 }}>
+            <div style={{ fontSize: 11, color: C.muted, marginBottom: 8 }}>Octave band response from REW simulator:</div>
+            <svg width="100%" viewBox="0 0 500 80" style={{ display: 'block' }}>
+              {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 <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>;
+              })}
+              <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} />
+            </svg>
+          </div>
+        )}
+      </Panel>
+
+      <Btn onClick={runAnalysis} disabled={analysisState === 'loading' || analysisState === 'streaming'}
+        color={C.good} style={{ width: '100%', padding: 14, fontSize: 14, marginBottom: 14 }}>
+        {(analysisState === 'loading' || analysisState === 'streaming') ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`}
+      </Btn>
+
+      {analysisState === 'loading' && (
+        <Panel><div style={{ textAlign: 'center', padding: 30, color: C.muted }}>Sending geometry and coverage data to {providerLabel}…</div></Panel>
+      )}
+      {(analysisState === 'streaming' || analysisState === 'done') && displayText && (
+        <Panel>
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
+            <SectionHead>Acoustic Analysis Report</SectionHead>
+            <div style={{ display: 'flex', gap: 8 }}>
+              {analysisState === 'streaming' && <Tag color={C.accent}>● Streaming</Tag>}
+              <Tag color={settings.provider === 'ollama' ? C.purple : C.accent}>{providerLabel}</Tag>
+            </div>
+          </div>
+          <div>{renderMD(displayText)}</div>
+        </Panel>
+      )}
+      {analysisState === 'error' && (
+        <Panel accent={C.danger}><div style={{ color: C.danger }}>⚠ Analysis failed — check Log tab</div></Panel>
+      )}
+    </div>
+  );
+}
+
+// ─── 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 }));
+
+  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: ${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 }) => (
+    <button onClick={() => set('provider', id)} style={{
+      flex: 1, padding: '14px 10px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
+      border: `2px solid ${settings.provider === id ? C.accent : C.border}`,
+      background: settings.provider === id ? C.accentBg : C.panelAlt,
+      color: settings.provider === id ? C.accent : C.muted, textAlign: 'center', transition: 'all 0.15s',
+    }}>
+      <div style={{ fontSize: 20, marginBottom: 4 }}>{icon}</div>
+      <div style={{ fontWeight: 700, fontSize: 13 }}>{title}</div>
+      <div style={{ fontSize: 10, opacity: 0.7, marginTop: 2 }}>{sub}</div>
+    </button>
+  );
+
+  return (
+    <div>
+      <Panel>
+        <SectionHead>LLM Provider</SectionHead>
+        <div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
+          <ProvBtn id="ollama"    icon="🦙" title="Ollama"    sub="Local / Private" />
+          <ProvBtn id="anthropic" icon="✦"  title="Anthropic" sub="Claude API" />
+        </div>
+
+        {settings.provider === 'ollama' && <>
+          <div style={{ display: 'grid', gridTemplateColumns: '1fr 100px', gap: 10, marginBottom: 12 }}>
+            <Field label="Ollama Host"><TxtIn value={settings.ollamaHost} onChange={v => set('ollamaHost', v)} placeholder="http://127.0.0.1" /></Field>
+            <Field label="Port"><TxtIn value={settings.ollamaPort} onChange={v => set('ollamaPort', v)} /></Field>
+          </div>
+          <Field label="Model">
+            <div style={{ display: 'flex', gap: 10 }}>
+              {ollamaModels.length > 0
+                ? <select value={settings.ollamaModel} onChange={e => set('ollamaModel', e.target.value)} style={numStyle}>{ollamaModels.map(m => <option key={m} value={m}>{m}</option>)}</select>
+                : <TxtIn value={settings.ollamaModel} onChange={v => set('ollamaModel', v)} placeholder="e.g. llama3.2, mistral, qwen2.5…" />}
+              <Btn onClick={fetchModels} disabled={ollamaStatus === 'fetching'} color={C.accentDim}>
+                {ollamaStatus === 'fetching' ? '…' : 'Fetch'}
+              </Btn>
+            </div>
+          </Field>
+          {ollamaStatus !== 'idle' && (
+            <div style={{ fontSize: 11, color: ollamaStatus === 'ok' ? C.good : ollamaStatus === 'error' ? C.danger : C.accent, marginBottom: 8 }}>
+              {ollamaStatus === 'ok' ? `✓ ${ollamaModels.length} model(s) found` : ollamaStatus === 'error' ? 'Cannot reach Ollama — run: OLLAMA_ORIGINS=* ollama serve' : 'Fetching…'}
+            </div>
+          )}
+        </>}
+
+        {settings.provider === 'anthropic' && <>
+          <Field label="API Key"><TxtIn value={settings.anthropicKey} onChange={v => set('anthropicKey', v)} type="password" placeholder="sk-ant-api03-…" /></Field>
+          <Field label="Model">
+            <select value={settings.anthropicModel} onChange={e => set('anthropicModel', e.target.value)} style={numStyle}>
+              {['claude-sonnet-4-6', 'claude-opus-4-7', 'claude-haiku-4-5-20251001'].map(m => <option key={m} value={m}>{m}</option>)}
+            </select>
+          </Field>
+        </>}
+      </Panel>
+
+      <Panel>
+        <SectionHead>REW API</SectionHead>
+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 100px', gap: 10, marginBottom: 10 }}>
+          <Field label="REW Host"><TxtIn value={settings.rewHost} onChange={v => set('rewHost', v)} /></Field>
+          <Field label="Port"><TxtIn value={settings.rewPort} onChange={v => set('rewPort', v)} /></Field>
+        </div>
+        <div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
+          <Btn onClick={testREW} disabled={rewTestStatus === 'checking'} color={C.accentDim}>Test Connection</Btn>
+          {rewTestStatus !== 'idle' && (
+            <Tag color={rewTestStatus === 'connected' ? C.good : rewTestStatus === 'error' ? C.danger : C.accent}>
+              {rewTestStatus === 'connected' ? 'REW reachable ✓' : rewTestStatus === 'error' ? 'Failed' : 'Testing…'}
+            </Tag>
+          )}
+        </div>
+      </Panel>
+
+      <Panel>
+        <SectionHead>LLM Behaviour</SectionHead>
+        <Field label="System Prompt">
+          <textarea value={settings.systemPrompt} onChange={e => set('systemPrompt', e.target.value)} rows={5}
+            style={{ ...numStyle, resize: 'vertical', lineHeight: 1.6, fontSize: 12 }} />
+        </Field>
+        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
+          <Field label="Max Tokens"><NumIn value={settings.maxTokens} onChange={v => set('maxTokens', v)} min={256} max={8192} step={256} /></Field>
+          <Field label="Temperature"><NumIn value={settings.temperature} onChange={v => set('temperature', Math.min(2, Math.max(0, v)))} step={0.05} /></Field>
+        </div>
+      </Panel>
+
+      <Btn onClick={() => { setFlash(true); setTimeout(() => setFlash(false), 1600); }} color={flash ? C.good : C.accent} style={{ width: '100%', padding: 13, fontSize: 14 }}>
+        {flash ? '✓ Settings Applied' : 'Save Settings'}
+      </Btn>
+    </div>
+  );
+}
+
+// ─── ROOT 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 (
+    <div style={{ minHeight: '100vh', background: C.bg, color: C.text, fontFamily: "'IBM Plex Mono', 'Courier New', monospace", paddingBottom: 60 }}>
+      <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 }}>
+        <div>
+          <div style={{ fontSize: 9, letterSpacing: 2.5, color: C.accentDim, textTransform: 'uppercase', marginBottom: 2 }}>PBCC Universal Hall · MS6 Speaker System</div>
+          <h1 style={{ margin: 0, fontSize: 16, color: C.heading, fontWeight: 700, letterSpacing: 0.5 }}>Church Acoustic Analysis Suite</h1>
+        </div>
+        <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+          <Tag color={C.gold}>{(hall.hallWidth / 1000).toFixed(3)} m sq</Tag>
+          <Tag color={C.good}>{hall.rows} rows</Tag>
+          <Tag color={C.accent}>ceil {hall.ceilFlat} mm flat</Tag>
+          <Tag color={settings.provider === 'ollama' ? C.purple : C.accent}>{provLabel}</Tag>
+        </div>
+      </div>
+
+      <div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 16px' }}>
+        <div style={{ display: 'flex', gap: 6, marginBottom: 20, flexWrap: 'wrap' }}>
+          {TABS.map(t => <button key={t.id} style={tabStyle(activeTab === t.id)} onClick={() => setActiveTab(t.id)}>{t.icon} {t.label}</button>)}
+        </div>
+
+        {activeTab === 'hall'     && <HallTab     hall={hall} setHall={setHall} />}
+        {activeTab === 'speakers' && <SpeakerTab  hall={hall} speakers={speakers} setSpeakers={setSpeakers} />}
+        {activeTab === 'coverage' && <CoverageTab hall={hall} speakers={speakers} />}
+        {activeTab === 'analysis' && <AnalysisTab hall={hall} speakers={speakers} settings={settings} addLog={addLog} />}
+        {activeTab === 'settings' && <SettingsTab settings={settings} setSettings={setSettings} addLog={addLog} />}
+        {activeTab === 'log' && (
+          <Panel>
+            <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
+              <span style={{ fontSize: 11, letterSpacing: 1.5, color: C.muted, textTransform: 'uppercase' }}>Activity Log</span>
+              <Btn onClick={() => { logRef.current = []; setLog([]); }} color={C.muted} outline style={{ padding: '3px 10px', fontSize: 10 }}>Clear</Btn>
+            </div>
+            {log.length === 0 && <div style={{ color: C.muted, fontSize: 11 }}>No activity yet.</div>}
+            {[...log].reverse().map((e, i) => (
+              <div key={i} style={{ display: 'flex', gap: 10, marginBottom: 4, fontSize: 11, borderBottom: `1px solid ${C.border}22`, paddingBottom: 3 }}>
+                <span style={{ color: C.muted, minWidth: 68, flexShrink: 0 }}>{e.t}</span>
+                <span style={{ color: e.type === 'error' ? C.danger : e.type === 'good' ? C.good : e.type === 'warn' ? C.gold : C.text }}>{e.msg}</span>
+              </div>
+            ))}
+          </Panel>
+        )}
+      </div>
+    </div>
+  );
+}

+ 322 - 0
src/__tests__/geometry.test.js

@@ -0,0 +1,322 @@
+import { describe, it, expect } from 'vitest';
+import {
+  floorHeightAtRadius,
+  ceilAbsoluteAt,
+  floorToCeilingAt,
+  computeHallGeometry,
+  computeCoverage,
+  computeFlutter,
+  haasColor,
+  haasLabel,
+} from '../geometry.js';
+import { HALL_DEFAULTS, SPEAKER_DEFAULTS } from '../constants.js';
+
+// ─── floorHeightAtRadius ──────────────────────────────────────────────────────
+
+describe('floorHeightAtRadius', () => {
+  it('returns 0 at the geometric centre (r=0)', () => {
+    expect(floorHeightAtRadius(0, HALL_DEFAULTS)).toBe(0);
+  });
+
+  it('returns 0 inside the flat communion table zone (r < row1Radius)', () => {
+    expect(floorHeightAtRadius(HALL_DEFAULTS.row1Radius - 1, HALL_DEFAULTS)).toBe(0);
+    expect(floorHeightAtRadius(1000, HALL_DEFAULTS)).toBe(0);
+  });
+
+  it('returns 0 at row1Radius (front edge of row 1 = start of dish slope)', () => {
+    expect(floorHeightAtRadius(HALL_DEFAULTS.row1Radius, HALL_DEFAULTS)).toBe(0);
+  });
+
+  it('returns dishRise (700 mm) at row8Back', () => {
+    expect(floorHeightAtRadius(HALL_DEFAULTS.row8Back, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.dishRise);
+  });
+
+  it('rises monotonically with radius beyond row1Radius', () => {
+    const r1 = floorHeightAtRadius(3000, HALL_DEFAULTS);
+    const r2 = floorHeightAtRadius(6000, HALL_DEFAULTS);
+    const r3 = floorHeightAtRadius(8411, HALL_DEFAULTS);
+    expect(r1).toBeLessThan(r2);
+    expect(r2).toBeLessThan(r3);
+  });
+
+  it('extrapolates linearly beyond row8Back into aisle/corner zone', () => {
+    const atRow8  = floorHeightAtRadius(HALL_DEFAULTS.row8Back, HALL_DEFAULTS);
+    const beyond  = floorHeightAtRadius(HALL_DEFAULTS.row8Back + 883, HALL_DEFAULTS);
+    expect(beyond).toBeGreaterThan(atRow8);
+  });
+});
+
+// ─── ceilAbsoluteAt ──────────────────────────────────────────────────────────
+
+describe('ceilAbsoluteAt', () => {
+  it('returns ceilFlat (4300) everywhere in the main seating zone', () => {
+    expect(ceilAbsoluteAt(0, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat);
+    expect(ceilAbsoluteAt(HALL_DEFAULTS.row1Radius, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat);
+    expect(ceilAbsoluteAt(5720, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat); // inner ring
+    expect(ceilAbsoluteAt(8600, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat); // outer ring
+    // row8Back is still in main zone (cornerZoneStart ≈ 9837)
+    expect(ceilAbsoluteAt(HALL_DEFAULTS.row8Back, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat);
+  });
+
+  it('returns a lower absolute ceiling in the corner zone', () => {
+    // Corner zone starts at hallWidth/2 - cornerFrontOffset = 9837 mm
+    const inCorner = ceilAbsoluteAt(10000, HALL_DEFAULTS);
+    expect(inCorner).toBeLessThan(HALL_DEFAULTS.ceilFlat);
+  });
+
+  it('corner ceiling absolute = cornerCeilClearance + floor rise there', () => {
+    const r = 10000;
+    const expected = HALL_DEFAULTS.cornerCeilClearance + floorHeightAtRadius(r, HALL_DEFAULTS);
+    expect(ceilAbsoluteAt(r, HALL_DEFAULTS)).toBe(expected);
+  });
+});
+
+// ─── floorToCeilingAt ────────────────────────────────────────────────────────
+
+describe('floorToCeilingAt', () => {
+  it('equals ceilFlat at the centre (floor = 0)', () => {
+    expect(floorToCeilingAt(0, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat);
+  });
+
+  it('equals 3600 mm at row8Back (confirmed measurement)', () => {
+    // ceilFlat (4300) − dishRise (700) = 3600 ✓
+    expect(floorToCeilingAt(HALL_DEFAULTS.row8Back, HALL_DEFAULTS)).toBe(3600);
+  });
+
+  it('decreases as radius increases (floor rises, flat ceiling stays)', () => {
+    const atRow1 = floorToCeilingAt(HALL_DEFAULTS.row1Radius + 500, HALL_DEFAULTS);
+    const atRow8 = floorToCeilingAt(HALL_DEFAULTS.row8Back, HALL_DEFAULTS);
+    expect(atRow1).toBeGreaterThan(atRow8);
+  });
+
+  it('corner clearance is structurally lower than main zone at similar radius', () => {
+    // At r=10000: corner zone gives cornerCeilClearance; no-corner hypothetical would be higher
+    const actual = floorToCeilingAt(10000, HALL_DEFAULTS);
+    expect(actual).toBe(HALL_DEFAULTS.cornerCeilClearance);
+  });
+});
+
+// ─── computeHallGeometry ─────────────────────────────────────────────────────
+
+describe('computeHallGeometry', () => {
+  const rowData = computeHallGeometry(HALL_DEFAULTS);
+
+  it('returns 8 main rows + 4 corner rows', () => {
+    expect(rowData.filter(r => !r.isCorner)).toHaveLength(8);
+    expect(rowData.filter(r => r.isCorner)).toHaveLength(HALL_DEFAULTS.cornerRows);
+  });
+
+  it('row 1 front edge starts at row1Radius', () => {
+    expect(rowData[0].frontEdge).toBe(HALL_DEFAULTS.row1Radius);
+  });
+
+  it('row 8 front edge is row1Radius + 7 × rowPitch', () => {
+    expect(rowData[7].frontEdge).toBe(HALL_DEFAULTS.row1Radius + 7 * HALL_DEFAULTS.rowPitch);
+  });
+
+  it('all main rows have ceilHeight = ceilFlat (flat ceiling)', () => {
+    rowData.filter(r => !r.isCorner).forEach(r => {
+      expect(r.ceilHeight).toBe(HALL_DEFAULTS.ceilFlat);
+    });
+  });
+
+  it('earToCell decreases from row 1 to row 8 (floor rises, ceiling is flat)', () => {
+    const mainRows = rowData.filter(r => !r.isCorner);
+    for (let i = 1; i < mainRows.length; i++) {
+      expect(mainRows[i].earToCell).toBeLessThan(mainRows[i - 1].earToCell);
+    }
+  });
+
+  it('earAFF = earHeight + floorRise for each row', () => {
+    rowData.forEach(r => {
+      expect(r.earAFF).toBe(HALL_DEFAULTS.earHeight + r.floorRise);
+    });
+  });
+
+  it('earToCell = ceilHeight − earAFF for each row', () => {
+    rowData.forEach(r => {
+      expect(r.earToCell).toBe(r.ceilHeight - r.earAFF);
+    });
+  });
+
+  it('corner rows are flagged isCorner = true', () => {
+    rowData.filter(r => r.isCorner).forEach(r => {
+      expect(r.isCorner).toBe(true);
+    });
+  });
+});
+
+// ─── computeCoverage ─────────────────────────────────────────────────────────
+
+describe('computeCoverage', () => {
+  const rowData = computeHallGeometry(HALL_DEFAULTS);
+  const coverage = computeCoverage(HALL_DEFAULTS, SPEAKER_DEFAULTS, rowData);
+
+  it('returns a result entry for every row', () => {
+    expect(coverage).toHaveLength(rowData.length);
+  });
+
+  it('every row has exactly one primary speaker (the nearest)', () => {
+    coverage.forEach(row => {
+      const primaries = Object.values(row.speakers)
+        .filter(Boolean)
+        .filter(s => s.haasStatus === 'primary');
+      expect(primaries).toHaveLength(1);
+    });
+  });
+
+  it('slant distance is always positive', () => {
+    coverage.forEach(row => {
+      Object.values(row.speakers).filter(Boolean).forEach(s => {
+        expect(s.slantM).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  it('delay is always positive', () => {
+    coverage.forEach(row => {
+      Object.values(row.speakers).filter(Boolean).forEach(s => {
+        expect(s.delay).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  it('haasExcess is 0 for the primary speaker', () => {
+    coverage.forEach(row => {
+      const primary = Object.values(row.speakers).filter(Boolean).find(s => s.haasStatus === 'primary');
+      expect(primary.haasExcess).toBe(0);
+    });
+  });
+
+  it('off-axis angle is between 0° and 90°', () => {
+    coverage.forEach(row => {
+      Object.values(row.speakers).filter(Boolean).forEach(s => {
+        expect(s.offAxisDeg).toBeGreaterThanOrEqual(0);
+        expect(s.offAxisDeg).toBeLessThanOrEqual(90);
+      });
+    });
+  });
+
+  it('centre speaker is primary for row 1 (closest to centre)', () => {
+    // Row 1 is at r≈2651mm; centre speaker at r=0 is almost directly above
+    const row1 = coverage[0];
+    expect(row1.speakers.centre?.haasStatus).toBe('primary');
+  });
+
+  it('inner ring (r=5720mm) is primary for rows near that radius', () => {
+    // Row 4 mid is at approximately 5301mm — inner ring at 5720mm is closest
+    const row4 = coverage[3];
+    expect(row4.speakers.inner?.haasStatus).toBe('primary');
+  });
+
+  it('SPL at ear is a reasonable value (50–110 dB range)', () => {
+    coverage.forEach(row => {
+      Object.values(row.speakers).filter(Boolean).forEach(s => {
+        expect(s.splAtEar).toBeGreaterThan(50);
+        expect(s.splAtEar).toBeLessThan(120);
+      });
+    });
+  });
+
+  it('disabled speakers produce null entries', () => {
+    const modifiedSpeakers = { ...SPEAKER_DEFAULTS, corner: { ...SPEAKER_DEFAULTS.corner, enabled: false } };
+    const result = computeCoverage(HALL_DEFAULTS, modifiedSpeakers, rowData);
+    result.forEach(row => {
+      expect(row.speakers.corner).toBeNull();
+    });
+  });
+});
+
+// ─── computeFlutter ──────────────────────────────────────────────────────────
+
+describe('computeFlutter', () => {
+  const flutter = computeFlutter(HALL_DEFAULTS);
+
+  it('centre clearance = ceilFlat − earHeight', () => {
+    expect(flutter.centreClearMm).toBe(HALL_DEFAULTS.ceilFlat - HALL_DEFAULTS.earHeight);
+    // = 4300 − 1100 = 3200 mm
+    expect(flutter.centreClearMm).toBe(3200);
+  });
+
+  it('row 8 clearance is less than centre (floor has risen)', () => {
+    expect(flutter.row8ClearMm).toBeLessThan(flutter.centreClearMm);
+  });
+
+  it('fundamental frequencies are positive', () => {
+    expect(flutter.fundamentalCentre).toBeGreaterThan(0);
+    expect(flutter.fundamentalRow8).toBeGreaterThan(0);
+    expect(flutter.fundamentalCorner).toBeGreaterThan(0);
+  });
+
+  it('fundamental increases toward perimeter (clearance decreases)', () => {
+    expect(flutter.fundamentalRow8).toBeGreaterThan(flutter.fundamentalCentre);
+  });
+
+  it('harmonics array has 5 entries', () => {
+    expect(flutter.harmonics).toHaveLength(5);
+  });
+
+  it('harmonics are integer multiples of the fundamental', () => {
+    flutter.harmonics.forEach((h, i) => {
+      expect(h).toBeCloseTo(flutter.fundamentalCentre * (i + 1), 0);
+    });
+  });
+
+  it('tile diffraction = speedOfSound × 1000 / tileGrid', () => {
+    const expected = Math.round((HALL_DEFAULTS.speedOfSound * 1000) / HALL_DEFAULTS.tileGrid);
+    expect(flutter.tileDiffraction).toBe(expected);
+    // 343 × 1000 / 600 = 572 Hz
+    expect(flutter.tileDiffraction).toBe(572);
+  });
+});
+
+// ─── haasColor / haasLabel ───────────────────────────────────────────────────
+
+describe('haasColor', () => {
+  it('returns green for primary', () => expect(haasColor('primary')).toBe('#34d399'));
+  it('returns blue for ok',      () => expect(haasColor('ok')).toBe('#4f9eff'));
+  it('returns amber for warn',   () => expect(haasColor('warn')).toBe('#f0b429'));
+  it('returns red for danger',   () => expect(haasColor('danger')).toBe('#f87171'));
+  it('returns muted for unknown',() => expect(haasColor('unknown')).toBe('#475569'));
+});
+
+describe('haasLabel', () => {
+  it('returns Primary for primary',          () => expect(haasLabel('primary')).toBe('Primary'));
+  it('returns OK <10ms for ok',              () => expect(haasLabel('ok')).toBe('OK <10ms'));
+  it('returns Warn 10–30ms for warn',        () => expect(haasLabel('warn')).toBe('Warn 10–30ms'));
+  it('returns Echo >30ms for danger',        () => expect(haasLabel('danger')).toBe('Echo >30ms'));
+  it('returns dash for unrecognised status', () => expect(haasLabel('whatever')).toBe('—'));
+});
+
+// ─── Integration: confirmed physical measurements ─────────────────────────────
+
+describe('Integration — confirmed physical measurements', () => {
+  it('floor-to-ceiling at row 8 back face = 3600 mm (confirmed survey)', () => {
+    expect(floorToCeilingAt(HALL_DEFAULTS.row8Back, HALL_DEFAULTS)).toBe(3600);
+  });
+
+  it('floor-to-ceiling at centre = 4300 mm (confirmed survey)', () => {
+    expect(floorToCeilingAt(0, HALL_DEFAULTS)).toBe(HALL_DEFAULTS.ceilFlat);
+    expect(floorToCeilingAt(0, HALL_DEFAULTS)).toBe(4300);
+  });
+
+  it('floor rise at row 8 back = 700 mm (dishRise)', () => {
+    expect(floorHeightAtRadius(HALL_DEFAULTS.row8Back, HALL_DEFAULTS)).toBe(700);
+  });
+
+  it('ceilFlat = floorToCeilingAtRow8 + dishRise (flat ceiling identity)', () => {
+    const row8Clearance = floorToCeilingAt(HALL_DEFAULTS.row8Back, HALL_DEFAULTS);
+    expect(HALL_DEFAULTS.ceilFlat).toBe(row8Clearance + HALL_DEFAULTS.dishRise);
+    // 4300 = 3600 + 700 ✓
+  });
+
+  it('seating plan width = 20914 mm (confirmed SK-303)', () => {
+    expect(HALL_DEFAULTS.hallWidth).toBe(20914);
+  });
+
+  it('aisle = hallWidth/2 − row8Back (confirmed)', () => {
+    const computed = HALL_DEFAULTS.hallWidth / 2 - HALL_DEFAULTS.row8Back;
+    expect(computed).toBe(HALL_DEFAULTS.aisleWidth);
+    expect(computed).toBe(2046);
+  });
+});

+ 126 - 0
src/constants.js

@@ -0,0 +1,126 @@
+// ─── HALL GEOMETRY ────────────────────────────────────────────────────────────
+// Confirmed measurements — SK-303 Version B + site survey
+//
+// PLAN  (square seating zone, all radii from geometric centre):
+//   Overall seating plan:  20914 × 20914 mm   (half-width = 10457 mm)
+//   Row 1 front edge:       2210 mm  (inner edge of first bench row)
+//   Row pitch (FFL–FFL):     883 mm  × 8 main rows
+//   Row 8 back face:        8411 mm  (per SK-303 drawing; includes ~20 mm bench thickness)
+//   Back aisle:             2046 mm  (10457 − 8411 = 2046 ✓)
+//   Corner rows:            4 rows per corner, same 883 mm pitch
+//   Corner row 1 front:     620 mm from the circular seating edge
+//
+// SECTION:
+//   CEILING IS FLAT — one horizontal plane at ceilFlat above the centre floor datum.
+//   Measured floor-to-ceiling at row 8 back = 3600 mm; floor at row 8 = +700 mm
+//   → flat ceiling at 3600 + 700 = 4300 mm above centre datum.
+//   Corner zone ceiling is structurally lower at cornerCeilClearance floor-to-ceiling.
+//
+//   FLOOR IS DISHED — rises from 0 mm at centre (communion table zone)
+//   to +700 mm at row 8 back face, slope ≈ 1:8.9.
+//   The flat centre zone (r = 0 → row1Radius) is the communion table area.
+//
+// SPEAKERS:  All flush-mounted (no drop rods).  Hanging face at ceiling height.
+//   Suggested power (Dudley Wilkin, 2010 Aberdeen layout):
+//     Centre (spk 13):  6 W   — covers rows 1–3
+//     Inner ring (1–8): 8 W   — covers rows 4–7
+//     Outer ring (9–12):4 W   — covers row 8 + aisle back
+//     Corner (2 per corner): 4 W
+
+export const HALL_DEFAULTS = {
+  // Plan
+  hallWidth:           20914, // mm — confirmed seating plan dimension
+  rows:                    8, // main circular rows
+  row1Radius:           2210, // mm — centre to front edge of row 1 (inner edge)
+  rowPitch:              883, // mm — FFL to FFL
+  row8Back:             8411, // mm — centre to back face of row 8 (per SK-303)
+  aisleWidth:           2046, // mm — back aisle behind row 8
+  // Corner seating
+  hasCornerRows:        true,
+  cornerRows:              4, // rows in each corner zone
+  cornerFrontOffset:     620, // mm — front of corner row 1 measured from circular seating edge
+  // Ceiling — FLAT throughout main seating zone
+  ceilFlat:             4300, // mm — absolute ceiling height above centre floor datum
+  //   Derivation: floor-to-ceiling at row 8 back (3600 mm) + floor rise at row 8 (700 mm)
+  cornerCeilClearance:  2950, // mm — floor-to-ceiling in corner zone (structural drop)
+  // Floor — dished bowl, lowest at centre
+  dishRise:              700, // mm — floor rise from centre datum to row 8 back face
+  //   Slope: 700 mm over 6201 mm (row1Radius → row8Back) ≈ 1:8.9
+  // Acoustic
+  earHeight:            1100, // mm — seated ear height above local floor
+  speedOfSound:          343, // m/s  (20 °C)
+  tileGrid:              600, // mm — ceiling tile grid (constrains speaker positions)
+};
+
+// ─── SPEAKER RINGS ────────────────────────────────────────────────────────────
+// All flush-mounted (dropRod = 0). Acoustic face is at the ceiling surface.
+// Power settings per Dudley Wilkin's 2010 Aberdeen 8-row hall proposal.
+
+export const SPEAKER_DEFAULTS = {
+  centre: {
+    enabled:     true,
+    count:          1,
+    radius:         0,   // mm — geometric centre of ceiling
+    dropRod:        0,   // mm — flush (no rod)
+    power:          6,   // W
+    sensitivity:   88,   // dB / 1 W / 1 m  (Visaton FRS8M + G20SC combined)
+    phase:          1,   // +1 normal, -1 anti-phase
+    label: 'Centre (×1)',
+  },
+  inner: {
+    enabled:     true,
+    count:          8,
+    radius:      5720,   // mm — confirmed site measurement
+    dropRod:        0,
+    power:          8,   // W
+    sensitivity:   88,
+    phase:          1,
+    label: 'Inner Ring (×8)',
+  },
+  outer: {
+    enabled:     true,
+    count:          8,
+    radius:      8600,   // mm — confirmed site measurement
+    dropRod:        0,
+    power:          4,   // W
+    sensitivity:   88,
+    phase:          1,
+    label: 'Outer Ring (×8)',
+  },
+  corner: {
+    enabled:     true,
+    count:          8,   // 2 per corner × 4 corners
+    radius:     10310,   // mm — confirmed; 1500 mm from side walls
+    dropRod:        0,
+    power:          4,   // W
+    sensitivity:   88,
+    phase:          1,
+    label: 'Corner (×8, 2/corner)',
+  },
+};
+
+// ─── APPLICATION SETTINGS ─────────────────────────────────────────────────────
+
+export const SETTINGS_DEFAULTS = {
+  // LLM provider
+  provider:        'ollama',
+  ollamaHost:      'http://127.0.0.1',
+  ollamaPort:      '11434',
+  ollamaModel:     '',
+  anthropicKey:    '',
+  anthropicModel:  'claude-sonnet-4-6',
+  // REW API
+  rewHost:         'http://127.0.0.1',
+  rewPort:         '4735',
+  // LLM behaviour
+  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 PBCC Universal Hall design,',
+    'including the MS6 speaker (Visaton FRS8M + G20SC drivers), the MIDAS MR12 active crossover',
+    'with its 433 Hz gap at 3 kHz, anti-phasing techniques, and the Haas effect in multi-speaker',
+    'environments. Analyse the provided coverage data and give specific, technical, actionable',
+    'recommendations.',
+  ].join(' '),
+};

+ 241 - 0
src/geometry.js

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

+ 5 - 0
src/main.jsx

@@ -0,0 +1,5 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import App from './App';
+
+createRoot(document.getElementById('root')).render(<App />);

+ 24 - 0
src/palette.js

@@ -0,0 +1,24 @@
+export 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',
+};

+ 16 - 0
vite.config.js

@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+  plugins: [react()],
+  test: {
+    globals: true,
+    environment: 'node',
+    include: ['src/__tests__/**/*.test.js'],
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'html'],
+      include: ['src/geometry.js', 'src/constants.js'],
+    },
+  },
+});