vendor voxsplit.c into lib/ — was pointing at local dev path

This commit is contained in:
russell@unturf.com 2026-02-18 00:22:54 -05:00
parent f77d06a80b
commit be40ab432b
2 changed files with 719 additions and 1 deletions

View file

@ -18,7 +18,7 @@ log = logging.getLogger(__name__)
VOXSPLIT_SOURCE = os.environ.get(
"VOXSPLIT_SOURCE_PATH",
os.path.expanduser("~/git/uncloseai-cli/audiotoys/voxsplit.c"),
os.path.join(os.path.dirname(__file__), "voxsplit.c"),
)

View file

@ -0,0 +1,718 @@
/*
* voxsplit.c — Spectral mid-side vocal isolation tool.
*
* Separates vocals from instrumental in stereo WAV files using spectral
* mid-side decomposition with Wiener masking. No ML dependencies.
*
* Algorithm: Vocals are almost always panned center in professional mixes.
* 1. STFT both channels (Hann window, 4096 samples, 75% overlap)
* 2. Decompose each bin into mid (L+R)/2 and side (L-R)/2
* 3. Wiener mask: vocal_mask = |mid|^s / (|mid|^s + |side|^s + eps)
* 4. Vocals = mask * mid, Instrumental = original - vocals
* 5. ISTFT with overlap-add reconstruction
*
* Zero dependencies beyond libc + libm. Calls ffmpeg for MP3 encoding.
*
* Usage:
* voxsplit song.wav -o split
* voxsplit song.wav --mp3 --strength 1.5 -o karaoke
*
* Build:
* gcc -O2 -o voxsplit voxsplit.c -lm
*/
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define FFT_SIZE 4096
#define HOP_SIZE 1024 /* 75% overlap: FFT_SIZE / 4 */
#define EPSILON 1e-10
/* ── Complex Arithmetic ────────────────────────────────────────── */
typedef struct { double re, im; } cpx;
static cpx cpx_add(cpx a, cpx b) { return (cpx){a.re + b.re, a.im + b.im}; }
static cpx cpx_sub(cpx a, cpx b) { return (cpx){a.re - b.re, a.im - b.im}; }
static cpx cpx_mul(cpx a, cpx b)
{
return (cpx){a.re * b.re - a.im * b.im, a.re * b.im + a.im * b.re};
}
static cpx cpx_scale(cpx a, double s) { return (cpx){a.re * s, a.im * s}; }
static double cpx_mag2(cpx a) { return a.re * a.re + a.im * a.im; }
static double cpx_mag(cpx a) { return sqrt(cpx_mag2(a)); }
/* ── FFT — Cooley-Tukey Radix-2 In-Place ──────────────────────── */
static void bit_reverse(cpx *buf, int n)
{
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
while (j & bit) { j ^= bit; bit >>= 1; }
j ^= bit;
if (i < j) {
cpx tmp = buf[i];
buf[i] = buf[j];
buf[j] = tmp;
}
}
}
/* Forward FFT (sign = -1) or inverse FFT (sign = +1). */
static void fft_impl(cpx *buf, int n, int sign)
{
bit_reverse(buf, n);
for (int len = 2; len <= n; len <<= 1) {
double angle = sign * 2.0 * M_PI / len;
cpx wn = {cos(angle), sin(angle)};
for (int i = 0; i < n; i += len) {
cpx w = {1.0, 0.0};
for (int j = 0; j < len / 2; j++) {
cpx u = buf[i + j];
cpx v = cpx_mul(w, buf[i + j + len / 2]);
buf[i + j] = cpx_add(u, v);
buf[i + j + len / 2] = cpx_sub(u, v);
w = cpx_mul(w, wn);
}
}
}
if (sign == 1) {
for (int i = 0; i < n; i++) {
buf[i].re /= n;
buf[i].im /= n;
}
}
}
static void fft_forward(cpx *buf, int n) { fft_impl(buf, n, -1); }
static void fft_inverse(cpx *buf, int n) { fft_impl(buf, n, 1); }
/* ── Window Functions ──────────────────────────────────────────── */
static void make_hann_window(double *win, int n)
{
for (int i = 0; i < n; i++)
win[i] = 0.5 * (1.0 - cos(2.0 * M_PI * i / n));
}
/* ── WAV I/O ───────────────────────────────────────────────────── */
static uint16_t read_le16(FILE *f)
{
uint8_t b[2] = {0};
if (fread(b, 1, 2, f) < 2) return 0;
return b[0] | (b[1] << 8);
}
static uint32_t read_le32(FILE *f)
{
uint8_t b[4] = {0};
if (fread(b, 1, 4, f) < 4) return 0;
return b[0] | (b[1] << 8) | (b[2] << 16) | ((uint32_t)b[3] << 24);
}
static void write_le16(FILE *f, uint16_t v)
{
uint8_t b[2] = {v & 0xFF, (v >> 8) & 0xFF};
fwrite(b, 1, 2, f);
}
static void write_le32(FILE *f, uint32_t v)
{
uint8_t b[4] = {v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF};
fwrite(b, 1, 4, f);
}
typedef struct {
int sample_rate;
int channels;
int bits_per_sample;
int n_samples; /* total samples (all channels) */
double *left; /* normalized -1..1 */
double *right; /* normalized -1..1 */
int frames; /* samples per channel */
} WavFile;
static int wav_read(const char *path, WavFile *wav)
{
FILE *f = fopen(path, "rb");
if (!f) { fprintf(stderr, "error: cannot open '%s'\n", path); return -1; }
/* RIFF header */
char riff[4] = {0};
if (fread(riff, 1, 4, f) < 4 || memcmp(riff, "RIFF", 4) != 0) {
fprintf(stderr, "error: '%s' is not a WAV file\n", path);
fclose(f); return -1;
}
read_le32(f); /* file size */
char wave[4] = {0};
if (fread(wave, 1, 4, f) < 4 || memcmp(wave, "WAVE", 4) != 0) {
fprintf(stderr, "error: '%s' is not a WAV file\n", path);
fclose(f); return -1;
}
/* Find fmt and data chunks */
int got_fmt = 0;
uint32_t data_size = 0;
int audio_format = 0;
while (!feof(f)) {
char chunk_id[4];
if (fread(chunk_id, 1, 4, f) != 4) break;
uint32_t chunk_size = read_le32(f);
if (memcmp(chunk_id, "fmt ", 4) == 0) {
audio_format = read_le16(f);
wav->channels = read_le16(f);
wav->sample_rate = read_le32(f);
read_le32(f); /* byte rate */
read_le16(f); /* block align */
wav->bits_per_sample = read_le16(f);
/* Skip extra fmt bytes */
if (chunk_size > 16) fseek(f, chunk_size - 16, SEEK_CUR);
got_fmt = 1;
} else if (memcmp(chunk_id, "data", 4) == 0) {
data_size = chunk_size;
break; /* data follows */
} else {
fseek(f, chunk_size, SEEK_CUR);
}
}
if (!got_fmt || data_size == 0) {
fprintf(stderr, "error: '%s' missing fmt or data chunk\n", path);
fclose(f); return -1;
}
if (audio_format != 1) {
fprintf(stderr, "error: '%s' is not PCM (format=%d)\n", path, audio_format);
fclose(f); return -1;
}
if (wav->channels != 2) {
fprintf(stderr, "error: '%s' has %d channel(s) — voxsplit requires stereo input\n",
path, wav->channels);
fclose(f); return -1;
}
int bytes_per_sample = wav->bits_per_sample / 8;
int block = bytes_per_sample * wav->channels;
wav->frames = data_size / block;
wav->n_samples = wav->frames * wav->channels;
wav->left = malloc(wav->frames * sizeof(double));
wav->right = malloc(wav->frames * sizeof(double));
if (!wav->left || !wav->right) {
fprintf(stderr, "error: out of memory (%.1f MB needed)\n",
wav->frames * 2.0 * sizeof(double) / (1024 * 1024));
fclose(f); return -1;
}
/* Read and normalize samples */
uint8_t *raw = malloc(data_size);
if (!raw) {
fprintf(stderr, "error: out of memory for raw audio\n");
fclose(f); return -1;
}
if (fread(raw, 1, data_size, f) < data_size) {
fprintf(stderr, "warning: truncated audio data in '%s'\n", path);
}
fclose(f);
for (int i = 0; i < wav->frames; i++) {
double l, r;
uint8_t *p = raw + i * block;
if (bytes_per_sample == 2) {
int16_t sl = (int16_t)(p[0] | (p[1] << 8));
int16_t sr = (int16_t)(p[2] | (p[3] << 8));
l = sl / 32768.0;
r = sr / 32768.0;
} else if (bytes_per_sample == 3) {
int32_t sl = (p[0] << 8) | (p[1] << 16) | ((int32_t)(int8_t)p[2] << 24);
int32_t sr_v = (p[3] << 8) | (p[4] << 16) | ((int32_t)(int8_t)p[5] << 24);
l = sl / 2147483648.0;
r = sr_v / 2147483648.0;
} else if (bytes_per_sample == 4) {
int32_t sl = (int32_t)(p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t)p[3] << 24));
int32_t sr_v = (int32_t)(p[4] | (p[5] << 8) | (p[6] << 16) | ((uint32_t)p[7] << 24));
l = sl / 2147483648.0;
r = sr_v / 2147483648.0;
} else {
fprintf(stderr, "error: unsupported bit depth %d\n", wav->bits_per_sample);
free(raw); free(wav->left); free(wav->right);
return -1;
}
wav->left[i] = l;
wav->right[i] = r;
}
free(raw);
return 0;
}
static int wav_write_stereo(const char *path, const double *left, const double *right,
int frames, int sample_rate)
{
FILE *f = fopen(path, "wb");
if (!f) { fprintf(stderr, "error: cannot create '%s'\n", path); return -1; }
int bytes_per_sample = 2;
int channels = 2;
int data_size = frames * channels * bytes_per_sample;
/* RIFF header */
fwrite("RIFF", 1, 4, f);
write_le32(f, 36 + data_size);
fwrite("WAVE", 1, 4, f);
/* fmt chunk */
fwrite("fmt ", 1, 4, f);
write_le32(f, 16);
write_le16(f, 1); /* PCM */
write_le16(f, channels);
write_le32(f, sample_rate);
write_le32(f, sample_rate * channels * bytes_per_sample); /* byte rate */
write_le16(f, channels * bytes_per_sample); /* block align */
write_le16(f, bytes_per_sample * 8); /* bits */
/* data chunk */
fwrite("data", 1, 4, f);
write_le32(f, data_size);
for (int i = 0; i < frames; i++) {
double l = left[i];
double r = right[i];
/* Clamp */
if (l > 1.0) l = 1.0;
if (l < -1.0) l = -1.0;
if (r > 1.0) r = 1.0;
if (r < -1.0) r = -1.0;
int16_t sl = (int16_t)(l * 32767.0);
int16_t sr = (int16_t)(r * 32767.0);
write_le16(f, (uint16_t)sl);
write_le16(f, (uint16_t)sr);
}
fclose(f);
return 0;
}
static void wav_free(WavFile *wav)
{
free(wav->left);
free(wav->right);
wav->left = wav->right = NULL;
}
/* ── STFT / ISTFT ──────────────────────────────────────────────── */
/*
* Compute STFT of a mono signal.
* Returns a 2D array of complex spectra: n_frames x FFT_SIZE.
* Sets *out_frames to the number of frames.
*/
static cpx *stft(const double *signal, int n_samples, const double *window,
int *out_frames)
{
int n_frames = (n_samples - FFT_SIZE) / HOP_SIZE + 1;
if (n_frames < 1) n_frames = 1;
*out_frames = n_frames;
cpx *spectra = calloc((size_t)n_frames * FFT_SIZE, sizeof(cpx));
if (!spectra) return NULL;
for (int f = 0; f < n_frames; f++) {
int offset = f * HOP_SIZE;
cpx *frame = spectra + (size_t)f * FFT_SIZE;
for (int i = 0; i < FFT_SIZE; i++) {
int idx = offset + i;
double val = (idx < n_samples) ? signal[idx] : 0.0;
frame[i] = (cpx){val * window[i], 0.0};
}
fft_forward(frame, FFT_SIZE);
}
return spectra;
}
/*
* Inverse STFT with overlap-add.
* Takes n_frames x FFT_SIZE complex spectra, returns reconstructed signal.
* Sets *out_samples to length of output.
*/
static double *istft(const cpx *spectra, int n_frames, const double *window,
int *out_samples)
{
int out_len = (n_frames - 1) * HOP_SIZE + FFT_SIZE;
*out_samples = out_len;
double *output = calloc(out_len, sizeof(double));
double *norm = calloc(out_len, sizeof(double));
if (!output || !norm) { free(output); free(norm); return NULL; }
cpx *frame = malloc(FFT_SIZE * sizeof(cpx));
for (int f = 0; f < n_frames; f++) {
int offset = f * HOP_SIZE;
memcpy(frame, spectra + (size_t)f * FFT_SIZE, FFT_SIZE * sizeof(cpx));
fft_inverse(frame, FFT_SIZE);
for (int i = 0; i < FFT_SIZE; i++) {
int idx = offset + i;
if (idx < out_len) {
output[idx] += frame[i].re * window[i];
norm[idx] += window[i] * window[i];
}
}
}
free(frame);
/* Normalize by window sum */
for (int i = 0; i < out_len; i++) {
if (norm[i] > EPSILON)
output[i] /= norm[i];
}
free(norm);
return output;
}
/* ── Separation ────────────────────────────────────────────────── */
typedef struct {
double *vocal_left;
double *vocal_right;
double *inst_left;
double *inst_right;
int frames;
} SplitResult;
static SplitResult separate(const WavFile *wav, double strength)
{
SplitResult result = {0};
int n = wav->frames;
fprintf(stderr, " input: %d frames, %d Hz, %.1fs\n",
n, wav->sample_rate, (double)n / wav->sample_rate);
/* Analysis window */
double window[FFT_SIZE];
make_hann_window(window, FFT_SIZE);
/* STFT both channels */
fprintf(stderr, " computing STFT...\n");
int n_frames_l, n_frames_r;
cpx *spec_l = stft(wav->left, n, window, &n_frames_l);
cpx *spec_r = stft(wav->right, n, window, &n_frames_r);
int n_frames = n_frames_l < n_frames_r ? n_frames_l : n_frames_r;
fprintf(stderr, " %d STFT frames (%d bins each)\n", n_frames, FFT_SIZE);
/* Allocate vocal spectra (same dimensions) */
cpx *vocal_spec_l = calloc((size_t)n_frames * FFT_SIZE, sizeof(cpx));
cpx *vocal_spec_r = calloc((size_t)n_frames * FFT_SIZE, sizeof(cpx));
/* Mid-side decomposition + Wiener masking */
fprintf(stderr, " applying Wiener mask (strength=%.2f)...\n", strength);
for (int f = 0; f < n_frames; f++) {
cpx *fl = spec_l + (size_t)f * FFT_SIZE;
cpx *fr = spec_r + (size_t)f * FFT_SIZE;
cpx *vl = vocal_spec_l + (size_t)f * FFT_SIZE;
cpx *vr = vocal_spec_r + (size_t)f * FFT_SIZE;
for (int b = 0; b < FFT_SIZE; b++) {
/* Mid = (L+R)/2, Side = (L-R)/2 */
cpx mid = cpx_scale(cpx_add(fl[b], fr[b]), 0.5);
cpx side = cpx_scale(cpx_sub(fl[b], fr[b]), 0.5);
double mid_mag = cpx_mag(mid);
double side_mag = cpx_mag(side);
/* Wiener mask: |mid|^s / (|mid|^s + |side|^s + eps) */
double mid_pow = pow(mid_mag, strength);
double side_pow = pow(side_mag, strength);
double mask = mid_pow / (mid_pow + side_pow + EPSILON);
/* Vocal component: mask applied to mid, duplicated to both channels */
cpx vocal = cpx_scale(mid, mask);
vl[b] = vocal;
vr[b] = vocal;
}
}
/* ISTFT to recover vocal signals */
fprintf(stderr, " reconstructing vocals...\n");
int out_l, out_r;
double *vocal_left = istft(vocal_spec_l, n_frames, window, &out_l);
double *vocal_right = istft(vocal_spec_r, n_frames, window, &out_r);
free(vocal_spec_l);
free(vocal_spec_r);
free(spec_l);
free(spec_r);
/* Output length = original length */
int out_frames = n;
if (out_l < out_frames) out_frames = out_l;
if (out_r < out_frames) out_frames = out_r;
/* Instrumental = original - vocals */
fprintf(stderr, " computing instrumental...\n");
double *inst_left = malloc(out_frames * sizeof(double));
double *inst_right = malloc(out_frames * sizeof(double));
for (int i = 0; i < out_frames; i++) {
inst_left[i] = wav->left[i] - vocal_left[i];
inst_right[i] = wav->right[i] - vocal_right[i];
}
result.vocal_left = vocal_left;
result.vocal_right = vocal_right;
result.inst_left = inst_left;
result.inst_right = inst_right;
result.frames = out_frames;
return result;
}
static void split_free(SplitResult *r)
{
free(r->vocal_left);
free(r->vocal_right);
free(r->inst_left);
free(r->inst_right);
}
/* ── MP3 via ffmpeg ────────────────────────────────────────────── */
static int wav_to_mp3(const char *wav_path, const char *mp3_path)
{
char cmd[2048];
snprintf(cmd, sizeof(cmd),
"ffmpeg -y -i \"%s\" -codec:a libmp3lame -b:a 192k \"%s\" 2>/dev/null",
wav_path, mp3_path);
int ret = system(cmd);
if (ret != 0) {
fprintf(stderr, "error: ffmpeg failed (is it installed?)\n");
return -1;
}
return 0;
}
/* ── Input conversion via ffmpeg ────────────────────────────────── */
static int needs_conversion(const char *path)
{
const char *dot = strrchr(path, '.');
if (!dot) return 0;
/* WAV is native — everything else gets converted */
if (strcasecmp(dot, ".wav") == 0) return 0;
return 1;
}
/*
* Convert any audio/video file to stereo 16-bit WAV via ffmpeg.
* Writes to tmp_path. Returns 0 on success.
*/
static int convert_to_wav(const char *input, char *tmp_path, int tmp_len)
{
snprintf(tmp_path, tmp_len, "/tmp/voxsplit_%d.wav", (int)getpid());
char cmd[2048];
snprintf(cmd, sizeof(cmd),
"ffmpeg -y -i \"%s\" -vn -ac 2 -ar 44100 -sample_fmt s16 \"%s\" 2>/dev/null",
input, tmp_path);
int ret = system(cmd);
if (ret != 0) {
fprintf(stderr, "error: ffmpeg could not extract audio from '%s'\n", input);
return -1;
}
return 0;
}
/* ── CLI ───────────────────────────────────────────────────────── */
static void usage(const char *prog)
{
fprintf(stderr,
"usage: %s <input> [options]\n"
"\n"
" Spectral mid-side vocal isolation. Splits stereo audio into\n"
" vocals and instrumental tracks.\n"
"\n"
" Accepts WAV directly. MP3, MP4, FLAC, etc. converted via ffmpeg.\n"
"\n"
" --strength F separation aggressiveness (default: 1.0)\n"
" --mp3 also encode MP3 via ffmpeg\n"
" --vocals-only only output vocals\n"
" --instrumental-only only output instrumental\n"
" -o PATH output base path (default: input stem)\n"
"\n"
" Output:\n"
" %s song.wav -o split\n"
" → split-vocals.wav, split-instrumental.wav\n"
"\n"
" %s video.mp4 --mp3 --strength 1.5 -o karaoke\n"
" → extracts audio, splits, writes .wav + .mp3\n"
"\n",
prog, prog, prog);
}
/* Strip directory and extension from path to get stem */
static void get_stem(const char *path, char *stem, int stem_len)
{
/* Find last slash */
const char *base = strrchr(path, '/');
base = base ? base + 1 : path;
/* Find last dot */
const char *dot = strrchr(base, '.');
int len = dot ? (int)(dot - base) : (int)strlen(base);
if (len >= stem_len) len = stem_len - 1;
memcpy(stem, base, len);
stem[len] = '\0';
}
int main(int argc, char **argv)
{
const char *input = NULL;
const char *output = NULL;
double strength = 1.0;
int do_mp3 = 0;
int vocals_only = 0;
int instrumental_only = 0;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--strength") == 0 && i + 1 < argc) {
strength = atof(argv[++i]);
} else if (strcmp(argv[i], "--mp3") == 0) {
do_mp3 = 1;
} else if (strcmp(argv[i], "--vocals-only") == 0) {
vocals_only = 1;
} else if (strcmp(argv[i], "--instrumental-only") == 0) {
instrumental_only = 1;
} else if (strcmp(argv[i], "-o") == 0 && i + 1 < argc) {
output = argv[++i];
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
usage(argv[0]);
return 0;
} else if (argv[i][0] == '-') {
fprintf(stderr, "error: unknown flag '%s'\n", argv[i]);
usage(argv[0]);
return 1;
} else {
input = argv[i];
}
}
if (!input) {
fprintf(stderr, "error: no input file\n");
usage(argv[0]);
return 1;
}
/* Default output base = input stem */
char stem[512];
if (!output) {
get_stem(input, stem, sizeof(stem));
output = stem;
}
if (strength <= 0.0) {
fprintf(stderr, "error: --strength must be positive\n");
return 1;
}
fprintf(stderr, "voxsplit: %s → %s-{vocals,instrumental}.wav\n", input, output);
/* Convert non-WAV input via ffmpeg */
char tmp_wav[512] = {0};
int converted = 0;
const char *wav_path = input;
if (needs_conversion(input)) {
fprintf(stderr, " converting via ffmpeg...\n");
if (convert_to_wav(input, tmp_wav, sizeof(tmp_wav)) != 0) return 1;
wav_path = tmp_wav;
converted = 1;
}
/* Read input */
WavFile wav = {0};
if (wav_read(wav_path, &wav) != 0) {
if (converted) remove(tmp_wav);
return 1;
}
/* Separate */
SplitResult split = separate(&wav, strength);
if (!split.vocal_left) {
fprintf(stderr, "error: separation failed\n");
wav_free(&wav);
return 1;
}
/* Build output paths */
char voc_wav[1024], inst_wav[1024], voc_mp3[1024], inst_mp3[1024];
snprintf(voc_wav, sizeof(voc_wav), "%s-vocals.wav", output);
snprintf(inst_wav, sizeof(inst_wav), "%s-instrumental.wav", output);
snprintf(voc_mp3, sizeof(voc_mp3), "%s-vocals.mp3", output);
snprintf(inst_mp3, sizeof(inst_mp3), "%s-instrumental.mp3", output);
double duration = (double)split.frames / wav.sample_rate;
int wrote = 0;
/* Write vocals */
if (!instrumental_only) {
if (wav_write_stereo(voc_wav, split.vocal_left, split.vocal_right,
split.frames, wav.sample_rate) == 0) {
fprintf(stderr, " wrote %s (%.1fs)\n", voc_wav, duration);
wrote++;
}
if (do_mp3) {
if (wav_to_mp3(voc_wav, voc_mp3) == 0)
fprintf(stderr, " wrote %s\n", voc_mp3);
}
}
/* Write instrumental */
if (!vocals_only) {
if (wav_write_stereo(inst_wav, split.inst_left, split.inst_right,
split.frames, wav.sample_rate) == 0) {
fprintf(stderr, " wrote %s (%.1fs)\n", inst_wav, duration);
wrote++;
}
if (do_mp3) {
if (wav_to_mp3(inst_wav, inst_mp3) == 0)
fprintf(stderr, " wrote %s\n", inst_mp3);
}
}
fprintf(stderr, "done — %d file(s)\n", wrote + (do_mp3 ? wrote : 0));
/* Cleanup */
split_free(&split);
wav_free(&wav);
if (converted) remove(tmp_wav);
return 0;
}