ソースを参照

Add Speech Transmission Index

Benjamin Harris 1 ヶ月 前
コミット
957d6d33f0
3 ファイル変更173 行追加7 行削除
  1. 38 7
      CLAUDE.md
  2. 76 0
      src/App.jsx
  3. 59 0
      src/geometry.js

+ 38 - 7
CLAUDE.md

@@ -185,22 +185,53 @@ Mono audio source
 ## Acoustic Analysis Objectives
 
 ### 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
+
+1. **STIPA** — Speech Transmission Index for PA systems (IEC 60268-16); the single number that captures all intelligibility-degrading effects; target ≥ 0.75 (Excellent)
+2. **Direct-to-Reverberant ratio** per seat — primary intelligibility driver; improved by anti-phasing (each listener hears only their nearest segment)
+3. **Haas window compliance** — speakers reaching a listener more than 30ms after the nearest speaker cause echo/colouration; 10–30ms causes tonal colouration; both degrade STI modulation depth
+4. **Off-axis angle** per speaker per row — must be within 55° MS6 coverage cone
+5. **SPL at ear** per row — from inverse square law using speaker sensitivity and power
+6. **Flutter echo** — flat ceiling + rising floor creates parallel surfaces at decreasing clearance toward perimeter; harmonics in 500–4kHz band directly degrade STI
+
+### STIPA measurement
+
+**STIPA** (Speech Transmission Index for PA systems) is the simplified single-measurement STI method defined in IEC 60268-16. It uses a pre-recorded test signal with specific modulation at frequencies across seven octave bands (125 Hz–8 kHz), played through the live PA system and measured with a handheld analyser.
+
+**IEC 60268-16 rating scale:**
+
+| Rating | STI range | Colour |
+| ------ | --------- | ------ |
+| Excellent | 0.75–1.00 | Green |
+| Good | 0.60–0.75 | Blue |
+| Fair | 0.45–0.60 | Amber |
+| Poor | 0.30–0.45 | Orange |
+| Bad | 0.00–0.30 | Red |
+
+Target for this hall: ≥ 0.75 (Excellent)
+
+Measurement equipment: NTI Acoustilyzer AL1 or equivalent STIPA-capable analyser.
+
+Key degradation mechanisms in this hall (computed risk factors in Analysis tab):
+
+- Haas echo rows (>30ms) — directly reduce modulation depth in all STI bands
+- Off-axis coverage gaps (outside 55° cone) — reduces signal level, lowering D/R ratio
+- Flutter harmonics in 500–4kHz speech band — reduce modulation depth at those frequencies
+- 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.
 
 ### 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)
+- **Warn:** 10–30ms Haas excess — amber (audible colouration, degrades STI)
+- **Danger:** >30ms Haas excess — red (distinct echo, significantly degrades STI)
 
 ### 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
+- Harmonics landing in 500–4kHz range are flagged as STI risk factors in the Analysis tab
 
 ---
 

+ 76 - 0
src/App.jsx

@@ -10,6 +10,9 @@ import {
   computeFlutter,
   haasColor,
   haasLabel,
+  stipaColor,
+  stipaLabel,
+  computeSTIRisks,
 } from './geometry';
 
 // ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
@@ -475,11 +478,13 @@ function AnalysisTab({ hall, speakers, settings, addLog }) {
   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);
   const coverage = computeCoverage(hall, speakers, rowData);
   const flutter  = computeFlutter(hall);
