diff --git a/README.md b/README.md index b4e8f27..30a32de 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ via [cpal](https://crates.io/crates/cpal) — while a - [stdin pipe](#stdin-pipe) - [FIFO (named pipe)](#fifo-named-pipe) - [Unix socket](#unix-socket) +- [Output Targets](#output-targets) - [Spotify via spotifyd](#spotify-via-spotifyd) - [Remote Control (gRPC API)](#remote-control-grpc-api) - [CLI Options](#cli-options) @@ -50,6 +51,8 @@ via [cpal](https://crates.io/crates/cpal) — while a - **Any raw PCM source** — stdin, FIFO, or unix socket; `s16le`, `s24le`, `s32le`, `f32le`, `f64le`; mono/stereo/multichannel; any sample rate (resampled to the device rate by the DSP) +- **Configurable output** — sound card by default, or run as a pure PCM + filter writing s16le to stdout / a FIFO (`--output`) - **Persistent settings** — every change is saved to a TOML file and restored on the next run - **Presets** — rock, pop, jazz, classical, electronic, vocal, @@ -198,6 +201,30 @@ and reads PCM from the peer: equalizer /tmp/audio.sock ``` +## Output Targets + +By default the processed audio plays on the sound card (`--device` picks +one). With `--output` the equalizer becomes a pure PCM filter instead — +raw interleaved stereo `s16le` at the input rate (no resampling), paced +by whatever consumes the pipe: + +```sh +# stdout: chain into any player or encoder +ffmpeg -i track.flac -f s16le -ac 2 -ar 44100 - 2>/dev/null \ + | equalizer --output - \ + | ffplay -f s16le -ac 2 -ar 44100 -nodisp - # or aplay, sox, ffmpeg … + +# FIFO: created if missing; opening blocks until a reader connects +equalizer /tmp/in.fifo --output /tmp/out.fifo +``` + +The TUI still works on either (it renders to stderr, keys come from +`/dev/tty`), and so does the [control API](#remote-control-grpc-api) — +e.g. spotifyd → equalizer → FIFO, with the EQ tweaked from another +machine. In `--no-tui` mode with `--output -`, the auto-generated API +token is logged to stderr instead of stdout so it never corrupts the +PCM stream. + ## Spotify via spotifyd [spotifyd](https://github.com/Spotifyd/spotifyd) can feed Spotify straight @@ -287,6 +314,7 @@ the CLI flags below; `--no-api` turns the whole thing off. | `-c, --channels ` | `2` | Input channels (1 = upmixed, >2 = front pair) | | `-f, --format ` | `s16le` | `s16le`, `s24le`, `s32le`, `f32le`, `f64le` | | `-d, --device ` | default | Output device (case-insensitive substring) | +| `-o, --output ` | `default` | `default` = sound card, `-` = raw s16le on stdout, else FIFO path | | `--list-devices` | | Print output devices and exit | | `-p, --preset ` | | Apply a [preset](#presets) on startup (local or remote) | | `--config ` | user config dir | Settings file location | diff --git a/src/audio.rs b/src/audio.rs index d08ba39..1276e40 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -7,7 +7,7 @@ //! [`Equalizer`](crate::equalizer::Equalizer) version counter. use std::fs::File; -use std::io::{self, Read}; +use std::io::{self, Read, Write}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicI32, AtomicU8, AtomicU64, AtomicUsize, Ordering}; @@ -109,6 +109,83 @@ pub struct PipelineConfig { pub out_rate: u32, } +/// Where processed audio goes when it is NOT played on a device +/// (`--output`): raw interleaved stereo s16le at the pipeline's output +/// rate, either to stdout or into a FIFO. +pub enum OutputTarget { + Stdout, + Fifo(std::path::PathBuf), +} + +impl OutputTarget { + pub fn label(&self) -> String { + match self { + OutputTarget::Stdout => "stdout".to_string(), + OutputTarget::Fifo(path) => path.display().to_string(), + } + } +} + +/// Pipe-output counterpart of [`build_stream`]: drain the channel and +/// write raw s16le to the target until the input ends or the consumer +/// goes away. There is no clock on this path — pacing comes from the +/// input side and the pipe's own backpressure. +pub fn writer_loop(target: OutputTarget, status: Arc, rx: Receiver>) { + let mut writer: Box = match open_output(&target) { + Ok(writer) => writer, + Err(err) => { + status.set_error(format!("cannot open {}: {err}", target.label())); + return; + } + }; + let mut bytes: Vec = Vec::new(); + while let Ok(chunk) = rx.recv() { + bytes.clear(); + bytes.extend(chunk.iter().flat_map(|s| s.to_le_bytes())); + if let Err(err) = writer.write_all(&bytes) { + // EPIPE = the consumer closed; anything else is a real fault. + if err.kind() != io::ErrorKind::BrokenPipe { + status.set_error(format!("output write error: {err}")); + } + break; + } + status + .queued + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |q| { + Some(q.saturating_sub(chunk.len())) + }) + .ok(); + status + .frames_played + .fetch_add(chunk.len() as u64 / 2, Ordering::Relaxed); + } + let _ = writer.flush(); +} + +/// Open the pipe output. A missing FIFO path is created first; opening a +/// FIFO for writing blocks until a reader shows up (the status line shows +/// "waiting for input" during that window, since nothing streams yet). +fn open_output(target: &OutputTarget) -> Result> { + match target { + OutputTarget::Stdout => Ok(Box::new(io::stdout().lock())), + OutputTarget::Fifo(path) => { + if !path.exists() { + let cpath = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) + .context("invalid path")?; + let rc = unsafe { libc::mkfifo(cpath.as_ptr(), 0o644) }; + if rc != 0 { + bail!("mkfifo failed: {}", io::Error::last_os_error()); + } + } + let file = File::options() + .write(true) + .open(path) + .with_context(|| format!("cannot open {}", path.display()))?; + Ok(Box::new(file)) + } + } +} + /// Blocking read → DSP → send loop. Runs until the input ends (stdin / /// regular file), the output side hangs up, or an I/O error. FIFO inputs /// are reopened on EOF so another process can stream again later. diff --git a/src/main.rs b/src/main.rs index 28d0732..902cf7d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use clap::Parser; use clap::builder::styling::{Color, RgbColor, Style, Styles}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -use crate::audio::{AudioStatus, PcmFormat, PipelineConfig}; +use crate::audio::{AudioStatus, OutputTarget, PcmFormat, PipelineConfig}; use crate::control::{Controller, LocalController}; use crate::equalizer::Equalizer; use crate::settings::{Settings, default_socket_path}; @@ -83,9 +83,16 @@ struct Cli { /// Output device name (case-insensitive substring); default device /// otherwise. See --list-devices. - #[arg(short = 'd', long)] + #[arg(short = 'd', long, conflicts_with = "output")] device: Option, + /// Audio output: "default" plays on the sound card; "-" writes raw + /// s16le stereo PCM to stdout; any other value is a FIFO path + /// (created if missing). Pipe outputs run at the input rate (no + /// resampling) and are paced by the consumer. + #[arg(short = 'o', long, value_name = "TARGET")] + output: Option, + /// List output devices and exit. #[arg(long)] list_devices: bool, @@ -128,7 +135,7 @@ struct Cli { value_name = "ADDR", num_args = 0..=1, default_missing_value = "", - conflicts_with_all = ["input", "rate", "channels", "format", "device", + conflicts_with_all = ["input", "rate", "channels", "format", "device", "output", "list_devices", "no_tui", "api_socket", "port", "no_api"] )] connect: Option, @@ -176,6 +183,7 @@ fn main() -> Result<()> { .unwrap_or_else(default_socket_path); let connect_target = cli.connect.clone().or_else(|| { (cli.input == "-" + && cli.output.is_none() && std::io::stdin().is_terminal() && !cli.no_tui && server::socket_is_live(&socket_path)) @@ -216,12 +224,33 @@ fn main() -> Result<()> { None => None, }; - let device = pick_device(&host, cli.device.as_deref())?; - let device_name = device.name().unwrap_or_else(|_| "".into()); - let config = device - .default_output_config() - .context("no default output config")?; - let out_rate = config.sample_rate().0; + let output = match cli.output.as_deref() { + None | Some("default") => None, + Some("-") => Some(OutputTarget::Stdout), + Some(path) => Some(OutputTarget::Fifo(path.into())), + }; + + // Pipe outputs have no device clock: the DSP runs at the input rate + // (resampler inactive) and the consumer paces the pipeline. + let device_stuff = match &output { + None => { + let device = pick_device(&host, cli.device.as_deref())?; + let config = device + .default_output_config() + .context("no default output config")?; + Some((device, config)) + } + Some(_) => None, + }; + let out_rate = match &device_stuff { + Some((_, config)) => config.sample_rate().0, + None => cli.rate, + }; + let device_name = match (&output, &device_stuff) { + (Some(target), _) => target.label(), + (None, Some((device, _))) => device.name().unwrap_or_else(|_| "".into()), + (None, None) => unreachable!(), + }; let status = Arc::new(AudioStatus::new()); // Small bound: post-DSP buffering is what delays audible EQ changes @@ -240,20 +269,36 @@ fn main() -> Result<()> { move || audio::reader_loop(pipeline, status, tx) }); - let stream_config: cpal::StreamConfig = config.clone().into(); - let stream = match config.sample_format() { - cpal::SampleFormat::F32 => { - audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) - } - cpal::SampleFormat::I16 => { - audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) - } - cpal::SampleFormat::U16 => { - audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) + // Either a live cpal stream (kept alive by the binding) or the writer + // thread draining the channel into a pipe. + enum Sink { + Device(#[allow(dead_code)] cpal::Stream), + Pipe(std::thread::JoinHandle<()>), + } + let sink = match (output, device_stuff) { + (Some(target), _) => Sink::Pipe(std::thread::spawn({ + let status = Arc::clone(&status); + move || audio::writer_loop(target, status, rx) + })), + (None, Some((device, config))) => { + let stream_config: cpal::StreamConfig = config.clone().into(); + let stream = match config.sample_format() { + cpal::SampleFormat::F32 => { + audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) + } + cpal::SampleFormat::I16 => { + audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) + } + cpal::SampleFormat::U16 => { + audio::build_stream::(&device, &stream_config, rx, Arc::clone(&status)) + } + other => bail!("unsupported output sample format {other:?}"), + }?; + stream.play().context("failed to start output stream")?; + Sink::Device(stream) } - other => bail!("unsupported output sample format {other:?}"), - }?; - stream.play().context("failed to start output stream")?; + (None, None) => unreachable!(), + }; let info = ui::StreamInfo { input: input_label(&cli.input), @@ -282,15 +327,28 @@ fn main() -> Result<()> { } if let Some(token) = &endpoints.token { // stdout on purpose (not a log): scripts starting a headless - // server capture the token to hand to remote clients. - println!("api token: {token}"); + // server capture the token to hand to remote clients — unless + // stdout is the PCM output, where it would corrupt the stream. + if matches!(sink, Sink::Pipe(_)) && device_name == "stdout" { + tracing::info!("api token: {token}"); + } else { + println!("api token: {token}"); + } } } reader.join().ok(); - while status.queued.load(std::sync::atomic::Ordering::Acquire) > 0 { - std::thread::sleep(Duration::from_millis(50)); + match sink { + // The writer exits once the channel drains after the reader ends. + Sink::Pipe(writer) => { + writer.join().ok(); + } + Sink::Device(_) => { + while status.queued.load(std::sync::atomic::Ordering::Acquire) > 0 { + std::thread::sleep(Duration::from_millis(50)); + } + std::thread::sleep(Duration::from_millis(200)); // let the device drain + } } - std::thread::sleep(Duration::from_millis(200)); // let the device drain cleanup_socket(&api); if let Some(err) = status.error.lock().unwrap().clone() { bail!(err);