subtitlemaker 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.
subtitlemaker/cli.py ADDED
@@ -0,0 +1,771 @@
1
+ """Command line interface."""
2
+
3
+ import argparse
4
+ import dataclasses
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import DEVICES, ConfigError, load_config
11
+ from .formats import DEFAULT_FORMAT, FORMATS, output_path, parse_formats
12
+ from .log import LOGGER_NAME, setup_logging
13
+ from .media import find_media, get_duration
14
+ from .progress import TranscriptionProgress, format_time
15
+ from .segmenter import build_subtitles
16
+
17
+ log = logging.getLogger(LOGGER_NAME)
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class _Options:
22
+ """Everything that stays the same for every file in a run."""
23
+
24
+ style: object
25
+ model_config: object
26
+ task: str
27
+ languages: list
28
+ language_hint: object
29
+ multilingual: bool
30
+ condition_on_previous_text: bool
31
+ detect_segments: int
32
+ detect_threshold: float
33
+ quiet: bool
34
+
35
+
36
+ def _expand_inputs(inputs, recursive):
37
+ """
38
+ Turn the command line inputs into a list of media files.
39
+
40
+ Folders contribute the media files in them (and in sub folders with
41
+ `recursive`). Returns `(files, missing)`; duplicates are dropped.
42
+ """
43
+
44
+ files = []
45
+ missing = []
46
+ seen = set()
47
+
48
+ for raw in inputs:
49
+
50
+ path = Path(raw)
51
+
52
+ if path.is_dir():
53
+ found = find_media(path, recursive)
54
+
55
+ if not found:
56
+ log.warning("no media files found in %s", path)
57
+
58
+ elif path.exists():
59
+ found = [path]
60
+
61
+ else:
62
+ missing.append(path)
63
+ continue
64
+
65
+ for file in found:
66
+
67
+ key = file.resolve()
68
+
69
+ if key not in seen:
70
+ seen.add(key)
71
+ files.append(file)
72
+
73
+ return files, missing
74
+
75
+
76
+ def _plan_outputs(input_file, args, formats):
77
+ """The `{format: path}` files to write for one input."""
78
+
79
+ if args.output:
80
+
81
+ base = Path(args.output)
82
+
83
+ # One format: the name is used exactly as given.
84
+ if len(formats) == 1:
85
+ return {formats[0]: base}
86
+
87
+ return {name: output_path(base, name) for name in formats}
88
+
89
+ folder = Path(args.output_dir) if args.output_dir else input_file.parent
90
+
91
+ return {
92
+ name: folder / f"{input_file.stem}.{FORMATS[name].extension}"
93
+ for name in formats
94
+ }
95
+
96
+
97
+ def _process_file(model, input_file, outputs, options, console):
98
+ """Transcribe one file and write its outputs. Returns True on success."""
99
+
100
+ if options.multilingual:
101
+
102
+ if options.languages:
103
+ language_display = (
104
+ f"{', '.join(options.languages)} "
105
+ f"(re-detected per segment)"
106
+ )
107
+
108
+ else:
109
+ language_display = "auto-detect per segment"
110
+
111
+ else:
112
+
113
+ language_display = (
114
+ options.languages[0] if options.languages else "auto-detect"
115
+ )
116
+
117
+ log.info("Input: %s", input_file)
118
+ log.info("Output: %s", ", ".join(str(p) for p in outputs.values()))
119
+ log.info("Language: %s", language_display)
120
+ log.info("Task: %s", options.task)
121
+ log.info(
122
+ "Prompt history: %s",
123
+ "on" if options.condition_on_previous_text else "off",
124
+ )
125
+
126
+ log.debug("Reading media information...")
127
+
128
+ try:
129
+ duration = get_duration(input_file)
130
+
131
+ except Exception as e:
132
+ log.warning("couldn't determine duration: %s", e)
133
+ duration = 0.0
134
+
135
+ if duration:
136
+ log.info("Duration: %s", format_time(duration))
137
+
138
+ log.info("Transcribing...")
139
+
140
+ transcription_start = time.monotonic()
141
+
142
+ segments, info = model.transcribe(
143
+ str(input_file),
144
+ language=options.language_hint,
145
+ task=options.task,
146
+ vad_filter=options.model_config.vad_filter,
147
+ word_timestamps=options.model_config.word_timestamps,
148
+ multilingual=options.multilingual,
149
+ condition_on_previous_text=options.condition_on_previous_text,
150
+ language_detection_segments=options.detect_segments,
151
+ language_detection_threshold=options.detect_threshold,
152
+ )
153
+
154
+ processed_segments = []
155
+
156
+ with TranscriptionProgress(
157
+ duration,
158
+ console,
159
+ enabled=not options.quiet,
160
+ ) as progress:
161
+
162
+ for segment in segments:
163
+
164
+ processed_segments.append(segment)
165
+
166
+ progress.update(float(segment.end))
167
+
168
+ # Final progress
169
+ progress.update(duration)
170
+
171
+ elapsed = time.monotonic() - transcription_start
172
+
173
+ log.info("Transcription complete in %s.", format_time(elapsed))
174
+
175
+ if duration and elapsed > 0:
176
+ log.info("Average speed: %.2fx realtime", duration / elapsed)
177
+
178
+ if options.multilingual:
179
+
180
+ log.info(
181
+ "Initial language: %s (%.1f%%)",
182
+ info.language,
183
+ info.language_probability * 100,
184
+ )
185
+
186
+ log.info(
187
+ "Languages were re-detected for every segment; "
188
+ "the value above is only the first guess."
189
+ )
190
+
191
+ else:
192
+
193
+ log.info(
194
+ "Detected language: %s (%.1f%%)",
195
+ info.language,
196
+ info.language_probability * 100,
197
+ )
198
+
199
+ log.info("Formatting subtitles...")
200
+
201
+ subtitles = build_subtitles(processed_segments, options.style)
202
+
203
+ log.info("Generated %d subtitle cues.", len(subtitles))
204
+
205
+ for name, path in outputs.items():
206
+
207
+ log.info("Writing: %s", path)
208
+
209
+ try:
210
+ FORMATS[name].write(subtitles, path)
211
+
212
+ except OSError as e:
213
+ log.error("couldn't write %s: %s", path, e)
214
+ return False
215
+
216
+ return True
217
+
218
+
219
+ def main():
220
+
221
+ # Imported here so --help and argument errors don't wait for the
222
+ # heavy machine learning libraries to load.
223
+ from .transcribe import (
224
+ ModelError,
225
+ load_model,
226
+ parse_language_argument,
227
+ resolve_model_config,
228
+ )
229
+
230
+ parser = argparse.ArgumentParser(
231
+ prog="subtitlemaker",
232
+ description=(
233
+ "Generate subtitles (SRT, WebVTT or plain text) "
234
+ "from audio and video files using Faster-Whisper."
235
+ )
236
+ )
237
+
238
+ parser.add_argument(
239
+ "--version",
240
+ action="version",
241
+ version=f"%(prog)s {__version__}",
242
+ )
243
+
244
+ parser.add_argument(
245
+ "inputs",
246
+ nargs="+",
247
+ metavar="INPUT",
248
+ help=(
249
+ "Video/audio files, or folders to scan for them "
250
+ "(see -r)"
251
+ ),
252
+ )
253
+
254
+ parser.add_argument(
255
+ "-f",
256
+ "--format",
257
+ metavar="FORMATS",
258
+ help=(
259
+ "Output format(s), comma separated: "
260
+ f"{', '.join(FORMATS)} (default: {DEFAULT_FORMAT}). "
261
+ "Each is written next to the input"
262
+ ),
263
+ )
264
+
265
+ parser.add_argument(
266
+ "-o",
267
+ "--output",
268
+ metavar="FILE",
269
+ help=(
270
+ "Output file, for a single input. With several "
271
+ "formats it is a base name and the extension is "
272
+ "replaced for each one"
273
+ ),
274
+ )
275
+
276
+ parser.add_argument(
277
+ "-d",
278
+ "--output-dir",
279
+ metavar="DIR",
280
+ help="Write outputs here instead of next to each input",
281
+ )
282
+
283
+ parser.add_argument(
284
+ "-r",
285
+ "--recursive",
286
+ action="store_true",
287
+ help="Also look in sub folders of folders given as input",
288
+ )
289
+
290
+ parser.add_argument(
291
+ "--skip-existing",
292
+ action="store_true",
293
+ help=(
294
+ "Skip inputs whose output files all exist already "
295
+ "(by default they are overwritten)"
296
+ ),
297
+ )
298
+
299
+ parser.add_argument(
300
+ "-l",
301
+ "--language",
302
+ help=(
303
+ "Source language. Several "
304
+ "comma-separated codes are "
305
+ "allowed for mixed-language "
306
+ "audio (e.g. en, sv, he,en)"
307
+ ),
308
+ )
309
+
310
+ parser.add_argument(
311
+ "-m",
312
+ "--multilingual",
313
+ action="store_true",
314
+ help=(
315
+ "Audio contains more than one "
316
+ "language: re-detect the language "
317
+ "before every segment instead of "
318
+ "using one language for the whole "
319
+ "file"
320
+ ),
321
+ )
322
+
323
+ parser.add_argument(
324
+ "-t",
325
+ "--task",
326
+ choices=[
327
+ "transcribe",
328
+ "translate",
329
+ ],
330
+ default="transcribe",
331
+ help=(
332
+ "transcribe = subtitles in the spoken language; "
333
+ "translate = translate to English (default: transcribe)"
334
+ ),
335
+ )
336
+
337
+ parser.add_argument(
338
+ "--detect-segments",
339
+ type=int,
340
+ default=1,
341
+ metavar="N",
342
+ help=(
343
+ "Segments sampled for the initial "
344
+ "language detection (default: 1)"
345
+ ),
346
+ )
347
+
348
+ parser.add_argument(
349
+ "--detect-threshold",
350
+ type=float,
351
+ default=0.5,
352
+ metavar="P",
353
+ help=(
354
+ "Confidence needed to accept the "
355
+ "initial language detection "
356
+ "(default: 0.5)"
357
+ ),
358
+ )
359
+
360
+ parser.add_argument(
361
+ "--condition-on-previous-text",
362
+ action=argparse.BooleanOptionalAction,
363
+ default=None,
364
+ help=(
365
+ "Use previous text as a prompt for "
366
+ "the next segment "
367
+ "(default: off for multilingual, "
368
+ "on otherwise)"
369
+ ),
370
+ )
371
+
372
+ parser.add_argument(
373
+ "--config",
374
+ metavar="FILE",
375
+ help=(
376
+ "TOML config file (default: "
377
+ "~/.config/subtitlemaker/config.toml "
378
+ "if it exists)"
379
+ ),
380
+ )
381
+
382
+ verbosity = parser.add_mutually_exclusive_group()
383
+
384
+ verbosity.add_argument(
385
+ "-q",
386
+ "--quiet",
387
+ action="store_true",
388
+ help="Only show warnings and errors (no progress bar)",
389
+ )
390
+
391
+ verbosity.add_argument(
392
+ "-v",
393
+ "--verbose",
394
+ action="store_true",
395
+ help="Show debug output",
396
+ )
397
+
398
+ model_group = parser.add_argument_group(
399
+ "model",
400
+ "Override the defaults and any config file values",
401
+ )
402
+
403
+ model_group.add_argument(
404
+ "--model",
405
+ metavar="NAME",
406
+ help=(
407
+ "Whisper model: tiny, base, small, medium, large-v3, "
408
+ "turbo, ..., a Hugging Face repo id, or a local path "
409
+ "(default: large-v3 on GPU, small on CPU). Models are "
410
+ "downloaded on first use."
411
+ ),
412
+ )
413
+
414
+ model_group.add_argument(
415
+ "--device",
416
+ choices=DEVICES,
417
+ help=(
418
+ "Where to run: auto uses the GPU if one is found and "
419
+ "falls back to the CPU (default: auto)"
420
+ ),
421
+ )
422
+
423
+ model_group.add_argument(
424
+ "--compute-type",
425
+ metavar="TYPE",
426
+ help=(
427
+ "Numeric precision, e.g. float16, int8_float16, int8, "
428
+ "float32 (default: float16 on GPU, int8 on CPU)"
429
+ ),
430
+ )
431
+
432
+ model_group.add_argument(
433
+ "--word-timestamps",
434
+ action=argparse.BooleanOptionalAction,
435
+ default=None,
436
+ help=(
437
+ "Time cues from individual words, which splits long "
438
+ "sentences accurately (default: on; --no-word-timestamps "
439
+ "is faster but less precise)"
440
+ ),
441
+ )
442
+
443
+ style_group = parser.add_argument_group(
444
+ "subtitle style",
445
+ "Override the defaults and any config file values",
446
+ )
447
+
448
+ style_group.add_argument(
449
+ "--max-line-length",
450
+ type=int,
451
+ metavar="N",
452
+ help="Maximum characters per line (default: 42)",
453
+ )
454
+
455
+ style_group.add_argument(
456
+ "--max-lines",
457
+ type=int,
458
+ metavar="N",
459
+ help="Maximum lines per subtitle (default: 2)",
460
+ )
461
+
462
+ style_group.add_argument(
463
+ "--max-cps",
464
+ type=float,
465
+ metavar="N",
466
+ help="Maximum characters per second (default: 20)",
467
+ )
468
+
469
+ style_group.add_argument(
470
+ "--min-duration",
471
+ type=float,
472
+ metavar="SEC",
473
+ help="Minimum subtitle duration (default: 1.0)",
474
+ )
475
+
476
+ style_group.add_argument(
477
+ "--max-duration",
478
+ type=float,
479
+ metavar="SEC",
480
+ help="Maximum subtitle duration (default: 7.0)",
481
+ )
482
+
483
+ style_group.add_argument(
484
+ "--min-gap",
485
+ type=float,
486
+ metavar="SEC",
487
+ help="Minimum gap between subtitles (default: 0.05)",
488
+ )
489
+
490
+ args = parser.parse_args()
491
+
492
+ console = setup_logging(
493
+ quiet=args.quiet,
494
+ verbose=args.verbose,
495
+ )
496
+
497
+ # --------------------------------------------------------
498
+ # Settings: defaults < config file < command line
499
+ # --------------------------------------------------------
500
+
501
+ try:
502
+ style, model_config = load_config(args.config)
503
+
504
+ overrides = {
505
+ field.name: getattr(args, field.name)
506
+ for field in dataclasses.fields(style)
507
+ if getattr(args, field.name) is not None
508
+ }
509
+
510
+ style = dataclasses.replace(style, **overrides)
511
+
512
+ # Command line option -> ModelConfig field
513
+ model_overrides = {
514
+ field: getattr(args, option)
515
+ for option, field in (
516
+ ("model", "name"),
517
+ ("device", "device"),
518
+ ("compute_type", "compute_type"),
519
+ ("word_timestamps", "word_timestamps"),
520
+ )
521
+ if getattr(args, option) is not None
522
+ }
523
+
524
+ model_config = dataclasses.replace(model_config, **model_overrides)
525
+
526
+ except ConfigError as e:
527
+ parser.error(str(e))
528
+
529
+ # --------------------------------------------------------
530
+ # Output options
531
+ # --------------------------------------------------------
532
+
533
+ try:
534
+ formats = parse_formats(args.format)
535
+
536
+ except ValueError as e:
537
+ parser.error(str(e))
538
+
539
+ if args.output and args.output_dir:
540
+ parser.error("-o/--output and -d/--output-dir cannot be combined")
541
+
542
+ # --------------------------------------------------------
543
+ # Language options
544
+ # --------------------------------------------------------
545
+
546
+ try:
547
+ languages, listed_multilingual = (
548
+ parse_language_argument(
549
+ args.language
550
+ )
551
+ )
552
+
553
+ except ValueError as e:
554
+ parser.error(str(e))
555
+
556
+ if args.detect_segments < 1:
557
+ parser.error(
558
+ "--detect-segments must be at least 1"
559
+ )
560
+
561
+ if not 0.0 < args.detect_threshold <= 1.0:
562
+ parser.error(
563
+ "--detect-threshold must be "
564
+ "greater than 0 and at most 1"
565
+ )
566
+
567
+ # More than one language code, or -m/--multilingual,
568
+ # means the language has to be decided again for every
569
+ # segment instead of once for the whole file.
570
+ multilingual = (
571
+ args.multilingual or listed_multilingual
572
+ )
573
+
574
+ # Whisper holds a single language token per decode, so a
575
+ # hint is only used when the language is fixed for the
576
+ # whole file. Leaving it unset also keeps the initial
577
+ # detection available for the report.
578
+ language_hint = (
579
+ None
580
+ if multilingual
581
+ else (languages[0] if languages else None)
582
+ )
583
+
584
+ if args.condition_on_previous_text is not None:
585
+
586
+ condition_on_previous_text = (
587
+ args.condition_on_previous_text
588
+ )
589
+
590
+ else:
591
+
592
+ # Text from the previous segment pulls the next one
593
+ # back towards the language it was written in, which
594
+ # fights per-segment language detection.
595
+ condition_on_previous_text = (
596
+ model_config.condition_on_previous_text
597
+ and not multilingual
598
+ )
599
+
600
+ # --------------------------------------------------------
601
+ # Inputs and where their outputs go
602
+ # --------------------------------------------------------
603
+
604
+ files, missing = _expand_inputs(args.inputs, args.recursive)
605
+
606
+ for path in missing:
607
+ log.error("input file does not exist: %s", path)
608
+
609
+ if not files:
610
+
611
+ if not missing:
612
+ log.error("no media files to process")
613
+
614
+ return 1
615
+
616
+ if args.output and len(files) > 1:
617
+ parser.error(
618
+ "-o/--output can only be used with a single input file; "
619
+ "use -d/--output-dir for several"
620
+ )
621
+
622
+ plan = {file: _plan_outputs(file, args, formats) for file in files}
623
+
624
+ owners = {}
625
+
626
+ for file, outputs in plan.items():
627
+
628
+ for path in outputs.values():
629
+
630
+ if path in owners:
631
+ parser.error(
632
+ f"{owners[path]} and {file} would both write {path}; "
633
+ f"use separate output folders"
634
+ )
635
+
636
+ owners[path] = file
637
+
638
+ failed = len(missing)
639
+ skipped = 0
640
+ todo = []
641
+
642
+ for file in files:
643
+
644
+ if args.skip_existing and all(p.exists() for p in plan[file].values()):
645
+ log.info("Skipping %s (output exists)", file)
646
+ skipped += 1
647
+
648
+ else:
649
+ todo.append(file)
650
+
651
+ # --------------------------------------------------------
652
+ # Model: resolved and loaded once, only if there is work
653
+ # --------------------------------------------------------
654
+
655
+ done = 0
656
+
657
+ if todo:
658
+
659
+ if args.output_dir:
660
+
661
+ try:
662
+ Path(args.output_dir).mkdir(parents=True, exist_ok=True)
663
+
664
+ except OSError as e:
665
+ log.error("couldn't create output folder %s: %s", args.output_dir, e)
666
+ return 1
667
+
668
+ # A GPU that turns out to be unusable only falls back to the CPU when
669
+ # the device was left on auto.
670
+ allow_cpu_fallback = model_config.device == "auto"
671
+
672
+ try:
673
+ model_config = resolve_model_config(model_config)
674
+
675
+ except ModelError as e:
676
+ log.error("%s", e)
677
+ return 1
678
+
679
+ log.info("Model: %s", model_config.name)
680
+ log.info("Device: %s", model_config.device)
681
+ log.info("Compute type: %s", model_config.compute_type)
682
+
683
+ if multilingual and args.task == "translate":
684
+ log.warning(
685
+ "task=translate turns every segment into English."
686
+ )
687
+
688
+ log.debug("Subtitle configuration:")
689
+ log.debug(" Max line length: %s", style.max_line_length)
690
+ log.debug(" Max lines: %s", style.max_lines)
691
+ log.debug(" Max CPS: %g", style.max_cps)
692
+ log.debug(" Min duration: %ss", style.min_duration)
693
+ log.debug(" Max duration: %ss", style.max_duration)
694
+ log.debug(" Minimum gap: %ss", style.min_gap)
695
+
696
+ log.info("Loading Whisper model...")
697
+
698
+ try:
699
+ model, model_config = load_model(
700
+ model_config,
701
+ allow_cpu_fallback=allow_cpu_fallback,
702
+ )
703
+
704
+ except ModelError as e:
705
+ log.error("%s", e)
706
+ return 1
707
+
708
+ options = _Options(
709
+ style=style,
710
+ model_config=model_config,
711
+ task=args.task,
712
+ languages=languages,
713
+ language_hint=language_hint,
714
+ multilingual=multilingual,
715
+ condition_on_previous_text=condition_on_previous_text,
716
+ detect_segments=args.detect_segments,
717
+ detect_threshold=args.detect_threshold,
718
+ quiet=args.quiet,
719
+ )
720
+
721
+ for index, file in enumerate(todo, start=1):
722
+
723
+ if len(todo) > 1:
724
+ log.info("[%d/%d] %s", index, len(todo), file.name)
725
+
726
+ try:
727
+ ok = _process_file(model, file, plan[file], options, console)
728
+
729
+ except Exception as e:
730
+ # One bad file must not stop the rest of a batch.
731
+ log.error("failed to process %s: %s", file, e)
732
+ log.debug("details:", exc_info=True)
733
+ ok = False
734
+
735
+ if ok:
736
+ done += 1
737
+
738
+ else:
739
+ failed += 1
740
+
741
+ # --------------------------------------------------------
742
+ # Summary
743
+ # --------------------------------------------------------
744
+
745
+ if failed:
746
+
747
+ log.warning(
748
+ "%d done, %d skipped, %d failed",
749
+ done,
750
+ skipped,
751
+ failed,
752
+ )
753
+
754
+ return 1
755
+
756
+ if len(files) == 1 and done == 1:
757
+
758
+ log.info("Done! 🎬")
759
+ log.info(
760
+ "Subtitle file: %s",
761
+ ", ".join(str(p) for p in plan[files[0]].values()),
762
+ )
763
+
764
+ else:
765
+ log.info("Done! 🎬 %d done, %d skipped", done, skipped)
766
+
767
+ return 0
768
+
769
+
770
+ if __name__ == "__main__":
771
+ raise SystemExit(main())