Added flac and mp3 output format.

This commit is contained in:
Johannes Findeisen 2026-10-15 02:31:32 +02:00
commit d1dca0f477
10 changed files with 745 additions and 128 deletions

2
.gitignore vendored
View file

@ -4,6 +4,8 @@
# Recordings
recordings/
*.wav
*.flac
*.mp3
# Rust backup files
*.rs.bk

3
Cargo.lock generated
View file

@ -73,8 +73,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "tuxrecord2"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"alsa",
"libc",
"pkg-config",
]

View file

@ -1,15 +1,16 @@
[package]
name = "tuxrecord2"
version = "1.0.0"
version = "1.1.0"
edition = "2021"
rust-version = "1.77"
description = "Record audio to a WAV file on Linux using ALSA"
description = "Record audio to WAV, FLAC or MP3 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"]
keywords = ["audio", "alsa", "wav", "flac", "mp3", "recording"]
categories = ["multimedia::audio", "command-line-utilities"]
build = "build.rs"
[[bin]]
name = "tuxrecord2"
@ -19,5 +20,8 @@ path = "src/main.rs"
alsa = "0.12"
libc = "0.2"
[build-dependencies]
pkg-config = "0.3"
[profile.release]
opt-level = 3

View file

@ -1,13 +1,14 @@
# tuxrecord2
Record audio to a WAV file on **Linux** using **ALSA** as the recording source.
Record audio to WAV, FLAC or MP3 files 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**.
amount of `libc` for terminal, polling and signal handling. FLAC and MP3 output
is encoded in real time through small FFI shims over the system `libFLAC` and
`libmp3lame` libraries. 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)
@ -17,6 +18,12 @@ macrecord was created by my good friend Armin. You can find his code here:
- ALSA development headers and `pkg-config`
(`libasound2-dev` on Debian/Ubuntu, `alsa-lib-devel` on Fedora,
`alsa-lib` on Arch)
- FLAC and LAME (MP3) development headers and `pkg-config`, used for the
bundled real-time encoders
(`libflac-dev` and `libmp3lame-dev` on Debian/Ubuntu, `flac-devel` and
`lame-devel` on Fedora, `flac` and `lame` on Arch). They are only needed
to build; at run time the shared `libFLAC` / `libmp3lame` libraries are
used.
## Usage
@ -36,6 +43,10 @@ tuxrecord2 -p myrec
# Use a specific ALSA capture device
tuxrecord2 -D hw:1,0
# Encode to FLAC (lossless) or MP3 (lossy) in real time
tuxrecord2 -f flac
tuxrecord2 --format mp3
# Record from PipeWire (the PipeWire sound server)
tuxrecord2 -D pipewire
```
@ -48,6 +59,7 @@ tuxrecord2 -D pipewire
| `-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) |
| `-f, --format <fmt>` | Output format: `wav`, `flac` or `mp3` (default: `wav`) |
| `-h, --help` | Show help |
Run `arecord -l` to list available ALSA capture devices. On systems with
@ -59,9 +71,26 @@ or `wpctl status` (see "Recording from PipeWire" below).
- **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.
The file is written as `<prefix>__<timestamp>.<ext>` where `<ext>` is `wav`,
`flac` or `mp3` depending on `-f/--format` (default prefix `tuxrecord2`). The VU
meter shows the input level of the left and right channels in real time when
stderr is a terminal.
## Output formats
Audio is captured once and handed to the selected encoder as it arrives, so
FLAC and MP3 are produced **in real time** — no intermediate WAV file is
written and no second conversion pass runs afterwards.
- **wav** (default) — uncompressed 16-bit little-endian PCM in a RIFF/WAVE
container.
- **flac** — lossless compression via libFLAC (streaming, default
compression level 5).
- **mp3** — lossy compression via LAME at a constant 192 kbit/s, quality 2.
`wav` and `flac` preserve the captured stream exactly; `mp3` is lossy. The
classic 4 GiB RIFF limit applies to `wav` only (the program stops cleanly
before exceeding it); `flac` and `mp3` have no such limit.
## Recording system audio
@ -132,9 +161,9 @@ 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
your players output ends up in the recorded file. Every capture follows the
same `<prefix>__<timestamp>.<ext>` 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

25
build.rs Normal file
View file

