import { fitView, newChordTrack, NOTE_VEL, newId, newNoteTrack, newProject, pickableTonic, placeChord, type Project, } from "../project/model.ts"; import type { ModeId } from "../theory/key.ts"; import { type HookpadSong, readSection } from "./hookpad.ts"; const API = "https://api.hooktheory.com/v1/songs/public"; // TheoryTab pages don't allow cross-origin reads, so the dev server fetches them for us (the // song api does allow them) const PAGE_PROXY = "/hooktheory"; /** the page path of a TheoryTab url, e.g. /theorytab/view/the-beatles/let-it-be */ export function theoryTabPath(input: string): string | undefined { let url: URL; try { url = new URL(input.trim()); } catch { return undefined; } if (!/(^|\.)hooktheory\.com$/.test(url.hostname)) return undefined; const parts = url.pathname.split("/").filter(Boolean); if (parts[0] !== "theorytab" || parts[1] !== "view" || parts.length < 4) return undefined; return "/" + parts.slice(0, 4).join("/"); } export type TheoryTab = { title: string; sections: { name: string; song: HookpadSong }[] }; /** every section of a song's TheoryTab, in page order */ export async function fetchTheoryTab(path: string): Promise { const page = await fetch(PAGE_PROXY + path); if (!page.ok) throw new Error(`hooktheory answered ${page.status} for ${path}`); const doc = new DOMParser().parseFromString(await page.text(), "text/html"); // the placeholders hooktheory's own player mounts into, one per section const placeholders = [...doc.querySelectorAll('[data-hash][id^="tab-"]')]; if (!placeholders.length) throw new Error("couldn't find any sections on that page"); const ogTitle = doc.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? ""; const title = ogTitle.replace(/ Chords, Melody.*$/, "").replace(/ by /, " – ") || path; const sections = await Promise.all( placeholders.map(async el => { const response = await fetch(`${API}/${el.dataset.hash}?fields=ID,song,jsonData`); if (!response.ok) throw new Error(`hooktheory answered ${response.status} for a section`); const { jsonData } = await response.json(); const name = (el.dataset.sectionUrl ?? "").replaceAll("-", " "); return { name, song: JSON.parse(jsonData) as HookpadSong }; }), ); return { title, sections }; } const MODES: Record = { major: "ionian", minor: "aeolian", dorian: "dorian", phrygian: "phrygian", lydian: "lydian", mixolydian: "mixolydian", locrian: "locrian", harmonicMinor: "harmonicMinor", phrygianDominant: "phrygian", }; /** a project with the sections one after another, each starting on a new bar */ export function theoryTabProject(tab: TheoryTab): Project { const sections = tab.sections .map(s => readSection(s.song)) .filter(s => s.chords.length || s.notes.length); if (!sections.length) throw new Error("there's no chords or melody in it"); const [first] = sections; const project = newProject(); const chords = newChordTrack(); const melody = newNoteTrack(); Object.assign(project, { name: tab.title, bpm: Math.round(first.bpm), beatsPerBar: first.beatsPerBar, key: { tonic: pickableTonic(first.key.tonic), mode: MODES[first.key.scale] ?? "ionian" }, tracks: [chords, melody], }); let offset = 0; for (const section of sections) { for (const c of section.chords) placeChord(chords, { ...c, id: newId(), start: offset + c.start }); for (const n of section.notes) { melody.notes.push({ ...n, id: newId(), start: offset + n.start, vel: NOTE_VEL }); } offset += Math.ceil(section.length / first.beatsPerBar) * first.beatsPerBar; } if (melody.notes.length) melody.view = fitView(melody.notes.map(n => n.pitch)); return project; }