浏览代码

STI Measurement

Benjamin Harris 1 月之前
父节点
当前提交
ca5f4facd8
共有 4 个文件被更改,包括 316 次插入12 次删除
  1. 2 1
      .claude/settings.local.json
  2. 97 5
      CLAUDE.md
  3. 98 6
      src/App.jsx
  4. 119 0
      src/sti.js

+ 2 - 1
.claude/settings.local.json

@@ -2,7 +2,8 @@
   "permissions": {
     "allow": [
       "PowerShell(Get-Command node, npm, npx -ErrorAction SilentlyContinue)",
-      "PowerShell(docker --version 2>&1)"
+      "PowerShell(docker --version 2>&1)",
+      "WebFetch(domain:www.roomeqwizard.com)"
     ]
   }
 }

+ 97 - 5
CLAUDE.md

@@ -219,7 +219,69 @@ Key degradation mechanisms in this hall (computed risk factors in Analysis tab):
 - Tile diffraction at ~571Hz — may introduce a spectral dip in a critical STI band
 - HVAC noise via corner staging void — raises background noise floor in corner seating
 
-**STIPA vs continuous-sound measurement:** STIPA is valid for this hall type (it uses a modulated test signal, not raw SLM levels). Standard pink noise SLM readings are still invalid for checking individual speaker coverage due to anti-phase null lines.
+STIPA is valid for this hall type (it uses a modulated test signal, not raw SLM levels). Standard pink noise SLM readings are still invalid for checking individual speaker coverage due to anti-phase null lines.
+
+---
+
+### How to measure STIPA in this hall
+
+#### Equipment
+
+- **STIPA analyser** — NTI Acoustilyzer AL1 (or XL2 with STIPA option), or any IEC 60268-16 compliant handheld STI meter. The analyser contains the standardised STIPA test signal internally; no separate playback source is required.
+- **Measurement microphone** — the analyser's own omni mic capsule at ear height (1,100 mm above local floor level, which rises with the dish).
+
+#### Operating conditions before measuring
+
+1. **All speakers active** — every ring that will be in use during a meeting must be powered on. STIPA measures the whole system as the listener experiences it.
+2. **Anti-phasing active** — the MIDAS MR12 must be configured with the normal quadrant (or per-speaker) anti-phasing wiring. Do not bypass or disable anti-phasing for the measurement.
+3. **HVAC running** — measure with the ventilation system in its normal operating state. This captures the real background noise contribution from the corner staging voids and ductwork. Record a separate background noise level (HVAC on, no audio signal) as reference.
+4. **Hall otherwise empty** — audience absorption changes the reverb time; an occupied-hall measurement requires people in seats.
+5. **Signal level** — set the PA to normal meeting operating level before starting. The STIPA signal is output at whatever level the system is set to, so level calibration matters.
+
+#### Measurement positions
+
+The anti-phasing null lines run along the aisle centrelines. A microphone placed exactly on a null line will read an artificially low STI — this is acoustically correct at that point (it is genuinely poor) but is not representative of normal seating positions. Measure **between** null lines.
+
+Recommended positions (minimum set):
+
+| Zone | Mic position | Speaker dominant | Notes |
+| ---- | ------------ | ---------------- | ----- |
+| Centre zone | Row 2, mid-bench, left of aisle | Centre (×1) | Single speaker — no null lines |
+| Inner ring | Row 5, mid-bench, between two inner speakers | Inner ring | Avoid aisle centreline |
+| Outer ring | Row 8 mid, between two outer speakers | Outer ring | Near row 8 back face |
+| Back aisle | At back aisle centreline, between outer speakers | Outer ring | Check rear coverage |
+| Corner C1 | Corner row 2, mid-bench | Corner (×2/corner) | One side of null between corner pairs |
+| Corner C2 | Corner row 2, opposite corner | Corner (×2/corner) | Verify symmetry |
+
+For a full survey, take at least two readings per row (one each side of the aisle centreline nearest to that seating position) to confirm the null-line boundary is where the geometry predicts it.
+
+#### Step-by-step procedure
+
+1. Set up the PA at normal operating level. Confirm all rings are active and anti-phasing is correct.
+2. Start the HVAC, wait 2 minutes for the system to stabilise, then measure and record the background noise level (A-weighted dBA and NC curve if possible) at each measurement position. This is used by the STIPA algorithm internally but is worth recording separately.
+3. Position the mic at Row 2 mid-bench, ear height (local floor + 1,100 mm). Start the STIPA measurement. Wait for the analyser to complete its averaging period (typically 15 seconds for STIPA). Record the STI value and IEC rating.
+4. Move to each position in the table above, repeating the measurement. Allow 15 seconds settling time after moving before starting each measurement.
+5. At any position rated Fair or below, take a second measurement 300–400 mm to the left and right of the original position to distinguish between a genuine coverage gap and an accidental null-line hit.
+6. Record all results in a log: position, STI value, IEC rating, HVAC state, PA level, date/time, operator.
+7. Photograph the MR12 fader positions, AUX send levels, and PEQ settings at the time of measurement.
+
+#### Interpreting results
+
+- **Excellent (≥ 0.75) everywhere** — system is performing correctly.
+- **One zone consistently Good (0.60–0.75)** — likely a coverage or level issue in that zone; check off-axis angles and SPL in the Analysis tab.
+- **Fair or below in any seat** — investigate: Haas violations (check delay values in Coverage tab), off-axis angles, flutter (compare flutter harmonic frequencies against any spectral dip visible in REW), or background noise.
+- **Corner seating consistently lower than main seating** — HVAC noise ingress via staging void is the most likely cause; re-measure with HVAC off to isolate.
+- **STI varies widely left-to-right at same row** — null-line boundaries may be misaligned with seating positions; review anti-phasing wiring orientation.
+
+#### What STIPA does not replace
+
+STIPA gives a single-number intelligibility score but does not identify the cause. Use it alongside:
+
+- The **Coverage tab** to diagnose Haas violations and off-axis angles
+- **REW impulse measurements** (ARTA method, minimum 10 ms window) to see the actual frequency response and early reflection pattern at each position
+- The **Flutter analysis** section to correlate any spectral dips with flutter harmonic frequencies
+
+---
 
 ### Haas window rules applied in code
 - **Primary:** nearest speaker to that row (0ms excess) — green
@@ -289,12 +351,42 @@ dishRise: 700,          // floor rise from centre to row 8 back face
 ## 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)
+- Full API spec: https://www.roomeqwizard.com/help/help_en-GB/html/api.html
+
+### Endpoints used
+
+| Endpoint | Purpose |
+| -------- | ------- |
+| `GET /application` | Connection test |
+| `PUT /roomsim/room-size` | Push hall dimensions to REW room simulator |
+| `GET /roomsim/frequency-response?micposition=Main&ppo=24` | Simulated frequency response (Base64 big-endian float32) |
+| `GET /measurements` | List all saved measurements (returns array with uuid, title) |
+| `GET /measurements/:id/rt60?octaveFrac=1` | Per-octave RT60 values for STI calculation |
+| `GET /measurements/:id/impulse-response` | Raw impulse response (Base64 big-endian float32) |
+| `GET /measurements/:id/frequency-response` | Frequency response for a saved measurement |
+
+### STI from REW impulse response
+
+REW can calculate STI internally (via its STI panel, IEC 60268-16:2020 indirect method). The app implements the same calculation:
+
+1. Connect to REW — the app fetches the saved measurement list automatically
+2. Select a measurement from the dropdown in the Analysis tab
+3. Click **Compute STI** — the app calls `GET /measurements/:id/rt60?octaveFrac=1` and runs the IEC 60268-16 Schroeder/Houtgast–Steeneken calculation
+4. The result shows overall STI, per-band Transmission Index (TI), and per-band T60
+
+**Calculation method** (implemented in `src/sti.js`):
+
+- For each of the 7 STI octave bands (125 Hz–8 kHz) and 14 modulation frequencies (0.63–12.5 Hz):
+  - MTF from T60: `1 / √(1 + (2π × Fm × T60 / 13.8)²)` (Schroeder formula)
+  - Optional noise correction: `MTF_eff = MTF × 10^(SNR/10) / (1 + 10^(SNR/10))`
+  - Apparent SNR: `10 × log10(MTF_eff / (1 − MTF_eff))`, clamped to [−15, +15] dB
+  - Band TI: `(SNR_apparent + 15) / 30`, averaged over 14 modulation frequencies
+- STI = `Σ(α_k × TI_k) − Σ(β_k × √(TI_k × TI_{k+1}))` using male speech weights (IEC Table B.1)
+- No background noise correction is applied unless per-band SNR is provided
+
+**Important:** The RT60 method gives a theoretically exact STI only for a diffuse reverberant field. In this hall with anti-phasing, each listener hears primarily one speaker, so the effective RT60 driving that listener's intelligibility is different from the room RT60. Treat the REW-derived STI as an upper bound; compare with measured STIPA at representative seat positions.
 
 ---
 

