Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: samples.mjs — sample library with save/load/browse

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) <noreply@anthropic.com>

+271
+5
fedac/native/initramfs/init
··· 92 92 sleep 1 93 93 done 94 94 95 + # Create samples directory on boot media 96 + if [ "$USB_MOUNTED" = "1" ]; then 97 + mkdir -p /mnt/samples 98 + fi 99 + 95 100 if [ -x /scripts/usb-midi-gadget.sh ]; then 96 101 if [ "$USB_MOUNTED" = "1" ] && [ -f /mnt/config.json ] && 97 102 grep -Eq '"usbMidi"[[:space:]]*:[[:space:]]*true' /mnt/config.json 2>/dev/null; then
+1
fedac/native/pieces/list.mjs
··· 12 12 13 13 const TOOLS = [ 14 14 { name: "os", desc: "system updates (OTA)" }, 15 + { name: "samples", desc: "sample library" }, 15 16 { name: "wifi", desc: "network picker" }, 16 17 { name: "code", desc: "AI assistant (Claude)" }, 17 18 { name: "terminal", desc: "shell (PTY)" },
+239
fedac/native/pieces/samples.mjs
··· 1 + // samples.mjs — Sample library manager for AC Native 2 + // Lists saved audio samples from /mnt/samples/, plays them back, 3 + // and saves new snapshots of the current sample buffer. 4 + // Jumped to from prompt.mjs via "samples" command. 5 + 6 + const SAMPLES_DIR = "/mnt/samples"; 7 + let samples = []; // { name, path, size, date } 8 + let selectedIdx = 0; 9 + let frame = 0; 10 + let message = ""; 11 + let messageFrame = 0; 12 + let storageInfo = ""; 13 + 14 + function scanSamples(system) { 15 + samples = []; 16 + // Read manifest file that we maintain 17 + const manifest = system?.readFile?.(`${SAMPLES_DIR}/manifest.json`); 18 + if (manifest) { 19 + try { samples = JSON.parse(manifest); } catch {} 20 + } 21 + } 22 + 23 + function saveManifest(system) { 24 + system?.writeFile?.(`${SAMPLES_DIR}/manifest.json`, JSON.stringify(samples, null, 2)); 25 + } 26 + 27 + function boot({ system }) { 28 + scanSamples(system); 29 + 30 + // Storage info 31 + const bootDev = system?.bootDevice || "?"; 32 + storageInfo = `${bootDev} -> ${SAMPLES_DIR}`; 33 + } 34 + 35 + function act({ event: e, sound, system }) { 36 + if (!e.is("keyboard:down")) return; 37 + 38 + if (e.is("keyboard:down:escape") || e.is("keyboard:down:backspace")) { 39 + system?.jump?.("prompt"); 40 + return; 41 + } 42 + 43 + // Navigate 44 + if (e.is("keyboard:down:arrowdown") || e.is("keyboard:down:tab")) { 45 + if (samples.length > 0) { 46 + selectedIdx = (selectedIdx + 1) % samples.length; 47 + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.025 }); 48 + } 49 + return; 50 + } 51 + if (e.is("keyboard:down:arrowup")) { 52 + if (samples.length > 0) { 53 + selectedIdx = (selectedIdx - 1 + samples.length) % samples.length; 54 + sound?.synth({ type: "sine", tone: 440, duration: 0.03, volume: 0.08, attack: 0.002, decay: 0.025 }); 55 + } 56 + return; 57 + } 58 + 59 + // Save current sample as timestamped file 60 + if (e.is("keyboard:down:s")) { 61 + const mic = sound?.microphone || {}; 62 + const len = mic.sampleLength || 0; 63 + if (len <= 0) { 64 + message = "no sample loaded"; 65 + messageFrame = frame; 66 + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); 67 + return; 68 + } 69 + // Generate timestamp filename 70 + const now = new Date(); 71 + const ts = [ 72 + now.getFullYear(), 73 + String(now.getMonth() + 1).padStart(2, "0"), 74 + String(now.getDate()).padStart(2, "0"), 75 + "-", 76 + String(now.getHours()).padStart(2, "0"), 77 + String(now.getMinutes()).padStart(2, "0"), 78 + String(now.getSeconds()).padStart(2, "0"), 79 + ].join(""); 80 + const path = `${SAMPLES_DIR}/${ts}.raw`; 81 + const saved = sound?.sample?.saveTo?.(path) || -1; 82 + if (saved > 0) { 83 + const secs = (mic.sampleRate > 0) ? (saved / mic.sampleRate).toFixed(2) : "?"; 84 + message = `saved ${secs}s -> ${ts}.raw`; 85 + sound?.synth({ type: "triangle", tone: 784, duration: 0.08, volume: 0.12, attack: 0.003, decay: 0.06 }); 86 + // Add to manifest 87 + samples.unshift({ name: ts, path, size: saved * 4 + 8, rate: mic.sampleRate || 0, len: saved }); 88 + saveManifest(system); 89 + selectedIdx = 0; 90 + } else { 91 + message = "save failed"; 92 + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); 93 + } 94 + messageFrame = frame; 95 + return; 96 + } 97 + 98 + // Load selected sample 99 + if (e.is("keyboard:down:enter") || e.is("keyboard:down:return")) { 100 + if (samples.length > 0 && samples[selectedIdx]) { 101 + const s = samples[selectedIdx]; 102 + const loaded = sound?.sample?.loadFrom?.(s.path) || -1; 103 + if (loaded > 0) { 104 + const mic = sound?.microphone || {}; 105 + const secs = (mic.sampleRate > 0) ? (loaded / mic.sampleRate).toFixed(2) : "?"; 106 + message = `loaded ${s.name} (${secs}s)`; 107 + sound?.synth({ type: "triangle", tone: 523, duration: 0.06, volume: 0.1, attack: 0.003, decay: 0.05 }); 108 + } else { 109 + message = "load failed"; 110 + sound?.synth({ type: "square", tone: 220, duration: 0.1, volume: 0.06, attack: 0.005, decay: 0.08 }); 111 + } 112 + messageFrame = frame; 113 + } 114 + return; 115 + } 116 + 117 + // Play preview of selected sample 118 + if (e.is("keyboard:down:space")) { 119 + if (samples.length > 0 && samples[selectedIdx]) { 120 + // Load and play at C4 121 + const s = samples[selectedIdx]; 122 + sound?.sample?.loadFrom?.(s.path); 123 + sound?.sample?.play?.({ tone: 261.63, base: 261.63, volume: 0.7, attack: 0.01, decay: 0.5 }); 124 + sound?.synth({ type: "sine", tone: 261.63, duration: 0.02, volume: 0.03, attack: 0.001, decay: 0.015 }); 125 + } 126 + return; 127 + } 128 + 129 + // Delete selected sample 130 + if (e.is("keyboard:down:delete") || e.is("keyboard:down:x")) { 131 + if (samples.length > 0 && samples[selectedIdx]) { 132 + const s = samples[selectedIdx]; 133 + // Remove from manifest (file stays on disk but is unlisted) 134 + samples.splice(selectedIdx, 1); 135 + saveManifest(system); 136 + message = `removed ${s.name}`; 137 + messageFrame = frame; 138 + if (selectedIdx >= samples.length) selectedIdx = Math.max(0, samples.length - 1); 139 + sound?.synth({ type: "sine", tone: 330, duration: 0.06, volume: 0.08, attack: 0.003, decay: 0.05 }); 140 + } 141 + return; 142 + } 143 + } 144 + 145 + function paint({ wipe, ink, box, write, screen, sound, system }) { 146 + frame++; 147 + const T = __theme.update(); 148 + const w = screen.width, h = screen.height; 149 + const pad = 10; 150 + const font = "font_1"; 151 + 152 + wipe(T.bg[0], T.bg[1], T.bg[2]); 153 + 154 + // Title 155 + ink(T.fg, T.fg + 10, T.fg); 156 + write("samples", { x: pad, y: 10, size: 2, font: "matrix" }); 157 + 158 + // Storage location 159 + ink(T.fgMute, T.fgMute, T.fgMute + 10); 160 + write(storageInfo, { x: pad, y: 34, size: 1, font }); 161 + 162 + // Current sample info 163 + const mic = sound?.microphone || {}; 164 + const curLen = mic.sampleLength || 0; 165 + const curRate = mic.sampleRate || 0; 166 + if (curLen > 0) { 167 + const secs = curRate > 0 ? (curLen / curRate).toFixed(2) : "?"; 168 + ink(80, 200, 120); 169 + write(`active: ${secs}s @ ${curRate}Hz`, { x: pad, y: 46, size: 1, font }); 170 + } else { 171 + ink(T.fgMute); 172 + write("no sample loaded", { x: pad, y: 46, size: 1, font }); 173 + } 174 + 175 + // Message 176 + if (message && frame - messageFrame < 180) { 177 + const fade = Math.max(0, 255 - (frame - messageFrame) * 2); 178 + ink(200, 255, 180, fade); 179 + write(message, { x: pad, y: 58, size: 1, font }); 180 + } 181 + 182 + // Sample list 183 + const listY = 74; 184 + const rowH = 14; 185 + const maxRows = Math.floor((h - listY - 40) / rowH); 186 + 187 + if (samples.length === 0) { 188 + ink(T.fgMute); 189 + write("no saved samples", { x: pad, y: listY, size: 1, font }); 190 + ink(T.fgMute - 20, T.fgMute, T.fgMute + 10); 191 + write("record in notepat, then press s here", { x: pad, y: listY + 14, size: 1, font }); 192 + } else { 193 + // Scroll window 194 + let startIdx = 0; 195 + if (selectedIdx >= maxRows) startIdx = selectedIdx - maxRows + 1; 196 + 197 + for (let i = startIdx; i < Math.min(samples.length, startIdx + maxRows); i++) { 198 + const s = samples[i]; 199 + const ry = listY + (i - startIdx) * rowH; 200 + const selected = i === selectedIdx; 201 + 202 + if (selected) { 203 + ink(30, 50, 60); 204 + box(pad - 2, ry - 1, w - pad * 2 + 4, rowH - 2, true); 205 + } 206 + 207 + ink(selected ? 255 : T.fgMute, selected ? 255 : T.fgMute, selected ? 255 : T.fgMute); 208 + write((selected ? "> " : " ") + s.name, { x: pad, y: ry, size: 1, font }); 209 + 210 + // Duration 211 + if (s.len && s.rate) { 212 + ink(80, 80, 100); 213 + const secs = (s.len / s.rate).toFixed(1); 214 + write(secs + "s", { x: w - pad - 24, y: ry, size: 1, font }); 215 + } else if (s.size) { 216 + ink(80, 80, 100); 217 + const kb = (s.size / 1024).toFixed(0); 218 + write(kb + "KB", { x: w - pad - 36, y: ry, size: 1, font }); 219 + } 220 + } 221 + 222 + // Scroll indicator 223 + if (samples.length > maxRows) { 224 + ink(60, 60, 80); 225 + write(`${selectedIdx + 1}/${samples.length}`, { x: w - pad - 30, y: listY - 12, size: 1, font }); 226 + } 227 + } 228 + 229 + // Controls 230 + const ctrlY = h - 26; 231 + ink(80, 80, 100); 232 + write("s:save enter:load space:play x:del", { x: pad, y: ctrlY, size: 1, font }); 233 + ink(T.fgMute, T.fgMute + 10, T.fgMute); 234 + write("esc: back", { x: pad, y: ctrlY + 12, size: 1, font }); 235 + } 236 + 237 + function sim() {} 238 + 239 + export { boot, paint, act, sim };
+26
fedac/native/src/js-bindings.c
··· 1252 1252 return f32; 1253 1253 } 1254 1254 1255 + // sound.sample.saveTo(path) — save current sample to a file, returns sample count or -1 1256 + static JSValue js_sample_save_to(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1257 + (void)this_val; 1258 + if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1); 1259 + const char *path = JS_ToCString(ctx, argv[0]); 1260 + if (!path) return JS_NewInt32(ctx, -1); 1261 + int result = audio_sample_save(current_rt->audio, path); 1262 + ac_log("[sample] saveTo(%s) -> %d samples\n", path, result); 1263 + JS_FreeCString(ctx, path); 1264 + return JS_NewInt32(ctx, result); 1265 + } 1266 + 1267 + // sound.sample.loadFrom(path) — load sample from a file, returns sample count or -1 1268 + static JSValue js_sample_load_from(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1269 + (void)this_val; 1270 + if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1); 1271 + const char *path = JS_ToCString(ctx, argv[0]); 1272 + if (!path) return JS_NewInt32(ctx, -1); 1273 + int result = audio_sample_load(current_rt->audio, path); 1274 + ac_log("[sample] loadFrom(%s) -> %d samples\n", path, result); 1275 + JS_FreeCString(ctx, path); 1276 + return JS_NewInt32(ctx, result); 1277 + } 1278 + 1255 1279 // sound.sample.loadData(float32array, rate) — load sample data from JS array 1256 1280 static JSValue js_sample_load_data(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) { 1257 1281 (void)this_val; ··· 2042 2066 JS_SetPropertyStr(ctx, samp, "kill", JS_NewCFunction(ctx, js_sample_kill, "kill", 2)); 2043 2067 JS_SetPropertyStr(ctx, samp, "getData", JS_NewCFunction(ctx, js_sample_get_data, "getData", 0)); 2044 2068 JS_SetPropertyStr(ctx, samp, "loadData", JS_NewCFunction(ctx, js_sample_load_data, "loadData", 2)); 2069 + JS_SetPropertyStr(ctx, samp, "saveTo", JS_NewCFunction(ctx, js_sample_save_to, "saveTo", 1)); 2070 + JS_SetPropertyStr(ctx, samp, "loadFrom", JS_NewCFunction(ctx, js_sample_load_from, "loadFrom", 1)); 2045 2071 JS_SetPropertyStr(ctx, sound, "sample", samp); 2046 2072 2047 2073 // TTS