Files
sst/Sources/SstUI/VUMeterView.swift

148 lines
7.1 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 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)
}
}