+ 98 - 6
src/App.jsx

@@ -14,6 +14,13 @@ import {
   stipaLabel,
   computeSTIRisks,
 } from './geometry';
+import {
+  STI_BANDS_HZ,
+  computeSTIfromRT60,
+  stiRatingLabel,
+  stiRatingColor,
+  parseRewRT60,
+} from './sti';
 
 // ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
 
@@ -473,12 +480,16 @@ function CoverageTab({ hall, speakers }) {
 // ─── 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 [stipaMeasured, setStipaMeasured] = useState(null);
+  const [rewStatus,      setRewStatus]      = useState('idle');
+  const [rewData,        setRewData]        = useState(null);
+  const [rewMeasures,    setRewMeasures]    = useState([]);
+  const [selectedMeasId, setSelectedMeasId] = useState('');
+  const [rewSTI,         setRewSTI]         = useState(null);
+  const [stiStatus,      setStiStatus]      = useState('idle');
+  const [analysisState,  setAnalysisState]  = useState('idle');
+  const [streamText,     setStreamText]     = useState('');
+  const [analysis,       setAnalysis]       = useState(null);
+  const [stipaMeasured,  setStipaMeasured]  = useState(null);
 
   const rewBase  = `${settings.rewHost}:${settings.rewPort}`;
   const rowData  = computeHallGeometry(hall);
@@ -526,6 +537,20 @@ function AnalysisTab({ hall, speakers, settings, addLog }) {
         return { freq: f, spl: near?.spl ?? 0 };
       });
       setRewData({ bandData, freqPoints: freqPoints.filter((_, i) => i % 4 === 0).slice(0, 80) });
+
+      // Fetch list of stored measurements for STI computation
+      try {
+        const measList = await fetch(`${rewBase}/measurements`).then(r => r.json());
+        const measures = Array.isArray(measList)
+          ? measList.map(m => ({ id: m.uuid ?? m.id, title: m.title ?? `Measurement ${m.uuid ?? m.id}` }))
+          : [];
+        setRewMeasures(measures);
+        if (measures.length > 0) setSelectedMeasId(measures[0].id);
+        addLog(`${measures.length} measurement(s) found in REW`, 'good');
+      } catch {
+        addLog('Could not list REW measurements (none saved yet?)', 'warn');
+      }
+
       setRewStatus('connected');
       addLog('REW data ready ✓', 'good');
     } catch (e) {
@@ -534,6 +559,25 @@ function AnalysisTab({ hall, speakers, settings, addLog }) {
     }
   }
 