@ -0,0 +1,25 @@
/*
* Build script for tuxrecord2.
*
* The FLAC and MP3 encoders are provided by the system libFLAC and
* libmp3lame libraries, which are used through the small FFI shims in
* src/flac.rs and src/mp3.rs. Locate them with pkg-config (which also
* emits the correct link flags and library search paths) so builds work
* on distributions where the headers/libraries live in non-standard
* locations.
*/
fn main() {
for lib in ["flac", "lame"] {
pkg_config::Config::new().probe(lib).unwrap_or_else(|err| {
panic!(
"could not find the '{lib}' library with pkg-config: {err}\n\
Install the development package (e.g. 'libflac-dev' and \
'libmp3lame-dev' on Debian/Ubuntu, 'flac-devel' and \
'lame-devel' on Fedora, 'flac' and 'lame' on Arch)."
);
});
}
println!("cargo:rerun-if-changed=build.rs");
}

114
src/encoder.rs Normal file
View file

@ -0,0 +1,114 @@
/*
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* Output-format abstraction: selects the WAV writer or the real-time FLAC
* / MP3 encoders and exposes a single streaming interface to the capture
* loop.
*
* Copyright 2026 Johannes Findeisen <you@hanez.org>
* Licensed under the terms of the Apache-2.0 license.
*/
use std::path::Path;
use crate::flac::FlacEncoder;
use crate::mp3::Mp3Encoder;
use crate::wav::WavWriter;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Format {
Wav,
Flac,
Mp3,
}
impl Format {
/// File extension (without the dot) used for this format.
pub fn extension(self) -> &'static str {
match self {
Format::Wav => "wav",
Format::Flac => "flac",
Format::Mp3 => "mp3",
}
}
/// Parse a user-supplied format name (case-insensitive).
pub fn parse(value: &str) -> Option<Format> {
match value.to_ascii_lowercase().as_str() {
"wav" => Some(Format::Wav),
"flac" => Some(Format::Flac),
"mp3" => Some(Format::Mp3),
_ => None,
}
}
/// Human-readable list of the supported format names.
pub fn names() -> &'static str {
"wav, flac, mp3"
}
}
enum Inner {
Wav(WavWriter),
Flac(FlacEncoder),
Mp3(Mp3Encoder),
}
/*
* Streaming output encoder.
*
* The capture loop hands interleaved S16_LE buffers to write_samples() as
* they are read from ALSA. finish() must be called once at the end to
* patch headers / flush the codec; it is also attempted from Drop so a
* partial recording is still left in a valid state.
*/
pub struct Encoder {
inner: Inner,
}
impl Encoder {
pub fn create(
format: Format,
path: &Path,
rate: u32,
channels: u32,
bits: u32,
) -> Result<Encoder, String> {
let inner = match format {
Format::Wav => Inner::Wav(
WavWriter::create(path, rate, channels, bits)
.map_err(|e| format!("cannot open output file: {e}"))?,
),
Format::Flac => Inner::Flac(FlacEncoder::create(path, rate, channels, bits)?),
Format::Mp3 => Inner::Mp3(Mp3Encoder::create(path, rate, channels)?),
};
Ok(Encoder { inner })
}
/// Upper bound on the number of PCM bytes to accept, if any (WAV only).
pub fn max_data_bytes(&self) -> Option<u64> {
match self.inner {
Inner::Wav(_) => Some(crate::wav::WAV_DATA_MAX),
Inner::Flac(_) | Inner::Mp3(_) => None,
}
}
/// Encode one buffer of interleaved S16_LE samples.
pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> {
match &mut self.inner {
Inner::Wav(w) => w.write_samples(samples).map_err(|e| e.to_string()),
Inner::Flac(f) => f.write_samples(samples),
Inner::Mp3(m) => m.write_samples(samples),
}
}
/// Finalize the stream (patch the WAV header / flush the codec).
pub fn finish(&mut self) -> Result<(), String> {
match &mut self.inner {
Inner::Wav(w) => w.finish().map_err(|e| e.to_string()),
Inner::Flac(f) => f.finish(),
Inner::Mp3(m) => m.finish(),
}
}
}

168
src/flac.rs Normal file
View file

