@mcptoolshop/ai-jam-sessions 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.es.md +177 -0
- package/README.fr.md +178 -0
- package/README.hi.md +175 -0
- package/README.it.md +177 -0
- package/README.ja.md +174 -0
- package/README.md +174 -0
- package/README.pt-BR.md +177 -0
- package/README.zh.md +173 -0
- package/dist/audio-engine.d.ts +12 -0
- package/dist/audio-engine.d.ts.map +1 -0
- package/dist/audio-engine.js +422 -0
- package/dist/audio-engine.js.map +1 -0
- package/dist/chord-detect.d.ts +14 -0
- package/dist/chord-detect.d.ts.map +1 -0
- package/dist/chord-detect.js +90 -0
- package/dist/chord-detect.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +805 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +41 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +149 -0
- package/dist/journal.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +1256 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/midi/parser.d.ts +16 -0
- package/dist/midi/parser.d.ts.map +1 -0
- package/dist/midi/parser.js +192 -0
- package/dist/midi/parser.js.map +1 -0
- package/dist/midi/types.d.ts +44 -0
- package/dist/midi/types.d.ts.map +1 -0
- package/dist/midi/types.js +8 -0
- package/dist/midi/types.js.map +1 -0
- package/dist/note-parser.d.ts +105 -0
- package/dist/note-parser.d.ts.map +1 -0
- package/dist/note-parser.js +319 -0
- package/dist/note-parser.js.map +1 -0
- package/dist/piano-roll.d.ts +28 -0
- package/dist/piano-roll.d.ts.map +1 -0
- package/dist/piano-roll.js +321 -0
- package/dist/piano-roll.js.map +1 -0
- package/dist/piano-voices.d.ts +90 -0
- package/dist/piano-voices.d.ts.map +1 -0
- package/dist/piano-voices.js +416 -0
- package/dist/piano-voices.js.map +1 -0
- package/dist/playback/controls.d.ts +124 -0
- package/dist/playback/controls.d.ts.map +1 -0
- package/dist/playback/controls.js +252 -0
- package/dist/playback/controls.js.map +1 -0
- package/dist/playback/midi-engine.d.ts +68 -0
- package/dist/playback/midi-engine.d.ts.map +1 -0
- package/dist/playback/midi-engine.js +227 -0
- package/dist/playback/midi-engine.js.map +1 -0
- package/dist/playback/position.d.ts +95 -0
- package/dist/playback/position.d.ts.map +1 -0
- package/dist/playback/position.js +223 -0
- package/dist/playback/position.js.map +1 -0
- package/dist/playback/timing.d.ts +31 -0
- package/dist/playback/timing.d.ts.map +1 -0
- package/dist/playback/timing.js +57 -0
- package/dist/playback/timing.js.map +1 -0
- package/dist/sample-engine.d.ts +17 -0
- package/dist/sample-engine.d.ts.map +1 -0
- package/dist/sample-engine.js +428 -0
- package/dist/sample-engine.js.map +1 -0
- package/dist/schemas.d.ts +40 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +42 -0
- package/dist/schemas.js.map +1 -0
- package/dist/session.d.ts +106 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +361 -0
- package/dist/session.js.map +1 -0
- package/dist/sfz-parser.d.ts +36 -0
- package/dist/sfz-parser.d.ts.map +1 -0
- package/dist/sfz-parser.js +95 -0
- package/dist/sfz-parser.js.map +1 -0
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +512 -0
- package/dist/smoke.js.map +1 -0
- package/dist/songs/config/loader.d.ts +14 -0
- package/dist/songs/config/loader.d.ts.map +1 -0
- package/dist/songs/config/loader.js +53 -0
- package/dist/songs/config/loader.js.map +1 -0
- package/dist/songs/config/schema.d.ts +79 -0
- package/dist/songs/config/schema.d.ts.map +1 -0
- package/dist/songs/config/schema.js +56 -0
- package/dist/songs/config/schema.js.map +1 -0
- package/dist/songs/index.d.ts +18 -0
- package/dist/songs/index.d.ts.map +1 -0
- package/dist/songs/index.js +22 -0
- package/dist/songs/index.js.map +1 -0
- package/dist/songs/jam.d.ts +48 -0
- package/dist/songs/jam.d.ts.map +1 -0
- package/dist/songs/jam.js +338 -0
- package/dist/songs/jam.js.map +1 -0
- package/dist/songs/library.d.ts +47 -0
- package/dist/songs/library.d.ts.map +1 -0
- package/dist/songs/library.js +156 -0
- package/dist/songs/library.js.map +1 -0
- package/dist/songs/loader.d.ts +27 -0
- package/dist/songs/loader.d.ts.map +1 -0
- package/dist/songs/loader.js +90 -0
- package/dist/songs/loader.js.map +1 -0
- package/dist/songs/midi/hands.d.ts +46 -0
- package/dist/songs/midi/hands.d.ts.map +1 -0
- package/dist/songs/midi/hands.js +134 -0
- package/dist/songs/midi/hands.js.map +1 -0
- package/dist/songs/midi/ingest.d.ts +8 -0
- package/dist/songs/midi/ingest.d.ts.map +1 -0
- package/dist/songs/midi/ingest.js +197 -0
- package/dist/songs/midi/ingest.js.map +1 -0
- package/dist/songs/midi/measures.d.ts +41 -0
- package/dist/songs/midi/measures.d.ts.map +1 -0
- package/dist/songs/midi/measures.js +64 -0
- package/dist/songs/midi/measures.js.map +1 -0
- package/dist/songs/midi/types.d.ts +25 -0
- package/dist/songs/midi/types.d.ts.map +1 -0
- package/dist/songs/midi/types.js +8 -0
- package/dist/songs/midi/types.js.map +1 -0
- package/dist/songs/registry.d.ts +37 -0
- package/dist/songs/registry.d.ts.map +1 -0
- package/dist/songs/registry.js +197 -0
- package/dist/songs/registry.js.map +1 -0
- package/dist/songs/types.d.ts +99 -0
- package/dist/songs/types.d.ts.map +1 -0
- package/dist/songs/types.js +29 -0
- package/dist/songs/types.js.map +1 -0
- package/dist/teaching/live-midi-feedback.d.ts +36 -0
- package/dist/teaching/live-midi-feedback.d.ts.map +1 -0
- package/dist/teaching/live-midi-feedback.js +259 -0
- package/dist/teaching/live-midi-feedback.js.map +1 -0
- package/dist/teaching/midi-feedback.d.ts +33 -0
- package/dist/teaching/midi-feedback.d.ts.map +1 -0
- package/dist/teaching/midi-feedback.js +208 -0
- package/dist/teaching/midi-feedback.js.map +1 -0
- package/dist/teaching/sing-on-midi.d.ts +77 -0
- package/dist/teaching/sing-on-midi.d.ts.map +1 -0
- package/dist/teaching/sing-on-midi.js +186 -0
- package/dist/teaching/sing-on-midi.js.map +1 -0
- package/dist/teaching.d.ts +148 -0
- package/dist/teaching.d.ts.map +1 -0
- package/dist/teaching.js +453 -0
- package/dist/teaching.js.map +1 -0
- package/dist/test-sound.d.ts +3 -0
- package/dist/test-sound.d.ts.map +1 -0
- package/dist/test-sound.js +41 -0
- package/dist/test-sound.js.map +1 -0
- package/dist/types.d.ts +229 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/dist/vmpk.d.ts +23 -0
- package/dist/vmpk.d.ts.map +1 -0
- package/dist/vmpk.js +236 -0
- package/dist/vmpk.js.map +1 -0
- package/docs/fur-elise-m1-8.svg +290 -0
- package/logo.png +0 -0
- package/package.json +71 -0
- package/songs/library/blues/blues-in-the-night.json +14 -0
- package/songs/library/blues/blues-in-the-night.mid +0 -0
- package/songs/library/blues/born-under-a-bad-sign.json +13 -0
- package/songs/library/blues/born-under-a-bad-sign.mid +0 -0
- package/songs/library/blues/crossroad-blues.json +14 -0
- package/songs/library/blues/crossroad-blues.mid +0 -0
- package/songs/library/blues/everyday-i-have-the-blues.json +13 -0
- package/songs/library/blues/everyday-i-have-the-blues.mid +0 -0
- package/songs/library/blues/hoochie-coochie-man.json +14 -0
- package/songs/library/blues/hoochie-coochie-man.mid +0 -0
- package/songs/library/blues/red-house.json +14 -0
- package/songs/library/blues/red-house.mid +0 -0
- package/songs/library/blues/st-louis-blues.json +14 -0
- package/songs/library/blues/st-louis-blues.mid +0 -0
- package/songs/library/blues/stormy-monday.json +13 -0
- package/songs/library/blues/stormy-monday.mid +0 -0
- package/songs/library/blues/sweet-home-chicago.json +14 -0
- package/songs/library/blues/sweet-home-chicago.mid +0 -0
- package/songs/library/blues/the-thrill-is-gone.json +17 -0
- package/songs/library/blues/the-thrill-is-gone.mid +0 -0
- package/songs/library/classical/bach-prelude-c-major-bwv846.json +37 -0
- package/songs/library/classical/bach-prelude-c-major-bwv846.mid +0 -0
- package/songs/library/classical/chopin-nocturne-op9-no2.json +36 -0
- package/songs/library/classical/chopin-nocturne-op9-no2.mid +0 -0
- package/songs/library/classical/chopin-prelude-e-minor.json +35 -0
- package/songs/library/classical/chopin-prelude-e-minor.mid +0 -0
- package/songs/library/classical/clair-de-lune.json +37 -0
- package/songs/library/classical/clair-de-lune.mid +0 -0
- package/songs/library/classical/debussy-arabesque-no1.json +35 -0
- package/songs/library/classical/debussy-arabesque-no1.mid +0 -0
- package/songs/library/classical/fur-elise.json +35 -0
- package/songs/library/classical/fur-elise.mid +0 -0
- package/songs/library/classical/mozart-k545-mvt1.json +35 -0
- package/songs/library/classical/mozart-k545-mvt1.mid +0 -0
- package/songs/library/classical/pathetique-mvt2.json +36 -0
- package/songs/library/classical/pathetique-mvt2.mid +0 -0
- package/songs/library/classical/satie-gymnopedie-no1.json +36 -0
- package/songs/library/classical/satie-gymnopedie-no1.mid +0 -0
- package/songs/library/classical/schumann-traumerei.json +36 -0
- package/songs/library/classical/schumann-traumerei.mid +0 -0
- package/songs/library/film/cinema-paradiso.json +14 -0
- package/songs/library/film/cinema-paradiso.mid +0 -0
- package/songs/library/film/comptine-dun-autre-ete.json +17 -0
- package/songs/library/film/comptine-dun-autre-ete.mid +0 -0
- package/songs/library/film/forrest-gump.json +14 -0
- package/songs/library/film/forrest-gump.mid +0 -0
- package/songs/library/film/hedwigs-theme.json +14 -0
- package/songs/library/film/hedwigs-theme.mid +0 -0
- package/songs/library/film/mia-and-sebastians-theme.json +14 -0
- package/songs/library/film/mia-and-sebastians-theme.mid +0 -0
- package/songs/library/film/moon-river.json +14 -0
- package/songs/library/film/moon-river.mid +0 -0
- package/songs/library/film/my-heart-will-go-on.json +14 -0
- package/songs/library/film/my-heart-will-go-on.mid +0 -0
- package/songs/library/film/nuvole-bianche.json +14 -0
- package/songs/library/film/nuvole-bianche.mid +0 -0
- package/songs/library/film/pink-panther.json +14 -0
- package/songs/library/film/pink-panther.mid +0 -0
- package/songs/library/film/schindlers-list-theme.json +14 -0
- package/songs/library/film/schindlers-list-theme.mid +0 -0
- package/songs/library/folk/amazing-grace.json +14 -0
- package/songs/library/folk/amazing-grace.mid +0 -0
- package/songs/library/folk/auld-lang-syne.json +14 -0
- package/songs/library/folk/auld-lang-syne.mid +0 -0
- package/songs/library/folk/danny-boy.json +14 -0
- package/songs/library/folk/danny-boy.mid +0 -0
- package/songs/library/folk/greensleeves.json +17 -0
- package/songs/library/folk/greensleeves.mid +0 -0
- package/songs/library/folk/house-of-the-rising-sun.json +14 -0
- package/songs/library/folk/house-of-the-rising-sun.mid +0 -0
- package/songs/library/folk/sakura-sakura.json +14 -0
- package/songs/library/folk/sakura-sakura.mid +0 -0
- package/songs/library/folk/scarborough-fair.json +14 -0
- package/songs/library/folk/scarborough-fair.mid +0 -0
- package/songs/library/folk/shenandoah.json +14 -0
- package/songs/library/folk/shenandoah.mid +0 -0
- package/songs/library/folk/simple-gifts.json +14 -0
- package/songs/library/folk/simple-gifts.mid +0 -0
- package/songs/library/folk/the-water-is-wide.json +14 -0
- package/songs/library/folk/the-water-is-wide.mid +0 -0
- package/songs/library/jazz/all-the-things-you-are.json +13 -0
- package/songs/library/jazz/all-the-things-you-are.mid +0 -0
- package/songs/library/jazz/autumn-leaves.json +17 -0
- package/songs/library/jazz/autumn-leaves.mid +0 -0
- package/songs/library/jazz/blue-bossa.json +14 -0
- package/songs/library/jazz/blue-bossa.mid +0 -0
- package/songs/library/jazz/fly-me-to-the-moon.json +14 -0
- package/songs/library/jazz/fly-me-to-the-moon.mid +0 -0
- package/songs/library/jazz/georgia-on-my-mind.json +14 -0
- package/songs/library/jazz/georgia-on-my-mind.mid +0 -0
- package/songs/library/jazz/misty.json +14 -0
- package/songs/library/jazz/misty.mid +0 -0
- package/songs/library/jazz/my-funny-valentine.json +14 -0
- package/songs/library/jazz/my-funny-valentine.mid +0 -0
- package/songs/library/jazz/round-midnight.json +14 -0
- package/songs/library/jazz/round-midnight.mid +0 -0
- package/songs/library/jazz/summertime.json +14 -0
- package/songs/library/jazz/summertime.mid +0 -0
- package/songs/library/jazz/take-the-a-train.json +14 -0
- package/songs/library/jazz/take-the-a-train.mid +0 -0
- package/songs/library/latin/agua-de-beber.json +14 -0
- package/songs/library/latin/agua-de-beber.mid +0 -0
- package/songs/library/latin/besame-mucho.json +14 -0
- package/songs/library/latin/besame-mucho.mid +0 -0
- package/songs/library/latin/black-orpheus.json +14 -0
- package/songs/library/latin/black-orpheus.mid +0 -0
- package/songs/library/latin/corcovado.json +14 -0
- package/songs/library/latin/corcovado.mid +0 -0
- package/songs/library/latin/desafinado.json +14 -0
- package/songs/library/latin/desafinado.mid +0 -0
- package/songs/library/latin/el-condor-pasa.json +14 -0
- package/songs/library/latin/el-condor-pasa.mid +0 -0
- package/songs/library/latin/girl-from-ipanema.json +17 -0
- package/songs/library/latin/girl-from-ipanema.mid +0 -0
- package/songs/library/latin/mas-que-nada.json +14 -0
- package/songs/library/latin/mas-que-nada.mid +0 -0
- package/songs/library/latin/perfidia.json +14 -0
- package/songs/library/latin/perfidia.mid +0 -0
- package/songs/library/latin/wave.json +14 -0
- package/songs/library/latin/wave.mid +0 -0
- package/songs/library/new-age/divenire.json +14 -0
- package/songs/library/new-age/divenire.mid +0 -0
- package/songs/library/new-age/experience.json +14 -0
- package/songs/library/new-age/experience.mid +0 -0
- package/songs/library/new-age/kiss-the-rain.json +14 -0
- package/songs/library/new-age/kiss-the-rain.mid +0 -0
- package/songs/library/new-age/may-be.json +13 -0
- package/songs/library/new-age/may-be.mid +0 -0
- package/songs/library/new-age/metamorphosis-two.json +14 -0
- package/songs/library/new-age/metamorphosis-two.mid +0 -0
- package/songs/library/new-age/nuvole-bianche-na.json +14 -0
- package/songs/library/new-age/nuvole-bianche-na.mid +0 -0
- package/songs/library/new-age/opening-glassworks.json +14 -0
- package/songs/library/new-age/opening-glassworks.mid +0 -0
- package/songs/library/new-age/river-flows-in-you.json +17 -0
- package/songs/library/new-age/river-flows-in-you.mid +0 -0
- package/songs/library/new-age/una-mattina.json +14 -0
- package/songs/library/new-age/una-mattina.mid +0 -0
- package/songs/library/new-age/watermark.json +14 -0
- package/songs/library/new-age/watermark.mid +0 -0
- package/songs/library/pop/a-thousand-years.json +14 -0
- package/songs/library/pop/a-thousand-years.mid +0 -0
- package/songs/library/pop/all-of-me.json +14 -0
- package/songs/library/pop/all-of-me.mid +0 -0
- package/songs/library/pop/bohemian-rhapsody.json +14 -0
- package/songs/library/pop/bohemian-rhapsody.mid +0 -0
- package/songs/library/pop/clocks.json +14 -0
- package/songs/library/pop/clocks.mid +0 -0
- package/songs/library/pop/imagine.json +17 -0
- package/songs/library/pop/imagine.mid +0 -0
- package/songs/library/pop/let-it-be.json +14 -0
- package/songs/library/pop/let-it-be.mid +0 -0
- package/songs/library/pop/piano-man.json +14 -0
- package/songs/library/pop/piano-man.mid +0 -0
- package/songs/library/pop/someone-like-you.json +14 -0
- package/songs/library/pop/someone-like-you.mid +0 -0
- package/songs/library/pop/someone-you-loved.json +14 -0
- package/songs/library/pop/someone-you-loved.mid +0 -0
- package/songs/library/pop/viva-la-vida.json +14 -0
- package/songs/library/pop/viva-la-vida.mid +0 -0
- package/songs/library/ragtime/bethena.json +14 -0
- package/songs/library/ragtime/bethena.mid +0 -0
- package/songs/library/ragtime/elite-syncopations.json +13 -0
- package/songs/library/ragtime/elite-syncopations.mid +0 -0
- package/songs/library/ragtime/gladiolus-rag.json +13 -0
- package/songs/library/ragtime/gladiolus-rag.mid +0 -0
- package/songs/library/ragtime/maple-leaf-rag.json +13 -0
- package/songs/library/ragtime/maple-leaf-rag.mid +0 -0
- package/songs/library/ragtime/peacherine-rag.json +13 -0
- package/songs/library/ragtime/peacherine-rag.mid +0 -0
- package/songs/library/ragtime/pineapple-rag.json +13 -0
- package/songs/library/ragtime/pineapple-rag.mid +0 -0
- package/songs/library/ragtime/solace.json +14 -0
- package/songs/library/ragtime/solace.mid +0 -0
- package/songs/library/ragtime/the-easy-winners.json +13 -0
- package/songs/library/ragtime/the-easy-winners.mid +0 -0
- package/songs/library/ragtime/the-entertainer.json +17 -0
- package/songs/library/ragtime/the-entertainer.mid +0 -0
- package/songs/library/ragtime/weeping-willow.json +14 -0
- package/songs/library/ragtime/weeping-willow.mid +0 -0
- package/songs/library/rnb/fallin.json +17 -0
- package/songs/library/rnb/fallin.mid +0 -0
- package/songs/library/rnb/halo.json +14 -0
- package/songs/library/rnb/halo.mid +0 -0
- package/songs/library/rnb/i-will-always-love-you.json +14 -0
- package/songs/library/rnb/i-will-always-love-you.mid +0 -0
- package/songs/library/rnb/if-i-aint-got-you.json +17 -0
- package/songs/library/rnb/if-i-aint-got-you.mid +0 -0
- package/songs/library/rnb/isnt-she-lovely.json +17 -0
- package/songs/library/rnb/isnt-she-lovely.mid +0 -0
- package/songs/library/rnb/killing-me-softly.json +14 -0
- package/songs/library/rnb/killing-me-softly.mid +0 -0
- package/songs/library/rnb/no-one.json +14 -0
- package/songs/library/rnb/no-one.mid +0 -0
- package/songs/library/rnb/ordinary-people.json +14 -0
- package/songs/library/rnb/ordinary-people.mid +0 -0
- package/songs/library/rnb/ribbon-in-the-sky.json +14 -0
- package/songs/library/rnb/ribbon-in-the-sky.mid +0 -0
- package/songs/library/rnb/superstition.json +17 -0
- package/songs/library/rnb/superstition.mid +0 -0
- package/songs/library/rock/baba-oriley.json +14 -0
- package/songs/library/rock/baba-oriley.mid +0 -0
- package/songs/library/rock/bennie-and-the-jets.json +14 -0
- package/songs/library/rock/bennie-and-the-jets.mid +0 -0
- package/songs/library/rock/dont-stop-believin.json +14 -0
- package/songs/library/rock/dont-stop-believin.mid +0 -0
- package/songs/library/rock/dream-on.json +14 -0
- package/songs/library/rock/dream-on.mid +0 -0
- package/songs/library/rock/layla-unplugged.json +14 -0
- package/songs/library/rock/layla-unplugged.mid +0 -0
- package/songs/library/rock/november-rain.json +15 -0
- package/songs/library/rock/november-rain.mid +0 -0
- package/songs/library/rock/rocket-man.json +14 -0
- package/songs/library/rock/rocket-man.mid +0 -0
- package/songs/library/rock/stairway-to-heaven.json +14 -0
- package/songs/library/rock/stairway-to-heaven.mid +0 -0
- package/songs/library/rock/tiny-dancer.json +14 -0
- package/songs/library/rock/tiny-dancer.mid +0 -0
- package/songs/library/rock/your-song.json +17 -0
- package/songs/library/rock/your-song.mid +0 -0
- package/songs/library/soul/a-change-is-gonna-come.json +14 -0
- package/songs/library/soul/a-change-is-gonna-come.mid +0 -0
- package/songs/library/soul/aint-no-sunshine.json +14 -0
- package/songs/library/soul/aint-no-sunshine.mid +0 -0
- package/songs/library/soul/dock-of-the-bay.json +13 -0
- package/songs/library/soul/dock-of-the-bay.mid +0 -0
- package/songs/library/soul/i-got-you.json +14 -0
- package/songs/library/soul/i-got-you.mid +0 -0
- package/songs/library/soul/lean-on-me.json +17 -0
- package/songs/library/soul/lean-on-me.mid +0 -0
- package/songs/library/soul/lets-stay-together.json +14 -0
- package/songs/library/soul/lets-stay-together.mid +0 -0
- package/songs/library/soul/my-girl.json +14 -0
- package/songs/library/soul/my-girl.mid +0 -0
- package/songs/library/soul/respect.json +14 -0
- package/songs/library/soul/respect.mid +0 -0
- package/songs/library/soul/stand-by-me.json +14 -0
- package/songs/library/soul/stand-by-me.mid +0 -0
- package/songs/library/soul/whats-going-on.json +14 -0
- package/songs/library/soul/whats-going-on.mid +0 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ─── pianoai: MCP Server ─────────────────────────────────────────────────────
|
|
3
|
+
//
|
|
4
|
+
// Exposes the ai-music-sheets registry and session engine as MCP tools.
|
|
5
|
+
// An LLM can browse songs, get teaching info, suggest practice setups,
|
|
6
|
+
// and push teaching interjections — all through the standard MCP protocol.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// node dist/mcp-server.js # stdio transport
|
|
10
|
+
//
|
|
11
|
+
// Tools:
|
|
12
|
+
// list_songs — browse/search the song library
|
|
13
|
+
// song_info — get detailed info for a specific song (+ practice tips)
|
|
14
|
+
// registry_stats — get registry statistics
|
|
15
|
+
// teaching_note — get the teaching note for a specific measure
|
|
16
|
+
// suggest_song — get a song recommendation based on criteria
|
|
17
|
+
// list_measures — overview of measures with teaching notes
|
|
18
|
+
// practice_setup — suggest speed, mode, and voice settings for a song
|
|
19
|
+
// sing_along — get singable text (note names/solfege/contour/syllables) for measures
|
|
20
|
+
// play_song — play a song through VMPK via MIDI
|
|
21
|
+
// stop_playback — stop the currently playing song
|
|
22
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { getSong, getSongsByGenre, searchSongs, getStats, registerSong, validateSong, saveSong, initializeFromLibrary, scanLibrary, ingestSong, midiToSongEntry, generateJamBrief, formatJamBrief, GENRES, DIFFICULTIES, } from "./songs/index.js";
|
|
27
|
+
import { safeParseMeasure, measureToSingableText } from "./note-parser.js";
|
|
28
|
+
import { renderPianoRoll } from "./piano-roll.js";
|
|
29
|
+
import { createAudioEngine } from "./audio-engine.js";
|
|
30
|
+
import { listVoices, getVoice, getMergedVoice, VOICE_IDS, TUNING_PARAMS, loadUserTuning, saveUserTuning, resetUserTuning, } from "./piano-voices.js";
|
|
31
|
+
import { createSession } from "./session.js";
|
|
32
|
+
import { createConsoleTeachingHook, composeTeachingHooks } from "./teaching.js";
|
|
33
|
+
import { parseMidiFile } from "./midi/parser.js";
|
|
34
|
+
import { MidiPlaybackEngine } from "./playback/midi-engine.js";
|
|
35
|
+
import { PlaybackController } from "./playback/controls.js";
|
|
36
|
+
import { createSingOnMidiHook } from "./teaching/sing-on-midi.js";
|
|
37
|
+
import { createLiveMidiFeedbackHook } from "./teaching/live-midi-feedback.js";
|
|
38
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { buildJournalEntry, appendJournalEntry, readJournal, journalStats, } from "./journal.js";
|
|
40
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
41
|
+
/** Suggest practice speed based on song difficulty. */
|
|
42
|
+
function suggestSpeed(difficulty) {
|
|
43
|
+
switch (difficulty) {
|
|
44
|
+
case "beginner": return { speed: 0.5, label: "0.5× (half speed)" };
|
|
45
|
+
case "intermediate": return { speed: 0.75, label: "0.75× (three-quarter speed)" };
|
|
46
|
+
case "advanced": return { speed: 0.7, label: "0.7× (recommended for first pass)" };
|
|
47
|
+
default: return { speed: 1.0, label: "1.0× (full speed)" };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Suggest playback mode based on difficulty. */
|
|
51
|
+
function suggestMode(difficulty) {
|
|
52
|
+
switch (difficulty) {
|
|
53
|
+
case "beginner":
|
|
54
|
+
return { mode: "measure", reason: "Step through one measure at a time for careful learning" };
|
|
55
|
+
case "intermediate":
|
|
56
|
+
return { mode: "hands", reason: "Practice hands separately before combining" };
|
|
57
|
+
case "advanced":
|
|
58
|
+
return { mode: "hands", reason: "Master each hand individually for complex passages" };
|
|
59
|
+
default:
|
|
60
|
+
return { mode: "full", reason: "Play straight through at tempo" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ─── Server ─────────────────────────────────────────────────────────────────
|
|
64
|
+
const server = new McpServer({
|
|
65
|
+
name: "pianoai",
|
|
66
|
+
version: "0.1.0",
|
|
67
|
+
});
|
|
68
|
+
// ─── Practice Journal State ─────────────────────────────────────────────────
|
|
69
|
+
let lastCompletedSession = null;
|
|
70
|
+
// ─── Tool: list_songs ───────────────────────────────────────────────────────
|
|
71
|
+
server.tool("list_songs", "Browse and search the piano song library. Filter by genre, difficulty, or search query.", {
|
|
72
|
+
genre: z.enum(GENRES).optional().describe("Filter by genre"),
|
|
73
|
+
difficulty: z.enum(DIFFICULTIES).optional().describe("Filter by difficulty"),
|
|
74
|
+
query: z.string().optional().describe("Search query (matches title, composer, tags, description)"),
|
|
75
|
+
}, async (params) => {
|
|
76
|
+
const results = searchSongs({
|
|
77
|
+
genre: params.genre,
|
|
78
|
+
difficulty: params.difficulty,
|
|
79
|
+
query: params.query,
|
|
80
|
+
});
|
|
81
|
+
const text = results.length === 0
|
|
82
|
+
? "No songs found matching your criteria."
|
|
83
|
+
: results
|
|
84
|
+
.map((s) => `${s.id} — ${s.title} (${s.genre}, ${s.difficulty}, ${s.measures.length} measures)`)
|
|
85
|
+
.join("\n");
|
|
86
|
+
return {
|
|
87
|
+
content: [{ type: "text", text: `Found ${results.length} song(s):\n\n${text}` }],
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
// ─── Tool: song_info ────────────────────────────────────────────────────────
|
|
91
|
+
server.tool("song_info", "Get detailed information about a specific song — musical language, teaching goals, key moments, structure.", {
|
|
92
|
+
id: z.string().describe("Song ID (kebab-case, e.g. 'moonlight-sonata-mvt1')"),
|
|
93
|
+
}, async ({ id }) => {
|
|
94
|
+
const song = getSong(id);
|
|
95
|
+
if (!song) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const ml = song.musicalLanguage;
|
|
102
|
+
const { speed, label: speedLabel } = suggestSpeed(song.difficulty);
|
|
103
|
+
const { mode, reason: modeReason } = suggestMode(song.difficulty);
|
|
104
|
+
const text = [
|
|
105
|
+
`# ${song.title}`,
|
|
106
|
+
`**Composer:** ${song.composer ?? "Traditional"}`,
|
|
107
|
+
`**Genre:** ${song.genre} | **Difficulty:** ${song.difficulty}`,
|
|
108
|
+
`**Key:** ${song.key} | **Tempo:** ${song.tempo} BPM | **Time:** ${song.timeSignature}`,
|
|
109
|
+
`**Duration:** ~${song.durationSeconds}s | **Measures:** ${song.measures.length}`,
|
|
110
|
+
``,
|
|
111
|
+
`## Description`,
|
|
112
|
+
ml.description,
|
|
113
|
+
``,
|
|
114
|
+
`## Structure`,
|
|
115
|
+
ml.structure,
|
|
116
|
+
``,
|
|
117
|
+
`## Key Moments`,
|
|
118
|
+
...ml.keyMoments.map((km) => `- ${km}`),
|
|
119
|
+
``,
|
|
120
|
+
`## Teaching Goals`,
|
|
121
|
+
...ml.teachingGoals.map((tg) => `- ${tg}`),
|
|
122
|
+
``,
|
|
123
|
+
`## Style Tips`,
|
|
124
|
+
...ml.styleTips.map((st) => `- ${st}`),
|
|
125
|
+
``,
|
|
126
|
+
`## Practice Suggestions`,
|
|
127
|
+
`- **Suggested speed:** ${speedLabel} → effective tempo: ${Math.round(song.tempo * speed)} BPM`,
|
|
128
|
+
`- **Suggested mode:** ${mode} — ${modeReason}`,
|
|
129
|
+
`- **Voice coaching:** Enable voice feedback for teaching notes at measure boundaries`,
|
|
130
|
+
`- Use \`practice_setup "${song.id}"\` for a full practice configuration`,
|
|
131
|
+
``,
|
|
132
|
+
`**Tags:** ${song.tags.join(", ")}`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
return { content: [{ type: "text", text }] };
|
|
135
|
+
});
|
|
136
|
+
// ─── Tool: registry_stats ───────────────────────────────────────────────────
|
|
137
|
+
server.tool("registry_stats", "Get statistics about the song registry: total songs, genres, difficulties, measures.", {}, async () => {
|
|
138
|
+
const stats = getStats();
|
|
139
|
+
const genreLines = Object.entries(stats.byGenre)
|
|
140
|
+
.filter(([, count]) => count > 0)
|
|
141
|
+
.map(([genre, count]) => ` ${genre}: ${count}`)
|
|
142
|
+
.join("\n");
|
|
143
|
+
const diffLines = Object.entries(stats.byDifficulty)
|
|
144
|
+
.filter(([, count]) => count > 0)
|
|
145
|
+
.map(([diff, count]) => ` ${diff}: ${count}`)
|
|
146
|
+
.join("\n");
|
|
147
|
+
const text = [
|
|
148
|
+
`# Registry Stats`,
|
|
149
|
+
`Total songs: ${stats.totalSongs}`,
|
|
150
|
+
`Total measures: ${stats.totalMeasures}`,
|
|
151
|
+
``,
|
|
152
|
+
`## By Genre`,
|
|
153
|
+
genreLines,
|
|
154
|
+
``,
|
|
155
|
+
`## By Difficulty`,
|
|
156
|
+
diffLines,
|
|
157
|
+
].join("\n");
|
|
158
|
+
return { content: [{ type: "text", text }] };
|
|
159
|
+
});
|
|
160
|
+
// ─── Tool: teaching_note ────────────────────────────────────────────────────
|
|
161
|
+
server.tool("teaching_note", "Get the teaching note, fingering, and dynamics for a specific measure in a song.", {
|
|
162
|
+
id: z.string().describe("Song ID"),
|
|
163
|
+
measure: z.number().int().min(1).describe("Measure number (1-based)"),
|
|
164
|
+
}, async ({ id, measure }) => {
|
|
165
|
+
const song = getSong(id);
|
|
166
|
+
if (!song) {
|
|
167
|
+
return {
|
|
168
|
+
content: [{ type: "text", text: `Song not found: "${id}"` }],
|
|
169
|
+
isError: true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const m = song.measures[measure - 1];
|
|
173
|
+
if (!m) {
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: `Measure ${measure} not found (song has ${song.measures.length} measures)` }],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const lines = [
|
|
180
|
+
`# ${song.title} — Measure ${measure}`,
|
|
181
|
+
``,
|
|
182
|
+
`**Right Hand:** ${m.rightHand}`,
|
|
183
|
+
`**Left Hand:** ${m.leftHand}`,
|
|
184
|
+
];
|
|
185
|
+
if (m.fingering)
|
|
186
|
+
lines.push(`**Fingering:** ${m.fingering}`);
|
|
187
|
+
if (m.dynamics)
|
|
188
|
+
lines.push(`**Dynamics:** ${m.dynamics}`);
|
|
189
|
+
if (m.teachingNote) {
|
|
190
|
+
lines.push(``, `## Teaching Note`, m.teachingNote);
|
|
191
|
+
}
|
|
192
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
193
|
+
});
|
|
194
|
+
// ─── Tool: suggest_song ─────────────────────────────────────────────────────
|
|
195
|
+
server.tool("suggest_song", "Get a song recommendation based on genre preference and/or difficulty level.", {
|
|
196
|
+
genre: z.enum(GENRES).optional().describe("Preferred genre"),
|
|
197
|
+
difficulty: z.enum(DIFFICULTIES).optional().describe("Desired difficulty"),
|
|
198
|
+
maxDuration: z.number().optional().describe("Maximum duration in seconds"),
|
|
199
|
+
}, async (params) => {
|
|
200
|
+
const results = searchSongs({
|
|
201
|
+
genre: params.genre,
|
|
202
|
+
difficulty: params.difficulty,
|
|
203
|
+
maxDuration: params.maxDuration,
|
|
204
|
+
});
|
|
205
|
+
if (results.length === 0) {
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: "No songs match your criteria. Try broadening your search." }],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Pick a random suggestion from matches
|
|
211
|
+
const song = results[Math.floor(Math.random() * results.length)];
|
|
212
|
+
const ml = song.musicalLanguage;
|
|
213
|
+
const text = [
|
|
214
|
+
`I'd suggest: **${song.title}** by ${song.composer ?? "Traditional"}`,
|
|
215
|
+
``,
|
|
216
|
+
`${ml.description}`,
|
|
217
|
+
``,
|
|
218
|
+
`**Why this song?**`,
|
|
219
|
+
...ml.teachingGoals.map((tg) => `- ${tg}`),
|
|
220
|
+
``,
|
|
221
|
+
`Use \`song_info\` with id "${song.id}" for full details.`,
|
|
222
|
+
].join("\n");
|
|
223
|
+
return { content: [{ type: "text", text }] };
|
|
224
|
+
});
|
|
225
|
+
// ─── Tool: list_measures ────────────────────────────────────────────────────
|
|
226
|
+
server.tool("list_measures", "Get an overview of all measures in a song, showing right hand, left hand, and any teaching notes.", {
|
|
227
|
+
id: z.string().describe("Song ID"),
|
|
228
|
+
startMeasure: z.number().int().min(1).optional().describe("Start measure (1-based, default: 1)"),
|
|
229
|
+
endMeasure: z.number().int().min(1).optional().describe("End measure (1-based, default: last)"),
|
|
230
|
+
}, async ({ id, startMeasure, endMeasure }) => {
|
|
231
|
+
const song = getSong(id);
|
|
232
|
+
if (!song) {
|
|
233
|
+
return {
|
|
234
|
+
content: [{ type: "text", text: `Song not found: "${id}"` }],
|
|
235
|
+
isError: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const start = (startMeasure ?? 1) - 1;
|
|
239
|
+
const end = Math.min((endMeasure ?? song.measures.length) - 1, song.measures.length - 1);
|
|
240
|
+
const measures = song.measures.slice(start, end + 1);
|
|
241
|
+
// Check for parse warnings
|
|
242
|
+
const warnings = [];
|
|
243
|
+
for (const m of measures) {
|
|
244
|
+
safeParseMeasure(m, song.tempo, warnings);
|
|
245
|
+
}
|
|
246
|
+
const lines = [`# ${song.title} — Measures ${start + 1} to ${end + 1}`, ``];
|
|
247
|
+
for (const m of measures) {
|
|
248
|
+
lines.push(`## Measure ${m.number}`);
|
|
249
|
+
lines.push(`RH: ${m.rightHand}`);
|
|
250
|
+
lines.push(`LH: ${m.leftHand}`);
|
|
251
|
+
if (m.fingering)
|
|
252
|
+
lines.push(`Fingering: ${m.fingering}`);
|
|
253
|
+
if (m.dynamics)
|
|
254
|
+
lines.push(`Dynamics: ${m.dynamics}`);
|
|
255
|
+
if (m.teachingNote)
|
|
256
|
+
lines.push(`Note: ${m.teachingNote}`);
|
|
257
|
+
lines.push(``);
|
|
258
|
+
}
|
|
259
|
+
if (warnings.length > 0) {
|
|
260
|
+
lines.push(`## ⚠ Parse Warnings`);
|
|
261
|
+
lines.push(`${warnings.length} note(s) could not be parsed and will be skipped during playback:`);
|
|
262
|
+
for (const w of warnings.slice(0, 10)) {
|
|
263
|
+
lines.push(`- ${w.location}: "${w.token}" — ${w.message}`);
|
|
264
|
+
}
|
|
265
|
+
if (warnings.length > 10) {
|
|
266
|
+
lines.push(`- … and ${warnings.length - 10} more`);
|
|
267
|
+
}
|
|
268
|
+
lines.push(``);
|
|
269
|
+
}
|
|
270
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
271
|
+
});
|
|
272
|
+
// ─── Tool: practice_setup ──────────────────────────────────────────────────
|
|
273
|
+
server.tool("practice_setup", "Get a recommended practice configuration for a song — speed, mode, voice settings, and CLI command. Tailored to the song's difficulty and teaching goals.", {
|
|
274
|
+
id: z.string().describe("Song ID"),
|
|
275
|
+
playerLevel: z.enum(["beginner", "intermediate", "advanced"]).optional()
|
|
276
|
+
.describe("Player's skill level (overrides song-based suggestion)"),
|
|
277
|
+
}, async ({ id, playerLevel }) => {
|
|
278
|
+
const song = getSong(id);
|
|
279
|
+
if (!song) {
|
|
280
|
+
return {
|
|
281
|
+
content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
|
|
282
|
+
isError: true,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
// Determine practice parameters
|
|
286
|
+
const effectiveDifficulty = (playerLevel ?? song.difficulty);
|
|
287
|
+
const { speed, label: speedLabel } = suggestSpeed(effectiveDifficulty);
|
|
288
|
+
const { mode, reason: modeReason } = suggestMode(effectiveDifficulty);
|
|
289
|
+
const effectiveTempo = Math.round(song.tempo * speed);
|
|
290
|
+
// Check for parse warnings
|
|
291
|
+
const warnings = [];
|
|
292
|
+
for (const m of song.measures) {
|
|
293
|
+
safeParseMeasure(m, effectiveTempo, warnings);
|
|
294
|
+
}
|
|
295
|
+
const ml = song.musicalLanguage;
|
|
296
|
+
const lines = [
|
|
297
|
+
`# Practice Setup: ${song.title}`,
|
|
298
|
+
``,
|
|
299
|
+
`## Song Profile`,
|
|
300
|
+
`- **Difficulty:** ${song.difficulty}`,
|
|
301
|
+
`- **Base tempo:** ${song.tempo} BPM`,
|
|
302
|
+
`- **Measures:** ${song.measures.length}`,
|
|
303
|
+
`- **Key:** ${song.key} | **Time:** ${song.timeSignature}`,
|
|
304
|
+
``,
|
|
305
|
+
`## Recommended Settings`,
|
|
306
|
+
`- **Speed:** ${speedLabel}`,
|
|
307
|
+
`- **Effective tempo:** ${effectiveTempo} BPM`,
|
|
308
|
+
`- **Mode:** ${mode} — ${modeReason}`,
|
|
309
|
+
`- **Voice coaching:** Enabled — speak teaching notes + key moments`,
|
|
310
|
+
``,
|
|
311
|
+
`## CLI Command`,
|
|
312
|
+
`\`\`\``,
|
|
313
|
+
`pianoai play ${song.id} --speed ${speed} --mode ${mode}`,
|
|
314
|
+
`\`\`\``,
|
|
315
|
+
``,
|
|
316
|
+
`## Practice Progression`,
|
|
317
|
+
`1. Start at ${speedLabel} in **${mode}** mode`,
|
|
318
|
+
`2. Focus on key moments:`,
|
|
319
|
+
...ml.keyMoments.slice(0, 3).map((km) => ` - ${km}`),
|
|
320
|
+
`3. Gradually increase speed: ${speed} → ${Math.min(speed + 0.25, 1.0)} → 1.0`,
|
|
321
|
+
`4. Switch to **full** mode once comfortable at speed`,
|
|
322
|
+
];
|
|
323
|
+
if (song.difficulty === "advanced") {
|
|
324
|
+
lines.push(`5. Try **loop** mode on difficult passages`, ` Example: \`pianoai play ${song.id} --mode loop\``);
|
|
325
|
+
}
|
|
326
|
+
if (warnings.length > 0) {
|
|
327
|
+
lines.push(``, `## ⚠ Note`, `${warnings.length} note(s) have parse warnings and will be skipped during playback.`, `Use \`list_measures "${song.id}"\` to see details.`);
|
|
328
|
+
}
|
|
329
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
330
|
+
});
|
|
331
|
+
// ─── Tool: sing_along ─────────────────────────────────────────────────────
|
|
332
|
+
server.tool("sing_along", "Get singable text (note names, solfege, contour, or syllables) for a range of measures. Optionally enable piano accompaniment for synchronized singing + playback.", {
|
|
333
|
+
id: z.string().describe("Song ID"),
|
|
334
|
+
startMeasure: z.number().int().min(1).optional().describe("Start measure (1-based, default: 1)"),
|
|
335
|
+
endMeasure: z.number().int().min(1).optional().describe("End measure (1-based, default: last)"),
|
|
336
|
+
mode: z.enum(["note-names", "solfege", "contour", "syllables"]).optional()
|
|
337
|
+
.describe("Sing-along mode (default: 'note-names')"),
|
|
338
|
+
hand: z.enum(["right", "left", "both"]).optional()
|
|
339
|
+
.describe("Which hand to narrate (default: 'right')"),
|
|
340
|
+
withPiano: z.boolean().optional()
|
|
341
|
+
.describe("Include piano accompaniment info and CLI command for live playback (default: false)"),
|
|
342
|
+
syncMode: z.enum(["concurrent", "before"]).optional()
|
|
343
|
+
.describe("Voice+piano sync mode: 'concurrent' = duet feel, 'before' = voice first (default: 'concurrent')"),
|
|
344
|
+
}, async ({ id, startMeasure, endMeasure, mode, hand, withPiano, syncMode }) => {
|
|
345
|
+
const song = getSong(id);
|
|
346
|
+
if (!song) {
|
|
347
|
+
return {
|
|
348
|
+
content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
|
|
349
|
+
isError: true,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const effectiveMode = mode ?? "note-names";
|
|
353
|
+
const effectiveHand = hand ?? "right";
|
|
354
|
+
const effectiveSyncMode = syncMode ?? "concurrent";
|
|
355
|
+
const start = (startMeasure ?? 1) - 1;
|
|
356
|
+
const end = Math.min((endMeasure ?? song.measures.length) - 1, song.measures.length - 1);
|
|
357
|
+
const measures = song.measures.slice(start, end + 1);
|
|
358
|
+
const lines = [
|
|
359
|
+
`# Sing Along: ${song.title}`,
|
|
360
|
+
`**Mode:** ${effectiveMode} | **Hand:** ${effectiveHand}`,
|
|
361
|
+
`**Measures:** ${start + 1} to ${end + 1}`,
|
|
362
|
+
];
|
|
363
|
+
if (withPiano) {
|
|
364
|
+
lines.push(`**Piano accompaniment:** enabled (${effectiveSyncMode} sync)`);
|
|
365
|
+
}
|
|
366
|
+
lines.push(``);
|
|
367
|
+
for (const m of measures) {
|
|
368
|
+
const singable = measureToSingableText({ rightHand: m.rightHand, leftHand: m.leftHand }, { mode: effectiveMode, hand: effectiveHand });
|
|
369
|
+
lines.push(`**Measure ${m.number}:** ${singable}`);
|
|
370
|
+
}
|
|
371
|
+
if (withPiano) {
|
|
372
|
+
const { speed, label: speedLabel } = suggestSpeed(song.difficulty);
|
|
373
|
+
const effectiveTempo = Math.round(song.tempo * speed);
|
|
374
|
+
lines.push(``, `---`, `## Piano Accompaniment`, `Voice and piano play **${effectiveSyncMode === "concurrent" ? "simultaneously (duet feel)" : "sequentially (voice first, then piano)"}**.`, ``, `**Suggested speed:** ${speedLabel} → ${effectiveTempo} BPM`, `**Live feedback:** encouragement every 4 measures + dynamics tips`, ``, `### CLI Command`, `\`\`\``, `pianoai sing ${song.id} --with-piano --mode ${effectiveMode} --hand ${effectiveHand} --sync ${effectiveSyncMode}`, `\`\`\``);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
lines.push(``, `---`, `*Tip: Add \`withPiano: true\` for synchronized singing + piano playback, or run:*`, `*\`pianoai sing ${song.id} --with-piano\`*`);
|
|
378
|
+
}
|
|
379
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
380
|
+
});
|
|
381
|
+
// ─── Active Playback State ────────────────────────────────────────────────
|
|
382
|
+
let activeSession = null;
|
|
383
|
+
let activeMidiEngine = null;
|
|
384
|
+
let activeController = null;
|
|
385
|
+
let activeConnector = null;
|
|
386
|
+
let activeVoiceId = "grand";
|
|
387
|
+
let activeNotes = new Set();
|
|
388
|
+
/** Stop whatever is currently playing. */
|
|
389
|
+
function stopActive() {
|
|
390
|
+
if (activeSession && activeSession.state === "playing") {
|
|
391
|
+
activeSession.stop();
|
|
392
|
+
}
|
|
393
|
+
activeSession = null;
|
|
394
|
+
if (activeMidiEngine && activeMidiEngine.state === "playing") {
|
|
395
|
+
activeMidiEngine.stop();
|
|
396
|
+
}
|
|
397
|
+
activeMidiEngine = null;
|
|
398
|
+
if (activeController && activeController.state === "playing") {
|
|
399
|
+
activeController.stop();
|
|
400
|
+
}
|
|
401
|
+
activeController = null;
|
|
402
|
+
if (activeConnector) {
|
|
403
|
+
activeConnector.disconnect().catch(() => { });
|
|
404
|
+
activeConnector = null;
|
|
405
|
+
}
|
|
406
|
+
activeNotes.clear();
|
|
407
|
+
}
|
|
408
|
+
// ─── Tool: play_song ──────────────────────────────────────────────────────
|
|
409
|
+
server.tool("play_song", "Play a song through the built-in piano engine. Accepts a library song ID or a path to a .mid file. Returns immediately with session info while playback runs in the background.", {
|
|
410
|
+
id: z.string().describe("Song ID (e.g. 'autumn-leaves', 'let-it-be') OR path to a .mid file"),
|
|
411
|
+
speed: z.number().min(0.1).max(4).optional().describe("Speed multiplier (0.5 = half speed, 1.0 = normal, 2.0 = double). Default: 1.0"),
|
|
412
|
+
tempo: z.number().int().min(10).max(400).optional().describe("Override tempo in BPM (10-400). Default: song's tempo"),
|
|
413
|
+
mode: z.enum(["full", "measure", "hands", "loop"]).optional().describe("Playback mode: full (default), measure (one at a time), hands (separate then together), loop"),
|
|
414
|
+
startMeasure: z.number().int().min(1).optional().describe("Start measure for loop mode (1-based)"),
|
|
415
|
+
endMeasure: z.number().int().min(1).optional().describe("End measure for loop mode (1-based)"),
|
|
416
|
+
withSinging: z.boolean().optional().describe("Enable sing-along narration during playback (note-names by default). Default: false"),
|
|
417
|
+
withTeaching: z.boolean().optional().describe("Enable live teaching feedback (encouragement, dynamics tips, difficulty warnings). Default: false"),
|
|
418
|
+
singMode: z.enum(["note-names", "solfege", "contour", "syllables"]).optional().describe("Sing-along mode when withSinging is true. Default: note-names"),
|
|
419
|
+
keyboard: z.enum(VOICE_IDS).optional().describe("Piano voice/keyboard: grand (default), upright, electric, honkytonk, musicbox, bright. Each has a different character suited to different genres."),
|
|
420
|
+
}, async ({ id, speed, tempo, mode, startMeasure, endMeasure, withSinging, withTeaching, singMode, keyboard }) => {
|
|
421
|
+
// Stop whatever is currently playing
|
|
422
|
+
stopActive();
|
|
423
|
+
// Determine if this is a .mid file path or a library song ID
|
|
424
|
+
const isMidiFile = id.endsWith(".mid") || id.endsWith(".midi") || existsSync(id);
|
|
425
|
+
const librarySong = isMidiFile ? null : getSong(id);
|
|
426
|
+
if (!isMidiFile && !librarySong) {
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs, or provide a path to a .mid file.` }],
|
|
429
|
+
isError: true,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
// Connect piano engine
|
|
433
|
+
const voiceId = (keyboard ?? "grand");
|
|
434
|
+
activeVoiceId = voiceId;
|
|
435
|
+
activeNotes.clear();
|
|
436
|
+
const connector = createAudioEngine(voiceId);
|
|
437
|
+
try {
|
|
438
|
+
await connector.connect();
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
442
|
+
return {
|
|
443
|
+
content: [{ type: "text", text: `Piano engine failed to start: ${msg}` }],
|
|
444
|
+
isError: true,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
activeConnector = connector;
|
|
448
|
+
// ── MIDI file playback ──
|
|
449
|
+
if (isMidiFile) {
|
|
450
|
+
let parsed;
|
|
451
|
+
try {
|
|
452
|
+
parsed = await parseMidiFile(id);
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
456
|
+
connector.disconnect().catch(() => { });
|
|
457
|
+
activeConnector = null;
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text: `Failed to parse MIDI file: ${msg}` }],
|
|
460
|
+
isError: true,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
// Build teaching hooks if requested
|
|
464
|
+
const hooks = [];
|
|
465
|
+
const singingLog = [];
|
|
466
|
+
const feedbackLog = [];
|
|
467
|
+
if (withSinging) {
|
|
468
|
+
const voiceSink = async (d) => {
|
|
469
|
+
singingLog.push(d.text);
|
|
470
|
+
console.error(`♪ ${d.text}`);
|
|
471
|
+
};
|
|
472
|
+
hooks.push(createSingOnMidiHook(voiceSink, parsed, {
|
|
473
|
+
mode: (singMode ?? "note-names"),
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
if (withTeaching) {
|
|
477
|
+
const voiceSink = async (d) => {
|
|
478
|
+
feedbackLog.push(d.text);
|
|
479
|
+
console.error(`🎓 ${d.text}`);
|
|
480
|
+
};
|
|
481
|
+
const asideSink = async (d) => {
|
|
482
|
+
feedbackLog.push(d.text);
|
|
483
|
+
console.error(`💡 ${d.text}`);
|
|
484
|
+
};
|
|
485
|
+
// Use position-aware feedback (measure-level context) over basic per-note
|
|
486
|
+
hooks.push(createLiveMidiFeedbackHook(voiceSink, asideSink, parsed));
|
|
487
|
+
}
|
|
488
|
+
hooks.push(createConsoleTeachingHook());
|
|
489
|
+
const teachingHook = composeTeachingHooks(...hooks);
|
|
490
|
+
// Use PlaybackController when hooks are active, raw engine otherwise
|
|
491
|
+
if (withSinging || withTeaching) {
|
|
492
|
+
const controller = new PlaybackController(connector, parsed);
|
|
493
|
+
activeController = controller;
|
|
494
|
+
const playPromise = controller.play({ speed: speed ?? 1.0, teachingHook });
|
|
495
|
+
playPromise
|
|
496
|
+
.then(() => {
|
|
497
|
+
console.error(`Finished playing MIDI file: ${id} (${parsed.noteCount} notes, ${parsed.durationSeconds.toFixed(1)}s)`);
|
|
498
|
+
})
|
|
499
|
+
.catch((err) => {
|
|
500
|
+
console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
|
|
501
|
+
})
|
|
502
|
+
.finally(() => {
|
|
503
|
+
connector.disconnect().catch(() => { });
|
|
504
|
+
if (activeController === controller)
|
|
505
|
+
activeController = null;
|
|
506
|
+
if (activeConnector === connector)
|
|
507
|
+
activeConnector = null;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
const engine = new MidiPlaybackEngine(connector, parsed);
|
|
512
|
+
activeMidiEngine = engine;
|
|
513
|
+
const playPromise = engine.play({ speed: speed ?? 1.0 });
|
|
514
|
+
playPromise
|
|
515
|
+
.then(() => {
|
|
516
|
+
console.error(`Finished playing MIDI file: ${id} (${parsed.noteCount} notes, ${parsed.durationSeconds.toFixed(1)}s)`);
|
|
517
|
+
})
|
|
518
|
+
.catch((err) => {
|
|
519
|
+
console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
|
|
520
|
+
})
|
|
521
|
+
.finally(() => {
|
|
522
|
+
connector.disconnect().catch(() => { });
|
|
523
|
+
if (activeMidiEngine === engine)
|
|
524
|
+
activeMidiEngine = null;
|
|
525
|
+
if (activeConnector === connector)
|
|
526
|
+
activeConnector = null;
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
const effectiveSpeed = speed ?? 1.0;
|
|
530
|
+
const durationAtSpeed = parsed.durationSeconds / effectiveSpeed;
|
|
531
|
+
const speedLabel = effectiveSpeed !== 1.0 ? ` × ${effectiveSpeed}x` : "";
|
|
532
|
+
const trackInfo = parsed.trackNames.length > 0 ? parsed.trackNames.join(", ") : "Unknown";
|
|
533
|
+
const features = [];
|
|
534
|
+
if (withSinging)
|
|
535
|
+
features.push(`singing (${singMode ?? "note-names"})`);
|
|
536
|
+
if (withTeaching)
|
|
537
|
+
features.push("teaching feedback");
|
|
538
|
+
const lines = [
|
|
539
|
+
`Now playing: **${id}** (MIDI file)`,
|
|
540
|
+
``,
|
|
541
|
+
`- **Tracks:** ${trackInfo} (${parsed.trackCount} track${parsed.trackCount !== 1 ? "s" : ""})`,
|
|
542
|
+
`- **Notes:** ${parsed.noteCount}`,
|
|
543
|
+
`- **Tempo:** ${parsed.bpm} BPM${speedLabel}`,
|
|
544
|
+
`- **Duration:** ~${Math.round(durationAtSpeed)}s`,
|
|
545
|
+
`- **Format:** MIDI type ${parsed.format}`,
|
|
546
|
+
];
|
|
547
|
+
if (features.length > 0) {
|
|
548
|
+
lines.push(`- **Features:** ${features.join(", ")}`);
|
|
549
|
+
}
|
|
550
|
+
lines.push(``, `Use \`stop_playback\` to stop. Playback runs in the background.`);
|
|
551
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
552
|
+
}
|
|
553
|
+
// ── Library song playback ──
|
|
554
|
+
const song = librarySong;
|
|
555
|
+
const loopRange = startMeasure && endMeasure ? [startMeasure, endMeasure] : undefined;
|
|
556
|
+
const playbackMode = (mode ?? "full");
|
|
557
|
+
// Build teaching hooks
|
|
558
|
+
const libHooks = [];
|
|
559
|
+
if (withSinging) {
|
|
560
|
+
const { createSingAlongHook } = await import("./teaching.js");
|
|
561
|
+
const voiceSink = async (d) => {
|
|
562
|
+
console.error(`♪ ${d.text}`);
|
|
563
|
+
};
|
|
564
|
+
libHooks.push(createSingAlongHook(voiceSink, song, {
|
|
565
|
+
mode: (singMode ?? "note-names"),
|
|
566
|
+
}));
|
|
567
|
+
}
|
|
568
|
+
if (withTeaching) {
|
|
569
|
+
const { createLiveFeedbackHook } = await import("./teaching.js");
|
|
570
|
+
const voiceSink = async (d) => {
|
|
571
|
+
console.error(`🎓 ${d.text}`);
|
|
572
|
+
};
|
|
573
|
+
const asideSink = async (d) => {
|
|
574
|
+
console.error(`💡 ${d.text}`);
|
|
575
|
+
};
|
|
576
|
+
libHooks.push(createLiveFeedbackHook(voiceSink, asideSink, song));
|
|
577
|
+
}
|
|
578
|
+
libHooks.push(createConsoleTeachingHook());
|
|
579
|
+
const teachingHook = composeTeachingHooks(...libHooks);
|
|
580
|
+
const syncMode = (withSinging && !withTeaching) ? "before" : "concurrent";
|
|
581
|
+
const session = createSession(song, connector, {
|
|
582
|
+
mode: playbackMode,
|
|
583
|
+
syncMode,
|
|
584
|
+
speed,
|
|
585
|
+
tempo,
|
|
586
|
+
loopRange,
|
|
587
|
+
teachingHook,
|
|
588
|
+
});
|
|
589
|
+
activeSession = session;
|
|
590
|
+
// Play in background
|
|
591
|
+
const playStartTime = Date.now();
|
|
592
|
+
const playPromise = session.play();
|
|
593
|
+
playPromise
|
|
594
|
+
.then(() => {
|
|
595
|
+
const elapsed = Math.round((Date.now() - playStartTime) / 1000);
|
|
596
|
+
lastCompletedSession = {
|
|
597
|
+
songId: song.id,
|
|
598
|
+
title: song.title,
|
|
599
|
+
composer: song.composer,
|
|
600
|
+
genre: song.genre,
|
|
601
|
+
difficulty: song.difficulty,
|
|
602
|
+
key: song.key,
|
|
603
|
+
tempo: session.effectiveTempo(),
|
|
604
|
+
speed: session.session.speed,
|
|
605
|
+
mode: session.session.mode,
|
|
606
|
+
measuresPlayed: session.session.measuresPlayed,
|
|
607
|
+
totalMeasures: song.measures.length,
|
|
608
|
+
durationSeconds: elapsed,
|
|
609
|
+
timestamp: new Date().toISOString(),
|
|
610
|
+
};
|
|
611
|
+
console.error(`Finished playing: ${song.title} (${session.session.measuresPlayed} measures)`);
|
|
612
|
+
})
|
|
613
|
+
.catch((err) => {
|
|
614
|
+
console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
|
|
615
|
+
})
|
|
616
|
+
.finally(() => {
|
|
617
|
+
connector.disconnect().catch(() => { });
|
|
618
|
+
if (activeSession === session)
|
|
619
|
+
activeSession = null;
|
|
620
|
+
if (activeConnector === connector)
|
|
621
|
+
activeConnector = null;
|
|
622
|
+
});
|
|
623
|
+
const effectiveSpeed = speed ?? 1.0;
|
|
624
|
+
const baseTempo = tempo ?? song.tempo;
|
|
625
|
+
const effectiveTempo = Math.round(baseTempo * effectiveSpeed);
|
|
626
|
+
const speedLabel = effectiveSpeed !== 1.0 ? ` × ${effectiveSpeed}x` : "";
|
|
627
|
+
const warnings = session.parseWarnings;
|
|
628
|
+
const lines = [
|
|
629
|
+
`Now playing: **${song.title}** by ${song.composer ?? "Traditional"}`,
|
|
630
|
+
``,
|
|
631
|
+
`- **Keyboard:** ${voiceId}`,
|
|
632
|
+
`- **Mode:** ${playbackMode}`,
|
|
633
|
+
`- **Tempo:** ${baseTempo} BPM${speedLabel} → ${effectiveTempo} BPM effective`,
|
|
634
|
+
`- **Key:** ${song.key} | **Time:** ${song.timeSignature}`,
|
|
635
|
+
`- **Measures:** ${song.measures.length}`,
|
|
636
|
+
];
|
|
637
|
+
if (loopRange) {
|
|
638
|
+
lines.push(`- **Loop range:** measures ${loopRange[0]}–${loopRange[1]}`);
|
|
639
|
+
}
|
|
640
|
+
if (warnings.length > 0) {
|
|
641
|
+
lines.push(``, `⚠ ${warnings.length} note(s) had parse warnings and will be skipped.`);
|
|
642
|
+
}
|
|
643
|
+
lines.push(``, `Use \`stop_playback\` to stop. Playback runs in the background.`);
|
|
644
|
+
lines.push(``, `Tip: After listening, use \`save_practice_note\` to record what you learned.`);
|
|
645
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
646
|
+
});
|
|
647
|
+
// ─── Tool: stop_playback ──────────────────────────────────────────────────
|
|
648
|
+
server.tool("stop_playback", "Stop the currently playing song and disconnect MIDI.", {}, async () => {
|
|
649
|
+
const wasPlaying = activeSession || activeMidiEngine || activeController;
|
|
650
|
+
if (!wasPlaying) {
|
|
651
|
+
return {
|
|
652
|
+
content: [{ type: "text", text: "No song is currently playing." }],
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const info = activeSession
|
|
656
|
+
? `${activeSession.session.song.title} (${activeSession.session.measuresPlayed} measures played)`
|
|
657
|
+
: activeMidiEngine
|
|
658
|
+
? `MIDI file (${activeMidiEngine.eventsPlayed}/${activeMidiEngine.totalEvents} events played)`
|
|
659
|
+
: activeController
|
|
660
|
+
? `MIDI file (${activeController.eventsPlayed}/${activeController.totalEvents} events played)`
|
|
661
|
+
: "Unknown";
|
|
662
|
+
stopActive();
|
|
663
|
+
return {
|
|
664
|
+
content: [{ type: "text", text: `Stopped: ${info}` }],
|
|
665
|
+
};
|
|
666
|
+
});
|
|
667
|
+
// ─── Tool: pause_playback ─────────────────────────────────────────────────
|
|
668
|
+
server.tool("pause_playback", "Pause or resume the currently playing song.", {
|
|
669
|
+
resume: z.boolean().optional().describe("If true, resume playback. If false or omitted, pause."),
|
|
670
|
+
}, async ({ resume }) => {
|
|
671
|
+
if (resume) {
|
|
672
|
+
// Resume
|
|
673
|
+
if (activeController && activeController.state === "paused") {
|
|
674
|
+
activeController.resume().catch(() => { });
|
|
675
|
+
return { content: [{ type: "text", text: "Resumed playback." }] };
|
|
676
|
+
}
|
|
677
|
+
if (activeSession && activeSession.state === "paused") {
|
|
678
|
+
activeSession.play().catch(() => { });
|
|
679
|
+
return { content: [{ type: "text", text: "Resumed playback." }] };
|
|
680
|
+
}
|
|
681
|
+
return { content: [{ type: "text", text: "Nothing is paused." }] };
|
|
682
|
+
}
|
|
683
|
+
// Pause
|
|
684
|
+
if (activeController && activeController.state === "playing") {
|
|
685
|
+
activeController.pause();
|
|
686
|
+
const pos = activeController.positionSeconds;
|
|
687
|
+
return {
|
|
688
|
+
content: [{
|
|
689
|
+
type: "text",
|
|
690
|
+
text: `Paused at ${pos.toFixed(1)}s (${activeController.eventsPlayed}/${activeController.totalEvents} events).`,
|
|
691
|
+
}],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
if (activeMidiEngine && activeMidiEngine.state === "playing") {
|
|
695
|
+
activeMidiEngine.pause();
|
|
696
|
+
return {
|
|
697
|
+
content: [{
|
|
698
|
+
type: "text",
|
|
699
|
+
text: `Paused at ${activeMidiEngine.positionSeconds.toFixed(1)}s.`,
|
|
700
|
+
}],
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
if (activeSession && activeSession.state === "playing") {
|
|
704
|
+
activeSession.pause();
|
|
705
|
+
return {
|
|
706
|
+
content: [{
|
|
707
|
+
type: "text",
|
|
708
|
+
text: `Paused (${activeSession.session.measuresPlayed} measures played).`,
|
|
709
|
+
}],
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return { content: [{ type: "text", text: "No song is currently playing." }] };
|
|
713
|
+
});
|
|
714
|
+
// ─── Tool: set_speed ──────────────────────────────────────────────────────
|
|
715
|
+
server.tool("set_speed", "Change the playback speed of the currently playing song. Takes effect on the next note.", {
|
|
716
|
+
speed: z.number().min(0.1).max(4).describe("New speed multiplier (0.1–4.0)"),
|
|
717
|
+
}, async ({ speed }) => {
|
|
718
|
+
if (activeController) {
|
|
719
|
+
const prev = activeController.speed;
|
|
720
|
+
activeController.setSpeed(speed);
|
|
721
|
+
return {
|
|
722
|
+
content: [{
|
|
723
|
+
type: "text",
|
|
724
|
+
text: `Speed changed: ${prev}x → ${speed}x. Takes effect on next note.`,
|
|
725
|
+
}],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (activeMidiEngine) {
|
|
729
|
+
const prev = activeMidiEngine.speed;
|
|
730
|
+
activeMidiEngine.setSpeed(speed);
|
|
731
|
+
return {
|
|
732
|
+
content: [{
|
|
733
|
+
type: "text",
|
|
734
|
+
text: `Speed changed: ${prev}x → ${speed}x.`,
|
|
735
|
+
}],
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (activeSession) {
|
|
739
|
+
activeSession.setSpeed(speed);
|
|
740
|
+
return {
|
|
741
|
+
content: [{
|
|
742
|
+
type: "text",
|
|
743
|
+
text: `Speed changed to ${speed}x.`,
|
|
744
|
+
}],
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return { content: [{ type: "text", text: "No song is currently playing." }] };
|
|
748
|
+
});
|
|
749
|
+
// ─── Tool: ai_jam_sessions ──────────────────────────────────────────────
|
|
750
|
+
server.tool("ai_jam_sessions", "Start a jam session — get a 'jam brief' with chord progression, melody outline, structure, and style hints. Provide a songId for a specific song, or just a genre to jam on a random pick. Use the brief to create your own interpretation, then save with add_song and play with play_song.", {
|
|
751
|
+
songId: z.string().optional()
|
|
752
|
+
.describe("Source song ID to jam on (e.g. 'autumn-leaves'). Optional if genre is provided."),
|
|
753
|
+
genre: z.enum(GENRES).optional()
|
|
754
|
+
.describe("Pick a random song from this genre to jam on (e.g., 'jazz', 'blues'). Used when no songId is provided."),
|
|
755
|
+
style: z.enum(GENRES).optional()
|
|
756
|
+
.describe("Target genre for reinterpretation (e.g., turn a classical piece into jazz)"),
|
|
757
|
+
mood: z.string().optional()
|
|
758
|
+
.describe("Target mood (e.g., 'upbeat', 'melancholic', 'dreamy', 'energetic', 'gentle', 'playful')"),
|
|
759
|
+
difficulty: z.enum(DIFFICULTIES).optional()
|
|
760
|
+
.describe("Target difficulty level"),
|
|
761
|
+
measures: z.string().optional()
|
|
762
|
+
.describe("Measure range to focus on (e.g., '1-8' for just the opening)"),
|
|
763
|
+
}, async ({ songId, genre, style, mood, difficulty, measures }) => {
|
|
764
|
+
if (!songId && !genre) {
|
|
765
|
+
return {
|
|
766
|
+
content: [{ type: "text", text: "Provide either a songId or a genre. Use list_songs to browse, or pass a genre like \"jazz\" to jam on a random pick." }],
|
|
767
|
+
isError: true,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
let song;
|
|
771
|
+
if (songId) {
|
|
772
|
+
song = getSong(songId);
|
|
773
|
+
if (!song) {
|
|
774
|
+
return {
|
|
775
|
+
content: [{ type: "text", text: `Song not found: "${songId}". Use list_songs to see available songs.` }],
|
|
776
|
+
isError: true,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
const candidates = getSongsByGenre(genre);
|
|
782
|
+
if (candidates.length === 0) {
|
|
783
|
+
return {
|
|
784
|
+
content: [{ type: "text", text: `No songs found in genre "${genre}". Use registry_stats to see available genres.` }],
|
|
785
|
+
isError: true,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
song = candidates[Math.floor(Math.random() * candidates.length)];
|
|
789
|
+
}
|
|
790
|
+
const options = {
|
|
791
|
+
style: style,
|
|
792
|
+
mood,
|
|
793
|
+
difficulty: difficulty,
|
|
794
|
+
measures,
|
|
795
|
+
};
|
|
796
|
+
const brief = generateJamBrief(song, options);
|
|
797
|
+
const text = formatJamBrief(brief, options);
|
|
798
|
+
return { content: [{ type: "text", text }] };
|
|
799
|
+
});
|
|
800
|
+
// ─── Tool: add_song ──────────────────────────────────────────────────────
|
|
801
|
+
server.tool("add_song", "Add a new song to the library. Provide a complete SongEntry as JSON. The song is validated, registered, and saved to the user songs directory.", {
|
|
802
|
+
song: z.string().describe("Complete SongEntry as a JSON string"),
|
|
803
|
+
}, async ({ song: songJson }) => {
|
|
804
|
+
try {
|
|
805
|
+
const parsed = JSON.parse(songJson);
|
|
806
|
+
const errors = validateSong(parsed);
|
|
807
|
+
if (errors.length > 0) {
|
|
808
|
+
return {
|
|
809
|
+
content: [{
|
|
810
|
+
type: "text",
|
|
811
|
+
text: `Song validation failed:\n - ${errors.join("\n - ")}`,
|
|
812
|
+
}],
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
// Check for duplicates
|
|
816
|
+
if (getSong(parsed.id)) {
|
|
817
|
+
return {
|
|
818
|
+
content: [{
|
|
819
|
+
type: "text",
|
|
820
|
+
text: `A song with ID "${parsed.id}" already exists in the library.`,
|
|
821
|
+
}],
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
registerSong(parsed);
|
|
825
|
+
// Save to user songs directory
|
|
826
|
+
const userDir = getUserSongsDir();
|
|
827
|
+
const filePath = saveSong(parsed, userDir);
|
|
828
|
+
return {
|
|
829
|
+
content: [{
|
|
830
|
+
type: "text",
|
|
831
|
+
text: `Song "${parsed.title}" (${parsed.id}) added to the library.\n` +
|
|
832
|
+
`Genre: ${parsed.genre} | Difficulty: ${parsed.difficulty} | ` +
|
|
833
|
+
`${parsed.measures.length} measures | ${parsed.durationSeconds}s\n` +
|
|
834
|
+
`Saved to: ${filePath}`,
|
|
835
|
+
}],
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
return {
|
|
840
|
+
content: [{
|
|
841
|
+
type: "text",
|
|
842
|
+
text: `Failed to add song: ${err instanceof Error ? err.message : String(err)}`,
|
|
843
|
+
}],
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
// ─── Tool: import_midi ──────────────────────────────────────────────────
|
|
848
|
+
server.tool("import_midi", "Import a MIDI file as a song. Provide the file path and metadata. The MIDI is parsed, converted to a SongEntry, and saved to the user songs directory.", {
|
|
849
|
+
midi_path: z.string().describe("Path to .mid file"),
|
|
850
|
+
id: z.string().describe("Song ID (kebab-case, e.g. 'fur-elise')"),
|
|
851
|
+
title: z.string().describe("Song title"),
|
|
852
|
+
genre: z.enum(GENRES).describe("Genre"),
|
|
853
|
+
difficulty: z.enum(DIFFICULTIES).describe("Difficulty"),
|
|
854
|
+
key: z.string().describe("Key signature (e.g. 'C major', 'A minor')"),
|
|
855
|
+
composer: z.string().optional().describe("Composer or artist"),
|
|
856
|
+
description: z.string().optional().describe("1-3 sentence description of the piece"),
|
|
857
|
+
tags: z.array(z.string()).optional().describe("Tags for search (default: genre + difficulty)"),
|
|
858
|
+
}, async ({ midi_path, id, title, genre, difficulty, key, composer, description, tags }) => {
|
|
859
|
+
try {
|
|
860
|
+
const midiBuffer = new Uint8Array(readFileSync(midi_path));
|
|
861
|
+
const config = {
|
|
862
|
+
id,
|
|
863
|
+
title,
|
|
864
|
+
genre: genre,
|
|
865
|
+
difficulty: difficulty,
|
|
866
|
+
key,
|
|
867
|
+
composer,
|
|
868
|
+
tags: tags ?? [genre, difficulty],
|
|
869
|
+
status: "ready",
|
|
870
|
+
musicalLanguage: {
|
|
871
|
+
description: description ?? `${title} — a ${difficulty} ${genre} piece in ${key}.`,
|
|
872
|
+
structure: "To be determined",
|
|
873
|
+
keyMoments: [`Bar 1: ${title} begins`],
|
|
874
|
+
teachingGoals: [`Learn ${title} at ${difficulty} level`],
|
|
875
|
+
styleTips: [`Play in ${genre} style`],
|
|
876
|
+
},
|
|
877
|
+
};
|
|
878
|
+
const song = midiToSongEntry(midiBuffer, config);
|
|
879
|
+
// Check for duplicates
|
|
880
|
+
if (getSong(song.id)) {
|
|
881
|
+
return {
|
|
882
|
+
content: [{
|
|
883
|
+
type: "text",
|
|
884
|
+
text: `A song with ID "${song.id}" already exists in the library.`,
|
|
885
|
+
}],
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
registerSong(song);
|
|
889
|
+
const userDir = getUserSongsDir();
|
|
890
|
+
const filePath = saveSong(song, userDir);
|
|
891
|
+
return {
|
|
892
|
+
content: [{
|
|
893
|
+
type: "text",
|
|
894
|
+
text: `MIDI imported as "${song.title}" (${song.id}).\n` +
|
|
895
|
+
`Genre: ${song.genre} | Difficulty: ${song.difficulty} | Key: ${song.key}\n` +
|
|
896
|
+
`Tempo: ${song.tempo} BPM | Time: ${song.timeSignature} | ` +
|
|
897
|
+
`${song.measures.length} measures | ${song.durationSeconds}s\n` +
|
|
898
|
+
`Saved to: ${filePath}`,
|
|
899
|
+
}],
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
catch (err) {
|
|
903
|
+
return {
|
|
904
|
+
content: [{
|
|
905
|
+
type: "text",
|
|
906
|
+
text: `Failed to import MIDI: ${err instanceof Error ? err.message : String(err)}`,
|
|
907
|
+
}],
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
// ─── Tool: view_piano_roll ─────────────────────────────────────────────────
|
|
912
|
+
server.tool("view_piano_roll", "Render a piano roll visualization of a song as SVG. Returns an image showing note positions over time. Color modes: 'hand' (blue RH / coral LH, default) or 'pitch-class' (chromatic rainbow — each pitch class gets its own color, making harmonic patterns visible).", {
|
|
913
|
+
songId: z.string().describe("Song ID from the library (e.g. 'fur-elise')"),
|
|
914
|
+
startMeasure: z.number().int().min(1).optional().describe("First measure to render (1-based). Default: 1"),
|
|
915
|
+
endMeasure: z.number().int().min(1).optional().describe("Last measure to render (1-based). Default: last measure"),
|
|
916
|
+
color_mode: z.enum(["hand", "pitch-class"]).optional().describe("Note coloring: 'hand' (RH/LH, default) or 'pitch-class' (chromatic rainbow)"),
|
|
917
|
+
}, async ({ songId, startMeasure, endMeasure, color_mode }) => {
|
|
918
|
+
const song = getSong(songId);
|
|
919
|
+
if (!song) {
|
|
920
|
+
return {
|
|
921
|
+
content: [{ type: "text", text: `Song not found: "${songId}". Use list_songs to see available songs.` }],
|
|
922
|
+
isError: true,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
const svg = renderPianoRoll(song, {
|
|
926
|
+
startMeasure,
|
|
927
|
+
endMeasure,
|
|
928
|
+
colorMode: (color_mode ?? "hand"),
|
|
929
|
+
});
|
|
930
|
+
return {
|
|
931
|
+
content: [{
|
|
932
|
+
type: "image",
|
|
933
|
+
data: Buffer.from(svg).toString("base64"),
|
|
934
|
+
mimeType: "image/svg+xml",
|
|
935
|
+
}],
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
// ─── Tool: list_keyboards ──────────────────────────────────────────────────
|
|
939
|
+
server.tool("list_keyboards", "List available piano keyboard voices. Each voice has a different timbre suited to different genres. Use the keyboard parameter in play_song to choose one.", {}, async () => {
|
|
940
|
+
const voices = listVoices();
|
|
941
|
+
const lines = [
|
|
942
|
+
`# Piano Keyboards`,
|
|
943
|
+
``,
|
|
944
|
+
`${voices.length} voices available. Pass the ID to \`play_song\` with the \`keyboard\` parameter.`,
|
|
945
|
+
``,
|
|
946
|
+
];
|
|
947
|
+
for (const v of voices) {
|
|
948
|
+
const isDefault = v.id === "grand" ? " **(default)**" : "";
|
|
949
|
+
lines.push(`## ${v.name}${isDefault}`);
|
|
950
|
+
lines.push(`**ID:** \`${v.id}\``);
|
|
951
|
+
lines.push(`${v.description}`);
|
|
952
|
+
lines.push(`**Best for:** ${v.suggestedFor.join(", ")}`);
|
|
953
|
+
lines.push(``);
|
|
954
|
+
}
|
|
955
|
+
lines.push(`---`);
|
|
956
|
+
lines.push(`*Tip: Use \`suggestVoice\` logic — the play_song tool will use the genre-suggested keyboard if none is specified.*`);
|
|
957
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
958
|
+
});
|
|
959
|
+
// ─── Tool: tune_keyboard ──────────────────────────────────────────────────
|
|
960
|
+
server.tool("tune_keyboard", "Tune a piano keyboard voice by adjusting synthesis parameters. Changes are saved and persist across sessions. Use get_keyboard_config to see current settings, reset_keyboard to restore factory defaults.", {
|
|
961
|
+
id: z.enum(VOICE_IDS).describe("Voice ID to tune"),
|
|
962
|
+
brightness: z.number().min(0.05).max(0.5).optional().describe("Brightness at moderate velocity (0.05=very bright, 0.5=very dark)"),
|
|
963
|
+
"brightness-slope": z.number().min(0.03).max(0.2).optional().describe("Velocity sensitivity for upper partials"),
|
|
964
|
+
decay: z.number().min(1).max(10).optional().describe("Sustain length in seconds (treble end)"),
|
|
965
|
+
"bass-decay": z.number().min(4).max(25).optional().describe("Additional sustain for bass in seconds"),
|
|
966
|
+
hammer: z.number().min(0).max(0.5).optional().describe("Hammer attack intensity (0=none)"),
|
|
967
|
+
detune: z.number().min(0).max(20).optional().describe("Random detuning in cents (chorus effect)"),
|
|
968
|
+
stereo: z.number().min(0).max(1).optional().describe("Stereo spread (0=mono, 1=full)"),
|
|
969
|
+
volume: z.number().min(0.1).max(0.5).optional().describe("Per-voice volume"),
|
|
970
|
+
release: z.number().min(0.03).max(0.3).optional().describe("Damper speed in seconds"),
|
|
971
|
+
rolloff: z.number().min(0.3).max(1.5).optional().describe("Harmonic darkness (higher=darker)"),
|
|
972
|
+
"attack-fast": z.number().min(0.001).max(0.01).optional().describe("Fastest attack time (ff) in seconds"),
|
|
973
|
+
"attack-slow": z.number().min(0.003).max(0.02).optional().describe("Slowest attack time (pp) in seconds"),
|
|
974
|
+
}, async (params) => {
|
|
975
|
+
const { id, ...tuningParams } = params;
|
|
976
|
+
// Collect non-undefined tuning params
|
|
977
|
+
const overrides = {};
|
|
978
|
+
for (const [key, val] of Object.entries(tuningParams)) {
|
|
979
|
+
if (val !== undefined)
|
|
980
|
+
overrides[key] = val;
|
|
981
|
+
}
|
|
982
|
+
if (Object.keys(overrides).length === 0) {
|
|
983
|
+
return {
|
|
984
|
+
content: [{ type: "text", text: `No tuning parameters provided. Available: ${TUNING_PARAMS.map(p => p.key).join(", ")}` }],
|
|
985
|
+
isError: true,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
saveUserTuning(id, overrides);
|
|
989
|
+
const merged = getMergedVoice(id);
|
|
990
|
+
const userTuning = loadUserTuning(id);
|
|
991
|
+
const lines = [
|
|
992
|
+
`Tuned **${merged.name}** (\`${id}\`):`,
|
|
993
|
+
``,
|
|
994
|
+
];
|
|
995
|
+
for (const [key, val] of Object.entries(overrides)) {
|
|
996
|
+
const param = TUNING_PARAMS.find(p => p.key === key);
|
|
997
|
+
lines.push(`- **${key}**: ${val}${param ? ` — ${param.description}` : ""}`);
|
|
998
|
+
}
|
|
999
|
+
lines.push(``, `${Object.keys(userTuning).length} total override(s) saved. Use \`reset_keyboard\` to restore factory defaults.`);
|
|
1000
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1001
|
+
});
|
|
1002
|
+
// ─── Tool: get_keyboard_config ────────────────────────────────────────────
|
|
1003
|
+
server.tool("get_keyboard_config", "Show the full configuration of a keyboard voice, including any user tuning overrides. Shows both the factory preset values and any custom adjustments.", {
|
|
1004
|
+
id: z.enum(VOICE_IDS).describe("Voice ID to inspect"),
|
|
1005
|
+
}, async ({ id }) => {
|
|
1006
|
+
const base = getVoice(id);
|
|
1007
|
+
const userTuning = loadUserTuning(id);
|
|
1008
|
+
const merged = getMergedVoice(id);
|
|
1009
|
+
const hasOverrides = Object.keys(userTuning).length > 0;
|
|
1010
|
+
const lines = [
|
|
1011
|
+
`# ${merged.name} (\`${id}\`)`,
|
|
1012
|
+
`${merged.description}`,
|
|
1013
|
+
`**Best for:** ${merged.suggestedFor.join(", ")}`,
|
|
1014
|
+
``,
|
|
1015
|
+
`## Tunable Parameters`,
|
|
1016
|
+
``,
|
|
1017
|
+
`| Parameter | Factory | Current | Range |`,
|
|
1018
|
+
`|-----------|---------|---------|-------|`,
|
|
1019
|
+
];
|
|
1020
|
+
for (const param of TUNING_PARAMS) {
|
|
1021
|
+
let factoryVal;
|
|
1022
|
+
let currentVal;
|
|
1023
|
+
if (param.isArrayIndex !== undefined) {
|
|
1024
|
+
factoryVal = base[param.configKey][param.isArrayIndex];
|
|
1025
|
+
currentVal = merged[param.configKey][param.isArrayIndex];
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
factoryVal = base[param.configKey];
|
|
1029
|
+
currentVal = merged[param.configKey];
|
|
1030
|
+
}
|
|
1031
|
+
const isOverridden = param.key in userTuning;
|
|
1032
|
+
const marker = isOverridden ? " *" : "";
|
|
1033
|
+
lines.push(`| ${param.key} | ${factoryVal} | ${currentVal}${marker} | ${param.min}–${param.max} |`);
|
|
1034
|
+
}
|
|
1035
|
+
if (hasOverrides) {
|
|
1036
|
+
lines.push(``, `*\\* = user override*`);
|
|
1037
|
+
lines.push(``, `Use \`reset_keyboard\` to clear all overrides.`);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
lines.push(``, `*No user overrides. Using factory preset.*`);
|
|
1041
|
+
}
|
|
1042
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1043
|
+
});
|
|
1044
|
+
// ─── Tool: reset_keyboard ─────────────────────────────────────────────────
|
|
1045
|
+
server.tool("reset_keyboard", "Reset a keyboard voice to factory default settings, clearing all user tuning overrides.", {
|
|
1046
|
+
id: z.enum(VOICE_IDS).describe("Voice ID to reset"),
|
|
1047
|
+
}, async ({ id }) => {
|
|
1048
|
+
const hadOverrides = Object.keys(loadUserTuning(id)).length > 0;
|
|
1049
|
+
resetUserTuning(id);
|
|
1050
|
+
const voice = getVoice(id);
|
|
1051
|
+
if (hadOverrides) {
|
|
1052
|
+
return { content: [{ type: "text", text: `Reset **${voice.name}** (\`${id}\`) to factory defaults. All user tuning overrides cleared.` }] };
|
|
1053
|
+
}
|
|
1054
|
+
return { content: [{ type: "text", text: `**${voice.name}** (\`${id}\`) was already at factory defaults.` }] };
|
|
1055
|
+
});
|
|
1056
|
+
// ─── Tool: playback_status ────────────────────────────────────────────────
|
|
1057
|
+
server.tool("playback_status", "Get a real-time snapshot of the current playback state: measure, tempo, speed, keyboard voice, and more. Returns nothing if no song is playing.", {}, async () => {
|
|
1058
|
+
// Library song session
|
|
1059
|
+
if (activeSession) {
|
|
1060
|
+
const s = activeSession.session;
|
|
1061
|
+
const state = activeSession.state;
|
|
1062
|
+
const song = s.song;
|
|
1063
|
+
const measure = activeSession.currentMeasureDisplay;
|
|
1064
|
+
const total = activeSession.totalMeasures;
|
|
1065
|
+
const effectiveTempo = activeSession.effectiveTempo();
|
|
1066
|
+
const baseTempo = activeSession.baseTempo();
|
|
1067
|
+
const speed = s.speed;
|
|
1068
|
+
const lines = [
|
|
1069
|
+
`# Playback Status`,
|
|
1070
|
+
``,
|
|
1071
|
+
`- **Song:** ${song.title}${song.composer ? ` — ${song.composer}` : ""}`,
|
|
1072
|
+
`- **State:** ${state}`,
|
|
1073
|
+
`- **Keyboard:** ${activeVoiceId}`,
|
|
1074
|
+
`- **Measure:** ${measure} / ${total} (${Math.round(measure / total * 100)}%)`,
|
|
1075
|
+
`- **Tempo:** ${baseTempo} BPM × ${speed}x = ${effectiveTempo} BPM`,
|
|
1076
|
+
`- **Key:** ${song.key} | **Time:** ${song.timeSignature}`,
|
|
1077
|
+
`- **Mode:** ${s.mode}`,
|
|
1078
|
+
`- **Measures played:** ${s.measuresPlayed}`,
|
|
1079
|
+
];
|
|
1080
|
+
// Current measure info
|
|
1081
|
+
if (measure > 0 && measure <= song.measures.length) {
|
|
1082
|
+
const m = song.measures[measure - 1];
|
|
1083
|
+
if (m.dynamics)
|
|
1084
|
+
lines.push(`- **Dynamics:** ${m.dynamics}`);
|
|
1085
|
+
if (m.teachingNote)
|
|
1086
|
+
lines.push(`- **Teaching:** ${m.teachingNote}`);
|
|
1087
|
+
}
|
|
1088
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1089
|
+
}
|
|
1090
|
+
// MIDI file playback
|
|
1091
|
+
if (activeController) {
|
|
1092
|
+
const state = activeController.state;
|
|
1093
|
+
const pos = activeController.positionSeconds;
|
|
1094
|
+
const events = activeController.eventsPlayed;
|
|
1095
|
+
const total = activeController.totalEvents;
|
|
1096
|
+
const speed = activeController.speed;
|
|
1097
|
+
const lines = [
|
|
1098
|
+
`# Playback Status (MIDI)`,
|
|
1099
|
+
``,
|
|
1100
|
+
`- **State:** ${state}`,
|
|
1101
|
+
`- **Keyboard:** ${activeVoiceId}`,
|
|
1102
|
+
`- **Position:** ${pos.toFixed(1)}s`,
|
|
1103
|
+
`- **Events:** ${events} / ${total} (${Math.round(events / total * 100)}%)`,
|
|
1104
|
+
`- **Speed:** ${speed}x`,
|
|
1105
|
+
];
|
|
1106
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
1107
|
+
}
|
|
1108
|
+
if (activeMidiEngine) {
|
|
1109
|
+
return { content: [{ type: "text", text: `Playback active (MIDI engine, no detailed status available).` }] };
|
|
1110
|
+
}
|
|
1111
|
+
return { content: [{ type: "text", text: `No active playback. Use \`play_song\` to start playing.` }] };
|
|
1112
|
+
});
|
|
1113
|
+
// ─── Tool: save_practice_note ──────────────────────────────────────────────
|
|
1114
|
+
server.tool("save_practice_note", "Save a practice journal entry. Combines your reflections with auto-captured session data (what you just played, speed, measures, duration). The journal persists across sessions — next time, use read_practice_journal to pick up where you left off.", {
|
|
1115
|
+
note: z.string().describe("Your reflection — what you learned, what you noticed, what to try next. Write naturally, like a musician's notebook."),
|
|
1116
|
+
song_id: z.string().optional().describe("Override which song this entry is about (defaults to the last song you played)"),
|
|
1117
|
+
}, async ({ note, song_id }) => {
|
|
1118
|
+
// Resolve session: use override song_id or fall back to last played
|
|
1119
|
+
let session = lastCompletedSession;
|
|
1120
|
+
if (song_id) {
|
|
1121
|
+
const song = getSong(song_id);
|
|
1122
|
+
if (song) {
|
|
1123
|
+
session = {
|
|
1124
|
+
songId: song.id,
|
|
1125
|
+
title: song.title,
|
|
1126
|
+
composer: song.composer,
|
|
1127
|
+
genre: song.genre,
|
|
1128
|
+
difficulty: song.difficulty,
|
|
1129
|
+
key: song.key,
|
|
1130
|
+
tempo: song.tempo,
|
|
1131
|
+
speed: 1.0,
|
|
1132
|
+
mode: "note",
|
|
1133
|
+
measuresPlayed: 0,
|
|
1134
|
+
totalMeasures: song.measures.length,
|
|
1135
|
+
durationSeconds: 0,
|
|
1136
|
+
timestamp: new Date().toISOString(),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const entry = buildJournalEntry(session, note);
|
|
1141
|
+
const filepath = appendJournalEntry(entry);
|
|
1142
|
+
const stats = journalStats();
|
|
1143
|
+
return {
|
|
1144
|
+
content: [{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: `Journal entry saved to ${filepath}\n` +
|
|
1147
|
+
`Total: ${stats.totalEntries} entries across ${stats.totalDays} day(s).\n\n` +
|
|
1148
|
+
`---\n${entry}`,
|
|
1149
|
+
}],
|
|
1150
|
+
};
|
|
1151
|
+
});
|
|
1152
|
+
// ─── Tool: read_practice_journal ────────────────────────────────────────────
|
|
1153
|
+
server.tool("read_practice_journal", "Read your practice journal — reflections, observations, and session history from previous sessions. Use this at the start of a session to remember what you learned before, or to review notes on a specific song.", {
|
|
1154
|
+
days: z.number().int().min(1).max(90).optional().describe("How many days back to read (default: 7)"),
|
|
1155
|
+
song_id: z.string().optional().describe("Filter entries to a specific song"),
|
|
1156
|
+
}, async ({ days, song_id }) => {
|
|
1157
|
+
const journal = readJournal(days ?? 7, song_id);
|
|
1158
|
+
const stats = journalStats();
|
|
1159
|
+
if (stats.totalEntries === 0) {
|
|
1160
|
+
return {
|
|
1161
|
+
content: [{
|
|
1162
|
+
type: "text",
|
|
1163
|
+
text: "No practice journal entries yet. Play a song and use `save_practice_note` to start your journal.",
|
|
1164
|
+
}],
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
return {
|
|
1168
|
+
content: [{
|
|
1169
|
+
type: "text",
|
|
1170
|
+
text: `Practice journal (${stats.totalEntries} entries across ${stats.totalDays} days):\n\n${journal}`,
|
|
1171
|
+
}],
|
|
1172
|
+
};
|
|
1173
|
+
});
|
|
1174
|
+
// ─── Tool: annotate_song ──────────────────────────────────────────────────
|
|
1175
|
+
server.tool("annotate_song", "Annotate a raw song with musical language and promote it to 'ready' status. This is how you do your homework — study the exemplar in the genre, then write your own annotation for a raw song. Once annotated, the song becomes playable immediately.", {
|
|
1176
|
+
song_id: z.string().describe("The song ID to annotate (must be a raw or annotated song in the library)"),
|
|
1177
|
+
description: z.string().describe("1-3 sentence musical description of the piece"),
|
|
1178
|
+
structure: z.string().describe("Form/structure description (e.g. 'AABA 32-bar form', '12-bar blues')"),
|
|
1179
|
+
key_moments: z.array(z.string()).min(1).max(5).describe("Notable musical moments (1-5 items)"),
|
|
1180
|
+
teaching_goals: z.array(z.string()).min(1).max(5).describe("What this song teaches (1-5 items)"),
|
|
1181
|
+
style_tips: z.array(z.string()).min(1).max(5).describe("How to play it authentically (1-5 items)"),
|
|
1182
|
+
}, async ({ song_id, description, structure, key_moments, teaching_goals, style_tips }) => {
|
|
1183
|
+
// Find the config file in the library
|
|
1184
|
+
const { join, dirname } = await import("node:path");
|
|
1185
|
+
const { fileURLToPath } = await import("node:url");
|
|
1186
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1187
|
+
const libraryDir = join(__dirname, "..", "songs", "library");
|
|
1188
|
+
// Scan library for this song
|
|
1189
|
+
const entries = scanLibrary(libraryDir);
|
|
1190
|
+
const entry = entries.find(e => e.config.id === song_id);
|
|
1191
|
+
if (!entry) {
|
|
1192
|
+
return {
|
|
1193
|
+
content: [{ type: "text", text: `Song "${song_id}" not found in the library.` }],
|
|
1194
|
+
isError: true,
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
// Update the config JSON
|
|
1198
|
+
const config = entry.config;
|
|
1199
|
+
config.status = "ready";
|
|
1200
|
+
config.musicalLanguage = {
|
|
1201
|
+
description,
|
|
1202
|
+
structure,
|
|
1203
|
+
keyMoments: key_moments,
|
|
1204
|
+
teachingGoals: teaching_goals,
|
|
1205
|
+
styleTips: style_tips,
|
|
1206
|
+
};
|
|
1207
|
+
// Write back to disk
|
|
1208
|
+
writeFileSync(entry.configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
1209
|
+
// Re-ingest so the song is immediately playable
|
|
1210
|
+
try {
|
|
1211
|
+
const song = ingestSong(entry);
|
|
1212
|
+
registerSong(song);
|
|
1213
|
+
return {
|
|
1214
|
+
content: [{
|
|
1215
|
+
type: "text",
|
|
1216
|
+
text: `Song "${config.title}" annotated and promoted to ready!\n` +
|
|
1217
|
+
`Genre: ${config.genre} | Key: ${config.key} | ${song.measures.length} measures\n` +
|
|
1218
|
+
`The song is now playable — try \`play_song { id: "${song_id}" }\``,
|
|
1219
|
+
}],
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
catch (err) {
|
|
1223
|
+
return {
|
|
1224
|
+
content: [{
|
|
1225
|
+
type: "text",
|
|
1226
|
+
text: `Annotation saved but ingestion failed: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
1227
|
+
`The config was updated at ${entry.configPath}. Check the MIDI file.`,
|
|
1228
|
+
}],
|
|
1229
|
+
isError: true,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
1234
|
+
function getUserSongsDir() {
|
|
1235
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
1236
|
+
return `${home}/.pianoai/songs`;
|
|
1237
|
+
}
|
|
1238
|
+
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
1239
|
+
async function main() {
|
|
1240
|
+
// Load songs from library + user directories
|
|
1241
|
+
const { dirname } = await import("node:path");
|
|
1242
|
+
const { fileURLToPath } = await import("node:url");
|
|
1243
|
+
const { join } = await import("node:path");
|
|
1244
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1245
|
+
const libraryDir = join(__dirname, "..", "songs", "library");
|
|
1246
|
+
const userDir = join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".pianoai", "songs");
|
|
1247
|
+
initializeFromLibrary(libraryDir, userDir);
|
|
1248
|
+
const transport = new StdioServerTransport();
|
|
1249
|
+
await server.connect(transport);
|
|
1250
|
+
console.error("pianoai MCP server running on stdio");
|
|
1251
|
+
}
|
|
1252
|
+
main().catch((err) => {
|
|
1253
|
+
console.error("Fatal error:", err);
|
|
1254
|
+
process.exit(1);
|
|
1255
|
+
});
|
|
1256
|
+
//# sourceMappingURL=mcp-server.js.map
|