Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
140 changes: 59 additions & 81 deletions src/audio/sample_source/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,14 @@ impl AudioSampleSource {
start_time: Option<std::time::Duration>,
buffer_size: usize,
) -> Result<Self, SampleSourceError> {
// Open the file
let file = File::open(&path)?;
// Open the file (include path in error so user sees which file failed)
let path_ref = path.as_ref();
let file = File::open(path_ref).map_err(|e| {
SampleSourceError::IoError(std::io::Error::new(
e.kind(),
format!("{}: {}", path_ref.display(), e),
))
})?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());

// Create a hint to help the format registry guess the format
Expand Down Expand Up @@ -257,6 +263,45 @@ impl AudioSampleSource {
}
}

/// Reads and decodes the next packet for the given track. Handles ResetRequired by
/// resetting the decoder and retrying. Returns `Ok(Some((samples, channels)))` when
/// a packet was decoded, `Ok(None)` on EOF, or `Err` on other errors.
fn read_and_decode_next_packet_for_track(
format_reader: &mut dyn FormatReader,
decoder: &mut dyn symphonia::core::codecs::Decoder,
track_id: u32,
) -> Result<Option<(Vec<f32>, usize)>, SampleSourceError> {
loop {
let packet = match Self::read_next_packet(format_reader) {
Ok(Some(packet)) => packet,
Ok(None) => return Ok(None),
Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => {
decoder.reset();
continue;
}
Err(e) => return Err(e),
};
if packet.track_id() != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(SymphoniaError::ResetRequired) => {
decoder.reset();
match decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(e) => return Err(SampleSourceError::AudioError(e)),
}
}
Err(e) => return Err(SampleSourceError::AudioError(e)),
};
let (samples, channels) = Self::decode_buffer_to_f32(decoded)?;
if channels > 0 && !samples.is_empty() {
return Ok(Some((samples, channels)));
}
}
}

