1import SwiftUI
2import QuartzCore
3
4/// pitchreader.com-style visualiser. Two layers:
5/// • `GridCanvas` — staff lines + right-hand labels. Redraws only when the
6/// range / naming / transpose / appearance change (never per frame).
7/// • `TraceCanvas` — the active-note band, the scrolling pitch trace, the dot
8/// and the note pill. Driven by `TimelineView(.animation)` so it advances at
9/// the display refresh rate.
10struct PitchGraphView: View {
11 @ObservedObject var engine: AudioEngine
12 @ObservedObject var settings: Settings
13 var palette: Palette
14
15 var body: some View {
16 ZStack {
17 palette.background
18 GridCanvas(settings: settings, palette: palette)
19 TraceCanvas(engine: engine, settings: settings, palette: palette)
20 }
21 }
22}
23
24/// Natural-note staff lines and labels, laid out on the right like pitchreader.
25private struct GridCanvas: View {
26 @ObservedObject var settings: Settings
27 var palette: Palette
28
29 var body: some View {
30 Canvas { ctx, size in
31 let layout = PitchLayout(size: size, range: settings.range)
32 let lineRight = layout.plotWidth - 2
33 let labelX = layout.plotWidth + 7
34
35 for midi in settings.range.low...settings.range.high {
36 let displayed = midi + settings.transpose
37 let accidental = Music.isAccidental(displayed)
38 let isC = Music.pitchClass(displayed) == 0
39 let yy = layout.y(Double(midi))
40
41 // Every semitone gets a line; sharps/flats are drawn fainter.
42 var line = Path()
43 line.move(to: CGPoint(x: 0, y: yy))
44 line.addLine(to: CGPoint(x: lineRight, y: yy))
45 let lineColor = accidental ? palette.accidentalLine : (isC ? palette.cLine : palette.naturalLine)
46 ctx.stroke(line, with: .color(lineColor), lineWidth: isC ? 1.4 : (accidental ? 0.75 : 1))
47
48 // Only naturals are labelled, to keep the gutter readable.
49 guard !accidental else { continue }
50 let color = isC ? palette.cLabel : palette.naturalLabel
51 let label = Text(Music.name(displayed, naming: settings.naming))
52 .font(.system(size: 11.5, weight: isC ? .bold : .semibold, design: .rounded))
53 .foregroundColor(color)
54 + Text(" \(Music.octave(displayed))")
55 .font(.system(size: 9.5, weight: isC ? .semibold : .regular, design: .rounded))
56 .foregroundColor(color.opacity(0.85))
57 ctx.draw(label, at: CGPoint(x: labelX, y: yy), anchor: .leading)
58 }
59 }
60 }
61}
62
63/// The moving parts: active band, pitch trace, current dot, and the note pill.
64private struct TraceCanvas: View {
65 @ObservedObject var engine: AudioEngine
66 @ObservedObject var settings: Settings
67 var palette: Palette
68
69 var body: some View {
70 TimelineView(.animation) { timeline in
71 // Capturing a per-frame value (the frame's timestamp) into the
72 // Canvas closure is what makes SwiftUI invalidate & redraw it every
73 // display frame — without it the Canvas is memoized and only
74 // repaints on geometry changes (e.g. a window resize).
75 let now = timeline.date.timeIntervalSinceReferenceDate + Self.clockOffset
76 Canvas { ctx, size in
77 let layout = PitchLayout(size: size, range: settings.range)
78 let visible = settings.visibleSeconds
79 let live = engine.currentLive()
80
81 drawNotes(ctx: &ctx, layout: layout, now: now, visible: visible)
82 drawBand(ctx: &ctx, size: size, layout: layout, live: live)
83 drawTrace(ctx: &ctx, layout: layout, now: now, visible: visible)
84 drawPill(ctx: &ctx, size: size, layout: layout, live: live)
85 }
86 }
87 }
88
89 /// Fixed offset between `Date` (reference-date epoch) and the
90 /// `CACurrentMediaTime` clock that sample timestamps use. Both advance at
91 /// real-time, so this difference is constant for the process lifetime.
92 private static let clockOffset: Double =
93 CACurrentMediaTime() - Date().timeIntervalSinceReferenceDate
94
95 // Piano-roll bars for the segmented "intentional" notes, drawn behind the
96 // live trace. Committed notes are solid; the note being sung right now is
97 // brighter and grows at the leading edge.
98 private func drawNotes(ctx: inout GraphicsContext, layout: PitchLayout,
99 now: Double, visible: Double) {
100 let (committed, pending) = engine.noteSnapshot(since: now - visible - 1)
101 // Bars are taller than the trace (4.2 px) so the note reads as a distinct
102 // block with the pitch line threading through it, not a sliver the trace
103 // hides. A crisp outline defines each note's edges.
104 let h = min(layout.rowHeight * 0.85, 26)
105
106 func bar(_ n: NoteEvent, fill: Color) {
107 let x0 = max(0, layout.x(n.onset, now: now, visible: visible))
108 let x1 = min(layout.plotWidth, layout.x(n.offset, now: now, visible: visible))
109 guard x1 > x0 else { return }
110 let yy = layout.y(Double(n.midi))
111 let rect = CGRect(x: x0, y: yy - h / 2, width: x1 - x0, height: h)
112 let path = Path(roundedRect: rect, cornerRadius: min(5, h / 2))
113 ctx.fill(path, with: .color(fill))
114 ctx.stroke(path, with: .color(palette.noteBarEdge), lineWidth: 1)
115 }
116
117 for n in committed { bar(n, fill: palette.noteBar) }
118 if let p = pending { bar(p, fill: palette.notePending) }
119 }
120
121 // Thin solid coral line marking the nearest note — a couple of pixels
122 // thicker than a staff line.
123 private func drawBand(ctx: inout GraphicsContext, size: CGSize,
124 layout: PitchLayout, live: LivePitch?) {
125 guard let live else { return }
126 let nearest = Int(live.midi.rounded())
127 guard nearest >= settings.range.low, nearest <= settings.range.high else { return }
128 let yy = layout.y(Double(nearest))
129 let h: CGFloat = 3.5
130 let rect = CGRect(x: 0, y: yy - h / 2, width: size.width, height: h)
131 ctx.fill(Path(roundedRect: rect, cornerRadius: h / 2), with: .color(palette.pill))
132 }
133
134 // Thick, smoothed pitch line. Breaks into a new segment on a silence gap or
135 // an implausible pitch jump, so stray octave errors never draw as long
136 // vertical spikes.
137 private func drawTrace(ctx: inout GraphicsContext, layout: PitchLayout,
138 now: Double, visible: Double) {
139 let samples = engine.snapshot(since: now - visible)
140 guard samples.count > 1 else { return }
141
142 let gapLimit = 0.08 // seconds
143 let maxJump = 4.0 // semitones between adjacent ~5 ms frames
144 var run: [CGPoint] = []
145
146 func stroke() {
147 guard run.count > 1 else { run.removeAll(keepingCapacity: true); return }
148 var path = Path()
149 path.move(to: run[0])
150 // Quadratic smoothing through midpoints for a clean, hand-drawn line.
151 for i in 1..<run.count - 1 {
152 let mid = CGPoint(x: (run[i].x + run[i + 1].x) / 2,
153 y: (run[i].y + run[i + 1].y) / 2)
154 path.addQuadCurve(to: mid, control: run[i])
155 }
156 path.addLine(to: run[run.count - 1])
157 ctx.stroke(path, with: .color(palette.trace),
158 style: StrokeStyle(lineWidth: 4.2, lineCap: .round, lineJoin: .round))
159 run.removeAll(keepingCapacity: true)
160 }
161
162 var prev: PitchSample?
163 for s in samples {
164 if let p = prev, s.time - p.time > gapLimit || abs(s.midi - p.midi) > maxJump {
165 stroke()
166 }
167 run.append(CGPoint(x: layout.x(s.time, now: now, visible: visible), y: layout.y(s.midi)))
168 prev = s
169 }
170 stroke()
171
172 // Current dot at the leading edge (only while actively voiced).
173 if let last = samples.last, engine.currentLive() != nil {
174 let p = CGPoint(x: layout.x(last.time, now: now, visible: visible), y: layout.y(last.midi))
175 ctx.fill(Path(ellipseIn: CGRect(x: p.x - 4, y: p.y - 4, width: 8, height: 8)),
176 with: .color(palette.dot))
177 }
178 }
179
180 // Solid coral pill with the active note name, in the right-hand gutter.
181 private func drawPill(ctx: inout GraphicsContext, size: CGSize,
182 layout: PitchLayout, live: LivePitch?) {
183 guard let live else { return }
184 let nearest = Int(live.midi.rounded())
185 guard nearest >= settings.range.low, nearest <= settings.range.high else { return }
186 let displayed = nearest + settings.transpose
187 let yy = layout.y(Double(nearest))
188
189 let pillRect = CGRect(x: layout.plotWidth + 1, y: yy - 9,
190 width: layout.labelWidth - 3, height: 18)
191 ctx.fill(Path(roundedRect: pillRect, cornerRadius: 9), with: .color(palette.pill))
192
193 let label = Text(Music.name(displayed, naming: settings.naming))
194 .font(.system(size: 11.5, weight: .bold, design: .rounded))
195 .foregroundColor(palette.pillText)
196 + Text(" \(Music.octave(displayed))")
197 .font(.system(size: 9.5, weight: .semibold, design: .rounded))
198 .foregroundColor(palette.pillText.opacity(0.9))
199 ctx.draw(label, at: CGPoint(x: pillRect.minX + 6, y: yy), anchor: .leading)
200 }
201}