+ New

utensil

Public
e6e77dd4eef2c5e9df336c2aa629096dc3ffa143
diff --git a/examples/lstm_kws.c b/examples/lstm_kws.c
new file mode 100644
index 0000000..fcf520f
--- /dev/null
+++ b/examples/lstm_kws.c
@@ -0,0 +1,158 @@
+#include "utensil.h"
+
+#include "speech_commands_loader.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+// Keyword spotting on Google Speech Commands: a single-layer LSTM reads a
+// clip's log-mel spectrogram one frame at a time, and a linear head classifies
+// the final hidden state into one of 12 classes (10 core command words plus
+// "_unknown_" and "_silence_"). This is the LSTM-primitive analogue of
+// mnist_mlp.c -- a small, complete reference to build a real keyword spotter
+// (e.g. microwakeword-style single-wakeword detection) from.
+
+#define HIDDEN 64
+#define BATCH 32
+
+// x_seq is [T,B,NMEL]; a dataset's samples are stored [n,T,NMEL], so a batch
+// has to be transposed on the way in.
+static ut_tensor* make_batch(sc_dataset_t* d, const int* idx, int off, int B, int* labels) {
+  float* bx = malloc((size_t)SC_T * B * SC_NMEL * sizeof(float));
+  for (int i = 0; i < B; i++) {
+    int ii = idx[off + i];
+    labels[i] = d->labels[ii];
+    const float* src = d->feats + (size_t)ii * SC_T * SC_NMEL;
+    for (int t = 0; t < SC_T; t++)
+      memcpy(bx + ((size_t)t * B + i) * SC_NMEL, src + (size_t)t * SC_NMEL,
+             SC_NMEL * sizeof(float));
+  }
+  ut_tensor* x = ut_from_data(3, (int[]){SC_T, B, SC_NMEL}, bx, UT_CPU);
+  free(bx);
+  return x;
+}
+
+// runs the LSTM over the whole sequence and classifies the final hidden
+// state; cache may be NULL for an inference-only pass (see ut_lstm_forward_seq)
+static ut_tensor* forward(ut_lstm* lstm, ut_linear* fc, ut_tensor* x, ut_lstm_seq_cache* cache,
+                          ut_tensor** h_n_out) {
+  ut_tensor *h_n, *c_n;
+  ut_tensor* h_seq = ut_lstm_forward_seq(lstm, x, NULL, NULL, cache, &h_n, &c_n);
+  ut_free_all(h_seq, c_n);
+  ut_tensor* logits = ut_linear_forward(fc, h_n);
+  if (h_n_out)
+    *h_n_out = h_n;
+  else
+    ut_free(h_n);
+  return logits;
+}
+
+static int eval_split(ut_lstm* lstm, ut_linear* fc, sc_dataset_t* d, int cm[SC_NCLASS][SC_NCLASS]) {
+  int ok = 0;
+  for (int i = 0; i < d->n; i += BATCH) {
+    int nb = (i + BATCH <= d->n) ? BATCH : (d->n - i);
+    int all_idx[BATCH];
+    for (int j = 0; j < nb; j++) all_idx[j] = i + j;
+    int bl[BATCH];
+    ut_tensor* x = make_batch(d, all_idx, 0, nb, bl);
+    ut_tensor* logits = forward(lstm, fc, x, NULL, NULL);
+    ut_sync_cpu(logits);
+    for (int j = 0; j < nb; j++) {
+      int pred = sc_argmax(logits, j);
+      if (pred == bl[j]) ok++;
+      if (cm) cm[bl[j]][pred]++;
+    }
+    ut_free_all(x, logits);
+  }
+  return ok;
+}
+
+int main(void) {
+  srand(42);
+  ut_dev dev = UT_CPU;  // LSTM gate math is CPU-only; see utensil.h
+
+  printf("Loading Speech Commands (this scans the dataset once per split)...\n");
+  sc_dataset_t train = sc_load_split("speech_commands", SC_TRAIN);
+  sc_dataset_t val = sc_load_split("speech_commands", SC_VAL);
+  sc_dataset_t test = sc_load_split("speech_commands", SC_TEST);
+  printf("train: %d  val: %d  test: %d\n\n", train.n, val.n, test.n);
+
+  ut_lstm lstm = ut_lstm_alloc(SC_NMEL, HIDDEN, dev);
+  ut_linear fc = ut_linear_alloc(HIDDEN, SC_NCLASS, true, dev);
+  ut_adam opt = ut_adam_alloc((ut_tensor*[]){lstm.W_ih, lstm.W_hh, lstm.bias, fc.weight, fc.bias},
+                              5, 1e-3f, 0.9f, 0.999f, 1e-8f, 0.f);
+
+  int B = BATCH, epochs = 15, batches = train.n / B;
+  ut_tensor* grad_logits = ut_alloc(2, (int[]){B, SC_NCLASS}, dev);
+  int* idx = malloc((size_t)train.n * sizeof(int));
+  for (int i = 0; i < train.n; i++) idx[i] = i;
+
+  for (int ep = 0; ep < epochs; ep++) {
+    sc_shuffle(idx, train.n);
+    clock_t t0 = clock();
+    float loss_sum = 0;
+    int correct = 0;
+
+    for (int bi = 0; bi < batches; bi++) {
+      int bl[BATCH];
+      ut_tensor* x = make_batch(&train, idx, bi * B, B, bl);
+
+      ut_lstm_seq_cache cache;
+      ut_tensor* h_n;
+      ut_tensor* logits = forward(&lstm, &fc, x, &cache, &h_n);
+
+      float loss = ut_cross_entropy(logits, bl, grad_logits);
+      ut_sync_cpu(logits);
+      for (int i = 0; i < B; i++)
+        if (sc_argmax(logits, i) == bl[i]) correct++;
+
+      // grad w.r.t. h flows in only at the last timestep -- every other
+      // slice of grad_h_seq stays zero.
+      ut_tensor* dh_n = ut_linear_backward(&fc, h_n, grad_logits, opt.grads[3], opt.grads[4]);
+      ut_sync_cpu(dh_n);
+      ut_tensor* grad_h_seq = ut_alloc(3, (int[]){SC_T, B, HIDDEN}, dev);
+      memcpy(grad_h_seq->data + (size_t)(SC_T - 1) * B * HIDDEN, dh_n->data,
+             (size_t)B * HIDDEN * sizeof(float));
+      ut_tensor* dx_seq =
+          ut_lstm_backward_seq(&lstm, &cache, grad_h_seq, opt.grads[0], opt.grads[1], opt.grads[2]);
+
+      ut_adam_step(&opt, 5.0f);
+      loss_sum += loss;
+
+      ut_lstm_seq_cache_free(&cache);
+      ut_free_all(x, logits, h_n, dh_n, grad_h_seq, dx_seq);
+    }
+
+    float secs = (float)(clock() - t0) / (float)CLOCKS_PER_SEC;
+    int val_ok = eval_split(&lstm, &fc, &val, NULL);
+
+    printf("epoch %2d  loss %7.4f  train %5.1f%%  val %5.1f%%  %5.1fs\n", ep + 1,
+           loss_sum / (float)batches, 100.f * (float)correct / (float)(batches * B),
+           100.f * (float)val_ok / (float)val.n, secs);
+  }
+
+  int cm[SC_NCLASS][SC_NCLASS] = {0};
+  int test_ok = eval_split(&lstm, &fc, &test, cm);
+  printf("\ntest accuracy: %5.1f%%\n\n", 100.f * (float)test_ok / (float)test.n);
+
+  printf("Confusion matrix:\n%14s", "");
+  for (int j = 0; j < SC_NCLASS; j++) printf("%8s", SC_CLASSES[j]);
+  printf("\n");
+  for (int r = 0; r < SC_NCLASS; r++) {
+    printf("%14s", SC_CLASSES[r]);
+    for (int c = 0; c < SC_NCLASS; c++) printf("%8d", cm[r][c]);
+    printf("\n");
+  }
+
+  free(idx);
+  ut_free(grad_logits);
+  ut_adam_free(&opt);
+  ut_lstm_free(&lstm);
+  ut_linear_free(&fc);
+  sc_dataset_free(&train);
+  sc_dataset_free(&val);
+  sc_dataset_free(&test);
+  return 0;
+}
diff --git a/examples/speech_commands_loader.h b/examples/speech_commands_loader.h
new file mode 100644
index 0000000..9d6cb45
--- /dev/null
+++ b/examples/speech_commands_loader.h
@@ -0,0 +1,420 @@
+#ifndef SPEECH_COMMANDS_LOADER_H
+#define SPEECH_COMMANDS_LOADER_H
+
+// Loader + feature extraction for the Google Speech Commands dataset
+// (v0.02, http://download.tensorflow.org/data/speech_commands_v0.02.tar.gz).
+// Each utterance is a ~1s, 16kHz, mono, 16-bit PCM WAV file living in a
+// directory named after the word spoken. This header turns that directory
+// tree into fixed-length [T, NMEL] log-mel spectrograms plus integer labels,
+// the same role mnist_loader.h/cifar10_loader.h play for their datasets.
+//
+// Classification task: the 10 core command words, plus "_unknown_" (a
+// subsample of the other ~25 recorded words) and "_silence_" (crops of the
+// bundled background noise clips) -- the standard 12-way Speech Commands
+// benchmark. Include this after utensil.h and dirent.h is available (POSIX;
+// this loader is example-only code, not part of the library itself).
+
+#include <dirent.h>
+#include <math.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define SC_SR 16000
+#define SC_FRAME 400  // 25ms analysis window
+#define SC_HOP 160    // 10ms hop
+#define SC_NFFT 512   // next power of 2 >= SC_FRAME
+#define SC_NMEL 40
+#define SC_CLIP_LEN SC_SR                       // every clip is padded/truncated to 1s
+#define SC_T ((SC_CLIP_LEN - SC_FRAME) / SC_HOP + 1)  // frames per clip
+#define SC_NCLASS 12
+#define SC_PI 3.14159265358979323846f
+
+static const char* SC_CLASSES[SC_NCLASS] = {"yes",  "no",   "up",         "down",
+                                             "left", "right", "on",        "off",
+                                             "stop", "go",   "_unknown_", "_silence_"};
+
+// caps keep feature extraction and the resulting in-memory dataset bounded
+// regardless of how many raw files a class actually has
+#define SC_MAX_TRAIN_PER_CLASS 2000
+#define SC_MAX_VAL_PER_CLASS 300
+#define SC_MAX_TEST_PER_CLASS 400
+
+typedef struct {
+  int n;
+  float* feats;     // [n, SC_T, SC_NMEL]
+  uint8_t* labels;  // [n], index into SC_CLASSES
+} sc_dataset_t;
+
+// =========================================================
+// WAV reading
+// =========================================================
+
+// Reads a 16-bit PCM mono WAV file into [-1,1] floats. Returns NULL (and
+// frees any partial buffer) if the file is missing or not in that format --
+// every clip in the dataset is, so callers just skip a NULL return.
+static float* sc_wav_read(const char* path, int* n) {
+  FILE* f = fopen(path, "rb");
+  if (!f) return NULL;
+  char tag[4];
+  uint32_t sz;
+  if (fread(tag, 1, 4, f) != 4 || memcmp(tag, "RIFF", 4) != 0) { fclose(f); return NULL; }
+  fread(&sz, 4, 1, f);
+  if (fread(tag, 1, 4, f) != 4 || memcmp(tag, "WAVE", 4) != 0) { fclose(f); return NULL; }
+
+  int channels = 0, bits = 0;
+  uint32_t rate = 0;
+  int16_t* pcm = NULL;
+  uint32_t nsamp = 0;
+
+  while (fread(tag, 1, 4, f) == 4 && fread(&sz, 4, 1, f) == 1) {
+    if (memcmp(tag, "fmt ", 4) == 0) {
+      uint16_t fmt_tag, ch, block_align, bps;
+      uint32_t sr, byte_rate;
+      fread(&fmt_tag, 2, 1, f);
+      fread(&ch, 2, 1, f);
+      fread(&sr, 4, 1, f);
+      fread(&byte_rate, 4, 1, f);
+      fread(&block_align, 2, 1, f);
+      fread(&bps, 2, 1, f);
+      channels = ch;
+      rate = sr;
+      bits = bps;
+      if (sz > 16) fseek(f, (long)(sz - 16), SEEK_CUR);
+      (void)byte_rate;
+      (void)block_align;
+    } else if (memcmp(tag, "data", 4) == 0) {
+      nsamp = sz / 2;
+      pcm = malloc(sz);
+      if (fread(pcm, 1, sz, f) != sz) { free(pcm); pcm = NULL; }
+    } else {
+      fseek(f, (long)sz, SEEK_CUR);
+    }
+    if (sz & 1) fseek(f, 1, SEEK_CUR);  // chunks are word-aligned
+  }
+  fclose(f);
+
+  if (!pcm || channels != 1 || bits != 16 || rate != SC_SR) {
+    free(pcm);
+    return NULL;
+  }
+  float* out = malloc((size_t)nsamp * sizeof(float));
+  for (uint32_t i = 0; i < nsamp; i++) out[i] = (float)pcm[i] / 32768.f;
+  free(pcm);
+  *n = (int)nsamp;
+  return out;
+}
+
+// =========================================================
+// Log-mel feature extraction
+// =========================================================
+
+// In-place iterative radix-2 Cooley-Tukey FFT; n must be a power of 2.
+static void sc_fft(float* re, float* im, int n) {
+  for (int i = 1, j = 0; i < n; i++) {
+    int bit = n >> 1;
+    for (; j & bit; bit >>= 1) j ^= bit;
+    j ^= bit;
+    if (i < j) {
+      float t = re[i];
+      re[i] = re[j];
+      re[j] = t;
+      t = im[i];
+      im[i] = im[j];
+      im[j] = t;
+    }
+  }
+  for (int len = 2; len <= n; len <<= 1) {
+    float ang = -2.f * SC_PI / (float)len;
+    float wr = cosf(ang), wi = sinf(ang);
+    for (int i = 0; i < n; i += len) {
+      float cwr = 1.f, cwi = 0.f;
+      for (int k = 0; k < len / 2; k++) {
+        float ur = re[i + k], ui = im[i + k];
+        float vr = re[i + k + len / 2] * cwr - im[i + k + len / 2] * cwi;
+        float vi = re[i + k + len / 2] * cwi + im[i + k + len / 2] * cwr;
+        re[i + k] = ur + vr;
+        im[i + k] = ui + vi;
+        re[i + k + len / 2] = ur - vr;
+        im[i + k + len / 2] = ui - vi;
+        float nwr = cwr * wr - cwi * wi;
+        float nwi = cwr * wi + cwi * wr;
+        cwr = nwr;
+        cwi = nwi;
+      }
+    }
+  }
+}
+
+// Triangular mel filterbank, HTK mel scale, 0..Nyquist. fb[m][k] weights FFT
+// bin k (0..SC_NFFT/2) into mel band m.
+static void sc_mel_filterbank(float fb[SC_NMEL][SC_NFFT / 2 + 1]) {
+  float low_mel = 0.f;
+  float high_mel = 2595.f * log10f(1.f + (SC_SR / 2.f) / 700.f);
+  int bin[SC_NMEL + 2];
+  for (int i = 0; i < SC_NMEL + 2; i++) {
+    float mel = low_mel + (high_mel - low_mel) * (float)i / (float)(SC_NMEL + 1);
+    float hz = 700.f * (powf(10.f, mel / 2595.f) - 1.f);
+    bin[i] = (int)floorf((SC_NFFT + 1) * hz / SC_SR);
+  }
+  memset(fb, 0, sizeof(float) * SC_NMEL * (SC_NFFT / 2 + 1));
+  for (int m = 1; m <= SC_NMEL; m++) {
+    int f_prev = bin[m - 1], f_curr = bin[m], f_next = bin[m + 1];
+    for (int k = f_prev; k < f_curr; k++)
+      if (k >= 0 && k <= SC_NFFT / 2 && f_curr > f_prev)
+        fb[m - 1][k] = (float)(k - f_prev) / (float)(f_curr - f_prev);
+    for (int k = f_curr; k < f_next; k++)
+      if (k >= 0 && k <= SC_NFFT / 2 && f_next > f_curr)
+        fb[m - 1][k] = (float)(f_next - k) / (float)(f_next - f_curr);
+  }
+}
+
+// samples/n: raw waveform (any length -- shorter than 1s is zero-padded,
+// longer is truncated, matching the dataset's mostly-1s clips). Writes
+// SC_T*SC_NMEL log-mel values into out, row-major [SC_T, SC_NMEL].
+static void sc_logmel(const float* samples, int n, const float fb[SC_NMEL][SC_NFFT / 2 + 1],
+                      float* out) {
+  float frame[SC_NFFT], re[SC_NFFT], im[SC_NFFT], power[SC_NFFT / 2 + 1];
+  for (int t = 0; t < SC_T; t++) {
+    int start = t * SC_HOP;
+    for (int k = 0; k < SC_NFFT; k++) {
+      if (k >= SC_FRAME) {
+        frame[k] = 0.f;
+        continue;
+      }
+      float s = (start + k < n) ? samples[start + k] : 0.f;
+      float w = 0.5f - 0.5f * cosf(2.f * SC_PI * (float)k / (float)(SC_FRAME - 1));
+      frame[k] = s * w;
+    }
+    memcpy(re, frame, sizeof(frame));
+    memset(im, 0, sizeof(im));
+    sc_fft(re, im, SC_NFFT);
+    for (int k = 0; k <= SC_NFFT / 2; k++) power[k] = (re[k] * re[k] + im[k] * im[k]) / SC_NFFT;
+    for (int m = 0; m < SC_NMEL; m++) {
+      float e = 0.f;
+      for (int k = 0; k <= SC_NFFT / 2; k++) e += power[k] * fb[m][k];
+      out[t * SC_NMEL + m] = logf(e + 1e-6f);
+    }
+  }
+}
+
+// =========================================================
+// Dataset assembly
+// =========================================================
+
+typedef struct {
+  char** paths;  // "word/file.wav", relative to the dataset root
+  int n, cap;
+} sc_pathlist_t;
+
+static char* sc_strdup(const char* s) {
+  size_t len = strlen(s) + 1;
+  char* out = malloc(len);
+  memcpy(out, s, len);
+  return out;
+}
+
+static void sc_pathlist_add(sc_pathlist_t* l, const char* rel) {
+  if (l->n == l->cap) {
+    l->cap = l->cap ? l->cap * 2 : 1024;
+    l->paths = realloc(l->paths, (size_t)l->cap * sizeof(char*));
+  }
+  l->paths[l->n++] = sc_strdup(rel);
+}
+
+static void sc_pathlist_free(sc_pathlist_t* l) {
+  for (int i = 0; i < l->n; i++) free(l->paths[i]);
+  free(l->paths);
+}
+
+// loads testing_list.txt or validation_list.txt ("word/file.wav" per line)
+static void sc_load_split_list(const char* root, const char* name, sc_pathlist_t* out) {
+  char path[1024];
+  snprintf(path, sizeof(path), "%s/%s", root, name);
+  FILE* f = fopen(path, "r");
+  if (!f) return;
+  char line[512];
+  while (fgets(line, sizeof(line), f)) {
+    size_t l = strlen(line);
+    while (l > 0 && (line[l - 1] == '\n' || line[l - 1] == '\r')) line[--l] = 0;
+    if (l > 0) sc_pathlist_add(out, line);
+  }
+  fclose(f);
+}
+
+static int sc_in_pathlist(sc_pathlist_t* l, const char* rel) {
+  for (int i = 0; i < l->n; i++)
+    if (strcmp(l->paths[i], rel) == 0) return 1;
+  return 0;
+}
+
+static void sc_shuffle_str(char** a, int n) {
+  for (int i = n - 1; i > 0; i--) {
+    int j = rand() % (i + 1);
+    char* t = a[i];
+    a[i] = a[j];
+    a[j] = t;
+  }
+}
+
+typedef enum { SC_TRAIN, SC_VAL, SC_TEST } sc_split_t;
+
+// Scans `root` (the extracted speech_commands_v0.02 directory) and builds
+// one split. `root` must contain testing_list.txt/validation_list.txt (the
+// dataset's own split) plus a _background_noise_ directory (for
+// "_silence_"). Every word directory not in SC_CLASSES[0..9] is a candidate
+// "_unknown_" source; feature extraction runs once per clip here, so the
+// caller pays this cost once per split, not once per epoch.
+static sc_dataset_t sc_load_split(const char* root, sc_split_t split) {
+  sc_pathlist_t test_list = {0}, val_list = {0};
+  sc_load_split_list(root, "testing_list.txt", &test_list);
+  sc_load_split_list(root, "validation_list.txt", &val_list);
+
+  int max_per_class = split == SC_TRAIN   ? SC_MAX_TRAIN_PER_CLASS
+                      : split == SC_VAL   ? SC_MAX_VAL_PER_CLASS
+                                          : SC_MAX_TEST_PER_CLASS;
+
+  static float fb[SC_NMEL][SC_NFFT / 2 + 1];
+  static int fb_ready = 0;
+  if (!fb_ready) {
+    sc_mel_filterbank(fb);
+    fb_ready = 1;
+  }
+
+  sc_dataset_t d = {0};
+  int cap = SC_NCLASS * max_per_class;
+  d.feats = malloc((size_t)cap * SC_T * SC_NMEL * sizeof(float));
+  d.labels = malloc((size_t)cap);
+
+  DIR* rd = opendir(root);
+  if (!rd) return d;
+  struct dirent* ent;
+
+  // core keywords + "_unknown_" source words share one directory scan
+  sc_pathlist_t unknown_pool = {0};
+  while ((ent = readdir(rd))) {
+    if (ent->d_name[0] == '.' || strcmp(ent->d_name, "_background_noise_") == 0) continue;
+    char wordpath[768];
+    snprintf(wordpath, sizeof(wordpath), "%s/%s", root, ent->d_name);
+    DIR* wd = opendir(wordpath);
+    if (!wd) continue;
+    int label = -1;
+    for (int c = 0; c < 10; c++)
+      if (strcmp(ent->d_name, SC_CLASSES[c]) == 0) label = c;
+
+    sc_pathlist_t files = {0};
+    struct dirent* we;
+    while ((we = readdir(wd))) {
+      if (we->d_name[0] == '.') continue;
+      char rel[512];
+      snprintf(rel, sizeof(rel), "%s/%s", ent->d_name, we->d_name);
+      int is_test = sc_in_pathlist(&test_list, rel);
+      int is_val = sc_in_pathlist(&val_list, rel);
+      int want = (split == SC_TEST && is_test) || (split == SC_VAL && is_val) ||
+                 (split == SC_TRAIN && !is_test && !is_val);
+      if (want) sc_pathlist_add(&files, rel);
+    }
+    closedir(wd);
+
+    if (label >= 0) {
+      sc_shuffle_str(files.paths, files.n);
+      int take = files.n < max_per_class ? files.n : max_per_class;
+      for (int i = 0; i < take && d.n < cap; i++) {
+        char full[1024];
+        snprintf(full, sizeof(full), "%s/%s", root, files.paths[i]);
+        int n;
+        float* samples = sc_wav_read(full, &n);
+        if (!samples) continue;
+        sc_logmel(samples, n, fb, d.feats + (size_t)d.n * SC_T * SC_NMEL);
+        d.labels[d.n] = (uint8_t)label;
+        d.n++;
+        free(samples);
+      }
+    } else {
+      for (int i = 0; i < files.n; i++) sc_pathlist_add(&unknown_pool, files.paths[i]);
+    }
+    sc_pathlist_free(&files);
+  }
+  closedir(rd);
+
+  // "_unknown_"
+  sc_shuffle_str(unknown_pool.paths, unknown_pool.n);
+  int take = unknown_pool.n < max_per_class ? unknown_pool.n : max_per_class;
+  for (int i = 0; i < take && d.n < cap; i++) {
+    char full[1024];
+    snprintf(full, sizeof(full), "%s/%s", root, unknown_pool.paths[i]);
+    int n;
+    float* samples = sc_wav_read(full, &n);
+    if (!samples) continue;
+    sc_logmel(samples, n, fb, d.feats + (size_t)d.n * SC_T * SC_NMEL);
+    d.labels[d.n] = 10;  // "_unknown_"
+    d.n++;
+    free(samples);
+  }
+  sc_pathlist_free(&unknown_pool);
+
+  // "_silence_": random 1s crops of the background noise recordings. Splits
+  // draw from disjoint offset ranges so the same crop can't leak across
+  // train/val/test.
+  char bgpath[768];
+  snprintf(bgpath, sizeof(bgpath), "%s/_background_noise_", root);
+  DIR* bd = opendir(bgpath);
+  if (bd) {
+    sc_pathlist_t bg_files = {0};
+    struct dirent* be;
+    while ((be = readdir(bd))) {
+      if (be->d_name[0] == '.' || strstr(be->d_name, ".wav") == NULL) continue;
+      sc_pathlist_add(&bg_files, be->d_name);
+    }
+    closedir(bd);
+    for (int i = 0; i < max_per_class && d.n < cap && bg_files.n > 0; i++) {
+      const char* fname = bg_files.paths[rand() % bg_files.n];
+      char full[1024];
+      snprintf(full, sizeof(full), "%s/%s", bgpath, fname);
+      int n;
+      float* samples = sc_wav_read(full, &n);
+      if (!samples || n <= SC_CLIP_LEN) {
+        free(samples);
+        continue;
+      }
+      // carve the file into thirds, one per split, then pick a random crop within it
+      int third = (n - SC_CLIP_LEN) / 3;
+      int lo = split * third, hi = lo + third;
+      int start = lo + (hi > lo ? rand() % (hi - lo) : 0);
+      sc_logmel(samples + start, SC_CLIP_LEN, fb, d.feats + (size_t)d.n * SC_T * SC_NMEL);
+      d.labels[d.n] = 11;  // "_silence_"
+      d.n++;
+      free(samples);
+    }
+    sc_pathlist_free(&bg_files);
+  }
+
+  sc_pathlist_free(&test_list);
+  sc_pathlist_free(&val_list);
+  return d;
+}
+
+static void sc_dataset_free(sc_dataset_t* d) {
+  free(d->feats);
+  free(d->labels);
+}
+
+static void sc_shuffle(int* a, int n) {
+  for (int i = n - 1; i > 0; i--) {
+    int j = rand() % (i + 1);
+    int t = a[i];
+    a[i] = a[j];
+    a[j] = t;
+  }
+}
+
+static int sc_argmax(ut_tensor* t, int row) {
+  int C = t->shape.shape[1], best = 0;
+  float* r = t->data + row * C;
+  for (int j = 1; j < C; j++)
+    if (r[j] > r[best]) best = j;
+  return best;
+}
+
+#endif