mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
374 lines
14 KiB
Rust
374 lines
14 KiB
Rust
//! 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,
|
|
Other,
|
|
}
|
|
|
|
/// 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,
|
|
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.
|
|
fn id3v2_len(b: &[u8]) -> Option<usize> {
|
|
if b.get(..3)? != b"ID3" {
|
|
return None;
|
|
}
|
|
let flags = *b.get(5)?;
|
|
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
|
|
Some(10 + size + if flags & 0x10 != 0 { 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
|
|
}
|
|
|
|
fn detect_by_ext(ext: &str) -> FileFormat {
|
|
match ext.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
|
"wav" | "wave" | "bwf" | "rf64" => FileFormat::Wav,
|
|
"aif" | "aiff" | "aifc" => FileFormat::Aiff,
|
|
"flac" => FileFormat::Flac,
|
|
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
|
|
"ogg" | "oga" => FileFormat::Ogg,
|
|
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
|
|
"caf" => FileFormat::Caf,
|
|
_ => FileFormat::Other,
|
|
}
|
|
}
|
|
|
|
/// 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.map(detect_by_ext).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"))),
|
|
}
|
|
}
|
|
|
|
/// Formats [`encode`] can write, as the extensions that name them.
|
|
pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac";
|
|
|
|
/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it.
|
|
pub fn encode_format_for(name: &str) -> Option<FileFormat> {
|
|
match name.trim_start_matches('.').to_ascii_lowercase().as_str() {
|
|
"wav" | "wave" | "bwf" => Some(FileFormat::Wav),
|
|
"aif" | "aiff" => Some(FileFormat::Aiff),
|
|
"flac" => Some(FileFormat::Flac),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// 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, opts.bwf.as_ref(), mask, 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)
|
|
}
|