diff --git a/README.md b/README.md index 2dfa543..422c5b2 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Modules (one feature = one file in `src/ui/`): | Module | Description | | --- | --- | -| Transport | play/stop, BPM, swing, loop toggle, pattern name, SAVE / OPEN / NEW / EXPORT / EXPORT PROJ / IMPORT PROJ, status readout; shortcuts `Space`, `Ctrl+S/O/N`, `Ctrl+Z/Y` (`Ctrl+Shift+Z` = redo) | +| Transport | play/stop, REC, BPM, swing, loop toggle, pattern name, SAVE / OPEN / NEW / EXPORT / EXPORT STEMS / EXPORT PROJ / IMPORT PROJ, status readout; shortcuts `Space`, `R`, `Ctrl+S/O/N`, `Ctrl+Z/Y` (`Ctrl+Shift+Z` = redo) | | Step sequencer | 16 steps × 5 tracks, rubber MPC pads, playing-column highlight, per-step velocity accents (right-click or `Shift`+click an active pad), row tools: clear / shift left / shift right / re-roll accents | | Piano roll | 24 pitch rows (B4..C3) × 16 steps, key strip with preview, draw / resize / erase, playing-cell highlight | | Playlist | 20 bar cells, pattern switching, clip drag & drop, loop region with draggable handles | @@ -66,7 +66,8 @@ Module positions persist across reloads (`voidstation-wall-v1`). scheduling, BPM, offline render to WAV export. - **Effects**: reverb (ConvolverNode + procedural stereo IR, Room/Hall/Plate), delay (feedback loop), 3-band EQ (lowshelf 250 Hz / peaking 1 kHz / highshelf - 4 kHz) — per-track, live and in export. + 4 kHz) — per-track, live and in export. Reorder the insert chain by + dragging an effect's name; the ▲▼ buttons still work. - **Samples**: load a sample per track, canvas waveform with draggable start/end trim handles, preview by clicking the wave, tape-style pitch (-24..+12 semitones) and a REVERSE toggle. @@ -87,6 +88,11 @@ Module positions persist across reloads (`voidstation-wall-v1`). - **Live controller**: held-note synth voices (attack → decay → sustain until release) from the computer keyboard or any Web MIDI input device; octave shift, on-screen keys, device hot-plug. +- **Note recording**: arm REC (button or `R`) while the transport plays and + anything played on the live controller is quantized into the current + pattern's piano roll — nearest-step start, note-off defines the length, + velocity is kept. The whole take is a single undo entry; an empty take + leaves the history untouched. - **Pattern copy/paste**: COPY grabs every track of the current pattern into an in-memory clipboard, PASTE overwrites the active pattern (single undo entry). diff --git a/src/audio/engine.ts b/src/audio/engine.ts index a1469d8..9f2fa16 100644 --- a/src/audio/engine.ts +++ b/src/audio/engine.ts @@ -34,6 +34,7 @@ export class AudioEngine { // Transport state transportTime = 0; // current position in seconds nextStepTime = 0; // when next step is due + private _transportStartTime = 0; // ctx time of step 0 (recording quantize anchor) schedulerInterval: number | null = null; // setInterval handle lookahead = 0.1; // schedule 100ms ahead scheduleInterval = 25; // scheduler tick every 25ms @@ -73,6 +74,12 @@ export class AudioEngine { // (still persisted so the user's preference survives a reload). metronome = false; + // Note recording: while armed AND the transport plays, live noteOn/noteOff + // pairs (computer keyboard or MIDI) are quantized into the current + // pattern's piano grid. The whole take is ONE undo transaction. + isRecording = false; + private _recPending = new Map(); + // Groove: swing amount 0..1. Odd 16th steps are delayed by up to 1/3 of a // step (1.0 = full triplet feel). Applied live AND in the offline render. swing = 0; @@ -373,6 +380,11 @@ export class AudioEngine { env.connect(dest); source.start(time); this._liveVoices.set(key, { source, env }); + + // Recording: remember where this held note started. + if (this.isRecording && this.isPlaying) { + this._recPending.set(key, { startFrac: this._liveStepFraction(), velocity }); + } } // Release the sustained voice for a track+note (no-op when not held). @@ -382,6 +394,15 @@ export class AudioEngine { const key = `${trackIndex}:${midiNote}`; const voice = this._liveVoices.get(key); if (!voice) return; + + // Recording: quantize the completed note into the grid. + const rec = this._recPending.get(key); + if (rec) { + this._recPending.delete(key); + if (this.isRecording && this.isPlaying) { + this.captureNoteLive(trackIndex, midiNote, rec.startFrac, this._liveStepFraction(), rec.velocity); + } + } this._liveVoices.delete(key); const rel = Math.max(0.02, this.tracks[trackIndex]?.adsr.release ?? 0.1); @@ -461,6 +482,55 @@ export class AudioEngine { source.stop(releaseEnd + 0.05); } + // --- Note recording (live input -> piano grid, quantized) --- + + toggleRecording() { + if (this.isRecording) this._stopRecording(); + else this._startRecording(); + } + + private _startRecording() { + if (this.isRecording) return; + // One transaction for the whole take: commitHistory() discards it when + // nothing was captured, so arming alone never pollutes the undo stack. + this.beginHistory(); + this.isRecording = true; + this._recPending.clear(); + this._notifyStateChange(); + } + + private _stopRecording() { + if (!this.isRecording) return; + this.isRecording = false; + this._recPending.clear(); + this.commitHistory(); // no-op when the take captured nothing + this._notifyStateChange(); + } + + // Live position in fractional steps since the transport started (>= 0). + private _liveStepFraction(): number { + if (!this.ctx || !this.isPlaying || this.stepDuration <= 0) return NaN; + return Math.max(0, (this.ctx.currentTime - this._transportStartTime) / this.stepDuration); + } + + // Write one quantized note into the current pattern's piano grid. + // Returns true when a cell was written. Exposed for tests; called from + // noteOff() while recording. + captureNoteLive(trackIndex: number, midi: number, startFrac: number, endFrac: number, velocity = 1): boolean { + if (!Number.isFinite(startFrac) || !Number.isFinite(endFrac)) return false; + const pitch = 71 - midi; + if (pitch < 0 || pitch > 23) return false; + const track = this.patterns[this.currentPatternIndex]?.tracks[trackIndex]; + if (!track) return false; + + const step = ((Math.round(startFrac) % 16) + 16) % 16; + const length = Math.max(1, Math.min(16, Math.round(endFrac) - Math.round(startFrac))); + track.pianoGrid[pitch][step] = length; + track.velocity[step] = Math.max(0.05, Math.min(1, velocity)); + this._notifyPatternChange(); + return true; + } + get hasSample() { return this.tracks.some(t => t.sample !== null); } @@ -1203,12 +1273,14 @@ export class AudioEngine { this.stepIndex = 0; this.totalSteps = 0; this.nextStepTime = ctx.currentTime + 0.005; // tiny offset + this._transportStartTime = this.nextStepTime; this._notifyStateChange(); // Scheduler loop this.schedulerInterval = setInterval(() => this._schedulerTick(), this.scheduleInterval); } stopTransport() { + if (this.isRecording) this._stopRecording(); // commits the take this.isPlaying = false; if (this.schedulerInterval) { clearInterval(this.schedulerInterval); diff --git a/src/styles/fx.css b/src/styles/fx.css index 12a4ea9..70a6e8f 100644 --- a/src/styles/fx.css +++ b/src/styles/fx.css @@ -78,9 +78,16 @@ font-weight: 700; letter-spacing: 0.1em; color: var(--text-dim); + cursor: grab; /* drag handle: reorder the chain */ + user-select: none; } .fx-row.fx-disabled .fx-type-name { color: var(--text-faint); } +/* Drag & drop states */ +.fx-row.dragging { opacity: 0.45; } +.fx-row.drop-above { box-shadow: inset 0 2px 0 var(--accent-bright), inset 0 1px 0 rgba(255, 255, 255, 0.04); } +.fx-row.drop-below { box-shadow: inset 0 -2px 0 var(--accent-bright), inset 0 1px 0 rgba(255, 255, 255, 0.04); } + .fx-preset { height: 24px; padding: 0 6px; diff --git a/src/styles/transport.css b/src/styles/transport.css index 059867d..675ac32 100644 --- a/src/styles/transport.css +++ b/src/styles/transport.css @@ -29,6 +29,19 @@ .hw-stop { background: linear-gradient(180deg, #B0584E 0%, #7A382F 60%, #5A2A23 100%); } .hw-stop:hover { filter: brightness(1.15); } +/* REC: dark idle, glowing red while armed (mirrors .hw-btn.active shape) */ +.hw-rec { background: linear-gradient(180deg, #4A3A3C 0%, #3A2C2E 60%, #2C2123 100%); } +.hw-rec.active { + background: linear-gradient(180deg, #D96A50 0%, var(--danger) 60%, #7A382F 100%); + color: #FFF6EE; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 0 10px rgba(217, 106, 80, 0.75); + animation: hw-rec-pulse 900ms ease-in-out infinite; +} +@keyframes hw-rec-pulse { + 0%, 100% { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 0 8px rgba(217, 106, 80, 0.55); } + 50% { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 0 14px rgba(217, 106, 80, 0.95); } +} + .hw-bpm-group .hw-readout { width: 54px; height: 22px; font-size: 13px; } .hw-tap { diff --git a/src/ui/fx.ts b/src/ui/fx.ts index c6eaf14..76dba81 100644 --- a/src/ui/fx.ts +++ b/src/ui/fx.ts @@ -138,9 +138,13 @@ export class FxRack { const typeName = document.createElement('span'); typeName.className = 'fx-type-name mono'; typeName.textContent = FX_LABELS[fx.type]; + typeName.title = 'Drag to reorder the chain'; enableLabel.append(checkbox, typeName); head.appendChild(enableLabel); + // Drag the row by its type name to reorder the insert chain. + this.bindRowDrag(row, index, typeName); + if (fx.type === 'reverb') { const preset = document.createElement('select'); preset.className = 'fx-preset'; @@ -204,6 +208,66 @@ export class FxRack { this.renderList(); } + // --- Row drag & drop (grab the type name, drop between rows) --- + + private bindRowDrag(row: HTMLElement, fromIndex: number, handle: HTMLElement) { + let active = false; + let startY = 0; + let target = -1; + + const clearMarkers = () => { + this.listEl.querySelectorAll('.fx-row').forEach((el) => { + el.classList.remove('dragging', 'drop-above', 'drop-below'); + }); + }; + + // Insert position (before row `target`) from a pointer Y in list space. + const computeTarget = (clientY: number): number => { + const rows = Array.from(this.listEl.querySelectorAll('.fx-row')); + for (let i = 0; i < rows.length; i++) { + const rect = rows[i].getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) return i; + } + return rows.length; + }; + + handle.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + handle.setPointerCapture(e.pointerId); + startY = e.clientY; + active = false; + }); + + handle.addEventListener('pointermove', (e) => { + if (!handle.hasPointerCapture(e.pointerId)) return; + if (!active && Math.abs(e.clientY - startY) < 5) return; + active = true; + row.classList.add('dragging'); + target = computeTarget(e.clientY); + clearMarkers(); + const rows = Array.from(this.listEl.querySelectorAll('.fx-row')); + // Skip no-op gaps (dropping right back where the row came from). + if (target !== fromIndex && target !== fromIndex + 1) { + rows[target]?.classList.add('drop-above'); + rows[target - 1]?.classList.add('drop-below'); + } + }); + + const finish = (commit: boolean) => { + if (!active) return; + active = false; + clearMarkers(); + if (!commit || target < 0) return; + let to = target; + if (fromIndex < to) to -= 1; // removal shifts later rows up + this.move(fromIndex, to); + }; + + handle.addEventListener('pointerup', () => finish(true)); + handle.addEventListener('pointercancel', () => finish(false)); + } + private buildSlider(fx: TrackEffect, index: number, p: FxParam): HTMLElement { const wrap = document.createElement('label'); wrap.className = 'fx-param'; diff --git a/src/ui/transport.ts b/src/ui/transport.ts index b37f669..b01f878 100644 --- a/src/ui/transport.ts +++ b/src/ui/transport.ts @@ -13,6 +13,7 @@ export class Transport { private filesBody: HTMLElement; private playBtn!: HTMLButtonElement; + private recBtn!: HTMLButtonElement; private undoBtn!: HTMLButtonElement; private redoBtn!: HTMLButtonElement; private bpmInput!: HTMLInputElement; @@ -66,7 +67,17 @@ export class Transport { stopBtn.classList.add('hw-stop'); stopBtn.addEventListener('click', () => this.engine.stopTransport()); - cluster.append(this.playBtn, stopBtn); + this.recBtn = makeBtn('REC'); + this.recBtn.id = 'btn-rec'; + this.recBtn.classList.add('hw-rec'); + this.recBtn.title = 'Record live notes into the current pattern while playing (R)'; + this.recBtn.addEventListener('click', () => { + // The engine wraps the whole take in a single history transaction. + this.engine.toggleRecording(); + this.sync(); + }); + + cluster.append(this.playBtn, stopBtn, this.recBtn); this.barBody.appendChild(cluster); // --- Position readout (BAR:STEP, live from the transport) --- @@ -265,6 +276,8 @@ export class Transport { private sync() { this.playBtn.classList.toggle('active', this.engine.isPlaying); this.playBtn.textContent = this.engine.isPlaying ? 'STOP' : 'PLAY'; + this.recBtn.classList.toggle('active', this.engine.isRecording); + this.recBtn.textContent = this.engine.isRecording ? 'REC ●' : 'REC'; this.undoBtn.disabled = !this.engine.canUndo; this.redoBtn.disabled = !this.engine.canRedo; this.loopBtn.classList.toggle('active', this.engine.loopEnabled); @@ -474,6 +487,11 @@ export class Transport { e.preventDefault(); this.playBtn.click(); } + if (e.key.toLowerCase() === 'r' && !typing && !e.ctrlKey && !e.metaKey && el?.tagName !== 'BUTTON') { + e.preventDefault(); + this.engine.toggleRecording(); + this.sync(); + } if ((e.ctrlKey || e.metaKey) && !typing) { const k = e.key.toLowerCase(); if (k === 's') { e.preventDefault(); this.saveProject(); } diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 94710b6..ea8710d 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -440,3 +440,51 @@ test('mute forces silence even under solo, solo isolates other tracks', () => { e.toggleMute(1); assert.equal(e._effectiveVolume(1), 0); }); + +test('captureNoteLive quantizes into the current pattern grid', () => { + const e = fresh(); + // 2.4 steps in -> nearest step 2; ends at 5.6 -> length round(5.6)-round(2.4) = 4. + assert.equal(e.captureNoteLive(0, 71, 2.4, 5.6, 0.8), true); + assert.equal(e.tracks[0].pianoGrid[0][2], 4); // midi 71 = pitch row 0 + assert.equal(e.tracks[0].velocity[2], 0.8); + + // Low C3 = midi 48 -> bottom row. + e.captureNoteLive(1, 48, 0, 1); + assert.equal(e.tracks[1].pianoGrid[23][0], 1); + + // Out-of-range pitches are ignored. + assert.equal(e.captureNoteLive(0, 90, 0, 1), false); + assert.equal(e.captureNoteLive(0, 20, 0, 1), false); + + // Non-finite fractions rejected; minimum length is one step. + assert.equal(e.captureNoteLive(0, 60, NaN, 3), false); + e.captureNoteLive(0, 60, 7.7, 7.9); + assert.equal(e.tracks[0].pianoGrid[11][8], 1); +}); + +test('recording take is a single undo entry only when notes were captured', () => { + const e = fresh(); + e.toggleRecording(); // arm + assert.equal(e.isRecording, true); + assert.equal(e.canUndo, false); + e.toggleRecording(); // disarm without capturing -> no history entry + assert.equal(e.canUndo, false); + + e.toggleRecording(); + e.captureNoteLive(0, 65, 0.2, 2.1, 0.9); + e.toggleRecording(); // commit the take + assert.equal(e.canUndo, true); + assert.equal(e.isRecording, false); +}); + +test('stopping the transport while armed commits and disarms', () => { + const e = fresh(); + e.toggleRecording(); + e.isPlaying = true; // simulate running transport (headless) + e._liveStepFraction; // touch internals indirectly below + e.captureNoteLive(2, 60, 0, 1.4, 1); + e.stopTransport(); + assert.equal(e.isRecording, false); + assert.equal(e.canUndo, true); + assert.equal(e.tracks[2].pianoGrid[11][0], 1); +});