Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand All @@ -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).
Expand Down
72 changes: 72 additions & 0 deletions src/audio/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { startFrac: number; velocity: number }>();

// 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;
Expand Down Expand Up @@ -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).
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/styles/fx.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions src/styles/transport.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 64 additions & 0 deletions src/ui/fx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLElement>('.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<HTMLElement>('.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';
Expand Down
20 changes: 19 additions & 1 deletion src/ui/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) ---
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(); }
Expand Down
48 changes: 48 additions & 0 deletions tests/engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading