This commit is contained in:
atagen 2026-05-19 16:33:09 +10:00
commit ca1910de60
39 changed files with 6328 additions and 0 deletions

View file

@ -0,0 +1,25 @@
[package]
name = "headroom-cli"
description = "Headroom CLI binary."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[[bin]]
name = "headroom"
path = "src/main.rs"
[dependencies]
headroom-client = { workspace = true }
headroom-core = { workspace = true }
headroom-ipc = { workspace = true }
clap = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View file

@ -0,0 +1,256 @@
//! `headroom` — the user-facing CLI binary.
//!
//! For every subcommand other than `daemon`, this binary connects to
//! the running daemon over its Unix-domain socket and issues the
//! corresponding op. `daemon` enters [`headroom_core::run`] directly.
#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use headroom_client::{Client, ClientError, Route, Topic};
/// Headroom CLI.
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Override the daemon control socket path.
#[arg(long, global = true)]
socket: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Debug, Subcommand)]
enum Cmd {
/// Run the headroom daemon in the foreground.
Daemon,
/// Show daemon status (active profile, sinks, current streams).
Status,
/// Profile management.
#[command(subcommand)]
Profile(ProfileCmd),
/// Routing rules and per-stream decisions.
#[command(subcommand)]
Route(RouteCmd),
/// Get a setting value from the active profile.
Get {
/// Dotted setting key.
key: String,
},
/// Set a setting value in the active profile.
Set {
/// Dotted setting key.
key: String,
/// New value, JSON-encoded.
value: String,
},
/// Toggle the global bypass kill switch.
Bypass {
/// `on` or `off`.
#[arg(value_enum)]
state: BypassState,
},
/// Reload profile files from disk.
Reload,
/// Subscribe to meter ticks and print as line-delimited JSON.
Monitor,
}
#[derive(Debug, Subcommand)]
enum ProfileCmd {
/// List known profiles.
List,
/// Activate the named profile.
Use {
/// Profile name.
name: String,
},
/// Show a profile in full.
Show {
/// Profile name (defaults to the active profile).
name: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum RouteCmd {
/// List routing rules and current per-stream decisions.
List,
/// Add or replace a routing rule for an app.
Set {
/// Application identifier (e.g. `application.process.binary`).
app: String,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
/// Remove an app's user routing rule.
Unset {
/// Application identifier.
app: String,
},
/// Reroute a specific live stream by node id.
Stream {
/// PipeWire node id.
node_id: u32,
/// Where to route.
#[arg(value_enum)]
to: RouteArg,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BypassState {
On,
Off,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum RouteArg {
Processed,
Bypass,
}
impl From<RouteArg> for Route {
fn from(r: RouteArg) -> Self {
match r {
RouteArg::Processed => Route::Processed,
RouteArg::Bypass => Route::Bypass,
}
}
}
fn init_tracing() {
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("headroom=info"));
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_target(false)
.compact()
.init();
}
fn run() -> Result<(), CliError> {
let cli = Cli::parse();
init_tracing();
match cli.cmd {
Cmd::Daemon => {
headroom_core::run().map_err(|e| CliError::Daemon(e.to_string()))?;
Ok(())
}
cmd => with_client(cli.socket.as_deref(), |c| dispatch(c, cmd)),
}
}
fn with_client<F>(socket: Option<&std::path::Path>, f: F) -> Result<(), CliError>
where
F: FnOnce(&mut Client) -> Result<(), CliError>,
{
let mut client = match socket {
Some(p) => Client::connect_at(p)?,
None => Client::connect()?,
};
f(&mut client)
}
fn dispatch(client: &mut Client, cmd: Cmd) -> Result<(), CliError> {
match cmd {
Cmd::Daemon => unreachable!("handled in `run`"),
Cmd::Status => {
let status = client.status()?;
println!("{}", serde_json::to_string_pretty(&status)?);
}
Cmd::Profile(ProfileCmd::List) => {
let profiles = client.profile_list()?;
for p in profiles {
let marker = if p.active { '*' } else { ' ' };
println!("{marker} {:<16} {}", p.name, p.description);
}
}
Cmd::Profile(ProfileCmd::Use { name }) => {
let active = client.profile_use(&name)?;
println!("active profile: {active}");
}
Cmd::Profile(ProfileCmd::Show { name }) => {
let body = client.profile_show(name.as_deref())?;
println!("{}", serde_json::to_string_pretty(&body)?);
}
Cmd::Route(RouteCmd::List) => {
let list = client.route_list()?;
println!("{}", serde_json::to_string_pretty(&list)?);
}
Cmd::Route(RouteCmd::Set { app, to }) => {
client.route_set(&app, to.into())?;
}
Cmd::Route(RouteCmd::Unset { app }) => {
client.route_unset(&app)?;
}
Cmd::Route(RouteCmd::Stream { node_id, to }) => {
client.route_stream(node_id, to.into())?;
}
Cmd::Get { key } => {
let v = client.setting_get(&key)?;
println!("{}", serde_json::to_string(&v)?);
}
Cmd::Set { key, value } => {
let parsed: serde_json::Value = serde_json::from_str(&value)
.map_err(|e| CliError::Other(format!("value is not valid JSON: {e}")))?;
client.setting_set(&key, parsed)?;
}
Cmd::Bypass { state } => {
client.bypass_set(matches!(state, BypassState::On))?;
}
Cmd::Reload => {
let reloaded = client.profile_reload()?;
println!("reloaded: {reloaded:?}");
}
Cmd::Monitor => {
client.subscribe(&[Topic::Meters])?;
loop {
let ev = client.next_event()?;
println!("{}", serde_json::to_string(&ev.data)?);
}
}
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("client: {0}")]
Client(#[from] ClientError),
#[error("daemon: {0}")]
Daemon(String),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Other(String),
}
fn main() -> ExitCode {
if let Err(e) = run() {
eprintln!("headroom: {e}");
return ExitCode::from(1);
}
ExitCode::SUCCESS
}

View file

@ -0,0 +1,22 @@
[package]
name = "headroom-client"
description = "Blocking Rust client for the Headroom control protocol."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
headroom-ipc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[features]
default = []
# Reserved for an eventual `tokio::net::UnixStream`-based async client.
async = []

View file

@ -0,0 +1,30 @@
# headroom-client
Blocking Rust client for the Headroom control protocol.
```rust
use headroom_client::{Client, Route};
use headroom_ipc::Topic;
let mut client = Client::connect()?;
println!("connected to headroom {}", client.hello().version);
client.profile_use("night")?;
client.route_set("firefox", Route::Processed)?;
client.subscribe(&[Topic::Meters])?;
loop {
let event = client.next_event()?;
println!("{}/{}: {}", event.topic, event.event, event.data);
}
# Ok::<(), headroom_client::ClientError>(())
```
The crate is a thin layer over [`headroom-ipc`](../headroom-ipc) — it
re-exports the wire types and adds a `Client` that owns a `UnixStream`,
correlates responses by `id`, and queues stray events received while a
request is in flight.
## License
MPL-2.0. Safe to depend on from non-GPL clients.

View file

@ -0,0 +1,498 @@
//! The blocking [`Client`].
use std::collections::VecDeque;
use std::io::{BufReader, BufWriter};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use serde::de::DeserializeOwned;
use headroom_ipc::{
default_socket_path, Codec, Event, HelloData, Op, ProtoError, Request, Response,
ResponsePayload, Route, ServerFrame, Status, Topic,
};
/// Errors produced by the blocking client.
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
/// I/O or codec failure on the underlying socket.
#[error("ipc: {0}")]
Ipc(#[from] headroom_ipc::Error),
/// The server's first frame was not the expected `hello` event.
#[error("expected hello event from server, got {0}")]
BadHello(String),
/// The server sent a response with an id we never issued.
#[error("response with unknown id {0}")]
UnknownResponseId(u64),
/// The server returned a protocol-level error for an op.
#[error("server error: {0}")]
Protocol(#[from] ProtoError),
/// Could not determine the default socket path.
#[error("no default socket path (XDG_RUNTIME_DIR unset and /proc/self/status unreadable)")]
NoDefaultPath,
/// A typed-helper response failed to deserialize into the expected
/// shape.
#[error("response shape mismatch: {0}")]
DecodeResult(serde_json::Error),
}
/// Blocking client for the Headroom control protocol.
///
/// Owns a connected `UnixStream`. Single-threaded by construction; do
/// not share across threads. If you need to do request/response on one
/// connection while another consumes events, open two connections.
pub struct Client {
reader: BufReader<UnixStream>,
writer: BufWriter<UnixStream>,
codec: Codec,
next_id: u64,
pending_events: VecDeque<Event>,
hello: HelloData,
socket_path: PathBuf,
}
impl Client {
/// Connect to the headroom daemon at its default socket path.
pub fn connect() -> Result<Self, ClientError> {
let path = default_socket_path().ok_or(ClientError::NoDefaultPath)?;
Self::connect_at(&path)
}
/// Connect to the headroom daemon at the given socket path.
pub fn connect_at(path: &Path) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).map_err(|e| ClientError::Ipc(e.into()))?;
let reader_half = stream.try_clone().map_err(|e| ClientError::Ipc(e.into()))?;
let writer_half = stream;
let mut me = Self {
reader: BufReader::new(reader_half),
writer: BufWriter::new(writer_half),
codec: Codec::new(),
next_id: 1,
pending_events: VecDeque::new(),
// Placeholder; populated immediately below.
hello: HelloData {
daemon: String::new(),
version: String::new(),
protocol: 0,
},
socket_path: path.to_path_buf(),
};
me.handshake()?;
Ok(me)
}
fn handshake(&mut self) -> Result<(), ClientError> {
let frame: ServerFrame = self.codec.read(&mut self.reader)?;
match frame {
ServerFrame::Event(ev)
if ev.topic == Topic::Control && ev.event.as_str() == "hello" =>
{
let hello: HelloData =
serde_json::from_value(ev.data).map_err(ClientError::DecodeResult)?;
self.hello = hello;
Ok(())
}
ServerFrame::Event(ev) => Err(ClientError::BadHello(format!(
"{} event on {}",
ev.event, ev.topic
))),
ServerFrame::Response(r) => Err(ClientError::BadHello(format!("response id={}", r.id))),
}
}
/// The `hello` payload received on connect.
#[must_use]
pub fn hello(&self) -> &HelloData {
&self.hello
}
/// The socket path this client is connected to.
#[must_use]
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
fn alloc_id(&mut self) -> u64 {
let id = self.next_id;
// Wrap unconditionally — `u64::MAX` requests on one connection
// is the universe heat-death threshold; correctness, not perf.
self.next_id = self.next_id.wrapping_add(1);
id
}
/// Send a request and block until the paired response arrives.
///
/// Stray events received in the meantime are queued and surfaced
/// by subsequent [`next_event`](Self::next_event) calls.
pub fn send(&mut self, op: Op) -> Result<serde_json::Value, ClientError> {
let payload = self.send_raw(op)?;
match payload {
ResponsePayload::Ok { result } => Ok(result),
ResponsePayload::Err { error } => Err(ClientError::Protocol(error)),
}
}
/// Like [`send`](Self::send) but returns the raw [`ResponsePayload`].
///
/// Useful when you need the protocol-level error in-band rather
/// than as a [`ClientError::Protocol`].
pub fn send_raw(&mut self, op: Op) -> Result<ResponsePayload, ClientError> {
let id = self.alloc_id();
let req = Request::new(id, op);
self.codec.write(&mut self.writer, &req)?;
loop {
let frame: ServerFrame = self.codec.read(&mut self.reader)?;
match frame {
ServerFrame::Response(Response {
id: rid,
payload: _,
}) if rid != id => {
return Err(ClientError::UnknownResponseId(rid));
}
ServerFrame::Response(Response { payload, .. }) => return Ok(payload),
ServerFrame::Event(ev) => {
self.pending_events.push_back(ev);
}
}
}
}
/// Block until the next event arrives.
///
/// Drains the internal queue first; only then reads from the socket.
/// If a response is read instead of an event, it is rejected as
/// [`ClientError::UnknownResponseId`] — meaning the client issued
/// no matching request, so the response is unsolicited.
pub fn next_event(&mut self) -> Result<Event, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(ev);
}
match self.codec.read::<_, ServerFrame>(&mut self.reader)? {
ServerFrame::Event(ev) => Ok(ev),
ServerFrame::Response(r) => Err(ClientError::UnknownResponseId(r.id)),
}
}
/// Return a queued event without blocking, if any.
///
/// Does **not** read from the socket. Use this in a hand-rolled
/// loop where you interleave [`send`](Self::send) with event
/// draining.
pub fn pending_event(&mut self) -> Option<Event> {
self.pending_events.pop_front()
}
// ---------------------------------------------------------------
// Typed convenience wrappers
// ---------------------------------------------------------------
fn send_into<T: DeserializeOwned>(&mut self, op: Op) -> Result<T, ClientError> {
let value = self.send(op)?;
serde_json::from_value(value).map_err(ClientError::DecodeResult)
}
/// `status`
pub fn status(&mut self) -> Result<Status, ClientError> {
self.send_into(Op::Status)
}
/// `profile.list`
pub fn profile_list(&mut self) -> Result<Vec<headroom_ipc::ProfileInfo>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
profiles: Vec<headroom_ipc::ProfileInfo>,
}
let body: Body = self.send_into(Op::ProfileList)?;
Ok(body.profiles)
}
/// `profile.use`
pub fn profile_use(&mut self, name: &str) -> Result<String, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
name: String,
}
let body: Body = self.send_into(Op::ProfileUse {
name: name.to_owned(),
})?;
Ok(body.name)
}
/// `profile.show`
pub fn profile_show(
&mut self,
name: Option<&str>,
) -> Result<serde_json::Value, ClientError> {
self.send(Op::ProfileShow {
name: name.map(String::from),
})
}
/// `profile.reload`
pub fn profile_reload(&mut self) -> Result<Vec<String>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
reloaded: Vec<String>,
}
let body: Body = self.send_into(Op::ProfileReload)?;
Ok(body.reloaded)
}
/// `route.list`
pub fn route_list(&mut self) -> Result<headroom_ipc::RouteList, ClientError> {
self.send_into(Op::RouteList)
}
/// `route.set`
pub fn route_set(&mut self, app: &str, to: Route) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteSet {
app: app.to_owned(),
to,
})?;
Ok(())
}
/// `route.unset`
pub fn route_unset(&mut self, app: &str) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteUnset {
app: app.to_owned(),
})?;
Ok(())
}
/// `route.stream`
pub fn route_stream(&mut self, node_id: u32, to: Route) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::RouteStream { node_id, to })?;
Ok(())
}
/// `setting.get`
pub fn setting_get(&mut self, key: &str) -> Result<serde_json::Value, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
#[allow(dead_code)]
key: String,
value: serde_json::Value,
}
let body: Body = self.send_into(Op::SettingGet {
key: key.to_owned(),
})?;
Ok(body.value)
}
/// `setting.set`
pub fn setting_set(
&mut self,
key: &str,
value: serde_json::Value,
) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::SettingSet {
key: key.to_owned(),
value,
})?;
Ok(())
}
/// `bypass.set`
pub fn bypass_set(&mut self, enabled: bool) -> Result<(), ClientError> {
let _: serde_json::Value = self.send(Op::BypassSet { enabled })?;
Ok(())
}
/// `subscribe`
pub fn subscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
subscribed: Vec<Topic>,
}
let body: Body = self.send_into(Op::Subscribe {
topics: topics.to_vec(),
})?;
Ok(body.subscribed)
}
/// `unsubscribe`
pub fn unsubscribe(&mut self, topics: &[Topic]) -> Result<Vec<Topic>, ClientError> {
#[derive(serde::Deserialize)]
struct Body {
unsubscribed: Vec<Topic>,
}
let body: Body = self.send_into(Op::Unsubscribe {
topics: topics.to_vec(),
})?;
Ok(body.unsubscribed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufReader, BufWriter};
use std::os::unix::net::UnixStream;
use std::thread;
use headroom_ipc::{Codec, Event, HelloData, Op, Request, Response, ServerFrame, Topic};
/// A tiny in-process server that runs on the other end of a
/// `UnixStream::pair`. Knows just enough to exercise the client.
fn spawn_test_server() -> (UnixStream, thread::JoinHandle<()>) {
let (a, b) = UnixStream::pair().unwrap();
let server_handle = thread::spawn(move || {
let codec = Codec::new();
let read_side = b.try_clone().unwrap();
let mut reader = BufReader::new(read_side);
let mut writer = BufWriter::new(b);
// Send hello.
let hello = Event::new(
Topic::Control,
"hello",
&HelloData {
daemon: "headroom".into(),
version: "0.1.0-test".into(),
protocol: headroom_ipc::PROTOCOL_VERSION,
},
)
.unwrap();
codec
.write(&mut writer, &ServerFrame::Event(hello))
.unwrap();
// Serve one round.
loop {
let req: Request = match codec.read(&mut reader) {
Ok(r) => r,
Err(_) => return,
};
let resp = match req.op {
Op::Status => Response::ok(
req.id,
&serde_json::json!({
"version": "0.1.0-test",
"protocol": headroom_ipc::PROTOCOL_VERSION,
"uptime_s": 0u64,
"profile": "default",
"bypass": false,
"sinks": {
"processed": {"ready": false},
"real": {"ready": false},
},
"streams": []
}),
)
.unwrap(),
Op::ProfileUse { name } => {
Response::ok(req.id, &serde_json::json!({ "name": name })).unwrap()
}
Op::Subscribe { topics } => {
// Acknowledge, then push one event of each
// subscribed topic so the client can demonstrate
// event handling.
let body = serde_json::json!({ "subscribed": &topics });
let resp = Response::ok(req.id, &body).unwrap();
codec
.write(&mut writer, &ServerFrame::Response(resp.clone()))
.unwrap();
for t in topics {
let ev = Event::new(t, "tick", &serde_json::json!({})).unwrap();
codec.write(&mut writer, &ServerFrame::Event(ev)).unwrap();
}
continue;
}
_ => Response::ok(req.id, &serde_json::Value::Null).unwrap(),
};
codec
.write(&mut writer, &ServerFrame::Response(resp))
.unwrap();
}
});
(a, server_handle)
}
fn client_on(stream: UnixStream) -> Client {
let reader_half = stream.try_clone().unwrap();
let writer_half = stream;
let mut me = Client {
reader: BufReader::new(reader_half),
writer: BufWriter::new(writer_half),
codec: Codec::new(),
next_id: 1,
pending_events: VecDeque::new(),
hello: HelloData {
daemon: String::new(),
version: String::new(),
protocol: 0,
},
socket_path: PathBuf::from("<test>"),
};
me.handshake().unwrap();
me
}
#[test]
fn handshake_then_status() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
assert_eq!(client.hello().daemon, "headroom");
assert_eq!(client.hello().protocol, headroom_ipc::PROTOCOL_VERSION);
let status = client.status().unwrap();
assert_eq!(status.profile, "default");
assert!(!status.bypass);
}
#[test]
fn profile_use_returns_name() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
let name = client.profile_use("night").unwrap();
assert_eq!(name, "night");
}
#[test]
fn subscribe_then_consume_event() {
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
let acked = client.subscribe(&[Topic::Meters]).unwrap();
assert_eq!(acked, vec![Topic::Meters]);
let ev = client.next_event().unwrap();
assert_eq!(ev.topic, Topic::Meters);
assert_eq!(ev.event, "tick");
}
#[test]
fn events_interleaved_during_request_are_queued() {
// The test server pushes events *after* the subscribe response,
// so let's check that requesting another op afterwards drains
// them through the queue.
let (client_sock, _server) = spawn_test_server();
let mut client = client_on(client_sock);
client.subscribe(&[Topic::Meters, Topic::Profile]).unwrap();
// Now issue another request. The server hasn't sent the events
// until we read more, but our client will keep reading.
let status = client.status().unwrap();
assert_eq!(status.profile, "default");
// We may have buffered events from the prior subscribe and from
// any in flight; drain them.
let mut topics = Vec::new();
while let Some(ev) = client.pending_event() {
topics.push(ev.topic);
}
// The events arrived between the subscribe-ack and the status
// response; both should be queued.
assert!(topics.contains(&Topic::Meters));
assert!(topics.contains(&Topic::Profile));
}
}

