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.
Files changed (44) hide show
  1. {audioexport-0.1.2 → audioexport-0.1.4}/PKG-INFO +35 -25
  2. {audioexport-0.1.2 → audioexport-0.1.4}/README.md +34 -24
  3. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/__init__.py +16 -1
  4. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/_version.py +3 -3
  5. audioexport-0.1.4/audioexport/_workspace.py +215 -0
  6. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/api.py +15 -0
  7. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/book.py +47 -198
  8. audioexport-0.1.4/audioexport/bundle.py +166 -0
  9. audioexport-0.1.4/audioexport/bundle_contract.py +100 -0
  10. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/chapters.py +3 -3
  11. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/cli.py +125 -126
  12. audioexport-0.1.4/audioexport/document.py +449 -0
  13. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/formats.py +2 -2
  14. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport.egg-info/SOURCES.txt +5 -2
  15. {audioexport-0.1.2 → audioexport-0.1.4}/docs/changelog.md +19 -2
  16. {audioexport-0.1.2 → audioexport-0.1.4}/docs/index.md +25 -34
  17. audioexport-0.1.2/examples/create_ssmdbook_demo.py → audioexport-0.1.4/examples/create_ssmd_bundle_demo.py +5 -3
  18. audioexport-0.1.2/docs/readio-migration.md +0 -87
  19. {audioexport-0.1.2 → audioexport-0.1.4}/LICENSE +0 -0
  20. {audioexport-0.1.2 → audioexport-0.1.4}/MANIFEST.in +0 -0
  21. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/__main__.py +0 -0
  22. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/audiobook.py +0 -0
  23. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/audiobook_profile.py +0 -0
  24. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/errors.py +0 -0
  25. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/fftools.py +0 -0
  26. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/inputs.py +0 -0
  27. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/metadata.py +0 -0
  28. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/pipeline.py +0 -0
  29. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/preflight.py +0 -0
  30. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/profile.py +0 -0
  31. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/py.typed +0 -0
  32. {audioexport-0.1.2 → audioexport-0.1.4}/audioexport/validation.py +0 -0
  33. {audioexport-0.1.2 → audioexport-0.1.4}/docs/Makefile +0 -0
  34. {audioexport-0.1.2 → audioexport-0.1.4}/docs/conf.py +0 -0
  35. {audioexport-0.1.2 → audioexport-0.1.4}/docs/make.bat +0 -0
  36. {audioexport-0.1.2 → audioexport-0.1.4}/docs/make.py +0 -0
  37. {audioexport-0.1.2 → audioexport-0.1.4}/docs/requirements.txt +0 -0
  38. {audioexport-0.1.2 → audioexport-0.1.4}/examples/audiobook.toml +0 -0
  39. {audioexport-0.1.2 → audioexport-0.1.4}/examples/chapters.json +0 -0
  40. {audioexport-0.1.2 → audioexport-0.1.4}/examples/chapters.txt +0 -0
  41. {audioexport-0.1.2 → audioexport-0.1.4}/examples/create_demo_wav.py +0 -0
  42. {audioexport-0.1.2 → audioexport-0.1.4}/examples/export.toml +0 -0
  43. {audioexport-0.1.2 → audioexport-0.1.4}/pyproject.toml +0 -0
  44. {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.2
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
- An independent Python library and command-line interface for exporting audio through **FFmpeg**. This is the first extraction step from Readio's output/encoding subsystem, but **audioexport does not import or install Readio, AudioCompose, UtterPlan, or synthesis engines**.
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 does not install or import Readio, AudioCompose, UtterPlan, VoiceRender, soundfile, or NumPy.
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 the existing JSON/Readio timeline format.
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 Readio's prepared master and project timeline.
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
- ## Native `.ssmdbook` pipeline
152
+ ## SSMD bundle export
153
153
 
154
- Use `export-book` when AudioCompose has already written a variant in a standard `.ssmdbook` workspace. It is the native book-workspace adapter; `audioexport audiobook` remains the generic standalone track/directory assembler, and `encode` remains for one prepared source with a timeline.
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-book . --variant "$VARIANT"
158
- audioexport export-book . --variant "$VARIANT"
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
- `export-book` reads `manifest.json` and `audiocompose/<variant>/compose-report.toml`, accepts the producer's singular `[[item]]` tables (and legacy `[[items]]` tables), validates selected entries and WAV checksums/geometry with bare or `sha256:`-prefixed digests, then delegates final M4B assembly and verification to AudioExport. It does not run AudioCompose, repair or synthesize chapters, or use Readio-specific state. Chapter selection accepts `all` (the default), a source number, an inclusive range, or comma-separated source numbers/ranges, for example `--chapters 5,7,11-13`. Selection follows manifest chapter order.
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
- For M4A/M4B, language metadata is written on the audio track using ISO 639-2/T. BCP 47 tags such as `en-US` are normalized to their three-letter track code (`eng`).
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
- By default, output is written to `audioexport/<variant>/<workspace-name-without-.ssmdbook>.m4b` in the workspace. `--output` may override this; when an audiobook profile is supplied, its output is used unless `--output` is specified.
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
- Metadata precedence is **manifest metadata < audiobook profile metadata < explicit CLI/API metadata**. Audio settings use **AudioExport defaults < audiobook profile < explicit CLI/API flags**. Thus explicit metadata fields replace matching profile/manifest values, profile values replace manifest defaults, and unset values continue to fall through. Explicit cover and audio flags override corresponding profile settings. The profile's inputs and chapter configuration do not replace the variant chapters selected from the compose report.
175
+ Python callers use the same manifest-dispatched facade:
168
176
 
169
- If `--output` is not set for `encode`, the output filename defaults to the input stem and chosen format. To avoid destroying the input, encoding input and output to the same real path is always rejected.
177
+ ```python
178
+ from audioexport import export_bundle
170
179
 
171
- Without `--force`, an untracked, symlink, or manually modified output is never overwritten. A previously generated output is updated when its sidecar and checksum still match; re-running identical options returns `reused: true` without re-encoding. `--force` authorizes replacing an unknown or modified regular file, but symlink outputs and sidecars are always rejected. Writes are staged with their manifest; a failed manifest commit restores the last good pair when possible, and an incomplete recovery reports the preserved backup path. A per-target process lock rejects concurrent writers with `audioexport.output_busy`; remove a stale lock only after confirming no writer is active.
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`; `export_book()` and `resolve_book_export()` provide verified composed-book export through `BookExportContext`, `BookExportResult`, and `ComposedChapter`. These symbols, like the other public functions, are available from both `audioexport` and `audioexport.api`.
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
- # A consumer such as Readio performs its target/ownership/overwrite checks here.
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: Readio remains responsible for project locks and freshness, destination selection and collision/ownership checks, overwrite authorization, state/index updates, result types, and partial-batch bookkeeping. Call `encode()` only after those consumer-owned checks pass. `ResolvedOutput`, `resolve_output`, and `preflight_profile` are available from both `audioexport` and `audioexport.api`.
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
- For an incremental Readio migration, the same `--timeline` API also reads **Readio's `readio.composition-timeline` JSON** with `sample_rate` and chapter `start_sample`. FFmetadata is constructed with its original sample timebase for precision, with generic millisecond chapter fields available for non-Readio workflows.
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, Readio sample 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.
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. No automatic conversion of Readio project configs, legacy export cache, or Audiocompose timeline semantics other than chapter starts. Encoder output may vary across FFmpeg versions; the cache key includes tool versions. Chapter verification has a small tolerance for FFmpeg container timebase rounding. Standalone encoding does not modify Readio projects.
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
- An independent Python library and command-line interface for exporting audio through **FFmpeg**. This is the first extraction step from Readio's output/encoding subsystem, but **audioexport does not import or install Readio, AudioCompose, UtterPlan, or synthesis engines**.
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 does not install or import Readio, AudioCompose, UtterPlan, VoiceRender, soundfile, or NumPy.
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 the existing JSON/Readio timeline format.
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 Readio's prepared master and project timeline.
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
- ## Native `.ssmdbook` pipeline
119
+ ## SSMD bundle export
120
120
 
121
- Use `export-book` when AudioCompose has already written a variant in a standard `.ssmdbook` workspace. It is the native book-workspace adapter; `audioexport audiobook` remains the generic standalone track/directory assembler, and `encode` remains for one prepared source with a timeline.
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-book . --variant "$VARIANT"
125
- audioexport export-book . --variant "$VARIANT"
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
- `export-book` reads `manifest.json` and `audiocompose/<variant>/compose-report.toml`, accepts the producer's singular `[[item]]` tables (and legacy `[[items]]` tables), validates selected entries and WAV checksums/geometry with bare or `sha256:`-prefixed digests, then delegates final M4B assembly and verification to AudioExport. It does not run AudioCompose, repair or synthesize chapters, or use Readio-specific state. Chapter selection accepts `all` (the default), a source number, an inclusive range, or comma-separated source numbers/ranges, for example `--chapters 5,7,11-13`. Selection follows manifest chapter order.
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
- For M4A/M4B, language metadata is written on the audio track using ISO 639-2/T. BCP 47 tags such as `en-US` are normalized to their three-letter track code (`eng`).
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
- By default, output is written to `audioexport/<variant>/<workspace-name-without-.ssmdbook>.m4b` in the workspace. `--output` may override this; when an audiobook profile is supplied, its output is used unless `--output` is specified.
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
- Metadata precedence is **manifest metadata < audiobook profile metadata < explicit CLI/API metadata**. Audio settings use **AudioExport defaults < audiobook profile < explicit CLI/API flags**. Thus explicit metadata fields replace matching profile/manifest values, profile values replace manifest defaults, and unset values continue to fall through. Explicit cover and audio flags override corresponding profile settings. The profile's inputs and chapter configuration do not replace the variant chapters selected from the compose report.
142
+ Python callers use the same manifest-dispatched facade:
135
143
 
136
- If `--output` is not set for `encode`, the output filename defaults to the input stem and chosen format. To avoid destroying the input, encoding input and output to the same real path is always rejected.
144
+ ```python
145
+ from audioexport import export_bundle
137
146
 
138
- Without `--force`, an untracked, symlink, or manually modified output is never overwritten. A previously generated output is updated when its sidecar and checksum still match; re-running identical options returns `reused: true` without re-encoding. `--force` authorizes replacing an unknown or modified regular file, but symlink outputs and sidecars are always rejected. Writes are staged with their manifest; a failed manifest commit restores the last good pair when possible, and an incomplete recovery reports the preserved backup path. A per-target process lock rejects concurrent writers with `audioexport.output_busy`; remove a stale lock only after confirming no writer is active.
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`; `export_book()` and `resolve_book_export()` provide verified composed-book export through `BookExportContext`, `BookExportResult`, and `ComposedChapter`. These symbols, like the other public functions, are available from both `audioexport` and `audioexport.api`.
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
- # A consumer such as Readio performs its target/ownership/overwrite checks here.
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: Readio remains responsible for project locks and freshness, destination selection and collision/ownership checks, overwrite authorization, state/index updates, result types, and partial-batch bookkeeping. Call `encode()` only after those consumer-owned checks pass. `ResolvedOutput`, `resolve_output`, and `preflight_profile` are available from both `audioexport` and `audioexport.api`.
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
- For an incremental Readio migration, the same `--timeline` API also reads **Readio's `readio.composition-timeline` JSON** with `sample_rate` and chapter `start_sample`. FFmetadata is constructed with its original sample timebase for precision, with generic millisecond chapter fields available for non-Readio workflows.
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, Readio sample 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.
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. No automatic conversion of Readio project configs, legacy export cache, or Audiocompose timeline semantics other than chapter starts. Encoder output may vary across FFmpeg versions; the cache key includes tool versions. Chapter verification has a small tolerance for FFmpeg container timebase rounding. Standalone encoding does not modify Readio projects.
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; no import of Readio or AudioCompose."""
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.2'
22
- __version_tuple__ = version_tuple = (0, 1, 2)
21
+ __version__ = version = '0.1.4'
22
+ __version_tuple__ = version_tuple = (0, 1, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g356ce267b'
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