mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-12 08:53:41 -06:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5796bb9a | ||
|
|
58dfbcd013 |
@@ -1,28 +0,0 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -27,10 +27,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
|
||||
@@ -27,10 +27,6 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "36 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -19,14 +15,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# The shared workflow creates the draft release, which needs write access to contents.
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -34,11 +23,9 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: soundcraft
|
||||
app-title: SoundCraft
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -54,4 +41,3 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -1,62 +1,11 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# SoundCraft — instructions for agents
|
||||
|
||||
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
## Start every session here
|
||||
Read `ROADMAP.md` for stage, status and the parity numbers, and pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
|
||||
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
|
||||
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
|
||||
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
|
||||
|
||||
## ⚠️ Assets: the absolute rule
|
||||
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
|
||||
@@ -81,12 +30,13 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
## Architecture (layers enforced by `cargo xtask layers`)
|
||||
| Layer | Crate | Role |
|
||||
|---|---|---|
|
||||
| L0 | `soundcraft-time`, `soundcraft-midi` | Timebases/tempo/grid; Standard MIDI Files + MIDI ops |
|
||||
| L0 | `soundcraft-time`, `soundcraft-audio-io`, `soundcraft-midi` | Timebases/tempo/grid; audio file formats + peaks; Standard MIDI Files + MIDI ops |
|
||||
| L1 | `soundcraft-dsp` | Plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utility, instruments), offline processing, meters, FFT |
|
||||
| L2 | `soundcraft-model` | The session document (tracks, playlists, clips, mixer, routing, automation, markers, groups) |
|
||||
| L3 | `soundcraft-mix` | Mix engine shared by realtime playback and offline bounce |
|
||||
@@ -95,8 +45,6 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
|
||||
| L6 | `soundcraft-ui-egui` | The swappable UI (egui) |
|
||||
| apps | `soundcraft`, `soundcraft-cli`, `soundcraft-web` | Entry points |
|
||||
|
||||
Audio file formats, codecs and waveform peaks are not a SoundCraft crate: they come from `craft-audio` in `storytold/craft-libs`, which every Craft app shares. Fix or extend a format there, never in a local copy; SoundCraft keeps only the mapping onto its session model (`soundcraft_model::FileFormat`).
|
||||
|
||||
Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read engine state and act through `engine.execute(id, params)`.
|
||||
|
||||
## Everything is a command
|
||||
@@ -141,5 +89,3 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -55,3 +55,4 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
|
||||
| `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
|
||||
| `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
|
||||
| `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 |
|
||||
| `crates/audio-io/tests/fixtures/` | SoundCraft contributors | original test tones and silence generated with ffmpeg (sine) or by hand; commands in `crates/audio-io/tests/fixtures/README.md` | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
@AGENTS.md
|
||||
Read `AGENTS.md`: it is the entry point for agents in this repo.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+65
-91
@@ -1061,32 +1061,20 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-audio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"log",
|
||||
"serde",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1109,25 +1097,15 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-midi"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1148,7 +1126,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1158,7 +1136,6 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1445,19 +1422,6 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1511,26 +1475,6 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -4353,11 +4297,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"craft-audio",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4367,6 +4310,7 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-automation",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-playback",
|
||||
@@ -4377,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4385,14 +4329,26 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"craft-audio",
|
||||
"log",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-time",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4400,7 +4356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4413,16 +4369,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"craft-audio",
|
||||
"log",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-automation",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-engine",
|
||||
@@ -4431,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4442,15 +4398,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-docpath",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-au-host",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-midi",
|
||||
@@ -4460,26 +4416,27 @@ dependencies = [
|
||||
"soundcraft-video",
|
||||
"soundcraft-vst3-host",
|
||||
"thiserror 2.0.21",
|
||||
"winsafe",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-midi",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"log",
|
||||
"rayon",
|
||||
"soundcraft-au-host",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-midi",
|
||||
@@ -4490,12 +4447,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-midi",
|
||||
"soundcraft-time",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4503,13 +4460,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"craft-audio",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-mix",
|
||||
"soundcraft-model",
|
||||
@@ -4518,7 +4475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4528,19 +4485,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-au-host",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-engine",
|
||||
@@ -4558,7 +4514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4570,7 +4526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4586,15 +4542,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-model",
|
||||
"soundcraft-playback",
|
||||
@@ -6234,7 +6190,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6466,6 +6422,7 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6480,6 +6437,18 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
@@ -6541,6 +6510,11 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+8
-18
@@ -4,15 +4,16 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.20.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/soundcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
soundcraft-time = { path = "crates/time" }
|
||||
soundcraft-audio-io = { path = "crates/audio-io" }
|
||||
soundcraft-midi = { path = "crates/midi" }
|
||||
soundcraft-video = { path = "crates/video" }
|
||||
soundcraft-dsp = { path = "crates/dsp" }
|
||||
@@ -42,29 +43,23 @@ same-file = "1"
|
||||
proptest = "1"
|
||||
|
||||
# audio
|
||||
symphonia = { version = "0.6", default-features = false, features = ["mp1", "mp2", "mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
|
||||
rustfft = "6"
|
||||
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
|
||||
zip = { version = "9", default-features = false }
|
||||
cpal = "0.18"
|
||||
|
||||
# Which file paths stored in a session may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
|
||||
# Video containers and bitstreams, shared with the other craft apps.
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" }
|
||||
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
|
||||
# types with craft-media, so both stay on one rev.
|
||||
# Standard MIDI Files and the note/controller model, shared with the other craft apps.
|
||||
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] }
|
||||
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
egui = { version = "0.37" }
|
||||
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
|
||||
web-time = "1.1"
|
||||
@@ -91,11 +86,6 @@ debug = false
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
# symphonia's FLAC decoder overflows an i32 add on 32-bit LPC FLAC; the wrapped value is correct
|
||||
# (craft-audio README, "symphonia").
|
||||
[profile.dev.package.symphonia-bundle-flac]
|
||||
overflow-checks = false
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
|
||||
@@ -134,8 +134,8 @@ where things are.
|
||||
playlist per take, input monitoring through the full channel strip, and autosave with recovery.
|
||||
- **Time and tempo:** Bars|Beats, Min:Secs, Timecode (every rate, drop-frame included),
|
||||
Feet+Frames and Samples; tempo and meter maps with a tempo editor; linear, parabolic and
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Analysis and Identify Beat;
|
||||
pitch-preserving Elastic Audio warping.
|
||||
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
|
||||
pitch-preserving Elastic warping.
|
||||
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
|
||||
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
|
||||
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
|
||||
@@ -146,7 +146,8 @@ where things are.
|
||||
## Getting started
|
||||
|
||||
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
|
||||
from source with Rust 1.95 or later:
|
||||
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
|
||||
minimum remains Rust 1.90):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/soundcraft
|
||||
@@ -227,8 +228,7 @@ soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
|
||||
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
|
||||
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
|
||||
one's parameters and details, as JSON).
|
||||
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
|
||||
|
||||
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
|
||||
mismatches and clip source bounds across all playlists, including alternate takes. The report
|
||||
@@ -244,6 +244,7 @@ about egui, so the interface could be swapped for another one.
|
||||
| Crate | What it does |
|
||||
|---|---|
|
||||
| `soundcraft-time` | Timebases, tempo and meter maps, timecode, grid |
|
||||
| `soundcraft-audio-io` | Audio file formats and waveform overviews |
|
||||
| `soundcraft-midi` | Standard MIDI Files and MIDI operations |
|
||||
| `soundcraft-dsp` | Plugins, offline processing, meters, loudness, FFT |
|
||||
| `soundcraft-model` | The session document |
|
||||
|
||||
@@ -20,7 +20,7 @@ workspace = true
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
soundcraft-automation.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -11,30 +11,31 @@ use std::sync::OnceLock;
|
||||
use clap::builder::NonEmptyStringValueParser;
|
||||
use clap::error::ErrorKind;
|
||||
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||
use craft_audio::{DECODE_EXTENSIONS, ENCODE_EXTENSIONS};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_automation::ControlTarget;
|
||||
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
|
||||
|
||||
/// Help text that names the extensions in `list`, built once from craft-audio's
|
||||
/// [`DECODE_EXTENSIONS`] or [`ENCODE_EXTENSIONS`] so it can't drift from what the code accepts.
|
||||
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: &[&str], after: &str) -> &'static str {
|
||||
cell.get_or_init(|| format!("{before}({}){after}", list.join(", ")))
|
||||
/// Help text that names the extensions `list` gives, built once from
|
||||
/// [`FileFormat::readable_extensions`] or [`FileFormat::writable_extensions`] so it can't drift
|
||||
/// from what the code accepts.
|
||||
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: fn() -> Vec<&'static str>, after: &str) -> &'static str {
|
||||
cell.get_or_init(|| format!("{before}({}){after}", list().join(", ")))
|
||||
}
|
||||
|
||||
fn info_file_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "An audio file ", DECODE_EXTENSIONS, " or a .scraft session")
|
||||
with_formats(&C, "An audio file ", FileFormat::readable_extensions, " or a .scraft session")
|
||||
}
|
||||
|
||||
fn read_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to read ", DECODE_EXTENSIONS, "")
|
||||
with_formats(&C, "The audio file to read ", FileFormat::readable_extensions, "")
|
||||
}
|
||||
|
||||
fn write_help() -> &'static str {
|
||||
static C: OnceLock<String> = OnceLock::new();
|
||||
with_formats(&C, "The audio file to write; its extension picks the format ", ENCODE_EXTENSIONS, "")
|
||||
with_formats(&C, "The audio file to write; its extension picks the format ", FileFormat::writable_extensions, "")
|
||||
}
|
||||
|
||||
/// soundcraft-cli: SoundCraft without a window
|
||||
@@ -167,12 +168,6 @@ pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id, or label in any case, contains this text
|
||||
#[arg(value_parser = NonEmptyStringValueParser::new())]
|
||||
pub(crate) filter: Option<String>,
|
||||
/// Print a JSON array of each command's details, as describe prints them
|
||||
#[arg(long)]
|
||||
pub(crate) json: bool,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long, requires = "json")]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Print one engine command's details as JSON
|
||||
@@ -360,7 +355,7 @@ pub(crate) enum BitDepthArg {
|
||||
Float32,
|
||||
}
|
||||
|
||||
impl From<BitDepthArg> for craft_audio::BitDepth {
|
||||
impl From<BitDepthArg> for soundcraft_audio_io::BitDepth {
|
||||
fn from(depth: BitDepthArg) -> Self {
|
||||
match depth {
|
||||
BitDepthArg::Int16 => Self::Int16,
|
||||
|
||||
@@ -7,15 +7,11 @@ use std::io::Write;
|
||||
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
|
||||
let filter = args.filter.clone().unwrap_or_default();
|
||||
let e = Engine::default();
|
||||
let infos = soundcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
|
||||
.map(|c| c.info(&e));
|
||||
if args.json {
|
||||
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
|
||||
return;
|
||||
}
|
||||
for i in infos {
|
||||
for c in soundcraft_engine::command_specs() {
|
||||
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let i = c.info(&e);
|
||||
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,7 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
|
||||
};
|
||||
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
|
||||
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let (info, mut buf) = craft_audio::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
|
||||
// The decoder replaces packets it cannot decode with silence of their length and counts them.
|
||||
// Such a file is not a copy of what was recorded: never let it replace its source, and say so
|
||||
// when it is written elsewhere.
|
||||
if info.damaged > 0 {
|
||||
if args.in_place {
|
||||
return Err(ConvertError::Damaged { input: input.clone(), packets: info.damaged }.into());
|
||||
}
|
||||
log::warn!("{}: {} damaged packet(s) decoded as silence", input.display(), info.damaged);
|
||||
}
|
||||
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
|
||||
let source_rate = buf.sample_rate;
|
||||
if let Some(rate) = rate {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
|
||||
@@ -37,22 +28,17 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
|
||||
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
|
||||
// Lossy only for the message: an extension that is not valid Unicode names no format.
|
||||
let out_ext = output.extension().map_or_else(|| "wav".into(), |e| e.to_string_lossy().to_ascii_lowercase());
|
||||
let Some(format) = craft_audio::encode_format_for(&out_ext) else {
|
||||
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
|
||||
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
|
||||
};
|
||||
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
|
||||
// samples, so it moves to the output rate.
|
||||
let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither);
|
||||
// The source's speakers, not the usual ones for its channel count (5.1 with side surrounds
|
||||
// stays that). Channels with no speaker assignment get the format's default.
|
||||
if info.layout.is_ordered() {
|
||||
opts = opts.with_layout(info.layout);
|
||||
}
|
||||
opts.bwf = info.bwf.map(|mut b| {
|
||||
let bwf = info.bwf.map(|mut b| {
|
||||
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
|
||||
b
|
||||
});
|
||||
let bytes = craft_audio::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
|
||||
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
|
||||
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
|
||||
Ok(())
|
||||
@@ -89,15 +75,13 @@ fn write_replacing(target: &std::path::Path, bytes: &[u8]) -> std::io::Result<()
|
||||
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
|
||||
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
|
||||
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
|
||||
fn auto_dither(source: craft_audio::SampleFormat, target: craft_audio::BitDepth, processed: bool) -> bool {
|
||||
use craft_audio::{BitDepth, SampleFormat};
|
||||
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
let target_bits = match target {
|
||||
BitDepth::Float32 => return false,
|
||||
BitDepth::Int16 => 16,
|
||||
BitDepth::Int24 => 24,
|
||||
BitDepth::Int32 => 32,
|
||||
// A depth added to craft-audio later: dither, as for any step that may lose resolution.
|
||||
_ => return true,
|
||||
};
|
||||
if processed {
|
||||
return true;
|
||||
@@ -107,8 +91,8 @@ fn auto_dither(source: craft_audio::SampleFormat, target: craft_audio::BitDepth,
|
||||
SampleFormat::Int16 => target_bits < 16,
|
||||
SampleFormat::Int24 => target_bits < 24,
|
||||
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
|
||||
// resolution, so treat it like float (as is any format added to craft-audio later).
|
||||
_ => true,
|
||||
// resolution, so treat it like float.
|
||||
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +102,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn convert_dithers_only_when_resolution_is_lost() {
|
||||
use craft_audio::{BitDepth, SampleFormat};
|
||||
use soundcraft_audio_io::{BitDepth, SampleFormat};
|
||||
// Same depth or wider: the samples fit exactly.
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
|
||||
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use craft_audio::AudioError;
|
||||
use soundcraft_audio_io::AudioError;
|
||||
use soundcraft_automation::RemoteError;
|
||||
use soundcraft_engine::EngineError;
|
||||
use soundcraft_engine::file_access::RootError;
|
||||
@@ -46,8 +46,6 @@ pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
|
||||
/// A `convert` option or output the tool refuses.
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
pub(crate) enum ConvertError {
|
||||
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), craft_audio::ENCODE_EXTENSIONS.join(", "))]
|
||||
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), soundcraft_audio_io::FileFormat::writable_extensions().join(", "))]
|
||||
OutputFormat { output: PathBuf, ext: String },
|
||||
#[error("{}: {packets} damaged packet(s) would be written as silence; not replacing the file (convert to another file to keep what decodes)", input.display())]
|
||||
Damaged { input: PathBuf, packets: u64 },
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ pub(crate) fn info(args: &InfoArgs, out: &mut dyn Write, err: &mut dyn Write) ->
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|source| CliError::File { path: path.clone(), source })?;
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str);
|
||||
let i = craft_audio::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
|
||||
let i = soundcraft_audio_io::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
|
||||
print_json(
|
||||
out,
|
||||
&json!({"format": soundcraft_engine::model::FileFormat::of(&i), "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
|
||||
&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
|
||||
args.compact,
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use craft_audio::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions};
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat};
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
|
||||
@@ -20,8 +20,8 @@ fn write_wav16(path: &Path, samples: &[i16]) {
|
||||
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
|
||||
*d = f32::from(*s) / 32768.0;
|
||||
}
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(path, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn convert(input: &Path, output: &Path, extra: &[&str]) {
|
||||
@@ -38,7 +38,7 @@ fn convert(input: &Path, output: &Path, extra: &[&str]) {
|
||||
|
||||
/// The decoded samples of `path`, scaled to 16-bit steps.
|
||||
fn steps16(path: &Path) -> Vec<f64> {
|
||||
let (_, buf) = craft_audio::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
|
||||
for (i, s) in buf.channels[0].iter_mut().enumerate() {
|
||||
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
|
||||
}
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int24);
|
||||
std::fs::write(&src, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
|
||||
let dithered = dir.join("d16.wav");
|
||||
convert(&src, &dithered, &["--bit-depth", "16"]);
|
||||
@@ -99,15 +99,18 @@ fn convert_keeps_the_bwf_time_stamp() {
|
||||
let dir = tmpdir("bext");
|
||||
let src = dir.join("take.wav");
|
||||
// 01:00:00:00 at 48 kHz.
|
||||
let bwf = BwfInfo::default()
|
||||
.with_description("take 3")
|
||||
.with_originator("recorder")
|
||||
.with_origination("2026-11-03", "10:00:00")
|
||||
.with_time_reference(172_800_000);
|
||||
let bwf = BwfInfo {
|
||||
description: "take 3".into(),
|
||||
originator: "recorder".into(),
|
||||
origination_date: "2026-11-03".into(),
|
||||
origination_time: "10:00:00".into(),
|
||||
time_reference: 172_800_000,
|
||||
..Default::default()
|
||||
};
|
||||
let buf = AudioBuffer::new(48_000, 1, 4800);
|
||||
let opts = EncodeOptions::default().with_bwf(bwf.clone());
|
||||
std::fs::write(&src, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let bext = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) };
|
||||
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
|
||||
|
||||
let out = dir.join("16.wav");
|
||||
convert(&src, &out, &["--bit-depth", "16"]);
|
||||
@@ -115,58 +118,6 @@ fn convert_keeps_the_bwf_time_stamp() {
|
||||
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
|
||||
let out = dir.join("44k.wav");
|
||||
convert(&src, &out, &["--rate", "44100"]);
|
||||
assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Twelve MPEG-1 Layer I frames (mono, 32 kHz, 48 bytes each) of silence. The seventh has the
|
||||
/// forbidden bit allocation 15 in every subband: the decoder writes silence for it and counts it.
|
||||
fn mp1_with_a_damaged_frame() -> Vec<u8> {
|
||||
let header = [0xFF, 0xFF, 0x18, 0xC0];
|
||||
let good = [&header[..], &[0; 44]].concat();
|
||||
let bad = [&header[..], &[0xFF; 16], &[0; 28]].concat();
|
||||
(0..12).flat_map(|i| if i == 6 { bad.clone() } else { good.clone() }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_warns_about_damaged_packets_and_never_replaces_the_source_with_them() {
|
||||
let dir = tmpdir("damaged");
|
||||
let src = dir.join("take.mp1");
|
||||
let bytes = mp1_with_a_damaged_frame();
|
||||
std::fs::write(&src, &bytes).unwrap();
|
||||
let run = |args: &[&std::ffi::OsStr]| {
|
||||
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).env("SOUNDCRAFT_NO_PREFS", "1").arg("convert").args(args).output().unwrap()
|
||||
};
|
||||
// To another file: written, with a warning that says how much was lost.
|
||||
let out = dir.join("take.wav");
|
||||
let o = run(&[src.as_os_str(), out.as_os_str()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let warning = format!("warning: {}: 1 damaged packet(s) decoded as silence\n", src.display());
|
||||
assert_eq!(String::from_utf8_lossy(&o.stderr), warning);
|
||||
assert_eq!(steps16(&out).len(), 12 * 384);
|
||||
// Over the source: refused, and the source is untouched.
|
||||
let o = run(&[src.as_os_str(), src.as_os_str(), "--in-place".as_ref()]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(stderr.starts_with(&format!("error: {}: 1 damaged packet(s) would be written as silence", src.display())), "{stderr}");
|
||||
assert_eq!(std::fs::read(&src).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_keeps_the_speaker_layout() {
|
||||
use craft_audio::ChannelLayout;
|
||||
let dir = tmpdir("layout");
|
||||
let (src, out) = (dir.join("side.wav"), dir.join("out.wav"));
|
||||
// 5.1 with side surrounds: not the layout a reader assumes for six channels.
|
||||
let opts = EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE);
|
||||
std::fs::write(&src, craft_audio::encode(&AudioBuffer::new(48_000, 6, 480), &opts).unwrap()).unwrap();
|
||||
let layout = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().layout;
|
||||
assert_eq!(layout(&src), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
convert(&src, &out, &[]);
|
||||
assert_eq!(layout(&out), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
let flac = dir.join("out.flac");
|
||||
convert(&src, &flac, &[]);
|
||||
assert_eq!(layout(&flac), ChannelLayout::SURROUND_5_1_SIDE);
|
||||
assert_eq!(bext(&out), Some(BwfInfo { time_reference: 158_760_000, ..bwf }));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use craft_audio::{AudioBuffer, BitDepth, EncodeOptions};
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
|
||||
/// What one run of the binary produced.
|
||||
struct Invoke {
|
||||
@@ -37,20 +37,20 @@ fn sandbox(tag: &str) -> PathBuf {
|
||||
for (i, d) in buf.channels[0].iter_mut().enumerate() {
|
||||
*d = i as f32 / 400.0;
|
||||
}
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(dir.join("in.wav"), craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(dir.join("in.wav"), soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
std::fs::write(dir.join("s.txt"), "track.new {}\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// The sample rate of the WAV `name` in `dir`.
|
||||
fn rate_of(dir: &Path, name: &str) -> u32 {
|
||||
craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
|
||||
soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
|
||||
}
|
||||
|
||||
/// Bit depth of the WAV `name` in `dir`, as the probe reports the sample format.
|
||||
fn format_of(dir: &Path, name: &str) -> String {
|
||||
format!("{:?}", craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
|
||||
format!("{:?}", soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
|
||||
}
|
||||
|
||||
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
|
||||
@@ -186,10 +186,7 @@ fn repeated_flags_are_usage_errors_in_either_spelling() {
|
||||
fn convert_input_and_output_errors() {
|
||||
let dir = sandbox("convert-io");
|
||||
assert_fails(&invoke(&dir, &["convert", "nope.wav", "o.wav"]), &format!("nope.wav: {}", not_found()));
|
||||
assert_fails(
|
||||
&invoke(&dir, &["convert", "in.wav", "o.xyz"]),
|
||||
"o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, aifc, flac)",
|
||||
);
|
||||
assert_fails(&invoke(&dir, &["convert", "in.wav", "o.xyz"]), "o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, flac)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -253,31 +253,3 @@ fn script_bounce_without_range_covers_the_session() {
|
||||
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `commands --json` lists every command with the details `describe` prints for one (#744).
|
||||
#[test]
|
||||
fn commands_json_lists_what_describe_prints() {
|
||||
let json = |o: &Output| -> Value {
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
serde_json::from_slice(&o.stdout).unwrap()
|
||||
};
|
||||
let all = json(&run(&["commands", "--json"]));
|
||||
let all = all.as_array().unwrap();
|
||||
let table = run(&["commands"]);
|
||||
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
|
||||
let bounce = json(&run(&["describe", "file.bounce_mix"]));
|
||||
assert!(all.contains(&bounce), "{bounce}");
|
||||
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
|
||||
// The filter applies, and --compact prints one line.
|
||||
let compact = run(&["commands", "bounce", "--json", "--compact"]);
|
||||
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
|
||||
let some = json(&compact);
|
||||
let some = some.as_array().unwrap();
|
||||
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
|
||||
// No match is an empty list, still JSON.
|
||||
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
|
||||
// --compact shapes JSON output only.
|
||||
let bad = run(&["commands", "--compact"]);
|
||||
assert_eq!(bad.status.code(), Some(2));
|
||||
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
}
|
||||
|
||||
@@ -100,8 +100,9 @@ fn help_subcommand_shows_that_subcommands_help() {
|
||||
|
||||
#[test]
|
||||
fn help_names_every_format_the_command_reads_and_writes() {
|
||||
let read = format!("({})", craft_audio::DECODE_EXTENSIONS.join(", "));
|
||||
let write = format!("its extension picks the format ({})", craft_audio::ENCODE_EXTENSIONS.join(", "));
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
let read = format!("({})", FileFormat::readable_extensions().join(", "));
|
||||
let write = format!("its extension picks the format ({})", FileFormat::writable_extensions().join(", "));
|
||||
let convert = invoke(&["convert", "--help"]);
|
||||
let convert = assert_help(&convert);
|
||||
assert!(convert.contains(&read) && convert.contains(&write), "{convert}");
|
||||
@@ -150,7 +151,7 @@ fn every_subcommand_has_help_listing_its_arguments() {
|
||||
),
|
||||
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
|
||||
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
|
||||
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
|
||||
("commands", "commands [FILTER]", &["[FILTER]"]),
|
||||
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
|
||||
(
|
||||
"app",
|
||||
|
||||
@@ -15,7 +15,7 @@ path = "src/main.rs"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
soundcraft-engine = { workspace = true }
|
||||
craft-audio.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback = { workspace = true }
|
||||
soundcraft-model = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The browser shell: the eframe web runner around [`SoundApp`].
|
||||
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_engine::{Engine, io};
|
||||
use soundcraft_ui_egui::dialogs::Dialog;
|
||||
use soundcraft_ui_egui::sampler_window::SampleRead;
|
||||
@@ -95,7 +96,7 @@ impl Purpose {
|
||||
fn filter(self) -> (&'static str, Vec<&'static str>) {
|
||||
match self {
|
||||
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
|
||||
Self::Audio => ("Audio", craft_audio::DECODE_EXTENSIONS.to_vec()),
|
||||
Self::Audio => ("Audio", FileFormat::readable_extensions()),
|
||||
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
|
||||
}
|
||||
}
|
||||
@@ -234,7 +235,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Some("txt") => "text/plain",
|
||||
Some("json" | "scraft") => "application/json",
|
||||
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
|
||||
ext => ext.and_then(craft_audio::encode_format_for).map_or("application/octet-stream", craft_audio::EncodeFormat::mime_type),
|
||||
ext => ext.and_then(FileFormat::from_extension).map_or("application/octet-stream", FileFormat::mime_type),
|
||||
});
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
|
||||
@@ -16,7 +16,7 @@ asio = ["soundcraft-playback/asio"]
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-ui-egui.workspace = true
|
||||
soundcraft-playback.workspace = true
|
||||
soundcraft-automation.workspace = true
|
||||
|
||||
@@ -166,7 +166,13 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// Control characters are written escaped: a message quoting a session file (a plugin id, a
|
||||
// path) must not start a line of its own in the log or steer the terminal.
|
||||
let mut escaped = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
if c.is_control() { escaped.extend(c.escape_default()) } else { escaped.push(c) }
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {escaped}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
|
||||
@@ -158,8 +158,15 @@ fn a_line_carries_time_level_thread_target_and_message() {
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "soundcraft_mix", "eq_7band: the plugin did not accept its stored state"),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A message with breaks of its own stays one record; the record still ends in exactly one newline.
|
||||
assert!(
|
||||
format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\\nb\n"),
|
||||
"a break inside the message is escaped, the final one dropped"
|
||||
);
|
||||
assert!(
|
||||
format_line("t", log::Level::Error, "w", "x", "plugin \u{1b}[2J\r\n2026 INFO forged")
|
||||
.ends_with("x: plugin \\u{1b}[2J\\r\\n2026 INFO forged\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,7 @@ mod native_menu;
|
||||
mod switch;
|
||||
|
||||
use env::Env;
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
|
||||
use std::sync::Arc;
|
||||
@@ -107,11 +108,9 @@ impl eframe::App for App {
|
||||
logger.report_audio_thread();
|
||||
}
|
||||
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here, and
|
||||
// start the helper of an update that waits for the app to close.
|
||||
// returns: release the player's plugin instances and shut plugin hosting down here.
|
||||
self.0.player = None;
|
||||
soundcraft_engine::shutdown_plugin_hosts();
|
||||
self.0.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,13 +151,13 @@ fn services() -> Services {
|
||||
let d = match purpose {
|
||||
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
|
||||
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
|
||||
_ => d.add_filter("Audio", craft_audio::DECODE_EXTENSIONS),
|
||||
_ => d.add_filter("Audio", &FileFormat::readable_extensions()),
|
||||
};
|
||||
d.pick_file()
|
||||
})),
|
||||
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
|
||||
let mut d = match purpose {
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", craft_audio::ENCODE_EXTENSIONS),
|
||||
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &FileFormat::writable_extensions()),
|
||||
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
|
||||
};
|
||||
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
|
||||
|
||||
@@ -33,7 +33,6 @@ impl NativeMenu {
|
||||
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
|
||||
let app_menu = Submenu::new("SoundCraft", true);
|
||||
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
|
||||
native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
|
||||
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
|
||||
app_menu.append(&PredefinedMenuItem::separator())?;
|
||||
@@ -95,8 +94,8 @@ impl NativeMenu {
|
||||
&& let Some(command) = &entry.command
|
||||
&& menus::item_state(app, &entry.path, Some(command)).0
|
||||
{
|
||||
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
|
||||
// Application-menu window entries open their window rather than toggling it.
|
||||
if entry.path.is_empty() && command.starts_with("window.") {
|
||||
// The application menu's About / Session Info open their window (never close it).
|
||||
let _ = app.run(command, serde_json::json!({"value": true}));
|
||||
} else {
|
||||
menus::invoke_menu(app, command, &entry.path);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "soundcraft-audio-io"
|
||||
description = "SoundCraft audio file I/O: WAV/BWF/RF64, AIFF/AIFF-C native readers and writers, FLAC encoder, symphonia-backed decode of compressed formats, waveform peaks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
symphonia.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
|
||||
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
/// AIFF-C version 1 timestamp for the FVER chunk.
|
||||
const AIFC_VERSION_1: u32 = 0xA280_5140;
|
||||
const MAX_SAMPLE_RATE: f64 = 10_000_000.0;
|
||||
|
||||
pub(crate) struct AiffLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
pub(crate) enum AiffParse {
|
||||
Native(AiffLayout),
|
||||
/// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia.
|
||||
Foreign,
|
||||
}
|
||||
|
||||
/// True when the bytes start like an AIFF or AIFF-C file.
|
||||
pub(crate) fn is_aiff(b: &[u8]) -> bool {
|
||||
tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC")
|
||||
}
|
||||
|
||||
/// Decode an 80-bit IEEE 754 extended-precision float.
|
||||
pub(crate) fn extended_to_f64(b: &[u8]) -> Option<f64> {
|
||||
let se = be_u16(b, 0)?;
|
||||
let hi = u64::from(be_u32(b, 2)?);
|
||||
let lo = u64::from(be_u32(b, 6)?);
|
||||
let mant = (hi << 32) | lo;
|
||||
if mant == 0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let exp = i32::from(se & 0x7FFF);
|
||||
if exp == 0x7FFF {
|
||||
return None; // inf / NaN
|
||||
}
|
||||
let v = mant as f64 * 2f64.powi(exp - 16383 - 63);
|
||||
Some(if se & 0x8000 != 0 { -v } else { v })
|
||||
}
|
||||
|
||||
/// Encode a positive integer as an 80-bit IEEE 754 extended float (exact).
|
||||
pub(crate) fn u32_to_extended(r: u32) -> [u8; 10] {
|
||||
let mut out = [0u8; 10];
|
||||
if r == 0 {
|
||||
return out;
|
||||
}
|
||||
let e = 31 - r.leading_zeros();
|
||||
let exp = (16383 + e) as u16;
|
||||
let mant = u64::from(r) << (63 - e);
|
||||
out[..2].copy_from_slice(&exp.to_be_bytes());
|
||||
out[2..].copy_from_slice(&mant.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<AiffParse> {
|
||||
if !is_aiff(b) {
|
||||
return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into()));
|
||||
}
|
||||
let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC");
|
||||
let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None;
|
||||
let mut ssnd: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), be_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let declared = usize::try_from(size32).unwrap_or(usize::MAX);
|
||||
let len = declared.min(remaining);
|
||||
let c = b.get(body..body + len).unwrap_or_default();
|
||||
match &id {
|
||||
b"COMM" => {
|
||||
let short = || AudioError::Malformed("COMM chunk too short".into());
|
||||
let channels = be_u16(c, 0).ok_or_else(short)? as i16;
|
||||
let frames = be_u32(c, 2).ok_or_else(short)?;
|
||||
let bits = be_u16(c, 6).ok_or_else(short)? as i16;
|
||||
let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?;
|
||||
let compression = if is_aifc { tag(c, 18).ok_or_else(short)? } else { *b"NONE" };
|
||||
comm = Some((channels, frames, bits, rate, compression));
|
||||
}
|
||||
b"SSND" if ssnd.is_none() => {
|
||||
let offset = usize::try_from(be_u32(c, 0).unwrap_or(0)).unwrap_or(usize::MAX);
|
||||
let start = body.saturating_add(8).saturating_add(offset).min(b.len());
|
||||
let data_len = len.saturating_sub(8).saturating_sub(offset);
|
||||
ssnd = Some((start, data_len));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
pos = match body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?;
|
||||
if channels <= 0 {
|
||||
return Err(AudioError::Malformed("AIFF declares zero or negative channels".into()));
|
||||
}
|
||||
if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) {
|
||||
return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range")));
|
||||
}
|
||||
let sample_rate = rate.round() as u32;
|
||||
let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> {
|
||||
if !(1..=32).contains(&bits) {
|
||||
return Err(AudioError::Unsupported(format!("AIFF sample size {bits}")));
|
||||
}
|
||||
let bytes = (bits as usize).div_ceil(8);
|
||||
let sf = match bits {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf))
|
||||
};
|
||||
let (kind, sample_format) = match &compression {
|
||||
b"NONE" | b"twos" => int_kind(true)?,
|
||||
b"sowt" => int_kind(false)?,
|
||||
b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32),
|
||||
b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64),
|
||||
_ => return Ok(AiffParse::Foreign),
|
||||
};
|
||||
let (data_start, data_len) = ssnd.unwrap_or((b.len(), 0));
|
||||
let block = kind.bytes().saturating_mul(channels as usize).max(1);
|
||||
let frames = u64::from(frames).min((data_len / block) as u64);
|
||||
Ok(AiffParse::Native(AiffLayout {
|
||||
info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let frames = buf.frames();
|
||||
let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len();
|
||||
let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames);
|
||||
let total = head_len.saturating_add(data_len).saturating_add(1);
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
|
||||
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// An AIFF written block by block: a placeholder header first, then the real one (the same
|
||||
/// length) written over it by [`Writer::finish`].
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
depth: BitDepth,
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
|
||||
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
header(self.channels, self.sample_rate, self.depth, total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
}
|
||||
let head = header(self.channels, self.sample_rate, self.depth, self.frames)?;
|
||||
self.out.seek(SeekFrom::Start(self.start))?;
|
||||
self.out.write_all(&head)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't
|
||||
/// depend on `frames`.
|
||||
fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result<Vec<u8>> {
|
||||
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
|
||||
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
|
||||
let (bits, is_float) = sample_format(depth);
|
||||
let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames);
|
||||
|
||||
let mut comm = Vec::with_capacity(40);
|
||||
comm.extend_from_slice(&channels_i16.to_be_bytes());
|
||||
comm.extend_from_slice(&frames_u32.to_be_bytes());
|
||||
comm.extend_from_slice(&bits.to_be_bytes());
|
||||
comm.extend_from_slice(&u32_to_extended(sample_rate));
|
||||
if is_float {
|
||||
comm.extend_from_slice(b"fl32");
|
||||
let name = b"32-bit float";
|
||||
comm.push(name.len() as u8);
|
||||
comm.extend_from_slice(name);
|
||||
if (name.len() + 1) % 2 == 1 {
|
||||
comm.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(128);
|
||||
head.extend_from_slice(b"FORM");
|
||||
head.extend_from_slice(&[0, 0, 0, 0]); // patched below
|
||||
head.extend_from_slice(if is_float { b"AIFC" } else { b"AIFF" });
|
||||
if is_float {
|
||||
push_chunk(&mut head, b"FVER", &AIFC_VERSION_1.to_be_bytes());
|
||||
}
|
||||
push_chunk(&mut head, b"COMM", &comm);
|
||||
head.extend_from_slice(b"SSND");
|
||||
let ssnd_len = u32::try_from(data_len.saturating_add(8)).map_err(|_| AudioError::TooLarge("AIFF data exceeds 4 GiB".into()))?;
|
||||
head.extend_from_slice(&ssnd_len.to_be_bytes());
|
||||
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
|
||||
|
||||
let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1);
|
||||
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
|
||||
if let Some(slot) = head.get_mut(4..8) {
|
||||
slot.copy_from_slice(&form_len.to_be_bytes());
|
||||
}
|
||||
Ok(head)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Planar `f32` audio buffer.
|
||||
|
||||
/// Planar f32 audio.
|
||||
///
|
||||
/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the
|
||||
/// shortest channel so a ragged buffer never causes out-of-bounds access.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct AudioBuffer {
|
||||
/// Sample rate in Hz.
|
||||
pub sample_rate: u32,
|
||||
/// One `Vec` of samples per channel.
|
||||
pub channels: Vec<Vec<f32>>,
|
||||
}
|
||||
|
||||
impl AudioBuffer {
|
||||
/// A silent buffer with `channels` channels of `frames` frames each.
|
||||
pub fn new(sample_rate: u32, channels: usize, frames: usize) -> Self {
|
||||
Self { sample_rate, channels: vec![vec![0.0; frames]; channels] }
|
||||
}
|
||||
|
||||
/// Number of frames (the length of the shortest channel; 0 when there are no channels).
|
||||
pub fn frames(&self) -> usize {
|
||||
self.channels.iter().map(Vec::len).min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn num_channels(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
||||
/// Duration in seconds (0 when the sample rate is 0).
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.frames() as f64 / f64::from(self.sample_rate)
|
||||
}
|
||||
|
||||
/// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`).
|
||||
pub fn interleaved(&self) -> Vec<f32> {
|
||||
let frames = self.frames();
|
||||
let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len()));
|
||||
for f in 0..frames {
|
||||
for ch in &self.channels {
|
||||
out.push(ch.get(f).copied().unwrap_or(0.0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a planar buffer from interleaved samples. A trailing partial frame is dropped;
|
||||
/// `channels == 0` yields an empty buffer.
|
||||
pub fn from_interleaved(sr: u32, channels: usize, data: &[f32]) -> Self {
|
||||
if channels == 0 {
|
||||
return Self { sample_rate: sr, channels: Vec::new() };
|
||||
}
|
||||
let frames = data.len() / channels;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(channels) {
|
||||
for (ch, &s) in out.iter_mut().zip(frame) {
|
||||
ch.push(s);
|
||||
}
|
||||
}
|
||||
Self { sample_rate: sr, channels: out }
|
||||
}
|
||||
|
||||
/// Absolute peak over all channels (NaN samples are ignored).
|
||||
pub fn peak(&self) -> f32 {
|
||||
self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
|
||||
//! a packet table or a packet claims, and a failed allocation aborts the process.
|
||||
|
||||
use crate::pcm::{be_u16, be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
|
||||
/// than the file holds.
|
||||
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
|
||||
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn malformed(what: &str) -> AudioError {
|
||||
AudioError::Malformed(format!("CAF {what} is larger than the file"))
|
||||
}
|
||||
|
||||
/// Walk the chunks the way the CAF reader reads them: it advances by the bytes it parses, not by
|
||||
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
|
||||
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
|
||||
// Whether the packet table lists each packet's size and frame count (variable per packet).
|
||||
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
|
||||
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
|
||||
let body = pos + 12;
|
||||
let used = match kind {
|
||||
b"desc" if size == 32 => {
|
||||
lists_sizes = be_u32(b, body + 16) == Some(0);
|
||||
lists_frames = be_u32(b, body + 20) == Some(0);
|
||||
described = true;
|
||||
Some(32)
|
||||
}
|
||||
b"data" if size == u64::MAX => Some(4),
|
||||
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
|
||||
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
|
||||
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
|
||||
b"desc" | b"pakt" | b"chan" => None,
|
||||
_ => Some(size),
|
||||
};
|
||||
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
|
||||
Some(next) if next < b.len() => pos = next,
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
|
||||
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
|
||||
/// it would refuse after that, or that runs past the file, is refused first.
|
||||
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
|
||||
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
|
||||
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
|
||||
let mut at = body + 24;
|
||||
if packets > b.len().saturating_sub(at) as u64 {
|
||||
return Err(malformed("packet table"));
|
||||
}
|
||||
if !lists_sizes && !lists_frames {
|
||||
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
|
||||
}
|
||||
for _ in 0..packets {
|
||||
if lists_sizes {
|
||||
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
if size > b.len() as u64 {
|
||||
return Err(malformed("packet"));
|
||||
}
|
||||
at += len;
|
||||
}
|
||||
if lists_frames {
|
||||
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
|
||||
at += len;
|
||||
}
|
||||
}
|
||||
Ok(Some((at - body) as u64))
|
||||
}
|
||||
|
||||
/// A CAF variable-length integer at `at` and its length in bytes.
|
||||
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
|
||||
let mut v = 0u64;
|
||||
for i in 0..9 {
|
||||
let x = *b.get(at + i)?;
|
||||
v |= u64::from(x & 0x7f);
|
||||
if x & 0x80 == 0 {
|
||||
return Some((v, i + 1));
|
||||
}
|
||||
v <<= 7;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn rejects_cookie_size_above_signed_range() {
|
||||
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
|
||||
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
|
||||
|
||||
assert!(super::check(&bytes, 0).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
|
||||
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
|
||||
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
|
||||
|
||||
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const BLOCK_SIZE: usize = 4096;
|
||||
const MAX_PARTITION_ORDER: u32 = 6;
|
||||
|
||||
struct BitWriter {
|
||||
out: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
fn new() -> Self {
|
||||
Self { out: Vec::new(), acc: 0, nbits: 0 }
|
||||
}
|
||||
|
||||
/// Write the low `n` (≤ 32) bits of `v`, MSB first.
|
||||
fn write(&mut self, v: u64, n: u32) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let n = n.min(32);
|
||||
let mask = (1u64 << n) - 1;
|
||||
self.acc = (self.acc << n) | (v & mask);
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.out.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
|
||||
fn write_signed(&mut self, v: i64, n: u32) {
|
||||
self.write(v as u64, n);
|
||||
}
|
||||
|
||||
/// `q` zero bits followed by a one bit.
|
||||
fn write_unary(&mut self, mut q: u64) {
|
||||
while q >= 32 {
|
||||
self.write(0, 32);
|
||||
q -= 32;
|
||||
}
|
||||
self.write(1, (q as u32) + 1);
|
||||
}
|
||||
|
||||
fn align(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
let pad = 8 - self.nbits;
|
||||
self.write(0, pad);
|
||||
}
|
||||
}
|
||||
|
||||
fn into_bytes(mut self) -> Vec<u8> {
|
||||
self.align();
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
fn crc8(data: &[u8]) -> u8 {
|
||||
let mut crc: u8 = 0;
|
||||
for &b in data {
|
||||
crc ^= b;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
fn crc16(data: &[u8]) -> u16 {
|
||||
let mut crc: u16 = 0;
|
||||
for &b in data {
|
||||
crc ^= u16::from(b) << 8;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 };
|
||||
}
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// FLAC's UTF-8-like variable-length integer coding (up to 36 bits).
|
||||
fn write_utf8_number(w: &mut BitWriter, v: u64) {
|
||||
if v < 0x80 {
|
||||
w.write(v, 8);
|
||||
return;
|
||||
}
|
||||
// Payload bits available for an n-byte coding: 5n + 1 (n = 2..=6), 36 for n = 7.
|
||||
let n: u32 = (2..=7).find(|&n| if n == 7 { true } else { v < (1u64 << (5 * n + 1)) }).unwrap_or(7);
|
||||
let cont_bits = 6 * (n - 1);
|
||||
let lead_payload = if n == 7 { 0 } else { 7 - n };
|
||||
let prefix: u64 = (0xFFu64 << (8 - n)) & 0xFF;
|
||||
let lead = prefix | ((v >> cont_bits) & ((1u64 << lead_payload) - 1));
|
||||
w.write(lead, 8);
|
||||
for i in (0..n - 1).rev() {
|
||||
w.write(0x80 | ((v >> (6 * i)) & 0x3F), 8);
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_rate_code(r: u32) -> (u64, Option<(u64, u32)>) {
|
||||
match r {
|
||||
88_200 => (1, None),
|
||||
176_400 => (2, None),
|
||||
192_000 => (3, None),
|
||||
8_000 => (4, None),
|
||||
16_000 => (5, None),
|
||||
22_050 => (6, None),
|
||||
24_000 => (7, None),
|
||||
32_000 => (8, None),
|
||||
44_100 => (9, None),
|
||||
48_000 => (10, None),
|
||||
96_000 => (11, None),
|
||||
r if r % 1000 == 0 && r / 1000 <= 255 => (12, Some((u64::from(r / 1000), 8))),
|
||||
r if r <= 65_535 => (13, Some((u64::from(r), 16))),
|
||||
r if r % 10 == 0 && r / 10 <= 65_535 => (14, Some((u64::from(r / 10), 16))),
|
||||
_ => (0, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn bps_code(bps: u32) -> u64 {
|
||||
match bps {
|
||||
8 => 1,
|
||||
12 => 2,
|
||||
16 => 4,
|
||||
20 => 5,
|
||||
24 => 6,
|
||||
_ => 0, // from STREAMINFO
|
||||
}
|
||||
}
|
||||
|
||||
fn zigzag(r: i64) -> u64 {
|
||||
((r << 1) ^ (r >> 63)) as u64
|
||||
}
|
||||
|
||||
/// Fixed-predictor residuals for `order`, or `None` when any residual leaves the i32 range.
|
||||
fn fixed_residual(x: &[i64], order: usize) -> Option<Vec<i64>> {
|
||||
let mut res = Vec::with_capacity(x.len().saturating_sub(order));
|
||||
for n in order..x.len() {
|
||||
let g = |k: usize| x.get(n - k).copied().unwrap_or(0);
|
||||
let r = match order {
|
||||
0 => g(0),
|
||||
1 => g(0) - g(1),
|
||||
2 => g(0) - 2 * g(1) + g(2),
|
||||
3 => g(0) - 3 * g(1) + 3 * g(2) - g(3),
|
||||
_ => g(0) - 4 * g(1) + 6 * g(2) - 4 * g(3) + g(4),
|
||||
};
|
||||
if r < i64::from(i32::MIN) || r > i64::from(i32::MAX) {
|
||||
return None;
|
||||
}
|
||||
res.push(r);
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
/// Best Rice parameter and its cost in bits for one partition.
|
||||
fn best_rice(part: &[u64]) -> (u32, u64) {
|
||||
if part.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
let sum: u64 = part.iter().sum();
|
||||
let mean = sum / part.len() as u64;
|
||||
let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() };
|
||||
let mut best = (0u32, u64::MAX);
|
||||
for k in guess.saturating_sub(1)..=(guess + 1).min(30) {
|
||||
let cost = part.len() as u64 * u64::from(k + 1) + part.iter().map(|&u| u >> k).sum::<u64>();
|
||||
if cost < best.1 {
|
||||
best = (k, cost);
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Residual coding plan: partition order, per-partition parameters, total bits.
|
||||
struct RicePlan {
|
||||
order: u32,
|
||||
params: Vec<u32>,
|
||||
bits: u64,
|
||||
wide: bool,
|
||||
}
|
||||
|
||||
fn plan_residual(u: &[u64], block_len: usize, pred_order: usize) -> RicePlan {
|
||||
let mut best: Option<RicePlan> = None;
|
||||
for p in 0..=MAX_PARTITION_ORDER {
|
||||
let parts = 1usize << p;
|
||||
if !block_len.is_multiple_of(parts) || block_len / parts <= pred_order {
|
||||
break;
|
||||
}
|
||||
let per = block_len / parts;
|
||||
let mut params = Vec::with_capacity(parts);
|
||||
let mut bits = 0u64;
|
||||
let mut start = 0usize;
|
||||
for i in 0..parts {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
let part = u.get(start..start + len).unwrap_or_default();
|
||||
start += len;
|
||||
let (k, cost) = best_rice(part);
|
||||
params.push(k);
|
||||
bits += cost;
|
||||
}
|
||||
let wide = params.iter().any(|&k| k > 14);
|
||||
bits += 6 + parts as u64 * if wide { 5 } else { 4 };
|
||||
if best.as_ref().is_none_or(|b| bits < b.bits) {
|
||||
best = Some(RicePlan { order: p, params, bits, wide });
|
||||
}
|
||||
}
|
||||
best.unwrap_or(RicePlan { order: 0, params: vec![0], bits: u64::MAX, wide: false })
|
||||
}
|
||||
|
||||
fn write_residual(w: &mut BitWriter, u: &[u64], block_len: usize, pred_order: usize, plan: &RicePlan) {
|
||||
w.write(u64::from(plan.wide), 2);
|
||||
w.write(u64::from(plan.order), 4);
|
||||
let parts = 1usize << plan.order;
|
||||
let per = block_len / parts;
|
||||
let mut start = 0usize;
|
||||
for (i, &k) in plan.params.iter().enumerate() {
|
||||
let len = if i == 0 { per - pred_order } else { per };
|
||||
w.write(u64::from(k), if plan.wide { 5 } else { 4 });
|
||||
for &v in u.get(start..start + len).unwrap_or_default() {
|
||||
w.write_unary(v >> k);
|
||||
w.write(v, k);
|
||||
}
|
||||
start += len;
|
||||
}
|
||||
}
|
||||
|
||||
fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
|
||||
let n = x.len();
|
||||
let first = x.first().copied().unwrap_or(0);
|
||||
if x.iter().all(|&s| s == first) {
|
||||
w.write(0, 1);
|
||||
w.write(0, 6);
|
||||
w.write(0, 1);
|
||||
w.write_signed(first, bps);
|
||||
return;
|
||||
}
|
||||
let verbatim_bits = n as u64 * u64::from(bps);
|
||||
let mut best: Option<(usize, Vec<u64>, RicePlan)> = None;
|
||||
for order in 0..=4usize.min(n.saturating_sub(1)) {
|
||||
let Some(res) = fixed_residual(x, order) else { continue };
|
||||
let u: Vec<u64> = res.into_iter().map(zigzag).collect();
|
||||
let plan = plan_residual(&u, n, order);
|
||||
let bits = plan.bits.saturating_add(order as u64 * u64::from(bps));
|
||||
if bits < verbatim_bits && best.as_ref().is_none_or(|b| bits < b.2.bits.saturating_add(b.0 as u64 * u64::from(bps))) {
|
||||
best = Some((order, u, plan));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((order, u, plan)) => {
|
||||
w.write(0, 1);
|
||||
w.write(8 + order as u64, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x.iter().take(order) {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
write_residual(w, &u, n, order, &plan);
|
||||
}
|
||||
None => {
|
||||
w.write(0, 1);
|
||||
w.write(1, 6);
|
||||
w.write(0, 1);
|
||||
for &s in x {
|
||||
w.write_signed(s, bps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks that FLAC can hold the layout; returns the bits per sample.
|
||||
fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result<u32> {
|
||||
crate::pcm::validate_layout(channels, sample_rate)?;
|
||||
if channels > 8 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
|
||||
}
|
||||
if sample_rate > 655_350 {
|
||||
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
|
||||
}
|
||||
match depth {
|
||||
BitDepth::Int16 => Ok(16),
|
||||
BitDepth::Int24 => Ok(24),
|
||||
BitDepth::Int32 => Ok(32),
|
||||
BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec<u8> {
|
||||
let mut si = BitWriter::new();
|
||||
si.write(1, 1); // last metadata block
|
||||
si.write(0, 7); // STREAMINFO
|
||||
si.write(34, 24);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(BLOCK_SIZE as u64, 16);
|
||||
si.write(0, 24);
|
||||
si.write(0, 24);
|
||||
si.write(u64::from(sample_rate), 20);
|
||||
si.write(channels as u64 - 1, 3);
|
||||
si.write(u64::from(bps - 1), 5);
|
||||
si.write(total >> 32, 4);
|
||||
si.write(total & 0xFFFF_FFFF, 32);
|
||||
for _ in 0..4 {
|
||||
si.write(0, 32); // MD5 unknown
|
||||
}
|
||||
si.into_bytes()
|
||||
}
|
||||
|
||||
/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count.
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
bps: u32,
|
||||
quantizers: Vec<Quantizer>,
|
||||
pending: Vec<Vec<i64>>,
|
||||
frames: u64,
|
||||
frame_no: u64,
|
||||
/// Set when a frame write has failed: `write_frame` clears the pending samples before
|
||||
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
|
||||
/// every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
|
||||
let bps = check(channels, sample_rate, depth)?;
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(b"fLaC")?;
|
||||
out.write_all(&stream_info(channels, sample_rate, bps, 0))?;
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
bps,
|
||||
quantizers: Quantizer::per_channel(bps, dither, channels),
|
||||
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
|
||||
frames: 0,
|
||||
frame_no: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
if total >= 1u64 << 36 {
|
||||
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
|
||||
/// fit (see [`Writer::check_room`]).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
let mut done = 0;
|
||||
while done < frames {
|
||||
let held = self.pending.first().map_or(0, Vec::len);
|
||||
let n = (BLOCK_SIZE - held).min(frames - done);
|
||||
for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() {
|
||||
let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default();
|
||||
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
|
||||
}
|
||||
done += n;
|
||||
if held + n == BLOCK_SIZE
|
||||
&& let Err(e) = self.write_frame()
|
||||
{
|
||||
self.failed = true;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_frame(&mut self) -> Result<()> {
|
||||
let len = self.pending.first().map_or(0, Vec::len);
|
||||
if len == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let (sr_code, sr_extra) = sample_rate_code(self.sample_rate);
|
||||
let mut w = BitWriter::new();
|
||||
w.write(0x3FFE, 14);
|
||||
w.write(0, 1);
|
||||
w.write(0, 1); // fixed blocksize stream
|
||||
w.write(7, 4); // 16-bit (blocksize-1) follows
|
||||
w.write(sr_code, 4);
|
||||
w.write(self.channels as u64 - 1, 4);
|
||||
w.write(bps_code(self.bps), 3);
|
||||
w.write(0, 1);
|
||||
write_utf8_number(&mut w, self.frame_no);
|
||||
w.write(len as u64 - 1, 16);
|
||||
if let Some((v, n)) = sr_extra {
|
||||
w.write(v, n);
|
||||
}
|
||||
let header = w.into_bytes();
|
||||
let mut w = BitWriter::new();
|
||||
for &b in &header {
|
||||
w.write(u64::from(b), 8);
|
||||
}
|
||||
w.write(u64::from(crc8(&header)), 8);
|
||||
for p in &mut self.pending {
|
||||
write_subframe(&mut w, p, self.bps);
|
||||
p.clear();
|
||||
}
|
||||
let mut frame = w.into_bytes();
|
||||
let crc = crc16(&frame);
|
||||
frame.extend_from_slice(&crc.to_be_bytes());
|
||||
self.out.write_all(&frame)?;
|
||||
self.frame_no += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
self.write_frame()?;
|
||||
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
|
||||
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
|
||||
self.out.write_all(&info)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let bps = check(buf.channels.len(), buf.sample_rate, depth)?;
|
||||
let frames = buf.frames();
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2)
|
||||
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
|
||||
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
|
||||
/// metadata block holds: symphonia allocates each field's length before reading it.
|
||||
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
|
||||
let mut pos = at + 4;
|
||||
while let Some(head) = be_u32(b, pos) {
|
||||
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
|
||||
let end = (body + len).min(b.len());
|
||||
let overruns = match (head >> 24) & 0x7f {
|
||||
4 => comments_overrun(b, body, end),
|
||||
// Picture: type, MIME type, description, four size fields, data.
|
||||
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
|
||||
_ => false,
|
||||
};
|
||||
if overruns {
|
||||
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
|
||||
}
|
||||
if head >> 31 == 1 {
|
||||
break;
|
||||
}
|
||||
pos = body + len;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
|
||||
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
|
||||
/// frame, so the caller answers them itself.
|
||||
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
|
||||
let mut pos = at.checked_add(4)?;
|
||||
let mut first = true;
|
||||
loop {
|
||||
let head = be_u32(b, pos)?;
|
||||
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
|
||||
let end = body.checked_add(len)?;
|
||||
if end > b.len() {
|
||||
return None;
|
||||
}
|
||||
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
|
||||
return None;
|
||||
}
|
||||
first = false;
|
||||
if head >> 31 == 1 {
|
||||
if end != b.len() {
|
||||
return None;
|
||||
}
|
||||
break;
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
|
||||
let si = at.checked_add(4 + 4 + 10)?;
|
||||
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
|
||||
let sample_rate = (packed >> 44) as u32;
|
||||
let channels = ((packed >> 41) & 0x7) as u16 + 1;
|
||||
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
|
||||
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
|
||||
return None;
|
||||
}
|
||||
let sample_format = match bps {
|
||||
1..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
|
||||
}
|
||||
|
||||
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
|
||||
/// the block holds.
|
||||
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
|
||||
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
|
||||
if vendor as usize > end - (body + 4) {
|
||||
return true;
|
||||
}
|
||||
let at = (body + 4).saturating_add(vendor as usize);
|
||||
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
|
||||
}
|
||||
|
||||
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
|
||||
/// the bytes skipped before each length, which `read` reads.
|
||||
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<u32>, gaps: impl IntoIterator<Item = usize>) -> bool {
|
||||
for gap in gaps {
|
||||
at = at.saturating_add(gap);
|
||||
let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
|
||||
at += 4;
|
||||
if n as usize > end - at {
|
||||
return true;
|
||||
}
|
||||
at += n as usize;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn crc_known_values() {
|
||||
// CRC-8/SMBUS and CRC-16/UMTS (FLAC's polynomials) of "123456789".
|
||||
assert_eq!(crc8(b"123456789"), 0xF4);
|
||||
assert_eq!(crc16(b"123456789"), 0xFEE8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_numbers() {
|
||||
let enc = |v| {
|
||||
let mut w = BitWriter::new();
|
||||
write_utf8_number(&mut w, v);
|
||||
w.into_bytes()
|
||||
};
|
||||
assert_eq!(enc(0x7F), vec![0x7F]);
|
||||
assert_eq!(enc(0x80), vec![0xC2, 0x80]);
|
||||
assert_eq!(enc(0x800), vec![0xE0, 0xA0, 0x80]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
//! SoundCraft audio file I/O.
|
||||
//!
|
||||
//! Bytes in, bytes out (no filesystem access, so the crate also builds for `wasm32`):
|
||||
//!
|
||||
//! * [`detect_format`] / [`probe`] / [`decode`] read WAV (PCM 8/16/24/32, float 32/64,
|
||||
//! `WAVE_FORMAT_EXTENSIBLE`, RF64/BW64, BWF `bext`) and AIFF/AIFF-C (big-endian, `sowt`,
|
||||
//! `fl32`/`fl64`) with native readers, and everything else (FLAC, MP3, Ogg Vorbis, AAC/ALAC in
|
||||
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
|
||||
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
|
||||
//! encoder), with optional TPDF dither.
|
||||
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
|
||||
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
|
||||
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
|
||||
//! of any length never has to fit in memory.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod aiff;
|
||||
mod buffer;
|
||||
mod caf;
|
||||
mod flac;
|
||||
mod mp4;
|
||||
mod pcm;
|
||||
pub mod peaks;
|
||||
mod symph;
|
||||
mod wav;
|
||||
|
||||
pub use buffer::AudioBuffer;
|
||||
use std::io::{Seek, Write};
|
||||
|
||||
/// Errors from probing, decoding and encoding.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AudioError {
|
||||
/// The format, codec or option is valid but not supported.
|
||||
#[error("unsupported audio: {0}")]
|
||||
Unsupported(String),
|
||||
/// The input is corrupt or not what it claims to be.
|
||||
#[error("malformed audio: {0}")]
|
||||
Malformed(String),
|
||||
/// The input (or requested output) exceeds a size limit.
|
||||
#[error("audio too large: {0}")]
|
||||
TooLarge(String),
|
||||
/// Encoding failed (e.g. an empty buffer).
|
||||
#[error("audio encode failed: {0}")]
|
||||
Encode(String),
|
||||
/// Writing the output failed.
|
||||
#[error("audio write failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Result alias for this crate.
|
||||
pub type Result<T> = std::result::Result<T, AudioError>;
|
||||
|
||||
/// Container / codec family of an audio file.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FileFormat {
|
||||
Wav,
|
||||
Aiff,
|
||||
Flac,
|
||||
Mp3,
|
||||
Ogg,
|
||||
Aac,
|
||||
Alac,
|
||||
Caf,
|
||||
/// Any other container `symphonia` reads: Matroska and WebM by name.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FileFormat {
|
||||
/// The formats [`decode`] reads, found by extension. Contents decide when they disagree.
|
||||
pub const READ: &'static [FileFormat] =
|
||||
&[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac, FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Caf, FileFormat::Other];
|
||||
/// The formats [`encode`] writes.
|
||||
pub const WRITE: &'static [FileFormat] = &[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac];
|
||||
|
||||
/// The extensions that name this format, lower case and without the dot; the first is the one
|
||||
/// a new file gets. ALAC shares AAC's MP4 container, so a `.m4a` name reads as AAC until the
|
||||
/// contents say otherwise.
|
||||
pub fn extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
FileFormat::Wav => &["wav", "wave", "bwf", "rf64"],
|
||||
FileFormat::Aiff => &["aif", "aiff", "aifc"],
|
||||
FileFormat::Flac => &["flac"],
|
||||
FileFormat::Mp3 => &["mp3", "mp2", "mp1"],
|
||||
FileFormat::Ogg => &["ogg", "oga"],
|
||||
FileFormat::Aac => &["m4a", "m4b", "aac", "mp4", "adts"],
|
||||
FileFormat::Alac => &["m4a"],
|
||||
FileFormat::Caf => &["caf"],
|
||||
FileFormat::Other => &["mkv", "webm"],
|
||||
}
|
||||
}
|
||||
|
||||
/// The name people know the format by, as menus show it.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Wav => "WAV",
|
||||
FileFormat::Aiff => "AIFF",
|
||||
FileFormat::Flac => "FLAC",
|
||||
FileFormat::Mp3 => "MP3",
|
||||
FileFormat::Ogg => "Ogg Vorbis",
|
||||
FileFormat::Aac => "AAC",
|
||||
FileFormat::Alac => "ALAC",
|
||||
FileFormat::Caf => "CAF",
|
||||
FileFormat::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
/// The media type a file of this format is served or downloaded as.
|
||||
pub fn mime_type(self) -> &'static str {
|
||||
match self {
|
||||
FileFormat::Wav => "audio/wav",
|
||||
FileFormat::Aiff => "audio/aiff",
|
||||
FileFormat::Flac => "audio/flac",
|
||||
FileFormat::Mp3 => "audio/mpeg",
|
||||
FileFormat::Ogg => "audio/ogg",
|
||||
FileFormat::Aac | FileFormat::Alac => "audio/mp4",
|
||||
FileFormat::Caf => "audio/x-caf",
|
||||
FileFormat::Other => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension a new file of this format gets.
|
||||
pub fn default_extension(self) -> &'static str {
|
||||
self.extensions().first().copied().unwrap_or("bin")
|
||||
}
|
||||
|
||||
/// The readable format an extension or format name names (`wav`, `.AIFF`; ASCII
|
||||
/// case-insensitive), or `None` when nothing reads it.
|
||||
pub fn from_extension(ext: &str) -> Option<FileFormat> {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
Self::READ.iter().copied().find(|f| f.extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
|
||||
}
|
||||
|
||||
/// The readable format `path`'s extension names, or `None` without a known extension.
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Option<FileFormat> {
|
||||
path.as_ref().extension().and_then(std::ffi::OsStr::to_str).and_then(Self::from_extension)
|
||||
}
|
||||
|
||||
/// The extensions [`encode`] writes this format under: the names whose readers expect what it
|
||||
/// writes. RF64 and AIFF-C names are left out because `encode` writes plain RIFF and AIFF
|
||||
/// headers for them.
|
||||
pub fn write_extensions(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
FileFormat::Wav => &["wav", "wave", "bwf"],
|
||||
FileFormat::Aiff => &["aif", "aiff"],
|
||||
FileFormat::Flac => &["flac"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The format [`encode`] writes for an extension or format name (ASCII case-insensitive), or
|
||||
/// `None` when it writes none.
|
||||
pub fn from_write_extension(ext: &str) -> Option<FileFormat> {
|
||||
let ext = ext.trim_start_matches('.');
|
||||
Self::WRITE.iter().copied().find(|f| f.write_extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
|
||||
}
|
||||
|
||||
/// Every extension of [`FileFormat::READ`], in order: what file pickers and browsers filter by.
|
||||
pub fn readable_extensions() -> Vec<&'static str> {
|
||||
Self::READ.iter().flat_map(|f| f.extensions().iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// Every extension of [`FileFormat::WRITE`], in order: what save dialogs, help and errors list.
|
||||
pub fn writable_extensions() -> Vec<&'static str> {
|
||||
Self::WRITE.iter().flat_map(|f| f.write_extensions().iter().copied()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored sample representation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum SampleFormat {
|
||||
Int8,
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
Float32,
|
||||
Float64,
|
||||
/// A lossy codec (MP3, AAC, Vorbis, ...): no fixed bit depth.
|
||||
Compressed,
|
||||
}
|
||||
|
||||
/// Broadcast Wave (`bext`) metadata.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BwfInfo {
|
||||
pub description: String,
|
||||
pub originator: String,
|
||||
pub originator_reference: String,
|
||||
/// `yyyy-mm-dd`.
|
||||
pub origination_date: String,
|
||||
/// `hh:mm:ss`.
|
||||
pub origination_time: String,
|
||||
/// Sample count since midnight of the first sample (the timestamp Pro Tools spots to).
|
||||
pub time_reference: u64,
|
||||
}
|
||||
|
||||
/// What [`probe`] learns about a file without decoding all of it.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AudioInfo {
|
||||
pub format: FileFormat,
|
||||
pub sample_format: SampleFormat,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u16,
|
||||
pub frames: u64,
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl AudioInfo {
|
||||
/// Duration in seconds.
|
||||
pub fn duration_secs(&self) -> f64 {
|
||||
if self.sample_rate == 0 { 0.0 } else { self.frames as f64 / f64::from(self.sample_rate) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit depth for [`encode`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum BitDepth {
|
||||
Int16,
|
||||
Int24,
|
||||
Int32,
|
||||
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
|
||||
Float32,
|
||||
}
|
||||
|
||||
/// Options for [`encode`].
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncodeOptions {
|
||||
/// `Wav`, `Aiff` or `Flac`; anything else is [`AudioError::Unsupported`].
|
||||
pub format: FileFormat,
|
||||
pub bit_depth: BitDepth,
|
||||
/// Add TPDF dither when quantizing to an integer depth.
|
||||
pub dither: bool,
|
||||
/// Write a BWF `bext` chunk (WAV only; ignored for other formats).
|
||||
pub bwf: Option<BwfInfo>,
|
||||
}
|
||||
|
||||
impl Default for EncodeOptions {
|
||||
fn default() -> Self {
|
||||
Self { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`)
|
||||
/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip.
|
||||
fn id3v2_len(b: &[u8]) -> Option<usize> {
|
||||
if b.get(..3)? != b"ID3" {
|
||||
return None;
|
||||
}
|
||||
let version = *b.get(3)?;
|
||||
let flags = *b.get(5)?;
|
||||
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
|
||||
let footer = version == 4 && flags & 0x10 != 0;
|
||||
Some(10 + size + if footer { 10 } else { 0 })
|
||||
}
|
||||
|
||||
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
|
||||
let head = |n: usize| b.get(..n);
|
||||
if wav::is_wav(b) {
|
||||
return Some(FileFormat::Wav);
|
||||
}
|
||||
if aiff::is_aiff(b) {
|
||||
return Some(FileFormat::Aiff);
|
||||
}
|
||||
match head(4)? {
|
||||
b"fLaC" => return Some(FileFormat::Flac),
|
||||
b"OggS" => return Some(FileFormat::Ogg),
|
||||
b"caff" => return Some(FileFormat::Caf),
|
||||
[0x1A, 0x45, 0xDF, 0xA3] => return Some(FileFormat::Other),
|
||||
_ => {}
|
||||
}
|
||||
if b.get(4..8) == Some(b"ftyp") {
|
||||
// MP4 / M4A: the sample entry inside `stsd` names the codec.
|
||||
let found = b.windows(4).position(|w| w == b"stsd");
|
||||
let codec = found.and_then(|p| b.get(p.checked_add(16)?..p.checked_add(20)?));
|
||||
return Some(if codec == Some(b"alac") { FileFormat::Alac } else { FileFormat::Aac });
|
||||
}
|
||||
if let Some(len) = id3v2_len(b) {
|
||||
if depth == 0
|
||||
&& let Some(f) = b.get(len..).and_then(|rest| detect_by_magic(rest, 1))
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
let (b0, b1) = (*b.first()?, *b.get(1)?);
|
||||
if b0 == 0xFF && b1 & 0xE0 == 0xE0 {
|
||||
let layer = (b1 >> 1) & 0x3;
|
||||
if b1 & 0xF0 == 0xF0 && layer == 0 {
|
||||
return Some(FileFormat::Aac); // ADTS
|
||||
}
|
||||
if layer != 0 {
|
||||
return Some(FileFormat::Mp3);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
|
||||
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
|
||||
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.and_then(FileFormat::from_extension).unwrap_or(FileFormat::Other))
|
||||
}
|
||||
|
||||
/// Read a file's format, sample rate, channel count, length and BWF metadata.
|
||||
pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => Ok(l.info),
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let mut info = symph::probe(bytes, format)?;
|
||||
info.bwf = bwf;
|
||||
Ok(info)
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => Ok(l.info),
|
||||
aiff::AiffParse::Foreign => symph::probe(bytes, format),
|
||||
},
|
||||
_ => symph::probe(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode a whole file to planar `f32`.
|
||||
pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let format = detect_format(bytes, ext_hint);
|
||||
match format {
|
||||
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
|
||||
wav::WavParse::Native(l) => {
|
||||
let buf = wav::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
wav::WavParse::Foreign { bwf } => {
|
||||
let (mut info, buf) = symph::decode(bytes, format)?;
|
||||
info.bwf = bwf;
|
||||
Ok((info, buf))
|
||||
}
|
||||
},
|
||||
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
|
||||
aiff::AiffParse::Native(l) => {
|
||||
let buf = aiff::decode(bytes, &l)?;
|
||||
let mut info = l.info;
|
||||
info.frames = buf.frames() as u64;
|
||||
Ok((info, buf))
|
||||
}
|
||||
aiff::AiffParse::Foreign => symph::decode(bytes, format),
|
||||
},
|
||||
_ => symph::decode(bytes, format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1].
|
||||
pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
match opts.format {
|
||||
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, None),
|
||||
FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither),
|
||||
FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither),
|
||||
other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
|
||||
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
|
||||
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
|
||||
}
|
||||
|
||||
/// Like [`encode`], with an explicit WAV speaker mask (`dwChannelMask`, e.g. 0x60F) for layouts
|
||||
/// a channel count alone cannot identify (7.0 vs 6.1, 5.1.4 vs 7.1.2, Ambisonics = 0). AIFF and
|
||||
/// FLAC have fixed channel orders and ignore it.
|
||||
pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u32) -> Result<Vec<u8>> {
|
||||
match opts.format {
|
||||
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, Some(mask)),
|
||||
_ => encode(buf, opts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The error a stream encoder returns for every call after one of its writes has failed: the
|
||||
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
|
||||
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
|
||||
pub(crate) fn failed_write_error() -> AudioError {
|
||||
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
|
||||
}
|
||||
|
||||
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
|
||||
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
|
||||
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
|
||||
|
||||
enum Writer<W> {
|
||||
Wav(wav::Writer<W>),
|
||||
Aiff(aiff::Writer<W>),
|
||||
Flac(flac::Writer<W>),
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> FileEncoder<W> {
|
||||
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
|
||||
/// derived from the channel count.
|
||||
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
|
||||
let (depth, dither) = (opts.bit_depth, opts.dither);
|
||||
Ok(FileEncoder(match opts.format {
|
||||
FileFormat::Wav => Writer::Wav(wav::Writer::new(
|
||||
out,
|
||||
channels,
|
||||
sample_rate,
|
||||
depth,
|
||||
dither,
|
||||
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
|
||||
)?),
|
||||
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
|
||||
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
|
||||
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
|
||||
pub fn check_room(&self, frames: u64) -> Result<()> {
|
||||
match &self.0 {
|
||||
Writer::Wav(w) => w.check_room(frames),
|
||||
Writer::Aiff(w) => w.check_room(frames),
|
||||
Writer::Flac(w) => w.check_room(frames),
|
||||
}
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
|
||||
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
|
||||
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
|
||||
/// every later `write` and `finish` fails instead of completing an inconsistent file.
|
||||
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
match &mut self.0 {
|
||||
Writer::Wav(w) => w.write(block, frames),
|
||||
Writer::Aiff(w) => w.write(block, frames),
|
||||
Writer::Flac(w) => w.write(block, frames),
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes the headers and hands back `out`, positioned at the end of the file.
|
||||
pub fn finish(self) -> Result<W> {
|
||||
match self.0 {
|
||||
Writer::Wav(w) => w.finish(),
|
||||
Writer::Aiff(w) => w.finish(),
|
||||
Writer::Flac(w) => w.finish(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
|
||||
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
|
||||
wav::read_channel_mask(bytes)
|
||||
}
|
||||
|
||||
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
|
||||
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
|
||||
/// switches to RF64 above 4 GiB.
|
||||
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
|
||||
if channels == 0 || sample_rate == 0 {
|
||||
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
|
||||
}
|
||||
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample
|
||||
//! table or a sample claims, and a failed allocation aborts the process.
|
||||
|
||||
use crate::pcm::{be_u32, be_u64};
|
||||
use crate::{AudioError, Result};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Level {
|
||||
Top,
|
||||
Moov,
|
||||
Trak,
|
||||
Mdia,
|
||||
Minf,
|
||||
Stbl,
|
||||
Udta,
|
||||
Meta,
|
||||
Ilst,
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a
|
||||
/// sample table or a sample claims more bytes than the file holds.
|
||||
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
|
||||
atoms(b, at, b.len(), Level::Top).map(|_| ())
|
||||
}
|
||||
|
||||
fn malformed(what: &str) -> AudioError {
|
||||
AudioError::Malformed(format!("MP4 {what} is larger than the file"))
|
||||
}
|
||||
|
||||
/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where
|
||||
/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`.
|
||||
fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
|
||||
// A header that starts inside the container is read whole, even past its end.
|
||||
while pos < end {
|
||||
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) };
|
||||
let head = if size == 1 { 16 } else { 8 };
|
||||
// Size 0: no data, and the next atom starts at the container's end.
|
||||
let len = match size {
|
||||
0 => head,
|
||||
1 => be_u64(b, pos + 8).unwrap_or(0),
|
||||
n => u64::from(n),
|
||||
};
|
||||
if len < head {
|
||||
return Ok(false);
|
||||
}
|
||||
let body = pos + head as usize;
|
||||
let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n));
|
||||
let next = if size == 0 { Some(end) } else { data_end };
|
||||
let inner = data_end.map_or(b.len(), |n| n.min(b.len()));
|
||||
let more = match (level, kind) {
|
||||
(Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?,
|
||||
(Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?,
|
||||
(Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?,
|
||||
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
|
||||
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
|
||||
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
|
||||
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
|
||||
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
|
||||
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
|
||||
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
|
||||
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
|
||||
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
|
||||
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
|
||||
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
|
||||
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
match next {
|
||||
Some(n) if more && n <= end => pos = n,
|
||||
_ => return Ok(false),
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
|
||||
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
|
||||
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
|
||||
let Some(n) = be_u32(b, count) else { return Ok(false) };
|
||||
let (first, bytes) = (count + 4, u64::from(n) * entry);
|
||||
if bytes > b.len().saturating_sub(first) as u64 {
|
||||
return Err(malformed("sample table"));
|
||||
}
|
||||
Ok(first as u64 + bytes <= end as u64)
|
||||
}
|
||||
|
||||
/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size.
|
||||
/// Sizes past the atom are never used, as symphonia stops before reading a packet.
|
||||
fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result<bool> {
|
||||
let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) };
|
||||
let file = b.len() as u64;
|
||||
if u64::from(size) > file {
|
||||
return Err(malformed("sample"));
|
||||
}
|
||||
if size != 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let fits = table(b, body + 8, 4, end)?;
|
||||
let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>();
|
||||
if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) {
|
||||
return Err(malformed("sample"));
|
||||
}
|
||||
Ok(fits)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
|
||||
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
|
||||
|
||||
use crate::{AudioError, BitDepth, Result};
|
||||
use std::io::Write;
|
||||
|
||||
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
|
||||
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
|
||||
|
||||
/// Little-endian `u16` at `pos`.
|
||||
pub(crate) fn le_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s = b.get(pos..pos.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
/// Little-endian `u32` at `pos`.
|
||||
pub(crate) fn le_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Little-endian `u64` at `pos`.
|
||||
pub(crate) fn le_u64(b: &[u8], pos: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(u64::from_le_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u16` at `pos`.
|
||||
pub(crate) fn be_u16(b: &[u8], pos: usize) -> Option<u16> {
|
||||
let s: [u8; 2] = b.get(pos..pos.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(u16::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u32` at `pos`.
|
||||
pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
|
||||
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Big-endian `u64` at `pos`.
|
||||
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
|
||||
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
|
||||
Some(u64::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// Four-byte tag at `pos`.
|
||||
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
|
||||
b.get(pos..pos.checked_add(4)?)?.try_into().ok()
|
||||
}
|
||||
|
||||
/// How the samples in a PCM byte stream are encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PcmKind {
|
||||
/// Signed (or, for 1-byte WAV, unsigned) integers occupying `bytes` bytes each, left-justified.
|
||||
Int { bytes: usize, big_endian: bool, unsigned: bool },
|
||||
/// IEEE float, 4 or 8 bytes.
|
||||
Float { bytes: usize, big_endian: bool },
|
||||
}
|
||||
|
||||
impl PcmKind {
|
||||
pub(crate) fn bytes(self) -> usize {
|
||||
match self {
|
||||
PcmKind::Int { bytes, .. } | PcmKind::Float { bytes, .. } => bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one sample from exactly `kind.bytes()` bytes. Returns 0 for a short slice.
|
||||
fn decode_sample(s: &[u8], kind: PcmKind) -> f32 {
|
||||
match kind {
|
||||
PcmKind::Int { bytes, big_endian, unsigned } => {
|
||||
if bytes == 0 || bytes > 4 || s.len() < bytes {
|
||||
return 0.0;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for i in 0..bytes {
|
||||
let idx = if big_endian { i } else { bytes - 1 - i };
|
||||
let mut byte = s.get(idx).copied().unwrap_or(0);
|
||||
if unsigned && i == 0 {
|
||||
byte ^= 0x80;
|
||||
}
|
||||
acc = (acc << 8) | u32::from(byte);
|
||||
}
|
||||
let shifted = acc << (32 - 8 * bytes as u32);
|
||||
(f64::from(shifted as i32) / 2_147_483_648.0) as f32
|
||||
}
|
||||
PcmKind::Float { bytes, big_endian } => {
|
||||
let v = match bytes {
|
||||
4 => {
|
||||
let Some(a) = s.get(..4).and_then(|x| <[u8; 4]>::try_from(x).ok()) else { return 0.0 };
|
||||
if big_endian { f32::from_be_bytes(a) } else { f32::from_le_bytes(a) }
|
||||
}
|
||||
8 => {
|
||||
let Some(a) = s.get(..8).and_then(|x| <[u8; 8]>::try_from(x).ok()) else { return 0.0 };
|
||||
(if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) }) as f32
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`.
|
||||
pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result<Vec<Vec<f32>>> {
|
||||
let bytes = kind.bytes();
|
||||
let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?;
|
||||
let available = (data.len() / block) as u64;
|
||||
let frames = frames.min(available);
|
||||
let total = frames.checked_mul(channels as u64).ok_or_else(|| AudioError::TooLarge("sample count overflows".into()))?;
|
||||
if total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the {MAX_DECODE_SAMPLES}-sample decode limit")));
|
||||
}
|
||||
let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?;
|
||||
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
|
||||
for frame in data.chunks_exact(block).take(frames) {
|
||||
for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) {
|
||||
ch.push(decode_sample(s, kind));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Converts `f32` samples to integers of a given bit depth, clamping to [-1, 1] and optionally
|
||||
/// adding triangular (TPDF) dither of ±1 LSB.
|
||||
pub(crate) struct Quantizer {
|
||||
bits: u32,
|
||||
dither: bool,
|
||||
rng: u64,
|
||||
}
|
||||
|
||||
impl Quantizer {
|
||||
/// A quantizer for one channel; each channel gets an independent dither sequence.
|
||||
pub(crate) fn new(bits: u32, dither: bool, channel: usize) -> Self {
|
||||
let rng = 0x9E37_79B9_7F4A_7C15u64 ^ (channel as u64).wrapping_add(1).wrapping_mul(0xD1B5_4A32_D192_ED03);
|
||||
Self { bits: bits.clamp(2, 32), dither, rng: if rng == 0 { 1 } else { rng } }
|
||||
}
|
||||
|
||||
/// One quantizer per channel.
|
||||
pub(crate) fn per_channel(bits: u32, dither: bool, channels: usize) -> Vec<Self> {
|
||||
(0..channels).map(|c| Self::new(bits, dither, c)).collect()
|
||||
}
|
||||
|
||||
/// Uniform in [0, 1) from a xorshift64* generator (deterministic, no OS entropy needed).
|
||||
fn uniform(&mut self) -> f64 {
|
||||
let mut x = self.rng;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.rng = x;
|
||||
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
pub(crate) fn quantize(&mut self, x: f32) -> i32 {
|
||||
let x = if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) };
|
||||
let scale = (1u64 << (self.bits - 1)) as f64;
|
||||
let mut v = f64::from(x) * scale;
|
||||
if self.dither {
|
||||
v += self.uniform() - self.uniform();
|
||||
}
|
||||
let max = scale - 1.0;
|
||||
round_half_away(v.clamp(-scale, max)) as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn round_half_away(v: f64) -> f64 {
|
||||
let t = v as i64;
|
||||
let f = v - t as f64;
|
||||
(t + i64::from(f >= 0.5) - i64::from(f <= -0.5)) as f64
|
||||
}
|
||||
|
||||
/// Bits per sample at `depth`, and whether they are float.
|
||||
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
|
||||
match depth {
|
||||
BitDepth::Int16 => (16, false),
|
||||
BitDepth::Int24 => (24, false),
|
||||
BitDepth::Int32 => (32, false),
|
||||
BitDepth::Float32 => (32, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
|
||||
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
|
||||
pub(crate) fn write_interleaved(
|
||||
out: &mut impl Write,
|
||||
scratch: &mut Vec<u8>,
|
||||
block: &[Vec<f32>],
|
||||
frames: usize,
|
||||
quantizers: &mut [Quantizer],
|
||||
(bits, is_float): (u16, bool),
|
||||
big_endian: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let size = usize::from(bits / 8);
|
||||
let mut start = 0;
|
||||
while start < frames {
|
||||
let end = frames.min(start + 4096);
|
||||
scratch.clear();
|
||||
for f in start..end {
|
||||
for (c, q) in quantizers.iter_mut().enumerate() {
|
||||
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
|
||||
match (is_float, big_endian) {
|
||||
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
|
||||
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
|
||||
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
|
||||
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
out.write_all(scratch)?;
|
||||
start = end;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
|
||||
fn sanitize_float(x: f32) -> f32 {
|
||||
if x.is_finite() {
|
||||
x
|
||||
} else if x.is_nan() {
|
||||
0.0
|
||||
} else {
|
||||
x.signum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
|
||||
validate_layout(buf.channels.len(), buf.sample_rate)
|
||||
}
|
||||
|
||||
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
|
||||
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
|
||||
if channels == 0 {
|
||||
return Err(AudioError::Encode("buffer has no channels".into()));
|
||||
}
|
||||
if channels > usize::from(u16::MAX) {
|
||||
return Err(AudioError::Encode("too many channels".into()));
|
||||
}
|
||||
if sample_rate == 0 {
|
||||
return Err(AudioError::Encode("sample rate is 0".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_half_away_matches_f64_round_on_the_quantizer_range() {
|
||||
let edges = [
|
||||
0.0,
|
||||
-0.0,
|
||||
0.5,
|
||||
-0.5,
|
||||
1.5,
|
||||
-1.5,
|
||||
2.5,
|
||||
0.499_999_999_999_999_94,
|
||||
-0.499_999_999_999_999_94,
|
||||
8_388_607.5,
|
||||
-8_388_608.0,
|
||||
2_147_483_647.0,
|
||||
-2_147_483_648.0,
|
||||
];
|
||||
let mut x = 0x2545_f491_4f6c_dd1du64;
|
||||
let spread = (0..10_000).map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
(x >> 11) as f64 / (1u64 << 53) as f64 * 4_294_967_295.0 - 2_147_483_648.0
|
||||
});
|
||||
for v in edges.into_iter().chain(spread) {
|
||||
assert!(round_half_away(v) == v.round(), "{v}");
|
||||
assert_eq!(round_half_away(v) as i32, v.round() as i32, "{v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Multi-resolution overview for waveform drawing.
|
||||
//!
|
||||
//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following
|
||||
//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range
|
||||
//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks
|
||||
//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom.
|
||||
|
||||
/// Samples per level-0 block.
|
||||
pub const BLOCK: usize = 32;
|
||||
/// Blocks of level `n` merged into one block of level `n + 1`.
|
||||
pub const FANOUT: usize = 4;
|
||||
/// Upper bound on the number of spans [`Peaks::spans`] will produce.
|
||||
pub const MAX_COLUMNS: usize = 1 << 20;
|
||||
|
||||
/// Multi-resolution overview of one channel for waveform drawing.
|
||||
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Peaks {
|
||||
len: usize,
|
||||
levels: Vec<Vec<Block>>,
|
||||
}
|
||||
|
||||
/// What a run of samples sums up to; NaN samples count for nothing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
struct Block {
|
||||
min: f32,
|
||||
max: f32,
|
||||
squares: f64,
|
||||
}
|
||||
|
||||
impl Block {
|
||||
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
|
||||
|
||||
fn point(s: f32) -> Block {
|
||||
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
|
||||
}
|
||||
|
||||
fn of(samples: &[f32]) -> Block {
|
||||
samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s)))
|
||||
}
|
||||
|
||||
fn merge(self, o: Block) -> Block {
|
||||
Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares }
|
||||
}
|
||||
|
||||
/// (min, max), or (0, 0) when nothing was summed up.
|
||||
fn range(self) -> (f32, f32) {
|
||||
if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Peaks {
|
||||
/// Build the overview of `samples` (one channel). NaN samples are ignored.
|
||||
pub fn build(samples: &[f32]) -> Peaks {
|
||||
if samples.is_empty() {
|
||||
return Peaks::default();
|
||||
}
|
||||
let mut levels: Vec<Vec<Block>> = vec![samples.chunks(BLOCK).map(Block::of).collect()];
|
||||
while let Some(last) = levels.last().filter(|l| l.len() > 1) {
|
||||
let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect();
|
||||
levels.push(next);
|
||||
}
|
||||
Peaks { len: samples.len(), levels }
|
||||
}
|
||||
|
||||
/// Number of samples the overview was built from.
|
||||
pub fn len_samples(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// Number of resolution levels (0 for an empty overview).
|
||||
pub fn num_levels(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
|
||||
/// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the
|
||||
/// data the overview was built from. Returns `(0.0, 0.0)` for an empty range.
|
||||
pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) {
|
||||
self.sum(samples, start, end).range()
|
||||
}
|
||||
|
||||
fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block {
|
||||
let end = end.min(self.len).min(samples.len());
|
||||
let (first, last) = (start.div_ceil(BLOCK), end / BLOCK);
|
||||
let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default());
|
||||
if first >= last {
|
||||
return raw(start, end);
|
||||
}
|
||||
raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last))
|
||||
}
|
||||
|
||||
/// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit.
|
||||
fn blocks(&self, mut lo: usize, mut hi: usize) -> Block {
|
||||
let mut acc = Block::EMPTY;
|
||||
for (lvl, level) in self.levels.iter().enumerate() {
|
||||
let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY));
|
||||
if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT {
|
||||
(lo..hi).for_each(add);
|
||||
break;
|
||||
}
|
||||
while !lo.is_multiple_of(FANOUT) {
|
||||
add(lo);
|
||||
lo += 1;
|
||||
}
|
||||
while !hi.is_multiple_of(FANOUT) {
|
||||
hi -= 1;
|
||||
add(hi);
|
||||
}
|
||||
lo /= FANOUT;
|
||||
hi /= FANOUT;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
/// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that
|
||||
/// reach the data, clamped to it.
|
||||
fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator<Item = Option<(f64, f64)>> {
|
||||
let n = self.len.min(samples.len());
|
||||
let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0;
|
||||
let last = n.saturating_sub(1) as f64;
|
||||
(0..cols.min(MAX_COLUMNS)).map(move |c| {
|
||||
let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap);
|
||||
(valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last)))
|
||||
})
|
||||
}
|
||||
|
||||
/// (min, max) of the line through `samples` over each of `cols` spans of sample positions
|
||||
/// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate
|
||||
/// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from
|
||||
/// them stays connected at any zoom. `None` for spans outside the data.
|
||||
pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<(f32, f32)>> {
|
||||
let at = |t: f64| {
|
||||
let i = t.floor();
|
||||
let a = samples.get(i as usize).copied().unwrap_or(0.0);
|
||||
let b = samples.get(i as usize + 1).copied().unwrap_or(a);
|
||||
Block::point(a + (b - a) * (t - i) as f32)
|
||||
};
|
||||
self.windows(samples, start, step, overlap, cols)
|
||||
.map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just
|
||||
/// outside a span's ends.
|
||||
pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<f32>> {
|
||||
self.windows(samples, start, step, overlap, cols)
|
||||
.map(|w| {
|
||||
w.map(|(a, b)| {
|
||||
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
|
||||
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn brute(samples: &[f32], start: usize, end: usize) -> (f32, f32) {
|
||||
let s = &samples[start..end];
|
||||
(s.iter().copied().fold(f32::INFINITY, f32::min), s.iter().copied().fold(f32::NEG_INFINITY, f32::max))
|
||||
}
|
||||
|
||||
fn signal(n: usize) -> Vec<f32> {
|
||||
(0..n).map(|i| ((i as f32 * 0.037).sin() * (i as f32 * 0.0011).cos()) + if i % 997 == 0 { 0.5 } else { 0.0 }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_range_is_exact() {
|
||||
let x = signal(100_003);
|
||||
let p = Peaks::build(&x);
|
||||
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] {
|
||||
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
let mut seed = 12345u64;
|
||||
for _ in 0..2000 {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let a = (seed >> 33) as usize % 100_003;
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let b = a + 1 + (seed >> 33) as usize % (100_003 - a);
|
||||
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_inputs() {
|
||||
let p = Peaks::build(&[]);
|
||||
assert_eq!(p.len_samples(), 0);
|
||||
assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0));
|
||||
assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]);
|
||||
let x = [0.5, -0.25];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0));
|
||||
assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5));
|
||||
assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5));
|
||||
for (start, step, overlap) in [
|
||||
(f64::NAN, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
(0.0, -1.0, 0.0),
|
||||
(0.0, f64::INFINITY, 0.0),
|
||||
(f64::NEG_INFINITY, 1.0, 0.0),
|
||||
(0.0, 1.0, -0.5),
|
||||
(0.0, 1.0, f64::NAN),
|
||||
] {
|
||||
assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]);
|
||||
}
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS);
|
||||
let x = [f32::NAN, 1.0];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0));
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]);
|
||||
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_large_samples_keep_finite_rms() {
|
||||
let samples = vec![1e20_f32; BLOCK];
|
||||
let peaks = Peaks::build(&samples);
|
||||
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rms_spans_match_the_samples() {
|
||||
let x = signal(20_000);
|
||||
let p = Peaks::build(&x);
|
||||
let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() / (e - s) as f64).sqrt() as f32;
|
||||
for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] {
|
||||
let cols = ((19_999.0 - overlap) / step) as usize;
|
||||
for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() {
|
||||
let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0));
|
||||
let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000)));
|
||||
assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
let sine: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect();
|
||||
let p = Peaks::build(&sine);
|
||||
let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap();
|
||||
assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}");
|
||||
assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]);
|
||||
assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_cover_their_samples_and_touch_their_neighbours() {
|
||||
let x = signal(10_000);
|
||||
let p = Peaks::build(&x);
|
||||
for step in [0.3, 1.0, 7.5, 100.0, 333.3] {
|
||||
let cols = (9_999.0 / step) as usize;
|
||||
let spans = p.spans(&x, 0.0, step, 0.0, cols);
|
||||
for (c, s) in spans.iter().enumerate() {
|
||||
let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}"));
|
||||
let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize);
|
||||
if a <= b {
|
||||
let truth = brute(&x, a, b + 1);
|
||||
assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}");
|
||||
}
|
||||
if let Some(Some((nlo, nhi))) = spans.get(c + 1) {
|
||||
assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1);
|
||||
}
|
||||
}
|
||||
let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1)));
|
||||
assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_follow_the_line_between_samples() {
|
||||
let x = [0.0, 1.0, -1.0, 0.5];
|
||||
let p = Peaks::build(&x);
|
||||
assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]);
|
||||
assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]);
|
||||
let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None];
|
||||
assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
|
||||
assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
|
||||
|
||||
use std::io::{Cursor, Seek, SeekFrom};
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use symphonia::core::codecs::audio::well_known::{
|
||||
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
|
||||
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
|
||||
};
|
||||
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
|
||||
use symphonia::core::errors::Error as SymError;
|
||||
use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable};
|
||||
use symphonia::core::formats::{FormatOptions, FormatReader, Track};
|
||||
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream};
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
|
||||
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
|
||||
|
||||
/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC
|
||||
/// takes its frame length from the file (encoders use 4096).
|
||||
const MAX_PACKET_FRAMES: u64 = 1 << 20;
|
||||
|
||||
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
|
||||
/// probe scans.
|
||||
const MAX_PREFIX_SCAN: usize = 1 << 20;
|
||||
|
||||
/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable`
|
||||
/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds
|
||||
/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is
|
||||
/// spent, the scan stops at the next audio candidate: a container-like byte string later in an
|
||||
/// unscored audio payload must not become authoritative. A container marker reached first is still
|
||||
/// checked; an unchecked one the probe selects is refused after probing.
|
||||
const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256;
|
||||
|
||||
/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score
|
||||
/// rejects the candidate, so a stream of false syncs cannot multiply the work.
|
||||
struct ScoreBudget {
|
||||
used: usize,
|
||||
}
|
||||
|
||||
impl ScoreBudget {
|
||||
/// Start a scan with no expensive score attempts spent.
|
||||
fn new() -> Self {
|
||||
Self { used: 0 }
|
||||
}
|
||||
|
||||
/// Spend one score attempt, returning false once the per-scan budget is exhausted.
|
||||
fn take(&mut self) -> bool {
|
||||
if self.exhausted() {
|
||||
return false;
|
||||
}
|
||||
self.used = self.used.saturating_add(1);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether no attempt is left for this scan.
|
||||
fn exhausted(&self) -> bool {
|
||||
self.used >= MAX_AUDIO_SCORE_ATTEMPTS
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
|
||||
/// distinct from a malformed file.
|
||||
fn map_err(e: SymError) -> AudioError {
|
||||
match e {
|
||||
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
|
||||
SymError::LimitError(m) => AudioError::TooLarge(m.to_string()),
|
||||
other => AudioError::Malformed(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f`, turning a panic inside the third-party decoder into an error.
|
||||
fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
|
||||
}
|
||||
|
||||
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
|
||||
enum Opened {
|
||||
Reader(Box<dyn FormatReader>),
|
||||
Empty(AudioInfo),
|
||||
}
|
||||
|
||||
/// Decoder options for every compressed format. Gapless playback used to be a
|
||||
/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the
|
||||
/// encoder delay and padding that the demuxer reports on each packet.
|
||||
fn decoder_options() -> AudioDecoderOptions {
|
||||
AudioDecoderOptions::default().gapless(true)
|
||||
}
|
||||
|
||||
/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates
|
||||
/// whatever a frame claims, so the tags are skipped, not read: they are not used here.
|
||||
fn skip_id3v2(b: &[u8]) -> usize {
|
||||
let mut at = 0usize;
|
||||
loop {
|
||||
match b.get(at..).and_then(crate::id3v2_len) {
|
||||
Some(n) if n > 0 => at = at.saturating_add(n),
|
||||
_ => return at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit.
|
||||
#[derive(Clone, Copy)]
|
||||
enum FirstMarker {
|
||||
Flac(usize),
|
||||
Caf(usize),
|
||||
Mp4(usize),
|
||||
Wav(usize),
|
||||
/// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check.
|
||||
Audio,
|
||||
}
|
||||
|
||||
/// What the preflight container checks covered before the probe built a reader.
|
||||
enum Precheck {
|
||||
/// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first
|
||||
/// audio frame to resynchronize to.
|
||||
Empty(AudioInfo),
|
||||
/// The size checks ran for the container the probe reports under this short name.
|
||||
Checked(&'static str),
|
||||
/// No container size check was needed.
|
||||
Unchecked,
|
||||
}
|
||||
|
||||
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
|
||||
/// check has to run before the stream is decoded.
|
||||
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
|
||||
|
||||
/// The container markers to check for the family `detected` names. `None` is a raw audio stream
|
||||
/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below
|
||||
/// finds a container only when one starts the stream, before any audio marker.
|
||||
fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> {
|
||||
match detected {
|
||||
// RF64 and BW64 are the >4 GiB RIFF forms.
|
||||
FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]),
|
||||
FileFormat::Flac => Some(&[b"fLaC"]),
|
||||
FileFormat::Caf => Some(&[b"caff"]),
|
||||
// MP4/M4A put `ftyp` after the first atom's size field.
|
||||
FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]),
|
||||
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
|
||||
FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]),
|
||||
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short name a container marker stands for.
|
||||
fn expected_probe_format(marker: &[u8; 4]) -> &'static str {
|
||||
match marker {
|
||||
b"caff" => "caf",
|
||||
// The isomp4 reader reads the `ftyp` atom.
|
||||
b"ftyp" => "isomp4",
|
||||
b"fLaC" => "flac",
|
||||
// RIFF, RF64 and BW64.
|
||||
_ => "wave",
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first marker of `markers` at or after `at`, within symphonia's probe depth. A RIFF
|
||||
/// form other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
|
||||
fn find_container_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
|
||||
let end = bytes.len().min(at.saturating_add(MAX_PREFIX_SCAN));
|
||||
for pos in at..end.saturating_sub(3) {
|
||||
let window = bytes.get(pos..pos + 4)?;
|
||||
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
|
||||
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
|
||||
continue;
|
||||
}
|
||||
return Some((pos, *marker));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its
|
||||
/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there.
|
||||
fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) {
|
||||
match marker {
|
||||
b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)),
|
||||
b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)),
|
||||
b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)),
|
||||
_ => (FirstMarker::Wav(at), expected_probe_format(marker)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe short name a container scan variant stands for.
|
||||
fn marker_name(marker: FirstMarker) -> &'static str {
|
||||
match marker {
|
||||
FirstMarker::Flac(_) => "flac",
|
||||
FirstMarker::Caf(_) => "caf",
|
||||
FirstMarker::Mp4(_) => "isomp4",
|
||||
FirstMarker::Wav(_) => "wave",
|
||||
FirstMarker::Audio => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `rest` starts with one of `R`'s exact registered probe markers.
|
||||
fn has_probe_marker<R: ProbeableFormat<'static>>(rest: &[u8]) -> bool {
|
||||
R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker)))
|
||||
}
|
||||
|
||||
/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every
|
||||
/// other scoring error mean "not this format", while a real I/O error aborts the probe.
|
||||
fn scores_supported<R: Scoreable>(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result<bool> {
|
||||
stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?;
|
||||
match R::score(ScopedStream::new(stream, u64::from(depth))) {
|
||||
Ok(Score::Supported(_)) => Ok(true),
|
||||
Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact
|
||||
/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is
|
||||
/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the
|
||||
/// scan before a later FLAC/CAF/MP4 candidate.
|
||||
fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option<MediaSourceStream<'a>>, budget: &mut ScoreBudget) -> Result<bool> {
|
||||
let rest = b.get(at..).unwrap_or_default();
|
||||
let adts = has_probe_marker::<symphonia::default::formats::AdtsReader<'static>>(rest);
|
||||
let mpa = has_probe_marker::<symphonia::default::formats::MpaReader<'static>>(rest);
|
||||
if !adts && !mpa {
|
||||
return Ok(false);
|
||||
}
|
||||
if stream.is_none() {
|
||||
*stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default()));
|
||||
}
|
||||
let Some(mss) = stream.as_mut() else { return Ok(false) };
|
||||
let depth = ProbeOptions::default().max_score_depth;
|
||||
if adts && budget.take() && scores_supported::<symphonia::default::formats::AdtsReader<'static>>(mss, at, depth)? {
|
||||
return Ok(true);
|
||||
}
|
||||
if mpa && budget.take() && scores_supported::<symphonia::default::formats::MpaReader<'static>>(mss, at, depth)? {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe`
|
||||
/// constructs the reader itself and exposes no pre-construction hook, while checking every magic
|
||||
/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's
|
||||
/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects
|
||||
/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below.
|
||||
/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a
|
||||
/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at
|
||||
/// the next audio candidate instead of trusting a container-like byte string later in the unscored
|
||||
/// audio payload. A container marker reached before any such candidate is still preflighted; if
|
||||
/// the probe selects a size-checked container the scan did not check, `open` refuses it.
|
||||
///
|
||||
/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only
|
||||
/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign
|
||||
/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected.
|
||||
fn first_marker(b: &[u8], start: usize) -> Result<Option<FirstMarker>> {
|
||||
first_marker_with_budget(b, start, &mut ScoreBudget::new())
|
||||
}
|
||||
|
||||
/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with
|
||||
/// false sync markers performs only the bounded number of reader scores.
|
||||
fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result<Option<FirstMarker>> {
|
||||
let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN));
|
||||
let mut at = start;
|
||||
let mut audio_probe = None;
|
||||
while at.saturating_add(4) <= end {
|
||||
let rest = b.get(at..).unwrap_or_default();
|
||||
if rest.get(..4) == Some(b"fLaC".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Flac(at)));
|
||||
}
|
||||
if rest.get(..4) == Some(b"caff".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Caf(at)));
|
||||
}
|
||||
if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) {
|
||||
return Ok(Some(FirstMarker::Audio));
|
||||
}
|
||||
if crate::wav::is_wav(rest) {
|
||||
return Ok(Some(FirstMarker::Wav(at)));
|
||||
}
|
||||
if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1))
|
||||
&& b0 == 0xFF
|
||||
&& b1 & 0xE0 == 0xE0
|
||||
{
|
||||
// A candidate after the cap cannot be scored. Stop rather than let a container-like
|
||||
// byte string later in an unscored audio payload become preflight-authoritative; the
|
||||
// probe will select the reader, and `open` refuses an unchecked size-checked one.
|
||||
if budget.exhausted() {
|
||||
return Ok(None);
|
||||
}
|
||||
if audio_sync_at(b, at, &mut audio_probe, budget)? {
|
||||
return Ok(Some(FirstMarker::Audio));
|
||||
}
|
||||
}
|
||||
if rest.get(4..8) == Some(b"ftyp".as_slice()) {
|
||||
return Ok(Some(FirstMarker::Mp4(at)));
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Run the early size checks on the container the probe can select. `start` is the first byte
|
||||
/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia
|
||||
/// 0.6's reader requires a first audio frame to resynchronize to.
|
||||
fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result<Precheck> {
|
||||
let any_marker = |b: &[u8], start: usize| -> Result<Option<(FirstMarker, &'static str)>> {
|
||||
Ok(match first_marker(b, start)? {
|
||||
// Stop at the first marker of any kind, so container magic incidental to an audio
|
||||
// payload is not mistaken for the stream's container.
|
||||
Some(FirstMarker::Audio) | None => None,
|
||||
Some(marker) => Some((marker, marker_name(marker))),
|
||||
})
|
||||
};
|
||||
let found = match container_markers(detected) {
|
||||
// Follow the detection: check the first marker of the family it names, so a misleading
|
||||
// marker cannot let a differently sized container reach the probe unchecked. A detection
|
||||
// is only a hint, though: when the stream holds none of its markers (a FLAC behind a
|
||||
// `.mp3` name), the probe can still select another container, which is checked here.
|
||||
Some(markers) => match find_container_marker(b, start, markers) {
|
||||
Some((at, marker)) => Some(marker_kind(at, marker)),
|
||||
None => any_marker(b, start)?,
|
||||
},
|
||||
None => any_marker(b, start)?,
|
||||
};
|
||||
let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) };
|
||||
match marker {
|
||||
FirstMarker::Flac(at) => {
|
||||
crate::flac::check_metadata(b, at)?;
|
||||
if let Some(info) = crate::flac::empty_stream_info(b, at) {
|
||||
return Ok(Precheck::Empty(info));
|
||||
}
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Caf(at) => {
|
||||
crate::caf::check(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Mp4(at) => {
|
||||
crate::mp4::check(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Wav(at) => {
|
||||
crate::wav::check_info(b, at)?;
|
||||
Ok(Precheck::Checked(expected))
|
||||
}
|
||||
FirstMarker::Audio => Ok(Precheck::Unchecked),
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by
|
||||
/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing
|
||||
/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits.
|
||||
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
|
||||
let start = skip_id3v2(bytes);
|
||||
if start > 0 && start >= bytes.len() {
|
||||
return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into()));
|
||||
}
|
||||
let checked = precheck(bytes, start, detected)?;
|
||||
if let Precheck::Empty(info) = checked {
|
||||
return Ok(Opened::Empty(info));
|
||||
}
|
||||
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
|
||||
// Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start`
|
||||
// too: container offsets such as MP4's `stco` are absolute file offsets.
|
||||
mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?;
|
||||
let reader: Box<dyn FormatReader> =
|
||||
symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
|
||||
let probed = reader.format_info().short_name;
|
||||
match checked {
|
||||
// Only containers whose readers allocate from sizes in the file need the check. A stream
|
||||
// the probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a
|
||||
// marker found in its audio data must not reject it.
|
||||
Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
|
||||
}
|
||||
Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => {
|
||||
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Opened::Reader(reader))
|
||||
}
|
||||
|
||||
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
|
||||
if detected != FileFormat::Other {
|
||||
return detected;
|
||||
}
|
||||
match codec {
|
||||
c if c == CODEC_ID_FLAC => FileFormat::Flac,
|
||||
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
|
||||
c if c == CODEC_ID_AAC => FileFormat::Aac,
|
||||
c if c == CODEC_ID_ALAC => FileFormat::Alac,
|
||||
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
|
||||
_ => FileFormat::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
|
||||
/// codecs decode to `Compressed`.
|
||||
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
|
||||
let c = p.codec;
|
||||
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
|
||||
return SampleFormat::Float32;
|
||||
}
|
||||
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
|
||||
return SampleFormat::Float64;
|
||||
}
|
||||
let lossy = [
|
||||
CODEC_ID_MP1,
|
||||
CODEC_ID_MP2,
|
||||
CODEC_ID_MP3,
|
||||
CODEC_ID_AAC,
|
||||
CODEC_ID_VORBIS,
|
||||
CODEC_ID_OPUS,
|
||||
CODEC_ID_ADPCM_MS,
|
||||
CODEC_ID_ADPCM_IMA_WAV,
|
||||
CODEC_ID_ADPCM_IMA_QT,
|
||||
CODEC_ID_ADPCM_G722,
|
||||
CODEC_ID_ADPCM_G726,
|
||||
CODEC_ID_ADPCM_G726LE,
|
||||
CODEC_ID_PCM_ALAW,
|
||||
CODEC_ID_PCM_MULAW,
|
||||
];
|
||||
let lossless = !lossy.contains(&c);
|
||||
match (lossless, p.bits_per_sample) {
|
||||
(true, Some(1..=8)) => SampleFormat::Int8,
|
||||
(true, Some(9..=16)) => SampleFormat::Int16,
|
||||
(true, Some(17..=24)) => SampleFormat::Int24,
|
||||
(true, Some(25..=32)) => SampleFormat::Int32,
|
||||
_ => SampleFormat::Compressed,
|
||||
}
|
||||
}
|
||||
|
||||
/// The audio parameters of `track`, if it carries any.
|
||||
fn audio_params(track: &Track) -> Option<&AudioCodecParameters> {
|
||||
track.codec_params.as_ref()?.audio()
|
||||
}
|
||||
|
||||
/// The first track with a decodable audio codec, with the stream information the UI shows.
|
||||
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
|
||||
let track = reader
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO))
|
||||
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
|
||||
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
|
||||
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
|
||||
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((
|
||||
track.id,
|
||||
AudioInfo {
|
||||
format: format_from_codec(p.codec, detected),
|
||||
sample_format: sample_format_of(p),
|
||||
sample_rate,
|
||||
channels,
|
||||
frames: track.num_frames.unwrap_or(0),
|
||||
bwf: None,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
|
||||
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
|
||||
if frames == 0 {
|
||||
return Err(map_err(e));
|
||||
}
|
||||
log::warn!("stopping decode after demux error: {e}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
|
||||
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
|
||||
log::debug!("skipping corrupt packet: {msg}");
|
||||
if errors > 1000 && frames == 0 {
|
||||
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
|
||||
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
|
||||
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
|
||||
Ok((0..ch).map(|_| Vec::new()).collect())
|
||||
}
|
||||
|
||||
/// Append one interleaved block to the planes, replacing non-finite samples with silence.
|
||||
fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
|
||||
for frame in interleaved.chunks_exact(ch) {
|
||||
for (dst, &s) in planar.iter_mut().zip(frame) {
|
||||
dst.push(if s.is_finite() { s } else { 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
|
||||
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => {
|
||||
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
|
||||
return Ok((info, buf));
|
||||
}
|
||||
};
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
if let Some(n) = track.num_frames {
|
||||
let total = n.saturating_mul(u64::from(info.channels.max(1)));
|
||||
if !count_only && total > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
|
||||
}
|
||||
}
|
||||
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
|
||||
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
|
||||
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
|
||||
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
|
||||
}
|
||||
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(¶ms, &decoder_options()).map_err(map_err)?;
|
||||
|
||||
let mut planar: Vec<Vec<f32>> = Vec::new();
|
||||
let mut interleaved: Vec<f32> = Vec::new();
|
||||
let mut frames: u64 = 0;
|
||||
let mut errors = 0usize;
|
||||
loop {
|
||||
let packet = match reader.next_packet() {
|
||||
Ok(Some(packet)) => packet,
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
demux_failure(frames, e)?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if packet.track_id != track_id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(SymError::DecodeError(m)) => {
|
||||
errors += 1;
|
||||
corrupt_packet(errors, frames, m)?;
|
||||
continue;
|
||||
}
|
||||
Err(SymError::IoError(_)) => break,
|
||||
Err(e) => return Err(map_err(e)),
|
||||
};
|
||||
// The interleaved copy below is laid out by planes: use their count so a decoder whose
|
||||
// channel spec disagrees with its planes cannot trip the copy's length assertion.
|
||||
let ch = decoded.num_planes();
|
||||
if ch == 0 {
|
||||
continue;
|
||||
}
|
||||
if planar.is_empty() {
|
||||
planar = init_planar(&mut info, ch)?;
|
||||
}
|
||||
let n = decoded.frames() as u64;
|
||||
frames = frames.saturating_add(n);
|
||||
if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES {
|
||||
return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()));
|
||||
}
|
||||
if count_only {
|
||||
continue;
|
||||
}
|
||||
let needed = usize::try_from(n)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_mul(ch))
|
||||
.ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?;
|
||||
if interleaved.len() < needed {
|
||||
interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?;
|
||||
interleaved.resize(needed, 0.0);
|
||||
}
|
||||
decoded.copy_to_slice_interleaved(&mut interleaved[..needed]);
|
||||
append_interleaved(&mut planar, &interleaved[..needed], ch);
|
||||
}
|
||||
if info.channels == 0 {
|
||||
return Err(AudioError::Malformed("stream contains no audio".into()));
|
||||
}
|
||||
if planar.is_empty() {
|
||||
planar = (0..usize::from(info.channels)).map(|_| Vec::new()).collect();
|
||||
}
|
||||
info.frames = if count_only { frames } else { planar.iter().map(Vec::len).min().unwrap_or(0) as u64 };
|
||||
let sample_rate = info.sample_rate;
|
||||
Ok((info, AudioBuffer { sample_rate, channels: planar }))
|
||||
}
|
||||
|
||||
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
|
||||
const AAC_FRAMES_PER_PACKET: u64 = 1024;
|
||||
|
||||
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
|
||||
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
|
||||
/// sample whose duration differs from a frame makes the two disagree.
|
||||
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
|
||||
reader.format_info().short_name == "isomp4"
|
||||
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
|
||||
}
|
||||
|
||||
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
|
||||
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
|
||||
let mut frames: u64 = 0;
|
||||
loop {
|
||||
match reader.next_packet() {
|
||||
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => break,
|
||||
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(SymError::ResetRequired) => break,
|
||||
Err(e) => {
|
||||
demux_failure(frames, e)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
|
||||
guarded(|| {
|
||||
let mut reader = match open(bytes, detected)? {
|
||||
Opened::Reader(reader) => reader,
|
||||
Opened::Empty(info) => return Ok(info),
|
||||
};
|
||||
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
|
||||
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
|
||||
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
|
||||
}
|
||||
if info.frames > 0 && info.channels > 0 {
|
||||
return Ok(info);
|
||||
}
|
||||
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
|
||||
run(bytes, detected, true).map(|(i, _)| i)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
|
||||
guarded(|| run(bytes, detected, false))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use symphonia::core::codecs::audio::well_known::{
|
||||
CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
|
||||
CODEC_ID_VORBIS,
|
||||
};
|
||||
|
||||
/// Build codec parameters for a test, optionally declaring a bit depth.
|
||||
fn params(codec: AudioCodecId, bits_per_sample: Option<u32>) -> AudioCodecParameters {
|
||||
let mut params = AudioCodecParameters::new();
|
||||
params.for_codec(codec);
|
||||
if let Some(bits) = bits_per_sample {
|
||||
params.with_bits_per_sample(bits);
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// Codec ids map to their reported formats, while a format detected from the container wins over the codec.
|
||||
#[test]
|
||||
fn codecs_map_to_the_reported_formats() {
|
||||
assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3);
|
||||
assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac);
|
||||
assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg);
|
||||
// The container detected from the bytes wins over the codec.
|
||||
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac);
|
||||
}
|
||||
|
||||
/// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded
|
||||
/// width.
|
||||
#[test]
|
||||
fn sample_formats_keep_their_depth_and_lossy_marker() {
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64);
|
||||
// Lossy codecs report no fixed bit depth, whatever their decoded sample format is.
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed);
|
||||
assert_eq!(sample_format_of(¶ms(CODEC_ID_ALAC, None)), SampleFormat::Compressed);
|
||||
}
|
||||
|
||||
/// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are
|
||||
/// removed.
|
||||
#[test]
|
||||
fn gapless_decoding_stays_enabled() {
|
||||
assert!(decoder_options().gapless);
|
||||
}
|
||||
|
||||
/// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring,
|
||||
/// while a registered but truncated header is rejected by the reader's score after consuming a budget slot.
|
||||
#[test]
|
||||
fn only_the_readers_own_audio_markers_are_candidates() {
|
||||
/// Whether the preflight would score `b` as an audio candidate at offset 0.
|
||||
fn candidate(b: &[u8]) -> bool {
|
||||
let mut stream = None;
|
||||
let mut budget = ScoreBudget::new();
|
||||
matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true))
|
||||
}
|
||||
let mut mp3_frame = vec![0u8; 417];
|
||||
mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz
|
||||
assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat()));
|
||||
for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
|
||||
assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate");
|
||||
}
|
||||
}
|
||||
|
||||
/// A prefix made of false MPEG sync markers must not ask the readers to score every byte
|
||||
/// position: the per-scan budget caps the expensive work. A cheap container marker reached
|
||||
/// before the next audio candidate is still found, but once the cap is spent an audio
|
||||
/// candidate stops the scan so later payload magic cannot be preflight-authoritative.
|
||||
#[test]
|
||||
fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() {
|
||||
let mut crowded = vec![0u8; 1 << 16];
|
||||
for pair in crowded.as_chunks_mut::<2>().0 {
|
||||
pair.copy_from_slice(&[0xFF, 0xE2]);
|
||||
}
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None)));
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored");
|
||||
|
||||
// At the cap boundary, with no later audio candidate, the cheap marker is still found.
|
||||
let mut at_cap: Vec<u8> = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
at_cap.extend_from_slice(b"fLaC");
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(
|
||||
matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))),
|
||||
"a container marker reached before the next audio candidate is still found"
|
||||
);
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
|
||||
|
||||
// One more false candidate between the cap and the magic makes the scan stop unchecked:
|
||||
// the same magic could instead sit in a valid audio payload.
|
||||
let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec();
|
||||
after_candidate.extend_from_slice(&[0xFF, 0xE2]);
|
||||
after_candidate.extend_from_slice(b"fLaC");
|
||||
let mut budget = ScoreBudget::new();
|
||||
assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan");
|
||||
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and
|
||||
/// the v2.3 reserved footer bit does not extend a tag.
|
||||
#[test]
|
||||
fn leading_id3_tags_are_skipped_by_their_declared_size() {
|
||||
let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat();
|
||||
assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30);
|
||||
assert_eq!(skip_id3v2(b"fLaC"), 0);
|
||||
// The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved.
|
||||
assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10));
|
||||
assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
|
||||
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
|
||||
|
||||
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
|
||||
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
const WAVE_FORMAT_PCM: u16 = 1;
|
||||
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
|
||||
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
|
||||
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
|
||||
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
|
||||
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
|
||||
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
|
||||
const BEXT_MIN_LEN: usize = 602;
|
||||
|
||||
/// Parsed WAV layout (no sample data decoded yet).
|
||||
pub(crate) struct WavLayout {
|
||||
pub info: AudioInfo,
|
||||
pub kind: PcmKind,
|
||||
pub data_start: usize,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
/// Outcome of parsing a WAV header.
|
||||
pub(crate) enum WavParse {
|
||||
/// A PCM / float layout we decode natively.
|
||||
Native(WavLayout),
|
||||
/// A valid WAV whose codec we hand to symphonia (ADPCM, A-law, ...).
|
||||
Foreign { bwf: Option<BwfInfo> },
|
||||
}
|
||||
|
||||
struct Fmt {
|
||||
tag: u16,
|
||||
channels: u16,
|
||||
sample_rate: u32,
|
||||
block_align: u16,
|
||||
bits: u16,
|
||||
valid_bits: u16,
|
||||
}
|
||||
|
||||
fn parse_fmt(c: &[u8]) -> Result<Fmt> {
|
||||
let short = || AudioError::Malformed("fmt chunk too short".into());
|
||||
let mut fmt = Fmt {
|
||||
tag: le_u16(c, 0).ok_or_else(short)?,
|
||||
channels: le_u16(c, 2).ok_or_else(short)?,
|
||||
sample_rate: le_u32(c, 4).ok_or_else(short)?,
|
||||
block_align: le_u16(c, 12).ok_or_else(short)?,
|
||||
bits: le_u16(c, 14).unwrap_or(0),
|
||||
valid_bits: 0,
|
||||
};
|
||||
fmt.valid_bits = fmt.bits;
|
||||
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
|
||||
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
|
||||
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
let Some(tail) = c.get(26..40) else {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
|
||||
};
|
||||
if cb_size < 22 {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
|
||||
}
|
||||
if valid > fmt.bits {
|
||||
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
|
||||
}
|
||||
if valid != 0 {
|
||||
fmt.valid_bits = valid;
|
||||
}
|
||||
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
|
||||
// keep the extensible tag so the file goes to the foreign decoder.
|
||||
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
|
||||
fmt.tag = sub;
|
||||
}
|
||||
}
|
||||
Ok(fmt)
|
||||
}
|
||||
|
||||
/// Read a NUL-padded ASCII field.
|
||||
fn fixed_str(b: &[u8]) -> String {
|
||||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||||
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim_end().to_string()
|
||||
}
|
||||
|
||||
fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
|
||||
if c.len() < 346 {
|
||||
return None;
|
||||
}
|
||||
let lo = u64::from(le_u32(c, 338)?);
|
||||
let hi = u64::from(le_u32(c, 342)?);
|
||||
Some(BwfInfo {
|
||||
description: fixed_str(c.get(0..256)?),
|
||||
originator: fixed_str(c.get(256..288)?),
|
||||
originator_reference: fixed_str(c.get(288..320)?),
|
||||
origination_date: fixed_str(c.get(320..330)?),
|
||||
origination_time: fixed_str(c.get(330..338)?),
|
||||
time_reference: (hi << 32) | lo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
|
||||
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
|
||||
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
|
||||
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
|
||||
if tag(b, at + 8) != Some(*b"WAVE") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pos = at + 12;
|
||||
while let Some(id) = tag(b, pos) {
|
||||
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
|
||||
let (body, pad) = (pos + 8, len & 1);
|
||||
let used = match &id {
|
||||
b"data" => break,
|
||||
b"fmt " => fmt_used(b, body, len),
|
||||
b"LIST" if len < 4 => break,
|
||||
b"LIST" => {
|
||||
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
|
||||
// The list pads itself, then the chunk is padded again.
|
||||
4 + entries.max(len - 4) + pad
|
||||
}
|
||||
_ => len,
|
||||
};
|
||||
pos = body.saturating_add(used).saturating_add(pad);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
|
||||
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
|
||||
match le_u16(b, body) {
|
||||
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
|
||||
Some(0xfffe) => 40,
|
||||
_ => len,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
|
||||
/// them, with the pad it reads after an odd entry.
|
||||
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
|
||||
let mut used = 0;
|
||||
while used + 8 <= len {
|
||||
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
|
||||
if n > b.len().saturating_sub(start + used + 8) {
|
||||
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
|
||||
}
|
||||
used += 8 + n;
|
||||
used += used & 1;
|
||||
}
|
||||
Ok(used)
|
||||
}
|
||||
|
||||
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
|
||||
pub(crate) fn is_wav(b: &[u8]) -> bool {
|
||||
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
|
||||
}
|
||||
|
||||
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
|
||||
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
|
||||
/// missing): the metadata of a finished file, not samples.
|
||||
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
|
||||
let mut pos = from;
|
||||
while pos < b.len() {
|
||||
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
|
||||
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
|
||||
return false;
|
||||
}
|
||||
let size = size as usize;
|
||||
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
|
||||
if end > b.len() {
|
||||
return false;
|
||||
}
|
||||
let next = end + (size & 1);
|
||||
if end == b.len() || next == b.len() {
|
||||
return true;
|
||||
}
|
||||
pos = next;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
|
||||
struct DataChunk {
|
||||
body: usize,
|
||||
size32: u32,
|
||||
is_rf64: bool,
|
||||
remaining: usize,
|
||||
declared: usize,
|
||||
}
|
||||
|
||||
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
|
||||
/// the recording was never finalised.
|
||||
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
|
||||
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
|
||||
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
|
||||
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
|
||||
}
|
||||
|
||||
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
|
||||
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
|
||||
Ok(Some(match (fmt.tag, fmt.bits) {
|
||||
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
|
||||
let bytes = usize::from(bits.div_ceil(8));
|
||||
let sf = match fmt.valid_bits.min(bits) {
|
||||
0..=8 => SampleFormat::Int8,
|
||||
9..=16 => SampleFormat::Int16,
|
||||
17..=24 => SampleFormat::Int24,
|
||||
_ => SampleFormat::Int32,
|
||||
};
|
||||
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
|
||||
}
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
|
||||
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
|
||||
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
|
||||
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
|
||||
if !is_wav(b) {
|
||||
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
|
||||
}
|
||||
let is_rf64 = tag(b, 0).is_some_and(|t| &t != b"RIFF");
|
||||
let mut ds64_data: Option<u64> = None;
|
||||
let mut fmt: Option<Fmt> = None;
|
||||
let mut bwf: Option<BwfInfo> = None;
|
||||
let mut data: Option<(usize, usize)> = None;
|
||||
|
||||
let mut pos: usize = 12;
|
||||
// Bound the number of chunks we look at so a file of tiny chunks can't spin forever.
|
||||
let mut chunks = 0usize;
|
||||
while let (Some(id), Some(size32)) = (tag(b, pos), le_u32(b, pos + 4)) {
|
||||
chunks += 1;
|
||||
if chunks > 100_000 {
|
||||
break;
|
||||
}
|
||||
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
|
||||
let remaining = b.len().saturating_sub(body);
|
||||
let mut size = u64::from(size32);
|
||||
if &id == b"data" && is_rf64 && size32 == u32::MAX {
|
||||
size = ds64_data.unwrap_or(remaining as u64);
|
||||
}
|
||||
let declared = usize::try_from(size).unwrap_or(usize::MAX);
|
||||
if &id == b"data" {
|
||||
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
|
||||
// yields whatever audio is actually present.
|
||||
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
|
||||
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
|
||||
// that was never finalised, and what follows is audio.
|
||||
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
|
||||
if data.is_none() {
|
||||
data = Some((body, len));
|
||||
}
|
||||
if declared > remaining {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if declared > remaining {
|
||||
// A truncated non-audio chunk at the end of the file: stop scanning.
|
||||
break;
|
||||
}
|
||||
let c = b.get(body..body + declared).unwrap_or_default();
|
||||
match &id {
|
||||
b"fmt " => fmt = Some(parse_fmt(c)?),
|
||||
b"bext" => bwf = parse_bext(c),
|
||||
b"ds64" => ds64_data = le_u64(c, 8),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let padded = declared.saturating_add(declared & 1);
|
||||
pos = match body.checked_add(padded) {
|
||||
Some(p) => p,
|
||||
None => break,
|
||||
};
|
||||
}
|
||||
|
||||
let fmt = fmt.ok_or_else(|| AudioError::Malformed("WAV has no fmt chunk".into()))?;
|
||||
if fmt.channels == 0 {
|
||||
return Err(AudioError::Malformed("WAV declares zero channels".into()));
|
||||
}
|
||||
if fmt.sample_rate == 0 {
|
||||
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
|
||||
}
|
||||
let channels = usize::from(fmt.channels);
|
||||
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
|
||||
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
|
||||
if usize::from(fmt.block_align) != block {
|
||||
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
|
||||
}
|
||||
let (data_start, data_len) = data.ok_or_else(|| AudioError::Malformed("WAV has no data chunk".into()))?;
|
||||
let frames = (data_len / block) as u64;
|
||||
Ok(WavParse::Native(WavLayout {
|
||||
info: AudioInfo { format: FileFormat::Wav, sample_format, sample_rate: fmt.sample_rate, channels: fmt.channels, frames, bwf },
|
||||
kind,
|
||||
data_start,
|
||||
data_len,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
|
||||
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
|
||||
let data = b.get(layout.data_start..end).unwrap_or_default();
|
||||
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
|
||||
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
|
||||
}
|
||||
|
||||
/// Default speaker mask for a channel count (callers that know the layout pass their own):
|
||||
/// mono, stereo, LCR, quad, 5.0, 5.1, 6.1, 7.1 (sides + rears), 7.1.4.
|
||||
fn channel_mask(channels: usize) -> u32 {
|
||||
match channels {
|
||||
1 => 0x4,
|
||||
2 => 0x3,
|
||||
3 => 0x7,
|
||||
4 => 0x33,
|
||||
5 => 0x37,
|
||||
6 => 0x3F,
|
||||
7 => 0x13F,
|
||||
8 => 0x63F,
|
||||
// L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr (7.1.4).
|
||||
12 => 0x2_D63F,
|
||||
n if n <= 18 => (1u32 << n) - 1,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `dwChannelMask` of a WAVE_FORMAT_EXTENSIBLE file (None for plain PCM/float headers).
|
||||
pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
|
||||
if !is_wav(b) {
|
||||
return None;
|
||||
}
|
||||
let mut pos = 12usize;
|
||||
for _ in 0..10_000 {
|
||||
let (id, size) = (tag(b, pos)?, usize::try_from(le_u32(b, pos + 4)?).ok()?);
|
||||
if &id == b"fmt " {
|
||||
return (le_u16(b, pos + 8)? == WAVE_FORMAT_EXTENSIBLE).then(|| le_u32(b, pos + 8 + 20)).flatten();
|
||||
}
|
||||
pos = pos.checked_add(8)?.checked_add(size)?.checked_add(size & 1)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
|
||||
let bytes = s.as_bytes();
|
||||
let mut n = bytes.len().min(len);
|
||||
while !s.is_char_boundary(n) {
|
||||
n -= 1;
|
||||
}
|
||||
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
out.resize(out.len() + (len - n), 0);
|
||||
}
|
||||
|
||||
fn bext_chunk(bwf: &BwfInfo) -> Vec<u8> {
|
||||
let mut c = Vec::with_capacity(BEXT_MIN_LEN);
|
||||
put_fixed_str(&mut c, &bwf.description, 256);
|
||||
put_fixed_str(&mut c, &bwf.originator, 32);
|
||||
put_fixed_str(&mut c, &bwf.originator_reference, 32);
|
||||
put_fixed_str(&mut c, &bwf.origination_date, 10);
|
||||
put_fixed_str(&mut c, &bwf.origination_time, 8);
|
||||
c.extend_from_slice(&((bwf.time_reference & 0xFFFF_FFFF) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&((bwf.time_reference >> 32) as u32).to_le_bytes());
|
||||
c.extend_from_slice(&1u16.to_le_bytes()); // version 1
|
||||
c.resize(BEXT_MIN_LEN, 0); // UMID, loudness, reserved; no coding history
|
||||
c
|
||||
}
|
||||
|
||||
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
|
||||
out.extend_from_slice(id);
|
||||
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes());
|
||||
out.extend_from_slice(body);
|
||||
if body.len() % 2 == 1 {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
|
||||
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
|
||||
pub(crate) struct Writer<W> {
|
||||
out: W,
|
||||
start: u64,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
format: (u16, bool),
|
||||
bwf: Option<BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
force_rf64: bool,
|
||||
quantizers: Vec<Quantizer>,
|
||||
frames: u64,
|
||||
bytes: Vec<u8>,
|
||||
/// Set when a block write has failed: the sink may hold a prefix of that block while
|
||||
/// `frames` does not count it, so the writer refuses every later call.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct HeaderExtras<'a> {
|
||||
pub(crate) bwf: Option<&'a BwfInfo>,
|
||||
pub(crate) mask: Option<u32>,
|
||||
pub(crate) force_rf64: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Writer<W> {
|
||||
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
|
||||
let HeaderExtras { bwf, mask, force_rf64 } = extras;
|
||||
validate_layout(channels, sample_rate)?;
|
||||
let format = sample_format(depth);
|
||||
let start = out.stream_position()?;
|
||||
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
|
||||
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
|
||||
Ok(Writer {
|
||||
out,
|
||||
start,
|
||||
channels,
|
||||
sample_rate,
|
||||
format,
|
||||
bwf: bwf.cloned(),
|
||||
mask,
|
||||
force_rf64,
|
||||
quantizers,
|
||||
frames: 0,
|
||||
bytes: Vec::new(),
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn header(&self, frames: u64) -> Result<Vec<u8>> {
|
||||
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
|
||||
}
|
||||
|
||||
/// The frame count after `frames` more (RF64 has room for any bounce).
|
||||
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
|
||||
let total = self.frames.saturating_add(frames);
|
||||
self.header(total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Appends `frames` frames of planar audio (missing samples are silence).
|
||||
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let total = self.check_room(frames as u64)?;
|
||||
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
|
||||
self.failed = true;
|
||||
return Err(AudioError::from(e));
|
||||
}
|
||||
self.frames = total;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut self) -> Result<W> {
|
||||
if self.failed {
|
||||
return Err(crate::failed_write_error());
|
||||
}
|
||||
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
|
||||
if data_len % 2 == 1 {
|
||||
self.out.write_all(&[0])?;
|
||||
}
|
||||
let head = self.header(self.frames)?;
|
||||
self.out.seek(SeekFrom::Start(self.start))?;
|
||||
self.out.write_all(&head)?;
|
||||
self.out.seek(SeekFrom::End(0))?;
|
||||
self.out.flush()?;
|
||||
Ok(self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
|
||||
/// it is used automatically when the file would exceed 4 GiB.
|
||||
/// `mask` overrides the speaker mask derived from the channel count.
|
||||
pub(crate) fn encode(
|
||||
buf: &AudioBuffer,
|
||||
depth: BitDepth,
|
||||
dither: bool,
|
||||
bwf: Option<&BwfInfo>,
|
||||
force_rf64: bool,
|
||||
mask: Option<u32>,
|
||||
) -> Result<Vec<u8>> {
|
||||
validate_buffer(buf)?;
|
||||
let channels = buf.channels.len();
|
||||
let frames = buf.frames();
|
||||
let format = sample_format(depth);
|
||||
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
|
||||
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
|
||||
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
|
||||
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
|
||||
w.write(&buf.channels, frames)?;
|
||||
Ok(w.finish()?.into_inner())
|
||||
}
|
||||
|
||||
/// Everything before the sample data of a WAV holding `frames` frames.
|
||||
pub(crate) fn header(
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
(bits, is_float): (u16, bool),
|
||||
bwf: Option<&BwfInfo>,
|
||||
mask: Option<u32>,
|
||||
frames: u64,
|
||||
force_rf64: bool,
|
||||
) -> Result<Vec<u8>> {
|
||||
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
|
||||
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
|
||||
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
|
||||
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
|
||||
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
|
||||
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
|
||||
|
||||
let mut fmt = Vec::with_capacity(40);
|
||||
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
|
||||
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
|
||||
fmt.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
fmt.extend_from_slice(&block_align.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
if extensible {
|
||||
fmt.extend_from_slice(&22u16.to_le_bytes());
|
||||
fmt.extend_from_slice(&bits.to_le_bytes());
|
||||
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
|
||||
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
|
||||
fmt.extend_from_slice(&GUID_TAIL);
|
||||
}
|
||||
|
||||
let mut head = Vec::with_capacity(1024);
|
||||
let rf64_body_len = 28usize;
|
||||
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
|
||||
let mut meta = Vec::new();
|
||||
if let Some(bwf) = bwf {
|
||||
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
|
||||
}
|
||||
push_chunk(&mut meta, b"fmt ", &fmt);
|
||||
if is_float {
|
||||
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
|
||||
}
|
||||
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
|
||||
.checked_add(data_len)
|
||||
.and_then(|t| t.checked_add(data_len & 1))
|
||||
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
|
||||
let rf64 = force_rf64 || u32::try_from(total).is_err();
|
||||
|
||||
if rf64 {
|
||||
head.extend_from_slice(b"RF64");
|
||||
head.extend_from_slice(&u32::MAX.to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
let mut ds64 = Vec::with_capacity(rf64_body_len);
|
||||
ds64.extend_from_slice(&total.to_le_bytes());
|
||||
ds64.extend_from_slice(&data_len.to_le_bytes());
|
||||
ds64.extend_from_slice(&frames.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
push_chunk(&mut head, b"ds64", &ds64);
|
||||
} else {
|
||||
head.extend_from_slice(b"RIFF");
|
||||
head.extend_from_slice(&(total as u32).to_le_bytes());
|
||||
head.extend_from_slice(b"WAVE");
|
||||
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
|
||||
}
|
||||
head.extend_from_slice(&meta);
|
||||
head.extend_from_slice(b"data");
|
||||
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::header;
|
||||
|
||||
#[test]
|
||||
fn rejects_byte_rate_overflow() {
|
||||
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
|
||||
assert!(result.is_err(), "overflowing byte rate was accepted");
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
Tiny fixtures for `tests/it/decode_read_list.rs`: one per container or codec the READ list names
|
||||
that the crate cannot write itself. Made with ffmpeg 8.0 from a 0.1 s mono 440 Hz sine
|
||||
(`F="-y -f lavfi -i sine=frequency=440:duration=0.1 -ac 1"`):
|
||||
|
||||
ffmpeg $F -ar 8000 -c:a libmp3lame -b:a 16k tone.mp3
|
||||
ffmpeg $F -ar 24000 -c:a mp2 -b:a 32k tone.mp2
|
||||
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.ogg
|
||||
ffmpeg $F -ar 8000 -c:a aac -b:a 16k tone.m4a
|
||||
ffmpeg $F -ar 8000 -c:a aac -b:a 16k -f adts tone.aac
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.caf
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mkv
|
||||
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.webm
|
||||
ffmpeg $F -ar 8000 -c:a pcm_s16le -f aiff tone.aifc # AIFF-C "sowt"
|
||||
|
||||
`silence.mp1` has no ffmpeg encoder. It is four hand-built MPEG-1 Layer I frames (mono, 32 kHz,
|
||||
32 kbit/s, 48 bytes each: header `FF FF 18 C0`, all subband allocations zero), which decode to
|
||||
silence.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,51 @@
|
||||
//! Every extension the format table reads decodes with the compiled-in `symphonia` features.
|
||||
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat, decode, encode, probe};
|
||||
|
||||
fn fixture(name: &str) -> Vec<u8> {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);
|
||||
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn generated(format: FileFormat) -> Vec<u8> {
|
||||
let tone: Vec<f32> = (0..800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
|
||||
let buf = AudioBuffer { sample_rate: 8000, channels: vec![tone] };
|
||||
encode(&buf, &EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
fn bytes_for(ext: &str) -> Vec<u8> {
|
||||
match ext {
|
||||
"wav" | "wave" | "bwf" => generated(FileFormat::Wav),
|
||||
"rf64" => {
|
||||
let tone = AudioBuffer { sample_rate: 8000, channels: vec![vec![0.25; 800]] };
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
soundcraft_audio_io::encode_wav_rf64(&tone, &opts).unwrap()
|
||||
}
|
||||
"aif" | "aiff" => generated(FileFormat::Aiff),
|
||||
"aifc" => fixture("tone.aifc"),
|
||||
"flac" => generated(FileFormat::Flac),
|
||||
"mp3" => fixture("tone.mp3"),
|
||||
"mp2" => fixture("tone.mp2"),
|
||||
"mp1" => fixture("silence.mp1"),
|
||||
"ogg" | "oga" => fixture("tone.ogg"),
|
||||
"m4a" | "m4b" | "mp4" => fixture("tone.m4a"),
|
||||
"aac" | "adts" => fixture("tone.aac"),
|
||||
"caf" => fixture("tone.caf"),
|
||||
"mkv" => fixture("tone.mkv"),
|
||||
"webm" => fixture("tone.webm"),
|
||||
other => panic!("no fixture for the READ extension {other:?}: add one"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_read_extension_probes_and_decodes() {
|
||||
for &format in FileFormat::READ {
|
||||
for ext in format.extensions() {
|
||||
let bytes = bytes_for(ext);
|
||||
let info = probe(&bytes, Some(ext)).unwrap_or_else(|e| panic!("probe .{ext}: {e}"));
|
||||
let (_, buf) = decode(&bytes, Some(ext)).unwrap_or_else(|e| panic!("decode .{ext}: {e}"));
|
||||
assert!(info.channels >= 1, ".{ext}");
|
||||
assert!(buf.frames() > 0, ".{ext}: no frames");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
|
||||
#[test]
|
||||
fn every_read_and_write_format_round_trips_through_its_extensions() {
|
||||
for &format in FileFormat::READ {
|
||||
assert!(!format.extensions().is_empty(), "{format:?}");
|
||||
for ext in format.extensions() {
|
||||
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_extension(&format!(".{}", ext.to_ascii_uppercase())), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_path(format!("dir.x/Take 1.{ext}")), Some(format), "{ext}");
|
||||
}
|
||||
}
|
||||
for &format in FileFormat::WRITE {
|
||||
assert!(!format.write_extensions().is_empty(), "{format:?}");
|
||||
for ext in format.write_extensions() {
|
||||
assert_eq!(FileFormat::from_write_extension(ext), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_write_extension(&ext.to_ascii_uppercase()), Some(format), "{ext}");
|
||||
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}: what is written reads back");
|
||||
}
|
||||
assert!(format.write_extensions().contains(&format.default_extension()), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_names_whose_readers_expect_the_encoder_output_are_writable() {
|
||||
assert_eq!(FileFormat::writable_extensions(), ["wav", "wave", "bwf", "aif", "aiff", "flac"]);
|
||||
for read_only in ["rf64", "aifc", "mp3", "ogg", "m4a", "mkv"] {
|
||||
assert!(FileFormat::from_extension(read_only).is_some(), "{read_only}");
|
||||
assert_eq!(FileFormat::from_write_extension(read_only), None, "{read_only}");
|
||||
}
|
||||
for unknown in ["xyz", "", "mid"] {
|
||||
assert_eq!(FileFormat::from_extension(unknown), None, "{unknown}");
|
||||
}
|
||||
assert_eq!(FileFormat::from_path("no-extension"), None);
|
||||
assert_eq!(FileFormat::Aiff.default_extension(), "aif");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
|
||||
if !streaming {
|
||||
return encode(audio, options).unwrap();
|
||||
}
|
||||
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
|
||||
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
|
||||
for start in (0..audio.frames()).step_by(4097) {
|
||||
let end = (start + 4097).min(audio.frames());
|
||||
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
|
||||
writer.write(&block, end - start).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn assert_float_headroom(streaming: bool) {
|
||||
let samples = [
|
||||
-f32::MAX,
|
||||
-2.0,
|
||||
-1.25,
|
||||
-1.0,
|
||||
-0.5,
|
||||
-f32::MIN_POSITIVE,
|
||||
-f32::from_bits(1),
|
||||
-0.0,
|
||||
0.0,
|
||||
f32::from_bits(1),
|
||||
f32::MIN_POSITIVE,
|
||||
0.5,
|
||||
1.0,
|
||||
1.25,
|
||||
2.0,
|
||||
f32::MAX,
|
||||
];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for dither in [false, true] {
|
||||
for channels in [1, 2] {
|
||||
let audio = AudioBuffer {
|
||||
sample_rate: 48_000,
|
||||
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
|
||||
};
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let pcm: Vec<u8> =
|
||||
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
|
||||
let (info, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float32);
|
||||
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
|
||||
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
|
||||
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
|
||||
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_float32_preserves_finite_headroom() {
|
||||
assert_float_headroom(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float32_keeps_non_finite_sample_handling() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
|
||||
let expected = [0.0_f32, -1.0, 1.0];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
// Inspect stored samples too: the decoder itself replaces non-finite values.
|
||||
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
|
||||
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
|
||||
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_pcm_still_clips_at_full_scale() {
|
||||
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
|
||||
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for streaming in [false, true] {
|
||||
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
|
||||
let bytes = encoded(&audio, &options, streaming);
|
||||
let (_, decoded) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Integration tests, built as one test binary: each file is a module.
|
||||
|
||||
mod decode_read_list;
|
||||
mod file_format;
|
||||
mod float_headroom;
|
||||
mod malformed;
|
||||
mod mp4_length;
|
||||
mod roundtrip;
|
||||
mod stream;
|
||||
mod wav_empty_data;
|
||||
@@ -0,0 +1,821 @@
|
||||
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
|
||||
|
||||
use proptest::prelude::*;
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
|
||||
|
||||
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
|
||||
let mut b = AudioBuffer::new(44_100, channels, 300);
|
||||
for ch in &mut b.channels {
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = ((i as f32) * 0.1).sin() * 0.5;
|
||||
}
|
||||
}
|
||||
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
|
||||
}
|
||||
|
||||
/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body.
|
||||
fn id3_header(size: usize) -> [u8; 10] {
|
||||
let size = size as u32;
|
||||
[b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8]
|
||||
}
|
||||
|
||||
/// Wrap `body` in an ID3v2.4 tag.
|
||||
fn id3_tag(body: &[u8]) -> Vec<u8> {
|
||||
[&id3_header(body.len())[..], body].concat()
|
||||
}
|
||||
|
||||
/// Little helper for building RIFF files by hand.
|
||||
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for (id, data) in chunks {
|
||||
body.extend_from_slice(*id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
body.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
body.push(0);
|
||||
}
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
|
||||
let align = channels * bits.div_ceil(8);
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&bits.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_truncation_is_handled() {
|
||||
let files = [
|
||||
small(FileFormat::Wav, BitDepth::Int16, 2),
|
||||
small(FileFormat::Wav, BitDepth::Float32, 6),
|
||||
small(FileFormat::Aiff, BitDepth::Int24, 1),
|
||||
small(FileFormat::Aiff, BitDepth::Float32, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int16, 2),
|
||||
small(FileFormat::Flac, BitDepth::Int24, 1),
|
||||
];
|
||||
for file in &files {
|
||||
for n in 0..file.len() {
|
||||
let cut = &file[..n];
|
||||
let _ = probe(cut, None);
|
||||
if let Ok((info, buf)) = decode(cut, None) {
|
||||
assert_eq!(buf.num_channels(), info.channels as usize);
|
||||
assert_eq!(buf.frames() as u64, info.frames);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_data_chunk_yields_partial_audio() {
|
||||
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
|
||||
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_single_byte_corruption_is_handled() {
|
||||
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
|
||||
{
|
||||
for i in 0..file.len().min(200) {
|
||||
for v in [0x00, 0xFF, 0x7F, 0x80] {
|
||||
let mut f = file.clone();
|
||||
f[i] = v;
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_channels_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
|
||||
assert!(probe(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_negative_channel_count_is_malformed() {
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0x80;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(probe(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
for b in &mut aiff[comm + 16..comm + 26] {
|
||||
*b = 0;
|
||||
}
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
// Infinite exponent.
|
||||
aiff[comm + 16] = 0x7F;
|
||||
aiff[comm + 17] = 0xFF;
|
||||
assert!(decode(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_chunk_sizes() {
|
||||
// fmt chunk claims 4 GiB.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// data chunk claims 4 GiB: lenient read of what's there.
|
||||
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
let d = wav.windows(4).position(|w| w == b"data").unwrap();
|
||||
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (4, 4));
|
||||
// An unknown chunk with an absurd size before fmt.
|
||||
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
|
||||
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
|
||||
assert!(decode(&wav, None).is_err());
|
||||
// RF64 with a ds64 claiming an exabyte of data.
|
||||
let mut ds64 = Vec::new();
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
|
||||
ds64.extend_from_slice(&0u32.to_le_bytes());
|
||||
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
|
||||
rf[..4].copy_from_slice(b"RF64");
|
||||
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
|
||||
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let (info, buf) = decode(&rf, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (10, 10));
|
||||
// AIFF COMM claims more frames than present; SSND offset beyond the end.
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
|
||||
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
|
||||
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = b"caff\0\x01\0\0".to_vec();
|
||||
for (kind, size, body) in chunks {
|
||||
out.extend_from_slice(*kind);
|
||||
out.extend_from_slice(&size.to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
|
||||
let mut d = 48_000f64.to_be_bytes().to_vec();
|
||||
d.extend_from_slice(format);
|
||||
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
|
||||
d.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
(b"desc", 32, d)
|
||||
}
|
||||
|
||||
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
|
||||
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
|
||||
let huge = 1i64 << 50;
|
||||
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
|
||||
let files = [
|
||||
cookie.clone(),
|
||||
[vec![0; 100], cookie].concat(),
|
||||
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
|
||||
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
|
||||
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
|
||||
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
|
||||
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
|
||||
}
|
||||
|
||||
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
|
||||
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
|
||||
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
|
||||
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
|
||||
[id, &size.to_be_bytes(), body].concat()
|
||||
}
|
||||
|
||||
/// The EBML header of a Matroska file.
|
||||
fn ebml_header() -> Vec<u8> {
|
||||
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
|
||||
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
|
||||
}
|
||||
|
||||
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
|
||||
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
|
||||
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
|
||||
/// for the header, for codec data and for block frames.
|
||||
#[test]
|
||||
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let huge = 1u64 << 47;
|
||||
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
|
||||
let reported = [
|
||||
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
|
||||
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
|
||||
];
|
||||
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
|
||||
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
|
||||
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
|
||||
// A valid 16-bit PCM track entry's children, followed by `extra`.
|
||||
let pcm = |extra: &[u8]| {
|
||||
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
|
||||
[
|
||||
ebml(&[0xD7], 1, &[1]),
|
||||
ebml(&[0x73, 0xC5], 1, &[1]),
|
||||
ebml(&[0x83], 1, &[2]),
|
||||
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
|
||||
ebml(&[0xE1], audio.len() as u64, &audio),
|
||||
extra.to_vec(),
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
|
||||
// segment is valid up to that read, which runs out of file.
|
||||
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
|
||||
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
|
||||
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
|
||||
let track = pcm(&[]);
|
||||
let track = ebml(&[0xAE], track.len() as u64, &track);
|
||||
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
|
||||
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
|
||||
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
|
||||
for hint in [None, Some("mkv"), Some("wav")] {
|
||||
assert!(probe(&reported, hint).is_err(), "{hint:?}");
|
||||
assert!(decode(&reported, hint).is_err(), "{hint:?}");
|
||||
// Refused at the oversized read itself, not by anything before it.
|
||||
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
|
||||
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
|
||||
// The file and its track open, so decoding reaches the block; the block reader stops at the
|
||||
// end of the file, a best-effort read of what is there.
|
||||
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
|
||||
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_caf_still_reads() {
|
||||
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
|
||||
let data = [vec![0; 4], pcm].concat();
|
||||
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
|
||||
let (info, buf) = decode(&lpcm, None).unwrap();
|
||||
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
|
||||
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
|
||||
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
|
||||
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
|
||||
}
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
|
||||
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
|
||||
}
|
||||
|
||||
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
|
||||
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
|
||||
let full = |kind: &[u8; 4], fields: &[u32]| {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
swap(kind, atom(kind, &body))
|
||||
};
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
|
||||
let stbl =
|
||||
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
|
||||
// The first field after version and flags: stts's entry count, stsz's sample size.
|
||||
let first_field_max = |mut a: Vec<u8>| {
|
||||
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
a
|
||||
};
|
||||
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
|
||||
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
|
||||
// A header that starts inside its container is read whole, past the container's end.
|
||||
let mdia = atom(b"mdia", &1u32.to_be_bytes());
|
||||
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
|
||||
let files = [
|
||||
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
|
||||
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
|
||||
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
|
||||
];
|
||||
for f in files {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atoms_running_past_their_parent_are_walked_once() {
|
||||
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
|
||||
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
|
||||
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
|
||||
assert!(decode(&f, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packets_longer_than_any_codec_uses_are_refused() {
|
||||
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
|
||||
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
|
||||
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
|
||||
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
|
||||
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
|
||||
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
|
||||
assert_eq!(buf.frames(), 480);
|
||||
for entry in [lpcm(1 << 21), alac(1 << 21)] {
|
||||
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
|
||||
}
|
||||
}
|
||||
|
||||
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
|
||||
#[test]
|
||||
fn flac_metadata_fields_beyond_their_block_are_refused() {
|
||||
let with_block = |kind: u8, body: &[u8]| {
|
||||
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
|
||||
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
|
||||
};
|
||||
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
|
||||
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
|
||||
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
|
||||
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
|
||||
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id3_tag_before_the_audio_is_skipped_unread() {
|
||||
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
|
||||
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
|
||||
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
|
||||
}
|
||||
|
||||
/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream.
|
||||
#[test]
|
||||
fn a_valid_mp3_with_incidental_container_magic_still_probes() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
|
||||
let mut f: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
|
||||
f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
|
||||
f[at + 8..at + 12].copy_from_slice(b"kuki");
|
||||
f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
|
||||
let info = probe(&f, None).expect("valid MP3 with incidental container magic");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
}
|
||||
|
||||
/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional
|
||||
/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe
|
||||
/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep
|
||||
/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames
|
||||
/// behind it (which would also let a real malformed container bypass the size guard).
|
||||
#[test]
|
||||
fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
|
||||
let mp3: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
|
||||
for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] {
|
||||
// The hint still names the raw stream, but the probe sees the CAF marker first.
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
// Plain leading junk without format magic is tolerated, as before.
|
||||
let f = [vec![0u8; 32], mp3].concat();
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
let info = probe(&f, Some("mp3")).expect("junk before a valid MP3");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
}
|
||||
|
||||
/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container
|
||||
/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the
|
||||
/// probe selects the MPEG reader at the first real frame.
|
||||
#[test]
|
||||
fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() {
|
||||
let mut frame = vec![0u8; 417];
|
||||
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
|
||||
let mut audio: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
|
||||
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
|
||||
audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
|
||||
audio[at + 8..at + 12].copy_from_slice(b"kuki");
|
||||
audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
|
||||
let false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
let f = [false_syncs, audio].concat();
|
||||
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
|
||||
let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3");
|
||||
assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2));
|
||||
assert_eq!(decoded.frames, buf.frames() as u64);
|
||||
}
|
||||
|
||||
/// Past the score budget the preflight does not run, so if the probe then selects a container
|
||||
/// whose reader allocates from sizes in the file, the post-probe refusal still applies.
|
||||
#[test]
|
||||
fn a_container_selected_without_its_size_check_is_refused() {
|
||||
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
|
||||
// Exactly the cap of false candidates, then one more that the reader rejects (invalid
|
||||
// bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it.
|
||||
let mut false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
|
||||
false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]);
|
||||
let f = [false_syncs, flac].concat();
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
|
||||
}
|
||||
|
||||
/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a
|
||||
/// decode failure.
|
||||
#[test]
|
||||
fn zero_frame_flac_after_a_prefix_is_still_empty() {
|
||||
let flac =
|
||||
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
|
||||
.unwrap();
|
||||
let tag = id3_tag(b"tag");
|
||||
for (f, detected) in [
|
||||
([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other),
|
||||
([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3),
|
||||
([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3),
|
||||
] {
|
||||
assert_eq!(detect_format(&f, None), detected);
|
||||
let info = probe(&f, None).expect("empty FLAC after a prefix");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
|
||||
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte
|
||||
/// header.
|
||||
fn adts_frame(payload: &[u8]) -> Vec<u8> {
|
||||
// MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header.
|
||||
let len = 7 + payload.len();
|
||||
let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC];
|
||||
h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits
|
||||
h[4] = ((len >> 3) & 0xFF) as u8;
|
||||
h[5] = (((len & 0x07) as u8) << 5) | 0x1F;
|
||||
[&h[..], payload].concat()
|
||||
}
|
||||
|
||||
/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty.
|
||||
#[test]
|
||||
fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() {
|
||||
let flac =
|
||||
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
|
||||
.unwrap();
|
||||
for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
|
||||
let f = [junk, flac.clone()].concat();
|
||||
let info = probe(&f, None).expect("empty FLAC after a false sync");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
|
||||
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight.
|
||||
#[test]
|
||||
fn a_false_sync_does_not_bypass_container_checks() {
|
||||
let bad_caf = caf(&[
|
||||
caf_desc(b"aac ", 0, 1024, 2, 0),
|
||||
(b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])),
|
||||
(b"data", -1, vec![0; 64]),
|
||||
]);
|
||||
assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat();
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
|
||||
let pcm = sample_entry(b"sowt", 0, &[], &[]);
|
||||
let bad_mp4 = mp4(480, &pcm, |kind, mut a| {
|
||||
if kind == b"stsz" {
|
||||
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
}
|
||||
a
|
||||
});
|
||||
assert!(probe(&bad_mp4, None).is_err());
|
||||
let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat();
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail.
|
||||
#[test]
|
||||
fn a_valid_adts_stream_with_incidental_container_magic_still_probes() {
|
||||
let payload = [0u8; 400];
|
||||
let mut frames: Vec<Vec<u8>> = (0..8).map(|_| adts_frame(&payload)).collect();
|
||||
let at = 100; // Inside frame 2's payload, leaving every frame header intact.
|
||||
frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki");
|
||||
let f: Vec<u8> = frames.concat();
|
||||
assert_eq!(detect_format(&f, None), FileFormat::Aac);
|
||||
let info = probe(&f, None).expect("valid ADTS with incidental container magic");
|
||||
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2));
|
||||
}
|
||||
|
||||
/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects.
|
||||
#[test]
|
||||
fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() {
|
||||
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
|
||||
let pcm: Vec<u8> = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
|
||||
let data = [vec![0; 4], pcm].concat();
|
||||
let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]);
|
||||
let f = [vec![0u8; 32], malformed, valid].concat();
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed
|
||||
/// for both probe and decode.
|
||||
#[test]
|
||||
fn truncated_or_oversized_id3_tags_are_malformed() {
|
||||
let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")];
|
||||
for f in cases {
|
||||
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_info_entries_beyond_the_file_are_refused() {
|
||||
let alaw = |len: u32| {
|
||||
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
|
||||
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
|
||||
};
|
||||
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
|
||||
let mut f = alaw(0x7fff_fff0);
|
||||
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
|
||||
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wave_behind_another_riff_form_is_checked() {
|
||||
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
|
||||
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
|
||||
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
|
||||
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
|
||||
// The probe skips the AVI form and reads the WAVE behind it.
|
||||
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_info_entries_are_found_where_symphonia_reads_them() {
|
||||
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
|
||||
let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat());
|
||||
// A-law's extra bytes are read past its chunk; an odd list is padded twice.
|
||||
let files =
|
||||
[riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()];
|
||||
for mut f in files {
|
||||
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_tiny_chunks_terminate() {
|
||||
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
|
||||
for _ in 0..200_000 {
|
||||
chunks.push((b"pad ", Vec::new()));
|
||||
}
|
||||
let wav = riff(&chunks);
|
||||
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_wav_variants() {
|
||||
// 8-bit unsigned.
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int8);
|
||||
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
|
||||
// 64-bit float, with NaN replaced by silence.
|
||||
let mut data = Vec::new();
|
||||
for v in [0.25f64, -0.5, f64::NAN] {
|
||||
data.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
|
||||
let (info, buf) = decode(&wav, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Float64);
|
||||
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
|
||||
// Unsupported PCM width.
|
||||
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
|
||||
// No data chunk / no fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
|
||||
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
|
||||
// Short fmt chunk.
|
||||
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
|
||||
// Unknown codec tag goes to symphonia and fails cleanly.
|
||||
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
|
||||
assert!(decode(&wav, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_sowt_little_endian() {
|
||||
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
|
||||
let (_, expect) = decode(&be, None).unwrap();
|
||||
// Rebuild as AIFC/sowt by hand.
|
||||
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
|
||||
let mut comm_body = be[comm + 8..comm + 26].to_vec();
|
||||
comm_body.extend_from_slice(b"sowt");
|
||||
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
|
||||
let mut ssnd_body = be[ssnd + 8..].to_vec();
|
||||
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
|
||||
pair.swap(0, 1);
|
||||
}
|
||||
let mut body = b"AIFC".to_vec();
|
||||
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
|
||||
body.extend_from_slice(id);
|
||||
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||
body.extend_from_slice(data);
|
||||
}
|
||||
let mut f = b"FORM".to_vec();
|
||||
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
f.extend_from_slice(&body);
|
||||
let (info, got) = decode(&f, None).unwrap();
|
||||
assert_eq!(info.sample_format, SampleFormat::Int16);
|
||||
assert_eq!(got, expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_format_by_magic_and_hint() {
|
||||
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
|
||||
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
|
||||
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
|
||||
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
|
||||
id3_flac.extend_from_slice(b"fLaC");
|
||||
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
|
||||
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
|
||||
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
|
||||
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
|
||||
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
|
||||
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
|
||||
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
|
||||
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
|
||||
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
|
||||
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
|
||||
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
|
||||
assert_eq!(detect_format(b"", None), FileFormat::Other);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_in_compressed_containers_errors() {
|
||||
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
|
||||
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
|
||||
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
|
||||
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
|
||||
}
|
||||
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
|
||||
let mut f = magic.to_vec();
|
||||
f.extend_from_slice(&garbage);
|
||||
let _ = probe(&f, None);
|
||||
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
|
||||
}
|
||||
assert!(decode(&[], None).is_err());
|
||||
}
|
||||
|
||||
/// A marker that passes its container check must not let the probe select a different container
|
||||
/// without its own checks: the reader is refused when the probed format disagrees.
|
||||
#[test]
|
||||
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
|
||||
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
|
||||
// probe reads the WAVE before it.
|
||||
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
|
||||
match decode(&bytes, Some("flac")) {
|
||||
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
|
||||
Err(other) => panic!("unexpected error kind: {other:?}"),
|
||||
Ok(_) => panic!("the mismatched container must be refused"),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
|
||||
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
|
||||
let _ = detect_format(&bytes, hint);
|
||||
let _ = probe(&bytes, hint);
|
||||
let _ = decode(&bytes, hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
|
||||
let mut f = match which {
|
||||
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
|
||||
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
|
||||
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
|
||||
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
|
||||
};
|
||||
f.extend_from_slice(&body);
|
||||
let _ = probe(&f, None);
|
||||
let _ = decode(&f, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
|
||||
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
|
||||
prop_assume!(buf.frames() > 0);
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
|
||||
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
|
||||
prop_assert_eq!(flac, wav);
|
||||
}
|
||||
}
|
||||
|
||||
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
|
||||
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
|
||||
let mut f = fmt(0xFFFE, 1, 44_100, 16);
|
||||
f.extend_from_slice(&cb_size.to_le_bytes());
|
||||
f.extend_from_slice(&valid_bits.to_le_bytes());
|
||||
f.extend_from_slice(&4u32.to_le_bytes());
|
||||
f.extend_from_slice(guid);
|
||||
f
|
||||
}
|
||||
|
||||
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
|
||||
|
||||
#[test]
|
||||
fn extensible_fmt_is_checked_in_full() {
|
||||
let data = vec![0u8; 8];
|
||||
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
// Subtype GUID cut short after the format code.
|
||||
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
|
||||
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
|
||||
// cbSize too small for the extension.
|
||||
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
|
||||
// More valid bits than the 16-bit container holds.
|
||||
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
|
||||
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
|
||||
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
|
||||
let mut foreign = PCM_GUID;
|
||||
foreign[15] ^= 0xFF;
|
||||
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
|
||||
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
|
||||
// The Ambisonic PCM subtype is PCM.
|
||||
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
|
||||
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
|
||||
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
|
||||
}
|
||||
|
||||
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
|
||||
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
|
||||
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
|
||||
atom(kind, &body)
|
||||
}
|
||||
|
||||
/// An MPEG-4 descriptor with a one-byte length.
|
||||
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
|
||||
[&[tag, body.len() as u8][..], body].concat()
|
||||
}
|
||||
|
||||
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
|
||||
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
|
||||
|
||||
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
|
||||
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
|
||||
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
|
||||
let dsi = descriptor(5, &[0x11, 0x88]);
|
||||
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
|
||||
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
|
||||
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
|
||||
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
|
||||
let total = (packets - 1) * 1024 + last_dur;
|
||||
let moov = |offset: u32| {
|
||||
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
|
||||
let stbl = [
|
||||
stsd,
|
||||
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
|
||||
full(b"stsc", &[1, 1, packets, 1]),
|
||||
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
|
||||
full(b"stco", &[1, offset]),
|
||||
];
|
||||
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
|
||||
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
|
||||
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
|
||||
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
|
||||
};
|
||||
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
|
||||
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
|
||||
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
|
||||
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
|
||||
for last_dur in [1145, 546] {
|
||||
let file = aac_mp4(10, last_dur);
|
||||
let (_, buf) = decode(&file, Some("m4a")).unwrap();
|
||||
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
|
||||
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use soundcraft_audio_io::{
|
||||
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
|
||||
};
|
||||
|
||||
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
|
||||
let mut b = AudioBuffer::new(sr, channels, frames);
|
||||
for (c, ch) in b.channels.iter_mut().enumerate() {
|
||||
let f = 220.0 * (c as f32 + 1.0);
|
||||
for (i, s) in ch.iter_mut().enumerate() {
|
||||
*s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin();
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn tolerance(depth: BitDepth, dither: bool) -> f32 {
|
||||
let lsb = match depth {
|
||||
BitDepth::Int16 => 1.0 / 32768.0,
|
||||
BitDepth::Int24 => 1.0 / 8_388_608.0,
|
||||
BitDepth::Int32 => 1e-7,
|
||||
BitDepth::Float32 => 0.0,
|
||||
};
|
||||
lsb * if dither { 1.6 } else { 0.6 } + 1e-7
|
||||
}
|
||||
|
||||
fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 {
|
||||
assert_eq!(a.num_channels(), b.num_channels());
|
||||
assert_eq!(a.frames(), b.frames());
|
||||
a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32];
|
||||
|
||||
fn expected_sf(d: BitDepth) -> SampleFormat {
|
||||
match d {
|
||||
BitDepth::Int16 => SampleFormat::Int16,
|
||||
BitDepth::Int24 => SampleFormat::Int24,
|
||||
BitDepth::Int32 => SampleFormat::Int32,
|
||||
BitDepth::Float32 => SampleFormat::Float32,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_and_aiff_roundtrip_all_depths_and_layouts() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
for depth in DEPTHS {
|
||||
for channels in [1, 2, 6] {
|
||||
for dither in [false, true] {
|
||||
let src = sine(48_000, channels, 4801);
|
||||
let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
assert_eq!(detect_format(&bytes, None), format);
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.format, format);
|
||||
assert_eq!(info.sample_rate, 48_000);
|
||||
assert_eq!(info.channels as usize, channels);
|
||||
assert_eq!(info.frames, 4801);
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (dinfo, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo, info);
|
||||
assert_eq!(out.sample_rate, 48_000);
|
||||
let err = max_err(&src, &out);
|
||||
assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_roundtrip_is_lossless_vs_wav() {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for channels in [1, 2, 6] {
|
||||
// Spans several blocks plus a short final block.
|
||||
let src = sine(44_100, channels, 4096 * 2 + 17);
|
||||
let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap();
|
||||
assert_eq!(detect_format(&flac, None), FileFormat::Flac);
|
||||
let info = probe(&flac, None).unwrap();
|
||||
assert_eq!(info.format, FileFormat::Flac);
|
||||
assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64));
|
||||
assert_eq!(info.sample_format, expected_sf(depth));
|
||||
let (_, from_wav) = decode(&wav, None).unwrap();
|
||||
let (finfo, from_flac) = decode(&flac, None).unwrap();
|
||||
assert_eq!(finfo.frames, src.frames() as u64);
|
||||
assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch");
|
||||
if depth != BitDepth::Int32 {
|
||||
assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_handles_silence_noise_full_scale_and_odd_rates() {
|
||||
let frames = 10_000;
|
||||
let mut b = AudioBuffer::new(37_123, 3, frames);
|
||||
let mut seed = 1u32;
|
||||
for i in 0..frames {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise
|
||||
b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square
|
||||
}
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
|
||||
for dither in [false, true] {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None };
|
||||
let flac = encode(&b, &opts).unwrap();
|
||||
let (info, out) = decode(&flac, None).unwrap();
|
||||
assert_eq!(info.sample_rate, 37_123);
|
||||
let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap();
|
||||
let (_, expect) = decode(&wav, None).unwrap();
|
||||
assert_eq!(out, expect, "{depth:?} dither={dither}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_rejects_unsupported_layouts() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
assert!(encode(&sine(48_000, 2, 10), &opts).is_err());
|
||||
let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
|
||||
assert!(encode(&sine(48_000, 9, 10), &opts).is_err());
|
||||
assert!(encode(&sine(700_000, 1, 10), &opts).is_err());
|
||||
// Tiny buffers still encode and decode.
|
||||
let one = sine(48_000, 2, 1);
|
||||
let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_roundtrip() {
|
||||
let bwf = BwfInfo {
|
||||
description: "Scene 12 take 3".into(),
|
||||
originator: "SoundCraft".into(),
|
||||
originator_reference: "SC-0001".into(),
|
||||
origination_date: "2026-10-06".into(),
|
||||
origination_time: "12:34:56".into(),
|
||||
time_reference: 0x1_2345_6789, // > 32 bits
|
||||
};
|
||||
let src = sine(96_000, 2, 1000);
|
||||
for depth in DEPTHS {
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) };
|
||||
let bytes = encode(&src, &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!(info.bwf.as_ref(), Some(&bwf));
|
||||
let (dinfo, _) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(dinfo.bwf, Some(bwf.clone()));
|
||||
// RF64 layout reads back identically.
|
||||
let rf64 = encode_wav_rf64(&src, &opts).unwrap();
|
||||
assert_eq!(&rf64[..4], b"RF64");
|
||||
let (rinfo, rbuf) = decode(&rf64, None).unwrap();
|
||||
assert_eq!(rinfo.bwf, Some(bwf.clone()));
|
||||
assert_eq!(rinfo.frames, 1000);
|
||||
assert_eq!(rbuf, decode(&bytes, None).unwrap().1);
|
||||
}
|
||||
// Overlong strings are truncated to the field width, not an error.
|
||||
let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_layout() {
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap();
|
||||
// Plain PCM for 16-bit stereo; extensible otherwise.
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1);
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap();
|
||||
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
|
||||
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE);
|
||||
let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(riff + 8, b.len());
|
||||
// Odd data length (24-bit mono, odd frames) gets a pad byte.
|
||||
let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(b.len() % 2, 0);
|
||||
assert_eq!(decode(&b, None).unwrap().1.frames(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_float_is_aifc() {
|
||||
let b =
|
||||
encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFC");
|
||||
assert!(b.windows(4).any(|w| w == b"fl32"));
|
||||
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(&b[8..12], b"AIFF");
|
||||
let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
|
||||
assert_eq!(form + 8, b.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusual_sample_rates_roundtrip() {
|
||||
for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff] {
|
||||
let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap();
|
||||
assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rejects_bad_buffers_and_formats() {
|
||||
assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err());
|
||||
assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err());
|
||||
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
|
||||
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
|
||||
}
|
||||
// Non-finite samples are sanitized; only integer output clips finite samples.
|
||||
let mut b = AudioBuffer::new(48_000, 1, 4);
|
||||
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in DEPTHS {
|
||||
if format == FileFormat::Flac && depth == BitDepth::Float32 {
|
||||
continue;
|
||||
}
|
||||
let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap();
|
||||
let (_, out) = decode(&bytes, None).unwrap();
|
||||
assert_eq!(out.channels[0][0], 0.0);
|
||||
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
|
||||
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
|
||||
}
|
||||
}
|
||||
// Ragged buffers encode the common length.
|
||||
let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] };
|
||||
let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap();
|
||||
assert_eq!(out.frames(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_buffer_helpers() {
|
||||
let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]);
|
||||
assert_eq!(b.frames(), 2);
|
||||
assert_eq!(b.num_channels(), 2);
|
||||
assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]);
|
||||
assert!((b.peak() - 0.4).abs() < 1e-7);
|
||||
assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12);
|
||||
assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0);
|
||||
assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0);
|
||||
assert_eq!(AudioBuffer::default().frames(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_types_roundtrip() {
|
||||
let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() };
|
||||
let s = serde_json::to_string(&opts).unwrap();
|
||||
assert_eq!(serde_json::from_str::<EncodeOptions>(&s).unwrap(), opts);
|
||||
let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap();
|
||||
let s = serde_json::to_string(&info).unwrap();
|
||||
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&s).unwrap(), info);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multichannel_wav_speaker_masks() {
|
||||
let buf = |n: usize| AudioBuffer { sample_rate: 48_000, channels: (0..n).map(|c| vec![0.01 * c as f32; 64]).collect() };
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
for (n, mask) in [(6usize, 0x3Fu32), (8, 0x63F), (12, 0x2_D63F)] {
|
||||
let bytes = soundcraft_audio_io::encode(&buf(n), &opts).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(mask), "{n} channels");
|
||||
assert_eq!(soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap().1.channels.len(), n);
|
||||
}
|
||||
// Explicit masks: 7.0 (sides + rears, no LFE) and Ambisonics (no positions).
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(7), &opts, 0x637).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x637));
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(4), &opts, 0).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0));
|
||||
// Plain 16-bit stereo stays a plain PCM header.
|
||||
let o16 = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
|
||||
let take = sine(48_000, 2, 1_000);
|
||||
let mut file = wav_float_header(2, 48_000, 0).unwrap();
|
||||
let len = file.len();
|
||||
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
|
||||
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
|
||||
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
|
||||
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
|
||||
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
|
||||
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
|
||||
assert!(wav_float_header(0, 48_000, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
for depth in [BitDepth::Int16, BitDepth::Int24] {
|
||||
for channels in [1, 2] {
|
||||
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
|
||||
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
|
||||
let info = probe(&bytes, None).unwrap();
|
||||
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
|
||||
assert_eq!(usize::from(info.channels), channels);
|
||||
let (info, buf) = decode(&bytes, None).unwrap();
|
||||
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flac_claiming_frames_but_holding_none_still_errors() {
|
||||
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
|
||||
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
|
||||
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
|
||||
assert!(decode(&bytes, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
|
||||
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
|
||||
let frames = u64::MAX / 4;
|
||||
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bwf_truncation_keeps_utf8_boundaries() {
|
||||
let src = sine(48_000, 1, 10);
|
||||
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
|
||||
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
|
||||
let out = probe(&bytes, None).unwrap().bwf.unwrap();
|
||||
assert_eq!(out.originator, "a".repeat(31));
|
||||
assert_eq!(out.description, "d".repeat(255));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
|
||||
use std::io::{Cursor, Seek, SeekFrom, Write};
|
||||
|
||||
fn tone(channels: usize, frames: usize) -> AudioBuffer {
|
||||
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
|
||||
AudioBuffer { sample_rate: 44_100, channels }
|
||||
}
|
||||
|
||||
fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option<u32>) -> Vec<u8> {
|
||||
let mut out = Cursor::new(b"pre".to_vec());
|
||||
out.set_position(3);
|
||||
let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap();
|
||||
let mut start = 0;
|
||||
for len in [1, 7, 4095, 4097, 333].into_iter().cycle() {
|
||||
if start >= buf.frames() {
|
||||
break;
|
||||
}
|
||||
let len = len.min(buf.frames() - start);
|
||||
let block: Vec<Vec<f32>> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect();
|
||||
enc.write(&block, len).unwrap();
|
||||
start += len;
|
||||
}
|
||||
enc.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_files_match_whole_buffer_encodes() {
|
||||
let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() };
|
||||
let cases = [
|
||||
(FileFormat::Wav, BitDepth::Int24, true, None, None),
|
||||
(FileFormat::Wav, BitDepth::Float32, false, None, None),
|
||||
(FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)),
|
||||
(FileFormat::Aiff, BitDepth::Int24, true, None, None),
|
||||
(FileFormat::Aiff, BitDepth::Float32, false, None, None),
|
||||
(FileFormat::Flac, BitDepth::Int16, true, None, None),
|
||||
(FileFormat::Flac, BitDepth::Int24, false, None, None),
|
||||
];
|
||||
for (format, bit_depth, dither, bwf, mask) in cases {
|
||||
let opts = EncodeOptions { format, bit_depth, dither, bwf };
|
||||
for channels in [1, 6] {
|
||||
let buf = tone(channels, 20_001);
|
||||
let whole = match mask {
|
||||
Some(m) => encode_with_channel_mask(&buf, &opts, m),
|
||||
None => encode(&buf, &opts),
|
||||
}
|
||||
.unwrap();
|
||||
assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
|
||||
/// a `write_all` on it appends a prefix of the block and only then reports the error.
|
||||
struct FailAfter {
|
||||
inner: Cursor<Vec<u8>>,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl Write for FailAfter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if self.budget == 0 {
|
||||
return Err(std::io::Error::other("sink is full"));
|
||||
}
|
||||
let n = buf.len().min(self.budget);
|
||||
self.budget -= n;
|
||||
self.inner.write(&buf[..n])
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.inner.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for FailAfter {
|
||||
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
|
||||
self.inner.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_poisons_the_encoder() {
|
||||
let buf = tone(2, 20_001);
|
||||
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
|
||||
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
|
||||
// The sink accepts a prefix of the first block, then fails.
|
||||
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
|
||||
// Later calls must not claim success: the bytes on the sink no longer match the
|
||||
// encoder's counters (and for FLAC the failed frame's samples are already gone).
|
||||
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
|
||||
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
|
||||
let buf = tone(2, 100);
|
||||
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
|
||||
enc.write(&buf.channels, 100).unwrap();
|
||||
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
|
||||
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
if let Ok(frames) = usize::try_from(limit) {
|
||||
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
|
||||
}
|
||||
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
|
||||
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
|
||||
|
||||
use soundcraft_audio_io::{decode, probe};
|
||||
|
||||
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
|
||||
let align = channels * 2;
|
||||
let mut f = Vec::new();
|
||||
f.extend_from_slice(&1u16.to_le_bytes());
|
||||
f.extend_from_slice(&channels.to_le_bytes());
|
||||
f.extend_from_slice(&sr.to_le_bytes());
|
||||
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
|
||||
f.extend_from_slice(&align.to_le_bytes());
|
||||
f.extend_from_slice(&16u16.to_le_bytes());
|
||||
f
|
||||
}
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut c = id.to_vec();
|
||||
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
c.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
c.push(0);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// A finished file: correct RIFF size, chunks as given.
|
||||
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut body = b"WAVE".to_vec();
|
||||
for c in chunks {
|
||||
body.extend_from_slice(c);
|
||||
}
|
||||
let mut out = b"RIFF".to_vec();
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&body);
|
||||
out
|
||||
}
|
||||
|
||||
fn list_info() -> Vec<u8> {
|
||||
let mut l = b"INFO".to_vec();
|
||||
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
|
||||
chunk(b"LIST", &l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
|
||||
let info = probe(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!((info.frames, buf.frames()), (0, 0));
|
||||
// The same with the metadata first.
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
// And with a single trailing chunk of odd size and no pad byte at the very end.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
|
||||
file.pop();
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
|
||||
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
|
||||
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
file[4..8].copy_from_slice(&0u32.to_le_bytes());
|
||||
let (info, buf) = decode(&file, Some("wav")).unwrap();
|
||||
assert_eq!(info.frames, 200);
|
||||
assert_eq!(buf.frames(), 200);
|
||||
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
|
||||
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
|
||||
file.extend_from_slice(&samples);
|
||||
let riff_size = (file.len() - 8) as u32;
|
||||
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_data_chunk_with_audio_is_unchanged() {
|
||||
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
|
||||
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
|
||||
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-engine.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-time.workspace = true
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use crate::Backend;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::FileFormat;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
@@ -70,7 +71,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"bounce_mix",
|
||||
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": craft_audio::ENCODE_EXTENSIONS}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": FileFormat::writable_extensions()}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
|
||||
&["path"],
|
||||
),
|
||||
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
|
||||
|
||||
@@ -310,36 +310,6 @@ fn links_out_of_the_roots_are_refused() {
|
||||
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn exports_refuse_hard_links_without_changing_either_name() {
|
||||
let f = fixture("hard-links");
|
||||
let midi = f.write.join("linked.mid");
|
||||
let preset = f.write.join("linked.json");
|
||||
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
|
||||
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
|
||||
let mut srv = demo_server(Some(&f.read), Some(&f.write));
|
||||
let before = listing(&f.write);
|
||||
|
||||
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
|
||||
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
|
||||
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
|
||||
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
|
||||
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
|
||||
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
|
||||
}
|
||||
assert_eq!(listing(&f.write), before);
|
||||
|
||||
// The error offers a new export path; both commands must still work at ordinary paths.
|
||||
let fresh_midi = f.write.join("fresh.mid");
|
||||
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
|
||||
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
|
||||
let fresh_preset = f.write.join("fresh.json");
|
||||
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
|
||||
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
|
||||
assert_eq!(preset_json["name"], "Kick");
|
||||
}
|
||||
|
||||
/// A session file is hostile input too: media it names outside the read root is not loaded,
|
||||
/// and the refusal is listed with the other missing media.
|
||||
#[test]
|
||||
|
||||
@@ -17,11 +17,9 @@ pub(crate) fn on_main_thread() -> bool {
|
||||
unsafe { pthread_main_np() == 1 }
|
||||
}
|
||||
|
||||
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
|
||||
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub(crate) fn on_main_thread() -> bool {
|
||||
std::thread::current().name() == Some("main")
|
||||
true
|
||||
}
|
||||
|
||||
/// The windowing API of this platform.
|
||||
|
||||
+16
-209
@@ -10,10 +10,6 @@ use std::sync::atomic::Ordering;
|
||||
const MAX_BLOCK: usize = 1 << 16;
|
||||
/// Most channels a host strip may ask for.
|
||||
const MAX_CHANNELS: usize = 64;
|
||||
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
|
||||
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
|
||||
/// a reset (#1464).
|
||||
const RELEASE_RESERVE: usize = 128;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum NoteMode {
|
||||
@@ -83,104 +79,14 @@ impl ClapPlugin {
|
||||
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
|
||||
}
|
||||
|
||||
/// Queues an event other than a note release; see [`push_event`].
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
fn push(&mut self, e: Event) {
|
||||
push_event(&mut self.queue, e, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
|
||||
/// Returns whether the event was queued.
|
||||
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
|
||||
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
|
||||
if queue.len() >= room {
|
||||
return false;
|
||||
}
|
||||
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
queue.insert(pos, e);
|
||||
true
|
||||
}
|
||||
|
||||
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
|
||||
/// later reset still releases it (#1464).
|
||||
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
|
||||
if let Some(e) = release_event(notes, time, key)
|
||||
&& push_event(queue, e, true)
|
||||
&& let Some(h) = held.get_mut(usize::from(key))
|
||||
{
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The release of `key` in the note port's dialect (`None` without a note port).
|
||||
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
|
||||
match notes {
|
||||
NoteMode::Clap => Some(Event::NoteOff { time, key }),
|
||||
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
|
||||
NoteMode::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
|
||||
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
|
||||
///
|
||||
/// It never runs out of room: events other than `note_off` releases fill at most
|
||||
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
|
||||
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
|
||||
keep_releases(queue);
|
||||
for e in queue.iter() {
|
||||
let key = match *e {
|
||||
Event::NoteOff { key, .. } => Some(key),
|
||||
// Channel 1, where `note_off` sends them.
|
||||
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
|
||||
*h = false;
|
||||
if self.queue.len() >= self.queue.capacity() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for key in 0..128u8 {
|
||||
if held.get(usize::from(key)).copied().unwrap_or(false)
|
||||
&& let Some(e) = release_event(notes, 0, key)
|
||||
{
|
||||
let _ = push_event(queue, e, true);
|
||||
}
|
||||
}
|
||||
*held = [false; 128];
|
||||
}
|
||||
|
||||
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
|
||||
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
|
||||
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
|
||||
/// for `all_notes_off`.
|
||||
fn keep_releases(queue: &mut Vec<Event>) {
|
||||
let mut clap_off = [false; 128];
|
||||
let mut midi_off = [[false; 128]; 16];
|
||||
queue.retain(|e| match *e {
|
||||
Event::Param { .. } => true,
|
||||
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
|
||||
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
|
||||
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
for e in queue.iter_mut() {
|
||||
e.set_time(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a key's release as seen; true only the first time (false for a key out of range).
|
||||
fn first_release(seen: Option<&mut bool>) -> bool {
|
||||
seen.is_some_and(|s| !std::mem::replace(s, true))
|
||||
}
|
||||
|
||||
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
|
||||
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
|
||||
match status & 0xF0 {
|
||||
0x80 => true,
|
||||
0x90 => velocity == 0,
|
||||
_ => false,
|
||||
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.queue.insert(pos, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,10 +185,8 @@ impl Plugin for ClapPlugin {
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
|
||||
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
|
||||
// left to release them (#1464).
|
||||
release_on_reset(&mut self.queue, &mut self.held, self.notes);
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
self.held = [false; 128];
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
@@ -383,7 +287,14 @@ impl Plugin for ClapPlugin {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
@@ -472,107 +383,3 @@ impl PluginEditor for ClapEditor {
|
||||
self.link.host().dirty.swap(false, Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reset keeps parameter changes and one release per key, at the start of the next block,
|
||||
/// and drops note-ons and other MIDI.
|
||||
#[test]
|
||||
fn reset_keeps_params_and_one_release_per_key() {
|
||||
let mut q = vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
|
||||
Event::NoteOff { time: 5, key: 60 },
|
||||
Event::NoteOff { time: 9, key: 60 },
|
||||
Event::NoteOff { time: 12, key: 64 },
|
||||
Event::Midi { time: 20, data: [0x90, 62, 90] },
|
||||
Event::Midi { time: 21, data: [0xB0, 64, 127] },
|
||||
Event::Midi { time: 22, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 23, data: [0x80, 62, 40] },
|
||||
Event::Midi { time: 24, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 25, data: [0x91, 67, 0] },
|
||||
Event::Midi { time: 26, data: [0x80, 200, 0] },
|
||||
];
|
||||
keep_releases(&mut q);
|
||||
assert_eq!(
|
||||
q,
|
||||
vec![
|
||||
Event::Param { time: 0, id: 4, value: 0.5 },
|
||||
Event::NoteOff { time: 0, key: 60 },
|
||||
Event::NoteOff { time: 0, key: 64 },
|
||||
Event::Midi { time: 0, data: [0x80, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x83, 62, 0] },
|
||||
Event::Midi { time: 0, data: [0x91, 67, 0] },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A queue that parameter changes filled still releases every held key on a reset, once
|
||||
/// each, and keeps the parameter changes.
|
||||
#[test]
|
||||
fn a_full_queue_still_releases_every_held_key_on_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
|
||||
// Keys 0..4 already have a release queued, and are held again after it.
|
||||
for key in 0..4u8 {
|
||||
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
|
||||
}
|
||||
let mut held = [true; 128];
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
assert_eq!(held, [false; 128]);
|
||||
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
|
||||
for key in 0..128u8 {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
|
||||
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
|
||||
/// then `reset` does on Stop.
|
||||
#[test]
|
||||
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
|
||||
for notes in [NoteMode::Clap, NoteMode::Midi] {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
let mut held = [false; 128];
|
||||
for id in 0..EVENT_CAP as u32 {
|
||||
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
|
||||
}
|
||||
// Key 0 pressed and released over and over fills the reserve with its releases.
|
||||
for t in 0..RELEASE_RESERVE as u32 {
|
||||
held[0] = true;
|
||||
queue_release(&mut q, &mut held, notes, t, 0);
|
||||
}
|
||||
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
|
||||
held[5] = true;
|
||||
queue_release(&mut q, &mut held, notes, 0, 5);
|
||||
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
|
||||
release_on_reset(&mut q, &mut held, notes);
|
||||
for key in [0u8, 5] {
|
||||
let want = release_event(notes, 0, key).unwrap();
|
||||
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
|
||||
}
|
||||
assert_eq!(held, [false; 128]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queue full of releases still has room to release every held key after a reset.
|
||||
#[test]
|
||||
fn reset_keeps_room_to_release_every_held_key() {
|
||||
let mut q = Vec::with_capacity(EVENT_CAP);
|
||||
for i in 0..EVENT_CAP {
|
||||
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
|
||||
}
|
||||
keep_releases(&mut q);
|
||||
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
|
||||
assert_eq!(q, want, "one release per key remains");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,9 +150,7 @@ mod tests {
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
// Created exclusively: a folder still there after the cleanup (someone else's) would
|
||||
// let a link inside it redirect the test's writes.
|
||||
std::fs::create_dir(&d).unwrap();
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
mod common;
|
||||
|
||||
use soundcraft_dsp::Plugin;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
|
||||
@@ -15,31 +14,12 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
|
||||
io
|
||||
}
|
||||
|
||||
/// The warnings logged, to tell which way the host went.
|
||||
static WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
struct Warnings;
|
||||
|
||||
impl log::Log for Warnings {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
WARNINGS.lock().unwrap().push(r.args().to_string());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// nextest lists custom-harness tests with `--list`; each binary here is one test.
|
||||
if std::env::args().any(|a| a == "--list") {
|
||||
println!("editor_main_thread: test");
|
||||
return;
|
||||
}
|
||||
log::set_logger(&Warnings).expect("logger");
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
common::fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
@@ -52,26 +32,6 @@ fn main() {
|
||||
// block and is reported (in SoundCraft units) by idle.
|
||||
let io = block(&mut p);
|
||||
assert!((io[0][10] - 0.25).abs() < 1e-6, "{}", io[0][10]);
|
||||
// Only the main thread drives the editor, on every platform (#901). Another thread, named or
|
||||
// not, is refused: it neither takes the edit idle reports nor closes or reopens the editor.
|
||||
for name in [None, Some("soundcraft-ui")] {
|
||||
let mut other = p.editor().expect("editor handle");
|
||||
let thread = match name {
|
||||
Some(n) => std::thread::Builder::new().name(n.to_string()),
|
||||
None => std::thread::Builder::new(),
|
||||
};
|
||||
let seen = thread
|
||||
.spawn(move || {
|
||||
let edits = other.idle();
|
||||
other.close();
|
||||
(edits, other.open().map_err(|e| e.contains("main thread")))
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("no panic");
|
||||
assert_eq!(seen, (Vec::new(), Err(true)), "{name:?}");
|
||||
assert!(ed.is_open(), "{name:?}");
|
||||
}
|
||||
assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);
|
||||
assert!(ed.idle().is_empty(), "drained");
|
||||
ed.close();
|
||||
@@ -89,17 +49,5 @@ fn main() {
|
||||
assert!(ed.open().is_err());
|
||||
assert!(ed.idle().is_empty());
|
||||
ed.close();
|
||||
// #901: a plugin dropped on another thread with its editor open (an audio callback dropped
|
||||
// by the device's thread) does not destroy the editor there, clap.gui being main-thread only:
|
||||
// it warns and leaves it.
|
||||
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let mut ed = p.editor().expect("editor handle");
|
||||
ed.open().expect("open on the main thread");
|
||||
std::thread::Builder::new().name("audio".into()).spawn(move || drop(p)).expect("spawn").join().expect("no panic");
|
||||
let left = "clap: editor still open while its plugin is destroyed off the main thread";
|
||||
assert!(WARNINGS.lock().unwrap().iter().any(|w| w == left), "{:?}", WARNINGS.lock().unwrap());
|
||||
assert!(!ed.is_open());
|
||||
assert!(ed.open().is_err());
|
||||
println!("editor_main_thread: ok");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
|
||||
//! MIDI CC 7 (volume) sets the constant to its value / 127.
|
||||
//!
|
||||
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
|
||||
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
|
||||
@@ -102,8 +102,6 @@ struct Fx {
|
||||
active: bool,
|
||||
processing: bool,
|
||||
gui_created: bool,
|
||||
/// The thread that created the GUI (the host's main thread).
|
||||
gui_thread: Option<std::thread::ThreadId>,
|
||||
gui_visible: bool,
|
||||
gui_edit_pending: bool,
|
||||
/// `clap.render` set the offline mode (wide fixtures only).
|
||||
@@ -132,10 +130,7 @@ unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
|
||||
}
|
||||
|
||||
unsafe extern "C" fn destroy(p: *const clap_plugin) {
|
||||
let f = unsafe { fx(p) };
|
||||
// A host destroying the plugin off the GUI's thread can't destroy the GUI there (clap.gui is
|
||||
// main-thread only), so it may leave it; on the GUI's thread it must destroy it first.
|
||||
assert!(!f.gui_created || f.gui_thread != Some(std::thread::current().id()), "plugin destroyed with its GUI alive");
|
||||
assert!(!unsafe { fx(p) }.gui_created, "plugin destroyed with its GUI alive");
|
||||
let data = unsafe { (*p).plugin_data } as *mut Fx;
|
||||
drop(unsafe { Box::from_raw(data) });
|
||||
}
|
||||
@@ -166,9 +161,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.processing = false;
|
||||
}
|
||||
|
||||
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
|
||||
/// (Surge XT's), so the host must send the releases itself.
|
||||
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
|
||||
unsafe extern "C" fn reset(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.level = 0.0;
|
||||
}
|
||||
|
||||
fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
|
||||
@@ -497,7 +492,6 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa
|
||||
let f = unsafe { fx(p) };
|
||||
assert!(floating && !f.gui_created, "create");
|
||||
f.gui_created = true;
|
||||
f.gui_thread = Some(std::thread::current().id());
|
||||
true
|
||||
}
|
||||
|
||||
@@ -644,7 +638,6 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
|
||||
active: false,
|
||||
processing: false,
|
||||
gui_created: false,
|
||||
gui_thread: None,
|
||||
gui_visible: false,
|
||||
gui_edit_pending: false,
|
||||
offline: false,
|
||||
|
||||
@@ -109,48 +109,6 @@ fn synth_plays_notes_at_their_offsets() {
|
||||
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
|
||||
}
|
||||
|
||||
/// Renders one 64-frame stereo block of `p` and returns its left channel.
|
||||
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
io.swap_remove(0)
|
||||
}
|
||||
|
||||
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
|
||||
/// reset leaves its voices sounding, and a later block is silent (#1464).
|
||||
#[test]
|
||||
fn reset_delivers_the_releases_of_held_notes() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
|
||||
|
||||
// As the mix engine does on Stop: release the held note, then reset.
|
||||
p.note_on(0, 60, 127);
|
||||
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
|
||||
p.all_notes_off();
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
|
||||
|
||||
// A reset alone releases what is still held.
|
||||
p.note_on(0, 61, 127);
|
||||
render_block(p.as_mut());
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
|
||||
|
||||
// A release queued for a block that never ran is still delivered.
|
||||
p.note_on(0, 62, 127);
|
||||
render_block(p.as_mut());
|
||||
p.note_off(30, 62);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
|
||||
|
||||
// A note-on queued for the position being left is dropped, not played after the reset.
|
||||
p.note_on(10, 63, 127);
|
||||
p.reset();
|
||||
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
fixture_dir();
|
||||
@@ -257,12 +215,14 @@ fn state_reader_reads_while_another_thread_processes() {
|
||||
#[test]
|
||||
fn editors_refuse_to_open_off_the_main_thread() {
|
||||
fixture_dir();
|
||||
// Test threads are never the process main thread, on any platform (#901).
|
||||
// Test threads are never the process main thread.
|
||||
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
let mut ed = p.editor().expect("the gain fixture has clap.gui");
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(ed.open().unwrap_err().contains("main thread"));
|
||||
assert!(p.open_editor().is_err());
|
||||
assert!(!ed.is_open());
|
||||
}
|
||||
ed.close();
|
||||
p.close_editor();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Harmonic effects: Saturator (tanh), Bit Crusher (bit/sample-rate reduction), Rectifier and
|
||||
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
|
||||
//! Subharmonic (octave-down bass synthesis).
|
||||
|
||||
use crate::biquad::{Biquad, FilterType};
|
||||
@@ -134,8 +134,8 @@ static LOFI_PARAMS: [ParamInfo; 4] = [
|
||||
|
||||
pub static LOFI_INFO: PluginInfo = PluginInfo {
|
||||
id: "lofi",
|
||||
name: "Bit Crusher",
|
||||
short_name: "Crush",
|
||||
name: "Lo-Fi",
|
||||
short_name: "LoFi",
|
||||
category: Category::Harmonic,
|
||||
params: &LOFI_PARAMS,
|
||||
is_instrument: false,
|
||||
|
||||
@@ -9,7 +9,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-time.workspace = true
|
||||
craft-audio.workspace = true
|
||||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-clap-host.workspace = true
|
||||
@@ -25,9 +25,6 @@ log.workspace = true
|
||||
zip.workspace = true
|
||||
craft-docpath.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winsafe = { version = "0.0.29", features = ["kernel"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
|
||||
+17
-195
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::io::{Recording, open_session_linked, sanitize_name};
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use craft_audio::{BitDepth, EncodeOptions};
|
||||
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{Session, SourceAudio, SourceId};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
@@ -212,11 +212,6 @@ impl Autosave {
|
||||
let audio_dir = self.dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
let mut copies = BTreeMap::new();
|
||||
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
|
||||
// (a session may name one file under many ids) share one file.
|
||||
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
|
||||
// Copies still to make, written once the size is checked; their numbers are taken then too.
|
||||
let (mut fresh, mut next) = (Vec::new(), self.next);
|
||||
for src in &mut s.sources {
|
||||
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
|
||||
let audio = s.pool.get(src.id);
|
||||
@@ -227,37 +222,26 @@ impl Autosave {
|
||||
continue;
|
||||
}
|
||||
let Some(audio) = audio else { continue };
|
||||
let name = match self.copies.get(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
|
||||
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
|
||||
let name = match self.copies.remove(&src.id) {
|
||||
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
|
||||
_ => {
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
|
||||
next += 1;
|
||||
fresh.push((name.clone(), Arc::clone(audio)));
|
||||
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
|
||||
self.next += 1;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
name
|
||||
}
|
||||
};
|
||||
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
|
||||
src.unsaved = true;
|
||||
written.insert(Arc::as_ptr(audio), name.clone());
|
||||
copies.insert(src.id, (Arc::downgrade(audio), name));
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// An autosave too large to recover is not written, nor its new copies: the last autosave
|
||||
// stays as it was, and the failure is reported (#892).
|
||||
crate::io::session_size("the autosave", text.len() as u64, true)?;
|
||||
self.next = next;
|
||||
for (name, audio) in &fresh {
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Float32);
|
||||
let bytes = craft_audio::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
|
||||
}
|
||||
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
|
||||
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
|
||||
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
|
||||
if !self.copies.values().any(|(_, kept)| *kept == name) {
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
}
|
||||
let _ = std::fs::remove_file(audio_dir.join(name));
|
||||
}
|
||||
if let Some(r) = recording {
|
||||
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
@@ -306,7 +290,9 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
drop(lock);
|
||||
match saved {
|
||||
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
|
||||
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
|
||||
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
|
||||
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
out.push(Leftover { dir, name, origin, saved, recording });
|
||||
}
|
||||
_ => {
|
||||
@@ -318,21 +304,6 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
|
||||
/// file is missing, not a session, or too large to open (#892).
|
||||
fn saved_name(file: &Path) -> Option<String> {
|
||||
use std::io::Read as _;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Named {
|
||||
name: String,
|
||||
}
|
||||
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
|
||||
return None;
|
||||
}
|
||||
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
|
||||
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
|
||||
}
|
||||
|
||||
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
|
||||
/// with the takes of a recording that never finished placed as Stop would have. Returns the
|
||||
/// media that couldn't be loaded.
|
||||
@@ -404,7 +375,7 @@ fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) ->
|
||||
if !folder.is_some_and(|d| folders.contains(&d)) {
|
||||
return Err(refuse("not in a folder SoundCraft records into"));
|
||||
}
|
||||
let header = |frames| craft_audio::wav_float_header(rate, u16::try_from(channels).unwrap_or(u16::MAX), frames).map_err(std::io::Error::other);
|
||||
let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other);
|
||||
let expected = header(0)?;
|
||||
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?;
|
||||
let mut head = vec![0u8; expected.len()];
|
||||
@@ -454,32 +425,6 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
|
||||
/// exclusively (it is created by this call or the call fails), so a folder or link another account
|
||||
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
|
||||
/// (`0o700`); on other systems it has the mode `parent` gives it.
|
||||
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
// One plain name: a prefix like `../x` would make a sibling of `parent`, not a child.
|
||||
let mut parts = Path::new(prefix).components();
|
||||
if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None)) || prefix.contains(['/', '\\']) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the folder prefix must be a single name"));
|
||||
}
|
||||
for _ in 0..16 {
|
||||
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
|
||||
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
match builder.create(&dir) {
|
||||
Ok(()) => return Ok(dir),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
|
||||
}
|
||||
|
||||
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::File::create(path)?;
|
||||
f.write_all(bytes)?;
|
||||
@@ -502,49 +447,9 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::open_session;
|
||||
use craft_audio::AudioBuffer;
|
||||
|
||||
#[test]
|
||||
fn unique_folders_are_new_and_owner_only() {
|
||||
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
std::fs::create_dir_all(&parent).unwrap();
|
||||
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
|
||||
assert_ne!(a, b);
|
||||
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
|
||||
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
|
||||
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
|
||||
for bad in ["../outside", "a/b", "a\\b", "..", ".", "", "/abs"] {
|
||||
assert!(create_unique_folder(&parent, bad).is_err(), "{bad:?} is not a single name");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&parent);
|
||||
}
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_model::{ChannelFormat, Clip, FileFormat, TrackKind};
|
||||
|
||||
/// Recovery loads document edits without re-enabling live inputs from an autosave.
|
||||
#[test]
|
||||
fn recovered_live_input_states_start_disabled() {
|
||||
let root = folder("live-input-recovery");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
let mut autosave = Autosave::create(&root, "Live inputs").unwrap();
|
||||
autosave.write(e.session(), None, None).unwrap();
|
||||
drop(autosave);
|
||||
let mut recovered = Engine::default();
|
||||
recover(&mut recovered, &leftovers(&root)[0]).unwrap();
|
||||
let mixer = &recovered.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert_eq!(mixer.volume_db, -6.0);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
|
||||
fn folder(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
|
||||
@@ -602,25 +507,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_sharing_one_buffer_share_one_copy() {
|
||||
let dir = folder("autosave-aliases");
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
// Nine more source ids over the same decoded audio, as a session file may declare.
|
||||
let s = e.session_mut();
|
||||
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
|
||||
for _ in 0..9 {
|
||||
let id = SourceId(s.alloc());
|
||||
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
|
||||
s.pool.insert(id, audio.clone());
|
||||
}
|
||||
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_recorded_after_undo_gets_its_own_copy() {
|
||||
let dir = folder("autosave-undo");
|
||||
@@ -719,7 +605,7 @@ mod tests {
|
||||
let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav");
|
||||
std::fs::create_dir_all(take.parent().unwrap()).unwrap();
|
||||
let audio: Vec<u8> = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect();
|
||||
std::fs::write(&take, [craft_audio::wav_float_header(48_000, 1, 100).unwrap(), audio].concat()).unwrap();
|
||||
std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap();
|
||||
let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] };
|
||||
let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::<Vec<_>>();
|
||||
let w = Autosaver::new(root.clone());
|
||||
@@ -750,7 +636,7 @@ mod tests {
|
||||
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
|
||||
let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files");
|
||||
std::fs::create_dir_all(&takes).unwrap();
|
||||
let wav = [craft_audio::wav_float_header(48_000, 1, 0).unwrap(), vec![0; 4_000]].concat();
|
||||
let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat();
|
||||
let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4);
|
||||
let files =
|
||||
[(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())];
|
||||
@@ -787,70 +673,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A test folder, removed when the test ends, passed or not.
|
||||
struct Gone(PathBuf);
|
||||
|
||||
impl Drop for Gone {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
|
||||
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
|
||||
// and keeps the last autosave and its copies as they were.
|
||||
let dir = Gone(folder("autosave-too-large"));
|
||||
let mut e = Engine::default();
|
||||
record(&mut e, 0.5);
|
||||
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let state = |a: &Autosave| {
|
||||
let mut files: Vec<String> =
|
||||
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
files.sort();
|
||||
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
|
||||
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
|
||||
};
|
||||
let before = state(&a);
|
||||
record(&mut e, 0.25);
|
||||
{
|
||||
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
|
||||
for _ in 0..2 {
|
||||
let err = a.write(e.session(), None, None).unwrap_err().to_string();
|
||||
assert!(err.starts_with("the autosave is too large to save"), "{err}");
|
||||
assert!(state(&a) == before);
|
||||
}
|
||||
// The writer thread reports it as it reports any failed autosave.
|
||||
let failure = Arc::new(Mutex::new(None));
|
||||
let pending = Some((e.session_arc(), None, None));
|
||||
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
|
||||
w.write_pending();
|
||||
let reported = failure.lock().unwrap().take().unwrap_or_default();
|
||||
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
|
||||
}
|
||||
a.write(e.session(), None, None).unwrap();
|
||||
let after = state(&a);
|
||||
assert_eq!((after.0.len(), after.1.len()), (2, 2));
|
||||
assert!(after.0.contains(&before.0[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
|
||||
// #892: the name is read on its own, and an autosave too large to open is not read.
|
||||
let dir = Gone(folder("autosave-names"));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let file = dir.0.join(SESSION);
|
||||
let s = Session { name: "Song".into(), ..Session::default() };
|
||||
std::fs::write(&file, s.to_json().unwrap()).unwrap();
|
||||
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
|
||||
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
|
||||
assert_eq!(saved_name(&file), None);
|
||||
assert_eq!(saved_name(&dir.0.join("none")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sessions_started_together_get_folders_of_their_own() {
|
||||
// #579: the folder name has millisecond resolution, so two sessions created back to back
|
||||
|
||||
@@ -65,50 +65,13 @@ pub const ALIASES: &[(&str, &str)] = &[
|
||||
("View > Rulers > Chord Symbols", "view.ruler"),
|
||||
];
|
||||
|
||||
// Name migration fallbacks run after explicit UI mappings so opening a dialog
|
||||
// keeps its original command and enablement rather than becoming a direct edit.
|
||||
const LEGACY_LABEL_ALIASES: &[(&str, &str)] = &[
|
||||
// Historical catalog paths remain valid when their display names change.
|
||||
("Clip > Elastic Properties", "clip.elastic_properties"),
|
||||
("Options > QuickPunch", "options.quickpunch"),
|
||||
("Options > TrackPunch", "options.trackpunch"),
|
||||
("View > Transport > Ableton Link", "view.transport_ableton_link"),
|
||||
("Event > Beat Detective", "event.beat_detective"),
|
||||
("File > Export > Sibelius...", "file.export_sibelius"),
|
||||
("File > Send To... > Sibelius", "file.send_to_sibelius"),
|
||||
];
|
||||
|
||||
/// Catalog leaves naming features we do not have. They keep the incumbent's place — and with it
|
||||
/// the parity floor — under a descriptive label, but they are never interactive: the UI draws them
|
||||
/// disabled with a "Not available yet." hover until the behaviour exists, as Preferences does for
|
||||
/// settings without a backend. Naming the place does not claim the protocol, plug-in format or
|
||||
/// processor behind it.
|
||||
pub const PLACEHOLDERS: &[&str] = &[
|
||||
"View > Transport > Ableton Link",
|
||||
"Track > ARA Plugins > Melodyne",
|
||||
"Clip > ARA Plugins > Melodyne",
|
||||
"View > Mix Window Views > HEAT",
|
||||
"Options > Activate HEAT",
|
||||
];
|
||||
|
||||
/// Whether `path` is a catalog leaf kept as a placeholder for a feature we do not have.
|
||||
pub fn is_placeholder(path: &str) -> bool {
|
||||
PLACEHOLDERS.contains(&path)
|
||||
}
|
||||
|
||||
pub fn catalog() -> Vec<&'static str> {
|
||||
MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Command id implementing a catalog entry, if any.
|
||||
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
|
||||
if let Some((_, id)) = ALIASES
|
||||
.iter()
|
||||
.chain(crate::catalog_more::ALIASES_MORE.iter())
|
||||
.chain(extra.iter())
|
||||
.chain(LEGACY_LABEL_ALIASES.iter())
|
||||
.find(|(p, _)| *p == entry)
|
||||
{
|
||||
if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) {
|
||||
return Some((*id).to_string());
|
||||
}
|
||||
let parts: Vec<&str> = entry.split(" > ").collect();
|
||||
@@ -153,7 +116,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn aliases_point_at_real_commands() {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(LEGACY_LABEL_ALIASES.iter()) {
|
||||
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) {
|
||||
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
|
||||
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
|
||||
}
|
||||
@@ -168,40 +131,4 @@ mod tests {
|
||||
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
|
||||
assert!(done >= 396, "engine parity regressed: {done} menu items");
|
||||
}
|
||||
|
||||
/// A placeholder must keep its catalog place (so the floor holds and the menu still lists it)
|
||||
/// and must not read as the incumbent's own name: the label says what it is, not who makes it.
|
||||
#[test]
|
||||
fn placeholders_keep_their_place_with_honest_labels() {
|
||||
let entries = catalog();
|
||||
for path in PLACEHOLDERS {
|
||||
assert!(entries.contains(path), "placeholder for an unknown catalog entry: {path}");
|
||||
assert!(is_placeholder(path), "{path}");
|
||||
let leaf = path.rsplit_once(" > ").map_or(*path, |(_, leaf)| leaf);
|
||||
let label = crate::feature_names::display_label(leaf);
|
||||
assert_ne!(label, leaf, "{path} still shows the incumbent's name");
|
||||
for retired in ["ableton", "melodyne", "heat"] {
|
||||
assert!(!label.to_lowercase().contains(retired), "{path}: {label}");
|
||||
}
|
||||
}
|
||||
assert!(!is_placeholder("Options > Loop Record"), "an implemented item is not a placeholder");
|
||||
assert!(!is_placeholder("View > Transport > MIDI Controls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renamed_labels_keep_legacy_catalog_dispatch() {
|
||||
let ui = [("Event > Beat Detective", "window.beat_detective")];
|
||||
assert_eq!(implemented_by("Event > Beat Detective", &ui).as_deref(), Some("window.beat_detective"));
|
||||
for (path, id, label) in [
|
||||
("Options > QuickPunch", "options.quickpunch", "Punch Recording"),
|
||||
("Options > TrackPunch", "options.trackpunch", "TrackPunch"),
|
||||
("View > Transport > Ableton Link", "view.transport_ableton_link", "Tempo Sync"),
|
||||
("Event > Beat Detective", "event.beat_detective", "Beat Analysis"),
|
||||
("File > Export > Sibelius...", "file.export_sibelius", "MusicXML..."),
|
||||
("File > Send To... > Sibelius", "file.send_to_sibelius", "MusicXML"),
|
||||
] {
|
||||
assert_eq!(implemented_by(path, &[]).as_deref(), Some(id));
|
||||
assert_eq!(crate::find_command(id).unwrap().label, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
//! as 32-bit float WAVs in a `<name> Audio/` folder next to it.
|
||||
|
||||
use crate::{Engine, EngineError, Result};
|
||||
use craft_audio::EncodeOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_audio_io::{EncodeOptions, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent, ClipId, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -66,8 +65,8 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
{
|
||||
let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0);
|
||||
crate::io::make_dir(dir.join(&audio_dir_name))?;
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(craft_audio::BitDepth::Float32);
|
||||
let bytes = craft_audio::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
crate::io::write_path(dir.join(&rel), bytes)?;
|
||||
slot.insert(rel);
|
||||
}
|
||||
@@ -86,32 +85,25 @@ fn decode_group_audio(
|
||||
dir: &Path,
|
||||
rate: u32,
|
||||
cmd_id: &str,
|
||||
) -> Result<(Vec<(String, craft_audio::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
|
||||
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
|
||||
// The folder as the file system knows it, so a link among the group's files can't lead out.
|
||||
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
|
||||
let root = crate::io::canonical_path(dir)?;
|
||||
for (old, rel) in audio_entries {
|
||||
let full = dir.join(rel);
|
||||
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
|
||||
if !full.starts_with(dir)
|
||||
|| Path::new(rel)
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
|
||||
{
|
||||
return Err(escapes(rel));
|
||||
}
|
||||
let key = crate::io::canonical_path(&full)?;
|
||||
if !key.starts_with(&root) {
|
||||
return Err(escapes(rel));
|
||||
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let key = crate::io::file_key(&full);
|
||||
let n = match index.get(&key) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let bytes = crate::io::read_file_inside(&full, &root)?;
|
||||
let (_, buf) = craft_audio::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
|
||||
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
|
||||
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
|
||||
decoded.push((rel.clone(), buf));
|
||||
index.insert(key, decoded.len() - 1);
|
||||
decoded.len() - 1
|
||||
@@ -276,59 +268,9 @@ mod tests {
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
|
||||
// A link inside the folder that leads out of it is refused once resolved.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
|
||||
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!("link.wav");
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
|
||||
let _ = std::fs::remove_file(&outside);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
|
||||
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
|
||||
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (dir, outside) = (base.join("group"), base.join("outside"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
|
||||
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
|
||||
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
|
||||
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
|
||||
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for target in ["out/real.wav", "out/missing.wav"] {
|
||||
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
|
||||
*rel = json!(target);
|
||||
}
|
||||
std::fs::write(&path, file.to_string()).unwrap();
|
||||
let r = crate::file_access::confine(Some(&roots), || {
|
||||
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
|
||||
});
|
||||
refusals.push(r.unwrap_err().to_string());
|
||||
}
|
||||
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
|
||||
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_clips_carry_no_auto_rename_scheme() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
|
||||
@@ -415,8 +357,8 @@ mod tests {
|
||||
fn a_file_named_by_many_ids_becomes_one_source() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let audio = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
|
||||
std::fs::write(dir.join("k.wav"), craft_audio::encode(&audio, &craft_audio::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
|
||||
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}});
|
||||
let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"});
|
||||
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths});
|
||||
|
||||
@@ -347,10 +347,7 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
|
||||
|
||||
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
|
||||
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
|
||||
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
|
||||
// for a render, re-rendered into another source) once per repeat.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
|
||||
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
|
||||
}
|
||||
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
|
||||
return vec![ClipId(n)];
|
||||
|
||||
@@ -47,7 +47,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
#[test]
|
||||
@@ -59,7 +59,7 @@ mod tests {
|
||||
a.extend(tone(330.0));
|
||||
let frames = a.len() as i64;
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, "mel", AudioBuffer { sample_rate: 48_000, channels: vec![a] }, None, soundcraft_model::FileFormat::Wav);
|
||||
let src = crate::io::add_source(s, "mel", AudioBuffer { sample_rate: 48_000, channels: vec![a] }, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(cid, "mel", src, 0, 0, frames));
|
||||
|
||||
@@ -3,10 +3,9 @@ use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_pa
|
||||
use soundcraft_time::Range;
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use craft_audio::AudioBuffer;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_dsp::offline::{SpeedRampChange, SpeedRampSelection};
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_model::{Clip, ClipContent, TrackKind};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -215,7 +214,7 @@ fn short(n: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Session, TrackKind};
|
||||
|
||||
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
|
||||
|
||||
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
let s = e.session();
|
||||
let moves: Vec<(soundcraft_model::ClipId, i64)> =
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
|
||||
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
|
||||
let s = e.session_mut();
|
||||
for (id, d) in &moves {
|
||||
crate::edit::move_clips(s, &[*id], *d, &[]);
|
||||
@@ -464,8 +464,8 @@ mod tests {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
|
||||
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
|
||||
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
|
||||
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
|
||||
let mut e = crate::Engine::new(s);
|
||||
@@ -552,8 +552,8 @@ mod tests {
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
||||
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
|
||||
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let ids = [0, 1, 2].map(|k| {
|
||||
let cid = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
|
||||
|
||||
@@ -66,8 +66,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
|
||||
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
|
||||
let s = e.session();
|
||||
|
||||
@@ -511,8 +511,8 @@ mod tests {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..12_000).map(|i| (i as f32 * 0.07).sin() * 0.5).collect();
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
let mut c = Clip::audio(id, "tone", src, 0, 0, 18_000);
|
||||
c.stretch = 1.5;
|
||||
@@ -544,8 +544,8 @@ mod tests {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
|
||||
s.edit.selected_clips = vec![id];
|
||||
@@ -575,8 +575,8 @@ mod tests {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
|
||||
s.edit.selected_clips = vec![id];
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
shuffle_lock
|
||||
),
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10, F6+F7, F7+F8, Cmd+6-7"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
|
||||
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
|
||||
e.session_mut().edit.tool = t;
|
||||
Ok(json!({"tool": t}))
|
||||
@@ -301,7 +301,6 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
|
||||
cut_track(s, *t, range, sh, follow);
|
||||
}
|
||||
s.edit.selection = soundcraft_time::Range::point(range.start);
|
||||
cb.cut_from = Some(range.start);
|
||||
}
|
||||
let n: usize = cb.tracks.iter().map(Vec::len).sum();
|
||||
e.clipboard = cb;
|
||||
@@ -329,7 +328,6 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
|
||||
cb
|
||||
}
|
||||
|
||||
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
|
||||
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.paste", p)?;
|
||||
let given = position_param(e, "edit.paste", p, "at")?;
|
||||
@@ -337,10 +335,9 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session();
|
||||
let at = match given {
|
||||
Some(at) => at,
|
||||
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
|
||||
s.edit.selection.start
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => {
|
||||
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
|
||||
}
|
||||
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
|
||||
None => s.edit.selection.start,
|
||||
};
|
||||
let sh = shuffle(s) && !cb.automation_only;
|
||||
@@ -671,16 +668,11 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"separated": n}))
|
||||
}
|
||||
|
||||
/// Separates selected clips at every active Grid boundary in the requested range.
|
||||
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
|
||||
let r = range_param(e, "edit.separate_on_grid", p)?;
|
||||
let s = e.session_mut();
|
||||
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
|
||||
let lines = s.grid_lines(r.start, r.end, max);
|
||||
if lines.len() > 10_000 {
|
||||
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
|
||||
}
|
||||
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
|
||||
let mut n = 0;
|
||||
for t in &tracks {
|
||||
for at in &lines {
|
||||
@@ -964,7 +956,6 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"moved": n, "by": step}))
|
||||
}
|
||||
|
||||
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
|
||||
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
@@ -987,21 +978,17 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let mode = s.edit.edit_mode;
|
||||
let delta = match first {
|
||||
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
|
||||
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
|
||||
_ => delta,
|
||||
};
|
||||
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
|
||||
let s = e.session_mut();
|
||||
let n = if mode == EditMode::Shuffle {
|
||||
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
|
||||
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
|
||||
0
|
||||
} else {
|
||||
ops::move_clips(s, &ids, effective_delta, &dest)
|
||||
ops::move_clips(s, &ids, delta, &dest)
|
||||
};
|
||||
s.edit.selected_clips = ids;
|
||||
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
|
||||
Ok(json!({"moved": n, "by": delta}))
|
||||
}
|
||||
|
||||
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
@@ -1228,8 +1215,8 @@ mod tests {
|
||||
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
|
||||
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
|
||||
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let mut ids = Vec::new();
|
||||
for &(start, len, off) in clips {
|
||||
let id = s.new_clip_id();
|
||||
|
||||
@@ -1158,8 +1158,8 @@ mod tests {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let tone: Vec<f32> = (0..24_000).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = s.new_clip_id();
|
||||
ops::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 24_000));
|
||||
s.edit.selected_tracks = vec![t];
|
||||
|
||||
@@ -154,7 +154,6 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::insert_silence(s, t, r);
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
for l in &mut tr.automation {
|
||||
@@ -186,8 +185,6 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
|
||||
for t in ids {
|
||||
crate::edit::separate_all_at(s, t, r.start);
|
||||
crate::edit::separate_all_at(s, t, r.end);
|
||||
crate::edit::clear_range(s, t, r, false);
|
||||
crate::edit::shift_from(s, t, r.end, -r.len());
|
||||
if let Some(tr) = s.track_mut(t) {
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"event.beat_detective",
|
||||
"Beat Analysis",
|
||||
"Beat Detective",
|
||||
["Event"],
|
||||
None,
|
||||
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
|
||||
|
||||
@@ -45,8 +45,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.sources.clear();
|
||||
s.videos.clear();
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
// A template too large to open is not saved (#892).
|
||||
crate::io::session_size(file.display(), text.len() as u64, true)?;
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
|
||||
} else {
|
||||
@@ -94,21 +92,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format_name = match p.get("format") {
|
||||
None => "wav",
|
||||
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
|
||||
};
|
||||
let format = craft_audio::encode_format_for(format_name).ok_or_else(|| {
|
||||
let supported = craft_audio::ENCODE_EXTENSIONS.join(", ");
|
||||
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let format = str_param(p, "format")
|
||||
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
|
||||
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => craft_audio::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => craft_audio::BitDepth::Float32,
|
||||
_ => craft_audio::BitDepth::Int24,
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let fold = fold_down_param(p, "file.bounce_stems")?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(bool_or(p, "dither", true)), fold)?;
|
||||
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;
|
||||
Ok(json!({"files": files}))
|
||||
}),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
@@ -149,10 +142,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
crate::io::save_session(&mut tmp, &path)?;
|
||||
Ok(json!({"path": path}))
|
||||
}),
|
||||
cmd!(noundo "file.export_sibelius", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
|
||||
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
|
||||
write_score(e, p, "file.export_sibelius", false)
|
||||
}),
|
||||
cmd!(noundo "file.send_to_sibelius", "MusicXML", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
|
||||
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
|
||||
write_score(e, p, "file.send_to_sibelius", false)
|
||||
}),
|
||||
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
|
||||
@@ -318,16 +311,16 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
|
||||
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
|
||||
let format = craft_audio::encode_format_for(format_name).ok_or_else(|| {
|
||||
let supported = craft_audio::ENCODE_EXTENSIONS.join(", ");
|
||||
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
|
||||
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
|
||||
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {supported})"))
|
||||
})?;
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => craft_audio::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => craft_audio::BitDepth::Float32,
|
||||
_ => craft_audio::BitDepth::Int24,
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(bool_or(p, "dither", true));
|
||||
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
|
||||
let normalize = bool_or(p, "normalize", false);
|
||||
let fold = fold_down_param(p, "file.bounce_mix")?;
|
||||
let bus = bounce_bus(e, p)?;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::*;
|
||||
use crate::Engine;
|
||||
use craft_audio::{AudioBuffer, EncodeOptions};
|
||||
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
|
||||
use soundcraft_model::ChannelFormat;
|
||||
|
||||
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
|
||||
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let bytes = craft_audio::encode(&buf, &EncodeOptions::default()).unwrap();
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
@@ -118,71 +118,6 @@ fn import_audio_honours_new_tracks() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_unsupported_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
|
||||
let created_output = stems.exists();
|
||||
// Own this directory exclusively; remove any baseline output before the assertion can fail.
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
|
||||
assert!(err.to_string().contains("supported: wav"), "{err}");
|
||||
assert!(!created_output, "an unsupported format must not create the output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_rejects_non_string_formats_before_writing() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
|
||||
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, format)| {
|
||||
let stems = dir.join(index.to_string());
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
|
||||
(result, stems.exists())
|
||||
})
|
||||
.collect();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for (result, created_output) in results {
|
||||
let err = result.map(|_| ()).unwrap_err();
|
||||
assert!(err.to_string().contains("`format` must be a string"), "{err}");
|
||||
assert!(!created_output, "a non-string format must not create the output directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_stems_accepts_explicit_wav_format() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let wav = tone_wav(&dir, "tone.wav");
|
||||
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
|
||||
let stems = dir.join("stems");
|
||||
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
|
||||
let files = result["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 1);
|
||||
let path = std::path::Path::new(files[0].as_str().unwrap());
|
||||
assert_eq!(path.parent(), Some(stems.as_path()));
|
||||
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
|
||||
let (info, audio) = craft_audio::decode(&std::fs::read(path).unwrap(), None).unwrap();
|
||||
assert_eq!(info.container, craft_audio::Container::Wav);
|
||||
assert_eq!(audio.frames(), 32);
|
||||
assert_eq!(audio.channels.len(), e.session().main_format().channels());
|
||||
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounce_refuses_formats_it_cannot_write() {
|
||||
let mut e = Engine::default();
|
||||
@@ -289,7 +224,7 @@ fn tone_engine() -> (Engine, u64) {
|
||||
let track = e.session().tracks[0].id;
|
||||
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
|
||||
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
let id = e.session_mut().new_clip_id();
|
||||
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
|
||||
e.session_mut().edit.selected_tracks = vec![track];
|
||||
@@ -392,7 +327,7 @@ fn midi_export_keeps_controllers_and_honours_tracks() {
|
||||
fn import_surround_creates_a_matching_track() {
|
||||
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels };
|
||||
let bytes = craft_audio::encode(&buf, &EncodeOptions::default()).unwrap();
|
||||
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
|
||||
let mut e = Engine::default();
|
||||
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
|
||||
assert_eq!(v["channels"], 6);
|
||||
|
||||
@@ -96,7 +96,7 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{Clip, ClipId, SourceAudio, SourceId};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::{Engine, EngineError, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
|
||||
|
||||
/// Commands for mixer controls, with live input toggles kept outside document history.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
|
||||
@@ -43,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(json!({}))
|
||||
}),
|
||||
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.record_arm",
|
||||
@@ -52,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
|tr| tr.mixer.record_arm
|
||||
)),
|
||||
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
|
||||
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
|
||||
e,
|
||||
p,
|
||||
"mix.input_monitor",
|
||||
@@ -219,23 +218,6 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
/// Toggle live input flags without marking the saved document as changed.
|
||||
fn toggle_runtime(
|
||||
e: &mut Engine,
|
||||
p: &Value,
|
||||
cmd: &str,
|
||||
set: fn(&mut Session, TrackId, bool),
|
||||
get: fn(&soundcraft_model::Track) -> bool,
|
||||
) -> Result<Value> {
|
||||
let tracks = tracks_required(e, cmd, p)?;
|
||||
let s = e.runtime_session_mut();
|
||||
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
|
||||
for t in &tracks {
|
||||
set(s, *t, v);
|
||||
}
|
||||
Ok(json!({"value": v}))
|
||||
}
|
||||
|
||||
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let tracks = tracks_required(e, "mix.volume", p)?;
|
||||
let abs = p.get("db").and_then(Value::as_f64);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::sync::Arc;
|
||||
@@ -378,109 +378,3 @@ fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
assert!(lane.points.len() as i64 >= n);
|
||||
}
|
||||
|
||||
/// Live input toggles stay outside document history in both enabled and disabled states.
|
||||
#[test]
|
||||
fn live_input_states_are_transient_and_survive_document_history() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let track = e.session().tracks[0].id;
|
||||
e.mark_clean();
|
||||
let history = e.undo_history();
|
||||
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "live input state is not a document change");
|
||||
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
|
||||
|
||||
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
|
||||
// resurrect the live state that happened to be present when that snapshot was taken.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
|
||||
assert!(e.is_dirty());
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(!e.is_dirty(), "undo returned to the saved document");
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.is_dirty());
|
||||
|
||||
// Live states that are enabled after another document edit survive both directions too.
|
||||
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
|
||||
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(track).unwrap().mixer;
|
||||
assert!(mixer.record_arm && mixer.input_monitor);
|
||||
}
|
||||
|
||||
/// Returning to a saved point stays clean with independently enabled live flags on each track.
|
||||
#[test]
|
||||
fn live_input_states_keep_saved_history_points_clean() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.mark_clean();
|
||||
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
|
||||
for redo in [true, false, true] {
|
||||
assert!(if redo { e.redo() } else { e.undo() });
|
||||
let a = &e.session().track(a).unwrap().mixer;
|
||||
let b = &e.session().track(b).unwrap().mixer;
|
||||
assert!(a.record_arm && !a.input_monitor);
|
||||
assert!(!b.record_arm && b.input_monitor);
|
||||
assert_eq!(e.is_dirty(), redo);
|
||||
}
|
||||
e.mark_clean();
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
|
||||
assert!(!e.is_dirty());
|
||||
assert!(e.undo());
|
||||
assert!(e.is_dirty());
|
||||
assert!(e.redo());
|
||||
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
|
||||
}
|
||||
|
||||
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
|
||||
#[test]
|
||||
fn live_input_states_of_restored_tracks_start_disabled() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": 2})).unwrap();
|
||||
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
|
||||
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
|
||||
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
|
||||
assert!(e.undo());
|
||||
let mixer = &e.session().track(a).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
assert!(e.redo());
|
||||
assert!(e.undo());
|
||||
assert!(!e.session().track(a).unwrap().mixer.record_arm);
|
||||
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let c = e.session().tracks.last().unwrap().id;
|
||||
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
|
||||
assert!(e.undo());
|
||||
assert!(e.session().track(c).is_none());
|
||||
assert!(e.redo());
|
||||
let mixer = &e.session().track(c).unwrap().mixer;
|
||||
assert!(!mixer.record_arm && !mixer.input_monitor);
|
||||
assert!(e.session().track(b).unwrap().mixer.input_monitor);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
use super::bad;
|
||||
use crate::{Engine, Result};
|
||||
|
||||
use craft_audio::AudioBuffer;
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
|
||||
use soundcraft_time::Samples;
|
||||
|
||||
@@ -228,7 +227,7 @@ mod tests {
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
|
||||
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
|
||||
let cid = s.new_clip_id();
|
||||
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
|
||||
|
||||
@@ -19,7 +19,7 @@ macro_rules! toggle {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
|
||||
toggle!("options.quickpunch", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
|
||||
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
|
||||
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
|
||||
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
|
||||
|
||||
@@ -70,15 +70,15 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
|
||||
}
|
||||
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
|
||||
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let (file, buf) = craft_audio::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
if buf.frames() == 0 || buf.num_channels() == 0 {
|
||||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
|
||||
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
|
||||
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
|
||||
let channels = buf.num_channels();
|
||||
let s = e.session_mut();
|
||||
let src = crate::io::add_source(s, name, buf, path, soundcraft_model::FileFormat::of(&file));
|
||||
let src = crate::io::add_source(s, name, buf, path, format.format);
|
||||
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
|
||||
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
|
||||
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
|
||||
@@ -110,8 +110,8 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use craft_audio::{AudioBuffer, BitDepth, EncodeFormat, EncodeOptions};
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::{Clip, TrackId};
|
||||
use soundcraft_time::{Range, TICKS_PER_QUARTER};
|
||||
@@ -130,8 +130,8 @@ mod tests {
|
||||
let n = (rate as f32 * secs) as usize;
|
||||
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
|
||||
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(path, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn instrument_track(e: &mut Engine) -> TrackId {
|
||||
@@ -232,11 +232,11 @@ mod tests {
|
||||
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
|
||||
let mut e = Engine::default();
|
||||
let t = instrument_track(&mut e);
|
||||
for format in [EncodeFormat::Flac, EncodeFormat::Aiff] {
|
||||
let ext = format.extension();
|
||||
for format in [FileFormat::Flac, FileFormat::Aiff] {
|
||||
let ext = format.default_extension();
|
||||
let path = d.join(format!("tone.{ext}"));
|
||||
let opts = EncodeOptions::new(format);
|
||||
std::fs::write(&path, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
|
||||
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
|
||||
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["channels"], 2, "{ext}");
|
||||
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
|
||||
@@ -320,8 +320,8 @@ mod tests {
|
||||
let junk = d.join("junk.wav");
|
||||
std::fs::write(&junk, b"definitely not audio").unwrap();
|
||||
let empty = d.join("empty.wav");
|
||||
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
|
||||
std::fs::write(&empty, craft_audio::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
|
||||
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
|
||||
let wav = d.join("ok.wav");
|
||||
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
|
||||
let mut e = Engine::default();
|
||||
|
||||
@@ -472,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "file.import_session_data";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let text_v = crate::io::read_session_text(&path)?;
|
||||
let text_v = crate::io::read_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
@@ -761,8 +761,8 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let wav = dir.join("hit.wav");
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
|
||||
std::fs::write(&wav, craft_audio::encode(&buf, &craft_audio::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
|
||||
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
|
||||
let mut donor = Engine::default();
|
||||
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
|
||||
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
|
||||
|
||||
@@ -145,7 +145,7 @@ fn main_format(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_model::{Clip, SourceAudio, SourceId};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -211,13 +211,13 @@ mod tests {
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f"})).unwrap();
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(craft_audio::probe(&bytes, None).unwrap().layout.mask(), Some(0x63F));
|
||||
let (_, buf) = craft_audio::decode(&bytes, Some("wav")).unwrap();
|
||||
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x63F));
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap();
|
||||
assert_eq!(buf.channels.len(), 8);
|
||||
assert!((buf.channels[2][1000] - 0.25).abs() < 1e-5, "front centre lands on C");
|
||||
// ITU stereo fold-down.
|
||||
e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f", "fold_down": "stereo"})).unwrap();
|
||||
let (_, buf) = craft_audio::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap();
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap();
|
||||
assert_eq!(buf.channels.len(), 2);
|
||||
assert!((buf.channels[0][1000] - 0.25 * std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-4);
|
||||
assert!(e.execute("file.bounce_mix", &json!({"path": ps, "fold_down": "quad"})).is_err());
|
||||
@@ -225,7 +225,7 @@ mod tests {
|
||||
let sdir = dir.join("stems");
|
||||
let r = e.execute("file.bounce_stems", &json!({"dir": sdir.to_string_lossy(), "start": 0, "end": 4800})).unwrap();
|
||||
let f = r["files"][0].as_str().unwrap().to_string();
|
||||
let (_, buf) = craft_audio::decode(&std::fs::read(&f).unwrap(), Some("wav")).unwrap();
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&f).unwrap(), Some("wav")).unwrap();
|
||||
assert_eq!(buf.channels.len(), 8);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -175,10 +175,9 @@ fn analysed(s: &Session, t: TrackId, around: Range) -> Vec<Samples> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use craft_audio::AudioBuffer;
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_model::{ChannelFormat, Clip, Session, TrackId, TrackKind};
|
||||
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
|
||||
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
|
||||
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
|
||||
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.runtime_session_mut();
|
||||
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
|
||||
let s = e.session_mut();
|
||||
let mut n = 0;
|
||||
for t in &mut s.tracks {
|
||||
if t.mixer.record_arm {
|
||||
@@ -483,11 +483,11 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
|
||||
// Keep the full channel: the cloned clips retain their offsets, stretch,
|
||||
// gain and fades, and every reader sees genuine mono media (including saves).
|
||||
let buf = craft_audio::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
|
||||
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
|
||||
let original = s.source(*source);
|
||||
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
|
||||
let time_reference = original.map_or(0, |source| source.time_reference);
|
||||
let id = crate::io::add_source(s, &name, buf, None, soundcraft_model::FileFormat::Wav);
|
||||
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
|
||||
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
|
||||
source.time_reference = time_reference;
|
||||
}
|
||||
|
||||
@@ -267,7 +267,6 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "track": first}))
|
||||
}
|
||||
|
||||
/// Load persistent mixer settings while retaining destination routing and live input flags.
|
||||
fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "track.load_preset";
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
@@ -284,17 +283,15 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let s = e.session_mut();
|
||||
for t in &tracks {
|
||||
if let Some(tr) = s.track_mut(*t) {
|
||||
// Routing and live input state are track-specific: a preset must not replace them.
|
||||
let keep =
|
||||
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
|
||||
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
|
||||
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
|
||||
tr.mixer = mixer.clone();
|
||||
tr.mixer.input = keep.0;
|
||||
tr.mixer.output = keep.1;
|
||||
tr.mixer.vca = keep.2;
|
||||
tr.mixer.record_arm = keep.3;
|
||||
tr.mixer.input_monitor = keep.4;
|
||||
if tr.mixer.pan.len() != keep.5.len() {
|
||||
tr.mixer.pan = keep.5;
|
||||
if tr.mixer.pan.len() != keep.4.len() {
|
||||
tr.mixer.pan = keep.4;
|
||||
}
|
||||
if tr.kind == TrackKind::Instrument && instrument.is_some() {
|
||||
tr.instrument = instrument.clone();
|
||||
@@ -605,7 +602,6 @@ mod tests {
|
||||
assert_eq!(e.session().track(pad).unwrap().folder, Some(verb));
|
||||
}
|
||||
|
||||
/// A preset transfers persistent mix settings without replacing live destination states.
|
||||
#[test]
|
||||
fn preset_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id()));
|
||||
@@ -615,13 +611,10 @@ mod tests {
|
||||
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
|
||||
e.execute("track.save_preset", &json!({"path": path})).unwrap();
|
||||
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
|
||||
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
|
||||
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
|
||||
e.execute("track.load_preset", &json!({"path": path})).unwrap();
|
||||
let hats = e.session().track_by_name("Hats").unwrap();
|
||||
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
|
||||
assert_eq!(hats.mixer.volume_db, -5.0);
|
||||
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("mix.wav");
|
||||
e.execute("file.bounce_mix", &json!({"path": out.to_string_lossy(), "start": 0, "end": 96_000, "dither": false})).unwrap();
|
||||
let (_, buf) = craft_audio::decode(&std::fs::read(&out).unwrap(), Some("wav")).unwrap();
|
||||
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&out).unwrap(), Some("wav")).unwrap();
|
||||
assert!(buf.channels.iter().flatten().all(|x| *x == 0.0));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
+13
-166
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
|
||||
|
||||
use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
|
||||
use soundcraft_time::TimeFormat;
|
||||
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
|
||||
|
||||
/// Returns commands that control saved view, counter, Grid, and Nudge state.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
|
||||
@@ -65,18 +65,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
|
||||
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
|
||||
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.main_counter = f;
|
||||
let scale = grid_scale(f);
|
||||
if edit.grid_follow_main && edit.grid.scale() != scale {
|
||||
edit.grid = GridValue::for_scale(scale);
|
||||
}
|
||||
if edit.grid_follow_main {
|
||||
edit.grid_clips_markers = false;
|
||||
}
|
||||
if edit.nudge_follow_main && edit.nudge.scale() != scale {
|
||||
edit.nudge = GridValue::for_scale(scale);
|
||||
}
|
||||
e.session_mut().edit.main_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
|
||||
@@ -84,58 +73,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
e.session_mut().edit.sub_counter = f;
|
||||
Ok(json!({"format": f}))
|
||||
}),
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.grid;
|
||||
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
|
||||
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
|
||||
let lines = p.get("lines").and_then(Value::as_bool);
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let s = e.session_mut();
|
||||
s.edit.grid = g;
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
if let Some(scale) = requested_scale {
|
||||
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
|
||||
}
|
||||
if follow == Some(true) {
|
||||
s.edit.grid_clips_markers = false;
|
||||
}
|
||||
s.edit.grid_follow_main = follow.unwrap_or(
|
||||
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
|
||||
);
|
||||
if let Some(l) = lines { s.edit.grid_lines = l }
|
||||
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
|
||||
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
|
||||
Ok(json!({"grid": g.label()}))
|
||||
}),
|
||||
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
|
||||
let s = e.session_mut();
|
||||
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
|
||||
Ok(json!({"value": s.edit.grid_lines}))
|
||||
}),
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
|
||||
let current = e.session().edit.nudge;
|
||||
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
|
||||
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
|
||||
}
|
||||
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
|
||||
let follow = p.get("follow_main").and_then(Value::as_bool);
|
||||
if follow == Some(true) {
|
||||
let scale = grid_scale(e.session().edit.main_counter);
|
||||
if g.scale() != scale {
|
||||
g = GridValue::for_scale(scale);
|
||||
}
|
||||
}
|
||||
let edit = &mut e.session_mut().edit;
|
||||
edit.nudge = g;
|
||||
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
|
||||
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
|
||||
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
|
||||
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
|
||||
e.session_mut().edit.nudge = g;
|
||||
Ok(json!({"nudge": g.label()}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -183,33 +137,7 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
|
||||
}
|
||||
|
||||
/// Maps a counter format to the corresponding Grid/Nudge scale.
|
||||
fn grid_scale(format: TimeFormat) -> GridScale {
|
||||
match format {
|
||||
TimeFormat::BarsBeats => GridScale::BarsBeats,
|
||||
TimeFormat::MinSecs => GridScale::MinSecs,
|
||||
TimeFormat::Timecode => GridScale::Timecode,
|
||||
TimeFormat::FeetFrames => GridScale::FeetFrames,
|
||||
TimeFormat::Samples => GridScale::Samples,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
|
||||
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
|
||||
if p.get("value").is_some() || p.get("step").is_some() {
|
||||
return value_or_step(p, current);
|
||||
}
|
||||
let scale = str_param(p, "format").and_then(GridScale::from_id);
|
||||
match scale {
|
||||
Some(GridScale::ClipsMarkers) => Some(current),
|
||||
Some(scale) if current.scale() == scale => Some(current),
|
||||
Some(scale) => Some(GridValue::for_scale(scale)),
|
||||
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
|
||||
/// Parse grid/nudge values.
|
||||
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
let v = p.get(key)?;
|
||||
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
|
||||
@@ -218,23 +146,15 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Frames(f.clamp(1, 100_000)));
|
||||
}
|
||||
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
|
||||
return Some(GridValue::Timecode(value));
|
||||
}
|
||||
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
|
||||
return Some(GridValue::FeetFrames(value));
|
||||
}
|
||||
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
|
||||
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
|
||||
}
|
||||
let s = v.as_str()?.trim().to_ascii_lowercase();
|
||||
let triplet = s.ends_with('t') || s.contains("triplet");
|
||||
let dotted = s.ends_with('.') || s.contains("dotted");
|
||||
let core = s.replace("triplet", "").replace("dotted", "");
|
||||
let core = core.trim().trim_end_matches(['t', '.']);
|
||||
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
|
||||
let value = match core.trim() {
|
||||
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
|
||||
"whole" | "whole note" => NoteValue::Whole,
|
||||
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
|
||||
"1/2" => NoteValue::Half,
|
||||
"1/4" => NoteValue::Quarter,
|
||||
"1/8" => NoteValue::Eighth,
|
||||
@@ -250,59 +170,15 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use soundcraft_time::Samples;
|
||||
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
|
||||
#[test]
|
||||
fn grid_values_parse() {
|
||||
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
|
||||
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
|
||||
for alias in ["1", "1/1"] {
|
||||
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
|
||||
}
|
||||
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
|
||||
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
|
||||
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
|
||||
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
|
||||
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
|
||||
}
|
||||
|
||||
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
|
||||
#[test]
|
||||
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
|
||||
for command in ["view.grid", "view.nudge"] {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
|
||||
assert_eq!(result["follow_main"], true);
|
||||
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
e.execute(command, &json!({"follow_main": true})).unwrap();
|
||||
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
|
||||
assert_eq!(result["follow_main"], false);
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
|
||||
}
|
||||
|
||||
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
|
||||
#[test]
|
||||
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
|
||||
let mut e = Engine::default();
|
||||
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
|
||||
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
|
||||
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
|
||||
let s = e.session();
|
||||
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
|
||||
}
|
||||
}
|
||||
|
||||
fn view(e: &Engine) -> (f64, Samples) {
|
||||
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
|
||||
}
|
||||
@@ -365,7 +241,6 @@ mod tests {
|
||||
assert_eq!(state(&e), before);
|
||||
}
|
||||
|
||||
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
|
||||
#[test]
|
||||
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
|
||||
let mut e = Engine::default();
|
||||
@@ -387,32 +262,4 @@ mod tests {
|
||||
assert_eq!(e.session().edit.selection.start, 6_000);
|
||||
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
|
||||
}
|
||||
|
||||
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
|
||||
#[test]
|
||||
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
|
||||
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
|
||||
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
|
||||
assert!(e.session().edit.grid_clips_markers);
|
||||
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
|
||||
|
||||
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
|
||||
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
|
||||
assert!(!e.session().edit.grid_clips_markers);
|
||||
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
|
||||
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
|
||||
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
|
||||
|
||||
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert!(!e.session().edit.grid_follow_main);
|
||||
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
|
||||
assert_eq!(e.session().edit.grid, GridValue::default());
|
||||
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"),
|
||||
flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"),
|
||||
flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"),
|
||||
flag!("view.transport_ableton_link", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
|
||||
flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"),
|
||||
flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"),
|
||||
flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"),
|
||||
cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| {
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
//! a 24-bar groove at 104 BPM with drums, bass, keys, pad, lead, a reverb aux and a master.
|
||||
|
||||
use crate::Engine;
|
||||
use craft_audio::AudioBuffer;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_midi::{Note, Sequence};
|
||||
use soundcraft_model::FileFormat;
|
||||
use soundcraft_model::{ChannelFormat, Clip, Fade, FadeShape, Insert, MarkerKind, Route, SendSlot, Session, TrackKind};
|
||||
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
|
||||
|
||||
|
||||
+8
-128
@@ -1,15 +1,12 @@
|
||||
//! Borrowed document equality for the saved-state indicator.
|
||||
|
||||
use soundcraft_model::{EditState, Mixer, Session, Track};
|
||||
use soundcraft_model::{EditState, Session};
|
||||
|
||||
/// Equal apart from navigation and live input flags, without copying document data.
|
||||
/// Equal apart from the selection, playhead and zoom, without copying document data.
|
||||
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
|
||||
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
|
||||
&& saved.tracks.len() == doc.tracks.len()
|
||||
&& saved.tracks.iter().zip(&doc.tracks).all(|(saved, doc)| track_values(saved) == track_values(doc))
|
||||
}
|
||||
|
||||
/// Borrow persistent session fields; track fields are compared separately.
|
||||
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
// No `..`: adding a document field must also decide how it affects saved-state equality.
|
||||
let Session {
|
||||
@@ -21,7 +18,7 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
timecode_start,
|
||||
tempo,
|
||||
key_signatures,
|
||||
tracks: _,
|
||||
tracks,
|
||||
sources,
|
||||
videos,
|
||||
busses,
|
||||
@@ -38,66 +35,11 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
|
||||
} = s;
|
||||
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
|
||||
(
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, sources, videos, busses),
|
||||
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
|
||||
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent track fields without allocating or copying media.
|
||||
fn track_values(track: &Track) -> impl PartialEq + '_ {
|
||||
let Track {
|
||||
id,
|
||||
name,
|
||||
kind,
|
||||
format,
|
||||
color,
|
||||
height,
|
||||
playlists,
|
||||
active_playlist,
|
||||
mixer,
|
||||
automation,
|
||||
view,
|
||||
hidden,
|
||||
inactive,
|
||||
frozen,
|
||||
comments,
|
||||
folder,
|
||||
folder_open,
|
||||
instrument,
|
||||
ticks_timebase,
|
||||
elastic,
|
||||
pinned,
|
||||
} = track;
|
||||
(
|
||||
(id, name, kind, format, color, height, playlists, active_playlist, mixer_values(mixer), automation, view),
|
||||
(hidden, inactive, frozen, comments, folder, folder_open, instrument, ticks_timebase, elastic, pinned),
|
||||
)
|
||||
}
|
||||
|
||||
/// Borrow persistent mixer fields without allocating or copying media.
|
||||
fn mixer_values(mixer: &Mixer) -> impl PartialEq + '_ {
|
||||
let Mixer {
|
||||
volume_db,
|
||||
pan,
|
||||
mute,
|
||||
solo,
|
||||
solo_safe,
|
||||
record_arm: _,
|
||||
input_monitor: _,
|
||||
phase_invert,
|
||||
input,
|
||||
output,
|
||||
inserts,
|
||||
sends,
|
||||
automation_mode,
|
||||
trim_db,
|
||||
vca,
|
||||
surround,
|
||||
} = mixer;
|
||||
((volume_db, pan, mute, solo, solo_safe, phase_invert, input, output, inserts, sends, automation_mode), (trim_db, vca, surround))
|
||||
}
|
||||
|
||||
/// Borrow edit settings while normalizing transient navigation to the saved values.
|
||||
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
|
||||
let EditState {
|
||||
tool,
|
||||
@@ -110,9 +52,6 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
grid,
|
||||
nudge,
|
||||
grid_lines,
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
main_counter,
|
||||
sub_counter,
|
||||
zoom: _,
|
||||
@@ -191,17 +130,7 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
|
||||
scrolling,
|
||||
solo_mode,
|
||||
),
|
||||
(
|
||||
grid_follow_main,
|
||||
nudge_follow_main,
|
||||
grid_clips_markers,
|
||||
pre_fader_metering,
|
||||
delay_compensation,
|
||||
keyboard_focus,
|
||||
&navigation.timeline_selection,
|
||||
flags,
|
||||
values,
|
||||
),
|
||||
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,7 +141,7 @@ mod tests {
|
||||
use soundcraft_time::{Range, TimeFormat};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The clone-based policy, extended to ignore live input flags, serves as a test oracle.
|
||||
// The previous comparison is an oracle for preserving the saved-state policy.
|
||||
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
|
||||
let mut doc = doc.clone();
|
||||
let (from, to) = (&saved.edit, &mut doc.edit);
|
||||
@@ -222,14 +151,9 @@ mod tests {
|
||||
to.selected_clips.clone_from(&from.selected_clips);
|
||||
to.playhead = from.playhead;
|
||||
to.zoom = from.zoom;
|
||||
for (saved, doc) in saved.tracks.iter().zip(&mut doc.tracks) {
|
||||
doc.mixer.record_arm = saved.mixer.record_arm;
|
||||
doc.mixer.input_monitor = saved.mixer.input_monitor;
|
||||
}
|
||||
*saved == doc
|
||||
}
|
||||
|
||||
/// Check both directions and compare against the normalized clone oracle.
|
||||
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
|
||||
assert_eq!(same_document(saved, doc), expected, "{field}");
|
||||
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
|
||||
@@ -262,9 +186,6 @@ mod tests {
|
||||
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
|
||||
("grid_lines", |e| e.grid_lines = !e.grid_lines),
|
||||
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
|
||||
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
|
||||
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
|
||||
("main_counter", |e| e.main_counter = TimeFormat::Samples),
|
||||
("sub_counter", |e| e.sub_counter = None),
|
||||
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
|
||||
@@ -313,7 +234,8 @@ mod tests {
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
|
||||
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
|
||||
let audio = Arc::new(soundcraft_model::SourceAudio::new(craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
|
||||
let audio =
|
||||
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
|
||||
saved.pool.insert(SourceId(900), audio);
|
||||
check(&saved, &saved.clone(), true, "shared decoded audio");
|
||||
let mut doc = saved.clone();
|
||||
@@ -330,48 +252,6 @@ mod tests {
|
||||
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
|
||||
}
|
||||
|
||||
/// Only arm and monitoring are transient; every other mixer field still contributes to dirtiness.
|
||||
#[test]
|
||||
fn live_flags_do_not_hide_persistent_mixer_changes() {
|
||||
use soundcraft_model::{AutomationMode, BusId, Insert, Route, SendSlot, SurroundPan};
|
||||
let mut saved = Session::default();
|
||||
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut live = saved.clone();
|
||||
live.track_mut(track).unwrap().mixer.record_arm = true;
|
||||
live.track_mut(track).unwrap().mixer.input_monitor = true;
|
||||
check(&saved, &live, true, "live input flags");
|
||||
let edits: &[(&str, fn(&mut Mixer))] = &[
|
||||
("volume", |m| m.volume_db = -6.0),
|
||||
("pan", |m| m.pan[0] = 0.5),
|
||||
("mute", |m| m.mute = true),
|
||||
("solo", |m| m.solo = true),
|
||||
("solo_safe", |m| m.solo_safe = true),
|
||||
("phase_invert", |m| m.phase_invert = true),
|
||||
("input", |m| m.input = Route::Hardware("Mic".into())),
|
||||
("output", |m| m.output = Route::Bus(BusId(900))),
|
||||
("inserts", |m| m.inserts[0] = Some(Insert::new("eq7"))),
|
||||
("sends", |m| m.sends[0] = Some(SendSlot::new(Route::Main))),
|
||||
("automation_mode", |m| m.automation_mode = AutomationMode::Touch),
|
||||
("trim_db", |m| m.trim_db = -3.0),
|
||||
("vca", |m| m.vca = Some(900)),
|
||||
("surround", |m| m.surround = Some(SurroundPan::default())),
|
||||
];
|
||||
for &(field, edit) in edits {
|
||||
let mut doc = live.clone();
|
||||
edit(&mut doc.track_mut(track).unwrap().mixer);
|
||||
check(&saved, &doc, false, field);
|
||||
}
|
||||
let mut doc = live.clone();
|
||||
doc.tracks.push(doc.tracks[0].clone());
|
||||
check(&saved, &doc, false, "track count");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].id = TrackId(900);
|
||||
check(&saved, &doc, false, "track identity");
|
||||
let mut doc = live.clone();
|
||||
doc.tracks[0].mixer.volume_db = f32::NAN;
|
||||
assert!(!same_document(&doc, &doc), "persistent NaN keeps PartialEq semantics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
|
||||
let mut saved = Session::default();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user