|
@@ -10,6 +10,9 @@ import {
|
|
|
computeFlutter,
|
|
computeFlutter,
|
|
|
haasColor,
|
|
haasColor,
|
|
|
haasLabel,
|
|
haasLabel,
|
|
|
|
|
+ stipaColor,
|
|
|
|
|
+ stipaLabel,
|
|
|
|
|
+ computeSTIRisks,
|
|
|
} from './geometry';
|
|
} from './geometry';
|
|
|
|
|
|
|
|
// ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
|
|
// ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
|
|
@@ -475,11 +478,13 @@ function AnalysisTab({ hall, speakers, settings, addLog }) {
|
|
|
const [analysisState, setAnalysisState] = useState('idle');
|
|
const [analysisState, setAnalysisState] = useState('idle');
|
|
|
const [streamText, setStreamText] = useState('');
|
|
const [streamText, setStreamText] = useState('');
|
|
|
const [analysis, setAnalysis] = useState(null);
|
|
const [analysis, setAnalysis] = useState(null);
|
|
|
|
|
+ const [stipaMeasured, setStipaMeasured] = useState(null);
|
|
|
|
|
|
|
|
const rewBase = `${settings.rewHost}:${settings.rewPort}`;
|
|
const rewBase = `${settings.rewHost}:${settings.rewPort}`;
|
|
|
const rowData = computeHallGeometry(hall);
|
|
const rowData = computeHallGeometry(hall);
|
|
|
const coverage = computeCoverage(hall, speakers, rowData);
|
|
const coverage = computeCoverage(hall, speakers, rowData);
|
|
|
const flutter = computeFlutter(hall);
|
|
const flutter = computeFlutter(hall);
|
|
|
|
|
+ const stiRisks = computeSTIRisks(coverage, flutter);
|
|
|
|
|
|
|
|
async function connectREW() {
|
|
async function connectREW() {
|
|
|
setRewStatus('checking');
|
|
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)'}
|
|
${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:
|
|
Provide structured analysis:
|
|
@@ -587,6 +600,9 @@ Provide structured analysis:
|
|
|
### Hall Assessment
|
|
### Hall Assessment
|
|
|
Speech intelligibility assessment for 360° circular seating with dished floor and flat ceiling.
|
|
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
|
|
### Coverage Issues
|
|
|
Identify rows outside the 55° MS6 cone, Haas violations (>10 ms = colouration, >30 ms = echo), SPL variation.
|
|
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>
|
|
|
|
|
|
|
|
|
|
+ <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'}
|
|
<Btn onClick={runAnalysis} disabled={analysisState === 'loading' || analysisState === 'streaming'}
|
|
|
color={C.good} style={{ width: '100%', padding: 14, fontSize: 14, marginBottom: 14 }}>
|
|
color={C.good} style={{ width: '100%', padding: 14, fontSize: 14, marginBottom: 14 }}>
|
|
|
{(analysisState === 'loading' || analysisState === 'streaming') ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`}
|
|
{(analysisState === 'loading' || analysisState === 'streaming') ? `Analysing with ${providerLabel}…` : `🧠 Run AI Acoustic Analysis — ${providerLabel}`}
|