From 1dc0be6235d8b885137c23ec88f5821e69928086 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sat, 31 Jan 2026 19:54:19 -0800 Subject: [PATCH 1/7] Create rtrb --- Cargo.toml | 4 +- src/contrib/controller.rs | 119 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 src/contrib/controller.rs diff --git a/Cargo.toml b/Cargo.toml index 8a44aa766..861956313 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ jack-sys = {version = "0.5", path = "./jack-sys"} lazy_static = "1.4" libc = "0.2" log = { version = "0.4", optional = true} +rtrb = { version = "0.3.2", optional = true } [dev-dependencies] approx = "0.5" @@ -23,5 +24,6 @@ crossbeam-channel = "0.5" ctor = "0.2" [features] -default = ["dynamic_loading", "log"] +default = ["dynamic_loading", "log", "controller"] dynamic_loading = ["jack-sys/dynamic_loading"] +controller = ["rtrb"] diff --git a/src/contrib/controller.rs b/src/contrib/controller.rs new file mode 100644 index 000000000..9cd5c6c2f --- /dev/null +++ b/src/contrib/controller.rs @@ -0,0 +1,119 @@ +//! Utilities for building controllable JACK processors with lock-free communication. + +use rtrb::{Consumer, Producer, RingBuffer}; + +use crate::{ + Client, Control, Frames, ProcessHandler, ProcessScope, TransportPosition, TransportState, +}; + +/// Communication channels available to a processor in the real-time audio thread. +pub struct ProcessorChannels { + /// Send notifications from the processor to the controller. + pub notifications: Producer, + /// Receive commands from the controller. + pub commands: Consumer, +} + +/// Handle for controlling a processor from outside the real-time audio thread. +pub struct ProcessorHandle { + /// Receive notifications from the processor. + pub notifications: Consumer, + /// Send commands to the processor. + pub commands: Producer, +} + +/// A JACK processor that can be controlled via lock-free channels. +/// +/// Implement this trait to create a processor that communicates with external +/// code through commands and notifications while running in the real-time thread. +pub trait ControlledProcessorTrait: Send + Sized { + /// Commands sent from the controller to the processor. + type Command: Send; + /// Notifications sent from the processor to the controller. + type Notification: Send; + + /// See [`ProcessHandler::SLOW_SYNC`]. + const SLOW_SYNC: bool = false; + + /// Called when the transport state changes. See [`ProcessHandler::sync`]. + fn sync( + &mut self, + _client: &Client, + _state: TransportState, + _pos: &TransportPosition, + _channels: &mut ProcessorChannels, + ) -> bool { + true + } + + /// Called when the buffer size changes. See [`ProcessHandler::buffer_size`]. + fn buffer_size( + &mut self, + client: &Client, + size: Frames, + channels: &mut ProcessorChannels, + ) -> Control; + + /// Process audio. See [`ProcessHandler::process`]. + fn process( + &mut self, + client: &Client, + scope: &ProcessScope, + channels: &mut ProcessorChannels, + ) -> Control; + + /// Create a processor instance and its control handle with the given channel capacities. + fn instance( + self, + notification_channel_size: usize, + command_channel_size: usize, + ) -> ( + ControlledProcessorInstance, + ProcessorHandle, + ) { + let (notifications, notifications_other) = + RingBuffer::::new(notification_channel_size); + let (commands_other, commands) = RingBuffer::::new(command_channel_size); + let handle = ProcessorHandle { + notifications: notifications_other, + commands: commands_other, + }; + let processor = ControlledProcessorInstance { + inner: self, + channels: ProcessorChannels { + notifications, + commands, + }, + }; + (processor, handle) + } +} + +/// A [`ProcessHandler`] wrapper that provides channel-based communication. +/// +/// Created via [`ControlledProcessorTrait::instance`]. +pub struct ControlledProcessorInstance { + inner: T, + channels: ProcessorChannels, +} + +impl ProcessHandler for ControlledProcessorInstance { + fn process(&mut self, client: &Client, scope: &ProcessScope) -> Control { + self.inner.process(client, scope, &mut self.channels) + } + + const SLOW_SYNC: bool = T::SLOW_SYNC; + + fn buffer_size(&mut self, client: &Client, size: Frames) -> Control { + self.inner.buffer_size(client, size, &mut self.channels) + } + + fn sync( + &mut self, + client: &Client, + state: crate::TransportState, + pos: &crate::TransportPosition, + ) -> bool { + self.inner.sync(client, state, pos, &mut self.channels) + } +} diff --git a/src/lib.rs b/src/lib.rs index 14f37ead2..dc815c892 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,6 +72,9 @@ pub mod contrib { mod closure; pub use closure::ClosureProcessHandler; + + #[cfg(feature = "controller")] + pub mod controller; } #[cfg(test)] From a8cf78507e1da92b135bfade5163e763a6cd042c Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sat, 31 Jan 2026 19:58:50 -0800 Subject: [PATCH 2/7] Add docs --- docs/src/SUMMARY.md | 1 + docs/src/contrib/controller.md | 160 +++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 docs/src/contrib/controller.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 81b5edc41..cf49b7832 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -5,4 +5,5 @@ - [Logging](./logging.md) - [Contrib](./contrib/index.md) - [Closure Callbacks](./contrib/closure_callbacks.md) + - [Controller](./contrib/controller.md) diff --git a/docs/src/contrib/controller.md b/docs/src/contrib/controller.md new file mode 100644 index 000000000..1089eb5d1 --- /dev/null +++ b/docs/src/contrib/controller.md @@ -0,0 +1,160 @@ +# Controller + +The controller module provides utilities for building controllable JACK processors +with lock-free communication. This is useful when you need to send commands to or +receive notifications from your audio processor without blocking the real-time thread. + +## Overview + +The controller pattern separates your audio processing into two parts: + +1. **Processor** - Runs in the real-time audio thread and handles audio/midi processing +2. **Controller** - Runs outside the real-time thread and can send commands or receive notifications + +Communication between them uses lock-free ring buffers, making it safe for real-time audio. + +## Basic Usage + +Implement the `ControlledProcessorTrait` to create a controllable processor: + +```rust +use jack::contrib::controller::{ + ControlledProcessorTrait, ProcessorChannels, ProcessorHandle, +}; + +// Define your command and notification types +enum Command { + SetVolume(f32), + Mute, + Unmute, +} + +enum Notification { + ClippingDetected, + VolumeChanged(f32), +} + +// Define your processor state +struct VolumeProcessor { + output: jack::Port, + input: jack::Port, + volume: f32, + muted: bool, +} + +impl ControlledProcessorTrait for VolumeProcessor { + type Command = Command; + type Notification = Notification; + + fn buffer_size( + &mut self, + _client: &jack::Client, + _size: jack::Frames, + _channels: &mut ProcessorChannels, + ) -> jack::Control { + jack::Control::Continue + } + + fn process( + &mut self, + _client: &jack::Client, + scope: &jack::ProcessScope, + channels: &mut ProcessorChannels, + ) -> jack::Control { + // Handle incoming commands + while let Ok(cmd) = channels.commands.pop() { + match cmd { + Command::SetVolume(v) => { + self.volume = v; + let _ = channels.notifications.push(Notification::VolumeChanged(v)); + } + Command::Mute => self.muted = true, + Command::Unmute => self.muted = false, + } + } + + // Process audio + let input = self.input.as_slice(scope); + let output = self.output.as_mut_slice(scope); + let gain = if self.muted { 0.0 } else { self.volume }; + + for (out, inp) in output.iter_mut().zip(input.iter()) { + *out = inp * gain; + } + + jack::Control::Continue + } +} +``` + +## Creating and Using the Processor + +Use the `instance` method to create both the processor and its control handle: + +```rust +let (client, _status) = + jack::Client::new("controlled", jack::ClientOptions::default()).unwrap(); + +let input = client.register_port("in", jack::AudioIn::default()).unwrap(); +let output = client.register_port("out", jack::AudioOut::default()).unwrap(); + +let processor = VolumeProcessor { + input, + output, + volume: 1.0, + muted: false, +}; + +// Create the processor instance and control handle +// Arguments: notification channel size, command channel size +let (processor_instance, handle) = processor.instance(16, 16); + +// Activate the client with the processor +let active_client = client.activate_async((), processor_instance).unwrap(); + +// Now you can control the processor from any thread +handle.commands.push(Command::SetVolume(0.5)).unwrap(); + +// And receive notifications +while let Ok(notification) = handle.notifications.pop() { + match notification { + Notification::ClippingDetected => println!("Clipping detected!"), + Notification::VolumeChanged(v) => println!("Volume changed to {}", v), + } +} +``` + +## Channel Capacities + +When calling `instance`, you specify the capacity of both ring buffers: + +- `notification_channel_size` - How many notifications can be queued from processor to controller +- `command_channel_size` - How many commands can be queued from controller to processor + +Choose sizes based on your expected message rates. If a channel is full, `push` will fail, +so handle this appropriately in your code. + +## Transport Sync + +If your processor needs to respond to JACK transport changes, implement the `sync` method +and optionally set `SLOW_SYNC`: + +```rust +impl ControlledProcessorTrait for MyProcessor { + // ... + + const SLOW_SYNC: bool = true; // Set if sync may take multiple cycles + + fn sync( + &mut self, + _client: &jack::Client, + state: jack::TransportState, + pos: &jack::TransportPosition, + channels: &mut ProcessorChannels, + ) -> bool { + // Handle transport state changes + // Return true when ready to play + true + } +} +``` From fb332bc2004a7596f7bdea408425efd83119e97a Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sat, 31 Jan 2026 19:59:29 -0800 Subject: [PATCH 3/7] Polish --- docs/src/contrib/controller.md | 28 ++++++-------- src/contrib/controller.rs | 67 ++++++++++++++++++++++++++-------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/docs/src/contrib/controller.md b/docs/src/contrib/controller.md index 1089eb5d1..9ef27ff1c 100644 --- a/docs/src/contrib/controller.md +++ b/docs/src/contrib/controller.md @@ -62,16 +62,14 @@ impl ControlledProcessorTrait for VolumeProcessor { channels: &mut ProcessorChannels, ) -> jack::Control { // Handle incoming commands - while let Ok(cmd) = channels.commands.pop() { - match cmd { - Command::SetVolume(v) => { - self.volume = v; - let _ = channels.notifications.push(Notification::VolumeChanged(v)); - } - Command::Mute => self.muted = true, - Command::Unmute => self.muted = false, + channels.drain_commands(|cmd| match cmd { + Command::SetVolume(v) => { + self.volume = v; + channels.try_notify(Notification::VolumeChanged(v)); } - } + Command::Mute => self.muted = true, + Command::Unmute => self.muted = false, + }); // Process audio let input = self.input.as_slice(scope); @@ -113,15 +111,13 @@ let (processor_instance, handle) = processor.instance(16, 16); let active_client = client.activate_async((), processor_instance).unwrap(); // Now you can control the processor from any thread -handle.commands.push(Command::SetVolume(0.5)).unwrap(); +handle.send_command(Command::SetVolume(0.5)).unwrap(); // And receive notifications -while let Ok(notification) = handle.notifications.pop() { - match notification { - Notification::ClippingDetected => println!("Clipping detected!"), - Notification::VolumeChanged(v) => println!("Volume changed to {}", v), - } -} +handle.drain_notifications(|notification| match notification { + Notification::ClippingDetected => println!("Clipping detected!"), + Notification::VolumeChanged(v) => println!("Volume changed to {}", v), +}); ``` ## Channel Capacities diff --git a/src/contrib/controller.rs b/src/contrib/controller.rs index 9cd5c6c2f..be2da14e9 100644 --- a/src/contrib/controller.rs +++ b/src/contrib/controller.rs @@ -8,18 +8,53 @@ use crate::{ /// Communication channels available to a processor in the real-time audio thread. pub struct ProcessorChannels { - /// Send notifications from the processor to the controller. - pub notifications: Producer, - /// Receive commands from the controller. - pub commands: Consumer, + notifications: Producer, + commands: Consumer, +} + +impl ProcessorChannels { + /// Drain and process all pending commands. + pub fn drain_commands(&mut self, mut f: impl FnMut(Command)) { + while let Ok(cmd) = self.commands.pop() { + f(cmd); + } + } + + /// Try to send a notification, ignoring if the buffer is full. + /// + /// Returns `true` if the notification was sent, `false` if the buffer was full. + pub fn try_notify(&mut self, notification: Notification) -> bool { + self.notifications.push(notification).is_ok() + } } /// Handle for controlling a processor from outside the real-time audio thread. -pub struct ProcessorHandle { - /// Receive notifications from the processor. - pub notifications: Consumer, - /// Send commands to the processor. - pub commands: Producer, +pub struct Controller { + notifications: Consumer, + commands: Producer, +} + +impl Controller { + /// Try to send a command to the processor. + /// + /// Returns `Ok(())` if the command was sent, or `Err(command)` if the buffer was full. + pub fn send_command(&mut self, command: Command) -> Result<(), Command> { + self.commands.push(command).map_err(|e| e.into_inner()) + } + + /// Try to receive a notification from the processor. + /// + /// Returns `Some(notification)` if one was available, or `None` if the buffer was empty. + pub fn recv_notification(&mut self) -> Option { + self.notifications.pop().ok() + } + + /// Drain and process all pending notifications. + pub fn drain_notifications(&mut self, mut f: impl FnMut(Notification)) { + while let Ok(notification) = self.notifications.pop() { + f(notification); + } + } } /// A JACK processor that can be controlled via lock-free channels. @@ -63,18 +98,19 @@ pub trait ControlledProcessorTrait: Send + Sized { ) -> Control; /// Create a processor instance and its control handle with the given channel capacities. + #[must_use = "the processor instance must be used with Client::activate"] fn instance( self, notification_channel_size: usize, command_channel_size: usize, ) -> ( ControlledProcessorInstance, - ProcessorHandle, + Controller, ) { let (notifications, notifications_other) = RingBuffer::::new(notification_channel_size); let (commands_other, commands) = RingBuffer::::new(command_channel_size); - let handle = ProcessorHandle { + let controller = Controller { notifications: notifications_other, commands: commands_other, }; @@ -85,7 +121,7 @@ pub trait ControlledProcessorTrait: Send + Sized { commands, }, }; - (processor, handle) + (processor, controller) } } @@ -93,7 +129,8 @@ pub trait ControlledProcessorTrait: Send + Sized { /// /// Created via [`ControlledProcessorTrait::instance`]. pub struct ControlledProcessorInstance { - inner: T, + /// The inner processor implementation. + pub inner: T, channels: ProcessorChannels, } @@ -111,8 +148,8 @@ impl ProcessHandler for ControlledProcessorInstance fn sync( &mut self, client: &Client, - state: crate::TransportState, - pos: &crate::TransportPosition, + state: TransportState, + pos: &TransportPosition, ) -> bool { self.inner.sync(client, state, pos, &mut self.channels) } From d8d902c7c03d7a27417a1c39e69d468be54fb831 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sat, 31 Jan 2026 20:19:20 -0800 Subject: [PATCH 4/7] Don't make controller a default feature --- .github/workflows/site.yml | 6 ------ Cargo.toml | 2 +- docs/src/contrib/controller.md | 3 +++ docs/src/features.md | 8 ++++++++ src/contrib/controller.rs | 7 +------ 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 404e5ef22..c78192bc4 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -28,12 +28,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.0 - bundler-cache: true - cache-version: 0 # Increment this number to re-download cached gems. - name: Setup Pages id: pages uses: actions/configure-pages@v5 diff --git a/Cargo.toml b/Cargo.toml index 861956313..debdb4da8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,6 @@ crossbeam-channel = "0.5" ctor = "0.2" [features] -default = ["dynamic_loading", "log", "controller"] +default = ["dynamic_loading", "log"] dynamic_loading = ["jack-sys/dynamic_loading"] controller = ["rtrb"] diff --git a/docs/src/contrib/controller.md b/docs/src/contrib/controller.md index 9ef27ff1c..d994c72c1 100644 --- a/docs/src/contrib/controller.md +++ b/docs/src/contrib/controller.md @@ -1,5 +1,8 @@ # Controller +**Note:** This module requires the `controller` feature, which is not enabled by default. +Add `jack = { version = "...", features = ["controller"] }` to your `Cargo.toml`. + The controller module provides utilities for building controllable JACK processors with lock-free communication. This is useful when you need to send commands to or receive notifications from your audio processor without blocking the real-time thread. diff --git a/docs/src/features.md b/docs/src/features.md index 399bbdba8..71616a067 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -30,3 +30,11 @@ Default: Yes Load `libjack` at runtime as opposed to the standard dynamic linking. This is preferred as it allows `pw-jack` to intercept the loading at runtime to provide the Pipewire JACK server implementation. + +## `controller` + +Default: No + +Enables the `jack::contrib::controller` module which provides utilities for +building controllable JACK processors with lock-free communication. See the +[Controller documentation](contrib/controller.md) for usage details. diff --git a/src/contrib/controller.rs b/src/contrib/controller.rs index be2da14e9..5275e3da8 100644 --- a/src/contrib/controller.rs +++ b/src/contrib/controller.rs @@ -145,12 +145,7 @@ impl ProcessHandler for ControlledProcessorInstance self.inner.buffer_size(client, size, &mut self.channels) } - fn sync( - &mut self, - client: &Client, - state: TransportState, - pos: &TransportPosition, - ) -> bool { + fn sync(&mut self, client: &Client, state: TransportState, pos: &TransportPosition) -> bool { self.inner.sync(client, state, pos, &mut self.channels) } } From 8a82307dcc811a72555cec480aaeb08dbbcb31d6 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sun, 1 Feb 2026 11:04:08 -0800 Subject: [PATCH 5/7] Test CI with all features --- .github/workflows/testing.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 15db47437..76a3baa7a 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -26,6 +26,8 @@ jobs: run: cargo build --verbose - name: Build (No Features) run: cargo build --verbose --no-default-features + - name: Build (All Features) + run: cargo build --verbose --all-features - name: Build (examples) run: cargo build --verbose --examples - name: Run Tests (Default Features) From 7d0b921612af4737d7b64789eaa5540a9e3d238e Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sun, 1 Feb 2026 11:04:08 -0800 Subject: [PATCH 6/7] Fix the build --- src/contrib/controller.rs | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/contrib/controller.rs b/src/contrib/controller.rs index 5275e3da8..1a95a0834 100644 --- a/src/contrib/controller.rs +++ b/src/contrib/controller.rs @@ -13,18 +13,18 @@ pub struct ProcessorChannels { } impl ProcessorChannels { - /// Drain and process all pending commands. - pub fn drain_commands(&mut self, mut f: impl FnMut(Command)) { - while let Ok(cmd) = self.commands.pop() { - f(cmd); - } + /// Drain and return an iterator over all pending commands. + pub fn drain_commands(&mut self) -> impl Iterator + '_ { + std::iter::from_fn(move || self.commands.pop().ok()) } - /// Try to send a notification, ignoring if the buffer is full. + /// Try to send a notification. /// - /// Returns `true` if the notification was sent, `false` if the buffer was full. - pub fn try_notify(&mut self, notification: Notification) -> bool { - self.notifications.push(notification).is_ok() + /// Returns `Ok(())` if the notification was sent, or `Err(notification)` if the buffer was full. + pub fn try_notify(&mut self, notification: Notification) -> Result<(), Notification> { + self.notifications + .push(notification) + .map_err(|rtrb::PushError::Full(n)| n) } } @@ -39,7 +39,9 @@ impl Controller { /// /// Returns `Ok(())` if the command was sent, or `Err(command)` if the buffer was full. pub fn send_command(&mut self, command: Command) -> Result<(), Command> { - self.commands.push(command).map_err(|e| e.into_inner()) + self.commands + .push(command) + .map_err(|rtrb::PushError::Full(cmd)| cmd) } /// Try to receive a notification from the processor. @@ -49,11 +51,9 @@ impl Controller { self.notifications.pop().ok() } - /// Drain and process all pending notifications. - pub fn drain_notifications(&mut self, mut f: impl FnMut(Notification)) { - while let Ok(notification) = self.notifications.pop() { - f(notification); - } + /// Drain and return an iterator over all pending notifications. + pub fn drain_notifications(&mut self) -> impl Iterator + '_ { + std::iter::from_fn(move || self.notifications.pop().ok()) } } From 1a0f90f2509c5a9775620a00d847302132bbaad0 Mon Sep 17 00:00:00 2001 From: wmedrano Date: Sun, 1 Feb 2026 11:17:30 -0800 Subject: [PATCH 7/7] Fix docs --- .github/workflows/testing.yml | 2 +- Cargo.toml | 4 + README.md | 126 +++++++++++++---------- docs/src/contrib/controller.md | 28 +++--- examples/controlled_sine.rs | 179 +++++++++++++++++++++++++++++++++ src/contrib/controller.rs | 7 ++ 6 files changed, 282 insertions(+), 64 deletions(-) create mode 100644 examples/controlled_sine.rs diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 76a3baa7a..112273d08 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -29,7 +29,7 @@ jobs: - name: Build (All Features) run: cargo build --verbose --all-features - name: Build (examples) - run: cargo build --verbose --examples + run: cargo build --verbose --examples --all-features - name: Run Tests (Default Features) run: cargo nextest run - name: Run Doc Tests diff --git a/Cargo.toml b/Cargo.toml index debdb4da8..00234e94f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,7 @@ ctor = "0.2" default = ["dynamic_loading", "log"] dynamic_loading = ["jack-sys/dynamic_loading"] controller = ["rtrb"] + +[[example]] +name = "controlled_sine" +required-features = ["controller"] diff --git a/README.md b/README.md index 5ff2749c3..acb725bab 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,102 @@ # JACK (for Rust) -Rust bindings for [JACK Audio Connection Kit](). +Rust bindings for [JACK Audio Connection Kit](https://jackaudio.org). -| [![Crates.io](https://img.shields.io/crates/v/jack.svg)](https://crates.io/crates/jack) | [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) | -|-----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [![Docs.rs](https://docs.rs/jack/badge.svg)](https://docs.rs/jack) | [![Test](https://github.com/RustAudio/rust-jack/actions/workflows/testing.yml/badge.svg)](https://github.com/RustAudio/rust-jack/actions/workflows/testing.yml) | -| [📚 Documentation](https://rustaudio.github.io/rust-jack) | [:heart: Sponsor]() | +[![Crates.io](https://img.shields.io/crates/v/jack.svg)](https://crates.io/crates/jack) +[![Docs.rs](https://docs.rs/jack/badge.svg)](https://docs.rs/jack) +[![Test](https://github.com/RustAudio/rust-jack/actions/workflows/testing.yml/badge.svg)](https://github.com/RustAudio/rust-jack/actions/workflows/testing.yml) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[:heart: Sponsor](https://github.com/sponsors/wmedrano) -## Using JACK +## Overview +JACK is a low-latency audio server that allows multiple applications to share +audio and MIDI devices and route signals between each other. This crate provides +safe Rust bindings to create JACK clients that can process audio and MIDI in +real-time. -The JACK server is usually started by the user or system. Clients can request -that the JACK server is started on demand when they connect, but this can be -disabled by creating a client with the `NO_START_SERVER` option or -`ClientOptions::default()`. +## Documentation -- Linux and BSD users may install JACK1, JACK2 (preferred for low latency), or - Pipewire JACK (preferred for ease of use) from their system package manager. -- Windows users may install JACK from the [official - website]() or [Chocolatey](). -- MacOS users may install JACK from the [official - website]() or [Homebrew](). +- [Guide](https://rustaudio.github.io/rust-jack) - Quickstart, features, and tutorials +- [API Reference](https://docs.rs/jack/) - Complete API documentation -Refer to the [docs.rs documentation]() for details about -the API. For more general documentation, visit . +## Quick Example +```rust +use std::io; + +fn main() { + // Create a JACK client + let (client, _status) = + jack::Client::new("rust_jack_simple", jack::ClientOptions::default()).unwrap(); + + // Register input and output ports + let in_port = client + .register_port("input", jack::AudioIn::default()) + .unwrap(); + let mut out_port = client + .register_port("output", jack::AudioOut::default()) + .unwrap(); + + // Create a processing callback that copies input to output + let process = jack::contrib::ClosureProcessHandler::new( + move |_: &jack::Client, ps: &jack::ProcessScope| -> jack::Control { + out_port.as_mut_slice(ps).clone_from_slice(in_port.as_slice(ps)); + jack::Control::Continue + }, + ); + + // Activate the client + let _active_client = client.activate_async((), process).unwrap(); + + // Wait for user to quit + println!("Press enter to quit..."); + let mut input = String::new(); + io::stdin().read_line(&mut input).ok(); +} +``` -## FAQ +See the [examples](examples/) directory for more. -### How do I return an `AsyncClient` with many generics? +## Installation -This is especially useful when using `jack::contrib::ClosureProcessHandler` -which may have an innaccessible type. +Add to your `Cargo.toml`: -```rust -// Shortest and allows access to the underlying client. -fn make_client() -> impl AsRef { - todo!() -} +```toml +[dependencies] +jack = "0.13" +``` -// With extra bounds -fn make_client() -> impl 'static + AsRef { - todo!(); -} +### JACK Server Setup -// For the full async client -fn async_client() -> impl jack::AsyncClient { - todo!(); -} -``` +A JACK server must be running for clients to connect. Install one of: -# Testing +- **Linux/BSD**: JACK2 (lowest latency), Pipewire JACK (easiest), or JACK1 via + your package manager +- **Windows**: [Official installer](http://jackaudio.org/downloads/) or + [Chocolatey](https://community.chocolatey.org/packages/jack) +- **macOS**: [Official installer](http://jackaudio.org/downloads/) or + [Homebrew](https://formulae.brew.sh/formula/jack) -Testing requires setting up a dummy server and running the tests using a single -thread. `rust-jack` automatically configures `cargo nextest` to use a single -thread. +By default, clients request the server to start on demand. Use +`ClientOptions::default()` or the `NO_START_SERVER` flag to disable this. + +## Testing + +Tests require a dummy JACK server and must run single-threaded: ```sh -# Set up a dummy server for tests. The script is included in this repository. ./dummy_jack_server.sh & -# Run tests cargo nextest run ``` -Note: If cargo nextest is not available, use `RUST_TEST_THREADS=1 cargo test` to -run in single threaded mode. - +If `cargo nextest` is unavailable: `RUST_TEST_THREADS=1 cargo test` -## Possible Issues +### Troubleshooting -If the tests are failing, a possible gotcha may be timing issues. +- Use `cargo nextest` instead of `cargo test` for better handling of timing-sensitive tests +- Try libjack2 or pipewire-jack if tests fail with your current JACK implementation -1. If using `cargo test`, try `cargo nextest`. The `cargo nextest` - configuration is set up to run single threaded and to retry flaky tests. +## License -Another case is that libjack may be broken on your setup. Try using libjack2 or -pipewire-jack. +MIT - see [LICENSE](LICENSE) for details. diff --git a/docs/src/contrib/controller.md b/docs/src/contrib/controller.md index d994c72c1..09def4202 100644 --- a/docs/src/contrib/controller.md +++ b/docs/src/contrib/controller.md @@ -65,14 +65,16 @@ impl ControlledProcessorTrait for VolumeProcessor { channels: &mut ProcessorChannels, ) -> jack::Control { // Handle incoming commands - channels.drain_commands(|cmd| match cmd { - Command::SetVolume(v) => { - self.volume = v; - channels.try_notify(Notification::VolumeChanged(v)); + while let Some(cmd) = channels.recv_command() { + match cmd { + Command::SetVolume(v) => { + self.volume = v; + let _ = channels.try_notify(Notification::VolumeChanged(v)); + } + Command::Mute => self.muted = true, + Command::Unmute => self.muted = false, } - Command::Mute => self.muted = true, - Command::Unmute => self.muted = false, - }); + } // Process audio let input = self.input.as_slice(scope); @@ -108,7 +110,7 @@ let processor = VolumeProcessor { // Create the processor instance and control handle // Arguments: notification channel size, command channel size -let (processor_instance, handle) = processor.instance(16, 16); +let (processor_instance, mut handle) = processor.instance(16, 16); // Activate the client with the processor let active_client = client.activate_async((), processor_instance).unwrap(); @@ -117,10 +119,12 @@ let active_client = client.activate_async((), processor_instance).unwrap(); handle.send_command(Command::SetVolume(0.5)).unwrap(); // And receive notifications -handle.drain_notifications(|notification| match notification { - Notification::ClippingDetected => println!("Clipping detected!"), - Notification::VolumeChanged(v) => println!("Volume changed to {}", v), -}); +for notification in handle.drain_notifications() { + match notification { + Notification::ClippingDetected => println!("Clipping detected!"), + Notification::VolumeChanged(v) => println!("Volume changed to {}", v), + } +} ``` ## Channel Capacities diff --git a/examples/controlled_sine.rs b/examples/controlled_sine.rs new file mode 100644 index 000000000..6c1bafd4c --- /dev/null +++ b/examples/controlled_sine.rs @@ -0,0 +1,179 @@ +//! Sine wave generator using the controller pattern for lock-free communication. +//! +//! This example demonstrates how to use `ControlledProcessorTrait` to build +//! a controllable audio processor with commands and notifications. + +use jack::contrib::controller::{ControlledProcessorTrait, ProcessorChannels}; +use std::f64::consts::PI; +use std::io; +use std::str::FromStr; + +/// Commands that can be sent to the audio processor. +enum Command { + SetFrequency(f64), + SetVolume(f32), + Mute, + Unmute, +} + +/// Notifications sent from the audio processor. +enum Notification { + FrequencyChanged(f64), + VolumeChanged(f32), + MuteStateChanged(bool), +} + +/// The audio processor state. +struct SineProcessor { + out_port: jack::Port, + frequency: f64, + volume: f32, + muted: bool, + frame_t: f64, + time: f64, +} + +impl ControlledProcessorTrait for SineProcessor { + type Command = Command; + type Notification = Notification; + + fn buffer_size( + &mut self, + _client: &jack::Client, + _size: jack::Frames, + _channels: &mut ProcessorChannels, + ) -> jack::Control { + jack::Control::Continue + } + + fn process( + &mut self, + _client: &jack::Client, + scope: &jack::ProcessScope, + channels: &mut ProcessorChannels, + ) -> jack::Control { + // Handle incoming commands + while let Some(cmd) = channels.recv_command() { + match cmd { + Command::SetFrequency(f) => { + self.frequency = f; + self.time = 0.0; + let _ = channels.try_notify(Notification::FrequencyChanged(f)); + } + Command::SetVolume(v) => { + self.volume = v; + let _ = channels.try_notify(Notification::VolumeChanged(v)); + } + Command::Mute => { + self.muted = true; + let _ = channels.try_notify(Notification::MuteStateChanged(true)); + } + Command::Unmute => { + self.muted = false; + let _ = channels.try_notify(Notification::MuteStateChanged(false)); + } + } + } + + // Generate sine wave + let out = self.out_port.as_mut_slice(scope); + let gain = if self.muted { 0.0 } else { self.volume }; + + for sample in out.iter_mut() { + let x = self.frequency * self.time * 2.0 * PI; + *sample = (x.sin() as f32) * gain; + self.time += self.frame_t; + } + + jack::Control::Continue + } +} + +fn main() { + // 1. Open a client + let (client, _status) = + jack::Client::new("controlled_sine", jack::ClientOptions::default()).unwrap(); + + // 2. Register port + let out_port = client + .register_port("sine_out", jack::AudioOut::default()) + .unwrap(); + + // 3. Create the processor + let processor = SineProcessor { + out_port, + frequency: 220.0, + volume: 0.5, + muted: false, + frame_t: 1.0 / client.sample_rate() as f64, + time: 0.0, + }; + + // 4. Create the processor instance and control handle + let (processor_instance, mut handle) = processor.instance(16, 16); + + // 5. Activate the client + let active_client = client.activate_async((), processor_instance).unwrap(); + active_client + .as_client() + .connect_ports_by_name("controlled_sine:sine_out", "system:playback_1") + .unwrap(); + active_client + .as_client() + .connect_ports_by_name("controlled_sine:sine_out", "system:playback_2") + .unwrap(); + + // 6. Interactive control loop + println!("Controlled Sine Wave Generator"); + println!("Commands:"); + println!(" - Set frequency in Hz (e.g., 440)"); + println!(" v - Set volume 0-100 (e.g., v50)"); + println!(" m - Mute"); + println!(" u - Unmute"); + println!(" q - Quit"); + println!(); + + loop { + // Check for notifications + for notification in handle.drain_notifications() { + match notification { + Notification::FrequencyChanged(f) => println!("-> Frequency: {f} Hz"), + Notification::VolumeChanged(v) => println!("-> Volume: {:.0}%", v * 100.0), + Notification::MuteStateChanged(muted) => { + println!("-> {}", if muted { "Muted" } else { "Unmuted" }) + } + } + } + + // Read user input + let mut input = String::new(); + if io::stdin().read_line(&mut input).is_err() { + break; + } + let input = input.trim(); + + if input.eq_ignore_ascii_case("q") { + break; + } else if input.eq_ignore_ascii_case("m") { + let _ = handle.send_command(Command::Mute); + } else if input.eq_ignore_ascii_case("u") { + let _ = handle.send_command(Command::Unmute); + } else if let Some(vol_str) = input.strip_prefix('v').or_else(|| input.strip_prefix('V')) { + if let Ok(vol) = u8::from_str(vol_str) { + let volume = (vol.min(100) as f32) / 100.0; + let _ = handle.send_command(Command::SetVolume(volume)); + } else { + println!("Invalid volume. Use v0-v100"); + } + } else if let Ok(freq) = f64::from_str(input) { + let _ = handle.send_command(Command::SetFrequency(freq)); + } else if !input.is_empty() { + println!("Unknown command: {input}"); + } + } + + // 7. Deactivate + if let Err(err) = active_client.deactivate() { + eprintln!("JACK exited with error: {err}"); + } +} diff --git a/src/contrib/controller.rs b/src/contrib/controller.rs index 1a95a0834..e4a5411a5 100644 --- a/src/contrib/controller.rs +++ b/src/contrib/controller.rs @@ -13,6 +13,13 @@ pub struct ProcessorChannels { } impl ProcessorChannels { + /// Try to receive a command from the controller. + /// + /// Returns `Some(command)` if one was available, or `None` if the buffer was empty. + pub fn recv_command(&mut self) -> Option { + self.commands.pop().ok() + } + /// Drain and return an iterator over all pending commands. pub fn drain_commands(&mut self) -> impl Iterator + '_ { std::iter::from_fn(move || self.commands.pop().ok())