View file

@ -0,0 +1,19 @@
//! Blocking client for the Headroom control protocol.
//!
//! See [`Client`] for the entry point. The wire types are re-exported
//! from [`headroom-ipc`](headroom_ipc); third-party clients that want to
//! talk JSON directly should target the spec in `IPC.md`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod client;
pub use client::{Client, ClientError};
pub use headroom_ipc::{
default_socket_path, Codec, DaemonEvent, Error as IpcError, ErrorCode, Event, HelloData,
MeterTick, Op, ProfileEvent, ProfileInfo, ProtoError, Request, Response, ResponsePayload,
Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame, SinkInfo, Sinks,
Status, StreamRoute, Topic, PROTOCOL_VERSION,
};

View file

@ -0,0 +1,39 @@
[package]
name = "headroom-core"
description = "Headroom daemon core: PipeWire integration, routing, profiles, IPC server."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
[dependencies]
headroom-dsp = { workspace = true }
headroom-ipc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
crossbeam-channel = { workspace = true }
parking_lot = { workspace = true }
signal-hook = { workspace = true }
# The PipeWire and audio-thread deps are declared but not yet wired up
# in the v0 scaffolding. They are pulled in here so the workspace
# resolves a consistent dep tree from the start.
# pipewire = { workspace = true }
# libspa = { workspace = true }
# rtrb = { workspace = true }
# basedrop = { workspace = true }
# ebur128 = { workspace = true }
# notify = { workspace = true }
# notify-debouncer-mini = { workspace = true }
# tracing-journald = { workspace = true }
[features]
default = []

View file

@ -0,0 +1,31 @@
//! Headroom daemon core.
//!
//! Phase 0/1 scaffolding: this crate currently exposes only the daemon
//! entry-point shape that `headroom-cli` calls into. The real daemon
//! (PipeWire integration, routing, slow AGC loop, IPC server) lands in
//! Phase 3 and 4 per `PLAN.md`.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
/// Run the daemon to completion. Currently a placeholder.
///
/// # Errors
/// Returns `Err` if startup fails. The current scaffolding always
/// returns `Ok` — it logs an "unimplemented" message and exits.
pub fn run() -> Result<(), DaemonError> {
tracing::warn!("headroom-core::run is a placeholder; daemon not yet implemented");
Ok(())
}
/// Errors from running the daemon.
#[derive(Debug, thiserror::Error)]
pub enum DaemonError {
/// I/O error.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// Generic failure with a message.
#[error("{0}")]
Other(String),
}

View file

@ -0,0 +1,19 @@
[package]
name = "headroom-dsp"
description = "DSP kernels for Headroom: true-peak limiter, compressor, AGC envelope helpers."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
# Kept intentionally empty. The DSP crate must build clean on any host
# and is the most reusable piece in the workspace. If you find yourself
# wanting to add a dependency here, think twice.
[features]
default = []

View file

@ -0,0 +1,19 @@
# headroom-dsp
DSP kernels for Headroom. Pure Rust, no dependencies.
- `Limiter` — feed-forward true-peak brickwall with configurable
oversampling (1/2/4/8×), lookahead, hold, and release.
- `Compressor` — log-domain feed-forward with peak or RMS detector,
soft knee, attack/release, and optional auto-makeup.
- `AttackRelease` — exponential envelope follower (peak / inverse-gain
modes).
- `DelayLine`, `SlidingMaxBuffer`, `PolyphaseUpsampler`,
`PolyphaseDownsampler` — supporting building blocks.
All processors are allocation-free in their `process_*` methods.
Construction allocates; do not construct in the audio thread.
## License
MPL-2.0.

View file

