fix: add VUScale.spokenLevel with proper clamping and non-finite handling

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:27:29 +02:00
co-authored by Claude Sonnet 5.5
parent 114548f63b
commit d0f78f5165
3 changed files with 32 additions and 2 deletions
+8
View File
@@ -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.
+1 -2
View File
@@ -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)
+23
View File
@@ -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")
}
}