Files
sst/Sources/SstUI/Instruments.swift

119 lines
5.0 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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())
}
}