audioexport 0.1.2__tar.gz → 0.1.4__tar.gz
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-0.1.2 → audioexport-0.1.4}/PKG-INFO +35 -25
- {audioexport-0.1.2 → audioexport-0.1.4}/README.md +34 -24
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/__init__.py +16 -1
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/_version.py +3 -3
- audioexport-0.1.4/audioexport/_workspace.py +215 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/api.py +15 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/book.py +47 -198
- audioexport-0.1.4/audioexport/bundle.py +166 -0
- audioexport-0.1.4/audioexport/bundle_contract.py +100 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/chapters.py +3 -3
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/cli.py +125 -126
- audioexport-0.1.4/audioexport/document.py +449 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/formats.py +2 -2
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport.egg-info/SOURCES.txt +5 -2
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/changelog.md +19 -2
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/index.md +25 -34
- audioexport-0.1.2/examples/create_ssmdbook_demo.py → audioexport-0.1.4/examples/create_ssmd_bundle_demo.py +5 -3
- audioexport-0.1.2/docs/readio-migration.md +0 -87
- {audioexport-0.1.2 → audioexport-0.1.4}/LICENSE +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/MANIFEST.in +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/__main__.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/audiobook.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/audiobook_profile.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/errors.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/fftools.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/inputs.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/metadata.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/pipeline.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/preflight.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/profile.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/py.typed +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/validation.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/Makefile +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/conf.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/make.bat +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/make.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/docs/requirements.txt +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/examples/audiobook.toml +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/examples/chapters.json +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/examples/chapters.txt +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/examples/create_demo_wav.py +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/examples/export.toml +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/pyproject.toml +0 -0
- {audioexport-0.1.2 → audioexport-0.1.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: audioexport
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Standalone FFmpeg audio encoder with metadata, chapter, cover and reproducible export manifests
|
|
5
5
|
Author: audioexport contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -33,7 +33,7 @@ Dynamic: license-file
|
|
|
33
33
|
|
|
34
34
|
# audioexport
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
AudioExport is a standalone Python library and CLI for verified FFmpeg audio export.
|
|
37
37
|
|
|
38
38
|
Supported formats: WAV (PCM16), FLAC, MP3, M4A (AAC), M4B (AAC audiobook), OGG (Vorbis), and Opus. Handles tagged metadata, embedded JPEG/PNG cover art for MP3/M4A/M4B, JSON chapter timelines for M4A/M4B, standalone ordered multi-track audiobook assembly, FFprobe verification, artifact identities and safe reuse.
|
|
39
39
|
|
|
@@ -57,7 +57,7 @@ audioexport doctor --json
|
|
|
57
57
|
|
|
58
58
|
`doctor` reports package/tool versions and encoder availability per format. Its existing `ready` key means FFmpeg and FFprobe are present; `all_formats_ready` and each `formats` entry report encoder capability and a reason when unavailable. The additive `features` entries report `m4b_encode`, `m4b_audiobook`, and `m4b_stream_copy` availability; missing optional capabilities do not make `ready` false. `run_profile()` checks the encoders selected by the profile before writing outputs.
|
|
59
59
|
|
|
60
|
-
Base Python dependencies are `langcodes>=3.3` on all supported versions and `tomli>=2` on Python 3.10. FFmpeg/FFprobe are external executables; AudioExport
|
|
60
|
+
Base Python dependencies are `langcodes>=3.3` on all supported versions and `tomli>=2` on Python 3.10. FFmpeg/FFprobe are external executables; AudioExport has no runtime dependency on synthesis, planning, or composition packages.
|
|
61
61
|
|
|
62
62
|
## CLI quickstart
|
|
63
63
|
|
|
@@ -96,7 +96,7 @@ audioexport audiobook book-tracks/ -o book.m4b \
|
|
|
96
96
|
audioexport audiobook 01-intro.mp3 02-chapter-one.flac 03-chapter-two.wav \
|
|
97
97
|
-o book.m4b --bitrate 96k --language eng
|
|
98
98
|
|
|
99
|
-
# Explicit chapter starts from chapters.txt or
|
|
99
|
+
# Explicit chapter starts from chapters.txt or a generic JSON timeline.
|
|
100
100
|
audioexport audiobook book-tracks/ -o book.m4b --chapters-file chapters.txt
|
|
101
101
|
```
|
|
102
102
|
|
|
@@ -147,29 +147,43 @@ mode = "explicit" # tracks | explicit | none
|
|
|
147
147
|
# title_source = "tag" # tag (falls back to filename) | filename
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
Run it with `audioexport audiobook --profile audiobook.toml`. CLI `--output`, metadata, audio settings, cover, and `--chapters-file` override profile values; the TOML input order remains authoritative. `mode = "tracks"` (the default) creates one chapter per track; `mode = "explicit"` requires `chapters_file`; `mode = "none"` disables chapters. Existing `encode(..., format="m4b", timeline=...)` remains the right choice for
|
|
150
|
+
Run it with `audioexport audiobook --profile audiobook.toml`. CLI `--output`, metadata, audio settings, cover, and `--chapters-file` override profile values; the TOML input order remains authoritative. `mode = "tracks"` (the default) creates one chapter per track; `mode = "explicit"` requires `chapters_file`; `mode = "none"` disables chapters. Existing `encode(..., format="m4b", timeline=...)` remains the right choice for one prepared source with caller-supplied chapters.
|
|
151
151
|
|
|
152
|
-
##
|
|
152
|
+
## SSMD bundle export
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
Book and single-document workspaces share one canonical editable directory root, `<name>.ssmd-bundle/`. The manifest selects the export path: `format = "ssmdconvert.book"` uses verified audiobook assembly, while `format = "ssmdconvert.document"` uses verified one-document encoding; both manifest schemas remain at version 1. AudioExport accepts only the bundle root, not an internal chapter directory, the document source file, or a ZIP archive.
|
|
155
|
+
|
|
156
|
+
Use the same command for either kind:
|
|
155
157
|
|
|
156
158
|
```bash
|
|
157
|
-
audiocompose compose
|
|
158
|
-
audioexport export
|
|
159
|
+
audiocompose bundle compose "My Book.ssmd-bundle" --variant "$VARIANT"
|
|
160
|
+
audioexport bundle export "My Book.ssmd-bundle" --variant "$VARIANT" \
|
|
161
|
+
--chapters 5,7,11-13 --audiocompose-root /srv/audio-stages/audiocompose \
|
|
162
|
+
--output-root /srv/audio-exports --title "My Book" --author "An Author" \
|
|
163
|
+
--language en-US
|
|
164
|
+
|
|
165
|
+
audioexport bundle export story.ssmd-bundle --variant "$VARIANT" \
|
|
166
|
+
--output story.m4a --title "Story" --artist "An Author"
|
|
159
167
|
```
|
|
160
168
|
|
|
161
|
-
|
|
169
|
+
AudioExport verifies the selected AudioCompose report, output paths, WAV checksums, and audio geometry before exporting. For a book, `--chapters` selects manifest chapters (`all` by default), output defaults to M4B, `--format` accepts only `m4b`, and audiobook profiles remain supported. For a document, only `--chapters all` is valid; `--format` or the output filename extension selects the final format, and `--timeline` supplies an optional generic chapter timeline. `--author` and `--artist` are aliases for the appropriate author/artist metadata field. Kind-specific options that have no meaning for the selected manifest are rejected rather than ignored.
|
|
162
170
|
|
|
163
|
-
|
|
171
|
+
Default output names strip only `.ssmd-bundle`: books use `audioexport/<variant>/<name>.m4b`, while documents use `audioexport/<variant>/<name>.<format>`. `--output-root` relocates these defaults; `--output` selects an explicit destination. Book metadata precedence is manifest, then audiobook profile, then explicit CLI/API values. BCP 47 language tags such as `en-US` are normalized to ISO 639-2/T track codes such as `eng` for M4A/M4B.
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
The JSON result uses one versioned envelope with `format: "audioexport-bundle-result"`, `version: 1`, `kind`, and a kind-specific `result` object. Human output begins with `AudioExport bundle export` and reports the selected kind, workspace, variant, and output.
|
|
166
174
|
|
|
167
|
-
|
|
175
|
+
Python callers use the same manifest-dispatched facade:
|
|
168
176
|
|
|
169
|
-
|
|
177
|
+
```python
|
|
178
|
+
from audioexport import export_bundle
|
|
170
179
|
|
|
171
|
-
|
|
180
|
+
book_result = export_bundle("My Book.ssmd-bundle", variant=variant, chapters="5,7")
|
|
181
|
+
document_result = export_bundle("story.ssmd-bundle", variant=variant, format="mp3")
|
|
182
|
+
print(book_result.kind, book_result.output)
|
|
183
|
+
print(document_result.kind, document_result.output)
|
|
184
|
+
```
|
|
172
185
|
|
|
186
|
+
`BundleExportResult.value` contains the specialized book or document result; `BundleExportResult.output` gives the final artifact path. The facade is available from both `audioexport` and `audioexport.api`.
|
|
173
187
|
OGG defaults to Vorbis quality-based encoding (`-qscale:a 4`) to avoid low-rate/mono bitrate incompatibilities. `--bitrate` accepts values such as `192k`, `192000`, or `1M`; whitespace is trimmed. A bare integer is interpreted as **bits per second**, so use `192k` (or `192000`), not `192`, for a nominal 192 kb/s rate. WAV/FLAC don't accept bitrate. WAV output is always PCM16 conversion, not a byte-for-byte passthrough; lossy codecs and even PCM output can vary with FFmpeg versions, which are included in the export identity.
|
|
174
188
|
|
|
175
189
|
## Python API
|
|
@@ -203,7 +217,7 @@ print(probe("exported/book.m4b")["chapters"])
|
|
|
203
217
|
|
|
204
218
|
You may import the same public functions from `audioexport.api` as well as from `audioexport`.
|
|
205
219
|
|
|
206
|
-
The public errors are `AudioExportError` and subclasses `InvalidExportError`, `ToolNotFoundError`, `EncodingError`, and `VerificationError`, with a stable machine-readable `.code` value. `encode()` returns an immutable `ExportResult`; `build_audiobook()` returns an `AudiobookResult`; `
|
|
220
|
+
The public errors are `AudioExportError` and subclasses `InvalidExportError`, `ToolNotFoundError`, `EncodingError`, and `VerificationError`, with a stable machine-readable `.code` value. `encode()` returns an immutable `ExportResult`; `build_audiobook()` returns an `AudiobookResult`; `export_bundle()` provides one manifest-dispatched entry point for verified book and document bundles and returns `BundleExportResult`. The facade and result types are available from both `audioexport` and `audioexport.api`.
|
|
207
221
|
|
|
208
222
|
## Export profile
|
|
209
223
|
|
|
@@ -279,7 +293,7 @@ resolved_outputs = preflight_profile(profile, source)
|
|
|
279
293
|
|
|
280
294
|
for resolved in resolved_outputs:
|
|
281
295
|
target = Path("exports") / resolved.filename
|
|
282
|
-
#
|
|
296
|
+
# Applications should perform their own target and overwrite checks here.
|
|
283
297
|
result = encode(
|
|
284
298
|
source,
|
|
285
299
|
target,
|
|
@@ -291,7 +305,7 @@ for resolved in resolved_outputs:
|
|
|
291
305
|
)
|
|
292
306
|
```
|
|
293
307
|
|
|
294
|
-
A consumer may instead call `resolve_output()` for a single selected spec; use full-profile preflight when the operation requires every configured output validated. Preflight is not an ownership authorization:
|
|
308
|
+
A consumer may instead call `resolve_output()` for a single selected spec; use full-profile preflight when the operation requires every configured output validated. Preflight is not an ownership authorization: applications remain responsible for their own locks, target selection, overwrite authorization, and state updates. Call `encode()` only after those consumer-owned checks pass. `ResolvedOutput`, `resolve_output`, and `preflight_profile` are available from both `audioexport` and `audioexport.api`.
|
|
295
309
|
|
|
296
310
|
## Chapters/timeline
|
|
297
311
|
|
|
@@ -308,7 +322,7 @@ Use an independent JSON file such as [`examples/chapters.json`](examples/chapter
|
|
|
308
322
|
|
|
309
323
|
`end_ms` is optional; without it, chapter ends are the next chapter start or the audio duration. Chapter offsets must be strictly increasing and within the audio file. FFmpeg metadata escaping is handled internally. Embedded chapter markers are currently supported for **M4A and M4B**.
|
|
310
324
|
|
|
311
|
-
|
|
325
|
+
Exact-sample timelines may include a top-level `sample_rate` and chapter `start_sample` values. FFmetadata preserves their original sample timebase and coordinates using `TIMEBASE=1/<sample_rate>`.
|
|
312
326
|
|
|
313
327
|
## Integrity and versioning
|
|
314
328
|
|
|
@@ -320,10 +334,6 @@ Version is dynamic through `setuptools-scm`: `pyproject.toml` has no static `ver
|
|
|
320
334
|
|
|
321
335
|
For a release, first commit the code and wait for the complete Linux/Windows/macOS, Python 3.10–3.14 CI matrix, including its Sphinx warning-as-error documentation build. Then create and push the intended version tag (for example `v0.1.2`). Manually dispatch the GitHub Actions workflow named **Release** with that existing tag. It reruns the full supported-Python test matrix, checks the tag against wheel/sdist metadata, inspects package contents, installs the wheel outside the checkout, and checks CLI/API version parity. Publishing uses the configured `PYPI_API_TOKEN` secret after approval in the `pypi` GitHub environment; repository administrators must ensure the token and environment approval rules are configured. Do not publish from an untagged/fallback build.
|
|
322
336
|
|
|
323
|
-
## Readio migration
|
|
324
|
-
|
|
325
|
-
**Readio is deliberately not patched by this project.** Its existing export commands continue using their current code until an opt-in bridge is tested. A future integration must call AudioExport's Python library API; it must never launch the `audioexport` CLI as a subprocess. See [`docs/readio-migration.md`](docs/readio-migration.md) for the compatibility boundary, integration order, and rollback rules. Readio continues owning project locks, path defaults, staleness, indexed state, and orchestration.
|
|
326
|
-
|
|
327
337
|
## Tests
|
|
328
338
|
|
|
329
339
|
```bash
|
|
@@ -339,11 +349,11 @@ python scripts/check_package_artifacts.py
|
|
|
339
349
|
audioexport doctor --json
|
|
340
350
|
```
|
|
341
351
|
|
|
342
|
-
Tests cover seven real formats in one profile, mixed-format audiobook assembly, 100-track ordering/chapters, audiobook cache invalidation, M4A/M4B chapters and covers,
|
|
352
|
+
Tests cover seven real formats in one profile, mixed-format audiobook assembly, 100-track ordering/chapters, audiobook cache invalidation, M4A/M4B chapters and covers, exact-sample chapter timelines, malformed probes, encoder availability, transactional failure recovery, no-clobber behavior, and concurrent writers. Normal CI runs Python 3.10–3.14 on Linux, Windows, and macOS, and checks tests, lint, formatting, typing, build metadata, Twine, package contents, and isolated wheel installation. CI also builds the Sphinx documentation on Linux/Python 3.13 with warnings treated as errors. The manually dispatched **Release** workflow retests the full supported Python range before packaging and API-token publishing. Cross-platform encoder availability depends on the FFmpeg build installed by each runner.
|
|
343
353
|
|
|
344
354
|
## Limitations (MVP)
|
|
345
355
|
|
|
346
|
-
No audio mastering, TTS, audio mixing, streaming PCM API, FFmpeg filter graphs, network service, or GUI.
|
|
356
|
+
No audio mastering, TTS, audio mixing, streaming PCM API, FFmpeg filter graphs, network service, or GUI. Encoder output may vary across FFmpeg versions; the export identity includes tool versions. Chapter verification tolerates small FFmpeg container timebase rounding.
|
|
347
357
|
|
|
348
358
|
## License
|
|
349
359
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# audioexport
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AudioExport is a standalone Python library and CLI for verified FFmpeg audio export.
|
|
4
4
|
|
|
5
5
|
Supported formats: WAV (PCM16), FLAC, MP3, M4A (AAC), M4B (AAC audiobook), OGG (Vorbis), and Opus. Handles tagged metadata, embedded JPEG/PNG cover art for MP3/M4A/M4B, JSON chapter timelines for M4A/M4B, standalone ordered multi-track audiobook assembly, FFprobe verification, artifact identities and safe reuse.
|
|
6
6
|
|
|
@@ -24,7 +24,7 @@ audioexport doctor --json
|
|
|
24
24
|
|
|
25
25
|
`doctor` reports package/tool versions and encoder availability per format. Its existing `ready` key means FFmpeg and FFprobe are present; `all_formats_ready` and each `formats` entry report encoder capability and a reason when unavailable. The additive `features` entries report `m4b_encode`, `m4b_audiobook`, and `m4b_stream_copy` availability; missing optional capabilities do not make `ready` false. `run_profile()` checks the encoders selected by the profile before writing outputs.
|
|
26
26
|
|
|
27
|
-
Base Python dependencies are `langcodes>=3.3` on all supported versions and `tomli>=2` on Python 3.10. FFmpeg/FFprobe are external executables; AudioExport
|
|
27
|
+
Base Python dependencies are `langcodes>=3.3` on all supported versions and `tomli>=2` on Python 3.10. FFmpeg/FFprobe are external executables; AudioExport has no runtime dependency on synthesis, planning, or composition packages.
|
|
28
28
|
|
|
29
29
|
## CLI quickstart
|
|
30
30
|
|
|
@@ -63,7 +63,7 @@ audioexport audiobook book-tracks/ -o book.m4b \
|
|
|
63
63
|
audioexport audiobook 01-intro.mp3 02-chapter-one.flac 03-chapter-two.wav \
|
|
64
64
|
-o book.m4b --bitrate 96k --language eng
|
|
65
65
|
|
|
66
|
-
# Explicit chapter starts from chapters.txt or
|
|
66
|
+
# Explicit chapter starts from chapters.txt or a generic JSON timeline.
|
|
67
67
|
audioexport audiobook book-tracks/ -o book.m4b --chapters-file chapters.txt
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -114,29 +114,43 @@ mode = "explicit" # tracks | explicit | none
|
|
|
114
114
|
# title_source = "tag" # tag (falls back to filename) | filename
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
-
Run it with `audioexport audiobook --profile audiobook.toml`. CLI `--output`, metadata, audio settings, cover, and `--chapters-file` override profile values; the TOML input order remains authoritative. `mode = "tracks"` (the default) creates one chapter per track; `mode = "explicit"` requires `chapters_file`; `mode = "none"` disables chapters. Existing `encode(..., format="m4b", timeline=...)` remains the right choice for
|
|
117
|
+
Run it with `audioexport audiobook --profile audiobook.toml`. CLI `--output`, metadata, audio settings, cover, and `--chapters-file` override profile values; the TOML input order remains authoritative. `mode = "tracks"` (the default) creates one chapter per track; `mode = "explicit"` requires `chapters_file`; `mode = "none"` disables chapters. Existing `encode(..., format="m4b", timeline=...)` remains the right choice for one prepared source with caller-supplied chapters.
|
|
118
118
|
|
|
119
|
-
##
|
|
119
|
+
## SSMD bundle export
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
Book and single-document workspaces share one canonical editable directory root, `<name>.ssmd-bundle/`. The manifest selects the export path: `format = "ssmdconvert.book"` uses verified audiobook assembly, while `format = "ssmdconvert.document"` uses verified one-document encoding; both manifest schemas remain at version 1. AudioExport accepts only the bundle root, not an internal chapter directory, the document source file, or a ZIP archive.
|
|
122
|
+
|
|
123
|
+
Use the same command for either kind:
|
|
122
124
|
|
|
123
125
|
```bash
|
|
124
|
-
audiocompose compose
|
|
125
|
-
audioexport export
|
|
126
|
+
audiocompose bundle compose "My Book.ssmd-bundle" --variant "$VARIANT"
|
|
127
|
+
audioexport bundle export "My Book.ssmd-bundle" --variant "$VARIANT" \
|
|
128
|
+
--chapters 5,7,11-13 --audiocompose-root /srv/audio-stages/audiocompose \
|
|
129
|
+
--output-root /srv/audio-exports --title "My Book" --author "An Author" \
|
|
130
|
+
--language en-US
|
|
131
|
+
|
|
132
|
+
audioexport bundle export story.ssmd-bundle --variant "$VARIANT" \
|
|
133
|
+
--output story.m4a --title "Story" --artist "An Author"
|
|
126
134
|
```
|
|
127
135
|
|
|
128
|
-
|
|
136
|
+
AudioExport verifies the selected AudioCompose report, output paths, WAV checksums, and audio geometry before exporting. For a book, `--chapters` selects manifest chapters (`all` by default), output defaults to M4B, `--format` accepts only `m4b`, and audiobook profiles remain supported. For a document, only `--chapters all` is valid; `--format` or the output filename extension selects the final format, and `--timeline` supplies an optional generic chapter timeline. `--author` and `--artist` are aliases for the appropriate author/artist metadata field. Kind-specific options that have no meaning for the selected manifest are rejected rather than ignored.
|
|
129
137
|
|
|
130
|
-
|
|
138
|
+
Default output names strip only `.ssmd-bundle`: books use `audioexport/<variant>/<name>.m4b`, while documents use `audioexport/<variant>/<name>.<format>`. `--output-root` relocates these defaults; `--output` selects an explicit destination. Book metadata precedence is manifest, then audiobook profile, then explicit CLI/API values. BCP 47 language tags such as `en-US` are normalized to ISO 639-2/T track codes such as `eng` for M4A/M4B.
|
|
131
139
|
|
|
132
|
-
|
|
140
|
+
The JSON result uses one versioned envelope with `format: "audioexport-bundle-result"`, `version: 1`, `kind`, and a kind-specific `result` object. Human output begins with `AudioExport bundle export` and reports the selected kind, workspace, variant, and output.
|
|
133
141
|
|
|
134
|
-
|
|
142
|
+
Python callers use the same manifest-dispatched facade:
|
|
135
143
|
|
|
136
|
-
|
|
144
|
+
```python
|
|
145
|
+
from audioexport import export_bundle
|
|
137
146
|
|
|
138
|
-
|
|
147
|
+
book_result = export_bundle("My Book.ssmd-bundle", variant=variant, chapters="5,7")
|
|
148
|
+
document_result = export_bundle("story.ssmd-bundle", variant=variant, format="mp3")
|
|
149
|
+
print(book_result.kind, book_result.output)
|
|
150
|
+
print(document_result.kind, document_result.output)
|
|
151
|
+
```
|
|
139
152
|
|
|
153
|
+
`BundleExportResult.value` contains the specialized book or document result; `BundleExportResult.output` gives the final artifact path. The facade is available from both `audioexport` and `audioexport.api`.
|
|
140
154
|
OGG defaults to Vorbis quality-based encoding (`-qscale:a 4`) to avoid low-rate/mono bitrate incompatibilities. `--bitrate` accepts values such as `192k`, `192000`, or `1M`; whitespace is trimmed. A bare integer is interpreted as **bits per second**, so use `192k` (or `192000`), not `192`, for a nominal 192 kb/s rate. WAV/FLAC don't accept bitrate. WAV output is always PCM16 conversion, not a byte-for-byte passthrough; lossy codecs and even PCM output can vary with FFmpeg versions, which are included in the export identity.
|
|
141
155
|
|
|
142
156
|
## Python API
|
|
@@ -170,7 +184,7 @@ print(probe("exported/book.m4b")["chapters"])
|
|
|
170
184
|
|
|
171
185
|
You may import the same public functions from `audioexport.api` as well as from `audioexport`.
|
|
172
186
|
|
|
173
|
-
The public errors are `AudioExportError` and subclasses `InvalidExportError`, `ToolNotFoundError`, `EncodingError`, and `VerificationError`, with a stable machine-readable `.code` value. `encode()` returns an immutable `ExportResult`; `build_audiobook()` returns an `AudiobookResult`; `
|
|
187
|
+
The public errors are `AudioExportError` and subclasses `InvalidExportError`, `ToolNotFoundError`, `EncodingError`, and `VerificationError`, with a stable machine-readable `.code` value. `encode()` returns an immutable `ExportResult`; `build_audiobook()` returns an `AudiobookResult`; `export_bundle()` provides one manifest-dispatched entry point for verified book and document bundles and returns `BundleExportResult`. The facade and result types are available from both `audioexport` and `audioexport.api`.
|
|
174
188
|
|
|
175
189
|
## Export profile
|
|
176
190
|
|
|
@@ -246,7 +260,7 @@ resolved_outputs = preflight_profile(profile, source)
|
|
|
246
260
|
|
|
247
261
|
for resolved in resolved_outputs:
|
|
248
262
|
target = Path("exports") / resolved.filename
|
|
249
|
-
#
|
|
263
|
+
# Applications should perform their own target and overwrite checks here.
|
|
250
264
|
result = encode(
|
|
251
265
|
source,
|
|
252
266
|
target,
|
|
@@ -258,7 +272,7 @@ for resolved in resolved_outputs:
|
|
|
258
272
|
)
|
|
259
273
|
```
|
|
260
274
|
|
|
261
|
-
A consumer may instead call `resolve_output()` for a single selected spec; use full-profile preflight when the operation requires every configured output validated. Preflight is not an ownership authorization:
|
|
275
|
+
A consumer may instead call `resolve_output()` for a single selected spec; use full-profile preflight when the operation requires every configured output validated. Preflight is not an ownership authorization: applications remain responsible for their own locks, target selection, overwrite authorization, and state updates. Call `encode()` only after those consumer-owned checks pass. `ResolvedOutput`, `resolve_output`, and `preflight_profile` are available from both `audioexport` and `audioexport.api`.
|
|
262
276
|
|
|
263
277
|
## Chapters/timeline
|
|
264
278
|
|
|
@@ -275,7 +289,7 @@ Use an independent JSON file such as [`examples/chapters.json`](examples/chapter
|
|
|
275
289
|
|
|
276
290
|
`end_ms` is optional; without it, chapter ends are the next chapter start or the audio duration. Chapter offsets must be strictly increasing and within the audio file. FFmpeg metadata escaping is handled internally. Embedded chapter markers are currently supported for **M4A and M4B**.
|
|
277
291
|
|
|
278
|
-
|
|
292
|
+
Exact-sample timelines may include a top-level `sample_rate` and chapter `start_sample` values. FFmetadata preserves their original sample timebase and coordinates using `TIMEBASE=1/<sample_rate>`.
|
|
279
293
|
|
|
280
294
|
## Integrity and versioning
|
|
281
295
|
|
|
@@ -287,10 +301,6 @@ Version is dynamic through `setuptools-scm`: `pyproject.toml` has no static `ver
|
|
|
287
301
|
|
|
288
302
|
For a release, first commit the code and wait for the complete Linux/Windows/macOS, Python 3.10–3.14 CI matrix, including its Sphinx warning-as-error documentation build. Then create and push the intended version tag (for example `v0.1.2`). Manually dispatch the GitHub Actions workflow named **Release** with that existing tag. It reruns the full supported-Python test matrix, checks the tag against wheel/sdist metadata, inspects package contents, installs the wheel outside the checkout, and checks CLI/API version parity. Publishing uses the configured `PYPI_API_TOKEN` secret after approval in the `pypi` GitHub environment; repository administrators must ensure the token and environment approval rules are configured. Do not publish from an untagged/fallback build.
|
|
289
303
|
|
|
290
|
-
## Readio migration
|
|
291
|
-
|
|
292
|
-
**Readio is deliberately not patched by this project.** Its existing export commands continue using their current code until an opt-in bridge is tested. A future integration must call AudioExport's Python library API; it must never launch the `audioexport` CLI as a subprocess. See [`docs/readio-migration.md`](docs/readio-migration.md) for the compatibility boundary, integration order, and rollback rules. Readio continues owning project locks, path defaults, staleness, indexed state, and orchestration.
|
|
293
|
-
|
|
294
304
|
## Tests
|
|
295
305
|
|
|
296
306
|
```bash
|
|
@@ -306,11 +316,11 @@ python scripts/check_package_artifacts.py
|
|
|
306
316
|
audioexport doctor --json
|
|
307
317
|
```
|
|
308
318
|
|
|
309
|
-
Tests cover seven real formats in one profile, mixed-format audiobook assembly, 100-track ordering/chapters, audiobook cache invalidation, M4A/M4B chapters and covers,
|
|
319
|
+
Tests cover seven real formats in one profile, mixed-format audiobook assembly, 100-track ordering/chapters, audiobook cache invalidation, M4A/M4B chapters and covers, exact-sample chapter timelines, malformed probes, encoder availability, transactional failure recovery, no-clobber behavior, and concurrent writers. Normal CI runs Python 3.10–3.14 on Linux, Windows, and macOS, and checks tests, lint, formatting, typing, build metadata, Twine, package contents, and isolated wheel installation. CI also builds the Sphinx documentation on Linux/Python 3.13 with warnings treated as errors. The manually dispatched **Release** workflow retests the full supported Python range before packaging and API-token publishing. Cross-platform encoder availability depends on the FFmpeg build installed by each runner.
|
|
310
320
|
|
|
311
321
|
## Limitations (MVP)
|
|
312
322
|
|
|
313
|
-
No audio mastering, TTS, audio mixing, streaming PCM API, FFmpeg filter graphs, network service, or GUI.
|
|
323
|
+
No audio mastering, TTS, audio mixing, streaming PCM API, FFmpeg filter graphs, network service, or GUI. Encoder output may vary across FFmpeg versions; the export identity includes tool versions. Chapter verification tolerates small FFmpeg container timebase rounding.
|
|
314
324
|
|
|
315
325
|
## License
|
|
316
326
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Standalone audio export
|
|
1
|
+
"""Standalone verified audio export and workspace adapters."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -13,7 +13,15 @@ from .book import (
|
|
|
13
13
|
export_book,
|
|
14
14
|
resolve_book_export,
|
|
15
15
|
)
|
|
16
|
+
from .bundle import BundleExportResult, export_bundle
|
|
16
17
|
from .chapters import Chapter, load_chapters
|
|
18
|
+
from .document import (
|
|
19
|
+
ComposedDocument,
|
|
20
|
+
DocumentExportContext,
|
|
21
|
+
DocumentExportResult,
|
|
22
|
+
export_document,
|
|
23
|
+
resolve_document_export,
|
|
24
|
+
)
|
|
17
25
|
from .errors import (
|
|
18
26
|
AudioExportError,
|
|
19
27
|
EncodingError,
|
|
@@ -43,8 +51,12 @@ __all__ = [
|
|
|
43
51
|
"AudiobookTrack",
|
|
44
52
|
"BookExportContext",
|
|
45
53
|
"BookExportResult",
|
|
54
|
+
"BundleExportResult",
|
|
46
55
|
"Chapter",
|
|
47
56
|
"ComposedChapter",
|
|
57
|
+
"ComposedDocument",
|
|
58
|
+
"DocumentExportContext",
|
|
59
|
+
"DocumentExportResult",
|
|
48
60
|
"EncodingError",
|
|
49
61
|
"ExportProfile",
|
|
50
62
|
"ExportResult",
|
|
@@ -58,6 +70,8 @@ __all__ = [
|
|
|
58
70
|
"doctor",
|
|
59
71
|
"encode",
|
|
60
72
|
"export_book",
|
|
73
|
+
"export_bundle",
|
|
74
|
+
"export_document",
|
|
61
75
|
"load_audiobook_profile",
|
|
62
76
|
"load_chapters",
|
|
63
77
|
"load_profile",
|
|
@@ -65,6 +79,7 @@ __all__ = [
|
|
|
65
79
|
"preflight_profile",
|
|
66
80
|
"probe",
|
|
67
81
|
"resolve_book_export",
|
|
82
|
+
"resolve_document_export",
|
|
68
83
|
"resolve_output",
|
|
69
84
|
"run_profile",
|
|
70
85
|
]
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.1.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1,
|
|
21
|
+
__version__ = version = '0.1.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gb9fbcb9c0'
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Shared security checks for native workspace adapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import math
|
|
7
|
+
import stat
|
|
8
|
+
import unicodedata
|
|
9
|
+
import wave
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from pathlib import Path, PureWindowsPath
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import InvalidExportError, VerificationError
|
|
15
|
+
|
|
16
|
+
DURATION_TOLERANCE_SECONDS = 0.01
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def safe_variant(variant: str, *, error_code: str) -> str:
|
|
20
|
+
"""Validate a variant as one portable, safe directory component."""
|
|
21
|
+
if (
|
|
22
|
+
not isinstance(variant, str)
|
|
23
|
+
or not variant
|
|
24
|
+
or variant in {".", ".."}
|
|
25
|
+
or "/" in variant
|
|
26
|
+
or "\\" in variant
|
|
27
|
+
or ":" in variant
|
|
28
|
+
or PureWindowsPath(variant).is_absolute()
|
|
29
|
+
or any(unicodedata.category(character) == "Cc" for character in variant)
|
|
30
|
+
):
|
|
31
|
+
raise InvalidExportError(
|
|
32
|
+
"variant must be a safe single directory component", code=error_code
|
|
33
|
+
)
|
|
34
|
+
return variant
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_stage_root(
|
|
38
|
+
workspace: Path,
|
|
39
|
+
root: str | Path | None,
|
|
40
|
+
*,
|
|
41
|
+
stage_name: str,
|
|
42
|
+
missing_code: str,
|
|
43
|
+
invalid_code: str,
|
|
44
|
+
) -> Path:
|
|
45
|
+
"""Resolve an existing stage root, interpreting relative overrides under workspace."""
|
|
46
|
+
selected = Path(root).expanduser() if root is not None else workspace / stage_name
|
|
47
|
+
if not selected.is_absolute():
|
|
48
|
+
selected = workspace / selected
|
|
49
|
+
try:
|
|
50
|
+
resolved = selected.resolve(strict=True)
|
|
51
|
+
except (OSError, RuntimeError) as exc:
|
|
52
|
+
raise InvalidExportError(
|
|
53
|
+
f"{stage_name} directory does not exist: {selected}", code=missing_code
|
|
54
|
+
) from exc
|
|
55
|
+
if selected.is_symlink() or not resolved.is_dir():
|
|
56
|
+
raise InvalidExportError(
|
|
57
|
+
f"{stage_name} root must be a non-symlink directory: {selected}",
|
|
58
|
+
code=invalid_code,
|
|
59
|
+
)
|
|
60
|
+
return resolved
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def path_is_within(root: Path, path: Path) -> bool:
|
|
64
|
+
"""Return whether an absolute path is contained by an absolute directory."""
|
|
65
|
+
try:
|
|
66
|
+
path.relative_to(root)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return False
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def safe_report_file(
|
|
73
|
+
variant_dir: Path,
|
|
74
|
+
reported: Any,
|
|
75
|
+
*,
|
|
76
|
+
error_code: str,
|
|
77
|
+
) -> Path:
|
|
78
|
+
"""Resolve a regular report-declared file without traversal or symlink hops."""
|
|
79
|
+
if not isinstance(reported, str) or not reported or "\x00" in reported or "\\" in reported:
|
|
80
|
+
raise VerificationError(
|
|
81
|
+
"compose report output must be a relative POSIX path", code=error_code
|
|
82
|
+
)
|
|
83
|
+
relative = Path(reported)
|
|
84
|
+
windows = PureWindowsPath(reported)
|
|
85
|
+
if relative.is_absolute() or windows.is_absolute() or windows.drive:
|
|
86
|
+
raise VerificationError(
|
|
87
|
+
"compose report output must be relative to its variant directory",
|
|
88
|
+
code=error_code,
|
|
89
|
+
)
|
|
90
|
+
if any(part in {"", ".", ".."} for part in reported.split("/")):
|
|
91
|
+
raise VerificationError(
|
|
92
|
+
"compose report output must not contain traversal components",
|
|
93
|
+
code=error_code,
|
|
94
|
+
)
|
|
95
|
+
candidate = variant_dir / relative
|
|
96
|
+
current = variant_dir
|
|
97
|
+
try:
|
|
98
|
+
for part in relative.parts:
|
|
99
|
+
current = current / part
|
|
100
|
+
if current.is_symlink():
|
|
101
|
+
raise VerificationError(
|
|
102
|
+
"compose report output must not traverse symlinks", code=error_code
|
|
103
|
+
)
|
|
104
|
+
resolved = candidate.resolve(strict=True)
|
|
105
|
+
except VerificationError:
|
|
106
|
+
raise
|
|
107
|
+
except (OSError, RuntimeError) as exc:
|
|
108
|
+
raise VerificationError(
|
|
109
|
+
f"compose report output does not resolve to a regular file: {reported}",
|
|
110
|
+
code=error_code,
|
|
111
|
+
) from exc
|
|
112
|
+
if not path_is_within(variant_dir, resolved):
|
|
113
|
+
raise VerificationError(
|
|
114
|
+
"compose report output escapes its variant directory", code=error_code
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
mode = resolved.lstat().st_mode
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise VerificationError(
|
|
120
|
+
f"compose report output is unavailable: {reported}", code=error_code
|
|
121
|
+
) from exc
|
|
122
|
+
if not stat.S_ISREG(mode):
|
|
123
|
+
raise VerificationError(
|
|
124
|
+
f"compose report output must be a regular non-symlink file: {reported}",
|
|
125
|
+
code=error_code,
|
|
126
|
+
)
|
|
127
|
+
return resolved
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def sha256_file(path: Path, *, error_code: str) -> str:
|
|
131
|
+
digest = hashlib.sha256()
|
|
132
|
+
try:
|
|
133
|
+
with path.open("rb") as stream:
|
|
134
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
135
|
+
digest.update(chunk)
|
|
136
|
+
except OSError as exc:
|
|
137
|
+
raise VerificationError(f"cannot hash composed WAV {path}", code=error_code) from exc
|
|
138
|
+
return digest.hexdigest()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _positive_int(value: Any, field: str, *, error_code: str) -> int:
|
|
142
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
143
|
+
raise InvalidExportError(f"{field} must be a positive integer", code=error_code)
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def verify_reported_wav(
|
|
148
|
+
path: Path,
|
|
149
|
+
report: Mapping[str, Any],
|
|
150
|
+
*,
|
|
151
|
+
report_code: str,
|
|
152
|
+
geometry_code: str,
|
|
153
|
+
item_label: str = "compose item",
|
|
154
|
+
) -> tuple[int, int, int, float]:
|
|
155
|
+
"""Verify a WAV header against report geometry using the established tolerance."""
|
|
156
|
+
expected_rate = _positive_int(
|
|
157
|
+
report.get("sample_rate"), f"{item_label} sample_rate", error_code=report_code
|
|
158
|
+
)
|
|
159
|
+
expected_channels = _positive_int(
|
|
160
|
+
report.get("channels"), f"{item_label} channels", error_code=report_code
|
|
161
|
+
)
|
|
162
|
+
expected_frames = _positive_int(
|
|
163
|
+
report.get("frames"), f"{item_label} frames", error_code=report_code
|
|
164
|
+
)
|
|
165
|
+
duration = report.get("duration_seconds")
|
|
166
|
+
if (
|
|
167
|
+
isinstance(duration, bool)
|
|
168
|
+
or not isinstance(duration, (int, float))
|
|
169
|
+
or not math.isfinite(float(duration))
|
|
170
|
+
or duration <= 0
|
|
171
|
+
):
|
|
172
|
+
raise InvalidExportError(
|
|
173
|
+
f"{item_label} duration_seconds must be a positive finite number",
|
|
174
|
+
code=report_code,
|
|
175
|
+
)
|
|
176
|
+
complete_audio = True
|
|
177
|
+
try:
|
|
178
|
+
with wave.open(str(path), "rb") as source:
|
|
179
|
+
actual_rate = source.getframerate()
|
|
180
|
+
actual_channels = source.getnchannels()
|
|
181
|
+
actual_frames = source.getnframes()
|
|
182
|
+
sample_width = source.getsampwidth()
|
|
183
|
+
frame_size = actual_channels * sample_width
|
|
184
|
+
if frame_size <= 0:
|
|
185
|
+
complete_audio = False
|
|
186
|
+
else:
|
|
187
|
+
remaining_frames = actual_frames
|
|
188
|
+
while remaining_frames > 0:
|
|
189
|
+
chunk = source.readframes(min(remaining_frames, 65536))
|
|
190
|
+
if not chunk or len(chunk) % frame_size:
|
|
191
|
+
complete_audio = False
|
|
192
|
+
break
|
|
193
|
+
frames_read = len(chunk) // frame_size
|
|
194
|
+
if frames_read <= 0:
|
|
195
|
+
complete_audio = False
|
|
196
|
+
break
|
|
197
|
+
remaining_frames -= frames_read
|
|
198
|
+
if remaining_frames:
|
|
199
|
+
complete_audio = False
|
|
200
|
+
except (OSError, EOFError, wave.Error) as exc:
|
|
201
|
+
raise VerificationError(
|
|
202
|
+
f"composed artifact is not a readable WAV file: {path}", code=geometry_code
|
|
203
|
+
) from exc
|
|
204
|
+
actual_duration = actual_frames / actual_rate if actual_rate > 0 else 0.0
|
|
205
|
+
if (
|
|
206
|
+
not complete_audio
|
|
207
|
+
or actual_rate != expected_rate
|
|
208
|
+
or actual_channels != expected_channels
|
|
209
|
+
or actual_frames != expected_frames
|
|
210
|
+
or abs(actual_duration - float(duration)) > DURATION_TOLERANCE_SECONDS
|
|
211
|
+
):
|
|
212
|
+
raise VerificationError(
|
|
213
|
+
f"composed WAV geometry does not match the report: {path}", code=geometry_code
|
|
214
|
+
)
|
|
215
|
+
return actual_rate, actual_channels, actual_frames, actual_duration
|