@ -0,0 +1,268 @@
//! Feed-forward dynamics compressor.
//!
//! Log-domain detector → static curve with soft knee → smoothed
//! envelope → linear gain → apply (no internal delay).
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
/// Detector type used to build the side-chain signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Detector {
/// Maximum of `|left|, |right|`. Fast, low CPU, slightly more
/// percussive feel on transients.
Peak,
/// One-pole low-passed mean square. Smoother, more relaxed on
/// percussive material.
Rms,
}
/// Compressor parameters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CompressorConfig {
/// Threshold in dBFS. Inputs above this start compressing.
pub threshold_db: f32,
/// Compression ratio (>= 1.0).
pub ratio: f32,
/// Soft-knee width in dB. `0.0` is a hard knee.
pub knee_db: f32,
/// Attack time in ms.
pub attack_ms: f32,
/// Release time in ms.
pub release_ms: f32,
/// Makeup gain in dB. `None` selects an automatic mild boost.
pub makeup_db: Option<f32>,
/// Detector type.
pub detector: Detector,
/// RMS window in ms (only used when `detector == Rms`).
pub rms_window_ms: f32,
}
impl Default for CompressorConfig {
fn default() -> Self {
Self {
threshold_db: -24.0,
ratio: 2.5,
knee_db: 6.0,
attack_ms: 10.0,
release_ms: 100.0,
makeup_db: None,
detector: Detector::Peak,
rms_window_ms: 5.0,
}
}
}
impl CompressorConfig {
/// Clamp ratio to `>= 1.0`, knee/attack/release/window to `>= 0`.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.ratio < 1.0 {
self.ratio = 1.0;
}
self.knee_db = self.knee_db.max(0.0);
self.attack_ms = self.attack_ms.max(0.0);
self.release_ms = self.release_ms.max(0.0);
self.rms_window_ms = self.rms_window_ms.max(0.1);
self
}
}
/// Feed-forward compressor (stereo-linked).
pub struct Compressor {
cfg: CompressorConfig,
sample_rate: f32,
envelope_db: f32,
attack_alpha: f32,
release_alpha: f32,
rms_state: f32,
rms_alpha: f32,
last_gr_db: f32,
}
impl Compressor {
/// Construct with the given config and input sample rate.
#[must_use]
pub fn new(cfg: CompressorConfig, sample_rate: f32) -> Self {
let cfg = cfg.sanitized();
Self {
cfg,
sample_rate,
envelope_db: -200.0,
attack_alpha: time_to_alpha(cfg.attack_ms, sample_rate),
release_alpha: time_to_alpha(cfg.release_ms, sample_rate),
rms_state: 0.0,
rms_alpha: time_to_alpha(cfg.rms_window_ms, sample_rate),
last_gr_db: 0.0,
}
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> CompressorConfig {
self.cfg
}
/// Most recent gain reduction in dB (negative when compressing).
#[must_use]
pub fn gain_reduction_db(&self) -> f32 {
self.last_gr_db
}
/// Update parameters. Recomputes alphas. Envelope state is kept,
/// so live tweaks don't pop.
pub fn set_config(&mut self, cfg: CompressorConfig) {
let cfg = cfg.sanitized();
self.cfg = cfg;
self.attack_alpha = time_to_alpha(cfg.attack_ms, self.sample_rate);
self.release_alpha = time_to_alpha(cfg.release_ms, self.sample_rate);
self.rms_alpha = time_to_alpha(cfg.rms_window_ms, self.sample_rate);
}
/// Process one stereo frame.
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
let det_lin = match self.cfg.detector {
Detector::Peak => left.abs().max(right.abs()),
Detector::Rms => {
let sq = 0.5 * left.mul_add(left, right * right);
self.rms_state += self.rms_alpha * (sq - self.rms_state);
self.rms_state.max(0.0).sqrt()
}
};
let det_db = lin_to_db(det_lin.max(1e-20));
if det_db > self.envelope_db {
self.envelope_db += self.attack_alpha * (det_db - self.envelope_db);
} else {
self.envelope_db += self.release_alpha * (det_db - self.envelope_db);
}
let gr_db = static_curve_gain_reduction(
self.envelope_db,
self.cfg.threshold_db,
self.cfg.ratio,
self.cfg.knee_db,
);
let makeup_db = self
.cfg
.makeup_db
.unwrap_or_else(|| auto_makeup(self.cfg.threshold_db, self.cfg.ratio));
let lin_gain = db_to_lin(-gr_db + makeup_db);
self.last_gr_db = -gr_db;
(left * lin_gain, right * lin_gain)
}
/// Reset envelopes and detector state.
pub fn reset(&mut self) {
self.envelope_db = -200.0;
self.rms_state = 0.0;
self.last_gr_db = 0.0;
}
}
/// Static compression curve. Returns the positive gain reduction (in
/// dB) that should be subtracted from the input level.
fn static_curve_gain_reduction(
input_db: f32,
threshold_db: f32,
ratio: f32,
knee_db: f32,
) -> f32 {
let over = input_db - threshold_db;
if knee_db > 0.0 && over > -knee_db * 0.5 && over < knee_db * 0.5 {
// Quadratic soft knee.
let x = over + knee_db * 0.5;
let factor = (x * x) / (2.0 * knee_db);
factor * (1.0 - 1.0 / ratio)
} else if over > 0.0 {
over * (1.0 - 1.0 / ratio)
} else {
0.0
}
}
/// Mild auto-makeup: compensate for half the static gain reduction at
/// 0 dBFS. Conservative on purpose — the limiter is downstream and we
/// don't want to push it.
fn auto_makeup(threshold_db: f32, ratio: f32) -> f32 {
let gr_at_zero = (-threshold_db).max(0.0) * (1.0 - 1.0 / ratio);
gr_at_zero * 0.5
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn below_threshold_is_unity_minus_makeup() {
let mut c = Compressor::new(CompressorConfig::default(), 48_000.0);
// Drive a long, low signal and check we land at expected gain.
let mut last = (0.0_f32, 0.0_f32);
for _ in 0..10_000 {
last = c.process_frame(0.01, 0.01);
}
// Below threshold: gain reduction is zero, only makeup applied.
let makeup_db = auto_makeup(-24.0, 2.5);
let expected = 0.01 * db_to_lin(makeup_db);
assert!(
(last.0 - expected).abs() < 1e-3,
"got {} expected {}",
last.0,
expected
);
assert!(c.gain_reduction_db().abs() < 0.1);
}
#[test]
fn above_threshold_reduces_gain() {
let cfg = CompressorConfig {
threshold_db: -20.0,
ratio: 4.0,
knee_db: 0.0, // hard knee for clean math
attack_ms: 0.1,
release_ms: 0.1,
makeup_db: Some(0.0), // no makeup so we test pure reduction
..CompressorConfig::default()
};
let mut c = Compressor::new(cfg, 48_000.0);
// Drive ~-6 dBFS = 0.5 in linear.
let target = 0.5_f32;
let mut last_out = 0.0;
for _ in 0..2_000 {
let (l, _) = c.process_frame(target, target);
last_out = l;
}
// Input is 14 dB above threshold. With ratio 4, GR = 14*(1-0.25) = 10.5 dB.
// Expected output: -6 - 10.5 = -16.5 dB linear = 0.1496.
let expected_db = -6.0 - 14.0 * (1.0 - 0.25);
let expected_lin = db_to_lin(expected_db);
let got_db = lin_to_db(last_out);
assert!(
(got_db - expected_db).abs() < 0.5,
"got {got_db} expected {expected_db}"
);
assert!(c.gain_reduction_db() < -5.0, "gr was {}", c.gain_reduction_db());
let _ = expected_lin;
}
#[test]
fn ratio_below_one_is_clamped() {
let cfg = CompressorConfig {
ratio: 0.5,
..CompressorConfig::default()
}
.sanitized();
assert_eq!(cfg.ratio, 1.0);
}
#[test]
fn static_curve_at_threshold_with_soft_knee() {
// At exactly threshold, soft knee contributes exactly half the
// ratio's compression amount at the upper knee shoulder.
let gr = static_curve_gain_reduction(-24.0, -24.0, 4.0, 6.0);
// At over==0 inside the knee, x = knee/2, factor = knee/8.
// GR = knee/8 * (1 - 1/4) = 6/8 * 0.75 = 0.5625
assert!((gr - 0.5625).abs() < 1e-4, "gr={gr}");
}
}

View file

@ -0,0 +1,76 @@
//! A simple fixed-length sample delay line.
/// FIFO sample delay of fixed length.
///
/// `push_pop(x)` writes `x` and returns the sample that was written
/// `len` calls ago (initialized to zero).
pub struct DelayLine {
buf: Vec<f32>,
write_idx: usize,
}
impl DelayLine {
/// Construct a new delay line of `samples` length.
///
/// Lengths of 0 are clamped to 1 so the type always behaves like a
/// one-sample identity at minimum.
#[must_use]
pub fn new(samples: usize) -> Self {
Self {
buf: vec![0.0; samples.max(1)],
write_idx: 0,
}
}
/// Effective delay in samples.
#[must_use]
pub fn len(&self) -> usize {
self.buf.len()
}
/// Always false.
#[must_use]
pub fn is_empty(&self) -> bool {
false
}
/// Write `x` and return the sample written `len` calls ago.
pub fn push_pop(&mut self, x: f32) -> f32 {
let out = self.buf[self.write_idx];
self.buf[self.write_idx] = x;
self.write_idx = (self.write_idx + 1) % self.buf.len();
out
}
/// Clear the delay line to silence.
pub fn reset(&mut self) {
for v in &mut self.buf {
*v = 0.0;
}
self.write_idx = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn delays_exactly_n_samples() {
let mut d = DelayLine::new(4);
let expected = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0];
let inputs = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
for (i, &x) in inputs.iter().enumerate() {
let y = d.push_pop(x);
assert!((y - expected[i]).abs() < 1e-9, "i={i} y={y}");
}
}
#[test]
fn zero_length_clamps_to_one() {
let mut d = DelayLine::new(0);
assert_eq!(d.len(), 1);
assert_eq!(d.push_pop(1.0), 0.0);
assert_eq!(d.push_pop(2.0), 1.0);
}
}

View file

