Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/audio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
# Each feature on its own, so code that needs another feature fails here.
- name: Clippy, each feature alone
run: |
for f in "" decode decode-native symphonia encode lossless-encode pcm-encode peaks serde; do
for f in "" decode decode-native symphonia encode lossless-encode pcm-encode flac-encode peaks serde; do
echo "::group::features: ${f:-none}"
cargo clippy -p craft-audio --all-targets --locked --no-default-features ${f:+--features "$f"} -- -D warnings
echo "::endgroup::"
Expand Down Expand Up @@ -82,6 +82,7 @@ jobs:
key: craft-audio-features
- run: cargo test -p craft-audio --no-default-features --features lossless-encode --locked
- run: cargo test -p craft-audio --no-default-features --features pcm-encode --locked --lib
- run: cargo test -p craft-audio --no-default-features --features flac-encode --locked --lib
- run: cargo test -p craft-audio --no-default-features --features decode-native --locked
test:
timeout-minutes: 20
Expand All @@ -101,7 +102,16 @@ jobs:
run: |
# The man-db index rebuild after dpkg is slow on hosted runners.
sudo rm -f /var/lib/man-db/auto-update
(sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends ffmpeg) > "$RUNNER_TEMP/apt.log" 2>&1 &
# A mirror can stall for most of the job's time limit, and the next try usually gets a good one.
install_ffmpeg() {
for attempt in 1 2 3; do
timeout -k 10 180 sudo sh -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ffmpeg' && return 0
echo "apt: attempt $attempt failed with exit code $?"
timeout -k 10 60 sudo dpkg --configure -a || true
done
return 1
}
install_ffmpeg > "$RUNNER_TEMP/apt.log" 2>&1 &
apt=$!
cargo test -p craft-audio --all-features --locked --no-run
wait "$apt" || { cat "$RUNNER_TEMP/apt.log"; exit 1; }
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
scopes, conflicts, user overrides stored through craft-settings and import of other
applications' shortcut sets. Command catalogs, file readers and the shortcut editor UI stay
in each app.
- [`craft-audio`](crates/audio/README.md): audio file I/O and codecs (WAV, AIFF), streaming and
seeking, and packet decoding for any demuxer. See its README for formats and features.
- [`craft-audio`](crates/audio/README.md): audio file I/O and codecs (WAV, AIFF, FLAC), streaming
and seeking, and packet decoding for any demuxer. See its README for formats and features.
- [`craft-media`](crates/media/README.md): bit-level I/O (`bitstream`), clean-room MP4/MOV
(`isobmff`) and Matroska/WebM (`matroska`) demuxers and muxers, an Ogg demuxer (`ogg`), from
FilmCraft, and an ISO BMFF box reader. Containers only; codecs are in `craft-audio`, and
Expand Down
8 changes: 5 additions & 3 deletions crates/audio/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "craft-audio"
description = "Audio file I/O and codecs: WAV/AIFF read and write."
description = "Audio file I/O and codecs: WAV/AIFF read and write, FLAC encoder."
version.workspace = true
edition.workspace = true
license.workspace = true
Expand All @@ -23,10 +23,12 @@ decode-native = ["craft-media/isobmff", "craft-media/matroska", "craft-media/ogg
symphonia = ["decode-native", "dep:symphonia"]
# Every encoder: the lossless file writers.
encode = ["lossless-encode"]
# Encode WAV/BWF/RF64 and AIFF/AIFF-C files.
lossless-encode = ["pcm-encode"]
# Encode WAV/BWF/RF64, AIFF/AIFF-C and FLAC files.
lossless-encode = ["pcm-encode", "flac-encode"]
# Encode WAV/BWF/RF64 and AIFF/AIFF-C files.
pcm-encode = []
# Encode FLAC files.
flac-encode = ["craft-media/bitstream"]
# Multi-resolution waveform overviews (`peaks`).
peaks = []
# Serialize and Deserialize for the shared types.
Expand Down
34 changes: 25 additions & 9 deletions crates/audio/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# craft-audio

Audio file I/O and codecs for the Craft apps. Native implementations: WAV and AIFF read/write.
Audio file I/O and codecs for the Craft apps. Native implementations: WAV and AIFF read/write,
FLAC encoder.
Formats without a native decoder are decoded by [symphonia](#symphonia), which is to be replaced
by native decoders.

Expand All @@ -25,16 +26,17 @@ No default features. The shared types (`AudioBuffer`, `AudioInfo`, `ChannelLayou
| `decode` | `decode`, `probe`, `Decoder`, `tracks`, `TrackDecoder`; every format in [Format support](#format-support) | `decode-native`, `symphonia` |
| `decode-native` | the same, native formats only (others are `Unsupported`) | craft-media `isobmff`/`matroska`/`ogg`, `log` |
| `symphonia` | the fallback for formats with no native decoder; turning it on is the same as `decode` | `decode-native`, symphonia |
| `encode` | every encoder: the WAV and AIFF file writers | `lossless-encode` |
| `lossless-encode` | WAV and AIFF writing | `pcm-encode` |
| `encode` | every encoder: the WAV, AIFF and FLAC file writers | `lossless-encode` |
| `lossless-encode` | WAV, AIFF and FLAC writing | `pcm-encode`, `flac-encode` |
| `pcm-encode` | `encode`, `FileEncoder` for WAV and AIFF; `wav_float_header` | none |
| `flac-encode` | `encode`, `FileEncoder` for FLAC | craft-media `bitstream` |
| `peaks` | `peaks::Peaks` | none |
| `serde` | `Serialize`/`Deserialize` on the shared types | `serde` |

An app that decodes 32-bit FLAC with `decode` needs a profile override in its workspace; see
[symphonia](#symphonia).

WAV and AIFF export only (no decoders, no demuxers, no symphonia):
WAV, AIFF and FLAC export only (no decoders, no demuxers, no symphonia):

```toml
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["lossless-encode"] }
Expand Down Expand Up @@ -179,8 +181,8 @@ use craft_audio::{AudioBuffer, BitDepth, ChannelLayout, EncodeFormat, EncodeOpti
// makes a silent one to fill.
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![left, right] };

let opts = EncodeOptions::new(EncodeFormat::Aiff).with_bit_depth(BitDepth::Int24).with_dither(true);
let aiff: Vec<u8> = encode(&buf, &opts)?;
let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int24).with_dither(true);
let flac: Vec<u8> = encode(&buf, &opts)?;

// 5.1 with side surrounds: a layout other than the default for 6 channels, recorded in the WAV.
let wav = encode(&surround, &EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE))?;
Expand Down Expand Up @@ -240,7 +242,7 @@ match craft_audio::decode(&bytes, None) {
|---|---|---|
| WAV, BWF, RF64/BW64 | native | native |
| AIFF, AIFF-C | native | native |
| FLAC | symphonia | — |
| FLAC | symphonia | native |
| AAC-LC, HE-AAC | symphonia | — |
| MP1, MP2, MP3 (MPEG audio Layer I/II/III) | symphonia | — |
| ALAC, Vorbis, ADPCM, A-law, µ-law | symphonia | — |
Expand All @@ -258,6 +260,16 @@ match craft_audio::decode(&bytes, None) {
## Limits

- AIFF with more than four channels decodes with an unordered layout, in the file's order.
- A FLAC layout that decoders would not assume for the channel count depends on its
`WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment. This includes the usual layouts for 5 and 6 channels:
without the comment, back surrounds are read as side surrounds. It includes the usual 7-channel
layout too: without the comment, its back left, back right and back centre are read as back
centre, side left and side right.
- FLAC at 32 bits codes stereo channels independently, because a side channel would need 33 bits.
- Outside RFC 9639's streamable subset (§7), which some decoders require: FLAC at 32 bits, at a
sample rate the frame header cannot name, or with a layout that needs the mask comment.
- `ChannelLayout::unordered` is written to FLAC as mask `0x0000`, as RFC 9639 §8.6.2 allows, but
ffmpeg and `flac -d` ignore that mask and use their default layout for the channel count.
- Not decoded at all: AC-3, E-AC-3, Opus and Speex.
- `TrackDecoder` returns `Unsupported` for every codec but PCM; `decode` and `Decoder` read the
other codecs in the table above through symphonia.
Expand All @@ -269,6 +281,7 @@ match craft_audio::decode(&bytes, None) {
| Codec | Spec | Scope | Verified against |
|---|---|---|---|
| WAV, AIFF | Microsoft RIFF, Apple AIFF/AIFF-C | read: integer 8–32-bit, float 32/64-bit, `WAVE_FORMAT_EXTENSIBLE` masks, RF64/BW64, BWF `bext`, truncated files; write: 16/24/32-bit integer and 32-bit float, RF64 above 4 GiB, any layout, TPDF dither | round trips; hostile-input tests |
| FLAC encoder | RFC 9639 | 16, 24, 32 bits; 1–8 channels, layouts decoders would not assume for the count as a `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment; up to 655,350 Hz; LPC, stereo decorrelation, wasted bits, STREAMINFO MD5 | bit-exact through ffmpeg, STREAMINFO MD5 checked against ffmpeg |

## symphonia

Expand Down Expand Up @@ -320,12 +333,14 @@ the full tests with ffmpeg required.
| Part | Source |
|---|---|
| WAV, AIFF, `decode`/`probe`, peaks | SoundCraft `soundcraft-audio-io` [7dc56a4](https://github.com/storytold/soundcraft/commit/7dc56a4dbeebf57eacece09536726c57844fc59d) |
| FLAC encoder | FilmCraft [8fda2f2](https://github.com/storytold/filmcraft/commit/8fda2f232198c4c79ff9cfe5b6a347bb1375fa9e) |

Where apps had overlapping implementations, the one that measured better was kept, with the
tests of both:

| Format | Kept | Reason |
|---|---|---|
| FLAC encoding | FilmCraft | 0.1–37% smaller output than SoundCraft's on 12 test signals; STREAMINFO MD5 |
| WAV/AIFF writing | SoundCraft | only one with BWF, dither, arbitrary speaker masks, streaming without known length |

## Migration notes for apps
Expand All @@ -350,11 +365,12 @@ What an app adjusts:
`#[non_exhaustive]`.
`Speaker::ALL` is a slice.
- `TrackInfo::sample_rate` and `channels` are `Option`s.
- Features are per format. SoundCraft's `encode` is now `lossless-encode`, which turns on
`pcm-encode` (WAV, AIFF). `encode` turns on every encoder. Writing a format
- Features are per format. SoundCraft's `encode` (WAV, AIFF, FLAC) is now `lossless-encode`, made
of `pcm-encode` (WAV, AIFF) and `flac-encode`. `encode` turns on every encoder. Writing a format
whose feature is off returns `AudioError::Unsupported`.
- `FileEncoder::write` takes any per-channel slices, but refuses a block with missing channels or
short slices instead of filling them with silence.
- `DecodeOptions` is not `Copy`.
- The FLAC encoder's vendor string is `craft-audio <version>`.
- `decode` tries the native path first (WAV, AIFF, and PCM in MP4 and Matroska), which decodes
MP4 and Matroska files to presentation length. A failed source read is `AudioError::Io`.
8 changes: 4 additions & 4 deletions crates/audio/examples/encode.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Write a tone as WAV and AIFF, whole and streamed.
//! Write a tone as WAV and FLAC, whole and streamed.
//!
//! `cargo run -p craft-audio --example encode --features lossless-encode -- out`

Expand All @@ -20,9 +20,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let wav = encode(&surround, &EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE))?;
std::fs::write(dir.join("tone-5.1.wav"), wav)?;

// Streamed: the same bytes, block by block into a file.
let opts = EncodeOptions::new(EncodeFormat::Aiff);
let mut enc = FileEncoder::new(std::fs::File::create(dir.join("tone.aiff"))?, &opts, rate as u32, 2)?;
// Streamed: the bytes `encode` would give, block by block into a file.
let opts = EncodeOptions::new(EncodeFormat::Flac);
let mut enc = FileEncoder::new(std::fs::File::create(dir.join("tone.flac"))?, &opts, rate as u32, 2)?;
for chunk in tone.chunks(4096) {
enc.write(&[chunk, chunk], chunk.len())?;
}
Expand Down
13 changes: 13 additions & 0 deletions crates/audio/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,19 @@ impl std::fmt::Display for ChannelLayout {
}
}

/// The layout decoders assume for 1–8 FLAC channels with no channel mask comment, as a WAV mask.
/// RFC 9639 §9.1.3 leaves "back/surround" open for 5 and 6 channels; libFLAC and ffmpeg read side
/// surrounds. 7 channels are 6.1 with sides (FL FR FC LFE BC SL SR).
#[cfg(any(feature = "symphonia", feature = "flac-encode"))]
pub(crate) fn flac_assumed_mask(channels: usize) -> u32 {
match channels {
5 => 0x607,
6 => 0x60F,
7 => 0x70F,
n => ChannelLayout::default_for(n as u16).mask().unwrap_or(0),
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
28 changes: 19 additions & 9 deletions crates/audio/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
//! - [`TrackDecoder`] (feature `decode-native`): one track's packets from any demuxer to planar
//! `f32` in WAV channel order.
//! - [`encode`], [`FileEncoder`]: WAV/BWF/RF64 and AIFF/AIFF-C (feature `pcm-encode`, which also
//! gives [`wav_float_header`]), whole-buffer or incremental into any `Write + Seek`. Features
//! `lossless-encode` and `encode` turn it on.
//! gives [`wav_float_header`]) and FLAC (feature `flac-encode`), whole-buffer or incremental into
//! any `Write + Seek`. Feature `lossless-encode` turns on both; feature `encode` is
//! the same as `lossless-encode`.
//! - [`peaks`] (feature `peaks`): min/max/RMS pyramid for waveform drawing.
//!
//! The shared types below build with no features.
Expand All @@ -33,7 +34,7 @@ mod read;
mod track;
#[cfg(any(feature = "decode-native", feature = "pcm-encode"))]
mod wav;
#[cfg(feature = "pcm-encode")]
#[cfg(any(feature = "pcm-encode", feature = "flac-encode"))]
mod write;

pub use buffer::AudioBuffer;
Expand All @@ -49,7 +50,9 @@ pub use read::{DEFAULT_MAX_SAMPLES, DecodeOptions, Decoder, TrackInfo, decode, d
#[cfg(feature = "decode-native")]
pub use track::{LOOKAHEAD_PACKETS, TrackDecoder};
#[cfg(feature = "pcm-encode")]
pub use write::{ENCODE_EXTENSIONS, FileEncoder, encode, encode_format_for, wav_float_header};
pub use write::wav_float_header;
#[cfg(any(feature = "pcm-encode", feature = "flac-encode"))]
pub use write::{ENCODE_EXTENSIONS, FileEncoder, encode, encode_format_for};

/// Errors from probing, decoding and encoding.
#[non_exhaustive]
Expand All @@ -76,7 +79,8 @@ pub enum AudioError {
/// Result alias for this module.
pub type Result<T> = std::result::Result<T, AudioError>;

/// The file formats [`encode`] writes (feature `pcm-encode`).
/// The file formats [`encode`] writes. Each needs its feature: WAV and AIFF `pcm-encode`, FLAC
/// `flac-encode`; asking for one that is not built returns [`AudioError::Unsupported`].
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
Expand All @@ -85,6 +89,8 @@ pub enum EncodeFormat {
Wav,
/// AIFF (integer depths) or AIFF-C `fl32` (float).
Aiff,
/// FLAC (integer depths).
Flac,
}

impl EncodeFormat {
Expand All @@ -93,6 +99,7 @@ impl EncodeFormat {
match self {
EncodeFormat::Wav => Container::Wav,
EncodeFormat::Aiff => Container::Aiff,
EncodeFormat::Flac => Container::Flac,
}
}
}
Expand Down Expand Up @@ -244,8 +251,8 @@ pub enum BitDepth {
/// ```
/// use craft_audio::{BitDepth, EncodeFormat, EncodeOptions};
///
/// let opts = EncodeOptions::new(EncodeFormat::Aiff).with_bit_depth(BitDepth::Int16).with_dither(true);
/// assert_eq!(opts.format, EncodeFormat::Aiff);
/// let opts = EncodeOptions::new(EncodeFormat::Flac).with_bit_depth(BitDepth::Int16).with_dither(true);
/// assert_eq!(opts.format, EncodeFormat::Flac);
/// ```
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
Expand All @@ -261,8 +268,11 @@ pub struct EncodeOptions {
pub bwf: Option<BwfInfo>,
/// The speaker layout to record, for layouts a channel count alone cannot identify (5.1 with
/// side vs back surrounds, 7.0 vs 6.1, Ambisonics as [`ChannelLayout::unordered`]). `None` is
/// the usual layout for the channel count. WAV stores it as `dwChannelMask`; AIFF and FLAC
/// have fixed channel orders and ignore it. It must have as many channels as the audio.
/// the usual layout for the channel count. WAV stores it as `dwChannelMask`, FLAC as a
/// `WAVEFORMATEXTENSIBLE_CHANNEL_MASK` comment when decoders would assume another layout for
/// the count (they read 5 and 6 channels with side surrounds and 7 as 6.1 with sides, so the
/// usual layouts for those counts get one; see the README's limits); AIFF has a fixed channel
/// order and ignores it. It must have as many channels as the audio.
pub layout: Option<ChannelLayout>,
/// Write WAV in the RF64 layout even below 4 GiB (WAV only; ignored for other formats).
pub force_rf64: bool,
Expand Down
Loading
Loading