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.
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
+ ]
@@ -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