@ -0,0 +1,98 @@
//! Exponential attack/release envelope follower.
use crate::util::time_to_alpha;
/// One-pole smoother with separate attack and release coefficients.
pub struct AttackRelease {
attack_alpha: f32,
release_alpha: f32,
state: f32,
}
impl AttackRelease {
/// Construct from times in milliseconds and a sample rate (Hz).
#[must_use]
pub fn new(attack_ms: f32, release_ms: f32, sample_rate: f32) -> Self {
Self {
attack_alpha: time_to_alpha(attack_ms, sample_rate),
release_alpha: time_to_alpha(release_ms, sample_rate),
state: 0.0,
}
}
/// Update the coefficients (e.g. after a sample-rate change).
pub fn set_times(&mut self, attack_ms: f32, release_ms: f32, sample_rate: f32) {
self.attack_alpha = time_to_alpha(attack_ms, sample_rate);
self.release_alpha = time_to_alpha(release_ms, sample_rate);
}
/// Peak-detector mode: attack on rising input, release on falling.
/// Typical use: envelope detector for compressors.
pub fn process_peak(&mut self, target: f32) -> f32 {
if target > self.state {
self.state += self.attack_alpha * (target - self.state);
} else {
self.state += self.release_alpha * (target - self.state);
}
self.state
}
/// Gain-follower mode: attack on falling input (gain dropping),
/// release on rising input (gain recovering toward unity). The
/// inverse direction from [`process_peak`](Self::process_peak).
pub fn process_gain(&mut self, target: f32) -> f32 {
if target < self.state {
self.state += self.attack_alpha * (target - self.state);
} else {
self.state += self.release_alpha * (target - self.state);
}
self.state
}
/// Current state.
#[must_use]
pub fn state(&self) -> f32 {
self.state
}
/// Force the state to a given value.
pub fn reset(&mut self, value: f32) {
self.state = value;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peak_mode_attacks_fast_releases_slow() {
let fs = 48_000.0;
let mut env = AttackRelease::new(0.1, 100.0, fs);
// Drive to 1.0 and let it settle.
for _ in 0..100 {
env.process_peak(1.0);
}
assert!(env.state() > 0.99);
// Drop input to 0.0 and verify slow decay.
env.process_peak(0.0);
assert!(env.state() > 0.999);
for _ in 0..10 {
env.process_peak(0.0);
}
// Still well above zero on the release time scale.
assert!(env.state() > 0.8);
}
#[test]
fn gain_mode_attacks_on_drop() {
let fs = 48_000.0;
let mut env = AttackRelease::new(0.1, 100.0, fs);
env.reset(1.0);
// Demand a gain drop. Should snap down quickly.
for _ in 0..100 {
env.process_gain(0.5);
}
assert!(env.state() < 0.51);
}
}

View file

@ -0,0 +1,25 @@
//! DSP kernels for Headroom.
//!
//! The contract: every `process_*` method on the public types is
//! allocation-free and bounded-time. Construction (`new`) allocates and
//! is not realtime-safe — do it ahead of time.
//!
//! See `PLAN.md` §3 for the role each kernel plays in the daemon.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod compressor;
mod delay;
mod envelope;
mod limiter;
mod oversample;
mod sliding_max;
pub mod util;
pub use compressor::{Compressor, CompressorConfig, Detector};
pub use delay::DelayLine;
pub use envelope::AttackRelease;
pub use limiter::{Limiter, LimiterConfig, SoftTierConfig};
pub use oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
pub use sliding_max::SlidingMaxBuffer;

View file

@ -0,0 +1,850 @@
//! Two-tier true-peak limiter.
//!
//! Architecture (per channel, stereo-linked gain):
//!
//! ```text
//! input ─► upsample ─► delay ─► × gain ─► clamp ─► downsample ─► clamp ─► out
//! │ ▲
//! └─► peak ─────┤
//! │ │
//! ▼ │
//! ┌────────┐ │
//! │ soft │── × │ smooth attack/release
//! │ ceil │ │ target = program_lufs + max_psr_db
//! └────────┘ │
//! ┌────────┐ │
//! │ hard │── × ┘ instant attack + hold + release
//! │ ceil │ target = ceiling_dbtp (e.g. 0.1)
//! └────────┘
//! ```
//!
//! The **hard tier** enforces the absolute output contract: a
//! configurable ceiling (default `0.1 dBTP`) with full inter-sample
//! peak handling. Its gain has instant attack, a brief hold, and an
//! exponential release. Two defensive `clamp` stages downstream
//! guarantee the contract numerically — the envelope can misbehave and
//! the contract still holds.
//!
//! The **soft tier** sits in parallel. Its target ceiling is *dynamic*:
//! `program_loudness_lufs + soft.max_psr_db`. It uses a smooth
//! attack/release envelope (musical, not slappy) and pulls transients
//! down to a comfortable peak-to-loudness ratio *before* they ever
//! threaten the hard ceiling. Listeners hear "loud-but-not-shocking"
//! transients instead of bricks landing exactly at the ceiling.
//!
//! The two tiers share the upsampler, downsampler, delay line, and
//! sliding peak buffer. The cost of the soft tier is one extra
//! envelope evaluation per oversampled sample — no additional latency,
//! no additional FIR work.
//!
//! When no program loudness has been provided (typical at startup, or
//! before the AGC's first `ebur128` window completes), the soft tier
//! falls back to a static ceiling. When the soft tier is disabled
//! entirely (`LimiterConfig::soft = None`), the limiter behaves as a
//! pure brickwall — see the `transparent` profile.
use crate::delay::DelayLine;
use crate::envelope::AttackRelease;
use crate::oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
use crate::sliding_max::SlidingMaxBuffer;
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
/// Soft-tier configuration.
///
/// The soft tier targets a *dynamic* ceiling computed as
/// `program_loudness_lufs + max_psr_db`. It is responsible for the
/// listening experience — keeping the peak-to-loudness ratio bounded
/// — without acting as a safety contract (that's the hard tier's job).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SoftTierConfig {
/// Maximum allowed peak-to-shortterm-loudness ratio in dB.
/// Effective ceiling becomes `program_lufs + max_psr_db`.
pub max_psr_db: f32,
/// Fallback ceiling in dBTP used when no program loudness has been
/// supplied (e.g. during startup before the AGC has measured the
/// first short-term window).
pub static_ceiling_dbtp: f32,
/// Attack time in ms (smooth, not instant).
pub attack_ms: f32,
/// Release time in ms.
pub release_ms: f32,
}
impl Default for SoftTierConfig {
fn default() -> Self {
Self {
max_psr_db: 14.0,
static_ceiling_dbtp: -6.0,
attack_ms: 5.0,
release_ms: 200.0,
}
}
}
impl SoftTierConfig {
/// Sanitize: positive ceilings clamp to 0, non-finite or negative
/// times clamp to small positives.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.static_ceiling_dbtp > 0.0 {
self.static_ceiling_dbtp = 0.0;
}
if !self.max_psr_db.is_finite() || self.max_psr_db < 0.0 {
self.max_psr_db = 0.0;
}
if self.attack_ms < 0.0 || !self.attack_ms.is_finite() {
self.attack_ms = 0.0;
}
if self.release_ms < 0.0 || !self.release_ms.is_finite() {
self.release_ms = 0.0;
}
self
}
}
/// Configurable limiter parameters.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LimiterConfig {
/// Hard-tier output ceiling in dBTP. Must be `<= 0.0`.
pub ceiling_dbtp: f32,
/// Lookahead time in milliseconds. Sets the delay-line length and
/// the size of the peak-detector sliding window. Shared by both
/// tiers.
pub lookahead_ms: f32,
/// Hard-tier exponential release (toward unity gain) in ms.
pub release_ms: f32,
/// Hard-tier hold time after a gain reduction before release
/// begins, in ms.
pub hold_ms: f32,
/// Oversampling factor. 1 disables ISP detection; 4 is the
/// BS.1770-4 reference.
pub oversample: usize,
/// Number of FIR taps used by the oversampling filter (odd).
pub fir_taps: usize,
/// Soft-tier configuration. `None` disables the soft tier and the
/// limiter behaves as a pure brickwall.
pub soft: Option<SoftTierConfig>,
}
impl Default for LimiterConfig {
fn default() -> Self {
Self {
ceiling_dbtp: -0.1,
lookahead_ms: 2.0,
release_ms: 80.0,
hold_ms: 5.0,
oversample: 4,
fir_taps: 31,
soft: Some(SoftTierConfig::default()),
}
}
}
impl LimiterConfig {
/// Sanitize a user-supplied configuration: clamp ceiling,
/// oversample factor, ensure odd FIR length, sanitize the soft
/// tier if present.
#[must_use]
pub fn sanitized(mut self) -> Self {
if self.ceiling_dbtp > 0.0 {
self.ceiling_dbtp = 0.0;
}
self.oversample = self.oversample.clamp(1, 8);
if self.fir_taps < 5 {
self.fir_taps = 5;
}
if self.fir_taps % 2 == 0 {
self.fir_taps += 1;
}
if let Some(soft) = self.soft {
self.soft = Some(soft.sanitized());
}
self
}
/// Convenience: brickwall only (no soft tier).
#[must_use]
pub fn brickwall_only() -> Self {
Self {
soft: None,
..Self::default()
}
}
}
const MAX_OVERSAMPLE: usize = 8;
/// Two-tier feed-forward true-peak limiter.
pub struct Limiter {
cfg: LimiterConfig,
ceiling_lin: f32,
os: usize,
// Per-channel oversampler / downsampler / delay-line paths.
up_l: PolyphaseUpsampler,
up_r: PolyphaseUpsampler,
down_l: PolyphaseDownsampler,
down_r: PolyphaseDownsampler,
delay_l: DelayLine,
delay_r: DelayLine,
/// Sliding-window peak (oversampled domain), shared across
/// channels and tiers.
peak_buf: SlidingMaxBuffer,
// ---- Hard tier state (instant attack + hold + release) ----
hard_gain: f32,
hold_remaining: u32,
hold_samples_os: u32,
hard_release_alpha: f32,
// ---- Soft tier state (smooth envelope) ----
soft_envelope: Option<AttackRelease>,
soft_max_psr_db: f32,
soft_static_ceiling_lin: f32,
program_loudness_lufs: Option<f32>,
/// Effective soft ceiling in linear gain, recomputed whenever the
/// program loudness changes (or once at startup).
soft_ceiling_lin: f32,
// Scratch buffers (sized for the maximum supported oversample
// factor).
up_buf_l: [f32; MAX_OVERSAMPLE],
up_buf_r: [f32; MAX_OVERSAMPLE],
gained_buf_l: [f32; MAX_OVERSAMPLE],
gained_buf_r: [f32; MAX_OVERSAMPLE],
// Telemetry (sampled per frame).
last_peak_lin: f32,
last_gr_db: f32,
last_soft_gr_db: f32,
last_hard_gr_db: f32,
}
impl Limiter {
/// Construct from a configuration and input sample rate.
///
/// Allocates the FIR coefficients, polyphase tables, and delay
/// buffers. Not realtime-safe.
#[must_use]
pub fn new(cfg: LimiterConfig, sample_rate: f32) -> Self {
let cfg = cfg.sanitized();
let os = cfg.oversample;
let lowpass = if os > 1 {
design_lowpass_blackman(cfg.fir_taps, 0.45 / os as f32)
} else {
vec![1.0]
};
let os_rate = sample_rate * os as f32;
let lookahead_samples_os = (cfg.lookahead_ms * 1e-3 * os_rate).round() as usize;
let lookahead_samples_os = lookahead_samples_os.max(1);
let hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
let hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
let ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
let (soft_envelope, soft_max_psr_db, soft_static_ceiling_lin, soft_ceiling_lin) =
if let Some(soft) = cfg.soft {
let env = AttackRelease::new(soft.attack_ms, soft.release_ms, os_rate);
let static_ceiling_lin = db_to_lin(soft.static_ceiling_dbtp);
(
Some(env),
soft.max_psr_db,
static_ceiling_lin,
static_ceiling_lin,
)
} else {
(None, 0.0, 1.0, 1.0)
};
let mut me = Self {
cfg,
ceiling_lin,
os,
up_l: PolyphaseUpsampler::new(os, &lowpass),
up_r: PolyphaseUpsampler::new(os, &lowpass),
down_l: PolyphaseDownsampler::new(os, &lowpass),
down_r: PolyphaseDownsampler::new(os, &lowpass),
delay_l: DelayLine::new(lookahead_samples_os),
delay_r: DelayLine::new(lookahead_samples_os),
peak_buf: SlidingMaxBuffer::new(lookahead_samples_os),
hard_gain: 1.0,
hold_remaining: 0,
hold_samples_os,
hard_release_alpha,
soft_envelope,
soft_max_psr_db,
soft_static_ceiling_lin,
program_loudness_lufs: None,
soft_ceiling_lin,
up_buf_l: [0.0; MAX_OVERSAMPLE],
up_buf_r: [0.0; MAX_OVERSAMPLE],
gained_buf_l: [0.0; MAX_OVERSAMPLE],
gained_buf_r: [0.0; MAX_OVERSAMPLE],
last_peak_lin: 0.0,
last_gr_db: 0.0,
last_soft_gr_db: 0.0,
last_hard_gr_db: 0.0,
};
// Seed soft envelope to unity so we don't start with phantom
// gain reduction during the first frames.
if let Some(env) = &mut me.soft_envelope {
env.reset(1.0);
}
me
}
/// Active configuration.
#[must_use]
pub fn config(&self) -> LimiterConfig {
self.cfg
}
/// Hard-tier output ceiling in dBTP.
#[must_use]
pub fn ceiling_dbtp(&self) -> f32 {
self.cfg.ceiling_dbtp
}
/// Most recent total gain reduction in dB (negative when limiting).
/// This is the *applied* reduction: `min(soft_gain, hard_gain)`.
#[must_use]
pub fn gain_reduction_db(&self) -> f32 {
self.last_gr_db
}
/// Most recent soft-tier gain reduction in dB.
#[must_use]
pub fn soft_gain_reduction_db(&self) -> f32 {
self.last_soft_gr_db
}
/// Most recent hard-tier gain reduction in dB.
///
/// A non-zero value here indicates the soft tier did not keep the
/// signal under the absolute ceiling and the brickwall engaged.
/// Routinely non-zero values for benign material suggest the soft
/// tier is under-configured (too high `max_psr_db`, too slow
/// attack, or the lookahead is too short for the chosen attack).
#[must_use]
pub fn hard_gain_reduction_db(&self) -> f32 {
self.last_hard_gr_db
}
/// Most recent observed true-peak in dBTP.
#[must_use]
pub fn true_peak_dbtp(&self) -> f32 {
lin_to_db(self.last_peak_lin.max(1e-20))
}
/// Effective soft ceiling currently in use, in dBTP.
///
/// Equals `program_loudness_lufs + soft.max_psr_db` when both are
/// known, otherwise the configured `soft.static_ceiling_dbtp`.
/// Returns `None` if the soft tier is disabled.
#[must_use]
pub fn effective_soft_ceiling_dbtp(&self) -> Option<f32> {
self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
}
/// Update the program loudness used to compute the dynamic soft
/// ceiling. Typically called by the AGC at its tick rate with the
/// short-term BS.1770 loudness; non-finite values are ignored.
pub fn set_program_loudness_lufs(&mut self, lufs: f32) {
if !lufs.is_finite() {
return;
}
self.program_loudness_lufs = Some(lufs);
self.recompute_soft_ceiling();
}
/// Forget the program loudness; soft tier falls back to its static
/// ceiling. Useful when the AGC stalls or is reset.
pub fn clear_program_loudness(&mut self) {
self.program_loudness_lufs = None;
self.recompute_soft_ceiling();
}
fn recompute_soft_ceiling(&mut self) {
self.soft_ceiling_lin = match (self.cfg.soft, self.program_loudness_lufs) {
(Some(_), Some(lufs)) => {
let dynamic_dbtp = (lufs + self.soft_max_psr_db).min(0.0);
db_to_lin(dynamic_dbtp)
}
(Some(_), None) => self.soft_static_ceiling_lin,
(None, _) => 1.0,
};
}
/// Process one stereo frame.
///
/// Allocation-free. Returns `(left, right)` guaranteed to lie
/// within `±ceiling_dbtp` (the hard contract).
pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
// Sanitize NaN / Inf to zero defensively; never propagate
// garbage into the limiter state.
let left = if left.is_finite() { left } else { 0.0 };
let right = if right.is_finite() { right } else { 0.0 };
self.up_l.process(left, &mut self.up_buf_l[..self.os]);
self.up_r.process(right, &mut self.up_buf_r[..self.os]);
let mut frame_peak = 0.0_f32;
let mut min_soft_gain = 1.0_f32;
let mut min_total_gain = 1.0_f32;
for k in 0..self.os {
let s_l = self.up_buf_l[k];
let s_r = self.up_buf_r[k];
let peak = s_l.abs().max(s_r.abs());
frame_peak = frame_peak.max(peak);
let window_peak = self.peak_buf.push_and_max(peak);
// ---- Soft tier --------------------------------------
let soft_gain = if let Some(env) = &mut self.soft_envelope {
let target = if window_peak > self.soft_ceiling_lin && window_peak > 1e-20 {
self.soft_ceiling_lin / window_peak
} else {
1.0
};
env.process_gain(target)
} else {
1.0
};
if soft_gain < min_soft_gain {
min_soft_gain = soft_gain;
}
// ---- Hard tier --------------------------------------
// The hard tier defends the ceiling, but it shouldn't do
// redundant work when the soft tier already handles the
// peak. So compute the predicted peak *after* the soft
// tier acts, then size the hard gain against that.
//
// `predicted_post_soft` takes the max of:
// - `immediate`: the peak with the *current* soft gain
// applied (safe if soft hasn't ramped yet)
// - `asymptotic`: the peak after the soft tier converges
// to its target (the steady-state)
// The max is the more conservative (larger) prediction.
let predicted_post_soft = if self.soft_envelope.is_some() {
let asymptotic = window_peak.min(self.soft_ceiling_lin);
let immediate = window_peak * soft_gain;
asymptotic.max(immediate)
} else {
window_peak
};
let hard_target =
if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {
self.ceiling_lin / predicted_post_soft
} else {
1.0
};
if hard_target < self.hard_gain {
self.hard_gain = hard_target;
self.hold_remaining = self.hold_samples_os;
} else if self.hold_remaining > 0 {
self.hold_remaining -= 1;
} else {
self.hard_gain += self.hard_release_alpha * (hard_target - self.hard_gain);
if self.hard_gain > hard_target {
self.hard_gain = hard_target;
}
}
// ---- Combine ----------------------------------------
let total_gain = soft_gain.min(self.hard_gain);
if total_gain < min_total_gain {
min_total_gain = total_gain;
}
let d_l = self.delay_l.push_pop(s_l);
let d_r = self.delay_r.push_pop(s_r);
let mut out_l = d_l * total_gain;
let mut out_r = d_r * total_gain;
// Defense-in-depth #1: brickwall clip in the oversampled
// domain. Prevents extreme overshoots from passing into
// the downsampler.
out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
self.gained_buf_l[k] = out_l;
self.gained_buf_r[k] = out_r;
}
let mut out_l = self.down_l.process(&self.gained_buf_l[..self.os]);
let mut out_r = self.down_r.process(&self.gained_buf_r[..self.os]);
// Defense-in-depth #2: brickwall clip at the input sample
// rate, after downsampling. Guards against FIR-induced ringing
// nudging the output above the ceiling in the downsampled
// domain.
out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
self.last_peak_lin = frame_peak;
self.last_soft_gr_db = lin_to_db(min_soft_gain.max(1e-12));
self.last_hard_gr_db = lin_to_db(self.hard_gain.max(1e-12));
self.last_gr_db = lin_to_db(min_total_gain.max(1e-12));
(out_l, out_r)
}
/// Process an interleaved stereo buffer in place.
pub fn process_interleaved_stereo(&mut self, buf: &mut [f32]) {
debug_assert!(buf.len() % 2 == 0);
for frame in buf.chunks_exact_mut(2) {
let (l, r) = self.process_frame(frame[0], frame[1]);
frame[0] = l;
frame[1] = r;
}
}
/// Reset all internal state. Program loudness is also cleared.
pub fn reset(&mut self) {
self.up_l.reset();
self.up_r.reset();
self.down_l.reset();
self.down_r.reset();
self.delay_l.reset();
self.delay_r.reset();
self.peak_buf.reset();
self.hard_gain = 1.0;
self.hold_remaining = 0;
if let Some(env) = &mut self.soft_envelope {
env.reset(1.0);
}
self.program_loudness_lufs = None;
self.recompute_soft_ceiling();
self.last_peak_lin = 0.0;
self.last_gr_db = 0.0;
self.last_soft_gr_db = 0.0;
self.last_hard_gr_db = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f32::consts::PI;
fn run_sine(
limiter: &mut Limiter,
freq: f32,
amp_db: f32,
samples: usize,
sr: f32,
) -> Vec<f32> {
let amp = db_to_lin(amp_db);
let mut out = Vec::with_capacity(samples * 2);
for n in 0..samples {
let t = n as f32 / sr;
let s = amp * (2.0 * PI * freq * t).sin();
let (l, r) = limiter.process_frame(s, s);
out.push(l);
out.push(r);
}
out
}
// ----------------------------------------------------------------
// Hard-tier contract: holds with or without the soft tier present.
// ----------------------------------------------------------------
#[test]
fn passes_signal_below_both_ceilings_unchanged() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// -18 dBFS is below the default static soft ceiling of -6 dBTP
// and the hard ceiling. Neither tier should engage.
let out = run_sine(&mut l, 440.0, -18.0, 4_800, sr);
let max_abs = out.iter().skip(1_000).fold(0.0_f32, |a, &b| a.max(b.abs()));
let max_db = lin_to_db(max_abs);
assert!(
(max_db - (-18.0)).abs() < 0.5,
"expected ~-18 dB, got {max_db}"
);
assert!(
l.gain_reduction_db().abs() < 0.5,
"expected ~0 GR, got {}",
l.gain_reduction_db()
);
}
#[test]
fn enforces_hard_ceiling_on_hot_signal_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
let ceiling_lin = db_to_lin(-0.1);
let max_abs = out
.iter()
.skip(2_000)
.fold(0.0_f32, |a, &b| a.max(b.abs()));
assert!(
max_abs <= ceiling_lin + 1e-6,
"above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn enforces_hard_ceiling_on_intersample_peak_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let ceiling_lin = db_to_lin(-0.1);
let mut max_abs = 0.0_f32;
let mut sign = 1.0_f32;
let amp = 0.95_f32;
for n in 0..9_600 {
let s = sign * amp;
sign = -sign;
let (lo, ro) = l.process_frame(s, s);
if n > 1_500 {
max_abs = max_abs.max(lo.abs()).max(ro.abs());
}
}
assert!(
max_abs <= ceiling_lin + 1e-6,
"ISP: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn enforces_hard_ceiling_on_transient_impulse_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
let ceiling_lin = db_to_lin(-0.1);
let mut max_abs = 0.0_f32;
for n in 0..4_800_usize {
let s = if n == 1_000 { 4.0 } else { 0.0 };
let (lo, ro) = l.process_frame(s, s);
max_abs = max_abs.max(lo.abs()).max(ro.abs());
}
assert!(
max_abs <= ceiling_lin + 1e-6,
"impulse: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
);
}
#[test]
fn brickwall_only_skips_soft_tier_entirely() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
assert!(l.effective_soft_ceiling_dbtp().is_none());
// Drive a hot signal; brickwall must still hold.
let out = run_sine(&mut l, 440.0, 6.0, 4_800, sr);
let ceiling_lin = db_to_lin(-0.1);
let max_abs = out.iter().skip(800).fold(0.0_f32, |a, &b| a.max(b.abs()));
assert!(max_abs <= ceiling_lin + 1e-6);
// No soft gain reduction should ever have been recorded.
assert!(l.soft_gain_reduction_db().abs() < 1e-6);
}
// ----------------------------------------------------------------
// Soft tier: static fallback ceiling
// ----------------------------------------------------------------
#[test]
fn soft_tier_static_ceiling_engages_before_hard() {
let sr = 48_000.0;
// Static soft ceiling at -6 dBTP, attack short enough to
// settle inside the lookahead.
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
static_ceiling_dbtp: -6.0,
attack_ms: 1.0,
release_ms: 100.0,
..SoftTierConfig::default()
}),
..LimiterConfig::default()
};
let mut l = Limiter::new(cfg, sr);
// Drive a +6 dB sine — well above the soft ceiling.
let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
// Output should sit near the soft ceiling, well below hard.
let soft_ceiling_lin = db_to_lin(-6.0);
let max_abs = out
.iter()
.skip(2_000)
.fold(0.0_f32, |a, &b| a.max(b.abs()));
// Allow small overshoot during soft attack (gain hasn't fully
// settled when the peak arrives), but it must be well under
// the hard ceiling.
assert!(
max_abs <= soft_ceiling_lin * 1.1,
"output above soft ceiling: max_abs={max_abs}, soft_lin={soft_ceiling_lin}"
);
// Soft tier should report meaningful GR; hard tier ideally
// does very little once the soft tier has settled.
assert!(
l.soft_gain_reduction_db() < -3.0,
"soft GR too small: {}",
l.soft_gain_reduction_db()
);
}
// ----------------------------------------------------------------
// Soft tier: dynamic ceiling from program loudness
// ----------------------------------------------------------------
#[test]
fn dynamic_ceiling_tracks_program_loudness() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// Default max_psr_db = 14.
l.set_program_loudness_lufs(-18.0);
let dyn_ceiling = l.effective_soft_ceiling_dbtp().expect("soft tier active");
assert!(
(dyn_ceiling - (-4.0)).abs() < 1e-3,
"expected -4 dBTP, got {dyn_ceiling}"
);
// Move the program louder; ceiling rises (and clamps at 0).
l.set_program_loudness_lufs(-2.0);
let dyn_ceiling = l.effective_soft_ceiling_dbtp().unwrap();
assert!(
(-0.1..=0.0).contains(&dyn_ceiling),
"expected clamp near 0 dBTP, got {dyn_ceiling}"
);
// Clear it; falls back to static.
l.clear_program_loudness();
let fallback = l.effective_soft_ceiling_dbtp().unwrap();
assert!(
(fallback - (-6.0)).abs() < 1e-3,
"expected static -6 dBTP, got {fallback}"
);
}
#[test]
fn dynamic_ceiling_bounds_psr_on_hot_transient() {
let sr = 48_000.0;
// Long lookahead and fast soft attack so the soft tier
// demonstrably catches the transient before the hard tier
// needs to.
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
max_psr_db: 14.0,
static_ceiling_dbtp: -6.0,
attack_ms: 1.0,
release_ms: 100.0,
}),
..LimiterConfig::default()
};
let mut l = Limiter::new(cfg, sr);
l.set_program_loudness_lufs(-18.0);
// Expected dynamic ceiling: -18 + 14 = -4 dBTP ≈ 0.631 lin.
let dyn_ceil_lin = db_to_lin(-4.0);
// Slam a +6 dBFS impulse.
let mut max_after = 0.0_f32;
for n in 0..4_800_usize {
let s = if n == 800 { db_to_lin(6.0) } else { 0.0 };
let (lo, _) = l.process_frame(s, s);
if n > 700 {
max_after = max_after.max(lo.abs());
}
}
// Output should be at or below the dynamic soft ceiling with
// a small ringing margin. Critically, the hard tier should
// *not* be the thing that catches it — its GR should be small.
assert!(
max_after <= dyn_ceil_lin * 1.15,
"soft tier didn't bound the transient: max={max_after}, dyn_ceil={dyn_ceil_lin}"
);
// The hard tier may snap briefly at peak entry (soft envelope
// hasn't ramped yet), then take its release time to recover.
// We don't require zero hard engagement here — only that it
// isn't doing the majority of the work.
assert!(
l.hard_gain_reduction_db().abs() < 4.0,
"hard tier engaged unreasonably: {}",
l.hard_gain_reduction_db()
);
}
// ----------------------------------------------------------------
// Misc
// ----------------------------------------------------------------
#[test]
fn nan_inputs_do_not_propagate_with_soft_tier() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
for _ in 0..1_000 {
let (lo, ro) = l.process_frame(f32::NAN, f32::INFINITY);
assert!(lo.is_finite() && ro.is_finite());
}
}
#[test]
fn ceiling_clamps_positive_config_to_zero() {
let cfg = LimiterConfig {
ceiling_dbtp: 3.0,
..LimiterConfig::default()
}
.sanitized();
assert_eq!(cfg.ceiling_dbtp, 0.0);
}
#[test]
fn set_program_loudness_ignores_non_finite() {
let sr = 48_000.0;
let mut l = Limiter::new(LimiterConfig::default(), sr);
// Establish a baseline.
l.set_program_loudness_lufs(-20.0);
let baseline = l.effective_soft_ceiling_dbtp().unwrap();
// NaN / Inf should be ignored.
l.set_program_loudness_lufs(f32::NAN);
assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
l.set_program_loudness_lufs(f32::INFINITY);
assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
}
#[test]
fn soft_tier_reduces_perceived_peak_to_loudness_ratio() {
// The whole point of the soft tier: a transient on top of a
// quieter program should NOT come out near the hard ceiling.
let sr = 48_000.0;
let cfg = LimiterConfig {
lookahead_ms: 5.0,
soft: Some(SoftTierConfig {
max_psr_db: 12.0,
static_ceiling_dbtp: -8.0,
attack_ms: 1.0,
release_ms: 100.0,
}),
..LimiterConfig::default()
};
let mut brickwall = Limiter::new(LimiterConfig::brickwall_only(), sr);
let mut two_tier = Limiter::new(cfg, sr);
two_tier.set_program_loudness_lufs(-20.0);
let mut bw_peak = 0.0_f32;
let mut tt_peak = 0.0_f32;
for n in 0..4_800_usize {
// Quiet program with a single big spike.
let s = if n == 1_200 { db_to_lin(3.0) } else { 0.01 };
let (lo_bw, _) = brickwall.process_frame(s, s);
let (lo_tt, _) = two_tier.process_frame(s, s);
if n > 1_000 {
bw_peak = bw_peak.max(lo_bw.abs());
tt_peak = tt_peak.max(lo_tt.abs());
}
}
// Brickwall lets the spike through near the hard ceiling.
// Two-tier holds it much lower.
assert!(
tt_peak < bw_peak * 0.6,
"soft tier did not meaningfully reduce peak: bw={bw_peak}, tt={tt_peak}"
);
}
}

