feat: add instrument logic (VU scale, needle spring, segment glyphs, LED bar)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:21:10 +02:00
co-authored by Claude Sonnet 5.5
parent bd6bc3b890
commit 9a0effa881
2 changed files with 218 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
import CoreGraphics
import Foundation
/// dBFS → VU → needle position and angle, all from the meter tokens.
public enum VUScale {
public static func vu(fromDbfs dbfs: Double) -> Double {
dbfs - MeterTokens.vuReferenceDbfs
}
/// 0 = bottom of the scale, 1 = top. The needle may swing slightly past the end (up to 1.03).
public static func fraction(forVU vu: Double) -> Double {
min(1.03, max(0, (vu - MeterTokens.vuMin) / (MeterTokens.vuMax - MeterTokens.vuMin)))
}
public static func fraction(forDbfs dbfs: Double) -> Double {
fraction(forVU: vu(fromDbfs: dbfs))
}
/// Radians from vertical; negative is left of vertical.
public static func angle(forFraction fraction: Double) -> Double {
(-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180
}
}
/// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1.
public struct NeedleSpring: Equatable {
public private(set) var position: Double
public private(set) var velocity: Double = 0
private let omega: Double
private let zeta: Double
public init(response: Double = Motion.needleResponse, damping: Double = Motion.needleDamping, position: Double = 0) {
self.zeta = damping
self.omega = 4.6 / (damping * response)
self.position = position
}
/// Advances the simulation by `dt` seconds in small fixed sub-steps (stable for any frame time).
@discardableResult
public mutating func step(target: Double, dt: Double) -> Double {
var remaining = max(0, dt)
let h = 1.0 / 240.0
while remaining > 1e-9 {
let s = min(h, remaining)
let acceleration = omega * omega * (target - position) - 2 * zeta * omega * velocity
velocity += acceleration * s
position += velocity * s
remaining -= s
}
return position
}
}
public enum SegmentID: CaseIterable, Hashable {
case a, b, c, d, e, f, g
}
/// Which segments of a seven-segment digit are lit, and where each segment sits (from the segment tokens).
public enum SegmentGlyph {
static let map: [Character: Set<SegmentID>] = [
"0": [.a, .b, .c, .d, .e, .f], "1": [.b, .c], "2": [.a, .b, .d, .e, .g], "3": [.a, .b, .c, .d, .g],
"4": [.b, .c, .f, .g], "5": [.a, .c, .d, .f, .g], "6": [.a, .c, .d, .e, .f, .g], "7": [.a, .b, .c],
"8": Set(SegmentID.allCases), "9": [.a, .b, .c, .d, .f, .g],
]
public static func lit(for character: Character) -> Set<SegmentID> {
map[character] ?? []
}
/// Hexagonal bar for one segment, inside a `Segment.digitWidth` × `Segment.digitHeight` box.
public static func polygon(_ id: SegmentID) -> [CGPoint] {
let width = Segment.digitWidth, height = Segment.digitHeight
let h = Segment.thickness / 2, gap = Segment.gap
let left = h, right = width - h, mid = height / 2
func horizontal(_ x0: CGFloat, _ x1: CGFloat, _ cy: CGFloat) -> [CGPoint] {
[CGPoint(x: x0, y: cy), CGPoint(x: x0 + h, y: cy - h), CGPoint(x: x1 - h, y: cy - h),
CGPoint(x: x1, y: cy), CGPoint(x: x1 - h, y: cy + h), CGPoint(x: x0 + h, y: cy + h)]
}
func vertical(_ cx: CGFloat, _ y0: CGFloat, _ y1: CGFloat) -> [CGPoint] {
[CGPoint(x: cx, y: y0), CGPoint(x: cx + h, y: y0 + h), CGPoint(x: cx + h, y: y1 - h),
CGPoint(x: cx, y: y1), CGPoint(x: cx - h, y: y1 - h), CGPoint(x: cx - h, y: y0 + h)]
}
let x0 = left + h + gap, x1 = right - h - gap
switch id {
case .a: return horizontal(x0, x1, h)
case .g: return horizontal(x0, x1, mid)
case .d: return horizontal(x0, x1, height - h)
case .f: return vertical(left, h + h + gap, mid - h - gap)
case .b: return vertical(right, h + h + gap, mid - h - gap)
case .e: return vertical(left, mid + h + gap, height - h - h - gap)
case .c: return vertical(right, mid + h + gap, height - h - h - gap)
}
}
}
public enum LEDBar {
public enum Tone { case green, amber, red }
/// The last segment is red, the three before it amber, the rest green.
public static func tone(forSegment index: Int, segments: Int = MeterTokens.ledSegments) -> Tone {
if index >= segments - 1 { return .red }
if index >= segments - 4 { return .amber }
return .green
}
public static func litCount(forFraction fraction: Double, segments: Int = MeterTokens.ledSegments) -> Int {
Int((min(1, max(0, fraction)) * Double(segments)).rounded())
}
}
+108
View File
@@ -0,0 +1,108 @@
import CoreGraphics
import Testing
@testable import SstUI
struct VUScaleTests {
@Test func zeroVUIsMinusTwentyDbfs() {
#expect(VUScale.vu(fromDbfs: -20) == 0)
#expect(VUScale.vu(fromDbfs: -40) == -20)
}
@Test func fractionSpansTheScale() {
#expect(VUScale.fraction(forVU: -20) == 0)
#expect(abs(VUScale.fraction(forVU: 3) - 1) < 1e-9)
#expect(abs(VUScale.fraction(forVU: 0) - 20.0 / 23.0) < 1e-9)
}
@Test func fractionIsClampedAtBothEnds() {
#expect(VUScale.fraction(forVU: -60) == 0)
#expect(VUScale.fraction(forVU: 30) == 1.03)
#expect(VUScale.fraction(forDbfs: -120) == 0)
}
@Test func angleIsSymmetricAroundVertical() {
#expect(abs(VUScale.angle(forFraction: 0) + VUScale.angle(forFraction: 1)) < 1e-9)
#expect(abs(VUScale.angle(forFraction: 0.5)) < 1e-9)
#expect(abs(VUScale.angle(forFraction: 1) - 45 * .pi / 180) < 1e-9)
}
}
struct NeedleSpringTests {
func simulate(_ spring: NeedleSpring, seconds: Double, target: Double = 1) -> (final: Double, peak: Double) {
var spring = spring
var peak = 0.0
let dt = 1.0 / 240
for _ in 0..<Int(seconds / dt) { peak = max(peak, spring.step(target: target, dt: dt)) }
return (spring.position, peak)
}
@Test func reachesTheTargetWithinTheResponseTime() {
let result = simulate(NeedleSpring(), seconds: Motion.needleResponse)
#expect(abs(result.final - 1) < 0.02)
}
@Test func overshootsSlightlyWithTheTokenDamping() {
let result = simulate(NeedleSpring(), seconds: 1)
#expect(result.peak > 1.003)
#expect(result.peak < 1.03)
#expect(abs(result.final - 1) < 0.001)
}
@Test func criticalDampingNeverOvershoots() {
let result = simulate(NeedleSpring(damping: 1), seconds: 1)
#expect(result.peak <= 1.000001)
}
@Test func largeFrameStepsStayStable() {
var spring = NeedleSpring()
for _ in 0..<40 { spring.step(target: 1, dt: 0.05) }
#expect(abs(spring.position - 1) < 0.001)
}
@Test func restsAtTheStartWithoutATarget() {
var spring = NeedleSpring()
for _ in 0..<60 { spring.step(target: 0, dt: 1.0 / 60) }
#expect(spring.position == 0)
}
}
struct SegmentGlyphTests {
@Test func everyDigitHasASegmentPattern() {
for digit in "0123456789" { #expect(!SegmentGlyph.lit(for: digit).isEmpty) }
#expect(SegmentGlyph.lit(for: "8").count == 7)
#expect(SegmentGlyph.lit(for: "1") == [.b, .c])
#expect(SegmentGlyph.lit(for: ":").isEmpty)
}
@Test func polygonsStayInsideTheDigitBoxAndDiffer() {
var seen: [[CGPoint]] = []
for id in SegmentID.allCases {
let polygon = SegmentGlyph.polygon(id)
#expect(polygon.count == 6)
for point in polygon {
#expect(point.x >= 0 && point.x <= Segment.digitWidth)
#expect(point.y >= 0 && point.y <= Segment.digitHeight)
}
#expect(!seen.contains(polygon))
seen.append(polygon)
}
#expect(seen.count == 7)
}
}
struct LEDBarTests {
@Test func tonesFollowTheEightThreeOneSplit() {
let tones = (0..<12).map { LEDBar.tone(forSegment: $0) }
#expect(tones.prefix(8).allSatisfy { $0 == .green })
#expect(tones[8...10].allSatisfy { $0 == .amber })
#expect(tones[11] == .red)
}
@Test func litCountScalesAndClamps() {
#expect(LEDBar.litCount(forFraction: 0) == 0)
#expect(LEDBar.litCount(forFraction: 0.5) == 6)
#expect(LEDBar.litCount(forFraction: 1) == 12)
#expect(LEDBar.litCount(forFraction: 1.03) == 12)
#expect(LEDBar.litCount(forFraction: -1) == 0)
}
}