char-slop/chordpad.vibed.software

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

Charlotte Somcbor project file formata5e999d

main
3.7 KiB103 linesraw
1import {
2  fitView,
3  newChordTrack,
4  NOTE_VEL,
5  newId,
6  newNoteTrack,
7  newProject,
8  pickableTonic,
9  placeChord,
10  type Project,
11} from "../project/model.ts";
12import type { ModeId } from "../theory/key.ts";
13import { type HookpadSong, readSection } from "./hookpad.ts";
14
15const API = "https://api.hooktheory.com/v1/songs/public";
16// TheoryTab pages don't allow cross-origin reads, so the dev server fetches them for us (the
17// song api does allow them)
18const PAGE_PROXY = "/hooktheory";
19
20/** the page path of a TheoryTab url, e.g. /theorytab/view/the-beatles/let-it-be */
21export function theoryTabPath(input: string): string | undefined {
22  let url: URL;
23  try {
24    url = new URL(input.trim());
25  } catch {
26    return undefined;
27  }
28  if (!/(^|\.)hooktheory\.com$/.test(url.hostname)) return undefined;
29  const parts = url.pathname.split("/").filter(Boolean);
30  if (parts[0] !== "theorytab" || parts[1] !== "view" || parts.length < 4) return undefined;
31  return "/" + parts.slice(0, 4).join("/");
32}
33
34export type TheoryTab = { title: string; sections: { name: string; song: HookpadSong }[] };
35
36/** every section of a song's TheoryTab, in page order */
37export async function fetchTheoryTab(path: string): Promise<TheoryTab> {
38  const page = await fetch(PAGE_PROXY + path);
39  if (!page.ok) throw new Error(`hooktheory answered ${page.status} for ${path}`);
40  const doc = new DOMParser().parseFromString(await page.text(), "text/html");
41
42  // the placeholders hooktheory's own player mounts into, one per section
43  const placeholders = [...doc.querySelectorAll<HTMLElement>('[data-hash][id^="tab-"]')];
44  if (!placeholders.length) throw new Error("couldn't find any sections on that page");
45
46  const ogTitle = doc.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? "";
47  const title = ogTitle.replace(/ Chords, Melody.*$/, "").replace(/ by /, " – ") || path;
48
49  const sections = await Promise.all(
50    placeholders.map(async el => {
51      const response = await fetch(`${API}/${el.dataset.hash}?fields=ID,song,jsonData`);
52      if (!response.ok) throw new Error(`hooktheory answered ${response.status} for a section`);
53      const { jsonData } = await response.json();
54      const name = (el.dataset.sectionUrl ?? "").replaceAll("-", " ");
55      return { name, song: JSON.parse(jsonData) as HookpadSong };
56    }),
57  );
58  return { title, sections };
59}
60
61const MODES: Record<string, ModeId> = {
62  major: "ionian",
63  minor: "aeolian",
64  dorian: "dorian",
65  phrygian: "phrygian",
66  lydian: "lydian",
67  mixolydian: "mixolydian",
68  locrian: "locrian",
69  harmonicMinor: "harmonicMinor",
70  phrygianDominant: "phrygian",
71};
72
73/** a project with the sections one after another, each starting on a new bar */
74export function theoryTabProject(tab: TheoryTab): Project {
75  const sections = tab.sections
76    .map(s => readSection(s.song))
77    .filter(s => s.chords.length || s.notes.length);
78  if (!sections.length) throw new Error("there's no chords or melody in it");
79
80  const [first] = sections;
81  const project = newProject();
82  const chords = newChordTrack();
83  const melody = newNoteTrack();
84  Object.assign(project, {
85    name: tab.title,
86    bpm: Math.round(first.bpm),
87    beatsPerBar: first.beatsPerBar,
88    key: { tonic: pickableTonic(first.key.tonic), mode: MODES[first.key.scale] ?? "ionian" },
89    tracks: [chords, melody],
90  });
91
92  let offset = 0;
93  for (const section of sections) {
94    for (const c of section.chords)
95      placeChord(chords, { ...c, id: newId(), start: offset + c.start });
96    for (const n of section.notes) {
97      melody.notes.push({ ...n, id: newId(), start: offset + n.start, vel: NOTE_VEL });
98    }
99    offset += Math.ceil(section.length / first.beatsPerBar) * first.beatsPerBar;
100  }
101  if (melody.notes.length) melody.view = fitView(melody.notes.map(n => n.pitch));
102  return project;
103}