audio_samples 1.0.13

A typed audio processing library for Rust that treats audio as a first-class, invariant-preserving object rather than an unstructured numeric buffer.
Documentation
# Error Handling Overhaul — miette Diagnostics for `audio_samples`

## Context

The crate already has a well-structured error *hierarchy* (`src/error.rs`): a root
`AudioSampleError` (`#[non_exhaustive]`) wrapping five domain enums (`Conversion`,
`Parameter`, `Layout`, `Processing`, `Feature`) plus standalone variants, all built on
`thiserror 2.0`, with ergonomic constructors and `From` impls. The roadmap note in
`src/lib.rs:285` states the problem precisely:

> *"Improve the error system — it has breadth currently, but not depth or aesthetics.
> Rust set a new standard for error handling (errors that tell you exactly what went
> wrong, where, and how to fix it)."*

So the **breadth is done**; what's missing is **depth, actionability, correctness of the
taxonomy, and presentation**:

- No machine-stable error **codes**.
- No **help/fix-it** text telling the caller how to recover.
- No **source spans** — text-parsing failures (note names, enum `FromStr`) report a flat
  message instead of pointing at the offending character.
- **Information loss**: `.map_err(|_| …)` discards source errors (10+ sites), the
  `From<&str>`/`From<String>` shortcuts stamp `"unknown"` parameter/algorithm names
  (5 sites), and 22+ bare `EmptyData` returns carry no context about *which* operation
  failed.
- **Wrong-shaped errors**: some failures are modelled too broadly (one variant covering
  several distinct causes), and some carry stringly-typed payloads where a structured
  variant or enum would be clearer and matchable. Bringing miette in is only half the win —
  the other half is making sure we have **the right errors in the right places**,
  independent of any rendering library.

This overhaul therefore has two intertwined goals:
1. **Adopt miette** for codes, help text, severity, and source-span rendering.
2. **Right-size the error taxonomy** — split overly-broad variants, replace stringly-typed
   payloads with structured/enumerated data, and put each failure in the correct domain —
   so the diagnostics miette renders are *accurate* and *actionable*, not just prettier.

**Decisions taken (confirmed with user):**
1. **Use miette**, with the **`fancy`** renderer — accept the dependency cost for the
   functionality (codes, help, severity, real `SourceSpan` rendering).
2. **Breaking `2.0` is allowed** — we may restructure variants, delete lossy `From` impls,
   and add required fields.
3. **Scope = errors only** — enrich the ~316 `Err`/59 `map_err` sites. The ~1463
   `unwrap`/`expect`/`panic!` sites are **out of scope** (mostly tests/doctests/invariants).
4. **Full source spans on parsable paths** — invest in `#[source_code]`/`#[label]` wherever
   user-supplied *text* is parsed.
5. **Improve the taxonomy itself** — narrow over-broad variants and replace stringly-typed
   errors with structured ones wherever it improves clarity, matchability, or correctness
   (see §A7), regardless of miette.

---

## Part A — What the errors will look like (design)

### A1. Dependency & feature wiring (`Cargo.toml`)

```toml
miette = "7"                      # core Diagnostic trait + derive, no renderer
# fancy renderer gated so downstreams aren't forced to pull graphical deps:
[features]
fancy = ["miette/fancy"]          # enables coloured caret-underline rendering
```

- Library code only ever needs `#[derive(Diagnostic)]` from the core crate. The `fancy`
  renderer (`miette/fancy`, which pulls `owo-colors`, `supports-color`, etc.) is gated
  behind our own `fancy` feature so library consumers opt in.
- **Enable `fancy` by default for our `examples/` and the `educational` module** (add
  `fancy` to the relevant example/bin requirements and to the default feature set used in
  `cargo run --example …`) so the pretty output is visible out of the box, per the
  "bite the bullet" decision. Downstream library users still get plain `Display` unless
  they turn `fancy` on.
- MSRV: miette 7 requires rustc ≥ 1.82; the crate is edition 2024, so this is satisfied.

### A2. Every error variant gains `#[derive(Diagnostic)]` + `code` + `help`

`AudioSampleError` and all five sub-enums get `Diagnostic` derived alongside the existing
`Error` derive. Each variant gets:

- **`code(...)`** — a stable, namespaced identifier, scheme:
  `audio_samples::<domain>::<variant>`, e.g.
  `audio_samples::parameter::out_of_range`, `audio_samples::layout::non_contiguous`,
  `audio_samples::conversion::audio_conversion`, `audio_samples::feature::not_enabled`.
