-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathevents.py
More file actions
294 lines (244 loc) · 10.7 KB
/
Copy pathevents.py
File metadata and controls
294 lines (244 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
"""Public streaming events and per-chunk event builder.
`TranscriptionModel.transcribe` is a generator that yields these dataclasses
one at a time. Every :class:`NoteStartEvent` is guaranteed to be followed by
exactly one matching :class:`NoteEndEvent` (same `index`) later in the stream.
"""
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from muscriptor.tokenizer.notes import (
MINIMUM_NOTE_DURATION_SEC,
Event,
)
_DRUM_INSTRUMENT = "drums"
@dataclass
class NoteStartEvent:
pitch: int
start_time: float
index: int
instrument: str
@dataclass
class NoteEndEvent:
end_time: float
start_event: NoteStartEvent
@property
def start_event_index(self) -> int:
return self.start_event.index
@dataclass
class ProgressEvent:
"""A coarse transcription-progress signal, woven into the event stream.
Marks that ``completed`` of ``total`` fixed-size audio chunks have been
transcribed (``completed == 0`` is emitted once up front so consumers learn
``total`` and get a timing baseline; ``completed == total`` marks the end).
These are deliberately coarse anchors — the frontend smooths between them
and derives an ETA, since wall-clock time per chunk is only observable
there. Advisory only: consumers that build notes/MIDI ignore them.
"""
completed: int
total: int
@dataclass
class ChunkBoundary:
"""Marks the start of a new model-output chunk in the token stream.
``seek_time`` is the chunk's start time in seconds; ``next_seek_time`` is
the following chunk's start (``None`` for the last chunk), used to drop
events the model emits past its window.
"""
seek_time: float
next_seek_time: float | None
@dataclass
class _StartNote:
"""A note opens: (program, pitch) starts sounding at `time`."""
program: int
pitch: int
time: float
@dataclass
class _EndNote:
"""An open note closes: (program, pitch) stops sounding at `time`."""
program: int
pitch: int
time: float
@dataclass
class _DrumHit:
"""An instantaneous drum hit at `time`; never enters the open set."""
pitch: int
time: float
_NoteAction = _StartNote | _EndNote | _DrumHit
class OpenNoteTracker:
"""The chunk-decoding state machine for the model's token stream.
:meth:`feed` consumes the interleaved :class:`ChunkBoundary` markers and
token indices and returns the note actions they imply; :meth:`finish`
flushes the end-of-stream closes. All decode rules live here — the tie
prologue (open notes absent from the tie set close at the boundary),
malformed chunks (a shift before the ``tie`` token closes everything and
drops the rest), the next_seek_time window, and retriggers.
Two consumers share it: :func:`decode_model_tokens` turns the actions into
indexed NoteStart/NoteEnd events, and the prelude-forcing path
(``TranscriptionModel._generate_token_stream``) ignores the actions and
reads :meth:`open_keys` at chunk boundaries — the ``(program, pitch)``
pairs the next chunk's tie prologue must declare as sustained (see
``MT3Tokenizer.tie_section_token_ids``). One state machine serving both
keeps decoding and forcing consistent by construction.
"""
def __init__(self, vocab: list[Event], frame_rate: int = 100):
self._vocab = vocab
self._frame_rate = frame_rate
# (program, pitch) -> onset time. Insertion-ordered: end-of-stream
# closes replay in onset order.
self._open: dict[tuple[int, int], float] = {}
# Per-chunk state, reset at every ChunkBoundary.
self._seek_time = 0.0
self._next_seek_time: float | None = None
self._start_tick = 0
self._tick_state = 0
self._program: int | None = None
self._velocity: int | None = None
self._in_prologue = True
self._skip_rest = False
self._tie_set: set[tuple[int, int]] = set()
self._chunk_started = False
def feed(self, item: "int | ChunkBoundary") -> list[_NoteAction]:
if isinstance(item, ChunkBoundary):
actions: list[_NoteAction] = []
# If the previous chunk never closed its tie prologue (malformed:
# no `tie` token before it ended), treat its tie set as empty so
# every still-open note ends at that chunk's boundary.
if self._chunk_started and self._in_prologue:
actions = self._end_all(self._seek_time)
self._seek_time = item.seek_time
self._next_seek_time = item.next_seek_time
self._start_tick = round(item.seek_time * self._frame_rate)
self._tick_state = self._start_tick
self._program = None
self._velocity = None
self._in_prologue = True
self._skip_rest = False
self._tie_set = set()
self._chunk_started = True
return actions
event = self._vocab[item]
etype = event.type
if self._in_prologue:
if etype == "tie":
# End of the tie section: close prior notes not sustained here.
self._in_prologue = False
self._velocity = None
ended = [k for k in self._open if k not in self._tie_set]
for key in ended:
del self._open[key]
return [_EndNote(*key, self._seek_time) for key in ended]
if etype == "shift":
# No tie token: the chunk is malformed. Close all open notes at
# the boundary and drop the rest of the chunk.
self._in_prologue = False
self._skip_rest = True
return self._end_all(self._seek_time)
if etype == "program":
self._program = event.value
elif etype == "pitch" and self._program is not None:
self._tie_set.add((self._program, event.value))
return []
if self._skip_rest:
return []
if etype == "shift":
if event.value > 0:
self._tick_state = self._start_tick + event.value
elif etype == "program":
self._program = event.value
elif etype == "velocity":
self._velocity = event.value
elif etype == "drum":
time = self._tick_state / self._frame_rate
if self._next_seek_time is None or time < self._next_seek_time:
return [_DrumHit(event.value, time)]
elif etype == "pitch":
if self._program is None or self._velocity is None:
return []
time = self._tick_state / self._frame_rate
if self._next_seek_time is not None and time >= self._next_seek_time:
return []
key = (self._program, event.value)
actions = []
if key in self._open:
del self._open[key]
actions.append(_EndNote(*key, time))
if self._velocity > 0:
self._open[key] = time
actions.append(_StartNote(*key, time))
return actions
return []
def finish(self) -> list[_NoteAction]:
"""End of stream: close anything still open.
A well-formed final chunk uses the minimum-duration fallback; a chunk
that ended mid-prologue closes at its boundary (matching the
malformed-chunk rule in :meth:`feed`).
"""
if self._chunk_started and self._in_prologue:
return self._end_all(self._seek_time)
actions = [
_EndNote(*key, onset + MINIMUM_NOTE_DURATION_SEC)
for key, onset in self._open.items()
]
self._open.clear()
return actions
def _end_all(self, time: float) -> list[_NoteAction]:
actions: list[_NoteAction] = [_EndNote(*key, time) for key in self._open]
self._open.clear()
return actions
def open_keys(self) -> list[tuple[int, int]]:
"""Sorted ``(program, pitch)`` pairs currently held open."""
return sorted(self._open)
def decode_model_tokens(
stream: Iterator[int | ChunkBoundary | ProgressEvent],
vocab: list[Event],
instrument_for_program: Callable[[int], str],
frame_rate: int = 100,
) -> Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent]:
"""Stream model token indices straight into NoteStart/NoteEnd events.
``stream`` interleaves :class:`ChunkBoundary` markers with token indices:
each boundary starts a new chunk, followed by that chunk's tokens (EOS and
anything after it already stripped). Tokens are consumed strictly in
order: no buffering, no end-of-chunk sort. Each chunk begins with a *tie
prologue* — ``(program, pitch)`` pairs for notes sustained from the
previous chunk, terminated by a ``tie`` token — after which any prior open
note not in that tie set is closed at the chunk boundary. The rest of the
chunk drives note onsets/offsets directly.
The decode rules themselves live in :class:`OpenNoteTracker`; this
generator only turns its actions into events — minting indices, naming
instruments, and pairing every NoteEndEvent with its NoteStartEvent.
"""
tracker = OpenNoteTracker(vocab, frame_rate)
open_notes: dict[tuple[int, int], NoteStartEvent] = {}
next_index = 0
def mint(pitch: int, start_time: float, instrument: str) -> NoteStartEvent:
nonlocal next_index
ev = NoteStartEvent(
pitch=pitch, start_time=start_time, index=next_index, instrument=instrument
)
next_index += 1
return ev
def events_for(
actions: list[_NoteAction],
) -> Iterator[NoteStartEvent | NoteEndEvent]:
for action in actions:
if isinstance(action, _EndNote):
start = open_notes.pop((action.program, action.pitch))
yield NoteEndEvent(end_time=action.time, start_event=start)
elif isinstance(action, _StartNote):
start = mint(
action.pitch, action.time, instrument_for_program(action.program)
)
open_notes[(action.program, action.pitch)] = start
yield start
else: # _DrumHit: an instantaneous start/end pair
start = mint(action.pitch, action.time, _DRUM_INSTRUMENT)
yield start
yield NoteEndEvent(
end_time=action.time + MINIMUM_NOTE_DURATION_SEC, start_event=start
)
for item in stream:
if isinstance(item, ProgressEvent):
# Advisory progress signal — pass straight through, untouched by
# the decode state machine.
yield item
continue
yield from events_for(tracker.feed(item))
yield from events_for(tracker.finish())