From 274722ea1fd5dc6030a685b9c751de22a9c6a279 Mon Sep 17 00:00:00 2001 From: Martin Wimpress Date: Fri, 6 Feb 2026 15:00:25 +0000 Subject: [PATCH] feat(logging): make recording tips speech-aware and refine rules - Use SpeechProfile.RMSLevel when available for level_too_quiet and level_quiet checks; fall back to InputI-based LUFS thresholds when no speech profile exists. - Set speech gain target to -24 dBFS (speech RMS) and keep -18 LUFS for InputI fallback; compute and surface suggested gain dynamically. - Expand mutual-exclusion logic so clipping (level_clipping) and near-clipping (level_near_clipping) suppress quiet tips to avoid confusing recommendations. - Simplify dynamic-range tip to trigger only when InputLRA > 18 LU. - Update and extend tests (internal/logging/recording_tips_test.go) to cover speech-aware paths and mutual-exclusion scenarios. Signed-off-by: Martin Wimpress --- internal/logging/recording_tips.go | 61 ++++++--- internal/logging/recording_tips_test.go | 171 ++++++++++++++++-------- 2 files changed, 157 insertions(+), 75 deletions(-) diff --git a/internal/logging/recording_tips.go b/internal/logging/recording_tips.go index 3a6c244..20ac179 100644 --- a/internal/logging/recording_tips.go +++ b/internal/logging/recording_tips.go @@ -73,8 +73,8 @@ func applyExclusions(tips []RecordingTip, fired map[string]bool) []RecordingTip var result []RecordingTip for _, tip := range tips { switch tip.RuleID { - case "level_quiet": - if fired["too_far_from_mic"] { + case "level_too_quiet", "level_quiet": + if fired["level_clipping"] || fired["level_near_clipping"] || fired["too_far_from_mic"] { continue } case "poor_snr": @@ -111,10 +111,24 @@ func wrapText(text string, maxWidth int, indent string) string { return strings.Join(lines, "\n"+indent) } -// tipLevelTooQuiet fires when input is very quiet (InputI < -30 LUFS). -// At this level, 12+ dB of gain is needed to reach -18 LUFS target, -// which raises the noise floor significantly. +// tipLevelTooQuiet fires when recording level is very quiet. +// Uses SpeechProfile.RMSLevel when available (speech RMS < -42 dBFS), +// falling back to InputI < -30 LUFS when no speech profile exists. +// Gain target is -24 dBFS for speech RMS, -18 LUFS for InputI fallback. func tipLevelTooQuiet(m *processor.AudioMeasurements, _ *processor.FilterChainConfig) *RecordingTip { + if m.SpeechProfile != nil { + speechRMS := m.SpeechProfile.RMSLevel + if speechRMS >= -42.0 { + return nil + } + gainNeeded := -24.0 - speechRMS + return &RecordingTip{ + Priority: 10, + RuleID: "level_too_quiet", + Message: fmt.Sprintf("Your microphone gain is too low - try increasing it by about %.0f dB.", gainNeeded), + } + } + // Fallback: no speech profile, use integrated LUFS if m.InputI >= -30.0 { return nil } @@ -126,9 +140,24 @@ func tipLevelTooQuiet(m *processor.AudioMeasurements, _ *processor.FilterChainCo } } -// tipLevelQuiet fires when input is moderately quiet (InputI between -30 and -24 LUFS). -// Still needs noticeable gain to reach target, worth mentioning but less urgent. +// tipLevelQuiet fires when recording level is moderately quiet. +// Uses SpeechProfile.RMSLevel when available (speech RMS between -42 and -36 dBFS), +// falling back to InputI between -30 and -24 LUFS when no speech profile exists. +// Gain target is -24 dBFS for speech RMS, -18 LUFS for InputI fallback. func tipLevelQuiet(m *processor.AudioMeasurements, _ *processor.FilterChainConfig) *RecordingTip { + if m.SpeechProfile != nil { + speechRMS := m.SpeechProfile.RMSLevel + if speechRMS < -42.0 || speechRMS >= -36.0 { + return nil + } + gainNeeded := -24.0 - speechRMS + return &RecordingTip{ + Priority: 8, + RuleID: "level_quiet", + Message: fmt.Sprintf("Your recording is a bit quiet - increasing your microphone gain by about %.0f dB would improve quality.", gainNeeded), + } + } + // Fallback: no speech profile, use integrated LUFS if m.InputI < -30.0 || m.InputI >= -24.0 { return nil } @@ -280,22 +309,10 @@ func tipSibilance(m *processor.AudioMeasurements, config *processor.FilterChainC } } -// tipDynamicRange fires when the loudness range is very wide, indicating -// inconsistent speaking volume or microphone distance. -// Thresholds: InputLRA > 18 LU (very wide) or InputLRA > 14 LU with -// CrestFactor > 18 dB (wide with extreme dynamics). -// References: adaptive.go la2aLRAExpressive = 14.0 LU, -// Spectral-Metrics-Reference.md crest > 18 dB = extreme dynamics. +// tipDynamicRange fires when the loudness range is very wide (InputLRA > 18 LU), +// indicating inconsistent speaking volume or microphone distance. func tipDynamicRange(m *processor.AudioMeasurements, _ *processor.FilterChainConfig) *RecordingTip { - crest := m.CrestFactor - if m.SpeechProfile != nil && m.SpeechProfile.CrestFactor > 0 { - crest = m.SpeechProfile.CrestFactor - } - - veryWide := m.InputLRA > 18.0 - wideWithCrest := m.InputLRA > 14.0 && crest > 18.0 - - if !veryWide && !wideWithCrest { + if m.InputLRA <= 18.0 { return nil } return &RecordingTip{ diff --git a/internal/logging/recording_tips_test.go b/internal/logging/recording_tips_test.go index 392c7a0..6ebdf6f 100644 --- a/internal/logging/recording_tips_test.go +++ b/internal/logging/recording_tips_test.go @@ -70,21 +70,51 @@ func TestWrapText(t *testing.T) { func TestTipLevelTooQuiet(t *testing.T) { tests := []struct { - name string - inputI float64 - wantTip bool - wantRuleID string - wantGain string // substring to check in message, empty to skip + name string + inputI float64 + speechProfile *processor.SpeechCandidateMetrics + wantTip bool + wantRuleID string + wantGain string // substring to check in message, empty to skip }{ - {"very quiet -35 LUFS", -35.0, true, "level_too_quiet", "17 dB"}, - {"boundary -30 LUFS", -30.0, false, "", ""}, - {"moderately quiet -28 LUFS", -28.0, false, "", ""}, - {"normal -20 LUFS", -20.0, false, "", ""}, + // InputI fallback (no SpeechProfile) + {"very quiet -35 LUFS fallback", -35.0, nil, true, "level_too_quiet", "17 dB"}, + {"boundary -30 LUFS fallback", -30.0, nil, false, "", ""}, + {"moderately quiet -28 LUFS fallback", -28.0, nil, false, "", ""}, + {"normal -20 LUFS fallback", -20.0, nil, false, "", ""}, + // Speech-aware path + { + name: "speech RMS too quiet -45 dBFS", + inputI: -20.0, // InputI would not trigger + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -45.0}, + wantTip: true, + wantRuleID: "level_too_quiet", + wantGain: "21 dB", // -24.0 - (-45.0) = 21 + }, + { + name: "speech RMS at boundary -42 dBFS no tip", + inputI: -35.0, // InputI would trigger fallback + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -42.0}, + wantTip: false, + }, + { + name: "speech RMS acceptable -38 dBFS suppresses InputI", + inputI: -35.0, // InputI would trigger fallback + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -38.0}, + wantTip: false, + }, + { + name: "speech RMS good -30 dBFS no tip", + inputI: -35.0, + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -30.0}, + wantTip: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &processor.AudioMeasurements{} m.InputI = tt.inputI + m.SpeechProfile = tt.speechProfile tip := tipLevelTooQuiet(m, nil) if (tip != nil) != tt.wantTip { t.Errorf("tipLevelTooQuiet() returned tip=%v, want tip=%v", tip != nil, tt.wantTip) @@ -103,22 +133,60 @@ func TestTipLevelTooQuiet(t *testing.T) { func TestTipLevelQuiet(t *testing.T) { tests := []struct { - name string - inputI float64 - wantTip bool - wantRuleID string - wantGain string + name string + inputI float64 + speechProfile *processor.SpeechCandidateMetrics + wantTip bool + wantRuleID string + wantGain string }{ - {"very quiet handled by too_quiet", -35.0, false, "", ""}, - {"boundary -30 LUFS triggers quiet", -30.0, true, "level_quiet", "12 dB"}, - {"moderately quiet -28 LUFS", -28.0, true, "level_quiet", "10 dB"}, - {"boundary -24 LUFS no tip", -24.0, false, "", ""}, - {"normal -20 LUFS", -20.0, false, "", ""}, + // InputI fallback (no SpeechProfile) + {"very quiet handled by too_quiet fallback", -35.0, nil, false, "", ""}, + {"boundary -30 LUFS triggers quiet fallback", -30.0, nil, true, "level_quiet", "12 dB"}, + {"moderately quiet -28 LUFS fallback", -28.0, nil, true, "level_quiet", "10 dB"}, + {"boundary -24 LUFS no tip fallback", -24.0, nil, false, "", ""}, + {"normal -20 LUFS fallback", -20.0, nil, false, "", ""}, + // Speech-aware path + { + name: "speech RMS too quiet for level_quiet handled by too_quiet", + inputI: -20.0, + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -45.0}, + wantTip: false, // < -42 is level_too_quiet territory + }, + { + name: "speech RMS moderately quiet -40 dBFS", + inputI: -20.0, // InputI would not trigger + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -40.0}, + wantTip: true, + wantRuleID: "level_quiet", + wantGain: "16 dB", // -24.0 - (-40.0) = 16 + }, + { + name: "speech RMS at boundary -42 dBFS triggers quiet", + inputI: -20.0, + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -42.0}, + wantTip: true, + wantRuleID: "level_quiet", + wantGain: "18 dB", // -24.0 - (-42.0) = 18 + }, + { + name: "speech RMS at boundary -36 dBFS no tip", + inputI: -28.0, // InputI would trigger fallback + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -36.0}, + wantTip: false, + }, + { + name: "speech RMS acceptable -34 dBFS suppresses InputI", + inputI: -28.0, // InputI would trigger fallback + speechProfile: &processor.SpeechCandidateMetrics{RMSLevel: -34.0}, + wantTip: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &processor.AudioMeasurements{} m.InputI = tt.inputI + m.SpeechProfile = tt.speechProfile tip := tipLevelQuiet(m, nil) if (tip != nil) != tt.wantTip { t.Errorf("tipLevelQuiet() returned tip=%v, want tip=%v", tip != nil, tt.wantTip) @@ -504,46 +572,19 @@ func TestTipSibilance(t *testing.T) { func TestTipDynamicRange(t *testing.T) { tests := []struct { - name string - inputLRA float64 - crestFactor float64 - speechProfile *processor.SpeechCandidateMetrics - wantTip bool + name string + inputLRA float64 + wantTip bool }{ - {"very wide LRA", 20.0, 12.0, nil, true}, - {"wide LRA with high crest", 15.0, 20.0, nil, true}, - {"wide LRA with normal crest", 15.0, 12.0, nil, false}, - {"normal LRA", 10.0, 12.0, nil, false}, - {"boundary LRA 18 no tip", 18.0, 12.0, nil, false}, - {"boundary LRA 14 with crest 18 no tip", 14.0, 18.0, nil, false}, - { - name: "speech crest overrides full-file no wideWithCrest", - inputLRA: 15.0, - crestFactor: 20.0, - speechProfile: &processor.SpeechCandidateMetrics{CrestFactor: 12.0}, - wantTip: false, - }, - { - name: "speech crest triggers wideWithCrest", - inputLRA: 15.0, - crestFactor: 12.0, - speechProfile: &processor.SpeechCandidateMetrics{CrestFactor: 20.0}, - wantTip: true, - }, - { - name: "veryWide ignores crest speech profile", - inputLRA: 20.0, - crestFactor: 12.0, - speechProfile: &processor.SpeechCandidateMetrics{CrestFactor: 5.0}, - wantTip: true, - }, + {"very wide LRA", 20.0, true}, + {"normal LRA", 10.0, false}, + {"boundary LRA 18 no tip", 18.0, false}, + {"just above boundary", 18.1, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := &processor.AudioMeasurements{} m.InputLRA = tt.inputLRA - m.CrestFactor = tt.crestFactor - m.SpeechProfile = tt.speechProfile tip := tipDynamicRange(m, nil) if (tip != nil) != tt.wantTip { t.Errorf("tipDynamicRange() returned tip=%v, want tip=%v", tip != nil, tt.wantTip) @@ -729,6 +770,30 @@ func TestGenerateRecordingTips(t *testing.T) { }(), wantEmpty: true, }, + { + name: "mutual exclusion clipping suppresses level_too_quiet", + measurements: func() *processor.AudioMeasurements { + m := &processor.AudioMeasurements{} + m.InputI = -35.0 // would trigger level_too_quiet + m.InputTP = 0.5 // clipping + m.CrestFactor = 12.0 + return m + }(), + wantRuleIDs: []string{"level_clipping"}, + excludeRuleIDs: []string{"level_too_quiet", "level_quiet"}, + }, + { + name: "mutual exclusion near_clipping suppresses level_quiet", + measurements: func() *processor.AudioMeasurements { + m := &processor.AudioMeasurements{} + m.InputI = -28.0 // would trigger level_quiet + m.InputTP = -0.5 // near clipping + m.CrestFactor = 12.0 + return m + }(), + wantRuleIDs: []string{"level_near_clipping"}, + excludeRuleIDs: []string{"level_too_quiet", "level_quiet"}, + }, { name: "all bad recording returns exactly 5", measurements: func() *processor.AudioMeasurements {