Created
May 12, 2026 15:44
-
-
Save EncodeTheCode/88a1255abbedd055071a41c96189a998 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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