← Commits · e6e77dd4
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