| 1 | import SwiftUI |
| 2 | import 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. |
| 10 | struct 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. |
| 25 | private 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. |
| 64 | private 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 | } |