Skip to content

Instantly share code, notes, and snippets.

@EncodeTheCode
Created May 12, 2026 15:44
Show Gist options
  • Select an option

  • Save EncodeTheCode/88a1255abbedd055071a41c96189a998 to your computer and use it in GitHub Desktop.

Select an option

Save EncodeTheCode/88a1255abbedd055071a41c96189a998 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polyphonic Chiptune Converter</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lamejs/1.2.1/lame.min.js"></script>
<style>
:root {
--bg: #0f1220;
--panel: #171b2e;
--panel2: #1f2540;
--text: #e9ecff;
--muted: #9aa3c7;
--accent: #8dd6ff;
--accent2: #b7ffb0;
--danger: #ff8f8f;
--shadow: 0 12px 32px rgba(0,0,0,.35);
--radius: 18px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: radial-gradient(circle at top, #1a2040 0%, var(--bg) 60%);
color: var(--text);
display: grid;
place-items: center;
padding: 20px;
}
.app {
width: min(1100px, 100%);
background: linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.01));
border: 1px solid rgba(255,255,255,.08);
border-radius: 24px;
box-shadow: var(--shadow);
overflow: hidden;
}
header {
padding: 24px 24px 12px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
h1 {
margin: 0;
font-size: clamp(24px, 3vw, 36px);
letter-spacing: .2px;
}
.sub { color: var(--muted); margin-top: 6px; line-height: 1.45; max-width: 72ch; }
.grid {
display: grid;
grid-template-columns: 1.15fr .85fr;
gap: 18px;
padding: 18px 24px 24px;
}
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
.card {
background: linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.015));
border: 1px solid rgba(255,255,255,.08);
border-radius: var(--radius);
padding: 18px;
}
.dropzone {
display: grid;
gap: 12px;
align-content: center;
min-height: 180px;
border: 2px dashed rgba(141,214,255,.38);
border-radius: 18px;
background: rgba(141,214,255,.04);
padding: 18px;
transition: .2s ease;
user-select: none;
}
.dropzone.dragover { transform: scale(1.01); background: rgba(141,214,255,.08); border-color: rgba(141,214,255,.8); }
.dropzone strong { font-size: 18px; }
.dropzone small, .meta, .status, label { color: var(--muted); }
.controls {
display: grid;
gap: 12px;
margin-top: 16px;
}
.row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 700px) { .row { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
button, select, input[type="range"], input[type="number"] {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.09);
background: var(--panel2);
color: var(--text);
padding: 12px 14px;
font: inherit;
outline: none;
}
button { cursor: pointer; font-weight: 700; }
button.primary { background: linear-gradient(180deg, #8dd6ff, #5fb7ff); color: #08111e; border: none; }
button.good { background: linear-gradient(180deg, #b7ffb0, #8deb87); color: #081108; border: none; }
button.danger { background: linear-gradient(180deg, #ffb0b0, #ff8686); color: #190808; border: none; }
button:disabled { opacity: .5; cursor: not-allowed; }
.status {
min-height: 20px;
line-height: 1.35;
word-break: break-word;
}
.meter {
height: 10px;
background: rgba(255,255,255,.08);
border-radius: 999px;
overflow: hidden;
}
.meter > div {
height: 100%; width: 0%;
background: linear-gradient(90deg, #8dd6ff, #b7ffb0);
transition: width .15s linear;
}
.list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-radius: 999px;
background: rgba(255,255,255,.06);
color: var(--muted);
font-size: 13px;
line-height: 1;
}
.muted { color: var(--muted); }
a.download {
display: inline-flex; align-items: center; justify-content: center;
text-decoration: none;
margin-top: 10px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(141,214,255,.13);
color: var(--text);
border: 1px solid rgba(141,214,255,.22);
width: 100%;
}
input[type="file"] { display: none; }
.file-label {
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
border-radius: 14px; padding: 12px 14px; width: 100%;
}
.small { font-size: 13px; }
.split {
display: grid; gap: 10px;
grid-template-columns: 1fr 1fr;
}
.kv { display: grid; gap: 8px; }
.kv label { font-size: 13px; }
.footer-note { padding: 0 24px 24px; color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<div class="app">
<header>
<div>
<h1>Polyphonic Chiptune Converter</h1>
<div class="sub">Drop an MP3, WAV, or other browser-supported audio file. The player analyzes the spectrum, turns it into limited-voice chip-style synthesis, and lets you export the result as MP3.</div>
</div>
<span class="pill" id="compatPill">Ready</span>
</header>
<div class="grid">
<section class="card">
<div id="dropzone" class="dropzone">
<strong>Drag and drop audio here</strong>
<small>or choose a file with the button below</small>
<label class="file-label" for="fileInput">Choose audio file</label>
<input id="fileInput" type="file" accept="audio/*" />
</div>
<div class="controls">
<div class="row">
<button id="playBtn" class="primary" disabled>Play</button>
<button id="pauseBtn" disabled>Pause</button>
<button id="stopBtn" class="danger" disabled>Stop</button>
<button id="exportBtn" class="good" disabled>Export MP3</button>
</div>
<div class="split">
<div class="kv">
<label for="voices">Polyphony voices</label>
<select id="voices">
<option value="2">2 voices</option>
<option value="4" selected>4 voices</option>
<option value="6">6 voices</option>
<option value="8">8 voices</option>
</select>
</div>
<div class="kv">
<label for="tone">Tone</label>
<select id="tone">
<option value="square" selected>Square</option>
<option value="triangle">Triangle</option>
<option value="sawtooth">Sawtooth</option>
<option value="mixed">Mixed chip</option>
</select>
</div>
</div>
<div class="split">
<div class="kv">
<label for="delayMix">Echo / delay mix</label>
<input id="delayMix" type="range" min="0" max="0.45" step="0.01" value="0.12" />
</div>
<div class="kv">
<label for="lowpass">Brightness</label>
<input id="lowpass" type="range" min="2000" max="14000" step="100" value="7200" />
</div>
</div>
<div class="meter"><div id="progressBar"></div></div>
<div class="status" id="status">Load a file to begin.</div>
</div>
</section>
<aside class="card">
<div class="pill">Processed output</div>
<div class="list">
<div class="meta"><strong>How it works</strong><br/>The file is decoded in-browser, analyzed in short windows, and converted into note events that drive chip-style oscillators. This keeps playback light and gives a ringtone-like result.</div>
<div class="meta"><strong>Export</strong><br/>The rendered synth output is encoded to MP3 in the browser using LAME.js when available.</div>
<div class="meta"><strong>Limitations</strong><br/>True note separation from a mixed MP3 is approximate. Clean monophonic melodies produce the best results.</div>
<div id="fileMeta" class="meta">No file loaded.</div>
<div id="downloadSlot"></div>
</div>
</aside>
</div>
<div class="footer-note">Tip: For ring-tone style results, try melody stems, vocal leads, or simple songs with a clear dominant line.</div>
</div>
<script>
const el = {
dropzone: document.getElementById('dropzone'),
fileInput: document.getElementById('fileInput'),
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'),
stopBtn: document.getElementById('stopBtn'),
exportBtn: document.getElementById('exportBtn'),
voices: document.getElementById('voices'),
tone: document.getElementById('tone'),
delayMix: document.getElementById('delayMix'),
lowpass: document.getElementById('lowpass'),
status: document.getElementById('status'),
fileMeta: document.getElementById('fileMeta'),
progressBar: document.getElementById('progressBar'),
compatPill: document.getElementById('compatPill'),
downloadSlot: document.getElementById('downloadSlot')
};
let audioCtx = null;
let buffer = null;
let events = [];
let rendered = null;
let sourceFileName = 'output';
let isPlaying = false;
let startOffset = 0;
let startTime = 0;
let schedulerTimer = null;
let activeNodes = [];
let sessionDuration = 0;
const FFT_SIZE = 4096;
const HOP_SIZE = 1024;
const MIN_FREQ = 70;
const MAX_FREQ = 2200;
const MAX_VOICES = 8;
function setStatus(text) { el.status.textContent = text; }
function setProgress(p) { el.progressBar.style.width = `${Math.max(0, Math.min(100, p))}%`; }
function fmtTime(sec) {
if (!isFinite(sec)) return '0:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function ensureContext() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
audioCtx = new AC({ latencyHint: 'interactive' });
}
return audioCtx;
}
async function decodeFile(file) {
const ctx = ensureContext();
const arr = await file.arrayBuffer();
const decoded = await ctx.decodeAudioData(arr.slice(0));
return decoded;
}
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function hannWindow(i, n) {
return 0.5 * (1 - Math.cos((2 * Math.PI * i) / (n - 1)));
}
function nearestMidi(freq) {
return Math.round(69 + 12 * Math.log2(freq / 440));
}
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function blackmanHarris(n, N) {
const a0 = 0.35875, a1 = 0.48829, a2 = 0.14128, a3 = 0.01168;
const x = 2 * Math.PI * n / (N - 1);
return a0 - a1 * Math.cos(x) + a2 * Math.cos(2*x) - a3 * Math.cos(3*x);
}
function fft(re, im) {
const n = re.length;
if (n <= 1) return;
let evenRe = new Float64Array(n / 2), evenIm = new Float64Array(n / 2);
let oddRe = new Float64Array(n / 2), oddIm = new Float64Array(n / 2);
for (let i = 0; i < n / 2; i++) {
evenRe[i] = re[2 * i]; evenIm[i] = im[2 * i];
oddRe[i] = re[2 * i + 1]; oddIm[i] = im[2 * i + 1];
}
fft(evenRe, evenIm);
fft(oddRe, oddIm);
for (let k = 0; k < n / 2; k++) {
const ang = -2 * Math.PI * k / n;
const cos = Math.cos(ang), sin = Math.sin(ang);
const tre = cos * oddRe[k] - sin * oddIm[k];
const tim = sin * oddRe[k] + cos * oddIm[k];
re[k] = evenRe[k] + tre;
im[k] = evenIm[k] + tim;
re[k + n / 2] = evenRe[k] - tre;
im[k + n / 2] = evenIm[k] - tim;
}
}
function analyseBufferToEvents(audioBuffer, progressCb) {
const sr = audioBuffer.sampleRate;
const data = audioBuffer.getChannelData(0);
const nFrames = Math.max(1, Math.floor((data.length - FFT_SIZE) / HOP_SIZE));
const active = new Map();
const out = [];
const maxVoices = parseInt(el.voices.value, 10);
let prevNotes = new Set();
const freqForBin = (bin) => bin * sr / FFT_SIZE;
const binForFreq = (freq) => Math.round(freq * FFT_SIZE / sr);
for (let frame = 0; frame < nFrames; frame++) {
const start = frame * HOP_SIZE;
const re = new Float64Array(FFT_SIZE);
const im = new Float64Array(FFT_SIZE);
for (let i = 0; i < FFT_SIZE; i++) {
const idx = start + i;
const sample = idx < data.length ? data[idx] : 0;
re[i] = sample * blackmanHarris(i, FFT_SIZE);
}
fft(re, im);
const mags = [];
const minBin = Math.max(1, binForFreq(MIN_FREQ));
const maxBin = Math.min(FFT_SIZE / 2 - 1, binForFreq(MAX_FREQ));
for (let b = minBin + 1; b < maxBin - 1; b++) {
const mag = Math.hypot(re[b], im[b]);
if (mag > Math.hypot(re[b - 1], im[b - 1]) && mag >= Math.hypot(re[b + 1], im[b + 1])) {
mags.push({ b, mag, freq: freqForBin(b) });
}
}
mags.sort((a, b) => b.mag - a.mag);
const chosen = [];
for (const p of mags) {
if (chosen.length >= maxVoices) break;
const midi = nearestMidi(p.freq);
const snapped = midiToFreq(midi);
const tooClose = chosen.some(c => Math.abs(Math.log2(c.freq / snapped)) < 0.08);
if (!tooClose && p.mag > 0.01) chosen.push({ freq: snapped, vel: clamp(p.mag / 12, 0.08, 1), midi });
}
const nowNotes = new Set(chosen.map(c => c.midi));
for (const note of prevNotes) {
if (!nowNotes.has(note) && active.has(note)) {
const ev = active.get(note);
ev.end = frame * HOP_SIZE / sr;
out.push(ev);
active.delete(note);
}
}
for (const c of chosen) {
if (!active.has(c.midi)) {
active.set(c.midi, {
start: frame * HOP_SIZE / sr,
end: (frame + 1) * HOP_SIZE / sr,
freq: c.freq,
vel: c.vel,
midi: c.midi
});
} else {
const ev = active.get(c.midi);
ev.end = (frame + 1) * HOP_SIZE / sr;
ev.vel = Math.max(ev.vel, c.vel);
}
}
prevNotes = nowNotes;
if (progressCb && frame % 6 === 0) progressCb(frame / nFrames);
}
for (const ev of active.values()) out.push(ev);
out.sort((a, b) => a.start - b.start);
// Merge short adjacent notes of same pitch.
const merged = [];
for (const ev of out) {
const last = merged[merged.length - 1];
if (last && last.midi === ev.midi && ev.start - last.end < 0.035) {
last.end = ev.end;
last.vel = Math.max(last.vel, ev.vel);
} else {
merged.push({ ...ev });
}
}
return merged;
}
function getToneType(index) {
const t = el.tone.value;
if (t === 'mixed') return ['square', 'triangle', 'sawtooth'][index % 3];
return t;
}
function clearNodes() {
for (const n of activeNodes) {
try { n.stop?.(); } catch {}
try { n.disconnect?.(); } catch {}
}
activeNodes = [];
}
function stopPlayback(reset = true) {
if (schedulerTimer) {
clearInterval(schedulerTimer);
schedulerTimer = null;
}
clearNodes();
isPlaying = false;
if (reset) startOffset = 0;
el.playBtn.textContent = 'Play';
el.pauseBtn.textContent = 'Pause';
el.pauseBtn.disabled = true;
el.stopBtn.disabled = true;
if (audioCtx) {
audioCtx.suspend().catch(() => {});
}
setStatus('Stopped.');
}
function scheduleEvents(fromTime = 0) {
const ctx = ensureContext();
const master = ctx.createGain();
master.gain.value = 0.9;
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -18;
compressor.knee.value = 12;
compressor.ratio.value = 8;
compressor.attack.value = 0.004;
compressor.release.value = 0.14;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = parseFloat(el.lowpass.value);
filter.Q.value = 0.7;
const delay = ctx.createDelay(0.35);
delay.delayTime.value = 0.12;
const feedback = ctx.createGain();
feedback.gain.value = 0.22;
const wet = ctx.createGain();
wet.gain.value = parseFloat(el.delayMix.value);
const dry = ctx.createGain();
dry.gain.value = 1 - parseFloat(el.delayMix.value);
master.connect(filter);
filter.connect(dry);
filter.connect(delay);
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
dry.connect(compressor);
wet.connect(compressor);
compressor.connect(ctx.destination);
const localNodes = [];
const voiceLimit = parseInt(el.voices.value, 10);
const playable = events.filter(ev => ev.end > fromTime);
const startAt = ctx.currentTime + 0.08;
const total = sessionDuration;
for (const ev of playable) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const type = getToneType(ev.midi % 3);
osc.type = type;
osc.frequency.setValueAtTime(ev.freq, startAt + Math.max(0, ev.start - fromTime));
osc.detune.setValueAtTime((ev.midi % 12) * 0.15, startAt + Math.max(0, ev.start - fromTime));
const t0 = startAt + Math.max(0, ev.start - fromTime);
const t1 = startAt + Math.max(0, ev.end - fromTime);
const attack = 0.01;
const release = 0.045;
const vol = clamp(ev.vel * 0.24, 0.02, 0.28);
gain.gain.setValueAtTime(0.0001, t0);
gain.gain.linearRampToValueAtTime(vol, t0 + attack);
gain.gain.setValueAtTime(vol, Math.max(t0 + attack, t1 - release));
gain.gain.exponentialRampToValueAtTime(0.0001, t1);
osc.connect(gain);
gain.connect(master);
osc.start(t0);
osc.stop(t1 + 0.02);
localNodes.push(osc, gain);
}
activeNodes = localNodes;
ctx.resume().catch(() => {});
const started = performance.now();
schedulerTimer = setInterval(() => {
if (!isPlaying) return;
const elapsed = (performance.now() - started) / 1000;
const pos = fromTime + elapsed;
const pct = clamp(pos / total, 0, 1);
setProgress(pct * 100);
setStatus(`Playing ${fmtTime(pos)} / ${fmtTime(total)} — ${events.length} events`);
if (pos >= total + 0.3) {
stopPlayback(true);
setProgress(0);
}
}, 80);
}
async function play() {
if (!buffer || !events.length) return;
ensureContext();
if (audioCtx.state === 'suspended') await audioCtx.resume();
isPlaying = true;
el.playBtn.textContent = 'Playing...';
el.pauseBtn.disabled = false;
el.stopBtn.disabled = false;
scheduleEvents(startOffset);
}
function pause() {
if (!isPlaying) return;
const ctx = ensureContext();
startOffset += Math.max(0, ctx.currentTime - startTime);
clearNodes();
if (schedulerTimer) clearInterval(schedulerTimer);
schedulerTimer = null;
isPlaying = false;
el.playBtn.textContent = 'Resume';
el.pauseBtn.disabled = true;
setStatus(`Paused at ${fmtTime(startOffset)}.`);
}
function buildTimeline() {
if (!buffer) return;
setStatus('Analyzing audio and building polyphonic note timeline...');
setProgress(0);
const total = buffer.duration;
sessionDuration = total;
const start = performance.now();
events = analyseBufferToEvents(buffer, (p) => {
setProgress(p * 100);
const secs = p * total;
setStatus(`Analyzing ${fmtTime(secs)} / ${fmtTime(total)}...`);
});
const ms = Math.round(performance.now() - start);
setProgress(100);
setStatus(`Ready: ${events.length} note events built in ${ms} ms.`);
el.playBtn.disabled = false;
el.pauseBtn.disabled = true;
el.stopBtn.disabled = false;
el.exportBtn.disabled = false;
el.playBtn.textContent = 'Play';
}
function makeWaveForm(ctx, type, freq, midi, vel) {
const osc = ctx.createOscillator();
osc.type = type;
osc.frequency.value = freq;
osc.detune.value = (midi % 12) * 0.12;
return osc;
}
async function renderOfflineToBuffer() {
const sampleRate = 44100;
const length = Math.ceil(sessionDuration * sampleRate) + sampleRate;
const ctx = new OfflineAudioContext(2, length, sampleRate);
const master = ctx.createGain();
master.gain.value = 0.9;
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -18;
compressor.knee.value = 12;
compressor.ratio.value = 8;
compressor.attack.value = 0.004;
compressor.release.value = 0.14;
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = parseFloat(el.lowpass.value);
filter.Q.value = 0.7;
const delay = ctx.createDelay(0.35);
delay.delayTime.value = 0.12;
const feedback = ctx.createGain();
feedback.gain.value = 0.22;
const wet = ctx.createGain();
wet.gain.value = parseFloat(el.delayMix.value);
const dry = ctx.createGain();
dry.gain.value = 1 - parseFloat(el.delayMix.value);
master.connect(filter);
filter.connect(dry);
filter.connect(delay);
delay.connect(feedback);
feedback.connect(delay);
delay.connect(wet);
dry.connect(compressor);
wet.connect(compressor);
compressor.connect(ctx.destination);
for (const ev of events) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
const type = getToneType(ev.midi % 3);
osc.type = type;
osc.frequency.setValueAtTime(ev.freq, ev.start);
osc.detune.setValueAtTime((ev.midi % 12) * 0.15, ev.start);
const attack = 0.01;
const release = 0.045;
const vol = clamp(ev.vel * 0.24, 0.02, 0.28);
gain.gain.setValueAtTime(0.0001, ev.start);
gain.gain.linearRampToValueAtTime(vol, ev.start + attack);
gain.gain.setValueAtTime(vol, Math.max(ev.start + attack, ev.end - release));
gain.gain.exponentialRampToValueAtTime(0.0001, ev.end);
osc.connect(gain);
gain.connect(master);
osc.start(ev.start);
osc.stop(ev.end + 0.02);
}
return await ctx.startRendering();
}
function interleaveTo16BitStereo(buffer) {
const ch0 = buffer.getChannelData(0);
const ch1 = buffer.numberOfChannels > 1 ? buffer.getChannelData(1) : ch0;
const len = Math.min(ch0.length, ch1.length);
const pcm = new Int16Array(len * 2);
for (let i = 0; i < len; i++) {
const l = clamp(ch0[i], -1, 1);
const r = clamp(ch1[i], -1, 1);
pcm[i * 2] = l < 0 ? l * 0x8000 : l * 0x7FFF;
pcm[i * 2 + 1] = r < 0 ? r * 0x8000 : r * 0x7FFF;
}
return pcm;
}
function encodeMp3FromBuffer(renderedBuffer) {
if (typeof lamejs === 'undefined') throw new Error('LAME.js failed to load.');
const sampleRate = renderedBuffer.sampleRate;
const channels = Math.min(2, renderedBuffer.numberOfChannels);
const mp3Encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
const blockSize = 1152;
const ch0 = renderedBuffer.getChannelData(0);
const ch1 = channels > 1 ? renderedBuffer.getChannelData(1) : ch0;
const mp3Data = [];
for (let i = 0; i < ch0.length; i += blockSize) {
const left = new Int16Array(blockSize);
const right = new Int16Array(blockSize);
for (let j = 0; j < blockSize; j++) {
const idx = i + j;
const l = idx < ch0.length ? clamp(ch0[idx], -1, 1) : 0;
const r = idx < ch1.length ? clamp(ch1[idx], -1, 1) : 0;
left[j] = l < 0 ? l * 0x8000 : l * 0x7FFF;
right[j] = r < 0 ? r * 0x8000 : r * 0x7FFF;
}
const chunk = channels === 2 ? mp3Encoder.encodeBuffer(left, right) : mp3Encoder.encodeBuffer(left);
if (chunk.length) mp3Data.push(chunk);
}
const end = mp3Encoder.flush();
if (end.length) mp3Data.push(end);
return new Blob(mp3Data, { type: 'audio/mpeg' });
}
async function exportMp3() {
if (!buffer || !events.length) return;
el.exportBtn.disabled = true;
setStatus('Rendering processed audio for export...');
try {
const renderedBuffer = await renderOfflineToBuffer();
setStatus('Encoding MP3...');
const mp3Blob = encodeMp3FromBuffer(renderedBuffer);
const url = URL.createObjectURL(mp3Blob);
const base = sourceFileName.replace(/\.[^.]+$/, '');
el.downloadSlot.innerHTML = `<a class="download" href="${url}" download="${base}_polyphonic_chip.mp3">Download MP3</a>`;
setStatus('MP3 export ready.');
} catch (err) {
console.error(err);
setStatus('MP3 export failed in this browser. You can still play the processed audio here.');
} finally {
el.exportBtn.disabled = false;
}
}
async function loadFile(file) {
if (!file) return;
sourceFileName = file.name || 'audio';
el.fileMeta.textContent = `${sourceFileName} • ${Math.round(file.size / 1024)} KB`;
setStatus('Decoding audio...');
setProgress(5);
stopPlayback(true);
clearNodes();
try {
buffer = await decodeFile(file);
sessionDuration = buffer.duration;
el.compatPill.textContent = `Loaded • ${fmtTime(buffer.duration)}`;
buildTimeline();
} catch (err) {
console.error(err);
setStatus('Could not decode that file in this browser. Try another audio file or a different browser.');
el.compatPill.textContent = 'Decode failed';
}
}
// UI wiring
el.fileInput.addEventListener('change', e => loadFile(e.target.files[0]));
el.dropzone.addEventListener('dragover', e => { e.preventDefault(); el.dropzone.classList.add('dragover'); });
el.dropzone.addEventListener('dragleave', () => el.dropzone.classList.remove('dragover'));
el.dropzone.addEventListener('drop', e => {
e.preventDefault();
el.dropzone.classList.remove('dragover');
const file = e.dataTransfer.files && e.dataTransfer.files[0];
if (file) loadFile(file);
});
el.playBtn.addEventListener('click', async () => {
if (!buffer || !events.length) return;
if (!isPlaying) {
startTime = ensureContext().currentTime;
await play();
}
});
el.pauseBtn.addEventListener('click', pause);
el.stopBtn.addEventListener('click', () => stopPlayback(true));
el.exportBtn.addEventListener('click', exportMp3);
el.delayMix.addEventListener('input', () => {
if (isPlaying) setStatus('Delay mix changed for the next playback or export.');
});
el.lowpass.addEventListener('input', () => {
if (isPlaying) setStatus('Brightness changed for the next playback or export.');
});
// Compatibility check.
el.compatPill.textContent = (window.AudioContext || window.webkitAudioContext)
? 'Web Audio ready'
: 'Web Audio unavailable';
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment