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