Initial commit.

This commit is contained in:
Johannes Findeisen 2026-10-15 01:07:30 +02:00
commit d0a20cd562
7 changed files with 1160 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -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/

80
Cargo.lock generated Normal file
View file

@ -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",
]

23
Cargo.toml Normal file
View file

@ -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 <you@hanez.org>"]
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

13
LICENSE Normal file
View file

@ -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.

42
Makefile Normal file
View file

@ -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

199
README.md Normal file
View file

@ -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 <dir>` | Output directory (default: current directory) |
| `-d <secs>` | Stop automatically after `<secs>` seconds |
| `-p <prefix>` | Filename prefix instead of `tuxrecord2` |
| `-D <device>` | 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 `<prefix>__<timestamp>.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
`<prefix>__<timestamp>.wav` naming and can be stopped with `q` or **Ctrl+C**.
The default setting can be restored later with
`pactl set-default-source <your-input-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

785
src/main.rs Normal file
View file

@ -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 <you@hanez.org>
* 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 <dir> Output directory (default: current directory)\n\
\x20 -d <secs> Stop automatically after <secs> seconds (default: until q or Ctrl+C)\n\
\x20 -p <prefix> Filename prefix instead of \"{prefix}\" (default: {prefix})\n\
\x20 -D <device> ALSA capture device (default: \"{device}\")\n\
\x20 -h, --help Show this help\n\
\n\
The file is written as <prefix>__<timestamp>.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<File> {
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:<device>' 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<libc::termios> {
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<libc::termios>) {
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 "<YYYYmmdd_HHMMSS>" 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
* "<dir>/<prefix>__<timestamp>.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<String> = 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::<f64>() {
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::<i16>();
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)
}