@ -0,0 +1,168 @@
/*
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* Real-time FLAC encoding through a minimal FFI shim over the system
* libFLAC stream encoder. Captured PCM buffers are fed directly to
* libFLAC as they arrive, so no intermediate WAV file is needed.
*
* Copyright 2026 Johannes Findeisen <you@hanez.org>
* Licensed under the terms of the Apache-2.0 license.
*/
use std::ffi::CString;
use std::os::raw::{c_char, c_int, c_uint, c_void};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
/// Compression level passed to libFLAC (0 = fastest, 8 = best).
const COMPRESSION_LEVEL: u32 = 5;
/// Opaque `FLAC__StreamEncoder`.
#[repr(C)]
struct StreamEncoder {
_private: [u8; 0],
}
// Safety: the encoder handle is only ever used from the thread that owns
// it; tuxrecord2 is single-threaded.
unsafe impl Send for StreamEncoder {}
extern "C" {
fn FLAC__stream_encoder_new() -> *mut StreamEncoder;
fn FLAC__stream_encoder_delete(encoder: *mut StreamEncoder);
fn FLAC__stream_encoder_set_compression_level(
encoder: *mut StreamEncoder,
value: c_uint,
) -> c_int;
fn FLAC__stream_encoder_set_channels(encoder: *mut StreamEncoder, value: c_uint) -> c_int;
fn FLAC__stream_encoder_set_bits_per_sample(
encoder: *mut StreamEncoder,
value: c_uint,
) -> c_int;
fn FLAC__stream_encoder_set_sample_rate(encoder: *mut StreamEncoder, value: c_uint) -> c_int;
fn FLAC__stream_encoder_init_file(
encoder: *mut StreamEncoder,
filename: *const c_char,
progress_callback: *const c_void,
client_data: *mut c_void,
) -> c_int;
fn FLAC__stream_encoder_process_interleaved(
encoder: *mut StreamEncoder,
buffer: *const i32,
samples: c_uint,
) -> c_int;
fn FLAC__stream_encoder_finish(encoder: *mut StreamEncoder) -> c_int;
}
/*
* Real-time FLAC encoder.
*
* Owns a libFLAC stream encoder writing to the given path. Interleaved
* S16_LE buffers are expanded into the i32 layout libFLAC expects using a
* reusable conversion buffer, then handed to the encoder; the final
* STREAMINFO block is written by finish().
*/
pub struct FlacEncoder {
encoder: *mut StreamEncoder,
channels: usize,
/// Reusable i32 conversion buffer (the processing buffer).
buffer: Vec<i32>,
finished: bool,
}
impl FlacEncoder {
pub fn create(path: &Path, rate: u32, channels: u32, bits: u32) -> Result<FlacEncoder, String> {
let filename = CString::new(path.as_os_str().as_bytes())
.map_err(|_| "invalid output path".to_string())?;
// SAFETY: all libFLAC calls are made on a freshly allocated encoder
// and the return values checked before use.
unsafe {
let encoder = FLAC__stream_encoder_new();
if encoder.is_null() {
return Err("could not allocate the FLAC encoder".to_string());
}
let ok = FLAC__stream_encoder_set_compression_level(encoder, COMPRESSION_LEVEL) != 0
&& FLAC__stream_encoder_set_channels(encoder, channels) != 0
&& FLAC__stream_encoder_set_bits_per_sample(encoder, bits) != 0
&& FLAC__stream_encoder_set_sample_rate(encoder, rate) != 0;
if !ok {
FLAC__stream_encoder_delete(encoder);
return Err("could not configure the FLAC encoder".to_string());
}
let status = FLAC__stream_encoder_init_file(
encoder,
filename.as_ptr(),
std::ptr::null(),
std::ptr::null_mut(),
);
if status != 0 {
FLAC__stream_encoder_delete(encoder);
return Err(format!(
"could not open the FLAC output file (status {status})"
));
}
Ok(FlacEncoder {
encoder,
channels: channels as usize,
buffer: Vec::new(),
finished: false,
})
}
}
/// Encode one buffer of interleaved S16_LE samples.
pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> {
if self.finished {
return Err("FLAC encoder already finished".to_string());
}
if self.channels == 0 || samples.is_empty() {
return Ok(());
}
self.buffer.clear();
self.buffer.extend(samples.iter().map(|&s| i32::from(s)));
// libFLAC counts frames (samples per channel), not interleaved items.
let frames = (self.buffer.len() / self.channels) as c_uint;
// SAFETY: buffer holds exactly frames * channels valid i32 samples.
let ok = unsafe {
FLAC__stream_encoder_process_interleaved(self.encoder, self.buffer.as_ptr(), frames)
};
if ok == 0 {
return Err("FLAC encoding failed".to_string());
}
Ok(())
}
/// Flush the remaining frames and write the final STREAMINFO block.
pub fn finish(&mut self) -> Result<(), String> {
if self.finished {
return Ok(());
}
self.finished = true;
// SAFETY: self.encoder is a live encoder initialised in create().
let ok = unsafe { FLAC__stream_encoder_finish(self.encoder) };
if ok == 0 {
return Err("could not finalize the FLAC stream".to_string());
}
Ok(())
}
}
impl Drop for FlacEncoder {
fn drop(&mut self) {
if !self.finished {
let _ = self.finish();
}
// SAFETY: the encoder is deleted exactly once; its pointer is never
// used again afterwards.
unsafe {
FLAC__stream_encoder_delete(self.encoder);
}
}
}

