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/__init__.py +3 -0
- subtitlemaker/__main__.py +5 -0
- subtitlemaker/_cuda.py +91 -0
- subtitlemaker/cli.py +771 -0
- subtitlemaker/config.py +158 -0
- subtitlemaker/formats/__init__.py +80 -0
- subtitlemaker/formats/_timestamp.py +18 -0
- subtitlemaker/formats/srt.py +27 -0
- subtitlemaker/formats/txt.py +10 -0
- subtitlemaker/formats/vtt.py +33 -0
- subtitlemaker/log.py +88 -0
- subtitlemaker/media.py +55 -0
- subtitlemaker/progress.py +97 -0
- subtitlemaker/segmenter.py +304 -0
- subtitlemaker/transcribe.py +253 -0
- subtitlemaker-0.1.0.dist-info/METADATA +207 -0
- subtitlemaker-0.1.0.dist-info/RECORD +20 -0
- subtitlemaker-0.1.0.dist-info/WHEEL +4 -0
- subtitlemaker-0.1.0.dist-info/entry_points.txt +2 -0
- subtitlemaker-0.1.0.dist-info/licenses/LICENSE +21 -0
subtitlemaker/config.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Configuration: defaults, config file loading and validation.
|
|
2
|
+
|
|
3
|
+
Precedence, lowest to highest: built-in defaults, config file, command line.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass, fields, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
if sys.version_info >= (3, 11):
|
|
12
|
+
import tomllib
|
|
13
|
+
else: # pragma: no cover
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
"""Raised for an invalid config file or setting."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class SubtitleStyle:
|
|
23
|
+
"""How subtitle cues are laid out and timed."""
|
|
24
|
+
|
|
25
|
+
max_line_length: int = 42 # Maximum characters per line
|
|
26
|
+
max_lines: int = 2 # Maximum number of lines
|
|
27
|
+
max_cps: float = 20.0 # Maximum characters per second
|
|
28
|
+
min_duration: float = 1.0 # Minimum subtitle duration (seconds)
|
|
29
|
+
max_duration: float = 7.0 # Maximum subtitle duration (seconds)
|
|
30
|
+
min_gap: float = 0.05 # Minimum gap between subtitles (seconds)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self):
|
|
33
|
+
if not isinstance(self.max_line_length, int) or self.max_line_length < 1:
|
|
34
|
+
raise ConfigError("max_line_length must be an integer of at least 1")
|
|
35
|
+
|
|
36
|
+
if self.max_lines not in (1, 2):
|
|
37
|
+
raise ConfigError("max_lines must be 1 or 2")
|
|
38
|
+
|
|
39
|
+
for name in ("max_cps", "min_duration", "max_duration"):
|
|
40
|
+
if getattr(self, name) <= 0:
|
|
41
|
+
raise ConfigError(f"{name} must be greater than 0")
|
|
42
|
+
|
|
43
|
+
if self.min_gap < 0:
|
|
44
|
+
raise ConfigError("min_gap must not be negative")
|
|
45
|
+
|
|
46
|
+
if self.min_duration > self.max_duration:
|
|
47
|
+
raise ConfigError("min_duration must not be greater than max_duration")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
DEVICES = ("auto", "cuda", "cpu")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ModelConfig:
|
|
55
|
+
"""
|
|
56
|
+
Which Whisper model to run, and how.
|
|
57
|
+
|
|
58
|
+
`name`, `device` and `compute_type` may be "auto", which is resolved
|
|
59
|
+
at run time from the hardware (see `transcribe.resolve_model_config`).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str = "auto" # model size, Hugging Face repo id, or local path
|
|
63
|
+
device: str = "auto" # auto, cuda or cpu
|
|
64
|
+
compute_type: str = "auto" # e.g. float16, int8, int8_float16
|
|
65
|
+
vad_filter: bool = True
|
|
66
|
+
condition_on_previous_text: bool = True
|
|
67
|
+
word_timestamps: bool = True # time cues from words, not whole segments
|
|
68
|
+
|
|
69
|
+
def __post_init__(self):
|
|
70
|
+
if self.device not in DEVICES:
|
|
71
|
+
raise ConfigError(f"device must be one of: {', '.join(DEVICES)}")
|
|
72
|
+
|
|
73
|
+
for name in ("name", "compute_type"):
|
|
74
|
+
if not getattr(self, name).strip():
|
|
75
|
+
raise ConfigError(f"{name} must not be empty")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Names accepted in each section of the config file.
|
|
79
|
+
_SECTIONS = {
|
|
80
|
+
"style": SubtitleStyle,
|
|
81
|
+
"model": ModelConfig,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def default_config_path():
|
|
86
|
+
"""Location of the user config file (respects XDG_CONFIG_HOME)."""
|
|
87
|
+
|
|
88
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
89
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
90
|
+
|
|
91
|
+
return root / "subtitlemaker" / "config.toml"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_config(path=None):
|
|
95
|
+
"""
|
|
96
|
+
Load the config file and return `(SubtitleStyle, ModelConfig)`.
|
|
97
|
+
|
|
98
|
+
With `path=None` the default location is used and a missing file is
|
|
99
|
+
fine. An explicit `path` that does not exist is an error.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
explicit = path is not None
|
|
103
|
+
path = Path(path) if explicit else default_config_path()
|
|
104
|
+
|
|
105
|
+
if not path.is_file():
|
|
106
|
+
if explicit:
|
|
107
|
+
raise ConfigError(f"config file not found: {path}")
|
|
108
|
+
|
|
109
|
+
return SubtitleStyle(), ModelConfig()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with open(path, "rb") as f:
|
|
113
|
+
data = tomllib.load(f)
|
|
114
|
+
|
|
115
|
+
except tomllib.TOMLDecodeError as e:
|
|
116
|
+
raise ConfigError(f"{path}: invalid TOML: {e}") from e
|
|
117
|
+
|
|
118
|
+
unknown_sections = sorted(set(data) - set(_SECTIONS))
|
|
119
|
+
|
|
120
|
+
if unknown_sections:
|
|
121
|
+
raise ConfigError(f"{path}: unknown section(s): {', '.join(unknown_sections)}")
|
|
122
|
+
|
|
123
|
+
built = {}
|
|
124
|
+
|
|
125
|
+
for section, cls in _SECTIONS.items():
|
|
126
|
+
values = data.get(section, {})
|
|
127
|
+
allowed = {f.name: f for f in fields(cls)}
|
|
128
|
+
unknown = sorted(set(values) - set(allowed))
|
|
129
|
+
|
|
130
|
+
if unknown:
|
|
131
|
+
raise ConfigError(
|
|
132
|
+
f"{path}: unknown key(s) in [{section}]: {', '.join(unknown)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
defaults = cls()
|
|
136
|
+
|
|
137
|
+
for key, value in values.items():
|
|
138
|
+
expected = type(getattr(defaults, key))
|
|
139
|
+
|
|
140
|
+
# TOML integers are fine where a float is expected.
|
|
141
|
+
if expected is float and isinstance(value, int) and not isinstance(value, bool):
|
|
142
|
+
values[key] = float(value)
|
|
143
|
+
|
|
144
|
+
elif not isinstance(value, expected) or (
|
|
145
|
+
expected is int and isinstance(value, bool)
|
|
146
|
+
):
|
|
147
|
+
raise ConfigError(
|
|
148
|
+
f"{path}: [{section}] {key} must be {expected.__name__}, "
|
|
149
|
+
f"got {type(value).__name__}"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
built[section] = replace(defaults, **values)
|
|
154
|
+
|
|
155
|
+
except ConfigError as e:
|
|
156
|
+
raise ConfigError(f"{path}: [{section}] {e}") from e
|
|
157
|
+
|
|
158
|
+
return built["style"], built["model"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Subtitle output formats.
|
|
2
|
+
|
|
3
|
+
A format is a name, a file extension and a function
|
|
4
|
+
`write(cues, path)`, where each cue is a dict with `start` and `end` in
|
|
5
|
+
seconds and `text` (lines separated by newlines).
|
|
6
|
+
|
|
7
|
+
To add a format: write a module like `vtt.py` and register it below.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .srt import write_srt
|
|
16
|
+
from .txt import write_txt
|
|
17
|
+
from .vtt import write_vtt
|
|
18
|
+
|
|
19
|
+
DEFAULT_FORMAT = "srt"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class OutputFormat:
|
|
24
|
+
name: str
|
|
25
|
+
extension: str
|
|
26
|
+
write: Callable
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
FORMATS = {
|
|
30
|
+
f.name: f
|
|
31
|
+
for f in (
|
|
32
|
+
OutputFormat("srt", "srt", write_srt),
|
|
33
|
+
OutputFormat("vtt", "vtt", write_vtt),
|
|
34
|
+
OutputFormat("txt", "txt", write_txt),
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_formats(value):
|
|
40
|
+
"""
|
|
41
|
+
Parse a comma or space separated list of format names.
|
|
42
|
+
|
|
43
|
+
Returns the names in order without duplicates; an empty value gives the
|
|
44
|
+
default format. Raises ValueError for unknown names.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
names = []
|
|
48
|
+
|
|
49
|
+
for name in re.split(r"[,\s]+", (value or "").strip().lower()):
|
|
50
|
+
|
|
51
|
+
if name and name not in names:
|
|
52
|
+
names.append(name)
|
|
53
|
+
|
|
54
|
+
unknown = [name for name in names if name not in FORMATS]
|
|
55
|
+
|
|
56
|
+
if unknown:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"unknown output format(s): {', '.join(unknown)} "
|
|
59
|
+
f"(choose from: {', '.join(FORMATS)})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return names or [DEFAULT_FORMAT]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def output_path(base, name):
|
|
66
|
+
"""
|
|
67
|
+
The file to write format `name` to, derived from `base`.
|
|
68
|
+
|
|
69
|
+
A known format extension on `base` is replaced; anything else (such as
|
|
70
|
+
the dots in `show.s01e01`) is kept and the extension is appended.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
base = Path(base)
|
|
74
|
+
known = {"." + f.extension for f in FORMATS.values()}
|
|
75
|
+
extension = "." + FORMATS[name].extension
|
|
76
|
+
|
|
77
|
+
if base.suffix.lower() in known:
|
|
78
|
+
return base.with_suffix(extension)
|
|
79
|
+
|
|
80
|
+
return base.with_name(base.name + extension)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Timestamp formatting shared by the subtitle formats."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def format_timestamp(seconds, decimal):
|
|
5
|
+
"""
|
|
6
|
+
Format seconds as `HH:MM:SS<decimal>mmm`.
|
|
7
|
+
|
|
8
|
+
SubRip uses a comma as the decimal separator and WebVTT a dot. Hours
|
|
9
|
+
are not capped at 23.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
milliseconds = int(round(seconds * 1000))
|
|
13
|
+
|
|
14
|
+
hours, milliseconds = divmod(milliseconds, 3_600_000)
|
|
15
|
+
minutes, milliseconds = divmod(milliseconds, 60_000)
|
|
16
|
+
secs, milliseconds = divmod(milliseconds, 1000)
|
|
17
|
+
|
|
18
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}{decimal}{milliseconds:03d}"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""SubRip (.srt) output."""
|
|
2
|
+
|
|
3
|
+
from ._timestamp import format_timestamp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_srt_time(seconds):
|
|
7
|
+
"""Convert seconds to an SRT timestamp (`HH:MM:SS,mmm`)."""
|
|
8
|
+
|
|
9
|
+
return format_timestamp(seconds, ",")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_srt(subtitles, path):
|
|
13
|
+
"""Write subtitle cues to `path` as SRT."""
|
|
14
|
+
|
|
15
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
16
|
+
|
|
17
|
+
for index, subtitle in enumerate(subtitles, start=1):
|
|
18
|
+
|
|
19
|
+
f.write(f"{index}\n")
|
|
20
|
+
|
|
21
|
+
f.write(
|
|
22
|
+
f"{format_srt_time(subtitle['start'])} "
|
|
23
|
+
f"--> "
|
|
24
|
+
f"{format_srt_time(subtitle['end'])}\n"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
f.write(f"{subtitle['text']}\n\n")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Plain text transcript output: one cue per line, no timing."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def write_txt(subtitles, path):
|
|
5
|
+
"""Write the text of each cue to `path`, one cue per line."""
|
|
6
|
+
|
|
7
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
8
|
+
|
|
9
|
+
for subtitle in subtitles:
|
|
10
|
+
f.write(subtitle["text"].replace("\n", " ") + "\n")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""WebVTT (.vtt) output."""
|
|
2
|
+
|
|
3
|
+
from ._timestamp import format_timestamp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_vtt_time(seconds):
|
|
7
|
+
"""Convert seconds to a WebVTT timestamp (`HH:MM:SS.mmm`)."""
|
|
8
|
+
|
|
9
|
+
return format_timestamp(seconds, ".")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _escape(text):
|
|
13
|
+
# In WebVTT cue text, & < > are markup. Escaping > also keeps a literal
|
|
14
|
+
# "-->" from being read as the start of a timing line.
|
|
15
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_vtt(subtitles, path):
|
|
19
|
+
"""Write subtitle cues to `path` as WebVTT."""
|
|
20
|
+
|
|
21
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
22
|
+
|
|
23
|
+
f.write("WEBVTT\n\n")
|
|
24
|
+
|
|
25
|
+
for subtitle in subtitles:
|
|
26
|
+
|
|
27
|
+
f.write(
|
|
28
|
+
f"{format_vtt_time(subtitle['start'])} "
|
|
29
|
+
f"--> "
|
|
30
|
+
f"{format_vtt_time(subtitle['end'])}\n"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
f.write(f"{_escape(subtitle['text'])}\n\n")
|
subtitlemaker/log.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Logging setup: messages and progress share one stderr console."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
LOGGER_NAME = "subtitlemaker"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _Formatter(logging.Formatter):
|
|
11
|
+
"""Plain messages, with a prefix for warnings and errors."""
|
|
12
|
+
|
|
13
|
+
def format(self, record):
|
|
14
|
+
message = record.getMessage()
|
|
15
|
+
|
|
16
|
+
# Other libraries often write their own "Warning:" prefix.
|
|
17
|
+
if message.lower().startswith(("warning:", "error:")):
|
|
18
|
+
return message
|
|
19
|
+
|
|
20
|
+
if record.levelno >= logging.ERROR:
|
|
21
|
+
return f"Error: {message}"
|
|
22
|
+
|
|
23
|
+
if record.levelno >= logging.WARNING:
|
|
24
|
+
return f"Warning: {message}"
|
|
25
|
+
|
|
26
|
+
return message
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _ConsoleHandler(logging.Handler):
|
|
30
|
+
"""
|
|
31
|
+
Print records through a `rich` console.
|
|
32
|
+
|
|
33
|
+
Going through the console keeps log lines and the live progress bar
|
|
34
|
+
from overwriting each other. Markup, highlighting and emoji codes are
|
|
35
|
+
off because file names may contain [brackets] or :colons:.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, console):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.console = console
|
|
41
|
+
|
|
42
|
+
def emit(self, record):
|
|
43
|
+
try:
|
|
44
|
+
self.console.print(
|
|
45
|
+
self.format(record),
|
|
46
|
+
markup=False,
|
|
47
|
+
highlight=False,
|
|
48
|
+
emoji=False,
|
|
49
|
+
soft_wrap=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
except Exception:
|
|
53
|
+
self.handleError(record)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def setup_logging(quiet=False, verbose=False):
|
|
57
|
+
"""
|
|
58
|
+
Configure logging and return the shared `rich` console.
|
|
59
|
+
|
|
60
|
+
Normal runs show INFO and above, `quiet` shows only warnings and
|
|
61
|
+
errors, `verbose` adds DEBUG (including faster-whisper's own log).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
console = Console(stderr=True)
|
|
65
|
+
|
|
66
|
+
handler = _ConsoleHandler(console)
|
|
67
|
+
|
|
68
|
+
handler.setFormatter(_Formatter())
|
|
69
|
+
|
|
70
|
+
root = logging.getLogger()
|
|
71
|
+
# Replace only our own handler from an earlier call, never other people's.
|
|
72
|
+
root.handlers[:] = [h for h in root.handlers if not isinstance(h, _ConsoleHandler)]
|
|
73
|
+
root.addHandler(handler)
|
|
74
|
+
root.setLevel(logging.WARNING)
|
|
75
|
+
|
|
76
|
+
if quiet:
|
|
77
|
+
level = logging.WARNING
|
|
78
|
+
|
|
79
|
+
elif verbose:
|
|
80
|
+
level = logging.DEBUG
|
|
81
|
+
logging.getLogger("faster_whisper").setLevel(logging.DEBUG)
|
|
82
|
+
|
|
83
|
+
else:
|
|
84
|
+
level = logging.INFO
|
|
85
|
+
|
|
86
|
+
logging.getLogger(LOGGER_NAME).setLevel(level)
|
|
87
|
+
|
|
88
|
+
return console
|
subtitlemaker/media.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Media file inspection."""
|
|
2
|
+
|
|
3
|
+
import av
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_duration(filename):
|
|
7
|
+
"""Get media duration using PyAV."""
|
|
8
|
+
|
|
9
|
+
container = av.open(filename)
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
|
|
13
|
+
if container.duration is not None:
|
|
14
|
+
return container.duration / av.time_base
|
|
15
|
+
|
|
16
|
+
# Fallback
|
|
17
|
+
for stream in container.streams:
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
stream.duration is not None
|
|
21
|
+
and stream.time_base is not None
|
|
22
|
+
):
|
|
23
|
+
return float(
|
|
24
|
+
stream.duration
|
|
25
|
+
* stream.time_base
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
return 0.0
|
|
29
|
+
|
|
30
|
+
finally:
|
|
31
|
+
container.close()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Containers and codecs people commonly want subtitles for. Anything else
|
|
35
|
+
# can still be given explicitly as a file; this only limits what is picked
|
|
36
|
+
# up when a folder is scanned.
|
|
37
|
+
MEDIA_EXTENSIONS = frozenset(
|
|
38
|
+
{
|
|
39
|
+
".mp4", ".mkv", ".mov", ".avi", ".webm", ".m4v", ".ts", ".flv", ".wmv",
|
|
40
|
+
".mpg", ".mpeg", ".3gp", ".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg",
|
|
41
|
+
".oga", ".opus", ".wma", ".mka",
|
|
42
|
+
}
|
|
43
|
+
) # fmt: skip
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_media(directory, recursive=False):
|
|
47
|
+
"""The media files in `directory`, sorted by path."""
|
|
48
|
+
|
|
49
|
+
candidates = directory.rglob("*") if recursive else directory.iterdir()
|
|
50
|
+
|
|
51
|
+
return sorted(
|
|
52
|
+
path
|
|
53
|
+
for path in candidates
|
|
54
|
+
if path.suffix.lower() in MEDIA_EXTENSIONS and path.is_file()
|
|
55
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Time formatting and the live transcription progress display."""
|
|
2
|
+
|
|
3
|
+
from rich.progress import BarColumn, Progress, ProgressColumn, TaskProgressColumn, TextColumn
|
|
4
|
+
from rich.text import Text
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_time(seconds):
|
|
8
|
+
"""Format seconds as HH:MM:SS."""
|
|
9
|
+
|
|
10
|
+
seconds = max(0, int(seconds))
|
|
11
|
+
|
|
12
|
+
hours = seconds // 3600
|
|
13
|
+
minutes = (seconds % 3600) // 60
|
|
14
|
+
secs = seconds % 60
|
|
15
|
+
|
|
16
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _PositionColumn(ProgressColumn):
|
|
20
|
+
"""Media position, e.g. `00:00:06 / 00:01:00`."""
|
|
21
|
+
|
|
22
|
+
def render(self, task):
|
|
23
|
+
return Text(f"{format_time(task.completed)} / {format_time(task.total)}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SpeedColumn(ProgressColumn):
|
|
27
|
+
"""Media seconds processed per real second, e.g. ` 7.4x`."""
|
|
28
|
+
|
|
29
|
+
def render(self, task):
|
|
30
|
+
elapsed = task.elapsed or 0
|
|
31
|
+
|
|
32
|
+
speed = task.completed / elapsed if elapsed > 0 else 0.0
|
|
33
|
+
|
|
34
|
+
return Text(f"{speed:4.1f}x")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _EtaColumn(ProgressColumn):
|
|
38
|
+
"""Estimated real time remaining, based on the average speed so far."""
|
|
39
|
+
|
|
40
|
+
def render(self, task):
|
|
41
|
+
elapsed = task.elapsed or 0
|
|
42
|
+
|
|
43
|
+
speed = task.completed / elapsed if elapsed > 0 else 0.0
|
|
44
|
+
|
|
45
|
+
if speed > 0 and task.total > task.completed:
|
|
46
|
+
eta = (task.total - task.completed) / speed
|
|
47
|
+
|
|
48
|
+
else:
|
|
49
|
+
eta = 0
|
|
50
|
+
|
|
51
|
+
return Text(f"ETA {format_time(eta)}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TranscriptionProgress:
|
|
55
|
+
"""
|
|
56
|
+
Context manager showing transcription progress.
|
|
57
|
+
|
|
58
|
+
with TranscriptionProgress(duration, console) as progress:
|
|
59
|
+
progress.update(seconds_processed)
|
|
60
|
+
|
|
61
|
+
With `enabled=False` (or an unknown duration) it does nothing.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, total, console, enabled=True):
|
|
65
|
+
self._total = float(total)
|
|
66
|
+
self._enabled = bool(enabled and total)
|
|
67
|
+
|
|
68
|
+
self._progress = Progress(
|
|
69
|
+
TextColumn("Transcribing"),
|
|
70
|
+
BarColumn(bar_width=30),
|
|
71
|
+
TaskProgressColumn(),
|
|
72
|
+
_PositionColumn(),
|
|
73
|
+
_SpeedColumn(),
|
|
74
|
+
_EtaColumn(),
|
|
75
|
+
console=console,
|
|
76
|
+
disable=not self._enabled,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
self._task = None
|
|
80
|
+
|
|
81
|
+
def __enter__(self):
|
|
82
|
+
self._progress.start()
|
|
83
|
+
self._task = self._progress.add_task("", total=self._total)
|
|
84
|
+
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, exc_type, exc, tb):
|
|
88
|
+
self._progress.stop()
|
|
89
|
+
|
|
90
|
+
def update(self, processed):
|
|
91
|
+
"""Report that `processed` seconds of media are done."""
|
|
92
|
+
|
|
93
|
+
if self._enabled:
|
|
94
|
+
self._progress.update(
|
|
95
|
+
self._task,
|
|
96
|
+
completed=min(float(processed), self._total),
|
|
97
|
+
)
|