@mcptoolshop/ai_jam_session 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.
Files changed (246) hide show
  1. package/LICENSE +21 -0
  2. package/README.es.md +212 -0
  3. package/README.fr.md +212 -0
  4. package/README.hi.md +212 -0
  5. package/README.it.md +212 -0
  6. package/README.ja.md +214 -0
  7. package/README.md +214 -0
  8. package/README.pt-BR.md +212 -0
  9. package/dist/audio-engine.d.ts +9 -0
  10. package/dist/audio-engine.d.ts.map +1 -0
  11. package/dist/audio-engine.js +422 -0
  12. package/dist/audio-engine.js.map +1 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +551 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/index.d.ts +32 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +41 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/mcp-server.d.ts +3 -0
  22. package/dist/mcp-server.d.ts.map +1 -0
  23. package/dist/mcp-server.js +903 -0
  24. package/dist/mcp-server.js.map +1 -0
  25. package/dist/midi/parser.d.ts +16 -0
  26. package/dist/midi/parser.d.ts.map +1 -0
  27. package/dist/midi/parser.js +192 -0
  28. package/dist/midi/parser.js.map +1 -0
  29. package/dist/midi/types.d.ts +44 -0
  30. package/dist/midi/types.d.ts.map +1 -0
  31. package/dist/midi/types.js +8 -0
  32. package/dist/midi/types.js.map +1 -0
  33. package/dist/note-parser.d.ts +105 -0
  34. package/dist/note-parser.d.ts.map +1 -0
  35. package/dist/note-parser.js +278 -0
  36. package/dist/note-parser.js.map +1 -0
  37. package/dist/playback/controls.d.ts +124 -0
  38. package/dist/playback/controls.d.ts.map +1 -0
  39. package/dist/playback/controls.js +252 -0
  40. package/dist/playback/controls.js.map +1 -0
  41. package/dist/playback/midi-engine.d.ts +68 -0
  42. package/dist/playback/midi-engine.d.ts.map +1 -0
  43. package/dist/playback/midi-engine.js +227 -0
  44. package/dist/playback/midi-engine.js.map +1 -0
  45. package/dist/playback/position.d.ts +95 -0
  46. package/dist/playback/position.d.ts.map +1 -0
  47. package/dist/playback/position.js +223 -0
  48. package/dist/playback/position.js.map +1 -0
  49. package/dist/playback/timing.d.ts +31 -0
  50. package/dist/playback/timing.d.ts.map +1 -0
  51. package/dist/playback/timing.js +57 -0
  52. package/dist/playback/timing.js.map +1 -0
  53. package/dist/sample-engine.d.ts +17 -0
  54. package/dist/sample-engine.d.ts.map +1 -0
  55. package/dist/sample-engine.js +428 -0
  56. package/dist/sample-engine.js.map +1 -0
  57. package/dist/schemas.d.ts +40 -0
  58. package/dist/schemas.d.ts.map +1 -0
  59. package/dist/schemas.js +42 -0
  60. package/dist/schemas.js.map +1 -0
  61. package/dist/session.d.ts +106 -0
  62. package/dist/session.d.ts.map +1 -0
  63. package/dist/session.js +361 -0
  64. package/dist/session.js.map +1 -0
  65. package/dist/sfz-parser.d.ts +36 -0
  66. package/dist/sfz-parser.d.ts.map +1 -0
  67. package/dist/sfz-parser.js +95 -0
  68. package/dist/sfz-parser.js.map +1 -0
  69. package/dist/smoke.d.ts +2 -0
  70. package/dist/smoke.d.ts.map +1 -0
  71. package/dist/smoke.js +512 -0
  72. package/dist/smoke.js.map +1 -0
  73. package/dist/songs/config/loader.d.ts +14 -0
  74. package/dist/songs/config/loader.d.ts.map +1 -0
  75. package/dist/songs/config/loader.js +53 -0
  76. package/dist/songs/config/loader.js.map +1 -0
  77. package/dist/songs/config/schema.d.ts +70 -0
  78. package/dist/songs/config/schema.d.ts.map +1 -0
  79. package/dist/songs/config/schema.js +53 -0
  80. package/dist/songs/config/schema.js.map +1 -0
  81. package/dist/songs/index.d.ts +16 -0
  82. package/dist/songs/index.d.ts.map +1 -0
  83. package/dist/songs/index.js +20 -0
  84. package/dist/songs/index.js.map +1 -0
  85. package/dist/songs/jam.d.ts +48 -0
  86. package/dist/songs/jam.d.ts.map +1 -0
  87. package/dist/songs/jam.js +324 -0
  88. package/dist/songs/jam.js.map +1 -0
  89. package/dist/songs/loader.d.ts +27 -0
  90. package/dist/songs/loader.d.ts.map +1 -0
  91. package/dist/songs/loader.js +90 -0
  92. package/dist/songs/loader.js.map +1 -0
  93. package/dist/songs/midi/hands.d.ts +46 -0
  94. package/dist/songs/midi/hands.d.ts.map +1 -0
  95. package/dist/songs/midi/hands.js +134 -0
  96. package/dist/songs/midi/hands.js.map +1 -0
  97. package/dist/songs/midi/ingest.d.ts +8 -0
  98. package/dist/songs/midi/ingest.d.ts.map +1 -0
  99. package/dist/songs/midi/ingest.js +191 -0
  100. package/dist/songs/midi/ingest.js.map +1 -0
  101. package/dist/songs/midi/measures.d.ts +41 -0
  102. package/dist/songs/midi/measures.d.ts.map +1 -0
  103. package/dist/songs/midi/measures.js +64 -0
  104. package/dist/songs/midi/measures.js.map +1 -0
  105. package/dist/songs/midi/types.d.ts +25 -0
  106. package/dist/songs/midi/types.d.ts.map +1 -0
  107. package/dist/songs/midi/types.js +8 -0
  108. package/dist/songs/midi/types.js.map +1 -0
  109. package/dist/songs/registry.d.ts +37 -0
  110. package/dist/songs/registry.d.ts.map +1 -0
  111. package/dist/songs/registry.js +197 -0
  112. package/dist/songs/registry.js.map +1 -0
  113. package/dist/songs/types.d.ts +99 -0
  114. package/dist/songs/types.d.ts.map +1 -0
  115. package/dist/songs/types.js +27 -0
  116. package/dist/songs/types.js.map +1 -0
  117. package/dist/teaching/live-midi-feedback.d.ts +36 -0
  118. package/dist/teaching/live-midi-feedback.d.ts.map +1 -0
  119. package/dist/teaching/live-midi-feedback.js +259 -0
  120. package/dist/teaching/live-midi-feedback.js.map +1 -0
  121. package/dist/teaching/midi-feedback.d.ts +33 -0
  122. package/dist/teaching/midi-feedback.d.ts.map +1 -0
  123. package/dist/teaching/midi-feedback.js +208 -0
  124. package/dist/teaching/midi-feedback.js.map +1 -0
  125. package/dist/teaching/sing-on-midi.d.ts +77 -0
  126. package/dist/teaching/sing-on-midi.d.ts.map +1 -0
  127. package/dist/teaching/sing-on-midi.js +186 -0
  128. package/dist/teaching/sing-on-midi.js.map +1 -0
  129. package/dist/teaching.d.ts +148 -0
  130. package/dist/teaching.d.ts.map +1 -0
  131. package/dist/teaching.js +453 -0
  132. package/dist/teaching.js.map +1 -0
  133. package/dist/test-sound.d.ts +3 -0
  134. package/dist/test-sound.d.ts.map +1 -0
  135. package/dist/test-sound.js +41 -0
  136. package/dist/test-sound.js.map +1 -0
  137. package/dist/types.d.ts +229 -0
  138. package/dist/types.d.ts.map +1 -0
  139. package/dist/types.js +22 -0
  140. package/dist/types.js.map +1 -0
  141. package/dist/vmpk.d.ts +23 -0
  142. package/dist/vmpk.d.ts.map +1 -0
  143. package/dist/vmpk.js +236 -0
  144. package/dist/vmpk.js.map +1 -0
  145. package/logo.png +0 -0
  146. package/package.json +70 -0
  147. package/songs/builtin/a-change-is-gonna-come.json +95 -0
  148. package/songs/builtin/a-thousand-years.json +93 -0
  149. package/songs/builtin/aint-no-sunshine.json +98 -0
  150. package/songs/builtin/all-blues.json +92 -0
  151. package/songs/builtin/autumn-leaves.json +100 -0
  152. package/songs/builtin/baba-oriley.json +91 -0
  153. package/songs/builtin/bach-invention-no1.json +70 -0
  154. package/songs/builtin/bach-prelude-c-major-bwv846.json +1282 -0
  155. package/songs/builtin/basic-12-bar-blues.json +119 -0
  156. package/songs/builtin/beethoven-waldstein-mvt1.json +7766 -0
  157. package/songs/builtin/bennie-and-the-jets.json +92 -0
  158. package/songs/builtin/besame-mucho.json +93 -0
  159. package/songs/builtin/black-orpheus.json +92 -0
  160. package/songs/builtin/blue-bossa.json +94 -0
  161. package/songs/builtin/blues-in-g.json +92 -0
  162. package/songs/builtin/bohemian-rhapsody-intro.json +94 -0
  163. package/songs/builtin/boogie-woogie-basics.json +93 -0
  164. package/songs/builtin/bossa-nova-basic.json +95 -0
  165. package/songs/builtin/chopin-nocturne-op9-no2.json +70 -0
  166. package/songs/builtin/cinema-paradiso.json +94 -0
  167. package/songs/builtin/clair-de-lune.json +11511 -0
  168. package/songs/builtin/clocks.json +91 -0
  169. package/songs/builtin/crystal-stream.json +70 -0
  170. package/songs/builtin/desafinado.json +93 -0
  171. package/songs/builtin/dont-stop-believin.json +91 -0
  172. package/songs/builtin/dream-on.json +100 -0
  173. package/songs/builtin/easy-winners.json +70 -0
  174. package/songs/builtin/el-condor-pasa.json +93 -0
  175. package/songs/builtin/elite-syncopations.json +70 -0
  176. package/songs/builtin/evening-calm.json +70 -0
  177. package/songs/builtin/everyday-blues.json +93 -0
  178. package/songs/builtin/fly-me-to-the-moon.json +91 -0
  179. package/songs/builtin/forrest-gump-suite.json +93 -0
  180. package/songs/builtin/fur-elise.json +20094 -0
  181. package/songs/builtin/georgia-on-my-mind.json +93 -0
  182. package/songs/builtin/girl-from-ipanema.json +92 -0
  183. package/songs/builtin/gladiolus-rag.json +70 -0
  184. package/songs/builtin/great-balls-of-fire.json +92 -0
  185. package/songs/builtin/guantanamera.json +92 -0
  186. package/songs/builtin/hallelujah.json +92 -0
  187. package/songs/builtin/hedwigs-theme.json +93 -0
  188. package/songs/builtin/hotel-california.json +92 -0
  189. package/songs/builtin/imagine.json +92 -0
  190. package/songs/builtin/just-the-two-of-us.json +92 -0
  191. package/songs/builtin/la-bamba.json +92 -0
  192. package/songs/builtin/layla-piano-coda.json +93 -0
  193. package/songs/builtin/lean-on-me.json +91 -0
  194. package/songs/builtin/let-it-be.json +101 -0
  195. package/songs/builtin/lets-stay-together.json +93 -0
  196. package/songs/builtin/magnetic-rag.json +70 -0
  197. package/songs/builtin/maple-leaf-rag.json +99 -0
  198. package/songs/builtin/mia-and-sebastians-theme.json +93 -0
  199. package/songs/builtin/minor-blues-in-a.json +94 -0
  200. package/songs/builtin/misty.json +94 -0
  201. package/songs/builtin/moon-river.json +93 -0
  202. package/songs/builtin/moonlight-sonata-mvt1.json +101 -0
  203. package/songs/builtin/morning-light.json +70 -0
  204. package/songs/builtin/mountain-dawn.json +70 -0
  205. package/songs/builtin/mozart-k545-mvt1.json +2956 -0
  206. package/songs/builtin/my-girl.json +92 -0
  207. package/songs/builtin/night-train.json +92 -0
  208. package/songs/builtin/november-rain.json +93 -0
  209. package/songs/builtin/ocean-waves.json +70 -0
  210. package/songs/builtin/over-the-rainbow.json +93 -0
  211. package/songs/builtin/oye-como-va.json +93 -0
  212. package/songs/builtin/peacherine-rag.json +70 -0
  213. package/songs/builtin/piano-man.json +92 -0
  214. package/songs/builtin/pineapple-rag.json +70 -0
  215. package/songs/builtin/pink-panther.json +94 -0
  216. package/songs/builtin/ragtime-dance.json +70 -0
  217. package/songs/builtin/river-flows-in-you.json +102 -0
  218. package/songs/builtin/rocket-man.json +92 -0
  219. package/songs/builtin/satie-gymnopedie-no1.json +70 -0
  220. package/songs/builtin/satin-doll.json +93 -0
  221. package/songs/builtin/schindlers-list.json +96 -0
  222. package/songs/builtin/schumann-traumerei.json +70 -0
  223. package/songs/builtin/sitting-on-the-dock.json +91 -0
  224. package/songs/builtin/slow-blues-in-bb.json +98 -0
  225. package/songs/builtin/snowfall.json +70 -0
  226. package/songs/builtin/so-what.json +92 -0
  227. package/songs/builtin/solace.json +70 -0
  228. package/songs/builtin/someone-like-you.json +92 -0
  229. package/songs/builtin/spirited-away.json +94 -0
  230. package/songs/builtin/st-louis-blues.json +93 -0
  231. package/songs/builtin/stairway-intro.json +93 -0
  232. package/songs/builtin/starlight-waltz.json +70 -0
  233. package/songs/builtin/stay-with-me.json +93 -0
  234. package/songs/builtin/stormy-monday.json +94 -0
  235. package/songs/builtin/superstition.json +93 -0
  236. package/songs/builtin/sweet-home-chicago.json +93 -0
  237. package/songs/builtin/take-five.json +92 -0
  238. package/songs/builtin/take-the-a-train.json +93 -0
  239. package/songs/builtin/the-entertainer.json +98 -0
  240. package/songs/builtin/the-godfather-waltz.json +93 -0
  241. package/songs/builtin/thrill-is-gone.json +94 -0
  242. package/songs/builtin/twilight-garden.json +70 -0
  243. package/songs/builtin/watermark.json +70 -0
  244. package/songs/builtin/wave.json +93 -0
  245. package/songs/builtin/whats-going-on.json +93 -0
  246. package/songs/builtin/yesterday.json +92 -0