View file

@ -0,0 +1,230 @@
//! Polyphase FIR up/downsamplers.
//!
//! Used by the true-peak limiter to detect inter-sample peaks via
//! oversampled-domain peak detection (per ITU-R BS.1770-4).
use std::f32::consts::PI;
/// Design a Blackman-windowed sinc lowpass FIR.
///
/// * `taps` — filter length. Odd values give a linear-phase filter
/// with an exact group delay of `(taps - 1) / 2` samples.
/// * `fc` — normalized cutoff in `0.0..0.5` (fraction of sample rate).
///
/// Coefficients are normalized for unity DC gain. Suitable as the
/// prototype lowpass for `M`-times oversampling at `fc = 0.5 / M`
/// (slightly below Nyquist of the lower rate).
#[must_use]
pub fn design_lowpass_blackman(taps: usize, fc: f32) -> Vec<f32> {
let taps = taps.max(1);
let m = (taps as f32 - 1.0).max(1.0);
let mut h = vec![0.0_f32; taps];
let mut sum = 0.0_f32;
for (n, h_n) in h.iter_mut().enumerate() {
let x = n as f32 - m / 2.0;
let sinc = if x.abs() < 1e-9 {
2.0 * fc
} else {
(2.0 * PI * fc * x).sin() / (PI * x)
};
let w = 0.42 - 0.5 * (2.0 * PI * n as f32 / m).cos() + 0.08 * (4.0 * PI * n as f32 / m).cos();
*h_n = sinc * w;
sum += *h_n;
}
if sum.abs() > 1e-12 {
for v in &mut h {
*v /= sum;
}
}
h
}
/// Polyphase FIR upsampler.
///
/// One input sample produces `factor` output samples. Coefficients are
/// pre-scaled by `factor` so the output's DC gain equals the input.
pub struct PolyphaseUpsampler {
factor: usize,
taps_per_phase: usize,
/// `phases[j * taps_per_phase + p] = h[p * factor + j] * factor`.
phases: Vec<f32>,
history: Vec<f32>,
write_idx: usize,
}
impl PolyphaseUpsampler {
/// Construct from a prototype lowpass and an upsample `factor`.
#[must_use]
pub fn new(factor: usize, fir_taps: &[f32]) -> Self {
let factor = factor.max(1);
let taps_per_phase = fir_taps.len().div_ceil(factor);
let mut phases = vec![0.0_f32; factor * taps_per_phase];
for (n, &h) in fir_taps.iter().enumerate() {
let j = n % factor;
let p = n / factor;
phases[j * taps_per_phase + p] = h * factor as f32;
}
Self {
factor,
taps_per_phase,
phases,
history: vec![0.0_f32; taps_per_phase.max(1)],
write_idx: 0,
}
}
/// Upsample factor.
#[must_use]
pub fn factor(&self) -> usize {
self.factor
}
/// Push one input sample and emit `factor` output samples into
/// `out[..factor]`. `out` must have length `>= factor`.
pub fn process(&mut self, x: f32, out: &mut [f32]) {
debug_assert!(out.len() >= self.factor);
let len = self.history.len();
self.history[self.write_idx] = x;
let just_written = self.write_idx;
self.write_idx = (self.write_idx + 1) % len;
for (j, slot) in out.iter_mut().take(self.factor).enumerate() {
let phase = &self.phases[j * self.taps_per_phase..(j + 1) * self.taps_per_phase];
let mut acc = 0.0_f32;
for (p, &h) in phase.iter().enumerate() {
let idx = (just_written + len - p) % len;
acc += h * self.history[idx];
}
*slot = acc;
}
}
/// Clear filter history.
pub fn reset(&mut self) {
for v in &mut self.history {
*v = 0.0;
}
self.write_idx = 0;
}
}
/// FIR downsampler. Takes `factor` input samples and emits one output.
///
/// Uses the same prototype lowpass coefficients as the upsampler.
/// Implementation is straightforward (no polyphase split) — for our
/// filter length the savings are modest and code clarity wins.
pub struct PolyphaseDownsampler {
factor: usize,
taps: Vec<f32>,
history: Vec<f32>,
write_idx: usize,
}
impl PolyphaseDownsampler {
/// Construct from a prototype lowpass and a downsample `factor`.
#[must_use]
pub fn new(factor: usize, fir_taps: &[f32]) -> Self {
let factor = factor.max(1);
Self {
factor,
taps: fir_taps.to_vec(),
history: vec![0.0_f32; fir_taps.len().max(1)],
write_idx: 0,
}
}
/// Downsample factor.
#[must_use]
pub fn factor(&self) -> usize {
self.factor
}
/// Push `factor` input samples and return one filtered output.
pub fn process(&mut self, ins: &[f32]) -> f32 {
debug_assert_eq!(ins.len(), self.factor);
let len = self.history.len();
for &x in ins {
self.history[self.write_idx] = x;
self.write_idx = (self.write_idx + 1) % len;
}
let mut acc = 0.0_f32;
for (k, &h) in self.taps.iter().enumerate() {
let idx = (self.write_idx + len - 1 - k) % len;
acc += h * self.history[idx];
}
acc
}
/// Clear filter history.
pub fn reset(&mut self) {
for v in &mut self.history {
*v = 0.0;
}
self.write_idx = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fir(taps: usize, factor: usize) -> Vec<f32> {
design_lowpass_blackman(taps, 0.45 / factor as f32)
}
#[test]
fn upsampler_dc_gain_preserved() {
let h = fir(31, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
// Drive DC and let the filter settle, then check unity gain.
let mut buf = [0.0_f32; 8];
let mut last_avg = 0.0;
for _ in 0..200 {
up.process(1.0, &mut buf);
last_avg = buf[..4].iter().sum::<f32>() / 4.0;
}
assert!((last_avg - 1.0).abs() < 1e-3, "got {last_avg}");
}
#[test]
fn down_then_up_roundtrip_is_bounded() {
// Stuff zero-padded input through up then down; output amplitude
// should approximately equal input on smooth signals.
let h = fir(31, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
let mut down = PolyphaseDownsampler::new(4, &h);
let mut max_err = 0.0_f32;
let mut up_buf = [0.0_f32; 8];
// Drive a slow sine well below Nyquist.
for n in 0..2_000 {
let t = n as f32 / 48_000.0;
let x = (2.0 * std::f32::consts::PI * 1_000.0 * t).sin() * 0.5;
up.process(x, &mut up_buf);
let y = down.process(&up_buf[..4]);
// After group-delay warm-up, the error should be small.
if n > 80 {
max_err = max_err.max((x - y).abs());
}
}
// The filter is symmetric, so up/down with the same kernel
// introduces ~6 dB attenuation by design (each pass contributes
// half the gain). What we care about here is finite, bounded
// output and no runaway.
assert!(max_err < 1.0, "max_err {max_err}");
}
#[test]
fn upsampler_handles_impulse() {
let h = fir(15, 4);
let mut up = PolyphaseUpsampler::new(4, &h);
let mut buf = [0.0_f32; 8];
up.process(1.0, &mut buf);
// Some non-zero output expected on first impulse already.
assert!(buf[..4].iter().any(|&v| v.abs() > 1e-6));
// Drive zeros; output decays to zero.
for _ in 0..200 {
up.process(0.0, &mut buf);
}
assert!(buf[..4].iter().all(|&v| v.abs() < 1e-6));
}
}

View file

@ -0,0 +1,108 @@
//! Amortized-O(1) sliding-window maximum.
//!
//! Uses the standard monotonic-decreasing-deque trick. The deque's
//! capacity is bounded by `window`, so it never reallocates after
//! construction.
use std::collections::VecDeque;
/// Streaming max over a fixed-size sliding window.
pub struct SlidingMaxBuffer {
window: usize,
counter: u64,
/// `(index, value)`, monotonically decreasing in value from front.
deque: VecDeque<(u64, f32)>,
}
impl SlidingMaxBuffer {
/// Construct with the given window size. Lengths of 0 are clamped
/// to 1.
#[must_use]
pub fn new(window: usize) -> Self {
let window = window.max(1);
Self {
window,
counter: 0,
deque: VecDeque::with_capacity(window),
}
}
/// Window length in samples.
#[must_use]
pub fn window(&self) -> usize {
self.window
}
/// Push `value` and return the maximum over the most recent
/// `window` samples (inclusive of the value just pushed).
pub fn push_and_max(&mut self, value: f32) -> f32 {
// Drop entries that have aged out of the window.
let cutoff = self.counter.saturating_sub(self.window as u64 - 1);
while let Some(&(idx, _)) = self.deque.front() {
if idx < cutoff {
self.deque.pop_front();
} else {
break;
}
}
// Drop entries from the back smaller than the new value — they
// can never become the maximum.
while let Some(&(_, v)) = self.deque.back() {
if v <= value {
self.deque.pop_back();
} else {
break;
}
}
self.deque.push_back((self.counter, value));
self.counter += 1;
// SAFETY-ish: deque is non-empty (we just pushed).
self.deque.front().map_or(0.0, |&(_, v)| v)
}
/// Reset to empty state.
pub fn reset(&mut self) {
self.counter = 0;
self.deque.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tracks_window_max() {
let mut s = SlidingMaxBuffer::new(3);
assert_eq!(s.push_and_max(1.0), 1.0);
assert_eq!(s.push_and_max(3.0), 3.0);
assert_eq!(s.push_and_max(2.0), 3.0);
assert_eq!(s.push_and_max(2.0), 3.0); // 3.0 aged out... actually still in (window=3, last 3 are [3,2,2])
assert_eq!(s.push_and_max(0.5), 2.0); // window is now [2,2,0.5]
assert_eq!(s.push_and_max(0.5), 2.0); // [2,0.5,0.5]
assert_eq!(s.push_and_max(0.5), 0.5); // [0.5,0.5,0.5]
}
#[test]
fn monotonically_decreasing_input() {
let mut s = SlidingMaxBuffer::new(4);
for (i, &v) in [5.0_f32, 4.0, 3.0, 2.0, 1.0, 0.5].iter().enumerate() {
let m = s.push_and_max(v);
// After window is filled, max is the value `window-1` back.
let expected = match i {
0..=3 => 5.0,
4 => 4.0,
_ => 3.0,
};
assert_eq!(m, expected);
}
}
#[test]
fn window_one_is_identity() {
let mut s = SlidingMaxBuffer::new(1);
for v in [1.0, 2.0, 0.5, 9.0_f32, -3.0] {
assert_eq!(s.push_and_max(v), v);
}
}
}

View file

@ -0,0 +1,60 @@
//! Common helpers: dB <-> linear conversions, time constants.
/// Lower bound used to avoid `log10(0)`.
pub const PEAK_FLOOR: f32 = 1e-20;
/// Convert linear amplitude to decibels. Inputs at or below
/// [`PEAK_FLOOR`] clamp to `-200 dB`.
#[must_use]
pub fn lin_to_db(x: f32) -> f32 {
if x <= PEAK_FLOOR {
-200.0
} else {
20.0 * x.log10()
}
}
/// Convert decibels to linear amplitude.
#[must_use]
pub fn db_to_lin(db: f32) -> f32 {
10.0_f32.powf(db / 20.0)
}
/// Convert a time constant in milliseconds to a one-pole smoother
/// coefficient at the given sample rate.
///
/// `y[n] = y[n-1] + alpha * (x[n] - y[n-1])`. The returned alpha is
/// `1 - exp(-1 / (tau * fs))` where `tau` is `time_ms / 1000`. A
/// `time_ms` of 0 or below returns `1.0` (instantaneous).
#[must_use]
pub fn time_to_alpha(time_ms: f32, sample_rate: f32) -> f32 {
if time_ms <= 0.0 || sample_rate <= 0.0 {
1.0
} else {
let tau_samples = (time_ms * 1e-3) * sample_rate;
1.0 - (-1.0 / tau_samples).exp()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn db_round_trips() {
for db in [-60.0, -20.0, -6.0, -0.1, 0.0, 3.0, 6.0_f32] {
let lin = db_to_lin(db);
let back = lin_to_db(lin);
assert!((back - db).abs() < 1e-3, "db={db} back={back}");
}
}
#[test]
fn time_to_alpha_endpoints() {
assert_eq!(time_to_alpha(0.0, 48_000.0), 1.0);
assert!(time_to_alpha(1000.0, 48_000.0) < 0.01);
// Very fast attack: alpha approaches 1.
let a_fast = time_to_alpha(0.01, 48_000.0);
assert!(a_fast > 0.05, "fast alpha was {a_fast}");
}
}

View file

@ -0,0 +1,16 @@
[package]
name = "headroom-ipc"
description = "Headroom control-protocol types, framing, and codec. No I/O."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license = "MPL-2.0"
homepage.workspace = true
repository.workspace = true
authors.workspace = true
readme = "README.md"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View file

@ -0,0 +1,12 @@
# headroom-ipc
Wire types, framing, and codec for the Headroom control protocol.
This crate is the authoritative Rust binding to the protocol defined in
[`IPC.md`](../../IPC.md). It performs no I/O; pair it with `headroom-client`
for a ready-to-use client, or use it directly to implement your own.
## License
MPL-2.0. Third-party clients (including non-GPL ones) can depend on this
crate without affecting their license.

View file

@ -0,0 +1,267 @@
//! Length-prefixed JSON framing.
//!
//! Wire format: a 4-byte big-endian unsigned length, followed by exactly
//! that many bytes of UTF-8 JSON. Each frame is a single JSON value.
use std::io::{Read, Write};
use serde::{de::DeserializeOwned, Serialize};
use crate::error::Error;
/// Default upper bound on a single frame's payload size.
pub const DEFAULT_MAX_FRAME_BYTES: usize = 1024 * 1024; // 1 MiB
/// Lower bound enforced by [`Codec::with_max_frame_size`].
///
/// Frames below this size are silly small and almost always indicate a
/// bug; the limit is enforced so misuse fails loudly rather than
/// rejecting normal traffic.
pub const MIN_MAX_FRAME_BYTES: usize = 256;
/// A stateless framing codec.
///
/// Instances are cheap to clone. The codec owns no buffers; callers
/// supply their own readers and writers.
#[derive(Debug, Clone, Copy)]
pub struct Codec {
max_frame_bytes: usize,
}
impl Default for Codec {
fn default() -> Self {
Self {
max_frame_bytes: DEFAULT_MAX_FRAME_BYTES,
}
}
}
impl Codec {
/// Returns a codec with the default 1 MiB frame limit.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns a codec with the supplied frame size cap.
///
/// The cap is clamped to at least [`MIN_MAX_FRAME_BYTES`].
#[must_use]
pub fn with_max_frame_size(bytes: usize) -> Self {
Self {
max_frame_bytes: bytes.max(MIN_MAX_FRAME_BYTES),
}
}
/// Maximum payload size, in bytes.
#[must_use]
pub fn max_frame_bytes(self) -> usize {
self.max_frame_bytes
}
/// Serialize `msg` and write it as a framed payload.
///
/// # Errors
/// - [`Error::Json`] if the value cannot be serialized.
/// - [`Error::FrameTooLarge`] if the serialized form exceeds the cap.
/// - [`Error::Io`] on write failure.
pub fn write<W: Write, T: Serialize>(self, mut w: W, msg: &T) -> Result<(), Error> {
// Serialize first so we know the exact length up-front.
let buf = serde_json::to_vec(msg)?;
if buf.len() > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: buf.len(),
limit: self.max_frame_bytes,
});
}
let len = u32::try_from(buf.len()).expect("buf.len() <= max_frame_bytes <= u32::MAX");
w.write_all(&len.to_be_bytes())?;
w.write_all(&buf)?;
w.flush()?;
Ok(())
}
/// Read one framed payload from `r` and deserialize it.
///
/// # Errors
/// - [`Error::Closed`] if EOF is hit before any bytes of the length
/// prefix arrive (graceful close).
/// - [`Error::Io`] on partial reads or other I/O failure.
/// - [`Error::FrameTooLarge`] if the announced length exceeds the cap.
/// - [`Error::Json`] if the payload fails to deserialize.
pub fn read<R: Read, T: DeserializeOwned>(self, mut r: R) -> Result<T, Error> {
let mut len_buf = [0u8; 4];
match read_full_or_eof(&mut r, &mut len_buf)? {
ReadOutcome::Full => {}
ReadOutcome::ZeroAtStart => return Err(Error::Closed),
}
let len = u32::from_be_bytes(len_buf) as usize;
if len > self.max_frame_bytes {
return Err(Error::FrameTooLarge {
actual: len,
limit: self.max_frame_bytes,
});
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?;
Ok(serde_json::from_slice(&buf)?)
}
}
enum ReadOutcome {
Full,
ZeroAtStart,
}
fn read_full_or_eof<R: Read>(r: &mut R, buf: &mut [u8]) -> Result<ReadOutcome, std::io::Error> {
let mut read = 0;
while read < buf.len() {
match r.read(&mut buf[read..]) {
Ok(0) => {
if read == 0 {
return Ok(ReadOutcome::ZeroAtStart);
}
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"eof mid-length-prefix",
));
}
Ok(n) => read += n,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(ReadOutcome::Full)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Event, Op, Request, Response, ServerFrame, Topic};
use std::io::Cursor;
#[test]
fn write_read_request_roundtrip() {
let codec = Codec::new();
let req = Request::new(
42,
Op::ProfileUse {
name: "night".into(),
},
);
let mut buf = Vec::new();
codec.write(&mut buf, &req).unwrap();
let mut cur = Cursor::new(&buf);
let back: Request = codec.read(&mut cur).unwrap();
assert_eq!(back, req);
}
#[test]
fn server_frame_round_trip_response() {
let codec = Codec::new();
let resp = Response::ok(1, &serde_json::json!({"name": "default"})).unwrap();
let frame = ServerFrame::Response(resp.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Response(r) => assert_eq!(r, resp),
ServerFrame::Event(_) => panic!("decoded as event"),
}
}
#[test]
fn server_frame_round_trip_event() {
let codec = Codec::new();
let ev = Event::new(Topic::Daemon, "shutdown", &serde_json::json!({})).unwrap();
let frame = ServerFrame::Event(ev.clone());
let mut buf = Vec::new();
codec.write(&mut buf, &frame).unwrap();
let mut cur = Cursor::new(&buf);
let back: ServerFrame = codec.read(&mut cur).unwrap();
match back {
ServerFrame::Event(e) => assert_eq!(e, ev),
ServerFrame::Response(_) => panic!("decoded as response"),
}
}
#[test]
fn rejects_oversized_frames_on_write() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// A big string that will serialize > 256 bytes.
let req = Request::new(
1,
Op::SettingSet {
key: "x".into(),
value: serde_json::Value::String("a".repeat(1024)),
},
);
let mut buf = Vec::new();
let err = codec.write(&mut buf, &req).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn rejects_oversized_frames_on_read() {
let codec = Codec::with_max_frame_size(MIN_MAX_FRAME_BYTES);
// Hand-craft a length prefix that exceeds the cap.
let mut buf = Vec::new();
let bad_len: u32 = MIN_MAX_FRAME_BYTES as u32 + 1;
buf.extend_from_slice(&bad_len.to_be_bytes());
// No need to follow with payload; we expect early rejection.
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, serde_json::Value>(&mut cur).unwrap_err();
assert!(matches!(err, Error::FrameTooLarge { .. }));
}
#[test]
fn graceful_eof_at_frame_boundary() {
let codec = Codec::new();
let mut cur = Cursor::new(Vec::<u8>::new());
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Closed));
}
#[test]
fn mid_frame_eof_is_io_error() {
let codec = Codec::new();
// Half a length prefix.
let mut cur = Cursor::new(vec![0u8, 0u8]);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Io(_)));
}
#[test]
fn rejects_invalid_json_payload() {
let codec = Codec::new();
let payload = b"not-json";
let mut buf = Vec::new();
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
buf.extend_from_slice(payload);
let mut cur = Cursor::new(&buf);
let err = codec.read::<_, Request>(&mut cur).unwrap_err();
assert!(matches!(err, Error::Json(_)));
}
#[test]
fn back_to_back_frames() {
let codec = Codec::new();
let a = Request::new(1, Op::Status);
let b = Request::new(2, Op::ProfileList);
let mut buf = Vec::new();
codec.write(&mut buf, &a).unwrap();
codec.write(&mut buf, &b).unwrap();
let mut cur = Cursor::new(&buf);
let ra: Request = codec.read(&mut cur).unwrap();
let rb: Request = codec.read(&mut cur).unwrap();
assert_eq!(ra, a);
assert_eq!(rb, b);
}
}

