use miette::{Diagnostic, SourceSpan};
use thiserror::Error;
use crate::repr::SampleType;
pub type AudioSampleResult<T> = Result<T, AudioSampleError>;
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum AudioSampleError {
#[error(transparent)]
#[diagnostic(transparent)]
Conversion(#[from] ConversionError),
#[error(transparent)]
#[diagnostic(transparent)]
Parameter(#[from] ParameterError),
#[error(transparent)]
#[diagnostic(transparent)]
Layout(#[from] LayoutError),
#[error(transparent)]
#[diagnostic(transparent)]
Processing(#[from] ProcessingError),
#[error(transparent)]
#[diagnostic(transparent)]
Feature(#[from] FeatureError),
#[error("Failed to parse {type_name}. Context: {context}")]
#[diagnostic(
code(audio_samples::parse::failed),
help("check the input format described in the context message")
)]
Parse {
type_name: String,
context: String,
},
#[error(transparent)]
#[diagnostic(transparent)]
NoteParse(#[from] NoteParseError),
#[error(transparent)]
#[diagnostic(transparent)]
EnumParse(#[from] EnumParseError),
#[cfg(feature = "transforms")]
#[error(transparent)]
#[diagnostic(code(audio_samples::transforms::spectrogram))]
Spectrogram(#[from] spectrograms::SpectrogramError),
#[error("Empty audio data in {operation}, where non-empty data is required")]
#[diagnostic(
code(audio_samples::empty_data),
help("ensure `{operation}` is given at least one sample")
)]
EmptyData {
operation: String,
},
#[error(
"Invalid number of samples ({total_samples}) for {channels} channels: not evenly divisible"
)]
#[diagnostic(
code(audio_samples::invalid_number_of_samples),
help("provide a sample count that is an exact multiple of the channel count")
)]
InvalidNumberOfSamples {
total_samples: usize,
channels: u32,
},
#[error("Fmt error occurred: {0}")]
#[diagnostic(code(audio_samples::fmt))]
Fmt(#[from] std::fmt::Error),
#[error("I/O error during {operation}: {message}")]
#[diagnostic(
code(audio_samples::io),
help("check file paths, permissions, and that any external viewer is available")
)]
Io {
operation: String,
kind: std::io::ErrorKind,
message: String,
},
#[error("Unsupported operation '{operation}': {reason}")]
#[diagnostic(
code(audio_samples::unsupported),
help(
"this configuration is not implemented; see the operation's docs for supported inputs"
)
)]
Unsupported {
operation: String,
reason: String,
},
}
impl AudioSampleError {
#[inline]
pub fn unsupported<O, R>(operation: O, reason: R) -> Self
where
O: ToString,
R: ToString,
{
Self::Unsupported {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn empty_data<O>(operation: O) -> Self
where
O: ToString,
{
Self::EmptyData {
operation: operation.to_string(),
}
}
#[inline]
pub fn io<O>(operation: O, err: &std::io::Error) -> Self
where
O: ToString,
{
Self::Io {
operation: operation.to_string(),
kind: err.kind(),
message: err.to_string(),
}
}
#[inline]
#[must_use]
pub const fn invalid_number_of_samples(total_samples: usize, channels: u32) -> Self {
Self::InvalidNumberOfSamples {
total_samples,
channels,
}
}
#[inline]
pub fn parse<T, S>(msg: S) -> Self
where
S: ToString,
{
Self::Parse {
type_name: std::any::type_name::<T>().to_string(),
context: msg.to_string(),
}
}
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[error("invalid note name: {kind}")]
#[diagnostic(
code(audio_samples::parse::note_name),
help("expected scientific pitch notation like `A4`, `C#3`, `Bb2`")
)]
pub struct NoteParseError {
#[source_code]
pub input: String,
#[label("{kind}")]
pub span: SourceSpan,
pub kind: String,
}
impl NoteParseError {
#[inline]
pub fn new<I, K>(input: I, span: impl Into<SourceSpan>, kind: K) -> Self
where
I: ToString,
K: ToString,
{
Self {
input: input.to_string(),
span: span.into(),
kind: kind.to_string(),
}
}
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[error("unrecognised value for {type_name}")]
#[diagnostic(code(audio_samples::parse::enum_value))]
pub struct EnumParseError {
#[source_code]
pub input: String,
#[label("not one of the accepted values")]
pub span: SourceSpan,
pub type_name: String,
#[help]
pub help: String,
}
impl EnumParseError {
#[inline]
pub fn new<N, I>(type_name: N, input: I, expected: &[&str]) -> Self
where
N: ToString,
I: AsRef<str>,
{
let input = input.as_ref().to_string();
let len = input.len();
Self {
span: (0, len).into(),
help: format!("expected one of: {}", expected.join(", ")),
type_name: type_name.to_string(),
input,
}
}
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum ConversionError {
#[error("Failed to convert sample value {value} from {from} to {to}: {reason}")]
#[diagnostic(
code(audio_samples::conversion::audio_conversion),
url(docsrs),
help(
"use `to_format`/`to_type` for audio-aware conversion; the value may be out of the target range"
)
)]
AudioConversion {
value: String,
from: SampleType,
to: SampleType,
reason: String,
},
#[error("Failed to cast value {value} from {from} to {to}: {reason}")]
#[diagnostic(
code(audio_samples::conversion::numeric_cast),
url(docsrs),
help("raw casts do not normalise; use `to_format`/`to_type` for audio-aware conversion")
)]
NumericCast {
value: String,
from: SampleType,
to: SampleType,
reason: String,
},
#[error("Conversion from {from} to {to} is not supported")]
#[diagnostic(
code(audio_samples::conversion::unsupported),
url(docsrs),
help(
"use `to_format`/`to_type` for audio-aware conversion, or `cast_as`/`cast_to` for raw bit-casts"
)
)]
UnsupportedConversion {
from: SampleType,
to: SampleType,
},
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum ParameterError {
#[error(
"Parameter '{parameter}' value {value} is outside valid range [{min}, {max}]: {reason}"
)]
#[diagnostic(
code(audio_samples::parameter::out_of_range),
url(docsrs),
help("pass a value for `{parameter}` in [{min}, {max}]; got {value}")
)]
OutOfRange {
parameter: String,
value: String,
min: String,
max: String,
reason: String,
},
#[error("Invalid value for parameter '{parameter}': {reason}")]
#[diagnostic(
code(audio_samples::parameter::invalid_value),
url(docsrs),
help("check the documented constraints for `{parameter}`")
)]
InvalidValue {
parameter: String,
reason: String,
},
#[error("Required parameter '{parameter}' is missing or empty")]
#[diagnostic(
code(audio_samples::parameter::missing),
url(docsrs),
help("supply a value for `{parameter}`")
)]
Missing {
parameter: String,
},
#[error("Invalid configuration for {operation}: {reason}")]
#[diagnostic(
code(audio_samples::parameter::invalid_configuration),
url(docsrs),
help("review the configuration fields for `{operation}`; some settings conflict")
)]
InvalidConfiguration {
operation: String,
reason: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ChannelRequirement {
Mono,
Stereo,
Exactly(u32),
AtLeast(u32),
}
impl std::fmt::Display for ChannelRequirement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mono => write!(f, "mono (1 channel)"),
Self::Stereo => write!(f, "stereo (2 channels)"),
Self::Exactly(n) => write!(f, "exactly {n} channels"),
Self::AtLeast(n) => write!(f, "at least {n} channels"),
}
}
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum LayoutError {
#[error(
"Array layout error: {operation} requires contiguous memory layout, but array is {layout_type}"
)]
#[diagnostic(
code(audio_samples::layout::non_contiguous),
url(docsrs),
help("clone or copy the data into a contiguous buffer before `{operation}`")
)]
NonContiguous {
operation: String,
layout_type: String,
},
#[error(
"Dimension mismatch: expected {expected_dims}, got {actual_dims} for operation '{operation}'"
)]
#[diagnostic(
code(audio_samples::layout::dimension_mismatch),
url(docsrs),
help("reshape the input to {expected_dims} before `{operation}`")
)]
DimensionMismatch {
expected_dims: String,
actual_dims: String,
operation: String,
},
#[error("Cannot modify borrowed audio data in {operation}: {reason}")]
#[diagnostic(
code(audio_samples::layout::borrowed_data_mutation),
url(docsrs),
help("clone the audio into an owned value before `{operation}`")
)]
BorrowedDataMutation {
operation: String,
reason: String,
},
#[error("Incompatible data format for {operation}: {reason}")]
#[diagnostic(
code(audio_samples::layout::incompatible_format),
url(docsrs),
help("convert the data to the format `{operation}` expects")
)]
IncompatibleFormat {
operation: String,
reason: String,
},
#[error("{operation} requires {required}, but the input has {actual} channel(s)")]
#[diagnostic(code(audio_samples::layout::channel_count_unsupported), url(docsrs))]
ChannelCountUnsupported {
operation: String,
required: ChannelRequirement,
actual: u32,
#[help]
help: String,
},
#[error("Shape error in {operation}")]
#[diagnostic(
code(audio_samples::layout::shape_error),
url(docsrs),
help("check that the array dimensions are compatible with `{operation}`")
)]
ShapeError {
operation: String,
#[source]
source: ndarray::ShapeError,
},
#[error("Invalid operation on {0}:\nReason: {1}")]
#[diagnostic(
code(audio_samples::layout::invalid_operation),
url(docsrs),
help("see the operation's documentation for its preconditions")
)]
InvalidOperation(String, String),
}
impl From<ndarray::ShapeError> for AudioSampleError {
#[inline]
fn from(err: ndarray::ShapeError) -> Self {
Self::Layout(LayoutError::ShapeError {
operation: "ndarray operation".to_string(),
source: err,
})
}
}
impl LayoutError {
#[inline]
pub fn shape_error<O>(operation: O, source: ndarray::ShapeError) -> Self
where
O: ToString,
{
Self::ShapeError {
operation: operation.to_string(),
source,
}
}
#[inline]
pub fn channel_count_unsupported<O>(
operation: O,
required: ChannelRequirement,
actual: u32,
) -> Self
where
O: ToString,
{
let help = match required {
ChannelRequirement::Mono => "convert to mono with `.to_mono()` first".to_string(),
ChannelRequirement::Stereo => "convert to stereo with `.to_stereo()` first".to_string(),
other => format!("supply audio with {other}"),
};
Self::ChannelCountUnsupported {
operation: operation.to_string(),
required,
actual,
help,
}
}
#[inline]
pub fn invalid_operation<S>(operation: S, reason: S) -> Self
where
S: ToString,
{
Self::InvalidOperation(operation.to_string(), reason.to_string())
}
#[inline]
pub fn borrowed_mutation<O, R>(operation: O, reason: R) -> Self
where
O: ToString,
R: ToString,
{
Self::BorrowedDataMutation {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn dimension_mismatch<E, A, O>(expected: E, actual: A, operation: O) -> Self
where
E: ToString,
A: ToString,
O: ToString,
{
Self::DimensionMismatch {
expected_dims: expected.to_string(),
actual_dims: actual.to_string(),
operation: operation.to_string(),
}
}
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum ProcessingError {
#[error("Mathematical operation '{operation}' failed: {reason}")]
#[diagnostic(
code(audio_samples::processing::mathematical_failure),
url(docsrs),
help("check the input values for `{operation}` (NaN, infinity, or division by zero)")
)]
MathematicalFailure {
operation: String,
reason: String,
},
#[error("Audio processing algorithm '{algorithm}' failed: {reason}")]
#[diagnostic(
code(audio_samples::processing::algorithm_failure),
url(docsrs),
help("see the documentation for `{algorithm}` for its preconditions")
)]
AlgorithmFailure {
algorithm: String,
reason: String,
},
#[error("Insufficient data for {operation}: {reason}")]
#[diagnostic(
code(audio_samples::processing::insufficient_data),
url(docsrs),
help("provide more samples to `{operation}`")
)]
InsufficientData {
operation: String,
reason: String,
},
#[error("Algorithm '{algorithm}' failed to converge after {iterations} iterations")]
#[diagnostic(
code(audio_samples::processing::convergence_failure),
url(docsrs),
help("raise the iteration limit or relax the convergence tolerance for `{algorithm}`")
)]
ConvergenceFailure {
algorithm: String,
iterations: u32,
},
#[error("External dependency '{dependency}' required for {operation} is unavailable: {reason}")]
#[diagnostic(
code(audio_samples::processing::external_dependency),
url(docsrs),
help("ensure the `{dependency}` backend is configured correctly for `{operation}`")
)]
ExternalDependency {
dependency: String,
operation: String,
reason: String,
},
}
#[derive(Error, Debug, Clone, Diagnostic)]
#[non_exhaustive]
pub enum FeatureError {
#[error("Feature '{feature}' is required for {operation} but not enabled")]
#[diagnostic(
code(audio_samples::feature::not_enabled),
url(docsrs),
help("rebuild with `--features {feature}`")
)]
NotEnabled {
feature: String,
operation: String,
},
#[error(
"Operation '{operation}' requires features [{required_features}], but only [{enabled_features}] are enabled"
)]
#[diagnostic(
code(audio_samples::feature::multiple_required),
url(docsrs),
help("rebuild with `--features {required_features}`")
)]
MultipleRequired {
operation: String,
required_features: String,
enabled_features: String,
},
#[error("Feature '{feature}' is enabled but misconfigured: {reason}")]
#[diagnostic(
code(audio_samples::feature::misconfigured),
url(docsrs),
help("review the build configuration for the `{feature}` feature")
)]
Misconfigured {
feature: String,
reason: String,
},
}
impl ConversionError {
#[inline]
pub fn audio_conversion<V, R>(value: V, from: SampleType, to: SampleType, reason: R) -> Self
where
V: ToString,
R: ToString,
{
Self::AudioConversion {
value: value.to_string(),
from,
to,
reason: reason.to_string(),
}
}
#[inline]
pub fn numeric_cast<V, R>(value: V, from: SampleType, to: SampleType, reason: R) -> Self
where
V: ToString,
R: ToString,
{
Self::NumericCast {
value: value.to_string(),
from,
to,
reason: reason.to_string(),
}
}
#[inline]
#[must_use]
pub const fn unsupported_conversion(from: SampleType, to: SampleType) -> Self {
Self::UnsupportedConversion { from, to }
}
}
impl ParameterError {
#[inline]
pub fn out_of_range<P, V, Min, Max, R>(
parameter: P,
value: V,
min: Min,
max: Max,
reason: R,
) -> Self
where
P: ToString,
V: ToString,
Min: ToString,
Max: ToString,
R: ToString,
{
Self::OutOfRange {
parameter: parameter.to_string(),
value: value.to_string(),
min: min.to_string(),
max: max.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn invalid_value<P, R>(parameter: P, reason: R) -> Self
where
P: ToString,
R: ToString,
{
Self::InvalidValue {
parameter: parameter.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn missing<P>(parameter: P) -> Self
where
P: ToString,
{
Self::Missing {
parameter: parameter.to_string(),
}
}
#[inline]
pub fn invalid_configuration<O, R>(operation: O, reason: R) -> Self
where
O: ToString,
R: ToString,
{
Self::InvalidConfiguration {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
}
impl ProcessingError {
#[inline]
pub fn algorithm_failure<A, R>(algorithm: A, reason: R) -> Self
where
A: ToString,
R: ToString,
{
Self::AlgorithmFailure {
algorithm: algorithm.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn mathematical_failure<O, R>(operation: O, reason: R) -> Self
where
O: ToString,
R: ToString,
{
Self::MathematicalFailure {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn external_dependency<D, O, R>(dependency: D, operation: O, reason: R) -> Self
where
D: ToString,
O: ToString,
R: ToString,
{
Self::ExternalDependency {
dependency: dependency.to_string(),
operation: operation.to_string(),
reason: reason.to_string(),
}
}
#[inline]
pub fn insufficient_data<O, R>(operation: O, reason: R) -> Self
where
O: ToString,
R: ToString,
{
Self::InsufficientData {
operation: operation.to_string(),
reason: reason.to_string(),
}
}
}
impl FeatureError {
#[inline]
pub fn not_enabled<F, O>(feature: F, operation: O) -> Self
where
F: ToString,
O: ToString,
{
Self::NotEnabled {
feature: feature.to_string(),
operation: operation.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use miette::Diagnostic;
#[test]
fn test_error_hierarchy() {
let conversion_err = ConversionError::audio_conversion(
"32768",
SampleType::I16,
SampleType::U8,
"Out of range",
);
let audio_err = AudioSampleError::Conversion(conversion_err);
assert!(matches!(audio_err, AudioSampleError::Conversion(_)));
assert!(format!("{}", audio_err).contains("Failed to convert sample value 32768"));
}
#[test]
fn test_parameter_error() {
let param_err = ParameterError::out_of_range(
"cutoff_hz",
"25000",
"20",
"22050",
"Exceeds Nyquist limit",
);
assert!(format!("{}", param_err).contains("cutoff_hz"));
assert!(format!("{}", param_err).contains("25000"));
assert!(format!("{}", param_err).contains("22050"));
}
#[test]
fn test_layout_error() {
let layout_err = LayoutError::borrowed_mutation("slice_mut", "Data is borrowed immutably");
assert!(format!("{}", layout_err).contains("slice_mut"));
assert!(format!("{}", layout_err).contains("borrowed"));
}
#[test]
fn test_processing_error() {
let proc_err =
ProcessingError::algorithm_failure("transforms", "Input size must be power of 2");
assert!(format!("{}", proc_err).contains("transforms"));
assert!(format!("{}", proc_err).contains("power of 2"));
}
#[test]
fn test_feature_error() {
let feat_err = FeatureError::not_enabled("transforms", "spectral analysis");
assert!(format!("{}", feat_err).contains("transforms"));
assert!(format!("{}", feat_err).contains("spectral analysis"));
}
#[test]
fn test_result_type_alias() {
let ok_result: AudioSampleResult<i32> = Ok(42);
assert_eq!(ok_result.unwrap(), 42);
let err_result: AudioSampleResult<i32> = Err(AudioSampleError::Parameter(
ParameterError::invalid_value("test_param", "Invalid for testing"),
));
assert!(err_result.is_err());
}
#[test]
fn test_error_chain_display() {
let conversion_err = ConversionError::audio_conversion(
"1.5",
SampleType::F32,
SampleType::I16,
"Value out of signed 16-bit range",
);
let audio_err = AudioSampleError::Conversion(conversion_err);
let error_string = format!("{}", audio_err);
assert!(error_string.contains("Failed to convert sample value 1.5"));
assert!(error_string.contains("f32"));
assert!(error_string.contains("i16"));
}
#[test]
fn test_diagnostic_codes() {
let err = AudioSampleError::Parameter(ParameterError::out_of_range(
"cutoff_hz",
"25000",
"20",
"22050",
"exceeds Nyquist",
));
assert_eq!(
err.code().unwrap().to_string(),
"audio_samples::parameter::out_of_range"
);
let layout = AudioSampleError::Layout(LayoutError::channel_count_unsupported(
"stft",
ChannelRequirement::Mono,
2,
));
assert_eq!(
layout.code().unwrap().to_string(),
"audio_samples::layout::channel_count_unsupported"
);
}
#[test]
fn test_diagnostic_help() {
let err =
ParameterError::out_of_range("cutoff_hz", "25000", "20", "22050", "exceeds Nyquist");
let help = err.help().unwrap().to_string();
assert!(help.contains("cutoff_hz"));
assert!(help.contains("22050"));
let layout = LayoutError::channel_count_unsupported("stft", ChannelRequirement::Mono, 2);
let help = layout.help().unwrap().to_string();
assert!(help.contains("to_mono"));
}
#[test]
fn test_enum_parse_error_span() {
let err = EnumParseError::new("PadSide", "lefty", &["left", "right"]);
assert_eq!(err.span.offset(), 0);
assert_eq!(err.span.len(), "lefty".len());
let help = err.help().unwrap().to_string();
assert!(help.contains("left"));
assert!(help.contains("right"));
}
}