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
43 changes: 37 additions & 6 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ name: Fuzz
on:
workflow_dispatch:
schedule:
# Weekly, Mondays at 05:23 UTC.
- cron: '23 5 * * 1'
# Twice weekly, Mondays and Thursdays at 05:23 UTC. Twice rather than
# once because the corpus cache below is evicted after seven days without
# a read, and a weekly schedule reads it exactly every seven days: no
# margin, and a silent failure at the boundary. Three and four days apart
# puts every read well inside the window.
- cron: '23 5 * * 1,4'

# Read-only. This job checks out the source, builds it and runs it; it
# publishes nothing and needs no write scope anywhere.
Expand Down Expand Up @@ -104,11 +108,38 @@ jobs:
cargo +nightly-2026-07-01 fuzz build \
--target x86_64-unknown-linux-gnu ${{ matrix.fuzz_target }}

# What the previous run left behind. Without this the corpus is
# destroyed with the runner and every run rediscovers the same ground
# from the same committed floor: the first scheduled run grew the nine
# targets by between 3x and 49x, and kept none of it.
#
# The key carries the run id, so it never hits exactly and the post-job
# save always writes a fresh entry; restore-keys matches the most recent
# entry for this target by prefix. That is the rolling-cache shape,
# which is what an accumulating corpus needs.
#
# A miss is not a failure. The step below copies the committed seeds and
# minimised corpus in afterwards either way, so a run with no cache
# starts from the committed floor, which is what every run did before
# this step existed. A job that fails because a cache was cold is worse
# than a shallow one, on a schedule nobody is watching.
- name: Restore the accumulated corpus
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: fuzz/corpus/${{ matrix.fuzz_target }}
key: fuzz-corpus-${{ matrix.fuzz_target }}-${{ github.run_id }}
restore-keys: |
fuzz-corpus-${{ matrix.fuzz_target }}-

# The committed seeds are what a cold run starts from; the committed
# minimised corpus, where there is one, is the floor under that. No
# cache is consulted: GitHub evicts a cache not read for seven days and
# a weekly schedule reads it every seven days, so a cache here would
# fail silently at the boundary.
# minimised corpus, where there is one, is the floor under that. Both
# are copied in after any cache restore rather than instead of one, so
# the floor holds whether the cache hit or missed.
#
# The entry count is logged here, before the run reads the directory, so
# a lost cache is visible in the job summary: a target that normally
# starts from nine hundred entries and reports forty-five has plainly
# not been restored.
- name: Assemble the corpus
run: |
target=${{ matrix.fuzz_target }}
Expand Down
27 changes: 26 additions & 1 deletion .github/workflows/publish-crates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,39 @@ jobs:
steps:
- uses: actions/checkout@v7

# A tag of crate-v0.2.0 against a manifest still saying 0.1.4 either
# publishes a version nobody asked for or fails somewhere inside cargo
# with a message about the registry. Checked here, before
# authentication, so a mismatch never reaches crates.io and the job
# fails saying which two values disagreed.
#
# Guarded on the tag prefix because workflow_dispatch runs this workflow
# from a branch ref, which has no version in it to compare.
- name: Check the tag matches the manifest version
if: startsWith(github.ref, 'refs/tags/crate-v')
run: |
tag_version="${GITHUB_REF#refs/tags/crate-v}"
manifest_version=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)
echo "tag says $tag_version, Cargo.toml says $manifest_version"
if [ -z "$manifest_version" ]; then
echo "::error::no version found in Cargo.toml"
exit 1
fi
if [ "$tag_version" != "$manifest_version" ]; then
echo "::error::tag crate-v$tag_version does not match the Cargo.toml version $manifest_version"
exit 1
fi

- uses: rust-lang/crates-io-auth-action@v1
id: auth

- name: Confirm authentication
run: echo "Trusted publishing authentication succeeded."

# --locked so the release is built from the committed lock file rather
# than from whatever resolves at publish time.
- name: Publish
if: startsWith(github.ref, 'refs/tags/crate-v')
run: cargo publish
run: cargo publish --locked
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.4] - 2026-08-06

### Added

- Four accessors on `AudioStreamDecoder` reaching the detail of the reader the
probe chose: `wav_format` returning `Option<WavFormat>`, `aiff_format`
returning `Option<AiffFormat>`, `flac_stream_info` returning
`Option<FlacStreamInfo>` and `flac_md5_check` returning `Option<Md5Check>`.
Each is the same value the corresponding per-format streaming reader
reports: `WavStreamDecoder::format`, `AiffStreamDecoder::format`,
`FlacStreamDecoder::stream_info` and `FlacStreamDecoder::md5_check`. Each
returns `None` before the stream has been identified, `None` for a stream
identified as a container the accessor is not about, and `None` after
identification until the header chunk carrying the detail has arrived;
`flac_md5_check` returns `None` until `finish` has run.

### Changed

- The crate-level guarantee no longer states a sample count for "decoding with
rate conversion". Decoding a source of `n` frames yields exactly
`n * channels` samples, and the count under a later resample is stated as
`decibri-resampler`'s rather than this crate's.