View file

@ -0,0 +1,125 @@
//! Error types for the protocol crate.
use serde::{Deserialize, Serialize};
/// Stable machine-readable error code emitted in `error.code`.
///
/// Adding variants is a non-breaking change. Removing or renaming
/// variants is breaking and bumps `PROTOCOL_VERSION`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
#[non_exhaustive]
pub enum ErrorCode {
/// Malformed framing or non-JSON payload. Connection is closed.
InvalidFrame,
/// Valid JSON, but does not match any known message shape.
InvalidMessage,
/// `op` does not name a known operation.
UnknownOp,
/// `args` is missing a field, has the wrong type, or is out of range.
InvalidArgs,
/// Named profile / app / stream / setting key does not exist.
NotFound,
/// Operation would violate an invariant.
Conflict,
/// Daemon transiently cannot serve the request.
Busy,
/// Server-side bug. `message` contains debug detail.
Internal,
}
impl ErrorCode {
/// Returns the canonical SCREAMING_SNAKE wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::InvalidFrame => "INVALID_FRAME",
ErrorCode::InvalidMessage => "INVALID_MESSAGE",
ErrorCode::UnknownOp => "UNKNOWN_OP",
ErrorCode::InvalidArgs => "INVALID_ARGS",
ErrorCode::NotFound => "NOT_FOUND",
ErrorCode::Conflict => "CONFLICT",
ErrorCode::Busy => "BUSY",
ErrorCode::Internal => "INTERNAL",
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Error payload as it appears on the wire inside a `Response`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProtoError {
/// Stable machine-readable code.
pub code: ErrorCode,
/// Human-readable English message. Not stable; do not pattern match.
pub message: String,
}
impl ProtoError {
/// Construct a new protocol error.
#[must_use]
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for ProtoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.message)
}
}
impl std::error::Error for ProtoError {}
/// Errors produced by the codec and high-level helpers in this crate.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Underlying I/O failed.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// JSON encoding or decoding failed.
#[error("json: {0}")]
Json(#[from] serde_json::Error),
/// A frame exceeded the configured maximum size.
#[error("frame too large: {actual} bytes (limit {limit})")]
FrameTooLarge {
/// Actual size of the offending frame in bytes.
actual: usize,
/// Configured maximum, in bytes.
limit: usize,
},
/// The peer answered with an error response.
#[error("protocol: {0}")]
Protocol(#[from] ProtoError),
/// The peer sent an unexpected frame given protocol context (e.g.
/// a response with a mismatched id).
#[error("unexpected frame: {0}")]
UnexpectedFrame(String),
/// The connection was closed mid-frame.
#[error("connection closed")]
Closed,
}
impl Error {
/// True if this error indicates the connection should be torn down.
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(
self,
Error::Io(_) | Error::FrameTooLarge { .. } | Error::Closed
)
}
}

