From d0a20cd5621e7218fc659790f823b1ab3405a732 Mon Sep 17 00:00:00 2001 From: hanez Date: Thu, 15 Oct 2026 01:07:30 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 18 ++ Cargo.lock | 80 ++++++ Cargo.toml | 23 ++ LICENSE | 13 + Makefile | 42 +++ README.md | 199 +++++++++++++ src/main.rs | 785 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1160 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 src/main.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3069603 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Rust build output +/target/ + +# Recordings +recordings/ +*.wav + +# Rust backup files +*.rs.bk + +# IDE ignores +.idea/ + +# Vim swap files +*.swp + +# Temporary files +tmp/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4f5a5fd --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,80 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "alsa" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3f93573e7ddb15145d27c58c0fa0a452faad9f5452ac17ed4554ea035ea9b71" +dependencies = [ + "alsa-sys", + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0320138053ef93099f168102c37b46ac0b9c53affc56ef7696633ad5477e2be" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "tuxrecord2" +version = "1.0.0" +dependencies = [ + "alsa", + "libc", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d04424e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tuxrecord2" +version = "1.0.0" +edition = "2021" +rust-version = "1.77" +description = "Record audio to a WAV file on Linux using ALSA" +authors = ["Johannes Findeisen "] +license = "Apache-2.0" +readme = "README.md" +repository = "https://xw3.org/hanez/tuxrecord2" +keywords = ["audio", "alsa", "wav", "recording"] +categories = ["multimedia::audio", "command-line-utilities"] + +[[bin]] +name = "tuxrecord2" +path = "src/main.rs" + +[dependencies] +alsa = "0.12" +libc = "0.2" + +[profile.release] +opt-level = 3 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d2c57e8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2025 Johannes Findeisen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..48fe5d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Makefile for tuxrecord2 +# +# tuxrecord2 is written in Rust and built with Cargo; this Makefile is a +# convenience wrapper around the common Cargo commands and provides +# system-wide install/uninstall targets. + +CARGO ?= cargo + +PREFIX ?= /usr/local +BINDIR = $(PREFIX)/bin + +BINARY = tuxrecord2 +RELEASE = target/release/$(BINARY) + +all: release + +release: + $(CARGO) build --release + +debug: + $(CARGO) build + +test: + $(CARGO) test + +fmt: + $(CARGO) fmt + +lint: + $(CARGO) clippy -- -D warnings + +install: release + install -d $(DESTDIR)$(BINDIR) + install -m 755 $(RELEASE) $(DESTDIR)$(BINDIR)/$(BINARY) + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/$(BINARY) + +clean: + $(CARGO) clean + +.PHONY: all release debug test fmt lint install uninstall clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..49025c6 --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# tuxrecord2 + +Record audio to a WAV file on **Linux** using **ALSA** as the recording source. + +`tuxrecord2` is the **Rust** port of `tuxrecord`, the Linux port of the former +macOS-only **macrecord** tool. The original port was written in C99; this rewrite +keeps the same command-line interface and behaviour but is implemented in Rust +on top of the safe [alsa](https://crates.io/crates/alsa) bindings and a small +amount of `libc` for terminal, polling and signal handling. It builds with +**Cargo**. +macrecord was created by my good friend Armin. You can find his code here: +[https://codeberg.org/armin/macrecord](https://codeberg.org/armin/macrecord) + +## Requirements + +- Rust toolchain (Cargo) — 1.77 or newer (uses C string literals) +- ALSA development headers and `pkg-config` + (`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora, + `alsa-lib` on Arch) + +## Usage + +```bash +# Record until q or Ctrl+C +tuxrecord2 + +# Record for N seconds +tuxrecord2 -d 30 + +# Specify output directory +tuxrecord2 -o /path/to/dir + +# Use a custom filename prefix +tuxrecord2 -p myrec + +# Use a specific ALSA capture device +tuxrecord2 -D hw:1,0 + +# Record from PipeWire (the PipeWire sound server) +tuxrecord2 -D pipewire +``` + +## Options + +| Option | Description | +| -------------- | -------------------------------------------------------- | +| `-o ` | Output directory (default: current directory) | +| `-d ` | Stop automatically after `` seconds | +| `-p ` | Filename prefix instead of `tuxrecord2` | +| `-D ` | ALSA capture device (default: `default`; `pipewire` on PipeWire systems) | +| `-h, --help` | Show help | + +Run `arecord -l` to list available ALSA capture devices. On systems with +PipeWire, list the PipeWire source nodes with `pactl list short sources` +or `wpctl status` (see "Recording from PipeWire" below). + +## Controls + +- **q** - Stop recording and save the file +- **Ctrl+C** - Stop recording and save the file + +The file is written as `__.wav` (default prefix +`tuxrecord2`). The VU meter shows the input level of the left and right channels +in real time when stderr is a terminal. + +## Recording system audio + +The default ALSA capture device (`default`) is usually the microphone. To +record **system audio** (everything playing on the machine), route playback +through the ALSA loopback driver `snd-aloop` and capture from the other side +of the loop. + +```bash +# 1. Load the loopback module (once per boot) +sudo modprobe snd-aloop + +# 2. Play audio on the "playback" side of the loop +aplay -D hw:Loopback,0 song.wav + +# 3. In another terminal, record from the paired "capture" side +tuxrecord2 -D hw:Loopback,1 +``` + +Audio written to `hw:Loopback,0` (playback) is heard on `hw:Loopback,1` +(capture): the two subdevices form a loop and the samples pass straight +through. Point your players at `hw:Loopback,0` (default sink) and record from +`hw:Loopback,1` to capture the system mix. Subdevice pairing depends on the +kernel version; if one direction is silent, try recording from +`hw:Loopback,0` while playing to `hw:Loopback,1`. + +## Recording from PipeWire + +On modern distributions (Fedora 34+, Ubuntu 22.04+, Arch Linux) the **PipeWire** +sound server takes over audio handling. PipeWire ships an ALSA compatibility +plugin (`pipewire-alsa`) that exposes the server as a normal ALSA capture +device named `pipewire`, so `tuxrecord2` can record from it with no extra +setup: + +```bash +# Record from PipeWire's default source (usually the microphone) +tuxrecord2 -D pipewire + +# Record a fixed duration +tuxrecord2 -D pipewire -d 10 +``` + +### What "pipewire" records + +The pipewire-alsa plugin routes capture to PipeWire's **default source**: + +```bash +# Show the current default source +pactl get-default-source # or: wpctl status + +# List all available PipeWire sources +wpctl status +pactl list short sources +``` + +### Recording system audio (what you hear) + +To capture everything that is currently playing (system audio), point the +default source at the **monitor** of the default sink. This is the PipeWire +equivalent of the ALSA loopback approach above but needs no kernel module: + +```bash +# 1. Find the monitor of the default sink +pactl set-default-source "$(pactl get-default-sink).monitor" + +# 2. Record it with tuxrecord2 +tuxrecord2 -D pipewire +``` + +The `.monitor` source carries exactly what the sink is playing, so anything +your players output ends up in the WAV file. Every capture follows the same +`__.wav` naming and can be stopped with `q` or **Ctrl+C**. +The default setting can be restored later with +`pactl set-default-source `. + +### Troubleshooting + +- **"cannot open ALSA capture device 'pipewire'"** — the `pipewire-alsa` + plugin is missing. Install it (e.g. `pipewire-alsa` on Debian/Ubuntu/Arch, + `pipewire-alsa` on Fedora), or the PipeWire server is not running + (`systemctl --user status pipewire`). In that case fall back to a regular + ALSA device with `tuxrecord2 -D hw:0,0`. +- Recording from a specific device or virtual node instead of the default + source is possible by setting the default source first (see `pactl` / + `wpctl` output) or by creating a loopback with `pw-loopback` and recording + that node. + +## Build + +```bash +make +# or directly: +# cargo build --release +``` + +The resulting binary is `target/release/tuxrecord2`. You can also install it +straight from the source tree: + +```bash +cargo install --path . +``` + +Install (installs to `$(PREFIX)/bin`, default `/usr/local/bin`): + +```bash +sudo make install +# or: sudo cp target/release/tuxrecord2 /usr/local/bin/tuxrecord2 +``` + +Install to a custom prefix: + +```bash +make install PREFIX=~/.local +``` + +Uninstall: + +```bash +sudo make uninstall +``` + +## Development + +```bash +make debug # cargo build +make test # cargo test +make fmt # cargo fmt +make lint # cargo clippy -- -D warnings +``` + +## License + +Apache License 2.0 — see the LICENSE file for details. + +Copyright 2026 Johannes Findeisen diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..07753e3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,785 @@ +/* + * This file is part of tuxrecord2. + * https://xw3.org/hanez/tuxrecord2 + * + * tuxrecord2 - record audio to a WAV file using ALSA + * + * tuxrecord2 is the Rust port of "tuxrecord", the Linux port of the former + * macOS-only "macrecord" tool. The original tuxrecord was rewritten in C99 + * and used the ALSA capture API directly; this version keeps the same + * behaviour and command-line interface but is implemented in Rust on top of + * the safe `alsa` bindings and a small amount of `libc` for the terminal, + * polling and signal handling. macrecord was created by my good friend Armin. + * You can find his code here: https://codeberg.org/armin/macrecord + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + * https://opensource.org/license/apache-2-0 + */ + +use std::fs::{File, OpenOptions}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use alsa::pcm::{Access, Format, HwParams, PCM}; +use alsa::{Direction, PollDescriptors, ValueOr}; + +const DEFAULT_RATE: u32 = 48_000; +const DEFAULT_CHANNELS: u32 = 2; +const DEFAULT_BITS: u32 = 16; +const DEFAULT_PREFIX: &str = "tuxrecord2"; +const DEFAULT_DEVICE: &str = "default"; + +const VU_WIDTH: usize = 48; +const VU_MIN_DB: f64 = -60.0; +/// Redraw the VU meter at most every 50 ms. +const VU_INTERVAL: Duration = Duration::from_millis(50); +const HEADER_LENGTH: usize = 44; +/// Classic 32-bit RIFF limit. +const WAV_DATA_MAX: u64 = u32::MAX as u64 - 36; + +/* + * Raw samples are handed to the WAV writer as bytes, relying on the host + * being little-endian: we negotiate S16_LE with ALSA, so the in-memory + * representation is already the byte order the RIFF/WAV container expects. + * Refuse to build on big-endian hosts instead of silently producing a + * corrupted file. + */ +#[cfg(not(target_endian = "little"))] +compile_error!("tuxrecord2 assumes a little-endian host for raw S16_LE output"); + +/// Set to true by the signal handler to request a clean stop. +static STOP: AtomicBool = AtomicBool::new(false); + +/* + * Handle SIGINT and SIGTERM by requesting a clean stop. + * + * This is an async-signal-safe handler: it only stores to the atomic STOP + * flag, which the recording loop checks on every iteration. Because no I/O + * or locking is done here, Ctrl+C (or a SIGTERM) does not terminate the + * process abruptly; instead the loop exits normally, the WAV header is + * patched, the file is closed and the terminal restored. + */ +extern "C" fn handle_signal(_sig: libc::c_int) { + STOP.store(true, Ordering::SeqCst); +} + +/* + * Print the usage/help text to stdout. + * + * Called when -h/--help is given (the program then exits 0) and for any + * unknown, missing, or malformed option (the program then exits 2). + */ +fn usage() { + print!( + "tuxrecord2 - record audio to a WAV file using ALSA\n\ + \n\ + USAGE\n\ + \x20 tuxrecord2 [options]\n\ + \n\ + OPTIONS\n\ + \x20 -o Output directory (default: current directory)\n\ + \x20 -d Stop automatically after seconds (default: until q or Ctrl+C)\n\ + \x20 -p Filename prefix instead of \"{prefix}\" (default: {prefix})\n\ + \x20 -D ALSA capture device (default: \"{device}\")\n\ + \x20 -h, --help Show this help\n\ + \n\ + The file is written as __.wav\n\ + \n\ + By default the ALSA capture device \"default\" is used, which is usually\n\ + the microphone. To record system audio (everything playing on the\n\ + system) see the \"Recording system audio\" section in the README.\n\ + \n\ + PipeWire users: record from the PipeWire default source via the\n\ + ALSA compatibility plugin by passing the device name \"pipewire\":\n\ + \x20 tuxrecord2 -D pipewire (default source)\n\ + \x20 pactl set-default-source \"$(pactl get-default-sink).monitor\"\n\ + \x20 tuxrecord2 -D pipewire (system audio, uses the sink monitor)\n", + prefix = DEFAULT_PREFIX, + device = DEFAULT_DEVICE, + ); + let _ = io::stdout().flush(); +} + +/* ------------------------------------------------------------------------ */ +/* VU meter */ +/* ------------------------------------------------------------------------ */ + +/* + * Compute RMS levels (0.0..1.0) for the left and right channel. + * + * Scans all interleaved frames: for each frame the first sample is the + * left channel and the second sample (when channels > 1) is the right + * channel. Per-channel squared samples are summed, the mean is taken and + * the square root is normalized by the full-scale amplitude of i16 + * (32768.0) so a full-scale sine wave reads near 0.7. + */ +fn rms_levels(samples: &[i16], frames: usize, channels: usize) -> (f64, f64) { + let mut sum_l = 0.0f64; + let mut sum_r = 0.0f64; + let mut n_l = 0usize; + let mut n_r = 0usize; + + for f in 0..frames { + let v = samples[f * channels] as f64; + sum_l += v * v; + n_l += 1; + if channels > 1 { + let v = samples[f * channels + 1] as f64; + sum_r += v * v; + n_r += 1; + } + } + + let left = if n_l > 0 { + (sum_l / n_l as f64).sqrt() / 32768.0 + } else { + 0.0 + }; + let right = if channels > 1 && n_r > 0 { + (sum_r / n_r as f64).sqrt() / 32768.0 + } else { + left + }; + (left, right) +} + +/* + * Convert a linear 0.0..1.0 level into decibels. + * + * Returns VU_MIN_DB for 0.0, otherwise 20*log10(level), clamped to the + * meter's dynamic range [VU_MIN_DB, 0.0] so the VU bar is never + * completely empty above the floor or offset at full scale. + */ +fn to_db(level: f64) -> f64 { + if level <= 0.0 { + return VU_MIN_DB; + } + (20.0 * level.log10()).clamp(VU_MIN_DB, 0.0) +} + +/* + * Render one horizontal VU meter line (label + 48 segments) into a String. + * + * Converts a dB level into a filled fraction of the meter width + * (VU_MIN_DB..0) and draws VU_WIDTH segments: the lit part as full blocks + * (U+2588) colored by level (red above 85 %, yellow above 60 %, green + * otherwise) and the unlit part as faint blocks (U+2591). Each emitted + * segment carries the ANSI color code and a reset, so the result is safe + * to print to a terminal. + */ +fn vu_meter_line(db: f64, label: &str) -> String { + let fraction = ((db - VU_MIN_DB) / (0.0 - VU_MIN_DB)).clamp(0.0, 1.0); + let filled = (fraction * VU_WIDTH as f64) as usize; + let pct = fraction * 100.0; + let fg = if pct > 85.0 { + "\x1b[31m" + } else if pct > 60.0 { + "\x1b[33m" + } else { + "\x1b[32m" + }; + + let mut out = String::with_capacity(label.len() + 1 + VU_WIDTH * 12); + out.push_str(label); + out.push(' '); + for k in 0..VU_WIDTH { + if k < filled { + out.push_str(fg); + out.push('\u{2588}'); + out.push_str("\x1b[0m"); + } else { + out.push_str("\x1b[32m"); + out.push('\u{2591}'); + out.push_str("\x1b[0m"); + } + } + out +} + +/* + * Draw the left and right VU meter lines to stderr. + * + * Renders both channels and prints them as two consecutive lines. After the + * first draw, an ANSI "move cursor up two lines" escape (\x1b[2A) is emitted + * first so each redraw overwrites the previous meter in place instead of + * scrolling. + */ +fn draw_meter(db_l: f64, db_r: f64, first: bool) { + let l = vu_meter_line(db_l, "L"); + let r = vu_meter_line(db_r, "R"); + + if !first { + eprint!("\x1b[2A"); // move cursor up two lines, then redraw + } + eprintln!("{l}\n{r}"); +} + +/* ------------------------------------------------------------------------ */ +/* WAV file writer */ +/* ------------------------------------------------------------------------ */ + +/* + * Create and open a WAV file, writing a 44-byte PCM header. + * + * Creates (or truncates) the file with mode 0644 and writes the RIFF / + * WAVE / fmt / data chunk layout for the given rate, channel count and + * bit depth. The RIFF chunk size and data chunk size fields are left as + * zero placeholders and patched later by wav_finalize() once the exact + * amount of audio data is known. + */ +fn wav_open(path: &Path, rate: u32, channels: u32, bits: u32) -> io::Result { + let byte_rate = rate * channels * bits / 8; + let mut hdr = [0u8; HEADER_LENGTH]; + + hdr[0..4].copy_from_slice(b"RIFF"); + hdr[4..8].copy_from_slice(&0u32.to_le_bytes()); // RIFF chunk size + hdr[8..12].copy_from_slice(b"WAVE"); + hdr[12..16].copy_from_slice(b"fmt "); + hdr[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size + hdr[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM + hdr[22..24].copy_from_slice(&(channels as u16).to_le_bytes()); + hdr[24..28].copy_from_slice(&rate.to_le_bytes()); + hdr[28..32].copy_from_slice(&byte_rate.to_le_bytes()); + hdr[32..34].copy_from_slice(&((channels * bits / 8) as u16).to_le_bytes()); + hdr[34..36].copy_from_slice(&(bits as u16).to_le_bytes()); + hdr[36..40].copy_from_slice(b"data"); + hdr[40..44].copy_from_slice(&0u32.to_le_bytes()); // data chunk size + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o644) + .open(path)?; + file.write_all(&hdr)?; + Ok(file) +} + +/* + * Patch the header chunk sizes and flush the WAV file. + * + * Seeks back to the beginning of the file and writes the real RIFF chunk + * size (header + data - 8) at offset 4 and the data chunk size (the + * number of audio bytes) at offset 40, then fsyncs. Called once after the + * recording loop with the total bytes accumulated. + */ +fn wav_finalize(file: &mut File, data_bytes: u64) -> io::Result<()> { + file.seek(SeekFrom::Start(4))?; + let riff_size = (HEADER_LENGTH as u32) + .wrapping_add(data_bytes as u32) + .wrapping_sub(8); + file.write_all(&riff_size.to_le_bytes())?; + + file.seek(SeekFrom::Start(40))?; + file.write_all(&(data_bytes as u32).to_le_bytes())?; + + file.sync_all()?; + Ok(()) +} + +/* ------------------------------------------------------------------------ */ +/* ALSA capture */ +/* ------------------------------------------------------------------------ */ + +fn config_fail(device: &str, err: &alsa::Error) { + eprintln!("Error: failed to configure ALSA capture on '{device}': {err}"); +} + +/* + * Open an ALSA capture device and negotiate the stream parameters. + * + * Opens the named PCM in capture mode and starts from the device's + * current ("any") hardware parameters, then requests interleaved S16_LE + * access, a channel count/sample rate "near" the requested values, and + * commits the configuration. The actually negotiated rate, channel + * count and period size are read back so the caller can allocate sane + * buffers and record a correct WAV header even when the device adjusted + * the values. + * + * On failure a descriptive error is printed to stderr (including a hint + * to use 'plug:' when S16_LE is rejected) and None is returned. + */ +fn open_capture(device: &str, rate_req: u32, channels_req: u32) -> Option<(PCM, u32, u32, i64)> { + let pcm = match PCM::new(device, Direction::Capture, false) { + Ok(pcm) => pcm, + Err(err) => { + eprintln!("Error: cannot open ALSA capture device '{device}': {err}"); + eprintln!("Run 'arecord -l' to list available capture devices."); + return None; + } + }; + + let hw = match HwParams::any(&pcm) { + Ok(hw) => hw, + Err(err) => { + config_fail(device, &err); + return None; + } + }; + + if let Err(err) = hw.set_access(Access::RWInterleaved) { + config_fail(device, &err); + return None; + } + + if let Err(_err) = hw.set_format(Format::s16()) { + eprintln!( + "Error: device '{device}' does not support 16-bit signed PCM; try 'plug:{device}'" + ); + return None; + } + + if let Err(err) = hw.set_channels_near(channels_req) { + config_fail(device, &err); + return None; + } + + if let Err(err) = hw.set_rate_near(rate_req, ValueOr::Nearest) { + config_fail(device, &err); + return None; + } + + if let Err(err) = pcm.hw_params(&hw) { + config_fail(device, &err); + return None; + } + + // Read back the values that were actually negotiated. + let rate = match hw.get_rate() { + Ok(v) => v, + Err(err) => { + config_fail(device, &err); + return None; + } + }; + let channels = match hw.get_channels() { + Ok(v) => v, + Err(err) => { + config_fail(device, &err); + return None; + } + }; + let period = match hw.get_period_size() { + Ok(p) if p > 0 => p, + _ => 1024, + }; + + // Release the hardware-parameters handle (it borrows `pcm`) so the PCM + // can be moved out and returned to the caller. + drop(hw); + + Some((pcm, rate, channels, period)) +} + +/* ------------------------------------------------------------------------ */ +/* Terminal */ +/* ------------------------------------------------------------------------ */ + +/* + * Switch stdin into raw (non-canonical, no-echo) terminal mode. + * + * Only active when stdin is a TTY; otherwise it returns None and leaves + * the terminal alone (recording works, 'q' just is not available). Raw + * mode makes each keystroke immediately available to read() (VMIN=1, + * VTIME=0) with echo disabled, so the recording loop can detect 'q' as + * it is typed. TCSANOW applies the change without flushing a key that + * may already have been typed. + * + * Returns the saved terminal settings to be passed to restore_terminal() + * later, or None if stdin is not a TTY or the change failed. + */ +fn enable_raw_mode() -> Option { + unsafe { + if libc::isatty(libc::STDIN_FILENO) != 1 { + return None; + } + let mut old: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(libc::STDIN_FILENO, &mut old) != 0 { + return None; + } + + let mut raw = old; + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + raw.c_cc[libc::VMIN] = 1; + raw.c_cc[libc::VTIME] = 0; + // TCSANOW: don't flush a key that was already typed before raw mode + if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) != 0 { + return None; + } + Some(old) + } +} + +/* + * Restore the terminal settings saved by enable_raw_mode(). + * + * Re-applies the saved termios with TCSAFLUSH (drops any buffered + * input/output) so the terminal is always left in its original state. + * Safe to call even if raw mode was never enabled. + */ +fn restore_terminal(saved: &Option) { + if let Some(old) = saved { + unsafe { + libc::tcsetattr(libc::STDIN_FILENO, libc::TCSAFLUSH, old); + } + } +} + +/* + * Install handlers for SIGINT and SIGTERM that request a clean stop. + * + * Uses sigaction with zero flags (matching the C original) so that a + * signal interrupts a blocking poll() with EINTR instead of being + * transparently restarted; the loop then observes the STOP flag. + */ +fn install_signals() { + unsafe { + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = handle_signal as *const () as usize; + sa.sa_flags = 0; + libc::sigemptyset(&mut sa.sa_mask); + libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()); + libc::sigaction(libc::SIGTERM, &sa, std::ptr::null_mut()); + } +} + +/* + * Build the "" timestamp used in the output filename. + * + * Uses the local time zone, exactly like the C original; falls back to a + * fixed placeholder if the time or formatting calls fail. + */ +fn timestamp() -> String { + let mut buf = [0u8; 32]; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + + let ok = unsafe { + let t = libc::time(std::ptr::null_mut()); + !libc::localtime_r(&t, &mut tm).is_null() + && libc::strftime( + buf.as_mut_ptr() as *mut libc::c_char, + buf.len(), + c"%Y%m%d_%H%M%S".as_ptr(), + &tm, + ) != 0 + }; + + if !ok { + return "00000000_000000".to_string(); + } + let len = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..len]).into_owned() +} + +/* ------------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------------ */ + +/* + * Program entry point: parse options, record audio, write a WAV file. + * + * Parses the command line (-o dir, -d seconds, -p prefix, -D device, + * -h/--help), validates the output directory, builds the output filename + * "/__.wav" and opens the ALSA capture device + * and the WAV file. It then installs SIGINT/SIGTERM handlers and runs a + * poll() loop that reads captured PCM frames from ALSA and appends them + * to the file, redrawing the VU meter on a tty stderr at most every + * VU_INTERVAL. The loop stops on 'q' (raw mode), Ctrl+C/SIGINT, + * SIGTERM, the -d duration elapsing, or on error. Finally the WAV header + * is patched, the terminal restored and the result announced. + * + * Returns 0 on success, 1 on a runtime error, 2 on a command-line error. + */ +fn run() -> i32 { + let args: Vec = std::env::args().collect(); + let mut output_dir = String::from("."); + let mut prefix = DEFAULT_PREFIX.to_string(); + let mut device = DEFAULT_DEVICE.to_string(); + let mut duration = 0.0f64; // 0 = record until stopped + + // ---- argument parsing ------------------------------------------- + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "-o" => { + i += 1; + if i >= args.len() { + usage(); + return 2; + } + output_dir = args[i].clone(); + } + "-d" => { + i += 1; + if i >= args.len() { + usage(); + return 2; + } + match args[i].parse::() { + Ok(d) if d.is_finite() && d > 0.0 => duration = d, + _ => { + usage(); + return 2; + } + } + } + "-p" => { + i += 1; + if i >= args.len() || args[i].is_empty() { + usage(); + return 2; + } + prefix = args[i].clone(); + } + "-D" => { + i += 1; + if i >= args.len() || args[i].is_empty() { + usage(); + return 2; + } + device = args[i].clone(); + } + "-h" | "--help" => { + usage(); + return 0; + } + _ => { + usage(); + return 2; + } + } + i += 1; + } + + // ---- output directory and filename ------------------------------- + if !std::fs::metadata(&output_dir) + .map(|m| m.is_dir()) + .unwrap_or(false) + { + eprintln!("Error: output directory does not exist: {output_dir}"); + return 1; + } + + let stamp = timestamp(); + let path_str = format!("{}/{prefix}__{stamp}.wav", output_dir); + if path_str.len() >= 4096 { + eprintln!("Error: output path too long"); + return 1; + } + let path = Path::new(&path_str); + + // ---- audio device and output file -------------------------------- + let (pcm, rate, channels, period) = match open_capture(&device, DEFAULT_RATE, DEFAULT_CHANNELS) + { + Some(v) => v, + None => return 1, + }; + + let mut file = match wav_open(path, rate, channels, DEFAULT_BITS) { + Ok(f) => f, + Err(e) => { + eprintln!("Error: cannot open output file: {}: {e}", path.display()); + return 1; + } + }; + + // ---- signals ----------------------------------------------------- + install_signals(); + + let stderr_tty = unsafe { libc::isatty(libc::STDERR_FILENO) } == 1; + let use_stdin = unsafe { libc::isatty(libc::STDIN_FILENO) } == 1; + + eprintln!("Recording to {}", path.display()); + eprintln!( + "Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit" + ); + eprintln!("Press q or Ctrl+C to stop."); + + if let Err(err) = pcm.start() { + eprintln!("Error: could not start capture on '{device}': {err}"); + return 1; + } + + eprintln!("Recording started."); + if stderr_tty { + draw_meter(VU_MIN_DB, VU_MIN_DB, true); + } + + // ---- recording loop ----------------------------------------------- + let frame_samples = period as usize * channels as usize; + let mut samples = vec![0i16; frame_samples]; + + let mut pfds = match pcm.get() { + Ok(v) => v, + Err(err) => { + eprintln!("Error: could not query ALSA poll descriptors: {err}"); + return 1; + } + }; + let pcm_count = pfds.len(); + if pcm_count == 0 { + eprintln!("Error: ALSA returned no poll descriptors"); + return 1; + } + if use_stdin { + pfds.push(alsa::poll::pollfd { + fd: libc::STDIN_FILENO, + events: libc::POLLIN, + revents: 0, + }); + } + + let saved_term = if use_stdin { enable_raw_mode() } else { None }; + + let io = match pcm.io_i16() { + Ok(io) => io, + Err(err) => { + eprintln!("Error: could not open PCM I/O: {err}"); + restore_terminal(&saved_term); + return 1; + } + }; + + let start_t = Instant::now(); + let mut last_draw = start_t; + let mut data_bytes: u64 = 0; + let mut result = 0; + + while !STOP.load(Ordering::SeqCst) { + let mut timeout: i32 = -1; + + // automatic stop timer + if duration > 0.0 { + let remaining = duration - start_t.elapsed().as_secs_f64(); + if remaining <= 0.0 { + break; + } + timeout = (remaining * 1000.0) as i32; + if timeout < 1 { + timeout = 1; + } + } + + match alsa::poll::poll(&mut pfds, timeout) { + Ok(0) => continue, // timed out; re-check timer + Ok(_) => {} + Err(err) if err.errno() == libc::EINTR => continue, // interrupted by a signal + Err(err) => { + eprintln!("Error: poll failed: {err}"); + result = 1; + break; + } + } + + // keyboard input (raw mode, 'q' to quit) + if use_stdin && (pfds[pcm_count].revents & libc::POLLIN) != 0 { + let mut buf = [0u8; 16]; + let n = unsafe { + libc::read( + libc::STDIN_FILENO, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len(), + ) + }; + if n > 0 { + for &b in &buf[..n as usize] { + if b == b'q' || b == b'Q' { + eprintln!("Quit key received, stopping..."); + STOP.store(true, Ordering::SeqCst); + } + } + } + } + + if STOP.load(Ordering::SeqCst) { + break; + } + + // capture audio + let revents = match pcm.revents(&pfds[..pcm_count]) { + Ok(f) => f, + Err(err) => { + eprintln!("Error: could not read ALSA poll state: {err}"); + result = 1; + break; + } + }; + if !(revents.contains(alsa::poll::Flags::IN) || revents.contains(alsa::poll::Flags::ERR)) { + continue; + } + + match io.readi(&mut samples) { + Err(err) if err.errno() == libc::EINTR => continue, + Err(err) if err.errno() == libc::EPIPE || err.errno() == libc::ESTRPIPE => { + eprintln!("Capture underrun/device suspended, resuming..."); + if let Err(re) = pcm.try_recover(err, true) { + eprintln!("Error: could not recover the ALSA stream: {re}"); + result = 1; + break; + } + continue; + } + Err(err) => { + eprintln!("Error: failed to read audio: {err}"); + result = 1; + break; + } + Ok(got) if got > 0 => { + let n_samples = got * channels as usize; + let bytes = n_samples * std::mem::size_of::(); + + if data_bytes + bytes as u64 > WAV_DATA_MAX { + eprintln!("Warning: 4 GiB WAV limit reached, stopping."); + break; + } + + // S16_LE samples are already in the byte order RIFF expects. + let raw = + unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, bytes) }; + if let Err(err) = file.write_all(raw) { + eprintln!("Error: failed to write audio: {err}"); + result = 1; + break; + } + data_bytes += bytes as u64; + + // VU meter, redrawn at most every 50 ms + let meter_now = Instant::now(); + if stderr_tty && meter_now.duration_since(last_draw) >= VU_INTERVAL { + let (l, r) = rms_levels(&samples[..n_samples], got, channels as usize); + draw_meter(to_db(l), to_db(r), false); + last_draw = meter_now; + } + } + Ok(_) => {} + } + } + + // ---- wrap up ------------------------------------------------------ + drop(io); + restore_terminal(&saved_term); + eprintln!("\nRecording ended."); + + match wav_finalize(&mut file, data_bytes) { + Ok(()) => { + if result == 0 { + eprintln!("Saved {}", path.display()); + } + } + Err(_) => { + eprintln!("Error: could not finalize WAV file {}", path.display()); + result = 1; + } + } + + result +} + +fn main() -> ExitCode { + ExitCode::from(run() as u8) +}