/// Refills the sample buffer by reading a chunk from the audio file
fn refill_buffer(&mut self) -> Result<(), SampleSourceError> {
// Clear the buffer and reset position
Expand Down Expand Up @@ -292,53 +337,22 @@ impl AudioSampleSource {
// those as "no progress" would cause us to bail out early and never see
// the real audio data.
loop {
// Read the next packet
let packet = match Self::read_next_packet(self.format_reader.as_mut()) {
Ok(Some(packet)) => packet,
Ok(None) => {
// EOF reached
break;
}
Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => {
// The codec needs to be reset after a discontinuity (e.g., after seeking).
// Reset the decoder and continue reading the next packet.
self.decoder.reset();
continue;
}
let (samples, _decoded_channels) = match Self::read_and_decode_next_packet_for_track(
self.format_reader.as_mut(),
self.decoder.as_mut(),
self.track_id,
) {
Ok(Some((samples, ch))) => (samples, ch),
Ok(None) => break,
Err(e) => {
// For very small files, some errors might indicate EOF
// Check if we've read any samples - if not, this might be a false error
if samples_read == 0 && self.sample_buffer.is_empty() {
break;
}
return Err(e);
}
};

// Only process packets from the track we're interested in
if packet.track_id() != self.track_id {
continue;
}

// Decode the packet
let decoded = match self.decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(SymphoniaError::ResetRequired) => {
// The codec needs to be reset. Reset and retry decoding the same packet.
self.decoder.reset();
match self.decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(e) => return Err(SampleSourceError::AudioError(e)),
}
}
Err(e) => return Err(SampleSourceError::AudioError(e)),
};

// Convert the decoded buffer to f32 samples. Channel count is
// established during construction; here we only care about the
// sample data.
let (samples, _decoded_channels) = Self::decode_buffer_to_f32(decoded)?;

// Add samples to the buffer
if !samples.is_empty() {
let remaining = target_samples.saturating_sub(samples_read);
Expand Down Expand Up @@ -396,48 +410,12 @@ impl AudioSampleSource {
decoder: &mut dyn symphonia::core::codecs::Decoder,
track_id: u32,
) -> Result<(u16, Vec<f32>), SampleSourceError> {
loop {
let packet = match Self::read_next_packet(format_reader) {
Ok(Some(packet)) => packet,
Ok(None) => {
// EOF reached
break;
}
Err(SampleSourceError::AudioError(SymphoniaError::ResetRequired)) => {
// The codec needs to be reset after a discontinuity.
// Reset the decoder and continue reading the next packet.
decoder.reset();
continue;
}
Err(e) => return Err(e),
};

if packet.track_id() != track_id {
continue;
}

let decoded = match decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(SymphoniaError::ResetRequired) => {
// The codec needs to be reset. Reset and retry decoding the same packet.
decoder.reset();
match decoder.decode(&packet) {
Ok(decoded) => decoded,
Err(e) => return Err(SampleSourceError::AudioError(e)),
}
}
Err(e) => return Err(SampleSourceError::AudioError(e)),
};

let (samples, channels) = Self::decode_buffer_to_f32(decoded)?;
if channels > 0 && !samples.is_empty() {
return Ok((channels as u16, samples));
}
match Self::read_and_decode_next_packet_for_track(format_reader, decoder, track_id)? {
Some((samples, channels)) => Ok((channels as u16, samples)),
None => Err(SampleSourceError::SampleConversionFailed(
"Channels not specified".to_string(),
)),
}

Err(SampleSourceError::SampleConversionFailed(
"Channels not specified".to_string(),
))
}

/// Converts a decoded AudioBufferRef to a Vec<f32> of interleaved samples
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
mod audio;
mod controller;
mod dmx;
mod error;
pub mod lighting;
#[cfg(test)]
pub mod midi;
Expand All @@ -35,6 +36,7 @@ pub use self::controller::OscController;
pub use self::controller::DEFAULT_GRPC_PORT;
pub use self::dmx::Dmx;
pub use self::dmx::Universe;
pub use self::error::ConfigError;
pub use self::lighting::Lighting;
pub use self::midi::Midi;
pub use self::midi::MidiTransformer;
Expand Down
21 changes: 21 additions & 0 deletions src/config/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (C) 2026 Michael Wilson <mike@mdwn.dev>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, version 3.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//

/// Typed error for config load/parse failures so callers can distinguish
/// e.g. file-not-found from parse errors without string matching.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Config load/parse error: {0}")]
Load(#[from] config::ConfigError),
}
6 changes: 4 additions & 2 deletions src/config/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ use super::trackmappings::TrackMappings;
use config::{Config, File};
use serde::Deserialize;
use std::collections::HashMap;
use std::error::Error;
use std::path::{Path, PathBuf};

use super::error::ConfigError;
use std::error::Error;
use tracing::{error, info};

/// The configuration for the multitrack player.
Expand Down Expand Up @@ -92,7 +94,7 @@ impl Player {
}

/// Deserializes a file from the path into a player configuration struct.
pub fn deserialize(path: &Path) -> Result<Player, Box<dyn Error>> {
pub fn deserialize(path: &Path) -> Result<Player, ConfigError> {
Ok(Config::builder()
.add_source(File::from(path))
.build()?
Expand Down
6 changes: 4 additions & 2 deletions src/config/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.
//
use std::{error::Error, path::Path};
use std::path::Path;

use config::{Config, File};
use serde::Deserialize;

use super::error::ConfigError;

/// The configuration for a playlist.
#[derive(Deserialize)]
pub struct Playlist {
Expand All @@ -32,7 +34,7 @@ impl Playlist {
}

/// Parse a playlist from a YAML file.
pub fn deserialize(path: &Path) -> Result<Playlist, Box<dyn Error>> {
pub fn deserialize(path: &Path) -> Result<Playlist, ConfigError> {
Ok(Config::builder()
.add_source(File::from(path))
.build()?
Expand Down
2 changes: 1 addition & 1 deletion src/config/song.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ impl Song {
}

/// Deserializes a file from the path into a song configuration struct.
pub fn deserialize(path: &Path) -> Result<Song, Box<dyn Error>> {
pub fn deserialize(path: &Path) -> Result<Song, crate::config::ConfigError> {
Ok(Config::builder()
.add_source(File::from(path))
.build()?
Expand Down
Loading