From 27ee2efd00f67f0f245ee91f56a2503bda163a28 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 17 Oct 2026 14:02:25 +0200 Subject: [PATCH] refactor: clean separation between lib and CLI --- Cargo.toml | 43 +++++++---- raust-cli/Cargo.toml | 17 +++++ raust-cli/src/main.rs | 43 +++++++++++ raust-engine/Cargo.toml | 20 ++++++ raust-engine/src/audio_player.rs | 120 +++++++++++++++++++++++++++++++ raust-engine/src/init.rs | 44 ++++++++++++ raust-engine/src/lib.rs | 24 +++++++ rust-toolchain.toml | 4 +- src/lib.rs | 98 ------------------------- src/main.rs | 5 -- 10 files changed, 300 insertions(+), 118 deletions(-) create mode 100644 raust-cli/Cargo.toml create mode 100644 raust-cli/src/main.rs create mode 100644 raust-engine/Cargo.toml create mode 100644 raust-engine/src/audio_player.rs create mode 100644 raust-engine/src/init.rs create mode 100644 raust-engine/src/lib.rs delete mode 100644 src/lib.rs delete mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6d0b111..cac1ad3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,12 @@ -[package] -name = "raust" +[workspace] +members = ["raust-cli", "raust-engine"] +resolver = "3" + +[profile.release] +lto = true +codegen-units = 1 + +[workspace.package] version = "0.1.0" edition = "2024" authors = ["Lucien Cartier-Tilet "] @@ -7,18 +14,28 @@ license = "GPL-3.0-or-later" homepage = "https://labs.phundrak.com/phundrak/raust" repository = "https://labs.phundrak.com/phundrak/raust" -[lib] -path = "src/lib.rs" - -[[bin]] -path = "src/main.rs" -name = "raust" - -[dependencies] +[workspace.dependencies] anyhow = "1.0.104" cpal = { version = "0.18.2", features = ["realtime"] } +raust-engine = { version = "=0.1.0", path = "raust-engine" } -[features] -default = [] +[workspace.lints.rust] +unsafe_code = "forbid" -pipewire = ["cpal/pipewire"] +[workspace.lints.clippy] +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +unwrap_used = "deny" +expect_used = "deny" +indexing_slicing = "deny" +arithmetic_side_effects = "deny" +unreachable = "deny" +unimplemented = "deny" +unchecked_time_subtraction = "deny" +todo = "deny" +string_slice = "deny" +panic_in_result_fn = "deny" +panic = "deny" +exit = "deny" +as_conversions = "deny" +many_single_char_names = "allow" diff --git a/raust-cli/Cargo.toml b/raust-cli/Cargo.toml new file mode 100644 index 0000000..161c63d --- /dev/null +++ b/raust-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "raust-cli" +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true + +[dependencies] +anyhow = { workspace = true } +clap = { version = "4.6.7", features = ["derive"] } +cpal = { workspace = true } +raust-engine = { workspace = true } + +[lints] +workspace = true diff --git a/raust-cli/src/main.rs b/raust-cli/src/main.rs new file mode 100644 index 0000000..77a5bf5 --- /dev/null +++ b/raust-cli/src/main.rs @@ -0,0 +1,43 @@ +use clap::Parser; +use cpal::traits::DeviceTrait; +use raust_engine::{AudioPlayer, Note}; + +/// Play a single tone through the default audio output device. +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Args { + /// Frequency of the tone to play, in Hertz. + #[arg(short, long, default_value = "440.0")] + frequency: f32, + + /// How long to play the tone, in milliseconds. + #[arg(short, long, default_value = "1000")] + length_milli: u64, +} + +fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let note = Note { + pitch: args.frequency, + length: args.length_milli, + }; + let (device, config) = raust_engine::get_device_and_config()?; + println!("Output device: {}", device.id()?); + println!("Default output config: {config:?}"); + let mut clock = 0f32; + // Sample rates are a few hundred kHz at most, nowhere near f32's + // 24-bit exact-integer range, so this cast never loses precision. + #[allow(clippy::cast_precision_loss, clippy::as_conversions)] + let sample_rate = config.sample_rate() as f32; + let generator = move || { + clock = (clock + 1.0) % sample_rate; + (clock * note.pitch * 2.0 * std::f32::consts::PI / sample_rate).sin() + }; + let audio_player = AudioPlayer::try_new(&device, config, Box::new(generator))?; + + audio_player.play()?; + std::thread::sleep(std::time::Duration::from_millis(note.length)); + audio_player.pause()?; + + Ok(()) +} diff --git a/raust-engine/Cargo.toml b/raust-engine/Cargo.toml new file mode 100644 index 0000000..ca1c111 --- /dev/null +++ b/raust-engine/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "raust-engine" +description = "Raust Synthesis Engine" +version.workspace = true +license.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +cpal = { workspace = true } +anyhow = { workspace = true } + +[features] +default = ["pipewire"] + +pipewire = ["cpal/pipewire"] + +[lints] +workspace = true diff --git a/raust-engine/src/audio_player.rs b/raust-engine/src/audio_player.rs new file mode 100644 index 0000000..79e1494 --- /dev/null +++ b/raust-engine/src/audio_player.rs @@ -0,0 +1,120 @@ +/// Plays audio produced by a generator closure on the host's default output device. +/// +/// `AudioPlayer` owns the live [`cpal::Stream`] and exposes only playback +/// control ([`play`](Self::play), [`pause`](Self::pause)). It has no +/// knowledge of how the generator produces samples, so polyphony, pitch +/// modulation, and voice mixing all belong behind the [`Generator`] closure, +/// not here. +pub struct AudioPlayer { + stream: Stream, +} +use super::Generator; +use cpal::{ + Device, Error, ErrorKind, FromSample, I24, OutputCallbackInfo, Sample, SampleFormat, SizedSample, Stream, StreamConfig, SupportedStreamConfig, traits::{DeviceTrait, StreamTrait}, +}; +use std::io::ErrorKind as IoErrorKind; + +impl AudioPlayer { + /// Builds a stream for `device`/`config` around `generator`, dispatching + /// on `config`'s sample format to the matching CPAL sample type. + /// + /// The stream starts paused; call [`play`](Self::play) to produce sound. + /// Use [`get_device_and_config`](crate::get_device_and_config) to obtain + /// `device` and `config`. + /// + /// # Errors + /// + /// Returns an error if: + /// - the device reports a sample format `raust` does not support, or + /// - CPAL fails to build the output stream. + pub fn try_new(device: &Device, config: SupportedStreamConfig, generator: Generator) -> anyhow::Result { + let stream = match config.sample_format() { + SampleFormat::I8 => Self::create_stream::(device, config.into(), generator), + SampleFormat::I16 => Self::create_stream::(device, config.into(), generator), + SampleFormat::I24 => Self::create_stream::(device, config.into(), generator), + SampleFormat::I32 => Self::create_stream::(device, config.into(), generator), + SampleFormat::I64 => Self::create_stream::(device, config.into(), generator), + SampleFormat::U8 => Self::create_stream::(device, config.into(), generator), + SampleFormat::U16 => Self::create_stream::(device, config.into(), generator), + SampleFormat::U32 => Self::create_stream::(device, config.into(), generator), + SampleFormat::U64 => Self::create_stream::(device, config.into(), generator), + SampleFormat::F32 => Self::create_stream::(device, config.into(), generator), + SampleFormat::F64 => Self::create_stream::(device, config.into(), generator), + sample_format => Err(anyhow::Error::new(std::io::Error::new( + IoErrorKind::InvalidData, + format!("Unsupported sample format '{sample_format}'"), + ))), + }?; + Ok(Self { stream }) + } + + /// Builds, but does not start, an output stream of sample type `T` that + /// pulls its samples from `generator`. + /// + /// # Errors + /// + /// Returns an error if CPAL fails to build the output stream for `device`. + pub fn create_stream( + device: &Device, + config: StreamConfig, + mut generator: Generator, + ) -> Result + where + T: SizedSample + FromSample, + { + let channels = usize::from(config.channels); + let err_fn = |err: Error| match err.kind() { + ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => { + eprintln!("{err}"); + } + _ => eprintln!("Stream error: {err}"), + }; + let stream = device.build_output_stream( + config, + move |data: &mut [T], _: &OutputCallbackInfo| { + Self::write_data(data, channels, &mut generator); + }, + err_fn, + None, + )?; + Ok(stream) + } + + fn write_data(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32) + where + T: Sample + FromSample, + { + for frame in output.chunks_mut(channels) { + let value: T = T::from_sample(next_sample()); + for sample in frame.iter_mut() { + *sample = value; + } + } + } + + /// Resumes playback. + /// + /// The generator continues from exactly where [`pause`](Self::pause) + /// left it; nothing resets. + /// + /// # Errors + /// + /// Returns an error if CPAL fails to resume the stream. + pub fn play(&self) -> anyhow::Result<()> { + self.stream.play()?; + Ok(()) + } + + /// Suspends playback. + /// + /// The backend stops calling the generator until the next + /// [`play`](Self::play) call, so no samples are produced while paused. + /// + /// # Errors + /// + /// Returns an error if CPAL fails to suspend the stream. + pub fn pause(&self) -> anyhow::Result<()> { + self.stream.pause()?; + Ok(()) + } +} diff --git a/raust-engine/src/init.rs b/raust-engine/src/init.rs new file mode 100644 index 0000000..d520fef --- /dev/null +++ b/raust-engine/src/init.rs @@ -0,0 +1,44 @@ +//! Output device and stream configuration discovery. + +use cpal::{Device, Error, ErrorKind, HostId, SupportedStreamConfig, traits::{DeviceTrait, HostTrait}}; +use std::io::{Error as IoError, ErrorKind as IoErrorKind}; + +/// Finds an output device and reads its default stream configuration. +/// +/// Prefers `PipeWire` on Linux when the `pipewire` feature is enabled, +/// falling back to the platform's default host otherwise. +/// +/// # Errors +/// +/// Returns an error if no default output device exists, or if the device's +/// default output configuration cannot be read. +pub fn get_device_and_config() -> anyhow::Result<(Device, SupportedStreamConfig)> { + #[allow(unused_mut, unused_assignments)] + let mut pipewire_host_id: Result = Err(ErrorKind::HostUnavailable.into()); + #[cfg(any( + target_os = "linux", + target_os = "dragonfly", + target_os = "freebsd", + target_os = "netbsd", + ))] + { + #[cfg(feature = "pipewire")] + { + pipewire_host_id = Ok(HostId::PipeWire); + } + } + let host = pipewire_host_id + .and_then(cpal::host_from_id) + .unwrap_or_else(|_| cpal::default_host()); + let device = host.default_output_device().map_or_else( + || { + Err(anyhow::Error::new(IoError::new( + IoErrorKind::NotFound, + "Could not find a default output device", + ))) + }, + Ok, + )?; + let config = device.default_output_config()?; + Ok((device, config)) +} diff --git a/raust-engine/src/lib.rs b/raust-engine/src/lib.rs new file mode 100644 index 0000000..3f650df --- /dev/null +++ b/raust-engine/src/lib.rs @@ -0,0 +1,24 @@ +//! Real-time audio synthesis engine built on CPAL: output device discovery +//! ([`get_device_and_config`]) and stream playback ([`AudioPlayer`]). + +mod audio_player; +pub use audio_player::AudioPlayer; +mod init; +pub use init::*; + +/// Produces one `f32` sample per call. +/// +/// `AudioPlayer` consumes a `Generator` when it builds its stream and moves +/// it onto the audio callback thread, so `Send + 'static` must hold — the +/// same bound [`cpal::Device::build_output_stream`] places on its data +/// callback. +pub type Generator = Box f32 + Send + 'static>; + +/// A single tone: a pitch held for a fixed duration. +#[derive(Debug, Clone, Copy)] +pub struct Note { + /// Frequency, in Hertz. + pub pitch: f32, + /// How long to hold the note, in milliseconds. + pub length: u64, +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index ea9b96b..cb6436a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly" +channel = "stable" components = ["clippy", "rust-docs", "rust-src", "rust-analyzer", "rustfmt"] -profile = "default" \ No newline at end of file +profile = "default" diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 49dfc7d..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,98 +0,0 @@ -use cpal::{ - Device, Error, ErrorKind, FromSample, HostId, I24, OutputCallbackInfo, Sample, SampleFormat, - SizedSample, StreamConfig, - traits::{DeviceTrait, HostTrait, StreamTrait}, -}; - -pub fn add(a: u32, b: u32) -> u32 { - a + b -} - -pub fn synthesize() -> anyhow::Result<()> { - #[allow(unused_mut, unused_assignments)] - let mut pipewire_host_id: Result = Err(ErrorKind::HostUnavailable.into()); - #[cfg(any( - target_os = "linux", - target_os = "dragonfly", - target_os = "freebsd", - target_os = "netbsd", - ))] - { - #[cfg(feature = "pipewire")] - { - pipewire_host_id = Ok(HostId::PipeWire); - } - } - let host = pipewire_host_id - .and_then(cpal::host_from_id) - .unwrap_or_else(|_| cpal::default_host()); - let device = host.default_output_device().expect("Failed to find output device"); - println!("Output device: {}", device.id()?); - let config = device.default_output_config()?; - println!("Default output config: {config:?}"); - match config.sample_format() { - SampleFormat::I8 => run::(&device, config.into()), - SampleFormat::I16 => run::(&device, config.into()), - SampleFormat::I24 => run::(&device, config.into()), - SampleFormat::I32 => run::(&device, config.into()), - // SampleFormat::I48 => run::(&device, config.into()), - SampleFormat::I64 => run::(&device, config.into()), - SampleFormat::U8 => run::(&device, config.into()), - SampleFormat::U16 => run::(&device, config.into()), - // SampleFormat::U24 => run::(&device, config.into()), - SampleFormat::U32 => run::(&device, config.into()), - // SampleFormat::U48 => run::(&device, config.into()), - SampleFormat::U64 => run::(&device, config.into()), - SampleFormat::F32 => run::(&device, config.into()), - SampleFormat::F64 => run::(&device, config.into()), - sample_format => panic!("Unsupported sample format '{sample_format}'"), - } -} - -pub fn run(device: &Device, config: StreamConfig) -> Result<(), anyhow::Error> -where - T: SizedSample + FromSample, -{ - let sample_rate = config.sample_rate as f32; - let channels = config.channels as usize; - - // Produce a sinusoid of maximum amplitude. - let mut sample_clock = 0f32; - let mut next_value = move || { - sample_clock = (sample_clock + 1.0) % sample_rate; - (sample_clock * 440.0 * 2.0 * std::f32::consts::PI / sample_rate).sin() - }; - - let err_fn = |err: Error| match err.kind() { - ErrorKind::DeviceChanged | ErrorKind::RealtimeDenied => { - eprintln!("{err}") - } - _ => eprintln!("Stream error: {err}"), - }; - - let stream = device.build_output_stream( - config, - move |data: &mut [T], _: &OutputCallbackInfo| write_data(data, channels, &mut next_value), - err_fn, - None, - )?; - stream.play()?; - - std::thread::sleep(std::time::Duration::from_millis(1000)); - - stream.pause()?; - - Ok(()) -} - -fn write_data(output: &mut [T], channels: usize, next_sample: &mut dyn FnMut() -> f32) -where - T: Sample + FromSample, -{ - for frame in output.chunks_mut(channels) { - let value: T = T::from_sample(next_sample()); - for sample in frame.iter_mut() { - *sample = value; - } - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 3048d7c..0000000 --- a/src/main.rs +++ /dev/null @@ -1,5 +0,0 @@ -use raust::synthesize; - -fn main() { - synthesize().expect("Failed to run synthesis engine"); -}