diff --git a/docs/CLI.md b/docs/CLI.md index 7cb6eb4..40138c9 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -13,7 +13,9 @@ bargekit fixtures --json ## `bargekit tune` -Analyze a fixture and print threshold guidance. +Analyze a fixture and print threshold guidance. The report separates the highest +ambient level below the configured speech threshold (`peakNoiseLevel`) from the +average level of frames classified as speech (`averageSpeechLevel`). ```sh bargekit tune --fixture tests/fixtures/long_utterance.json diff --git a/src/fixtures.js b/src/fixtures.js index d2222a5..a00662f 100644 --- a/src/fixtures.js +++ b/src/fixtures.js @@ -117,6 +117,7 @@ export function analyzeFixtureForThresholds(fixture, overrides = {}) { let noiseFrames = 0; let quietFrames = 0; let peakLevel = 0; + let peakNoiseLevel = 0; for (const sample of fixture.samples) { peakLevel = Math.max(peakLevel, sample.level); @@ -125,6 +126,7 @@ export function analyzeFixtureForThresholds(fixture, overrides = {}) { continue; } + peakNoiseLevel = Math.max(peakNoiseLevel, sample.level); if (sample.level >= config.noiseFloorThreshold) { noiseFrames += 1; continue; @@ -139,7 +141,8 @@ export function analyzeFixtureForThresholds(fixture, overrides = {}) { .reduce((sum, sample) => sum + sample.level, 0) / speechFrames; const suggestedProfile = Object.entries(PRESET_PROFILES).reduce((best, [name, profile]) => { - const distance = Math.abs(profile.speechThreshold - averageSpeechLevel || config.speechThreshold); + const comparisonLevel = averageSpeechLevel || config.speechThreshold; + const distance = Math.abs(profile.speechThreshold - comparisonLevel); if (!best || distance < best.distance) { return { name, distance }; } @@ -152,6 +155,7 @@ export function analyzeFixtureForThresholds(fixture, overrides = {}) { noiseFrames, quietFrames, peakLevel, + peakNoiseLevel, averageSpeechLevel, suggestedProfile, explanation: speechFrames === 0 diff --git a/src/tuning.js b/src/tuning.js index d9da23f..5c222b1 100644 --- a/src/tuning.js +++ b/src/tuning.js @@ -3,13 +3,16 @@ import { analyzeFixtureForThresholds } from './fixtures.js'; export function recommendThresholds(fixtures, options = {}) { const analyses = fixtures.map((fixture) => analyzeFixtureForThresholds(fixture, options)); - const peaks = analyses.map((analysis) => analysis.peakLevel).filter((level) => level > 0).sort((a, b) => a - b); + const noisePeaks = analyses + .map((analysis) => analysis.peakNoiseLevel) + .filter((level) => level > 0) + .sort((a, b) => a - b); const speechAverages = analyses .map((analysis) => analysis.averageSpeechLevel) .filter((level) => level > 0) .sort((a, b) => a - b); - const noiseCeiling = percentile(peaks, 0.5) || DEFAULT_CONFIG.noiseFloorThreshold; + const noiseCeiling = percentile(noisePeaks, 0.5) || DEFAULT_CONFIG.noiseFloorThreshold; const speechFloor = percentile(speechAverages, 0.25) || DEFAULT_CONFIG.speechThreshold; const speechThreshold = clamp(round(Math.max(speechFloor * 0.82, noiseCeiling + 0.18)), 0.2, 0.9); const noiseFloorThreshold = clamp(round(Math.min(noiseCeiling + 0.04, speechThreshold - 0.1)), 0.04, 0.5); diff --git a/test/fixtures.test.js b/test/fixtures.test.js index 8469cfc..2e41c42 100644 --- a/test/fixtures.test.js +++ b/test/fixtures.test.js @@ -28,6 +28,7 @@ test('tuning analysis explains threshold decisions and suggests a profile', () = }); assert.equal(analysis.speechFrames, 0); + assert.equal(analysis.peakNoiseLevel, 0.23); assert.match(analysis.explanation, /No speech crossed/); assert.ok(analysis.suggestedProfile); }); diff --git a/test/tuning.test.js b/test/tuning.test.js index b4cd46b..b4fed5c 100644 --- a/test/tuning.test.js +++ b/test/tuning.test.js @@ -1,10 +1,30 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { SYNTHETIC_FIXTURES, recommendThresholds } from '../src/index.js'; +import { + SYNTHETIC_FIXTURES, + analyzeFixtureForThresholds, + createSyntheticFixture, + recommendThresholds +} from '../src/index.js'; -test('threshold recommendation returns safe ordered thresholds and rationale', () => { +test('threshold recommendation separates ambient peaks from speech evidence', () => { const result = recommendThresholds([SYNTHETIC_FIXTURES.keyboard_noise, SYNTHETIC_FIXTURES.long_utterance]); - assert.ok(result.config.speechThreshold > result.config.noiseFloorThreshold); - assert.ok(result.closestProfile); + assert.equal(result.config.speechThreshold, 0.59); + assert.equal(result.config.noiseFloorThreshold, 0.27); + assert.equal(result.analyses[0].peakNoiseLevel, 0.23); + assert.ok(Math.abs(result.analyses[1].averageSpeechLevel - 0.72) < Number.EPSILON * 4); + assert.equal(result.closestProfile, 'laptop_speakers'); assert.match(result.rationale, /speechThreshold/); }); + +test('profile selection preserves zero distance for an exact speech threshold match', () => { + const fixture = createSyntheticFixture('exact-wired-headset', { + baseline: 0.04, + segments: [{ startMs: 100, endMs: 300, level: 0.5 }] + }); + + const analysis = analyzeFixtureForThresholds(fixture, { speechThreshold: 0.5 }); + + assert.equal(analysis.averageSpeechLevel, 0.5); + assert.equal(analysis.suggestedProfile, 'wired_headset'); +});