diff --git a/Sources/SstUI/Instruments.swift b/Sources/SstUI/Instruments.swift new file mode 100644 index 0000000..d5e2913 --- /dev/null +++ b/Sources/SstUI/Instruments.swift @@ -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] = [ + "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 { + 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()) + } +} diff --git a/Tests/SstUITests/InstrumentsTests.swift b/Tests/SstUITests/InstrumentsTests.swift new file mode 100644 index 0000000..145eb0e --- /dev/null +++ b/Tests/SstUITests/InstrumentsTests.swift @@ -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.. 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) + } +}