Time selections, loop playback, and further DAW-standard editing
Dragging in the ruler makes a time selection (snapped, shown as a band
across the roll). The transport then offers the arrangement edits:
select the notes inside it, clear them (also the delete key with no
note selection), cut the time span on every track with the tempo and
time-signature maps rippling along, insert empty time, and duplicate
the span after itself; notes crossing a boundary are trimmed and a
spanning note is split. The Loop button (ctrl+L) plays the selection
on repeat from cached renders. Note editing gains alt+drag copying,
resizing from the left edge, shift-while-dragging to bypass snap,
double-click deletion, ctrl+E split at the playhead, and alt+wheel
vertical zoom; Home/End move the playhead, and tracks can be
reordered and duplicated. Range operations are pure functions in
midi/ops.ts with node tests.
10 changed files+465−58
README.mdmodified+3−1View file
@@ -30,7 +30,9 @@ Recording takes input from any connected MIDI device through the Web MIDI API, f
3030
3131 ## Editing
3232
33-The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. The gestures follow the usual DAW conventions: clicking empty space moves the playhead there and clears the selection, dragging on empty space is a marquee selection (shift extends it), double-clicking adds a note at the snapped position, dragging notes moves them, dragging a note's right edge resizes, right-click deletes, and dragging in the velocity lane paints velocities. The selection responds to the expected keys: delete removes it, ctrl+A selects the active track, ctrl+C/X/V copy, cut, and paste at the playhead, ctrl+D duplicates in place after itself, and the arrow keys nudge by the snap grid horizontally and by semitones (shift for octaves) vertically. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
33+The piano roll is a single canvas: ruler, key column, note grid, and a velocity lane. The gestures follow the usual DAW conventions: clicking empty space moves the playhead there and clears the selection, dragging on empty space is a marquee selection (shift extends it), double-clicking adds a note at the snapped position (and deletes one when aimed at a note), dragging notes moves them (alt+drag moves a copy), dragging either edge of a note resizes it, right-click deletes, holding shift while dragging bypasses the snap grid, and dragging in the velocity lane paints velocities. Ctrl+wheel zooms horizontally and alt+wheel vertically. The selection responds to the expected keys: delete removes it, ctrl+A selects the active track, ctrl+C/X/V copy, cut, and paste at the playhead, ctrl+D duplicates in place after itself, ctrl+E splits the selected notes (or the active track's notes) at the playhead, and the arrow keys nudge by the snap grid horizontally and by semitones (shift for octaves) vertically. Home and End move the playhead to the start and end of the song, and tracks can be reordered and duplicated from the track list.
34+
35+Dragging in the ruler makes a *time selection*, shown as a band across the roll, and this is where the arrangement edits live. The transport then offers: Select (the notes inside it), Clear (delete those notes, keeping the time; also the delete key when no notes are selected), Cut time (remove the span on every track and close the gap, with the tempo and time-signature maps rippling along), Insert (open that much empty time at the selection start), and Dup (insert a copy of the span immediately after itself and move the selection onto the copy). Notes crossing a boundary are trimmed, and a note spanning the whole range is split, so the edits behave the way region editing does in a conventional DAW. The Loop button (ctrl+L) plays the time selection on repeat; the loop restarts from cached renders, so the seam is small. Escape clears the time selection, and clicking the ruler moves the playhead as before. Snap is selectable from a bar down to 1/32, holding alt bypasses it, and ctrl+wheel zooms. The active track is edited in full color while the other tracks show as ghosts. Undo and redo (ctrl+z, ctrl+shift+z) cover every edit, with a whole drag gesture counting as one step. Tracks carry name, instrument, volume, mute, solo, and a record arm.
3436
3537 ## Playback and rendering
3638
scripts/test-node.tsmodified+57−1View file
@@ -5,7 +5,7 @@ import assert from 'node:assert/strict';
55 import { TempoMap } from '../src/midi/time.ts';
66 import { parseMidi } from '../src/midi/parse.ts';
77 import { writeMidi } from '../src/midi/write.ts';
8-import { quantizeNotes, transposeNotes } from '../src/midi/ops.ts';
8+import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes, transposeNotes } from '../src/midi/ops.ts';
99 import { drumNote, epianoNote, leadNote, pluckNote } from '../src/instruments/dsp.ts';
1010 import { builtinInstruments } from '../src/instruments/builtins.ts';
1111 import { audibleTracks, encodeWavMono16, mixTracks, trackRenderKey } from '../src/audio/render.ts';
@@ -165,6 +165,62 @@ test('transpose clamps to the MIDI range', () => {
165165 assert.equal(transposeNotes(notes, -300)[0].pitch, 0);
166166 });
167167
168+test('clearRange trims, splits, and drops notes against the range', () => {
169+ const notes = [
170+ mkNote(0, 100, 60, 100, 'before'), // ends before the range
171+ mkNote(150, 100, 61, 100, 'inside'), // fully inside: dropped
172+ mkNote(50, 100, 62, 100, 'crossStart'), // trimmed to end at 100
173+ mkNote(250, 100, 63, 100, 'crossEnd'), // start moves to 300
174+ mkNote(50, 400, 64, 100, 'spans'), // split in two
175+ ];
176+ const out = clearRange(notes, 100, 300);
177+ const by = (id: string) => out.find((n) => n.id === id);
178+ assert.equal(out.length, 5); // before, crossStart, crossEnd, spans-left, spans-right
179+ assert.deepEqual([by('before')!.tick, by('before')!.duration], [0, 100]);
180+ assert.equal(by('inside'), undefined);
181+ assert.deepEqual([by('crossStart')!.tick, by('crossStart')!.duration], [50, 50]);
182+ assert.deepEqual([by('crossEnd')!.tick, by('crossEnd')!.duration], [300, 50]);
183+ assert.deepEqual([by('spans')!.tick, by('spans')!.duration], [50, 50]);
184+ const right = out.find((n) => n.pitch === 64 && n.id !== 'spans')!;
185+ assert.deepEqual([right.tick, right.duration], [300, 150]);
186+});
187+
188+test('deleteTimeRange closes the gap', () => {
189+ const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(150, 50, 61, 100, 'b'), mkNote(400, 50, 62, 100, 'c')];
190+ const out = deleteTimeRange(notes, 100, 300);
191+ assert.equal(out.length, 2); // 'b' was inside
192+ assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
193+ assert.equal(out.find((n) => n.id === 'c')!.tick, 200);
194+});
195+
196+test('insertTime shifts notes at and after the point', () => {
197+ const notes = [mkNote(0, 50, 60, 100, 'a'), mkNote(100, 50, 61, 100, 'b')];
198+ const out = insertTime(notes, 100, 480);
199+ assert.equal(out.find((n) => n.id === 'a')!.tick, 0);
200+ assert.equal(out.find((n) => n.id === 'b')!.tick, 580);
201+});
202+
203+test('extractRange copies the in-range parts with fresh ids', () => {
204+ const notes = [mkNote(50, 400, 64, 100, 'spans')];
205+ const out = extractRange(notes, 100, 300);
206+ assert.equal(out.length, 1);
207+ assert.deepEqual([out[0].tick, out[0].duration, out[0].pitch], [100, 200, 64]);
208+ assert.notEqual(out[0].id, 'spans');
209+});
210+
211+test('event lists ripple with time edits and keep tick 0', () => {
212+ const tempo = [
213+ { tick: 0, usPerQuarter: 500000 },
214+ { tick: 200, usPerQuarter: 400000 },
215+ { tick: 600, usPerQuarter: 300000 },
216+ ];
217+ const cut = deleteTimeFromEvents(tempo, 100, 500);
218+ assert.deepEqual(cut.map((e) => e.tick), [0, 200]);
219+ assert.equal(cut[1].usPerQuarter, 300000); // the 600-tick event moved to 200
220+ const ins = insertTimeIntoEvents(tempo, 200, 100);
221+ assert.deepEqual(ins.map((e) => e.tick), [0, 300, 700]);
222+});
223+
168224 // --- DSP ---
169225
170226 test('DSP is deterministic and finite', () => {
src/audio/player.tsmodified+29−4View file
@@ -12,6 +12,12 @@ import { audibleTracks, encodeWavMono16, mixTracks, renderTrackBuffer, trackRend
1212
1313 export type TransportState = 'stopped' | 'playing' | 'recording';
1414
15+export interface PlayOpts {
16+ record?: boolean;
17+ countInBars?: number;
18+ loop?: { startSec: number; endSec: number };
19+}
20+
1521 const EXPORT_SAMPLE_RATE = 44100;
1622
1723 function scheduleClick(ctx: BaseAudioContext, dest: AudioNode, when: number, accent: boolean) {
@@ -41,6 +47,8 @@ class Player {
4147 version = 0;
4248 state: TransportState = 'stopped';
4349 posSec = 0;
50+ private loop: { startSec: number; endSec: number } | null = null;
51+ private lastPlay: { song: Song; opts: PlayOpts } | null = null;
4452 metronome = false;
4553 preparing = false;
4654
@@ -82,8 +90,10 @@ class Player {
8290 }
8391 }
8492
85- async play(song: Song, opts: { record?: boolean; countInBars?: number } = {}): Promise<void> {
93+ async play(song: Song, opts: PlayOpts = {}): Promise<void> {
8694 if (this.state !== 'stopped') this.stop();
95+ this.loop = !opts.record && opts.loop ? opts.loop : null;
96+ this.lastPlay = { song, opts };
8797 const ctx = this.ensureCtx();
8898 await ctx.resume();
8999 this.preparing = true;
@@ -164,9 +174,24 @@ class Player {
164174 this.lastEmit = now;
165175 this.emit();
166176 }
167- if (this.state === 'playing' && this.getPositionSec() > this.endSec + 0.3) {
168- this.stop();
169- return;
177+ if (this.state === 'playing') {
178+ const pos = this.getPositionSec();
179+ if (this.loop && pos >= this.loop.endSec) {
180+ // Loop: restart from the loop start; renders are cached, so this
181+ // is quick. The tiny seam is acceptable for a preview transport.
182+ const lp = this.lastPlay;
183+ const start = this.loop.startSec;
184+ this.stop();
185+ if (lp) {
186+ this.posSec = start;
187+ void this.play(lp.song, lp.opts);
188+ }
189+ return;
190+ }
191+ if (!this.loop && pos > this.endSec + 0.3) {
192+ this.stop();
193+ return;
194+ }
170195 }
171196 this.raf = requestAnimationFrame(tick);
172197 };
src/components/App.tsxmodified+16−2View file
@@ -77,7 +77,20 @@ export function App() {
7777 store.redo();
7878 } else if (ev.key === 'Delete' || ev.key === 'Backspace') {
7979 ev.preventDefault();
80- store.deleteSelection();
80+ if (store.state.selection.length > 0) store.deleteSelection();
81+ else if (store.state.rangeTicks) store.rangeClearNotes();
82+ } else if (ev.key === 'Home') {
83+ ev.preventDefault();
84+ store.seekTo(0);
85+ } else if (ev.key === 'End') {
86+ ev.preventDefault();
87+ store.seekToEnd();
88+ } else if (mod && ev.key.toLowerCase() === 'e') {
89+ ev.preventDefault();
90+ store.splitAtPlayhead();
91+ } else if (mod && ev.key.toLowerCase() === 'l') {
92+ ev.preventDefault();
93+ store.toggleLoop();
8194 } else if (mod && ev.key.toLowerCase() === 'a') {
8295 ev.preventDefault();
8396 store.selectAll();
@@ -105,6 +118,7 @@ export function App() {
105118 } else if (ev.key === 'Escape') {
106119 store.setLibraryOpen(false);
107120 store.setSelection([]);
121+ store.setRange(null);
108122 }
109123 };
110124 window.addEventListener('keydown', onKey);
@@ -163,7 +177,7 @@ export function App() {
163177 <TrackList />
164178 <PianoRoll />
165179 </div>
166- <footer className="statusbar">{app.status || 'click sets the position; drag selects; double-click adds a note; right-click deletes; ctrl+C/X/V/D and arrows act on the selection; space plays'}</footer>
180+ <footer className="statusbar">{app.status || 'click sets the position; drag selects; drag the ruler for a time selection; double-click adds a note; alt+drag copies; ctrl+C/X/V/D/E and arrows act on the selection; space plays'}</footer>
167181 <Library />
168182 </div>
169183 );
src/components/PianoRoll.tsxmodified+120−47View file
@@ -5,7 +5,7 @@ import { TempoMap } from '../midi/time.ts';
55 import { newId, songEndTick, type Note, type Song } from '../types.ts';
66 import { noteName } from '../midi/gm.ts';
77 import { trackColor } from './colors.ts';
8-import { isBlackKey, KEY_H, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts';
8+import { isBlackKey, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts';
99
1010 // The piano roll: a single canvas holding the ruler, key column, note
1111 // grid, and velocity lane. It redraws every animation frame from current
@@ -13,33 +13,36 @@ import { isBlackKey, KEY_H, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VE
1313 // re-renders. Gestures follow the usual DAW conventions:
1414 // click empty space set the playhead there (and deselect)
1515 // drag on empty space marquee select (shift extends the selection)
16-// double-click empty space add a note at the snapped position
17-// drag a note move it (and the rest of the selection)
18-// drag a note's right edge resize
16+// double-click empty space add a note; double-click a note deletes it
17+// drag a note move it (alt+drag moves a copy)
18+// drag a note's edges resize from either end
1919 // right-click a note delete it
20-// drag in the ruler move the playhead
20+// click in the ruler move the playhead
21+// drag in the ruler make a time selection (loop, range edits)
2122 // drag in the velocity lane paint velocities
22-// wheel / shift+wheel scroll; ctrl+wheel zooms
23-// alt while dragging bypass snap
24-// Ctrl+A/C/X/V/D and the arrow keys act on the selection (see App.tsx).
23+// wheel / shift+wheel scroll; ctrl+wheel and alt+wheel zoom
24+// shift while dragging bypass snap (free positioning)
25+// Ctrl+A/C/X/V/D/E and the arrow keys act on the selection (see App.tsx).
2526
2627 interface OrigNote { tick: number; duration: number; pitch: number }
2728
2829 type Drag =
29- | { mode: 'scrub' }
30+ | { mode: 'ruler-pending'; x0: number }
31+ | { mode: 'range'; anchorTick: number }
3032 | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null }
3133 | { mode: 'velocity' }
3234 | { mode: 'pending'; x0: number; y0: number }
3335 | { mode: 'marquee'; additive: boolean; x0: number; y0: number; x1: number; y1: number }
3436 | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map<string, OrigNote>; lastBlipPitch: number }
35- | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
37+ | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> }
38+ | { mode: 'resize-left'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
3639
3740 const MIN_DUR_TICKS = 24;
3841
3942 export function PianoRoll() {
4043 const canvasRef = useRef<HTMLCanvasElement | null>(null);
4144 const containerRef = useRef<HTMLDivElement | null>(null);
42- const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * KEY_H });
45+ const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * 12, keyH: 12 });
4346 const drag = useRef<Drag | null>(null);
4447 const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null);
4548
@@ -74,8 +77,8 @@ export function PianoRoll() {
7477 const pxPerTick = (song: Song) => view.current.pxPerBeat / song.ppq;
7578 const tickToX = (song: Song, tick: number) => KEYS_W + tick * pxPerTick(song) - view.current.scrollX;
7679 const xToTick = (song: Song, x: number) => (x - KEYS_W + view.current.scrollX) / pxPerTick(song);
77- const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * KEY_H - view.current.scrollY;
78- const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / KEY_H);
80+ const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * view.current.keyH - view.current.scrollY;
81+ const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / view.current.keyH);
7982
8083 let lastDrawnPos = -1;
8184 const draw = () => {
@@ -110,9 +113,9 @@ export function PianoRoll() {
110113 // pitch rows
111114 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
112115 const y = pitchToY(p);
113- if (y + KEY_H < RULER_H || y > rollBottom) continue;
116+ if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
114117 ctx.fillStyle = isBlackKey(p) ? '#1a1d23' : '#20242c';
115- ctx.fillRect(KEYS_W, y, width - KEYS_W, KEY_H - 0.5);
118+ ctx.fillRect(KEYS_W, y, width - KEYS_W, view.current.keyH - 0.5);
116119 }
117120
118121 // vertical grid
@@ -139,6 +142,15 @@ export function PianoRoll() {
139142 ctx.stroke();
140143 }
141144
145+ // time selection band
146+ const range = st.rangeTicks;
147+ if (range) {
148+ const rx0 = tickToX(song, range[0]);
149+ const rx1 = tickToX(song, range[1]);
150+ ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.07)' : 'rgba(255, 210, 74, 0.07)';
151+ ctx.fillRect(rx0, RULER_H, rx1 - rx0, rollBottom - RULER_H);
152+ }
153+
142154 // notes: ghost tracks first, active track on top
143155 const drawTrackNotes = (ti: number, ghost: boolean) => {
144156 const t = song.tracks[ti];
@@ -148,15 +160,15 @@ export function PianoRoll() {
148160 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
149161 if (x + w < KEYS_W || x > width) continue;
150162 const y = pitchToY(n.pitch);
151- if (y + KEY_H < RULER_H || y > rollBottom) continue;
163+ if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
152164 ctx.globalAlpha = ghost ? 0.22 : 0.45 + 0.55 * (n.velocity / 127);
153165 ctx.fillStyle = color;
154- ctx.fillRect(x, y + 1, w, KEY_H - 2);
166+ ctx.fillRect(x, y + 1, w, view.current.keyH - 2);
155167 if (!ghost && selection.has(n.id)) {
156168 ctx.globalAlpha = 1;
157169 ctx.strokeStyle = '#fff';
158170 ctx.lineWidth = 1.5;
159- ctx.strokeRect(x + 0.5, y + 1.5, w - 1, KEY_H - 3);
171+ ctx.strokeRect(x + 0.5, y + 1.5, w - 1, view.current.keyH - 3);
160172 ctx.lineWidth = 1;
161173 }
162174 }
@@ -203,6 +215,10 @@ export function PianoRoll() {
203215 // ruler
204216 ctx.fillStyle = '#22262e';
205217 ctx.fillRect(0, 0, width, RULER_H);
218+ if (range) {
219+ ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.35)' : 'rgba(255, 210, 74, 0.3)';
220+ ctx.fillRect(tickToX(song, range[0]), 0, (range[1] - range[0]) * pxPerTick(song), RULER_H - 1);
221+ }
206222 for (const line of lines) {
207223 if (!line.isBar) continue;
208224 const x = tickToX(song, line.tick);
@@ -219,14 +235,14 @@ export function PianoRoll() {
219235 // key column
220236 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
221237 const y = pitchToY(p);
222- if (y + KEY_H < RULER_H || y > rollBottom) continue;
238+ if (y + view.current.keyH < RULER_H || y > rollBottom) continue;
223239 const black = isBlackKey(p);
224240 ctx.fillStyle = black ? '#2a2d34' : '#e8e8ea';
225- ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, KEY_H - 0.5);
241+ ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, view.current.keyH - 0.5);
226242 if (p % 12 === 0) {
227243 ctx.fillStyle = '#333';
228244 ctx.font = '9px system-ui';
229- ctx.fillText(noteName(p), 4, y + KEY_H - 3);
245+ ctx.fillText(noteName(p), 4, y + view.current.keyH - 3);
230246 }
231247 }
232248 ctx.fillStyle = '#16181d';
@@ -255,7 +271,7 @@ export function PianoRoll() {
255271 const nx = tickToX(song, n.tick);
256272 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
257273 const ny = pitchToY(n.pitch);
258- if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + KEY_H) hit = n;
274+ if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + view.current.keyH) hit = n;
259275 }
260276 return hit;
261277 };
@@ -274,6 +290,7 @@ export function PianoRoll() {
274290 };
275291
276292 const effSnap = (ev: PointerEvent | MouseEvent): number => (ev.altKey ? 0 : store.snapTicks());
293+ const effSnapDrag = (ev: PointerEvent): number => (ev.shiftKey ? 0 : store.snapTicks());
277294
278295 const onPointerDown = (ev: PointerEvent) => {
279296 const song = store.song();
@@ -285,14 +302,7 @@ export function PianoRoll() {
285302 canvas.setPointerCapture(ev.pointerId);
286303
287304 if (y < RULER_H) {
288- const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
289- if (player.state !== 'stopped') {
290- store.seekTo(sec);
291- drag.current = null;
292- } else {
293- player.seek(sec);
294- drag.current = { mode: 'scrub' };
295- }
305+ drag.current = { mode: 'ruler-pending', x0: x };
296306 return;
297307 }
298308 if (x < KEYS_W && y < rollBottom) {
@@ -326,17 +336,38 @@ export function PianoRoll() {
326336 sel = ev.shiftKey ? [...sel, hit.id] : [hit.id];
327337 store.setSelection(sel);
328338 }
329- const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
330- const origs = new Map<string, OrigNote>();
331- for (const n of track.notes) if (sel.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch });
332339 const nx = tickToX(song, hit.tick);
333340 const w = Math.max(3, hit.duration * pxPerTick(song) - 1);
341+ const nearRight = x > nx + w - 6 && w > 10;
342+ const nearLeft = x < nx + 5 && w > 10 && !nearRight;
334343 store.beginEdit();
335- if (x > nx + w - 6 && w > 10) {
336- drag.current = { mode: 'resize', startX: x, anchorId: hit.id, origs };
337- } else {
338- drag.current = { mode: 'move', startX: x, startY: y, anchorId: hit.id, origs, lastBlipPitch: hit.pitch };
344+ let ids = sel;
345+ let anchorId = hit.id;
346+ if (ev.altKey && !nearRight && !nearLeft) {
347+ // alt+drag: duplicate the selection and move the copies
348+ const map = new Map<string, string>();
349+ store.editSong((s) => {
350+ for (const t of s.tracks) {
351+ const copies = t.notes
352+ .filter((n) => ids.includes(n.id))
353+ .map((n) => {
354+ const id = newId('n');
355+ map.set(n.id, id);
356+ return { ...n, id };
357+ });
358+ t.notes.push(...copies);
359+ }
360+ });
361+ ids = [...map.values()];
362+ anchorId = map.get(hit.id)!;
363+ store.setSelection(ids);
339364 }
365+ const cur = store.song()!;
366+ const origs = new Map<string, OrigNote>();
367+ for (const t of cur.tracks) for (const n of t.notes) if (ids.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch });
368+ if (nearRight) drag.current = { mode: 'resize', startX: x, anchorId, origs };
369+ else if (nearLeft) drag.current = { mode: 'resize-left', startX: x, anchorId, origs };
370+ else drag.current = { mode: 'move', startX: x, startY: y, anchorId, origs, lastBlipPitch: hit.pitch };
340371 return;
341372 }
342373 if (ev.shiftKey) {
@@ -354,7 +385,14 @@ export function PianoRoll() {
354385 const x = ev.clientX - rect.left;
355386 const y = ev.clientY - rect.top;
356387 if (y < RULER_H || x < KEYS_W || y >= height - VEL_H) return;
357- if (hitNote(song, x, y)) return;
388+ const hitn = hitNote(song, x, y);
389+ if (hitn) {
390+ store.mutateSong((s) => {
391+ for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hitn.id);
392+ });
393+ store.setSelection(store.state.selection.filter((id) => id !== hitn.id));
394+ return;
395+ }
358396 const snap = effSnap(ev);
359397 const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor'));
360398 const pitch = yToPitch(y);
@@ -388,9 +426,17 @@ export function PianoRoll() {
388426 const rect = canvas.getBoundingClientRect();
389427 const x = ev.clientX - rect.left;
390428 const y = ev.clientY - rect.top;
391- if (d.mode === 'scrub') {
392- const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
393- player.seek(sec);
429+ if (d.mode === 'ruler-pending') {
430+ if (Math.abs(x - d.x0) > 4) {
431+ const grid = effSnapDrag(ev);
432+ const anchor = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, d.x0)), grid, 'round'));
433+ drag.current = { mode: 'range', anchorTick: anchor };
434+ }
435+ } else if (d.mode === 'range') {
436+ const grid = effSnapDrag(ev);
437+ const t = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, x)), grid, 'round'));
438+ const a = d.anchorTick;
439+ store.setRange(t === a ? null : [Math.min(a, t), Math.max(a, t)]);
394440 } else if (d.mode === 'keys') {
395441 const pitch = yToPitch(y);
396442 if (pitch !== d.pitch && pitch >= PITCH_MIN && pitch <= PITCH_MAX) {
@@ -408,11 +454,11 @@ export function PianoRoll() {
408454 d.x1 = x;
409455 d.y1 = y;
410456 } else if (d.mode === 'move') {
411- const snap = effSnap(ev);
457+ const snap = effSnapDrag(ev);
412458 const anchor = d.origs.get(d.anchorId)!;
413459 const rawDelta = (x - d.startX) / pxPerTick(song);
414460 const delta = snapValue(anchor.tick + rawDelta, snap, 'round') - anchor.tick;
415- const dPitch = -Math.round((y - d.startY) / KEY_H);
461+ const dPitch = -Math.round((y - d.startY) / view.current.keyH);
416462 store.editSong((s) => {
417463 for (const t of s.tracks) {
418464 for (const n of t.notes) {
@@ -429,7 +475,7 @@ export function PianoRoll() {
429475 blip(activeInstrument(song), newAnchorPitch);
430476 }
431477 } else if (d.mode === 'resize') {
432- const snap = effSnap(ev);
478+ const snap = effSnapDrag(ev);
433479 const anchor = d.origs.get(d.anchorId)!;
434480 const rawDelta = (x - d.startX) / pxPerTick(song);
435481 const newEnd = snapValue(anchor.tick + anchor.duration + rawDelta, snap, 'round');
@@ -443,6 +489,23 @@ export function PianoRoll() {
443489 }
444490 }
445491 });
492+ } else if (d.mode === 'resize-left') {
493+ const snap = effSnapDrag(ev);
494+ const anchor = d.origs.get(d.anchorId)!;
495+ const rawDelta = (x - d.startX) / pxPerTick(song);
496+ const newStart = snapValue(anchor.tick + rawDelta, snap, 'round');
497+ const delta = newStart - anchor.tick;
498+ store.editSong((s) => {
499+ for (const t of s.tracks) {
500+ for (const n of t.notes) {
501+ const o = d.origs.get(n.id);
502+ if (!o) continue;
503+ const tick = Math.max(0, Math.min(o.tick + o.duration - MIN_DUR_TICKS, o.tick + delta));
504+ n.tick = tick;
505+ n.duration = o.duration - (tick - o.tick);
506+ }
507+ }
508+ });
446509 }
447510 };
448511
@@ -457,6 +520,10 @@ export function PianoRoll() {
457520 const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
458521 store.seekTo(getTm(song).tickToSec(tick));
459522 store.setSelection([]);
523+ } else if (d.mode === 'ruler-pending') {
524+ const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
525+ store.seekTo(getTm(song).tickToSec(tick));
526+ store.setRange(null);
460527 } else if (d.mode === 'marquee') {
461528 const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
462529 if (!track) return;
@@ -469,10 +536,10 @@ export function PianoRoll() {
469536 const nx = tickToX(song, n.tick);
470537 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
471538 const ny = pitchToY(n.pitch);
472- if (nx + w >= x0 && nx <= x1 && ny + KEY_H >= y0 && ny <= y1) ids.push(n.id);
539+ if (nx + w >= x0 && nx <= x1 && ny + view.current.keyH >= y0 && ny <= y1) ids.push(n.id);
473540 }
474541 store.setSelection(d.additive ? [...new Set([...store.state.selection, ...ids])] : ids);
475- } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'velocity') {
542+ } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'resize-left' || d.mode === 'velocity') {
476543 store.endEdit();
477544 }
478545 };
@@ -488,10 +555,16 @@ export function PianoRoll() {
488555 const factor = Math.exp(-ev.deltaY * 0.0018);
489556 view.current.pxPerBeat = Math.max(12, Math.min(700, view.current.pxPerBeat * factor));
490557 view.current.scrollX = Math.max(0, tickAtMouse * pxPerTick(song) - (x - KEYS_W));
558+ } else if (ev.altKey) {
559+ const y = ev.clientY - rect.top;
560+ const pitchF = (y + view.current.scrollY - RULER_H) / view.current.keyH;
561+ const factor = Math.exp(-ev.deltaY * 0.0018);
562+ view.current.keyH = Math.max(6, Math.min(26, view.current.keyH * factor));
563+ view.current.scrollY = Math.max(0, pitchF * view.current.keyH - (y - RULER_H));
491564 } else if (ev.shiftKey) {
492565 view.current.scrollX = Math.max(0, view.current.scrollX + ev.deltaY);
493566 } else {
494- const rollH = (PITCH_MAX - PITCH_MIN + 1) * KEY_H;
567+ const rollH = (PITCH_MAX - PITCH_MIN + 1) * view.current.keyH;
495568 const viewH = Math.max(50, height - VEL_H - RULER_H);
496569 view.current.scrollY = Math.max(0, Math.min(rollH - viewH, view.current.scrollY + ev.deltaY));
497570 const maxX = Math.max(0, (songEndTick(song) + song.ppq * 64) * pxPerTick(song) - (width - KEYS_W));
src/components/TrackList.tsxmodified+3−0View file
@@ -86,6 +86,9 @@ export function TrackList() {
8686 title="Arm for recording"
8787 onClick={(e) => { e.stopPropagation(); store.setArmedTrack(armed ? null : t.id); }}
8888 >R</button>
89+ <button className="mini" title="Move track up" onClick={(e) => { e.stopPropagation(); store.moveTrack(t.id, -1); }}>↑</button>
90+ <button className="mini" title="Move track down" onClick={(e) => { e.stopPropagation(); store.moveTrack(t.id, 1); }}>↓</button>
91+ <button className="mini" title="Duplicate track" onClick={(e) => { e.stopPropagation(); store.duplicateTrack(t.id); }}>⧉</button>
8992 </div>
9093 {active && (getInstrument(t.instrumentId).knobs ?? []).length > 0 && (
9194 <div className="trackrow-knobs">
src/components/Transport.tsxmodified+22−0View file
@@ -80,6 +80,28 @@ export function Transport() {
8080 ))}
8181 </select>
8282 <button onClick={() => store.quantize()} title="Quantize selected notes (or the active track) to the snap grid">Quantize</button>
83+ <button
84+ className={app.loopEnabled && app.rangeTicks ? 'toggled' : ''}
85+ disabled={!app.rangeTicks}
86+ onClick={() => store.toggleLoop()}
87+ title="Loop playback over the time selection (drag in the ruler to make one)"
88+ >Loop</button>
89+ {app.rangeTicks && (
90+ <span className="rangeops">
91+ <span className="pos" title="time selection (bar.beat)">
92+ {(() => {
93+ const A = tm.barBeatAt(app.rangeTicks[0]);
94+ const B = tm.barBeatAt(app.rangeTicks[1]);
95+ return `${A.bar}.${A.beat} to ${B.bar}.${B.beat}`;
96+ })()}
97+ </span>
98+ <button onClick={() => store.rangeSelectNotes()} title="Select the notes inside the time selection">Select</button>
99+ <button onClick={() => store.rangeClearNotes()} title="Delete the notes inside the time selection, keeping the time (also the delete key when no notes are selected)">Clear</button>
100+ <button onClick={() => store.rangeDeleteTime()} title="Delete the selected time span on every track and close the gap; the tempo map ripples too">Cut time</button>
101+ <button onClick={() => store.rangeInsertTime()} title="Insert that much empty time at the selection start, on every track">Insert</button>
102+ <button onClick={() => store.rangeDuplicateTime()} title="Insert a copy of the selection immediately after itself">Dup</button>
103+ </span>
104+ )}
83105 </div>
84106 );
85107 }
src/midi/ops.tsmodified+59−0View file
@@ -1,4 +1,5 @@
11 import type { Note } from '../types.ts';
2+import { newId } from '../types.ts';
23
34 // Pure note-list operations used by the editor; kept free of DOM and state
45 // so the node test harness can exercise them directly.
@@ -19,3 +20,61 @@ export function transposeNotes(notes: Note[], semitones: number, ids?: Set<strin
1920 return { ...n, pitch };
2021 });
2122 }
23+
24+// --- time-range operations (the arrangement edits) ---
25+// All ranges are [start, end) in ticks. Notes crossing a boundary are
26+// trimmed, and a note spanning the whole range is split in two, which is
27+// what a user expects "delete the contents of this region" to mean.
28+
29+export function clearRange(notes: Note[], start: number, end: number): Note[] {
30+ const out: Note[] = [];
31+ for (const n of notes) {
32+ const nEnd = n.tick + n.duration;
33+ if (nEnd <= start || n.tick >= end) {
34+ out.push(n);
35+ } else if (n.tick < start) {
36+ out.push({ ...n, duration: start - n.tick });
37+ if (nEnd > end) out.push({ ...n, id: newId('n'), tick: end, duration: nEnd - end });
38+ } else if (nEnd > end) {
39+ out.push({ ...n, tick: end, duration: nEnd - end });
40+ }
41+ // fully inside: dropped
42+ }
43+ return out;
44+}
45+
46+/** Delete the range and close the gap (ripple). */
47+export function deleteTimeRange(notes: Note[], start: number, end: number): Note[] {
48+ const span = end - start;
49+ return clearRange(notes, start, end).map((n) => (n.tick >= end ? { ...n, tick: n.tick - span } : n));
50+}
51+
52+/** Insert empty time at a point; notes starting there or later shift right. */
53+export function insertTime(notes: Note[], at: number, span: number): Note[] {
54+ return notes.map((n) => (n.tick >= at ? { ...n, tick: n.tick + span } : n));
55+}
56+
57+/** Copies (fresh ids) of the parts of notes that fall inside the range. */
58+export function extractRange(notes: Note[], start: number, end: number): Note[] {
59+ const out: Note[] = [];
60+ for (const n of notes) {
61+ const t0 = Math.max(n.tick, start);
62+ const t1 = Math.min(n.tick + n.duration, end);
63+ if (t1 > t0) out.push({ ...n, id: newId('n'), tick: t0, duration: t1 - t0 });
64+ }
65+ return out;
66+}
67+
68+/** Ripple-delete a range from a tick-stamped event list (tempo map, time
69+ * signatures). Events strictly inside are dropped; an event exactly at the
70+ * start survives, so the map stays valid from tick 0. */
71+export function deleteTimeFromEvents<T extends { tick: number }>(events: T[], start: number, end: number): T[] {
72+ const span = end - start;
73+ return events
74+ .filter((e) => !(e.tick > start && e.tick < end))
75+ .map((e) => (e.tick >= end ? { ...e, tick: e.tick - span } : e));
76+}
77+
78+export function insertTimeIntoEvents<T extends { tick: number }>(events: T[], at: number, span: number): T[] {
79+ return events.map((e) => (e.tick > 0 && e.tick >= at ? { ...e, tick: e.tick + span } : e));
80+}
src/state/store.tsmodified+154−3View file
@@ -1,10 +1,10 @@
11 import { useSyncExternalStore } from 'react';
22 import type { Song, SongMeta, SongRecord, Track } from '../types.ts';
3-import { defaultSong, newId } from '../types.ts';
3+import { defaultSong, newId, songEndTick } from '../types.ts';
44 import * as db from '../storage/db.ts';
55 import { parseMidi } from '../midi/parse.ts';
66 import { TempoMap } from '../midi/time.ts';
7-import { quantizeNotes } from '../midi/ops.ts';
7+import { clearRange, deleteTimeFromEvents, deleteTimeRange, extractRange, insertTime, insertTimeIntoEvents, quantizeNotes } from '../midi/ops.ts';
88 import { player } from '../audio/player.ts';
99 import { Recorder, type CapturedNote } from '../input/recorder.ts';
1010
@@ -20,6 +20,9 @@ export interface AppState {
2020 activeTrackId: string | null;
2121 armedTrackId: string | null;
2222 selection: string[];
23+ /** Time selection [start, end) in ticks, made by dragging in the ruler. */
24+ rangeTicks: [number, number] | null;
25+ loopEnabled: boolean;
2326 snapBeats: number; // snap grid in quarter notes; 0 = off
2427 libraryOpen: boolean;
2528 status: string;
@@ -36,6 +39,8 @@ class Store {
3639 activeTrackId: null,
3740 armedTrackId: null,
3841 selection: [],
42+ rangeTicks: null,
43+ loopEnabled: false,
3944 snapBeats: 0.25,
4045 libraryOpen: false,
4146 status: '',
@@ -286,6 +291,144 @@ class Store {
286291
287292 setSelection(ids: string[]): void { this.set({ selection: ids }); }
288293
294+ // --- time selection and arrangement edits ---
295+
296+ setRange(r: [number, number] | null): void {
297+ this.set({ rangeTicks: r });
298+ }
299+
300+ toggleLoop(): void {
301+ this.set({ loopEnabled: !this.state.loopEnabled });
302+ }
303+
304+ rangeSelectNotes(): void {
305+ const song = this.song();
306+ const r = this.state.rangeTicks;
307+ if (!song || !r) return;
308+ const ids = song.tracks.flatMap((t) => t.notes.filter((n) => n.tick < r[1] && n.tick + n.duration > r[0]).map((n) => n.id));
309+ this.set({ selection: ids });
310+ }
311+
312+ /** Delete the notes inside the time selection; time itself is kept. */
313+ rangeClearNotes(): void {
314+ const r = this.state.rangeTicks;
315+ if (!r) return;
316+ this.mutateSong((s) => {
317+ for (const t of s.tracks) t.notes = clearRange(t.notes, r[0], r[1]);
318+ });
319+ this.set({ selection: [] });
320+ }
321+
322+ /** Delete the time selection on every track and close the gap; the tempo
323+ * and time-signature maps ripple with it. */
324+ rangeDeleteTime(): void {
325+ const r = this.state.rangeTicks;
326+ if (!r) return;
327+ this.mutateSong((s) => {
328+ for (const t of s.tracks) t.notes = deleteTimeRange(t.notes, r[0], r[1]);
329+ s.tempo = deleteTimeFromEvents(s.tempo, r[0], r[1]);
330+ s.timeSig = deleteTimeFromEvents(s.timeSig, r[0], r[1]);
331+ });
332+ this.set({ selection: [], rangeTicks: null });
333+ }
334+
335+ /** Insert that much empty time at the selection start, on every track. */
336+ rangeInsertTime(): void {
337+ const r = this.state.rangeTicks;
338+ if (!r) return;
339+ const span = r[1] - r[0];
340+ this.mutateSong((s) => {
341+ for (const t of s.tracks) t.notes = insertTime(t.notes, r[0], span);
342+ s.tempo = insertTimeIntoEvents(s.tempo, r[0], span);
343+ s.timeSig = insertTimeIntoEvents(s.timeSig, r[0], span);
344+ });
345+ }
346+
347+ /** Insert a copy of the time selection immediately after itself and move
348+ * the selection onto the copy. */
349+ rangeDuplicateTime(): void {
350+ const r = this.state.rangeTicks;
351+ if (!r) return;
352+ const span = r[1] - r[0];
353+ this.mutateSong((s) => {
354+ for (const t of s.tracks) {
355+ const copy = extractRange(t.notes, r[0], r[1]).map((n) => ({ ...n, tick: n.tick + span }));
356+ t.notes = insertTime(t.notes, r[1], span);
357+ t.notes.push(...copy);
358+ t.notes.sort((a, b) => a.tick - b.tick || a.pitch - b.pitch);
359+ }
360+ s.tempo = insertTimeIntoEvents(s.tempo, r[1], span);
361+ s.timeSig = insertTimeIntoEvents(s.timeSig, r[1], span);
362+ });
363+ this.set({ rangeTicks: [r[1], r[1] + span] });
364+ }
365+
366+ /** Split notes under the playhead: the selected ones, or every note of
367+ * the active track when nothing is selected. */
368+ splitAtPlayhead(): void {
369+ const song = this.song();
370+ if (!song) return;
371+ const tm = this.tempoMap()!;
372+ const tick = Math.round(tm.secToTick(Math.max(0, player.getPositionSec())));
373+ const sel = this.state.selection.length > 0 ? new Set(this.state.selection) : null;
374+ const activeId = this.state.activeTrackId;
375+ const ids: string[] = [];
376+ this.mutateSong((s) => {
377+ for (const t of s.tracks) {
378+ if (!sel && t.id !== activeId) continue;
379+ const next: typeof t.notes = [];
380+ for (const n of t.notes) {
381+ const target = sel ? sel.has(n.id) : true;
382+ if (target && n.tick < tick && tick < n.tick + n.duration) {
383+ const id2 = newId('n');
384+ next.push({ ...n, duration: tick - n.tick });
385+ next.push({ ...n, id: id2, tick, duration: n.tick + n.duration - tick });
386+ ids.push(n.id, id2);
387+ } else {
388+ next.push(n);
389+ }
390+ }
391+ t.notes = next;
392+ }
393+ });
394+ if (ids.length > 0) this.set({ selection: ids, status: `Split ${ids.length / 2} note(s)` });
395+ }
396+
397+ seekToEnd(): void {
398+ const song = this.song();
399+ if (!song) return;
400+ this.seekTo(this.tempoMap()!.tickToSec(songEndTick(song)));
401+ }
402+
403+ // --- track arrangement ---
404+
405+ moveTrack(id: string, dir: -1 | 1): void {
406+ this.mutateSong((s) => {
407+ const i = s.tracks.findIndex((t) => t.id === id);
408+ const j = i + dir;
409+ if (i < 0 || j < 0 || j >= s.tracks.length) return;
410+ const [t] = s.tracks.splice(i, 1);
411+ s.tracks.splice(j, 0, t);
412+ });
413+ }
414+
415+ duplicateTrack(id: string): void {
416+ let dupId: string | null = null;
417+ this.mutateSong((s) => {
418+ const i = s.tracks.findIndex((t) => t.id === id);
419+ if (i < 0) return;
420+ const src = s.tracks[i];
421+ dupId = newId('t');
422+ s.tracks.splice(i + 1, 0, {
423+ ...structuredClone(src),
424+ id: dupId,
425+ name: `${src.name} copy`,
426+ notes: src.notes.map((n) => ({ ...n, id: newId('n') })),
427+ });
428+ });
429+ if (dupId) this.set({ activeTrackId: dupId });
430+ }
431+
289432 selectAll(): void {
290433 const song = this.song();
291434 if (!song) return;
@@ -413,7 +556,15 @@ class Store {
413556 if (player.state !== 'stopped') this.stopTransport();
414557 else {
415558 try {
416- await player.play(song);
559+ let loop: { startSec: number; endSec: number } | undefined;
560+ const r = this.state.rangeTicks;
561+ if (this.state.loopEnabled && r) {
562+ const tm = this.tempoMap()!;
563+ loop = { startSec: tm.tickToSec(r[0]), endSec: tm.tickToSec(r[1]) };
564+ const pos = player.getPositionSec();
565+ if (pos < loop.startSec - 1e-3 || pos >= loop.endSec - 1e-3) player.seek(loop.startSec);
566+ }
567+ await player.play(song, { loop });
417568 } catch (err) {
418569 player.stop();
419570 this.set({ status: `Playback failed: ${err instanceof Error ? err.message : String(err)}` });
src/styles.cssmodified+2−0View file
@@ -12,6 +12,7 @@ body {
1212 header, .transport {
1313 display: flex;
1414 align-items: center;
15+ flex-wrap: wrap;
1516 gap: 8px;
1617 padding: 6px 10px;
1718 background: #191c22;
@@ -67,6 +68,7 @@ label { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap
6768 text-align: center;
6869 }
6970 .transport .status { color: #aaa; }
71+.rangeops { display: flex; align-items: center; gap: 6px; }
7072
7173 .main { display: flex; flex: 1; min-height: 0; }
7274