From f3d2a5ef7d45c0986d1a8be9a74f7f1efcc9d0ec Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 14:07:35 +0600 Subject: [PATCH 1/6] ci: run on all branches with unit tests, dist syntax/link checks and dependabot - CI now triggers on every branch push (not just main), cancels superseded runs, and runs the new node:test suite after build - tests/: headless engine tests (patterns, playlist ops, loop wrap, swing, undo/redo, serialize round-trip incl. legacy saves) + WAV encoder tests, all via the built-in node test runner (zero new dependencies) - fix engine deserialize crash on saves whose patterns contain fewer than five track entries; missing tracks are filled with empty pattern data - fix index.html loading src/main.js which never exists (tsc emits to dist/main.js): local dev per README was a guaranteed 404; deploy.yml rewrite updated to match - verify index.html asset links resolve in CI; dependabot keeps npm deps and github-actions versions fresh --- .github/dependabot.yml | 20 +++++ .github/workflows/ci.yml | 30 ++++++- .github/workflows/deploy.yml | 2 +- README.md | 1 + index.html | 2 +- package.json | 1 + src/audio/engine.ts | 30 ++++--- tests/engine.test.mjs | 166 +++++++++++++++++++++++++++++++++++ tests/theme.test.mjs | 17 ++++ tests/wav.test.mjs | 62 +++++++++++++ 10 files changed, 316 insertions(+), 15 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 tests/engine.test.mjs create mode 100644 tests/theme.test.mjs create mode 100644 tests/wav.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..48898c9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 + +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + commit-message: + prefix: chore + reviewers: + - steady41 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ci + reviewers: + - steady41 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1c6434..7f02d87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,16 @@ name: CI on: push: - branches: [main] + branches: ['**'] pull_request: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: check: runs-on: ubuntu-latest @@ -30,9 +34,31 @@ jobs: - name: Build run: npm run build + - name: Unit tests (node:test, headless engine + wav) + run: npm test + + - name: Syntax-check every compiled module + run: | + fail=0 + for f in $(find dist -name '*.js' -type f); do + node --check "$f" || fail=1 + done + exit $fail + - name: Verify build output run: | test -f dist/main.js test -f dist/audio/engine.js test -d dist/ui - echo "dist/ sanity check passed" \ No newline at end of file + echo "dist/ sanity check passed" + + - name: Verify index.html asset links resolve + run: | + missing=0 + for ref in $(grep -oE '(src|href)="[^"]+"' index.html | sed -E 's/(src|href)="([^"]+)"/\2/' | grep -v '^https\?:'); do + if [ ! -f "$ref" ]; then + echo "::error::index.html references missing file: $ref" + missing=1 + fi + done + exit $missing diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fc78941..b455cf7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,7 +41,7 @@ jobs: - name: Prepare dist for Pages run: | cp index.html dist/ - sed -i 's|src/styles/|styles/|g; s|src/main.js|main.js|' dist/index.html + sed -i 's|src/styles/|styles/|g; s|dist/main.js|main.js|' dist/index.html cp -r src/styles dist/ - name: Upload artifact diff --git a/README.md b/README.md index dcad518..c8242c3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # VOIDSTATION [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/steady41/voidstation/actions/workflows/ci.yml/badge.svg)](https://github.com/steady41/voidstation/actions/workflows/ci.yml) An analog-hardware-styled web DAW built with TypeScript and the Web Audio API. No backend, no dependencies: patterns, samples, effects, the playlist diff --git a/index.html b/index.html index 951cacb..8567c50 100644 --- a/index.html +++ b/index.html @@ -22,6 +22,6 @@
- + diff --git a/package.json b/package.json index fe0cfed..4787db8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "build": "tsc", "watch": "tsc --watch", + "test": "node --test tests/*.test.mjs", "serve": "python3 -m http.server 8000" }, "devDependencies": { diff --git a/src/audio/engine.ts b/src/audio/engine.ts index 90af068..372b13e 100644 --- a/src/audio/engine.ts +++ b/src/audio/engine.ts @@ -1292,7 +1292,7 @@ export class AudioEngine { patterns = [{ name: 'Pattern 1', tracks: this.tracks.map((_, i) => { - const saved = state.tracks[i]; + const saved = state.tracks[i] ?? {}; return { pattern: Array.isArray(saved.pattern) ? [...saved.pattern] : new Array(16).fill(false), pianoGrid: this._normalizePianoGrid(saved.pianoGrid, this._createPianoGrid()), @@ -1302,16 +1302,24 @@ export class AudioEngine { }]; } - this.patterns = patterns.map(p => ({ - name: p.name || 'Pattern', - tracks: p.tracks.map(t => ({ - pattern: Array.isArray(t.pattern) ? t.pattern.map(Boolean) : new Array(16).fill(false), - pianoGrid: Array.isArray(t.pianoGrid) - ? this._normalizePianoGrid(t.pianoGrid, this._createPianoGrid()) - : this._createPianoGrid(), - velocity: this._normalizeVelocity(t.velocity), - })), - })); + // Normalize every pattern to exactly one PatternTrackData per live track, + // filling defaults for missing/corrupt entries (short or broken saves). + this.patterns = patterns.map(p => { + const savedTracks = Array.isArray(p.tracks) ? p.tracks : []; + return { + name: p.name || 'Pattern', + tracks: Array.from({ length: this.tracks.length }, (_, i) => { + const t = savedTracks[i] ?? {}; + return { + pattern: Array.isArray(t.pattern) ? t.pattern.map(Boolean) : new Array(16).fill(false), + pianoGrid: Array.isArray(t.pianoGrid) + ? this._normalizePianoGrid(t.pianoGrid, this._createPianoGrid()) + : this._createPianoGrid(), + velocity: this._normalizeVelocity(t.velocity), + }; + }), + }; + }); this.currentPatternIndex = Math.min(state.currentPatternIndex || 0, this.patterns.length - 1); this.playlist = Array.isArray(state.playlist) diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs new file mode 100644 index 0000000..8b8febf --- /dev/null +++ b/tests/engine.test.mjs @@ -0,0 +1,166 @@ +// Headless engine tests: pattern/playlist/loop/swing/history logic without a +// Web Audio context (the AudioEngine only touches `window` inside +// ensureContext, which none of these paths call). Runs against dist/. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { AudioEngine } from '../dist/audio/engine.js'; + +function fresh() { + const e = new AudioEngine(); + e.setBpm(120); + return e; +} + +test('starts with one empty pattern and 5 tracks', () => { + const e = fresh(); + assert.equal(e.tracks.length, 5); + assert.equal(e.patterns.length, 1); + assert.equal(e.currentPatternIndex, 0); + assert.ok(e.patterns[0].tracks.every((t) => t.pattern.every((s) => s === false))); +}); + +test('toggleStep / setStepVelocity edit the live pattern in place', () => { + const e = fresh(); + e.toggleStep(0, 3); + assert.equal(e.tracks[0].pattern[3], true); + e.toggleStep(0, 3); + assert.equal(e.tracks[0].pattern[3], false); + e.setStepVelocity(1, 5, 2); // clamped high + assert.equal(e.tracks[1].velocity[5], 1); + e.setStepVelocity(1, 5, -3); // clamped low + assert.equal(e.tracks[1].velocity[5], 0); +}); + +test('pattern add/duplicate/delete keeps playlist references consistent', () => { + const e = fresh(); + e.addPattern(); + e.addPattern(); + assert.equal(e.patterns.length, 3); + assert.equal(e.currentPatternIndex, 2); + + // Arrange: P1 in bar 0, P3 in bar 1. + e.switchPattern(0); + e.setPlaylistCell(0, 0); + e.setPlaylistCell(1, 2); + + // Delete P2 (between them): P3 shifts down to index 1. + e.deletePattern(1); + assert.equal(e.playlist[0], 0); + assert.equal(e.playlist[1], 1); + assert.equal(e.patterns.length, 2); + + // Deleting down to the last pattern is refused. + e.deletePattern(0); + e.deletePattern(0); + assert.equal(e.patterns.length, 1); +}); + +test('movePlaylistClip moves and swaps', () => { + const e = fresh(); + e.addPattern(); + e.setPlaylistCell(0, 0); + e.setPlaylistCell(2, 1); + e.movePlaylistClip(0, 4); // move to free slot + assert.equal(e.playlist[0], undefined); + assert.equal(e.playlist[4], 0); + e.movePlaylistClip(4, 2); // swap with occupied slot + assert.equal(e.playlist[2], 0); + assert.equal(e.playlist[4], 1); +}); + +test('loop region wraps playback within [start, end)', () => { + const e = fresh(); + for (let b = 0; b < 8; b++) e.playlist[b] = 0; + e.loopEnabled = true; + e.setLoopRegion(2, 5); // region covers bars 2..4 + // absBar counts bars since transport start; playback begins at loopStart. + assert.equal(e._barInLoop(0), 2); + assert.equal(e._barInLoop(1), 3); + assert.equal(e._barInLoop(2), 4); + assert.equal(e._barInLoop(3), 2); // wraps + assert.equal(e._barInLoop(6), 2); + assert.equal(e._barInLoop(8), 4); + + // Looping off plays the arrangement straight from bar 0. + e.loopEnabled = false; + assert.equal(e._barInLoop(5), 5); +}); + +test('swing offsets odd steps up to 1/3 of a step duration', () => { + const e = fresh(); + assert.equal(e.stepDuration > 0, true); + assert.equal(e._swingOffset(0), 0); + assert.equal(e._swingOffset(2), 0); + e.setSwing(1); + assert.ok(Math.abs(e._swingOffset(1) - e.stepDuration / 3) < 1e-12); + assert.ok(Math.abs(e._swingOffset(3) - e.stepDuration / 3) < 1e-12); + e.setSwing(42); // clamped into 0..1 + assert.equal(e.swing, 1); +}); + +test('history: commit records changed gestures, undo restores them', async () => { + const e = fresh(); + e.beginHistory(); + e.toggleStep(0, 0); + e.commitHistory(); + assert.equal(e.canUndo, true); + + await e.undo(); + assert.equal(e.tracks[0].pattern[0], false); + assert.equal(e.canRedo, true); + + await e.redo(); + assert.equal(e.tracks[0].pattern[0], true); +}); + +test('history: unchanged gestures create no entries', () => { + const e = fresh(); + e.beginHistory(); + e.commitHistory(); // nothing changed + assert.equal(e.canUndo, false); +}); + +test('serialize/deserialize round-trips project state', async () => { + const e = fresh(); + e.toggleStep(0, 1); + e.setTrackVolume(2, 0.5); + e.setMasterVolume(0.66); + e.setLimiterEnabled(true); + e.setLimiterThreshold(-6); + e.setSwing(0.5); + e.addPattern(); + e.setPlaylistCell(3, 1); + e.setLoopRegion(0, 4); + + const state = JSON.parse(JSON.stringify(e.serialize())); // as over localStorage + const e2 = fresh(); + await e2.deserialize(state); + + assert.equal(e2.bpm, 120); + assert.equal(e2.tracks[0].pattern[1], true); + assert.equal(e2.tracks[2].volume, 0.5); + assert.equal(e2.masterVolume, 0.66); + assert.equal(e2.limiterEnabled, true); + assert.equal(e2.limiterThreshold, -6); + assert.equal(e2.swing, 0.5); + assert.equal(e2.patterns.length, 2); + assert.equal(e2.playlist[3], 1); + assert.equal(e2.loopEnd, 4); +}); + +test('deserialize tolerates legacy saves without new fields', async () => { + const e = fresh(); + await e.deserialize({ + version: 2, + bpm: 100, + tracks: [], + currentPatternIndex: 0, + playlist: [], + patterns: [{ name: 'P', tracks: [] }], + }); + assert.equal(e.swing, 0); + assert.equal(e.masterVolume, 0.8); + assert.equal(e.limiterEnabled, false); + assert.equal(e.bpm, 100); +}); diff --git a/tests/theme.test.mjs b/tests/theme.test.mjs new file mode 100644 index 0000000..c150197 --- /dev/null +++ b/tests/theme.test.mjs @@ -0,0 +1,17 @@ +// Unit tests for shared helpers compiled into dist/ui/theme.js. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { clamp, TRACK_NAMES, TRACK_COLORS, STORAGE_KEY } from '../dist/ui/theme.js'; + +test('clamp keeps values inside [lo, hi]', () => { + assert.equal(clamp(5, 0, 10), 5); + assert.equal(clamp(-1, 0, 10), 0); + assert.equal(clamp(11, 0, 10), 10); +}); + +test('track identity tables stay in sync (5 tracks)', () => { + assert.equal(TRACK_NAMES.length, TRACK_COLORS.length); + assert.equal(TRACK_NAMES.length, 5); + assert.ok(STORAGE_KEY.length > 0); +}); diff --git a/tests/wav.test.mjs b/tests/wav.test.mjs new file mode 100644 index 0000000..53cbb27 --- /dev/null +++ b/tests/wav.test.mjs @@ -0,0 +1,62 @@ +// Unit tests for the compiled WAV encoder (dist/audio/wav.js). +// Run after `npm run build` via `npm test` (node:test, zero dependencies). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { audioBufferToWav } from '../dist/audio/wav.js'; + +// Minimal AudioBuffer stand-in: mono or stereo Float32 channels. +function fakeBuffer(channels, sampleRate = 44100) { + const datas = channels.map((c) => Float32Array.from(c)); + return { + numberOfChannels: channels.length, + sampleRate, + length: channels.length ? datas[0].length : 0, + getChannelData: (i) => datas[i], + }; +} + +function readAscii(view, offset, len) { + let s = ''; + for (let i = 0; i < len; i++) s += String.fromCharCode(view.getUint8(offset + i)); + return s; +} + +test('encodes a valid 16-bit PCM RIFF/WAVE header', () => { + const blob = audioBufferToWav(fakeBuffer([[0, 0.5, -0.5]])); + blob.arrayBuffer().then((ab) => { + const view = new DataView(ab); + assert.equal(ab.byteLength, 44 + 3 * 2); // header + 3 mono samples + assert.equal(readAscii(view, 0, 4), 'RIFF'); + assert.equal(view.getUint32(4, true), 36 + 3 * 2); + assert.equal(readAscii(view, 8, 4), 'WAVE'); + assert.equal(readAscii(view, 12, 4), 'fmt '); + assert.equal(view.getUint16(20, true), 1); // linear PCM + assert.equal(view.getUint16(22, true), 1); // mono + assert.equal(view.getUint32(24, true), 44100); + assert.equal(readAscii(view, 36, 4), 'data'); + assert.equal(view.getUint32(40, true), 3 * 2); + }); +}); + +test('clamps out-of-range samples and converts to int16 LE', () => { + const blob = audioBufferToWav(fakeBuffer([[1.5, -1.5, 0.25]])); + return blob.arrayBuffer().then((ab) => { + const view = new DataView(ab); + assert.equal(view.getInt16(44, true), 0x7fff); // clamped high + assert.equal(view.getInt16(46, true), -0x8000); // clamped low + assert.ok(Math.abs(view.getInt16(48, true) - 0.25 * 0x7fff) <= 1); + }); +}); + +test('stereo input interleaves channel frames', () => { + const blob = audioBufferToWav(fakeBuffer([[1, -1], [-1, 1]])); + return blob.arrayBuffer().then((ab) => { + const view = new DataView(ab); + assert.equal(view.getUint16(22, true), 2); + assert.equal(view.getInt16(44, true), 0x7fff); // L frame 0 + assert.equal(view.getInt16(46, true), -0x8000); // R frame 0 + assert.equal(view.getInt16(48, true), -0x8000); // L frame 1 + assert.equal(view.getInt16(50, true), 0x7fff); // R frame 1 + }); +}); From c6f2e99e3f4d36234515317c429cf5995fdd7dbe Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 14:30:08 +0600 Subject: [PATCH 2/6] feat: stem export, live controller (keys/MIDI), pattern clipboard, sample pitch - EXPORT STEMS: renders every track playing in the export region to its own WAV (fx + master bus included); silent tracks are skipped - LIVE CONTROLLER module: held-note synth voices via noteOn/noteOff, computer-keyboard mapping (Z S X D C V G B H N J M..., -/= octave), Web MIDI input with hot-plug, on-screen keys; transport stop kills all live voices - COPY/PASTE pattern buttons in the arranger toolbar: full-track snapshot clipboard, element-wise paste keeps live pattern references linked - Sample PITCH: tape-style semitone offset (-24..+12 -> playbackRate), applied live/in exports/persisted; sampler slider + reset Note: shipped as one atomic commit because the four features interleave inside engine.ts; splitting would risk non-building intermediates. --- README.md | 20 +++- index.html | 1 + src/audio/engine.ts | 179 +++++++++++++++++++++++++++-- src/styles/keys.css | 100 +++++++++++++++++ src/styles/sampler.css | 11 ++ src/types.ts | 2 + src/ui/app.ts | 7 +- src/ui/keys.ts | 248 +++++++++++++++++++++++++++++++++++++++++ src/ui/playlist.ts | 19 +++- src/ui/sampler.ts | 60 ++++++++++ src/ui/transport.ts | 62 ++++++++--- tests/engine.test.mjs | 73 ++++++++++++ 12 files changed, 756 insertions(+), 26 deletions(-) create mode 100644 src/styles/keys.css create mode 100644 src/ui/keys.ts diff --git a/README.md b/README.md index c8242c3..c1b571e 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ The interface is a canvas-style studio room — drag hardware modules (step sequencer, piano roll, playlist, mixer, transport) around a 3400×2800 wall, zoom with `Ctrl`+wheel, and arrange a full track: 16-step sequencing, piano-roll notes, per-track reverb/delay/EQ, a synth editor (waveform + -ADSR), sample trimming, loop regions, swing/groove, a master bus with -limiter, WAV export, and gesture-based undo/redo. +ADSR), sample trimming and pitch, loop regions, swing/groove, a master bus +with limiter, WAV + stem export, computer-keyboard / Web MIDI live input, +and gesture-based undo/redo. The old UI is archived in git tag `legacy-vscode-ui`. @@ -46,6 +47,7 @@ Modules (one feature = one file in `src/ui/`): | Mixer | 5 analog channels: pan knob, LED meter, mute/solo, volume fader, channel click selects the piano-roll track | | Synth editor | per-track waveform (sine/tri/sqr/saw/noise) + ADSR envelope sliders with note preview | | Master section | master volume, brickwall limiter (toggle + threshold), real-time output LED meter | +| Live controller | play the selected track from the computer keyboard (`Z S X D C V G B H N J M…`, `-`/`=` octave) or a Web MIDI device, with held-note voices | Module positions persist across reloads (`voidstation-wall-v1`). @@ -73,6 +75,17 @@ Module positions persist across reloads (`voidstation-wall-v1`). (`DynamicsCompressor`, toggle + threshold). A real-time output LED meter is fed by an `AnalyserNode` tapped after the limiter. All audible live and in the WAV export. +- **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. +- **Pattern copy/paste**: COPY grabs every track of the current pattern into + an in-memory clipboard, PASTE overwrites the active pattern (single undo + entry). +- **Sample pitch**: per-track tape-style playback rate in semitones + (-24..+12), applied live and in exports; a fresh sample load resets it. +- **Stem export**: `EXPORT STEMS` renders every track that plays anything in + the export region to its own WAV file (`voidstation-stem-01-kick.wav`, …), + fx chains and master bus included. - **Piano roll**: draw / resize / erase notes, real-time playback preview. - **Playlist**: patterns, clip drag & drop (move/swap), loop region rendered into the WAV export. @@ -106,7 +119,10 @@ src/ui/fx.ts — per-track reverb/delay/EQ rack src/ui/sampler.ts — sample load/trim/preview src/ui/instrument.ts — per-track synth editor (waveform + ADSR) src/ui/master.ts — master bus: volume, limiter, output meter +src/ui/keys.ts — live controller: computer keyboard + Web MIDI src/ui/app.ts — assembly: engine + wall + modules + wiring +tests/ — node:test suites (headless engine + WAV encoder) + run via `npm test` against the compiled dist/ src/styles/ — theme/base/racks/topbar/transport/drawer/sequencer/pianoroll/playlist/mixer/fx/sampler/instrument/master ``` diff --git a/index.html b/index.html index 8567c50..5c244fa 100644 --- a/index.html +++ b/index.html @@ -19,6 +19,7 @@ +
diff --git a/src/audio/engine.ts b/src/audio/engine.ts index 372b13e..9d8c263 100644 --- a/src/audio/engine.ts +++ b/src/audio/engine.ts @@ -41,11 +41,11 @@ export class AudioEngine { // Tracks: each track has sample, gain, panner, insert fx chain, pattern // (16 steps), pianoGrid (24x16), mute/solo/volume/pan. tracks: Track[] = [ - { name: 'Kick', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sine', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Snare', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'noise', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Bass', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sawtooth', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Synth', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'square', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Pads', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'triangle', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Kick', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sine', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Snare', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'noise', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Bass', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sawtooth', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Synth', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'square', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Pads', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'triangle', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, ]; trackCount = 5; _activeTrackCount = 5; @@ -320,6 +320,90 @@ export class AudioEngine { this._buildSynthVoice(ctx, track, dest, midiNote, ctxTime, duration, velocity); } + // --- Live held voices (note-on / note-off, keyboard & MIDI controllers) --- + + private _liveVoices = new Map(); + + // Start a sustained voice on a track. Safe to call from user-gesture + // handlers (creates the context lazily). A retrigger of the same + // track+note hard-stops the previous voice. + noteOn(trackIndex: number, midiNote: number, velocity = 1) { + const ctx = this.ensureContext(); + const track = this.tracks[trackIndex]; + const dest = this._voiceDest(track); + if (!ctx || !track || !dest) return; + const key = `${trackIndex}:${midiNote}`; + const prev = this._liveVoices.get(key); + if (prev) { + try { prev.source.stop(); } catch { /* already stopped */ } + this._liveVoices.delete(key); + } + + const time = ctx.currentTime; + const adsr: AdsrParams = track.adsr; + const peak = 0.25 * Math.max(0, Math.min(1, velocity)); + + let source: OscillatorNode | AudioBufferSourceNode; + if (track.synthType === 'noise') { + if (!track.noiseBuffer) { + const len = Math.round(ctx.sampleRate * 2); + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1; + track.noiseBuffer = buf; + } + const src = ctx.createBufferSource(); + src.buffer = track.noiseBuffer; + src.loop = true; + source = src; + } else { + const osc = ctx.createOscillator(); + osc.type = track.synthType; + osc.frequency.setValueAtTime(this.midiToFreq(midiNote), time); + source = osc; + } + + // Attack -> decay -> hold at sustain. Release happens in noteOff(). + const env = ctx.createGain(); + env.gain.setValueAtTime(0.0001, time); + env.gain.linearRampToValueAtTime(peak, time + Math.max(0.001, adsr.attack)); + env.gain.linearRampToValueAtTime(peak * adsr.sustain, time + Math.max(0.001, adsr.attack) + Math.max(0.001, adsr.decay)); + + source.connect(env); + env.connect(dest); + source.start(time); + this._liveVoices.set(key, { source, env }); + } + + // Release the sustained voice for a track+note (no-op when not held). + noteOff(trackIndex: number, midiNote: number) { + const ctx = this.ctx; + if (!ctx) return; + const key = `${trackIndex}:${midiNote}`; + const voice = this._liveVoices.get(key); + if (!voice) return; + this._liveVoices.delete(key); + + const rel = Math.max(0.02, this.tracks[trackIndex]?.adsr.release ?? 0.1); + const t = ctx.currentTime; + try { voice.env.gain.cancelScheduledValues(t); } catch { /* older API */ } + voice.env.gain.setValueAtTime(Math.max(0.0001, voice.env.gain.value), t); + voice.env.gain.exponentialRampToValueAtTime(0.0001, t + rel); + try { voice.source.stop(t + rel + 0.05); } catch { /* already stopped */ } + } + + // Hard-stop every live voice (transport stop). + private _killAllLiveVoices() { + this._liveVoices.forEach((v) => { + try { v.source.stop(); } catch { /* already stopped */ } + }); + this._liveVoices.clear(); + } + + get liveVoiceCount(): number { + return this._liveVoices.size; + } + // Build a synth voice (oscillator / noise + ADSR envelope) in the given // context and connect it to destGain. Shared by live playback and the // offline renderer so exported audio matches what's heard. `velocity` @@ -493,6 +577,7 @@ export class AudioEngine { track.name = file.name.replace(/\.[^.]+$/, ''); track.sampleStart = 0; // fresh trim = whole file track.sampleEnd = Infinity; + track.playbackRate = 1; // fresh pitch = original this._notifyPatternChange(); return buffer; } @@ -516,6 +601,23 @@ export class AudioEngine { track.sampleEnd = Math.max(track.sampleStart + 0.001, Math.min(end, dur)); } + // --- Sample pitch (tape-style playback rate) --- + + // Set the sample pitch in semitones (-24..+12). Stored as a rate multiplier + // (2^(st/12)); a fresh sample load resets it to the original pitch. + setSamplePitch(trackIndex: number, semitones: number) { + const track = this.tracks[trackIndex]; + if (!track) return; + const st = Math.max(-24, Math.min(12, Math.round(semitones))); + track.playbackRate = Math.pow(2, st / 12); + } + + // Current pitch offset in whole semitones (inverse of setSamplePitch). + samplePitchSemis(trackIndex: number): number { + const rate = this.tracks[trackIndex]?.playbackRate ?? 1; + return Math.round(12 * Math.log2(Math.max(0.01, rate))); + } + // Remove the loaded sample (and trim) from a track. clearSample(trackIndex: number) { const track = this.tracks[trackIndex]; @@ -909,6 +1011,38 @@ export class AudioEngine { this._notifyPatternChange(); } + // --- Pattern clipboard (in-memory only, not part of save/export) --- + + private _patternClipboard: PatternTrackData[] | null = null; + + canPastePattern(): boolean { + return this._patternClipboard !== null; + } + + copyPattern() { + const pat = this.patterns[this.currentPatternIndex]; + if (pat) this._patternClipboard = JSON.parse(JSON.stringify(pat.tracks)); + } + + // Overwrite the current pattern with the clipboard. Writes element-wise so + // live track references stay linked to the active pattern's arrays. + pastePattern(): boolean { + const clip = this._patternClipboard; + const cur = this.patterns[this.currentPatternIndex]; + if (!clip || !cur) return false; + cur.tracks.forEach((t, i) => { + const src = clip[i]; + if (!src) return; + t.pattern.splice(0, 16, ...src.pattern); + t.velocity.splice(0, 16, ...src.velocity); + for (let p = 0; p < 24; p++) { + t.pianoGrid[p].splice(0, 16, ...(src.pianoGrid?.[p] ?? new Array(16).fill(0))); + } + }); + this._notifyPatternChange(); + return true; + } + // Play a single sample on a track immediately (for preview) playSample(trackIndex: number, offset = 0) { const ctx = this.ensureContext(); @@ -918,6 +1052,7 @@ export class AudioEngine { if (!track || !track.sample || !dest) return; const src = ctx.createBufferSource(); src.buffer = track.sample; + src.playbackRate.value = track.playbackRate || 1; src.connect(dest); const { start, end } = this._sampleBounds(track); const begin = Math.min(Math.max(offset, start), end); @@ -972,6 +1107,7 @@ export class AudioEngine { const velocity = this._stepVelocity(srcData, this.stepIndex); const src = this.ctx!.createBufferSource(); src.buffer = track.sample; + src.playbackRate.value = track.playbackRate || 1; const gain = this.ctx!.createGain(); gain.gain.value = velocity; src.connect(gain); @@ -1046,16 +1182,38 @@ export class AudioEngine { this._notifyStateChange(); } - // Stop everything (transport + any one-shots) + // Stop everything (transport + any one-shots and live held voices) stop() { this.stopTransport(); + this._killAllLiveVoices(); + } + + // Whether a track plays anything in the region offlineRender() covers + // (loop region / playlist / active pattern). Used to skip silent stems. + trackHasContent(trackIndex: number): boolean { + const track = this.tracks[trackIndex]; + if (!track) return false; + const hasPlaylist = this.playlist.length > 0; + const barOffset = hasPlaylist && this.loopEnabled ? this.loopStart : 0; + const barCount = hasPlaylist + ? (this.loopEnabled ? this._effectiveLoopEnd() - this.loopStart : this.playlist.length) + : 1; + for (let barIdx = 0; barIdx < barCount; barIdx++) { + const src = this._stepSourceForBar(trackIndex, barOffset + barIdx); + if (!src) continue; + if (track.sample && src.pattern.some(Boolean)) return true; + if (src.pianoGrid?.some((row) => row.some((v) => v > 0))) return true; + } + return false; } // Render the current loop region (or the active pattern when the playlist // is empty) to an AudioBuffer using an OfflineAudioContext. Mirrors the // live node chain: ADSR synth voices / sample buffer sources -> track // faders (volume + mute/solo) -> master. Renders faster than real time. - async offlineRender(): Promise { + // Pass `onlyTrack` to render a single track's stem (others are skipped, + // not just muted, so their fx chains never run). + async offlineRender(onlyTrack?: number): Promise { this._updateStepDuration(); const sampleRate = this.ctx?.sampleRate || 44100; const hasPlaylist = this.playlist.length > 0; @@ -1115,6 +1273,7 @@ export class AudioEngine { for (let step = 0; step < this.stepsPerBar; step++) { const time = (barIdx * this.stepsPerBar + step) * this.stepDuration + this._swingOffset(step); this.tracks.forEach((track, ti) => { + if (onlyTrack !== undefined && ti !== onlyTrack) return; // stem render: skip other tracks entirely const gain = trackGains[ti]; if (!gain || gain.gain.value <= 0) return; // muted / soloed-out / zero volume const dest = trackIns[ti]; @@ -1126,6 +1285,7 @@ export class AudioEngine { const velocity = this._stepVelocity(srcData, step); const src = offline.createBufferSource(); src.buffer = track.sample; + src.playbackRate.value = track.playbackRate || 1; const gain = offline.createGain(); gain.gain.value = velocity; src.connect(gain); @@ -1248,6 +1408,7 @@ export class AudioEngine { sampleData: t.sampleData ? this._arrayBufferToBase64(t.sampleData) : null, sampleStart: t.sampleStart, sampleEnd: Number.isFinite(t.sampleEnd) ? t.sampleEnd : undefined, + playbackRate: t.playbackRate, volume: t.volume, mute: t.mute, solo: t.solo, @@ -1376,6 +1537,9 @@ export class AudioEngine { track.sampleData = null; track.sampleStart = typeof saved.sampleStart === 'number' ? saved.sampleStart : 0; track.sampleEnd = typeof saved.sampleEnd === 'number' ? saved.sampleEnd : Infinity; + track.playbackRate = typeof saved.playbackRate === 'number' && saved.playbackRate > 0 + ? Math.max(0.25, Math.min(4, saved.playbackRate)) + : 1; if (saved.sampleData) { try { const data = this._base64ToArrayBuffer(saved.sampleData); @@ -1420,6 +1584,7 @@ export class AudioEngine { t.sampleName = null; t.sampleStart = 0; t.sampleEnd = Infinity; + t.playbackRate = 1; t.volume = 1; t.mute = false; t.solo = false; diff --git a/src/styles/keys.css b/src/styles/keys.css new file mode 100644 index 0000000..591941f --- /dev/null +++ b/src/styles/keys.css @@ -0,0 +1,100 @@ +/* keys.css — Live Controller module: computer-keyboard / Web MIDI input. */ + +.keys { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 560px; +} + +.keys-head { + display: flex; + align-items: flex-end; + gap: 14px; + flex-wrap: wrap; +} + +.keys-track { min-width: 130px; } +.keys-midi { min-width: 110px; height: 22px; font-size: 10px; } + +.keys-oct { display: flex; align-items: center; gap: 6px; } +.keys-oct-btn { + width: 28px; + min-width: 0; + padding: 0; +} +.keys-oct-val { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 24px; + border-radius: 4px; + border: 1px solid rgba(70, 72, 88, 0.85); + background: linear-gradient(180deg, #050B10 0%, #0A1218 100%); + color: #F0D9C8; + font-size: 12px; + box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.7); +} + +/* --- Keyboard strip (flat layout: blacks shorter, aligned to the top) --- */ +.keys-kbd { + display: flex; + gap: 2px; + padding: 8px; + border-radius: 6px; + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.08)), + var(--panel-3); + border: 1px solid #050B10; + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6); +} +.keys-key { + position: relative; + flex: 1 1 0; + height: 72px; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.55); + background: linear-gradient(180deg, #EFE9E1 0%, #CFC6BB 90%, #B4AAA0 100%); + cursor: pointer; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.7), + 0 2px 3px rgba(0, 0, 0, 0.45); + transition: transform 40ms ease, filter 60ms ease; +} +.keys-key:hover { filter: brightness(1.05); } +.keys-key.black { + height: 46px; + background: linear-gradient(180deg, #2C303C 0%, #171A22 80%, #0D1017 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.18), + 0 2px 4px rgba(0, 0, 0, 0.6); +} +.keys-key.active { + background: linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 70%, #7A4E42 100%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.4), + 0 0 10px rgba(217, 155, 127, 0.65); + transform: translateY(1px); +} +.keys-key.black.active { + background: linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 70%, #7A4E42 100%); +} + +.keys-key-label { + position: absolute; + left: 0; + right: 0; + bottom: 3px; + font-size: 8px; + text-align: center; + color: #5A5148; + pointer-events: none; +} +.keys-key.black .keys-key-label { color: rgba(235, 228, 220, 0.55); } + +.keys-hint { + font-size: 9px; + letter-spacing: 0.14em; + color: var(--text-faint); +} diff --git a/src/styles/sampler.css b/src/styles/sampler.css index eaffce5..4e6f80e 100644 --- a/src/styles/sampler.css +++ b/src/styles/sampler.css @@ -80,3 +80,14 @@ .sampler-file.error { color: #E8A89A; text-shadow: 0 0 6px rgba(176, 88, 78, 0.7); } .sampler-actions { display: flex; gap: 8px; } + +/* --- Pitch (playback rate) --- */ +.sampler-pitch { + display: flex; + align-items: center; + gap: 8px; +} +.sampler-pitch-slider { width: 150px; } +.sampler-pitch-slider.disabled { opacity: 0.35; cursor: not-allowed; } +.sampler-pitch-val { min-width: 56px; font-size: 11px; } +.sampler-pitch-reset { width: 30px; min-width: 0; padding: 0; } diff --git a/src/types.ts b/src/types.ts index 0fa5b43..5818524 100644 --- a/src/types.ts +++ b/src/types.ts @@ -117,6 +117,7 @@ export interface Track extends MixerChannel { sampleName: string | null; sampleStart: number; // trim: start offset in seconds (0 = from the top) sampleEnd: number; // trim: end offset in seconds (Infinity = to the tail) + playbackRate: number; // sample pitch as a rate multiplier (1 = original, 2 = +12 st) gain: GainNode | null; panner: StereoPannerNode | null; effects: TrackEffect[]; // per-track insert chain (order matters) @@ -138,6 +139,7 @@ export interface TrackSettings { sampleData: string | null; // base64 sampleStart?: number; // trim (optional so older saves still load) sampleEnd?: number; + playbackRate?: number; // optional so older saves still load volume: number; mute: boolean; solo: boolean; diff --git a/src/ui/app.ts b/src/ui/app.ts index 2f3d364..35e83d3 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -20,6 +20,7 @@ import { FxRack } from './fx.js'; import { Sampler } from './sampler.js'; import { Instrument } from './instrument.js'; import { MasterSection } from './master.js'; +import { Keys } from './keys.js'; export interface AppRefs { engine: AudioEngine; @@ -33,6 +34,7 @@ export interface AppRefs { sampler: Sampler; instrument: Instrument; master: MasterSection; + keys: Keys; } export function buildApp(container: HTMLElement): AppRefs { @@ -54,6 +56,7 @@ export function buildApp(container: HTMLElement): AppRefs { const mixMod = wall.addModule('mixer', 'MIXING CONSOLE', 1030, 30); const fxMod = wall.addModule('fx-rack', 'FX RACK', 1030, 420); const masterMod = wall.addModule('master-section', 'MASTER SECTION', 1030, 800); + const keysMod = wall.addModule('keys', 'LIVE CONTROLLER', 1030, 1120); // Sampler drawer: slide-out panel from the left edge, overlaying the wall. const drawer = new Drawer(); @@ -70,6 +73,7 @@ export function buildApp(container: HTMLElement): AppRefs { const sampler = new Sampler(engine, drawer.body); const instrument = new Instrument(engine, instrMod.body); const master = new MasterSection(engine, masterMod.body); + const keys = new Keys(engine, keysMod.body); // The top bar toggle opens/closes the sampler drawer. topbar.setSamplerToggleHandler(() => drawer.toggle()); @@ -80,7 +84,8 @@ export function buildApp(container: HTMLElement): AppRefs { fxRack.setTrack(track); sampler.setTrack(track); instrument.setTrack(track); + keys.setTrack(track); }); - return { engine, wall, transport, sequencer, pianoRoll, playlist, mixer, fxRack, sampler, instrument, master }; + return { engine, wall, transport, sequencer, pianoRoll, playlist, mixer, fxRack, sampler, instrument, master, keys }; } diff --git a/src/ui/keys.ts b/src/ui/keys.ts new file mode 100644 index 0000000..2096873 --- /dev/null +++ b/src/ui/keys.ts @@ -0,0 +1,248 @@ +// Live Controller module: play the selected track's synth from the computer +// keyboard or a Web MIDI device. Held notes use real note-on/note-off voices +// (attack -> decay -> sustain until release), unlike the fixed-length +// preview notes elsewhere in the UI. +// +// Computer keyboard mapping (relative to the octave readout): +// Z S X D C V G B H N J M , L . ; / -> chromatic notes C..E (+1 octave) +// - / = -> octave down / up + +import type { AudioEngine } from '../audio/engine.js'; +import { makeBtn, makeReadout, makeTag } from './rack.js'; +import { TRACK_NAMES } from './theme.js'; + +const KEY_SEMITONES: Record = { + z: 0, s: 1, x: 2, d: 3, c: 4, v: 5, g: 6, + b: 7, h: 8, n: 9, j: 10, m: 11, + ',': 12, l: 13, '.': 14, ';': 15, '/': 16, +}; + +const BLACK_KEYS = new Set([1, 3, 6, 8, 10]); +const MIN_OCTAVE = 1; +const MAX_OCTAVE = 7; + +interface MidiAccessLike extends MIDIAccess {} + +export class Keys { + private engine: AudioEngine; + private body: HTMLElement; + + private trackIndex = 0; + private octave = 4; + private heldKeys = new Set(); // computer keys currently held + private heldMidi = new Set(); // midi notes sounding via UI/MIDI + private keyEls = new Map(); + + private trackNameEl!: HTMLElement; + private octaveEl!: HTMLElement; + private kbdEl!: HTMLElement; + private midiStatusEl!: HTMLElement; + + constructor(engine: AudioEngine, body: HTMLElement) { + this.engine = engine; + this.body = body; + this.render(); + this.bindComputerKeyboard(); + void this.initMidi(); + this.engine.onStateChange(() => this.syncTrack()); + } + + setTrack(index: number) { + this.trackIndex = index; + this.syncTrack(); + } + + private render() { + this.body.innerHTML = ''; + this.body.className = 'keys'; + + const headRow = document.createElement('div'); + headRow.className = 'keys-head'; + + this.trackNameEl = makeReadout('1. KICK', 'keys-track'); + this.midiStatusEl = makeReadout('MIDI …', 'keys-midi'); + + const octGroup = document.createElement('div'); + octGroup.className = 'hw-group'; + octGroup.appendChild(makeTag('OCT')); + const octRow = document.createElement('div'); + octRow.className = 'keys-oct'; + const down = makeBtn('−', 'keys-oct-btn'); + down.title = 'Octave down (shortcut: "-")'; + down.addEventListener('click', () => this.shiftOctave(-1)); + this.octaveEl = document.createElement('span'); + this.octaveEl.className = 'keys-oct-val mono'; + this.octaveEl.textContent = String(this.octave); + const up = makeBtn('+', 'keys-oct-btn'); + up.title = 'Octave up (shortcut: "=")'; + up.addEventListener('click', () => this.shiftOctave(1)); + octRow.append(down, this.octaveEl, up); + octGroup.appendChild(octRow); + + headRow.append(this.trackNameEl, octGroup, this.midiStatusEl); + this.body.appendChild(headRow); + + // --- Visual keyboard: two octaves + a top C --- + this.kbdEl = document.createElement('div'); + this.kbdEl.className = 'keys-kbd'; + const baseMidi = this.octaveBaseMidi(); + for (let i = 0; i < 25; i++) { + const midi = baseMidi + i; + const black = BLACK_KEYS.has(midi % 12); + const key = document.createElement('button'); + key.type = 'button'; + key.className = `keys-key${black ? ' black' : ''}`; + key.dataset.midi = String(midi); + if (midi % 12 === 0) { + const label = document.createElement('span'); + label.className = 'keys-key-label mono'; + label.textContent = `C${Math.floor(midi / 12) - 1}`; + key.appendChild(label); + } + key.addEventListener('pointerdown', (e) => { + e.preventDefault(); + this.heldMidi.add(midi); + key.classList.add('active'); + this.engine.noteOn(this.trackIndex, midi); + }); + const release = () => { + if (!this.heldMidi.has(midi)) return; + this.heldMidi.delete(midi); + key.classList.remove('active'); + this.engine.noteOff(this.trackIndex, midi); + }; + key.addEventListener('pointerup', release); + key.addEventListener('pointerleave', release); + key.addEventListener('pointercancel', release); + + this.keyEls.set(midi, key); + this.kbdEl.appendChild(key); + } + this.body.appendChild(this.kbdEl); + + const hint = document.createElement('div'); + hint.className = 'keys-hint mono'; + hint.textContent = 'KEYS Z S X D C V G B H N J M… · OCT − / ='; + this.body.appendChild(hint); + + this.syncOctave(); + this.syncTrack(); + } + + // MIDI note of the C at the current octave (C4 = 60). + private octaveBaseMidi(): number { + return (this.octave + 1) * 12; + } + + private shiftOctave(delta: number) { + const next = Math.max(MIN_OCTAVE, Math.min(MAX_OCTAVE, this.octave + delta)); + if (next === this.octave) return; + this.releaseAllHeld(); + this.octave = next; + this.render(); // re-map visual keys to the new octave window + } + + private syncOctave() { + this.octaveEl.textContent = String(this.octave); + } + + private syncTrack() { + const track = this.engine.tracks[this.trackIndex]; + if (!track || !this.trackNameEl?.isConnected) return; + this.trackNameEl.textContent = `${this.trackIndex + 1}. ${TRACK_NAMES[this.trackIndex] ?? track.name}`; + } + + private isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable; + } + + // --- Computer keyboard --- + + private bindComputerKeyboard() { + document.addEventListener('keydown', (e) => { + if (e.repeat || this.isTypingTarget(e.target)) return; + const k = e.key.toLowerCase(); + + if (k === '-') { e.preventDefault(); this.shiftOctave(-1); return; } + if (k === '=') { e.preventDefault(); this.shiftOctave(1); return; } + + const semis = KEY_SEMITONES[k]; + if (semis === undefined || this.heldKeys.has(k)) return; + e.preventDefault(); + this.heldKeys.add(k); + const midi = Math.min(127, this.octaveBaseMidi() + semis); + this.keyEls.get(midi)?.classList.add('active'); + this.engine.noteOn(this.trackIndex, midi); + }); + + document.addEventListener('keyup', (e) => { + const k = e.key.toLowerCase(); + if (!this.heldKeys.has(k)) return; + this.heldKeys.delete(k); + const midi = Math.min(127, this.octaveBaseMidi() + KEY_SEMITONES[k]); + this.keyEls.get(midi)?.classList.remove('active'); + this.engine.noteOff(this.trackIndex, midi); + }); + + // Safety: drop everything when the window loses focus. + window.addEventListener('blur', () => { + this.heldKeys.clear(); + this.releaseAllHeld(); + }); + } + + private releaseAllHeld() { + this.heldMidi.forEach((midi) => { + this.engine.noteOff(this.trackIndex, midi); + this.keyEls.get(midi)?.classList.remove('active'); + }); + this.heldMidi.clear(); + this.keyEls.forEach((el) => el.classList.remove('active')); + } + + // --- Web MIDI (optional capability; silently stays "no device" without it) --- + + private async initMidi() { + const nav = navigator as Navigator; + if (!nav.requestMIDIAccess) { + this.midiStatusEl.textContent = 'MIDI n/a'; + return; + } + try { + const access: MidiAccessLike = await nav.requestMIDIAccess({ sysex: false }); + const bind = () => { + let names: string[] = []; + access.inputs.forEach((input) => { + input.onmidimessage = (e: MIDIMessageEvent) => this.onMidiMessage(e); + if (input.name) names.push(input.name); + }); + this.midiStatusEl.textContent = names.length ? `MIDI ${names[0].slice(0, 10)}` : 'MIDI none'; + }; + access.onstatechange = bind; + bind(); + } catch { + this.midiStatusEl.textContent = 'MIDI denied'; + } + } + + private onMidiMessage(e: MIDIMessageEvent) { + const data = e.data; + if (!data || data.length < 2) return; + const [status, note, velocity] = Array.from(data); + const command = status & 0xf0; + const clamped = Math.max(0, Math.min(127, note ?? 0)); + if (command === 0x90 && (velocity ?? 0) > 0) { + this.engine.noteOn(this.trackIndex, clamped, (velocity ?? 127) / 127); + this.flashKey(clamped, true); + } else if (command === 0x80 || (command === 0x90 && (velocity ?? 0) === 0)) { + this.engine.noteOff(this.trackIndex, clamped); + this.flashKey(clamped, false); + } + } + + private flashKey(midi: number, on: boolean) { + const el = this.keyEls.get(midi); + if (el) el.classList.toggle('active', on); + } +} diff --git a/src/ui/playlist.ts b/src/ui/playlist.ts index ee6abf3..2c27095 100644 --- a/src/ui/playlist.ts +++ b/src/ui/playlist.ts @@ -21,6 +21,7 @@ export class Playlist { private patternEl: HTMLInputElement | null = null; private countEl: HTMLElement | null = null; private loopBtn: HTMLButtonElement | null = null; + private pasteBtn: HTMLButtonElement | null = null; private currentBar = -1; private lastDragAt = 0; @@ -77,6 +78,21 @@ export class Playlist { this.syncControls(); }); + const copy = makeBtn('COPY'); + copy.title = 'Copy this pattern (all tracks) to the clipboard'; + copy.addEventListener('click', () => this.engine.copyPattern()); + + const paste = makeBtn('PASTE'); + paste.title = 'Overwrite this pattern with the clipboard'; + paste.addEventListener('click', () => { + if (!this.engine.canPastePattern()) return; + this.engine.beginHistory(); + this.engine.pastePattern(); + this.engine.commitHistory(); + this.syncControls(); + }); + this.pasteBtn = paste; + this.loopBtn = makeBtn('L'); this.loopBtn.title = 'Loop region on/off'; this.loopBtn.addEventListener('click', () => { @@ -86,7 +102,7 @@ export class Playlist { this.syncControls(); }); - toolbar.append(prev, next, name, this.countEl, add, del, this.loopBtn); + toolbar.append(prev, next, name, this.countEl, add, del, copy, paste, this.loopBtn); this.body.appendChild(toolbar); // --- Bar cells --- @@ -236,6 +252,7 @@ export class Playlist { if (this.patternEl) this.patternEl.value = pat.name; if (this.countEl) this.countEl.textContent = `${this.engine.currentPatternIndex + 1}/${this.engine.patterns.length}`; if (this.loopBtn) this.loopBtn.classList.toggle('active', this.engine.loopEnabled); + if (this.pasteBtn) this.pasteBtn.disabled = !this.engine.canPastePattern(); this.updateLoopVisuals(); } diff --git a/src/ui/sampler.ts b/src/ui/sampler.ts index bcf6a57..1de9f6f 100644 --- a/src/ui/sampler.ts +++ b/src/ui/sampler.ts @@ -22,6 +22,8 @@ export class Sampler { private fileNameEl!: HTMLElement; private trimInfoEl!: HTMLElement; private trackSelect!: HTMLSelectElement; + private pitchSlider!: HTMLInputElement; + private pitchVal!: HTMLElement; constructor(engine: AudioEngine, body: HTMLElement) { this.engine = engine; @@ -33,9 +35,24 @@ export class Sampler { setTrack(index: number) { this.trackIndex = index; this.trackSelect.value = String(index); + this.syncPitch(); this.redraw(); } + private updatePitchReadout(st: number) { + this.pitchVal.textContent = `${st > 0 ? '+' : ''}${st} st`; + } + + // Reflect the engine's pitch state (slider position + readout + usability). + private syncPitch() { + const hasSample = !!this.engine.tracks[this.trackIndex]?.sample; + const st = this.engine.samplePitchSemis(this.trackIndex); + if (document.activeElement !== this.pitchSlider) this.pitchSlider.value = String(st); + this.pitchSlider.disabled = !hasSample; + this.pitchSlider.classList.toggle('disabled', !hasSample); + this.updatePitchReadout(st); + } + private render() { this.body.innerHTML = ''; this.body.className = 'sampler'; @@ -88,6 +105,48 @@ export class Sampler { loadGroup.append(loadBtn, fileInput); this.body.appendChild(loadGroup); + // --- Pitch (tape-style playback rate in semitones) --- + const pitchGroup = document.createElement('div'); + pitchGroup.className = 'hw-group'; + pitchGroup.appendChild(makeTag('PITCH')); + const pitchRow = document.createElement('div'); + pitchRow.className = 'sampler-pitch'; + this.pitchSlider = document.createElement('input'); + this.pitchSlider.type = 'range'; + this.pitchSlider.className = 'hw-slider sampler-pitch-slider'; + this.pitchSlider.min = '-24'; + this.pitchSlider.max = '12'; + this.pitchSlider.step = '1'; + this.pitchSlider.title = 'Sample pitch in semitones'; + let pitchGesture = false; + this.pitchSlider.addEventListener('input', () => { + if (!pitchGesture) { + pitchGesture = true; + this.engine.beginHistory(); + } + const st = parseInt(this.pitchSlider.value, 10); + this.engine.setSamplePitch(this.trackIndex, st); + this.updatePitchReadout(st); + }); + const endPitchGesture = () => { + if (!pitchGesture) return; + pitchGesture = false; + this.engine.commitHistory(); + }; + this.pitchSlider.addEventListener('change', endPitchGesture); + const pitchReset = makeBtn('0', 'sampler-pitch-reset'); + pitchReset.title = 'Reset to original pitch'; + pitchReset.addEventListener('click', () => { + this.engine.beginHistory(); + this.engine.setSamplePitch(this.trackIndex, 0); + this.engine.commitHistory(); + this.syncPitch(); + }); + this.pitchVal = makeReadout('+0 st', 'sampler-pitch-val'); + pitchRow.append(this.pitchSlider, this.pitchVal, pitchReset); + pitchGroup.appendChild(pitchRow); + this.body.appendChild(pitchGroup); + // --- Waveform + trim handles --- const waveGroup = document.createElement('div'); waveGroup.className = 'hw-group'; @@ -189,6 +248,7 @@ export class Sampler { // Downsample the audio into N peak segments and draw them. private redraw() { + this.syncPitch(); const ctx = this.ctx; if (!ctx) return; const W = CANVAS_W; diff --git a/src/ui/transport.ts b/src/ui/transport.ts index 3bfc06c..b37f669 100644 --- a/src/ui/transport.ts +++ b/src/ui/transport.ts @@ -5,7 +5,7 @@ import type { AudioEngine } from '../audio/engine.js'; import { audioBufferToWav } from '../audio/wav.js'; import { makeBtn, makeReadout, makeTag } from './rack.js'; -import { STORAGE_KEY } from './theme.js'; +import { STORAGE_KEY, TRACK_NAMES } from './theme.js'; export class Transport { private engine: AudioEngine; @@ -236,6 +236,11 @@ export class Transport { exportProjBtn.title = 'Export the project as a JSON file'; exportProjBtn.addEventListener('click', () => this.exportProject()); + const exportStemsBtn = makeBtn('EXPORT STEMS'); + exportStemsBtn.id = 'btn-export-stems'; + exportStemsBtn.title = 'Render every playing track to its own WAV file'; + exportStemsBtn.addEventListener('click', () => this.exportStems()); + const importProjBtn = makeBtn('IMPORT PROJ'); importProjBtn.id = 'btn-import-proj'; importProjBtn.title = 'Import a project from a JSON file'; @@ -246,7 +251,7 @@ export class Transport { importInput.addEventListener('change', () => this.importProject(importInput)); importProjBtn.addEventListener('click', () => importInput.click()); - fileGroup.append(saveBtn, openBtn, newBtn, this.exportBtn, exportProjBtn, importProjBtn, importInput); + fileGroup.append(saveBtn, openBtn, newBtn, this.exportBtn, exportProjBtn, exportStemsBtn, importProjBtn, importInput); this.filesBody.appendChild(fileGroup); this.bindShortcuts(); @@ -365,12 +370,7 @@ export class Transport { try { const buffer = await this.engine.offlineRender(); const blob = audioBufferToWav(buffer); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'voidstation-export.wav'; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 10000); + this.downloadBlob(blob, 'voidstation-export.wav'); this.setStatus(`exported ${blob.size} bytes`); } catch (err) { console.error('Export failed:', err); @@ -382,17 +382,49 @@ export class Transport { } } + // Trigger a browser download for a blob (revokes the URL afterwards). + private downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 10000); + } + + // Render each track that plays anything in the export region to its own + // WAV ("stem"). Files download sequentially; browsers allow multiple + // downloads from one gesture chain with a small delay between them. + private async exportStems() { + if (!this.engine.hasContent) { + this.setStatus('nothing to export yet', true); + return; + } + this.setStatus('stems…'); + let exported = 0; + for (let i = 0; i < this.engine.tracks.length; i++) { + if (!this.engine.trackHasContent(i)) continue; + try { + const buffer = await this.engine.offlineRender(i); + const blob = audioBufferToWav(buffer); + const name = (TRACK_NAMES[i] ?? this.engine.tracks[i]?.name ?? `track-${i + 1}`) + .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || `track-${i + 1}`; + this.downloadBlob(blob, `voidstation-stem-${String(i + 1).padStart(2, '0')}-${name}.wav`); + exported++; + // Give the browser a beat to accept the previous download. + await new Promise((r) => setTimeout(r, 300)); + } catch (err) { + console.error(`Stem export failed for track ${i}:`, err); + } + } + this.setStatus(exported > 0 ? `exported ${exported} stem${exported === 1 ? '' : 's'}` : 'no tracks with content', exported === 0); + } + // Download the whole project as a JSON file (mirrors the localStorage save). private exportProject() { try { const json = JSON.stringify(this.engine.serialize(), null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'voidstation-project.json'; - a.click(); - setTimeout(() => URL.revokeObjectURL(url), 10000); + this.downloadBlob(new Blob([json], { type: 'application/json' }), 'voidstation-project.json'); this.setStatus('project exported'); } catch (err) { console.error('Project export failed:', err); diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 8b8febf..4818b1f 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -162,5 +162,78 @@ test('deserialize tolerates legacy saves without new fields', async () => { assert.equal(e.swing, 0); assert.equal(e.masterVolume, 0.8); assert.equal(e.limiterEnabled, false); + assert.equal(e.tracks[0].playbackRate, 1); assert.equal(e.bpm, 100); }); + +test('pattern clipboard copies and overwrites the active pattern in place', () => { + const e = fresh(); + assert.equal(e.canPastePattern(), false); + + // Build distinctive content on the current (empty) pattern. + e.toggleStep(0, 4); + e.setStepVelocity(0, 4, 0.7); + e.tracks[0].pianoGrid[10][2] = 3; + e.copyPattern(); + + // Wipe the same pattern completely. + e.clearTrackPattern(0); + e.tracks[0].velocity.fill(1); + e.tracks[0].pianoGrid.forEach((row) => row.fill(0)); + assert.equal(e.trackHasContent(0), false); + + // Paste restores everything, writing through the live references. + e.pastePattern(); + assert.equal(e.tracks[0].pattern[4], true); + assert.equal(e.tracks[0].velocity[4], 0.7); + assert.equal(e.tracks[0].pianoGrid[10][2], 3); + assert.equal(e.patterns[e.currentPatternIndex].tracks[0].pianoGrid[10][2], 3); + + // Clipboard survives switching; paste lands on whatever pattern is active. + e.addPattern(); // P2 = copy of P1, now current + e.clearTrackPattern(0); + e.tracks[0].pianoGrid.forEach((row) => row.fill(0)); + e.pastePattern(); + assert.equal(e.tracks[0].pattern[4], true); +}); + +test('trackHasContent reflects pattern/grid/sample activity', () => { + const e = fresh(); + // The step layer only sounds when a sample is loaded — stub one in. + e.tracks[0].sample = { duration: 1 }; + assert.equal(e.trackHasContent(0), false); + e.toggleStep(0, 0); + assert.equal(e.trackHasContent(0), true); + assert.equal(e.trackHasContent(1), false); + + // Piano grid activity counts too. + const e2 = fresh(); + e2.tracks[3].pianoGrid[5][7] = 2; + assert.equal(e2.trackHasContent(3), true); + + // Step on P2 but only empty P1 arranged -> silent in the region. + const e3 = fresh(); + e3.addPattern(); // creates P2 as a copy, P2 is current + e3.clearTrackPattern(0); // make sure P2 is actually empty + e3.tracks[0].sample = { duration: 1 }; + e3.toggleStep(0, 0); + e3.switchPattern(0); + e3.setPlaylistCell(0, 0); // arrange the still-empty P1 + assert.equal(e3.trackHasContent(0), false); +}); + +test('sample pitch clamps to -24..+12 semitones and round-trips', () => { + const e = fresh(); + e.setSamplePitch(0, 12); + assert.ok(Math.abs(e.tracks[0].playbackRate - 2) < 1e-9); + assert.equal(e.samplePitchSemis(0), 12); + + e.setSamplePitch(0, -24); + assert.ok(Math.abs(e.tracks[0].playbackRate - 0.25) < 1e-9); + + e.setSamplePitch(0, 99); // clamped high + assert.equal(e.samplePitchSemis(0), 12); + + e.setSamplePitch(0, 3.6); // rounded to whole semitones + assert.equal(e.tracks[0].playbackRate, Math.pow(2, 4 / 12)); +}); From aad69de16e81ddbafbe355582cd2aabec0105f77 Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 14:57:19 +0600 Subject: [PATCH 3/6] feat: per-track sample REVERSE with trim-aware bounds swap - REV toggle in the sampler actions row (disabled without a sample, waveform wrap gets a subtle accent outline when active) - reversed playback uses a lazily built per-track reversed buffer cache (invalidated on load/clear/restore/toggle); the trim window is measured from the end of the audio while reversed, so trims stay meaningful - applied at every trigger site: preview, live scheduler and offline render (stems/WAV export included); persisted as optional field --- src/audio/engine.ts | 59 ++++++++++++++++++++++++++++++++++++------ src/styles/sampler.css | 5 ++++ src/types.ts | 3 +++ src/ui/sampler.ts | 28 ++++++++++++++++++-- tests/engine.test.mjs | 41 +++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/src/audio/engine.ts b/src/audio/engine.ts index 9d8c263..a1469d8 100644 --- a/src/audio/engine.ts +++ b/src/audio/engine.ts @@ -41,11 +41,11 @@ export class AudioEngine { // Tracks: each track has sample, gain, panner, insert fx chain, pattern // (16 steps), pianoGrid (24x16), mute/solo/volume/pan. tracks: Track[] = [ - { name: 'Kick', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sine', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Snare', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'noise', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Bass', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sawtooth', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Synth', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'square', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, - { name: 'Pads', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'triangle', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Kick', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, reverse: false, reversedBuffer: null, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sine', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Snare', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, reverse: false, reversedBuffer: null, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'noise', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Bass', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, reverse: false, reversedBuffer: null, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'sawtooth', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Synth', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, reverse: false, reversedBuffer: null, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'square', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, + { name: 'Pads', sample: null, sampleData: null, sampleName: null, sampleStart: 0, sampleEnd: Infinity, playbackRate: 1, reverse: false, reversedBuffer: null, gain: null, panner: null, effects: [], fxIn: null, fxOut: null, fxNodes: [], pattern: new Array(16).fill(false), pianoGrid: this._createPianoGrid(), velocity: new Array(16).fill(1), volume: 1.0, mute: false, solo: false, synthType: 'triangle', adsr: { attack: 0.01, decay: 0.1, sustain: 0.5, release: 0.1 }, pan: 0, noiseBuffer: null }, ]; trackCount = 5; _activeTrackCount = 5; @@ -578,18 +578,45 @@ export class AudioEngine { track.sampleStart = 0; // fresh trim = whole file track.sampleEnd = Infinity; track.playbackRate = 1; // fresh pitch = original + track.reverse = false; // fresh direction = forward + track.reversedBuffer = null; this._notifyPatternChange(); return buffer; } // Resolve the effective trim window for a loaded sample (seconds). + // Reversed playback measures the window from the tail of the audio, so the + // bounds swap: a trim of [1,3] plays the last seconds mirrored. _sampleBounds(track: Track): { start: number; end: number } { const dur = track.sample?.duration || 0; const start = Math.max(0, Math.min(track.sampleStart, dur - 0.001)); const end = Math.max(start + 0.001, Math.min(track.sampleEnd, dur)); + if (track.reverse) return { start: dur - end, end: dur - start }; return { start, end }; } + // Reversed copy of the track's sample, built once and cached on the track + // (non-serialized). Returns null when it cannot be built (callers fall back + // to the forward buffer); the cache is dropped whenever the sample changes. + _reversedBufferFor(ctx: BaseAudioContext, track: Track): AudioBuffer | null { + if (!track.sample) return null; + if (!track.reversedBuffer) { + try { + const s = track.sample; + const buf = ctx.createBuffer(s.numberOfChannels, s.length, s.sampleRate); + for (let ch = 0; ch < s.numberOfChannels; ch++) { + const src = s.getChannelData(ch); + const dst = buf.getChannelData(ch); + for (let i = 0, n = src.length; i < n; i++) dst[i] = src[n - 1 - i]; + } + track.reversedBuffer = buf; + } catch { + return null; + } + } + return track.reversedBuffer; + } + // Set the trim window for a loaded sample (seconds). Clamps to the buffer. // Deliberately does NOT notify listeners: trim drags fire per-move and the // instrument panel redraws itself; no other view reads the trim. @@ -618,6 +645,16 @@ export class AudioEngine { return Math.round(12 * Math.log2(Math.max(0.01, rate))); } + // Toggle reversed playback for a track's sample. Cheap on purpose: flips + // the flag and drops the cached reversed copy (rebuilt lazily on next + // play); the sampler panel redraws itself, so no notification is needed. + setSampleReverse(trackIndex: number, reverse: boolean) { + const track = this.tracks[trackIndex]; + if (!track) return; + track.reverse = !!reverse; + track.reversedBuffer = null; + } + // Remove the loaded sample (and trim) from a track. clearSample(trackIndex: number) { const track = this.tracks[trackIndex]; @@ -627,6 +664,7 @@ export class AudioEngine { track.sampleName = null; track.sampleStart = 0; track.sampleEnd = Infinity; + track.reversedBuffer = null; // stale with the sample gone this._notifyPatternChange(); } @@ -1051,7 +1089,7 @@ export class AudioEngine { const dest = this._voiceDest(track); if (!track || !track.sample || !dest) return; const src = ctx.createBufferSource(); - src.buffer = track.sample; + src.buffer = (track.reverse ? this._reversedBufferFor(ctx, track) : null) ?? track.sample; src.playbackRate.value = track.playbackRate || 1; src.connect(dest); const { start, end } = this._sampleBounds(track); @@ -1106,7 +1144,7 @@ export class AudioEngine { if (dest) { const velocity = this._stepVelocity(srcData, this.stepIndex); const src = this.ctx!.createBufferSource(); - src.buffer = track.sample; + src.buffer = (track.reverse ? this._reversedBufferFor(this.ctx!, track) : null) ?? track.sample; src.playbackRate.value = track.playbackRate || 1; const gain = this.ctx!.createGain(); gain.gain.value = velocity; @@ -1284,7 +1322,7 @@ export class AudioEngine { if (track.sample && srcData.pattern[step]) { const velocity = this._stepVelocity(srcData, step); const src = offline.createBufferSource(); - src.buffer = track.sample; + src.buffer = (track.reverse ? this._reversedBufferFor(offline, track) : null) ?? track.sample; src.playbackRate.value = track.playbackRate || 1; const gain = offline.createGain(); gain.gain.value = velocity; @@ -1409,6 +1447,7 @@ export class AudioEngine { sampleStart: t.sampleStart, sampleEnd: Number.isFinite(t.sampleEnd) ? t.sampleEnd : undefined, playbackRate: t.playbackRate, + reverse: t.reverse, volume: t.volume, mute: t.mute, solo: t.solo, @@ -1535,11 +1574,13 @@ export class AudioEngine { // Restore sample data (decode base64 -> AudioBuffer) track.sample = null; track.sampleData = null; + track.reversedBuffer = null; // stale once the sample is restored track.sampleStart = typeof saved.sampleStart === 'number' ? saved.sampleStart : 0; track.sampleEnd = typeof saved.sampleEnd === 'number' ? saved.sampleEnd : Infinity; track.playbackRate = typeof saved.playbackRate === 'number' && saved.playbackRate > 0 ? Math.max(0.25, Math.min(4, saved.playbackRate)) : 1; + track.reverse = !!saved.reverse; // absent on old saves -> forward if (saved.sampleData) { try { const data = this._base64ToArrayBuffer(saved.sampleData); @@ -1585,6 +1626,8 @@ export class AudioEngine { t.sampleStart = 0; t.sampleEnd = Infinity; t.playbackRate = 1; + t.reverse = false; + t.reversedBuffer = null; t.volume = 1; t.mute = false; t.solo = false; diff --git a/src/styles/sampler.css b/src/styles/sampler.css index 4e6f80e..35eb856 100644 --- a/src/styles/sampler.css +++ b/src/styles/sampler.css @@ -80,6 +80,11 @@ .sampler-file.error { color: #E8A89A; text-shadow: 0 0 6px rgba(176, 88, 78, 0.7); } .sampler-actions { display: flex; gap: 8px; } +.sampler-actions .sampler-rev { min-width: 0; } +.sampler-actions .sampler-rev.disabled { opacity: 0.35; cursor: not-allowed; } + +/* Reversed playback state: accent outline around the waveform. */ +.sampler-wave.reversed { box-shadow: inset 0 0 0 2px rgba(217, 155, 127, 0.45); } /* --- Pitch (playback rate) --- */ .sampler-pitch { diff --git a/src/types.ts b/src/types.ts index 5818524..33ce727 100644 --- a/src/types.ts +++ b/src/types.ts @@ -118,6 +118,8 @@ export interface Track extends MixerChannel { sampleStart: number; // trim: start offset in seconds (0 = from the top) sampleEnd: number; // trim: end offset in seconds (Infinity = to the tail) playbackRate: number; // sample pitch as a rate multiplier (1 = original, 2 = +12 st) + reverse: boolean; // play the trimmed window backwards (trim measured from the tail) + reversedBuffer: AudioBuffer | null; // internal reversed copy of the sample (cache) gain: GainNode | null; panner: StereoPannerNode | null; effects: TrackEffect[]; // per-track insert chain (order matters) @@ -140,6 +142,7 @@ export interface TrackSettings { sampleStart?: number; // trim (optional so older saves still load) sampleEnd?: number; playbackRate?: number; // optional so older saves still load + reverse?: boolean; // optional so older saves still load volume: number; mute: boolean; solo: boolean; diff --git a/src/ui/sampler.ts b/src/ui/sampler.ts index 1de9f6f..58e0ab6 100644 --- a/src/ui/sampler.ts +++ b/src/ui/sampler.ts @@ -24,6 +24,7 @@ export class Sampler { private trackSelect!: HTMLSelectElement; private pitchSlider!: HTMLInputElement; private pitchVal!: HTMLElement; + private revBtn!: HTMLButtonElement; constructor(engine: AudioEngine, body: HTMLElement) { this.engine = engine; @@ -53,6 +54,18 @@ export class Sampler { this.updatePitchReadout(st); } + // Reflect the engine's reverse state (button + waveform accent). + private syncReverse() { + const track = this.engine.tracks[this.trackIndex]; + const hasSample = !!track?.sample; + const on = hasSample && !!track!.reverse; + this.revBtn.disabled = !hasSample; + this.revBtn.classList.toggle('disabled', !hasSample); + this.revBtn.classList.toggle('active', on); + this.revBtn.title = on ? 'Reversed — click to restore forward playback' : 'Play the sample backwards'; + this.wrap.classList.toggle('reversed', on); + } + private render() { this.body.innerHTML = ''; this.body.className = 'sampler'; @@ -175,11 +188,21 @@ export class Sampler { meta.append(this.fileNameEl, this.trimInfoEl); this.body.appendChild(meta); - // --- Preview / clear --- + // --- Preview / reverse / clear --- const actions = document.createElement('div'); actions.className = 'sampler-actions'; const preview = makeBtn('▶ PREVIEW'); preview.addEventListener('click', () => this.engine.playSample(this.trackIndex)); + this.revBtn = makeBtn('REV', 'sampler-rev'); + this.revBtn.title = 'Play the sample backwards'; + this.revBtn.addEventListener('click', () => { + const track = this.engine.tracks[this.trackIndex]; + if (!track?.sample) return; + this.engine.beginHistory(); + this.engine.setSampleReverse(this.trackIndex, !track.reverse); + this.engine.commitHistory(); + this.syncReverse(); + }); const clear = makeBtn('CLEAR'); clear.addEventListener('click', () => { this.engine.beginHistory(); @@ -189,7 +212,7 @@ export class Sampler { this.fileNameEl.classList.remove('error'); this.redraw(); }); - actions.append(preview, clear); + actions.append(preview, this.revBtn, clear); this.body.appendChild(actions); // --- Waveform interactions --- @@ -249,6 +272,7 @@ export class Sampler { // Downsample the audio into N peak segments and draw them. private redraw() { this.syncPitch(); + this.syncReverse(); const ctx = this.ctx; if (!ctx) return; const W = CANVAS_W; diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 4818b1f..aa73223 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -237,3 +237,44 @@ test('sample pitch clamps to -24..+12 semitones and round-trips', () => { e.setSamplePitch(0, 3.6); // rounded to whole semitones assert.equal(e.tracks[0].playbackRate, Math.pow(2, 4 / 12)); }); + +test('setSampleReverse round-trips through serialize/deserialize', async () => { + const e = fresh(); + e.setSampleReverse(2, true); + assert.equal(e.tracks[2].reverse, true); + // Toggling again flips back and keeps the flag in sync. + e.setSampleReverse(2, false); + assert.equal(e.tracks[2].reverse, false); + e.setSampleReverse(2, true); + + const state = JSON.parse(JSON.stringify(e.serialize())); // as over localStorage + assert.equal(state.tracks[2].reverse, true); + const e2 = fresh(); + await e2.deserialize(state); + assert.equal(e2.tracks[2].reverse, true); +}); + +test('legacy save without reverse defaults to false', async () => { + const e = fresh(); + await e.deserialize({ + version: 2, + bpm: 120, + tracks: [], + currentPatternIndex: 0, + playlist: [], + patterns: [{ name: 'P', tracks: [] }], + }); + assert.equal(e.tracks[0].reverse, false); +}); + +test('_sampleBounds swaps the trim window under reverse', () => { + const e = fresh(); + // Stub sample: _sampleBounds only reads track.sample?.duration. + e.tracks[0].sample = { duration: 10 }; + e.setSampleTrim(0, 1, 3); + assert.deepEqual(e._sampleBounds(e.tracks[0]), { start: 1, end: 3 }); + + e.setSampleReverse(0, true); + // Same trim measured from the tail of the audio. + assert.deepEqual(e._sampleBounds(e.tracks[0]), { start: 7, end: 9 }); +}); From d2c1822cfebbca2cef56ad76e194a7e08612302a Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 14:57:32 +0600 Subject: [PATCH 4/6] feat: MODULES dock sidebar with drag-to-wall window management - slide-out sidebar (left edge, MODULES tab / HUD dock button) listing every registered module as a chip with an LED state marker - drag a chip onto the wall to place its module at the drop point (restores it if it was put away); drag an on-wall chip to relocate; plain click auto-pans to the module and pulses its outline - new header button puts a module away into the dock; double-click on a header collapses/expands the body - wall layout persistence extended to { positions, docked, collapsed } with backwards-compatible parsing of the old flat format; RESET keeps docked units put away --- index.html | 1 + src/styles/dock.css | 174 +++++++++++++++++++++ src/ui/hardwareWall.ts | 342 +++++++++++++++++++++++++++++++++++++---- 3 files changed, 487 insertions(+), 30 deletions(-) create mode 100644 src/styles/dock.css diff --git a/index.html b/index.html index 5c244fa..9051749 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,7 @@ +
diff --git a/src/styles/dock.css b/src/styles/dock.css new file mode 100644 index 0000000..f399324 --- /dev/null +++ b/src/styles/dock.css @@ -0,0 +1,174 @@ +/* dock.css — the MODULES sidebar: slide-out rack of window chips that can be + dragged onto the wall, plus per-module put-away / collapse affordances. */ + +/* --- Sidebar shell (fixed to the left edge of the room) --- */ +.hw-dock { + position: fixed; + left: 0; + top: var(--topbar-h); + bottom: 0; + z-index: 60; + display: flex; + align-items: center; + pointer-events: none; /* only the tab + open panel catch events */ +} +.hw-dock.open { pointer-events: auto; } + +/* Vertical tab, always visible */ +.hw-dock-tab { + pointer-events: auto; + writing-mode: vertical-rl; + transform: rotate(180deg); + padding: 14px 5px; + border: 1px solid var(--edge); + border-left: none; + border-radius: 0 6px 6px 0; + background: + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.02) 0 2px, transparent 2px 4px), + linear-gradient(180deg, #5C5F73 0%, #464858 55%, #3A3C4C 100%); + color: var(--text-dim); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.22em; + cursor: pointer; + box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.2), 2px 0 8px rgba(0, 0, 0, 0.45); +} +.hw-dock-tab:hover { color: var(--accent-bright); } + +/* Slide-out panel */ +.hw-dock-panel { + width: 216px; + max-height: 78vh; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 10px; + border: 1px solid var(--edge); + border-left: none; + border-radius: 0 8px 8px 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(0, 0, 0, 0.06)), + linear-gradient(180deg, var(--panel), var(--panel-2)); + box-shadow: 4px 0 18px rgba(0, 0, 0, 0.5), inset 1px 0 0 rgba(255, 255, 255, 0.07); + transform: translateX(-110%); + transition: transform 160ms ease-out; +} +.hw-dock.open .hw-dock-panel { transform: translateX(0); } + +.hw-dock-caption { + font-size: 9px; + letter-spacing: 0.22em; + color: var(--text-faint); + text-transform: uppercase; +} + +/* --- Chip list --- */ +.hw-dock-list { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + scrollbar-width: thin; +} + +.hw-dock-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 9px; + border: 1px solid rgba(70, 72, 88, 0.55); + border-radius: 5px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.12)); + cursor: grab; + user-select: none; + touch-action: none; +} +.hw-dock-item:hover { + border-color: var(--accent-bright); + box-shadow: 0 0 8px rgba(217, 155, 127, 0.25); +} +.hw-dock-item.lifted { cursor: grabbing; opacity: 0.45; } +.hw-dock-item.docked .hw-led { + background: var(--accent-bright); /* lit LED = waiting in the dock */ + box-shadow: 0 0 7px rgba(217, 155, 127, 0.65); +} +.hw-dock-item:not(.docked) .hw-led { opacity: 0.35; } /* dim = already on the wall */ + +.hw-dock-name { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hw-dock-hint { + font-size: 8px; + letter-spacing: 0.12em; + color: var(--text-faint); + text-align: center; +} + +/* --- Drag ghost --- */ +.hw-dock-ghost { + position: fixed; + z-index: 200; + pointer-events: none; + padding: 6px 12px; + border-radius: 5px; + border: 1px solid var(--accent-bright); + background: + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.02) 0 2px, transparent 2px 4px), + linear-gradient(180deg, #5C5F73 0%, #464858 55%, #3A3C4C 100%); + color: var(--text); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.6); +} +.hw-dock-ghost.over-wall { + border-color: var(--accent-bright); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.6), 0 0 12px rgba(217, 155, 127, 0.5); +} + +/* --- Per-module affordances --- */ + +/* Put-away button in the header (right side, next to the screw) */ +.hw-header .hw-dock-away { + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + padding: 0; + border: 1px solid rgba(5, 11, 16, 0.7); + border-radius: 4px; + background: rgba(5, 11, 16, 0.35); + color: var(--text-faint); + font-size: 10px; + line-height: 1; + cursor: pointer; + opacity: 0; /* appears on header hover — keeps headers clean */ + transition: opacity 100ms ease; +} +.hw-header:hover .hw-dock-away, +.hw-dock-away:focus-visible { opacity: 1; } +.hw-header .hw-dock-away:hover { + color: var(--accent-bright); + border-color: var(--accent-bright); +} + +/* Collapsed module: body hidden, header stays grabbable */ +.hw-module.collapsed .hw-body { display: none; } +.hw-module.collapsed .hw-screw-r { margin-right: 0; } + +/* Locate pulse after a chip click / drop */ +@keyframes hw-reveal-pulse { + 0% { box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45), 0 0 0 0 rgba(217, 155, 127, 0.75); } + 100% { box-shadow: 0 8px 20px rgba(0, 0, 0, 0.45), 0 0 0 18px rgba(217, 155, 127, 0); } +} +.hw-module.reveal { animation: hw-reveal-pulse 650ms ease-out 2; } diff --git a/src/ui/hardwareWall.ts b/src/ui/hardwareWall.ts index 3c4e1bb..54349a7 100644 --- a/src/ui/hardwareWall.ts +++ b/src/ui/hardwareWall.ts @@ -6,7 +6,14 @@ // - Zoom = a single transform: scale() on the world, with the cursor point // kept anchored under the mouse. // - Modules are absolutely-positioned hardware units, draggable by header, -// positions persisted to localStorage. +// layout persisted to localStorage. +// +// Module dock: every registered module appears in the slide-out MODULES +// sidebar. Drag a chip from the sidebar onto the wall to place its module; +// drag an on-wall chip to relocate without hunting for the unit; click a +// chip to reveal (and auto-pan to) its module. The ⏏ button in a header +// puts the module away into the dock; double-clicking a header collapses +// its body. Docked/collapsed state persists with the layout. // // Performance notes: // - Wheel zoom events are COALESCED into a single per-frame update @@ -15,10 +22,6 @@ // - The viewport's screen rect is cached and only refreshed on resize // (the wall is inset:0 inside a position:fixed #app, so it does not move // on scroll or zoom). -// -// One wall -> many modules. Each module is built by rack.ts (chassis) and its -// body is filled by the feature class (transport / sequencer / piano roll / -// playlist / mixer). import { WALL_KEY, clamp } from './theme.js'; @@ -34,6 +37,20 @@ interface ZoomEvent { y: number; } +interface SavedLayout { + positions: Record; + docked?: string[]; + collapsed?: string[]; +} + +interface ChipDrag { + id: string; + startX: number; + startY: number; + active: boolean; + ghost: HTMLElement | null; +} + export class HardwareWall { readonly viewport: HTMLElement; readonly world: HTMLElement; @@ -46,13 +63,21 @@ export class HardwareWall { private readonly MAX_ZOOM = 2.0; private modules = new Map(); + private titles = new Map(); private positions: Record = {}; private defaults: Record = {}; + private dockedIds = new Set(); + private collapsedIds = new Set(); + private chips = new Map(); private zoom = 1; private hudZoom: HTMLElement | null = null; - // Cached viewport rect (screen position). Refreshed on window resize only. + // Sidebar (module dock) + private dockEl: HTMLElement | null = null; + private dockListEl: HTMLElement | null = null; + + // Cached viewport rect (screen position). Refreshed on resize only. private viewportRect = { left: 0, top: 0, width: 0, height: 0 }; // Coalesced zoom queue: wheel events accumulate here and are flushed once @@ -72,16 +97,49 @@ export class HardwareWall { container.appendChild(this.viewport); this.refreshViewportRect(); - this.loadPositions(); + this.loadLayout(); this.bindViewport(); this.bindResize(); this.buildHud(); + this.buildDock(); this.applyZoom(); // CSS may not be settled the very first frame; the wall sits below the // top bar, so re-measure once layout is stable. requestAnimationFrame(() => this.refreshViewportRect()); } + // --- Layout persistence --------------------------------------------------- + + private loadLayout() { + try { + const raw = localStorage.getItem(WALL_KEY); + if (!raw) return; + const parsed = JSON.parse(raw) as SavedLayout | Record; + if (parsed && typeof parsed === 'object' && 'positions' in parsed) { + const saved = parsed as SavedLayout; + this.positions = saved.positions || {}; + (saved.docked || []).forEach((id) => this.dockedIds.add(id)); + (saved.collapsed || []).forEach((id) => this.collapsedIds.add(id)); + } else { + // Legacy format: the whole object was the positions map. + this.positions = parsed as Record; + } + } catch { + this.positions = {}; + } + } + + private saveLayout() { + const layout: SavedLayout = { + positions: this.positions, + docked: [...this.dockedIds], + collapsed: [...this.collapsedIds], + }; + try { + localStorage.setItem(WALL_KEY, JSON.stringify(layout)); + } catch { /* storage full — non fatal */ } + } + // --- Module creation ----------------------------------------------------- addModule(id: string, title: string, defX: number, defY: number): WallModule { @@ -99,21 +157,256 @@ export class HardwareWall { header.className = 'hw-header'; header.innerHTML = `${title}`; + // Put-away button: sends the module back into the dock. + const dockAway = document.createElement('button'); + dockAway.type = 'button'; + dockAway.className = 'hw-dock-away'; + dockAway.textContent = '⏏'; + dockAway.title = `Put ${title} away into the MODULES dock`; + dockAway.addEventListener('click', (e) => { + e.stopPropagation(); + this.dockModule(id); + }); + header.appendChild(dockAway); + const body = document.createElement('div'); body.className = 'hw-body'; el.append(header, body); + if (this.collapsedIds.has(id)) el.classList.add('collapsed'); + + // Double-click on the header collapses/expands the module body. + header.addEventListener('dblclick', (e) => { + if (e.target instanceof HTMLElement && e.target.closest('button, input, select')) return; + const collapsed = el.classList.toggle('collapsed'); + if (collapsed) this.collapsedIds.add(id); + else this.collapsedIds.delete(id); + this.saveLayout(); + }); + this.world.appendChild(el); this.bindModuleDrag(header, el, id); - this.modules.set(id, { el, header, body }); - return { el, header, body }; + const mod: WallModule = { el, header, body }; + this.modules.set(id, mod); + this.titles.set(id, title); + + // A module restored from a previous session may be docked. + if (this.dockedIds.has(id)) el.style.display = 'none'; + this.registerChip(id, title); + + return mod; } getModule(id: string): WallModule | undefined { return this.modules.get(id); } + isDocked(id: string): boolean { + return this.dockedIds.has(id); + } + + // --- Module dock (sidebar) ------------------------------------------------- + + private buildDock() { + const dock = document.createElement('aside'); + dock.className = 'hw-dock'; + + const tab = document.createElement('button'); + tab.type = 'button'; + tab.className = 'hw-dock-tab mono'; + tab.textContent = 'MODULES'; + tab.title = 'Open the module dock'; + tab.addEventListener('click', () => this.toggleSidebar()); + + const panel = document.createElement('div'); + panel.className = 'hw-dock-panel'; + + const caption = document.createElement('div'); + caption.className = 'hw-dock-caption'; + caption.textContent = 'MODULE RACK'; + + this.dockListEl = document.createElement('div'); + this.dockListEl.className = 'hw-dock-list'; + + const hint = document.createElement('div'); + hint.className = 'hw-dock-hint mono'; + hint.textContent = 'DRAG ONTO THE WALL · CLICK TO LOCATE'; + + panel.append(caption, this.dockListEl, hint); + dock.append(panel, tab); + this.viewport.appendChild(dock); + this.dockEl = dock; + } + + toggleSidebar() { + this.dockEl?.classList.toggle('open'); + } + + private registerChip(id: string, title: string) { + if (!this.dockListEl) return; + const chip = document.createElement('div'); + chip.className = 'hw-dock-item'; + if (this.dockedIds.has(id)) chip.classList.add('docked'); + + const led = document.createElement('span'); + led.className = 'hw-led hw-led-channel'; + + const name = document.createElement('span'); + name.className = 'hw-dock-name mono'; + name.textContent = title; + + chip.append(led, name); + chip.title = `${title} — drag onto the wall or click to locate`; + this.chips.set(id, chip); + this.dockListEl.appendChild(chip); + + this.bindChipDrag(chip, id); + } + + private syncChip(id: string) { + const chip = this.chips.get(id); + if (!chip) return; + chip.classList.toggle('docked', this.dockedIds.has(id)); + } + + // Put a module away: hidden from the wall, still listed in the dock. + dockModule(id: string) { + const mod = this.modules.get(id); + if (!mod || this.dockedIds.has(id)) return; + this.dockedIds.add(id); + mod.el.style.display = 'none'; + mod.el.classList.remove('dragging'); + this.syncChip(id); + this.saveLayout(); + } + + // Bring a module back. Without coordinates it returns to its default spot. + restoreModule(id: string, worldX?: number, worldY?: number) { + const mod = this.modules.get(id); + if (!mod || !this.dockedIds.has(id)) return; + this.dockedIds.delete(id); + mod.el.style.display = ''; + if (worldX !== undefined && worldY !== undefined) this.setModulePos(id, worldX, worldY); + else { + const def = this.defaults[id]; + if (def) this.setModulePos(id, def.x, def.y); + } + this.syncChip(id); + this.saveLayout(); + } + + private setModulePos(id: string, worldX: number, worldY: number) { + const mod = this.modules.get(id); + if (!mod) return; + const x = Math.round(clamp(worldX, 0, this.WALL_W - 120)); + const y = Math.round(clamp(worldY, 0, this.WALL_H - 80)); + mod.el.style.left = `${x}px`; + mod.el.style.top = `${y}px`; + const p = this.positions[id]; + if (p) { + p.x = x; + p.y = y; + } + } + + // Pan the viewport so the module is centered, then pulse its outline. + revealModule(id: string) { + const mod = this.modules.get(id); + if (!mod || this.dockedIds.has(id)) return; + const x = parseInt(mod.el.style.left, 10); + const y = parseInt(mod.el.style.top, 10); + this.viewport.scrollTo({ + left: Math.max(0, x * this.zoom - this.viewport.clientWidth / 2), + top: Math.max(0, y * this.zoom - this.viewport.clientHeight / 2), + behavior: 'smooth', + }); + mod.el.classList.remove('reveal'); + void mod.el.offsetWidth; // restart the animation + mod.el.classList.add('reveal'); + setTimeout(() => mod.el.classList.remove('reveal'), 1300); + } + + // Convert client coordinates to world coordinates (zoom + scroll aware). + private clientToWorld(clientX: number, clientY: number): { x: number; y: number } { + return { + x: (clientX - this.viewportRect.left + this.viewport.scrollLeft) / this.zoom, + y: (clientY - this.viewportRect.top + this.viewport.scrollTop) / this.zoom, + }; + } + + private isOverViewport(clientX: number, clientY: number): boolean { + const r = this.viewportRect; + return clientX >= r.left && clientX <= r.left + r.width + && clientY >= r.top && clientY <= r.top + r.height; + } + + // Drag semantics for dock chips: + // press + move -> ghost follows the cursor; release over the wall drops + // the module there (restoring it if it was docked) + // plain click -> locate: pan to the module (pulls it out of the dock + // at its default position when necessary) + private bindChipDrag(chip: HTMLElement, id: string) { + const drag: ChipDrag = { id, startX: 0, startY: 0, active: false, ghost: null }; + + chip.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + e.preventDefault(); + chip.setPointerCapture(e.pointerId); + drag.startX = e.clientX; + drag.startY = e.clientY; + drag.active = false; + }); + + chip.addEventListener('pointermove', (e) => { + if (!chip.hasPointerCapture(e.pointerId)) return; + if (!drag.active) { + if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 6) return; + drag.active = true; + this.refreshViewportRect(); + const ghost = document.createElement('div'); + ghost.className = 'hw-dock-ghost mono'; + ghost.textContent = this.titles.get(id) ?? id; + document.body.appendChild(ghost); + drag.ghost = ghost; + chip.classList.add('lifted'); + } + if (drag.ghost) { + drag.ghost.style.left = `${e.clientX + 10}px`; + drag.ghost.style.top = `${e.clientY + 8}px`; + const over = this.isOverViewport(e.clientX, e.clientY); + drag.ghost.classList.toggle('over-wall', over); + } + }); + + const finish = (e: PointerEvent, commit: boolean) => { + const wasActive = drag.active; + drag.active = false; + chip.classList.remove('lifted'); + drag.ghost?.remove(); + drag.ghost = null; + if (!wasActive) return; + + if (commit && this.isOverViewport(e.clientX, e.clientY)) { + const world = this.clientToWorld(e.clientX, e.clientY); + if (this.dockedIds.has(id)) this.restoreModule(id, world.x - 40, world.y - 14); + else this.setModulePos(id, world.x - 40, world.y - 14); + this.revealModule(id); + } + }; + + chip.addEventListener('pointerup', (e) => { + const wasActive = drag.active; + finish(e, true); + // Plain click (no drag) locates the module instead. + if (!wasActive) { + if (this.dockedIds.has(id)) this.restoreModule(id); + this.revealModule(id); + } + }); + chip.addEventListener('pointercancel', (e) => finish(e, false)); + } + // --- Viewport: native scroll + ctrl+wheel zoom --------------------------- private bindViewport() { @@ -201,15 +494,15 @@ export class HardwareWall { resetLayout() { this.positions = { ...this.defaults }; - this.savePositions(); for (const [id, mod] of this.modules) { + if (this.dockedIds.has(id)) continue; // docked units stay put away const pos = this.defaults[id]; if (pos) { mod.el.style.left = `${pos.x}px`; mod.el.style.top = `${pos.y}px`; } } - localStorage.removeItem(WALL_KEY); + this.saveLayout(); this.recenter(); } @@ -251,7 +544,7 @@ export class HardwareWall { const p = this.positions[id]; p.x = parseInt(el.style.left, 10); p.y = parseInt(el.style.top, 10); - this.savePositions(); + this.saveLayout(); }; header.addEventListener('pointerup', end); header.addEventListener('pointercancel', end); @@ -289,7 +582,13 @@ export class HardwareWall { reset.title = 'Reset wall layout to defaults'; reset.addEventListener('click', () => this.resetLayout()); - hud.append(out, label, zin, home, reset); + const dockBtn = document.createElement('button'); + dockBtn.className = 'hw-hud-btn'; + dockBtn.textContent = '▤'; + dockBtn.title = 'Toggle the MODULES dock'; + dockBtn.addEventListener('click', () => this.toggleSidebar()); + + hud.append(out, label, zin, home, dockBtn, reset); this.viewport.appendChild(hud); this.syncHud(); } @@ -297,21 +596,4 @@ export class HardwareWall { private syncHud() { if (this.hudZoom) this.hudZoom.textContent = `${Math.round(this.zoom * 100)}%`; } - - // --- Persistence ---------------------------------------------------------- - - private loadPositions() { - try { - const raw = localStorage.getItem(WALL_KEY); - if (raw) this.positions = JSON.parse(raw) || {}; - } catch { - this.positions = {}; - } - } - - private savePositions() { - try { - localStorage.setItem(WALL_KEY, JSON.stringify(this.positions)); - } catch { /* storage full — non fatal */ } - } } From 4448ea47319e69f94d69fea8a6755b533d27aa5d Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 14:57:37 +0600 Subject: [PATCH 5/6] docs: module dock, sample reverse in README --- README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c1b571e..2dfa543 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ zoom with `Ctrl`+wheel, and arrange a full track: 16-step sequencing, piano-roll notes, per-track reverb/delay/EQ, a synth editor (waveform + ADSR), sample trimming and pitch, loop regions, swing/groove, a master bus with limiter, WAV + stem export, computer-keyboard / Web MIDI live input, +a MODULES dock for arranging the studio wall, sample trim/reverse/pitch, and gesture-based undo/redo. The old UI is archived in git tag `legacy-vscode-ui`. @@ -36,6 +37,13 @@ The main interface is a canvas-style "studio room": a large world their brushed-metal headers. Pan with native scroll, zoom with `Ctrl`+wheel (or the HUD `— / % / + / ⌂` buttons). +Every module is also listed in the **MODULES dock** — a slide-out sidebar on +the left edge (`MODULES` tab or the HUD `▤` button). Drag a chip onto the +wall to place its module, drag an on-wall chip to relocate it, click a chip +to auto-pan to it, and use the `⏏` button in a header to put a unit away. +Double-clicking a header collapses/expands the body; docked/collapsed state +persists with the wall layout (`voidstation-wall-v1`). + Modules (one feature = one file in `src/ui/`): | Module | Description | @@ -60,7 +68,8 @@ Module positions persist across reloads (`voidstation-wall-v1`). delay (feedback loop), 3-band EQ (lowshelf 250 Hz / peaking 1 kHz / highshelf 4 kHz) — per-track, live and in export. - **Samples**: load a sample per track, canvas waveform with draggable - start/end trim handles, preview by clicking the wave. + start/end trim handles, preview by clicking the wave, tape-style pitch + (-24..+12 semitones) and a REVERSE toggle. - **Velocity accents**: each sequencer step carries a velocity (1.0 / 0.7 / 0.45). Right-click (or `Shift`+click) an active pad to cycle its accent; dimmer pads play softer. Applied to both sample triggers and synth notes, @@ -123,7 +132,7 @@ src/ui/keys.ts — live controller: computer keyboard + Web MIDI src/ui/app.ts — assembly: engine + wall + modules + wiring tests/ — node:test suites (headless engine + WAV encoder) run via `npm test` against the compiled dist/ -src/styles/ — theme/base/racks/topbar/transport/drawer/sequencer/pianoroll/playlist/mixer/fx/sampler/instrument/master +src/styles/ — theme/base/racks/topbar/transport/drawer/sequencer/pianoroll/playlist/mixer/fx/sampler/instrument/master/keys/dock ``` `dist/` (compiled JS) and `node_modules/` are git-ignored; the engine From 4d3e5e2987a0ecfca253be9a91404c42579a4c6c Mon Sep 17 00:00:00 2001 From: Dread1ess Date: Wed, 26 Aug 2026 15:17:32 +0600 Subject: [PATCH 6/6] test: cover master bus, loop clamps, row tools, legacy v1 saves, fx round-trip - master volume/threshold clamping + persistence, metronome preference - setLoopRegion one-bar/inverted-input rules; clip assignment extends the region beyond its end - shiftTrackPattern keeps accents glued to their steps; randomizeTrackAccents only re-rolls active steps - _stepSourceForBar playlist/live/null-slot semantics - v1 projects (pattern/pianoGrid on the track entries) still load - insert-effect reorder/params survive serialize/deserialize - clearProject factory reset; _effectiveVolume mute/solo matrix --- tests/engine.test.mjs | 162 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index aa73223..94710b6 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -278,3 +278,165 @@ test('_sampleBounds swaps the trim window under reverse', () => { // Same trim measured from the tail of the audio. assert.deepEqual(e._sampleBounds(e.tracks[0]), { start: 7, end: 9 }); }); + +test('master bus setters clamp and persist', async () => { + const e = fresh(); + e.setMasterVolume(2); // clamped high + assert.equal(e.masterVolume, 1); + e.setMasterVolume(-0.5); // clamped low + assert.equal(e.masterVolume, 0); + e.setLimiterThreshold(-99); + assert.equal(e.limiterThreshold, -40); + + e.setMasterVolume(0.9); + e.setLimiterEnabled(true); + e.setLimiterThreshold(-3); + const state = JSON.parse(JSON.stringify(e.serialize())); + const e2 = fresh(); + await e2.deserialize(state); + assert.equal(e2.masterVolume, 0.9); + assert.equal(e2.limiterEnabled, true); + assert.equal(e2.limiterThreshold, -3); +}); + +test('metronome toggle persists as a session preference', async () => { + const e = fresh(); + assert.equal(e.metronome, false); + e.toggleMetronome(); + assert.equal(e.metronome, true); + const state = JSON.parse(JSON.stringify(e.serialize())); + const e2 = fresh(); + await e2.deserialize(state); + assert.equal(e2.metronome, true); +}); + +test('setLoopRegion keeps at least one bar and orders start < end', () => { + const e = fresh(); + e.setLoopRegion(5, 5); // empty range -> collapses to a single bar + assert.deepEqual([e.loopStart, e.loopEnd], [4, 5]); + e.setLoopRegion(8, 3); // inverted -> start clamps below end + assert.deepEqual([e.loopStart, e.loopEnd], [2, 3]); +}); + +test('assigning a clip beyond the loop end extends the region', () => { + const e = fresh(); + for (let b = 0; b < 4; b++) e.playlist[b] = 0; + e.loopEnabled = true; + const before = e.loopEnd; + e.setPlaylistCell(9, 0); + assert.equal(e.loopEnd, Math.max(before, 10)); +}); + +test('shiftTrackPattern rotates steps and their accents together', () => { + const e = fresh(); + e.toggleStep(0, 15); + e.setStepVelocity(0, 15, 0.45); + e.shiftTrackPattern(0, 1); // last step wraps to the front + assert.equal(e.tracks[0].pattern[0], true); + assert.equal(e.tracks[0].pattern[15], false); + assert.equal(e.tracks[0].velocity[0], 0.45); + e.shiftTrackPattern(0, -1); // and back + assert.equal(e.tracks[0].pattern[15], true); + assert.equal(e.tracks[0].velocity[15], 0.45); +}); + +test('randomizeTrackAccents only touches active steps', () => { + const e = fresh(); + const levels = [1, 0.7, 0.45]; + e.toggleStep(0, 2); + e.toggleStep(0, 7); + e.randomizeTrackAccents(0); + for (let s = 0; s < 16; s++) { + if (s === 2 || s === 7) { + assert.ok(levels.includes(e.tracks[0].velocity[s]), `step ${s} got a valid accent`); + } else { + assert.equal(e.tracks[0].velocity[s], 1, `inactive step ${s} stays default`); + } + } +}); + +test('_stepSourceForBar follows the playlist or falls back to the live pattern', () => { + const e = fresh(); + // Empty playlist: the live track data IS the source. + assert.equal(e._stepSourceForBar(1, 3), e.tracks[1]); + + e.addPattern(); // P2 current; make it distinctive + e.toggleStep(0, 6); + e.switchPattern(0); + e.setPlaylistCell(2, 1); // bar 2 plays P2 + assert.equal(e._stepSourceForBar(0, 2).pattern[6], true); + assert.equal(e._stepSourceForBar(0, 0), null); // unarranged slot -> silence +}); + +test('v1 legacy projects wrap per-track pattern/pianoGrid into one pattern', async () => { + const e = fresh(); + await e.deserialize({ + version: 2, + bpm: 96, + tracks: [ + { name: 'Kick', pattern: [true, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false] }, + { name: 'Snare', pianoGrid: [[], [], [], [], [], [], [], [], [], [], [], [1, 2]] }, + ], + currentPatternIndex: 0, + playlist: [], + }); + assert.equal(e.patterns.length, 1); + assert.equal(e.tracks[0].pattern[0], true); + assert.equal(e.tracks[1].pianoGrid[11][0], 1); + assert.equal(e.bpm, 96); +}); + +test('insert effect chains survive a serialize/deserialize round-trip', async () => { + const e = fresh(); + e.addEffect(0, 'reverb'); + e.addEffect(0, 'delay'); + e.setEffectParam(0, 0, 'mix', 0.55); + e.setEffectParam(0, 1, 'feedback', 0.6); + e.moveEffect(0, 1, 0); // delay first now + + const state = JSON.parse(JSON.stringify(e.serialize())); + const e2 = fresh(); + await e2.deserialize(state); + const fx = e2.tracks[0].effects; + assert.equal(fx.length, 2); + assert.equal(fx[0].type, 'delay'); + assert.equal(fx[0].feedback, 0.6); + assert.equal(fx[1].type, 'reverb'); + assert.equal(fx[1].mix, 0.55); +}); + +test('clearProject resets transport state to factory defaults', async () => { + const e = fresh(); + e.setBpm(180); + e.setSwing(0.8); + e.setMasterVolume(0.3); + e.setLimiterEnabled(true); + e.toggleStep(0, 0); + e.addPattern(); + e.setPlaylistCell(0, 1); + + e.clearProject(); + assert.equal(e.bpm, 124); + assert.equal(e.swing, 0); + assert.equal(e.masterVolume, 0.8); + assert.equal(e.limiterEnabled, false); + assert.equal(e.patterns.length, 1); + assert.equal(e.playlist.length, 0); + assert.equal(e.tracks[0].pattern[0], false); +}); + +test('mute forces silence even under solo, solo isolates other tracks', () => { + const e = fresh(); + // Solo track 0: everyone else is silent. + e.toggleSolo(0); + assert.equal(e._effectiveVolume(0), e.tracks[0].volume); + assert.equal(e._effectiveVolume(1), 0); + // Muting the soloed track still silences it. + e.toggleMute(0); + assert.equal(e._effectiveVolume(0), 0); + // No solo: mute alone silences. + e.toggleSolo(0); + assert.equal(e._effectiveVolume(1), e.tracks[1].volume); + e.toggleMute(1); + assert.equal(e._effectiveVolume(1), 0); +});