+  const stiRisks = computeSTIRisks(coverage, flutter);
 
   async function connectREW() {
     setRewStatus('checking');
@@ -580,6 +585,14 @@ ${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)'}
 
+## 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 ✓'}
+- Haas colouration rows (10–30 ms): ${stiRisks.haasWarnRows.length > 0 ? stiRisks.haasWarnRows.map(r => `Row ${r.row}`).join(', ') : 'None ✓'}
+- Rows fully outside 55° coverage cone: ${stiRisks.offAxisRows.length > 0 ? stiRisks.offAxisRows.map(r => `Row ${r.row}`).join(', ') : 'None ✓'}
+- Flutter harmonics in STI speech band (500–4000 Hz): ${stiRisks.flutterHarmonicsInBand.length > 0 ? stiRisks.flutterHarmonicsInBand.join(', ') + ' Hz' : 'None ✓'}
+- Tile diffraction (${flutter.tileDiffraction} Hz): ${stiRisks.tileDiffractionInBand ? '⚠ within speech band — may reduce modulation depth' : '✓ outside primary speech band'}
+
 ---
 
 Provide structured analysis:
@@ -587,6 +600,9 @@ Provide structured analysis:
 ### Hall Assessment
 Speech intelligibility assessment for 360° circular seating with dished floor and flat ceiling.
 
+### STI & Speech Intelligibility
+Assess STIPA result (if provided) against IEC 60268-16 thresholds. Correlate measured value with geometry-derived risk factors above. Identify the dominant degradation mechanisms and recommend corrective actions ranked by impact.
+
 ### Coverage Issues
 Identify rows outside the 55° MS6 cone, Haas violations (>10 ms = colouration, >30 ms = echo), SPL variation.
 
@@ -696,6 +712,66 @@ ARTA window size per row (limited by ceiling reflection time quoted above).`;
         )}
       </Panel>
 
+      <Panel>
+        <SectionHead sub="IEC 60268-16 — Excellent ≥ 0.75 | Good 0.60–0.75 | Fair 0.45–0.60 | Poor 0.30–0.45 | Bad < 0.30">STIPA Speech Intelligibility</SectionHead>
+        <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 16, alignItems: 'start', marginBottom: 16 }}>
+          <Field label="Measured STIPA" sub="NTI Acoustilyzer or equivalent (0.00–1.00)">
+            <NumIn value={stipaMeasured ?? ''} onChange={v => setStipaMeasured(v === '' ? null : Math.min(1, Math.max(0, v)))} min={0} max={1} step={0.01} />
+          </Field>
+          {stipaMeasured !== null && !isNaN(Number(stipaMeasured)) && (
+            <div style={{ paddingTop: 22, display: 'flex', alignItems: 'center', gap: 10 }}>
+              <div style={{ width: 14, height: 14, borderRadius: '50%', background: stipaColor(Number(stipaMeasured)), flexShrink: 0 }} />
+              <span style={{ color: stipaColor(Number(stipaMeasured)), fontWeight: 700, fontSize: 15 }}>{stipaLabel(Number(stipaMeasured))}</span>
+              <span style={{ color: C.muted, fontSize: 12 }}>STI {Number(stipaMeasured).toFixed(2)}</span>
+              {Number(stipaMeasured) < 0.75 && (
+                <span style={{ color: C.danger, fontSize: 11 }}>▲ {(0.75 - Number(stipaMeasured)).toFixed(2)} below Excellent</span>
+              )}
+            </div>
+          )}
+        </div>
+        <div style={{ fontSize: 11, color: C.muted, marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>Risk factors from geometry analysis</div>
+        <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
+          {[
+            {
+              label: `Haas echo rows (>30 ms)`,
+              detail: stiRisks.haasEchoRows.length > 0 ? stiRisks.haasEchoRows.map(r => `Row ${r.row}`).join(', ') : null,
+              ok: stiRisks.haasEchoRows.length === 0,
+              color: C.danger,
+            },
+            {
+              label: `Haas colouration rows (10–30 ms)`,
+              detail: stiRisks.haasWarnRows.length > 0 ? stiRisks.haasWarnRows.map(r => `Row ${r.row}`).join(', ') : null,
+              ok: stiRisks.haasWarnRows.length === 0,
+              color: C.gold,
+            },
+            {
+              label: `Rows outside 55° coverage cone`,
+              detail: stiRisks.offAxisRows.length > 0 ? stiRisks.offAxisRows.map(r => `Row ${r.row}`).join(', ') : null,
+              ok: stiRisks.offAxisRows.length === 0,
+              color: C.warn,
+            },
+            {
+              label: `Flutter harmonics in speech band (500–4 kHz)`,
+              detail: stiRisks.flutterHarmonicsInBand.length > 0 ? stiRisks.flutterHarmonicsInBand.join(', ') + ' Hz' : null,
+              ok: stiRisks.flutterHarmonicsInBand.length === 0,
+              color: C.warn,
+            },
+            {
+              label: `Tile diffraction (${flutter.tileDiffraction} Hz)`,
+              detail: stiRisks.tileDiffractionInBand ? 'Within speech band — may reduce modulation depth' : null,
+              ok: !stiRisks.tileDiffractionInBand,
+              color: C.gold,
+            },
+          ].map(({ label, detail, ok, color }) => (
+            <div key={label} style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: 12 }}>
+              <span style={{ color: ok ? C.good : color, flexShrink: 0 }}>{ok ? '✓' : '✗'}</span>
+              <span style={{ color: ok ? C.muted : C.text }}>{label}</span>
+              {detail && <span style={{ color, marginLeft: 4 }}>{detail}</span>}
+            </div>
+          ))}
+        </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}`}

+ 59 - 0
src/geometry.js

@@ -239,3 +239,62 @@ export const haasLabel = (s) => ({
   warn:    'Warn 10–30ms',
   danger:  'Echo >30ms',
 }[s] ?? '—');
+
+// ─── STIPA / STI HELPERS (IEC 60268-16) ──────────────────────────────────────
+
+export const stipaRating = (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 const stipaColor = (v) => ({
+  excellent: '#34d399',
+  good:      '#4f9eff',
+  fair:      '#f0b429',
+  poor:      '#fb923c',
+  bad:       '#f87171',
+})[stipaRating(v)];
+
+export const stipaLabel = (v) => ({
+  excellent: 'Excellent',
+  good:      'Good',
+  fair:      'Fair',
+  poor:      'Poor',
+  bad:       'Bad',
+})[stipaRating(v)];
+
+/**
+ * Derive STI risk factors from computed coverage and flutter data.
+ * These are acoustic conditions known to degrade modulation depth in STI bands.
+ * Returns counts and lists — not a substitute for a measured STIPA value.
+ */
+export function computeSTIRisks(coverage, flutter) {
+  const STI_SPEECH_LO = 500;
+  const STI_SPEECH_HI = 4000;
+
+  const haasEchoRows = coverage.filter(row =>
+    Object.values(row.speakers).some(s => s?.haasStatus === 'danger'),
+  );
+  const haasWarnRows = coverage.filter(row =>
+    Object.values(row.speakers).some(s => s?.haasStatus === 'warn') &&
+    !Object.values(row.speakers).some(s => s?.haasStatus === 'danger'),
+  );
+  const offAxisRows = coverage.filter(row =>
+    Object.values(row.speakers).filter(Boolean).length > 0 &&
+    Object.values(row.speakers).filter(Boolean).every(s => !s.inCoverage),
+  );
+
+  const flutterHarmonicsInBand = flutter.harmonics.filter(h => h >= STI_SPEECH_LO && h <= STI_SPEECH_HI);
+  const tileDiffractionInBand  = flutter.tileDiffraction >= STI_SPEECH_LO && flutter.tileDiffraction <= STI_SPEECH_HI;
+
+  return {
+    haasEchoRows,
+    haasWarnRows,
+    offAxisRows,
+    flutterHarmonicsInBand,
+    tileDiffractionInBand,
+  };
+}