1import Foundation
2
3/// One committed "intentional" note — a stable pitch you actually meant to sing,
4/// with a start, an end, and how sharp/flat you held it.
5struct NoteEvent: Identifiable {
6 let id: Int
7 let midi: Int // integer MIDI (concert pitch, before transpose)
8 let onset: Double // seconds on the CACurrentMediaTime clock
9 var offset: Double // seconds
10 let meanCents: Double // signed average deviation from the integer, in cents
11 var duration: Double { offset - onset }
12}
13
14/// Turns a stream of fractional-MIDI pitch samples into discrete note events —
15/// the piano-roll layer that sits above the raw pitch contour.
16///
17/// This is a lightweight, real-time analogue of pYIN's note HMM:
18/// • a tolerance band around the current note absorbs vibrato and small drift,
19/// so a wobbling held note reads as ONE note instead of flickering;
20/// • when the pitch leaves that band the note is closed and a new one opens;
21/// • notes shorter than `minDuration` are discarded, so scoops and passing
22/// tones on the way to a target don't become spurious notes;
23/// • a silence gap longer than `gapToEnd` closes the held note.
24final class NoteSegmenter {
25 // Tunables. Cents are hundredths of a semitone; a semitone is 100 cents, so
26 // a note "owns" ±centsTolerance around its center — the overlap past the
27 // 50-cent midpoint is the hysteresis that keeps vibrato from splitting notes.
28 private let centsTolerance = 62.0
29 private let minDuration = 0.10 // seconds; below this a region is dropped
30 private let gapToEnd = 0.13 // seconds of silence that ends a held note
31 private let retain = 30.0 // seconds of committed notes kept
32
33 private(set) var committed: [NoteEvent] = []
34
35 // In-progress note.
36 private var pendingMidi: Int?
37 private var pendingOnset = 0.0
38 private var pendingCentsSum = 0.0
39 private var pendingCount = 0
40 private var lastVoiced = 0.0
41 private var nextID = 0
42
43 /// Feed one voiced sample. Returns true iff a note was committed (ended).
44 @discardableResult
45 func feed(time: Double, midi: Double) -> Bool {
46 var didCommit = false
47
48 // A silence gap since the previous voiced sample closes the held note.
49 if pendingMidi != nil, time - lastVoiced > gapToEnd {
50 didCommit = closePending(offset: lastVoiced)
51 }
52
53 if let cur = pendingMidi {
54 let distCents = abs(midi - Double(cur)) * 100
55 if distCents <= centsTolerance {
56 // Still the same note — absorb the sample (this is what swallows
57 // vibrato even as the nearest semitone flips back and forth).
58 pendingCentsSum += (midi - Double(cur)) * 100
59 pendingCount += 1
60 lastVoiced = time
61 } else {
62 // Pitch has moved off the note: close it, open a new one.
63 didCommit = closePending(offset: lastVoiced) || didCommit
64 openPending(time: time, midi: midi)
65 }
66 } else {
67 openPending(time: time, midi: midi)
68 }
69 return didCommit
70 }
71
72 /// Feed a detector "silence" tick so a held note eventually closes even if no
73 /// further voiced sample arrives. (Gaps are also caught lazily in `feed`.)
74 @discardableResult
75 func feedSilence(now: Double) -> Bool {
76 guard pendingMidi != nil, now - lastVoiced > gapToEnd else { return false }
77 return closePending(offset: lastVoiced)
78 }
79
80 /// The note currently being sung, as a provisional event for live rendering.
81 var pending: NoteEvent? {
82 guard let m = pendingMidi, pendingCount > 0 else { return nil }
83 // Only surface it once it's plausibly a note, not a passing scoop.
84 guard lastVoiced - pendingOnset >= minDuration * 0.5 else { return nil }
85 return NoteEvent(id: -1, midi: m, onset: pendingOnset, offset: lastVoiced,
86 meanCents: pendingCentsSum / Double(pendingCount))
87 }
88
89 func reset() {
90 committed.removeAll(keepingCapacity: true)
91 pendingMidi = nil
92 pendingCount = 0
93 }
94
95 // MARK: - Private
96
97 private func openPending(time: Double, midi: Double) {
98 let m = Int(midi.rounded())
99 pendingMidi = m
100 pendingOnset = time
101 pendingCentsSum = (midi - Double(m)) * 100
102 pendingCount = 1
103 lastVoiced = time
104 }
105
106 @discardableResult
107 private func closePending(offset: Double) -> Bool {
108 guard let m = pendingMidi else { return false }
109 pendingMidi = nil
110 guard offset - pendingOnset >= minDuration, pendingCount > 0 else { return false }
111 committed.append(NoteEvent(id: nextID, midi: m, onset: pendingOnset,
112 offset: offset, meanCents: pendingCentsSum / Double(pendingCount)))
113 nextID += 1
114 let cutoff = offset - retain
115 if let first = committed.first, first.offset < cutoff {
116 committed.removeAll { $0.offset < cutoff }
117 }
118 return true
119 }
120}