|
@@ -14,6 +14,13 @@ import {
|
|
|
stipaLabel,
|
|
stipaLabel,
|
|
|
computeSTIRisks,
|
|
computeSTIRisks,
|
|
|
} from './geometry';
|
|
} from './geometry';
|
|
|
|
|
+import {
|
|
|
|
|
+ STI_BANDS_HZ,
|
|
|
|
|
+ computeSTIfromRT60,
|
|
|
|
|
+ stiRatingLabel,
|
|
|
|
|
+ stiRatingColor,
|
|
|
|
|
+ parseRewRT60,
|
|
|
|
|
+} from './sti';
|
|
|
|
|
|
|
|
// ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
|
|
// ─── UI PRIMITIVES ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
@@ -473,12 +480,16 @@ function CoverageTab({ hall, speakers }) {
|
|
|
// ─── ANALYSIS TAB ─────────────────────────────────────────────────────────────
|
|
// ─── ANALYSIS TAB ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
function AnalysisTab({ hall, speakers, settings, addLog }) {
|
|
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 rewBase = `${settings.rewHost}:${settings.rewPort}`;
|
|
|
const rowData = computeHallGeometry(hall);
|
|
const rowData = computeHallGeometry(hall);
|
|
@@ -526,6 +537,20 @@ function AnalysisTab({ hall, speakers, settings, addLog }) {
|
|
|
return { freq: f, spl: near?.spl ?? 0 };
|
|
return { freq: f, spl: near?.spl ?? 0 };
|
|
|
});
|
|
});
|
|
|
setRewData({ bandData, freqPoints: freqPoints.filter((_, i) => i % 4 === 0).slice(0, 80) });
|
|
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');
|
|
setRewStatus('connected');
|
|
|
addLog('REW data ready ✓', 'good');
|
|
addLog('REW data ready ✓', 'good');
|
|
|
} catch (e) {
|
|
} 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() {
|
|
function buildPrompt() {
|
|
|
const flutter = computeFlutter(hall);
|
|
const flutter = computeFlutter(hall);
|
|
|
const row8Clr = hall.ceilFlat - hall.dishRise;
|
|
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)'}
|
|
${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)
|
|
## 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'}
|
|
${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 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>
|
|
</svg>
|
|
|
</div>
|
|
</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>
|
|
|
|
|
|
|
|
<Panel>
|
|
<Panel>
|