+  async function computeSTIFromREW() {
+    if (!selectedMeasId) return;
+    setStiStatus('loading');
+    addLog(`Fetching RT60 for measurement ${selectedMeasId}…`);
+    try {
+      const rt60Resp = await fetch(`${rewBase}/measurements/${selectedMeasId}/rt60?octaveFrac=1`).then(r => r.json());
+      const rt60Bands = parseRewRT60(rt60Resp);
+      if (!rt60Bands) throw new Error('Could not parse RT60 data from REW response');
+
+      const result = computeSTIfromRT60(rt60Bands);
+      setRewSTI({ ...result, rt60Bands, measId: selectedMeasId });
+      setStiStatus('done');
+      addLog(`STI computed: ${result.sti.toFixed(2)} (${result.rating}) ✓`, 'good');
+    } catch (e) {
+      setStiStatus('error');
+      addLog(`STI error: ${e.message}`, 'error');
+    }
+  }
+
   function buildPrompt() {
     const flutter  = computeFlutter(hall);
     const row8Clr  = hall.ceilFlat - hall.dishRise;
@@ -585,6 +629,11 @@ ${coverage.map(row =>
 
 ${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)'}
 
+${rewSTI ? `## STI from REW Impulse Response (IEC 60268-16 indirect method)
+- Overall STI: ${rewSTI.sti.toFixed(3)} — ${rewSTI.rating}
+- Per-octave-band TI and RT60:
+${STI_BANDS_HZ.map((f, k) => `  ${f >= 1000 ? f / 1000 + 'kHz' : f + 'Hz'}: TI = ${rewSTI.tiPerBand[k].toFixed(3)}, T60 = ${rewSTI.rt60Bands[k].toFixed(2)} s`).join('\n')}` : '## REW STI: Not yet computed (connect to REW and select a measurement)'}
+
 ## STIPA Speech Intelligibility (IEC 60268-16 — target ≥ 0.75 Excellent)
 ${stipaMeasured !== null ? `- Measured STIPA: ${Number(stipaMeasured).toFixed(2)} — ${stipaLabel(Number(stipaMeasured))} (${Number(stipaMeasured) >= 0.75 ? 'meets' : 'BELOW'} Excellent threshold)` : '- No STIPA measurement entered — geometry-based risk assessment only'}
 - Haas echo rows (>30 ms — degrades STI modulation): ${stiRisks.haasEchoRows.length > 0 ? stiRisks.haasEchoRows.map(r => `Row ${r.row}`).join(', ') : 'None ✓'}
@@ -710,6 +759,49 @@ ARTA window size per row (limited by ceiling reflection time quoted above).`;
             </svg>
           </div>
         )}
+        {rewMeasures.length > 0 && (
+          <div style={{ marginTop: 16, borderTop: `1px solid ${C.border}`, paddingTop: 14 }}>
+            <div style={{ fontSize: 11, color: C.muted, textTransform: 'uppercase', letterSpacing: 1, marginBottom: 8 }}>
+              STI from impulse response (IEC 60268-16 indirect method)
+            </div>
+            <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
+              <select
+                value={selectedMeasId}
+                onChange={e => { setSelectedMeasId(e.target.value); setRewSTI(null); setStiStatus('idle'); }}
+                style={{ flex: 1, minWidth: 200, padding: '7px 10px', background: C.bgAlt, border: `1px solid ${C.border}`, borderRadius: 6, color: C.text, fontSize: 13 }}
+              >
+                {rewMeasures.map(m => <option key={m.id} value={m.id}>{m.title}</option>)}
+              </select>
+              <Btn onClick={computeSTIFromREW} disabled={stiStatus === 'loading' || !selectedMeasId} color={C.accentDim}>
+                {stiStatus === 'loading' ? 'Computing…' : 'Compute STI'}
+              </Btn>
+            </div>
+            {rewSTI && (
+              <div style={{ marginTop: 12 }}>
+                <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
+                  <div style={{ width: 12, height: 12, borderRadius: '50%', background: stiRatingColor(rewSTI.sti) }} />
+                  <span style={{ color: stiRatingColor(rewSTI.sti), fontWeight: 700, fontSize: 15 }}>{stiRatingLabel(rewSTI.sti)}</span>
+                  <span style={{ color: C.heading, fontSize: 18, fontFamily: 'monospace' }}>{rewSTI.sti.toFixed(3)}</span>
+                  <span style={{ color: C.muted, fontSize: 11 }}>STI (IEC 60268-16)</span>
+                  {rewSTI.sti < 0.75 && (
+                    <span style={{ color: C.danger, fontSize: 11 }}>▲ {(0.75 - rewSTI.sti).toFixed(3)} below Excellent</span>
+                  )}
+                </div>
+                <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 4 }}>
+                  {STI_BANDS_HZ.map((f, k) => (
+                    <div key={f} style={{ background: C.bgAlt, borderRadius: 6, padding: '6px 4px', textAlign: 'center' }}>
+                      <div style={{ fontSize: 10, color: C.muted, marginBottom: 3 }}>{f >= 1000 ? `${f / 1000}k` : f} Hz</div>
+                      <div style={{ fontSize: 12, color: stiRatingColor(rewSTI.tiPerBand[k]), fontFamily: 'monospace', fontWeight: 600 }}>{rewSTI.tiPerBand[k].toFixed(2)}</div>
+                      <div style={{ fontSize: 10, color: C.muted }}>{rewSTI.rt60Bands[k].toFixed(2)}s</div>
+                    </div>
+                  ))}
+                </div>
+                <div style={{ fontSize: 10, color: C.muted, marginTop: 4 }}>Per-band: TI (colour) / T60 — no background noise correction applied</div>
+              </div>
+            )}
+            {stiStatus === 'error' && <div style={{ color: C.danger, fontSize: 12, marginTop: 8 }}>⚠ Could not fetch RT60 — check REW measurement is saved</div>}
+          </div>
+        )}
       </Panel>
 
       <Panel>

+ 119 - 0
src/sti.js

@@ -0,0 +1,119 @@
+// ─── STI CALCULATION — IEC 60268-16:2020 INDIRECT METHOD ────────────────────
+// Computes Speech Transmission Index from per-octave RT60 and SNR values
+// (Schroeder/Houtgast–Steeneken approximation).
+//
+// Input:  rt60PerBand[7]  — T60 in seconds for 125, 250, 500, 1k, 2k, 4k, 8k Hz
+//         snrPerBand[7]   — signal-to-noise ratio in dB per octave band
+//                           (use 30 dB where noise is unknown — effectively no degradation)
+// Output: { sti, tiPerBand, rating }
+
+// ─── CONSTANTS ────────────────────────────────────────────────────────────────
+
+export const STI_BANDS_HZ = [125, 250, 500, 1000, 2000, 4000, 8000];
+
+// 14 modulation frequencies (Hz) defined in IEC 60268-16:2020
+const MOD_FREQS = [0.63, 0.80, 1.00, 1.25, 1.60, 2.00, 2.50, 3.15, 4.00, 5.00, 6.30, 8.00, 10.00, 12.50];
+
+// Male speech weighting (IEC 60268-16:2020 Table B.1, auditory masking excluded)
+// α weights per band; β inter-band redundancy (0 for last band)
+const ALPHA = [0.085, 0.127, 0.230, 0.233, 0.309, 0.224, 0.173];
+const BETA  = [0.085, 0.078, 0.065, 0.011, 0.047, 0.095, 0.000];
+// Σα − Σβ = 1.381 − 0.381 = 1.000 (identity check)
+
+// ─── CORE CALCULATION ─────────────────────────────────────────────────────────
+
+/**
+ * Compute STI from RT60 per octave band using the Schroeder formula.
+ *
+ * @param {number[]} rt60PerBand  T60 in seconds for each of the 7 STI octave bands
+ * @param {number[]} snrPerBand   SNR in dB per band (default 30 dB if not available)
+ * @returns {{ sti: number, tiPerBand: number[], rating: string }}
+ */
+export function computeSTIfromRT60(rt60PerBand, snrPerBand = Array(7).fill(30)) {
+  const tiPerBand = STI_BANDS_HZ.map((_, k) => {
+    const T  = Math.max(0.01, rt60PerBand[k]);
+    const snr = snrPerBand[k];
+
+    const modTIs = MOD_FREQS.map(Fm => {
+      // Schroeder: MTF from exponential decay with T60
+      const mtfT60 = 1 / Math.sqrt(1 + Math.pow((2 * Math.PI * Fm * T) / 13.8, 2));
+
+      // Noise correction (additive noise reduces MTF)
+      const linearSNR = Math.pow(10, snr / 10);
+      const mtfEff    = mtfT60 * linearSNR / (1 + linearSNR);
+
+      // Apparent SNR, clamped to [−15, +15] dB
+      const ratio    = Math.max(1e-6, Math.min(1 - 1e-6, mtfEff));
+      const snrApp   = Math.max(-15, Math.min(15, 10 * Math.log10(ratio / (1 - ratio))));
+
+      return (snrApp + 15) / 30;
+    });
+
+    return modTIs.reduce((s, v) => s + v, 0) / modTIs.length;
+  });
+
+  // Weighted combination across bands with inter-band redundancy correction
+  let sti = 0;
+  for (let k = 0; k < 7; k++) {
+    sti += ALPHA[k] * tiPerBand[k];
+    if (k < 6) sti -= BETA[k] * Math.sqrt(tiPerBand[k] * tiPerBand[k + 1]);
+  }
+  sti = Math.max(0, Math.min(1, sti));
+
+  return { sti, tiPerBand, rating: stiRatingLabel(sti) };
+}
+
+// ─── RATING HELPERS (IEC 60268-16:2020) ──────────────────────────────────────
+
+export function stiRatingLabel(v) {
+  if (v >= 0.75) return 'Excellent';
+  if (v >= 0.60) return 'Good';
+  if (v >= 0.45) return 'Fair';
+  if (v >= 0.30) return 'Poor';
+  return 'Bad';
+}
+
+export function stiRatingColor(v) {
+  if (v >= 0.75) return '#34d399';
+  if (v >= 0.60) return '#4f9eff';
+  if (v >= 0.45) return '#f0b429';
+  if (v >= 0.30) return '#fb923c';
+  return '#f87171';
+}
+
+// ─── REW RT60 RESPONSE PARSER ─────────────────────────────────────────────────
+
+/**
+ * Parse REW's RT60 API response into per-octave-band T60 values.
+ * REW returns Base64-encoded big-endian float32 pairs [frequency, T60_seconds].
+ * We pick the 7 STI octave band centre frequencies.
+ *
+ * @param {object} rewRT60Response  Raw JSON from GET /measurements/:id/rt60
+ * @returns {number[]}  T60 in seconds for STI_BANDS_HZ, or null if parse fails
+ */
+export function parseRewRT60(rewRT60Response) {
+  try {
+    const b64 = rewRT60Response?.data ?? rewRT60Response?.rt60 ?? rewRT60Response?.magnitudes;
+    if (!b64) return null;
+
+    const bin = atob(b64);
+    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);
+
+    // Pairs of [freq_Hz, T60_s] as big-endian float32
+    const points = [];
+    for (let i = 0; i + 7 < buf.length; i += 8) {
+      points.push({ freq: view.getFloat32(i, false), t60: view.getFloat32(i + 4, false) });
+    }
+    if (points.length === 0) return null;
+
+    return STI_BANDS_HZ.map(target => {
+      const nearest = points.reduce((b, p) =>
+        Math.abs(p.freq - target) < Math.abs(b.freq - target) ? p : b, points[0]);
+      return Math.max(0.1, nearest.t60);
+    });
+  } catch {
+    return null;
+  }
+}