View file

@ -0,0 +1,69 @@
//! Headroom control-protocol types and framing.
//!
//! The authoritative protocol specification is in `IPC.md` at the
//! repository root. This crate is its Rust binding.
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod codec;
mod error;
mod proto;
pub use codec::{Codec, DEFAULT_MAX_FRAME_BYTES, MIN_MAX_FRAME_BYTES};
pub use error::{Error, ErrorCode, ProtoError};
pub use proto::{
DaemonEvent, Event, HelloData, MeterTick, Op, ProfileEvent, ProfileInfo, Request, Response,
ResponsePayload, Route, RouteList, RouteRule, RouteRuleMatch, RoutingEvent, ServerFrame,
SinkInfo, Sinks, Status, StreamRoute, Topic,
};
/// Wire-protocol version. Bumped only on incompatible changes.
pub const PROTOCOL_VERSION: u32 = 1;
/// Default Unix-domain socket path stem inside `$XDG_RUNTIME_DIR`.
///
/// The full socket path is `${XDG_RUNTIME_DIR}/headroom/control.sock`,
/// falling back to `/run/user/$UID/headroom/control.sock` when
/// `XDG_RUNTIME_DIR` is unset.
pub const DEFAULT_SOCKET_DIR: &str = "headroom";
/// Default socket filename.
pub const DEFAULT_SOCKET_NAME: &str = "control.sock";
/// Returns the conventional control-socket path for the current user.
///
/// Honours `XDG_RUNTIME_DIR`, with the documented fallback to
/// `/run/user/$UID/...`. Returns `None` when neither is determinable.
#[must_use]
pub fn default_socket_path() -> Option<std::path::PathBuf> {
use std::path::PathBuf;
if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
if !dir.is_empty() {
return Some(
PathBuf::from(dir)
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
);
}
}
// SAFETY: `nix`-free fallback. Read uid from /proc/self/status as
// a pure-Rust dependency-light path.
let uid = read_self_uid()?;
Some(
PathBuf::from(format!("/run/user/{uid}"))
.join(DEFAULT_SOCKET_DIR)
.join(DEFAULT_SOCKET_NAME),
)
}
fn read_self_uid() -> Option<u32> {
let s = std::fs::read_to_string("/proc/self/status").ok()?;
for line in s.lines() {
if let Some(rest) = line.strip_prefix("Uid:") {
let first = rest.split_whitespace().next()?;
return first.parse().ok();
}
}
None
}

View file

