Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion anni-playback/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

- Make `decoder::CODEC_REGISTRY` public
- Upgraded `ratatui` used by example
- Upgraded `ratatui` used by example
- Added configurable `Player` and `AnniPlayer` builders plus playback/cache statistics
- Fixed partial ring-buffer reads, device format negotiation, and mono/multichannel output mapping
- Added a software output gate so pause works on backends without hardware stream pausing
- Added decoder-confirmed state/error events and deterministic shutdown/stop behavior
- Made cache keys codec/quality aware and cache completion atomic
- Added bounded multi-packet preloading and exact resampler delay/padding trimming
- Added sample-accurate seek trimming and separate source/output buffering statistics
- Key annil cache entries by the GET-resolved bitrate/codec and validate downloads before publish
91 changes: 81 additions & 10 deletions anni-playback/Readme.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,86 @@
# anni-playback

A simple audio playback library based on [SimpleAudio](https://github.com/erikas-taroza/simple_audio).
An audio playback library built on Symphonia, Rubato, and CPAL. It provides a
low-level configurable `Player` and an annil-aware `AnniPlayer` with variant-safe
on-disk caching.

## What's the difference?
## Configurable player

We've changed the following parts:
```rust,no_run
use std::time::Duration;
use anni_playback::{DecodeSettings, Player, PlayerEvent};

1. Removed Media Control
As it is a simple playback library, controls should be implemented outside it.
2. Removed flutter_rust_bridge related code
3. Removed `update_*` callbacks and setters
It can be implemented by simply listening events emitted by `Control::event_handler()`.
4. Removed the `Player`
Users of this library can follow the example and write their own `Player` struct.
# fn main() -> anyhow::Result<()> {
let (player, events) = Player::builder()
.buffer_duration(Duration::from_millis(750))
.preferred_sample_rate(Some(48_000))
.decode_settings(DecodeSettings {
gapless: true,
verify: false,
recover_decode_errors: true,
max_consecutive_errors: 8,
..Default::default()
})
.build()?;

player.open_file("track.flac", false)?;
player.play();

while let Ok(event) = events.recv() {
match event {
PlayerEvent::Error(error) => eprintln!("{error:?}"),
PlayerEvent::Stop => break,
_ => {}
}
}

let stats = player.stats();
println!(
"buffer={}ms underruns={} decoded_frames={}",
stats.buffered_duration_ms(),
stats.underruns,
stats.decoded_frames,
);
# Ok(())
# }
```

`build()` opens the output device immediately and returns device/configuration
errors to the caller. `build_lazy()` defers that work until `play()`, which is
useful for headless decoding or applications that may start before an audio
device is available; later output failures arrive as `PlayerEvent::Error`.

`PlayerConfig` is split into `OutputSettings`, `DecodeSettings`, and
`PreloadSettings`. The cheap snapshot returned by `stats()` includes buffer
occupancy, source-vs-output buffering, underruns, dropped/output samples,
decoded and preloaded packets/frames, recoverable decode errors, and the actual
source/output formats.

## Preload and gapless playback

Queue the next source with `open_file(path, true)` (or `AnniPlayer::preload`).
The decoder prepares multiple packets until `PreloadSettings::target_duration`
is satisfied and emits `PreloadReady`. At the current track's natural end it
flushes the exact resampler tail and appends the next track to the same PCM
ring; each track still receives a fresh converter, so sample-rate/channel
changes cannot reuse stale DSP state. `play_preloaded()` performs an immediate
manual switch and intentionally discards the current buffered tail.

## annil player

`AnniPlayer::builder(provider, cache_path)` accepts the same playback settings,
an optional HTTP client, and a network timeout. Use `AudioVariant` to select a
codec and quality explicitly. Cache entries include both values, so AAC, Opus,
and lossless representations cannot alias each other.

annil currently maps `Low`, `Medium`, and `High` to 128, 192, and 256 kbps;
`AudioQuality::bitrate_kbps()` exposes that mapping. `open_variant()` and
`preload()` return the effective `AudioVariant`, because annil may enforce a
different quality for guest access. Cache files are keyed by that effective
quality and the GET response codec (rather than trusting HEAD alone), validated
before publication, and incomplete downloads remain private `.part` files.

The legacy `Controls`/`Decoder` construction API and the legacy
`AnniPlayer::new` constructor remain available for compatibility. New code
should prefer the builders because initialization errors are returned instead
of being hidden on the decoder thread.
67 changes: 23 additions & 44 deletions anni-playback/examples/gapless.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,6 @@
use std::{ops::Deref, sync::mpsc::Receiver, thread};
use std::thread;

use anni_playback::{create_unbound_channel, types::PlayerEvent, Controls, Decoder};

pub struct Player {
controls: Controls,
}

impl Player {
pub fn new() -> (Player, Receiver<PlayerEvent>) {
let (sender, receiver) = std::sync::mpsc::channel();
let controls = Controls::new(sender);
let thread_killer = create_unbound_channel();

thread::spawn({
let controls = controls.clone();
move || {
let decoder = Decoder::new(controls, 48000, thread_killer.1.clone());
decoder.start();
}
});

(Player { controls }, receiver)
}
}

impl Deref for Player {
type Target = Controls;

fn deref(&self) -> &Self::Target {
&self.controls
}
}
use anni_playback::{types::PlayerEvent, Player};

fn main() -> anyhow::Result<()> {
let (Some(first), Some(second), Some(third)) = (
Expand All @@ -42,32 +12,38 @@ fn main() -> anyhow::Result<()> {
std::process::exit(1);
};

let (player, receiver) = Player::new();
let (player, receiver) = Player::builder()
.preferred_sample_rate(Some(48_000))
.build()?;

let thread = thread::spawn({
let controls = player.controls.clone();
let controls = player.controls().clone();
let mut third = Some(third);

move || loop {
match receiver.recv() {
Ok(msg) => match msg {
PlayerEvent::Play => {
println!("Play");

// Preload the second track after first track has started playing
let _ = controls.open_file(second.clone(), true);
PlayerEvent::Ready(progress) => {
println!("Ready: {} ms", progress.duration)
}
PlayerEvent::Play => println!("Play"),
PlayerEvent::Pause => println!("Pause"),
PlayerEvent::PreloadPlayed => {
println!("PreloadPlayed");

// The second track is played, load the third track
// FIXME: only load once
let _ = controls.open_file(third.clone(), true);
if let Some(third) = third.take() {
let _ = controls.open_file(third, true);
}
}
PlayerEvent::PreloadReady => println!("PreloadReady"),
PlayerEvent::EndOfTrack => println!("EndOfTrack"),
PlayerEvent::Buffering(buffering) => println!("Buffering: {buffering}"),
PlayerEvent::Error(error) => eprintln!("Playback error: {error:?}"),
PlayerEvent::Progress(progress) => {
println!("Progress: {}/{}", progress.position, progress.duration);
}
PlayerEvent::Stop => println!("Stop"),
PlayerEvent::Stop => break,
_ => {}
},
Err(e) => {
eprintln!("{}", e);
Expand All @@ -77,8 +53,11 @@ fn main() -> anyhow::Result<()> {
});

player.open_file(first, false)?;
player.open_file(second, true)?;
player.play();
thread.join().unwrap();
thread
.join()
.map_err(|_| anyhow::anyhow!("player event thread panicked"))?;

Ok(())
}
50 changes: 16 additions & 34 deletions anni-playback/examples/player.rs
Original file line number Diff line number Diff line change
@@ -1,56 +1,36 @@
use std::{ops::Deref, sync::mpsc::Receiver, thread};
use std::thread;

use anni_playback::{create_unbound_channel, types::PlayerEvent, Controls, Decoder};

pub struct Player {
controls: Controls,
}

impl Player {
pub fn new() -> (Player, Receiver<PlayerEvent>) {
let (sender, receiver) = std::sync::mpsc::channel();
let controls = Controls::new(sender);
let thread_killer = create_unbound_channel();

thread::spawn({
let controls = controls.clone();
move || {
let decoder = Decoder::new(controls, 48000, thread_killer.1.clone());
decoder.start();
}
});

(Player { controls }, receiver)
}
}

impl Deref for Player {
type Target = Controls;

fn deref(&self) -> &Self::Target {
&self.controls
}
}
use anni_playback::{types::PlayerEvent, Player};

fn main() -> anyhow::Result<()> {
let Some(filename) = std::env::args().nth(1) else {
println!("Please provide the filename to play!");
std::process::exit(1);
};

let (player, receiver) = Player::new();
let (player, receiver) = Player::builder()
.preferred_sample_rate(Some(48_000))
.build()?;

let thread = thread::spawn({
move || loop {
match receiver.recv() {
Ok(msg) => match msg {
PlayerEvent::Ready(progress) => {
println!("Ready: {} ms", progress.duration)
}
PlayerEvent::Play => println!("Play"),
PlayerEvent::Pause => println!("Pause"),
PlayerEvent::PreloadPlayed => println!("PreloadPlayed"),
PlayerEvent::PreloadReady => println!("PreloadReady"),
PlayerEvent::EndOfTrack => println!("EndOfTrack"),
PlayerEvent::Buffering(buffering) => println!("Buffering: {buffering}"),
PlayerEvent::Error(error) => eprintln!("Playback error: {error:?}"),
PlayerEvent::Progress(progress) => {
println!("Progress: {}/{}", progress.position, progress.duration);
}
PlayerEvent::Stop => break,
_ => {}
},
Err(e) => {
eprintln!("{}", e);
Expand All @@ -61,7 +41,9 @@ fn main() -> anyhow::Result<()> {

player.open_file(filename, false)?;
player.play();
thread.join().unwrap();
thread
.join()
.map_err(|_| anyhow::anyhow!("player event thread panicked"))?;

Ok(())
}
Loading