char-slop/chordpad.vibed.software

git clone https://git.t4t.associates/char-slop/chordpad.vibed.software

Charlotte Somctrl click to move transport on chord tracks4c8871a

main
31.1 KiB931 linesraw
1import type { JSX } from "@char/aftercare/jsx-runtime";
2import { audition, play, playhead, playing } from "../audio/transport.ts";
3import { sounding, type VoicedChord, voiceTrack } from "../project/harmony.ts";
4import {
5  type ChordTrack,
6  findTrack,
7  moveTrack,
8  newId,
9  NOTE_VEL,
10  type NoteTrack,
11  placeChord,
12  type Project,
13  projectEnd,
14  projectKey,
15  type Track,
16} from "../project/model.ts";
17import { beginGesture, commit, project } from "../project/store.ts";
18import { origin, prettyRoman } from "../theory/analysis.ts";
19import { chordSymbol } from "../theory/chord.ts";
20import { type Key, MODES } from "../theory/key.ts";
21import { formatNote, mod, pcOf, spellPc } from "../theory/pitch.ts";
22import { addTrack, createChordWithNote, removeTrack, toggleChordNote } from "./actions.ts";
23import { ChordEditor } from "./chord-editor.tsx";
24import { originClass } from "./origin.ts";
25import { drag, px, setChildren, watch } from "./dom.tsx";
26import {
27  activeTrackId,
28  chordEdit,
29  cursor,
30  HEADER_W,
31  laneHeight,
32  pxPerBeat,
33  ROW_H,
34  rollHeight,
35  selectedIds,
36  selection,
37  STRIP_H,
38} from "./state.ts";
39
40let lastNoteLength = 1;
41/** where a shift-click range of chords starts from */
42let chordAnchor: string | undefined;
43
44export function Timeline(): JSX.Element {
45  const rows = (<div class="tl-rows" />) as HTMLDivElement;
46  const cursorLine = (<div class="tl-line tl-cursor" />) as HTMLDivElement;
47  const playLine = (<div class="tl-line tl-playhead" />) as HTMLDivElement;
48  const content = (
49    <div class="tl-content">
50      {rows}
51      {cursorLine}
52      {playLine}
53      <ChordEditor />
54    </div>
55  ) as HTMLDivElement;
56
57  const corner = <div class="tl-corner" />;
58  const addRow = (
59    <div class="tl-add">
60      <button type="button" _onclick={() => addTrack("chords")}>
61        + chords
62      </button>
63      <button type="button" _onclick={() => addTrack("notes")}>
64        + notes
65      </button>
66    </div>
67  );
68  // the ruler and headers take clicks and double-clicks, so they're only rebuilt when what they
69  // show has changed: otherwise a re-render mid-click would swallow the click
70  const headers = new Map<string, { shows: string; el: JSX.Element }>();
71  let cachedRuler: { shows: string; el: JSX.Element } | undefined;
72  const rulerFor = (p: Project, beats: number, ppb: number) => {
73    const shows = JSON.stringify([beats, ppb, p.beatsPerBar, p.grid, p.loop, p.loopRegion]);
74    if (cachedRuler?.shows !== shows) cachedRuler = { shows, el: ruler(p, beats, ppb) };
75    return cachedRuler.el;
76  };
77  const header = (t: Track, p: Project) => {
78    const bass = t.kind === "chords" && t.bass;
79    const shows = JSON.stringify([t.name, t.muted, t.kind, bass, t.view, p.key.tonic]);
80    const cached = headers.get(t.id);
81    if (cached?.shows === shows) return cached.el;
82    const el = trackHeader(t, p);
83    headers.set(t.id, { shows, el });
84    return el;
85  };
86
87  watch(rows, [project, selection, cursor, activeTrackId, pxPerBeat], () => {
88    const p = project.get();
89    const ppb = pxPerBeat.get();
90    const bar = p.beatsPerBar;
91    const beats = Math.max(
92      16 * bar,
93      Math.ceil((Math.max(projectEnd(p), cursor.get()) + 4 * bar) / bar) * bar,
94    );
95    content.style.width = px(HEADER_W + beats * ppb);
96    rows.style.gridTemplateColumns = `${HEADER_W}px ${beats * ppb}px`;
97    for (const id of headers.keys()) if (!p.tracks.some(t => t.id === id)) headers.delete(id);
98    setChildren(rows, [
99      corner,
100      rulerFor(p, beats, ppb),
101      ...p.tracks.flatMap(t => [header(t, p), lane(t, p, ppb)]),
102      addRow,
103    ]);
104    for (const [id, { el }] of headers)
105      el.classList.toggle("active", id === activeTrackId.get());
106    for (const el of rows.querySelectorAll<HTMLElement>(".lane"))
107      el.classList.toggle("active", el.dataset.lane === activeTrackId.get());
108    cursorLine.style.translate = px(HEADER_W + cursor.get() * ppb);
109  });
110
111  watch(playLine, [playhead, pxPerBeat], () => {
112    const beat = playhead.get();
113    playLine.hidden = beat === null;
114    if (beat !== null) playLine.style.translate = px(HEADER_W + beat * pxPerBeat.get());
115  });
116
117  const scroller = (<div class="timeline">{content}</div>) as HTMLDivElement;
118
119  // ctrl-click moves the cursor to the nearest grid line, as clicking the ruler does
120  rows.addEventListener(
121    "pointerdown",
122    e => {
123      if (e.button !== 0 || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
124      const surface = (e.target as Element).closest<HTMLElement>(".roll, .strip");
125      const lane = surface?.closest<HTMLElement>("[data-lane]");
126      if (!surface || !lane) return;
127      e.preventDefault();
128      e.stopPropagation();
129      const grid = project.get().grid;
130      const at = (e.clientX - surface.getBoundingClientRect().left) / pxPerBeat.get();
131      const beat = Math.max(0, Math.round(at / grid) * grid);
132      activeTrackId.set(lane.dataset.lane!);
133      cursor.set(beat);
134      if (playing.get()) play(beat);
135    },
136    { capture: true },
137  );
138
139  // middle-dragging pans, with the content following the pointer: sideways through the song, and
140  // up and down through the pitches of the track the drag started on
141  scroller.addEventListener("pointerdown", e => {
142    if (e.button !== 1) return;
143    e.preventDefault();
144    const at = (e.target as Element).closest<HTMLElement>("[data-lane], [data-track]")?.dataset;
145    const track = project.get().tracks.find(t => t.id === (at?.lane ?? at?.track));
146    const left = scroller.scrollLeft;
147    const top = track && viewOf(track).high;
148    document.body.classList.add("panning");
149    drag(
150      e,
151      m => {
152        scroller.scrollLeft = left - (m.clientX - e.clientX);
153        if (track) panTo(track, top! + Math.round((m.clientY - e.clientY) / ROW_H));
154      },
155      () => {
156        document.body.classList.remove("panning");
157        savePans();
158      },
159    );
160  });
161  // firefox (and chrome on windows) would start autoscrolling
162  scroller.addEventListener("mousedown", e => e.button === 1 && e.preventDefault());
163
164  // zoom around the pointer: the beat under it stays under it
165  scroller.addEventListener("wheel", e => {
166    if (!e.ctrlKey) return;
167    e.preventDefault();
168    const old = pxPerBeat.get();
169    // a trackpad pinch comes as many small deltas, a mouse wheel as a few big ones (a notch is
170    // about 100): zoom with the pinch, but no more than about 15% a notch
171    const pixels = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
172    const step = Math.max(-0.15, Math.min(0.15, pixels * 0.01));
173    const ppb = Math.min(160, Math.max(8, old * Math.exp(-step)));
174    if (ppb === old) return;
175
176    // measured from the scroller's left edge, not counting the track headers pinned there
177    const x = Math.max(HEADER_W, e.clientX - scroller.getBoundingClientRect().left);
178    const beat = (scroller.scrollLeft + x - HEADER_W) / old;
179    pxPerBeat.set(ppb);
180    // queued behind the re-render the zoom causes, so the timeline is already wide enough
181    queueMicrotask(() => (scroller.scrollLeft = HEADER_W + beat * ppb - x));
182  });
183  return scroller;
184}
185
186const gridLines = (ppb: number, bar: number) =>
187  [
188    `repeating-linear-gradient(to right, var(--line-bar) 0 1px, transparent 1px ${ppb * bar}px)`,
189    `repeating-linear-gradient(to right, var(--line-beat) 0 1px, transparent 1px ${ppb}px)`,
190  ].join(",");
191
192function ruler(p: Project, beats: number, ppb: number) {
193  const labels = [];
194  for (let b = 0; b < beats; b += p.beatsPerBar) {
195    labels.push(<span style={{ left: px(b * ppb + 4) }}>{String(b / p.beatsPerBar + 1)}</span>);
196  }
197  // measured as a drag starts: the ruler is redrawn as the loop changes under it
198  const beatsFrom = () => {
199    const left = el.getBoundingClientRect().left;
200    return (x: number) => Math.max(0, Math.round((x - left) / ppb / p.grid) * p.grid);
201  };
202  const setLoop = (a: number, b: number) => {
203    const loopRegion = { start: Math.min(a, b), end: Math.max(a, b) };
204    commit(d => Object.assign(d, { loop: true, loopRegion }), { history: false });
205  };
206
207  /** a loop's end, to drag it somewhere else (past the other end, if need be) */
208  const edge = (moving: "start" | "end") => (
209    <div
210      class={`loop-edge ${moving}`}
211      _onpointerdown={e => {
212        if (e.button !== 0 || !p.loopRegion) return;
213        e.stopPropagation();
214        const beatAt = beatsFrom();
215        const fixed = moving === "start" ? p.loopRegion.end : p.loopRegion.start;
216        document.body.classList.add("resizing-loop");
217        drag(
218          e,
219          m => {
220            const to = beatAt(m.clientX);
221            if (to !== fixed) setLoop(fixed, to);
222          },
223          () => document.body.classList.remove("resizing-loop"),
224        );
225      }}
226    />
227  );
228  const region = p.loopRegion && (
229    <div
230      class={`loop-region${p.loop ? "" : " off"}`}
231      style={{
232        left: px(p.loopRegion.start * ppb),
233        width: px((p.loopRegion.end - p.loopRegion.start) * ppb),
234      }}
235      title="drag its ends to move them; double-click to loop the whole song"
236      _ondblclick={() => commit(d => void delete d.loopRegion, { history: false })}
237    >
238      {edge("start")}
239      {edge("end")}
240    </div>
241  );
242
243  const el = (
244    <div
245      class="ruler"
246      style={{ backgroundImage: gridLines(ppb, p.beatsPerBar) }}
247      title="click to move the cursor, drag to set the loop"
248      _onpointerdown={e => {
249        if (e.button !== 0) return;
250        const beatAt = beatsFrom();
251        const from = beatAt(e.clientX);
252        let dragging = false;
253        drag(
254          e,
255          m => {
256            dragging ||= Math.abs(m.clientX - e.clientX) > 3;
257            const to = beatAt(m.clientX);
258            if (dragging && to !== from) setLoop(from, to);
259          },
260          () => {
261            if (dragging) return;
262            cursor.set(from);
263            if (playing.get()) play(from);
264          },
265        );
266      }}
267    >
268      {region}
269      {labels}
270    </div>
271  );
272  return el;
273}
274
275function trackHeader(t: Track, p: Project) {
276  const name = (
277    <input
278      class="th-name"
279      value={t.name}
280      spellcheck={false}
281      _onchange={() => commit(d => void (d.tracks.find(x => x.id === t.id)!.name = name.value))}
282      _onkeydown={e => e.key === "Enter" && name.blur()}
283    />
284  ) as HTMLInputElement;
285  return (
286    <div
287      class="track-head"
288      dataset={{ track: t.id }}
289      style={{ height: px(laneHeight(t)) }}
290      title="drag to move the track"
291      _onpointerdown={e => {
292        activeTrackId.set(t.id);
293        const control = "input, button, label, .keys, .track-resize";
294        if (e.button === 0 && !(e.target as Element).closest(control)) reorder(e, t);
295      }}
296    >
297      <div class="th-top">
298        {name}
299        <button
300          type="button"
301          class={`th-btn${t.muted ? " on" : ""}`}
302          title="mute"
303          _onclick={() =>
304            commit(d => void (d.tracks.find(x => x.id === t.id)!.muted = !t.muted))
305          }
306        >
307          M
308        </button>
309        <button
310          type="button"
311          class="th-btn"
312          title="delete track"
313          _onclick={() => removeTrack(t.id)}
314        >
315          ×
316        </button>
317      </div>
318      {gainSlider(t)}
319      {t.kind === "chords" ? (
320        <label class="th-bass" title="off when another track carries the bass">
321          <input
322            type="checkbox"
323            checked={t.bass}
324            _onchange={e =>
325              commit(
326                d =>
327                  void (findTrack(d, t.id, "chords")!.bass = (
328                    e.target as HTMLInputElement
329                  ).checked),
330              )
331            }
332          />
333          bass
334        </label>
335      ) : undefined}
336      {pianoKeys(t, projectKey(p))}
337      {resizeHandle(t)}
338    </div>
339  );
340}
341
342/** drag a track's header up or down to move it, with a line showing where it'll go */
343function reorder(e: PointerEvent, t: Track) {
344  const heads = [...document.querySelectorAll(".track-head")].map(h =>
345    h.getBoundingClientRect(),
346  );
347  const timeline = document.querySelector(".timeline")!.getBoundingClientRect();
348  const line = (<div class="reorder-line" />) as HTMLDivElement;
349  let to: number | undefined;
350  drag(
351    e,
352    m => {
353      if (to === undefined && Math.abs(m.clientY - e.clientY) < 4) return;
354      if (to === undefined) {
355        document.body.append(line);
356        document.body.classList.add("reordering");
357      }
358      // the gap between tracks nearest the pointer
359      to = heads.filter(h => m.clientY > h.top + h.height / 2).length;
360      const y = to < heads.length ? heads[to].top : heads.at(-1)!.bottom;
361      Object.assign(line.style, {
362        top: px(y - 1),
363        left: px(timeline.left),
364        width: px(timeline.width),
365      });
366    },
367    () => {
368      line.remove();
369      document.body.classList.remove("reordering");
370      const from = project.get().tracks.findIndex(x => x.id === t.id);
371      if (to !== undefined && to !== from && to !== from + 1)
372        commit(d => moveTrack(d, t.id, to!));
373    },
374  );
375}
376
377/** a track's volume trim, in dB: one drag is one undo step, and a double click resets it */
378function gainSlider(t: Track) {
379  const set = (gain: number) => (d: Project) =>
380    void (d.tracks.find(x => x.id === t.id)!.gain = gain);
381  let gesture: ReturnType<typeof beginGesture> | undefined;
382  const slider = (
383    <input
384      type="range"
385      min="-24"
386      max="6"
387      step="0.5"
388      title="volume (double-click for 0 dB)"
389      _oninput={() => (gesture ??= beginGesture()).update(set(slider.valueAsNumber))}
390      _onchange={() => (gesture = undefined)}
391      // let go when the mouse does, or space would nudge the slider rather than play
392      _onpointerdown={() => addEventListener("pointerup", () => slider.blur(), { once: true })}
393      _ondblclick={() => commit(set(0))}
394    />
395  ) as HTMLInputElement;
396  const readout = (<span />) as HTMLSpanElement;
397  // not rebuilt with the header, which would swap the slider out from under a drag
398  watch(slider, [project], () => {
399    const gain = project.get().tracks.find(x => x.id === t.id)?.gain ?? 0;
400    slider.valueAsNumber = gain;
401    readout.textContent = gain ? `${gain > 0 ? "+" : "−"}${Math.abs(gain)} dB` : "0 dB";
402  });
403  return (
404    <label class="th-gain">
405      {slider}
406      {readout}
407    </label>
408  );
409}
410
411const MIN_ROWS = 12;
412
413/** dragging a track's bottom edge shows more or fewer rows below, keeping the top in place */
414function resizeHandle(t: Track) {
415  return (
416    <div
417      class="track-resize"
418      title="drag to resize"
419      _onpointerdown={e => {
420        if (e.button !== 0) return;
421        e.preventDefault();
422        const { low, high } = viewOf(t);
423        savePans();
424        let rows = high - low + 1;
425        document.body.classList.add("resizing-track");
426        drag(
427          e,
428          m => {
429            const want = high - low + 1 + Math.round((m.clientY - e.clientY) / ROW_H);
430            const next = Math.max(MIN_ROWS, Math.min(high + 1, want));
431            if (next === rows) return;
432            rows = next;
433            commit(
434              d =>
435                void (d.tracks.find(x => x.id === t.id)!.view = { low: high - rows + 1, high }),
436              { history: false },
437            );
438          },
439          () => document.body.classList.remove("resizing-track"),
440        );
441      }}
442    />
443  );
444}
445
446const PITCHES = 128;
447/** where the rows for pitches from `high` down start, in a roll or keyboard that holds them all */
448const offset = (high: number) => `0 ${px(-(PITCHES - 1 - high) * ROW_H)}`;
449
450/**
451 * pitch ranges moved but not yet saved, by their top pitch. rolls and keyboards hold every pitch,
452 * so moving through them is only a transform; saving (and so re-rendering) waits for the moving
453 * to stop, as doing it every row would drop frames
454 */
455const panned = new Map<string, number>();
456let panSave: ReturnType<typeof setTimeout> | undefined;
457
458const viewOf = (t: Track): Track["view"] => {
459  const high = panned.get(t.id);
460  return high === undefined ? t.view : { low: t.view.low + high - t.view.high, high };
461};
462
463function panTo(t: Track, high: number) {
464  high = Math.max(t.view.high - t.view.low, Math.min(PITCHES - 1, high));
465  panned.set(t.id, high);
466  for (const el of document.querySelectorAll<HTMLElement>(`[data-pan="${t.id}"]`))
467    el.style.translate = offset(high);
468  clearTimeout(panSave);
469  panSave = setTimeout(savePans, 250);
470}
471
472function savePans() {
473  clearTimeout(panSave);
474  if (!panned.size) return;
475  const views = new Map(project.get().tracks.map(t => [t.id, viewOf(t)]));
476  panned.clear();
477  commit(d => d.tracks.forEach(t => (t.view = views.get(t.id)!)), { history: false });
478}
479
480// fractions of a row scrolled so far
481const scrolledRows = new Map<string, number>();
482
483/** vertical wheel moves a track's pitch range like scrolling a document */
484function scrollPitches(t: Track) {
485  return (e: WheelEvent) => {
486    const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? rollHeight(t) : 1;
487    // shift+wheel scrolls the timeline sideways. some browsers turn it into a horizontal scroll
488    // themselves, but others (firefox) leave it vertical, which would land here as pitch
489    if (e.shiftKey) {
490      e.preventDefault();
491      const timeline = (e.currentTarget as Element).closest(".timeline")!;
492      timeline.scrollLeft +=
493        (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * unit;
494      return;
495    }
496    // leave zooming and horizontal scrolling to the timeline
497    if (e.ctrlKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) return;
498    // a mouse wheel's notch moves one key, whether it comes as lines (firefox, sometimes) or as
499    // one big jump in pixels; a trackpad's stream of small ones moves the rows with the fingers
500    const notches = Math.max(1, Math.round(Math.abs(e.deltaY) / 100)) * Math.sign(e.deltaY);
501    const moved =
502      e.deltaMode === 2
503        ? e.deltaY * (t.view.high - t.view.low + 1)
504        : e.deltaMode === 1
505          ? e.deltaY / 3
506          : Math.abs(e.deltaY) >= 40
507            ? notches
508            : e.deltaY / ROW_H;
509    const total = (scrolledRows.get(t.id) ?? 0) + moved;
510    const rows = Math.trunc(total);
511    const { low, high } = viewOf(t);
512    const shift = Math.max(-low, Math.min(PITCHES - 1 - high, -rows));
513    // at the end of the range, let the timeline scroll instead
514    if (rows && !shift) return scrolledRows.delete(t.id);
515
516    e.preventDefault();
517    scrolledRows.set(t.id, total - rows);
518    if (shift) panTo(t, high + shift);
519  };
520}
521
522const BLACK_KEYS = [1, 3, 6, 8, 10];
523
524function pianoKeys(t: Track, key: Key) {
525  const keys = [];
526  for (let m = PITCHES - 1; m >= 0; m--) {
527    const cls = [
528      "key",
529      BLACK_KEYS.includes(mod(m, 12)) ? "black" : "",
530      mod(m - pcOf(key.tonic), 12) === 0 ? "tonic" : "",
531    ];
532    keys.push(<div class={cls.join(" ")}>{mod(m, 12) === 0 ? `C${m / 12 - 1}` : ""}</div>);
533  }
534  const inner = (
535    <div
536      class="keys-inner"
537      dataset={{ pan: t.id }}
538      style={{ translate: offset(viewOf(t).high) }}
539    >
540      {keys}
541    </div>
542  ) as HTMLDivElement;
543  return (
544    <div
545      class="keys"
546      style={{ height: px(rollHeight(t)) }}
547      title="scroll or middle-drag to see other notes"
548      _onpointerdown={e => {
549        if (e.button !== 0) return;
550        const row = Math.floor((e.clientY - inner.getBoundingClientRect().top) / ROW_H);
551        audition([PITCHES - 1 - row], 0.5);
552      }}
553      _onwheel={scrollPitches(t)}
554    >
555      {inner}
556    </div>
557  );
558}
559
560function rollBackground(key: Key, ppb: number, bar: number) {
561  const tonic = pcOf(key.tonic);
562  const scale = MODES[key.mode].steps;
563  const stops = [];
564  for (let i = 0, m = PITCHES - 1; m >= 0; m--, i++) {
565    const rel = mod(m - tonic, 12);
566    const color =
567      rel === 0
568        ? "var(--row-tonic)"
569        : scale.includes(rel)
570          ? "var(--row-scale)"
571          : "var(--row-off)";
572    stops.push(`${color} ${i * ROW_H}px ${(i + 1) * ROW_H}px`);
573  }
574  return [
575    gridLines(ppb, bar),
576    `repeating-linear-gradient(to bottom, transparent 0 ${ROW_H - 1}px, var(--line-row) ${ROW_H - 1}px ${ROW_H}px)`,
577    `linear-gradient(to bottom, ${stops.join(",")})`,
578  ].join(",");
579}
580
581function lane(t: Track, p: Project, ppb: number) {
582  const key = projectKey(p);
583  // every pitch, of which the roll shows the track's range
584  const inner = (
585    <div
586      class="roll-inner"
587      dataset={{ pan: t.id }}
588      style={{
589        height: px(PITCHES * ROW_H),
590        translate: offset(viewOf(t).high),
591        backgroundImage: rollBackground(key, ppb, p.beatsPerBar),
592      }}
593    />
594  ) as HTMLDivElement;
595  const roll = (
596    <div
597      class="roll"
598      style={{ height: px(rollHeight(t)) }}
599      _oncontextmenu={e => e.preventDefault()}
600      _onwheel={scrollPitches(t)}
601    >
602      {inner}
603    </div>
604  ) as HTMLDivElement;
605  const locate = (e: PointerEvent) => {
606    const r = inner.getBoundingClientRect();
607    return {
608      beat: (e.clientX - r.left) / ppb,
609      pitch: PITCHES - 1 - Math.floor((e.clientY - r.top) / ROW_H),
610    };
611  };
612  const noteBox = (start: number, dur: number, pitch: number) => ({
613    left: px(start * ppb),
614    width: px(Math.max(3, dur * ppb - 1)),
615    top: px((PITCHES - 1 - pitch) * ROW_H),
616    height: px(ROW_H - 1),
617  });
618  const label = (pitch: number) => formatNote(spellPc(mod(pitch, 12), key.tonic));
619  const inScale = (pitch: number) =>
620    MODES[key.mode].steps.includes(mod(pitch - pcOf(key.tonic), 12));
621
622  if (t.kind === "notes") {
623    const selected = selectedIds(t.id);
624    inner.append(
625      ...t.notes.map(n => (
626        <div
627          class={`note${selected.includes(n.id) ? " selected" : ""}`}
628          style={noteBox(n.start, n.dur, n.pitch)}
629          dataset={{ id: n.id }}
630        >
631          {label(n.pitch)}
632        </div>
633      )),
634    );
635    roll.addEventListener("pointerdown", e => notePointer(e, t, p, ppb, locate(e)));
636    return (
637      <div class="lane notes" dataset={{ lane: t.id }}>
638        {roll}
639        {resizeHandle(t)}
640      </div>
641    );
642  }
643
644  // every note track, drawn under the chords to show how they sit against them
645  const noteTracks = p.tracks.filter((n): n is NoteTrack => n.kind === "notes");
646  inner.append(
647    ...noteTracks.flatMap(n =>
648      n.notes.map(note => (
649        <div class="note ghost" style={noteBox(note.start, note.dur, note.pitch)} />
650      )),
651    ),
652  );
653
654  const voiced = voiceTrack(t, key);
655  const current = activeTrackId.get() === t.id ? cursor.get() : undefined;
656  voiced.forEach((v, i) => {
657    const { start, dur } = v.event;
658    const heard = sounding(t, v.notes);
659    inner.append(
660      <div
661        class={`chord-col${i % 2 ? " alt" : ""}${start === current ? " current" : ""}`}
662        style={{ left: px(start * ppb), width: px(dur * ppb) }}
663      />,
664      ...v.notes.map(n => (
665        <div
666          class={`note${v.pinned ? "" : " auto"}${inScale(n) ? "" : " outside"}${heard.includes(n) ? "" : " silent"}`}
667          style={noteBox(start, dur, n)}
668        >
669          {label(n)}
670        </div>
671      )),
672    );
673  });
674  roll.addEventListener("pointerdown", e => {
675    if (e.button !== 0) return;
676    activeTrackId.set(t.id);
677    const { beat, pitch } = locate(e);
678    const v = voiced.find(v => v.event.start <= beat && beat < v.event.start + v.event.dur);
679    if (!v) return createChordWithNote(t.id, beat, pitch);
680    cursor.set(v.event.start);
681    toggleChordNote(t.id, v.event.id, pitch);
682  });
683
684  return (
685    <div class="lane chords" dataset={{ lane: t.id }}>
686      {chordStrip(t, voiced, ppb, p.grid, current)}
687      {roll}
688      {resizeHandle(t)}
689    </div>
690  );
691}
692
693function chordStrip(
694  t: ChordTrack,
695  voiced: VoicedChord[],
696  ppb: number,
697  grid: number,
698  current?: number,
699) {
700  const selected = selectedIds(t.id);
701  const blocks = voiced.map(v => {
702    const a = v.analysis;
703    // coloured by where it comes from, and tagged with it unless it's a secondary dominant
704    const from = a && origin(a);
705    const isVertex = from === "sharp" || from === "flat" || from === "inverse";
706    const tag =
707      a?.secondary ??
708      (isVertex ? `${from} vertex` : from && from !== "home" ? MODES[from].label : "");
709    const cls = [
710      "chord",
711      v.event.start === current ? "current" : "",
712      selected.includes(v.event.id) ? "selected" : "",
713      v.pinned ? "pinned" : "",
714      originClass(a),
715      v.chord ? "" : "unknown",
716    ];
717    return (
718      <div
719        class={cls.filter(Boolean).join(" ")}
720        style={{ left: px(v.event.start * ppb), width: px(v.event.dur * ppb) }}
721        dataset={{ id: v.event.id }}
722        title={[
723          v.pinned ? "hand-voiced (p to unpin)" : "voiced from its neighbours",
724          ...(v.alternatives.length
725            ? [`could also be read as ${v.alternatives.join(", ")}`]
726            : []),
727        ].join("\n")}
728      >
729        <div class="chord-name">{v.label}</div>
730        <div class="chord-roman">
731          {a ? prettyRoman(a.roman) : ""}
732          {tag ? <span class="chord-tag">{tag}</span> : undefined}
733        </div>
734        <div class="chord-handle" />
735      </div>
736    );
737  });
738
739  const strip = (
740    <div class="strip" style={{ height: px(STRIP_H) }}>
741      {blocks}
742    </div>
743  ) as HTMLDivElement;
744  const beatAt = (e: MouseEvent) => (e.clientX - strip.getBoundingClientRect().left) / ppb;
745  const snap = (pixels: number) => Math.round(pixels / ppb / grid) * grid;
746  const hit = (e: Event) => {
747    const el = (e.target as HTMLElement).closest<HTMLElement>(".chord");
748    return el ? voiced.find(v => v.event.id === el.dataset.id) : undefined;
749  };
750
751  strip.addEventListener("pointerdown", e => {
752    if (e.button !== 0) return;
753    activeTrackId.set(t.id);
754    const v = hit(e);
755    if (!v) {
756      selection.set(null);
757      return cursor.set(Math.max(0, Math.floor(beatAt(e) / grid) * grid));
758    }
759
760    const original = v.event;
761    const selected = selectedIds(t.id);
762    if (e.ctrlKey || e.metaKey) {
763      const ids = selected.includes(original.id)
764        ? selected.filter(id => id !== original.id)
765        : [...selected, original.id];
766      selection.set({ trackId: t.id, ids });
767      chordAnchor = original.id;
768      return cursor.set(original.start);
769    }
770    if (e.shiftKey) {
771      // from the chord last clicked (or at the cursor) to this one
772      const anchor =
773        t.chords.find(c => c.id === chordAnchor) ??
774        t.chords.find(c => c.start === cursor.get()) ??
775        original;
776      const [from, to] = [anchor.start, original.start].sort((a, b) => a - b);
777      const ids = t.chords.filter(c => c.start >= from && c.start <= to).map(c => c.id);
778      selection.set({ trackId: t.id, ids });
779      return cursor.set(original.start);
780    }
781    selection.set({ trackId: t.id, ids: [original.id] });
782    chordAnchor = original.id;
783    cursor.set(original.start);
784    const gesture = beginGesture();
785    const update = (chord: typeof original) =>
786      gesture.update(d => placeChord(findTrack(d, t.id, "chords")!, chord));
787
788    if ((e.target as HTMLElement).classList.contains("chord-handle")) {
789      drag(e, m => {
790        update({
791          ...original,
792          dur: Math.max(grid, original.dur + snap(m.clientX - e.clientX)),
793        });
794      });
795      return;
796    }
797
798    audition(v.notes, 0.9);
799    drag(e, m => {
800      const start = Math.max(0, original.start + snap(m.clientX - e.clientX));
801      if (start === cursor.get()) return;
802      update({ ...original, start });
803      cursor.set(start);
804    });
805  });
806
807  strip.addEventListener("dblclick", e => {
808    const v = hit(e);
809    const initial = v ? (v.event.symbol ?? (v.chord ? chordSymbol(v.chord) : "")) : "";
810    chordEdit.set({ trackId: t.id, initial, selectAll: true });
811  });
812
813  return strip;
814}
815
816function notePointer(
817  e: PointerEvent,
818  t: NoteTrack,
819  p: Project,
820  ppb: number,
821  at: { beat: number; pitch: number },
822) {
823  if (e.button !== 0 && e.button !== 2) return;
824  activeTrackId.set(t.id);
825  const grid = p.grid;
826  const snap = (beats: number) => Math.round(beats / grid) * grid;
827  const el = (e.target as HTMLElement).closest<HTMLElement>(".note");
828  const hit = el ? t.notes.find(n => n.id === el.dataset.id) : undefined;
829  const edit = (change: (track: NoteTrack) => void) => (d: Project) =>
830    change(findTrack(d, t.id, "notes")!);
831
832  if (e.button === 2 || e.altKey) {
833    if (hit)
834      commit(edit(track => void (track.notes = track.notes.filter(n => n.id !== hit.id))));
835    return;
836  }
837
838  const ctrl = e.ctrlKey || e.metaKey;
839  // shift-drag selects with a box; with ctrl too, adding to what's selected
840  if (!hit && e.shiftKey) return selectBox(e, t, ppb, ctrl);
841
842  if (!hit) {
843    // a note goes in the grid cell clicked
844    const start = Math.floor(at.beat / grid) * grid;
845    const gesture = beginGesture();
846    const note = {
847      id: newId(),
848      start,
849      dur: lastNoteLength,
850      pitch: at.pitch,
851      vel: NOTE_VEL,
852    };
853    selection.set({ trackId: t.id, ids: [note.id] });
854    audition([note.pitch], 0.4);
855    gesture.update(edit(track => void track.notes.push(note)));
856    drag(e, m => {
857      const dur = Math.max(grid, note.dur + snap((m.clientX - e.clientX) / ppb));
858      gesture.update(edit(track => void track.notes.push({ ...note, dur })));
859      lastNoteLength = dur;
860    });
861    return;
862  }
863
864  const gesture = beginGesture();
865  let ids = selectedIds(t.id);
866  if (e.shiftKey) {
867    ids = ids.includes(hit.id) ? ids.filter(id => id !== hit.id) : [...ids, hit.id];
868    selection.set({ trackId: t.id, ids });
869    if (!ids.includes(hit.id)) return;
870  } else if (!ids.includes(hit.id)) {
871    ids = [hit.id];
872    selection.set({ trackId: t.id, ids });
873  }
874
875  const resizing = e.clientX > el!.getBoundingClientRect().right - 6;
876  const originals = t.notes.filter(n => ids.includes(n.id));
877  let heard = 0;
878  if (!resizing) audition([hit.pitch], 0.4);
879
880  drag(e, m => {
881    const beats = snap((m.clientX - e.clientX) / ppb);
882    const pitch = resizing ? 0 : -Math.round((m.clientY - e.clientY) / ROW_H);
883    gesture.update(
884      edit(track => {
885        for (const o of originals) {
886          const n = track.notes.find(n => n.id === o.id)!;
887          if (resizing) n.dur = Math.max(grid, o.dur + beats);
888          else {
889            n.start = Math.max(0, o.start + beats);
890            n.pitch = Math.min(127, Math.max(0, o.pitch + pitch));
891          }
892        }
893      }),
894    );
895    if (resizing) lastNoteLength = Math.max(grid, hit.dur + beats);
896    if (pitch !== heard) audition([hit.pitch + (heard = pitch)], 0.3);
897  });
898}
899
900/** drag out a box and select the notes it touches; adding to the selection, or instead of it */
901function selectBox(e: PointerEvent, t: NoteTrack, ppb: number, adding: boolean) {
902  const roll = (e.target as HTMLElement)
903    .closest(".roll")!
904    .querySelector(".roll-inner")!
905    .getBoundingClientRect();
906  const before = adding ? selectedIds(t.id) : [];
907  // on the page rather than in the roll, which gets redrawn as the selection changes
908  const box = (<div class="select-box" />) as HTMLDivElement;
909  document.body.append(box);
910
911  const update = (m: PointerEvent) => {
912    const [x0, x1] = [Math.min(e.clientX, m.clientX), Math.max(e.clientX, m.clientX)];
913    const [y0, y1] = [Math.min(e.clientY, m.clientY), Math.max(e.clientY, m.clientY)];
914    Object.assign(box.style, {
915      left: px(x0),
916      top: px(y0),
917      width: px(x1 - x0),
918      height: px(y1 - y0),
919    });
920
921    const [from, to] = [(x0 - roll.left) / ppb, (x1 - roll.left) / ppb];
922    const pitchAt = (y: number) => PITCHES - 1 - Math.floor((y - roll.top) / ROW_H);
923    const [low, high] = [pitchAt(y1), pitchAt(y0)];
924    const inside = t.notes
925      .filter(n => n.start < to && n.start + n.dur > from && n.pitch >= low && n.pitch <= high)
926      .map(n => n.id);
927    selection.set({ trackId: t.id, ids: [...new Set([...before, ...inside])] });
928  };
929  update(e);
930  drag(e, update, () => box.remove());
931}