commit efee1b8dbbc05ca0c0cb2ee13ddd88a290258398 Author: hanez Date: Wed Oct 7 03:13:41 2026 +0200 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5c003d --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Built binary +tuxrecord + +# Build artifacts +build/ +recordings/ +*.o +*.a +*.so + +# IDE ignores +.idea/ + +# Vim swap files +*.swp + +# Misc +*.wav 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..8668fd6 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +CC = gcc +CFLAGS = -std=c99 -O2 -Wall -Wextra +PKGCONF = pkg-config +ALSA_CFLAGS := $(shell $(PKGCONF) --cflags alsa 2>/dev/null) +ALSA_LIBS := $(shell $(PKGCONF) --libs alsa 2>/dev/null) +ifeq ($(strip $(ALSA_LIBS)),) +ALSA_LIBS := -lasound +endif +LDLIBS = $(ALSA_LIBS) -lm + +PREFIX ?= /usr/local +BINDIR = $(PREFIX)/bin + +BINARY = tuxrecord +SRC = tuxrecord.c + +all: $(BINARY) + +$(BINARY): $(SRC) + $(CC) $(CFLAGS) $(ALSA_CFLAGS) -o $@ $< $(LDLIBS) + +install: $(BINARY) + install -d $(DESTDIR)$(BINDIR) + install -m 755 $(BINARY) $(DESTDIR)$(BINDIR)/$(BINARY) + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/$(BINARY) + +clean: + rm -f $(BINARY) + +.PHONY: all clean install uninstall + diff --git a/README.md b/README.md new file mode 100644 index 0000000..952de35 --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# tuxrecord + +Record audio to a WAV file on **Linux** using **ALSA** as the recording source. + +`tuxrecord` is the Linux port of the former macOS-only **macrecord** tool. The +Swift/AVFoundation code was rewritten in **C99** and now uses the native ALSA +capture API (`libasound`); it builds with **GCC**. + +The port is derived from [macrecord](https://codeberg.org/armin/macrecord) +which was created by my good friend armin. + +## Requirements + +- GCC and GNU make +- ALSA development headers (`libasound2-dev` on Debian/Ubuntu, + `alsa-lib-devel` on Fedora, `alsa-lib` on Arch) + +## Usage + +```bash +# Record until q or Ctrl+C +tuxrecord + +# Record for N seconds +tuxrecord -d 30 + +# Specify output directory +tuxrecord -o /path/to/dir + +# Use a custom filename prefix +tuxrecord -p myrec + +# Use a specific ALSA capture device +tuxrecord -D hw:1,0 +``` + +## Options + +| Option | Description | +| -------------- | -------------------------------------------------------- | +| `-o ` | Output directory (default: current directory) | +| `-d ` | Stop automatically after `` seconds | +| `-p ` | Filename prefix instead of `tuxrecord` | +| `-D ` | ALSA capture device (default: `default`) | +| `-h, --help` | Show help | + +Run `arecord -l` to list available ALSA capture devices. + +## Controls + +- **q** - Stop recording and save the file +- **Ctrl+C** - Stop recording and save the file + +The file is written as `__.wav` (default prefix +`tuxrecord`). 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 +tuxrecord -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`. + +## Build + +```bash +make +# or directly: +# gcc -std=c99 -O2 -Wall -Wextra tuxrecord.c -o tuxrecord $(pkg-config --libs alsa) -lm +``` + +Install (installs to `$(PREFIX)/bin`, default `/usr/local/bin`): + +```bash +sudo make install +# or: sudo cp tuxrecord /usr/local/bin/tuxrecord +``` + +Install to a custom prefix: + +```bash +make install PREFIX=~/.local +``` + +Uninstall: + +```bash +sudo make uninstall +``` + +## License + +Apache License 2.0 — see the LICENSE file for details. + +Copyright 2026 Johannes Findeisen + diff --git a/tuxrecord.c b/tuxrecord.c new file mode 100644 index 0000000..357b56e --- /dev/null +++ b/tuxrecord.c @@ -0,0 +1,889 @@ +/* + * This file is part of tuxrecord. + * https://xw3.org/hanez/tuxrecord + * + * tuxrecord - record audio to a WAV file using ALSA + * + * A Linux port of the former macOS-only "macrecord" tool, rewritten in C99 + * and using the ALSA capture API (libasound) as the recording source. + * + * The port is derived from [macrecord](https://codeberg.org/armin/macrecord) + * which was created by my good friend armin. + * + * Copyright 2026 Johannes Findeisen + * Licensed under the terms of the Apache-2.0 license. + * https://opensource.org/license/apache-2-0 + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define DEFAULT_RATE 48000u +#define DEFAULT_CHANNELS 2u +#define DEFAULT_BITS 16u +#define DEFAULT_PREFIX "tuxrecord" +#define DEFAULT_DEVICE "default" + +#define VU_WIDTH 48 +#define VU_MIN_DB (-60.0) +#define VU_INTERVAL_S 0.05 /* redraw the VU meter at most every 50 ms */ +#define HEADER_LENGTH 44 +#define WAV_DATA_MAX ((uint64_t)UINT32_MAX - 36u) /* classic 32-bit RIFF limit */ + +static volatile sig_atomic_t g_stop = 0; + +/* + * Handle SIGINT and SIGTERM by requesting a clean stop. + * + * This is an async-signal-safe handler: it only writes to the volatile + * sig_atomic_t flag g_stop, which the recording loop in main() 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. + * + * Parameters: + * sig - the signal number that triggered the handler (unused; required + * by the sigaction handler signature). + */ +static void handle_signal(int sig) +{ + (void)sig; + g_stop = 1; +} + +/* + * Print a formatted message followed by a newline to stderr. + * + * Acts like vfprintf(stderr, fmt, ...) with a trailing newline, and + * always flushes stderr so messages appear immediately even when stderr + * is redirected (e.g. into a file or pipe). Used for all user-facing + * status and error output. + * + * Parameters: + * fmt - printf-style format string. + * ... - arguments matching the conversion specifiers in fmt. + */ +static void logmsg(const char *fmt, ...) +{ + va_list ap; + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fputc('\n', stderr); + fflush(stderr); +} + +/* + * 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). The + * text is emitted through a single printf with adjacent string literals; + * the final argument list fills in the default prefix and device names. + */ +static void usage(void) +{ + printf( + "tuxrecord - record audio to a WAV file using ALSA\n" + "\n" + "USAGE\n" + " tuxrecord [options]\n" + "\n" + "OPTIONS\n" + " -o Output directory (default: current directory)\n" + " -d Stop automatically after seconds (default: until q or Ctrl+C)\n" + " -p Filename prefix instead of \"%s\" (default: %s)\n" + " -D ALSA capture device (default: \"%s\")\n" + " -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", + DEFAULT_PREFIX, DEFAULT_PREFIX, DEFAULT_DEVICE); +} + +/* ------------------------------------------------------------------------ */ +/* VU meter */ +/* ------------------------------------------------------------------------ */ + +/* + * Return the current time in seconds as a double. + * + * Uses CLOCK_MONOTONIC, so the value is unaffected by wall-clock + * adjustments (NTP, manual time changes) and cannot go backwards during + * a recording. Used to measure elapsed recording time for the -d option + * and to throttle VU meter redraws to VU_INTERVAL_S. + * + * Returns: + * current monotonic time in seconds. + */ +static double now_s(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +/* + * 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 int16_t + * (32768.0) so a full-scale sine wave reads near 0.7. + * + * Parameters: + * samples - buffer of interleaved S16_LE PCM samples. + * frames - number of sample frames (each frame holds 'channels' + * samples). + * channels - samples per frame (1 = mono, 2 = stereo). + * left - output: RMS level of the left channel (0 if empty). + * right - output: RMS level of the right channel, or the left value + * for mono input / empty input. + */ +static void rms_levels(const int16_t *samples, size_t frames, unsigned channels, + double *left, double *right) +{ + double sum_l = 0.0, sum_r = 0.0; + size_t n_l = 0, n_r = 0; + + for (size_t f = 0; f < frames; f++) { + double v = (double)samples[f * channels]; + sum_l += v * v; + n_l++; + if (channels > 1) { + v = (double)samples[f * channels + 1]; + sum_r += v * v; + n_r++; + } + } + + *left = n_l > 0 ? sqrt(sum_l / (double)n_l) / 32768.0 : 0.0; + *right = channels > 1 && n_r > 0 ? sqrt(sum_r / (double)n_r) / 32768.0 : *left; +} + +/* + * 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. + * + * Parameters: + * level - linear amplitude between 0.0 and 1.0. + * + * Returns: + * the level in dB, clamped to [VU_MIN_DB, 0.0]. + */ +static double to_db(double level) +{ + double db; + if (level <= 0.0) + return VU_MIN_DB; + db = 20.0 * log10(level); + if (db < VU_MIN_DB) + db = VU_MIN_DB; + if (db > 0.0) + db = 0.0; + return db; +} + +/* + * Render one horizontal VU meter line (label + 48 segments) into a buffer. + * + * 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. Stops early if the buffer could overflow. + * + * Parameters: + * db - level in dB (typically clamped by to_db()). + * label - short label printed before the bar, e.g. "L" or "R". + * out - destination buffer to fill. + * outsz - size of the destination buffer in bytes. + */ +static void vu_meter_line(double db, const char *label, char *out, size_t outsz) +{ + double fraction = (db - VU_MIN_DB) / (0.0 - VU_MIN_DB); + int filled, n; + + if (fraction < 0.0) + fraction = 0.0; + else if (fraction > 1.0) + fraction = 1.0; + + filled = (int)(fraction * VU_WIDTH); + + { + double pct = fraction * 100.0; + const char *fg = pct > 85.0 ? "\x1b[31m" + : pct > 60.0 ? "\x1b[33m" + : "\x1b[32m"; + n = snprintf(out, outsz, "%s ", label); + for (int k = 0; k < VU_WIDTH; k++) { + if (n < 0 || (size_t)n >= outsz - 64) + break; + if (k < filled) + n += snprintf(out + n, outsz - (size_t)n, "%s\u2588\x1b[0m", fg); + else + n += snprintf(out + n, outsz - (size_t)n, "\x1b[32m\u2591\x1b[0m"); + } + } +} + +/* + * Draw the left and right VU meter lines to stderr. + * + * Renders both channels into local buffers 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. stderr is flushed so the + * meter updates promptly on a TTY. + * + * Parameters: + * db_l - left channel level in dB. + * db_r - right channel level in dB. + * first - true for the initial (empty) draw before recording starts, + * false for subsequent in-place redraws. + */ +static void draw_meter(double db_l, double db_r, bool first) +{ + char l[1024], r[1024]; + + vu_meter_line(db_l, "L", l, sizeof l); + vu_meter_line(db_r, "R", r, sizeof r); + + if (!first) + fputs("\x1b[2A", stderr); /* move cursor up two lines, then redraw */ + fprintf(stderr, "%s\n%s\n", l, r); + fflush(stderr); +} + +/* ------------------------------------------------------------------------ */ +/* WAV file writer */ +/* ------------------------------------------------------------------------ */ + +/* + * Write a 32-bit unsigned integer to a buffer in little-endian order. + * + * Writes least-significant byte first, as required by the RIFF/WAV + * container format. + * + * Parameters: + * p - buffer to write into (must hold at least 4 bytes). + * v - value to store. + */ +static void put_u32_le(unsigned char *p, uint32_t v) +{ + p[0] = (unsigned char)(v & 0xff); + p[1] = (unsigned char)((v >> 8) & 0xff); + p[2] = (unsigned char)((v >> 16) & 0xff); + p[3] = (unsigned char)((v >> 24) & 0xff); +} + +/* + * Write a 16-bit unsigned integer to a buffer in little-endian order. + * + * Writes least-significant byte first, as required by the RIFF/WAV + * container format. + * + * Parameters: + * p - buffer to write into (must hold at least 2 bytes). + * v - value to store. + */ +static void put_u16_le(unsigned char *p, uint16_t v) +{ + p[0] = (unsigned char)(v & 0xff); + p[1] = (unsigned char)((v >> 8) & 0xff); +} + +/* + * Write the whole buffer to a file descriptor, retrying on short writes. + * + * Loops until 'len' bytes have been written or an unrecoverable error + * occurs. Partial writes advance the buffer pointer and remaining count; + * EINTR just retries. Guarantees that the entire 44-byte WAV header and + * the size patch are written in one go even on slow filesystems. + * + * Parameters: + * fd - file descriptor to write to. + * buf - data to write. + * len - number of bytes to write. + * + * Returns: + * 0 on success, -1 on a write error (errno preserved). + */ +static int write_all(int fd, const void *buf, size_t len) +{ + const unsigned char *p = buf; + while (len > 0) { + ssize_t w = write(fd, p, len); + if (w < 0) { + if (errno == EINTR) + continue; + return -1; + } + p += w; + len -= (size_t)w; + } + return 0; +} + +/* + * 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. + * + * Parameters: + * path - filesystem path of the file to create. + * rate - sample rate in Hz to record in the header. + * channels - number of channels to record in the header. + * bits - bits per sample to record in the header. + * + * Returns: + * the new file descriptor on success, or -1 on failure. + */ +static int wav_open(const char *path, unsigned rate, unsigned channels, unsigned bits) +{ + unsigned char hdr[HEADER_LENGTH]; + uint32_t byte_rate = (uint32_t)rate * channels * bits / 8; + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); + + if (fd < 0) + return -1; + + memcpy(hdr + 0, "RIFF", 4); + put_u32_le(hdr + 4, 0); /* RIFF chunk size */ + memcpy(hdr + 8, "WAVE", 4); + memcpy(hdr + 12, "fmt ", 4); + put_u32_le(hdr + 16, 16); /* fmt chunk size */ + put_u16_le(hdr + 20, 1); /* PCM */ + put_u16_le(hdr + 22, (uint16_t)channels); + put_u32_le(hdr + 24, rate); + put_u32_le(hdr + 28, byte_rate); + put_u16_le(hdr + 32, (uint16_t)(channels * bits / 8)); + put_u16_le(hdr + 34, (uint16_t)bits); + memcpy(hdr + 36, "data", 4); + put_u32_le(hdr + 40, 0); /* data chunk size */ + + if (write_all(fd, hdr, HEADER_LENGTH) != 0) { + close(fd); + return -1; + } + return fd; +} + +/* + * Patch the header chunk sizes and close 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 and closes the file. + * Called once after the recording loop with the total bytes accumulated. + * + * Parameters: + * fd - file descriptor from wav_open(). + * data_bytes - total number of audio data bytes that were recorded. + * + * Returns: + * 0 on success, -1 on failure. The descriptor is always closed. + */ +static int wav_finalize(int fd, uint64_t data_bytes) +{ + unsigned char b[4]; + + if (lseek(fd, 4, SEEK_SET) != 4) + goto fail; + put_u32_le(b, (uint32_t)HEADER_LENGTH + (uint32_t)data_bytes - 8u); + if (write_all(fd, b, 4) != 0) + goto fail; + + if (lseek(fd, 40, SEEK_SET) != 40) + goto fail; + put_u32_le(b, (uint32_t)data_bytes); + if (write_all(fd, b, 4) != 0) + goto fail; + + fsync(fd); + close(fd); + return 0; + +fail: + close(fd); + return -1; +} + +/* ------------------------------------------------------------------------ */ +/* ALSA capture */ +/* ------------------------------------------------------------------------ */ + +/* + * 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 NULL is returned. + * + * Parameters: + * device - ALSA PCM name, e.g. "default" or "hw:Loopback,1". + * rate_out - in: requested sample rate in Hz; out: actual rate. + * channels_out - in: requested channel count; out: actual count. + * period_out - out: period size in frames actually used. + * + * Returns: + * the open PCM handle on success, or NULL on failure. + */ +static snd_pcm_t *open_capture(const char *device, unsigned *rate_out, + unsigned *channels_out, snd_pcm_uframes_t *period_out) +{ + snd_pcm_t *pcm = NULL; + snd_pcm_hw_params_t *hw; + unsigned rate = *rate_out; + unsigned channels = *channels_out; + snd_pcm_uframes_t period; + int err; + + err = snd_pcm_open(&pcm, device, SND_PCM_STREAM_CAPTURE, 0); + if (err < 0) { + logmsg("Error: cannot open ALSA capture device '%s': %s", device, + snd_strerror(err)); + logmsg("Run 'arecord -l' to list available capture devices."); + return NULL; + } + + snd_pcm_hw_params_alloca(&hw); + + if ((err = snd_pcm_hw_params_any(pcm, hw)) < 0) + goto fail; + if ((err = snd_pcm_hw_params_set_access(pcm, hw, + SND_PCM_ACCESS_RW_INTERLEAVED)) < 0) + goto fail; + if ((err = snd_pcm_hw_params_set_format(pcm, hw, + SND_PCM_FORMAT_S16_LE)) < 0) { + logmsg("Error: device '%s' does not support 16-bit signed PCM; " + "try 'plug:%s'", device, device); + snd_pcm_close(pcm); + return NULL; + } + if ((err = snd_pcm_hw_params_set_channels_near(pcm, hw, &channels)) < 0) + goto fail; + if ((err = snd_pcm_hw_params_set_rate_near(pcm, hw, &rate, NULL)) < 0) + goto fail; + if ((err = snd_pcm_hw_params(pcm, hw)) < 0) + goto fail; + + /* Read back the values that were actually negotiated. */ + if ((err = snd_pcm_hw_params_get_rate(hw, &rate, NULL)) < 0) + goto fail; + if ((err = snd_pcm_hw_params_get_channels(hw, &channels)) < 0) + goto fail; + period = 1024; + if ((err = snd_pcm_hw_params_get_period_size(hw, &period, NULL)) < 0) + goto fail; + if (period == 0) + period = 1024; + + *rate_out = rate; + *channels_out = channels; + *period_out = period; + return pcm; + +fail: + logmsg("Error: failed to configure ALSA capture on '%s': %s", device, + snd_strerror(err)); + snd_pcm_close(pcm); + return NULL; +} + +/* ------------------------------------------------------------------------ */ +/* Terminal */ +/* ------------------------------------------------------------------------ */ + +static struct termios g_old_term; +static bool g_raw_mode = false; + +/* + * Switch stdin into raw (non-canonical, no-echo) terminal mode. + * + * Only active when stdin is a TTY; otherwise it returns false 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: + * true if raw mode was enabled (restore_terminal() must be called + * later), false if stdin is not a TTY or the change failed. + */ +static bool enable_raw_mode(void) +{ + struct termios raw; + + if (!isatty(STDIN_FILENO)) + return false; + if (tcgetattr(STDIN_FILENO, &g_old_term) != 0) + return false; + + raw = g_old_term; + raw.c_lflag &= (tcflag_t)~(ICANON | ECHO); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + /* TCSANOW: don't flush a key that was already typed before raw mode */ + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) + return false; + + g_raw_mode = true; + return true; +} + +/* + * 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, + * and clears the raw-mode flag so it runs at most once. Safe to call + * even if raw mode was never enabled. + */ +static void restore_terminal(void) +{ + if (g_raw_mode) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_old_term); + g_raw_mode = false; + } +} + +/* ------------------------------------------------------------------------ */ +/* 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_S. 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 usage error. + */ +int main(int argc, char **argv) +{ + const char *output_dir = "."; + const char *prefix = DEFAULT_PREFIX; + const char *device = DEFAULT_DEVICE; + double duration = 0.0; /* 0 = record until stopped */ + struct stat st; + char path[4096]; + char stamp[32]; + struct tm ltm; + time_t t; + snd_pcm_t *pcm; + snd_pcm_uframes_t period; + unsigned rate = DEFAULT_RATE; + unsigned channels = DEFAULT_CHANNELS; + snd_pcm_sframes_t got; + struct sigaction sa; + int fd, i, result = 0; + bool stderr_tty, use_stdin; + uint64_t data_bytes = 0; + double start_t, last_draw; + + /* ---- argument parsing ------------------------------------------- */ + i = 1; + while (i < argc) { + const char *a = argv[i]; + if (strcmp(a, "-o") == 0) { + if (++i >= argc) { + usage(); + return 2; + } + output_dir = argv[i]; + } else if (strcmp(a, "-d") == 0) { + char *end = NULL; + double d; + if (++i >= argc) { + usage(); + return 2; + } + d = strtod(argv[i], &end); + if (end == argv[i] || *end != '\0' || !isfinite(d) || d <= 0.0) { + usage(); + return 2; + } + duration = d; + } else if (strcmp(a, "-p") == 0) { + if (++i >= argc || argv[i][0] == '\0') { + usage(); + return 2; + } + prefix = argv[i]; + } else if (strcmp(a, "-D") == 0) { + if (++i >= argc || argv[i][0] == '\0') { + usage(); + return 2; + } + device = argv[i]; + } else if (strcmp(a, "-h") == 0 || strcmp(a, "--help") == 0) { + usage(); + return 0; + } else { + usage(); + return 2; + } + i++; + } + + /* ---- output directory and filename ------------------------------- */ + if (stat(output_dir, &st) != 0 || !S_ISDIR(st.st_mode)) { + fprintf(stderr, "Error: output directory does not exist: %s\n", + output_dir); + return 1; + } + + t = time(NULL); + localtime_r(&t, <m); + if (strftime(stamp, sizeof stamp, "%Y%m%d_%H%M%S", <m) == 0) + strcpy(stamp, "00000000_000000"); + + { + int need = snprintf(path, sizeof path, "%s/%s__%s.wav", + output_dir, prefix, stamp); + if (need < 0 || need >= (int)sizeof path) { + fputs("Error: output path too long\n", stderr); + return 1; + } + } + + /* ---- audio device and output file -------------------------------- */ + pcm = open_capture(device, &rate, &channels, &period); + if (pcm == NULL) + return 1; + + fd = wav_open(path, rate, channels, DEFAULT_BITS); + if (fd < 0) { + fprintf(stderr, "Error: cannot open output file: %s: %s\n", + path, strerror(errno)); + snd_pcm_close(pcm); + return 1; + } + + /* ---- signals ------------------------------------------------------ */ + memset(&sa, 0, sizeof sa); + sa.sa_handler = handle_signal; + sigaction(SIGINT, &sa, NULL); + sigaction(SIGTERM, &sa, NULL); + + stderr_tty = isatty(STDERR_FILENO); + use_stdin = isatty(STDIN_FILENO); + + logmsg("Recording to %s", path); + logmsg("Using ALSA device '%s' at %u Hz, %u channel(s), %d-bit", + device, rate, channels, DEFAULT_BITS); + logmsg("Press q or Ctrl+C to stop."); + + if (snd_pcm_start(pcm) < 0) { + logmsg("Error: could not start capture on '%s'", device); + close(fd); + snd_pcm_close(pcm); + return 1; + } + + fputs("Recording started.\n", stderr); + if (stderr_tty) + draw_meter(VU_MIN_DB, VU_MIN_DB, true); + fflush(stderr); + + /* ---- recording loop ----------------------------------------------- */ + { + size_t frames_per_read = (size_t)period; + int16_t *samples = malloc(frames_per_read * channels * sizeof(int16_t)); + struct pollfd *pfds; + int pcm_poll_count = snd_pcm_poll_descriptors_count(pcm); + int nfds; + + if (samples == NULL) { + logmsg("Error: out of memory"); + restore_terminal(); + wav_finalize(fd, data_bytes); + snd_pcm_close(pcm); + return 1; + } + + if (pcm_poll_count < 1) + pcm_poll_count = 1; + nfds = pcm_poll_count + (use_stdin ? 1 : 0); + pfds = calloc((size_t)nfds, sizeof *pfds); + if (pfds == NULL) { + logmsg("Error: out of memory"); + free(samples); + restore_terminal(); + wav_finalize(fd, data_bytes); + snd_pcm_close(pcm); + return 1; + } + + snd_pcm_poll_descriptors(pcm, pfds, (unsigned)pcm_poll_count); + if (use_stdin) { + pfds[pcm_poll_count].fd = STDIN_FILENO; + pfds[pcm_poll_count].events = POLLIN; + (void)enable_raw_mode(); + } + + start_t = now_s(); + last_draw = start_t; + + while (!g_stop) { + int pr; + int timeout = -1; + + /* automatic stop timer */ + if (duration > 0.0) { + double remaining = duration - (now_s() - start_t); + if (remaining <= 0.0) + break; + timeout = (int)(remaining * 1000.0); + if (timeout < 1) + timeout = 1; + } + + pr = poll(pfds, (nfds_t)nfds, timeout); + if (pr < 0) { + if (errno == EINTR) + continue; /* interrupted by a signal */ + logmsg("Error: poll failed: %s", strerror(errno)); + result = 1; + break; + } + if (pr == 0) + continue; /* timed out; re-check timer */ + + /* keyboard input (raw mode, 'q' to quit) */ + if (use_stdin && (pfds[pcm_poll_count].revents & POLLIN)) { + char buf[16]; + ssize_t n = read(STDIN_FILENO, buf, sizeof buf); + for (ssize_t k = 0; n > 0 && k < n; k++) { + if (buf[k] == 'q' || buf[k] == 'Q') { + logmsg("Quit key received, stopping..."); + g_stop = 1; + } + } + } + + if (g_stop) + break; + + /* capture audio */ + if (pcm_poll_count > 0 && nfds - (use_stdin ? 1 : 0) > 0) { + unsigned short pcm_rev = 0; + snd_pcm_poll_descriptors_revents(pcm, pfds, + (unsigned)pcm_poll_count, + &pcm_rev); + if (!(pcm_rev & (POLLIN | POLLERR))) + continue; + + got = snd_pcm_readi(pcm, samples, period); + if (got == -EINTR) { + continue; + } else if (got == -EPIPE || got == -ESTRPIPE) { + logmsg("Capture underrun/device suspended, resuming..."); + if (snd_pcm_recover(pcm, (int)got, 1) < 0) { + logmsg("Error: could not recover the ALSA stream"); + result = 1; + break; + } + continue; + } else if (got < 0) { + logmsg("Error: failed to read audio: %s", + snd_strerror((int)got)); + result = 1; + break; + } else if (got > 0) { + size_t bytes = (size_t)got * channels * sizeof(int16_t); + double meter_now; + + if (data_bytes + bytes > WAV_DATA_MAX) { + logmsg("Warning: 4 GiB WAV limit reached, stopping."); + break; + } + if (write_all(fd, samples, bytes) != 0) { + logmsg("Error: failed to write audio: %s", + strerror(errno)); + result = 1; + break; + } + data_bytes += bytes; + + /* VU meter, redrawn at most every 50 ms */ + meter_now = now_s(); + if (stderr_tty && meter_now - last_draw >= VU_INTERVAL_S) { + double l, r; + rms_levels(samples, (size_t)got, channels, &l, &r); + draw_meter(to_db(l), to_db(r), false); + last_draw = meter_now; + } + } + } + } + + free(pfds); + free(samples); + } + + /* ---- wrap up ------------------------------------------------------ */ + restore_terminal(); + logmsg("\nRecording ended."); + + if (wav_finalize(fd, data_bytes) != 0) { + logmsg("Error: could not finalize WAV file %s", path); + result = 1; + } else if (result == 0) { + logmsg("Saved %s", path); + } + + snd_pcm_close(pcm); + return result; +} +