import SwiftUI /// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`. /// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing. public struct VUMeterView: View { private let levelDbfs: Double private let isActive: Bool private let brand: String @State private var needle: NeedleState @State private var awake = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private final class NeedleState { var spring: NeedleSpring var dampingUsed = Motion.needleDamping var last: TimeInterval? var peakUntil: TimeInterval = 0 init(position: Double) { spring = NeedleSpring(position: position) } } /// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots. public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) { self.levelDbfs = levelDbfs self.isActive = isActive self.brand = brand _needle = State(initialValue: NeedleState(position: initialFraction)) } public var body: some View { TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in Canvas { context, size in draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate) } } .aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit) .background(Palette.surfaceMeterFace) .overlay( RoundedRectangle(cornerRadius: Radius.bezel, style: .circular) .strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel)) .clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)) .onChange(of: isActive) { _, active in if active { awake = true } else { Task { try? await Task.sleep(for: .seconds(1.5)) awake = false } } } .accessibilityElement() .accessibilityLabel("Niveau") .accessibilityValue(spokenValue) } /// Rounded to steps of 3 VU so VoiceOver is not flooded with changes. private var spokenValue: String { guard isActive else { return "stil" } return VUScale.spokenLevel(fromDbfs: levelDbfs) } // MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight) private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) { let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight let scale = min(size.width / width, size.height / height) context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2) context.scaleBy(x: scale, y: scale) context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height))) // physics let damping = reduceMotion ? 1.0 : Motion.needleDamping if needle.dampingUsed != damping { needle.spring = NeedleSpring(damping: damping, position: needle.spring.position) needle.dampingUsed = damping } let dt = needle.last.map { min(0.05, now - $0) } ?? 0 needle.last = now let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0 let position = needle.spring.step(target: target, dt: dt) let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds } let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset) func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint { let angle = VUScale.angle(forFraction: fraction) return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle))) } func arc(_ radius: CGFloat, from: Double, to: Double) -> Path { var path = Path() let steps = 48 for step in 0...steps { let p = point(radius, from + (to - from) * Double(step) / Double(steps)) step == 0 ? path.move(to: p) : path.addLine(to: p) } return path } let radius = MeterTokens.vuScaleRadius let redFrom = VUScale.fraction(forVU: 0) context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth) for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) { let major = MeterTokens.vuTicks.contains(vu) let fraction = VUScale.fraction(forVU: Double(vu)) let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter var tick = Path() tick.move(to: point(radius, fraction)) tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction)) context.stroke(tick, with: .color(color), lineWidth: Border.hairline) if major { let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color) context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center) } } context.draw( Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter), at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center) context.draw( Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)), at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading) // peak lamp let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6) let lampRadius = Size.lampDiameter / 2 let peakLit = now < needle.peakUntil context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit)) context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) context.draw( Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter), at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center) // needle var needlePath = Path() needlePath.move(to: pivot) needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position)))) context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth) } }