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 } /// 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. 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()) } }