@@ -0,0 +1,903 @@
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, initializeRegistry, midiToSongEntry, generateJamBrief, formatJamBrief, GENRES, DIFFICULTIES, } from "./songs/index.js";
27
+ import { readFileSync } from "node:fs";
28
+ import { safeParseMeasure, measureToSingableText } from "./note-parser.js";
29
+ import { createAudioEngine } from "./audio-engine.js";
30
+ import { createSession } from "./session.js";
31
+ import { createConsoleTeachingHook, composeTeachingHooks } from "./teaching.js";
32
+ import { parseMidiFile } from "./midi/parser.js";
33
+ import { MidiPlaybackEngine } from "./playback/midi-engine.js";
34
+ import { PlaybackController } from "./playback/controls.js";
35
+ import { createSingOnMidiHook } from "./teaching/sing-on-midi.js";
36
+ import { createLiveMidiFeedbackHook } from "./teaching/live-midi-feedback.js";
37
+ import { existsSync } from "node:fs";
38
+ // ─── Helpers ────────────────────────────────────────────────────────────────
39
+ /** Suggest practice speed based on song difficulty. */
40
+ function suggestSpeed(difficulty) {
41
+ switch (difficulty) {
42
+ case "beginner": return { speed: 0.5, label: "0.5× (half speed)" };
43
+ case "intermediate": return { speed: 0.75, label: "0.75× (three-quarter speed)" };
44
+ case "advanced": return { speed: 0.7, label: "0.7× (recommended for first pass)" };
45
+ default: return { speed: 1.0, label: "1.0× (full speed)" };
46
+ }
47
+ }
48
+ /** Suggest playback mode based on difficulty. */
49
+ function suggestMode(difficulty) {
50
+ switch (difficulty) {
51
+ case "beginner":
52
+ return { mode: "measure", reason: "Step through one measure at a time for careful learning" };
53
+ case "intermediate":
54
+ return { mode: "hands", reason: "Practice hands separately before combining" };
55
+ case "advanced":
56
+ return { mode: "hands", reason: "Master each hand individually for complex passages" };
57
+ default:
58
+ return { mode: "full", reason: "Play straight through at tempo" };
59
+ }
60
+ }
61
+ // ─── Server ─────────────────────────────────────────────────────────────────
62
+ const server = new McpServer({
63
+ name: "pianoai",
64
+ version: "0.1.0",
65
+ });
66
+ // ─── Tool: list_songs ───────────────────────────────────────────────────────
67
+ server.tool("list_songs", "Browse and search the piano song library. Filter by genre, difficulty, or search query.", {
68
+ genre: z.enum(GENRES).optional().describe("Filter by genre"),
69
+ difficulty: z.enum(DIFFICULTIES).optional().describe("Filter by difficulty"),
70
+ query: z.string().optional().describe("Search query (matches title, composer, tags, description)"),
71
+ }, async (params) => {
72
+ const results = searchSongs({
73
+ genre: params.genre,
74
+ difficulty: params.difficulty,
75
+ query: params.query,
76
+ });
77
+ const text = results.length === 0
78
+ ? "No songs found matching your criteria."
79
+ : results
80
+ .map((s) => `${s.id} — ${s.title} (${s.genre}, ${s.difficulty}, ${s.measures.length} measures)`)
81
+ .join("\n");
82
+ return {
83
+ content: [{ type: "text", text: `Found ${results.length} song(s):\n\n${text}` }],
84
+ };
85
+ });
86
+ // ─── Tool: song_info ────────────────────────────────────────────────────────
87
+ server.tool("song_info", "Get detailed information about a specific song — musical language, teaching goals, key moments, structure.", {
88
+ id: z.string().describe("Song ID (kebab-case, e.g. 'moonlight-sonata-mvt1')"),
89
+ }, async ({ id }) => {
90
+ const song = getSong(id);
91
+ if (!song) {
92
+ return {
93
+ content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
94
+ isError: true,
95
+ };
96
+ }
97
+ const ml = song.musicalLanguage;
98
+ const { speed, label: speedLabel } = suggestSpeed(song.difficulty);
99
+ const { mode, reason: modeReason } = suggestMode(song.difficulty);
100
+ const text = [
101
+ `# ${song.title}`,
102
+ `**Composer:** ${song.composer ?? "Traditional"}`,
103
+ `**Genre:** ${song.genre} | **Difficulty:** ${song.difficulty}`,
104
+ `**Key:** ${song.key} | **Tempo:** ${song.tempo} BPM | **Time:** ${song.timeSignature}`,
105
+ `**Duration:** ~${song.durationSeconds}s | **Measures:** ${song.measures.length}`,
106
+ ``,
107
+ `## Description`,
108
+ ml.description,
109
+ ``,
110
+ `## Structure`,
111
+ ml.structure,
112
+ ``,
113
+ `## Key Moments`,
114
+ ...ml.keyMoments.map((km) => `- ${km}`),
115
+ ``,
116
+ `## Teaching Goals`,
117
+ ...ml.teachingGoals.map((tg) => `- ${tg}`),
118
+ ``,
119
+ `## Style Tips`,
120
+ ...ml.styleTips.map((st) => `- ${st}`),
121
+ ``,
122
+ `## Practice Suggestions`,
123
+ `- **Suggested speed:** ${speedLabel} → effective tempo: ${Math.round(song.tempo * speed)} BPM`,
124
+ `- **Suggested mode:** ${mode} — ${modeReason}`,
125
+ `- **Voice coaching:** Enable voice feedback for teaching notes at measure boundaries`,
126
+ `- Use \`practice_setup "${song.id}"\` for a full practice configuration`,
127
+ ``,
128
+ `**Tags:** ${song.tags.join(", ")}`,
129
+ ].join("\n");
130
+ return { content: [{ type: "text", text }] };
131
+ });
132
+ // ─── Tool: registry_stats ───────────────────────────────────────────────────
133
+ server.tool("registry_stats", "Get statistics about the song registry: total songs, genres, difficulties, measures.", {}, async () => {
134
+ const stats = getStats();
135
+ const genreLines = Object.entries(stats.byGenre)
136
+ .filter(([, count]) => count > 0)
137
+ .map(([genre, count]) => ` ${genre}: ${count}`)
138
+ .join("\n");
139
+ const diffLines = Object.entries(stats.byDifficulty)
140
+ .filter(([, count]) => count > 0)
141
+ .map(([diff, count]) => ` ${diff}: ${count}`)
142
+ .join("\n");
143
+ const text = [
144
+ `# Registry Stats`,
145
+ `Total songs: ${stats.totalSongs}`,
146
+ `Total measures: ${stats.totalMeasures}`,
147
+ ``,
148
+ `## By Genre`,
149
+ genreLines,
150
+ ``,
151
+ `## By Difficulty`,
152
+ diffLines,
153
+ ].join("\n");
154
+ return { content: [{ type: "text", text }] };
155
+ });
156
+ // ─── Tool: teaching_note ────────────────────────────────────────────────────
157
+ server.tool("teaching_note", "Get the teaching note, fingering, and dynamics for a specific measure in a song.", {
158
+ id: z.string().describe("Song ID"),
159
+ measure: z.number().int().min(1).describe("Measure number (1-based)"),
160
+ }, async ({ id, measure }) => {
161
+ const song = getSong(id);
162
+ if (!song) {
163
+ return {
164
+ content: [{ type: "text", text: `Song not found: "${id}"` }],
165
+ isError: true,
166
+ };
167
+ }
168
+ const m = song.measures[measure - 1];
169
+ if (!m) {
170
+ return {
171
+ content: [{ type: "text", text: `Measure ${measure} not found (song has ${song.measures.length} measures)` }],
172
+ isError: true,
173
+ };
174
+ }
175
+ const lines = [
176
+ `# ${song.title} — Measure ${measure}`,
177
+ ``,
178
+ `**Right Hand:** ${m.rightHand}`,
179
+ `**Left Hand:** ${m.leftHand}`,
180
+ ];
181
+ if (m.fingering)
182
+ lines.push(`**Fingering:** ${m.fingering}`);
183
+ if (m.dynamics)
184
+ lines.push(`**Dynamics:** ${m.dynamics}`);
185
+ if (m.teachingNote) {
186
+ lines.push(``, `## Teaching Note`, m.teachingNote);
187
+ }
188
+ return { content: [{ type: "text", text: lines.join("\n") }] };
189
+ });
190
+ // ─── Tool: suggest_song ─────────────────────────────────────────────────────
191
+ server.tool("suggest_song", "Get a song recommendation based on genre preference and/or difficulty level.", {
192
+ genre: z.enum(GENRES).optional().describe("Preferred genre"),
193
+ difficulty: z.enum(DIFFICULTIES).optional().describe("Desired difficulty"),
194
+ maxDuration: z.number().optional().describe("Maximum duration in seconds"),
195
+ }, async (params) => {
196
+ const results = searchSongs({
197
+ genre: params.genre,
198
+ difficulty: params.difficulty,
199
+ maxDuration: params.maxDuration,
200
+ });
201
+ if (results.length === 0) {
202
+ return {
203
+ content: [{ type: "text", text: "No songs match your criteria. Try broadening your search." }],
204
+ };
205
+ }
206
+ // Pick a random suggestion from matches
207
+ const song = results[Math.floor(Math.random() * results.length)];
208
+ const ml = song.musicalLanguage;
209
+ const text = [
210
+ `I'd suggest: **${song.title}** by ${song.composer ?? "Traditional"}`,
211
+ ``,
212
+ `${ml.description}`,
213
+ ``,
214
+ `**Why this song?**`,
215
+ ...ml.teachingGoals.map((tg) => `- ${tg}`),
216
+ ``,
217
+ `Use \`song_info\` with id "${song.id}" for full details.`,
218
+ ].join("\n");
219
+ return { content: [{ type: "text", text }] };
220
+ });
221
+ // ─── Tool: list_measures ────────────────────────────────────────────────────
222
+ server.tool("list_measures", "Get an overview of all measures in a song, showing right hand, left hand, and any teaching notes.", {
223
+ id: z.string().describe("Song ID"),
224
+ startMeasure: z.number().int().min(1).optional().describe("Start measure (1-based, default: 1)"),
225
+ endMeasure: z.number().int().min(1).optional().describe("End measure (1-based, default: last)"),
226
+ }, async ({ id, startMeasure, endMeasure }) => {
227
+ const song = getSong(id);
228
+ if (!song) {
229
+ return {
230
+ content: [{ type: "text", text: `Song not found: "${id}"` }],
231
+ isError: true,
232
+ };
233
+ }
234
+ const start = (startMeasure ?? 1) - 1;
235
+ const end = Math.min((endMeasure ?? song.measures.length) - 1, song.measures.length - 1);
236
+ const measures = song.measures.slice(start, end + 1);
237
+ // Check for parse warnings
238
+ const warnings = [];
239
+ for (const m of measures) {
240
+ safeParseMeasure(m, song.tempo, warnings);
241
+ }
242
+ const lines = [`# ${song.title} — Measures ${start + 1} to ${end + 1}`, ``];
243
+ for (const m of measures) {
244
+ lines.push(`## Measure ${m.number}`);
245
+ lines.push(`RH: ${m.rightHand}`);
246
+ lines.push(`LH: ${m.leftHand}`);
247
+ if (m.fingering)
248
+ lines.push(`Fingering: ${m.fingering}`);
249
+ if (m.dynamics)
250
+ lines.push(`Dynamics: ${m.dynamics}`);
251
+ if (m.teachingNote)
252
+ lines.push(`Note: ${m.teachingNote}`);
253
+ lines.push(``);
254
+ }
255
+ if (warnings.length > 0) {
256
+ lines.push(`## ⚠ Parse Warnings`);
257
+ lines.push(`${warnings.length} note(s) could not be parsed and will be skipped during playback:`);
258
+ for (const w of warnings.slice(0, 10)) {
259
+ lines.push(`- ${w.location}: "${w.token}" — ${w.message}`);
260
+ }
261
+ if (warnings.length > 10) {
262
+ lines.push(`- … and ${warnings.length - 10} more`);
263
+ }
264
+ lines.push(``);
265
+ }
266
+ return { content: [{ type: "text", text: lines.join("\n") }] };
267
+ });
268
+ // ─── Tool: practice_setup ──────────────────────────────────────────────────
269
+ 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.", {
270
+ id: z.string().describe("Song ID"),
271
+ playerLevel: z.enum(["beginner", "intermediate", "advanced"]).optional()
272
+ .describe("Player's skill level (overrides song-based suggestion)"),
273
+ }, async ({ id, playerLevel }) => {
274
+ const song = getSong(id);
275
+ if (!song) {
276
+ return {
277
+ content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
278
+ isError: true,
279
+ };
280
+ }
281
+ // Determine practice parameters
282
+ const effectiveDifficulty = (playerLevel ?? song.difficulty);
283
+ const { speed, label: speedLabel } = suggestSpeed(effectiveDifficulty);
284
+ const { mode, reason: modeReason } = suggestMode(effectiveDifficulty);
285
+ const effectiveTempo = Math.round(song.tempo * speed);
286
+ // Check for parse warnings
287
+ const warnings = [];
288
+ for (const m of song.measures) {
289
+ safeParseMeasure(m, effectiveTempo, warnings);
290
+ }
291
+ const ml = song.musicalLanguage;
292
+ const lines = [
293
+ `# Practice Setup: ${song.title}`,
294
+ ``,
295
+ `## Song Profile`,
296
+ `- **Difficulty:** ${song.difficulty}`,
297
+ `- **Base tempo:** ${song.tempo} BPM`,
298
+ `- **Measures:** ${song.measures.length}`,
299
+ `- **Key:** ${song.key} | **Time:** ${song.timeSignature}`,
300
+ ``,
301
+ `## Recommended Settings`,
302
+ `- **Speed:** ${speedLabel}`,
303
+ `- **Effective tempo:** ${effectiveTempo} BPM`,
304
+ `- **Mode:** ${mode} — ${modeReason}`,
305
+ `- **Voice coaching:** Enabled — speak teaching notes + key moments`,
306
+ ``,
307
+ `## CLI Command`,
308
+ `\`\`\``,
309
+ `pianoai play ${song.id} --speed ${speed} --mode ${mode}`,
310
+ `\`\`\``,
311
+ ``,
312
+ `## Practice Progression`,
313
+ `1. Start at ${speedLabel} in **${mode}** mode`,
314
+ `2. Focus on key moments:`,
315
+ ...ml.keyMoments.slice(0, 3).map((km) => ` - ${km}`),
316
+ `3. Gradually increase speed: ${speed} → ${Math.min(speed + 0.25, 1.0)} → 1.0`,
317
+ `4. Switch to **full** mode once comfortable at speed`,
318
+ ];
319
+ if (song.difficulty === "advanced") {
320
+ lines.push(`5. Try **loop** mode on difficult passages`, ` Example: \`pianoai play ${song.id} --mode loop\``);
321
+ }
322
+ if (warnings.length > 0) {
323
+ lines.push(``, `## ⚠ Note`, `${warnings.length} note(s) have parse warnings and will be skipped during playback.`, `Use \`list_measures "${song.id}"\` to see details.`);
324
+ }
325
+ return { content: [{ type: "text", text: lines.join("\n") }] };
326
+ });
327
+ // ─── Tool: sing_along ─────────────────────────────────────────────────────
328
+ 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.", {
329
+ id: z.string().describe("Song ID"),
330
+ startMeasure: z.number().int().min(1).optional().describe("Start measure (1-based, default: 1)"),
331
+ endMeasure: z.number().int().min(1).optional().describe("End measure (1-based, default: last)"),
332
+ mode: z.enum(["note-names", "solfege", "contour", "syllables"]).optional()
333
+ .describe("Sing-along mode (default: 'note-names')"),
334
+ hand: z.enum(["right", "left", "both"]).optional()
335
+ .describe("Which hand to narrate (default: 'right')"),
336
+ withPiano: z.boolean().optional()
337
+ .describe("Include piano accompaniment info and CLI command for live playback (default: false)"),
338
+ syncMode: z.enum(["concurrent", "before"]).optional()
339
+ .describe("Voice+piano sync mode: 'concurrent' = duet feel, 'before' = voice first (default: 'concurrent')"),
340
+ }, async ({ id, startMeasure, endMeasure, mode, hand, withPiano, syncMode }) => {
341
+ const song = getSong(id);
342
+ if (!song) {
343
+ return {
344
+ content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs.` }],
345
+ isError: true,
346
+ };
347
+ }
348
+ const effectiveMode = mode ?? "note-names";
349
+ const effectiveHand = hand ?? "right";
350
+ const effectiveSyncMode = syncMode ?? "concurrent";
351
+ const start = (startMeasure ?? 1) - 1;
352
+ const end = Math.min((endMeasure ?? song.measures.length) - 1, song.measures.length - 1);
353
+ const measures = song.measures.slice(start, end + 1);
354
+ const lines = [
355
+ `# Sing Along: ${song.title}`,
356
+ `**Mode:** ${effectiveMode} | **Hand:** ${effectiveHand}`,
357
+ `**Measures:** ${start + 1} to ${end + 1}`,
358
+ ];
359
+ if (withPiano) {
360
+ lines.push(`**Piano accompaniment:** enabled (${effectiveSyncMode} sync)`);
361
+ }
362
+ lines.push(``);
363
+ for (const m of measures) {
364
+ const singable = measureToSingableText({ rightHand: m.rightHand, leftHand: m.leftHand }, { mode: effectiveMode, hand: effectiveHand });
365
+ lines.push(`**Measure ${m.number}:** ${singable}`);
366
+ }
367
+ if (withPiano) {
368
+ const { speed, label: speedLabel } = suggestSpeed(song.difficulty);
369
+ const effectiveTempo = Math.round(song.tempo * speed);
370
+ 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}`, `\`\`\``);
371
+ }
372
+ else {
373
+ lines.push(``, `---`, `*Tip: Add \`withPiano: true\` for synchronized singing + piano playback, or run:*`, `*\`pianoai sing ${song.id} --with-piano\`*`);
374
+ }
375
+ return { content: [{ type: "text", text: lines.join("\n") }] };
376
+ });
377
+ // ─── Active Playback State ────────────────────────────────────────────────
378
+ let activeSession = null;
379
+ let activeMidiEngine = null;
380
+ let activeController = null;
381
+ let activeConnector = null;
382
+ /** Stop whatever is currently playing. */
383
+ function stopActive() {
384
+ if (activeSession && activeSession.state === "playing") {
385
+ activeSession.stop();
386
+ }
387
+ activeSession = null;
388
+ if (activeMidiEngine && activeMidiEngine.state === "playing") {
389
+ activeMidiEngine.stop();
390
+ }
391
+ activeMidiEngine = null;
392
+ if (activeController && activeController.state === "playing") {
393
+ activeController.stop();
394
+ }
395
+ activeController = null;
396
+ if (activeConnector) {
397
+ activeConnector.disconnect().catch(() => { });
398
+ activeConnector = null;
399
+ }
400
+ }
401
+ // ─── Tool: play_song ──────────────────────────────────────────────────────
402
+ 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.", {
403
+ id: z.string().describe("Song ID (e.g. 'autumn-leaves', 'let-it-be') OR path to a .mid file"),
404
+ 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"),
405
+ tempo: z.number().int().min(10).max(400).optional().describe("Override tempo in BPM (10-400). Default: song's tempo"),
406
+ mode: z.enum(["full", "measure", "hands", "loop"]).optional().describe("Playback mode: full (default), measure (one at a time), hands (separate then together), loop"),
407
+ startMeasure: z.number().int().min(1).optional().describe("Start measure for loop mode (1-based)"),
408
+ endMeasure: z.number().int().min(1).optional().describe("End measure for loop mode (1-based)"),
409
+ withSinging: z.boolean().optional().describe("Enable sing-along narration during playback (note-names by default). Default: false"),
410
+ withTeaching: z.boolean().optional().describe("Enable live teaching feedback (encouragement, dynamics tips, difficulty warnings). Default: false"),
411
+ singMode: z.enum(["note-names", "solfege", "contour", "syllables"]).optional().describe("Sing-along mode when withSinging is true. Default: note-names"),
412
+ }, async ({ id, speed, tempo, mode, startMeasure, endMeasure, withSinging, withTeaching, singMode }) => {
413
+ // Stop whatever is currently playing
414
+ stopActive();
415
+ // Determine if this is a .mid file path or a library song ID
416
+ const isMidiFile = id.endsWith(".mid") || id.endsWith(".midi") || existsSync(id);
417
+ const librarySong = isMidiFile ? null : getSong(id);
418
+ if (!isMidiFile && !librarySong) {
419
+ return {
420
+ content: [{ type: "text", text: `Song not found: "${id}". Use list_songs to see available songs, or provide a path to a .mid file.` }],
421
+ isError: true,
422
+ };
423
+ }
424
+ // Connect piano engine
425
+ const connector = createAudioEngine();
426
+ try {
427
+ await connector.connect();
428
+ }
429
+ catch (err) {
430
+ const msg = err instanceof Error ? err.message : String(err);
431
+ return {
432
+ content: [{ type: "text", text: `Piano engine failed to start: ${msg}` }],
433
+ isError: true,
434
+ };
435
+ }
436
+ activeConnector = connector;
437
+ // ── MIDI file playback ──
438
+ if (isMidiFile) {
439
+ let parsed;
440
+ try {
441
+ parsed = await parseMidiFile(id);
442
+ }
443
+ catch (err) {
444
+ const msg = err instanceof Error ? err.message : String(err);
445
+ connector.disconnect().catch(() => { });
446
+ activeConnector = null;
447
+ return {
448
+ content: [{ type: "text", text: `Failed to parse MIDI file: ${msg}` }],
449
+ isError: true,
450
+ };
451
+ }
452
+ // Build teaching hooks if requested
453
+ const hooks = [];
454
+ const singingLog = [];
455
+ const feedbackLog = [];
456
+ if (withSinging) {
457
+ const voiceSink = async (d) => {
458
+ singingLog.push(d.text);
459
+ console.error(`♪ ${d.text}`);
460
+ };
461
+ hooks.push(createSingOnMidiHook(voiceSink, parsed, {
462
+ mode: (singMode ?? "note-names"),
463
+ }));
464
+ }
465
+ if (withTeaching) {
466
+ const voiceSink = async (d) => {
467
+ feedbackLog.push(d.text);
468
+ console.error(`🎓 ${d.text}`);
469
+ };
470
+ const asideSink = async (d) => {
471
+ feedbackLog.push(d.text);
472
+ console.error(`💡 ${d.text}`);
473
+ };
474
+ // Use position-aware feedback (measure-level context) over basic per-note
475
+ hooks.push(createLiveMidiFeedbackHook(voiceSink, asideSink, parsed));
476
+ }
477
+ hooks.push(createConsoleTeachingHook());
478
+ const teachingHook = composeTeachingHooks(...hooks);
479
+ // Use PlaybackController when hooks are active, raw engine otherwise
480
+ if (withSinging || withTeaching) {
481
+ const controller = new PlaybackController(connector, parsed);
482
+ activeController = controller;
483
+ const playPromise = controller.play({ speed: speed ?? 1.0, teachingHook });
484
+ playPromise
485
+ .then(() => {
486
+ console.error(`Finished playing MIDI file: ${id} (${parsed.noteCount} notes, ${parsed.durationSeconds.toFixed(1)}s)`);
487
+ })
488
+ .catch((err) => {
489
+ console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
490
+ })
491
+ .finally(() => {
492
+ connector.disconnect().catch(() => { });
493
+ if (activeController === controller)
494
+ activeController = null;
495
+ if (activeConnector === connector)
496
+ activeConnector = null;
497
+ });
498
+ }
499
+ else {
500
+ const engine = new MidiPlaybackEngine(connector, parsed);
501
+ activeMidiEngine = engine;
502
+ const playPromise = engine.play({ speed: speed ?? 1.0 });
503
+ playPromise
504
+ .then(() => {
505
+ console.error(`Finished playing MIDI file: ${id} (${parsed.noteCount} notes, ${parsed.durationSeconds.toFixed(1)}s)`);
506
+ })
507
+ .catch((err) => {
508
+ console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
509
+ })
510
+ .finally(() => {
511
+ connector.disconnect().catch(() => { });
512
+ if (activeMidiEngine === engine)
513
+ activeMidiEngine = null;
514
+ if (activeConnector === connector)
515
+ activeConnector = null;
516
+ });
517
+ }
518
+ const effectiveSpeed = speed ?? 1.0;
519
+ const durationAtSpeed = parsed.durationSeconds / effectiveSpeed;
520
+ const speedLabel = effectiveSpeed !== 1.0 ? ` × ${effectiveSpeed}x` : "";
521
+ const trackInfo = parsed.trackNames.length > 0 ? parsed.trackNames.join(", ") : "Unknown";
522
+ const features = [];
523
+ if (withSinging)
524
+ features.push(`singing (${singMode ?? "note-names"})`);
525
+ if (withTeaching)
526
+ features.push("teaching feedback");
527
+ const lines = [
528
+ `Now playing: **${id}** (MIDI file)`,
529
+ ``,
530
+ `- **Tracks:** ${trackInfo} (${parsed.trackCount} track${parsed.trackCount !== 1 ? "s" : ""})`,
531
+ `- **Notes:** ${parsed.noteCount}`,
532
+ `- **Tempo:** ${parsed.bpm} BPM${speedLabel}`,
533
+ `- **Duration:** ~${Math.round(durationAtSpeed)}s`,
534
+ `- **Format:** MIDI type ${parsed.format}`,
535
+ ];
536
+ if (features.length > 0) {
537
+ lines.push(`- **Features:** ${features.join(", ")}`);
538
+ }
539
+ lines.push(``, `Use \`stop_playback\` to stop. Playback runs in the background.`);
540
+ return { content: [{ type: "text", text: lines.join("\n") }] };
541
+ }
542
+ // ── Library song playback ──
543
+ const song = librarySong;
544
+ const loopRange = startMeasure && endMeasure ? [startMeasure, endMeasure] : undefined;
545
+ const playbackMode = (mode ?? "full");
546
+ // Build teaching hooks
547
+ const libHooks = [];
548
+ if (withSinging) {
549
+ const { createSingAlongHook } = await import("./teaching.js");
550
+ const voiceSink = async (d) => {
551
+ console.error(`♪ ${d.text}`);
552
+ };
553
+ libHooks.push(createSingAlongHook(voiceSink, song, {
554
+ mode: (singMode ?? "note-names"),
555
+ }));
556
+ }
557
+ if (withTeaching) {
558
+ const { createLiveFeedbackHook } = await import("./teaching.js");
559
+ const voiceSink = async (d) => {
560
+ console.error(`🎓 ${d.text}`);
561
+ };
562
+ const asideSink = async (d) => {
563
+ console.error(`💡 ${d.text}`);
564
+ };
565
+ libHooks.push(createLiveFeedbackHook(voiceSink, asideSink, song));
566
+ }
567
+ libHooks.push(createConsoleTeachingHook());
568
+ const teachingHook = composeTeachingHooks(...libHooks);
569
+ const syncMode = (withSinging && !withTeaching) ? "before" : "concurrent";
570
+ const session = createSession(song, connector, {
571
+ mode: playbackMode,
572
+ syncMode,
573
+ speed,
574
+ tempo,
575
+ loopRange,
576
+ teachingHook,
577
+ });
578
+ activeSession = session;
579
+ // Play in background
580
+ const playPromise = session.play();
581
+ playPromise
582
+ .then(() => {
583
+ console.error(`Finished playing: ${song.title} (${session.session.measuresPlayed} measures)`);
584
+ })
585
+ .catch((err) => {
586
+ console.error(`Playback error: ${err instanceof Error ? err.message : String(err)}`);
587
+ })
588
+ .finally(() => {
589
+ connector.disconnect().catch(() => { });
590
+ if (activeSession === session)
591
+ activeSession = null;
592
+ if (activeConnector === connector)
593
+ activeConnector = null;
594
+ });
595
+ const effectiveSpeed = speed ?? 1.0;
596
+ const baseTempo = tempo ?? song.tempo;
597
+ const effectiveTempo = Math.round(baseTempo * effectiveSpeed);
598
+ const speedLabel = effectiveSpeed !== 1.0 ? ` × ${effectiveSpeed}x` : "";
599
+ const warnings = session.parseWarnings;
600
+ const lines = [
601
+ `Now playing: **${song.title}** by ${song.composer ?? "Traditional"}`,
602
+ ``,
603
+ `- **Mode:** ${playbackMode}`,
604
+ `- **Tempo:** ${baseTempo} BPM${speedLabel} → ${effectiveTempo} BPM effective`,
605
+ `- **Key:** ${song.key} | **Time:** ${song.timeSignature}`,
606
+ `- **Measures:** ${song.measures.length}`,
607
+ ];
608
+ if (loopRange) {
609
+ lines.push(`- **Loop range:** measures ${loopRange[0]}–${loopRange[1]}`);
610
+ }
611
+ if (warnings.length > 0) {
612
+ lines.push(``, `⚠ ${warnings.length} note(s) had parse warnings and will be skipped.`);
613
+ }
614
+ lines.push(``, `Use \`stop_playback\` to stop. Playback runs in the background.`);
615
+ return { content: [{ type: "text", text: lines.join("\n") }] };
616
+ });
617
+ // ─── Tool: stop_playback ──────────────────────────────────────────────────
618
+ server.tool("stop_playback", "Stop the currently playing song and disconnect MIDI.", {}, async () => {
619
+ const wasPlaying = activeSession || activeMidiEngine || activeController;
620
+ if (!wasPlaying) {
621
+ return {
622
+ content: [{ type: "text", text: "No song is currently playing." }],
623
+ };
624
+ }
625
+ const info = activeSession
626
+ ? `${activeSession.session.song.title} (${activeSession.session.measuresPlayed} measures played)`
627
+ : activeMidiEngine
628
+ ? `MIDI file (${activeMidiEngine.eventsPlayed}/${activeMidiEngine.totalEvents} events played)`
629
+ : activeController
630
+ ? `MIDI file (${activeController.eventsPlayed}/${activeController.totalEvents} events played)`
631
+ : "Unknown";
632
+ stopActive();
633
+ return {
634
+ content: [{ type: "text", text: `Stopped: ${info}` }],
635
+ };
636
+ });
637
+ // ─── Tool: pause_playback ─────────────────────────────────────────────────
638
+ server.tool("pause_playback", "Pause or resume the currently playing song.", {
639
+ resume: z.boolean().optional().describe("If true, resume playback. If false or omitted, pause."),
640
+ }, async ({ resume }) => {
641
+ if (resume) {
642
+ // Resume
643
+ if (activeController && activeController.state === "paused") {
644
+ activeController.resume().catch(() => { });
645
+ return { content: [{ type: "text", text: "Resumed playback." }] };
646
+ }
647
+ if (activeSession && activeSession.state === "paused") {
648
+ activeSession.play().catch(() => { });
649
+ return { content: [{ type: "text", text: "Resumed playback." }] };
650
+ }
651
+ return { content: [{ type: "text", text: "Nothing is paused." }] };
652
+ }
653
+ // Pause
654
+ if (activeController && activeController.state === "playing") {
655
+ activeController.pause();
656
+ const pos = activeController.positionSeconds;
657
+ return {
658
+ content: [{
659
+ type: "text",
660
+ text: `Paused at ${pos.toFixed(1)}s (${activeController.eventsPlayed}/${activeController.totalEvents} events).`,
661
+ }],
662
+ };
663
+ }
664
+ if (activeMidiEngine && activeMidiEngine.state === "playing") {
665
+ activeMidiEngine.pause();
666
+ return {
667
+ content: [{
668
+ type: "text",
669
+ text: `Paused at ${activeMidiEngine.positionSeconds.toFixed(1)}s.`,
670
+ }],
671
+ };
672
+ }
673
+ if (activeSession && activeSession.state === "playing") {
674
+ activeSession.pause();
675
+ return {
676
+ content: [{
677
+ type: "text",
678
+ text: `Paused (${activeSession.session.measuresPlayed} measures played).`,
679
+ }],
680
+ };
681
+ }
682
+ return { content: [{ type: "text", text: "No song is currently playing." }] };
683
+ });
684
+ // ─── Tool: set_speed ──────────────────────────────────────────────────────
685
+ server.tool("set_speed", "Change the playback speed of the currently playing song. Takes effect on the next note.", {
686
+ speed: z.number().min(0.1).max(4).describe("New speed multiplier (0.1–4.0)"),
687
+ }, async ({ speed }) => {
688
+ if (activeController) {
689
+ const prev = activeController.speed;
690
+ activeController.setSpeed(speed);
691
+ return {
692
+ content: [{
693
+ type: "text",
694
+ text: `Speed changed: ${prev}x → ${speed}x. Takes effect on next note.`,
695
+ }],
696
+ };
697
+ }
698
+ if (activeMidiEngine) {
699
+ const prev = activeMidiEngine.speed;
700
+ activeMidiEngine.setSpeed(speed);
701
+ return {
702
+ content: [{
703
+ type: "text",
704
+ text: `Speed changed: ${prev}x → ${speed}x.`,
705
+ }],
706
+ };
707
+ }
708
+ if (activeSession) {
709
+ activeSession.setSpeed(speed);
710
+ return {
711
+ content: [{
712
+ type: "text",
713
+ text: `Speed changed to ${speed}x.`,
714
+ }],
715
+ };
716
+ }
717
+ return { content: [{ type: "text", text: "No song is currently playing." }] };
718
+ });
719
+ // ─── Tool: ai_jam_session ───────────────────────────────────────────────
720
+ server.tool("ai_jam_session", "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.", {
721
+ songId: z.string().optional()
722
+ .describe("Source song ID to jam on (e.g. 'autumn-leaves'). Optional if genre is provided."),
723
+ genre: z.enum(GENRES).optional()
724
+ .describe("Pick a random song from this genre to jam on (e.g., 'jazz', 'blues'). Used when no songId is provided."),
725
+ style: z.enum(GENRES).optional()
726
+ .describe("Target genre for reinterpretation (e.g., turn a classical piece into jazz)"),
727
+ mood: z.string().optional()
728
+ .describe("Target mood (e.g., 'upbeat', 'melancholic', 'dreamy', 'energetic', 'gentle', 'playful')"),
729
+ difficulty: z.enum(DIFFICULTIES).optional()
730
+ .describe("Target difficulty level"),
731
+ measures: z.string().optional()
732
+ .describe("Measure range to focus on (e.g., '1-8' for just the opening)"),
733
+ }, async ({ songId, genre, style, mood, difficulty, measures }) => {
734
+ if (!songId && !genre) {
735
+ return {
736
+ 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." }],
737
+ isError: true,
738
+ };
739
+ }
740
+ let song;
741
+ if (songId) {
742
+ song = getSong(songId);
743
+ if (!song) {
744
+ return {
745
+ content: [{ type: "text", text: `Song not found: "${songId}". Use list_songs to see available songs.` }],
746
+ isError: true,
747
+ };
748
+ }
749
+ }
750
+ else {
751
+ const candidates = getSongsByGenre(genre);
752
+ if (candidates.length === 0) {
753
+ return {
754
+ content: [{ type: "text", text: `No songs found in genre "${genre}". Use registry_stats to see available genres.` }],
755
+ isError: true,
756
+ };
757
+ }
758
+ song = candidates[Math.floor(Math.random() * candidates.length)];
759
+ }
760
+ const options = {
761
+ style: style,
762
+ mood,
763
+ difficulty: difficulty,
764
+ measures,
765
+ };
766
+ const brief = generateJamBrief(song, options);
767
+ const text = formatJamBrief(brief, options);
768
+ return { content: [{ type: "text", text }] };
769
+ });
770
+ // ─── Tool: add_song ──────────────────────────────────────────────────────
771
+ 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.", {
772
+ song: z.string().describe("Complete SongEntry as a JSON string"),
773
+ }, async ({ song: songJson }) => {
774
+ try {
775
+ const parsed = JSON.parse(songJson);
776
+ const errors = validateSong(parsed);
777
+ if (errors.length > 0) {
778
+ return {
779
+ content: [{
780
+ type: "text",
781
+ text: `Song validation failed:\n - ${errors.join("\n - ")}`,
782
+ }],
783
+ };
784
+ }
785
+ // Check for duplicates
786
+ if (getSong(parsed.id)) {
787
+ return {
788
+ content: [{
789
+ type: "text",
790
+ text: `A song with ID "${parsed.id}" already exists in the library.`,
791
+ }],
792
+ };
793
+ }
794
+ registerSong(parsed);
795
+ // Save to user songs directory
796
+ const userDir = getUserSongsDir();
797
+ const filePath = saveSong(parsed, userDir);
798
+ return {
799
+ content: [{
800
+ type: "text",
801
+ text: `Song "${parsed.title}" (${parsed.id}) added to the library.\n` +
802
+ `Genre: ${parsed.genre} | Difficulty: ${parsed.difficulty} | ` +
803
+ `${parsed.measures.length} measures | ${parsed.durationSeconds}s\n` +
804
+ `Saved to: ${filePath}`,
805
+ }],
806
+ };
807
+ }
808
+ catch (err) {
809
+ return {
810
+ content: [{
811
+ type: "text",
812
+ text: `Failed to add song: ${err instanceof Error ? err.message : String(err)}`,
813
+ }],
814
+ };
815
+ }
816
+ });
817
+ // ─── Tool: import_midi ──────────────────────────────────────────────────
818
+ 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.", {
819
+ midi_path: z.string().describe("Path to .mid file"),
820
+ id: z.string().describe("Song ID (kebab-case, e.g. 'fur-elise')"),
821
+ title: z.string().describe("Song title"),
822
+ genre: z.enum(GENRES).describe("Genre"),
823
+ difficulty: z.enum(DIFFICULTIES).describe("Difficulty"),
824
+ key: z.string().describe("Key signature (e.g. 'C major', 'A minor')"),
825
+ composer: z.string().optional().describe("Composer or artist"),
826
+ description: z.string().optional().describe("1-3 sentence description of the piece"),
827
+ tags: z.array(z.string()).optional().describe("Tags for search (default: genre + difficulty)"),
828
+ }, async ({ midi_path, id, title, genre, difficulty, key, composer, description, tags }) => {
829
+ try {
830
+ const midiBuffer = new Uint8Array(readFileSync(midi_path));
831
+ const config = {
832
+ id,
833
+ title,
834
+ genre: genre,
835
+ difficulty: difficulty,
836
+ key,
837
+ composer,
838
+ tags: tags ?? [genre, difficulty],
839
+ musicalLanguage: {
840
+ description: description ?? `${title} — a ${difficulty} ${genre} piece in ${key}.`,
841
+ structure: "To be determined",
842
+ keyMoments: [`Bar 1: ${title} begins`],
843
+ teachingGoals: [`Learn ${title} at ${difficulty} level`],
844
+ styleTips: [`Play in ${genre} style`],
845
+ },
846
+ };
847
+ const song = midiToSongEntry(midiBuffer, config);
848
+ // Check for duplicates
849
+ if (getSong(song.id)) {
850
+ return {
851
+ content: [{
852
+ type: "text",
853
+ text: `A song with ID "${song.id}" already exists in the library.`,
854
+ }],
855
+ };
856
+ }
857
+ registerSong(song);
858
+ const userDir = getUserSongsDir();
859
+ const filePath = saveSong(song, userDir);
860
+ return {
861
+ content: [{
862
+ type: "text",
863
+ text: `MIDI imported as "${song.title}" (${song.id}).\n` +
864
+ `Genre: ${song.genre} | Difficulty: ${song.difficulty} | Key: ${song.key}\n` +
865
+ `Tempo: ${song.tempo} BPM | Time: ${song.timeSignature} | ` +
866
+ `${song.measures.length} measures | ${song.durationSeconds}s\n` +
867
+ `Saved to: ${filePath}`,
868
+ }],
869
+ };
870
+ }
871
+ catch (err) {
872
+ return {
873
+ content: [{
874
+ type: "text",
875
+ text: `Failed to import MIDI: ${err instanceof Error ? err.message : String(err)}`,
876
+ }],
877
+ };
878
+ }
879
+ });
880
+ // ─── Helpers ─────────────────────────────────────────────────────────────
881
+ function getUserSongsDir() {
882
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
883
+ return `${home}/.pianoai/songs`;
884
+ }
885
+ // ─── Start ──────────────────────────────────────────────────────────────────
886
+ async function main() {
887
+ // Load songs from builtin + user directories
888
+ const { dirname } = await import("node:path");
889
+ const { fileURLToPath } = await import("node:url");
890
+ const { join } = await import("node:path");
891
+ const __dirname = dirname(fileURLToPath(import.meta.url));
892
+ const builtinDir = join(__dirname, "..", "songs", "builtin");
893
+ const userDir = join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".pianoai", "songs");
894
+ initializeRegistry(builtinDir, userDir);
895
+ const transport = new StdioServerTransport();
896
+ await server.connect(transport);
897
+ console.error("pianoai MCP server running on stdio");
898
+ }
899
+ main().catch((err) => {
900
+ console.error("Fatal error:", err);
901
+ process.exit(1);
902
+ });
903
+ //# sourceMappingURL=mcp-server.js.map