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 +63 -0
- transcpybe/__main__.py +8 -0
- transcpybe/audio.py +58 -0
- transcpybe/batch.py +359 -0
- transcpybe/cli.py +377 -0
- transcpybe/config.py +802 -0
- transcpybe/core.py +252 -0
- transcpybe/corpus.py +645 -0
- transcpybe/device.py +141 -0
- transcpybe/evaluate.py +152 -0
- transcpybe/f0.py +572 -0
- transcpybe/fragments.py +102 -0
- transcpybe/glide.py +258 -0
- transcpybe/landmarks/__init__.py +129 -0
- transcpybe/landmarks/features.py +66 -0
- transcpybe/landmarks/infer.py +138 -0
- transcpybe/landmarks/model.py +91 -0
- transcpybe/landmarks/voicing.py +31 -0
- transcpybe/landmarks/weights/LICENSE +438 -0
- transcpybe/landmarks/weights/README.md +102 -0
- transcpybe/landmarks/weights/seed0.pt +0 -0
- transcpybe/landmarks/weights/seed1.pt +0 -0
- transcpybe/landmarks/weights/seed2.pt +0 -0
- transcpybe/landmarks/weights/voicing-pesto.json +235 -0
- transcpybe/landmarks/weights/voicing-pyin.json +131 -0
- transcpybe/midi_io.py +200 -0
- transcpybe/notes.py +543 -0
- transcpybe/onsets.py +627 -0
- transcpybe/progress.py +237 -0
- transcpybe/segment.py +377 -0
- transcpybe/synth.py +569 -0
- transcpybe-2.0.0.dist-info/METADATA +356 -0
- transcpybe-2.0.0.dist-info/RECORD +38 -0
- transcpybe-2.0.0.dist-info/WHEEL +5 -0
- transcpybe-2.0.0.dist-info/entry_points.txt +2 -0
- transcpybe-2.0.0.dist-info/licenses/LICENSE +21 -0
- transcpybe-2.0.0.dist-info/licenses/src/transcpybe/landmarks/weights/LICENSE +438 -0
- transcpybe-2.0.0.dist-info/top_level.txt +1 -0
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
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"])
|