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 +7 -0
- muscriptor/__main__.py +3 -0
- muscriptor/events.py +208 -0
- muscriptor/main.py +327 -0
- muscriptor/models/__init__.py +0 -0
- muscriptor/models/lm.py +514 -0
- muscriptor/modules/__init__.py +0 -0
- muscriptor/modules/conditioners.py +321 -0
- muscriptor/modules/mel_spectrogram.py +106 -0
- muscriptor/modules/streaming.py +68 -0
- muscriptor/modules/transformer.py +202 -0
- muscriptor/server.py +267 -0
- muscriptor/tokenizer/__init__.py +3 -0
- muscriptor/tokenizer/mt3.py +231 -0
- muscriptor/tokenizer/notes.py +344 -0
- muscriptor/transcription_model.py +629 -0
- muscriptor/utils/__init__.py +0 -0
- muscriptor/utils/audio.py +112 -0
- muscriptor/utils/auralization.py +145 -0
- muscriptor/utils/download.py +68 -0
- muscriptor/utils/midi.py +25 -0
- muscriptor/utils/resample.py +191 -0
- muscriptor/utils/sampling.py +89 -0
- muscriptor-0.1.0.dist-info/METADATA +336 -0
- muscriptor-0.1.0.dist-info/RECORD +28 -0
- muscriptor-0.1.0.dist-info/WHEEL +4 -0
- muscriptor-0.1.0.dist-info/entry_points.txt +2 -0
- muscriptor-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|