- **`help(...)`** — an **actionable** recovery hint. Static where possible; a
  `#[help] help: Option<String>` field where the fix depends on runtime values. Examples:
  - `OutOfRange`*"pass a value in [{min}, {max}]; got {value}"* (can derive from the
    `#[error]` fields via a format string in `help(...)`).
  - `FeatureError::NotEnabled` → already embeds `--features {feature}`; promote that to the
    `help()` slot and keep the `#[error]` message short.
  - `Layout::NonContiguous`*"call `.to_contiguous()` / clone the data before `{operation}`"*.
  - `ConversionError::UnsupportedConversion`*"use `to_format`/`to_type` for audio-aware
    conversion, or `cast_as`/`cast_to` for raw bit-casts"*.
- **`url(docsrs)`** on the sub-enums so `fancy` output links straight to the variant's
  docs page (the doc pass already wrote rich per-variant docs — this surfaces them).
- **`severity(...)`** — default `Error`; reserve `Warning` for genuinely recoverable
  conditions (none currently, but the slot is wired for future use).

### A3. Source-span variants for parsable text (the "where")

Add `#[source_code]` + `#[label]` to the variants that arise from parsing user text. The
two concrete targets from the inventory:

1. **Music-notation parsing**`src/utils/audio_math.rs::note_to_midi` (~lines 800–833).
   Introduce a dedicated diagnostic, e.g.

   ```rust
   #[derive(Error, Debug, Diagnostic, Clone)]
   #[error("invalid note name")]
   #[diagnostic(code(audio_samples::parse::note_name),
                help("expected scientific pitch notation like `A4`, `C#3`, `Bb2`"))]
   pub struct NoteParseError {
       #[source_code] input: String,
       #[label("{kind} here")] span: SourceSpan,   // points at the bad note letter or octave
       kind: String,                                // "unrecognised note", "bad octave", …
   }
   ```

   This replaces the current `map_err(|_| invalid_value(…))` at line 821 (which discards the
   integer-parse error) and the flat string at lines 806–809.

2. **Enum `FromStr` impls** in `src/operations/types.rs` (`PadSide`, `NormalizationMethod`,
   `VadMethod`, `ResamplingQuality`) and `src/operations/onset/mod.rs`
   (`SpectralFluxMethod::from_str`). Each gets a span pointing at the unrecognised token
   plus a `help()` listing the valid alternatives. A single shared
   `EnumParseError { input, span, expected: &'static [&str] }` diagnostic covers all of them.

These thread up into `AudioSampleError::Parse` (which is generalised to carry an optional
`#[diagnostic_source]`), so the rich span survives propagation through `?`.

### A4. Foreign errors become `#[diagnostic_source]` chains, not stringly-typed

Stop flattening foreign errors to strings. Where a real error type exists, keep it as a
nested source so `fancy` prints the full cause chain:

- `ndarray::ShapeError` — keep the `From` impl but attach the original as
  `#[source]`/`#[diagnostic_source]` instead of `err.to_string()` (`src/error.rs:491`).
- `spectrograms::SpectrogramError` — already `#[from]`; add `#[diagnostic_source]` so its
  own diagnostics (if any) render nested.
- **rubato** resampler errors (`src/resampling.rs:460–536`) — capture the real error type as
  a source rather than `format!("…: {e}")`.
- **`non_empty_slice` empty errors** — add `From<EmptyError> for AudioSampleError` and a new
  contextful `EmptyData { operation: String }` so the 10+ `map_err(|_| EmptyData)` sites
  (resampling.rs:615, codecs/perceptual/{mod,stereo}.rs, editing.rs:1885/1903/1924) keep the
  source and say *which* operation produced no samples.
- **`std::io::Error`** in `src/educational/mod.rs:140` — wrap in a dedicated `Io` variant
  instead of leaking a raw `io::Error`.

### A5. Breaking cleanups (the "2.0" budget)

- **Delete** `From<&str>`/`From<String>` for `ParameterError` and `ProcessingError`
  (`src/error.rs:973–1011`) — they manufacture `"unknown"` names. Each of their call sites
  is rewritten to name the real parameter/algorithm. (Inventory: the shortcuts are the
  source of every `"unknown"` placeholder.)
- **`AudioSampleError::layout(msg)`** helper (`src/error.rs:289`) — drop the hardcoded
  `operation: "unknown"`; require an operation name, or remove in favour of
  `LayoutError::shape_error(operation, info)`.
- **`EmptyData`**`EmptyData { operation: String }` (carries context).
- **`Unsupported(String)`** → structured `Unsupported { operation, reason }`, and fix the
  vague "not yet implemented" sites (iir_filtering.rs:1484, 1596) to name the exact
  unsupported configuration.

### A6. Before / after (what a caller sees)

*Before* (flat `Display`):
```
Invalid value for parameter 'cutoff_hz': Cutoff frequency must be less than Nyquist frequency
```
*After* (with `fancy`):
```
  × parameter `cutoff_hz` is out of range
   ╭─[note_to_midi]
   │ cutoff_hz = 25000
   ·             ──┬──
   ·               ╰── must be in [20, 22050]
   ╰────
  help: the Nyquist limit for 44100 Hz is 22050; lower the cutoff or raise the sample rate
   code: audio_samples::parameter::out_of_range
   docs: https://docs.rs/audio_samples/latest/audio_samples/error/enum.ParameterError.html
```

### A7. Right-sizing the taxonomy (miette-independent)

Bringing miette in only renders what we already model. This section is about modelling the
*right* errors — work that stands on its own value even without a single `#[diagnostic]`
attribute. The implementation phase must, at every touched call site, ask three questions
and fix what it finds:

**(a) Is this variant too broad?** Split a single catch-all into causes the caller can
actually act on differently.
- `ProcessingError::AlgorithmFailure { algorithm, reason }` is the dumping ground (every
  `From<&str>`/`From<String>` lands here with `algorithm: "unknown"`). Audit its real call
  sites and promote recurring causes to first-class variants — e.g. a distinct
  *unstable-filter* condition vs. a *numerical-overflow* condition vs. a genuinely opaque
  algorithm failure. Only keep the generic variant for the genuinely-opaque tail.
- `LayoutError::InvalidOperation(String, String)` overlaps with `ShapeError`,
  `DimensionMismatch`, and `IncompatibleFormat`. Many "X is only supported for mono audio"
  sites (transforms.rs, processing.rs) are really a single recurring concept — a
  **mono-only / channel-count** precondition. Consider a dedicated
  `LayoutError::ChannelCountUnsupported { operation, required, actual }` so the ~10
  near-identical "only supported for mono" strings collapse into one structured,
  matchable variant with a uniform help ("convert to mono with `.to_mono()` first").

**(b) Is this stringly-typed where it should be structured/enumerated?** Replace free-text
payloads that encode a closed set of cases with real types.
- Conversion errors store `source_type`/`target_type` as `String`. These are a *closed set*
  (the `SampleType` enum already exists in `repr.rs`). Carry `SampleType` (or a small
  copy-able type id) instead of `String` so callers can match on the exact pair and so we
  can't typo a type name.
- The "not yet implemented" filter sites encode a *filter response kind* in prose. Carry the
  actual enum value (filter type / response) in the variant rather than describing it in a
  string.
- Anywhere a `reason: String` actually conveys one of a handful of fixed conditions, prefer
  an enum field over prose.

**(c) Is this error in the right domain?** Re-home misfiled variants.
- "X is only supported for mono audio" is currently raised as **both**
  `ParameterError::invalid_value("self", …)` (transforms.rs:173, 756) **and**
  `LayoutError::invalid_operation(…)` (transforms.rs:278, 334, …) for the *same* class of
  failure. Pick one home (Layout — it's a structural precondition) and apply it uniformly.
- Confirm conversion/parameter/layout/processing boundaries hold after the splits above; a
  failure should be discoverable under exactly one domain.

**Guard-rails for this work:**
- Do **not** invent variants speculatively. Promote a cause to its own variant only when it
  appears at **2+ real call sites** or when callers plausibly need to branch on it. The
  `#[non_exhaustive]` attribute means we can add more later without breaking changes.
- Every new/changed variant still gets its `code`/`help`/`url` per §A2 — taxonomy and
  diagnostics land together, not in separate passes.
- Record each split/merge/re-home decision inline in the PR description so the rationale is
  reviewable.

---

## Part B — Why we benefit

- **Actionable**: every variant ships a `help()` recovery hint — the caller is told *how to
  fix it*, not just what broke.
- **Locatable ("where")**: parse failures point a caret at the offending character; every
  operation error names its operation/parameter and the actual-vs-expected values.
- **Correctly modelled**: splitting over-broad variants and replacing stringly-typed payloads
  (§A7) means callers can `match` on the *real* cause and we stop encoding closed sets as
  free text — a correctness and ergonomics win independent of rendering.
- **Stable codes**: `audio_samples::<domain>::<variant>` lets downstreams match
  programmatically and lets us write a docs page per code.
- **No information loss**: source-error chains via `#[diagnostic_source]` replace the
  lossy `map_err(|_| …)` and `format!("…: {e}")` patterns.
- **Aesthetics**: `fancy` rendering in examples/educational gives the "new standard" look
  the roadmap asks for — immediately, since examples already propagate with `?`.
- **Zero-risk for consumers**: `Diagnostic` is purely additive over `std::error::Error`;
  the `fancy` renderer is feature-gated, so library users who don't want it pay nothing.

---

## Part C — Implementation roadmap (coverage-driven)

> Implementation is a **separate phase**. This is the ordered plan; coverage of all ~316
> `Err`/59 `map_err` sites is the explicit success criterion.

**Phase 0 — Taxonomy audit (design lock-in for §A7).** Before touching `Cargo.toml`, sweep
the inventory and produce the concrete variant list: which `AlgorithmFailure`/`InvalidOperation`
clusters get promoted, which `String` payloads become enums (`SampleType`, filter kinds),
and which variants get re-homed. Output is the final `enum` shapes that Phase 1 implements.
This prevents churning the error module twice.

**Phase 1 — Foundation (`src/error.rs` + `Cargo.toml`)**
- Add `miette` dep + `fancy` feature.
- Derive `Diagnostic` on root + 5 sub-enums; add `code`/`help`/`url`/`severity` to **every**
  variant.
- Apply the Phase-0 taxonomy changes: split over-broad variants, swap stringly-typed payloads
  for structured types, add the `ChannelCountUnsupported` (or equivalent) variant, re-home
  misfiled cases.
- Restructure standalone variants per A5 (`EmptyData{operation}`, `Unsupported{operation,reason}`,
  generalise `Parse` to hold a `#[diagnostic_source]`).
- Add new diagnostics: `NoteParseError`, `EnumParseError`, `Io`.
- Add `From<EmptyError>`; rework `From<ShapeError>` to keep the source.
- **Delete** the `From<&str>`/`From<String>` shortcuts and the `"unknown"`-stamping
  `layout()` helper.
- Update `src/lib.rs` re-exports (add new public diagnostic types).

**Phase 2 — Fix the compile fallout = the coverage sweep.** Deleting the lossy `From` impls
and changing variant shapes makes every affected call site fail to compile. Work the
compiler errors module-by-module, and at each site (i) upgrade the error to name its real
operation/parameter/values, (ii) attach sources, and (iii) apply the §A7 three-question
check — pick the right variant/domain, not just the nearest one. Module order by density
(from inventory): `repr.rs` (≈18 `EmptyData`), `operations/channels.rs`,
`operations/processing.rs`, `operations/iir_filtering.rs`, `operations/hpss.rs`,
`resampling.rs`, `utils/audio_math.rs`, `codecs/perceptual/*`,
`operations/{onset,vad,transforms,editing,…}.rs`, `operations/types.rs`.

**Phase 3 — Spans.** Implement `NoteParseError` in `note_to_midi` and `EnumParseError`
across the `FromStr` impls (types.rs, onset/mod.rs).

**Phase 4 — Presentation.** Enable `fancy` for `examples/` + `educational`; convert the
`unwrap()` in `educational/mod.rs:25` to `?`/proper handling; add a top-level example
showing a rendered diagnostic.

**Phase 5 — Tests & docs.** Extend `src/error.rs` tests to assert `code()` and `help()`
output; update the module doc + the doctests that match on variant shapes (any test asserting
on `EmptyData`/`Unsupported`/the split variants); refresh the `## Error Handling` section in
`lib.rs`.

---

## Verification

- `cargo build --all-features` and `cargo build` (default features) both clean — confirms the
  `fancy` gating works and no consumer is forced into graphical deps.
- `cargo test` and `cargo test --doc` green (doctests that match `EmptyData`/`Unsupported`/the
  split variants will need updates — covered in Phase 5).
- `cargo clippy --all-features` clean.
- **Manual fancy check**: `cargo run --example processing --features fancy` (or whichever
  example deliberately triggers a parameter error) and eyeball the caret-underline + help +
  code rendering.
- **Span check**: a unit test calling `note_to_midi("H4")` / `note_to_midi("C#x")` asserts the
  rendered diagnostic contains the label at the right offset and the help text.
- **Taxonomy check**: a test (or grep) confirms the "only supported for mono" concept now
  raises a single structured variant everywhere, and that conversion errors carry typed
  `SampleType` rather than `String`.
- **Coverage check**: `rg "map_err\(\|_\|" src/` returns nothing (all lossy discards removed);
  `rg '"unknown"' src/` returns nothing in non-test code.