Skip to main content

max / audiofiles

rust quality: typed errors, audio extensions, lazy-index unwrap - error.rs: PreviewError gains InvalidHash + FileNotFound variants. - state/playback.rs: resolve_sample_path and resolve_and_decode now return PreviewError instead of String. Call sites use .to_string() on the typed error to populate self.status. - app/midi.rs: new MidiError enum with #[from] InitError / ConnectError. midi::connect signature switched from Result<_, String>. - app/license.rs: new DeactivationError for deactivate_key (best-effort fire-and-forget; caller logs). - backend/direct.rs: lazy index init in find_similar and find_near_duplicates extracted to 'let built = idx.as_ref().expect( \"set on the line above\")' rather than the original is_none() + unwrap. (get_or_insert_with was the obvious suggestion but the closure can't carry the ? from load_data — the match-style refactor keeps error propagation intact.) - core/util.rs: AUDIO_EXTENSIONS gains m4a, alac, caf, bwf. Documented why opus and w64 are out (no symphonia upstream support yet). Test coverage added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-03 01:59 UTC
Commit: e857c68a6d9743eed5406a6163d096766db596a6
Parent: 158e309
6 files changed, +74 insertions, -27 deletions
@@ -10,6 +10,7 @@ use std::sync::Arc;
10 10
11 11 use parking_lot::Mutex;
12 12 use serde::{Deserialize, Serialize};
13 + use thiserror::Error;
13 14
14 15 /// Cached license data, persisted to `license.json`.
15 16 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -205,12 +206,27 @@ pub async fn activate_key(server_url: &str, key: &str, machine_id: &str) -> Resu
205 206 }
206 207 }
207 208
209 + /// Best-effort deactivation failure. The caller logs; no user-facing copy.
210 + #[derive(Error, Debug)]
211 + pub enum DeactivationError {
212 + #[error("HTTP client error: {0}")]
213 + ClientBuild(reqwest::Error),
214 + #[error("Network error: {0}")]
215 + Network(reqwest::Error),
216 + #[error("Server returned {0}")]
217 + Server(reqwest::StatusCode),
218 + #[error("Invalid response: {0}")]
219 + InvalidResponse(reqwest::Error),
220 + #[error("Server rejected deactivation: {0}")]
221 + Rejected(String),
222 + }
223 +
208 224 /// Deactivate a license key (best-effort, fire-and-forget).
209 - pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), String> {
225 + pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Result<(), DeactivationError> {
210 226 let client = reqwest::Client::builder()
211 227 .timeout(std::time::Duration::from_secs(15))
212 228 .build()
213 - .map_err(|e| format!("HTTP client error: {e}"))?;
229 + .map_err(DeactivationError::ClientBuild)?;
214 230
215 231 let url = format!("{server_url}/api/keys/deactivate");
216 232 let body = DeactivateRequest { key, machine_id };
@@ -220,21 +236,21 @@ pub async fn deactivate_key(server_url: &str, key: &str, machine_id: &str) -> Re
220 236 .json(&body)
221 237 .send()
222 238 .await
223 - .map_err(|e| format!("Network error: {e}"))?;
239 + .map_err(DeactivationError::Network)?;
224 240
225 241 if !resp.status().is_success() {
226 - return Err(format!("Server returned {}", resp.status()));
242 + return Err(DeactivationError::Server(resp.status()));
227 243 }
228 244
229 245 let parsed: DeactivateResponse = resp
230 246 .json()
231 247 .await
232 - .map_err(|e| format!("Invalid response: {e}"))?;
248 + .map_err(DeactivationError::InvalidResponse)?;
233 249
234 250 if parsed.success {
235 251 Ok(())
236 252 } else {
237 - Err(parsed.message)
253 + Err(DeactivationError::Rejected(parsed.message))
238 254 }
239 255 }
240 256
@@ -5,7 +5,8 @@ use std::time::Instant;
5 5
6 6 use audiofiles_browser::state::{MidiNoteEvent, SharedState};
7 7 use audiofiles_core::instrument::note_name;
8 - use midir::{MidiInput, MidiInputConnection};
8 + use midir::{ConnectError, InitError, MidiInput, MidiInputConnection};
9 + use thiserror::Error;
9 10 use tracing::instrument;
10 11
11 12 /// An active MIDI input connection. Dropping this disconnects.
@@ -13,6 +14,17 @@ pub struct MidiConnection {
13 14 _conn: MidiInputConnection<()>,
14 15 }
15 16
17 + /// Errors from MIDI port enumeration and connection.
18 + #[derive(Error, Debug)]
19 + pub enum MidiError {
20 + #[error("MIDI init: {0}")]
21 + Init(#[from] InitError),
22 + #[error("MIDI port index {idx} out of range ({count} ports available)")]
23 + PortOutOfRange { idx: usize, count: usize },
24 + #[error("MIDI connect: {0}")]
25 + Connect(#[from] ConnectError<MidiInput>),
26 + }
27 +
16 28 /// List available MIDI input port names.
17 29 #[instrument(skip_all)]
18 30 pub fn list_input_ports() -> Vec<String> {
@@ -32,13 +44,12 @@ pub fn list_input_ports() -> Vec<String> {
32 44 /// on the instrument directly (low latency). Also pushes `MidiNoteEvent`s to
33 45 /// `shared.midi_recent_notes` for the GUI activity display.
34 46 #[instrument(skip_all)]
35 - pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnection, String> {
36 - let midi_in = MidiInput::new("audiofiles-input")
37 - .map_err(|e| format!("MIDI init: {e}"))?;
47 + pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnection, MidiError> {
48 + let midi_in = MidiInput::new("audiofiles-input")?;
38 49 let ports = midi_in.ports();
39 50 let port = ports
40 51 .get(port_index)
41 - .ok_or_else(|| format!("MIDI port index {port_index} out of range"))?;
52 + .ok_or(MidiError::PortOutOfRange { idx: port_index, count: ports.len() })?;
42 53
43 54 let shared_cb = shared.clone();
44 55 let conn = midi_in
@@ -72,8 +83,7 @@ pub fn connect(port_index: usize, shared: Arc<SharedState>) -> Result<MidiConnec
72 83 }
73 84 },
74 85 (),
75 - )
76 - .map_err(|e| format!("MIDI connect: {e}"))?;
86 + )?;
77 87
78 88 Ok(MidiConnection { _conn: conn })
79 89 }
@@ -564,11 +564,12 @@ impl Backend for DirectBackend {
564 564 };
565 565 *idx = Some(similarity::SimilarityIndex::build_from_data(data));
566 566 }
567 + let built = idx.as_ref().expect("set on the line above");
567 568 let features = {
568 569 let db = self.db.lock();
569 570 similarity::load_features(&db, hash)?
570 571 };
571 - Ok(idx.as_ref().unwrap().find_similar(hash, &features, limit))
572 + Ok(built.find_similar(hash, &features, limit))
572 573 }
573 574
574 575 fn find_near_duplicates(
@@ -586,14 +587,12 @@ impl Backend for DirectBackend {
586 587 };
587 588 *idx = Some(fingerprint::FingerprintIndex::build_from_data(entries));
588 589 }
590 + let built = idx.as_ref().expect("set on the line above");
589 591 let reference = {
590 592 let db = self.db.lock();
591 593 fingerprint::load_fingerprint(&db, hash)?
592 594 };
593 - Ok(idx
594 - .as_ref()
595 - .unwrap()
596 - .find_near_duplicates(hash, &reference.envelope, limit))
595 + Ok(built.find_near_duplicates(hash, &reference.envelope, limit))
597 596 }
598 597
599 598 // --- Store ---
@@ -23,6 +23,10 @@ pub enum PreviewError {
23 23 Decode(String),
24 24 #[error("no audio data decoded")]
25 25 NoData,
26 + #[error("invalid hash: {0}")]
27 + InvalidHash(String),
28 + #[error("file not found: {0}")]
29 + FileNotFound(PathBuf),
26 30 }
27 31
28 32 /// Errors from theme file loading.
@@ -6,20 +6,20 @@ impl BrowserState {
6 6 // --- Sample resolution ---
7 7
8 8 /// Resolve a sample hash to its filesystem path via the backend.
9 - pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, String> {
9 + pub fn resolve_sample_path(&self, hash: &str) -> Result<PathBuf, crate::error::PreviewError> {
10 10 let ext = self.backend.sample_extension(hash).unwrap_or_default();
11 11 let path = self.backend.sample_path(hash, &ext)
12 - .map_err(|e| format!("Invalid hash: {e}"))?;
12 + .map_err(|e| crate::error::PreviewError::InvalidHash(e.to_string()))?;
13 13 if !path.exists() {
14 - return Err(format!("File not found: {}", path.display()));
14 + return Err(crate::error::PreviewError::FileNotFound(path));
15 15 }
16 16 Ok(path)
17 17 }
18 18
19 19 /// Resolve a sample hash and decode it to an interleaved stereo f32 buffer.
20 - pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, String> {
20 + pub fn resolve_and_decode(&self, hash: &str) -> Result<crate::preview::PreviewBuffer, crate::error::PreviewError> {
21 21 let path = self.resolve_sample_path(hash)?;
22 - crate::preview::decode_to_f32(&path).map_err(|e| format!("Decode error: {e}"))
22 + crate::preview::decode_to_f32(&path)
23 23 }
24 24
25 25 // --- Preview ---
@@ -33,7 +33,7 @@ impl BrowserState {
33 33 let path = match self.resolve_sample_path(hash) {
34 34 Ok(p) => p,
35 35 Err(e) => {
36 - self.status = e;
36 + self.status = e.to_string();
37 37 self.previewing_hash = None;
38 38 return;
39 39 }
@@ -141,7 +141,7 @@ impl BrowserState {
141 141 let buf = match self.resolve_and_decode(hash) {
142 142 Ok(b) => b,
143 143 Err(e) => {
144 - self.status = e;
144 + self.status = e.to_string();
145 145 return;
146 146 }
147 147 };
@@ -195,7 +195,7 @@ impl BrowserState {
195 195 let buf = match self.resolve_and_decode(hash) {
196 196 Ok(b) => b,
197 197 Err(e) => {
198 - self.status = e;
198 + self.status = e.to_string();
199 199 return;
200 200 }
201 201 };
@@ -3,7 +3,16 @@
3 3 use std::path::Path;
4 4
5 5 /// Audio file extensions recognised throughout audiofiles.
6 - pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "flac", "mp3", "ogg", "aiff", "aif"];
6 + ///
7 + /// `bwf` is WAV with a `bext` chunk; symphonia decodes it as WAV.
8 + /// `m4a`/`alac` route through symphonia's `isomp4` format with `aac`/`alac`
9 + /// codecs. `caf` uses the `caf` format with `pcm`/`alac`.
10 + ///
11 + /// Missing on purpose: `opus` (no symphonia codec), `w64` (Wave64; no
12 + /// symphonia format). Add when upstream support lands or we swap decoders.
13 + pub const AUDIO_EXTENSIONS: &[&str] = &[
14 + "wav", "flac", "mp3", "ogg", "aiff", "aif", "m4a", "alac", "caf", "bwf",
15 + ];
7 16
8 17 /// Get the lowercase file extension, or empty string if none.
9 18 pub fn get_extension(path: &Path) -> String {
@@ -146,6 +155,15 @@ mod tests {
146 155 }
147 156
148 157 #[test]
158 + fn is_audio_file_extended_formats() {
159 + assert!(is_audio_file(Path::new("vocal.m4a")));
160 + assert!(is_audio_file(Path::new("loop.alac")));
161 + assert!(is_audio_file(Path::new("snare.caf")));
162 + assert!(is_audio_file(Path::new("field.bwf")));
163 + assert!(is_audio_file(Path::new("FIELD.BWF")));
164 + }
165 +
166 + #[test]
149 167 fn is_audio_file_txt_rejected() {
150 168 assert!(!is_audio_file(Path::new("readme.txt")));
151 169 }