- 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>
343 lines
15 KiB
HTML
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 1 = <code>C-2</code>, MIDI 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>
|