## [0.1.3] - 2026-08-06

Documentation only. No behaviour change from 0.1.2.
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "decibri-decode"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
# Matches the decibri workspace MSRV (workspace.package.rust-version in
# decibri's root Cargo.toml). decibri-decode is consumed by decibri, so it may
Expand Down
11 changes: 6 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
//! reference path, where a wrong-rate reference is accepted silently and
//! cancels nothing.
//! - **Guarantee:** the number of samples produced for a given input is stated
//! and tested, not incidental. Decoding a source of `n` frames without rate
//! conversion yields exactly `n * channels` samples. Decoding with rate
//! conversion yields exactly the count `decibri-resampler` states for that
//! rate pair and input length. A codec that cannot state its output count
//! does not go behind [`Decoder`].
//! and tested, not incidental. Decoding a source of `n` frames yields exactly
//! `n * channels` samples, and there is no rate conversion here for that
//! count to be conditional on. A caller who goes on to resample gets the
//! count [`decibri_resampler`] states for that rate pair and input length,
//! which is that crate's guarantee rather than this one's. A codec that
//! cannot state its output count does not go behind [`Decoder`].
//! - **Failure:** every rejection is a [`DecodeError`] that names the specific
//! thing it rejected. "Unsupported" on its own tells a caller nothing they
//! can act on.
Expand Down
72 changes: 69 additions & 3 deletions src/probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@

use std::fmt;

use crate::aiff::{AiffReader, AiffStreamDecoder};
use crate::aiff::{AiffFormat, AiffReader, AiffStreamDecoder};
use crate::audio::{AudioBuffer, AudioSpec};
use crate::codec::FourCc;
use crate::error::DecodeError;
use crate::flac::{FlacReader, FlacStreamDecoder};
use crate::flac::{FlacReader, FlacStreamDecoder, FlacStreamInfo, Md5Check};
use crate::source::StreamSource;
use crate::wav::{WavReader, WavStreamDecoder};
use crate::wav::{WavFormat, WavReader, WavStreamDecoder};
use crate::{aiff, riff};

/// The four bytes every FLAC stream starts with.
Expand Down Expand Up @@ -375,6 +375,72 @@ impl AudioStreamDecoder {
self.container
}

/// What the WAV `fmt ` chunk declared, once it has arrived.
///
/// This is [`WavStreamDecoder::format`] on the reader the probe chose, so
/// a caller reaching the front door gets the same detail as one that
/// picked the reader by hand.
///
/// `None` in three cases: before the stream has been identified at all,
/// for a stream identified as any container other than
/// [`Container::Wav`], and for a WAV whose `fmt ` chunk has not yet
/// arrived. Identification is not the header: knowing a stream is a WAV
/// happens twelve bytes in and the `fmt ` chunk is later.
pub const fn wav_format(&self) -> Option<WavFormat> {
match &self.inner {
Some(Inner::Wav(reader)) => reader.format(),
_ => None,
}
}

/// What the AIFF `COMM` chunk declared, once it has arrived.
///
/// This is [`AiffStreamDecoder::format`] on the reader the probe chose.
///
/// `None` before the stream has been identified, for a stream identified
/// as any container other than [`Container::Aiff`], and for an AIFF whose
/// `COMM` chunk has not yet arrived.
pub const fn aiff_format(&self) -> Option<AiffFormat> {
match &self.inner {
Some(Inner::Aiff(reader)) => reader.format(),
_ => None,
}
}

/// What the FLAC streaminfo metadata block declared, once it has arrived.
///
/// This is [`FlacStreamDecoder::stream_info`] on the reader the probe
/// chose.
///
/// `None` before the stream has been identified, for a stream identified
/// as any container other than [`Container::Flac`], and for a FLAC stream
/// whose streaminfo block has not yet arrived. The probe never reaches a
/// bare frame stream, so the value here is always a block read from the
/// stream rather than one derived from a first frame or supplied by a
/// caller.
pub fn flac_stream_info(&self) -> Option<FlacStreamInfo> {
match &self.inner {
Some(Inner::Flac(reader)) => reader.stream_info(),
_ => None,
}
}

/// What the end of a FLAC stream did about the streaminfo MD5.
///
/// This is [`FlacStreamDecoder::md5_check`] on the reader the probe chose.
///
/// `None` before the stream has been identified, for a stream identified
/// as any container other than [`Container::Flac`], and for a FLAC stream
/// on which [`finish`](StreamSource::finish) has not run. `None` is not a
/// verdict: the check happens at the end of the stream, and a decode that
/// has not finished has nothing to report.
pub fn flac_md5_check(&self) -> Option<Md5Check> {
match &self.inner {
Some(Inner::Flac(reader)) => reader.md5_check(),
_ => None,
}
}

/// Offers the held leading bytes to the inner reader, front first.
///
/// A no-op before identification, and after it a loop rather than a single
Expand Down
Loading