Initial commit.
This commit is contained in:
commit
efee1b8dbb
5 changed files with 1068 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Built binary
|
||||
tuxrecord
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
recordings/
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# IDE ignores
|
||||
.idea/
|
||||
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
||||
# Misc
|
||||
*.wav
|
||||
13
LICENSE
Normal file
13
LICENSE
Normal 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.
|
||||
33
Makefile
Normal file
33
Makefile
Normal file
|
|
@ -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
|
||||
|
||||
115
README.md
Normal file
115
README.md
Normal file
|
|
@ -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 <dir>` | Output directory (default: current directory) |
|
||||
| `-d <secs>` | Stop automatically after `<secs>` seconds |
|
||||
| `-p <prefix>` | Filename prefix instead of `tuxrecord` |
|
||||
| `-D <device>` | 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 `<prefix>__<timestamp>.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
|
||||
|
||||
889
tuxrecord.c
Normal file
889
tuxrecord.c
Normal file
|
|
@ -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 <you@hanez.org>
|
||||
* Licensed under the terms of the Apache-2.0 license.
|
||||
* https://opensource.org/license/apache-2-0
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <alsa/asoundlib.h>
|
||||
#include <alloca.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
#include <termios.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#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 <dir> Output directory (default: current directory)\n"
|
||||
" -d <secs> Stop automatically after <secs> seconds (default: until q or Ctrl+C)\n"
|
||||
" -p <prefix> Filename prefix instead of \"%s\" (default: %s)\n"
|
||||
" -D <device> ALSA capture device (default: \"%s\")\n"
|
||||
" -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",
|
||||
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:<device>' 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
|
||||
* "<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_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;
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue