|
@@ -27,14 +27,16 @@ const MOD_FREQS_FULL = [
|
|
|
// STIPA: 2 modulation frequencies per octave band (IEC 60268-16:2020)
|
|
// STIPA: 2 modulation frequencies per octave band (IEC 60268-16:2020)
|
|
|
// Row = octave band [125, 250, 500, 1k, 2k, 4k, 8k Hz]
|
|
// Row = octave band [125, 250, 500, 1k, 2k, 4k, 8k Hz]
|
|
|
// Source: zawi01/stipa, confirmed against IEC standard Table C.1
|
|
// Source: zawi01/stipa, confirmed against IEC standard Table C.1
|
|
|
|
|
+// Source: zawi01/stipa stipa.m fm matrix, read column-by-column (k=1:7 bands)
|
|
|
|
|
+// fm = [1.6, 1, 0.63, 2, 1.25, 0.8, 2.5; 8, 5, 3.15, 10, 6.25, 4, 12.5]
|
|
|
export const STIPA_MOD_FREQS = [
|
|
export const STIPA_MOD_FREQS = [
|
|
|
- [1.00, 5.00], // 125 Hz
|
|
|
|
|
- [0.63, 3.15], // 250 Hz
|
|
|
|
|
- [2.00, 10.00], // 500 Hz
|
|
|
|
|
- [1.25, 8.00], // 1 kHz
|
|
|
|
|
- [0.80, 4.00], // 2 kHz
|
|
|
|
|
- [2.50, 12.50], // 4 kHz
|
|
|
|
|
- [6.30, 1.60], // 8 kHz
|
|
|
|
|
|
|
+ [1.60, 8.00], // 125 Hz
|
|
|
|
|
+ [1.00, 5.00], // 250 Hz
|
|
|
|
|
+ [0.63, 3.15], // 500 Hz
|
|
|
|
|
+ [2.00, 10.00], // 1 kHz
|
|
|
|
|
+ [1.25, 6.25], // 2 kHz (6.25, not 6.3)
|
|
|
|
|
+ [0.80, 4.00], // 4 kHz
|
|
|
|
|
+ [2.50, 12.50], // 8 kHz
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
// STIPA test-signal band levels relative to 0 dBFS (Revision 5, IEC 60268-16)
|
|
// STIPA test-signal band levels relative to 0 dBFS (Revision 5, IEC 60268-16)
|
|
@@ -142,6 +144,330 @@ export function stiRatingColor(v) {
|
|
|
return '#f87171';
|
|
return '#f87171';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// ─── STIPA SIGNAL GENERATOR (IEC 60268-16:2020) ──────────────────────────────
|
|
|
|
|
+//
|
|
|
|
|
+// Port of zawi01/stipa generateStipaSignal.m
|
|
|
|
|
+// Generates the standard STIPA test signal as an AudioBuffer.
|
|
|
|
|
+// Pink noise carrier, amplitude-modulated per octave band at 2 modulation
|
|
|
|
|
+// frequencies, with band-level scaling (Revision 5).
|
|
|
|
|
+//
|
|
|
|
|
+// NOTE: Uses the Web Audio API — call only in browser context.
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Generate the STIPA test signal and return an AudioBuffer.
|
|
|
|
|
+ * Caller can play via AudioContext or export as WAV via encodeWAV().
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {number} duration Signal length in seconds (15–25 recommended, default 18)
|
|
|
|
|
+ * @param {number} sampleRate Audio sample rate in Hz (min 22050, default 48000)
|
|
|
|
|
+ * @returns {Promise<AudioBuffer>}
|
|
|
|
|
+ */
|
|
|
|
|
+export async function generateStipaSignal(duration = 18, sampleRate = 48000) {
|
|
|
|
|
+ const N = Math.round(duration * sampleRate);
|
|
|
|
|
+ const ctx = new OfflineAudioContext(1, N, sampleRate);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Pink noise via Paul Kellett's approximation (voss-mcartney filtered white noise)
|
|
|
|
|
+ const whiteBuffer = ctx.createBuffer(1, N, sampleRate);
|
|
|
|
|
+ const whiteData = whiteBuffer.getChannelData(0);
|
|
|
|
|
+ // Approximate pink noise using sum of white noise sources at octave intervals
|
|
|
|
|
+ let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
|
|
|
|
|
+ for (let i = 0; i < N; i++) {
|
|
|
|
|
+ const white = Math.random() * 2 - 1;
|
|
|
|
|
+ b0 = 0.99886 * b0 + white * 0.0555179;
|
|
|
|
|
+ b1 = 0.99332 * b1 + white * 0.0750759;
|
|
|
|
|
+ b2 = 0.96900 * b2 + white * 0.1538520;
|
|
|
|
|
+ b3 = 0.86650 * b3 + white * 0.3104856;
|
|
|
|
|
+ b4 = 0.55000 * b4 + white * 0.5329522;
|
|
|
|
|
+ b5 = -0.7616 * b5 - white * 0.0168980;
|
|
|
|
|
+ whiteData[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) / 7;
|
|
|
|
|
+ b6 = white * 0.115926;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Per-band: octave bandpass filter → amplitude modulate → apply gain
|
|
|
|
|
+ const BAND_GAINS = STIPA_BAND_LEVELS_DB.map(db => Math.pow(10, db / 20));
|
|
|
|
|
+ const REF_DEPTH = 0.55; // IEC standard reference modulation depth
|
|
|
|
|
+
|
|
|
|
|
+ let outputData = new Float32Array(N);
|
|
|
|
|
+
|
|
|
|
|
+ for (let k = 0; k < 7; k++) {
|
|
|
|
|
+ const fc = STI_BANDS_HZ[k];
|
|
|
|
|
+ const [fm1, fm2] = STIPA_MOD_FREQS[k];
|
|
|
|
|
+
|
|
|
|
|
+ // Single-pole bandpass approximation around each octave centre
|
|
|
|
|
+ // Q = √2 for 1-octave bandwidth (2nd-order Butterworth bandpass)
|
|
|
|
|
+ const bandSrc = ctx.createBufferSource();
|
|
|
|
|
+ bandSrc.buffer = whiteBuffer;
|
|
|
|
|
+ const bpFilter = ctx.createBiquadFilter();
|
|
|
|
|
+ bpFilter.type = 'bandpass';
|
|
|
|
|
+ bpFilter.frequency.value = fc;
|
|
|
|
|
+ bpFilter.Q.value = Math.SQRT2;
|
|
|
|
|
+
|
|
|
|
|
+ const gainNode = ctx.createGain();
|
|
|
|
|
+ gainNode.gain.value = BAND_GAINS[k];
|
|
|
|
|
+
|
|
|
|
|
+ bandSrc.connect(bpFilter);
|
|
|
|
|
+ bpFilter.connect(gainNode);
|
|
|
|
|
+ gainNode.connect(ctx.destination);
|
|
|
|
|
+ bandSrc.start(0);
|
|
|
|
|
+
|
|
|
|
|
+ // We need the filtered samples to apply time-varying AM modulation.
|
|
|
|
|
+ // Web Audio API doesn't expose per-sample access in OfflineAudioContext
|
|
|
|
|
+ // mid-graph, so we render each band separately then modulate in JS.
|
|
|
|
|
+ const bandCtx = new OfflineAudioContext(1, N, sampleRate);
|
|
|
|
|
+ const bandSrc2 = bandCtx.createBufferSource();
|
|
|
|
|
+ bandSrc2.buffer = whiteBuffer;
|
|
|
|
|
+ const bpFilt2 = bandCtx.createBiquadFilter();
|
|
|
|
|
+ bpFilt2.type = 'bandpass';
|
|
|
|
|
+ bpFilt2.frequency.value = fc;
|
|
|
|
|
+ bpFilt2.Q.value = Math.SQRT2;
|
|
|
|
|
+ bandSrc2.connect(bpFilt2);
|
|
|
|
|
+ bpFilt2.connect(bandCtx.destination);
|
|
|
|
|
+ bandSrc2.start(0);
|
|
|
|
|
+
|
|
|
|
|
+ const rendered = await bandCtx.startRendering();
|
|
|
|
|
+ const bandData = rendered.getChannelData(0);
|
|
|
|
|
+
|
|
|
|
|
+ // Amplitude modulation envelope:
|
|
|
|
|
+ // envelope = √(0.5 × (1 + 0.55 × (sin(2π·fm1·t) − sin(2π·fm2·t))))
|
|
|
|
|
+ for (let i = 0; i < N; i++) {
|
|
|
|
|
+ const t = i / sampleRate;
|
|
|
|
|
+ const env = Math.sqrt(Math.max(0, 0.5 * (1 + REF_DEPTH * (
|
|
|
|
|
+ Math.sin(2 * Math.PI * fm1 * t) - Math.sin(2 * Math.PI * fm2 * t)
|
|
|
|
|
+ ))));
|
|
|
|
|
+ outputData[i] += bandData[i] * env * BAND_GAINS[k];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Normalise to target RMS = 0.1
|
|
|
|
|
+ const rms = Math.sqrt(outputData.reduce((s, v) => s + v * v, 0) / N);
|
|
|
|
|
+ if (rms > 0) {
|
|
|
|
|
+ const scale = 0.1 / rms;
|
|
|
|
|
+ for (let i = 0; i < N; i++) outputData[i] *= scale;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const outBuf = ctx.createBuffer(1, N, sampleRate);
|
|
|
|
|
+ outBuf.copyToChannel(outputData, 0);
|
|
|
|
|
+ return outBuf;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Encode an AudioBuffer (mono, Float32) as a 16-bit PCM WAV Blob.
|
|
|
|
|
+ * @param {AudioBuffer} audioBuffer
|
|
|
|
|
+ * @returns {Blob}
|
|
|
|
|
+ */
|
|
|
|
|
+export function encodeWAV(audioBuffer) {
|
|
|
|
|
+ const samples = audioBuffer.getChannelData(0);
|
|
|
|
|
+ const sampleRate = audioBuffer.sampleRate;
|
|
|
|
|
+ const numSamples = samples.length;
|
|
|
|
|
+ const buf = new ArrayBuffer(44 + numSamples * 2);
|
|
|
|
|
+ const view = new DataView(buf);
|
|
|
|
|
+ const write = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); };
|
|
|
|
|
+
|
|
|
|
|
+ write(0, 'RIFF');
|
|
|
|
|
+ view.setUint32(4, 36 + numSamples * 2, true);
|
|
|
|
|
+ write(8, 'WAVE');
|
|
|
|
|
+ write(12, 'fmt ');
|
|
|
|
|
+ view.setUint32(16, 16, true);
|
|
|
|
|
+ view.setUint16(20, 1, true); // PCM
|
|
|
|
|
+ view.setUint16(22, 1, true); // mono
|
|
|
|
|
+ view.setUint32(24, sampleRate, true);
|
|
|
|
|
+ view.setUint32(28, sampleRate * 2, true); // byte rate
|
|
|
|
|
+ view.setUint16(32, 2, true); // block align
|
|
|
|
|
+ view.setUint16(34, 16, true); // bits per sample
|
|
|
|
|
+ write(36, 'data');
|
|
|
|
|
+ view.setUint32(40, numSamples * 2, true);
|
|
|
|
|
+
|
|
|
|
|
+ let offset = 44;
|
|
|
|
|
+ for (let i = 0; i < numSamples; i++, offset += 2) {
|
|
|
|
|
+ const s = Math.max(-1, Math.min(1, samples[i]));
|
|
|
|
|
+ view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
|
|
|
|
+ }
|
|
|
|
|
+ return new Blob([buf], { type: 'audio/wav' });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── STIPA ANALYSER FROM RECORDED WAV ────────────────────────────────────────
|
|
|
|
|
+//
|
|
|
|
|
+// Port of zawi01/stipa stipa.m — direct STIPA measurement from recorded audio.
|
|
|
|
|
+// Feed the result of playing generateStipaSignal() through the PA system and
|
|
|
|
|
+// recording it (any smartphone recorder is sufficient).
|
|
|
|
|
+//
|
|
|
|
|
+// Pipeline: band-filter → discard 200ms → envelope detect → single-bin DFT
|
|
|
|
|
+// per modulation freq → mk matrix → SNR → TI → STI
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Compute STIPA from a recorded AudioBuffer.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {AudioBuffer} recordedBuffer Recording of the STIPA signal through the system
|
|
|
|
|
+ * @param {{ Lsk?: number[], Lnk?: number[] }} opts
|
|
|
|
|
+ * Lsk — per-band signal levels in dB (for auditory masking correction)
|
|
|
|
|
+ * Lnk — per-band ambient noise levels in dB (for noise correction)
|
|
|
|
|
+ * @returns {Promise<{ sti: number, mk: number[][], tiPerBand: number[], rating: string, warning?: string }>}
|
|
|
|
|
+ */
|
|
|
|
|
+export async function computeSTIPAfromRecording(recordedBuffer, { Lsk, Lnk } = {}) {
|
|
|
|
|
+ const fs = recordedBuffer.sampleRate;
|
|
|
|
|
+ const raw = recordedBuffer.getChannelData(0);
|
|
|
|
|
+ const trimMs = 200;
|
|
|
|
|
+ const trimSmp = Math.round((trimMs / 1000) * fs);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Per-band octave filtering (cascaded biquad, 3 sections ≈ 6th-order)
|
|
|
|
|
+ const bandSignals = await Promise.all(STI_BANDS_HZ.map(fc => filterOctave(raw, fs, fc)));
|
|
|
|
|
+
|
|
|
|
|
+ // ── Discard first 200ms (suppress IIR transients), detect envelope
|
|
|
|
|
+ const envelopes = bandSignals.map(band => {
|
|
|
|
|
+ const trimmed = band.slice(trimSmp);
|
|
|
|
|
+ return envelopeDetect(trimmed, fs);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── MTF: single-bin DFT at each modulation frequency (from zawi01/stipa MTF fn)
|
|
|
|
|
+ const mk = STIPA_MOD_FREQS.map(([fm1, fm2], k) => {
|
|
|
|
|
+ const env = envelopes[k];
|
|
|
|
|
+ const seconds = env.length / fs;
|
|
|
|
|
+ return [fm1, fm2].map(Fm => singleBinDFT(env, fs, Fm, seconds) / 0.55);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── Validity check (IEC: mk > 1.3 indicates impulsive noise)
|
|
|
|
|
+ const maxMk = Math.max(...mk.flat());
|
|
|
|
|
+ const warning = maxMk > 1.3
|
|
|
|
|
+ ? `One or more m-values exceed 1.3 (max ${maxMk.toFixed(2)}) — impulsive noise or invalid recording`
|
|
|
|
|
+ : undefined;
|
|
|
|
|
+
|
|
|
|
|
+ // ── Optional corrections
|
|
|
|
|
+ let mkCorrected = mk.map(row => [...row]);
|
|
|
|
|
+ if (Lsk && Lnk) {
|
|
|
|
|
+ mkCorrected = adjustAmbientNoise(mkCorrected, Lsk, Lnk);
|
|
|
|
|
+ mkCorrected = adjustAuditoryMasking(mkCorrected, Lsk, Lnk);
|
|
|
|
|
+ } else if (Lsk) {
|
|
|
|
|
+ mkCorrected = adjustAuditoryMasking(mkCorrected, Lsk, Array(7).fill(0));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Clamp mk to [0, 1]
|
|
|
|
|
+ mkCorrected = mkCorrected.map(row => row.map(v => Math.min(1, Math.max(0, v))));
|
|
|
|
|
+
|
|
|
|
|
+ // ── TI per band (mean of 2 modulation-frequency TIs)
|
|
|
|
|
+ const tiPerBand = mkCorrected.map(row => {
|
|
|
|
|
+ const avgSnr = row.reduce((s, m) => s + mtfToSnrDb(m), 0) / row.length;
|
|
|
|
|
+ return (avgSnr + 15) / 30;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── STI
|
|
|
|
|
+ const { alpha, beta } = WEIGHTS.male;
|
|
|
|
|
+ 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, mk, tiPerBand, rating: stiRatingLabel(sti), warning };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ─── SIGNAL PROCESSING HELPERS ───────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Apply a 1-octave bandpass filter centred at fc using cascaded biquad sections.
|
|
|
|
|
+ * 3 cascaded 2nd-order Butterworth bandpass sections ≈ 6th-order response.
|
|
|
|
|
+ * Not as sharp as the 18th-order filter in zawi01/stipa but adequate for STIPA.
|
|
|
|
|
+ */
|
|
|
|
|
+async function filterOctave(samples, fs, fc) {
|
|
|
|
|
+ const offlineCtx = new OfflineAudioContext(1, samples.length, fs);
|
|
|
|
|
+ const buf = offlineCtx.createBuffer(1, samples.length, fs);
|
|
|
|
|
+ buf.copyToChannel(samples instanceof Float32Array ? samples : Float32Array.from(samples), 0);
|
|
|
|
|
+
|
|
|
|
|
+ const src = offlineCtx.createBufferSource();
|
|
|
|
|
+ src.buffer = buf;
|
|
|
|
|
+
|
|
|
|
|
+ // 3 cascaded biquad bandpass sections
|
|
|
|
|
+ const Q = Math.SQRT2; // Q = √2 → 1-octave −3 dB bandwidth
|
|
|
|
|
+ let node = src;
|
|
|
|
|
+ for (let i = 0; i < 3; i++) {
|
|
|
|
|
+ const bp = offlineCtx.createBiquadFilter();
|
|
|
|
|
+ bp.type = 'bandpass';
|
|
|
|
|
+ bp.frequency.value = fc;
|
|
|
|
|
+ bp.Q.value = Q;
|
|
|
|
|
+ node.connect(bp);
|
|
|
|
|
+ node = bp;
|
|
|
|
|
+ }
|
|
|
|
|
+ node.connect(offlineCtx.destination);
|
|
|
|
|
+ src.start(0);
|
|
|
|
|
+ const rendered = await offlineCtx.startRendering();
|
|
|
|
|
+ return rendered.getChannelData(0);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Square-law envelope detection followed by 100 Hz low-pass filter.
|
|
|
|
|
+ * Mirrors stipa.m: envelope = lowpass(x.*x, 100, fs)
|
|
|
|
|
+ */
|
|
|
|
|
+function envelopeDetect(bandSamples, fs) {
|
|
|
|
|
+ // Single-pole IIR low-pass at 100 Hz
|
|
|
|
|
+ const wc = 2 * Math.PI * 100 / fs;
|
|
|
|
|
+ const a1 = Math.exp(-wc);
|
|
|
|
|
+ const b0 = 1 - a1;
|
|
|
|
|
+ const out = new Float32Array(bandSamples.length);
|
|
|
|
|
+ let prev = 0;
|
|
|
|
|
+ for (let i = 0; i < bandSamples.length; i++) {
|
|
|
|
|
+ const sq = bandSamples[i] * bandSamples[i];
|
|
|
|
|
+ prev = b0 * sq + a1 * prev;
|
|
|
|
|
+ out[i] = prev;
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Single-bin DFT at modulation frequency Fm — port of zawi01/stipa MTF computation.
|
|
|
|
|
+ * Uses only complete periods of Fm within the signal to avoid spectral leakage.
|
|
|
|
|
+ *
|
|
|
|
|
+ * mk(Fm) = 2 × |Σ I(t)·exp(j·2π·Fm·t)| / Σ I(t)
|
|
|
|
|
+ */
|
|
|
|
|
+function singleBinDFT(envelope, fs, Fm, totalSeconds) {
|
|
|
|
|
+ const wholePeriodSeconds = Math.floor(Fm * totalSeconds) / Fm;
|
|
|
|
|
+ const N = Math.round(wholePeriodSeconds * fs);
|
|
|
|
|
+ if (N < 1) return 0;
|
|
|
|
|
+
|
|
|
|
|
+ let re = 0, im = 0, dc = 0;
|
|
|
|
|
+ for (let i = 0; i < N; i++) {
|
|
|
|
|
+ const t = (i / fs);
|
|
|
|
|
+ const val = envelope[i] ?? 0;
|
|
|
|
|
+ re += val * Math.cos(2 * Math.PI * Fm * t);
|
|
|
|
|
+ im += val * Math.sin(2 * Math.PI * Fm * t);
|
|
|
|
|
+ dc += val;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (dc < 1e-12) return 0;
|
|
|
|
|
+ return 2 * Math.sqrt(re * re + im * im) / dc;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Adjust mk for ambient noise: mk_corrected = mk × Isk / (Isk + Ink) */
|
|
|
|
|
+function adjustAmbientNoise(mk, Lsk, Lnk) {
|
|
|
|
|
+ return mk.map((row, k) => {
|
|
|
|
|
+ const Isk = Math.pow(10, Lsk[k] / 10);
|
|
|
|
|
+ const Ink = Math.pow(10, Lnk[k] / 10);
|
|
|
|
|
+ const ratio = Isk / (Isk + Ink);
|
|
|
|
|
+ return row.map(v => v * ratio);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Adjust mk for auditory masking and reception threshold (IEC 60268-16:2020). */
|
|
|
|
|
+function adjustAuditoryMasking(mk, Lsk, Lnk) {
|
|
|
|
|
+ const Ak = AUDITORY_MASKING_THRESHOLDS;
|
|
|
|
|
+ const Isk = Lsk.map(l => Math.pow(10, l / 10));
|
|
|
|
|
+ const Ink = Lnk.map(l => Math.pow(10, l / 10));
|
|
|
|
|
+ const Ik = Isk.map((s, k) => s + Ink[k]);
|
|
|
|
|
+
|
|
|
|
|
+ // Level-dependent upward masking from adjacent lower band
|
|
|
|
|
+ const La = Lsk.slice(0, 6).map(L => {
|
|
|
|
|
+ if (L < 63) return 0.5 * L - 65;
|
|
|
|
|
+ if (L < 67) return 1.8 * L - 146.9;
|
|
|
|
|
+ if (L < 100) return 0.5 * L - 59.8;
|
|
|
|
|
+ return -10;
|
|
|
|
|
+ });
|
|
|
|
|
+ const a = La.map(la => Math.pow(10, la / 10));
|
|
|
|
|
+ const Iamk = [0, ...Isk.slice(0, 6).map((is, k) => is * a[k])];
|
|
|
|
|
+ const Irtk = Ak.map(ak => Math.pow(10, ak / 10));
|
|
|
|
|
+
|
|
|
|
|
+ return mk.map((row, k) => {
|
|
|
|
|
+ const ratio = Ik[k] / (Ik[k] + Iamk[k] + Irtk[k]);
|
|
|
|
|
+ return row.map(v => v * ratio);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ─── REW RT60 RESPONSE PARSER ─────────────────────────────────────────────────
|
|
// ─── REW RT60 RESPONSE PARSER ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
/**
|
|
/**
|