Skip to content

feat: implement rustify-core media player library#1

Merged
attmous merged 18 commits intomainfrom
claude/practical-lalande
Apr 7, 2026
Merged

feat: implement rustify-core media player library#1
attmous merged 18 commits intomainfrom
claude/practical-lalande

Conversation

@attmous
Copy link
Copy Markdown
Owner

@attmous attmous commented Apr 7, 2026

Summary

  • Scaffold Cargo workspace with rustify-core (pure Rust) + bindings/python (PyO3 cdylib), pyproject.toml, CI workflow
  • Core modules: error types, Track/Playlist/PlaybackState types, atomic mixer, VecDeque tracklist, M3U playlist parser, recursive file scanner, lofty metadata reader
  • Player engine: three-thread architecture (command loop, symphonia decode, cpal output) connected by crossbeam channels, callback-driven events, lock-free volume control
  • Python bindings: RustifyClient pyclass with full transport, tracklist, library, playlist, metadata, and callback API
  • CLI example: interactive play.rs for hardware testing on Pi

~1,900 LOC Rust + 56 unit tests across 8 modules. Replaces Mopidy + GStreamer (~70-80MB) with a single .so targeting ~3-4MB RAM.

Test plan

  • cargo test --workspace passes on dev machine
  • cargo clippy -- -D warnings clean
  • maturin develop && python -c "from rustify import RustifyClient" smoke test
  • Play an MP3 file via cargo run --example play -- song.mp3
  • Cross-compile and test on Pi Zero 2W with ALSA output

🤖 Generated with Claude Code

attmous and others added 12 commits April 7, 2026 17:30
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 627584c07b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

let stream = device
.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle non-f32 output sample formats

The output callback is hard-coded to &mut [f32], but the stream config is taken from default_output_config() and converted to StreamConfig without branching on the device sample format. On systems where the default output format is i16/u16 (common with ALSA backends), build_output_stream will fail, so playback silently has no audio even though the player instance is created.

Useful? React with 👍 / 👎.

Comment on lines +525 to +528
event_tx
.send(InternalEvent::Error(format!("open: {e}")))
.ok();
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Emit terminal state when track open fails

When opening a track fails, the decode thread sends an Error event and returns, but no terminal event (TrackEnded/state update) is emitted. Since handle_play already set state to Playing, the command loop can remain stuck in Playing with no active decoder, and subsequent play() calls become no-ops until an explicit stop/reset.

Useful? React with 👍 / 👎.

Comment on lines +425 to +427
fn handle_seek(&mut self, ms: u64) {
if let Some(ref handle) = self.decode_handle {
handle.control_tx.send(DecodeControl::Seek(ms)).ok();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Flush buffered audio when seeking

handle_seek only forwards DecodeControl::Seek to the decode thread and does not trigger buffer clearing. Because the output callback flushes stale samples only when clear_buffer is set (currently on start/stop), seeking while playing can continue outputting pre-seek audio from the queue for a noticeable interval before the new position is heard.

Useful? React with 👍 / 👎.

attmous and others added 6 commits April 7, 2026 18:39
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ate, seek buffer

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) <noreply@anthropic.com>
@attmous attmous merged commit a624db9 into main Apr 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant