audioexport 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.
- audioexport/__init__.py +44 -0
- audioexport/__main__.py +4 -0
- audioexport/_version.py +24 -0
- audioexport/api.py +36 -0
- audioexport/chapters.py +159 -0
- audioexport/cli.py +141 -0
- audioexport/errors.py +25 -0
- audioexport/fftools.py +278 -0
- audioexport/formats.py +83 -0
- audioexport/pipeline.py +728 -0
- audioexport/profile.py +212 -0
- audioexport/py.typed +0 -0
- audioexport-0.1.0.dist-info/METADATA +226 -0
- audioexport-0.1.0.dist-info/RECORD +18 -0
- audioexport-0.1.0.dist-info/WHEEL +5 -0
- audioexport-0.1.0.dist-info/entry_points.txt +2 -0
- audioexport-0.1.0.dist-info/licenses/LICENSE +201 -0
- audioexport-0.1.0.dist-info/top_level.txt +1 -0
audioexport/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Standalone audio export; no import of Readio or AudioCompose."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .chapters import Chapter, load_chapters
|
|
8
|
+
from .errors import (
|
|
9
|
+
AudioExportError,
|
|
10
|
+
EncodingError,
|
|
11
|
+
InvalidExportError,
|
|
12
|
+
ToolNotFoundError,
|
|
13
|
+
VerificationError,
|
|
14
|
+
)
|
|
15
|
+
from .fftools import doctor, probe
|
|
16
|
+
from .formats import FORMATS, normalize_bitrate
|
|
17
|
+
from .pipeline import ExportResult, encode, run_profile
|
|
18
|
+
from .profile import ExportProfile, OutputSpec, load_profile
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("audioexport")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
__version__ = "0+uninstalled"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"FORMATS",
|
|
27
|
+
"AudioExportError",
|
|
28
|
+
"Chapter",
|
|
29
|
+
"EncodingError",
|
|
30
|
+
"ExportProfile",
|
|
31
|
+
"ExportResult",
|
|
32
|
+
"InvalidExportError",
|
|
33
|
+
"OutputSpec",
|
|
34
|
+
"ToolNotFoundError",
|
|
35
|
+
"VerificationError",
|
|
36
|
+
"__version__",
|
|
37
|
+
"doctor",
|
|
38
|
+
"encode",
|
|
39
|
+
"load_chapters",
|
|
40
|
+
"load_profile",
|
|
41
|
+
"normalize_bitrate",
|
|
42
|
+
"probe",
|
|
43
|
+
"run_profile",
|
|
44
|
+
]
|
audioexport/__main__.py
ADDED
audioexport/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
audioexport/api.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Stable library API import surface (also available from `audioexport`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chapters import Chapter, load_chapters
|
|
6
|
+
from .errors import (
|
|
7
|
+
AudioExportError,
|
|
8
|
+
EncodingError,
|
|
9
|
+
InvalidExportError,
|
|
10
|
+
ToolNotFoundError,
|
|
11
|
+
VerificationError,
|
|
12
|
+
)
|
|
13
|
+
from .fftools import doctor, probe
|
|
14
|
+
from .formats import FORMATS, normalize_bitrate
|
|
15
|
+
from .pipeline import ExportResult, encode, run_profile
|
|
16
|
+
from .profile import ExportProfile, OutputSpec, load_profile
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"FORMATS",
|
|
20
|
+
"AudioExportError",
|
|
21
|
+
"Chapter",
|
|
22
|
+
"EncodingError",
|
|
23
|
+
"ExportProfile",
|
|
24
|
+
"ExportResult",
|
|
25
|
+
"InvalidExportError",
|
|
26
|
+
"OutputSpec",
|
|
27
|
+
"ToolNotFoundError",
|
|
28
|
+
"VerificationError",
|
|
29
|
+
"doctor",
|
|
30
|
+
"encode",
|
|
31
|
+
"load_chapters",
|
|
32
|
+
"load_profile",
|
|
33
|
+
"normalize_bitrate",
|
|
34
|
+
"probe",
|
|
35
|
+
"run_profile",
|
|
36
|
+
]
|
audioexport/chapters.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Portable chapter input plus optional Readio composition-timeline adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import InvalidExportError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Chapter:
|
|
17
|
+
title: str
|
|
18
|
+
start_ms: int
|
|
19
|
+
end_ms: int | None = None
|
|
20
|
+
start_sample: int | None = None
|
|
21
|
+
end_sample: int | None = None
|
|
22
|
+
sample_rate: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def escape_ffmetadata_value(value: str) -> str:
|
|
26
|
+
"""Ported from Readio: escape FFmetadata delimiters and newlines."""
|
|
27
|
+
value = value.replace("\\", "\\\\").replace("=", "\\=")
|
|
28
|
+
value = value.replace(";", "\\;").replace("#", "\\#")
|
|
29
|
+
return value.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _milliseconds(value: Any, field: str) -> int:
|
|
33
|
+
if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value):
|
|
34
|
+
raise InvalidExportError(f"invalid chapter {field}", code="audioexport.chapters_invalid")
|
|
35
|
+
# generic chapter schema uses integer millisecond offsets
|
|
36
|
+
if int(value) != value:
|
|
37
|
+
raise InvalidExportError(
|
|
38
|
+
f"chapter {field} must be whole milliseconds", code="audioexport.chapters_invalid"
|
|
39
|
+
)
|
|
40
|
+
return int(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_chapters(timeline: Path | str) -> tuple[Chapter, ...]:
|
|
44
|
+
"""Read JSON {chapters: [{title,start_ms,end_ms?}]} or Readio start_sample timeline."""
|
|
45
|
+
path = Path(timeline)
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
except (OSError, ValueError) as exc:
|
|
49
|
+
raise InvalidExportError(
|
|
50
|
+
f"invalid timeline {path}: {exc}", code="audioexport.timeline_invalid"
|
|
51
|
+
) from exc
|
|
52
|
+
if not isinstance(data, dict) or not isinstance(data.get("chapters"), list):
|
|
53
|
+
raise InvalidExportError(
|
|
54
|
+
"timeline must have chapters array", code="audioexport.timeline_invalid"
|
|
55
|
+
)
|
|
56
|
+
chapters = []
|
|
57
|
+
sample_rate = data.get("sample_rate")
|
|
58
|
+
for index, row in enumerate(data["chapters"], 1):
|
|
59
|
+
if not isinstance(row, dict):
|
|
60
|
+
raise InvalidExportError("invalid chapter row", code="audioexport.chapters_invalid")
|
|
61
|
+
title = row.get("title") or f"Chapter {index}"
|
|
62
|
+
if not isinstance(title, str) or not title.strip():
|
|
63
|
+
raise InvalidExportError("invalid chapter title", code="audioexport.chapters_invalid")
|
|
64
|
+
if "start_sample" in row:
|
|
65
|
+
if (
|
|
66
|
+
isinstance(sample_rate, bool)
|
|
67
|
+
or not isinstance(sample_rate, int)
|
|
68
|
+
or sample_rate <= 0
|
|
69
|
+
):
|
|
70
|
+
raise InvalidExportError(
|
|
71
|
+
"sample chapter requires sample_rate", code="audioexport.chapters_invalid"
|
|
72
|
+
)
|
|
73
|
+
sample = row["start_sample"]
|
|
74
|
+
if isinstance(sample, bool) or not isinstance(sample, int):
|
|
75
|
+
raise InvalidExportError(
|
|
76
|
+
"invalid start_sample", code="audioexport.chapters_invalid"
|
|
77
|
+
)
|
|
78
|
+
start_ms = round(sample * 1000 / sample_rate)
|
|
79
|
+
end_ms = None
|
|
80
|
+
chapters.append(Chapter(title.strip(), start_ms, end_ms, sample, None, sample_rate))
|
|
81
|
+
continue
|
|
82
|
+
else:
|
|
83
|
+
start_ms = _milliseconds(row.get("start_ms"), "start_ms")
|
|
84
|
+
end_ms = _milliseconds(row["end_ms"], "end_ms") if "end_ms" in row else None
|
|
85
|
+
chapters.append(Chapter(title.strip(), start_ms, end_ms))
|
|
86
|
+
return tuple(chapters)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_chapters(chapters: Sequence[Chapter], duration_ms: int) -> tuple[Chapter, ...]:
|
|
90
|
+
if not chapters:
|
|
91
|
+
return ()
|
|
92
|
+
if duration_ms <= 0:
|
|
93
|
+
raise InvalidExportError(
|
|
94
|
+
"audio duration must be positive", code="audioexport.chapters_invalid"
|
|
95
|
+
)
|
|
96
|
+
normalized = []
|
|
97
|
+
for index, chapter in enumerate(chapters):
|
|
98
|
+
next_start = chapters[index + 1].start_ms if index + 1 < len(chapters) else duration_ms
|
|
99
|
+
end_ms = chapter.end_ms if chapter.end_ms is not None else next_start
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(chapter.title, str)
|
|
102
|
+
or not chapter.title.strip()
|
|
103
|
+
or chapter.start_ms < 0
|
|
104
|
+
or chapter.start_ms >= duration_ms
|
|
105
|
+
or end_ms <= chapter.start_ms
|
|
106
|
+
or end_ms > duration_ms
|
|
107
|
+
or (index > 0 and chapter.start_ms <= chapters[index - 1].start_ms)
|
|
108
|
+
or (index + 1 < len(chapters) and end_ms > next_start)
|
|
109
|
+
):
|
|
110
|
+
raise InvalidExportError(
|
|
111
|
+
"chapters must be ordered, non-overlapping and within audio duration",
|
|
112
|
+
code="audioexport.chapters_invalid",
|
|
113
|
+
)
|
|
114
|
+
end_sample = None
|
|
115
|
+
if chapter.start_sample is not None:
|
|
116
|
+
if chapter.sample_rate is None or chapter.sample_rate <= 0:
|
|
117
|
+
raise InvalidExportError(
|
|
118
|
+
"sample chapter requires sample_rate", code="audioexport.chapters_invalid"
|
|
119
|
+
)
|
|
120
|
+
if index + 1 < len(chapters) and chapters[index + 1].start_sample is not None:
|
|
121
|
+
end_sample = chapters[index + 1].start_sample
|
|
122
|
+
else:
|
|
123
|
+
end_sample = round(end_ms * chapter.sample_rate / 1000)
|
|
124
|
+
normalized.append(
|
|
125
|
+
Chapter(
|
|
126
|
+
chapter.title,
|
|
127
|
+
chapter.start_ms,
|
|
128
|
+
end_ms,
|
|
129
|
+
chapter.start_sample,
|
|
130
|
+
end_sample,
|
|
131
|
+
chapter.sample_rate,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
return tuple(normalized)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def ffmetadata_text(chapters: Sequence[Chapter]) -> str:
|
|
138
|
+
lines = [";FFMETADATA1", ""]
|
|
139
|
+
for chapter in chapters:
|
|
140
|
+
assert chapter.end_ms is not None
|
|
141
|
+
if (
|
|
142
|
+
chapter.sample_rate
|
|
143
|
+
and chapter.start_sample is not None
|
|
144
|
+
and chapter.end_sample is not None
|
|
145
|
+
):
|
|
146
|
+
timebase, start, end = chapter.sample_rate, chapter.start_sample, chapter.end_sample
|
|
147
|
+
else:
|
|
148
|
+
timebase, start, end = 1000, chapter.start_ms, chapter.end_ms
|
|
149
|
+
lines.extend(
|
|
150
|
+
(
|
|
151
|
+
"[CHAPTER]",
|
|
152
|
+
f"TIMEBASE=1/{timebase}",
|
|
153
|
+
f"START={start}",
|
|
154
|
+
f"END={end}",
|
|
155
|
+
f"title={escape_ffmetadata_value(chapter.title)}",
|
|
156
|
+
"",
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return "\n".join(lines)
|
audioexport/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""CLI delegates to the same public library API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .errors import AudioExportError
|
|
14
|
+
from .fftools import doctor, probe
|
|
15
|
+
from .formats import FORMATS
|
|
16
|
+
from .pipeline import encode, run_profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="audioexport", description="Standalone FFmpeg audio encoder"
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"audioexport {__version__}")
|
|
24
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
encode_parser = sub.add_parser(
|
|
26
|
+
"encode", help="Encode one input to MP3/FLAC/M4A/M4B/OGG/Opus/WAV"
|
|
27
|
+
)
|
|
28
|
+
encode_parser.add_argument("input", type=Path)
|
|
29
|
+
encode_parser.add_argument("-o", "--output", type=Path)
|
|
30
|
+
encode_parser.add_argument("--format", choices=tuple(FORMATS))
|
|
31
|
+
encode_parser.add_argument("--bitrate", help="Bitrate e.g. 192k (lossy only)")
|
|
32
|
+
encode_parser.add_argument("--title")
|
|
33
|
+
encode_parser.add_argument("--artist")
|
|
34
|
+
encode_parser.add_argument("--album")
|
|
35
|
+
encode_parser.add_argument("--metadata", action="append", default=[], metavar="KEY=VALUE")
|
|
36
|
+
encode_parser.add_argument("--cover", type=Path)
|
|
37
|
+
encode_parser.add_argument(
|
|
38
|
+
"--timeline", type=Path, help="Generic JSON chapters or Readio timeline"
|
|
39
|
+
)
|
|
40
|
+
encode_parser.add_argument("--force", action="store_true")
|
|
41
|
+
encode_parser.add_argument("--json", action="store_true")
|
|
42
|
+
encode_parser.add_argument("--ffmpeg")
|
|
43
|
+
encode_parser.add_argument("--ffprobe")
|
|
44
|
+
|
|
45
|
+
run_parser = sub.add_parser("run", help="Encode several outputs from an export TOML profile")
|
|
46
|
+
run_parser.add_argument("input", type=Path)
|
|
47
|
+
run_parser.add_argument("--profile", type=Path, required=True)
|
|
48
|
+
run_parser.add_argument("--out-dir", type=Path, required=True)
|
|
49
|
+
run_parser.add_argument("--force", action="store_true")
|
|
50
|
+
run_parser.add_argument("--json", action="store_true")
|
|
51
|
+
run_parser.add_argument("--ffmpeg")
|
|
52
|
+
run_parser.add_argument("--ffprobe")
|
|
53
|
+
|
|
54
|
+
inspect = sub.add_parser("inspect", help="Inspect audio and audioexport sidecar")
|
|
55
|
+
inspect.add_argument("input", type=Path)
|
|
56
|
+
inspect.add_argument("--json", action="store_true")
|
|
57
|
+
inspect.add_argument("--ffprobe")
|
|
58
|
+
doctor_parser = sub.add_parser("doctor", help="Check FFmpeg tools and encoders")
|
|
59
|
+
doctor_parser.add_argument("--json", action="store_true")
|
|
60
|
+
doctor_parser.add_argument("--ffmpeg")
|
|
61
|
+
doctor_parser.add_argument("--ffprobe")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _print(payload: Any, *, as_json: bool) -> None:
|
|
66
|
+
if as_json:
|
|
67
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
|
|
68
|
+
else:
|
|
69
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
73
|
+
parser = _parser()
|
|
74
|
+
args = parser.parse_args(argv)
|
|
75
|
+
try:
|
|
76
|
+
if args.command == "doctor":
|
|
77
|
+
data = doctor(ffmpeg=args.ffmpeg, ffprobe=args.ffprobe)
|
|
78
|
+
_print(data, as_json=args.json)
|
|
79
|
+
return 0 if data["ready"] else 1
|
|
80
|
+
if args.command == "inspect":
|
|
81
|
+
data = probe(args.input, args.ffprobe)
|
|
82
|
+
manifest = args.input.with_name(args.input.name + ".audioexport.json")
|
|
83
|
+
if manifest.is_file():
|
|
84
|
+
try:
|
|
85
|
+
data["audioexport"] = json.loads(manifest.read_text(encoding="utf-8"))
|
|
86
|
+
except ValueError:
|
|
87
|
+
data["audioexport"] = {"invalid_manifest": True}
|
|
88
|
+
_print(data, as_json=args.json)
|
|
89
|
+
return 0
|
|
90
|
+
if args.command == "encode":
|
|
91
|
+
fmt = args.format or (args.output.suffix.lstrip(".").lower() if args.output else None)
|
|
92
|
+
if fmt is None:
|
|
93
|
+
parser.error("encode requires --format or --output with an audio extension")
|
|
94
|
+
output = args.output or args.input.with_suffix(FORMATS[fmt].extension)
|
|
95
|
+
metadata = {}
|
|
96
|
+
for item in args.metadata:
|
|
97
|
+
if "=" not in item:
|
|
98
|
+
parser.error("--metadata requires KEY=VALUE")
|
|
99
|
+
key, value = item.split("=", 1)
|
|
100
|
+
metadata[key] = value
|
|
101
|
+
for field in ("title", "artist", "album"):
|
|
102
|
+
value = getattr(args, field)
|
|
103
|
+
if value is not None:
|
|
104
|
+
metadata[field] = value
|
|
105
|
+
result = encode(
|
|
106
|
+
args.input,
|
|
107
|
+
output,
|
|
108
|
+
format=fmt,
|
|
109
|
+
bitrate=args.bitrate,
|
|
110
|
+
metadata=metadata,
|
|
111
|
+
cover=args.cover,
|
|
112
|
+
timeline=args.timeline,
|
|
113
|
+
force=args.force,
|
|
114
|
+
ffmpeg=args.ffmpeg,
|
|
115
|
+
ffprobe=args.ffprobe,
|
|
116
|
+
)
|
|
117
|
+
_print(result.to_dict(), as_json=args.json)
|
|
118
|
+
return 0
|
|
119
|
+
if args.command == "run":
|
|
120
|
+
results = run_profile(
|
|
121
|
+
args.input,
|
|
122
|
+
args.profile,
|
|
123
|
+
args.out_dir,
|
|
124
|
+
force=args.force,
|
|
125
|
+
ffmpeg=args.ffmpeg,
|
|
126
|
+
ffprobe=args.ffprobe,
|
|
127
|
+
)
|
|
128
|
+
_print([item.to_dict() for item in results], as_json=args.json)
|
|
129
|
+
return 0
|
|
130
|
+
except (AudioExportError, OSError, FileExistsError, ValueError, KeyError) as exc:
|
|
131
|
+
code = exc.code if isinstance(exc, AudioExportError) else "audioexport.error"
|
|
132
|
+
if getattr(args, "json", False):
|
|
133
|
+
_print({"error": {"code": code, "message": str(exc)}}, as_json=True)
|
|
134
|
+
else:
|
|
135
|
+
print(f"audioexport: {code}: {exc}", file=sys.stderr)
|
|
136
|
+
return 2
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
raise SystemExit(main())
|
audioexport/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Stable public exceptions suitable for API consumers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AudioExportError(Exception):
|
|
7
|
+
def __init__(self, message: str, *, code: str = "audioexport.error") -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidExportError(AudioExportError, ValueError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolNotFoundError(AudioExportError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EncodingError(AudioExportError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VerificationError(AudioExportError):
|
|
25
|
+
pass
|