From 114548f63bfa7065e20b0b4166ae6015aa6a0399 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:24:43 +0200 Subject: [PATCH] feat: add VU meter, segment counter and LED bar views Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstUI/CounterAndLEDs.swift | 83 ++++++++++++++++ Sources/SstUI/VUMeterView.swift | 148 +++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 Sources/SstUI/CounterAndLEDs.swift create mode 100644 Sources/SstUI/VUMeterView.swift diff --git a/Sources/SstUI/CounterAndLEDs.swift b/Sources/SstUI/CounterAndLEDs.swift new file mode 100644 index 0000000..926fa5b --- /dev/null +++ b/Sources/SstUI/CounterAndLEDs.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible. +public struct SegmentCounterView: View { + private let text: String + + public init(_ text: String) { + self.text = text + } + + private var contentWidth: CGFloat { + text.reduce(0) { total, character in + total + (character.isNumber ? Segment.advance : Segment.colonWidth) + } + } + + public var body: some View { + Canvas { context, _ in + var x: CGFloat = 0 + for character in text { + if character.isNumber { + var origin = context + origin.translateBy(x: x, y: 0) + let lit = SegmentGlyph.lit(for: character) + for id in SegmentID.allCases { + let polygon = SegmentGlyph.polygon(id) + var path = Path() + path.addLines(polygon) + path.closeSubpath() + origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit)) + } + x += Segment.advance + } else { + let dot = Segment.thickness + let inset = (Segment.colonWidth - dot) / 2 + let ys: [CGFloat] = character == ":" + ? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64] + : [Segment.digitHeight - dot] + for y in ys { + context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber)) + } + x += Segment.colonWidth + } + } + } + .frame(width: contentWidth, height: Segment.digitHeight) + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .background(DisplayBackground()) + .accessibilityElement() + .accessibilityLabel("Tijd") + .accessibilityValue(text) + } +} + +/// Twelve-segment level bar: green, amber, then one red segment. +public struct LEDBarView: View { + private let fraction: Double + + public init(fraction: Double) { + self.fraction = fraction + } + + public var body: some View { + let lit = LEDBar.litCount(forFraction: fraction) + HStack(spacing: MeterTokens.ledGap) { + ForEach(0.. Color { + switch LEDBar.tone(forSegment: index) { + case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit + case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit + case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit + } + } +} diff --git a/Sources/SstUI/VUMeterView.swift b/Sources/SstUI/VUMeterView.swift new file mode 100644 index 0000000..c3dcb3c --- /dev/null +++ b/Sources/SstUI/VUMeterView.swift @@ -0,0 +1,148 @@ +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" } + let vu = (VUScale.vu(fromDbfs: levelDbfs) / 3).rounded() * 3 + return "\(Int(vu)) VU" + } + + // 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) + } +}