diff --git a/lith/server.mjs b/lith/server.mjs index 4039848ee9..26167f4fa6 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1001,8 +1001,8 @@ app.all("/presigned-download-url/*rest", directFn("presigned-url")); app.all("/docs", directFn("docs")); app.all("/docs.json", directFn("docs")); app.all("/docs/*rest", directFn("docs")); -app.all("/pop-demos", directFn("pop-demos")); -app.all("/pop-demos/*rest", directFn("pop-demos")); +app.all("/pop", directFn("pop")); +app.all("/pop/*rest", directFn("pop")); app.all("/api-docs", directFn("api-docs")); app.all("/api-docs.json", directFn("api-docs")); app.all("/media-collection", directFn("media-collection")); diff --git a/pop/bin/render-instrument-samples.mjs b/pop/bin/render-instrument-samples.mjs new file mode 100644 index 0000000000..01b48e1493 --- /dev/null +++ b/pop/bin/render-instrument-samples.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +// render-instrument-samples.mjs — render a short demo mp3 for each pop +// instrument synth. One file per instrument, with preset segments played +// back to back (small gap between them). Used by the pop dashboard page. +// +// Usage: node pop/bin/render-instrument-samples.mjs +// Output: pop/demos/samples/.mp3 (one per instrument) + +import { writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +import { mixEventSupersaw } from "../dance/synths/supersaw.mjs"; +import { mixEventSinePower } from "../dance/synths/sinepower.mjs"; +import { mixEventSkrill } from "../dance/synths/skrill.mjs"; +import { mixEventHoover } from "../hippyhayzard/synths/hoover.mjs"; +import { mixEventZitar } from "../hippyhayzard/synths/zitar.mjs"; +import { mixEventMarimba } from "../marimba/synths/marimba.mjs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const POP = resolve(HERE, ".."); +const OUT_DIR = resolve(POP, "demos/samples"); +const SR = 48_000; +const GAP_SEC = 0.25; // silence between preset segments + +// ── D-minor arpeggio + held chord ───────────────────────────────────── +// [midi, durSec] pairs — one phrase that shows melodic + sustained character. +// MIDI 62=D4, 65=F4, 69=A4, 72=D5, 74=E5 +const PHRASE = [ + [62, 0.35], [65, 0.35], [69, 0.35], [72, 0.35], + [74, 0.40], [72, 0.40], [69, 0.40], + // held triad + [62, 1.50], [65, 1.50], [69, 1.50], +]; + +// A lower register phrase for bass-voiced presets (skrill sub, etc.) +const PHRASE_LOW = [ + [50, 0.40], [53, 0.40], [57, 0.40], [60, 0.40], + [62, 0.45], [60, 0.45], [57, 0.45], + [50, 1.60], [53, 1.60], [57, 1.60], +]; + +// Build a list of {startSec, midi, durSec, gain, preset} events from +// multiple preset segments, laying them out sequentially with a gap. +function buildEvents(presets, phrase = PHRASE, gain = 0.85) { + const events = []; + let cursor = 0; + for (const preset of presets) { + for (const [midi, durSec] of phrase) { + events.push({ startSec: cursor, midi, durSec, gain, preset }); + cursor += durSec; + } + cursor += GAP_SEC; + } + return { events, totalSec: cursor }; +} + +// Peak-normalize a Float32Array to ~0.9. +function normalize(buf) { + let peak = 0; + for (let i = 0; i < buf.length; i++) { const a = Math.abs(buf[i]); if (a > peak) peak = a; } + if (peak > 0) { const nrm = 0.9 / peak; for (let i = 0; i < buf.length; i++) buf[i] *= nrm; } +} + +// Write raw f32le, encode to mp3 via ffmpeg, then delete the raw file. +function encodeToMp3(buf, outPath) { + const rawPath = `${outPath}.f32.raw`; + const b = Buffer.alloc(buf.length * 4); + for (let i = 0; i < buf.length; i++) b.writeFloatLE(buf[i], i * 4); + writeFileSync(rawPath, b); + + const ff = spawnSync("ffmpeg", [ + "-hide_banner", "-y", "-loglevel", "error", + "-f", "f32le", "-ar", String(SR), "-ac", "1", "-i", rawPath, + "-c:a", "libmp3lame", "-q:a", "4", outPath, + ], { stdio: "inherit" }); + + try { unlinkSync(rawPath); } catch {} + if (ff.status !== 0) throw new Error(`ffmpeg failed for ${outPath}`); +} + +// Render one instrument: mix all events, normalize, encode. +function renderInstrument({ id, fn, presets, phrase, gain, tail = 2.0, opts = {} }) { + const { events, totalSec } = buildEvents(presets, phrase, gain); + const bufLen = Math.ceil((totalSec + tail) * SR); + const out = new Float32Array(bufLen); + + console.log(`→ ${id} presets=[${presets.join(", ")}] events=${events.length}`); + for (const ev of events) { + try { + fn(ev, out, { sampleRate: SR, preset: ev.preset, ...opts }); + } catch (err) { + console.warn(` ⚠ ${id} preset=${ev.preset} midi=${ev.midi}: ${err.message}`); + } + } + + normalize(out); + const outPath = resolve(OUT_DIR, `${id}.mp3`); + encodeToMp3(out, outPath); + console.log(`✓ ${outPath}`); +} + +// ── instrument table ─────────────────────────────────────────────────── +const INSTRUMENTS = [ + { + id: "supersaw", + fn: mixEventSupersaw, + presets: ["lead", "pad", "stab"], + phrase: PHRASE, + }, + { + id: "sinepower", + fn: mixEventSinePower, + presets: ["lead", "pad", "stab"], + phrase: PHRASE, + }, + { + id: "skrill", + fn: mixEventSkrill, + presets: ["growl", "reese", "sub"], + phrase: PHRASE_LOW, + opts: { bpm: 138 }, + }, + { + id: "hoover", + fn: mixEventHoover, + presets: ["whoop", "stab", "pad"], + phrase: PHRASE, + opts: { bpm: 174 }, + }, + { + id: "zitar", + fn: mixEventZitar, + presets: ["sitar", "lead", "drone"], + phrase: PHRASE, + }, + { + id: "marimba", + fn: mixEventMarimba, + presets: ["rosewood", "vibraphone", "glockenspiel"], + phrase: PHRASE, + }, +]; + +// ── main ─────────────────────────────────────────────────────────────── +mkdirSync(OUT_DIR, { recursive: true }); + +let ok = 0, fail = 0; +for (const inst of INSTRUMENTS) { + try { + renderInstrument(inst); + ok++; + } catch (err) { + console.error(`✗ ${inst.id}: ${err.message}`); + fail++; + } +} + +console.log(`\ndone — ${ok} rendered, ${fail} failed → ${OUT_DIR}`); diff --git a/pop/demos.html b/pop/demos.html index 9ef819278f..6231833f59 100644 --- a/pop/demos.html +++ b/pop/demos.html @@ -3,86 +3,181 @@ -the pop menu — playable demos +pop dashboard — songs · instruments · tools
-

the pop menu

-
playable demos · /pop toolkit
+

pop

- +
+
tool demos