Download soloscribe/model.py from DGring/bigears: direct link, hf CLI and curl.
- Browser
- Download file 7.67 kB
-
https://huggingface.co/spaces/DGring/bigears/resolve/main/soloscribe/model.py
- Command line
-
hf download hf://spaces/DGring/bigears/soloscribe/model.py
-
curl -L -o model.py https://huggingface.co/spaces/DGring/bigears/resolve/main/soloscribe/model.py
7.67 kB
| """Core data model for the soloscribe pipeline. | |
| The pipeline moves through three time domains: | |
| 1. Clock time (seconds) β raw audio and transcribed NoteEvents. | |
| 2. Beat time β a BeatGrid maps beat indices to clock time (jazz recordings | |
| drift; the grid absorbs the drift so notation stays musical). | |
| 3. Tick time β quantized musical time, TICKS_PER_QUARTER per quarter note. | |
| QNotes and the Score live here; the Score keeps a tempo_map so audit | |
| synthesis can be laid back onto the original recording's clock. | |
| """ | |
| from __future__ import annotations | |
| import bisect | |
| from dataclasses import dataclass, field | |
| TICKS_PER_QUARTER = 960 | |
| # Divisors of TICKS_PER_QUARTER we allow as grid cells. 960 is divisible by | |
| # 2, 3, 4, 6, 8 β eighths (480), triplet eighths (320), sixteenths (240), | |
| # triplet sixteenths (160), thirty-seconds (120). | |
| GRID_EIGHTH = TICKS_PER_QUARTER // 2 | |
| GRID_TRIPLET = TICKS_PER_QUARTER // 3 | |
| GRID_SIXTEENTH = TICKS_PER_QUARTER // 4 | |
| STANDARD_TUNING = (64, 59, 55, 50, 45, 40) # string 1 (high E) β string 6 (low E) | |
| class NoteEvent: | |
| """A transcribed note in continuous (seconds) time.""" | |
| start: float | |
| end: float | |
| pitch: int # MIDI note number | |
| velocity: int = 96 # 1β127 | |
| confidence: float = 1.0 # transcriber's belief this note is real | |
| # Pitch-bend contour, times normalized 0..1 across the note, values in | |
| # semitones relative to the nominal pitch. Empty list = no bend. | |
| bend: list[tuple[float, float]] = field(default_factory=list) | |
| vibrato: bool = False | |
| def duration(self) -> float: | |
| return self.end - self.start | |
| class BeatGrid: | |
| """Tracked beats in seconds; the bridge between clock and musical time.""" | |
| beat_times: list[float] # ascending, one entry per beat | |
| beats_per_bar: int = 4 | |
| first_downbeat: int = 0 # index into beat_times that is a bar's beat 1 | |
| bpm_nominal: float = 120.0 | |
| swing: bool = False # eighths are swung (jazz feel) | |
| def bars(self) -> int: | |
| usable = len(self.beat_times) - self.first_downbeat | |
| return max(1, (usable + self.beats_per_bar - 1) // self.beats_per_bar) | |
| class QNote: | |
| """A quantized note in tick time.""" | |
| onset: int # ticks from score start | |
| duration: int # ticks (> 0) | |
| pitch: int | |
| velocity: int = 96 | |
| string: int | None = None # 1 = high E β¦ 6 = low E (None until fretted) | |
| fret: int | None = None | |
| tied_from_prev: bool = False # continuation of an earlier QNote at this pitch | |
| vibrato: bool = False | |
| bend: list[tuple[float, float]] = field(default_factory=list) | |
| confidence: float = 1.0 | |
| def offset(self) -> int: | |
| return self.onset + self.duration | |
| class Score: | |
| """Quantized musical content plus the map back into the recording's clock.""" | |
| qnotes: list[QNote] = field(default_factory=list) | |
| beats_per_bar: int = 4 | |
| denominator: int = 4 # time-signature denominator (4 = quarter beat) | |
| tempo_bpm: float = 120.0 # nominal tempo written into the GP5 | |
| triplet_feel: bool = False # notate straight, mark "swing 8ths" in GP5 | |
| key: str = "C" # tonic + optional 'm', e.g. "F", "Bb", "Em" | |
| # (tick, seconds) anchors, ascending in both columns. Piecewise-linear | |
| # interpolation between anchors reconstructs where each tick falls in the | |
| # original recording β this is what lets the audit resynthesize the score | |
| # in alignment with the source audio even when the band drifts. | |
| tempo_map: list[tuple[int, float]] = field(default_factory=list) | |
| chords: list[str | None] = field(default_factory=list) # one per measure, optional | |
| tuning: tuple[int, ...] = STANDARD_TUNING | |
| capo: int = 0 | |
| title: str = "" | |
| subtitle: str = "" | |
| def ticks_per_beat(self) -> int: | |
| return TICKS_PER_QUARTER * 4 // self.denominator | |
| def ticks_per_bar(self) -> int: | |
| return self.ticks_per_beat * self.beats_per_bar | |
| def n_measures(self) -> int: | |
| if not self.qnotes: | |
| return max(1, len(self.chords) or 1) | |
| last = max(q.offset for q in self.qnotes) | |
| bars = (last + self.ticks_per_bar - 1) // self.ticks_per_bar | |
| return max(bars, len(self.chords) or 0, 1) | |
| def tick_to_seconds(self, tick: int | float) -> float: | |
| """Map a tick to seconds in the original recording via tempo_map.""" | |
| tm = self.tempo_map | |
| if not tm: | |
| return float(tick) / TICKS_PER_QUARTER * 60.0 / self.tempo_bpm | |
| ticks = [a for a, _ in tm] | |
| i = bisect.bisect_right(ticks, tick) - 1 | |
| if i < 0: | |
| i = 0 | |
| if i >= len(tm) - 1: | |
| i = len(tm) - 2 if len(tm) >= 2 else 0 | |
| if len(tm) == 1: | |
| t0, s0 = tm[0] | |
| return s0 + (tick - t0) / TICKS_PER_QUARTER * 60.0 / self.tempo_bpm | |
| t0, s0 = tm[i] | |
| t1, s1 = tm[i + 1] | |
| if t1 == t0: | |
| return s0 | |
| return s0 + (tick - t0) * (s1 - s0) / (t1 - t0) | |
| def merge_adjacent_events( | |
| events: list[NoteEvent], | |
| max_gap: float = 0.06, | |
| max_pitch_diff: int = 0, | |
| rearticulation_ratio: float = 0.85, | |
| ) -> list[NoteEvent]: | |
| """Merge same-pitch events separated by tiny gaps (transcriber splits on decay). | |
| Merging runs per pitch chain, not against the last emitted event β an | |
| interleaved event at another pitch must not block the repair of a | |
| decay-split note (found by the transcribe module's tests). | |
| Gap time alone cannot separate a decay split (~40 ms dip) from a genuine | |
| repeated fast note in the 15β60 ms band, so that band gets an amplitude | |
| gate: a decay continuation is the quiet tail of a dying note, a re-picked | |
| note has a fresh attack. Gaps β€ 15 ms (about one analysis frame) merge | |
| unconditionally. | |
| SCOPE, measured (do not overclaim this gate): the E2E harness's residual | |
| recall misses are repeated notes whose OBSERVED gap is 0.000 s β the | |
| transcriber receives them already contiguous β which this gate therefore | |
| cannot address (they take the unconditional path). An earlier version of | |
| this note credited the gate with those misses; that conflated the licks' | |
| NOTATED gaps (30β49 ms) with the emitted signal, where the renderer's | |
| ring-down closes them. Splitting 0-gap fusions needs onset re-detection, | |
| which was measured (0.03 discrimination margin, n=9) and declined. | |
| """ | |
| if not events: | |
| return [] | |
| by_chain: dict[int, list[NoteEvent]] = {} | |
| for ev in sorted(events, key=lambda e: (e.start, e.pitch)): | |
| merged = False | |
| for pitch in range(ev.pitch - max_pitch_diff, ev.pitch + max_pitch_diff + 1): | |
| chain = by_chain.get(pitch) | |
| if chain and 0 <= ev.start - chain[-1].end <= max_gap: | |
| prev = chain[-1] | |
| gap = ev.start - prev.end | |
| if gap > 0.015 and ev.velocity > prev.velocity * rearticulation_ratio: | |
| continue # fresh attack β a repeated note, not a split | |
| prev.end = max(prev.end, ev.end) | |
| prev.velocity = max(prev.velocity, ev.velocity) | |
| prev.confidence = max(prev.confidence, ev.confidence) | |
| prev.vibrato = prev.vibrato or ev.vibrato | |
| merged = True | |
| break | |
| if not merged: | |
| by_chain.setdefault(ev.pitch, []).append(ev) | |
| out = [ev for chain in by_chain.values() for ev in chain] | |
| out.sort(key=lambda e: (e.start, e.pitch)) | |
| return out | |