import { Signal } from "@char/aftercare"; import { audition, play, playing, stop } from "../audio/transport.ts"; import { fetchTheoryTab, theoryTabPath, theoryTabProject } from "../import/theorytab.ts"; import { sounding, voiceTrack } from "../project/harmony.ts"; import { type ChordTrack, type ChordClip, findTrack, fitView, insertLeadIn, type NoteClip, pasteChords, pasteNotes, newChordTrack, newId, newNoteTrack, canonBeat, placeChord, type Project, type Track, projectKey, } from "../project/model.ts"; import { commit, openNewProject, project } from "../project/store.ts"; import { type Chord, chordMatches, chordSymbol, isKeyRelative, parseChord, } from "../theory/chord.ts"; import { romanOf } from "../theory/analysis.ts"; import { type Key, stepInScale } from "../theory/key.ts"; import { voiceChord } from "../theory/voicing.ts"; import { projectMidi } from "../midi/export.ts"; import { parseMidi } from "../midi/smf.ts"; import { chordsOf } from "../midi/chords.ts"; import { activeTrackId, cursor, selectedIds, selection } from "./state.ts"; import { chooseSections } from "./theorytab-dialog.tsx"; import { choosePartKinds } from "./midi-dialog.tsx"; import { saveFile } from "./dom.tsx"; import { projectFromFile, projectToFile } from "../project/persist.ts"; export const togglePlay = (): void => (playing.get() ? stop() : play(cursor.get())); const chordTrack = (p: Project, id: string) => findTrack(p, id, "chords"); const voiced = (trackId: string, id: string) => { const p = project.get(); const track = chordTrack(p, trackId); return track && voiceTrack(track, projectKey(p)).find(v => v.event.id === id); }; /** hear some of a chord track's notes as the track would play them */ const auditionIn = (trackId: string, notes: readonly number[], dur: number, delay = 0) => { const track = chordTrack(project.get(), trackId); audition(track ? sounding(track, notes) : notes, dur, delay); }; const auditionChord = (trackId: string, id: string) => auditionIn(trackId, voiced(trackId, id)?.notes ?? [], 0.9); /** length of a new chord at `start`: the default, unless it'd run into the next chord */ function newChordLength(p: Project, track: ChordTrack, start: number) { const next = track.chords.find(c => c.start > start)?.start ?? Infinity; return Math.min(p.chordLength, next - start); } /** write a chord symbol at the cursor, replacing the chord there, then step past it */ export function writeChord(trackId: string, symbol: string, { quiet = false } = {}): void { const p = project.get(); const track = chordTrack(p, trackId); if (!track) return; const start = cursor.get(); const existing = track.chords.find(c => c.start === start); const dur = existing?.dur ?? newChordLength(p, track, start); const id = existing?.id ?? newId(); commit(d => placeChord(chordTrack(d, trackId)!, { id, start, dur, symbol })); cursor.set(start + dur); if (!quiet) auditionChord(trackId, id); } /** the chord track the palette writes to: the active one, or else the first */ export function paletteTrack(p: Project): ChordTrack | undefined { const active = p.tracks.find(t => t.id === activeTrackId.get()); return active?.kind === "chords" ? active : p.tracks.find(t => t.kind === "chords"); } /** * where to write a chord that follows on from the current one: just after the chord at the * cursor, if nothing's there yet; or else at the cursor, as the palette does */ export function nextChordAt(): number { const track = paletteTrack(project.get()); const here = track?.chords.find(c => c.start === cursor.get()); if (!here) return cursor.get(); const end = canonBeat(here.start + here.dur); return track!.chords.some(c => c.start === end) ? here.start : end; } /** hear a chord as it would sound written at a beat, led on from the chord before it */ export function previewChord(symbol: string, at = cursor.get()): void { const p = project.get(); const key = projectKey(p); const chord = parseChord(symbol, key); if (!chord) return; const track = paletteTrack(p); const before = track && voiceTrack(track, key).findLast(v => v.event.start < at); const notes = voiceChord(chord, before?.notes); audition(track ? sounding(track, notes) : notes, 0.9); } export function writeChordFromPalette(symbol: string, at = cursor.get()): void { cursor.set(at); let track = paletteTrack(project.get()); if (!track) { track = newChordTrack(); const added = track; commit(d => void d.tracks.unshift(added)); } activeTrackId.set(track.id); // quiet: double-clicking to get here already previewed it writeChord(track.id, symbol, { quiet: true }); } export function createChordWithNote(trackId: string, beat: number, pitch: number): void { const p = project.get(); const track = chordTrack(p, trackId); if (!track) return; const start = Math.floor(beat / p.grid) * p.grid; const chord = { id: newId(), start, dur: newChordLength(p, track, start), notes: [pitch] }; commit(d => placeChord(chordTrack(d, trackId)!, chord)); cursor.set(start); audition([pitch]); } /** hand-voice a chord; its symbol is kept only while it still describes the notes */ function setChordNotes(track: ChordTrack, id: string, notes: number[], key: Key) { const c = track.chords.find(c => c.id === id); if (!c) return; if (!notes.length) { track.chords = track.chords.filter(x => x !== c); return; } c.notes = [...new Set(notes)].sort((a, b) => a - b); const typed = c.symbol !== undefined ? parseChord(c.symbol, key) : undefined; if (!(typed && chordMatches(typed, c.notes))) delete c.symbol; } export function revoice( trackId: string, id: string, change: (notes: number[]) => number[], ): void { const v = voiced(trackId, id); if (!v) return; const notes = change(v.notes).filter(n => n >= 0 && n < 128); const key = projectKey(project.get()); commit(d => setChordNotes(chordTrack(d, trackId)!, id, notes, key)); auditionIn(trackId, notes, 0.9); } export const toggleChordNote = (trackId: string, id: string, pitch: number): void => revoice(trackId, id, notes => notes.includes(pitch) ? notes.filter(n => n !== pitch) : [...notes, pitch], ); /** throw away a chord's hand-set notes, so it's voiced from its neighbours again */ export function unpin(trackId: string, id: string): void { const v = voiced(trackId, id); if (!v?.pinned) return; const symbol = v.event.symbol ?? (v.chord && chordSymbol(v.chord)); if (symbol === undefined) return; commit(d => { const c = chordTrack(d, trackId)!.chords.find(c => c.id === id)!; c.symbol = symbol; delete c.notes; }); auditionChord(trackId, id); } export function resizeChord(trackId: string, id: string, delta: number): void { commit(d => { const track = chordTrack(d, trackId)!; const c = track.chords.find(c => c.id === id); if (c) placeChord(track, { ...c, dur: Math.max(d.grid, c.dur + delta) }); }); } /** step the cursor to the neighbouring chord boundary */ export function moveChordCursor(track: ChordTrack, direction: 1 | -1): void { const at = cursor.get(); const step = project.get().chordLength; const bounds = [ ...new Set([0, ...track.chords.flatMap(c => [c.start, canonBeat(c.start + c.dur)])]), ].sort((a, b) => a - b); cursor.set( direction > 0 ? (bounds.find(b => b > at) ?? at + step) : (bounds.findLast(b => b < at) ?? Math.max(0, at - step)), ); const here = track.chords.find(c => c.start === cursor.get()); selection.set(here ? { trackId: track.id, ids: [here.id] } : null); } export function deleteNotes(trackId: string, ids: readonly string[]): void { commit(d => { const track = findTrack(d, trackId, "notes")!; track.notes = track.notes.filter(n => !ids.includes(n.id)); }); selection.set(null); } export function nudgeNotes( trackId: string, ids: readonly string[], by: { beats?: number; pitch?: number; degrees?: number; dur?: number }, ): void { const p = project.get(); const key = projectKey(p); const pitched = (pitch: number) => (by.degrees ? stepInScale(pitch, key, by.degrees) : pitch) + (by.pitch ?? 0); commit(d => { for (const n of findTrack(d, trackId, "notes")!.notes) { if (!ids.includes(n.id)) continue; n.start = Math.max(0, n.start + (by.beats ?? 0)); n.pitch = Math.min(127, Math.max(0, pitched(n.pitch))); n.dur = Math.max(p.grid, n.dur + (by.dur ?? 0)); } }); if (by.pitch || by.degrees) { const track = findTrack(project.get(), trackId, "notes")!; audition( track.notes.filter(n => ids.includes(n.id)).map(n => n.pitch), 0.4, ); } } export function addTrack(kind: "chords" | "notes"): void { const track = kind === "chords" ? newChordTrack() : newNoteTrack(); commit(d => void d.tracks.push(track)); activeTrackId.set(track.id); } export function removeTrack(id: string): void { commit(d => void (d.tracks = d.tracks.filter(t => t.id !== id))); if (activeTrackId.get() === id) activeTrackId.set(project.get().tracks[0]?.id ?? null); } /** add every part of a midi file as a track of chords or of melody, starting from beat 0 */ export async function importMidi(file: File): Promise { let parts; try { parts = parseMidi(new Uint8Array(await file.arrayBuffer())); } catch (e) { alert(`couldn't import ${file.name}: ${(e as Error).message}`); return; } if (!parts.length) { alert(`${file.name} has no (non-drum) notes in it`); return; } const kinds = await choosePartKinds(file.name, parts); if (!kinds) return; const base = file.name.replace(/\.midi?$/i, ""); const tracks = parts.map((part, i): Track => { const name = parts.length > 1 ? `${base}: ${part.name}` : base; const view = fitView(part.notes.map(n => n.pitch)); if (kinds[i] === "chords") { const chords = chordsOf(part.notes).map(c => ({ id: newId(), ...c })); return { ...newChordTrack(name), view, chords }; } return { ...newNoteTrack(name), view, notes: part.notes.map(n => ({ id: newId(), ...n })) }; }); commit(d => void d.tracks.push(...tracks)); activeTrackId.set(tracks[0].id); // the header, not the lane: it's pinned to the left, so this won't scroll sideways requestAnimationFrame(() => document .querySelector(`.track-head[data-track="${tracks[0].id}"]`) ?.scrollIntoView({ block: "nearest" }), ); } export const importingTheoryTab = new Signal(false); /** start a new project from a TheoryTab: every section's chords and melody */ export async function importTheoryTab(url: string): Promise { const path = theoryTabPath(url); if (!path) { alert("that doesn't look like a TheoryTab link (hooktheory.com/theorytab/view/…)"); return; } if (importingTheoryTab.get()) return; importingTheoryTab.set(true); let tab; try { tab = await fetchTheoryTab(path); } catch (e) { alert(`couldn't import that TheoryTab: ${(e as Error).message}`); return; } finally { importingTheoryTab.set(false); } const sections = tab.sections.length > 1 ? await chooseSections(tab) : tab.sections; if (!sections) return; const part = sections.length === 1 && tab.sections.length > 1 ? ` (${sections[0].name})` : ""; try { openNewProject(theoryTabProject({ title: tab.title + part, sections })); } catch (e) { alert(`couldn't import that TheoryTab: ${(e as Error).message}`); } } /** a new chord written the way `like` was: roman if it was, so it keeps following the key */ const writtenLike = (like: string | undefined, chord: Chord, key: Key) => like !== undefined && isKeyRelative(like) ? romanOf(chord, key) : chordSymbol(chord); /** replace a chord with a variation of it, auto-voiced */ export function transformChord( trackId: string, id: string, change: (chord: Chord, key: Key) => Chord | undefined, ): void { const v = voiced(trackId, id); const key = projectKey(project.get()); const next = v?.chord && change(v.chord, key); if (!next) return; const symbol = writtenLike(v.event.symbol, next, key); commit(d => { const c = chordTrack(d, trackId)!.chords.find(c => c.id === id)!; c.symbol = symbol; delete c.notes; }); auditionChord(trackId, id); } /** put chords before this one that lead into it, then play them into it */ export function leadInto(trackId: string, id: string, chords: Chord[]): void { const v = voiced(trackId, id); const p = project.get(); const key = projectKey(p); if (!v) return; const symbols = chords.map(c => writtenLike(v.event.symbol, c, key)); if (!insertLeadIn(structuredClone(chordTrack(p, trackId)!), id, symbols, p.grid)) return; commit(d => void insertLeadIn(chordTrack(d, trackId)!, id, symbols, p.grid)); const after = voiceTrack(chordTrack(project.get(), trackId)!, key); const target = after.findIndex(c => c.event.id === id); const sequence = after.slice(target - chords.length, target + 1); sequence.forEach((c, i) => auditionIn(trackId, c.notes, 0.5, i * 0.45)); cursor.set(after[target].event.start); } /** download the project as a midi file */ export function exportMidi(): void { const p = project.get(); saveFile(p.name, ".mid", projectMidi(p), "audio/midi"); } export function exportProject(): void { const p = project.get(); saveFile(p.name, ".chordpad", projectToFile(p), "application/cbor"); } export async function importProject(file: File): Promise { try { openNewProject(projectFromFile(new Uint8Array(await file.arrayBuffer()))); } catch (e) { alert(`couldn't open ${file.name}: ${(e as Error).message}`); } } /** the chords an edit applies to: those selected in the track, or else the one at the cursor */ export function chordsToEdit(track: ChordTrack): string[] { const selected = selectedIds(track.id).filter(id => track.chords.some(c => c.id === id)); if (selected.length) return selected; const here = track.chords.find(c => c.start === cursor.get()); return here ? [here.id] : []; } export function deleteChords(trackId: string, ids: readonly string[]): void { if (!ids.length) return; commit(d => { const track = chordTrack(d, trackId)!; track.chords = track.chords.filter(c => !ids.includes(c.id)); }); selection.set(null); } /** put chords in at the cursor, over whatever's there, and select them */ export function pasteClip(trackId: string, clip: ChordClip): void { const at = cursor.get(); let ids: string[] = []; commit(d => void (ids = pasteChords(chordTrack(d, trackId)!, clip, at))); selection.set({ trackId, ids }); cursor.set(at + clip.span); } /** add notes at the cursor and select them, moving the cursor past them to paste again after */ export function pasteNoteClip(trackId: string, clip: NoteClip): void { const at = cursor.get(); let ids: string[] = []; commit(d => void (ids = pasteNotes(findTrack(d, trackId, "notes")!, clip, at))); selection.set({ trackId, ids }); cursor.set(at + clip.span); }