From b08781fede402e77e17f3b7d53d50bddea7f532e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 31 Mar 2026 16:58:30 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20samples.mjs=20=E2=80=94=20sample=20libr?= =?UTF-8?q?ary=20with=20save/load/browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New piece for managing recorded audio samples. Records from notepat are saved as timestamped .raw files to /mnt/samples/ on the boot media. Browse, preview (space), load (enter), and delete (x) saved samples. Adds sound.sample.saveTo() and sound.sample.loadFrom() C bindings for saving/loading sample data to arbitrary paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- fedac/native/initramfs/init | 5 + fedac/native/pieces/list.mjs | 1 + fedac/native/pieces/samples.mjs | 239 ++++++++++++++++++++++++++++++++ fedac/native/src/js-bindings.c | 26 ++++ 4 files changed, 271 insertions(+) create mode 100644 fedac/native/pieces/samples.mjs diff --git a/fedac/native/initramfs/init b/fedac/native/initramfs/init index 439067c08d..b12f6cfe4f 100755 --- a/fedac/native/initramfs/init +++ b/fedac/native/initramfs/init @@ -92,6 +92,11 @@ for attempt in 1 2 3 4 5 6 7 8 9 10; do sleep 1 done +# Create samples directory on boot media +if [ "$USB_MOUNTED" = "1" ]; then + mkdir -p /mnt/samples +fi + if [ -x /scripts/usb-midi-gadget.sh ]; then if [ "$USB_MOUNTED" = "1" ] && [ -f /mnt/config.json ] && grep -Eq '"usbMidi"[[:space:]]*:[[:space:]]*true' /mnt/config.json 2>/dev/null; then diff --git a/fedac/native/pieces/list.mjs b/fedac/native/pieces/list.mjs index 2090d98a27..1c9ca25da6 100644 --- a/fedac/native/pieces/list.mjs +++ b/fedac/native/pieces/list.mjs @@ -12,6 +12,7 @@ const MAIN = [ const TOOLS = [ { name: "os", desc: "system updates (OTA)" }, + { name: "samples", desc: "sample library" }, { name: "wifi", desc: "network picker" }, { name: "code", desc: "AI assistant (Claude)" }, { name: "terminal", desc: "shell (PTY)" }, diff --git a/fedac/native/pieces/samples.mjs b/fedac/native/pieces/samples.mjs new file mode 100644 index 0000000000..6a9969ee8c --- /dev/null +++ b/fedac/native/pieces/samples.mjs @@ -0,0 +1,239 @@ +// samples.mjs — Sample library manager for AC Native +// Lists saved audio samples from /mnt/samples/, plays them back, +// and saves new snapshots of the current sample buffer. +// Jumped to from prompt.mjs via "samples" command. + +const SAMPLES_DIR = "/mnt/samples"; +let samples = []; // { name, path, size, date } +let selectedIdx = 0; +let frame = 0; +let message = ""; +let messageFrame = 0; +let storageInfo = ""; + +function scanSamples(system) { + samples = []; + // Read manifest file that we maintain + const manifest = system?.readFile?.(`${SAMPLES_DIR}/manifest.json`); + if (manifest) { + try { samples = JSON.parse(manifest); } catch {} + } +} + +function saveManifest(system) { + system?.writeFile?.(`${SAMPLES_DIR}/manifest.json`, JSON.stringify(samples, null, 2)); +} + +function boot({ system }) { + scanSamples(system); + + // Storage info + const bootDev = system?.bootDevice || "?"; + storageInfo = `${bootDev} -> ${SAMPLES_DIR}`; +} + +function act({ event: e, sound, system }) { + if (!e.is("keyboard:down")) return; + + if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { + system?.jump?.("prompt"); + return; + } + + // Navigate + if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:tab")) { + if (samples.length > 0) { + selectedIdx = (selectedIdx + 1) % samples.length; + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.025 }); + } + return; + } + if (e.is("keyboard:down:arrowup")) { + if (samples.length > 0) { + selectedIdx = (selectedIdx - 1 + samples.length) % samples.length; + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.025 }); + } + return; + } + + // Save current sample as timestamped file + if (e.is("keyboard:down:s")) { + const mic = sound?.microphone || {}; + const len = mic.sampleLength || 0; + if (len <= 0) { + message = "no sample loaded"; + messageFrame = frame; + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); + return; + } + // Generate timestamp filename + const now = new Date(); + const ts = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ].join(""); + const path = `${SAMPLES_DIR}/${ts}.raw`; + const saved = sound?.sample?.saveTo?.(path) || -1; + if (saved > 0) { + const secs = (mic.sampleRate > 0) ? (saved / mic.sampleRate).toFixed(2) : "?"; + message = `saved ${secs}s -> ${ts}.raw`; + sound?.synth({ type: "triangle", tone: 784, duration: 0.08, volume: 0.12, attack: 0.003, decay: 0.06 }); + // Add to manifest + samples.unshift({ name: ts, path, size: saved * 4 + 8, rate: mic.sampleRate || 0, len: saved }); + saveManifest(system); + selectedIdx = 0; + } else { + message = "save failed"; + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); + } + messageFrame = frame; + return; + } + + // Load selected sample + if (e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { + if (samples.length > 0 && samples[selectedIdx]) { + const s = samples[selectedIdx]; + const loaded = sound?.sample?.loadFrom?.(s.path) || -1; + if (loaded > 0) { + const mic = sound?.microphone || {}; + const secs = (mic.sampleRate > 0) ? (loaded / mic.sampleRate).toFixed(2) : "?"; + message = `loaded ${s.name} (${secs}s)`; + sound?.synth({ type: "triangle", tone: 523, duration: 0.06, volume: 0.1, attack: 0.003, decay: 0.05 }); + } else { + message = "load failed"; + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); + } + messageFrame = frame; + } + return; + } + + // Play preview of selected sample + if (e.is("keyboard:down:space")) { + if (samples.length > 0 && samples[selectedIdx]) { + // Load and play at C4 + const s = samples[selectedIdx]; + sound?.sample?.loadFrom?.(s.path); + sound?.sample?.play?.({ tone: 261.63, base: 261.63, volume: 0.7, attack: 0.01, decay: 0.5 }); + sound?.synth({ type: "sine", tone: 261.63, duration: 0.02, volume: 0.03, attack: 0.001, decay: 0.015 }); + } + return; + } + + // Delete selected sample + if (e.is("keyboard:down:delete") || e.is("keyboard:down:x")) { + if (samples.length > 0 && samples[selectedIdx]) { + const s = samples[selectedIdx]; + // Remove from manifest (file stays on disk but is unlisted) + samples.splice(selectedIdx, 1); + saveManifest(system); + message = `removed ${s.name}`; + messageFrame = frame; + if (selectedIdx >= samples.length) selectedIdx = Math.max(0, samples.length - 1); + sound?.synth({ type: "sine", tone: 330, duration: 0.06, volume: 0.08, attack: 0.003, decay: 0.05 }); + } + return; + } +} + +function paint({ wipe, ink, box, write, screen, sound, system }) { + frame++; + const T = __theme.update(); + const w = screen.width, h = screen.height; + const pad = 10; + const font = "font_1"; + + wipe(T.bg[0], T.bg[1], T.bg[2]); + + // Title + ink(T.fg, T.fg + 10, T.fg); + write("samples", { x: pad, y: 10, size: 2, font: "matrix" }); + + // Storage location + ink(T.fgMute, T.fgMute, T.fgMute + 10); + write(storageInfo, { x: pad, y: 34, size: 1, font }); + + // Current sample info + const mic = sound?.microphone || {}; + const curLen = mic.sampleLength || 0; + const curRate = mic.sampleRate || 0; + if (curLen > 0) { + const secs = curRate > 0 ? (curLen / curRate).toFixed(2) : "?"; + ink(80, 200, 120); + write(`active: ${secs}s @ ${curRate}Hz`, { x: pad, y: 46, size: 1, font }); + } else { + ink(T.fgMute); + write("no sample loaded", { x: pad, y: 46, size: 1, font }); + } + + // Message + if (message && frame - messageFrame < 180) { + const fade = Math.max(0, 255 - (frame - messageFrame) * 2); + ink(200, 255, 180, fade); + write(message, { x: pad, y: 58, size: 1, font }); + } + + // Sample list + const listY = 74; + const rowH = 14; + const maxRows = Math.floor((h - listY - 40) / rowH); + + if (samples.length === 0) { + ink(T.fgMute); + write("no saved samples", { x: pad, y: listY, size: 1, font }); + ink(T.fgMute - 20, T.fgMute, T.fgMute + 10); + write("record in notepat, then press s here", { x: pad, y: listY + 14, size: 1, font }); + } else { + // Scroll window + let startIdx = 0; + if (selectedIdx >= maxRows) startIdx = selectedIdx - maxRows + 1; + + for (let i = startIdx; i < Math.min(samples.length, startIdx + maxRows); i++) { + const s = samples[i]; + const ry = listY + (i - startIdx) * rowH; + const selected = i === selectedIdx; + + if (selected) { + ink(30, 50, 60); + box(pad - 2, ry - 1, w - pad * 2 + 4, rowH - 2, true); + } + + ink(selected ? 255 : T.fgMute, selected ? 255 : T.fgMute, selected ? 255 : T.fgMute); + write((selected ? "> " : " ") + s.name, { x: pad, y: ry, size: 1, font }); + + // Duration + if (s.len && s.rate) { + ink(80, 80, 100); + const secs = (s.len / s.rate).toFixed(1); + write(secs + "s", { x: w - pad - 24, y: ry, size: 1, font }); + } else if (s.size) { + ink(80, 80, 100); + const kb = (s.size / 1024).toFixed(0); + write(kb + "KB", { x: w - pad - 36, y: ry, size: 1, font }); + } + } + + // Scroll indicator + if (samples.length > maxRows) { + ink(60, 60, 80); + write(`${selectedIdx + 1}/${samples.length}`, { x: w - pad - 30, y: listY - 12, size: 1, font }); + } + } + + // Controls + const ctrlY = h - 26; + ink(80, 80, 100); + write("s:save enter:load space:play x:del", { x: pad, y: ctrlY, size: 1, font }); + ink(T.fgMute, T.fgMute + 10, T.fgMute); + write("esc: back", { x: pad, y: ctrlY + 12, size: 1, font }); +} + +function sim() {} + +export { boot, paint, act, sim }; diff --git a/fedac/native/src/js-bindings.c b/fedac/native/src/js-bindings.c index cc91be7e94..11ba6b3aca 100644 --- a/fedac/native/src/js-bindings.c +++ b/fedac/native/src/js-bindings.c @@ -1252,6 +1252,30 @@ static JSValue js_sample_get_data(JSContext *ctx, JSValueConst this_val, int arg return f32; } +// sound.sample.saveTo(path) — save current sample to a file, returns sample count or -1 +static JSValue js_sample_save_to(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1); + const char *path = JS_ToCString(ctx, argv[0]); + if (!path) return JS_NewInt32(ctx, -1); + int result = audio_sample_save(current_rt->audio, path); + ac_log("[sample] saveTo(%s) -> %d samples\n", path, result); + JS_FreeCString(ctx, path); + return JS_NewInt32(ctx, result); +} + +// sound.sample.loadFrom(path) — load sample from a file, returns sample count or -1 +static JSValue js_sample_load_from(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { + (void)this_val; + if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1); + const char *path = JS_ToCString(ctx, argv[0]); + if (!path) return JS_NewInt32(ctx, -1); + int result = audio_sample_load(current_rt->audio, path); + ac_log("[sample] loadFrom(%s) -> %d samples\n", path, result); + JS_FreeCString(ctx, path); + return JS_NewInt32(ctx, result); +} + // sound.sample.loadData(float32array, rate) — load sample data from JS array static JSValue js_sample_load_data(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { (void)this_val; @@ -2042,6 +2066,8 @@ static JSValue build_sound_obj(JSContext *ctx, ACRuntime *rt) { JS_SetPropertyStr(ctx, samp, "kill", JS_NewCFunction(ctx, js_sample_kill, "kill", 2)); JS_SetPropertyStr(ctx, samp, "getData", JS_NewCFunction(ctx, js_sample_get_data, "getData", 0)); JS_SetPropertyStr(ctx, samp, "loadData", JS_NewCFunction(ctx, js_sample_load_data, "loadData", 2)); + JS_SetPropertyStr(ctx, samp, "saveTo", JS_NewCFunction(ctx, js_sample_save_to, "saveTo", 1)); + JS_SetPropertyStr(ctx, samp, "loadFrom", JS_NewCFunction(ctx, js_sample_load_from, "loadFrom", 1)); JS_SetPropertyStr(ctx, sound, "sample", samp); // TTS -- 2.51.2