From ca575d841efe534aeb6749cb76e22c4811420a2d Mon Sep 17 00:00:00 2001 From: Daniel Wolf Date: Tue, 29 Nov 2022 11:42:07 +0100 Subject: [PATCH] Add support for reading Ogg Vorbis files --- rhubarb/Cargo.lock | 7 + rhubarb/rhubarb-audio/Cargo.toml | 3 + rhubarb/rhubarb-audio/build.rs | 132 +++++++++ rhubarb/rhubarb-audio/src/audio_clip.rs | 3 +- rhubarb/rhubarb-audio/src/lib.rs | 2 + .../rhubarb-audio/src/ogg_audio_clip/mod.rs | 3 + .../src/ogg_audio_clip/ogg_audio_clip.rs | 133 +++++++++ .../src/ogg_audio_clip/vorbis-utils.c | 17 ++ .../src/ogg_audio_clip/vorbis_file.rs | 272 ++++++++++++++++++ .../src/ogg_audio_clip/vorbis_file_raw.rs | 65 +++++ rhubarb/rhubarb-audio/src/open_audio_file.rs | 3 +- .../tests/open_audio_file_test.rs | 26 +- .../tests/res/corrupt_file_type_txt.ogg | 3 + .../tests/res/corrupt_truncated_header.ogg | 3 + ...…‡‰‘’“”•™©±²½æ.ogg | 3 + ...filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg | 3 + .../res/filename-unicode-bmp-①∀⇨.ogg | 3 + ...filename-unicode-wide-😀🤣🙈🍨.ogg | 3 + .../rhubarb-audio/tests/res/zero-samples.ogg | 3 + 19 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 rhubarb/rhubarb-audio/build.rs create mode 100644 rhubarb/rhubarb-audio/src/ogg_audio_clip/mod.rs create mode 100644 rhubarb/rhubarb-audio/src/ogg_audio_clip/ogg_audio_clip.rs create mode 100644 rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis-utils.c create mode 100644 rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file.rs create mode 100644 rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file_raw.rs create mode 100644 rhubarb/rhubarb-audio/tests/res/corrupt_file_type_txt.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/corrupt_truncated_header.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/filename-ansi-€…‡‰‘’“”•™©±²½æ.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/filename-unicode-bmp-①∀⇨.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/filename-unicode-wide-😀🤣🙈🍨.ogg create mode 100644 rhubarb/rhubarb-audio/tests/res/zero-samples.ogg diff --git a/rhubarb/Cargo.lock b/rhubarb/Cargo.lock index 14bf39d..ba8076f 100644 --- a/rhubarb/Cargo.lock +++ b/rhubarb/Cargo.lock @@ -29,6 +29,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "cc" +version = "1.0.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4" + [[package]] name = "cfg-if" version = "1.0.0" @@ -315,6 +321,7 @@ version = "0.1.0" dependencies = [ "assert_matches", "byteorder", + "cc", "demonstrate", "derivative", "dyn-clone", diff --git a/rhubarb/rhubarb-audio/Cargo.toml b/rhubarb/rhubarb-audio/Cargo.toml index 33e3871..52a5fff 100644 --- a/rhubarb/rhubarb-audio/Cargo.toml +++ b/rhubarb/rhubarb-audio/Cargo.toml @@ -16,3 +16,6 @@ demonstrate = "0.4.5" rstest = "0.15.0" rstest_reuse = "0.4.0" speculoos = "0.10.0" + +[build-dependencies] +cc = "1.0.77" diff --git a/rhubarb/rhubarb-audio/build.rs b/rhubarb/rhubarb-audio/build.rs new file mode 100644 index 0000000..65580d5 --- /dev/null +++ b/rhubarb/rhubarb-audio/build.rs @@ -0,0 +1,132 @@ +use std::{ + env::var_os, + fs::remove_dir_all, + path::{Path, PathBuf}, + process::Command, +}; + +fn exec(current_dir: impl AsRef, command: &str, args: &[&str]) { + let exit_status = Command::new(command) + .args(args) + .current_dir(current_dir) + .status() + .unwrap(); + let exit_code = exit_status.code().unwrap(); + assert_eq!(exit_code, 0); +} + +fn checkout(parent_dir: impl AsRef, git_url: &str, refname: &str, dir_name: &str) -> PathBuf { + let repo_dir = parent_dir.as_ref().join(dir_name); + + if repo_dir.exists() && !repo_dir.join(".git").join("config").exists() { + // The repository directory exists, but is not a valid Git repository. + // Delete and check out from scratch. + remove_dir_all(&repo_dir).unwrap(); + } + + if repo_dir.exists() { + // If the repo directory's contents got corrupted, Git may not consider it a valid + // repository, applying the following commands to the Rhubarb repository instead. + // Prevent any resulting data loss this by failing if the repo was modified. + exec(&repo_dir, "git", &["diff", "--exit-code"]); + + exec(&repo_dir, "git", &["reset", "--hard"]); + exec(&repo_dir, "git", &["fetch"]); + exec(&repo_dir, "git", &["checkout", refname]); + } else { + exec( + &parent_dir, + "git", + &["clone", "--branch", refname, git_url, dir_name], + ); + }; + + repo_dir +} + +struct OggBuildResult { + ogg_include_dir: PathBuf, +} + +fn build_ogg(parent_dir: impl AsRef) -> OggBuildResult { + let repo_dir = checkout( + &parent_dir, + "https://github.com/xiph/ogg.git", + "v1.3.5", + "ogg", + ); + let include_dir = repo_dir.join("include"); + let src_dir = repo_dir.join("src"); + cc::Build::new() + .include(&include_dir) + .files(["bitwise.c", "framing.c"].map(|name| src_dir.join(name))) + .compile("ogg"); + + println!("cargo:rustc-link-lib=static=ogg"); + OggBuildResult { + ogg_include_dir: include_dir, + } +} + +struct VorbisBuildResult { + vorbis_utils_path: PathBuf, +} + +fn build_vorbis( + parent_dir: impl AsRef, + ogg_include_dir: impl AsRef, +) -> VorbisBuildResult { + let repo_dir = checkout( + &parent_dir, + "https://github.com/xiph/vorbis.git", + "v1.3.7", + "vorbis", + ); + let include_dir = repo_dir.join("include"); + let src_dir = repo_dir.join("lib"); + let vorbis_utils_path = + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/ogg_audio_clip/vorbis-utils.c"); + cc::Build::new() + .include(&include_dir) + .include(&ogg_include_dir) + .files( + [ + "bitrate.c", + "block.c", + "codebook.c", + "envelope.c", + "floor0.c", + "floor1.c", + "info.c", + "lpc.c", + "lsp.c", + "mapping0.c", + "mdct.c", + "psy.c", + "registry.c", + "res0.c", + "sharedbook.c", + "smallft.c", + "synthesis.c", + "vorbisfile.c", + "window.c", + ] + .iter() + .map(|name| src_dir.join(name)), + ) + .file(&vorbis_utils_path) + .compile("vorbis"); + + println!("cargo:rustc-link-lib=static=vorbis"); + VorbisBuildResult { vorbis_utils_path } +} + +fn main() { + let out_dir = Path::new(&var_os("OUT_DIR").unwrap()).to_path_buf(); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + + let OggBuildResult { ogg_include_dir } = build_ogg(&out_dir); + let VorbisBuildResult { vorbis_utils_path } = build_vorbis(&out_dir, ogg_include_dir); + + println!("cargo:rerun-if-changed={}", vorbis_utils_path.display()); +} diff --git a/rhubarb/rhubarb-audio/src/audio_clip.rs b/rhubarb/rhubarb-audio/src/audio_clip.rs index 2527549..e73170d 100644 --- a/rhubarb/rhubarb-audio/src/audio_clip.rs +++ b/rhubarb/rhubarb-audio/src/audio_clip.rs @@ -1,6 +1,7 @@ use crate::audio_error::AudioError; use dyn_clone::DynClone; -use std::{fmt::Debug, time::Duration}; +use std::fmt::Debug; +use std::time::Duration; const NANOS_PER_SEC: u32 = 1_000_000_000; diff --git a/rhubarb/rhubarb-audio/src/lib.rs b/rhubarb/rhubarb-audio/src/lib.rs index f8b5a0f..e5382e9 100644 --- a/rhubarb/rhubarb-audio/src/lib.rs +++ b/rhubarb/rhubarb-audio/src/lib.rs @@ -14,6 +14,7 @@ mod audio_clip; mod audio_error; +mod ogg_audio_clip; mod open_audio_file; mod read_and_seek; mod sample_reader_assertions; @@ -21,6 +22,7 @@ mod wave_audio_clip; pub use audio_clip::{AudioClip, Sample, SampleReader}; pub use audio_error::AudioError; +pub use ogg_audio_clip::ogg_audio_clip::OggAudioClip; pub use open_audio_file::{open_audio_file, open_audio_file_with_reader}; pub use read_and_seek::ReadAndSeek; pub use wave_audio_clip::wave_audio_clip::WaveAudioClip; diff --git a/rhubarb/rhubarb-audio/src/ogg_audio_clip/mod.rs b/rhubarb/rhubarb-audio/src/ogg_audio_clip/mod.rs new file mode 100644 index 0000000..26558cd --- /dev/null +++ b/rhubarb/rhubarb-audio/src/ogg_audio_clip/mod.rs @@ -0,0 +1,3 @@ +pub mod ogg_audio_clip; +mod vorbis_file; +mod vorbis_file_raw; diff --git a/rhubarb/rhubarb-audio/src/ogg_audio_clip/ogg_audio_clip.rs b/rhubarb/rhubarb-audio/src/ogg_audio_clip/ogg_audio_clip.rs new file mode 100644 index 0000000..aba2683 --- /dev/null +++ b/rhubarb/rhubarb-audio/src/ogg_audio_clip/ogg_audio_clip.rs @@ -0,0 +1,133 @@ +use std::{io, pin::Pin, sync::Arc}; + +use derivative::Derivative; + +use crate::{ + sample_reader_assertions::SampleReaderAssertions, AudioClip, AudioError, ReadAndSeek, + SampleReader, +}; + +use super::vorbis_file::{Metadata, VorbisFile}; + +/// An audio clip read on the fly from an Ogg Vorbis file. +#[derive(Derivative)] +#[derivative(Debug)] +pub struct OggAudioClip { + metadata: Metadata, + #[derivative(Debug = "ignore")] + create_reader: Arc Result>, +} + +impl OggAudioClip +where + TReader: ReadAndSeek + 'static, +{ + /// Creates a new `OggAudioClip` for the reader returned by the given callback function. + pub fn new( + create_reader: Box Result>, + ) -> Result { + let mut vorbis_file = VorbisFile::new(create_reader()?)?; + let metadata = vorbis_file.metadata()?; + Ok(Self { + metadata, + create_reader: Arc::from(create_reader), + }) + } +} + +impl Clone for OggAudioClip { + fn clone(&self) -> Self { + Self { + metadata: self.metadata, + create_reader: self.create_reader.clone(), + } + } +} + +impl AudioClip for OggAudioClip +where + TReader: ReadAndSeek + 'static, +{ + fn len(&self) -> u64 { + self.metadata.frame_count + } + + fn sampling_rate(&self) -> u32 { + self.metadata.sampling_rate + } + + fn create_sample_reader(&self) -> Result, AudioError> { + Ok(Box::new(OggFileSampleReader::new(self)?)) + } +} + +#[derive(Derivative)] +#[derivative(Debug)] +struct OggFileSampleReader { + #[derivative(Debug = "ignore")] + vorbis_file: Pin>, + metadata: Metadata, + // the position we're claiming to be at + logical_position: u64, + // the position we're actually at + physical_position: u64, +} + +impl OggFileSampleReader { + fn new(audio_clip: &OggAudioClip) -> Result + where + TReader: ReadAndSeek + 'static, + { + let vorbis_file = VorbisFile::new((audio_clip.create_reader)()?)?; + Ok(Self { + vorbis_file, + metadata: audio_clip.metadata, + logical_position: 0, + physical_position: 0, + }) + } +} + +impl SampleReader for OggFileSampleReader { + fn len(&self) -> u64 { + self.metadata.frame_count + } + + fn position(&self) -> u64 { + self.logical_position + } + + fn set_position(&mut self, position: u64) { + self.assert_valid_seek_position(position); + self.logical_position = position + } + + fn read(&mut self, buffer: &mut [crate::Sample]) -> Result<(), AudioError> { + self.assert_valid_read_size(buffer); + + if self.physical_position != self.logical_position { + self.vorbis_file.seek(self.logical_position)?; + self.physical_position = self.logical_position; + } + + let mut remaining_buffer = buffer; + let factor = 1.0f32 / self.metadata.channel_count as f32; + while !remaining_buffer.is_empty() { + let read_frame_count = self.vorbis_file.read( + remaining_buffer, + |channels, frame_count, target_buffer| { + // Downmix channels to output buffer + for frame_index in 0..frame_count as usize { + let mut sum = 0f32; + for channel in channels { + sum += channel[frame_index]; + } + target_buffer[frame_index] = sum * factor; + } + }, + )?; + remaining_buffer = remaining_buffer.split_at_mut(read_frame_count as usize).1; + } + Ok(()) + } +} diff --git a/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis-utils.c b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis-utils.c new file mode 100644 index 0000000..8971771 --- /dev/null +++ b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis-utils.c @@ -0,0 +1,17 @@ +#include +#include +#include + +// Creates an OggVorbis_File structure on the heap so that the caller doesn't need to know its size +extern OggVorbis_File* vu_create_oggvorbisfile() { + return malloc(sizeof (OggVorbis_File)); +} + +extern void vu_free_oggvorbisfile(OggVorbis_File* vf) { + ov_clear(vf); // never fails + free(vf); +} + +extern const int vu_seek_origin_start = SEEK_SET; +extern const int vu_seek_origin_current = SEEK_CUR; +extern const int vu_seek_origin_end = SEEK_END; diff --git a/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file.rs b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file.rs new file mode 100644 index 0000000..c905d77 --- /dev/null +++ b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file.rs @@ -0,0 +1,272 @@ +use std::{ + fmt::Debug, + io::{self, Read, Seek, SeekFrom}, + mem::MaybeUninit, + pin::Pin, + ptr::{null, null_mut}, + slice, +}; + +use std::os::raw::{c_int, c_long, c_void}; + +use crate::{AudioError, ReadAndSeek, Sample}; + +use super::vorbis_file_raw::{ + self as raw, ov_info, ov_open_callbacks, ov_pcm_seek, ov_pcm_total, ov_read_float, + vu_create_oggvorbisfile, vu_free_oggvorbisfile, vu_seek_origin_current, vu_seek_origin_end, + vu_seek_origin_start, +}; + +/// A safe wrapper around the vorbisfile API. +pub struct VorbisFile { + vf: *mut raw::OggVorbisFile, + reader: Box, + last_error: Option, + cached_metadata: Option, +} + +impl VorbisFile { + pub fn new(reader: impl ReadAndSeek + 'static) -> Result>, AudioError> { + let vf = unsafe { vu_create_oggvorbisfile() }; + assert!(!vf.is_null(), "Error creating raw OggVorbisFile."); + + // Pin the struct so that the pointer we pass for the callbacks stays valid + let mut vorbis_file = Pin::new(Box::new(Self { + vf, + reader: Box::new(reader), + last_error: None, + cached_metadata: None, + })); + + let callbacks = raw::Callbacks { + read_func, + seek_func: Some(seek_func), + close_func: None, // The reader will be closed by Rust + tell_func: Some(tell_func), + }; + unsafe { + let result_code = ov_open_callbacks( + &*vorbis_file as *const Self as *mut c_void, + vf, + null(), + 0, + callbacks, + ); + vorbis_file.handle_error(result_code)?; + } + Ok(vorbis_file) + } + + pub fn metadata(&mut self) -> Result { + if self.cached_metadata.is_some() { + return Ok(self.cached_metadata.unwrap()); + } + + unsafe { + let vorbis_info = ov_info(self.vf, -1); + assert!(!vorbis_info.is_null(), "Error retrieving Vorbis info."); + + let metadata = Metadata { + frame_count: self.handle_error(ov_pcm_total(self.vf, -1))? as u64, + sampling_rate: (*vorbis_info).sampling_rate as u32, + channel_count: (*vorbis_info).channel_count as u32, + }; + self.cached_metadata = Some(metadata); + Ok(metadata) + } + } + + pub fn seek(&mut self, position: u64) -> Result<(), AudioError> { + unsafe { + self.handle_error(ov_pcm_seek(self.vf, position as i64)) + .map(|_| ()) + } + } + + pub fn read( + &mut self, + target_buffer: &mut [Sample], + callback: impl Fn(&Vec<&[Sample]>, u64, &mut [Sample]), + ) -> Result { + let channel_count = self.metadata()?.channel_count; + + unsafe { + // Read to multi-channel buffer + let mut buffer = MaybeUninit::uninit(); + let read_frame_count = self.handle_error(ov_read_float( + self.vf, + buffer.as_mut_ptr(), + target_buffer.len().clamp(0, i32::MAX as usize) as i32, + null_mut(), + ))? as u64; + let multi_channel_buffer = buffer.assume_init(); + + // Transform to vector of slices + let mut channels = Vec::<&[Sample]>::new(); + for channel_index in 0..channel_count as usize { + let channel_buffer = *multi_channel_buffer.add(channel_index); + channels.push(slice::from_raw_parts( + channel_buffer, + read_frame_count as usize, + )); + } + + callback(&channels, read_frame_count, target_buffer); + Ok(read_frame_count) + } + } + + fn handle_error(&mut self, result_code: T) -> Result + where + T: Copy + TryInto + PartialOrd + Default, + >::Error: Debug, + { + // Constants from vorbis's codec.h file + const OV_HOLE: c_int = -3; + const OV_EREAD: c_int = -128; + const OV_EFAULT: c_int = -129; + const OV_EIMPL: c_int = -130; + const OV_EINVAL: c_int = -131; + const OV_ENOTVORBIS: c_int = -132; + const OV_EBADHEADER: c_int = -133; + const OV_EVERSION: c_int = -134; + const OV_ENOTAUDIO: c_int = -135; + const OV_EBADPACKET: c_int = -136; + const OV_EBADLINK: c_int = -137; + const OV_ENOSEEK: c_int = -138; + + if result_code >= Default::default() { + // A non-negative value is always valid + return Ok(result_code); + } + + let error_code: i32 = result_code.try_into().expect("Error code out of range."); + if error_code == OV_HOLE { + // OV_HOLE, though technically an error code, is only informational + return Ok(result_code); + } + + // If we captured a Rust error object, it is probably more precise than an error code + if let Some(last_error) = self.last_error.take() { + return Err(AudioError::IoError(last_error)); + } + + // The call failed. Handle the error. + match error_code { + OV_EREAD => Err(AudioError::IoError(io::Error::new( + io::ErrorKind::Other, + "Read error while fetching compressed data for decoding.".to_owned(), + ))), + OV_EFAULT => panic!("Internal logic fault; indicates a bug or heap/stack corruption."), + OV_EIMPL => panic!("Feature not implemented."), + OV_EINVAL => panic!( + "Either an invalid argument, or incompletely initialized argument passed to a call." + ), + OV_ENOTVORBIS => Err(AudioError::CorruptFile( + "The given file was not recognized as Ogg Vorbis data.".to_owned(), + )), + OV_EBADHEADER => Err(AudioError::CorruptFile( + "Ogg Vorbis stream contains a corrupted or undecipherable header.".to_owned(), + )), + + OV_EVERSION => Err(AudioError::UnsupportedFileFeature( + "Unsupported bit stream format revision.".to_owned(), + )), + OV_ENOTAUDIO => Err(AudioError::UnsupportedFileFeature( + "Packet is not an audio packet.".to_owned(), + )), + OV_EBADPACKET => Err(AudioError::CorruptFile("Error in packet.".to_owned())), + OV_EBADLINK => Err(AudioError::CorruptFile( + "Link in Vorbis data stream is not decipherable due to garbage or corruption." + .to_owned(), + )), + OV_ENOSEEK => { + // This would indicate a bug, since we're implementing the stream ourselves + panic!("The given stream is not seekable."); + } + _ => panic!("An unexpected Vorbis error with code {error_code} occurred."), + } + } +} + +impl Drop for VorbisFile { + fn drop(&mut self) { + unsafe { + vu_free_oggvorbisfile(self.vf); + } + } +} + +#[derive(Copy, Clone, Debug)] +pub struct Metadata { + pub frame_count: u64, + pub sampling_rate: u32, + pub channel_count: u32, +} + +unsafe extern "C" fn read_func( + buffer: *mut c_void, + element_size: usize, + element_count: usize, + data_source: *mut c_void, +) -> usize { + let vorbis_file = data_source.cast::(); + (*vorbis_file).last_error = None; + + let requested_byte_count = element_count * element_size; + let mut remaining_buffer = slice::from_raw_parts_mut(buffer.cast::(), requested_byte_count); + while !remaining_buffer.is_empty() { + let read_result = (*vorbis_file).reader.read(remaining_buffer); + match read_result { + Ok(read_bytes) => { + if read_bytes == 0 { + break; + } + remaining_buffer = remaining_buffer.split_at_mut(read_bytes).1; + } + Err(error) => { + (*vorbis_file).last_error = Some(error); + break; + } + } + } + requested_byte_count - remaining_buffer.len() +} + +unsafe extern "C" fn seek_func(data_source: *mut c_void, offset: i64, seek_origin: c_int) -> c_int { + let vorbis_file = data_source.cast::(); + (*vorbis_file).last_error = None; + + let seek_from = if seek_origin == vu_seek_origin_start { + SeekFrom::Start(offset as u64) + } else if seek_origin == vu_seek_origin_current { + SeekFrom::Current(offset) + } else if seek_origin == vu_seek_origin_end { + SeekFrom::End(offset) + } else { + panic!("Invalid seek origin {seek_origin}."); + }; + + let seek_result = (*vorbis_file).reader.seek(seek_from); + match seek_result { + Ok(_) => 0, + Err(error) => { + (*vorbis_file).last_error = Some(error); + -1 + } + } +} + +unsafe extern "C" fn tell_func(data_source: *mut c_void) -> c_long { + let vorbis_file = data_source.cast::(); + (*vorbis_file).last_error = None; + + let position_result = (*vorbis_file).reader.stream_position(); + match position_result { + Ok(position) => position as c_long, + Err(error) => { + (*vorbis_file).last_error = Some(error); + -1 + } + } +} diff --git a/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file_raw.rs b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file_raw.rs new file mode 100644 index 0000000..bee4aa5 --- /dev/null +++ b/rhubarb/rhubarb-audio/src/ogg_audio_clip/vorbis_file_raw.rs @@ -0,0 +1,65 @@ +use std::os::raw::{c_int, c_long, c_void}; + +// Raw FFI to the vorbisfile API + +#[link(name = "vorbis")] +extern "C" { + // vorbisfile functions + + pub fn ov_open_callbacks( + data_source: *mut c_void, + vf: *mut OggVorbisFile, + initial: *const u8, + ibytes: c_long, + callbacks: Callbacks, + ) -> c_int; + pub fn ov_pcm_total(vf: *mut OggVorbisFile, i: c_int) -> i64; + pub fn ov_pcm_seek(vf: *mut OggVorbisFile, pos: i64) -> c_int; + pub fn ov_info(vf: *mut OggVorbisFile, link: c_int) -> *const VorbisInfo; + pub fn ov_read_float( + vf: *mut OggVorbisFile, + pcm_channels: *mut *const *const f32, + samples: c_int, + bitstream: *mut c_int, + ) -> c_long; + + // Functions and constants defined by us in vorbis-utils.c + + pub fn vu_create_oggvorbisfile() -> *mut OggVorbisFile; + pub fn vu_free_oggvorbisfile(vf: *mut OggVorbisFile); + pub static vu_seek_origin_start: c_int; + pub static vu_seek_origin_current: c_int; + pub static vu_seek_origin_end: c_int; +} + +#[repr(C)] +pub struct OggVorbisFile { + private: [u8; 0], +} + +#[repr(C)] +pub struct Callbacks { + pub read_func: unsafe extern "C" fn( + buffer: *mut c_void, + element_size: usize, + element_count: usize, + data_source: *mut c_void, + ) -> usize, + pub seek_func: Option< + unsafe extern "C" fn(data_source: *mut c_void, offset: i64, seek_origin: c_int) -> c_int, + >, + pub close_func: Option c_int>, + pub tell_func: Option c_long>, +} + +#[repr(C)] +pub struct VorbisInfo { + pub encoder_version: c_int, + pub channel_count: c_int, + pub sampling_rate: c_int, + pub bitrate_upper: c_long, + pub bitrate_nominal: c_long, + pub bitrate_lower: c_long, + pub bitrate_window: c_long, + codec_setup: *mut c_void, +} diff --git a/rhubarb/rhubarb-audio/src/open_audio_file.rs b/rhubarb/rhubarb-audio/src/open_audio_file.rs index e92559d..493db00 100644 --- a/rhubarb/rhubarb-audio/src/open_audio_file.rs +++ b/rhubarb/rhubarb-audio/src/open_audio_file.rs @@ -4,7 +4,7 @@ use std::{ path::PathBuf, }; -use crate::{AudioClip, AudioError, ReadAndSeek, WaveAudioClip}; +use crate::{AudioClip, AudioError, OggAudioClip, ReadAndSeek, WaveAudioClip}; /// Creates an audio clip from the specified file. pub fn open_audio_file(path: impl Into) -> Result, AudioError> { @@ -30,6 +30,7 @@ where .map(|e| e.to_os_string().into_string().unwrap_or_default()); match lower_case_extension.as_deref() { Some("wav") => Ok(Box::new(WaveAudioClip::new(create_reader)?)), + Some("ogg") => Ok(Box::new(OggAudioClip::new(create_reader)?)), _ => Err(AudioError::UnsupportedFileType), } } diff --git a/rhubarb/rhubarb-audio/tests/open_audio_file_test.rs b/rhubarb/rhubarb-audio/tests/open_audio_file_test.rs index 123cbb0..25956be 100644 --- a/rhubarb/rhubarb-audio/tests/open_audio_file_test.rs +++ b/rhubarb/rhubarb-audio/tests/open_audio_file_test.rs @@ -60,6 +60,7 @@ mod open_audio_file { #[case::wav_f32_ffmpeg ("sine-triangle-f32-ffmpeg.wav", 48000, sine_triangle_1_khz, 2.0f32.powi(-21))] #[case::wav_f32_soundforge ("sine-triangle-f32-soundforge.wav", 48000, sine_triangle_1_khz, 2.0f32.powi(-21))] #[case::wav_f64_ffmpeg ("sine-triangle-f64-ffmpeg.wav", 48000, sine_triangle_1_khz, 2.0f32.powi(-21))] + #[case::ogg ("sine-triangle.ogg", 48000, sine_triangle_1_khz, 2.0f32.powi(-3))] // lossy fn supported_audio_files( #[case] file_name: &str, #[case] sampling_rate: u32, @@ -72,6 +73,10 @@ mod open_audio_file { "sine-triangle-i16-audacity.wav", "WaveAudioClip { wave_file_info: WaveFileInfo { sample_format: I16, channel_count: 2, sampling_rate: 48000, frame_count: 480000, bytes_per_frame: 4, data_offset: 44 } }" )] + #[case::ogg( + "sine-triangle.ogg", + "OggAudioClip { metadata: Metadata { frame_count: 480000, sampling_rate: 48000, channel_count: 2 } }" + )] fn supports_debug(#[case] file_name: &str, #[case] expected: &str) { let path = get_resource_file_path(file_name); let audio_clip = open_audio_file(path).unwrap(); @@ -83,6 +88,10 @@ mod open_audio_file { "sine-triangle-i16-audacity.wav", "WaveFileSampleReader { wave_file_info: WaveFileInfo { sample_format: I16, channel_count: 2, sampling_rate: 48000, frame_count: 480000, bytes_per_frame: 4, data_offset: 44 }, logical_position: 0, physical_position: None }" )] + #[case::ogg( + "sine-triangle.ogg", + "OggFileSampleReader { metadata: Metadata { frame_count: 480000, sampling_rate: 48000, channel_count: 2 }, logical_position: 0, physical_position: 0 }" + )] fn sample_reader_supports_debug(#[case] file_name: &str, #[case] expected: &str) { let path = get_resource_file_path(file_name); let audio_clip = open_audio_file(path).unwrap(); @@ -216,7 +225,6 @@ mod open_audio_file { } #[rstest] - #[case::ogg("sine-triangle.ogg")] #[case::mp3("sine-triangle.mp3")] #[case::flac("sine-triangle.flac")] fn fails_when_opening_file_of_unsupported_type(#[case] file_name: &str) { @@ -244,6 +252,7 @@ mod open_audio_file { #[rstest] #[case::wav("no-such-file.wav")] + #[case::ogg("no-such-file.ogg")] fn fails_if_file_does_not_exist(#[case] file_name: &str) { let path = get_resource_file_path(file_name); let result = open_audio_file(path); @@ -262,6 +271,14 @@ mod open_audio_file { )] #[case::wav_truncated_header("corrupt_truncated_header.wav", "Unexpected end of file.")] #[case::wav_truncated_data("corrupt_truncated_data.wav", "Unexpected end of file.")] + #[case::ogg_file_type_txt( + "corrupt_file_type_txt.ogg", + "The given file was not recognized as Ogg Vorbis data." + )] + #[case::ogg_truncated_header( + "corrupt_truncated_header.ogg", + "The given file was not recognized as Ogg Vorbis data." + )] fn fails_if_file_is_corrupt(#[case] file_name: &str, #[case] expected_message: &str) { let path = get_resource_file_path(file_name); let result = open_audio_file(path) @@ -280,6 +297,10 @@ mod open_audio_file { #[case::wav_ansi("filename-ansi-€…‡‰‘’“”•™©±²½æ.wav")] #[case::wav_unicode_bmp("filename-unicode-bmp-①∀⇨.wav")] #[case::wav_unicode_wide("filename-unicode-wide-😀🤣🙈🍨.wav")] + #[case::ogg_ascii("filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg")] + #[case::ogg_ansi("filename-ansi-€…‡‰‘’“”•™©±²½æ.ogg")] + #[case::ogg_unicode_bmp("filename-unicode-bmp-①∀⇨.ogg")] + #[case::ogg_unicode_wide("filename-unicode-wide-😀🤣🙈🍨.ogg")] fn supports_special_characters_in_file_names(#[case] file_name: &str) { let path = get_resource_file_path(file_name); let audio_clip = open_audio_file(path).unwrap(); @@ -291,6 +312,7 @@ mod open_audio_file { #[rstest] #[case::wav("zero-samples.wav")] + #[case::wav("zero-samples.ogg")] fn supports_zero_sample_files(#[case] file_name: &str) { let path = get_resource_file_path(file_name); let audio_clip = open_audio_file(path).unwrap(); @@ -344,10 +366,12 @@ mod open_audio_file_with_reader { #[rstest] #[case::wav_not_found("sine-triangle-i16-audacity.wav", io::ErrorKind::NotFound)] + #[case::ogg_not_found("sine-triangle.ogg", io::ErrorKind::NotFound)] #[case::wav_permission_denied( "sine-triangle-i16-audacity.wav", io::ErrorKind::PermissionDenied )] + #[case::ogg_permission_denied("sine-triangle.ogg", io::ErrorKind::PermissionDenied)] fn fails_on_io_errors(#[case] file_name: &'static str, #[case] error_kind: io::ErrorKind) { let next_error_kind = Rc::new(RefCell::new(None)); let audio_clip = { diff --git a/rhubarb/rhubarb-audio/tests/res/corrupt_file_type_txt.ogg b/rhubarb/rhubarb-audio/tests/res/corrupt_file_type_txt.ogg new file mode 100644 index 0000000..2f4c623 --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/corrupt_file_type_txt.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9a66978f378456c818fb8a3e7c6ad3d2c83e62724ccbdea7b36253fb8df5edd +size 11 diff --git a/rhubarb/rhubarb-audio/tests/res/corrupt_truncated_header.ogg b/rhubarb/rhubarb-audio/tests/res/corrupt_truncated_header.ogg new file mode 100644 index 0000000..dd858ae --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/corrupt_truncated_header.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11c29253ef4b081d14eae149ede1a6b972735166fafe70c97bf6b8e608788e4a +size 144 diff --git a/rhubarb/rhubarb-audio/tests/res/filename-ansi-€…‡‰‘’“”•™©±²½æ.ogg b/rhubarb/rhubarb-audio/tests/res/filename-ansi-€…‡‰‘’“”•™©±²½æ.ogg new file mode 100644 index 0000000..efcff5c --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/filename-ansi-€…‡‰‘’“”•™©±²½æ.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0684aecb92870be3fe92dd1fb4e1b1801e0e36d35e2701333bc8508fa75bb4a2 +size 73343 diff --git a/rhubarb/rhubarb-audio/tests/res/filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg b/rhubarb/rhubarb-audio/tests/res/filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg new file mode 100644 index 0000000..efcff5c --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/filename-ascii !#$%&'()+,-.;=@[]^_`{}~.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0684aecb92870be3fe92dd1fb4e1b1801e0e36d35e2701333bc8508fa75bb4a2 +size 73343 diff --git a/rhubarb/rhubarb-audio/tests/res/filename-unicode-bmp-①∀⇨.ogg b/rhubarb/rhubarb-audio/tests/res/filename-unicode-bmp-①∀⇨.ogg new file mode 100644 index 0000000..efcff5c --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/filename-unicode-bmp-①∀⇨.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0684aecb92870be3fe92dd1fb4e1b1801e0e36d35e2701333bc8508fa75bb4a2 +size 73343 diff --git a/rhubarb/rhubarb-audio/tests/res/filename-unicode-wide-😀🤣🙈🍨.ogg b/rhubarb/rhubarb-audio/tests/res/filename-unicode-wide-😀🤣🙈🍨.ogg new file mode 100644 index 0000000..efcff5c --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/filename-unicode-wide-😀🤣🙈🍨.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0684aecb92870be3fe92dd1fb4e1b1801e0e36d35e2701333bc8508fa75bb4a2 +size 73343 diff --git a/rhubarb/rhubarb-audio/tests/res/zero-samples.ogg b/rhubarb/rhubarb-audio/tests/res/zero-samples.ogg new file mode 100644 index 0000000..2f36146 --- /dev/null +++ b/rhubarb/rhubarb-audio/tests/res/zero-samples.ogg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2e124bb4752bbe7c80c97e59e5c57cd8b2b6e55b7623cc9f43dfea09a8403ff +size 3353