muscriptor 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
muscriptor/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """muscriptor — audio-to-MIDI transcription."""
2
+
3
+ from muscriptor.events import NoteStartEvent, NoteEndEvent
4
+ from muscriptor.transcription_model import TranscriptionModel
5
+ from muscriptor.tokenizer.notes import Note
6
+
7
+ __all__ = ["TranscriptionModel", "Note", "NoteStartEvent", "NoteEndEvent"]
muscriptor/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from muscriptor.main import main
2
+
3
+ main()
muscriptor/events.py ADDED
@@ -0,0 +1,208 @@
1
+ """Public streaming events and per-chunk event builder.
2
+
3
+ `TranscriptionModel.transcribe` is a generator that yields these dataclasses
4
+ one at a time. Every :class:`NoteStartEvent` is guaranteed to be followed by
5
+ exactly one matching :class:`NoteEndEvent` (same `index`) later in the stream.
6
+ """
7
+
8
+ from collections.abc import Callable, Iterator
9
+ from dataclasses import dataclass
10
+
11
+ from muscriptor.tokenizer.notes import (
12
+ MINIMUM_NOTE_DURATION_SEC,
13
+ Event,
14
+ )
15
+
16
+
17
+ _DRUM_INSTRUMENT = "drums"
18
+
19
+
20
+ @dataclass
21
+ class NoteStartEvent:
22
+ pitch: int
23
+ start_time: float
24
+ index: int
25
+ instrument: str
26
+
27
+
28
+ @dataclass
29
+ class NoteEndEvent:
30
+ end_time: float
31
+ start_event: NoteStartEvent
32
+
33
+ @property
34
+ def start_event_index(self) -> int:
35
+ return self.start_event.index
36
+
37
+
38
+ @dataclass
39
+ class ProgressEvent:
40
+ """A coarse transcription-progress signal, woven into the event stream.
41
+
42
+ Marks that ``completed`` of ``total`` fixed-size audio chunks have been
43
+ transcribed (``completed == 0`` is emitted once up front so consumers learn
44
+ ``total`` and get a timing baseline; ``completed == total`` marks the end).
45
+ These are deliberately coarse anchors — the frontend smooths between them
46
+ and derives an ETA, since wall-clock time per chunk is only observable
47
+ there. Advisory only: consumers that build notes/MIDI ignore them.
48
+ """
49
+
50
+ completed: int
51
+ total: int
52
+
53
+
54
+ @dataclass
55
+ class ChunkBoundary:
56
+ """Marks the start of a new model-output chunk in the token stream.
57
+
58
+ ``seek_time`` is the chunk's start time in seconds; ``next_seek_time`` is
59
+ the following chunk's start (``None`` for the last chunk), used to drop
60
+ events the model emits past its window.
61
+ """
62
+
63
+ seek_time: float
64
+ next_seek_time: float | None
65
+
66
+
67
+ def decode_model_tokens(
68
+ stream: Iterator[int | ChunkBoundary | ProgressEvent],
69
+ vocab: list[Event],
70
+ instrument_for_program: Callable[[int], str],
71
+ frame_rate: int = 100,
72
+ ) -> Iterator[NoteStartEvent | NoteEndEvent | ProgressEvent]:
73
+ """Stream model token indices straight into NoteStart/NoteEnd events.
74
+
75
+ ``stream`` interleaves :class:`ChunkBoundary` markers with token indices:
76
+ each boundary starts a new chunk, followed by that chunk's tokens (EOS and
77
+ anything after it already stripped). All state — open notes, the running
78
+ note index, the per-chunk decode state — lives in this generator's frame
79
+ and persists across chunks.
80
+
81
+ Tokens are consumed strictly in order: no buffering, no end-of-chunk sort.
82
+ Each chunk begins with a *tie prologue* — ``(program, pitch)`` pairs for
83
+ notes sustained from the previous chunk, terminated by a ``tie`` token —
84
+ after which any prior open note not in that tie set is closed at the chunk
85
+ boundary. The rest of the chunk drives note onsets/offsets directly.
86
+ """
87
+ open_notes: dict[tuple[int, int], NoteStartEvent] = {}
88
+ next_index = 0
89
+
90
+ def mint(pitch: int, start_time: float, instrument: str) -> NoteStartEvent:
91
+ nonlocal next_index
92
+ ev = NoteStartEvent(
93
+ pitch=pitch, start_time=start_time, index=next_index, instrument=instrument
94
+ )
95
+ next_index += 1
96
+ return ev
97
+
98
+ # Per-chunk state (reset at every ChunkBoundary).
99
+ seek_time = 0.0
100
+ next_seek_time: float | None = None
101
+ start_tick = 0
102
+ tick_state = 0
103
+ program_state: int | None = None
104
+ velocity_state: int | None = None
105
+ in_prologue = True
106
+ skip_rest = False
107
+ tie_set: set[tuple[int, int]] = set()
108
+ chunk_started = False
109
+
110
+ for item in stream:
111
+ if isinstance(item, ProgressEvent):
112
+ # Advisory progress signal — pass straight through, untouched by the
113
+ # decode state machine.
114
+ yield item
115
+ continue
116
+ if isinstance(item, ChunkBoundary):
117
+ # If the previous chunk never closed its tie prologue (malformed:
118
+ # no `tie` token before it ended), treat its tie set as empty so
119
+ # every still-open note ends at that chunk's boundary.
120
+ if chunk_started and in_prologue:
121
+ for key in list(open_notes):
122
+ yield NoteEndEvent(
123
+ end_time=seek_time, start_event=open_notes.pop(key)
124
+ )
125
+ seek_time = item.seek_time
126
+ next_seek_time = item.next_seek_time
127
+ start_tick = round(seek_time * frame_rate)
128
+ tick_state = start_tick
129
+ program_state = None
130
+ velocity_state = None
131
+ in_prologue = True
132
+ skip_rest = False
133
+ tie_set = set()
134
+ chunk_started = True
135
+ continue
136
+
137
+ event = vocab[item]
138
+ etype = event.type
139
+
140
+ if in_prologue:
141
+ if etype == "tie":
142
+ # End of the tie section: close prior notes not sustained here.
143
+ in_prologue = False
144
+ velocity_state = None
145
+ for key in list(open_notes):
146
+ if key not in tie_set:
147
+ yield NoteEndEvent(
148
+ end_time=seek_time, start_event=open_notes.pop(key)
149
+ )
150
+ elif etype == "shift":
151
+ # No tie token: the chunk is malformed. Close all open notes at
152
+ # the boundary and drop the rest of the chunk.
153
+ in_prologue = False
154
+ skip_rest = True
155
+ for key in list(open_notes):
156
+ yield NoteEndEvent(
157
+ end_time=seek_time, start_event=open_notes.pop(key)
158
+ )
159
+ elif etype == "program":
160
+ program_state = event.value
161
+ elif etype == "pitch" and program_state is not None:
162
+ tie_set.add((program_state, event.value))
163
+ continue
164
+
165
+ if skip_rest:
166
+ continue
167
+
168
+ if etype == "shift":
169
+ if event.value > 0:
170
+ tick_state = start_tick + event.value
171
+ elif etype == "program":
172
+ program_state = event.value
173
+ elif etype == "velocity":
174
+ velocity_state = event.value
175
+ elif etype == "drum":
176
+ time = tick_state / frame_rate
177
+ if next_seek_time is None or time < next_seek_time:
178
+ start = mint(event.value, time, _DRUM_INSTRUMENT)
179
+ yield start
180
+ yield NoteEndEvent(
181
+ end_time=time + MINIMUM_NOTE_DURATION_SEC, start_event=start
182
+ )
183
+ elif etype == "pitch":
184
+ if program_state is None or velocity_state is None:
185
+ continue
186
+ time = tick_state / frame_rate
187
+ if next_seek_time is not None and time >= next_seek_time:
188
+ continue
189
+ key = (program_state, event.value)
190
+ if key in open_notes:
191
+ yield NoteEndEvent(end_time=time, start_event=open_notes.pop(key))
192
+ if velocity_state > 0:
193
+ start = mint(event.value, time, instrument_for_program(program_state))
194
+ open_notes[key] = start
195
+ yield start
196
+
197
+ # End of stream: close anything still open. A well-formed final chunk uses
198
+ # the minimum-duration fallback; a chunk that ended mid-prologue closes at
199
+ # its boundary (matching the malformed-chunk behavior above).
200
+ if chunk_started and in_prologue:
201
+ for key in list(open_notes):
202
+ yield NoteEndEvent(end_time=seek_time, start_event=open_notes.pop(key))
203
+ else:
204
+ for ev in list(open_notes.values()):
205
+ yield NoteEndEvent(
206
+ end_time=ev.start_time + MINIMUM_NOTE_DURATION_SEC, start_event=ev
207
+ )
208
+ open_notes.clear()
muscriptor/main.py ADDED
@@ -0,0 +1,327 @@
1
+ """CLI for muscriptor: audio → MIDI transcription."""
2
+
3
+ import dataclasses
4
+ import json
5
+ import sys
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from muscriptor.events import NoteEndEvent, NoteStartEvent, ProgressEvent
13
+ from muscriptor.tokenizer.mt3 import (
14
+ MT3_FULL_PLUS_GROUP_NAMES,
15
+ resolve_instrument_names,
16
+ )
17
+ from muscriptor.transcription_model import TranscriptionModel
18
+
19
+ app = typer.Typer(add_completion=False, help="muscriptor — audio-to-MIDI transcription")
20
+
21
+
22
+ class OutputFormat(str, Enum):
23
+ midi = "midi"
24
+ json = "json"
25
+ jsonl = "jsonl"
26
+
27
+
28
+ def _event_to_dict(ev: NoteStartEvent | NoteEndEvent) -> dict:
29
+ if isinstance(ev, NoteStartEvent):
30
+ return {"type": "start", **dataclasses.asdict(ev)}
31
+ return {
32
+ "type": "end",
33
+ "end_time": ev.end_time,
34
+ "start_event_index": ev.start_event_index,
35
+ }
36
+
37
+
38
+ @app.command()
39
+ def transcribe(
40
+ audio_file: Annotated[
41
+ Path, typer.Argument(help="Input audio file (wav, mp3, flac, …)")
42
+ ],
43
+ output: Annotated[
44
+ Path | None,
45
+ typer.Option(
46
+ "--output",
47
+ "-o",
48
+ help=(
49
+ "Output file path. Use '-' to write to stdout (all progress / "
50
+ "timing info is sent to stderr in that case). "
51
+ "Default: <audio_file>.<ext> where ext matches --format."
52
+ ),
53
+ ),
54
+ ] = None,
55
+ format: Annotated[
56
+ OutputFormat,
57
+ typer.Option(
58
+ "--format",
59
+ "-f",
60
+ help=(
61
+ "Output format: midi (default), json (single array of events), "
62
+ "or jsonl (one event per line, streamed as transcription progresses)"
63
+ ),
64
+ case_sensitive=False,
65
+ ),
66
+ ] = OutputFormat.midi,
67
+ notes: Annotated[
68
+ bool, typer.Option("--notes", help="Print decoded events to stdout")
69
+ ] = False,
70
+ sampling: Annotated[
71
+ bool,
72
+ typer.Option(
73
+ "--sampling", help="Use temperature sampling instead of greedy decoding"
74
+ ),
75
+ ] = False,
76
+ temperature: Annotated[
77
+ float,
78
+ typer.Option(
79
+ "--temperature", "-t", help="Sampling temperature (only with --sampling)"
80
+ ),
81
+ ] = 1.0,
82
+ cfg_coef: Annotated[
83
+ float, typer.Option("--cfg-coef", help="Classifier-free guidance coefficient")
84
+ ] = 1.0, # todo: make it dynamic
85
+ model_path: Annotated[
86
+ str | None,
87
+ typer.Option(
88
+ "--model",
89
+ "-m",
90
+ help=(
91
+ "Model size ('small', 'medium', 'large'; default: medium), "
92
+ "a local safetensors path, or an hf:// / http(s):// URL"
93
+ ),
94
+ ),
95
+ ] = None,
96
+ device: Annotated[
97
+ str,
98
+ typer.Option(
99
+ "--device", "-d", help="Device: 'auto', 'cpu', 'cuda', 'cuda:0', …"
100
+ ),
101
+ ] = "auto",
102
+ batch_size: Annotated[
103
+ int | None,
104
+ typer.Option(
105
+ "--batch-size",
106
+ "-b",
107
+ help="Batch size for generation (default: 1 on CPU, 4 on GPU)",
108
+ ),
109
+ ] = None,
110
+ strict_eos: Annotated[
111
+ bool,
112
+ typer.Option(
113
+ "--strict-eos",
114
+ help="Raise an error if a chunk fails to emit EOS within the generation budget (default: downgrade to a warning)",
115
+ ),
116
+ ] = False,
117
+ beam_size: Annotated[
118
+ int,
119
+ typer.Option(
120
+ "--beam-size",
121
+ help="Beam search width (1 = greedy/sampling, ≥2 enables beam search)",
122
+ ),
123
+ ] = 1,
124
+ auralize: Annotated[
125
+ Path | None,
126
+ typer.Option(
127
+ "--auralize",
128
+ help=(
129
+ "Write a stereo auralization (L=original audio, R=MIDI synthesis) to "
130
+ "this path. Requires fluidsynth on PATH. Extension determines format: "
131
+ ".wav (default) or .mp3. Only valid with --format midi."
132
+ ),
133
+ ),
134
+ ] = None,
135
+ soundfont: Annotated[
136
+ Path | None,
137
+ typer.Option(
138
+ "--soundfont",
139
+ help=(
140
+ "Path to a .sf2 SoundFont for auralization. "
141
+ "Defaults to the bundled MuseScore_General.sf2."
142
+ ),
143
+ ),
144
+ ] = None,
145
+ instruments: Annotated[
146
+ str | None,
147
+ typer.Option(
148
+ "--instruments",
149
+ help=(
150
+ "Comma-separated list of expected instrument group names. "
151
+ "Case-insensitive; unambiguous abbreviations are accepted "
152
+ "(e.g. 'timp,cello,dist'). Run 'muscriptor list-instruments' "
153
+ "to see all available names."
154
+ ),
155
+ ),
156
+ ] = None,
157
+ ) -> None:
158
+ """Transcribe an audio file to MIDI."""
159
+ instrument_names: list[str] | None = None
160
+ if instruments is not None:
161
+ tokens = [n for n in instruments.split(",") if n.strip()]
162
+ try:
163
+ instrument_names = resolve_instrument_names(tokens)
164
+ except ValueError as e:
165
+ typer.echo(
166
+ f"Error: {e}. "
167
+ "Run 'muscriptor list-instruments' to see available names.",
168
+ err=True,
169
+ )
170
+ raise typer.Exit(1)
171
+ typer.echo(f"Instruments: {', '.join(instrument_names)}", err=True)
172
+
173
+ if not audio_file.exists():
174
+ typer.echo(f"Error: file not found: {audio_file}", err=True)
175
+ raise typer.Exit(1)
176
+
177
+ is_stdout = output is not None and str(output) == "-"
178
+
179
+ if output is None:
180
+ suffix = {
181
+ OutputFormat.midi: ".mid",
182
+ OutputFormat.json: ".json",
183
+ OutputFormat.jsonl: ".jsonl",
184
+ }[format]
185
+ output = audio_file.with_suffix(suffix)
186
+
187
+ _device = None if device == "auto" else device
188
+
189
+ # All chatty progress/timing info goes to stderr — stdout is reserved for
190
+ # the actual output when `-o -` is used.
191
+ typer.echo("Loading model…", err=True)
192
+ model = TranscriptionModel.load_model(
193
+ weights_path=model_path,
194
+ device=_device,
195
+ )
196
+ import torch
197
+
198
+ model._model = model._model.to(torch.float32)
199
+
200
+ typer.echo(f"Transcribing {audio_file} …", err=True)
201
+
202
+ if auralize is not None and format != OutputFormat.midi:
203
+ typer.echo("Error: --auralize requires --format midi", err=True)
204
+ raise typer.Exit(1)
205
+
206
+ kwargs = dict(
207
+ audio=audio_file,
208
+ use_sampling=sampling,
209
+ temperature=temperature,
210
+ cfg_coef=cfg_coef,
211
+ instruments=instrument_names,
212
+ batch_size=batch_size,
213
+ no_eos_is_ok=not strict_eos,
214
+ beam_size=beam_size,
215
+ )
216
+
217
+ if format == OutputFormat.midi:
218
+ midi_bytes = model.transcribe_to_midi(**kwargs)
219
+ if is_stdout:
220
+ sys.stdout.buffer.write(midi_bytes)
221
+ sys.stdout.buffer.flush()
222
+ else:
223
+ output.write_bytes(midi_bytes)
224
+ typer.echo(f"Saved MIDI to {output}", err=True)
225
+ if notes:
226
+ typer.echo(
227
+ "Re-run with --format json to inspect the event stream.", err=True
228
+ )
229
+ if auralize is not None and not is_stdout:
230
+ from muscriptor.utils.auralization import auralize as do_auralize
231
+
232
+ typer.echo(f"Auralizing → {auralize} …", err=True)
233
+ do_auralize(
234
+ midi_path=output,
235
+ original_audio_path=audio_file,
236
+ output_path=auralize,
237
+ soundfont_path=soundfont,
238
+ )
239
+ typer.echo(f"Saved auralization to {auralize}", err=True)
240
+ elif format == OutputFormat.jsonl:
241
+ # Stream one JSON object per line, flushing after each event so the
242
+ # file (or stdout pipe) can be consumed live.
243
+ if is_stdout:
244
+ sink = sys.stdout
245
+ close_after = False
246
+ else:
247
+ sink = output.open("w")
248
+ close_after = True
249
+ try:
250
+ for e in model.transcribe(**kwargs):
251
+ if isinstance(e, ProgressEvent):
252
+ continue
253
+ sink.write(json.dumps(_event_to_dict(e)) + "\n")
254
+ sink.flush()
255
+ if notes:
256
+ typer.echo(str(e), err=True)
257
+ finally:
258
+ if close_after:
259
+ sink.close()
260
+ if not is_stdout:
261
+ typer.echo(f"Saved JSONL to {output}", err=True)
262
+ else: # json
263
+ events = [
264
+ e
265
+ for e in model.transcribe(**kwargs)
266
+ if not isinstance(e, ProgressEvent)
267
+ ]
268
+ payload = json.dumps([_event_to_dict(e) for e in events], indent=2)
269
+ if is_stdout:
270
+ sys.stdout.write(payload + "\n")
271
+ sys.stdout.flush()
272
+ else:
273
+ output.write_text(payload)
274
+ typer.echo(f"Saved JSON to {output}", err=True)
275
+ if notes:
276
+ for e in events:
277
+ typer.echo(str(e), err=True)
278
+
279
+
280
+ @app.command()
281
+ def serve(
282
+ host: Annotated[str, typer.Option("--host", help="Bind address")] = "127.0.0.1",
283
+ port: Annotated[int, typer.Option("--port", help="Port to listen on")] = 8222,
284
+ model_path: Annotated[
285
+ str | None,
286
+ typer.Option(
287
+ "--model",
288
+ "-m",
289
+ help=(
290
+ "Model size ('small', 'medium', 'large'; default: medium), "
291
+ "a local safetensors path, or an hf:// / http(s):// URL"
292
+ ),
293
+ ),
294
+ ] = None,
295
+ device: Annotated[
296
+ str,
297
+ typer.Option(
298
+ "--device", "-d", help="Device: 'auto', 'cpu', 'cuda', 'cuda:0', …"
299
+ ),
300
+ ] = "auto",
301
+ ):
302
+ """Run the HTTP transcription server (POST /transcribe → SSE event stream)."""
303
+ import uvicorn
304
+
305
+ from muscriptor.server import create_app
306
+
307
+ _device = None if device == "auto" else device
308
+ typer.echo("Loading model…")
309
+ model = TranscriptionModel.load_model(weights_path=model_path, device=_device)
310
+ web_dir = Path(__file__).resolve().parent.parent / "web" / "dist"
311
+ fastapi_app = create_app(model, web_dir=web_dir if web_dir.is_dir() else None)
312
+ uvicorn.run(fastapi_app, host=host, port=port)
313
+
314
+
315
+ @app.command()
316
+ def list_instruments():
317
+ """List the instrument group names accepted by --instruments."""
318
+ for name in MT3_FULL_PLUS_GROUP_NAMES:
319
+ typer.echo(name)
320
+
321
+
322
+ def main():
323
+ app()
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()
File without changes