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/cli.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Command-line interface for vocal melody transcription.
|
|
2
|
+
|
|
3
|
+
python -m transcpybe lead-vocal.wav -o out/
|
|
4
|
+
python -m transcpybe solo.wav --preset acappella
|
|
5
|
+
python -m transcpybe stems/ -o midi/ --log-file run.log
|
|
6
|
+
python -m transcpybe --self-test
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .config import TranscriptionConfig
|
|
16
|
+
from .f0 import f0_backends
|
|
17
|
+
from .progress import ProgressReporter, setup_logging
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="transcpybe",
|
|
23
|
+
description="Transcribe monophonic singing into MIDI notes "
|
|
24
|
+
"(integer pitches, floating-point onsets). Give it a file, "
|
|
25
|
+
"or a directory to transcribe every audio file inside.",
|
|
26
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
parser.add_argument("audio", nargs="?",
|
|
30
|
+
help="Input audio file, or a directory of audio files")
|
|
31
|
+
parser.add_argument("-o", "--output-dir", default=None,
|
|
32
|
+
help="Directory for outputs (default: alongside the input)")
|
|
33
|
+
parser.add_argument("--name", default=None,
|
|
34
|
+
help="Base name for output files (single file only)")
|
|
35
|
+
|
|
36
|
+
formats = parser.add_argument_group("output formats")
|
|
37
|
+
formats.add_argument("--no-midi", action="store_true", help="Skip writing the .mid file")
|
|
38
|
+
formats.add_argument("--no-json", action="store_true", help="Skip writing the .json file")
|
|
39
|
+
formats.add_argument("--no-csv", action="store_true", help="Skip writing the .csv file")
|
|
40
|
+
formats.add_argument("--print-notes", action="store_true",
|
|
41
|
+
help="Print the full note table to stdout")
|
|
42
|
+
formats.add_argument("--max-rows", type=int, default=None,
|
|
43
|
+
help="Limit rows when printing (default: all)")
|
|
44
|
+
|
|
45
|
+
monitoring = parser.add_argument_group("progress and logging")
|
|
46
|
+
monitoring.add_argument("--no-progress", action="store_true",
|
|
47
|
+
help="Suppress progress reporting entirely")
|
|
48
|
+
monitoring.add_argument("--progress-interval", type=float, default=5.0,
|
|
49
|
+
help="Seconds between progress lines when output is "
|
|
50
|
+
"redirected to a file rather than a terminal")
|
|
51
|
+
monitoring.add_argument("--log-file", default=None,
|
|
52
|
+
help="Append timestamped logs to this file as well as "
|
|
53
|
+
"the console; survives a lost tmux session")
|
|
54
|
+
monitoring.add_argument("--gpu-memory-every", type=int, default=0,
|
|
55
|
+
dest="gpu_memory_log_every",
|
|
56
|
+
help="Log GPU memory every N CREPE batches (0 disables)")
|
|
57
|
+
monitoring.add_argument("-v", "--verbose", action="store_true",
|
|
58
|
+
help="Include debug-level detail in the log")
|
|
59
|
+
monitoring.add_argument("-q", "--quiet", action="store_true",
|
|
60
|
+
help="Suppress the closing summary")
|
|
61
|
+
|
|
62
|
+
starts = parser.add_argument_group("note starts")
|
|
63
|
+
starts.add_argument("--preset", choices=("pop", "acappella", "rules"), default="pop",
|
|
64
|
+
help="pop (a lead vocal separated from a mix) or acappella "
|
|
65
|
+
"(solo singing): transcPybe v2, note starts from three "
|
|
66
|
+
"small neural networks; sets the decoder flags it was "
|
|
67
|
+
"scored with (--min-note, --p-self, --note-level-floor, "
|
|
68
|
+
"--onset-delta, --onset-split-strength, --release-drop). "
|
|
69
|
+
"rules: transcPybe v1, no networks, every flag as given")
|
|
70
|
+
|
|
71
|
+
model = parser.add_argument_group("pitch model")
|
|
72
|
+
model.add_argument("--f0-backend", choices=f0_backends(), default="pyin",
|
|
73
|
+
help="pyin: the default, and v2's best scoring. crepe: "
|
|
74
|
+
"neural, fast on a GPU (pip install \"transcpybe[crepe]\"). "
|
|
75
|
+
"pesto: neural, small and fast (pip install \"transcpybe[pesto]\")")
|
|
76
|
+
model.add_argument("--crepe-model", choices=("full", "tiny"), default="full",
|
|
77
|
+
help="tiny is roughly 14x faster on CPU and less accurate")
|
|
78
|
+
model.add_argument("--device", default="auto",
|
|
79
|
+
help="Torch device: auto (cuda, else mps, else cpu), or an "
|
|
80
|
+
"explicit cpu / cuda / cuda:1 / mps")
|
|
81
|
+
model.add_argument("--crepe-batch-size", type=int, default=512,
|
|
82
|
+
help="CREPE frames per forward pass. Raise it on a large "
|
|
83
|
+
"GPU for speed, lower it if memory is tight")
|
|
84
|
+
model.add_argument("--seed", type=int, default=0,
|
|
85
|
+
help="Seed for CREPE's +/-20 cent pitch dither, making a "
|
|
86
|
+
"run reproducible")
|
|
87
|
+
model.add_argument("--no-seed", action="store_true",
|
|
88
|
+
help="Leave the dither unseeded, as torchcrepe does by default")
|
|
89
|
+
model.add_argument("--fmin", type=float, default=55.0, help="Lowest f0 tracked, in Hz")
|
|
90
|
+
model.add_argument("--fmax", type=float, default=1100.0, help="Highest f0 tracked, in Hz")
|
|
91
|
+
|
|
92
|
+
tuning = parser.add_argument_group("tuning")
|
|
93
|
+
tuning.add_argument("--min-note", type=float, default=0.055, dest="min_note_duration_s",
|
|
94
|
+
help="Discard notes shorter than this, in seconds")
|
|
95
|
+
tuning.add_argument("--pitch-sigma", type=float, default=0.55,
|
|
96
|
+
dest="pitch_sigma_semitones",
|
|
97
|
+
help="Emission width in semitones; larger absorbs more vibrato")
|
|
98
|
+
tuning.add_argument("--p-self", type=float, default=0.985,
|
|
99
|
+
help="Note persistence; raise for fewer, longer notes")
|
|
100
|
+
tuning.add_argument("--pitch-states-per-semitone", type=int, default=3,
|
|
101
|
+
dest="pitch_states_per_semitone",
|
|
102
|
+
help="How finely the decoder divides a semitone. 1 is the "
|
|
103
|
+
"old one-state-per-semitone decoder, which fragments "
|
|
104
|
+
"notes sung near the midpoint of two semitones")
|
|
105
|
+
tuning.add_argument("--pitch-reference", choices=("sung", "tuned"), default="sung",
|
|
106
|
+
dest="pitch_reference",
|
|
107
|
+
help="Round each note to the semitone nearest what was "
|
|
108
|
+
"sung (default; what sung-pitch annotations score), "
|
|
109
|
+
"or to the nearest step of the recording's own "
|
|
110
|
+
"estimated tuning grid (the note it meant)")
|
|
111
|
+
tuning.add_argument("--note-level-floor", type=float, default=30.0,
|
|
112
|
+
dest="note_level_floor_db",
|
|
113
|
+
help="Drop notes this many dB below the performance level. "
|
|
114
|
+
"Lower it to reject more separation residue, at the risk "
|
|
115
|
+
"of losing genuinely quiet singing")
|
|
116
|
+
tuning.add_argument("--onset-n-fft", type=int, default=512,
|
|
117
|
+
dest="onset_n_fft",
|
|
118
|
+
help="FFT window for the onset envelope, in samples. 512 "
|
|
119
|
+
"is 32 ms at 16 kHz; raise it for a smoother envelope "
|
|
120
|
+
"at the cost of placing attacks less precisely")
|
|
121
|
+
tuning.add_argument("--onset-aggregate", choices=("mean", "median"),
|
|
122
|
+
default="mean", dest="onset_aggregate",
|
|
123
|
+
help="How spectral flux is combined across frequency. "
|
|
124
|
+
"median only fires when most of the spectrum rises, "
|
|
125
|
+
"which misses vowel-initial onsets")
|
|
126
|
+
tuning.add_argument("--onset-split-strength", type=float, default=0.70,
|
|
127
|
+
dest="onset_split_strength_ratio",
|
|
128
|
+
help="How strong an onset must be, relative to the median "
|
|
129
|
+
"detected onset, to split a note in two. Lower splits "
|
|
130
|
+
"more; raise it if the output is over-segmented")
|
|
131
|
+
tuning.add_argument("--onset-delta", type=float, default=1.0,
|
|
132
|
+
dest="onset_delta_ratio",
|
|
133
|
+
help="Peak threshold as a multiple of the onset envelope's "
|
|
134
|
+
"median. Raise it if noisy input over-segments")
|
|
135
|
+
tuning.add_argument("--onset-max-size", type=int, default=9,
|
|
136
|
+
dest="onset_max_size",
|
|
137
|
+
help="Frequency max-filter width suppressing vibrato. "
|
|
138
|
+
"Raise it if sustained notes fragment")
|
|
139
|
+
tuning.add_argument("--release-drop", type=float, default=15.0,
|
|
140
|
+
dest="release_drop_db",
|
|
141
|
+
help="End a note where it falls this many dB below its own "
|
|
142
|
+
"sustained level. Raise it if note ends come out "
|
|
143
|
+
"early, lower it if notes run on into the room")
|
|
144
|
+
tuning.add_argument("--no-onsets", action="store_true",
|
|
145
|
+
help="Disable spectral-flux onset refinement")
|
|
146
|
+
tuning.add_argument("--no-glides", action="store_true",
|
|
147
|
+
help="Disable portamento handling")
|
|
148
|
+
tuning.add_argument("--no-tuning-estimate", action="store_true",
|
|
149
|
+
help="Force the A440 semitone grid instead of finding the "
|
|
150
|
+
"one this recording is actually sung on. The estimate "
|
|
151
|
+
"already declines when a performance has no clear "
|
|
152
|
+
"pitch centre, so this is for when you know the grid")
|
|
153
|
+
tuning.add_argument("--no-release-trim", action="store_true",
|
|
154
|
+
help="End notes where the decoder stops hearing pitch, "
|
|
155
|
+
"which in a room is where the reverberation dies "
|
|
156
|
+
"away, rather than where the note falls "
|
|
157
|
+
"--release-drop below its own level")
|
|
158
|
+
tuning.add_argument("--release-trim", action="store_true",
|
|
159
|
+
help=argparse.SUPPRESS) # now the default; kept so old command lines run
|
|
160
|
+
|
|
161
|
+
batch = parser.add_argument_group("batch mode (directory input)")
|
|
162
|
+
batch.add_argument("--no-recursive", action="store_true",
|
|
163
|
+
help="Do not descend into subdirectories")
|
|
164
|
+
batch.add_argument("--overwrite", action="store_true",
|
|
165
|
+
help="Re-transcribe files whose outputs already exist "
|
|
166
|
+
"(default is to skip them, so a run is resumable)")
|
|
167
|
+
batch.add_argument("--stop-on-error", action="store_true",
|
|
168
|
+
help="Abort the batch on the first failure "
|
|
169
|
+
"(default is to log it and carry on)")
|
|
170
|
+
|
|
171
|
+
output = parser.add_argument_group("midi")
|
|
172
|
+
output.add_argument("--program", type=int, default=0, dest="midi_program",
|
|
173
|
+
help="General MIDI program number for the output track")
|
|
174
|
+
output.add_argument("--tempo", type=float, default=120.0, dest="midi_tempo",
|
|
175
|
+
help="Tempo written to the MIDI header; does not move note times")
|
|
176
|
+
output.add_argument("--12tet", action="store_true", dest="twelve_tet",
|
|
177
|
+
help="Write whole equal-tempered semitones only. By default "
|
|
178
|
+
"each note carries its measured pitch as a pitch bend")
|
|
179
|
+
|
|
180
|
+
parser.add_argument("--self-test", action="store_true",
|
|
181
|
+
help="Run the synthetic validation suite and exit")
|
|
182
|
+
|
|
183
|
+
return parser
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# Flags that only CREPE reads, and --device, which only the neural trackers read.
|
|
187
|
+
CREPE_FLAGS = {"crepe_model": "--crepe-model", "crepe_batch_size": "--crepe-batch-size",
|
|
188
|
+
"gpu_memory_log_every": "--gpu-memory-every"}
|
|
189
|
+
|
|
190
|
+
# The flags whose config fields a v2 preset sets itself (landmarks.DECODER_FIELDS).
|
|
191
|
+
PRESET_FLAGS = {"min_note_duration_s": "--min-note", "p_self": "--p-self",
|
|
192
|
+
"note_level_floor_db": "--note-level-floor", "onset_delta_ratio": "--onset-delta",
|
|
193
|
+
"onset_split_strength_ratio": "--onset-split-strength", "release_drop_db": "--release-drop"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def config_from_args(args: argparse.Namespace) -> TranscriptionConfig:
|
|
197
|
+
from .f0 import backend_uses_device
|
|
198
|
+
|
|
199
|
+
defaults = build_parser()
|
|
200
|
+
unused = []
|
|
201
|
+
if args.f0_backend != "crepe":
|
|
202
|
+
unused += [flag for dest, flag in CREPE_FLAGS.items() if getattr(args, dest) != defaults.get_default(dest)]
|
|
203
|
+
if not backend_uses_device(args.f0_backend) and args.device != defaults.get_default("device"):
|
|
204
|
+
unused.append("--device")
|
|
205
|
+
if unused:
|
|
206
|
+
print(f"warning: {', '.join(unused)} {'does' if len(unused) == 1 else 'do'} nothing with "
|
|
207
|
+
f"--f0-backend {args.f0_backend} (the default is pyin; CREPE needs --f0-backend crepe)",
|
|
208
|
+
file=sys.stderr)
|
|
209
|
+
if args.preset != "rules":
|
|
210
|
+
if args.no_onsets:
|
|
211
|
+
raise SystemExit(f"error: --preset {args.preset} takes its note starts from the networks; "
|
|
212
|
+
"--no-onsets needs --preset rules")
|
|
213
|
+
ignored = [flag for dest, flag in PRESET_FLAGS.items()
|
|
214
|
+
if getattr(args, dest) != defaults.get_default(dest)]
|
|
215
|
+
if ignored:
|
|
216
|
+
print(f"warning: --preset {args.preset} sets {', '.join(ignored)} itself; "
|
|
217
|
+
"the values given are not used (--preset rules uses them)", file=sys.stderr)
|
|
218
|
+
config = TranscriptionConfig(
|
|
219
|
+
preset=args.preset,
|
|
220
|
+
f0_backend=args.f0_backend,
|
|
221
|
+
crepe_model=args.crepe_model,
|
|
222
|
+
crepe_batch_size=args.crepe_batch_size,
|
|
223
|
+
device=args.device,
|
|
224
|
+
seed=None if args.no_seed else args.seed,
|
|
225
|
+
gpu_memory_log_every=args.gpu_memory_log_every,
|
|
226
|
+
fmin_hz=args.fmin,
|
|
227
|
+
fmax_hz=args.fmax,
|
|
228
|
+
min_note_duration_s=args.min_note_duration_s,
|
|
229
|
+
pitch_sigma_semitones=args.pitch_sigma_semitones,
|
|
230
|
+
p_self=args.p_self,
|
|
231
|
+
note_level_floor_db=args.note_level_floor_db,
|
|
232
|
+
onset_n_fft=args.onset_n_fft,
|
|
233
|
+
onset_aggregate=args.onset_aggregate,
|
|
234
|
+
onset_max_size=args.onset_max_size,
|
|
235
|
+
onset_delta_ratio=args.onset_delta_ratio,
|
|
236
|
+
onset_split_strength_ratio=args.onset_split_strength_ratio,
|
|
237
|
+
release_drop_db=args.release_drop_db,
|
|
238
|
+
use_onsets=not args.no_onsets,
|
|
239
|
+
estimate_tuning=not args.no_tuning_estimate,
|
|
240
|
+
absorb_glides=not args.no_glides,
|
|
241
|
+
trim_note_releases=not args.no_release_trim,
|
|
242
|
+
pitch_states_per_semitone=args.pitch_states_per_semitone,
|
|
243
|
+
pitch_reference=args.pitch_reference,
|
|
244
|
+
midi_program=args.midi_program,
|
|
245
|
+
midi_tempo=args.midi_tempo,
|
|
246
|
+
twelve_tet=args.twelve_tet,
|
|
247
|
+
)
|
|
248
|
+
config.validate()
|
|
249
|
+
return config
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_self_test(progress: ProgressReporter, config: TranscriptionConfig) -> int:
|
|
253
|
+
"""Transcribe the synthetic suite and report accuracy.
|
|
254
|
+
|
|
255
|
+
Takes the configuration the command line asked for rather than building a
|
|
256
|
+
default one. Ignoring it meant ``--self-test --f0-backend pyin`` silently
|
|
257
|
+
ran CREPE, so the one command offered for checking an install failed with
|
|
258
|
+
an ImportError on precisely the install without CREPE the flag exists to
|
|
259
|
+
serve -- and any other flag passed alongside it was quietly discarded.
|
|
260
|
+
"""
|
|
261
|
+
from .core import transcribe_audio
|
|
262
|
+
from .evaluate import format_scores, score
|
|
263
|
+
from .synth import ALL_CASES
|
|
264
|
+
|
|
265
|
+
rows = []
|
|
266
|
+
with progress.stage("self-test", total=len(ALL_CASES)) as stage:
|
|
267
|
+
for name, make_case in ALL_CASES.items():
|
|
268
|
+
audio, ground_truth = make_case()
|
|
269
|
+
result = transcribe_audio(audio, config)
|
|
270
|
+
rows.append((name, score(result.notes, ground_truth)))
|
|
271
|
+
stage.advance(1, detail=name)
|
|
272
|
+
|
|
273
|
+
print()
|
|
274
|
+
print(format_scores(rows))
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _run_single(args, config, progress) -> int:
|
|
279
|
+
from .core import transcribe_file
|
|
280
|
+
from .midi_io import format_note_table, write_csv, write_json, write_midi
|
|
281
|
+
|
|
282
|
+
audio_path = Path(args.audio)
|
|
283
|
+
result = transcribe_file(audio_path, config, progress)
|
|
284
|
+
|
|
285
|
+
output_dir = Path(args.output_dir) if args.output_dir else audio_path.parent
|
|
286
|
+
stem = args.name or audio_path.stem
|
|
287
|
+
written = []
|
|
288
|
+
|
|
289
|
+
if not args.no_midi:
|
|
290
|
+
written.append(write_midi(result.notes, output_dir / f"{stem}.mid", result.config))
|
|
291
|
+
if not args.no_json:
|
|
292
|
+
written.append(write_json(
|
|
293
|
+
result.notes, output_dir / f"{stem}.notes.json", result.config,
|
|
294
|
+
extra={"source": str(audio_path), "duration": result.duration,
|
|
295
|
+
"device": result.device,
|
|
296
|
+
# In cents, because that is the unit the note table's own
|
|
297
|
+
# deviation column is in and the two are read together.
|
|
298
|
+
"tuning_offset_cents": 100.0 * result.tuning_offset},
|
|
299
|
+
))
|
|
300
|
+
if not args.no_csv:
|
|
301
|
+
written.append(write_csv(result.notes, output_dir / f"{stem}.notes.csv"))
|
|
302
|
+
|
|
303
|
+
if args.print_notes:
|
|
304
|
+
print(format_note_table(result.notes, args.max_rows))
|
|
305
|
+
|
|
306
|
+
if not args.quiet:
|
|
307
|
+
print()
|
|
308
|
+
print(result.summary())
|
|
309
|
+
for path in written:
|
|
310
|
+
print(f" wrote {path}")
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _run_batch(args, config, progress) -> int:
|
|
315
|
+
from .batch import find_audio_files, transcribe_batch
|
|
316
|
+
|
|
317
|
+
root = Path(args.audio)
|
|
318
|
+
paths = find_audio_files(root, recursive=not args.no_recursive)
|
|
319
|
+
if not paths:
|
|
320
|
+
print(f"error: no audio files found under {root}", file=sys.stderr)
|
|
321
|
+
return 1
|
|
322
|
+
|
|
323
|
+
output_dir = Path(args.output_dir) if args.output_dir else root
|
|
324
|
+
result = transcribe_batch(
|
|
325
|
+
paths, output_dir, config,
|
|
326
|
+
progress=progress,
|
|
327
|
+
skip_existing=not args.overwrite,
|
|
328
|
+
continue_on_error=not args.stop_on_error,
|
|
329
|
+
write_midi_files=not args.no_midi,
|
|
330
|
+
write_json_files=not args.no_json,
|
|
331
|
+
write_csv_files=not args.no_csv,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if not args.quiet:
|
|
335
|
+
print()
|
|
336
|
+
print(result.summary())
|
|
337
|
+
return 1 if result.failed else 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def main(argv: list[str] | None = None) -> int:
|
|
341
|
+
args = build_parser().parse_args(argv)
|
|
342
|
+
|
|
343
|
+
logger = setup_logging(verbose=args.verbose, log_file=args.log_file)
|
|
344
|
+
# Always route permanent lines through the logger, so every line carries a
|
|
345
|
+
# timestamp and reaches the log file. The live in-place progress line still
|
|
346
|
+
# goes straight to the terminal, where it belongs.
|
|
347
|
+
progress = ProgressReporter(
|
|
348
|
+
enabled=not args.no_progress,
|
|
349
|
+
logger=logger,
|
|
350
|
+
interval=args.progress_interval,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
if args.self_test:
|
|
354
|
+
return run_self_test(progress, config_from_args(args))
|
|
355
|
+
|
|
356
|
+
if not args.audio:
|
|
357
|
+
build_parser().print_help()
|
|
358
|
+
return 2
|
|
359
|
+
|
|
360
|
+
audio_path = Path(args.audio)
|
|
361
|
+
if not audio_path.exists():
|
|
362
|
+
print(f"error: no such file or directory: {audio_path}", file=sys.stderr)
|
|
363
|
+
return 1
|
|
364
|
+
|
|
365
|
+
config = config_from_args(args)
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
if audio_path.is_dir():
|
|
369
|
+
return _run_batch(args, config, progress)
|
|
370
|
+
return _run_single(args, config, progress)
|
|
371
|
+
except KeyboardInterrupt:
|
|
372
|
+
print("\nInterrupted.", file=sys.stderr)
|
|
373
|
+
return 130
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__":
|
|
377
|
+
sys.exit(main())
|