From a5960c790e454926db13520784304c2d0fe36df9 Mon Sep 17 00:00:00 2001 From: Jerome Potter Date: Thu, 4 Dec 2025 20:10:07 -0800 Subject: [PATCH 1/5] Improve slider responsiveness --- index.html | 363 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 293 insertions(+), 70 deletions(-) diff --git a/index.html b/index.html index 35b5cc9..eca7fd2 100644 --- a/index.html +++ b/index.html @@ -47,6 +47,15 @@ position: relative; } + @media (min-width: 900px) { + body { justify-content: center; } + .device-case { + max-width: none; + width: calc(100% - 40px); + margin: 0 20px; + } + } + /* --- HEADER --- */ .transport-bar { background: var(--win-gray); @@ -116,13 +125,13 @@ .workspace { flex: 1; padding: 10px; - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: 1fr; gap: 15px; overflow-y: auto; -webkit-overflow-scrolling: touch; background: #888; - padding-bottom: 120px; + padding-bottom: 120px; } .track-section { @@ -134,6 +143,9 @@ animation: slideIn 0.3s ease-out; transition: all 0.2s ease; } + .track-section.active-track { + box-shadow: 0 0 0 2px #fff, 0 0 10px #ff0; + } @keyframes slideIn { from { opacity:0; transform:translateY(20px); } to { opacity:1; transform:translateY(0); } } .track-header { @@ -181,7 +193,9 @@ /* --- SLIDERS --- */ input[type=range] { -webkit-appearance: none; background: transparent; - touch-action: none; + touch-action: pan-x pan-y; + min-height: 32px; + padding: 8px 0; } input[type=range]:focus { outline: none; } @@ -193,30 +207,30 @@ .fader-label { font-size: 8px; font-weight: bold; color: #222; margin-top: 2px; } input[type=range].vertical { - -webkit-appearance: slider-vertical; width: 30px; height: 100%; cursor: pointer; + -webkit-appearance: slider-vertical; width: 36px; height: 100%; cursor: pointer; } input[type=range].horiz { - width: 100%; height: 30px; margin: 0; + width: 100%; height: 36px; margin: 0; } input[type=range].horiz::-webkit-slider-runnable-track { - height: 8px; background: #222; border: 1px inset; border-radius: 4px; + height: 10px; background: #222; border: 1px inset; border-radius: 5px; } input[type=range].horiz::-webkit-slider-thumb { - -webkit-appearance: none; width: 22px; height: 22px; - background: #ccc; border: 2px outset #eee; - margin-top: -8px; border-radius: 2px; + -webkit-appearance: none; width: 28px; height: 28px; + background: #ccc; border: 2px outset #eee; + margin-top: -10px; border-radius: 6px; box-shadow: 1px 1px 3px rgba(0,0,0,0.5); } input[type=range].mini-vol { - width: 60px; height: 20px; + width: 72px; height: 28px; } input[type=range].mini-vol::-webkit-slider-runnable-track { - height: 4px; background: #999; border: 1px solid #555; + height: 10px; background: #999; border: 1px solid #555; border-radius: 6px; } input[type=range].mini-vol::-webkit-slider-thumb { - width: 12px; height: 12px; margin-top: -5px; background: #fff; border: 1px solid #000; + width: 20px; height: 20px; margin-top: -6px; background: #fff; border: 1px solid #000; border-radius: 50%; } /* --- GRID --- */ @@ -245,9 +259,9 @@ } .tiny-btn.active { background: #999; border: 1px inset; color: white; } - .add-track-area { - padding: 20px; - text-align: center; + .add-track-area { + padding: 20px; + text-align: center; margin-top: 10px; } .btn-add { @@ -256,10 +270,79 @@ } .btn-add:active { background: #ccc; } + /* --- MIDI PANEL --- */ + .midi-panel { + display: none; + background: #a0a0a0; + border-bottom: 2px solid var(--win-darker); + padding: 8px; + z-index: 45; + } + .midi-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + .midi-label { + font-size: 11px; + font-weight: bold; + color: #111; + } + .midi-notes { + font-family: monospace; + font-size: 12px; + background: #fff; + padding: 2px 6px; + border: 1px inset #777; + border-radius: 2px; + min-height: 20px; + flex: 1; + text-align: right; + color: #000; + } + .piano { + margin-top: 6px; + background: #777; + padding: 6px; + border: 1px inset #555; + display: grid; + gap: 3px; + height: 90px; + } + .piano-key { + border-radius: 2px; + border: 1px solid #111; + display: flex; + align-items: flex-end; + justify-content: center; + padding-bottom: 4px; + font-size: 9px; + font-weight: bold; + color: #000; + background: linear-gradient(180deg, #fdfdfd, #ddd); + } + .piano-key.black { + background: linear-gradient(180deg, #333, #111); + color: #fff; + } + .piano-key.active { + outline: 2px solid #39ff14; + box-shadow: 0 0 8px #39ff14; + } + select { font-size: 11px; padding: 2px; height: 24px; border: 1px inset; } .track-body.collapsed { display: none; } + @media (min-width: 900px) { + .workspace { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + align-items: start; + } + .add-track-area { grid-column: 1 / -1; } + } + @@ -310,6 +393,16 @@ + +
+
+ MIDI +
No device detected
+
---
+
+
+
+
@@ -337,7 +430,9 @@ root: 0, scale: 'major', globalTranspose: 0, sf2: null, sdta: 0, samples: [], tracks: [], - midiVoices: {} // Track active MIDI notes: key=note, val={src, gain, trackId} + activeTrack: 0, + midiVoices: {}, // Track active MIDI notes: key=note, val={src, gain, trackId, snapped} + activeMidiNotes: new Set() }; const ui = { @@ -356,17 +451,33 @@ gShiftDisp: document.getElementById('shift-disp'), globalLcd: document.getElementById('global-lcd'), sf2Input: document.getElementById('sf2-input'), - canvas: document.getElementById('scope') + canvas: document.getElementById('scope'), + midiPanel: document.getElementById('midi-panel'), + midiDevice: document.getElementById('midi-device'), + midiActive: document.getElementById('midi-active'), + midiPiano: document.getElementById('midi-piano') }; + ui.globalLcd.dataset.fileLabel = ui.globalLcd.innerText; + + const midiKeys = {}; + const PIANO_START = 48; // C3 + const PIANO_END = 84; // B5 + // --- MIDI SETUP --- if (navigator.requestMIDIAccess) { navigator.requestMIDIAccess().then(onMIDISuccess, onMIDIFailure); } function onMIDISuccess(midiAccess) { + sys.midiReady = true; + ui.midiPanel.style.display = 'block'; + const devices = []; for (let input of midiAccess.inputs.values()) { input.onmidimessage = getMIDIMessage; + devices.push(input.name || 'MIDI Input'); } + ui.midiDevice.innerText = devices.length ? devices.join(', ') : 'MIDI ready (no input detected)'; + buildMidiPiano(); } function onMIDIFailure() { console.warn("MIDI not supported"); } function getMIDIMessage(msg) { @@ -378,6 +489,44 @@ if (cmd === 128 || (cmd === 144 && vel === 0)) midiNoteOff(note); // Note Off } + function buildMidiPiano() { + if(ui.midiPiano.childElementCount > 0) return; + const count = PIANO_END - PIANO_START + 1; + ui.midiPiano.style.gridTemplateColumns = `repeat(${count}, 1fr)`; + for(let n = PIANO_START; n <= PIANO_END; n++) { + const key = document.createElement('div'); + const isBlack = [1,3,6,8,10].includes(n % 12); + key.className = 'piano-key' + (isBlack ? ' black' : ''); + if(NOTES[n % 12] === 'C') key.innerText = `C${Math.floor(n/12)-1}`; + key.dataset.note = n; + midiKeys[n] = key; + ui.midiPiano.appendChild(key); + } + } + + function midiNoteLabel(n) { + const name = NOTES[n % 12]; + const oct = Math.floor(n / 12) - 1; + return name + oct; + } + + function refreshMidiReadout() { + const active = Array.from(sys.activeMidiNotes).sort((a,b) => a-b); + const text = active.length ? active.map(midiNoteLabel).join(' ') : '---'; + ui.midiActive.innerText = text; + if(active.length) { + ui.globalLcd.innerText = `MIDI: ${text}`; + } else { + ui.globalLcd.innerText = ui.globalLcd.dataset.fileLabel || 'EMPTY'; + } + } + + function setPianoState(note, on) { + const key = midiKeys[note]; + if(!key) return; + if(on) key.classList.add('active'); else key.classList.remove('active'); + } + // --- TRACK MANAGEMENT --- function createTrack(id) { const t = { @@ -400,6 +549,7 @@ renderTrackHTML(id); renderGrid(id); updateTrackStatus(id); + if(sys.tracks.length === 1) setActiveTrack(0); } function renderTrackHTML(id) { @@ -498,11 +648,12 @@ const transposed = base + t.transpose + parseInt(sys.globalTranspose); const snapped = snapToScale(transposed); const noteName = NOTES[snapped % 12]; - + el.innerHTML = `${sIdx+1} ${step.active?noteName:''}`; - + el.onclick = () => { + setActiveTrack(id); if(t.sel !== sIdx) t.sel = sIdx; else step.active = !step.active; @@ -515,6 +666,14 @@ }); } + function setActiveTrack(id) { + if(!sys.tracks[id]) return; + sys.activeTrack = id; + document.querySelectorAll('.track-section').forEach(sec => sec.classList.remove('active-track')); + const sec = document.getElementById('track-sec-'+id); + if(sec) sec.classList.add('active-track'); + } + function updatePitchLCD(id, note) { const t = sys.tracks[id]; const eff = snapToScale(note + t.transpose + parseInt(sys.globalTranspose)); @@ -539,6 +698,30 @@ return 0; } + function buildSampleBuffer(sample) { + if (sample.buffer) return sample.buffer; + const len = sample.end - sample.start; + if (len <= 0 || !sys.ctx) return null; + + // Pad the buffer with a short sustain tail so envelopes can fade naturally + const pad = Math.floor(sample.rate * 2); + const buffer = sys.ctx.createBuffer(1, len + pad, sample.rate); + const channel = buffer.getChannelData(0); + const dv = new DataView(sys.sf2); + const off = sys.sdta + (sample.start * 2); + + for (let i = 0; i < len; i++) { + if (off + (i * 2) < sys.sf2.byteLength) channel[i] = dv.getInt16(off + (i * 2), true) / 32768.0; + } + + // Hold the final value through the padded tail to avoid abrupt drops + const last = channel[len - 1] || 0; + for (let i = len; i < len + pad; i++) channel[i] = last; + + sample.buffer = buffer; + return buffer; + } + function playSound(tIdx, note, time, gateDuration, isMidi) { const t = sys.tracks[tIdx]; const freq = 440 * Math.pow(2, (note-69)/12); @@ -556,19 +739,27 @@ } else if (t.mode === 'sampler') { const s = sys.samples[t.sampleIdx]; if(!s) return null; - const len = s.end - s.start; - if(len<=0) return null; - const b = sys.ctx.createBuffer(1, len, s.rate); - const d = b.getChannelData(0); - const dv = new DataView(sys.sf2); - const off = sys.sdta + (s.start*2); - for(let i=0; i s.loopStart && s.loopEnd <= s.end; + if(hasLoop) { + src.loop = true; + const loopStartSec = (s.loopStart - s.start) / s.rate; + const loopEndSec = (s.loopEnd - s.start) / s.rate; + src.loopStart = Math.max(0, loopStartSec); + src.loopEnd = Math.max(src.loopStart + 0.001, loopEndSec); + } else if (gateDuration <= 0) { + // Allow held MIDI notes to sustain even when a SoundFont lacks loop metadata + src.loop = true; + src.loopStart = 0; + src.loopEnd = buffer.duration; + } } const gain = sys.ctx.createGain(); @@ -604,19 +795,23 @@ function midiNoteOn(note) { // Map to Track 0 settings (ID 0) if(sys.midiVoices[note]) return; // Already playing - const tIdx = 0; - // No scale snap for MIDI typically, but lets follow Track 0 sound - const voice = playSound(tIdx, note, sys.ctx.currentTime, 0, true); - if(voice) sys.midiVoices[note] = voice; - - // Visual feedback on Global LCD - ui.globalLcd.innerText = "MIDI: " + note; + const tIdx = sys.tracks[sys.activeTrack] ? sys.activeTrack : 0; + const t = sys.tracks[tIdx]; + if(!t) return; + const effNote = snapToScale(note + t.transpose + parseInt(sys.globalTranspose)); + const voice = playSound(tIdx, effNote, sys.ctx.currentTime, 0, true); + if(voice) { + sys.midiVoices[note] = { ...voice, snapped: effNote, trackId: tIdx }; + sys.activeMidiNotes.add(effNote); + setPianoState(effNote, true); + refreshMidiReadout(); + } } function midiNoteOff(note) { const voice = sys.midiVoices[note]; if(!voice) return; - const t = sys.tracks[0]; + const t = sys.tracks[voice.trackId ?? 0] || sys.tracks[0]; const rel = getEnvSeconds(t.env.r, 'r'); const now = sys.ctx.currentTime; @@ -624,9 +819,15 @@ voice.gain.gain.cancelScheduledValues(now); voice.gain.gain.setValueAtTime(voice.gain.gain.value, now); voice.gain.gain.setTargetAtTime(0, now, rel/3); - + voice.src.stop(now + (rel*6)); delete sys.midiVoices[note]; + + if(voice.snapped !== undefined) { + sys.activeMidiNotes.delete(voice.snapped); + setPianoState(voice.snapped, false); + refreshMidiReadout(); + } } // --- SCHEDULER --- @@ -699,39 +900,54 @@ } let pThrottle = 0; + let sliderFrame = null; + const pendingSliders = new Map(); + + function applySliderChanges() { + pendingSliders.forEach((val, el) => { + const tid = parseInt(el.dataset.track); + + if(el.classList.contains('mini-vol')) { + sys.tracks[tid].gain = val / 100; + } + if(el.dataset.param) { + sys.tracks[tid].env[el.dataset.param] = val; + } + if(el.classList.contains('pitch-slider')) { + const t = sys.tracks[tid]; + t.grid[t.sel].note = val; + updatePitchLCD(tid, val); + renderGrid(tid); + const now = Date.now(); + if(now - pThrottle > 100) { + const eff = val + t.transpose + parseInt(sys.globalTranspose); + triggerNote(tid, eff, sys.ctx.currentTime, 0.15); + pThrottle = now; + } + } + if(el.classList.contains('trans-slider')) { + sys.tracks[tid].transpose = val; + renderGrid(tid); + } + }); + + pendingSliders.clear(); + sliderFrame = null; + } document.body.addEventListener('input', (e) => { const el = e.target; - const tid = parseInt(el.dataset.track); - - if(el.classList.contains('mini-vol')) { - sys.tracks[tid].gain = parseInt(el.value) / 100; - } - if(el.dataset.param) { - sys.tracks[tid].env[el.dataset.param] = parseInt(el.value); - } - if(el.classList.contains('pitch-slider')) { - const val = parseInt(el.value); - const t = sys.tracks[tid]; - t.grid[t.sel].note = val; - updatePitchLCD(tid, val); - renderGrid(tid); - const now = Date.now(); - if(now - pThrottle > 100) { - const eff = val + t.transpose + parseInt(sys.globalTranspose); - triggerNote(tid, eff, sys.ctx.currentTime, 0.15); - pThrottle = now; - } - } - if(el.classList.contains('trans-slider')) { - sys.tracks[tid].transpose = parseInt(el.value); - renderGrid(tid); - } - }); + if(!(el instanceof HTMLInputElement) || el.type !== 'range') return; + const val = parseInt(el.value); + pendingSliders.set(el, val); + if(!sliderFrame) sliderFrame = requestAnimationFrame(applySliderChanges); + }, { passive: true }); document.body.addEventListener('click', (e) => { const el = e.target; - + const tWrap = el.closest('.track-section'); + if(tWrap) setActiveTrack(parseInt(tWrap.id.replace('track-sec-',''))); + if(el.classList.contains('btn-min')) { const tid = parseInt(el.dataset.track); const body = document.getElementById('tbody-'+tid); @@ -796,7 +1012,9 @@ }); sel.disabled = false; }); - ui.globalLcd.innerText = f.name.substring(0,8).toUpperCase(); + const label = f.name.substring(0,8).toUpperCase(); + ui.globalLcd.innerText = label; + ui.globalLcd.dataset.fileLabel = label; }; r.readAsArrayBuffer(f); }; @@ -824,10 +1042,15 @@ const num = size/46; for(let i=0; i Date: Thu, 4 Dec 2025 21:00:43 -0800 Subject: [PATCH 2/5] Add WAV recording capability --- index.html | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index eca7fd2..4d7bf62 100644 --- a/index.html +++ b/index.html @@ -97,6 +97,10 @@ .btn-play { flex: 2; color: #006400; border-width: 3px; font-size: 14px; } .btn-stop { flex: 1; color: #8b0000; } .btn-init { flex: 3; background: #e0e080; border-color: #999; } + .btn-rec { flex: 2; color: #8b0000; background: #ffdede; border-color: #aa7777; position: relative; } + .btn-rec.recording { background: #ffb3b3; border-color: #c00; color: #600; } + .rec-dot { width: 10px; height: 10px; border-radius: 50%; background: #c00; display: inline-block; margin-right: 4px; box-shadow: 0 0 6px rgba(255,0,0,0.6); } + .rec-dot.idle { background: #700; box-shadow: none; } /* --- GLOBAL --- */ .global-strip { @@ -356,6 +360,7 @@
@@ -432,7 +437,8 @@ tracks: [], activeTrack: 0, midiVoices: {}, // Track active MIDI notes: key=note, val={src, gain, trackId, snapped} - activeMidiNotes: new Set() + activeMidiNotes: new Set(), + recorder: { active: false, length: 0, buffers: [], node: null, sampleRate: 44100 } }; const ui = { @@ -455,7 +461,8 @@ midiPanel: document.getElementById('midi-panel'), midiDevice: document.getElementById('midi-device'), midiActive: document.getElementById('midi-active'), - midiPiano: document.getElementById('midi-piano') + midiPiano: document.getElementById('midi-piano'), + rec: document.getElementById('btn-rec') }; ui.globalLcd.dataset.fileLabel = ui.globalLcd.innerText; @@ -463,6 +470,7 @@ const midiKeys = {}; const PIANO_START = 48; // C3 const PIANO_END = 84; // B5 + const REC_FILENAME_PREFIX = 'sf100-recording-'; // --- MIDI SETUP --- if (navigator.requestMIDIAccess) { @@ -870,12 +878,23 @@ sys.mainGain.connect(sys.analyser); sys.analyser.connect(sys.ctx.destination); + sys.recorder.sampleRate = sys.ctx.sampleRate; + sys.recorder.node = sys.ctx.createScriptProcessor(4096, 1, 1); + sys.recorder.node.onaudioprocess = handleRecordBuffer; + sys.mainGain.connect(sys.recorder.node); + sys.recorder.node.connect(sys.ctx.destination); + sys.ready = true; ui.init.style.display='none'; ui.ctrls.style.display='flex'; drawScope(); }; + ui.rec.onclick = () => { + if(!sys.ready) return; + if(sys.recorder.active) stopRecording(); else startRecording(); + }; + ui.play.onclick = () => { if(sys.playing) return; sys.playing = true; sys.step=0; sys.nextTime=sys.ctx.currentTime+0.05; @@ -1068,6 +1087,73 @@ sel.disabled = false; } + function handleRecordBuffer(evt) { + if(!sys.recorder.active) return; + const input = evt.inputBuffer.getChannelData(0); + sys.recorder.buffers.push(new Float32Array(input)); + sys.recorder.length += input.length; + } + + function startRecording() { + sys.recorder.buffers = []; + sys.recorder.length = 0; + sys.recorder.active = true; + ui.rec.classList.add('recording'); + ui.rec.innerHTML = 'STOP REC'; + } + + function stopRecording() { + sys.recorder.active = false; + ui.rec.classList.remove('recording'); + ui.rec.innerHTML = '● RECORD WAV'; + if(!sys.recorder.length) return; + const wav = exportWav(sys.recorder.buffers, sys.recorder.length, sys.recorder.sampleRate); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const url = URL.createObjectURL(wav); + const link = document.createElement('a'); + link.href = url; + link.download = `${REC_FILENAME_PREFIX}${ts}.wav`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 5000); + } + + function exportWav(buffers, length, sampleRate) { + const data = new Float32Array(length); + let offset = 0; + buffers.forEach(b => { data.set(b, offset); offset += b.length; }); + + const bytesPerSample = 2; + const blockAlign = bytesPerSample; + const buffer = new ArrayBuffer(44 + data.length * bytesPerSample); + const view = new DataView(buffer); + + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + data.length * bytesPerSample, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); // PCM chunk size + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, 1, true); // Channels + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bytesPerSample * 8, true); + writeString(view, 36, 'data'); + view.setUint32(40, data.length * bytesPerSample, true); + + let idx = 44; + for(let i=0; i Date: Thu, 4 Dec 2025 21:22:47 -0800 Subject: [PATCH 3/5] Improve envelopes and add FX sends --- index.html | 281 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 264 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index 4d7bf62..4283abf 100644 --- a/index.html +++ b/index.html @@ -196,26 +196,47 @@ /* --- SLIDERS --- */ input[type=range] { - -webkit-appearance: none; background: transparent; + -webkit-appearance: none; background: linear-gradient(to right, #4fa86b 0%, #4fa86b var(--fill, 50%), #1f1f1f var(--fill, 50%), #1f1f1f 100%); touch-action: pan-x pan-y; min-height: 32px; padding: 8px 0; + border-radius: 12px; + border: 1px solid #1a1a1a; } input[type=range]:focus { outline: none; } .adsr-container { - display: flex; justify-content: space-between; height: 80px; - background: #bbb; border: 1px inset; padding: 4px; + display: flex; justify-content: space-between; height: 130px; + background: linear-gradient(180deg, #d5d5d5, #b3b3b3); + border: 1px inset; padding: 6px; border-radius: 6px; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.25); } .fader-group { display: flex; flex-direction: column; align-items: center; flex: 1; } - .fader-label { font-size: 8px; font-weight: bold; color: #222; margin-top: 2px; } - + .fader-label { font-size: 9px; font-weight: bold; color: #111; margin-top: 6px; letter-spacing: 0.5px; } + input[type=range].vertical { - -webkit-appearance: slider-vertical; width: 36px; height: 100%; cursor: pointer; + -webkit-appearance: none; width: 48px; height: 100%; cursor: pointer; + background: linear-gradient(to top, #1d1d1d, #2c2c2c); + border-radius: 14px; border: 1px solid #111; padding: 10px 14px; + } + input[type=range].vertical::-webkit-slider-runnable-track { + width: 12px; height: 100%; background: transparent; + } + input[type=range].vertical::-webkit-slider-thumb { + -webkit-appearance: none; width: 22px; height: 22px; border-radius: 8px; + background: linear-gradient(180deg, #fff, #c8c8c8); + border: 1px solid #555; box-shadow: 0 2px 4px rgba(0,0,0,0.4); + margin-left: -5px; + } + + input[type=range].vertical::-moz-range-track { background: transparent; border: none; } + input[type=range].vertical::-moz-range-thumb { + width: 22px; height: 22px; border-radius: 8px; border: 1px solid #555; background: linear-gradient(180deg, #fff, #c8c8c8); + box-shadow: 0 2px 4px rgba(0,0,0,0.4); } input[type=range].horiz { - width: 100%; height: 36px; margin: 0; + width: 100%; height: 36px; margin: 0; border-radius: 10px; } input[type=range].horiz::-webkit-slider-runnable-track { height: 10px; background: #222; border: 1px inset; border-radius: 5px; @@ -282,6 +303,22 @@ padding: 8px; z-index: 45; } + .fx-rack { + background: #9b9b9b; + border-bottom: 2px solid var(--win-darker); + border-top: 2px solid var(--win-darker); + padding: 8px; + display: grid; + gap: 8px; + } + .fx-row { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } + .fx-module { background: rgba(255,255,255,0.35); border: 2px inset #666; padding: 8px; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); } + .fx-title { font-size: 12px; font-weight: bold; color: #000; margin-bottom: 6px; text-shadow: 1px 1px 0 #ddd; } + .fx-controls { display: grid; gap: 6px; } + .fx-control-row { display: flex; align-items: center; gap: 8px; } + .fx-control-row span { font-size: 10px; font-weight: bold; width: 70px; } + .send-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 6px; margin-top: 6px; } + .send-row span { font-size: 10px; font-weight: bold; } .midi-row { display: flex; align-items: center; @@ -408,6 +445,52 @@
+ +
+
+
+
REVERB SEND / TAL-STYLE PLATE
+
+
+ PRE-DELAY + + 35ms +
+
+ DECAY + + 3.2s +
+
+ TONE + + 6.5k +
+
+
+
+
PING-PONG DELAY SEND
+
+
+ TIME + + 320ms +
+
+ FEEDBACK + + 38% +
+
+ TONE + + 9.0k +
+
+
+
+
+
@@ -438,7 +521,11 @@ activeTrack: 0, midiVoices: {}, // Track active MIDI notes: key=note, val={src, gain, trackId, snapped} activeMidiNotes: new Set(), - recorder: { active: false, length: 0, buffers: [], node: null, sampleRate: 44100 } + recorder: { active: false, length: 0, buffers: [], node: null, sampleRate: 44100 }, + fx: { + reverb: { preDelay: 35, decay: 3.2, tone: 6500, nodes: {} }, + delay: { time: 0.32, feedback: 0.38, tone: 9000, nodes: {} } + } }; const ui = { @@ -518,6 +605,20 @@ return name + oct; } + function paintRange(el) { + const min = parseFloat(el.min || 0); + const max = parseFloat(el.max || 100); + const val = parseFloat(el.value || 0); + const pct = ((val - min) / (max - min)) * 100; + const dir = el.classList.contains('vertical') ? 'to top' : 'to right'; + el.style.setProperty('--fill', `${pct}%`); + el.style.background = `linear-gradient(${dir}, #4fa86b 0%, #4fa86b ${pct}%, #1f1f1f ${pct}%, #1f1f1f 100%)`; + } + + function paintAllRanges() { + document.querySelectorAll('input[type=range]').forEach(paintRange); + } + function refreshMidiReadout() { const active = Array.from(sys.activeMidiNotes).sort((a,b) => a-b); const text = active.length ? active.map(midiNoteLabel).join(' ') : '---'; @@ -544,9 +645,10 @@ mode: 'synth', wave: id%2===0 ? 'sine' : 'square', sampleIdx: 0, - env: {a:5, d:20, s:70, r:40}, + env: {a:5, d:20, s:70, r:40}, transpose: 0, - gain: 0.8, + gain: 0.8, + fxSend: { reverb: 0.25, delay: 0.15 }, minimized: false }; @@ -586,10 +688,20 @@
-
A
-
D
-
S
-
R
+
A
+
D
+
S
+
R
+
+
+
+ REV + +
+
+ DLY + +
@@ -618,6 +730,7 @@ div.innerHTML = html; ui.trackContainer.insertBefore(div.firstElementChild, ui.addArea); if(sys.samples.length > 0) populateSelect(id); + paintAllRanges(); } ui.addBtn.onclick = () => { @@ -730,6 +843,92 @@ return buffer; } + function setupFX() { + const ctx = sys.ctx; + // Reverb (plate-style impulse) + const revSend = ctx.createGain(); + const revPre = ctx.createDelay(1.0); + const revConvolver = ctx.createConvolver(); + const revTone = ctx.createBiquadFilter(); + const revOut = ctx.createGain(); + revTone.type = 'lowpass'; + revOut.gain.value = 1.0; + revSend.connect(revPre); + revPre.connect(revConvolver); + revConvolver.connect(revTone); + revTone.connect(revOut); + revOut.connect(sys.mainGain); + + sys.fx.reverb.nodes = { send: revSend, pre: revPre, convolver: revConvolver, tone: revTone }; + rebuildReverbImpulse(); + + // Ping-pong delay + const dSend = ctx.createGain(); + const dLeft = ctx.createDelay(2.0); + const dRight = ctx.createDelay(2.0); + const fbLeft = ctx.createGain(); + const fbRight = ctx.createGain(); + const panLeft = ctx.createStereoPanner(); panLeft.pan.value = -0.6; + const panRight = ctx.createStereoPanner(); panRight.pan.value = 0.6; + const dTone = ctx.createBiquadFilter(); dTone.type = 'lowpass'; + const dOut = ctx.createGain(); + + dSend.connect(dLeft); + dSend.connect(dRight); + dLeft.connect(panLeft); panLeft.connect(dTone); + dRight.connect(panRight); panRight.connect(dTone); + dTone.connect(dOut); + dOut.connect(sys.mainGain); + + dLeft.connect(fbRight); + dRight.connect(fbLeft); + fbLeft.connect(dLeft); + fbRight.connect(dRight); + + sys.fx.delay.nodes = { send: dSend, left: dLeft, right: dRight, fbLeft, fbRight, tone: dTone }; + applyFxParams(); + } + + function rebuildReverbImpulse() { + if(!sys.ctx) return; + const ctx = sys.ctx; + const seconds = Math.max(0.5, sys.fx.reverb.decay); + const length = Math.floor(ctx.sampleRate * (seconds + 0.2)); + const buffer = ctx.createBuffer(2, length, ctx.sampleRate); + + for(let ch=0; ch<2; ch++) { + const data = buffer.getChannelData(ch); + for(let i=0; i { const AC = window.AudioContext || window.webkitAudioContext; @@ -878,6 +1092,8 @@ sys.mainGain.connect(sys.analyser); sys.analyser.connect(sys.ctx.destination); + setupFX(); + sys.recorder.sampleRate = sys.ctx.sampleRate; sys.recorder.node = sys.ctx.createScriptProcessor(4096, 1, 1); sys.recorder.node.onaudioprocess = handleRecordBuffer; @@ -924,7 +1140,33 @@ function applySliderChanges() { pendingSliders.forEach((val, el) => { - const tid = parseInt(el.dataset.track); + paintRange(el); + if(el.classList.contains('fx-param')) { + const group = el.dataset.fx; + const param = el.dataset.param; + if(group === 'reverb') { + if(param === 'preDelay') sys.fx.reverb.preDelay = val; + if(param === 'decay') sys.fx.reverb.decay = val / 10; + if(param === 'tone') sys.fx.reverb.tone = val; + document.getElementById('fx-r-pre').innerText = `${sys.fx.reverb.preDelay}ms`; + document.getElementById('fx-r-decay').innerText = `${sys.fx.reverb.decay.toFixed(1)}s`; + document.getElementById('fx-r-tone').innerText = `${(sys.fx.reverb.tone/1000).toFixed(1)}k`; + rebuildReverbImpulse(); + } + if(group === 'delay') { + if(param === 'time') sys.fx.delay.time = val / 1000; + if(param === 'feedback') sys.fx.delay.feedback = Math.min(0.9, val/100); + if(param === 'tone') sys.fx.delay.tone = val; + document.getElementById('fx-d-time').innerText = `${val}ms`; + document.getElementById('fx-d-fb').innerText = `${val}%`; + document.getElementById('fx-d-tone').innerText = `${(sys.fx.delay.tone/1000).toFixed(1)}k`; + applyFxParams(); + } + return; + } + + const tid = el.dataset.track !== undefined ? parseInt(el.dataset.track) : null; + if(Number.isNaN(tid)) return; if(el.classList.contains('mini-vol')) { sys.tracks[tid].gain = val / 100; @@ -948,6 +1190,11 @@ sys.tracks[tid].transpose = val; renderGrid(tid); } + if(el.classList.contains('send-slider')) { + const amt = Math.min(1, Math.max(0, val/100)); + if(el.dataset.send === 'reverb') sys.tracks[tid].fxSend.reverb = amt; + if(el.dataset.send === 'delay') sys.tracks[tid].fxSend.delay = amt; + } }); pendingSliders.clear(); From 10d0bb971453e7111d7a6de4bea2c48a6e60ce1f Mon Sep 17 00:00:00 2001 From: Jerome Potter Date: Thu, 4 Dec 2025 22:23:21 -0800 Subject: [PATCH 4/5] Relocate FX rack beneath sequencers --- index.html | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index 4283abf..e7b1a62 100644 --- a/index.html +++ b/index.html @@ -135,7 +135,7 @@ overflow-y: auto; -webkit-overflow-scrolling: touch; background: #888; - padding-bottom: 120px; + padding-bottom: 20px; } .track-section { @@ -305,11 +305,11 @@ } .fx-rack { background: #9b9b9b; - border-bottom: 2px solid var(--win-darker); border-top: 2px solid var(--win-darker); padding: 8px; display: grid; gap: 8px; + flex-shrink: 0; } .fx-row { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } .fx-module { background: rgba(255,255,255,0.35); border: 2px inset #666; padding: 8px; box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); } @@ -445,6 +445,15 @@
+ +
+ + +
+ +
+
+
@@ -491,15 +500,6 @@
- -
- - -
- -
-
-