From d0f78f5165602dd4fea4428a2a7b0379a0c41125 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:27:29 +0200 Subject: [PATCH] fix: add VUScale.spokenLevel with proper clamping and non-finite handling Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstUI/Instruments.swift | 8 ++++++++ Sources/SstUI/VUMeterView.swift | 3 +-- Tests/SstUITests/InstrumentsTests.swift | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Sources/SstUI/Instruments.swift b/Sources/SstUI/Instruments.swift index d5e2913..c77fee9 100644 --- a/Sources/SstUI/Instruments.swift +++ b/Sources/SstUI/Instruments.swift @@ -20,6 +20,14 @@ public enum VUScale { public static func angle(forFraction fraction: Double) -> Double { (-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180 } + + /// Level as VoiceOver should say it: rounded to steps of 3 VU, clamped to the scale, "stil" for non-finite input. + public static func spokenLevel(fromDbfs dbfs: Double) -> String { + guard dbfs.isFinite else { return "stil" } + let stepped = (vu(fromDbfs: dbfs) / 3).rounded() * 3 + let clamped = min(MeterTokens.vuMax, max(MeterTokens.vuMin, stepped)) + return "\(Int(clamped)) VU" + } } /// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1. diff --git a/Sources/SstUI/VUMeterView.swift b/Sources/SstUI/VUMeterView.swift index c3dcb3c..2ae62cf 100644 --- a/Sources/SstUI/VUMeterView.swift +++ b/Sources/SstUI/VUMeterView.swift @@ -60,8 +60,7 @@ public struct VUMeterView: View { /// Rounded to steps of 3 VU so VoiceOver is not flooded with changes. private var spokenValue: String { guard isActive else { return "stil" } - let vu = (VUScale.vu(fromDbfs: levelDbfs) / 3).rounded() * 3 - return "\(Int(vu)) VU" + return VUScale.spokenLevel(fromDbfs: levelDbfs) } // MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight) diff --git a/Tests/SstUITests/InstrumentsTests.swift b/Tests/SstUITests/InstrumentsTests.swift index 145eb0e..00984c0 100644 --- a/Tests/SstUITests/InstrumentsTests.swift +++ b/Tests/SstUITests/InstrumentsTests.swift @@ -106,3 +106,26 @@ struct LEDBarTests { #expect(LEDBar.litCount(forFraction: -1) == 0) } } + +struct SpokenLevelTests { + @Test func nonFiniteInputReturnsStil() { + #expect(VUScale.spokenLevel(fromDbfs: -.infinity) == "stil") + #expect(VUScale.spokenLevel(fromDbfs: .nan) == "stil") + } + + @Test func referenceLevel() { + #expect(VUScale.spokenLevel(fromDbfs: -20) == "0 VU") + } + + @Test func negativeLevel() { + #expect(VUScale.spokenLevel(fromDbfs: -30) == "-9 VU") + } + + @Test func clampedToMinimum() { + #expect(VUScale.spokenLevel(fromDbfs: -120) == "-20 VU") + } + + @Test func clampedToMaximum() { + #expect(VUScale.spokenLevel(fromDbfs: 10) == "3 VU") + } +}