View file

@ -2,7 +2,7 @@
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* tuxrecord2 - record audio to a WAV file using ALSA
* tuxrecord2 - record audio to WAV, FLAC or MP3 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
@ -17,17 +17,22 @@
* 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;
mod encoder;
mod flac;
mod mp3;
mod wav;
use std::io::{self, Write};
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::pcm::{Access, Format as AlsaFormat, HwParams, PCM};
use alsa::{Direction, PollDescriptors, ValueOr};
use crate::encoder::{Encoder, Format};
const DEFAULT_RATE: u32 = 48_000;
const DEFAULT_CHANNELS: u32 = 2;
const DEFAULT_BITS: u32 = 16;
@ -38,19 +43,16 @@ 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.
* Samples are captured as S16_LE. The WAV writer hands the in-memory
* representation to the file as raw bytes, and the FLAC/MP3 encoders
* interpret the samples as native i16, so both rely on the host being
* little-endian. 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");
compile_error!("tuxrecord2 assumes a little-endian host for raw S16_LE audio");
/// Set to true by the signal handler to request a clean stop.
static STOP: AtomicBool = AtomicBool::new(false);
@ -61,8 +63,8 @@ static STOP: AtomicBool = AtomicBool::new(false);
* 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.
* process abruptly; instead the loop exits normally, the output is
* finalized, the file is closed and the terminal restored.
*/
extern "C" fn handle_signal(_sig: libc::c_int) {
STOP.store(true, Ordering::SeqCst);
@ -76,19 +78,20 @@ extern "C" fn handle_signal(_sig: libc::c_int) {
*/
fn usage() {
print!(
"tuxrecord2 - record audio to a WAV file using ALSA\n\
"tuxrecord2 - record audio to WAV, FLAC or MP3 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\
\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 -f, --format F Output format: {formats} (default: {format})\n\
\x20 -h, --help Show this help\n\
\n\
The file is written as <prefix>__<timestamp>.wav\n\
The file is written as <prefix>__<timestamp>.<ext> where <ext> is wav, flac, or mp3.\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\
@ -101,6 +104,8 @@ fn usage() {
\x20 tuxrecord2 -D pipewire (system audio, uses the sink monitor)\n",
prefix = DEFAULT_PREFIX,
device = DEFAULT_DEVICE,
format = Format::Wav.extension(),
formats = Format::names(),
);
let _ = io::stdout().flush();
}
@ -219,69 +224,6 @@ fn draw_meter(db_l: f64, db_r: f64, first: bool) {
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 */
/* ------------------------------------------------------------------------ */
@ -298,7 +240,7 @@ fn config_fail(device: &str, err: &alsa::Error) {
* 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
* buffers and describe the stream correctly even when the device adjusted
* the values.
*
* On failure a descriptive error is printed to stderr (including a hint
@ -327,7 +269,7 @@ fn open_capture(device: &str, rate_req: u32, channels_req: u32) -> Option<(PCM,
return None;
}
if let Err(_err) = hw.set_format(Format::s16()) {
if let Err(_err) = hw.set_format(AlsaFormat::s16()) {
eprintln!(
"Error: device '{device}' does not support 16-bit signed PCM; try 'plug:{device}'"
);
@ -481,17 +423,17 @@ fn timestamp() -> String {
/* ------------------------------------------------------------------------ */
/*
* Program entry point: parse options, record audio, write a WAV file.
* Program entry point: parse options, record audio, encode the output.
*
* 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.
* -f/--format, -h/--help), validates the output directory, builds the
* output filename "<dir>/<prefix>__<timestamp>.<ext>" and opens the ALSA
* capture device and the output encoder. It then installs SIGINT/SIGTERM
* handlers and runs a poll() loop that reads captured PCM frames from ALSA
* and feeds them to the encoder as they arrive, 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 output is finalized, the terminal restored and the result announced.
*
* Returns 0 on success, 1 on a runtime error, 2 on a command-line error.
*/
@ -500,6 +442,7 @@ fn run() -> i32 {
let mut output_dir = String::from(".");
let mut prefix = DEFAULT_PREFIX.to_string();
let mut device = DEFAULT_DEVICE.to_string();
let mut format = Format::Wav;
let mut duration = 0.0f64; // 0 = record until stopped
// ---- argument parsing -------------------------------------------
@ -544,6 +487,20 @@ fn run() -> i32 {
}
device = args[i].clone();
}
"-f" | "--format" => {
i += 1;
if i >= args.len() {
usage();
return 2;
}
match Format::parse(&args[i]) {
Some(f) => format = f,
None => {
usage();
return 2;
}
}
}
"-h" | "--help" => {
usage();
return 0;
@ -566,24 +523,24 @@ fn run() -> i32 {
}
let stamp = timestamp();
let path_str = format!("{}/{prefix}__{stamp}.wav", output_dir);
let path_str = format!("{}/{prefix}__{stamp}.{}", output_dir, format.extension());
if path_str.len() >= 4096 {
eprintln!("Error: output path too long");
return 1;
}
let path = Path::new(&path_str);
// ---- audio device and output file --------------------------------
// ---- audio device and output encoder -----------------------------
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());
let mut encoder = match Encoder::create(format, path, rate, channels, DEFAULT_BITS) {
Ok(e) => e,
Err(msg) => {
eprintln!("Error: cannot create output file {}: {msg}", path.display());
return 1;
}
};
@ -596,7 +553,8 @@ fn run() -> i32 {
eprintln!("Recording to {}", path.display());
eprintln!(
"Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit"
"Using ALSA device '{device}' at {rate} Hz, {channels} channel(s), {DEFAULT_BITS}-bit, {} format",
format.extension()
);
eprintln!("Press q or Ctrl+C to stop.");
@ -733,16 +691,18 @@ fn run() -> i32 {
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;
// The 32-bit RIFF size field limits WAV recordings; the
// streaming FLAC/MP3 encoders have no such limit.
if let Some(max) = encoder.max_data_bytes() {
if data_bytes + bytes as u64 > 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}");
// Feed the captured buffer to the encoder in real time.
if let Err(msg) = encoder.write_samples(&samples[..n_samples]) {
eprintln!("Error: failed to encode audio: {msg}");
result = 1;
break;
}
@ -765,14 +725,14 @@ fn run() -> i32 {
restore_terminal(&saved_term);
eprintln!("\nRecording ended.");
match wav_finalize(&mut file, data_bytes) {
match encoder.finish() {
Ok(()) => {
if result == 0 {
eprintln!("Saved {}", path.display());
}
}
Err(_) => {
eprintln!("Error: could not finalize WAV file {}", path.display());
Err(msg) => {
eprintln!("Error: could not finalize {}: {msg}", path.display());
result = 1;
}
}

206
src/mp3.rs Normal file
View file

@ -0,0 +1,206 @@
/*
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* Real-time MP3 encoding through a minimal FFI shim over the system
* libmp3lame encoder. Captured PCM buffers are handed to LAME as they
* arrive, so no intermediate WAV file is needed.
*
* Copyright 2026 Johannes Findeisen <you@hanez.org>
* Licensed under the terms of the Apache-2.0 license.
*/
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::raw::{c_int, c_uchar};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
/// Constant bitrate used for MP3 output, in kbit/s.
const BITRATE_KBPS: c_int = 192;
/// LAME quality (0 = best/slowest, 9 = worst/fastest).
const QUALITY: c_int = 2;
/// Slop added to the recommended output buffer size.
const OUTPUT_SLOP: usize = 7200;
/// Opaque `lame_global_flags`.
#[repr(C)]
struct LameGlobalFlags {
_private: [u8; 0],
}
extern "C" {
fn lame_init() -> *mut LameGlobalFlags;
fn lame_set_in_samplerate(gfp: *mut LameGlobalFlags, rate: c_int) -> c_int;
fn lame_set_num_channels(gfp: *mut LameGlobalFlags, channels: c_int) -> c_int;
fn lame_set_brate(gfp: *mut LameGlobalFlags, bitrate: c_int) -> c_int;
fn lame_set_quality(gfp: *mut LameGlobalFlags, quality: c_int) -> c_int;
fn lame_init_params(gfp: *mut LameGlobalFlags) -> c_int;
fn lame_encode_buffer_interleaved(
gfp: *mut LameGlobalFlags,
pcm: *mut i16,
num_samples: c_int,
mp3buf: *mut c_uchar,
mp3buf_size: c_int,
) -> c_int;
fn lame_encode_flush(gfp: *mut LameGlobalFlags, mp3buf: *mut c_uchar, size: c_int) -> c_int;
fn lame_close(gfp: *mut LameGlobalFlags) -> c_int;
}
/// Maximum number of samples per channel LAME accepts in a single call.
const MAX_SAMPLES_PER_CALL: usize = 100_000;
/*
* Real-time MP3 encoder.
*
* Owns a LAME encoder and the output file. Each captured buffer is
* converted to MP3 frames and appended to the file immediately; finish()
* flushes LAME's internal delay.
*/
pub struct Mp3Encoder {
gfp: *mut LameGlobalFlags,
channels: usize,
file: File,
/// Reusable encoded-output buffer (the processing buffer).
buffer: Vec<u8>,
finished: bool,
}
impl Mp3Encoder {
pub fn create(path: &Path, rate: u32, channels: u32) -> Result<Mp3Encoder, String> {
if channels != 1 && channels != 2 {
return Err(format!(
"MP3 supports only 1 or 2 channels (device negotiated {channels})"
));
}
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o644)
.open(path)
.map_err(|e| format!("cannot open output file: {e}"))?;
// SAFETY: LAME is driven through its documented C API; every return
// code is checked and the handle is closed on error and in Drop.
unsafe {
let gfp = lame_init();
if gfp.is_null() {
return Err("could not allocate the LAME encoder".to_string());
}
let ok = lame_set_in_samplerate(gfp, rate as c_int) >= 0
&& lame_set_num_channels(gfp, channels as c_int) >= 0
&& lame_set_brate(gfp, BITRATE_KBPS) >= 0
&& lame_set_quality(gfp, QUALITY) >= 0;
if !ok {
lame_close(gfp);
return Err("could not configure the LAME encoder".to_string());
}
if lame_init_params(gfp) < 0 {
lame_close(gfp);
return Err("could not initialize the LAME encoder".to_string());
}
Ok(Mp3Encoder {
gfp,
channels: channels as usize,
file,
buffer: Vec::new(),
finished: false,
})
}
}
/// Encode one buffer of interleaved S16_LE samples and append it.
pub fn write_samples(&mut self, samples: &[i16]) -> Result<(), String> {
if self.finished {
return Err("MP3 encoder already finished".to_string());
}
let total_frames = samples.len() / self.channels;
if total_frames == 0 {
return Ok(());
}
// LAME cannot process arbitrarily large buffers in one call.
let mut offset = 0;
while offset < total_frames {
let frames = (total_frames - offset).min(MAX_SAMPLES_PER_CALL);
let chunk = &samples[offset * self.channels..(offset + frames) * self.channels];
self.encode_chunk(chunk, frames)?;
offset += frames;
}
Ok(())
}
fn encode_chunk(&mut self, chunk: &[i16], frames: usize) -> Result<(), String> {
// LAME recommends 1.25 * samples + 7200 bytes of output space.
let capacity = frames + frames / 4 + OUTPUT_SLOP;
self.buffer.clear();
self.buffer.resize(capacity, 0);
// SAFETY: chunk holds frames * channels valid i16 samples and buffer
// is at least `capacity` bytes, matching LAME's requirements. LAME
// declares the input as mutable but does not modify it.
let written = unsafe {
lame_encode_buffer_interleaved(
self.gfp,
chunk.as_ptr() as *mut i16,
frames as c_int,
self.buffer.as_mut_ptr(),
capacity as c_int,
)
};
if written < 0 {
return Err(format!("MP3 encoding failed (code {written})"));
}
self.file
.write_all(&self.buffer[..written as usize])
.map_err(|e| format!("cannot write MP3 data: {e}"))
}
/// Flush LAME's remaining frames and close the file.
pub fn finish(&mut self) -> Result<(), String> {
if self.finished {
return Ok(());
}
self.finished = true;
self.buffer.clear();
self.buffer.resize(OUTPUT_SLOP, 0);
// SAFETY: buffer is at least OUTPUT_SLOP bytes and gfp is live.
let written = unsafe {
lame_encode_flush(
self.gfp,
self.buffer.as_mut_ptr(),
self.buffer.len() as c_int,
)
};
if written < 0 {
return Err(format!("could not flush the MP3 stream (code {written})"));
}
self.file
.write_all(&self.buffer[..written as usize])
.map_err(|e| format!("cannot write MP3 data: {e}"))?;
self.file
.flush()
.map_err(|e| format!("cannot flush MP3 data: {e}"))?;
Ok(())
}
}
impl Drop for Mp3Encoder {
fn drop(&mut self) {
if !self.finished {
let _ = self.finish();
}
// SAFETY: the LAME handle is closed exactly once and never reused.
unsafe {
lame_close(self.gfp);
}
}
}

108
src/wav.rs Normal file
View file

@ -0,0 +1,108 @@
/*
* This file is part of tuxrecord2.
* https://xw3.org/hanez/tuxrecord2
*
* Streaming WAV (RIFF/WAVE) writer.
*
* Copyright 2026 Johannes Findeisen <you@hanez.org>
* Licensed under the terms of the Apache-2.0 license.
*/
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
const HEADER_LENGTH: usize = 44;
/// Classic 32-bit RIFF limit.
pub const WAV_DATA_MAX: u64 = u32::MAX as u64 - 36;
/*
* Streaming writer for 16-bit little-endian PCM WAV files.
*
* The 44-byte header is written up front with zero placeholders for the
* RIFF and data chunk sizes; audio bytes are appended as they are
* captured and the two size fields are patched by finish().
*/
pub struct WavWriter {
file: File,
data_bytes: u64,
}
impl WavWriter {
/*
* Create (or truncate) the file with mode 0644 and write the RIFF /
* WAVE / fmt / data chunk layout for the given rate, channel count and
* bit depth.
*/
pub fn create(path: &Path, rate: u32, channels: u32, bits: u32) -> io::Result<WavWriter> {
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(WavWriter {
file,
data_bytes: 0,
})
}
/*
* Append one buffer of interleaved S16_LE samples.
*
* The samples are handed to the file as raw bytes, relying on the host
* being little-endian (enforced at compile time in main.rs): we
* negotiate S16_LE with ALSA, so the in-memory representation is
* already the byte order the RIFF/WAV container expects.
*/
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
let bytes = std::mem::size_of_val(samples);
// SAFETY: reinterpreting &[i16] as &[u8] is valid (same size,
// alignment of u8 is weaker) and only reads the samples.
let raw = unsafe { std::slice::from_raw_parts(samples.as_ptr() as *const u8, bytes) };
self.file.write_all(raw)?;
self.data_bytes += bytes as u64;
Ok(())
}
/*
* Patch the RIFF and data chunk sizes, then fsync and close the file.
*
* The RIFF chunk size is header + data - 8; the data chunk size is the
* number of audio bytes written.
*/
pub fn finish(&mut self) -> io::Result<()> {
self.file.seek(SeekFrom::Start(4))?;
let riff_size = (HEADER_LENGTH as u32)
.wrapping_add(self.data_bytes as u32)
.wrapping_sub(8);
self.file.write_all(&riff_size.to_le_bytes())?;
self.file.seek(SeekFrom::Start(40))?;
self.file
.write_all(&(self.data_bytes as u32).to_le_bytes())?;
self.file.sync_all()?;
Ok(())
}
}