@ -0,0 +1,693 @@
//! Protocol message types.
//!
//! See `IPC.md` for the normative specification.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::ProtoError;
// ---------------------------------------------------------------------------
// Topics
// ---------------------------------------------------------------------------
/// A subscription topic.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Topic {
/// Live loudness / peak / GR telemetry.
Meters,
/// Profile use / reload events.
Profile,
/// Routing rule and per-stream events.
Routing,
/// Daemon lifecycle and overflow.
Daemon,
/// Synthetic topic for `hello`. Clients never subscribe to it.
Control,
}
impl Topic {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Topic::Meters => "meters",
Topic::Profile => "profile",
Topic::Routing => "routing",
Topic::Daemon => "daemon",
Topic::Control => "control",
}
}
/// Topics that clients are allowed to subscribe to.
#[must_use]
pub const fn subscribable() -> &'static [Topic] {
&[Topic::Meters, Topic::Profile, Topic::Routing, Topic::Daemon]
}
}
impl std::fmt::Display for Topic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Route enum
// ---------------------------------------------------------------------------
/// Routing decision for a single application or stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Route {
/// Route the stream through the processed (filtered) sink.
Processed,
/// Route the stream directly to the real hardware sink (no
/// processing, no extra graph hop).
Bypass,
}
impl Route {
/// Returns the canonical wire string.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Route::Processed => "processed",
Route::Bypass => "bypass",
}
}
}
impl std::fmt::Display for Route {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// Operations
// ---------------------------------------------------------------------------
/// All known operations, as a strongly-typed enum.
///
/// The wire form is `{ "op": "<name>", "args": <args> }` with operations
/// that take no arguments omitting the `args` field. Operations with no
/// arguments serialize as `{ "op": "<name>" }`; serde fills the unit
/// variants accordingly.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "op", content = "args")]
#[non_exhaustive]
pub enum Op {
/// Snapshot of daemon state.
#[serde(rename = "status")]
Status,
/// List all known profiles.
#[serde(rename = "profile.list")]
ProfileList,
/// Activate the named profile.
#[serde(rename = "profile.use")]
ProfileUse {
/// Profile name.
name: String,
},
/// Show a profile in full. `name` defaults to the active profile
/// when omitted.
#[serde(rename = "profile.show")]
ProfileShow {
/// Profile name. `None` means the active profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
/// Reload all profile files from disk.
#[serde(rename = "profile.reload")]
ProfileReload,
/// List routing rules and current per-stream decisions.
#[serde(rename = "route.list")]
RouteList,
/// Add or replace a routing rule for an app (persistent).
#[serde(rename = "route.set")]
RouteSet {
/// Application identifier (typically `application.process.binary`).
app: String,
/// Target route.
to: Route,
},
/// Remove a user routing rule for an app.
#[serde(rename = "route.unset")]
RouteUnset {
/// Application identifier.
app: String,
},
/// One-shot reroute of a specific live stream.
#[serde(rename = "route.stream")]
RouteStream {
/// PipeWire node id of the stream.
node_id: u32,
/// Target route.
to: Route,
},
/// Get a single setting from the active profile.
#[serde(rename = "setting.get")]
SettingGet {
/// Dotted setting key (e.g. `compressor.threshold_db`).
key: String,
},
/// Set a single setting in the active profile.
#[serde(rename = "setting.set")]
SettingSet {
/// Dotted setting key.
key: String,
/// New value. Must match the setting's type.
value: Value,
},
/// List all settings (active profile).
#[serde(rename = "setting.list")]
SettingList,
/// Enable or disable the global bypass kill switch.
#[serde(rename = "bypass.set")]
BypassSet {
/// `true` to route everything through bypass.
enabled: bool,
},
/// Subscribe to one or more event topics on this connection.
#[serde(rename = "subscribe")]
Subscribe {
/// Topics to subscribe to.
topics: Vec<Topic>,
},
/// Unsubscribe from one or more topics on this connection.
#[serde(rename = "unsubscribe")]
Unsubscribe {
/// Topics to unsubscribe from.
topics: Vec<Topic>,
},
}
impl Op {
/// Canonical wire name of this operation.
#[must_use]
pub fn name(&self) -> &'static str {
match self {
Op::Status => "status",
Op::ProfileList => "profile.list",
Op::ProfileUse { .. } => "profile.use",
Op::ProfileShow { .. } => "profile.show",
Op::ProfileReload => "profile.reload",
Op::RouteList => "route.list",
Op::RouteSet { .. } => "route.set",
Op::RouteUnset { .. } => "route.unset",
Op::RouteStream { .. } => "route.stream",
Op::SettingGet { .. } => "setting.get",
Op::SettingSet { .. } => "setting.set",
Op::SettingList => "setting.list",
Op::BypassSet { .. } => "bypass.set",
Op::Subscribe { .. } => "subscribe",
Op::Unsubscribe { .. } => "unsubscribe",
}
}
}
// ---------------------------------------------------------------------------
// Request / Response / Event
// ---------------------------------------------------------------------------
/// A client-to-server request.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Request {
/// Client-chosen identifier. Echoed back in the paired response.
pub id: u64,
/// The operation and its arguments. Flattened: contributes `op`
/// and (optionally) `args` fields at the same level as `id`.
#[serde(flatten)]
pub op: Op,
}
impl Request {
/// Construct a new request.
#[must_use]
pub fn new(id: u64, op: Op) -> Self {
Self { id, op }
}
}
/// A server-to-client response, paired with a request by `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Response {
/// Matches the originating request's `id`.
pub id: u64,
/// Result or error.
#[serde(flatten)]
pub payload: ResponsePayload,
}
impl Response {
/// Build an OK response from any serializable value.
///
/// # Errors
/// Returns the underlying serde error if `value` fails to serialize.
pub fn ok<T: Serialize>(id: u64, value: &T) -> Result<Self, serde_json::Error> {
Ok(Self {
id,
payload: ResponsePayload::Ok {
result: serde_json::to_value(value)?,
},
})
}
/// Build an error response.
#[must_use]
pub fn err(id: u64, error: ProtoError) -> Self {
Self {
id,
payload: ResponsePayload::Err { error },
}
}
}
/// Discriminated body of a `Response`.
///
/// On the wire this is a single field — either `result` or `error` —
/// inlined alongside `id`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ResponsePayload {
/// Successful result.
Ok {
/// Operation-specific result body. JSON; typically an object.
result: Value,
},
/// Operation failed.
Err {
/// Error payload.
error: ProtoError,
},
}
/// Server-to-client event on a subscribed topic.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Event {
/// Event name within the topic (e.g. `tick`, `changed`).
pub event: String,
/// Subscription topic this event belongs to.
pub topic: Topic,
/// Event payload. Shape depends on `topic` and `event`.
pub data: Value,
}
impl Event {
/// Construct a new event with a JSON-serializable payload.
///
/// # Errors
/// Returns the underlying serde error if `data` fails to serialize.
pub fn new<T: Serialize>(
topic: Topic,
event: impl Into<String>,
data: &T,
) -> Result<Self, serde_json::Error> {
Ok(Self {
event: event.into(),
topic,
data: serde_json::to_value(data)?,
})
}
}
/// A single frame as a client would receive it from the server: either
/// a paired response or a fan-out event.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ServerFrame {
/// Response to a prior request.
Response(Response),
/// Subscription event.
Event(Event),
}
// ---------------------------------------------------------------------------
// Result body shapes for typed access
// ---------------------------------------------------------------------------
/// Result body of `status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Status {
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
/// Daemon uptime in seconds.
pub uptime_s: u64,
/// Active profile name.
pub profile: String,
/// Global bypass flag.
pub bypass: bool,
/// Sink status snapshot.
pub sinks: Sinks,
/// Currently-tracked playback streams.
pub streams: Vec<StreamRoute>,
}
/// Sink-side of `Status`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Sinks {
/// The processed virtual sink. The only sink Headroom creates.
pub processed: SinkInfo,
/// The hardware sink Headroom is currently forwarding to, and
/// where bypassed streams are routed directly.
pub real: SinkInfo,
}
/// Information about one sink.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SinkInfo {
/// PipeWire node id, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_id: Option<u32>,
/// Human-readable sink name, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// True if the sink is currently linked and accepting audio.
#[serde(default)]
pub ready: bool,
}
/// One playback stream and where it's routed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StreamRoute {
/// PipeWire node id.
pub node_id: u32,
/// Application identifier (typically `application.process.binary`).
pub app: String,
/// Active route.
pub route: Route,
}
/// Summary entry returned by `profile.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProfileInfo {
/// Profile name.
pub name: String,
/// True if this is the active profile.
pub active: bool,
/// Short description from the profile document.
pub description: String,
}
/// Result body of `route.list`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteList {
/// Active rules, in evaluation order.
pub rules: Vec<RouteRule>,
/// Current per-stream routing decisions.
pub current: Vec<StreamRoute>,
/// Fallback route when no rule matches.
pub default_route: Route,
}
/// One routing rule.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RouteRule {
/// Conditions that must all hold for this rule to fire.
#[serde(rename = "match")]
pub match_: RouteRuleMatch,
/// Route to assign when the rule fires.
pub route: Route,
}
/// Match conditions for a routing rule.
///
/// All present fields must hold (logical AND); within a field, any
/// listed value matches (logical OR). Empty match is implicitly true.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RouteRuleMatch {
/// Match on `application.process.binary`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub process_binary: Vec<String>,
/// Match on `application.name`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub application_name: Vec<String>,
/// Match on `pipewire.access.portal.app_id` (Flatpak).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub portal_app_id: Vec<String>,
/// Match on `media.role`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub media_role: Vec<String>,
}
// ---------------------------------------------------------------------------
// Event payload shapes (typed)
// ---------------------------------------------------------------------------
/// Payload of a `hello` event (sent on connect).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HelloData {
/// Always `"headroom"`.
pub daemon: String,
/// Daemon version string (semver).
pub version: String,
/// Wire protocol version.
pub protocol: u32,
}
/// Payload of a `meters` tick event.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MeterTick {
/// Momentary loudness (BS.1770 M, 400 ms window), in LUFS.
pub momentary_lufs: f32,
/// Short-term loudness (BS.1770 S, 3 s window), in LUFS.
pub shortterm_lufs: f32,
/// Integrated loudness (BS.1770 I, gated), in LUFS.
pub integrated_lufs: f32,
/// Maximum true peak across channels, in dBTP.
pub true_peak_dbtp: f32,
/// Combined gain reduction (compressor + limiter), in dB.
pub gain_reduction_db: f32,
/// Compressor stage gain reduction, in dB.
pub compressor_gr_db: f32,
/// Limiter stage gain reduction, in dB.
pub limiter_gr_db: f32,
/// AGC contribution (positive = boost), in dB.
pub agc_gain_db: f32,
}
/// `profile` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ProfileEvent {
/// Active profile changed.
Changed {
/// New active profile.
name: String,
/// Previous active profile.
previous: String,
},
/// One or more profile files were reloaded.
Reloaded {
/// Names of profiles whose definitions changed on disk.
changed: Vec<String>,
},
}
/// `routing` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum RoutingEvent {
/// A new stream was assigned a route.
StreamRouted {
/// Node id of the routed stream.
node_id: u32,
/// Application identifier.
app: String,
/// Route assigned.
to: Route,
},
/// A persistent rule was added, replaced, or removed.
RuleChanged,
}
/// `daemon` topic events.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
#[non_exhaustive]
pub enum DaemonEvent {
/// Daemon started.
Started {
/// Daemon version string.
version: String,
},
/// Daemon shutting down.
Shutdown,
/// One or more events were dropped on this connection.
Overflow {
/// Topic whose queue overflowed.
lost_topic: Topic,
/// Number lost in this batch.
lost: u32,
/// Total lost on this connection so far.
total_lost: u64,
},
/// Non-fatal daemon error.
Error {
/// Stable code (matches `ErrorCode` when applicable).
code: String,
/// Human-readable detail.
message: String,
},
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip<T>(v: &T) -> T
where
T: Serialize + for<'de> Deserialize<'de>,
{
let s = serde_json::to_string(v).expect("serialize");
serde_json::from_str(&s).expect("deserialize")
}
#[test]
fn op_status_serializes_without_args() {
let req = Request::new(1, Op::Status);
let s = serde_json::to_string(&req).unwrap();
// Must be the flat form, no `kind` wrapper, no `args` field.
assert_eq!(s, r#"{"id":1,"op":"status"}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn op_profile_use_round_trips_flat() {
let req = Request::new(
7,
Op::ProfileUse {
name: "night".into(),
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(s, r#"{"id":7,"op":"profile.use","args":{"name":"night"}}"#);
let back: Request = serde_json::from_str(&s).unwrap();
assert_eq!(back, req);
}
#[test]
fn route_set_serializes_canonical() {
let req = Request::new(
12,
Op::RouteSet {
app: "firefox".into(),
to: Route::Processed,
},
);
let s = serde_json::to_string(&req).unwrap();
assert_eq!(
s,
r#"{"id":12,"op":"route.set","args":{"app":"firefox","to":"processed"}}"#
);
}
#[test]
fn response_ok_shape() {
let resp = Response::ok(3, &serde_json::json!({ "name": "default" })).unwrap();
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(s, r#"{"id":3,"result":{"name":"default"}}"#);
}
#[test]
fn response_err_shape() {
let resp = Response::err(4, ProtoError::new(crate::ErrorCode::NotFound, "missing"));
let s = serde_json::to_string(&resp).unwrap();
assert_eq!(
s,
r#"{"id":4,"error":{"code":"NOT_FOUND","message":"missing"}}"#
);
}
#[test]
fn server_frame_distinguishes_response_from_event() {
let resp = Response::ok(1, &serde_json::json!(null)).unwrap();
let s = serde_json::to_string(&resp).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Response(_)));
let ev = Event::new(Topic::Meters, "tick", &serde_json::json!({})).unwrap();
let s = serde_json::to_string(&ev).unwrap();
let frame: ServerFrame = serde_json::from_str(&s).unwrap();
assert!(matches!(frame, ServerFrame::Event(_)));
}
#[test]
fn meter_tick_roundtrip() {
let m = MeterTick {
momentary_lufs: -19.3,
shortterm_lufs: -20.1,
integrated_lufs: -19.8,
true_peak_dbtp: -1.4,
gain_reduction_db: -2.1,
compressor_gr_db: -0.8,
limiter_gr_db: -1.3,
agc_gain_db: 0.5,
};
let back = roundtrip(&m);
assert_eq!(back, m);
}
#[test]
fn topic_string_canonical() {
assert_eq!(Topic::Meters.as_str(), "meters");
assert_eq!(serde_json::to_string(&Topic::Meters).unwrap(), "\"meters\"");
let t: Topic = serde_json::from_str("\"profile\"").unwrap();
assert_eq!(t, Topic::Profile);
}
#[test]
fn route_string_canonical() {
let r: Route = serde_json::from_str("\"bypass\"").unwrap();
assert_eq!(r, Route::Bypass);
assert_eq!(serde_json::to_string(&r).unwrap(), "\"bypass\"");
}
#[test]
fn error_code_screaming_snake() {
let s = serde_json::to_string(&crate::ErrorCode::InvalidFrame).unwrap();
assert_eq!(s, "\"INVALID_FRAME\"");
let c: crate::ErrorCode = serde_json::from_str("\"UNKNOWN_OP\"").unwrap();
assert_eq!(c, crate::ErrorCode::UnknownOp);
}
#[test]
fn subscribe_op_roundtrip() {
let req = Request::new(
5,
Op::Subscribe {
topics: vec![Topic::Meters, Topic::Profile],
},
);
let back: Request = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back, req);
}
}