From 8de0d3defe2022793df57ed7d90e40e6a2a815a0 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:30:38 +0200 Subject: [PATCH 01/18] feat: scaffold Cargo workspace with rustify-core and Python bindings Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ .gitignore | 18 ++++++++++++++++++ Cargo.toml | 4 ++++ bindings/python/Cargo.toml | 12 ++++++++++++ bindings/python/src/lib.rs | 7 +++++++ crates/rustify-core/Cargo.toml | 21 +++++++++++++++++++++ crates/rustify-core/src/lib.rs | 1 + pyproject.toml | 15 +++++++++++++++ 8 files changed, 109 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 bindings/python/Cargo.toml create mode 100644 bindings/python/src/lib.rs create mode 100644 crates/rustify-core/Cargo.toml create mode 100644 crates/rustify-core/src/lib.rs create mode 100644 pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea8c2a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - run: cargo test --workspace + - run: cargo clippy -- -D warnings + - run: cargo fmt --check + + build-wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - uses: PyO3/maturin-action@v1 + with: + args: --release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6a225b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/target/ +**/*.rs.bk +*.pdb +*.so +*.dylib +*.dll + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.whl + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b1cb100 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,4 @@ +[workspace] +members = ["crates/rustify-core", "bindings/python"] +default-members = ["crates/rustify-core"] +resolver = "2" diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml new file mode 100644 index 0000000..f618219 --- /dev/null +++ b/bindings/python/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "rustify-python" +version = "0.1.0" +edition = "2021" + +[lib] +name = "_rustify" +crate-type = ["cdylib"] + +[dependencies] +rustify-core = { path = "../../crates/rustify-core" } +pyo3 = { version = "0.23", features = ["extension-module"] } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs new file mode 100644 index 0000000..fa1ac87 --- /dev/null +++ b/bindings/python/src/lib.rs @@ -0,0 +1,7 @@ +use pyo3::prelude::*; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} diff --git a/crates/rustify-core/Cargo.toml b/crates/rustify-core/Cargo.toml new file mode 100644 index 0000000..409e51c --- /dev/null +++ b/crates/rustify-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rustify-core" +version = "0.1.0" +edition = "2021" +description = "Embedded Rust media player library for YoyoPod" + +[dependencies] +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "ogg", "wav", "pcm"] } +cpal = "0.15" +crossbeam = "0.8" +walkdir = "2" +lofty = "0.22" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" +hound = "3" + +[[example]] +name = "play" +path = "../../examples/play.rs" diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs new file mode 100644 index 0000000..8bb9358 --- /dev/null +++ b/crates/rustify-core/src/lib.rs @@ -0,0 +1 @@ +// Modules will be added as they are implemented. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c5b8eb8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "rustify" +version = "0.1.0" +description = "Embedded Rust media player for YoyoPod" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] +manifest-path = "bindings/python/Cargo.toml" +python-source = "bindings/python" +module-name = "rustify._rustify" From c32ffb98a6443a2eaf0e2d1f43afb20e1fada8b8 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:33:26 +0200 Subject: [PATCH 02/18] feat: add error and types modules with tests Co-Authored-By: Claude Sonnet 4.6 --- crates/rustify-core/Cargo.toml | 1 + crates/rustify-core/src/error.rs | 84 +++++++++++++++++++ crates/rustify-core/src/lib.rs | 3 +- crates/rustify-core/src/types.rs | 134 +++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 crates/rustify-core/src/error.rs create mode 100644 crates/rustify-core/src/types.rs diff --git a/crates/rustify-core/Cargo.toml b/crates/rustify-core/Cargo.toml index 409e51c..0bfa8ff 100644 --- a/crates/rustify-core/Cargo.toml +++ b/crates/rustify-core/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] } [dev-dependencies] tempfile = "3" hound = "3" +serde_json = "1" [[example]] name = "play" diff --git a/crates/rustify-core/src/error.rs b/crates/rustify-core/src/error.rs new file mode 100644 index 0000000..4b86e74 --- /dev/null +++ b/crates/rustify-core/src/error.rs @@ -0,0 +1,84 @@ +use std::fmt; +use std::io; + +/// Unified error type for all rustify-core operations. +#[derive(Debug)] +pub enum RustifyError { + /// I/O errors (file not found, permission denied, etc.) + Io(io::Error), + /// Audio decoding errors (corrupt file, unsupported codec) + Decode(String), + /// Audio output errors (device not found, ALSA error) + Audio(String), + /// Metadata reading errors (corrupt tags, unsupported format) + Metadata(String), + /// Playlist parsing errors (invalid M3U, missing files) + Playlist(String), +} + +impl fmt::Display for RustifyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Decode(msg) => write!(f, "decode error: {msg}"), + Self::Audio(msg) => write!(f, "audio error: {msg}"), + Self::Metadata(msg) => write!(f, "metadata error: {msg}"), + Self::Playlist(msg) => write!(f, "playlist error: {msg}"), + } + } +} + +impl std::error::Error for RustifyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for RustifyError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +/// Result type alias for rustify-core operations. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_io_error() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "gone")); + assert!(err.to_string().contains("IO error")); + assert!(err.to_string().contains("gone")); + } + + #[test] + fn display_decode_error() { + let err = RustifyError::Decode("bad frame".into()); + assert_eq!(err.to_string(), "decode error: bad frame"); + } + + #[test] + fn from_io_error() { + let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "nope"); + let err: RustifyError = io_err.into(); + assert!(matches!(err, RustifyError::Io(_))); + } + + #[test] + fn error_source_for_io() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "x")); + assert!(std::error::Error::source(&err).is_some()); + } + + #[test] + fn error_source_for_non_io() { + let err = RustifyError::Decode("x".into()); + assert!(std::error::Error::source(&err).is_none()); + } +} diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index 8bb9358..5c74364 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1 +1,2 @@ -// Modules will be added as they are implemented. +pub mod error; +pub mod types; diff --git a/crates/rustify-core/src/types.rs b/crates/rustify-core/src/types.rs new file mode 100644 index 0000000..b27213c --- /dev/null +++ b/crates/rustify-core/src/types.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Metadata for a single audio track. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Track { + /// File URI (e.g., "file:///path/to/song.mp3") + pub uri: String, + /// Track title (falls back to filename if no tags) + pub name: String, + /// Artist names + pub artists: Vec, + /// Album name + pub album: String, + /// Duration in milliseconds + pub length: u64, + /// Track number within album + pub track_no: Option, +} + +/// Metadata about a playlist file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Playlist { + /// File URI of the .m3u file + pub uri: String, + /// Playlist name (derived from filename) + pub name: String, + /// Number of tracks in the playlist + pub track_count: usize, +} + +/// Playback state of the player. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, +} + +/// Events emitted by the player to registered callbacks. +#[derive(Debug, Clone)] +pub enum PlayerEvent { + StateChanged(PlaybackState), + TrackChanged(Track), + PositionUpdate(u64), + Error(String), +} + +/// Commands sent to the player's command thread. +#[derive(Debug)] +pub enum PlayerCommand { + Play, + Pause, + Stop, + Next, + Previous, + Seek(u64), + SetVolume(u8), + LoadTrackUris(Vec), + ClearTracklist, + Shutdown, +} + +/// Convert a `file://` URI to a filesystem path. +/// Also accepts plain paths (returned as-is). +pub fn uri_to_path(uri: &str) -> PathBuf { + uri.strip_prefix("file://") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(uri)) +} + +/// Convert a filesystem path to a `file://` URI. +pub fn path_to_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn track_creation() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + assert_eq!(track.name, "Song"); + assert_eq!(track.length, 180_000); + } + + #[test] + fn track_serde_roundtrip() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + let json = serde_json::to_string(&track).unwrap(); + let decoded: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(track, decoded); + } + + #[test] + fn uri_to_path_with_scheme() { + let path = uri_to_path("file:///home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn uri_to_path_plain_path() { + let path = uri_to_path("/home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn path_to_uri_conversion() { + let uri = path_to_uri(Path::new("/home/pi/Music/song.mp3")); + assert_eq!(uri, "file:///home/pi/Music/song.mp3"); + } + + #[test] + fn playback_state_equality() { + assert_eq!(PlaybackState::Stopped, PlaybackState::Stopped); + assert_ne!(PlaybackState::Playing, PlaybackState::Paused); + } +} From 33c94ff3449da128282562ec635fb17679e8dbd2 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:39:05 +0200 Subject: [PATCH 03/18] feat: add lock-free mixer with atomic volume control --- crates/rustify-core/src/mixer.rs | 87 ++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 crates/rustify-core/src/mixer.rs diff --git a/crates/rustify-core/src/mixer.rs b/crates/rustify-core/src/mixer.rs new file mode 100644 index 0000000..3b3937e --- /dev/null +++ b/crates/rustify-core/src/mixer.rs @@ -0,0 +1,87 @@ +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Lock-free volume control using atomic operations. +/// Volume ranges from 0 (silent) to 100 (full). +pub struct Mixer { + volume: AtomicU8, +} + +impl Mixer { + /// Create a new mixer with the given initial volume (clamped to 0-100). + pub fn new(initial_volume: u8) -> Self { + Self { + volume: AtomicU8::new(initial_volume.min(100)), + } + } + + /// Set the volume (clamped to 0-100). + pub fn set_volume(&self, volume: u8) { + self.volume.store(volume.min(100), Ordering::Relaxed); + } + + /// Get the current volume (0-100). + pub fn get_volume(&self) -> u8 { + self.volume.load(Ordering::Relaxed) + } + + /// Get the gain multiplier (0.0 - 1.0) for applying to audio samples. + pub fn gain(&self) -> f32 { + self.get_volume() as f32 / 100.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_volume() { + let mixer = Mixer::new(75); + assert_eq!(mixer.get_volume(), 75); + } + + #[test] + fn clamps_initial_volume_to_100() { + let mixer = Mixer::new(150); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn set_and_get_volume() { + let mixer = Mixer::new(50); + mixer.set_volume(80); + assert_eq!(mixer.get_volume(), 80); + } + + #[test] + fn clamps_set_volume_to_100() { + let mixer = Mixer::new(50); + mixer.set_volume(200); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn volume_zero() { + let mixer = Mixer::new(0); + assert_eq!(mixer.get_volume(), 0); + assert_eq!(mixer.gain(), 0.0); + } + + #[test] + fn gain_at_full_volume() { + let mixer = Mixer::new(100); + assert!((mixer.gain() - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_half_volume() { + let mixer = Mixer::new(50); + assert!((mixer.gain() - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_zero_volume() { + let mixer = Mixer::new(0); + assert!((mixer.gain() - 0.0).abs() < f32::EPSILON); + } +} From dc80bf3d89e106a4fadfeaafb71cadf03e54e7b5 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:39:37 +0200 Subject: [PATCH 04/18] feat: add tracklist with VecDeque-backed playback queue --- crates/rustify-core/src/tracklist.rs | 211 +++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 crates/rustify-core/src/tracklist.rs diff --git a/crates/rustify-core/src/tracklist.rs b/crates/rustify-core/src/tracklist.rs new file mode 100644 index 0000000..9648d55 --- /dev/null +++ b/crates/rustify-core/src/tracklist.rs @@ -0,0 +1,211 @@ +use std::collections::VecDeque; + +/// A playback queue backed by VecDeque. +/// Stores track URIs and maintains a current position index. +pub struct Tracklist { + tracks: VecDeque, + current_index: Option, +} + +impl Tracklist { + pub fn new() -> Self { + Self { + tracks: VecDeque::new(), + current_index: None, + } + } + + /// Append a single track URI to the end of the queue. + pub fn add(&mut self, uri: String) { + self.tracks.push_back(uri); + } + + /// Replace the entire tracklist with the given URIs. + /// Sets the current position to the first track if non-empty. + pub fn load(&mut self, uris: Vec) { + self.tracks.clear(); + self.tracks.extend(uris); + self.current_index = if self.tracks.is_empty() { + None + } else { + Some(0) + }; + } + + /// Remove all tracks and reset position. + pub fn clear(&mut self) { + self.tracks.clear(); + self.current_index = None; + } + + /// Get the URI of the current track, if any. + pub fn current(&self) -> Option<&str> { + self.current_index + .and_then(|i| self.tracks.get(i)) + .map(String::as_str) + } + + /// Advance to the next track and return its URI. + /// Returns None if already at the end. + pub fn next(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx + 1 < self.tracks.len() { + self.current_index = Some(idx + 1); + self.current() + } else { + None + } + } + + /// Go back to the previous track and return its URI. + /// Returns None if already at the beginning. + pub fn previous(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx > 0 { + self.current_index = Some(idx - 1); + self.current() + } else { + None + } + } + + /// Get the current track index (0-based). + pub fn index(&self) -> Option { + self.current_index + } + + /// Get the total number of tracks. + pub fn len(&self) -> usize { + self.tracks.len() + } + + /// Check if the tracklist is empty. + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } +} + +impl Default for Tracklist { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_tracklist_is_empty() { + let tl = Tracklist::new(); + assert!(tl.is_empty()); + assert_eq!(tl.len(), 0); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn add_does_not_set_current() { + let mut tl = Tracklist::new(); + tl.add("file:///a.mp3".into()); + assert_eq!(tl.len(), 1); + assert!(tl.current().is_none()); + } + + #[test] + fn load_sets_current_to_first() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + ]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.index(), Some(0)); + assert_eq!(tl.current(), Some("file:///a.mp3")); + } + + #[test] + fn load_empty_sets_none() { + let mut tl = Tracklist::new(); + tl.load(vec![]); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + } + + #[test] + fn load_replaces_existing() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.load(vec!["file:///b.mp3".into(), "file:///c.mp3".into()]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.current(), Some("file:///b.mp3")); + } + + #[test] + fn clear_resets_everything() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.clear(); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn next_advances() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + assert_eq!(tl.next(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.next(), Some("file:///c.mp3")); + assert_eq!(tl.index(), Some(2)); + } + + #[test] + fn next_at_end_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.next(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn next_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.next(), None); + } + + #[test] + fn previous_goes_back() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.next(); // -> b + tl.next(); // -> c + assert_eq!(tl.previous(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.previous(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_at_start_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.previous(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.previous(), None); + } +} From 0207570a1a6451aaa9aae11e951119a602581264 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:41:08 +0200 Subject: [PATCH 05/18] feat: add M3U playlist parser with path resolution --- crates/rustify-core/src/lib.rs | 1 + crates/rustify-core/src/playlist.rs | 200 ++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 crates/rustify-core/src/playlist.rs diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index 5c74364..9661502 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1,2 +1,3 @@ pub mod error; +pub mod playlist; pub mod types; diff --git a/crates/rustify-core/src/playlist.rs b/crates/rustify-core/src/playlist.rs new file mode 100644 index 0000000..f1a481f --- /dev/null +++ b/crates/rustify-core/src/playlist.rs @@ -0,0 +1,200 @@ +use std::fs; +use std::path::Path; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, Playlist}; + +/// Supported audio file extensions for playlist entries. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Parse an M3U playlist file and return resolved file:// URIs. +/// +/// Handles simple M3U and extended M3U (`#EXTM3U` / `#EXTINF`). +/// Relative paths are resolved against the M3U file's parent directory. +/// Only entries with supported audio extensions are included. +pub fn parse_m3u(path: &Path) -> Result, RustifyError> { + let content = fs::read_to_string(path).map_err(|e| { + RustifyError::Playlist(format!("failed to read {}: {e}", path.display())) + })?; + + let base_dir = path + .parent() + .ok_or_else(|| RustifyError::Playlist("M3U path has no parent directory".into()))?; + + let mut uris = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let track_path = if Path::new(line).is_absolute() { + Path::new(line).to_path_buf() + } else { + base_dir.join(line) + }; + + if let Some(ext) = track_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(&track_path)); + } + } + } + + Ok(uris) +} + +/// Find all .m3u playlist files in a directory (non-recursive). +/// Returns metadata about each playlist including track count. +pub fn find_playlists(dir: &Path) -> Result, RustifyError> { + let mut playlists = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some("m3u") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let track_count = parse_m3u(&path).map(|uris| uris.len()).unwrap_or(0); + + playlists.push(Playlist { + uri: path_to_uri(&path), + name, + track_count, + }); + } + } + + Ok(playlists) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_m3u(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn touch(dir: &Path, name: &str) { + fs::write(dir.join(name), b"").unwrap(); + } + + #[test] + fn parse_simple_m3u_absolute_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song1.mp3\n/music/song2.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert_eq!(uris[0], "file:///music/song1.mp3"); + assert_eq!(uris[1], "file:///music/song2.flac"); + } + + #[test] + fn parse_m3u_relative_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "songs/track.mp3\n../other/track.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert!(uris[0].contains("songs")); + assert!(uris[1].contains("other")); + } + + #[test] + fn parse_extended_m3u_skips_directives() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "#EXTM3U\n#EXTINF:123,Artist - Title\n/music/song.mp3\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert_eq!(uris[0], "file:///music/song.mp3"); + } + + #[test] + fn parse_m3u_skips_blank_lines_and_comments() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "\n# comment\n\n/music/song.mp3\n\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + } + + #[test] + fn parse_m3u_filters_unsupported_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.mp3\n/music/image.png\n/music/doc.txt\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn parse_m3u_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "test.m3u", "/music/song.MP3\n/music/song.Flac\n"); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn parse_m3u_nonexistent_file_returns_error() { + let result = parse_m3u(Path::new("/nonexistent/playlist.m3u")); + assert!(result.is_err()); + } + + #[test] + fn parse_empty_m3u() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "empty.m3u", ""); + let uris = parse_m3u(&m3u).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn find_playlists_in_directory() { + let dir = TempDir::new().unwrap(); + create_m3u(dir.path(), "chill.m3u", "/music/a.mp3\n/music/b.flac\n"); + create_m3u(dir.path(), "rock.m3u", "/music/c.ogg\n"); + touch(dir.path(), "readme.txt"); + + let playlists = find_playlists(dir.path()).unwrap(); + assert_eq!(playlists.len(), 2); + + let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"chill")); + assert!(names.contains(&"rock")); + + let chill = playlists.iter().find(|p| p.name == "chill").unwrap(); + assert_eq!(chill.track_count, 2); + } +} From 9fdaf9a4d6beff893296cf9b614df7db63a55688 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:41:10 +0200 Subject: [PATCH 06/18] feat: add recursive audio file scanner with browse support --- crates/rustify-core/src/scanner.rs | 194 +++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 crates/rustify-core/src/scanner.rs diff --git a/crates/rustify-core/src/scanner.rs b/crates/rustify-core/src/scanner.rs new file mode 100644 index 0000000..0807dba --- /dev/null +++ b/crates/rustify-core/src/scanner.rs @@ -0,0 +1,194 @@ +use std::path::Path; + +use walkdir::WalkDir; + +use crate::error::RustifyError; +use crate::types::path_to_uri; + +/// Supported audio file extensions. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Recursively scan a directory for audio files. +/// Returns sorted `file://` URIs for all files matching supported extensions. +pub fn scan_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut uris = Vec::new(); + + for entry in WalkDir::new(path).follow_links(true) { + let entry = entry.map_err(|e| { + RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + })?; + + if !entry.file_type().is_file() { + continue; + } + + if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(entry.path())); + } + } + } + + uris.sort(); + Ok(uris) +} + +/// List the contents of a single directory (non-recursive). +/// Returns URIs for audio files and subdirectories. +pub fn browse_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut entries = Vec::new(); + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + entries.push(path_to_uri(&entry_path)); + } else if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + entries.push(path_to_uri(&entry_path)); + } + } + } + + entries.sort(); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn touch(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); + } + + #[test] + fn scan_finds_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("track.flac")); + touch(&dir.path().join("sound.ogg")); + touch(&dir.path().join("clip.wav")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 4); + } + + #[test] + fn scan_ignores_non_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("readme.txt")); + touch(&dir.path().join("image.png")); + touch(&dir.path().join("cover.jpg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn scan_recurses_into_subdirectories() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("artist1/album1/track1.mp3")); + touch(&dir.path().join("artist1/album2/track2.flac")); + touch(&dir.path().join("artist2/track3.ogg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 3); + } + + #[test] + fn scan_returns_sorted_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("c.mp3")); + touch(&dir.path().join("a.mp3")); + touch(&dir.path().join("b.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0] < uris[1]); + assert!(uris[1] < uris[2]); + } + + #[test] + fn scan_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("LOUD.MP3")); + touch(&dir.path().join("quiet.Flac")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn scan_returns_file_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0].starts_with("file://")); + } + + #[test] + fn scan_nonexistent_directory_returns_error() { + let result = scan_directory(Path::new("/nonexistent/path")); + assert!(result.is_err()); + } + + #[test] + fn scan_empty_directory() { + let dir = TempDir::new().unwrap(); + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn browse_lists_files_and_dirs() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + fs::create_dir(dir.path().join("subdir")).unwrap(); + touch(&dir.path().join("readme.txt")); + + let entries = browse_directory(dir.path()).unwrap(); + assert_eq!(entries.len(), 2); + } + + #[test] + fn browse_does_not_recurse() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("top.mp3")); + touch(&dir.path().join("sub/nested.mp3")); + + let entries = browse_directory(dir.path()).unwrap(); + assert_eq!(entries.len(), 2); + } +} From c62e54ba36ed381f0fc88670429ccee4bec29aca Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:41:47 +0200 Subject: [PATCH 07/18] feat: add metadata reader with lofty and filename fallback Co-Authored-By: Claude Sonnet 4.6 --- crates/rustify-core/src/lib.rs | 1 + crates/rustify-core/src/metadata.rs | 145 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 crates/rustify-core/src/metadata.rs diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index 9661502..69d2ca2 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1,3 +1,4 @@ pub mod error; +pub mod metadata; pub mod playlist; pub mod types; diff --git a/crates/rustify-core/src/metadata.rs b/crates/rustify-core/src/metadata.rs new file mode 100644 index 0000000..079f163 --- /dev/null +++ b/crates/rustify-core/src/metadata.rs @@ -0,0 +1,145 @@ +use std::path::Path; + +use lofty::prelude::*; +use lofty::probe::Probe; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, uri_to_path, Track}; + +/// Read audio metadata from a file URI or plain path. +/// Falls back to filename-derived metadata if tags are missing. +pub fn read_metadata(uri: &str) -> Result { + let path = uri_to_path(uri); + read_metadata_from_path(&path) +} + +/// Read audio metadata from a filesystem path. +pub fn read_metadata_from_path(path: &Path) -> Result { + let tagged_file = Probe::open(path) + .map_err(|e| RustifyError::Metadata(format!("failed to open {}: {e}", path.display())))? + .read() + .map_err(|e| { + RustifyError::Metadata(format!("failed to read tags from {}: {e}", path.display())) + })?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let name = tag + .and_then(|t| t.title().map(|s| s.to_string())) + .unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + + let artists = tag + .and_then(|t| t.artist().map(|s| vec![s.to_string()])) + .unwrap_or_default(); + + let album = tag + .and_then(|t| t.album().map(|s| s.to_string())) + .unwrap_or_default(); + + let track_no = tag.and_then(|t| t.track()); + + let length = tagged_file.properties().duration().as_millis() as u64; + + Ok(Track { + uri: path_to_uri(path), + name, + artists, + album, + length, + track_no, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a minimal valid WAV file (44-byte header + 1 second of silence). + fn create_test_wav() -> NamedTempFile { + let mut file = NamedTempFile::with_suffix(".wav").unwrap(); + let sample_rate: u32 = 44100; + let channels: u16 = 1; + let bits_per_sample: u16 = 16; + let num_samples: u32 = sample_rate; // 1 second + let data_size: u32 = num_samples * (bits_per_sample / 8) as u32 * channels as u32; + let file_size: u32 = 36 + data_size; + + // RIFF header + file.write_all(b"RIFF").unwrap(); + file.write_all(&file_size.to_le_bytes()).unwrap(); + file.write_all(b"WAVE").unwrap(); + // fmt chunk + file.write_all(b"fmt ").unwrap(); + file.write_all(&16u32.to_le_bytes()).unwrap(); // chunk size + file.write_all(&1u16.to_le_bytes()).unwrap(); // PCM format + file.write_all(&channels.to_le_bytes()).unwrap(); + file.write_all(&sample_rate.to_le_bytes()).unwrap(); + let byte_rate = sample_rate * channels as u32 * (bits_per_sample / 8) as u32; + file.write_all(&byte_rate.to_le_bytes()).unwrap(); + let block_align = channels * (bits_per_sample / 8); + file.write_all(&block_align.to_le_bytes()).unwrap(); + file.write_all(&bits_per_sample.to_le_bytes()).unwrap(); + // data chunk + file.write_all(b"data").unwrap(); + file.write_all(&data_size.to_le_bytes()).unwrap(); + // Write silence + let silence = vec![0u8; data_size as usize]; + file.write_all(&silence).unwrap(); + file.flush().unwrap(); + file + } + + #[test] + fn read_metadata_from_wav_falls_back_to_filename() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + + let expected_name = wav + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!(track.name, expected_name); + assert!(track.artists.is_empty()); + assert!(track.album.is_empty()); + } + + #[test] + fn read_metadata_returns_file_uri() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.uri.starts_with("file://")); + } + + #[test] + fn read_metadata_reports_duration() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.length > 900 && track.length < 1100); + } + + #[test] + fn read_metadata_via_uri() { + let wav = create_test_wav(); + let uri = path_to_uri(wav.path()); + let track = read_metadata(&uri).unwrap(); + assert!(track.length > 0); + } + + #[test] + fn read_metadata_nonexistent_file_returns_error() { + let result = read_metadata("file:///nonexistent/song.mp3"); + assert!(result.is_err()); + } +} From 9e06ce12038a920f5fe4c7a0a9b8c39c430f1b8d Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:42:23 +0200 Subject: [PATCH 08/18] feat: wire up all modules in lib.rs with re-exports Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index 69d2ca2..f0ddf79 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1,4 +1,11 @@ pub mod error; pub mod metadata; +pub mod mixer; pub mod playlist; +pub mod scanner; +pub mod tracklist; pub mod types; + +// Re-export primary types at crate root for convenience. +pub use error::{Result, RustifyError}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; From f457c8606bf4f60644f4c50c37734cd73f8cf02e Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:46:16 +0200 Subject: [PATCH 09/18] feat: add three-thread playback engine with symphonia + cpal Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/lib.rs | 2 + crates/rustify-core/src/player.rs | 805 ++++++++++++++++++++++++++++++ 2 files changed, 807 insertions(+) create mode 100644 crates/rustify-core/src/player.rs diff --git a/crates/rustify-core/src/lib.rs b/crates/rustify-core/src/lib.rs index f0ddf79..94f3bcc 100644 --- a/crates/rustify-core/src/lib.rs +++ b/crates/rustify-core/src/lib.rs @@ -1,6 +1,7 @@ pub mod error; pub mod metadata; pub mod mixer; +pub mod player; pub mod playlist; pub mod scanner; pub mod tracklist; @@ -8,4 +9,5 @@ pub mod types; // Re-export primary types at crate root for convenience. pub use error::{Result, RustifyError}; +pub use player::{Player, PlayerConfig}; pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs new file mode 100644 index 0000000..e96eb70 --- /dev/null +++ b/crates/rustify-core/src/player.rs @@ -0,0 +1,805 @@ +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use crossbeam::channel::{self, Receiver, Sender, TryRecvError}; + +use crate::error::RustifyError; +use crate::metadata::read_metadata_from_path; +use crate::mixer::Mixer; +use crate::tracklist::Tracklist; +use crate::types::{uri_to_path, PlaybackState, PlayerCommand, PlayerEvent, Track}; + +/// Number of audio chunks buffered between decode and output threads. +/// At ~1024 frames per chunk @ 44.1kHz stereo, each chunk is ~23ms. +/// 100 chunks provides ~2.3 seconds of buffer. +const BUFFER_CHUNKS: usize = 100; + +// --- Public API --- + +/// Configuration for creating a Player. +pub struct PlayerConfig { + pub alsa_device: String, + pub music_dirs: Vec, +} + +/// The main player handle. All methods are non-blocking — they send commands +/// to the internal command thread via a crossbeam channel. +pub struct Player { + cmd_tx: Sender, + shared: Arc, + mixer: Arc, + music_dirs: Vec, + _command_thread: Option>, +} + +impl Player { + /// Create a new player. Spawns the command thread immediately. + /// The output stream is created lazily on first `play()`. + pub fn new(config: PlayerConfig) -> Result { + let (cmd_tx, cmd_rx) = channel::unbounded::(); + let shared = Arc::new(SharedState::new()); + let mixer = Arc::new(Mixer::new(100)); + + let shared_clone = Arc::clone(&shared); + let mixer_clone = Arc::clone(&mixer); + let alsa_device = config.alsa_device.clone(); + + let handle = thread::Builder::new() + .name("rustify-cmd".into()) + .spawn(move || { + let mut cmd_loop = CommandLoop::new( + cmd_rx, + shared_clone, + mixer_clone, + alsa_device, + ); + cmd_loop.run(); + }) + .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; + + Ok(Self { + cmd_tx, + shared, + mixer, + music_dirs: config.music_dirs, + _command_thread: Some(handle), + }) + } + + // --- Transport commands (non-blocking, fire-and-forget) --- + + pub fn play(&self) { + self.cmd_tx.send(PlayerCommand::Play).ok(); + } + + pub fn pause(&self) { + self.cmd_tx.send(PlayerCommand::Pause).ok(); + } + + pub fn stop(&self) { + self.cmd_tx.send(PlayerCommand::Stop).ok(); + } + + pub fn next(&self) { + self.cmd_tx.send(PlayerCommand::Next).ok(); + } + + pub fn previous(&self) { + self.cmd_tx.send(PlayerCommand::Previous).ok(); + } + + pub fn seek(&self, position_ms: u64) { + self.cmd_tx.send(PlayerCommand::Seek(position_ms)).ok(); + } + + pub fn set_volume(&self, volume: u8) { + self.mixer.set_volume(volume); + } + + pub fn get_volume(&self) -> u8 { + self.mixer.get_volume() + } + + pub fn load_track_uris(&self, uris: Vec) { + self.cmd_tx.send(PlayerCommand::LoadTrackUris(uris)).ok(); + } + + pub fn clear_tracklist(&self) { + self.cmd_tx.send(PlayerCommand::ClearTracklist).ok(); + } + + pub fn shutdown(&self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } + + // --- State queries (read from shared atomic/mutex state) --- + + pub fn get_playback_state(&self) -> PlaybackState { + self.shared.get_playback_state() + } + + pub fn get_current_track(&self) -> Option { + self.shared.current_track.lock().unwrap().clone() + } + + pub fn get_time_position(&self) -> u64 { + self.shared.time_position_ms.load(Ordering::Relaxed) + } + + // --- Callback registration --- + + pub fn on_state_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_state_change + .push(callback); + } + + pub fn on_track_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_track_change + .push(callback); + } + + pub fn on_position_update(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_position_update + .push(callback); + } + + pub fn on_error(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_error + .push(callback); + } +} + +// --- Shared State --- + +struct SharedState { + /// Encoded PlaybackState: 0=Stopped, 1=Playing, 2=Paused + playback_state: AtomicU8, + current_track: Mutex>, + time_position_ms: AtomicU64, + callbacks: Mutex, +} + +impl SharedState { + fn new() -> Self { + Self { + playback_state: AtomicU8::new(0), + current_track: Mutex::new(None), + time_position_ms: AtomicU64::new(0), + callbacks: Mutex::new(Callbacks::default()), + } + } + + fn get_playback_state(&self) -> PlaybackState { + match self.playback_state.load(Ordering::Relaxed) { + 1 => PlaybackState::Playing, + 2 => PlaybackState::Paused, + _ => PlaybackState::Stopped, + } + } + + fn set_playback_state(&self, state: PlaybackState) { + let val = match state { + PlaybackState::Stopped => 0, + PlaybackState::Playing => 1, + PlaybackState::Paused => 2, + }; + self.playback_state.store(val, Ordering::Relaxed); + } +} + +#[derive(Default)] +struct Callbacks { + on_state_change: Vec>, + on_track_change: Vec>, + on_position_update: Vec>, + on_error: Vec>, +} + +// --- Internal Events (decode thread -> command loop) --- + +enum InternalEvent { + TrackChanged(Track), + Position(u64), + TrackEnded, + Error(String), +} + +/// Control messages from command loop to decode thread. +enum DecodeControl { + Pause, + Resume, + Seek(u64), + Stop, +} + +struct DecodeHandle { + control_tx: Sender, + _thread: JoinHandle<()>, +} + +// --- Command Loop (runs on dedicated thread) --- + +struct CommandLoop { + cmd_rx: Receiver, + event_rx: Receiver, + event_tx: Sender, + shared: Arc, + mixer: Arc, + tracklist: Tracklist, + decode_handle: Option, + audio_tx: Sender>, + _audio_stream: Option, + clear_buffer: Arc, + #[allow(dead_code)] + alsa_device: String, +} + +impl CommandLoop { + fn new( + cmd_rx: Receiver, + shared: Arc, + mixer: Arc, + alsa_device: String, + ) -> Self { + let (event_tx, event_rx) = channel::unbounded::(); + let (audio_tx, audio_rx) = channel::bounded::>(BUFFER_CHUNKS); + let clear_buffer = Arc::new(AtomicBool::new(false)); + + // Create the cpal output stream + let stream = + create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + + Self { + cmd_rx, + event_rx, + event_tx, + shared, + mixer, + tracklist: Tracklist::new(), + decode_handle: None, + audio_tx, + _audio_stream: stream.ok(), + clear_buffer, + alsa_device, + } + } + + fn run(&mut self) { + loop { + crossbeam::select! { + recv(self.cmd_rx) -> cmd => { + match cmd { + Ok(PlayerCommand::Shutdown) => { + self.stop_decode(); + break; + } + Ok(cmd) => self.handle_command(cmd), + Err(_) => break, // Sender dropped + } + } + recv(self.event_rx) -> event => { + match event { + Ok(evt) => self.handle_event(evt), + Err(_) => {} // No decode thread running + } + } + } + } + } + + fn handle_command(&mut self, cmd: PlayerCommand) { + match cmd { + PlayerCommand::Play => self.handle_play(), + PlayerCommand::Pause => self.handle_pause(), + PlayerCommand::Stop => self.handle_stop(), + PlayerCommand::Next => self.handle_next(), + PlayerCommand::Previous => self.handle_previous(), + PlayerCommand::Seek(ms) => self.handle_seek(ms), + PlayerCommand::SetVolume(vol) => self.mixer.set_volume(vol), + PlayerCommand::LoadTrackUris(uris) => { + self.handle_stop(); + self.tracklist.load(uris); + } + PlayerCommand::ClearTracklist => { + self.handle_stop(); + self.tracklist.clear(); + } + PlayerCommand::Shutdown => unreachable!(), + } + } + + fn handle_event(&mut self, event: InternalEvent) { + match event { + InternalEvent::TrackChanged(track) => { + *self.shared.current_track.lock().unwrap() = Some(track.clone()); + self.emit_callbacks(PlayerEvent::TrackChanged(track)); + } + InternalEvent::Position(ms) => { + self.shared.time_position_ms.store(ms, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::PositionUpdate(ms)); + } + InternalEvent::TrackEnded => { + // Try to advance to next track + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + // End of tracklist + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } + InternalEvent::Error(msg) => { + self.emit_callbacks(PlayerEvent::Error(msg)); + } + } + } + + fn handle_play(&mut self) { + match self.shared.get_playback_state() { + PlaybackState::Stopped => { + if let Some(uri) = self.tracklist.current() { + let uri = uri.to_string(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + PlaybackState::Paused => { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Resume).ok(); + } + self.set_state(PlaybackState::Playing); + } + PlaybackState::Playing => {} // Already playing + } + } + + fn handle_pause(&mut self) { + if self.shared.get_playback_state() == PlaybackState::Playing { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Pause).ok(); + } + self.set_state(PlaybackState::Paused); + } + } + + fn handle_stop(&mut self) { + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + + fn handle_next(&mut self) { + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } else { + self.handle_stop(); + } + } + + fn handle_previous(&mut self) { + if let Some(uri) = self.tracklist.previous() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + + fn handle_seek(&mut self, ms: u64) { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Seek(ms)).ok(); + } + } + + fn start_decode(&mut self, uri: String) { + // Clear any stale audio in the buffer + self.clear_buffer.store(true, Ordering::Relaxed); + + let (control_tx, control_rx) = channel::unbounded::(); + let audio_tx = self.audio_tx.clone(); + let event_tx = self.event_tx.clone(); + + let handle = thread::Builder::new() + .name("rustify-decode".into()) + .spawn(move || { + decode_thread(uri, audio_tx, control_rx, event_tx); + }) + .expect("failed to spawn decode thread"); + + self.decode_handle = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + } + + fn stop_decode(&mut self) { + if let Some(handle) = self.decode_handle.take() { + handle.control_tx.send(DecodeControl::Stop).ok(); + // Don't join — the thread will exit when it sees Stop or channel disconnect + } + self.clear_buffer.store(true, Ordering::Relaxed); + } + + fn set_state(&mut self, state: PlaybackState) { + self.shared.set_playback_state(state); + self.emit_callbacks(PlayerEvent::StateChanged(state)); + } + + fn emit_callbacks(&self, event: PlayerEvent) { + let callbacks = self.shared.callbacks.lock().unwrap(); + match &event { + PlayerEvent::StateChanged(state) => { + for cb in &callbacks.on_state_change { + cb(*state); + } + } + PlayerEvent::TrackChanged(track) => { + for cb in &callbacks.on_track_change { + cb(track.clone()); + } + } + PlayerEvent::PositionUpdate(ms) => { + for cb in &callbacks.on_position_update { + cb(*ms); + } + } + PlayerEvent::Error(msg) => { + for cb in &callbacks.on_error { + cb(msg.clone()); + } + } + } + } +} + +// --- Decode Thread --- + +fn decode_thread( + uri: String, + audio_tx: Sender>, + control_rx: Receiver, + event_tx: Sender, +) { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::DecoderOptions; + use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let path = uri_to_path(&uri); + + // Read metadata for TrackChanged event + match read_metadata_from_path(&path) { + Ok(track) => { + event_tx.send(InternalEvent::TrackChanged(track)).ok(); + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("metadata: {e}"))) + .ok(); + } + } + + // Open file with symphonia + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("open: {e}"))) + .ok(); + return; + } + }; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = match symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) { + Ok(p) => p, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("probe: {e}"))) + .ok(); + return; + } + }; + + let mut format = probed.format; + let track = match format.default_track() { + Some(t) => t, + None => { + event_tx + .send(InternalEvent::Error("no audio track found".into())) + .ok(); + return; + } + }; + let track_id = track.id; + let time_base = track.codec_params.time_base; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut decoder = match symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + { + Ok(d) => d, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("decoder: {e}"))) + .ok(); + return; + } + }; + + let mut paused = false; + let mut sample_buf: Option> = None; + let mut last_position_report_ms: u64 = 0; + + loop { + // Check for control messages (non-blocking when not paused) + if paused { + // Block until we get a control message + match control_rx.recv() { + Ok(DecodeControl::Resume) => { + paused = false; + continue; + } + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Ok(DecodeControl::Pause) => continue, + Err(_) => break, + } + } else { + match control_rx.try_recv() { + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Pause) => { + paused = true; + continue; + } + Ok(DecodeControl::Resume) => {} + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => break, + } + } + + // Decode next packet + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + event_tx.send(InternalEvent::TrackEnded).ok(); + break; + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("packet: {e}"))) + .ok(); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + // Compute position in milliseconds + let position_ms = if let Some(tb) = time_base { + (packet.ts() as f64 * tb.numer as f64 / tb.denom as f64 * 1000.0) as u64 + } else { + (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64 + }; + + // Report position every ~1 second + if position_ms >= last_position_report_ms + 1000 || position_ms < last_position_report_ms { + event_tx.send(InternalEvent::Position(position_ms)).ok(); + last_position_report_ms = position_ms; + } + + // Decode the packet + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + // Skip corrupt frames + event_tx + .send(InternalEvent::Error(format!("frame: {e}"))) + .ok(); + continue; + } + }; + + // Convert to interleaved f32 and send to output + let sbuf = sample_buf.get_or_insert_with(|| { + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()) + }); + + sbuf.copy_interleaved_ref(decoded); + let chunk = sbuf.samples().to_vec(); + + if audio_tx.send(chunk).is_err() { + break; // Output stream dropped + } + } +} + +fn seek_to( + format: &mut Box, + track_id: u32, + ms: u64, + time_base: Option, + sample_rate: u32, + event_tx: &Sender, +) { + use symphonia::core::formats::{SeekMode, SeekTo}; + + let seek_ts = if let Some(tb) = time_base { + (ms as f64 / 1000.0 * tb.denom as f64 / tb.numer as f64) as u64 + } else { + (ms as f64 / 1000.0 * sample_rate as f64) as u64 + }; + + if let Err(e) = format.seek(SeekMode::Coarse, SeekTo::TimeStamp { ts: seek_ts, track_id }) { + event_tx + .send(InternalEvent::Error(format!("seek: {e}"))) + .ok(); + } +} + +// --- Output Stream (cpal) --- + +fn create_output_stream( + audio_rx: Receiver>, + mixer: Arc, + clear_buffer: Arc, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RustifyError::Audio("no default output device".into()))?; + + let config = device + .default_output_config() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + let config: cpal::StreamConfig = config.into(); + + let mut buf: VecDeque = VecDeque::with_capacity(8192); + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Check if we should clear stale audio + if clear_buffer.swap(false, Ordering::Relaxed) { + buf.clear(); + while audio_rx.try_recv().is_ok() {} + } + + let gain = mixer.gain(); + for sample in data.iter_mut() { + if buf.is_empty() { + match audio_rx.try_recv() { + Ok(chunk) => buf.extend(chunk), + Err(_) => { + *sample = 0.0; + continue; + } + } + } + *sample = buf.pop_front().unwrap_or(0.0) * gain; + } + }, + |err| { + eprintln!("cpal stream error: {err}"); + }, + None, + ) + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + stream + .play() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_state_initial_values() { + let state = SharedState::new(); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + assert!(state.current_track.lock().unwrap().is_none()); + assert_eq!(state.time_position_ms.load(Ordering::Relaxed), 0); + } + + #[test] + fn shared_state_playback_transitions() { + let state = SharedState::new(); + state.set_playback_state(PlaybackState::Playing); + assert_eq!(state.get_playback_state(), PlaybackState::Playing); + + state.set_playback_state(PlaybackState::Paused); + assert_eq!(state.get_playback_state(), PlaybackState::Paused); + + state.set_playback_state(PlaybackState::Stopped); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + } + + #[test] + fn player_new_starts_in_stopped_state() { + // This test only works if an audio device is available. + // On CI without audio, it will fail at stream creation but the + // command thread will still start with Stopped state. + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + let player = Player::new(config); + if let Ok(player) = player { + assert_eq!(player.get_playback_state(), PlaybackState::Stopped); + assert!(player.get_current_track().is_none()); + assert_eq!(player.get_time_position(), 0); + player.shutdown(); + } + } + + #[test] + fn player_volume_control_bypasses_command_thread() { + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + if let Ok(player) = Player::new(config) { + player.set_volume(75); + assert_eq!(player.get_volume(), 75); + player.shutdown(); + } + } +} From ec103e6364e0f35ed1faeca6ee39da8038d7a078 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:48:23 +0200 Subject: [PATCH 10/18] feat: add CLI player example for hardware testing --- bindings/python/rustify/__init__.py | 5 + bindings/python/rustify/py.typed | 0 bindings/python/src/client.rs | 278 ++++++++++++++++++++++++++++ bindings/python/src/lib.rs | 7 + examples/play.rs | 97 ++++++++++ 5 files changed, 387 insertions(+) create mode 100644 bindings/python/rustify/__init__.py create mode 100644 bindings/python/rustify/py.typed create mode 100644 bindings/python/src/client.rs create mode 100644 examples/play.rs diff --git a/bindings/python/rustify/__init__.py b/bindings/python/rustify/__init__.py new file mode 100644 index 0000000..a9b55f4 --- /dev/null +++ b/bindings/python/rustify/__init__.py @@ -0,0 +1,5 @@ +"""Rustify — Embedded Rust media player for YoyoPod.""" + +from rustify._rustify import RustifyClient, Track, Playlist + +__all__ = ["RustifyClient", "Track", "Playlist"] diff --git a/bindings/python/rustify/py.typed b/bindings/python/rustify/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/bindings/python/src/client.rs b/bindings/python/src/client.rs new file mode 100644 index 0000000..fd954ef --- /dev/null +++ b/bindings/python/src/client.rs @@ -0,0 +1,278 @@ +use std::path::{Path, PathBuf}; + +use pyo3::prelude::*; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::PlaybackState; +use rustify_core::{metadata, playlist, scanner, types}; + +// --- Python-facing data types --- + +#[pyclass(name = "Track")] +#[derive(Clone)] +pub struct PyTrack { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub artists: Vec, + #[pyo3(get)] + pub album: String, + #[pyo3(get)] + pub length: u64, + #[pyo3(get)] + pub track_no: Option, +} + +#[pymethods] +impl PyTrack { + fn __repr__(&self) -> String { + format!("Track(name={:?}, artists={:?})", self.name, self.artists) + } +} + +impl From for PyTrack { + fn from(t: types::Track) -> Self { + Self { + uri: t.uri, + name: t.name, + artists: t.artists, + album: t.album, + length: t.length, + track_no: t.track_no, + } + } +} + +#[pyclass(name = "Playlist")] +#[derive(Clone)] +pub struct PyPlaylist { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub track_count: usize, +} + +#[pymethods] +impl PyPlaylist { + fn __repr__(&self) -> String { + format!( + "Playlist(name={:?}, track_count={})", + self.name, self.track_count + ) + } +} + +impl From for PyPlaylist { + fn from(p: types::Playlist) -> Self { + Self { + uri: p.uri, + name: p.name, + track_count: p.track_count, + } + } +} + +// --- RustifyClient --- + +#[pyclass] +pub struct RustifyClient { + player: Player, + music_dirs: Vec, +} + +#[pymethods] +impl RustifyClient { + #[new] + #[pyo3(signature = (alsa_device = "default".to_string(), music_dirs = vec![]))] + fn new(alsa_device: String, music_dirs: Vec) -> PyResult { + let dirs: Vec = music_dirs.iter().map(PathBuf::from).collect(); + let player = Player::new(PlayerConfig { + alsa_device, + music_dirs: dirs.clone(), + }) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + Ok(Self { + player, + music_dirs: dirs, + }) + } + + // --- Transport --- + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn next_track(&self) { + self.player.next(); + } + + fn previous_track(&self) { + self.player.previous(); + } + + fn seek(&self, position_ms: u64) { + self.player.seek(position_ms); + } + + // --- Volume --- + + fn set_volume(&self, volume: u8) { + self.player.set_volume(volume); + } + + fn get_volume(&self) -> u8 { + self.player.get_volume() + } + + // --- State queries --- + + fn get_playback_state(&self) -> &'static str { + match self.player.get_playback_state() { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + } + } + + fn get_current_track(&self) -> Option { + self.player.get_current_track().map(PyTrack::from) + } + + fn get_time_position(&self) -> u64 { + self.player.get_time_position() + } + + // --- Tracklist --- + + fn load_track_uris(&self, uris: Vec) { + self.player.load_track_uris(uris); + } + + fn clear_tracklist(&self) { + self.player.clear_tracklist(); + } + + // --- Library --- + + fn browse_library(&self, path: String) -> PyResult> { + let p = types::uri_to_path(&path); + scanner::browse_directory(&p) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + fn scan_library(&self) -> PyResult> { + let mut all_uris = Vec::new(); + for dir in &self.music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => all_uris.extend(uris), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + all_uris.sort(); + Ok(all_uris) + } + + // --- Playlists --- + + fn get_playlists(&self) -> PyResult> { + let mut all_playlists = Vec::new(); + for dir in &self.music_dirs { + match playlist::find_playlists(dir) { + Ok(pls) => all_playlists.extend(pls.into_iter().map(PyPlaylist::from)), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + Ok(all_playlists) + } + + fn load_playlist(&self, path: String) -> PyResult<()> { + let uris = playlist::parse_m3u(Path::new(&path)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + self.player.load_track_uris(uris); + Ok(()) + } + + // --- Metadata --- + + fn read_metadata(&self, uri: String) -> PyResult { + metadata::read_metadata(&uri) + .map(PyTrack::from) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + // --- Callbacks --- + + fn on_track_change(&self, callback: PyObject) { + self.player + .on_track_change(Box::new(move |track: types::Track| { + Python::with_gil(|py| { + let py_track = PyTrack::from(track); + if let Err(e) = callback.call1(py, (py_track,)) { + eprintln!("Python on_track_change callback error: {e}"); + } + }); + })); + } + + fn on_state_change(&self, callback: PyObject) { + self.player + .on_state_change(Box::new(move |state: PlaybackState| { + Python::with_gil(|py| { + let state_str = match state { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + }; + if let Err(e) = callback.call1(py, (state_str,)) { + eprintln!("Python on_state_change callback error: {e}"); + } + }); + })); + } + + fn on_position_update(&self, callback: PyObject) { + self.player + .on_position_update(Box::new(move |ms: u64| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (ms,)) { + eprintln!("Python on_position_update callback error: {e}"); + } + }); + })); + } + + fn on_error(&self, callback: PyObject) { + self.player + .on_error(Box::new(move |msg: String| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (msg,)) { + eprintln!("Python on_error callback error: {e}"); + } + }); + })); + } + + // --- Lifecycle --- + + fn shutdown(&self) { + self.player.shutdown(); + } +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index fa1ac87..959dc0f 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,7 +1,14 @@ +mod client; + use pyo3::prelude::*; +use client::{PyPlaylist, PyTrack, RustifyClient}; + #[pymodule] fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/examples/play.rs b/examples/play.rs new file mode 100644 index 0000000..63b82dc --- /dev/null +++ b/examples/play.rs @@ -0,0 +1,97 @@ +use std::env; +use std::io::{self, BufRead}; +use std::path::Path; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::path_to_uri; +use rustify_core::{playlist, scanner}; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: play [--playlist] [--scan]"); + eprintln!(" play song.mp3 Play a single file"); + eprintln!(" play --scan /Music Scan directory, play all"); + eprintln!(" play --playlist mix.m3u Load M3U playlist"); + std::process::exit(1); + } + + let config = PlayerConfig { + alsa_device: "default".to_string(), + music_dirs: vec![], + }; + + let player = Player::new(config).expect("Failed to create player"); + + // Register display callbacks + player.on_state_change(Box::new(|state| { + println!("[State] {state:?}"); + })); + player.on_track_change(Box::new(|track| { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + println!("[Track] {artist} — {}", track.name); + })); + player.on_position_update(Box::new(|ms| { + let secs = ms / 1000; + let mins = secs / 60; + print!("\r[{:02}:{:02}]", mins, secs % 60); + })); + player.on_error(Box::new(|msg| { + eprintln!("[Error] {msg}"); + })); + + // Parse args and load tracks + let is_scan = args.contains(&"--scan".to_string()); + let is_playlist = args.contains(&"--playlist".to_string()); + let path_arg = args + .iter() + .find(|a| !a.starts_with('-') && *a != &args[0]) + .expect("No path provided"); + + if is_scan { + let uris = scanner::scan_directory(Path::new(path_arg)).expect("Scan failed"); + println!("Found {} tracks", uris.len()); + player.load_track_uris(uris); + } else if is_playlist { + let uris = playlist::parse_m3u(Path::new(path_arg)).expect("Playlist parse failed"); + println!("Loaded {} tracks from playlist", uris.len()); + player.load_track_uris(uris); + } else { + player.load_track_uris(vec![path_to_uri(Path::new(path_arg))]); + } + + player.play(); + + println!("Commands: play, pause, stop, next, prev, vol <0-100>, quit"); + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + match line.trim() { + "play" | "p" => player.play(), + "pause" => player.pause(), + "stop" | "s" => player.stop(), + "next" | "n" => player.next(), + "prev" => player.previous(), + "quit" | "q" => { + player.shutdown(); + break; + } + cmd if cmd.starts_with("vol ") => { + if let Ok(vol) = cmd[4..].parse::() { + player.set_volume(vol); + println!("Volume: {vol}"); + } + } + "" => {} + other => println!("Unknown command: {other}"), + } + } +} From 6528f20eb111d653b5ead0048bcf39ed3c03d694 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:53:48 +0200 Subject: [PATCH 11/18] fix: address code review findings - Remove duplicate SeekMode/SeekTo imports in player.rs - Extract AUDIO_EXTENSIONS constant to types.rs (shared by playlist + scanner) - Add Drop impl for Player to send Shutdown on drop - Log audio stream creation failures instead of silently swallowing Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/player.rs | 12 +++++++++++- crates/rustify-core/src/playlist.rs | 5 +---- crates/rustify-core/src/scanner.rs | 5 +---- crates/rustify-core/src/types.rs | 3 +++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index e96eb70..ffcd9ac 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -168,6 +168,12 @@ impl Player { } } +impl Drop for Player { + fn drop(&mut self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } +} + // --- Shared State --- struct SharedState { @@ -268,6 +274,10 @@ impl CommandLoop { let stream = create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + if let Err(ref e) = stream { + eprintln!("rustify: failed to create audio stream: {e}"); + } + Self { cmd_rx, event_rx, @@ -489,7 +499,7 @@ fn decode_thread( ) { use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::DecoderOptions; - use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; + use symphonia::core::formats::FormatOptions; use symphonia::core::io::MediaSourceStream; use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; diff --git a/crates/rustify-core/src/playlist.rs b/crates/rustify-core/src/playlist.rs index f1a481f..f6689b0 100644 --- a/crates/rustify-core/src/playlist.rs +++ b/crates/rustify-core/src/playlist.rs @@ -2,10 +2,7 @@ use std::fs; use std::path::Path; use crate::error::RustifyError; -use crate::types::{path_to_uri, Playlist}; - -/// Supported audio file extensions for playlist entries. -const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; +use crate::types::{path_to_uri, Playlist, AUDIO_EXTENSIONS}; /// Parse an M3U playlist file and return resolved file:// URIs. /// diff --git a/crates/rustify-core/src/scanner.rs b/crates/rustify-core/src/scanner.rs index 0807dba..b7f181f 100644 --- a/crates/rustify-core/src/scanner.rs +++ b/crates/rustify-core/src/scanner.rs @@ -3,10 +3,7 @@ use std::path::Path; use walkdir::WalkDir; use crate::error::RustifyError; -use crate::types::path_to_uri; - -/// Supported audio file extensions. -const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; +use crate::types::{path_to_uri, AUDIO_EXTENSIONS}; /// Recursively scan a directory for audio files. /// Returns sorted `file://` URIs for all files matching supported extensions. diff --git a/crates/rustify-core/src/types.rs b/crates/rustify-core/src/types.rs index b27213c..504e302 100644 --- a/crates/rustify-core/src/types.rs +++ b/crates/rustify-core/src/types.rs @@ -62,6 +62,9 @@ pub enum PlayerCommand { Shutdown, } +/// Supported audio file extensions. +pub const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + /// Convert a `file://` URI to a filesystem path. /// Also accepts plain paths (returned as-is). pub fn uri_to_path(uri: &str) -> PathBuf { From 627584c07b18ab3e2eb5b1d600cb2d4e86978b90 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 17:53:54 +0200 Subject: [PATCH 12/18] docs: add implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-07-rustify-core.md | 2900 +++++++++++++++++ 1 file changed, 2900 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-07-rustify-core.md diff --git a/docs/superpowers/plans/2026-04-07-rustify-core.md b/docs/superpowers/plans/2026-04-07-rustify-core.md new file mode 100644 index 0000000..1b36e3f --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-rustify-core.md @@ -0,0 +1,2900 @@ +# Rustify Core Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build rustify-core (pure Rust media player library) and PyO3 Python bindings for the YoyoPod project. + +**Architecture:** Three-thread model (command, decode, output) connected by crossbeam channels. symphonia decodes audio, cpal outputs to ALSA. A lock-free ring buffer (bounded crossbeam channel) decouples decode from output. Exposed to Python via PyO3/maturin as `RustifyClient`. + +**Tech Stack:** Rust (symphonia, cpal, crossbeam, walkdir, lofty, serde), Python (PyO3 0.23 / maturin 1.x) + +**Design Spec:** `docs/specs/2026-04-07-rustify-embedded-player-design.md` + +--- + +## File Map + +### New files (rustify-core) + +| File | Responsibility | +|---|---| +| `Cargo.toml` | Workspace root | +| `crates/rustify-core/Cargo.toml` | Core library dependencies | +| `crates/rustify-core/src/lib.rs` | Module declarations + re-exports | +| `crates/rustify-core/src/error.rs` | `RustifyError` enum, `Result` alias | +| `crates/rustify-core/src/types.rs` | `Track`, `Playlist`, `PlaybackState`, `PlayerEvent`, `PlayerCommand`, URI helpers | +| `crates/rustify-core/src/mixer.rs` | `Mixer` — atomic volume control (0-100) | +| `crates/rustify-core/src/tracklist.rs` | `Tracklist` — VecDeque-backed playback queue | +| `crates/rustify-core/src/playlist.rs` | M3U parser + playlist discovery | +| `crates/rustify-core/src/scanner.rs` | Recursive audio file discovery via walkdir | +| `crates/rustify-core/src/metadata.rs` | Tag reading via lofty, filename fallback | +| `crates/rustify-core/src/player.rs` | Playback engine: command loop, decode thread, cpal output | + +### New files (Python bindings) + +| File | Responsibility | +|---|---| +| `bindings/python/Cargo.toml` | PyO3 cdylib crate | +| `bindings/python/src/lib.rs` | `#[pymodule]` entry point | +| `bindings/python/src/client.rs` | `RustifyClient` pyclass | +| `bindings/python/rustify/__init__.py` | Re-export from native module | +| `bindings/python/rustify/py.typed` | PEP 561 marker | +| `pyproject.toml` | maturin build config | + +### Other files + +| File | Responsibility | +|---|---| +| `examples/play.rs` | Standalone CLI player for hardware testing | +| `.gitignore` | Rust/Python ignores | +| `.github/workflows/ci.yml` | Test + clippy + fmt + wheel build | + +--- + +### Task 1: Scaffold Workspace + +**Files:** +- Create: `Cargo.toml` +- Create: `crates/rustify-core/Cargo.toml` +- Create: `crates/rustify-core/src/lib.rs` +- Create: `bindings/python/Cargo.toml` +- Create: `bindings/python/src/lib.rs` +- Create: `pyproject.toml` +- Create: `.gitignore` +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create workspace root `Cargo.toml`** + +```toml +[workspace] +members = ["crates/rustify-core", "bindings/python"] +default-members = ["crates/rustify-core"] +resolver = "2" +``` + +- [ ] **Step 2: Create `crates/rustify-core/Cargo.toml`** + +```toml +[package] +name = "rustify-core" +version = "0.1.0" +edition = "2021" +description = "Embedded Rust media player library for YoyoPod" + +[dependencies] +symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "ogg", "wav", "pcm"] } +cpal = "0.15" +crossbeam = "0.8" +walkdir = "2" +lofty = "0.22" +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" +hound = "3" + +[[example]] +name = "play" +path = "../../examples/play.rs" +``` + +Note: cpal and lofty version numbers come from the design spec (April 2026). If `cargo check` fails on versions, check crates.io for the latest compatible version and adjust. + +- [ ] **Step 3: Create `crates/rustify-core/src/lib.rs`** + +```rust +// Modules will be added as they are implemented. +``` + +- [ ] **Step 4: Create `bindings/python/Cargo.toml`** + +```toml +[package] +name = "rustify-python" +version = "0.1.0" +edition = "2021" + +[lib] +name = "_rustify" +crate-type = ["cdylib"] + +[dependencies] +rustify-core = { path = "../../crates/rustify-core" } +pyo3 = { version = "0.23", features = ["extension-module"] } +``` + +- [ ] **Step 5: Create `bindings/python/src/lib.rs`** + +```rust +use pyo3::prelude::*; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + Ok(()) +} +``` + +- [ ] **Step 6: Create `pyproject.toml`** + +```toml +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "rustify" +version = "0.1.0" +description = "Embedded Rust media player for YoyoPod" +requires-python = ">=3.9" + +[tool.maturin] +features = ["pyo3/extension-module"] +manifest-path = "bindings/python/Cargo.toml" +python-source = "bindings/python" +module-name = "rustify._rustify" +``` + +- [ ] **Step 7: Create `.gitignore`** + +``` +/target/ +**/*.rs.bk +*.pdb +*.so +*.dylib +*.dll + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +*.whl + +# IDE +.idea/ +.vscode/ +*.swp +``` + +- [ ] **Step 8: Create `.github/workflows/ci.yml`** + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - run: cargo test --workspace + - run: cargo clippy -- -D warnings + - run: cargo fmt --check + + build-wheel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install ALSA dev headers + run: sudo apt-get install -y libasound2-dev + - uses: PyO3/maturin-action@v1 + with: + args: --release +``` + +- [ ] **Step 9: Verify workspace compiles** + +Run: `cargo check --workspace` +Expected: Compiles with no errors (warnings are OK at this stage). + +If cpal or lofty version is not found, check crates.io and adjust the version in `crates/rustify-core/Cargo.toml`. + +- [ ] **Step 10: Commit** + +```bash +git add Cargo.toml crates/ bindings/ pyproject.toml .gitignore .github/ +git commit -m "feat: scaffold Cargo workspace with rustify-core and Python bindings" +``` + +--- + +### Task 2: error.rs + types.rs + +**Files:** +- Create: `crates/rustify-core/src/error.rs` +- Create: `crates/rustify-core/src/types.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write tests for error.rs** + +Add to `crates/rustify-core/src/error.rs`: + +```rust +use std::fmt; +use std::io; + +/// Unified error type for all rustify-core operations. +#[derive(Debug)] +pub enum RustifyError { + /// I/O errors (file not found, permission denied, etc.) + Io(io::Error), + /// Audio decoding errors (corrupt file, unsupported codec) + Decode(String), + /// Audio output errors (device not found, ALSA error) + Audio(String), + /// Metadata reading errors (corrupt tags, unsupported format) + Metadata(String), + /// Playlist parsing errors (invalid M3U, missing files) + Playlist(String), +} + +impl fmt::Display for RustifyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "IO error: {err}"), + Self::Decode(msg) => write!(f, "decode error: {msg}"), + Self::Audio(msg) => write!(f, "audio error: {msg}"), + Self::Metadata(msg) => write!(f, "metadata error: {msg}"), + Self::Playlist(msg) => write!(f, "playlist error: {msg}"), + } + } +} + +impl std::error::Error for RustifyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl From for RustifyError { + fn from(err: io::Error) -> Self { + Self::Io(err) + } +} + +/// Result type alias for rustify-core operations. +pub type Result = std::result::Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_io_error() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "gone")); + assert!(err.to_string().contains("IO error")); + assert!(err.to_string().contains("gone")); + } + + #[test] + fn display_decode_error() { + let err = RustifyError::Decode("bad frame".into()); + assert_eq!(err.to_string(), "decode error: bad frame"); + } + + #[test] + fn from_io_error() { + let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "nope"); + let err: RustifyError = io_err.into(); + assert!(matches!(err, RustifyError::Io(_))); + } + + #[test] + fn error_source_for_io() { + let err = RustifyError::Io(io::Error::new(io::ErrorKind::NotFound, "x")); + assert!(std::error::Error::source(&err).is_some()); + } + + #[test] + fn error_source_for_non_io() { + let err = RustifyError::Decode("x".into()); + assert!(std::error::Error::source(&err).is_none()); + } +} +``` + +- [ ] **Step 2: Write types.rs** + +Create `crates/rustify-core/src/types.rs`: + +```rust +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +/// Metadata for a single audio track. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Track { + /// File URI (e.g., "file:///path/to/song.mp3") + pub uri: String, + /// Track title (falls back to filename if no tags) + pub name: String, + /// Artist names + pub artists: Vec, + /// Album name + pub album: String, + /// Duration in milliseconds + pub length: u64, + /// Track number within album + pub track_no: Option, +} + +/// Metadata about a playlist file. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Playlist { + /// File URI of the .m3u file + pub uri: String, + /// Playlist name (derived from filename) + pub name: String, + /// Number of tracks in the playlist + pub track_count: usize, +} + +/// Playback state of the player. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PlaybackState { + Stopped, + Playing, + Paused, +} + +/// Events emitted by the player to registered callbacks. +#[derive(Debug, Clone)] +pub enum PlayerEvent { + StateChanged(PlaybackState), + TrackChanged(Track), + PositionUpdate(u64), + Error(String), +} + +/// Commands sent to the player's command thread. +#[derive(Debug)] +pub enum PlayerCommand { + Play, + Pause, + Stop, + Next, + Previous, + Seek(u64), + SetVolume(u8), + LoadTrackUris(Vec), + ClearTracklist, + Shutdown, +} + +/// Convert a `file://` URI to a filesystem path. +/// Also accepts plain paths (returned as-is). +pub fn uri_to_path(uri: &str) -> PathBuf { + uri.strip_prefix("file://") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(uri)) +} + +/// Convert a filesystem path to a `file://` URI. +pub fn path_to_uri(path: &Path) -> String { + format!("file://{}", path.display()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn track_creation() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + assert_eq!(track.name, "Song"); + assert_eq!(track.length, 180_000); + } + + #[test] + fn track_serde_roundtrip() { + let track = Track { + uri: "file:///music/song.mp3".into(), + name: "Song".into(), + artists: vec!["Artist".into()], + album: "Album".into(), + length: 180_000, + track_no: Some(1), + }; + let json = serde_json::to_string(&track).unwrap(); + let decoded: Track = serde_json::from_str(&json).unwrap(); + assert_eq!(track, decoded); + } + + #[test] + fn uri_to_path_with_scheme() { + let path = uri_to_path("file:///home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn uri_to_path_plain_path() { + let path = uri_to_path("/home/pi/Music/song.mp3"); + assert_eq!(path, PathBuf::from("/home/pi/Music/song.mp3")); + } + + #[test] + fn path_to_uri_conversion() { + let uri = path_to_uri(Path::new("/home/pi/Music/song.mp3")); + assert_eq!(uri, "file:///home/pi/Music/song.mp3"); + } + + #[test] + fn playback_state_equality() { + assert_eq!(PlaybackState::Stopped, PlaybackState::Stopped); + assert_ne!(PlaybackState::Playing, PlaybackState::Paused); + } +} +``` + +- [ ] **Step 3: Wire up modules in lib.rs** + +Replace `crates/rustify-core/src/lib.rs` with: + +```rust +pub mod error; +pub mod types; +``` + +- [ ] **Step 4: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/rustify-core/src/ +git commit -m "feat: add error and types modules with tests" +``` + +--- + +### Task 3: mixer.rs + +**Files:** +- Create: `crates/rustify-core/src/mixer.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write mixer.rs with tests** + +Create `crates/rustify-core/src/mixer.rs`: + +```rust +use std::sync::atomic::{AtomicU8, Ordering}; + +/// Lock-free volume control using atomic operations. +/// Volume ranges from 0 (silent) to 100 (full). +pub struct Mixer { + volume: AtomicU8, +} + +impl Mixer { + /// Create a new mixer with the given initial volume (clamped to 0-100). + pub fn new(initial_volume: u8) -> Self { + Self { + volume: AtomicU8::new(initial_volume.min(100)), + } + } + + /// Set the volume (clamped to 0-100). + pub fn set_volume(&self, volume: u8) { + self.volume.store(volume.min(100), Ordering::Relaxed); + } + + /// Get the current volume (0-100). + pub fn get_volume(&self) -> u8 { + self.volume.load(Ordering::Relaxed) + } + + /// Get the gain multiplier (0.0 - 1.0) for applying to audio samples. + pub fn gain(&self) -> f32 { + self.get_volume() as f32 / 100.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn initial_volume() { + let mixer = Mixer::new(75); + assert_eq!(mixer.get_volume(), 75); + } + + #[test] + fn clamps_initial_volume_to_100() { + let mixer = Mixer::new(150); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn set_and_get_volume() { + let mixer = Mixer::new(50); + mixer.set_volume(80); + assert_eq!(mixer.get_volume(), 80); + } + + #[test] + fn clamps_set_volume_to_100() { + let mixer = Mixer::new(50); + mixer.set_volume(200); + assert_eq!(mixer.get_volume(), 100); + } + + #[test] + fn volume_zero() { + let mixer = Mixer::new(0); + assert_eq!(mixer.get_volume(), 0); + assert_eq!(mixer.gain(), 0.0); + } + + #[test] + fn gain_at_full_volume() { + let mixer = Mixer::new(100); + assert!((mixer.gain() - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_half_volume() { + let mixer = Mixer::new(50); + assert!((mixer.gain() - 0.5).abs() < f32::EPSILON); + } + + #[test] + fn gain_at_zero_volume() { + let mixer = Mixer::new(0); + assert!((mixer.gain() - 0.0).abs() < f32::EPSILON); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add to `crates/rustify-core/src/lib.rs`: + +```rust +pub mod error; +pub mod mixer; +pub mod types; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass (error, types, and mixer). + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/mixer.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add lock-free mixer with atomic volume control" +``` + +--- + +### Task 4: tracklist.rs + +**Files:** +- Create: `crates/rustify-core/src/tracklist.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write tracklist.rs with tests** + +Create `crates/rustify-core/src/tracklist.rs`: + +```rust +use std::collections::VecDeque; + +/// A playback queue backed by VecDeque. +/// Stores track URIs and maintains a current position index. +pub struct Tracklist { + tracks: VecDeque, + current_index: Option, +} + +impl Tracklist { + pub fn new() -> Self { + Self { + tracks: VecDeque::new(), + current_index: None, + } + } + + /// Append a single track URI to the end of the queue. + pub fn add(&mut self, uri: String) { + self.tracks.push_back(uri); + } + + /// Replace the entire tracklist with the given URIs. + /// Sets the current position to the first track if non-empty. + pub fn load(&mut self, uris: Vec) { + self.tracks.clear(); + self.tracks.extend(uris); + self.current_index = if self.tracks.is_empty() { + None + } else { + Some(0) + }; + } + + /// Remove all tracks and reset position. + pub fn clear(&mut self) { + self.tracks.clear(); + self.current_index = None; + } + + /// Get the URI of the current track, if any. + pub fn current(&self) -> Option<&str> { + self.current_index + .and_then(|i| self.tracks.get(i)) + .map(String::as_str) + } + + /// Advance to the next track and return its URI. + /// Returns None if already at the end. + pub fn next(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx + 1 < self.tracks.len() { + self.current_index = Some(idx + 1); + self.current() + } else { + None + } + } + + /// Go back to the previous track and return its URI. + /// Returns None if already at the beginning. + pub fn previous(&mut self) -> Option<&str> { + let idx = self.current_index?; + if idx > 0 { + self.current_index = Some(idx - 1); + self.current() + } else { + None + } + } + + /// Get the current track index (0-based). + pub fn index(&self) -> Option { + self.current_index + } + + /// Get the total number of tracks. + pub fn len(&self) -> usize { + self.tracks.len() + } + + /// Check if the tracklist is empty. + pub fn is_empty(&self) -> bool { + self.tracks.is_empty() + } +} + +impl Default for Tracklist { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_tracklist_is_empty() { + let tl = Tracklist::new(); + assert!(tl.is_empty()); + assert_eq!(tl.len(), 0); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn add_does_not_set_current() { + let mut tl = Tracklist::new(); + tl.add("file:///a.mp3".into()); + // add alone does not set current_index + assert_eq!(tl.len(), 1); + assert!(tl.current().is_none()); + } + + #[test] + fn load_sets_current_to_first() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + ]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.index(), Some(0)); + assert_eq!(tl.current(), Some("file:///a.mp3")); + } + + #[test] + fn load_empty_sets_none() { + let mut tl = Tracklist::new(); + tl.load(vec![]); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + } + + #[test] + fn load_replaces_existing() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.load(vec!["file:///b.mp3".into(), "file:///c.mp3".into()]); + assert_eq!(tl.len(), 2); + assert_eq!(tl.current(), Some("file:///b.mp3")); + } + + #[test] + fn clear_resets_everything() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + tl.clear(); + assert!(tl.is_empty()); + assert!(tl.current().is_none()); + assert!(tl.index().is_none()); + } + + #[test] + fn next_advances() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + assert_eq!(tl.next(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.next(), Some("file:///c.mp3")); + assert_eq!(tl.index(), Some(2)); + } + + #[test] + fn next_at_end_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.next(), None); + // Index stays at 0 (doesn't advance past end) + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn next_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.next(), None); + } + + #[test] + fn previous_goes_back() { + let mut tl = Tracklist::new(); + tl.load(vec![ + "file:///a.mp3".into(), + "file:///b.mp3".into(), + "file:///c.mp3".into(), + ]); + tl.next(); // -> b + tl.next(); // -> c + assert_eq!(tl.previous(), Some("file:///b.mp3")); + assert_eq!(tl.index(), Some(1)); + assert_eq!(tl.previous(), Some("file:///a.mp3")); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_at_start_returns_none() { + let mut tl = Tracklist::new(); + tl.load(vec!["file:///a.mp3".into()]); + assert_eq!(tl.previous(), None); + assert_eq!(tl.index(), Some(0)); + } + + #[test] + fn previous_on_empty_returns_none() { + let mut tl = Tracklist::new(); + assert_eq!(tl.previous(), None); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod tracklist;` to `crates/rustify-core/src/lib.rs` (keep alphabetical order). + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/tracklist.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add tracklist with VecDeque-backed playback queue" +``` + +--- + +### Task 5: playlist.rs + +**Files:** +- Create: `crates/rustify-core/src/playlist.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write playlist.rs with tests** + +Create `crates/rustify-core/src/playlist.rs`: + +```rust +use std::fs; +use std::path::Path; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, Playlist}; + +/// Supported audio file extensions for playlist entries. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Parse an M3U playlist file and return resolved file:// URIs. +/// +/// Handles simple M3U and extended M3U (`#EXTM3U` / `#EXTINF`). +/// Relative paths are resolved against the M3U file's parent directory. +/// Only entries with supported audio extensions are included. +pub fn parse_m3u(path: &Path) -> Result, RustifyError> { + let content = fs::read_to_string(path).map_err(|e| { + RustifyError::Playlist(format!("failed to read {}: {e}", path.display())) + })?; + + let base_dir = path + .parent() + .ok_or_else(|| RustifyError::Playlist("M3U path has no parent directory".into()))?; + + let mut uris = Vec::new(); + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let track_path = if Path::new(line).is_absolute() { + Path::new(line).to_path_buf() + } else { + base_dir.join(line) + }; + + if let Some(ext) = track_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(&track_path)); + } + } + } + + Ok(uris) +} + +/// Find all .m3u playlist files in a directory (non-recursive). +/// Returns metadata about each playlist including track count. +pub fn find_playlists(dir: &Path) -> Result, RustifyError> { + let mut playlists = Vec::new(); + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|e| e.to_str()) == Some("m3u") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let track_count = parse_m3u(&path).map(|uris| uris.len()).unwrap_or(0); + + playlists.push(Playlist { + uri: path_to_uri(&path), + name, + track_count, + }); + } + } + + Ok(playlists) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_m3u(dir: &Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + fs::write(&path, content).unwrap(); + path + } + + fn touch(dir: &Path, name: &str) { + fs::write(dir.join(name), b"").unwrap(); + } + + #[test] + fn parse_simple_m3u_absolute_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song1.mp3\n/music/song2.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + assert_eq!(uris[0], "file:///music/song1.mp3"); + assert_eq!(uris[1], "file:///music/song2.flac"); + } + + #[test] + fn parse_m3u_relative_paths() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "songs/track.mp3\n../other/track.flac\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + // Should be resolved relative to M3U directory + assert!(uris[0].contains("songs")); + assert!(uris[1].contains("other")); + } + + #[test] + fn parse_extended_m3u_skips_directives() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "#EXTM3U\n#EXTINF:123,Artist - Title\n/music/song.mp3\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert_eq!(uris[0], "file:///music/song.mp3"); + } + + #[test] + fn parse_m3u_skips_blank_lines_and_comments() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "\n# comment\n\n/music/song.mp3\n\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + } + + #[test] + fn parse_m3u_filters_unsupported_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.mp3\n/music/image.png\n/music/doc.txt\n", + ); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn parse_m3u_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "test.m3u", "/music/song.MP3\n/music/song.Flac\n"); + let uris = parse_m3u(&m3u).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn parse_m3u_nonexistent_file_returns_error() { + let result = parse_m3u(Path::new("/nonexistent/playlist.m3u")); + assert!(result.is_err()); + } + + #[test] + fn parse_empty_m3u() { + let dir = TempDir::new().unwrap(); + let m3u = create_m3u(dir.path(), "empty.m3u", ""); + let uris = parse_m3u(&m3u).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn find_playlists_in_directory() { + let dir = TempDir::new().unwrap(); + // Create M3U files with referenced tracks + create_m3u(dir.path(), "chill.m3u", "/music/a.mp3\n/music/b.flac\n"); + create_m3u(dir.path(), "rock.m3u", "/music/c.ogg\n"); + // Create a non-M3U file (should be ignored) + touch(dir.path(), "readme.txt"); + + let playlists = find_playlists(dir.path()).unwrap(); + assert_eq!(playlists.len(), 2); + + let names: Vec<&str> = playlists.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"chill")); + assert!(names.contains(&"rock")); + + let chill = playlists.iter().find(|p| p.name == "chill").unwrap(); + assert_eq!(chill.track_count, 2); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod playlist;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/playlist.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add M3U playlist parser with path resolution" +``` + +--- + +### Task 6: scanner.rs + +**Files:** +- Create: `crates/rustify-core/src/scanner.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write scanner.rs with tests** + +Create `crates/rustify-core/src/scanner.rs`: + +```rust +use std::path::Path; + +use walkdir::WalkDir; + +use crate::error::RustifyError; +use crate::types::path_to_uri; + +/// Supported audio file extensions. +const AUDIO_EXTENSIONS: &[&str] = &["mp3", "flac", "ogg", "wav"]; + +/// Recursively scan a directory for audio files. +/// Returns sorted `file://` URIs for all files matching supported extensions. +pub fn scan_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut uris = Vec::new(); + + for entry in WalkDir::new(path).follow_links(true) { + let entry = entry.map_err(|e| { + RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + })?; + + if !entry.file_type().is_file() { + continue; + } + + if let Some(ext) = entry.path().extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + uris.push(path_to_uri(entry.path())); + } + } + } + + uris.sort(); + Ok(uris) +} + +/// List the contents of a single directory (non-recursive). +/// Returns URIs for audio files and subdirectories. +pub fn browse_directory(path: &Path) -> Result, RustifyError> { + if !path.is_dir() { + return Err(RustifyError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("not a directory: {}", path.display()), + ))); + } + + let mut entries = Vec::new(); + + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_dir() { + entries.push(path_to_uri(&entry_path)); + } else if let Some(ext) = entry_path.extension().and_then(|e| e.to_str()) { + if AUDIO_EXTENSIONS + .iter() + .any(|&ae| ae.eq_ignore_ascii_case(ext)) + { + entries.push(path_to_uri(&entry_path)); + } + } + } + + entries.sort(); + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn touch(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"").unwrap(); + } + + #[test] + fn scan_finds_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("track.flac")); + touch(&dir.path().join("sound.ogg")); + touch(&dir.path().join("clip.wav")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 4); + } + + #[test] + fn scan_ignores_non_audio_files() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + touch(&dir.path().join("readme.txt")); + touch(&dir.path().join("image.png")); + touch(&dir.path().join("cover.jpg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 1); + assert!(uris[0].ends_with(".mp3")); + } + + #[test] + fn scan_recurses_into_subdirectories() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("artist1/album1/track1.mp3")); + touch(&dir.path().join("artist1/album2/track2.flac")); + touch(&dir.path().join("artist2/track3.ogg")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 3); + } + + #[test] + fn scan_returns_sorted_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("c.mp3")); + touch(&dir.path().join("a.mp3")); + touch(&dir.path().join("b.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0] < uris[1]); + assert!(uris[1] < uris[2]); + } + + #[test] + fn scan_case_insensitive_extensions() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("LOUD.MP3")); + touch(&dir.path().join("quiet.Flac")); + + let uris = scan_directory(dir.path()).unwrap(); + assert_eq!(uris.len(), 2); + } + + #[test] + fn scan_returns_file_uris() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris[0].starts_with("file://")); + } + + #[test] + fn scan_nonexistent_directory_returns_error() { + let result = scan_directory(Path::new("/nonexistent/path")); + assert!(result.is_err()); + } + + #[test] + fn scan_empty_directory() { + let dir = TempDir::new().unwrap(); + let uris = scan_directory(dir.path()).unwrap(); + assert!(uris.is_empty()); + } + + #[test] + fn browse_lists_files_and_dirs() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("song.mp3")); + fs::create_dir(dir.path().join("subdir")).unwrap(); + touch(&dir.path().join("readme.txt")); + + let entries = browse_directory(dir.path()).unwrap(); + // Should include song.mp3 and subdir, but not readme.txt + assert_eq!(entries.len(), 2); + } + + #[test] + fn browse_does_not_recurse() { + let dir = TempDir::new().unwrap(); + touch(&dir.path().join("top.mp3")); + touch(&dir.path().join("sub/nested.mp3")); + + let entries = browse_directory(dir.path()).unwrap(); + // Should only include top.mp3 and sub/ directory + assert_eq!(entries.len(), 2); + } +} +``` + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod scanner;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/scanner.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add recursive audio file scanner with browse support" +``` + +--- + +### Task 7: metadata.rs + +**Files:** +- Create: `crates/rustify-core/src/metadata.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write metadata.rs with tests** + +Create `crates/rustify-core/src/metadata.rs`: + +```rust +use std::path::Path; + +use lofty::prelude::*; +use lofty::probe::Probe; + +use crate::error::RustifyError; +use crate::types::{path_to_uri, uri_to_path, Track}; + +/// Read audio metadata from a file URI or plain path. +/// Falls back to filename-derived metadata if tags are missing. +pub fn read_metadata(uri: &str) -> Result { + let path = uri_to_path(uri); + read_metadata_from_path(&path) +} + +/// Read audio metadata from a filesystem path. +pub fn read_metadata_from_path(path: &Path) -> Result { + let tagged_file = Probe::open(path) + .map_err(|e| RustifyError::Metadata(format!("failed to open {}: {e}", path.display())))? + .read() + .map_err(|e| { + RustifyError::Metadata(format!("failed to read tags from {}: {e}", path.display())) + })?; + + let tag = tagged_file + .primary_tag() + .or_else(|| tagged_file.first_tag()); + + let name = tag + .and_then(|t| t.title().map(|s| s.to_string())) + .unwrap_or_else(|| { + path.file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown") + .to_string() + }); + + let artists = tag + .and_then(|t| t.artist().map(|s| vec![s.to_string()])) + .unwrap_or_default(); + + let album = tag + .and_then(|t| t.album().map(|s| s.to_string())) + .unwrap_or_default(); + + let track_no = tag.and_then(|t| t.track()); + + let length = tagged_file.properties().duration().as_millis() as u64; + + Ok(Track { + uri: path_to_uri(path), + name, + artists, + album, + length, + track_no, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + /// Create a minimal valid WAV file (44-byte header + 1 second of silence). + fn create_test_wav() -> NamedTempFile { + let mut file = NamedTempFile::with_suffix(".wav").unwrap(); + let sample_rate: u32 = 44100; + let channels: u16 = 1; + let bits_per_sample: u16 = 16; + let num_samples: u32 = sample_rate; // 1 second + let data_size: u32 = num_samples * (bits_per_sample / 8) as u32 * channels as u32; + let file_size: u32 = 36 + data_size; + + // RIFF header + file.write_all(b"RIFF").unwrap(); + file.write_all(&file_size.to_le_bytes()).unwrap(); + file.write_all(b"WAVE").unwrap(); + // fmt chunk + file.write_all(b"fmt ").unwrap(); + file.write_all(&16u32.to_le_bytes()).unwrap(); // chunk size + file.write_all(&1u16.to_le_bytes()).unwrap(); // PCM format + file.write_all(&channels.to_le_bytes()).unwrap(); + file.write_all(&sample_rate.to_le_bytes()).unwrap(); + let byte_rate = sample_rate * channels as u32 * (bits_per_sample / 8) as u32; + file.write_all(&byte_rate.to_le_bytes()).unwrap(); + let block_align = channels * (bits_per_sample / 8); + file.write_all(&block_align.to_le_bytes()).unwrap(); + file.write_all(&bits_per_sample.to_le_bytes()).unwrap(); + // data chunk + file.write_all(b"data").unwrap(); + file.write_all(&data_size.to_le_bytes()).unwrap(); + // Write silence + let silence = vec![0u8; data_size as usize]; + file.write_all(&silence).unwrap(); + file.flush().unwrap(); + file + } + + #[test] + fn read_metadata_from_wav_falls_back_to_filename() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + + // WAV files without tags should fall back to filename + let expected_name = wav + .path() + .file_stem() + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert_eq!(track.name, expected_name); + assert!(track.artists.is_empty()); + assert!(track.album.is_empty()); + } + + #[test] + fn read_metadata_returns_file_uri() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + assert!(track.uri.starts_with("file://")); + } + + #[test] + fn read_metadata_reports_duration() { + let wav = create_test_wav(); + let track = read_metadata_from_path(wav.path()).unwrap(); + // 1 second of audio at 44100Hz should be ~1000ms + assert!(track.length > 900 && track.length < 1100); + } + + #[test] + fn read_metadata_via_uri() { + let wav = create_test_wav(); + let uri = path_to_uri(wav.path()); + let track = read_metadata(&uri).unwrap(); + assert!(track.length > 0); + } + + #[test] + fn read_metadata_nonexistent_file_returns_error() { + let result = read_metadata("file:///nonexistent/song.mp3"); + assert!(result.is_err()); + } +} +``` + +Note: The lofty import paths may differ between versions. If `lofty::prelude::*` doesn't exist, use `lofty::file::TaggedFileExt` and `lofty::tag::Accessor` directly. If `Probe::open` doesn't exist, try `lofty::read_from_path(path)`. + +- [ ] **Step 2: Add module to lib.rs** + +Add `pub mod metadata;` to `crates/rustify-core/src/lib.rs`. + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: All tests pass. If lofty API differs, adjust the imports. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/metadata.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add metadata reader with lofty and filename fallback" +``` + +--- + +### Task 8: Wire up lib.rs with re-exports + +**Files:** +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Update lib.rs with all modules and convenience re-exports** + +Replace `crates/rustify-core/src/lib.rs` with: + +```rust +pub mod error; +pub mod metadata; +pub mod mixer; +pub mod playlist; +pub mod scanner; +pub mod tracklist; +pub mod types; + +// Re-export primary types at crate root for convenience. +pub use error::{Result, RustifyError}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; +``` + +- [ ] **Step 2: Run full test suite** + +Run: `cargo test -p rustify-core` +Expected: All tests pass (error, types, mixer, tracklist, playlist, scanner, metadata). + +- [ ] **Step 3: Run clippy** + +Run: `cargo clippy -p rustify-core -- -D warnings` +Expected: No warnings. Fix any issues before committing. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/lib.rs +git commit -m "feat: wire up all modules in lib.rs with re-exports" +``` + +--- + +### Task 9: player.rs — Playback Engine + +This is the largest task. It implements the three-thread architecture: command loop, decode thread, and cpal output. + +**Files:** +- Create: `crates/rustify-core/src/player.rs` +- Modify: `crates/rustify-core/src/lib.rs` + +- [ ] **Step 1: Write the player module with internal types** + +Create `crates/rustify-core/src/player.rs`: + +```rust +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use crossbeam::channel::{self, Receiver, Sender, TryRecvError}; + +use crate::error::RustifyError; +use crate::metadata::read_metadata_from_path; +use crate::mixer::Mixer; +use crate::tracklist::Tracklist; +use crate::types::{uri_to_path, PlaybackState, PlayerCommand, PlayerEvent, Track}; + +/// Number of audio chunks buffered between decode and output threads. +/// At ~1024 frames per chunk @ 44.1kHz stereo, each chunk is ~23ms. +/// 100 chunks provides ~2.3 seconds of buffer. +const BUFFER_CHUNKS: usize = 100; + +// --- Public API --- + +/// Configuration for creating a Player. +pub struct PlayerConfig { + pub alsa_device: String, + pub music_dirs: Vec, +} + +/// The main player handle. All methods are non-blocking — they send commands +/// to the internal command thread via a crossbeam channel. +pub struct Player { + cmd_tx: Sender, + shared: Arc, + mixer: Arc, + music_dirs: Vec, + _command_thread: Option>, +} + +impl Player { + /// Create a new player. Spawns the command thread immediately. + /// The output stream is created lazily on first `play()`. + pub fn new(config: PlayerConfig) -> Result { + let (cmd_tx, cmd_rx) = channel::unbounded::(); + let shared = Arc::new(SharedState::new()); + let mixer = Arc::new(Mixer::new(100)); + + let shared_clone = Arc::clone(&shared); + let mixer_clone = Arc::clone(&mixer); + let alsa_device = config.alsa_device.clone(); + + let handle = thread::Builder::new() + .name("rustify-cmd".into()) + .spawn(move || { + let mut cmd_loop = CommandLoop::new( + cmd_rx, + shared_clone, + mixer_clone, + alsa_device, + ); + cmd_loop.run(); + }) + .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; + + Ok(Self { + cmd_tx, + shared, + mixer, + music_dirs: config.music_dirs, + _command_thread: Some(handle), + }) + } + + // --- Transport commands (non-blocking, fire-and-forget) --- + + pub fn play(&self) { + self.cmd_tx.send(PlayerCommand::Play).ok(); + } + + pub fn pause(&self) { + self.cmd_tx.send(PlayerCommand::Pause).ok(); + } + + pub fn stop(&self) { + self.cmd_tx.send(PlayerCommand::Stop).ok(); + } + + pub fn next(&self) { + self.cmd_tx.send(PlayerCommand::Next).ok(); + } + + pub fn previous(&self) { + self.cmd_tx.send(PlayerCommand::Previous).ok(); + } + + pub fn seek(&self, position_ms: u64) { + self.cmd_tx.send(PlayerCommand::Seek(position_ms)).ok(); + } + + pub fn set_volume(&self, volume: u8) { + self.mixer.set_volume(volume); + } + + pub fn get_volume(&self) -> u8 { + self.mixer.get_volume() + } + + pub fn load_track_uris(&self, uris: Vec) { + self.cmd_tx.send(PlayerCommand::LoadTrackUris(uris)).ok(); + } + + pub fn clear_tracklist(&self) { + self.cmd_tx.send(PlayerCommand::ClearTracklist).ok(); + } + + pub fn shutdown(&self) { + self.cmd_tx.send(PlayerCommand::Shutdown).ok(); + } + + // --- State queries (read from shared atomic/mutex state) --- + + pub fn get_playback_state(&self) -> PlaybackState { + self.shared.get_playback_state() + } + + pub fn get_current_track(&self) -> Option { + self.shared.current_track.lock().unwrap().clone() + } + + pub fn get_time_position(&self) -> u64 { + self.shared.time_position_ms.load(Ordering::Relaxed) + } + + // --- Callback registration --- + + pub fn on_state_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_state_change + .push(callback); + } + + pub fn on_track_change(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_track_change + .push(callback); + } + + pub fn on_position_update(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_position_update + .push(callback); + } + + pub fn on_error(&self, callback: Box) { + self.shared + .callbacks + .lock() + .unwrap() + .on_error + .push(callback); + } +} + +// --- Shared State --- + +struct SharedState { + /// Encoded PlaybackState: 0=Stopped, 1=Playing, 2=Paused + playback_state: AtomicU8, + current_track: Mutex>, + time_position_ms: AtomicU64, + callbacks: Mutex, +} + +impl SharedState { + fn new() -> Self { + Self { + playback_state: AtomicU8::new(0), + current_track: Mutex::new(None), + time_position_ms: AtomicU64::new(0), + callbacks: Mutex::new(Callbacks::default()), + } + } + + fn get_playback_state(&self) -> PlaybackState { + match self.playback_state.load(Ordering::Relaxed) { + 1 => PlaybackState::Playing, + 2 => PlaybackState::Paused, + _ => PlaybackState::Stopped, + } + } + + fn set_playback_state(&self, state: PlaybackState) { + let val = match state { + PlaybackState::Stopped => 0, + PlaybackState::Playing => 1, + PlaybackState::Paused => 2, + }; + self.playback_state.store(val, Ordering::Relaxed); + } +} + +#[derive(Default)] +struct Callbacks { + on_state_change: Vec>, + on_track_change: Vec>, + on_position_update: Vec>, + on_error: Vec>, +} + +// --- Internal Events (decode thread -> command loop) --- + +enum InternalEvent { + TrackChanged(Track), + Position(u64), + TrackEnded, + Error(String), +} + +/// Control messages from command loop to decode thread. +enum DecodeControl { + Pause, + Resume, + Seek(u64), + Stop, +} + +struct DecodeHandle { + control_tx: Sender, + _thread: JoinHandle<()>, +} + +// --- Command Loop (runs on dedicated thread) --- + +struct CommandLoop { + cmd_rx: Receiver, + event_rx: Receiver, + event_tx: Sender, + shared: Arc, + mixer: Arc, + tracklist: Tracklist, + decode_handle: Option, + audio_tx: Sender>, + _audio_stream: Option, + clear_buffer: Arc, + alsa_device: String, +} + +impl CommandLoop { + fn new( + cmd_rx: Receiver, + shared: Arc, + mixer: Arc, + alsa_device: String, + ) -> Self { + let (event_tx, event_rx) = channel::unbounded::(); + let (audio_tx, audio_rx) = channel::bounded::>(BUFFER_CHUNKS); + let clear_buffer = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + // Create the cpal output stream + let stream = + create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + + Self { + cmd_rx, + event_rx, + event_tx, + shared, + mixer, + tracklist: Tracklist::new(), + decode_handle: None, + audio_tx, + _audio_stream: stream.ok(), + clear_buffer, + alsa_device, + } + } + + fn run(&mut self) { + loop { + crossbeam::select! { + recv(self.cmd_rx) -> cmd => { + match cmd { + Ok(PlayerCommand::Shutdown) => { + self.stop_decode(); + break; + } + Ok(cmd) => self.handle_command(cmd), + Err(_) => break, // Sender dropped + } + } + recv(self.event_rx) -> event => { + match event { + Ok(evt) => self.handle_event(evt), + Err(_) => {} // No decode thread running + } + } + } + } + } + + fn handle_command(&mut self, cmd: PlayerCommand) { + match cmd { + PlayerCommand::Play => self.handle_play(), + PlayerCommand::Pause => self.handle_pause(), + PlayerCommand::Stop => self.handle_stop(), + PlayerCommand::Next => self.handle_next(), + PlayerCommand::Previous => self.handle_previous(), + PlayerCommand::Seek(ms) => self.handle_seek(ms), + PlayerCommand::SetVolume(vol) => self.mixer.set_volume(vol), + PlayerCommand::LoadTrackUris(uris) => { + self.handle_stop(); + self.tracklist.load(uris); + } + PlayerCommand::ClearTracklist => { + self.handle_stop(); + self.tracklist.clear(); + } + PlayerCommand::Shutdown => unreachable!(), + } + } + + fn handle_event(&mut self, event: InternalEvent) { + match event { + InternalEvent::TrackChanged(track) => { + *self.shared.current_track.lock().unwrap() = Some(track.clone()); + self.emit_callbacks(PlayerEvent::TrackChanged(track)); + } + InternalEvent::Position(ms) => { + self.shared.time_position_ms.store(ms, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::PositionUpdate(ms)); + } + InternalEvent::TrackEnded => { + // Try to advance to next track + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + } else { + // End of tracklist + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + } + InternalEvent::Error(msg) => { + self.emit_callbacks(PlayerEvent::Error(msg)); + } + } + } + + fn handle_play(&mut self) { + match self.shared.get_playback_state() { + PlaybackState::Stopped => { + if let Some(uri) = self.tracklist.current() { + let uri = uri.to_string(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + PlaybackState::Paused => { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Resume).ok(); + } + self.set_state(PlaybackState::Playing); + } + PlaybackState::Playing => {} // Already playing + } + } + + fn handle_pause(&mut self) { + if self.shared.get_playback_state() == PlaybackState::Playing { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Pause).ok(); + } + self.set_state(PlaybackState::Paused); + } + } + + fn handle_stop(&mut self) { + self.stop_decode(); + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + } + + fn handle_next(&mut self) { + if let Some(uri) = self.tracklist.next() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } else { + self.handle_stop(); + } + } + + fn handle_previous(&mut self) { + if let Some(uri) = self.tracklist.previous() { + let uri = uri.to_string(); + self.stop_decode(); + self.start_decode(uri); + self.set_state(PlaybackState::Playing); + } + } + + fn handle_seek(&mut self, ms: u64) { + if let Some(ref handle) = self.decode_handle { + handle.control_tx.send(DecodeControl::Seek(ms)).ok(); + } + } + + fn start_decode(&mut self, uri: String) { + // Clear any stale audio in the buffer + self.clear_buffer + .store(true, Ordering::Relaxed); + + let (control_tx, control_rx) = channel::unbounded::(); + let audio_tx = self.audio_tx.clone(); + let event_tx = self.event_tx.clone(); + + let handle = thread::Builder::new() + .name("rustify-decode".into()) + .spawn(move || { + decode_thread(uri, audio_tx, control_rx, event_tx); + }) + .expect("failed to spawn decode thread"); + + self.decode_handle = Some(DecodeHandle { + control_tx, + _thread: handle, + }); + } + + fn stop_decode(&mut self) { + if let Some(handle) = self.decode_handle.take() { + handle.control_tx.send(DecodeControl::Stop).ok(); + // Don't join — the thread will exit when it sees Stop or channel disconnect + } + self.clear_buffer + .store(true, Ordering::Relaxed); + } + + fn set_state(&mut self, state: PlaybackState) { + self.shared.set_playback_state(state); + self.emit_callbacks(PlayerEvent::StateChanged(state)); + } + + fn emit_callbacks(&self, event: PlayerEvent) { + let callbacks = self.shared.callbacks.lock().unwrap(); + match &event { + PlayerEvent::StateChanged(state) => { + for cb in &callbacks.on_state_change { + cb(*state); + } + } + PlayerEvent::TrackChanged(track) => { + for cb in &callbacks.on_track_change { + cb(track.clone()); + } + } + PlayerEvent::PositionUpdate(ms) => { + for cb in &callbacks.on_position_update { + cb(*ms); + } + } + PlayerEvent::Error(msg) => { + for cb in &callbacks.on_error { + cb(msg.clone()); + } + } + } + } +} + +// --- Decode Thread --- + +fn decode_thread( + uri: String, + audio_tx: Sender>, + control_rx: Receiver, + event_tx: Sender, +) { + use symphonia::core::audio::SampleBuffer; + use symphonia::core::codecs::DecoderOptions; + use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo}; + use symphonia::core::io::MediaSourceStream; + use symphonia::core::meta::MetadataOptions; + use symphonia::core::probe::Hint; + + let path = uri_to_path(&uri); + + // Read metadata for TrackChanged event + match read_metadata_from_path(&path) { + Ok(track) => { + event_tx.send(InternalEvent::TrackChanged(track)).ok(); + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("metadata: {e}"))) + .ok(); + } + } + + // Open file with symphonia + let file = match std::fs::File::open(&path) { + Ok(f) => f, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("open: {e}"))) + .ok(); + return; + } + }; + + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let probed = match symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions::default(), + &MetadataOptions::default(), + ) { + Ok(p) => p, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("probe: {e}"))) + .ok(); + return; + } + }; + + let mut format = probed.format; + let track = match format.default_track() { + Some(t) => t, + None => { + event_tx + .send(InternalEvent::Error("no audio track found".into())) + .ok(); + return; + } + }; + let track_id = track.id; + let time_base = track.codec_params.time_base; + let sample_rate = track.codec_params.sample_rate.unwrap_or(44100); + + let mut decoder = match symphonia::default::get_codecs() + .make(&track.codec_params, &DecoderOptions::default()) + { + Ok(d) => d, + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("decoder: {e}"))) + .ok(); + return; + } + }; + + let mut paused = false; + let mut sample_buf: Option> = None; + let mut last_position_report_ms: u64 = 0; + + loop { + // Check for control messages (non-blocking when not paused) + if paused { + // Block until we get a control message + match control_rx.recv() { + Ok(DecodeControl::Resume) => { + paused = false; + continue; + } + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Ok(DecodeControl::Pause) => continue, + Err(_) => break, + } + } else { + match control_rx.try_recv() { + Ok(DecodeControl::Stop) => break, + Ok(DecodeControl::Pause) => { + paused = true; + continue; + } + Ok(DecodeControl::Resume) => {} + Ok(DecodeControl::Seek(ms)) => { + seek_to(&mut format, track_id, ms, time_base, sample_rate, &event_tx); + continue; + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => break, + } + } + + // Decode next packet + let packet = match format.next_packet() { + Ok(p) => p, + Err(symphonia::core::errors::Error::IoError(ref e)) + if e.kind() == std::io::ErrorKind::UnexpectedEof => + { + event_tx.send(InternalEvent::TrackEnded).ok(); + break; + } + Err(e) => { + event_tx + .send(InternalEvent::Error(format!("packet: {e}"))) + .ok(); + break; + } + }; + + if packet.track_id() != track_id { + continue; + } + + // Compute position in milliseconds + let position_ms = if let Some(tb) = time_base { + (packet.ts() as f64 * tb.numer as f64 / tb.denom as f64 * 1000.0) as u64 + } else { + (packet.ts() as f64 / sample_rate as f64 * 1000.0) as u64 + }; + + // Report position every ~1 second + if position_ms >= last_position_report_ms + 1000 || position_ms < last_position_report_ms { + event_tx.send(InternalEvent::Position(position_ms)).ok(); + last_position_report_ms = position_ms; + } + + // Decode the packet + let decoded = match decoder.decode(&packet) { + Ok(d) => d, + Err(e) => { + // Skip corrupt frames + event_tx + .send(InternalEvent::Error(format!("frame: {e}"))) + .ok(); + continue; + } + }; + + // Convert to interleaved f32 and send to output + let sbuf = sample_buf.get_or_insert_with(|| { + SampleBuffer::::new(decoded.capacity() as u64, *decoded.spec()) + }); + + sbuf.copy_interleaved_ref(decoded); + let chunk = sbuf.samples().to_vec(); + + if audio_tx.send(chunk).is_err() { + break; // Output stream dropped + } + } +} + +fn seek_to( + format: &mut Box, + track_id: u32, + ms: u64, + time_base: Option, + sample_rate: u32, + event_tx: &Sender, +) { + let seek_ts = if let Some(tb) = time_base { + (ms as f64 / 1000.0 * tb.denom as f64 / tb.numer as f64) as u64 + } else { + (ms as f64 / 1000.0 * sample_rate as f64) as u64 + }; + + if let Err(e) = format.seek(SeekMode::Coarse, SeekTo::TimeStamp { ts: seek_ts, track_id }) { + event_tx + .send(InternalEvent::Error(format!("seek: {e}"))) + .ok(); + } +} + +// --- Output Stream (cpal) --- + +fn create_output_stream( + audio_rx: Receiver>, + mixer: Arc, + clear_buffer: Arc, +) -> Result { + use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| RustifyError::Audio("no default output device".into()))?; + + let config = device + .default_output_config() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + let config: cpal::StreamConfig = config.into(); + + let mut buf: VecDeque = VecDeque::with_capacity(8192); + + let stream = device + .build_output_stream( + &config, + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + // Check if we should clear stale audio + if clear_buffer.swap(false, Ordering::Relaxed) { + buf.clear(); + while audio_rx.try_recv().is_ok() {} + } + + let gain = mixer.gain(); + for sample in data.iter_mut() { + if buf.is_empty() { + match audio_rx.try_recv() { + Ok(chunk) => buf.extend(chunk), + Err(_) => { + *sample = 0.0; + continue; + } + } + } + *sample = buf.pop_front().unwrap_or(0.0) * gain; + } + }, + |err| { + eprintln!("cpal stream error: {err}"); + }, + None, + ) + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + stream + .play() + .map_err(|e| RustifyError::Audio(e.to_string()))?; + + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + use std::time::Duration; + + #[test] + fn shared_state_initial_values() { + let state = SharedState::new(); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + assert!(state.current_track.lock().unwrap().is_none()); + assert_eq!(state.time_position_ms.load(Ordering::Relaxed), 0); + } + + #[test] + fn shared_state_playback_transitions() { + let state = SharedState::new(); + state.set_playback_state(PlaybackState::Playing); + assert_eq!(state.get_playback_state(), PlaybackState::Playing); + + state.set_playback_state(PlaybackState::Paused); + assert_eq!(state.get_playback_state(), PlaybackState::Paused); + + state.set_playback_state(PlaybackState::Stopped); + assert_eq!(state.get_playback_state(), PlaybackState::Stopped); + } + + #[test] + fn player_new_starts_in_stopped_state() { + // This test only works if an audio device is available. + // On CI without audio, it will fail at stream creation but the + // command thread will still start with Stopped state. + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + let player = Player::new(config); + // Player creation may fail without audio device — that's expected on CI. + if let Ok(player) = player { + assert_eq!(player.get_playback_state(), PlaybackState::Stopped); + assert!(player.get_current_track().is_none()); + assert_eq!(player.get_time_position(), 0); + player.shutdown(); + } + } + + #[test] + fn player_volume_control_bypasses_command_thread() { + let config = PlayerConfig { + alsa_device: "default".into(), + music_dirs: vec![], + }; + if let Ok(player) = Player::new(config) { + player.set_volume(75); + assert_eq!(player.get_volume(), 75); + player.shutdown(); + } + } +} +``` + +- [ ] **Step 2: Add player module to lib.rs** + +Add to `crates/rustify-core/src/lib.rs`: + +```rust +pub mod error; +pub mod metadata; +pub mod mixer; +pub mod player; +pub mod playlist; +pub mod scanner; +pub mod tracklist; +pub mod types; + +pub use error::{Result, RustifyError}; +pub use player::{Player, PlayerConfig}; +pub use types::{PlaybackState, PlayerCommand, PlayerEvent, Playlist, Track}; +``` + +- [ ] **Step 3: Run tests** + +Run: `cargo test -p rustify-core` +Expected: Foundation module tests pass. Player tests may skip on CI if no audio device. + +Run: `cargo clippy -p rustify-core -- -D warnings` +Fix any issues. + +- [ ] **Step 4: Commit** + +```bash +git add crates/rustify-core/src/player.rs crates/rustify-core/src/lib.rs +git commit -m "feat: add three-thread playback engine with symphonia + cpal" +``` + +--- + +### Task 10: Python Bindings + +**Files:** +- Modify: `bindings/python/src/lib.rs` +- Create: `bindings/python/src/client.rs` +- Create: `bindings/python/rustify/__init__.py` +- Create: `bindings/python/rustify/py.typed` + +- [ ] **Step 1: Write the Python-facing types and module entry point** + +Replace `bindings/python/src/lib.rs`: + +```rust +mod client; + +use pyo3::prelude::*; + +use client::{PyPlaylist, PyTrack, RustifyClient}; + +#[pymodule] +fn _rustify(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add("__version__", env!("CARGO_PKG_VERSION"))?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + Ok(()) +} +``` + +- [ ] **Step 2: Write the RustifyClient pyclass** + +Create `bindings/python/src/client.rs`: + +```rust +use std::path::{Path, PathBuf}; + +use pyo3::prelude::*; +use pyo3::types::PyString; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::PlaybackState; +use rustify_core::{metadata, playlist, scanner, types}; + +// --- Python-facing data types --- + +#[pyclass(name = "Track")] +#[derive(Clone)] +pub struct PyTrack { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub artists: Vec, + #[pyo3(get)] + pub album: String, + #[pyo3(get)] + pub length: u64, + #[pyo3(get)] + pub track_no: Option, +} + +#[pymethods] +impl PyTrack { + fn __repr__(&self) -> String { + format!("Track(name={:?}, artists={:?})", self.name, self.artists) + } +} + +impl From for PyTrack { + fn from(t: types::Track) -> Self { + Self { + uri: t.uri, + name: t.name, + artists: t.artists, + album: t.album, + length: t.length, + track_no: t.track_no, + } + } +} + +#[pyclass(name = "Playlist")] +#[derive(Clone)] +pub struct PyPlaylist { + #[pyo3(get)] + pub uri: String, + #[pyo3(get)] + pub name: String, + #[pyo3(get)] + pub track_count: usize, +} + +#[pymethods] +impl PyPlaylist { + fn __repr__(&self) -> String { + format!( + "Playlist(name={:?}, track_count={})", + self.name, self.track_count + ) + } +} + +impl From for PyPlaylist { + fn from(p: types::Playlist) -> Self { + Self { + uri: p.uri, + name: p.name, + track_count: p.track_count, + } + } +} + +// --- RustifyClient --- + +#[pyclass] +pub struct RustifyClient { + player: Player, + music_dirs: Vec, +} + +#[pymethods] +impl RustifyClient { + #[new] + #[pyo3(signature = (alsa_device = "default".to_string(), music_dirs = vec![]))] + fn new(alsa_device: String, music_dirs: Vec) -> PyResult { + let dirs: Vec = music_dirs.iter().map(PathBuf::from).collect(); + let player = Player::new(PlayerConfig { + alsa_device, + music_dirs: dirs.clone(), + }) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + + Ok(Self { + player, + music_dirs: dirs, + }) + } + + // --- Transport --- + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn next_track(&self) { + self.player.next(); + } + + fn previous_track(&self) { + self.player.previous(); + } + + fn seek(&self, position_ms: u64) { + self.player.seek(position_ms); + } + + // --- Volume --- + + fn set_volume(&self, volume: u8) { + self.player.set_volume(volume); + } + + fn get_volume(&self) -> u8 { + self.player.get_volume() + } + + // --- State queries --- + + fn get_playback_state(&self) -> &'static str { + match self.player.get_playback_state() { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + } + } + + fn get_current_track(&self) -> Option { + self.player.get_current_track().map(PyTrack::from) + } + + fn get_time_position(&self) -> u64 { + self.player.get_time_position() + } + + // --- Tracklist --- + + fn load_track_uris(&self, uris: Vec) { + self.player.load_track_uris(uris); + } + + fn clear_tracklist(&self) { + self.player.clear_tracklist(); + } + + // --- Library --- + + fn browse_library(&self, path: String) -> PyResult> { + let p = types::uri_to_path(&path); + scanner::browse_directory(&p) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + fn scan_library(&self) -> PyResult> { + let mut all_uris = Vec::new(); + for dir in &self.music_dirs { + match scanner::scan_directory(dir) { + Ok(uris) => all_uris.extend(uris), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + all_uris.sort(); + Ok(all_uris) + } + + // --- Playlists --- + + fn get_playlists(&self) -> PyResult> { + let mut all_playlists = Vec::new(); + for dir in &self.music_dirs { + match playlist::find_playlists(dir) { + Ok(pls) => all_playlists.extend(pls.into_iter().map(PyPlaylist::from)), + Err(e) => { + return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + } + } + Ok(all_playlists) + } + + fn load_playlist(&self, path: String) -> PyResult<()> { + let uris = playlist::parse_m3u(Path::new(&path)) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + self.player.load_track_uris(uris); + Ok(()) + } + + // --- Metadata --- + + fn read_metadata(&self, uri: String) -> PyResult { + metadata::read_metadata(&uri) + .map(PyTrack::from) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) + } + + // --- Callbacks --- + + fn on_track_change(&self, callback: PyObject) { + self.player + .on_track_change(Box::new(move |track: types::Track| { + Python::with_gil(|py| { + let py_track = PyTrack::from(track); + if let Err(e) = callback.call1(py, (py_track,)) { + eprintln!("Python on_track_change callback error: {e}"); + } + }); + })); + } + + fn on_state_change(&self, callback: PyObject) { + self.player + .on_state_change(Box::new(move |state: PlaybackState| { + Python::with_gil(|py| { + let state_str = match state { + PlaybackState::Playing => "playing", + PlaybackState::Paused => "paused", + PlaybackState::Stopped => "stopped", + }; + if let Err(e) = callback.call1(py, (state_str,)) { + eprintln!("Python on_state_change callback error: {e}"); + } + }); + })); + } + + fn on_position_update(&self, callback: PyObject) { + self.player + .on_position_update(Box::new(move |ms: u64| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (ms,)) { + eprintln!("Python on_position_update callback error: {e}"); + } + }); + })); + } + + fn on_error(&self, callback: PyObject) { + self.player + .on_error(Box::new(move |msg: String| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (msg,)) { + eprintln!("Python on_error callback error: {e}"); + } + }); + })); + } + + // --- Lifecycle --- + + fn shutdown(&self) { + self.player.shutdown(); + } +} +``` + +- [ ] **Step 3: Create Python package files** + +Create `bindings/python/rustify/__init__.py`: + +```python +"""Rustify — Embedded Rust media player for YoyoPod.""" + +from rustify._rustify import RustifyClient, Track, Playlist + +__all__ = ["RustifyClient", "Track", "Playlist"] +``` + +Create `bindings/python/rustify/py.typed` (empty file — PEP 561 marker): + +``` +``` + +- [ ] **Step 4: Verify Rust compilation** + +Run: `cargo check --workspace` +Expected: Both crates compile. + +- [ ] **Step 5: Commit** + +```bash +git add bindings/ pyproject.toml +git commit -m "feat: add PyO3 Python bindings with RustifyClient" +``` + +--- + +### Task 11: CLI Example + +**Files:** +- Create: `examples/play.rs` + +- [ ] **Step 1: Write the CLI player example** + +Create `examples/play.rs`: + +```rust +use std::env; +use std::io::{self, BufRead}; +use std::path::Path; + +use rustify_core::player::{Player, PlayerConfig}; +use rustify_core::types::path_to_uri; +use rustify_core::{playlist, scanner}; + +fn main() { + let args: Vec = env::args().collect(); + + if args.len() < 2 { + eprintln!("Usage: play [--playlist] [--scan]"); + eprintln!(" play song.mp3 Play a single file"); + eprintln!(" play --scan /Music Scan directory, play all"); + eprintln!(" play --playlist mix.m3u Load M3U playlist"); + std::process::exit(1); + } + + let config = PlayerConfig { + alsa_device: "default".to_string(), + music_dirs: vec![], + }; + + let player = Player::new(config).expect("Failed to create player"); + + // Register display callbacks + player.on_state_change(Box::new(|state| { + println!("[State] {state:?}"); + })); + player.on_track_change(Box::new(|track| { + let artist = if track.artists.is_empty() { + "Unknown".to_string() + } else { + track.artists.join(", ") + }; + println!("[Track] {artist} — {}", track.name); + })); + player.on_position_update(Box::new(|ms| { + let secs = ms / 1000; + let mins = secs / 60; + print!("\r[{:02}:{:02}]", mins, secs % 60); + })); + player.on_error(Box::new(|msg| { + eprintln!("[Error] {msg}"); + })); + + // Parse args and load tracks + let is_scan = args.contains(&"--scan".to_string()); + let is_playlist = args.contains(&"--playlist".to_string()); + let path_arg = args + .iter() + .find(|a| !a.starts_with('-') && *a != &args[0]) + .expect("No path provided"); + + if is_scan { + let uris = scanner::scan_directory(Path::new(path_arg)).expect("Scan failed"); + println!("Found {} tracks", uris.len()); + player.load_track_uris(uris); + } else if is_playlist { + let uris = playlist::parse_m3u(Path::new(path_arg)).expect("Playlist parse failed"); + println!("Loaded {} tracks from playlist", uris.len()); + player.load_track_uris(uris); + } else { + player.load_track_uris(vec![path_to_uri(Path::new(path_arg))]); + } + + player.play(); + + println!("Commands: play, pause, stop, next, prev, vol <0-100>, quit"); + let stdin = io::stdin(); + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + match line.trim() { + "play" | "p" => player.play(), + "pause" => player.pause(), + "stop" | "s" => player.stop(), + "next" | "n" => player.next(), + "prev" => player.previous(), + "quit" | "q" => { + player.shutdown(); + break; + } + cmd if cmd.starts_with("vol ") => { + if let Ok(vol) = cmd[4..].parse::() { + player.set_volume(vol); + println!("Volume: {vol}"); + } + } + "" => {} + other => println!("Unknown command: {other}"), + } + } +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p rustify-core --example play` +Expected: Compiles (won't run without audio device + actual files). + +- [ ] **Step 3: Commit** + +```bash +git add examples/play.rs +git commit -m "feat: add CLI player example for hardware testing" +``` + +--- + +### Task 12: Final Integration Verification + +- [ ] **Step 1: Run full Rust test suite** + +Run: `cargo test -p rustify-core` +Expected: All unit tests pass. Player tests may be skipped on headless CI. + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy --workspace -- -D warnings` +Expected: No warnings. Fix any issues. + +- [ ] **Step 3: Check formatting** + +Run: `cargo fmt --check` +Expected: All files formatted. If not, run `cargo fmt` to fix. + +- [ ] **Step 4: Verify workspace check** + +Run: `cargo check --workspace` +Expected: Both rustify-core and rustify-python crates compile. + +- [ ] **Step 5: Commit any fixes** + +```bash +git add -A +git commit -m "chore: fix clippy warnings and formatting" +``` From 308af920b5c65af3d992edeb3eed32e1a18945f3 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 18:39:39 +0200 Subject: [PATCH 13/18] fix: handle Unix-style absolute paths in M3U parser on Windows On Windows, Path::is_absolute() returns false for /music/song.mp3 (needs drive letter like C:\). Add line.starts_with('/') check so M3U files with Unix paths work cross-platform. Update test assertions to use ends_with() instead of exact URI matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/playlist.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/rustify-core/src/playlist.rs b/crates/rustify-core/src/playlist.rs index f6689b0..178d54c 100644 --- a/crates/rustify-core/src/playlist.rs +++ b/crates/rustify-core/src/playlist.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::error::RustifyError; use crate::types::{path_to_uri, Playlist, AUDIO_EXTENSIONS}; @@ -25,8 +25,10 @@ pub fn parse_m3u(path: &Path) -> Result, RustifyError> { continue; } - let track_path = if Path::new(line).is_absolute() { - Path::new(line).to_path_buf() + // Check both OS-native absolute paths and Unix-style /paths + // (the latter handles M3U files from Linux when developing on Windows) + let track_path = if Path::new(line).is_absolute() || line.starts_with('/') { + PathBuf::from(line) } else { base_dir.join(line) }; @@ -99,8 +101,9 @@ mod tests { ); let uris = parse_m3u(&m3u).unwrap(); assert_eq!(uris.len(), 2); - assert_eq!(uris[0], "file:///music/song1.mp3"); - assert_eq!(uris[1], "file:///music/song2.flac"); + assert!(uris[0].starts_with("file://")); + assert!(uris[0].ends_with("/music/song1.mp3")); + assert!(uris[1].ends_with("/music/song2.flac")); } #[test] @@ -127,7 +130,8 @@ mod tests { ); let uris = parse_m3u(&m3u).unwrap(); assert_eq!(uris.len(), 1); - assert_eq!(uris[0], "file:///music/song.mp3"); + assert!(uris[0].starts_with("file://")); + assert!(uris[0].ends_with("/music/song.mp3")); } #[test] From 17eb29efe191899eb6e4a4b271bfafee1daafe93 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 18:59:01 +0200 Subject: [PATCH 14/18] fix: let audio buffer drain on track end instead of clearing it The decode thread finishes faster than real-time (especially for WAV), so TrackEnded fires while audio is still buffered. Previously, stop_decode() cleared the buffer, cutting off playback. Now when reaching end of tracklist, we let the cpal output thread drain remaining audio naturally. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/player.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index ffcd9ac..424ca2b 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -354,8 +354,10 @@ impl CommandLoop { self.stop_decode(); self.start_decode(uri); } else { - // End of tracklist - self.stop_decode(); + // End of tracklist — let remaining audio drain naturally. + // Don't call stop_decode() which would clear the buffer + // and cut off the last seconds of audio. + self.decode_handle = None; self.set_state(PlaybackState::Stopped); *self.shared.current_track.lock().unwrap() = None; self.shared.time_position_ms.store(0, Ordering::Relaxed); From 01b66446d306ad5a906e79f02ec2f37c161338ea Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 19:39:08 +0200 Subject: [PATCH 15/18] fix: handle multi-channel audio devices in output callback The output callback was writing stereo samples one-by-one into the device buffer, which scrambles audio on devices with >2 channels (e.g. Yeti X with 8 channels). Now properly maps decoded stereo (L/R) to device channels per-frame, silencing extra channels. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/player.rs | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index 424ca2b..8f6137d 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -717,10 +717,14 @@ fn create_output_stream( .default_output_config() .map_err(|e| RustifyError::Audio(e.to_string()))?; + let device_channels = config.channels() as usize; let config: cpal::StreamConfig = config.into(); let mut buf: VecDeque = VecDeque::with_capacity(8192); + // Decoded audio is interleaved stereo (L R L R...). + // The device may have more channels (e.g. 8 on some USB audio). + // We map stereo to the first 2 device channels and silence the rest. let stream = device .build_output_stream( &config, @@ -732,17 +736,31 @@ fn create_output_stream( } let gain = mixer.gain(); - for sample in data.iter_mut() { - if buf.is_empty() { + + // Process one device frame at a time + for frame in data.chunks_mut(device_channels) { + // Pop one stereo pair (L, R) from decoded audio + let left = if buf.is_empty() { match audio_rx.try_recv() { - Ok(chunk) => buf.extend(chunk), - Err(_) => { - *sample = 0.0; - continue; + Ok(chunk) => { + buf.extend(chunk); + buf.pop_front().unwrap_or(0.0) } + Err(_) => 0.0, } + } else { + buf.pop_front().unwrap_or(0.0) + }; + let right = buf.pop_front().unwrap_or(left); + + // Map stereo to device channels, silence extras + for (i, sample) in frame.iter_mut().enumerate() { + *sample = match i { + 0 => left * gain, + 1 => right * gain, + _ => 0.0, + }; } - *sample = buf.pop_front().unwrap_or(0.0) * gain; } }, |err| { From 6ffa117828c3338c8d007457654b43b47f6262e4 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 19:49:18 +0200 Subject: [PATCH 16/18] fix: resolve all clippy warnings for CI - Add #[allow(dead_code)] on Player.music_dirs (used by Python layer) - Replace match with if-let for single-pattern event handling - Use std::io::Error::other() instead of Error::new(ErrorKind::Other) - Allow clippy::should_implement_trait on Tracklist::next() - Remove debug examples and test fixtures - Add Cargo.lock Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1681 ++++++++++++++++++++++++++ crates/rustify-core/Cargo.toml | 1 + crates/rustify-core/src/player.rs | 6 +- crates/rustify-core/src/scanner.rs | 5 +- crates/rustify-core/src/tracklist.rs | 1 + 5 files changed, 1687 insertions(+), 7 deletions(-) create mode 100644 Cargo.lock diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..236fcf8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1681 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lofty" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e" +dependencies = [ + "byteorder", + "data-encoding", + "flate2", + "lofty_attr", + "log", + "ogg_pager", + "paste", +] + +[[package]] +name = "lofty_attr" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.0", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "thiserror", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "ogg_pager" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6d1ca8364b84e0cf725eed06b1460c44671e6c0fb28765f5262de3ece07fdc" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7778bffd85cf38175ac1f545509665d0b9b92a198ca7941f131f85f7a4f9a872" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f6cbe86ef3bf18998d9df6e0f3fc1050a8c5efa409bf712e661a4366e010fb" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f1b4c431c0bb1c8fb0a338709859eed0d030ff6daa34368d3b152a63dfdd8d" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc2201328f63c4710f68abdf653c89d8dbc2858b88c5d88b0ff38a75288a9da" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fca6726ad0f3da9c9de093d6f116a93c1a38e417ed73bf138472cf4064f72028" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustify-core" +version = "0.1.0" +dependencies = [ + "cpal", + "crossbeam", + "hound", + "lofty", + "serde", + "serde_json", + "symphonia", + "tempfile", + "walkdir", +] + +[[package]] +name = "rustify-python" +version = "0.1.0" +dependencies = [ + "pyo3", + "rustify-core", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-flac", + "symphonia-bundle-mp3", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-flac" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-ogg" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb" +dependencies = [ + "log", + "symphonia-core", + "symphonia-metadata", + "symphonia-utils-xiph", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-utils-xiph" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16" +dependencies = [ + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/rustify-core/Cargo.toml b/crates/rustify-core/Cargo.toml index 0bfa8ff..b9a4327 100644 --- a/crates/rustify-core/Cargo.toml +++ b/crates/rustify-core/Cargo.toml @@ -20,3 +20,4 @@ serde_json = "1" [[example]] name = "play" path = "../../examples/play.rs" + diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index 8f6137d..22b826d 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -31,6 +31,7 @@ pub struct Player { cmd_tx: Sender, shared: Arc, mixer: Arc, + #[allow(dead_code)] // used by Python bindings layer music_dirs: Vec, _command_thread: Option>, } @@ -307,9 +308,8 @@ impl CommandLoop { } } recv(self.event_rx) -> event => { - match event { - Ok(evt) => self.handle_event(evt), - Err(_) => {} // No decode thread running + if let Ok(evt) = event { + self.handle_event(evt); } } } diff --git a/crates/rustify-core/src/scanner.rs b/crates/rustify-core/src/scanner.rs index b7f181f..a55978e 100644 --- a/crates/rustify-core/src/scanner.rs +++ b/crates/rustify-core/src/scanner.rs @@ -19,10 +19,7 @@ pub fn scan_directory(path: &Path) -> Result, RustifyError> { for entry in WalkDir::new(path).follow_links(true) { let entry = entry.map_err(|e| { - RustifyError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )) + RustifyError::Io(std::io::Error::other(e.to_string())) })?; if !entry.file_type().is_file() { diff --git a/crates/rustify-core/src/tracklist.rs b/crates/rustify-core/src/tracklist.rs index 9648d55..e2a4c23 100644 --- a/crates/rustify-core/src/tracklist.rs +++ b/crates/rustify-core/src/tracklist.rs @@ -47,6 +47,7 @@ impl Tracklist { /// Advance to the next track and return its URI. /// Returns None if already at the end. + #[allow(clippy::should_implement_trait)] pub fn next(&mut self) -> Option<&str> { let idx = self.current_index?; if idx + 1 < self.tracks.len() { From 3dbc4f743eb2c8814ab616831cadb2a3ddc68355 Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 19:50:46 +0200 Subject: [PATCH 17/18] style: apply cargo fmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- bindings/python/src/client.rs | 38 ++++++++++++---------------- crates/rustify-core/src/player.rs | 18 ++++++------- crates/rustify-core/src/playlist.rs | 17 ++++++------- crates/rustify-core/src/scanner.rs | 4 +-- crates/rustify-core/src/tracklist.rs | 5 +--- 5 files changed, 35 insertions(+), 47 deletions(-) diff --git a/bindings/python/src/client.rs b/bindings/python/src/client.rs index fd954ef..c585d1b 100644 --- a/bindings/python/src/client.rs +++ b/bindings/python/src/client.rs @@ -179,9 +179,7 @@ impl RustifyClient { for dir in &self.music_dirs { match scanner::scan_directory(dir) { Ok(uris) => all_uris.extend(uris), - Err(e) => { - return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) - } + Err(e) => return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), } } all_uris.sort(); @@ -195,9 +193,7 @@ impl RustifyClient { for dir in &self.music_dirs { match playlist::find_playlists(dir) { Ok(pls) => all_playlists.extend(pls.into_iter().map(PyPlaylist::from)), - Err(e) => { - return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) - } + Err(e) => return Err(pyo3::exceptions::PyRuntimeError::new_err(e.to_string())), } } Ok(all_playlists) @@ -249,25 +245,23 @@ impl RustifyClient { } fn on_position_update(&self, callback: PyObject) { - self.player - .on_position_update(Box::new(move |ms: u64| { - Python::with_gil(|py| { - if let Err(e) = callback.call1(py, (ms,)) { - eprintln!("Python on_position_update callback error: {e}"); - } - }); - })); + self.player.on_position_update(Box::new(move |ms: u64| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (ms,)) { + eprintln!("Python on_position_update callback error: {e}"); + } + }); + })); } fn on_error(&self, callback: PyObject) { - self.player - .on_error(Box::new(move |msg: String| { - Python::with_gil(|py| { - if let Err(e) = callback.call1(py, (msg,)) { - eprintln!("Python on_error callback error: {e}"); - } - }); - })); + self.player.on_error(Box::new(move |msg: String| { + Python::with_gil(|py| { + if let Err(e) = callback.call1(py, (msg,)) { + eprintln!("Python on_error callback error: {e}"); + } + }); + })); } // --- Lifecycle --- diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index 22b826d..816acf7 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -51,12 +51,7 @@ impl Player { let handle = thread::Builder::new() .name("rustify-cmd".into()) .spawn(move || { - let mut cmd_loop = CommandLoop::new( - cmd_rx, - shared_clone, - mixer_clone, - alsa_device, - ); + let mut cmd_loop = CommandLoop::new(cmd_rx, shared_clone, mixer_clone, alsa_device); cmd_loop.run(); }) .map_err(|e| RustifyError::Audio(format!("failed to spawn command thread: {e}")))?; @@ -272,8 +267,7 @@ impl CommandLoop { let clear_buffer = Arc::new(AtomicBool::new(false)); // Create the cpal output stream - let stream = - create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); + let stream = create_output_stream(audio_rx, Arc::clone(&mixer), Arc::clone(&clear_buffer)); if let Err(ref e) = stream { eprintln!("rustify: failed to create audio stream: {e}"); @@ -692,7 +686,13 @@ fn seek_to( (ms as f64 / 1000.0 * sample_rate as f64) as u64 }; - if let Err(e) = format.seek(SeekMode::Coarse, SeekTo::TimeStamp { ts: seek_ts, track_id }) { + if let Err(e) = format.seek( + SeekMode::Coarse, + SeekTo::TimeStamp { + ts: seek_ts, + track_id, + }, + ) { event_tx .send(InternalEvent::Error(format!("seek: {e}"))) .ok(); diff --git a/crates/rustify-core/src/playlist.rs b/crates/rustify-core/src/playlist.rs index 178d54c..e061671 100644 --- a/crates/rustify-core/src/playlist.rs +++ b/crates/rustify-core/src/playlist.rs @@ -10,9 +10,8 @@ use crate::types::{path_to_uri, Playlist, AUDIO_EXTENSIONS}; /// Relative paths are resolved against the M3U file's parent directory. /// Only entries with supported audio extensions are included. pub fn parse_m3u(path: &Path) -> Result, RustifyError> { - let content = fs::read_to_string(path).map_err(|e| { - RustifyError::Playlist(format!("failed to read {}: {e}", path.display())) - })?; + let content = fs::read_to_string(path) + .map_err(|e| RustifyError::Playlist(format!("failed to read {}: {e}", path.display())))?; let base_dir = path .parent() @@ -137,11 +136,7 @@ mod tests { #[test] fn parse_m3u_skips_blank_lines_and_comments() { let dir = TempDir::new().unwrap(); - let m3u = create_m3u( - dir.path(), - "test.m3u", - "\n# comment\n\n/music/song.mp3\n\n", - ); + let m3u = create_m3u(dir.path(), "test.m3u", "\n# comment\n\n/music/song.mp3\n\n"); let uris = parse_m3u(&m3u).unwrap(); assert_eq!(uris.len(), 1); } @@ -162,7 +157,11 @@ mod tests { #[test] fn parse_m3u_case_insensitive_extensions() { let dir = TempDir::new().unwrap(); - let m3u = create_m3u(dir.path(), "test.m3u", "/music/song.MP3\n/music/song.Flac\n"); + let m3u = create_m3u( + dir.path(), + "test.m3u", + "/music/song.MP3\n/music/song.Flac\n", + ); let uris = parse_m3u(&m3u).unwrap(); assert_eq!(uris.len(), 2); } diff --git a/crates/rustify-core/src/scanner.rs b/crates/rustify-core/src/scanner.rs index a55978e..82be27d 100644 --- a/crates/rustify-core/src/scanner.rs +++ b/crates/rustify-core/src/scanner.rs @@ -18,9 +18,7 @@ pub fn scan_directory(path: &Path) -> Result, RustifyError> { let mut uris = Vec::new(); for entry in WalkDir::new(path).follow_links(true) { - let entry = entry.map_err(|e| { - RustifyError::Io(std::io::Error::other(e.to_string())) - })?; + let entry = entry.map_err(|e| RustifyError::Io(std::io::Error::other(e.to_string())))?; if !entry.file_type().is_file() { continue; diff --git a/crates/rustify-core/src/tracklist.rs b/crates/rustify-core/src/tracklist.rs index e2a4c23..ef37709 100644 --- a/crates/rustify-core/src/tracklist.rs +++ b/crates/rustify-core/src/tracklist.rs @@ -116,10 +116,7 @@ mod tests { #[test] fn load_sets_current_to_first() { let mut tl = Tracklist::new(); - tl.load(vec![ - "file:///a.mp3".into(), - "file:///b.mp3".into(), - ]); + tl.load(vec!["file:///a.mp3".into(), "file:///b.mp3".into()]); assert_eq!(tl.len(), 2); assert_eq!(tl.index(), Some(0)); assert_eq!(tl.current(), Some("file:///a.mp3")); From 853a88f9b67511d92d71581999dc614b1b201fce Mon Sep 17 00:00:00 2001 From: att1a Date: Tue, 7 Apr 2026 19:56:04 +0200 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20address=20Codex=20review=20finding?= =?UTF-8?q?s=20=E2=80=94=20sample=20format,=20decode=20failure=20state,=20?= =?UTF-8?q?seek=20buffer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Force f32 StreamConfig instead of trusting device default format. Devices with i16/u16 defaults (common ALSA) would fail silently. 2. Add DecodeFailed event for early decode thread exits. Previously the player stayed stuck in Playing state with no active decoder when file open/probe/codec init failed. 3. Clear audio buffer on seek so pre-seek samples don't continue playing from the ring buffer after the seek completes. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/rustify-core/src/player.rs | 35 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/rustify-core/src/player.rs b/crates/rustify-core/src/player.rs index 816acf7..b7dc13b 100644 --- a/crates/rustify-core/src/player.rs +++ b/crates/rustify-core/src/player.rs @@ -222,6 +222,9 @@ enum InternalEvent { TrackChanged(Track), Position(u64), TrackEnded, + /// Decode thread failed to open/decode the track and exited. + /// Command loop must reset state to Stopped. + DecodeFailed(String), Error(String), } @@ -357,6 +360,15 @@ impl CommandLoop { self.shared.time_position_ms.store(0, Ordering::Relaxed); } } + InternalEvent::DecodeFailed(msg) => { + // Decode thread exited without producing audio. + // Reset state so the player doesn't get stuck in Playing. + self.decode_handle = None; + self.set_state(PlaybackState::Stopped); + *self.shared.current_track.lock().unwrap() = None; + self.shared.time_position_ms.store(0, Ordering::Relaxed); + self.emit_callbacks(PlayerEvent::Error(msg)); + } InternalEvent::Error(msg) => { self.emit_callbacks(PlayerEvent::Error(msg)); } @@ -420,6 +432,8 @@ impl CommandLoop { fn handle_seek(&mut self, ms: u64) { if let Some(ref handle) = self.decode_handle { + // Clear buffered pre-seek audio so it doesn't keep playing + self.clear_buffer.store(true, Ordering::Relaxed); handle.control_tx.send(DecodeControl::Seek(ms)).ok(); } } @@ -519,7 +533,7 @@ fn decode_thread( Ok(f) => f, Err(e) => { event_tx - .send(InternalEvent::Error(format!("open: {e}"))) + .send(InternalEvent::DecodeFailed(format!("open: {e}"))) .ok(); return; } @@ -540,7 +554,7 @@ fn decode_thread( Ok(p) => p, Err(e) => { event_tx - .send(InternalEvent::Error(format!("probe: {e}"))) + .send(InternalEvent::DecodeFailed(format!("probe: {e}"))) .ok(); return; } @@ -551,7 +565,7 @@ fn decode_thread( Some(t) => t, None => { event_tx - .send(InternalEvent::Error("no audio track found".into())) + .send(InternalEvent::DecodeFailed("no audio track found".into())) .ok(); return; } @@ -566,7 +580,7 @@ fn decode_thread( Ok(d) => d, Err(e) => { event_tx - .send(InternalEvent::Error(format!("decoder: {e}"))) + .send(InternalEvent::DecodeFailed(format!("decoder: {e}"))) .ok(); return; } @@ -713,12 +727,19 @@ fn create_output_stream( .default_output_device() .ok_or_else(|| RustifyError::Audio("no default output device".into()))?; - let config = device + let supported_config = device .default_output_config() .map_err(|e| RustifyError::Audio(e.to_string()))?; - let device_channels = config.channels() as usize; - let config: cpal::StreamConfig = config.into(); + let device_channels = supported_config.channels() as usize; + + // Force f32 sample format — our decode pipeline outputs f32. + // Override the device default which may be i16/u16 on some ALSA backends. + let config = cpal::StreamConfig { + channels: supported_config.channels(), + sample_rate: supported_config.sample_rate(), + buffer_size: cpal::BufferSize::Default, + }; let mut buf: VecDeque = VecDeque::with_capacity(8192);