From 694ab1cbfcb6c610f9c7afdd0d94163240bf3b6a Mon Sep 17 00:00:00 2001 From: uqio <276879906+uqio@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:49:52 +1200 Subject: [PATCH 1/3] feat(serde): add serde support for `ChunkingOptions` --- CHANGELOG.md | 6 ++ README.md | 2 +- soundevents/Cargo.toml | 2 +- soundevents/src/lib.rs | 138 ++++++++++++++++++++++++++++++----------- 4 files changed, 109 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d0066..c169439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this workspace will be documented in this file. ## Unreleased +## 0.3.0 - 2026-04-21 + +### `soundevents` + +- Add serde support for `Options` and `ChunkingOptions` + ## 0.2.0 - 2026-04-08 ### `soundevents` diff --git a/README.md b/README.md index dd7777d..40c38c6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Production-oriented Rust inference for [CED](https://arxiv.org/abs/2308.11957) A ```toml [dependencies] -soundevents = "0.2" +soundevents = "0.3" ``` ```rust,no_run diff --git a/soundevents/Cargo.toml b/soundevents/Cargo.toml index 92dcbb3..93ac724 100644 --- a/soundevents/Cargo.toml +++ b/soundevents/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "soundevents" -version = "0.2.1" +version = "0.3.0" edition = "2024" description = "Production-oriented Rust inference wrapper for CED AudioSet classifiers." license.workspace = true diff --git a/soundevents/src/lib.rs b/soundevents/src/lib.rs index 4a4df7c..3d3f8e7 100644 --- a/soundevents/src/lib.rs +++ b/soundevents/src/lib.rs @@ -115,19 +115,20 @@ pub struct Options { } impl Default for Options { + #[cfg_attr(not(tarpaulin), inline(always))] fn default() -> Self { - Self { - model_path: None, - optimization_level: GraphOptimizationLevel::Disable, - } + Self::new() } } impl Options { - /// Creates options pointing to the given ONNX model file. + /// Returns a new `Options` with default settings: no model path and disabled graph optimization. #[cfg_attr(not(tarpaulin), inline(always))] - pub fn new(model_path: impl Into) -> Self { - Self::default().with_model_path(model_path) + pub const fn new() -> Self { + Self { + model_path: None, + optimization_level: GraphOptimizationLevel::Disable, + } } /// Returns the model path, if one has been configured. @@ -179,35 +180,65 @@ impl Options { } /// Controls how chunked inference aggregates chunk confidences. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum ChunkAggregation { /// Average confidence across all chunks. + #[default] Mean, /// Keep the peak confidence seen in any chunk. Max, } +#[cfg_attr(not(tarpaulin), inline(always))] +const fn default_window_samples() -> usize { + DEFAULT_CHUNK_SAMPLES +} + +#[cfg_attr(not(tarpaulin), inline(always))] +const fn default_hop_samples() -> usize { + DEFAULT_CHUNK_SAMPLES +} + +#[cfg_attr(not(tarpaulin), inline(always))] +const fn default_batch_size() -> usize { + 1 +} + /// Options for chunked inference over long clips. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct ChunkingOptions { + #[cfg_attr(feature = "serde", serde(default = "default_window_samples"))] window_samples: usize, + #[cfg_attr(feature = "serde", serde(default = "default_hop_samples"))] hop_samples: usize, + #[cfg_attr(feature = "serde", serde(default = "default_batch_size"))] batch_size: usize, + #[cfg_attr(feature = "serde", serde(default))] aggregation: ChunkAggregation, } impl Default for ChunkingOptions { + #[cfg_attr(not(tarpaulin), inline(always))] fn default() -> Self { + Self::new() + } +} + +impl ChunkingOptions { + /// Returns a new `ChunkingOptions` with default settings: 10-second windows, 10-second hops, batch size of 1, and mean aggregation. + #[cfg_attr(not(tarpaulin), inline(always))] + pub const fn new() -> Self { Self { - window_samples: DEFAULT_CHUNK_SAMPLES, - hop_samples: DEFAULT_CHUNK_SAMPLES, - batch_size: 1, + window_samples: default_window_samples(), + hop_samples: default_hop_samples(), + batch_size: default_batch_size(), aggregation: ChunkAggregation::Mean, } } -} -impl ChunkingOptions { /// Returns the chunk window size in samples. #[cfg_attr(not(tarpaulin), inline(always))] pub const fn window_samples(&self) -> usize { @@ -235,6 +266,13 @@ impl ChunkingOptions { /// Sets the chunk window size in samples. #[cfg_attr(not(tarpaulin), inline(always))] pub const fn with_window_samples(mut self, window_samples: usize) -> Self { + self.set_window_samples(window_samples); + self + } + + /// Sets the chunk window size in samples. + #[cfg_attr(not(tarpaulin), inline(always))] + pub const fn set_window_samples(&mut self, window_samples: usize) -> &mut Self { self.window_samples = window_samples; self } @@ -242,6 +280,13 @@ impl ChunkingOptions { /// Sets the chunk hop size in samples. #[cfg_attr(not(tarpaulin), inline(always))] pub const fn with_hop_samples(mut self, hop_samples: usize) -> Self { + self.set_hop_samples(hop_samples); + self + } + + /// Sets the chunk hop size in samples. + #[cfg_attr(not(tarpaulin), inline(always))] + pub const fn set_hop_samples(&mut self, hop_samples: usize) -> &mut Self { self.hop_samples = hop_samples; self } @@ -249,6 +294,13 @@ impl ChunkingOptions { /// Sets the chunk batch size used by batched chunked inference. #[cfg_attr(not(tarpaulin), inline(always))] pub const fn with_batch_size(mut self, batch_size: usize) -> Self { + self.set_batch_size(batch_size); + self + } + + /// Sets the chunk batch size used by batched chunked inference. + #[cfg_attr(not(tarpaulin), inline(always))] + pub const fn set_batch_size(&mut self, batch_size: usize) -> &mut Self { self.batch_size = batch_size; self } @@ -256,6 +308,13 @@ impl ChunkingOptions { /// Sets the aggregation strategy. #[cfg_attr(not(tarpaulin), inline(always))] pub const fn with_aggregation(mut self, aggregation: ChunkAggregation) -> Self { + self.set_aggregation(aggregation); + self + } + + /// Sets the aggregation strategy. + #[cfg_attr(not(tarpaulin), inline(always))] + pub const fn set_aggregation(&mut self, aggregation: ChunkAggregation) -> &mut Self { self.aggregation = aggregation; self } @@ -672,7 +731,7 @@ impl Classifier { pub fn classify_all_chunked( &mut self, samples_16k: &[f32], - options: ChunkingOptions, + options: &ChunkingOptions, ) -> Result, ClassifierError> { self.with_aggregated_confidences(samples_16k, options, |confidences| { confidences @@ -689,7 +748,7 @@ impl Classifier { &mut self, samples_16k: &[f32], top_k: usize, - options: ChunkingOptions, + options: &ChunkingOptions, ) -> Result, ClassifierError> { ensure_non_empty(samples_16k)?; @@ -746,7 +805,7 @@ impl Classifier { fn with_aggregated_confidences( &mut self, samples_16k: &[f32], - options: ChunkingOptions, + options: &ChunkingOptions, f: impl FnOnce(&[f32]) -> Result, ) -> Result { let mut confidences = std::mem::take(&mut self.confidence_scratch); @@ -762,7 +821,7 @@ fn fill_aggregated_confidences( classifier: &mut Classifier, aggregated: &mut Vec, samples_16k: &[f32], - options: ChunkingOptions, + options: &ChunkingOptions, ) -> Result<(), ClassifierError> { ensure_non_empty(samples_16k)?; validate_chunking(options)?; @@ -877,7 +936,7 @@ fn ensure_non_empty(samples_16k: &[f32]) -> Result<(), ClassifierError> { Ok(()) } -fn validate_chunking(options: ChunkingOptions) -> Result<(), ClassifierError> { +fn validate_chunking(options: &ChunkingOptions) -> Result<(), ClassifierError> { if options.window_samples() == 0 || options.hop_samples() == 0 || options.batch_size() == 0 { return Err(ClassifierError::InvalidChunkingOptions { window_samples: options.window_samples(), @@ -967,7 +1026,10 @@ fn validate_batch_inputs(batch_16k: &[&[f32]]) -> Result /// Groups consecutive equal-length chunks into batches so one tensor never /// mixes the usual short tail chunk with full-size windows. -fn chunk_batches(samples: &[f32], options: ChunkingOptions) -> impl Iterator> { +fn chunk_batches<'a>( + samples: &'a [f32], + options: &ChunkingOptions, +) -> impl Iterator> { let mut chunks = chunk_slices(samples, options.window_samples(), options.hop_samples()).peekable(); @@ -1168,16 +1230,16 @@ mod tests { let single_opts = ChunkingOptions::default() .with_hop_samples(DEFAULT_CHUNK_SAMPLES / 2) .with_batch_size(1); - let batched_opts = single_opts.with_batch_size(4); + let batched_opts = single_opts.clone().with_batch_size(4); let mut single = Classifier::tiny(Options::default()).expect("load bundled classifier"); let single_predictions = single - .classify_all_chunked(&clip, single_opts) + .classify_all_chunked(&clip, &single_opts) .expect("chunked single-batch inference"); let mut batched = Classifier::tiny(Options::default()).expect("load bundled classifier"); let batched_predictions = batched - .classify_all_chunked(&clip, batched_opts) + .classify_all_chunked(&clip, &batched_opts) .expect("chunked batched inference"); assert_eq!(single_predictions.len(), batched_predictions.len()); @@ -1219,7 +1281,7 @@ mod tests { GraphOptimizationLevel::Disable )); - let with_path = Options::new("some/path.onnx"); + let with_path = Options::new().with_model_path("some/path.onnx"); assert_eq!( with_path .model_path() @@ -1332,7 +1394,8 @@ mod tests { #[test] fn classifier_new_with_custom_optimization_surfaces_ort_error() { match Classifier::new( - Options::new("definitely/does/not/exist.onnx") + Options::new() + .with_model_path("definitely/does/not/exist.onnx") .with_optimization_level(GraphOptimizationLevel::Level3), ) { Err(ClassifierError::Ort(_)) => {} @@ -1344,7 +1407,7 @@ mod tests { fn validate_chunking_rejects_zero_window_hop_or_batch() { let zero_window = ChunkingOptions::default().with_window_samples(0); assert!(matches!( - validate_chunking(zero_window), + validate_chunking(&zero_window), Err(ClassifierError::InvalidChunkingOptions { window_samples: 0, .. @@ -1353,13 +1416,13 @@ mod tests { let zero_hop = ChunkingOptions::default().with_hop_samples(0); assert!(matches!( - validate_chunking(zero_hop), + validate_chunking(&zero_hop), Err(ClassifierError::InvalidChunkingOptions { hop_samples: 0, .. }) )); let zero_batch = ChunkingOptions::default().with_batch_size(0); assert!(matches!( - validate_chunking(zero_batch), + validate_chunking(&zero_batch), Err(ClassifierError::InvalidChunkingOptions { batch_size: 0, .. }) )); } @@ -1497,7 +1560,7 @@ mod tests { let clip = pseudo_audio(DEFAULT_CHUNK_SAMPLES + 8_000, 0x9999_aaaa); let mut classifier = tiny_classifier(); let result = classifier - .classify_chunked(&clip, 0, ChunkingOptions::default()) + .classify_chunked(&clip, 0, &ChunkingOptions::default()) .expect("classify_chunked with k=0"); assert!(result.is_empty()); } @@ -1507,7 +1570,7 @@ mod tests { fn classify_chunked_rejects_empty_input() { let mut classifier = tiny_classifier(); assert!(matches!( - classifier.classify_chunked(&[], 3, ChunkingOptions::default()), + classifier.classify_chunked(&[], 3, &ChunkingOptions::default()), Err(ClassifierError::EmptyInput) )); } @@ -1520,10 +1583,10 @@ mod tests { let mut classifier = tiny_classifier(); let all = classifier - .classify_all_chunked(&clip, opts) + .classify_all_chunked(&clip, &opts) .expect("classify_all_chunked"); let top = classifier - .classify_chunked(&clip, 4, opts) + .classify_chunked(&clip, 4, &opts) .expect("classify_chunked top 4"); assert_eq!(top.len(), 4); @@ -1544,14 +1607,14 @@ mod tests { fn chunked_max_aggregation_matches_per_class_max_of_chunks() { let clip = pseudo_audio(DEFAULT_CHUNK_SAMPLES * 3, 0xdead_beef); let mean_opts = ChunkingOptions::default(); - let max_opts = mean_opts.with_aggregation(ChunkAggregation::Max); + let max_opts = mean_opts.clone().with_aggregation(ChunkAggregation::Max); let mut classifier = tiny_classifier(); let mean = classifier - .classify_all_chunked(&clip, mean_opts) + .classify_all_chunked(&clip, &mean_opts) .expect("mean chunked"); let max = classifier - .classify_all_chunked(&clip, max_opts) + .classify_all_chunked(&clip, &max_opts) .expect("max chunked"); assert_eq!(mean.len(), NUM_CLASSES); @@ -1570,7 +1633,7 @@ mod tests { let mut classifier = tiny_classifier(); let bad_opts = ChunkingOptions::default().with_window_samples(0); assert!(matches!( - classifier.classify_chunked(&clip, 3, bad_opts), + classifier.classify_chunked(&clip, 3, &bad_opts), Err(ClassifierError::InvalidChunkingOptions { .. }) )); } @@ -1579,7 +1642,8 @@ mod tests { #[test] fn classifier_new_with_path_loads_model_from_disk() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/models/tiny.onnx"); - let mut classifier = Classifier::new(Options::new(path)).expect("load via new()"); + let mut classifier = + Classifier::new(Options::new().with_model_path(path)).expect("load via new()"); let clip = pseudo_audio(SAMPLE_RATE_HZ, 0x0abc_def0); let scores = classifier .predict_raw_scores(&clip) From fec1cfbcf4aaf03e298f8c3b0ae97838e7734d1e Mon Sep 17 00:00:00 2001 From: Al Liu Date: Tue, 21 Apr 2026 19:20:36 +0800 Subject: [PATCH 2/3] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c169439..8bbcfd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,11 @@ All notable changes to this workspace will be documented in this file. ### `soundevents` -- Add serde support for `Options` and `ChunkingOptions` +- Add serde support for `Options` and `ChunkingOptions`. +- Breaking changes: + - `Options::new` no longer takes a model path; construct options separately from model loading. + - Chunked classification APIs now take `&ChunkingOptions` instead of `ChunkingOptions`. + - `ChunkingOptions` is no longer `Copy`; pass it by reference or clone it explicitly where needed. ## 0.2.0 - 2026-04-08 From d86d76516ad79fd27d3797f593143bf49cbcfa90 Mon Sep 17 00:00:00 2001 From: uqio <276879906+uqio@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:23:57 +1200 Subject: [PATCH 3/3] feat(serde): add serde support for `ChunkingOptions` --- README.md | 75 +------------------------------------------------------ 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/README.md b/README.md index 40c38c6..a80fbf4 100644 --- a/README.md +++ b/README.md @@ -34,65 +34,6 @@ Production-oriented Rust inference for [CED](https://arxiv.org/abs/2308.11957) A soundevents = "0.3" ``` -```rust,no_run -use soundevents::{Classifier, Options}; - -fn load_mono_16k_audio(_: &str) -> Result, Box> { - Ok(vec![0.0; 16_000]) -} - -fn main() -> Result<(), Box> { - let mut classifier = Classifier::from_file("soundevents/models/tiny.onnx")?; - - // Bring your own decoder/resampler — soundevents expects mono f32 - // samples at 16 kHz, in [-1.0, 1.0]. - let samples: Vec = load_mono_16k_audio("clip.wav")?; - - // Top-5 predictions for a clip up to ~10 s long. - for prediction in classifier.classify(&samples, 5)? { - println!( - "{:>5.1}% {:>3} {} ({})", - prediction.confidence() * 100.0, - prediction.index(), - prediction.name(), - prediction.id(), - ); - } - Ok(()) -} -``` - -### Long clips: chunked inference - -`Classifier::classify_chunked` slides a window over the input and aggregates each chunk's per-class confidences. The defaults (10 s window, 10 s hop, mean aggregation) match CED's training setup; tune them for overlap or peak-pooling. - -```rust,no_run -use soundevents::{ChunkAggregation, ChunkingOptions, Classifier}; - -fn load_long_clip() -> Result, Box> { - Ok(vec![0.0; 320_000]) -} - -fn main() -> Result<(), Box> { - let mut classifier = Classifier::from_file("soundevents/models/tiny.onnx")?; - let samples: Vec = load_long_clip()?; - - let opts = ChunkingOptions::default() - // 5 s overlap (50%) between adjacent windows - .with_hop_samples(80_000) - // Batch up to 4 equal-length windows per session.run() - .with_batch_size(4) - // Keep the loudest detection in any window instead of averaging - .with_aggregation(ChunkAggregation::Max); - - let top3 = classifier.classify_chunked(&samples, 3, opts)?; - for prediction in top3 { - println!("{}: {:.2}", prediction.name(), prediction.confidence()); - } - Ok(()) -} -``` - ## Models The four CED variants are sourced from the [`mispeech`](https://huggingface.co/mispeech) Hugging Face organisation, exported to ONNX, and **checked into this repo** under [`soundevents/models/`](./soundevents/models). You should not normally need to download anything — `git clone` gives you a working classifier out of the box. @@ -130,21 +71,7 @@ sources and attribution details. Enable the `bundled-tiny` feature to embed `models/tiny.onnx` into your binary — useful for CLI tools and self-contained services where you don't want to ship a separate model file. ```toml -soundevents = { version = "0.2", features = ["bundled-tiny"] } -``` - -```rust -# #[cfg(feature = "bundled-tiny")] -use soundevents::{Classifier, Options}; - -# fn main() -> Result<(), Box> { -# #[cfg(feature = "bundled-tiny")] -# { -let mut classifier = Classifier::tiny(Options::default())?; -# let _ = &mut classifier; -# } -# Ok(()) -# } +soundevents = { version = "0.3", features = ["bundled-tiny"] } ``` ## Features