Files
sizzletracker-android/tools/samplepack-builder.html
Reactorcoremeltdown a5fdc7b6c4 Prepare for open-source release under GPL-3.0-or-later; docs & test maintenance
- Add GPL-3.0 LICENSE and SPDX headers (GPL-3.0-or-later) to every source file
  (58 Kotlin, the C++ file, and the sample-pack HTML tool).
- README: state the GPL-3.0-or-later license (+ badge); fix stale "256 beats"
  -> "256 bars"; fix build instructions (the Gradle wrapper is committed);
  mention master recording and the sample-pack tool.
- DEVELOPER_HANDOVER: resync with the codebase — corrected the wrapper note,
  architecture diagram, full file map, the add-instrument/effect guide, Oboe
  (now default with fallback, not an opt-in toggle), moved SF2/XI to implemented,
  and added recently shipped features with an honest remaining/optional list.
- Fix a pre-existing broken unit test: SngFormatTest called the old
  SngFormat.import(text); the API is importInto(project, text). Tests compile
  and pass again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:47:29 +02:00

343 lines
15 KiB
HTML

<!doctype html>
<!-- SPDX-FileCopyrightText: 2026 Reactorcoremeltdown -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sizzletracker Sample Pack Builder</title>
<style>
:root{
--bg:#0B0D0E; --surface:#15181A; --grid:#23282B; --text:#CFE8D8;
--dim:#6E7C74; --accent:#F2C14E; --play:#7CFC9A; --danger:#E05A4B;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--text);
font-family:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace;font-size:14px}
body{padding:16px;max-width:900px;margin:0 auto}
h1{font-size:18px;color:var(--accent);margin:0 0 2px;letter-spacing:.5px}
.sub{color:var(--dim);font-size:11px;margin:0 0 16px}
.bar{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end;
background:var(--surface);border:1px solid var(--grid);padding:12px;margin-bottom:16px}
label{display:block;font-size:10px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
input[type=text]{background:var(--bg);border:1px solid var(--grid);color:var(--text);
font-family:inherit;font-size:14px;padding:8px 10px;width:220px;outline:none}
input[type=text]:focus{border-color:var(--accent)}
button{font-family:inherit;font-size:12px;background:var(--surface);color:var(--text);
border:1px solid var(--grid);padding:9px 14px;cursor:pointer;letter-spacing:.5px}
button:hover{border-color:var(--accent);color:var(--accent)}
button.primary{background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:bold}
button.primary:hover{filter:brightness(1.1)}
button:disabled{opacity:.4;cursor:not-allowed;border-color:var(--grid);color:var(--dim)}
.spacer{flex:1}
.count{font-size:11px;color:var(--dim);align-self:center}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.pad{position:relative;background:var(--surface);border:1px solid var(--grid);
min-height:104px;padding:8px;display:flex;flex-direction:column;cursor:pointer;
transition:border-color .08s,background .08s;user-select:none;overflow:hidden}
.pad:hover{border-color:var(--dim)}
.pad.loaded{border-color:var(--accent);background:rgba(242,193,78,.10)}
/* Pads holding a sample identical to another pad's — flagged in red. */
.pad.dup{border-color:var(--danger);background:rgba(224,90,75,.18)}
.pad.dup .note{color:var(--danger)}
.pad.dup .dupmark{display:block}
.pad.over{border-color:var(--play);background:rgba(124,252,154,.14)}
.pad.playing{border-color:var(--play)}
.pad .note{font-size:15px;color:var(--accent);font-weight:bold}
.pad.empty .note{color:var(--dim)}
.pad .dupmark{display:none;position:absolute;top:26px;right:9px;font-size:8px;
color:var(--danger);letter-spacing:.5px}
.pad .idx{font-size:9px;color:var(--dim);position:absolute;top:8px;right:9px}
.pad .body{flex:1;display:flex;align-items:center;justify-content:center;
text-align:center;font-size:10px;color:var(--dim);word-break:break-all;line-height:1.35;padding:4px 2px}
.pad.loaded .body{color:var(--text)}
.pad .meta{font-size:9px;color:var(--dim);text-align:center;min-height:12px}
.pad .x{position:absolute;bottom:6px;right:8px;color:var(--danger);font-size:14px;
display:none;padding:2px 4px;line-height:1}
.pad.loaded .x{display:block}
.pad .x:hover{color:#ff8a7a}
.hint{margin-top:16px;font-size:11px;color:var(--dim);line-height:1.6}
.hint code{color:var(--text)}
.status{margin-top:10px;font-size:11px;min-height:16px}
.status.err{color:var(--danger)}
.status.ok{color:var(--play)}
</style>
</head>
<body>
<h1>▚ SIZZLETRACKER · SAMPLE PACK BUILDER</h1>
<p class="sub">Drop a WAV / MP3 / audio file on a pad, tap to audition, then download a Sampler <code>.zip</code> bundle ready to import in the app.</p>
<div class="bar">
<div>
<label for="name">Preset name</label>
<input id="name" type="text" value="My Sample Pack" spellcheck="false" autocomplete="off">
</div>
<div class="spacer"></div>
<span class="count" id="count">0 / 16 pads</span>
<button id="clearAll">Clear all</button>
<button id="download" class="primary" disabled>⤓ Download .zip</button>
</div>
<div class="grid" id="grid"></div>
<div class="status" id="status"></div>
<p class="hint">
Pads are labelled with the note each triggers in the app (pad&nbsp;1 = <code>C-2</code>, MIDI&nbsp;36), reading left-to-right, top-to-bottom.
Files are re-encoded to 16-bit mono WAV. Dropping several files on a pad fills it and the pads after it.
Pads holding the <strong>same sample</strong> as another pad are tinted <span style="color:var(--danger)">red</span> (matched by audio content, not file name).
Import the downloaded zip via the Sampler's preset toolbar (<code>Import</code>).
</p>
<input id="filepick" type="file" accept="audio/*,.wav" multiple style="display:none">
<script>
"use strict";
// ---- Note names: exactly matches the app's Pitch.name (Music.kt) ----
const NAMES = ["C-","C#","D-","D#","E-","F-","F#","G-","G#","A-","A#","B-"];
const BASE_NOTE = 36; // pad 0 = C2 (SamplerPads.BASE_NOTE)
function pitchName(midi){ return NAMES[midi % 12] + (Math.floor(midi/12) - 1); }
// ---- sanitize(): matches PresetLibrary.sanitize ----
function sanitize(name){
const t = (name || "").trim().replace(/[^A-Za-z0-9._ -]/g, "_");
return t.length ? t : "preset";
}
// ---- pad state ----
const PADS = 16;
const pads = new Array(PADS).fill(null); // {fileName, mono:Float32Array, sampleRate, buffer:AudioBuffer}
let audioCtx = null;
function ctx(){ return audioCtx || (audioCtx = new (window.AudioContext || window.webkitAudioContext)()); }
// ---- audio helpers ----
function toMono(ab){
const ch = ab.numberOfChannels, len = ab.length, out = new Float32Array(len);
for (let c=0;c<ch;c++){ const d = ab.getChannelData(c); for (let i=0;i<len;i++) out[i]+=d[i]; }
if (ch>1) for (let i=0;i<len;i++) out[i]/=ch;
return out;
}
// Content signature for a sample: FNV-1a over the 16-bit-quantised PCM plus
// length + rate. Two pads with the SAME audio produce the same signature
// regardless of the file name they came from, so duplicates are detected by
// content, not by name.
function sigOf(mono, sampleRate){
let h = 0x811c9dc5 >>> 0;
const mix = b => { h = Math.imul(h ^ (b & 0xff), 0x01000193) >>> 0; };
const n = mono.length;
mix(n); mix(n >> 8); mix(n >> 16); mix(sampleRate); mix(sampleRate >> 8);
for (let i=0;i<n;i++){ const v = (Math.max(-1, Math.min(1, mono[i])) * 32767) | 0; mix(v); mix(v >> 8); }
return n.toString(16) + ":" + sampleRate.toString(16) + ":" + (h >>> 0).toString(16);
}
function encodeWav(mono, sampleRate){
const n = mono.length, buf = new ArrayBuffer(44 + n*2), dv = new DataView(buf);
let p = 0;
const str = s => { for (let i=0;i<s.length;i++) dv.setUint8(p++, s.charCodeAt(i)); };
str("RIFF"); dv.setUint32(p,36+n*2,true); p+=4; str("WAVE");
str("fmt "); dv.setUint32(p,16,true); p+=4;
dv.setUint16(p,1,true); p+=2; // PCM
dv.setUint16(p,1,true); p+=2; // mono
dv.setUint32(p,sampleRate,true); p+=4;
dv.setUint32(p,sampleRate*2,true); p+=4; // byteRate
dv.setUint16(p,2,true); p+=2; // blockAlign
dv.setUint16(p,16,true); p+=2; // bits
str("data"); dv.setUint32(p,n*2,true); p+=4;
for (let i=0;i<n;i++){ let v = Math.max(-1, Math.min(1, mono[i])); dv.setInt16(p, v<0 ? v*32768 : v*32767, true); p+=2; }
return new Uint8Array(buf);
}
// ---- ZIP writer (STORE / no compression, self-contained) ----
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n=0;n<256;n++){ let c=n; for (let k=0;k<8;k++) c = (c&1) ? (0xEDB88320 ^ (c>>>1)) : (c>>>1); t[n]=c>>>0; }
return t;
})();
function crc32(buf){
let c = 0xFFFFFFFF;
for (let i=0;i<buf.length;i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c>>>8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
function zipStore(entries){ // entries: [{name, data:Uint8Array}]
const enc = new TextEncoder();
const parts = [], central = [];
let offset = 0;
for (const e of entries){
const nb = enc.encode(e.name), crc = crc32(e.data), size = e.data.length;
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0,0x04034b50,true); lh.setUint16(4,20,true); lh.setUint16(6,0,true);
lh.setUint16(8,0,true); // method = store
lh.setUint16(10,0,true); lh.setUint16(12,0x21,true); // time / date (1980-01-01)
lh.setUint32(14,crc,true); lh.setUint32(18,size,true); lh.setUint32(22,size,true);
lh.setUint16(26,nb.length,true); lh.setUint16(28,0,true);
parts.push(new Uint8Array(lh.buffer), nb, e.data);
const ch = new DataView(new ArrayBuffer(46));
ch.setUint32(0,0x02014b50,true); ch.setUint16(4,20,true); ch.setUint16(6,20,true);
ch.setUint16(8,0,true); ch.setUint16(10,0,true); ch.setUint16(12,0,true); ch.setUint16(14,0x21,true);
ch.setUint32(16,crc,true); ch.setUint32(20,size,true); ch.setUint32(24,size,true);
ch.setUint16(28,nb.length,true); ch.setUint16(30,0,true); ch.setUint16(32,0,true);
ch.setUint16(34,0,true); ch.setUint16(36,0,true); ch.setUint32(38,0,true); ch.setUint32(42,offset,true);
central.push({header:new Uint8Array(ch.buffer), name:nb});
offset += 30 + nb.length + size;
}
const cdParts = []; let cdSize = 0;
for (const c of central){ cdParts.push(c.header, c.name); cdSize += c.header.length + c.name.length; }
const eo = new DataView(new ArrayBuffer(22));
eo.setUint32(0,0x06054b50,true); eo.setUint16(4,0,true); eo.setUint16(6,0,true);
eo.setUint16(8,central.length,true); eo.setUint16(10,central.length,true);
eo.setUint32(12,cdSize,true); eo.setUint32(16,offset,true); eo.setUint16(20,0,true);
return new Blob([...parts, ...cdParts, new Uint8Array(eo.buffer)], {type:"application/zip"});
}
// ---- preset (.szp) text: matches PresetLibrary.exportSampler ----
function buildSzp(name){
const safe = sanitize(name);
const lines = ["SIZZLE-PRESET 1", "TYPE=SAMPLER", "NAME=" + name, "volume=0.8"];
for (let p=0;p<PADS;p++){
if (!pads[p]) continue;
const wn = safe + "_pad" + p + ".wav";
lines.push("pad" + p + "File=" + wn);
lines.push("pad" + p + "S=0.0");
lines.push("pad" + p + "E=1.0");
}
return lines.join("\n") + "\n";
}
// ---- UI ----
const grid = document.getElementById("grid");
const statusEl = document.getElementById("status");
const countEl = document.getElementById("count");
const downloadBtn = document.getElementById("download");
const nameInput = document.getElementById("name");
const filepick = document.getElementById("filepick");
let pickTarget = 0;
function setStatus(msg, kind){ statusEl.textContent = msg || ""; statusEl.className = "status" + (kind ? " " + kind : ""); }
function refresh(){
// Count how many pads share each content signature so duplicates can be tinted.
const counts = {};
for (let p=0;p<PADS;p++){ const s = pads[p]; if (s) counts[s.sig] = (counts[s.sig] || 0) + 1; }
let n = 0, dups = 0;
for (let p=0;p<PADS;p++){
const el = document.getElementById("pad" + p);
const s = pads[p];
const dup = !!s && counts[s.sig] > 1;
el.classList.toggle("loaded", !!s);
el.classList.toggle("empty", !s);
el.classList.toggle("dup", dup);
el.querySelector(".body").textContent = s ? s.fileName : "drop / click";
el.querySelector(".meta").textContent = s
? (s.buffer.duration.toFixed(2) + "s · " + Math.round(s.sampleRate/1000*10)/10 + "k") : "";
if (s) n++;
if (dup) dups++;
}
countEl.textContent = n + " / 16 pads" + (dups ? " · " + dups + " duplicate" + (dups===1?"":"s") : "");
downloadBtn.disabled = n === 0;
}
async function loadFileToPad(pad, file){
try{
const buf = await file.arrayBuffer();
const ab = await ctx().decodeAudioData(buf);
const mono = toMono(ab);
pads[pad] = { fileName: file.name, mono, sampleRate: ab.sampleRate, buffer: ab, sig: sigOf(mono, ab.sampleRate) };
refresh();
setStatus("Loaded “" + file.name + "” → pad " + (pad+1) + " (" + pitchName(BASE_NOTE+pad) + ")", "ok");
}catch(e){
setStatus("Could not decode “" + file.name + "” — not a supported audio file.", "err");
}
}
// Load a list of files starting at `pad`, filling consecutive pads.
async function loadFiles(pad, fileList){
const files = Array.from(fileList).filter(f => f);
for (let i=0;i<files.length && (pad+i)<PADS;i++){
await loadFileToPad(pad + i, files[i]);
}
}
function play(pad){
const s = pads[pad];
if (!s) return;
const c = ctx();
if (c.state === "suspended") c.resume();
const src = c.createBufferSource();
src.buffer = s.buffer;
src.connect(c.destination);
src.start();
const el = document.getElementById("pad" + pad);
el.classList.add("playing");
src.onended = () => el.classList.remove("playing");
}
// Build the 16 pads.
for (let p=0;p<PADS;p++){
const el = document.createElement("div");
el.className = "pad empty";
el.id = "pad" + p;
el.innerHTML =
'<div class="note">' + pitchName(BASE_NOTE + p) + '</div>' +
'<div class="idx">' + (p+1) + '</div>' +
'<div class="dupmark">DUP</div>' +
'<div class="body">drop / click</div>' +
'<div class="meta"></div>' +
'<div class="x" title="Clear pad">✕</div>';
el.addEventListener("click", (e) => {
if (e.target.classList.contains("x")) return;
if (pads[p]) play(p);
else { pickTarget = p; filepick.value = ""; filepick.click(); }
});
el.querySelector(".x").addEventListener("click", (e) => {
e.stopPropagation();
pads[p] = null; refresh(); setStatus("Cleared pad " + (p+1), "");
});
["dragenter","dragover"].forEach(ev => el.addEventListener(ev, (e) => {
e.preventDefault(); e.stopPropagation(); el.classList.add("over");
}));
["dragleave","dragend"].forEach(ev => el.addEventListener(ev, () => el.classList.remove("over")));
el.addEventListener("drop", (e) => {
e.preventDefault(); e.stopPropagation(); el.classList.remove("over");
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) loadFiles(p, e.dataTransfer.files);
});
grid.appendChild(el);
}
filepick.addEventListener("change", () => { if (filepick.files.length) loadFiles(pickTarget, filepick.files); });
// Prevent the browser from navigating when a file is dropped outside a pad.
["dragover","drop"].forEach(ev => window.addEventListener(ev, (e) => e.preventDefault()));
document.getElementById("clearAll").addEventListener("click", () => {
for (let p=0;p<PADS;p++) pads[p] = null;
refresh(); setStatus("Cleared all pads", "");
});
downloadBtn.addEventListener("click", () => {
const name = nameInput.value.trim() || "Sample Pack";
const safe = sanitize(name);
const entries = [{ name: safe + ".szp", data: new TextEncoder().encode(buildSzp(name)) }];
let n = 0;
for (let p=0;p<PADS;p++){
if (!pads[p]) continue;
entries.push({ name: safe + "_pad" + p + ".wav", data: encodeWav(pads[p].mono, pads[p].sampleRate) });
n++;
}
if (n === 0){ setStatus("Assign at least one pad first.", "err"); return; }
const blob = zipStore(entries);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = safe + ".zip"; a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
setStatus("Downloaded " + safe + ".zip — " + n + " pad" + (n===1?"":"s") + ".", "ok");
});
refresh();
</script>
</body>
</html>