transcpybe 2.0.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.
transcpybe/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Vocal melody to MIDI transcription.
2
+
3
+ Transcribes monophonic singing -- typically a source-separated lead vocal stem
4
+ -- into note events with integer MIDI pitches and floating-point onset times.
5
+ By default this is transcPybe v2's pop preset with pYIN pitch tracking; see
6
+ ``TranscriptionConfig.preset`` and ``transcpybe.landmarks``.
7
+
8
+ from transcpybe import transcribe_file, write_midi
9
+
10
+ result = transcribe_file("lead-vocal.wav")
11
+ write_midi(result.notes, "lead-vocal.mid", result.config)
12
+
13
+ Or from the command line::
14
+
15
+ python -m transcpybe lead-vocal.wav -o out/
16
+ python -m transcpybe stems/ -o midi/ --log-file run.log
17
+
18
+ See ``transcpybe/README.md`` for the algorithm and for credit to the
19
+ upstream projects this builds on.
20
+ """
21
+
22
+ from .batch import BatchItem, BatchResult, find_audio_files, transcribe_batch
23
+ from .config import TranscriptionConfig
24
+ from .core import TranscriptionResult, transcribe_audio, transcribe_file
25
+ from .device import DeviceInfo, resolve_device
26
+ from .f0 import F0Track, estimate_f0, hz_to_midi, midi_to_hz
27
+ from .midi_io import format_note_table, note_name, write_csv, write_json, write_midi
28
+ from .notes import Note
29
+ from .onsets import OnsetTrack, detect_onsets
30
+ from .progress import ProgressReporter, null_reporter, setup_logging
31
+ from .segment import Segment, segment_notes
32
+
33
+ __all__ = [
34
+ "TranscriptionConfig",
35
+ "TranscriptionResult",
36
+ "transcribe_audio",
37
+ "transcribe_file",
38
+ "transcribe_batch",
39
+ "find_audio_files",
40
+ "BatchItem",
41
+ "BatchResult",
42
+ "ProgressReporter",
43
+ "null_reporter",
44
+ "setup_logging",
45
+ "DeviceInfo",
46
+ "resolve_device",
47
+ "F0Track",
48
+ "estimate_f0",
49
+ "hz_to_midi",
50
+ "midi_to_hz",
51
+ "Note",
52
+ "Segment",
53
+ "segment_notes",
54
+ "OnsetTrack",
55
+ "detect_onsets",
56
+ "write_midi",
57
+ "write_json",
58
+ "write_csv",
59
+ "format_note_table",
60
+ "note_name",
61
+ ]
62
+
63
+ __version__ = "2.0.0"
transcpybe/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m transcpybe``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
transcpybe/audio.py ADDED
@@ -0,0 +1,58 @@
1
+ """Audio loading for transcription."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+
8
+ import numpy as np
9
+
10
+
11
+ def load_audio(path: str | Path, sample_rate: int) -> Tuple[np.ndarray, int]:
12
+ """Load an audio file as mono float32 at ``sample_rate``.
13
+
14
+ Multi-channel input is averaged to mono. Any format libsndfile or audioread
15
+ can open is accepted (wav, flac, ogg, mp3, m4a, ...).
16
+
17
+ Returns
18
+ -------
19
+ (audio, sample_rate)
20
+ ``audio`` is 1-D float32 in roughly [-1, 1].
21
+ """
22
+ import librosa
23
+
24
+ path = Path(path)
25
+ if not path.exists():
26
+ raise FileNotFoundError(f"Audio file not found: {path}")
27
+
28
+ audio, sr = librosa.load(str(path), sr=sample_rate, mono=True)
29
+ audio = np.ascontiguousarray(audio, dtype=np.float32)
30
+
31
+ if audio.size == 0:
32
+ raise ValueError(f"Audio file is empty: {path}")
33
+ if not np.isfinite(audio).all():
34
+ audio = np.nan_to_num(audio, nan=0.0, posinf=0.0, neginf=0.0)
35
+
36
+ return audio, sr
37
+
38
+
39
+ def frame_rms_db(audio: np.ndarray, hop_length: int, n_frames: int) -> np.ndarray:
40
+ """Per-frame RMS in dBFS, aligned to a centred frame grid of ``n_frames``.
41
+
42
+ The window is 4x the hop, which smooths the level enough that a single
43
+ glottal period does not swing the gate.
44
+ """
45
+ import librosa
46
+
47
+ frame_length = max(hop_length * 4, 256)
48
+ rms = librosa.feature.rms(
49
+ y=audio, frame_length=frame_length, hop_length=hop_length, center=True
50
+ )[0]
51
+
52
+ # librosa's frame count can differ by one from the f0 backend's; align.
53
+ if rms.shape[0] < n_frames:
54
+ rms = np.pad(rms, (0, n_frames - rms.shape[0]), mode="edge")
55
+ elif rms.shape[0] > n_frames:
56
+ rms = rms[:n_frames]
57
+
58
+ return 20.0 * np.log10(np.maximum(rms, 1e-10))
transcpybe/batch.py ADDED
@@ -0,0 +1,359 @@
1
+ """Transcribing a folder of audio files.
2
+
3
+ A batch is where progress reporting stops being a nicety: a few hundred songs
4
+ on a GPU box runs for hours, usually detached under tmux, and the only way to
5
+ know whether it is healthy is what it writes to the log.
6
+
7
+ Three properties matter for a run that long, and all three are here: it is
8
+ resumable, so an interrupted run does not start over; a single bad file does
9
+ not end the batch; and the estimated time remaining is weighted by audio
10
+ duration rather than file count, because songs are not all the same length.
11
+
12
+ A file is skipped only if its outputs exist and its ``.notes.json`` records the
13
+ settings this run would use, so a rerun after a change of settings or version
14
+ (2.0.0 changed the default preset and pitch tracker) redoes the files made
15
+ otherwise rather than leaving a folder of mixed transcriptions.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
26
+
27
+ from .config import TranscriptionConfig
28
+ from .core import TranscriptionResult, transcribe_file
29
+ from .midi_io import write_csv, write_json, write_midi
30
+ from .progress import ProgressReporter, format_duration, null_reporter
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ AUDIO_EXTENSIONS = (".wav", ".flac", ".mp3", ".m4a", ".ogg", ".aif", ".aiff", ".opus")
35
+
36
+ # Settings that change how a run goes but not its notes. A file made with other
37
+ # values of these is still current: "auto" resolves to a different device on
38
+ # another machine, and the batch size and memory logging change only speed and logs.
39
+ RUN_ONLY_SETTINGS = frozenset({"device", "crepe_batch_size", "gpu_memory_log_every"})
40
+ _NOT_RECORDED = object()
41
+
42
+
43
+ def settings_changed(json_path: Path, config: TranscriptionConfig) -> Optional[List[Tuple[str, object, object]]]:
44
+ """How the settings recorded in ``json_path`` differ from those ``config`` runs with.
45
+
46
+ Returns (setting, recorded, current) for each difference, an empty list if
47
+ there is none, or None if there is no record to compare against. A setting
48
+ the record lacks (an older version's output) counts as a difference.
49
+ """
50
+ from .landmarks import preset_config
51
+
52
+ if not json_path.exists():
53
+ return None
54
+ try:
55
+ recorded = json.loads(json_path.read_text())["config"]
56
+ if not isinstance(recorded, dict):
57
+ raise TypeError
58
+ except (OSError, ValueError, KeyError, TypeError):
59
+ return [("settings", "unreadable", "this run's")]
60
+ current = preset_config(config).to_dict() # what transcription records: the preset's decoder settings applied
61
+ return [(k, recorded.get(k, _NOT_RECORDED), v) for k, v in current.items()
62
+ if k not in RUN_ONLY_SETTINGS and recorded.get(k, _NOT_RECORDED) != v]
63
+
64
+
65
+ def _describe(changes) -> str:
66
+ shown = [f"{k} {'(not recorded)' if old is _NOT_RECORDED else repr(old)} -> {new!r}" for k, old, new in changes[:3]]
67
+ return ", ".join(shown) + (f" and {len(changes) - 3} more" if len(changes) > 3 else "")
68
+
69
+
70
+ @dataclass
71
+ class BatchItem:
72
+ """One file's outcome."""
73
+
74
+ path: Path
75
+ status: str # 'done', 'skipped' or 'failed'
76
+ n_notes: int = 0
77
+ duration: float = 0.0
78
+ elapsed: float = 0.0
79
+ error: Optional[str] = None
80
+
81
+
82
+ @dataclass
83
+ class BatchResult:
84
+ items: List[BatchItem] = field(default_factory=list)
85
+ elapsed: float = 0.0
86
+
87
+ @property
88
+ def done(self) -> List[BatchItem]:
89
+ return [i for i in self.items if i.status == "done"]
90
+
91
+ @property
92
+ def failed(self) -> List[BatchItem]:
93
+ return [i for i in self.items if i.status == "failed"]
94
+
95
+ @property
96
+ def skipped(self) -> List[BatchItem]:
97
+ return [i for i in self.items if i.status == "skipped"]
98
+
99
+ def summary(self) -> str:
100
+ lines = [
101
+ f"Batch finished in {format_duration(self.elapsed)}: "
102
+ f"{len(self.done)} transcribed, {len(self.skipped)} skipped, "
103
+ f"{len(self.failed)} failed"
104
+ ]
105
+ if self.done:
106
+ notes = sum(i.n_notes for i in self.done)
107
+ audio = sum(i.duration for i in self.done)
108
+ lines.append(f" {notes} notes over {format_duration(audio)} of audio")
109
+ for item in self.failed:
110
+ lines.append(f" FAILED {item.path.name}: {item.error}")
111
+ return "\n".join(lines)
112
+
113
+
114
+ def find_audio_files(
115
+ root: Path, extensions: Sequence[str] = AUDIO_EXTENSIONS, recursive: bool = True
116
+ ) -> List[Path]:
117
+ """Audio files under ``root``, sorted so runs are reproducible."""
118
+ root = Path(root)
119
+ if root.is_file():
120
+ return [root]
121
+
122
+ suffixes = {e.lower() for e in extensions}
123
+ pattern = "**/*" if recursive else "*"
124
+ found = [
125
+ p for p in root.glob(pattern)
126
+ if p.is_file() and p.suffix.lower() in suffixes and not p.name.startswith(".")
127
+ ]
128
+ return sorted(found)
129
+
130
+
131
+ def probe_duration(path: Path) -> Optional[float]:
132
+ """Read a file's duration from its header, without decoding it.
133
+
134
+ Used only to weight the batch ETA. Returns None for formats soundfile
135
+ cannot inspect cheaply, in which case the ETA falls back to file counts.
136
+ """
137
+ try:
138
+ import soundfile as sf
139
+
140
+ info = sf.info(str(path))
141
+ if info.samplerate > 0:
142
+ return info.frames / float(info.samplerate)
143
+ except Exception:
144
+ return None
145
+ return None
146
+
147
+
148
+ MAX_NAME_LEVEL = 6
149
+ """How many trailing path components a name may use before giving up on
150
+ readability and falling back to a numeric suffix."""
151
+
152
+
153
+ def _name_at_level(path: Path, level: int) -> str:
154
+ """A candidate output name built from ``level`` of surrounding context.
155
+
156
+ 0 is the bare stem, 1 is the containing directory alone -- which is what
157
+ makes a tree of ``<song>/lead-vocal.wav`` stems read as song names -- and
158
+ higher levels prepend more directories to the stem.
159
+ """
160
+ directories = list(path.parts[:-1])
161
+ if level == 0 or not directories:
162
+ return path.stem
163
+ if level == 1:
164
+ return directories[-1]
165
+ tail = directories[-(level - 1):]
166
+ return "__".join([*tail, path.stem])
167
+
168
+
169
+ def unique_output_names(paths: Sequence[Path]) -> Dict[Path, str]:
170
+ """Give every input a distinct output name, using as little context as possible.
171
+
172
+ Naming outputs after the file stem alone breaks badly on separated stems,
173
+ where every file is called ``lead-vocal.wav``: they would all write to
174
+ ``lead-vocal.mid``, and because finished work is skipped, a corpus of two
175
+ hundred songs would quietly produce one transcription.
176
+
177
+ The same level is used for the whole batch rather than resolving collisions
178
+ file by file, so names stay consistent and predictable: a flat directory
179
+ keeps its filenames untouched, and a tree of stems is named by song.
180
+ """
181
+ paths = list(paths)
182
+ if not paths:
183
+ return {}
184
+
185
+ # Naming by the containing directory alone is right when every file shares
186
+ # one stem, as separated stems all do, and misleading otherwise: it would
187
+ # turn albums/A/track1.wav into "A" and throw the track name away.
188
+ stems_all_identical = len({p.stem for p in paths}) == 1
189
+
190
+ for level in range(MAX_NAME_LEVEL + 1):
191
+ if level == 1 and not stems_all_identical:
192
+ continue
193
+ names = [_name_at_level(p, level) for p in paths]
194
+ if len(set(names)) == len(names):
195
+ return dict(zip(paths, names))
196
+
197
+ # Pathological input (identical paths, or nesting deeper than the cap).
198
+ # Uniqueness matters more than elegance here.
199
+ assigned: Dict[Path, str] = {}
200
+ seen: Dict[str, int] = {}
201
+ for path in paths:
202
+ base = _name_at_level(path, MAX_NAME_LEVEL)
203
+ seen[base] = seen.get(base, 0) + 1
204
+ assigned[path] = base if seen[base] == 1 else f"{base}__{seen[base]}"
205
+ return assigned
206
+
207
+
208
+ def output_paths(path: Path, output_dir: Path, name: Optional[str] = None) -> dict:
209
+ stem = name or path.stem
210
+ return {
211
+ "midi": output_dir / f"{stem}.mid",
212
+ "json": output_dir / f"{stem}.notes.json",
213
+ "csv": output_dir / f"{stem}.notes.csv",
214
+ }
215
+
216
+
217
+ def transcribe_batch(
218
+ paths: Iterable[Path],
219
+ output_dir: Path,
220
+ config: Optional[TranscriptionConfig] = None,
221
+ *,
222
+ progress: Optional[ProgressReporter] = None,
223
+ skip_existing: bool = True,
224
+ continue_on_error: bool = True,
225
+ write_midi_files: bool = True,
226
+ write_json_files: bool = True,
227
+ write_csv_files: bool = True,
228
+ ) -> BatchResult:
229
+ """Transcribe many files, reporting per-file and overall progress."""
230
+ config = config or TranscriptionConfig()
231
+ config.validate()
232
+
233
+ paths = list(paths)
234
+ output_dir = Path(output_dir)
235
+ output_dir.mkdir(parents=True, exist_ok=True)
236
+ progress = progress or null_reporter()
237
+
238
+ names = unique_output_names(paths)
239
+ durations = {p: probe_duration(p) for p in paths}
240
+ total_audio = sum(d for d in durations.values() if d)
241
+ known_all = all(d is not None for d in durations.values())
242
+
243
+ progress.log(
244
+ f"Batch: {len(paths)} files"
245
+ + (f", {format_duration(total_audio)} of audio" if total_audio else "")
246
+ + f" -> {output_dir}"
247
+ )
248
+
249
+ result = BatchResult()
250
+ started = time.time()
251
+ audio_done = 0.0
252
+ unverified_noted = False
253
+
254
+ for index, path in enumerate(paths, start=1):
255
+ outputs = output_paths(path, output_dir, names.get(path))
256
+ wanted = [
257
+ outputs["midi"] if write_midi_files else None,
258
+ outputs["json"] if write_json_files else None,
259
+ outputs["csv"] if write_csv_files else None,
260
+ ]
261
+ wanted = [p for p in wanted if p is not None]
262
+
263
+ if skip_existing and wanted and all(p.exists() for p in wanted):
264
+ changes = settings_changed(outputs["json"], config)
265
+ if not changes:
266
+ progress.log(f"[{index}/{len(paths)}] {path.name}: already done, skipping")
267
+ if changes is None and not unverified_noted:
268
+ progress.log(" (no .notes.json beside these outputs, so their settings cannot be "
269
+ "checked; --overwrite redoes them)")
270
+ unverified_noted = True
271
+ result.items.append(BatchItem(path=path, status="skipped",
272
+ duration=durations.get(path) or 0.0))
273
+ audio_done += durations.get(path) or 0.0
274
+ continue
275
+ progress.log(f"[{index}/{len(paths)}] {path.name}: made with other settings "
276
+ f"({_describe(changes)}); transcribing again")
277
+
278
+ header = f"[{index}/{len(paths)}] {path.name}"
279
+ if durations.get(path):
280
+ header += f" ({format_duration(durations[path])})"
281
+ progress.log(header)
282
+
283
+ # Each file gets its own audio-duration reference so that the per-stage
284
+ # realtime factors describe this file, not the batch.
285
+ file_progress = ProgressReporter(
286
+ enabled=progress.enabled, stream=progress.stream, logger=progress.logger,
287
+ interval=progress.interval, audio_seconds=durations.get(path),
288
+ )
289
+
290
+ file_started = time.time()
291
+ try:
292
+ transcription = transcribe_file(path, config, file_progress)
293
+ except KeyboardInterrupt:
294
+ progress.log("Interrupted; stopping the batch.")
295
+ raise
296
+ except Exception as exc:
297
+ logger.exception("Failed to transcribe %s", path)
298
+ progress.log(f" FAILED: {type(exc).__name__}: {exc}")
299
+ result.items.append(BatchItem(path=path, status="failed",
300
+ error=f"{type(exc).__name__}: {exc}",
301
+ duration=durations.get(path) or 0.0))
302
+ audio_done += durations.get(path) or 0.0
303
+ if not continue_on_error:
304
+ break
305
+ continue
306
+
307
+ _write_outputs(transcription, outputs, config,
308
+ write_midi_files, write_json_files, write_csv_files)
309
+
310
+ elapsed = time.time() - file_started
311
+ result.items.append(BatchItem(
312
+ path=path, status="done", n_notes=transcription.n_notes,
313
+ duration=transcription.duration, elapsed=elapsed,
314
+ ))
315
+ audio_done += durations.get(path) or transcription.duration
316
+ progress.log(f" {transcription.n_notes} notes in {format_duration(elapsed)}")
317
+
318
+ progress.log(" " + _overall_line(
319
+ index, len(paths), time.time() - started,
320
+ audio_done, total_audio, known_all,
321
+ ))
322
+
323
+ result.elapsed = time.time() - started
324
+ return result
325
+
326
+
327
+ def _overall_line(
328
+ index: int, total_files: int, elapsed: float,
329
+ audio_done: float, total_audio: float, weight_by_audio: bool,
330
+ ) -> str:
331
+ """Overall batch progress, weighted by audio duration where possible."""
332
+ if weight_by_audio and total_audio > 0:
333
+ fraction = min(audio_done / total_audio, 1.0)
334
+ else:
335
+ fraction = index / max(total_files, 1)
336
+
337
+ text = f"overall {index}/{total_files} files ({fraction * 100:.0f}%), elapsed {format_duration(elapsed)}"
338
+ if fraction > 0:
339
+ remaining = elapsed * (1.0 - fraction) / fraction
340
+ text += f", ETA {format_duration(remaining)}"
341
+ return text
342
+
343
+
344
+ def _write_outputs(
345
+ result: TranscriptionResult, outputs: dict, config: TranscriptionConfig,
346
+ midi: bool, json_: bool, csv: bool,
347
+ ) -> None:
348
+ # The config the transcription ran with: a preset's decoder settings, the
349
+ # device CREPE actually used.
350
+ config = result.config
351
+ if midi:
352
+ write_midi(result.notes, outputs["midi"], config)
353
+ if json_:
354
+ write_json(result.notes, outputs["json"], config,
355
+ extra={"source": result.source, "duration": result.duration,
356
+ "device": result.device,
357
+ "tuning_offset_cents": 100.0 * result.tuning_offset})
358
+ if csv:
359
+ write_csv(result.notes, outputs["csv"])