@pinkpixel/haku 0.0.0-stage → 0.2.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 (162) hide show
  1. package/README.md +151 -2
  2. package/dist/LICENSE +202 -0
  3. package/dist/cli.mjs +168 -0
  4. package/dist/fonts/Figtree-Regular.ttf +0 -0
  5. package/dist/fonts/Figtree-SemiBold.ttf +0 -0
  6. package/dist/fonts/OFL.txt +93 -0
  7. package/dist/mcp-DG9U-P0N.mjs +72 -0
  8. package/dist/node-CVxAljK8.mjs +24 -0
  9. package/dist/samples/ambience/rain_roof.ogg +0 -0
  10. package/dist/samples/ambience/rain_window.ogg +0 -0
  11. package/dist/samples/drums/brush_v0_r1.ogg +0 -0
  12. package/dist/samples/drums/brush_v0_r2.ogg +0 -0
  13. package/dist/samples/drums/brush_v1_r1.ogg +0 -0
  14. package/dist/samples/drums/brush_v1_r2.ogg +0 -0
  15. package/dist/samples/drums/brushhat_v0_r1.ogg +0 -0
  16. package/dist/samples/drums/brushhat_v0_r2.ogg +0 -0
  17. package/dist/samples/drums/brushhat_v1_r1.ogg +0 -0
  18. package/dist/samples/drums/brushhat_v1_r2.ogg +0 -0
  19. package/dist/samples/drums/hat_v0_r1.ogg +0 -0
  20. package/dist/samples/drums/hat_v0_r2.ogg +0 -0
  21. package/dist/samples/drums/hat_v1_r1.ogg +0 -0
  22. package/dist/samples/drums/hat_v1_r2.ogg +0 -0
  23. package/dist/samples/drums/kick_v0_r1.ogg +0 -0
  24. package/dist/samples/drums/kick_v0_r2.ogg +0 -0
  25. package/dist/samples/drums/kick_v1_r1.ogg +0 -0
  26. package/dist/samples/drums/kick_v1_r2.ogg +0 -0
  27. package/dist/samples/drums/openhat_v0_r1.ogg +0 -0
  28. package/dist/samples/drums/openhat_v0_r2.ogg +0 -0
  29. package/dist/samples/drums/openhat_v1_r1.ogg +0 -0
  30. package/dist/samples/drums/openhat_v1_r2.ogg +0 -0
  31. package/dist/samples/drums/ride_v0_r1.ogg +0 -0
  32. package/dist/samples/drums/ride_v0_r2.ogg +0 -0
  33. package/dist/samples/drums/ride_v1_r1.ogg +0 -0
  34. package/dist/samples/drums/ride_v1_r2.ogg +0 -0
  35. package/dist/samples/drums/rim_v0_r1.ogg +0 -0
  36. package/dist/samples/drums/rim_v0_r2.ogg +0 -0
  37. package/dist/samples/drums/rim_v1_r1.ogg +0 -0
  38. package/dist/samples/drums/rim_v1_r2.ogg +0 -0
  39. package/dist/samples/drums/snare_v0_r1.ogg +0 -0
  40. package/dist/samples/drums/snare_v0_r2.ogg +0 -0
  41. package/dist/samples/drums/snare_v1_r1.ogg +0 -0
  42. package/dist/samples/drums/snare_v1_r2.ogg +0 -0
  43. package/dist/samples/drums/tom_v0_r1.ogg +0 -0
  44. package/dist/samples/drums/tom_v0_r2.ogg +0 -0
  45. package/dist/samples/drums/tom_v1_r1.ogg +0 -0
  46. package/dist/samples/drums/tom_v1_r2.ogg +0 -0
  47. package/dist/samples/glock/v0_103.ogg +0 -0
  48. package/dist/samples/glock/v0_108.ogg +0 -0
  49. package/dist/samples/glock/v0_79.ogg +0 -0
  50. package/dist/samples/glock/v0_84.ogg +0 -0
  51. package/dist/samples/glock/v0_91.ogg +0 -0
  52. package/dist/samples/glock/v0_96.ogg +0 -0
  53. package/dist/samples/harp/v0_101.ogg +0 -0
  54. package/dist/samples/harp/v0_28.ogg +0 -0
  55. package/dist/samples/harp/v0_31.ogg +0 -0
  56. package/dist/samples/harp/v0_35.ogg +0 -0
  57. package/dist/samples/harp/v0_38.ogg +0 -0
  58. package/dist/samples/harp/v0_41.ogg +0 -0
  59. package/dist/samples/harp/v0_45.ogg +0 -0
  60. package/dist/samples/harp/v0_48.ogg +0 -0
  61. package/dist/samples/harp/v0_52.ogg +0 -0
  62. package/dist/samples/harp/v0_55.ogg +0 -0
  63. package/dist/samples/harp/v0_59.ogg +0 -0
  64. package/dist/samples/harp/v0_62.ogg +0 -0
  65. package/dist/samples/harp/v0_65.ogg +0 -0
  66. package/dist/samples/harp/v0_69.ogg +0 -0
  67. package/dist/samples/harp/v0_72.ogg +0 -0
  68. package/dist/samples/harp/v0_76.ogg +0 -0
  69. package/dist/samples/harp/v0_79.ogg +0 -0
  70. package/dist/samples/harp/v0_83.ogg +0 -0
  71. package/dist/samples/harp/v0_86.ogg +0 -0
  72. package/dist/samples/harp/v0_89.ogg +0 -0
  73. package/dist/samples/harp/v0_93.ogg +0 -0
  74. package/dist/samples/harp/v0_95.ogg +0 -0
  75. package/dist/samples/harp/v0_98.ogg +0 -0
  76. package/dist/samples/manifest.json +368 -0
  77. package/dist/samples/marimba/v0_41.ogg +0 -0
  78. package/dist/samples/marimba/v0_48.ogg +0 -0
  79. package/dist/samples/marimba/v0_55.ogg +0 -0
  80. package/dist/samples/marimba/v0_59.ogg +0 -0
  81. package/dist/samples/marimba/v0_65.ogg +0 -0
  82. package/dist/samples/marimba/v0_72.ogg +0 -0
  83. package/dist/samples/marimba/v0_79.ogg +0 -0
  84. package/dist/samples/marimba/v0_83.ogg +0 -0
  85. package/dist/samples/marimba/v0_89.ogg +0 -0
  86. package/dist/samples/marimba/v0_96.ogg +0 -0
  87. package/dist/samples/piano/v0_29.ogg +0 -0
  88. package/dist/samples/piano/v0_33.ogg +0 -0
  89. package/dist/samples/piano/v0_37.ogg +0 -0
  90. package/dist/samples/piano/v0_41.ogg +0 -0
  91. package/dist/samples/piano/v0_45.ogg +0 -0
  92. package/dist/samples/piano/v0_49.ogg +0 -0
  93. package/dist/samples/piano/v0_53.ogg +0 -0
  94. package/dist/samples/piano/v0_57.ogg +0 -0
  95. package/dist/samples/piano/v0_61.ogg +0 -0
  96. package/dist/samples/piano/v0_65.ogg +0 -0
  97. package/dist/samples/piano/v0_69.ogg +0 -0
  98. package/dist/samples/piano/v0_73.ogg +0 -0
  99. package/dist/samples/piano/v0_77.ogg +0 -0
  100. package/dist/samples/piano/v0_81.ogg +0 -0
  101. package/dist/samples/piano/v0_85.ogg +0 -0
  102. package/dist/samples/piano/v0_89.ogg +0 -0
  103. package/dist/samples/piano/v0_93.ogg +0 -0
  104. package/dist/samples/piano/v0_97.ogg +0 -0
  105. package/dist/samples/piano/v1_29.ogg +0 -0
  106. package/dist/samples/piano/v1_33.ogg +0 -0
  107. package/dist/samples/piano/v1_37.ogg +0 -0
  108. package/dist/samples/piano/v1_41.ogg +0 -0
  109. package/dist/samples/piano/v1_45.ogg +0 -0
  110. package/dist/samples/piano/v1_49.ogg +0 -0
  111. package/dist/samples/piano/v1_53.ogg +0 -0
  112. package/dist/samples/piano/v1_57.ogg +0 -0
  113. package/dist/samples/piano/v1_61.ogg +0 -0
  114. package/dist/samples/piano/v1_65.ogg +0 -0
  115. package/dist/samples/piano/v1_69.ogg +0 -0
  116. package/dist/samples/piano/v1_73.ogg +0 -0
  117. package/dist/samples/piano/v1_77.ogg +0 -0
  118. package/dist/samples/piano/v1_81.ogg +0 -0
  119. package/dist/samples/piano/v1_85.ogg +0 -0
  120. package/dist/samples/piano/v1_89.ogg +0 -0
  121. package/dist/samples/piano/v1_93.ogg +0 -0
  122. package/dist/samples/piano/v1_97.ogg +0 -0
  123. package/dist/samples/wurli/v0_33.ogg +0 -0
  124. package/dist/samples/wurli/v0_40.ogg +0 -0
  125. package/dist/samples/wurli/v0_45.ogg +0 -0
  126. package/dist/samples/wurli/v0_51.ogg +0 -0
  127. package/dist/samples/wurli/v0_56.ogg +0 -0
  128. package/dist/samples/wurli/v0_61.ogg +0 -0
  129. package/dist/samples/wurli/v0_66.ogg +0 -0
  130. package/dist/samples/wurli/v0_71.ogg +0 -0
  131. package/dist/samples/wurli/v0_73.ogg +0 -0
  132. package/dist/samples/wurli/v0_79.ogg +0 -0
  133. package/dist/samples/wurli/v0_85.ogg +0 -0
  134. package/dist/samples/wurli/v0_92.ogg +0 -0
  135. package/dist/samples/wurli/v1_33.ogg +0 -0
  136. package/dist/samples/wurli/v1_36.ogg +0 -0
  137. package/dist/samples/wurli/v1_41.ogg +0 -0
  138. package/dist/samples/wurli/v1_47.ogg +0 -0
  139. package/dist/samples/wurli/v1_52.ogg +0 -0
  140. package/dist/samples/wurli/v1_56.ogg +0 -0
  141. package/dist/samples/wurli/v1_61.ogg +0 -0
  142. package/dist/samples/wurli/v1_68.ogg +0 -0
  143. package/dist/samples/wurli/v1_73.ogg +0 -0
  144. package/dist/samples/wurli/v1_79.ogg +0 -0
  145. package/dist/samples/wurli/v1_85.ogg +0 -0
  146. package/dist/samples/wurli/v1_92.ogg +0 -0
  147. package/dist/src-CgZeiBJc.mjs +711 -0
  148. package/dist/src-dP0jXhPP.mjs +2322 -0
  149. package/dist/tools-_xkF2Yfv.mjs +1891 -0
  150. package/dist/web/assets/figtree-latin-ext-wght-normal-DCwSJGxG.woff2 +0 -0
  151. package/dist/web/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  152. package/dist/web/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  153. package/dist/web/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  154. package/dist/web/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  155. package/dist/web/assets/index-BmW6jd5Q.js +272 -0
  156. package/dist/web/assets/index-C7U7z4um.css +1 -0
  157. package/dist/web/icon-192.png +0 -0
  158. package/dist/web/icon-512.png +0 -0
  159. package/dist/web/index.html +18 -0
  160. package/dist/web/manifest.webmanifest +24 -0
  161. package/dist/web/sw.js +37 -0
  162. package/package.json +59 -3
@@ -0,0 +1,1891 @@
1
+ import { A as PROGRESSIONS, C as GROOVE_IDS, D as RAIN_KINDS, E as INSTRUMENT_IDS, F as prefersFlats, I as scalePcs, L as mod12, M as resolveProgression, N as MODES, O as WINDOW_SCENES, P as keyLabel, R as noteName, T as INSTRUMENTS, _ as BASS_STYLES, a as layerRange, b as COMP_STYLES, c as createProject, d as findScene, f as parseProject, g as getMood, h as MOOD_IDS, i as buildSceneScore, j as progressionText, k as drumRows, l as createScene, m as makeId, n as describeProject, o as pitchToY, r as buildArrangementScore, s as createLayer, t as scoreToMidi, u as findLayer, v as BRUSH_IDS, w as HARMONY_MODES, x as DRUM_KITS, y as CHORD_INSTRUMENTS, z as parseNote } from "./src-dP0jXhPP.mjs";
2
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { delimiter, extname, join, normalize, resolve, sep } from "node:path";
4
+ import { z } from "zod";
5
+ import { randomUUID } from "node:crypto";
6
+ import { WebSocket, WebSocketServer } from "ws";
7
+ import { spawn } from "node:child_process";
8
+ import { createReadStream, existsSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createServer } from "node:http";
12
+ // ../../packages/core/src/shapes.ts
13
+ const SHAPES = [
14
+ "wave",
15
+ "arc",
16
+ "zigzag",
17
+ "stairs-up",
18
+ "stairs-down"
19
+ ];
20
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
21
+ /** Spreads timestamps evenly along the path so drawing speed is constant. */
22
+ function timePoints(path, durationMs, pressure) {
23
+ const lengths = [0];
24
+ for (let i = 1; i < path.length; i++) lengths.push(lengths[i - 1] + Math.hypot(path[i].x - path[i - 1].x, path[i].y - path[i - 1].y));
25
+ const total = lengths[lengths.length - 1] || 1;
26
+ return path.map((p, i) => ({
27
+ x: clamp01(p.x),
28
+ y: clamp01(p.y),
29
+ t: Math.round(lengths[i] / total * durationMs),
30
+ ...pressure === void 0 ? {} : { p: clamp01(pressure) }
31
+ }));
32
+ }
33
+ function linePath(from, to) {
34
+ const steps = Math.max(2, Math.ceil(Math.hypot(to.x - from.x, to.y - from.y) * 200));
35
+ return Array.from({ length: steps + 1 }, (_, i) => ({
36
+ x: from.x + (to.x - from.x) * i / steps,
37
+ y: from.y + (to.y - from.y) * i / steps
38
+ }));
39
+ }
40
+ /** A smooth Catmull-Rom curve through every control point. */
41
+ function curvePath(points) {
42
+ if (points.length < 3) return points.length === 2 ? linePath(points[0], points[1]) : [...points];
43
+ const out = [];
44
+ for (let i = 0; i < points.length - 1; i++) {
45
+ const p0 = points[Math.max(0, i - 1)];
46
+ const p1 = points[i];
47
+ const p2 = points[i + 1];
48
+ const p3 = points[Math.min(points.length - 1, i + 2)];
49
+ const steps = Math.max(4, Math.ceil(Math.hypot(p2.x - p1.x, p2.y - p1.y) * 200));
50
+ for (let s = 0; s < steps; s++) {
51
+ const t = s / steps;
52
+ const t2 = t * t;
53
+ const t3 = t2 * t;
54
+ const f = (a, b, c, d) => .5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3);
55
+ out.push({
56
+ x: f(p0.x, p1.x, p2.x, p3.x),
57
+ y: f(p0.y, p1.y, p2.y, p3.y)
58
+ });
59
+ }
60
+ }
61
+ out.push(points[points.length - 1]);
62
+ return out;
63
+ }
64
+ function shapePath(kind, { x, y, width, height, cycles = 2 }) {
65
+ const n = Math.max(24, Math.ceil(width * 240));
66
+ const at = (f) => x + width * f;
67
+ switch (kind) {
68
+ case "wave": return Array.from({ length: n + 1 }, (_, i) => ({
69
+ x: at(i / n),
70
+ y: y - height / 2 * Math.sin(i / n * cycles * Math.PI * 2)
71
+ }));
72
+ case "arc": return Array.from({ length: n + 1 }, (_, i) => ({
73
+ x: at(i / n),
74
+ y: y + height / 2 - height * Math.sin(i / n * Math.PI)
75
+ }));
76
+ case "zigzag": {
77
+ const corners = [];
78
+ for (let i = 0; i <= cycles * 2; i++) corners.push({
79
+ x: at(i / (cycles * 2)),
80
+ y: y + (i % 2 === 0 ? height / 2 : -height / 2)
81
+ });
82
+ return corners.flatMap((c, i) => i === 0 ? [c] : linePath(corners[i - 1], c).slice(1));
83
+ }
84
+ case "stairs-up":
85
+ case "stairs-down": {
86
+ const steps = Math.max(2, Math.round(cycles * 2));
87
+ const dir = kind === "stairs-up" ? -1 : 1;
88
+ const out = [];
89
+ for (let i = 0; i < steps; i++) {
90
+ const sy = y - dir * height / 2 + dir * height * i / (steps - 1);
91
+ out.push(...linePath({
92
+ x: at(i / steps),
93
+ y: sy
94
+ }, {
95
+ x: at((i + 1) / steps) - .001,
96
+ y: sy
97
+ }));
98
+ }
99
+ return out;
100
+ }
101
+ }
102
+ }
103
+ // ../../packages/protocol/src/schemas.ts
104
+ const unit = z.number().min(0).max(1);
105
+ const point = z.object({
106
+ x: unit.describe("0 is the start of the scene, 1 the end"),
107
+ y: unit.describe("0 is the top (high notes), 1 the bottom")
108
+ });
109
+ const target = {
110
+ scene: z.string().optional().describe("Scene id or name. Defaults to the active scene."),
111
+ layer: z.string().optional().describe("Layer id or name. Defaults to the first melodic layer.")
112
+ };
113
+ const pen = {
114
+ ...target,
115
+ brush: z.enum(BRUSH_IDS).optional().describe("pencil: eighth-note melody. brush: long soft notes. chalk: two-note harmonies. spray: scattered sparkles. stamp: one hit per sixteenth."),
116
+ size: unit.optional().describe("Brush thickness. Thicker plays softer and darker. Default 0.3."),
117
+ velocity: unit.optional().describe("How hard to play, like pen pressure. Leave out to use drawing speed."),
118
+ duration_ms: z.number().min(20).max(2e4).optional().describe("How long the stroke takes to draw. Faster strokes play louder. Default 1500.")
119
+ };
120
+ const CommandSchema = z.discriminatedUnion("type", [
121
+ z.object({
122
+ type: z.literal("set_project"),
123
+ name: z.string().min(1).max(80).optional(),
124
+ bpm: z.number().min(50).max(140).optional(),
125
+ swing: unit.optional(),
126
+ humanize: unit.optional(),
127
+ window: z.enum(WINDOW_SCENES).optional().describe("What you see outside the window")
128
+ }),
129
+ z.object({
130
+ type: z.literal("set_key"),
131
+ tonic: z.string().regex(/^[A-Ga-g][#b]?$/).describe("Like C, F#, or Bb"),
132
+ mode: z.enum(MODES)
133
+ }),
134
+ z.object({
135
+ type: z.literal("set_mood"),
136
+ mood: z.enum(MOOD_IDS),
137
+ keep_key: z.boolean().optional().describe("Keep the current key instead of the mood key"),
138
+ scene: target.scene
139
+ }),
140
+ z.object({
141
+ type: z.literal("set_progression"),
142
+ progression: z.string().describe("A preset id, or chords like \"ii9 V13 Imaj9 vi9\" or \"Em9 A13 Dmaj9:2 Bm9:2\". \":N\" sets beats, default 4."),
143
+ scene: target.scene
144
+ }),
145
+ z.object({
146
+ type: z.literal("set_backing"),
147
+ scene: target.scene,
148
+ chords: z.object({
149
+ enabled: z.boolean(),
150
+ instrument: z.enum(CHORD_INSTRUMENTS),
151
+ style: z.enum(COMP_STYLES),
152
+ volume: unit
153
+ }).partial().optional(),
154
+ bass: z.object({
155
+ enabled: z.boolean(),
156
+ style: z.enum(BASS_STYLES),
157
+ volume: unit
158
+ }).partial().optional(),
159
+ drums: z.object({
160
+ enabled: z.boolean(),
161
+ groove: z.enum(GROOVE_IDS),
162
+ volume: unit
163
+ }).partial().optional()
164
+ }),
165
+ z.object({
166
+ type: z.literal("mix"),
167
+ master: unit.optional(),
168
+ warmth: unit.optional(),
169
+ crackle: unit.optional(),
170
+ reverb: unit.optional(),
171
+ wobble: unit.optional(),
172
+ rain: z.object({
173
+ enabled: z.boolean(),
174
+ kind: z.enum(RAIN_KINDS),
175
+ level: unit
176
+ }).partial().optional()
177
+ }),
178
+ z.object({
179
+ type: z.literal("add_layer"),
180
+ instrument: z.enum(INSTRUMENT_IDS),
181
+ name: z.string().min(1).max(40).optional(),
182
+ color: z.string().optional(),
183
+ harmony: z.enum(HARMONY_MODES).optional(),
184
+ octave: z.number().int().min(-2).max(2).optional(),
185
+ kit: z.enum(DRUM_KITS).optional(),
186
+ scene: target.scene
187
+ }),
188
+ z.object({
189
+ type: z.literal("update_layer"),
190
+ layer: z.string(),
191
+ scene: target.scene,
192
+ name: z.string().min(1).max(40).optional(),
193
+ instrument: z.enum(INSTRUMENT_IDS).optional(),
194
+ color: z.string().optional(),
195
+ volume: unit.optional(),
196
+ pan: z.number().min(-1).max(1).optional(),
197
+ muted: z.boolean().optional(),
198
+ solo: z.boolean().optional(),
199
+ harmony: z.enum(HARMONY_MODES).optional(),
200
+ octave: z.number().int().min(-2).max(2).optional(),
201
+ kit: z.enum(DRUM_KITS).optional()
202
+ }),
203
+ z.object({
204
+ type: z.literal("remove_layer"),
205
+ layer: z.string(),
206
+ scene: target.scene
207
+ }),
208
+ z.object({
209
+ type: z.literal("draw_path"),
210
+ ...pen,
211
+ points: z.array(z.object({
212
+ x: unit,
213
+ y: unit,
214
+ t: z.number().min(0).optional(),
215
+ p: unit.optional()
216
+ })).min(1).max(5e3)
217
+ }),
218
+ z.object({
219
+ type: z.literal("draw_line"),
220
+ ...pen,
221
+ from: point,
222
+ to: point
223
+ }),
224
+ z.object({
225
+ type: z.literal("draw_curve"),
226
+ ...pen,
227
+ points: z.array(point).min(2).max(64).describe("A smooth curve passes through every point")
228
+ }),
229
+ z.object({
230
+ type: z.literal("draw_shape"),
231
+ ...pen,
232
+ shape: z.enum(SHAPES),
233
+ x: unit.describe("Left edge"),
234
+ y: unit.describe("Vertical center"),
235
+ width: z.number().min(.005).max(1),
236
+ height: z.number().min(0).max(1),
237
+ cycles: z.number().min(.5).max(32).optional().describe("Waves, zigzags, or stair steps across the width")
238
+ }),
239
+ z.object({
240
+ type: z.literal("draw_melody"),
241
+ ...target,
242
+ notes: z.array(z.object({
243
+ pitch: z.union([z.string(), z.number().int().min(0).max(127)]).describe("Like \"A4\" or a MIDI number. Played exactly, no snapping."),
244
+ beat: z.number().min(0).describe("Start, in beats from the start of the scene (4 per bar)"),
245
+ beats: z.number().min(.25).max(16).optional().describe("Length in beats. Default 1."),
246
+ velocity: unit.optional()
247
+ })).min(1).max(256)
248
+ }),
249
+ z.object({
250
+ type: z.literal("erase"),
251
+ ...target,
252
+ strokes: z.array(z.string()).optional().describe("Stroke ids to remove"),
253
+ region: z.object({
254
+ x0: unit,
255
+ x1: unit,
256
+ y0: unit.optional(),
257
+ y1: unit.optional()
258
+ }).optional().describe("Remove strokes that pass through this box"),
259
+ all: z.boolean().optional().describe("Clear the whole layer")
260
+ }),
261
+ z.object({
262
+ type: z.literal("add_scene"),
263
+ name: z.string().min(1).max(40),
264
+ bars: z.number().int().min(1).max(16).optional(),
265
+ copy_from: z.string().optional().describe("Scene to copy layers, chords, and backing from"),
266
+ progression: z.string().optional(),
267
+ select: z.boolean().optional().describe("Make it the active scene. Default true.")
268
+ }),
269
+ z.object({
270
+ type: z.literal("update_scene"),
271
+ scene: z.string(),
272
+ name: z.string().min(1).max(40).optional(),
273
+ bars: z.number().int().min(1).max(16).optional()
274
+ }),
275
+ z.object({
276
+ type: z.literal("remove_scene"),
277
+ scene: z.string()
278
+ }),
279
+ z.object({
280
+ type: z.literal("select_scene"),
281
+ scene: z.string()
282
+ }),
283
+ z.object({
284
+ type: z.literal("arrange"),
285
+ items: z.array(z.object({
286
+ scene: z.string(),
287
+ repeat: z.number().int().min(1).max(16).optional()
288
+ })).min(1).max(64)
289
+ }),
290
+ z.object({
291
+ type: z.literal("describe"),
292
+ scene: target.scene
293
+ })
294
+ ]);
295
+ /** Commands that only read the project. */
296
+ const READ_ONLY_COMMANDS = ["describe"];
297
+ // ../../packages/protocol/src/draw.ts
298
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
299
+ function strokePoints(cmd) {
300
+ const ms = cmd.duration_ms ?? 1500;
301
+ switch (cmd.type) {
302
+ case "draw_path":
303
+ if (cmd.points.every((p) => p.t !== void 0)) return cmd.points.map((p) => {
304
+ const pressure = p.p ?? cmd.velocity;
305
+ return {
306
+ x: p.x,
307
+ y: p.y,
308
+ t: p.t,
309
+ ...pressure === void 0 ? {} : { p: pressure }
310
+ };
311
+ });
312
+ return timePoints(cmd.points, ms, cmd.velocity).map((t, i) => cmd.points[i].p === void 0 ? t : {
313
+ ...t,
314
+ p: cmd.points[i].p
315
+ });
316
+ case "draw_line": return timePoints(linePath(cmd.from, cmd.to), ms, cmd.velocity);
317
+ case "draw_curve": return timePoints(curvePath(cmd.points), ms, cmd.velocity);
318
+ case "draw_shape": return timePoints(shapePath(cmd.shape, cmd), ms, cmd.velocity);
319
+ }
320
+ }
321
+ /** A short readout of what a set of strokes now plays, so the drawer hears it in words. */
322
+ function summarizeNotes(project, sceneId, layer, strokeIds) {
323
+ const notes = buildSceneScore({
324
+ ...project,
325
+ swing: 0,
326
+ humanize: 0,
327
+ scenes: project.scenes.map((s) => s.id === sceneId ? {
328
+ ...s,
329
+ layers: s.layers.map((l) => ({
330
+ ...l,
331
+ muted: false,
332
+ solo: false
333
+ }))
334
+ } : s)
335
+ }, sceneId).events.filter((e) => e.strokeId && strokeIds.has(e.strokeId));
336
+ if (notes.length === 0) return {
337
+ text: "no notes (the stroke may be outside the canvas or too small)",
338
+ count: 0
339
+ };
340
+ const flats = prefersFlats(project.key);
341
+ if (INSTRUMENTS[layer.instrument].kind === "drums") {
342
+ const counts = /* @__PURE__ */ new Map();
343
+ for (const n of notes) counts.set(n.drum, (counts.get(n.drum) ?? 0) + 1);
344
+ return {
345
+ text: `${plural(notes.length, "hit")} (${[...counts].map(([k, v]) => `${k} ${v}`).join(", ")})`,
346
+ count: notes.length
347
+ };
348
+ }
349
+ const shown = notes.slice(0, 16).map((n) => `${noteName(n.midi, flats)}@${Math.round(n.time * 100) / 100}`);
350
+ const more = notes.length > 16 ? ` and ${notes.length - 16} more` : "";
351
+ return {
352
+ text: `${plural(notes.length, "note")} (pitch@beat): ${shown.join(" ")}${more}`,
353
+ count: notes.length
354
+ };
355
+ }
356
+ function drawStroke(project, cmd) {
357
+ const { scene, layer } = findLayer(project, cmd.layer, cmd.scene);
358
+ const stroke = {
359
+ id: makeId("k"),
360
+ brush: cmd.brush ?? "pencil",
361
+ size: cmd.size ?? .3,
362
+ points: strokePoints(cmd)
363
+ };
364
+ layer.strokes.push(stroke);
365
+ const { text, count } = summarizeNotes(project, scene.id, layer, /* @__PURE__ */ new Set([stroke.id]));
366
+ const what = cmd.type === "draw_shape" ? `${cmd.shape} shape` : cmd.type.replace("draw_", "");
367
+ return {
368
+ project,
369
+ message: `Drew a ${stroke.brush} ${what} on "${layer.name}" [${stroke.id}]: ${text}`,
370
+ data: {
371
+ strokeId: stroke.id,
372
+ notes: count
373
+ }
374
+ };
375
+ }
376
+ function drawMelody(project, cmd) {
377
+ const { scene, layer } = findLayer(project, cmd.layer, cmd.scene);
378
+ if (INSTRUMENTS[layer.instrument].kind === "drums") throw new Error(`"${layer.name}" is a drum layer. Use draw_path with dots, or pick a melodic layer.`);
379
+ const range = layerRange(layer);
380
+ const totalBeats = scene.bars * 4;
381
+ const flats = prefersFlats(project.key);
382
+ const ids = /* @__PURE__ */ new Set();
383
+ for (const n of cmd.notes) {
384
+ const midi = typeof n.pitch === "number" ? n.pitch : parseNote(n.pitch);
385
+ if (midi === null) throw new Error(`Can't read pitch "${n.pitch}". Use a note like "A4" or a MIDI number.`);
386
+ if (midi < range[0] || midi > range[1]) throw new Error(`${noteName(midi, flats)} is outside "${layer.name}" (${noteName(range[0], flats)} to ${noteName(range[1], flats)}). Change the layer octave or pick another note.`);
387
+ const beats = n.beats ?? 1;
388
+ if (n.beat + beats > totalBeats + 1e-6) throw new Error(`A note at beat ${n.beat} lasting ${beats} runs past the end of the scene (${totalBeats} beats)`);
389
+ const y = pitchToY(midi, range);
390
+ const x0 = (n.beat + .01) / totalBeats;
391
+ const x1 = (n.beat + beats - .01) / totalBeats;
392
+ const points = timePoints([{
393
+ x: x0,
394
+ y
395
+ }, {
396
+ x: x1,
397
+ y
398
+ }], 200, n.velocity ?? .6);
399
+ const stroke = {
400
+ id: makeId("k"),
401
+ brush: "pencil",
402
+ size: .3,
403
+ points,
404
+ exact: true
405
+ };
406
+ layer.strokes.push(stroke);
407
+ ids.add(stroke.id);
408
+ }
409
+ const { text } = summarizeNotes(project, scene.id, layer, ids);
410
+ return {
411
+ project,
412
+ message: `Wrote a melody on "${layer.name}": ${text}`,
413
+ data: { strokeIds: [...ids] }
414
+ };
415
+ }
416
+ function strokeHits(stroke, region) {
417
+ const [xa, xb] = [Math.min(region.x0, region.x1), Math.max(region.x0, region.x1)];
418
+ const [ya, yb] = [Math.min(region.y0 ?? 0, region.y1 ?? 1), Math.max(region.y0 ?? 0, region.y1 ?? 1)];
419
+ return stroke.points.some((p) => p.x >= xa && p.x <= xb && p.y >= ya && p.y <= yb);
420
+ }
421
+ function erase(project, cmd) {
422
+ const { layer } = findLayer(project, cmd.layer, cmd.scene);
423
+ if (!cmd.all && !cmd.strokes && !cmd.region) throw new Error("Say what to erase: strokes, a region, or all");
424
+ const before = layer.strokes.length;
425
+ const ids = new Set(cmd.strokes ?? []);
426
+ layer.strokes = cmd.all ? [] : layer.strokes.filter((s) => !ids.has(s.id) && !(cmd.region && strokeHits(s, cmd.region)));
427
+ const removed = before - layer.strokes.length;
428
+ return {
429
+ project,
430
+ message: `Erased ${plural(removed, "stroke")} from "${layer.name}"`,
431
+ data: { removed }
432
+ };
433
+ }
434
+ // ../../packages/protocol/src/execute.ts
435
+ /** Copies only the keys that were actually given. */
436
+ function assignDefined(target, patch) {
437
+ const changed = [];
438
+ for (const [k, v] of Object.entries(patch)) {
439
+ if (v === void 0) continue;
440
+ target[k] = v;
441
+ changed.push(k);
442
+ }
443
+ return changed;
444
+ }
445
+ const listChanges = (changed) => changed.length ? changed.join(", ") : "nothing";
446
+ /** Validates unknown input (from JSON, the bridge, or an agent) and runs it. */
447
+ function executeUnknown(project, input) {
448
+ const parsed = CommandSchema.safeParse(input);
449
+ if (!parsed.success) {
450
+ const issue = parsed.error.issues[0];
451
+ throw new Error(`Bad command: ${issue.path.join(".") || "command"}: ${issue.message}`);
452
+ }
453
+ return execute(project, parsed.data);
454
+ }
455
+ function execute(input, cmd) {
456
+ const result = run(structuredClone(input), cmd);
457
+ if (!READ_ONLY_COMMANDS.includes(cmd.type)) result.project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
458
+ return result;
459
+ }
460
+ function run(project, cmd) {
461
+ switch (cmd.type) {
462
+ case "set_project": {
463
+ const { type: _, ...patch } = cmd;
464
+ const changed = assignDefined(project, patch);
465
+ return {
466
+ project,
467
+ message: `Updated ${listChanges(changed)}`
468
+ };
469
+ }
470
+ case "set_key": {
471
+ project.key = {
472
+ tonic: cmd.tonic[0].toUpperCase() + cmd.tonic.slice(1),
473
+ mode: cmd.mode
474
+ };
475
+ const scene = findScene(project);
476
+ return {
477
+ project,
478
+ message: `Key is now ${keyLabel(project.key)}. Chords: ${progressionText(scene.progression, project.key)}`
479
+ };
480
+ }
481
+ case "set_mood": {
482
+ const mood = getMood(cmd.mood);
483
+ const scene = findScene(project, cmd.scene);
484
+ project.mood = cmd.mood;
485
+ project.bpm = mood.bpm;
486
+ project.swing = mood.swing;
487
+ project.window = mood.window;
488
+ project.mix = structuredClone(mood.mix);
489
+ if (!cmd.keep_key) project.key = { ...mood.key };
490
+ scene.progression = resolveProgression(mood.progression, project.key);
491
+ scene.backing = structuredClone(mood.backing);
492
+ return {
493
+ project,
494
+ message: `Mood is ${mood.label}: ${keyLabel(project.key)}, ${mood.bpm} BPM, chords ${progressionText(scene.progression, project.key)}. Your strokes stay put.`
495
+ };
496
+ }
497
+ case "set_progression": {
498
+ const scene = findScene(project, cmd.scene);
499
+ scene.progression = resolveProgression(cmd.progression, project.key);
500
+ return {
501
+ project,
502
+ message: `Scene ${scene.name} chords: ${progressionText(scene.progression, project.key)}`
503
+ };
504
+ }
505
+ case "set_backing": {
506
+ const scene = findScene(project, cmd.scene);
507
+ const changed = [
508
+ ...assignDefined(scene.backing.chords, cmd.chords ?? {}).map((k) => `chords.${k}`),
509
+ ...assignDefined(scene.backing.bass, cmd.bass ?? {}).map((k) => `bass.${k}`),
510
+ ...assignDefined(scene.backing.drums, cmd.drums ?? {}).map((k) => `drums.${k}`)
511
+ ];
512
+ return {
513
+ project,
514
+ message: `Scene ${scene.name} backing: updated ${listChanges(changed)}`
515
+ };
516
+ }
517
+ case "mix": {
518
+ const { type: _, rain, ...levels } = cmd;
519
+ const changed = [...assignDefined(project.mix, levels), ...assignDefined(project.mix.rain, rain ?? {}).map((k) => `rain.${k}`)];
520
+ return {
521
+ project,
522
+ message: `Mix: updated ${listChanges(changed)}`
523
+ };
524
+ }
525
+ case "add_layer": {
526
+ const scene = findScene(project, cmd.scene);
527
+ const layer = createLayer({
528
+ instrument: cmd.instrument,
529
+ name: cmd.name,
530
+ color: cmd.color,
531
+ index: scene.layers.length
532
+ });
533
+ assignDefined(layer, {
534
+ harmony: cmd.harmony,
535
+ octave: cmd.octave,
536
+ kit: cmd.kit
537
+ });
538
+ scene.layers.push(layer);
539
+ return {
540
+ project,
541
+ message: `Added layer "${layer.name}" [${layer.id}] (${INSTRUMENTS[layer.instrument].label}) to scene ${scene.name}`,
542
+ data: { layerId: layer.id }
543
+ };
544
+ }
545
+ case "update_layer": {
546
+ const { layer } = findLayer(project, cmd.layer, cmd.scene);
547
+ const { type: _, layer: __, scene: ___, ...patch } = cmd;
548
+ const changed = assignDefined(layer, patch);
549
+ return {
550
+ project,
551
+ message: `Layer "${layer.name}": updated ${listChanges(changed)}`
552
+ };
553
+ }
554
+ case "remove_layer": {
555
+ const { scene, layer } = findLayer(project, cmd.layer, cmd.scene);
556
+ scene.layers = scene.layers.filter((l) => l.id !== layer.id);
557
+ return {
558
+ project,
559
+ message: `Removed layer "${layer.name}" and its ${layer.strokes.length} strokes`
560
+ };
561
+ }
562
+ case "draw_path":
563
+ case "draw_line":
564
+ case "draw_curve":
565
+ case "draw_shape": return drawStroke(project, cmd);
566
+ case "draw_melody": return drawMelody(project, cmd);
567
+ case "erase": return erase(project, cmd);
568
+ case "add_scene": {
569
+ const source = cmd.copy_from ? findScene(project, cmd.copy_from) : null;
570
+ const scene = source ? {
571
+ ...structuredClone(source),
572
+ id: makeId("s"),
573
+ name: cmd.name
574
+ } : createScene(project, cmd.name, cmd.bars ?? findScene(project).bars);
575
+ if (source) for (const layer of scene.layers) layer.id = makeId("l");
576
+ if (cmd.bars) scene.bars = cmd.bars;
577
+ if (cmd.progression) scene.progression = resolveProgression(cmd.progression, project.key);
578
+ project.scenes.push(scene);
579
+ project.arrangement.push({
580
+ sceneId: scene.id,
581
+ repeat: 1
582
+ });
583
+ if (cmd.select !== false) project.activeSceneId = scene.id;
584
+ return {
585
+ project,
586
+ message: `Added scene ${scene.name} [${scene.id}], ${scene.bars} bars, and put it at the end of the arrangement`,
587
+ data: { sceneId: scene.id }
588
+ };
589
+ }
590
+ case "update_scene": {
591
+ const scene = findScene(project, cmd.scene);
592
+ const changed = assignDefined(scene, {
593
+ name: cmd.name,
594
+ bars: cmd.bars
595
+ });
596
+ return {
597
+ project,
598
+ message: `Scene ${scene.name}: updated ${listChanges(changed)}`
599
+ };
600
+ }
601
+ case "remove_scene": {
602
+ const scene = findScene(project, cmd.scene);
603
+ if (project.scenes.length === 1) throw new Error("Can't remove the only scene");
604
+ project.scenes = project.scenes.filter((s) => s.id !== scene.id);
605
+ project.arrangement = project.arrangement.filter((a) => a.sceneId !== scene.id);
606
+ if (project.arrangement.length === 0) project.arrangement = [{
607
+ sceneId: project.scenes[0].id,
608
+ repeat: 1
609
+ }];
610
+ if (project.activeSceneId === scene.id) project.activeSceneId = project.scenes[0].id;
611
+ return {
612
+ project,
613
+ message: `Removed scene ${scene.name}`
614
+ };
615
+ }
616
+ case "select_scene": {
617
+ const scene = findScene(project, cmd.scene);
618
+ project.activeSceneId = scene.id;
619
+ return {
620
+ project,
621
+ message: `Now editing scene ${scene.name}`
622
+ };
623
+ }
624
+ case "arrange":
625
+ project.arrangement = cmd.items.map((i) => ({
626
+ sceneId: findScene(project, i.scene).id,
627
+ repeat: i.repeat ?? 1
628
+ }));
629
+ return {
630
+ project,
631
+ message: `Arrangement: ${project.arrangement.map((a) => `${findScene(project, a.sceneId).name}${a.repeat > 1 ? `×${a.repeat}` : ""}`).join(" → ")}`
632
+ };
633
+ case "describe": return {
634
+ project,
635
+ message: describeProject(project, cmd.scene)
636
+ };
637
+ }
638
+ }
639
+ // ../../packages/protocol/src/help.ts
640
+ const COMMAND_HELP = {
641
+ set_project: "Rename the project or change tempo, swing, humanize, or the window view.",
642
+ set_key: "Change the key. Chords follow automatically, since they are stored relative to the tonic.",
643
+ set_mood: "Apply a mood preset: tempo, key, chords, swing, mix, backing band, and window. Drawn strokes stay.",
644
+ set_progression: "Set the chords for a scene, from a preset id, roman numerals, or chord symbols.",
645
+ set_backing: "Change the backing band for a scene: chord instrument and comping style, bass style, drum groove, and their volumes.",
646
+ mix: "Change the master mix: level, warmth, crackle, reverb, wobble, and rain.",
647
+ add_layer: "Add a layer to draw on, with its own instrument. Returns the new layer id.",
648
+ update_layer: "Change a layer: name, instrument, volume, pan, mute, solo, harmony snapping, octave, or drum kit.",
649
+ remove_layer: "Delete a layer and everything drawn on it.",
650
+ draw_path: "Draw a freehand stroke from raw points. x is time across the scene (0 to 1), y is pitch (0 is high). Notes snap to the chords.",
651
+ draw_line: "Draw a straight stroke between two points. A rising line plays a rising melody.",
652
+ draw_curve: "Draw a smooth curve through a few points. Good for phrases that rise and fall.",
653
+ draw_shape: "Draw a shape (wave, arc, zigzag, stairs up or down) inside a box. Quick way to get a musical figure.",
654
+ draw_melody: "Write exact notes by pitch and beat, with no snapping. Use when you know the notes you want.",
655
+ erase: "Remove strokes from a layer: by stroke id, by a box on the canvas, or all of them.",
656
+ add_scene: "Add a scene (a section like a verse or a bridge), optionally copied from another. It goes on the end of the arrangement.",
657
+ update_scene: "Rename a scene or change its length in bars. Strokes stretch to fit.",
658
+ remove_scene: "Delete a scene and take it out of the arrangement.",
659
+ select_scene: "Make a scene the active one, which is the default for drawing commands.",
660
+ arrange: "Set the song order: a list of scenes, each with an optional repeat count.",
661
+ describe: "Read the project back as text: key, tempo, chords, layers, and a small piano roll of what each layer plays."
662
+ };
663
+ // ../../packages/protocol/src/bridge.ts
664
+ const BRIDGE_HOST = "127.0.0.1";
665
+ const BRIDGE_PORT = 5179;
666
+ /** What an agent can ask the tab to do. */
667
+ const TabOpSchema = z.discriminatedUnion("op", [
668
+ z.object({ op: z.literal("get_project") }),
669
+ z.object({
670
+ op: z.literal("execute"),
671
+ commands: z.array(z.unknown()).min(1).max(500)
672
+ }),
673
+ z.object({
674
+ op: z.literal("open_project"),
675
+ project: z.unknown()
676
+ }),
677
+ z.object({
678
+ op: z.literal("play"),
679
+ mode: z.enum(["loop", "song"]).optional(),
680
+ scene: z.string().optional()
681
+ }),
682
+ z.object({ op: z.literal("stop") }),
683
+ z.object({ op: z.literal("status") })
684
+ ]);
685
+ /** Answered by the bridge itself, without a tab. */
686
+ const PingOpSchema = z.object({ op: z.literal("ping") });
687
+ const BridgeMessageSchema = z.discriminatedUnion("kind", [
688
+ z.object({
689
+ kind: z.literal("hello"),
690
+ app: z.literal("haku"),
691
+ version: z.string().optional()
692
+ }),
693
+ z.object({
694
+ kind: z.literal("request"),
695
+ id: z.string().min(1).max(64),
696
+ op: z.union([TabOpSchema, PingOpSchema])
697
+ }),
698
+ z.object({
699
+ kind: z.literal("response"),
700
+ id: z.string().min(1).max(64),
701
+ ok: z.boolean(),
702
+ result: z.unknown().optional(),
703
+ error: z.string().optional()
704
+ })
705
+ ]);
706
+ /** Parses one incoming frame. Returns null for anything that isn't a valid message. */
707
+ function parseBridgeMessage(raw) {
708
+ let data;
709
+ try {
710
+ data = JSON.parse(raw);
711
+ } catch {
712
+ return null;
713
+ }
714
+ const parsed = BridgeMessageSchema.safeParse(data);
715
+ return parsed.success ? parsed.data : null;
716
+ }
717
+ // src/bridge.ts
718
+ const NO_TAB = "No Haku tab is connected. Run \"haku web\" to open one, or pass a project file.";
719
+ const DEFAULT_TIMEOUT = 2e4;
720
+ function bridgePort() {
721
+ const env = Number(process.env.HAKU_BRIDGE_PORT);
722
+ return Number.isInteger(env) && env > 0 ? env : BRIDGE_PORT;
723
+ }
724
+ /** Pages on this machine only, plus anything listed in HAKU_ALLOW_ORIGINS (comma separated). */
725
+ function originAllowed(origin) {
726
+ if (!origin) return false;
727
+ if ((process.env.HAKU_ALLOW_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean).includes(origin)) return true;
728
+ try {
729
+ const { hostname, protocol } = new URL(origin);
730
+ return (protocol === "http:" || protocol === "https:") && [
731
+ "localhost",
732
+ "127.0.0.1",
733
+ "[::1]"
734
+ ].includes(hostname);
735
+ } catch {
736
+ return false;
737
+ }
738
+ }
739
+ const roleOf = (req) => new URL(req.url ?? "/", "http://bridge").pathname;
740
+ /** Tracks requests sent over one socket and matches up the responses. */
741
+ var Requests = class {
742
+ pending = /* @__PURE__ */ new Map();
743
+ send(socket, op, timeoutMs) {
744
+ const id = randomUUID();
745
+ return new Promise((resolve, reject) => {
746
+ const timer = setTimeout(() => {
747
+ this.pending.delete(id);
748
+ reject(/* @__PURE__ */ new Error(`The Haku tab didn't answer "${op.op}" within ${Math.round(timeoutMs / 1e3)} s`));
749
+ }, timeoutMs);
750
+ this.pending.set(id, {
751
+ resolve,
752
+ reject,
753
+ timer,
754
+ socket
755
+ });
756
+ socket.send(JSON.stringify({
757
+ kind: "request",
758
+ id,
759
+ op
760
+ }));
761
+ });
762
+ }
763
+ settle(msg) {
764
+ const p = this.pending.get(msg.id);
765
+ if (!p) return;
766
+ this.pending.delete(msg.id);
767
+ clearTimeout(p.timer);
768
+ if (msg.ok) p.resolve(msg.result);
769
+ else p.reject(new Error(msg.error ?? "The Haku tab reported an error"));
770
+ }
771
+ /** Fails everything still waiting on a socket that went away. */
772
+ drop(socket, reason) {
773
+ for (const [id, p] of this.pending) {
774
+ if (p.socket !== socket) continue;
775
+ this.pending.delete(id);
776
+ clearTimeout(p.timer);
777
+ p.reject(new Error(reason));
778
+ }
779
+ }
780
+ };
781
+ var BridgeHost = class BridgeHost {
782
+ tabs = [];
783
+ requests = new Requests();
784
+ /** Called with a short line when tabs come and go, for `haku bridge` to print. */
785
+ onEvent = null;
786
+ wss;
787
+ constructor(wss) {
788
+ this.wss = wss;
789
+ wss.on("connection", (ws, req) => roleOf(req) === "/tab" ? this.addTab(ws) : this.addAgent(ws));
790
+ }
791
+ /** Starts listening. Rejects with code EADDRINUSE when something already holds the port. */
792
+ static listen(port = bridgePort()) {
793
+ return new Promise((resolve, reject) => {
794
+ const wss = new WebSocketServer({
795
+ host: BRIDGE_HOST,
796
+ port,
797
+ maxPayload: 16777216,
798
+ verifyClient: ({ req, origin }) => {
799
+ const role = roleOf(req);
800
+ if (role === "/tab") return originAllowed(origin);
801
+ if (role === "/agent") return !req.headers.origin;
802
+ return false;
803
+ }
804
+ });
805
+ wss.once("listening", () => resolve(new BridgeHost(wss)));
806
+ wss.once("error", reject);
807
+ });
808
+ }
809
+ get tabCount() {
810
+ return this.tabs.length;
811
+ }
812
+ /** The port actually bound, which matters when listening on 0. */
813
+ get port() {
814
+ const addr = this.wss.address();
815
+ return typeof addr === "object" && addr ? addr.port : 0;
816
+ }
817
+ addTab(ws) {
818
+ this.tabs.push(ws);
819
+ this.onEvent?.(`Tab connected (${this.tabs.length} open)`);
820
+ ws.on("message", (raw) => {
821
+ const msg = parseBridgeMessage(raw.toString());
822
+ if (msg?.kind === "response") this.requests.settle(msg);
823
+ else if (msg?.kind === "hello") this.tabs = [...this.tabs.filter((t) => t !== ws), ws];
824
+ });
825
+ ws.on("close", () => {
826
+ this.tabs = this.tabs.filter((t) => t !== ws);
827
+ this.requests.drop(ws, "The Haku tab closed before it answered");
828
+ this.onEvent?.(`Tab disconnected (${this.tabs.length} open)`);
829
+ });
830
+ }
831
+ addAgent(ws) {
832
+ ws.on("message", async (raw) => {
833
+ const msg = parseBridgeMessage(raw.toString());
834
+ if (msg?.kind !== "request") return;
835
+ const reply = (body) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify({
836
+ kind: "response",
837
+ id: msg.id,
838
+ ...body
839
+ }));
840
+ try {
841
+ reply({
842
+ ok: true,
843
+ result: await this.request(msg.op)
844
+ });
845
+ } catch (err) {
846
+ reply({
847
+ ok: false,
848
+ error: err instanceof Error ? err.message : String(err)
849
+ });
850
+ }
851
+ });
852
+ }
853
+ async request(op, timeoutMs = DEFAULT_TIMEOUT) {
854
+ if (op.op === "ping") return {
855
+ tab: this.tabs.length > 0,
856
+ tabs: this.tabs.length
857
+ };
858
+ const tab = this.tabs.at(-1);
859
+ if (!tab) throw new Error(NO_TAB);
860
+ return this.requests.send(tab, op, timeoutMs);
861
+ }
862
+ close() {
863
+ for (const ws of this.wss.clients) ws.terminate();
864
+ return new Promise((resolve) => this.wss.close(() => resolve()));
865
+ }
866
+ };
867
+ var BridgeClient = class BridgeClient {
868
+ requests = new Requests();
869
+ onClose = null;
870
+ ws;
871
+ constructor(ws) {
872
+ this.ws = ws;
873
+ ws.on("message", (raw) => {
874
+ const msg = parseBridgeMessage(raw.toString());
875
+ if (msg?.kind === "response") this.requests.settle(msg);
876
+ });
877
+ ws.on("close", () => {
878
+ this.requests.drop(ws, "Lost the connection to the Haku bridge");
879
+ this.onClose?.();
880
+ });
881
+ }
882
+ /** Resolves to null when nothing is listening, so callers can fall back to offline. */
883
+ static connect(port = bridgePort(), timeoutMs = 1e3) {
884
+ return new Promise((resolve) => {
885
+ const ws = new WebSocket(`ws://${BRIDGE_HOST}:${port}/agent`, {
886
+ handshakeTimeout: timeoutMs,
887
+ maxPayload: 16777216
888
+ });
889
+ ws.once("open", () => resolve(new BridgeClient(ws)));
890
+ ws.once("error", () => resolve(null));
891
+ });
892
+ }
893
+ request(op, timeoutMs = DEFAULT_TIMEOUT) {
894
+ if (this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("Lost the connection to the Haku bridge"));
895
+ return this.requests.send(this.ws, op, timeoutMs + 2e3);
896
+ }
897
+ close() {
898
+ if (this.ws.readyState === WebSocket.CLOSED) return Promise.resolve();
899
+ return new Promise((resolve) => {
900
+ this.ws.once("close", () => resolve());
901
+ this.ws.close();
902
+ });
903
+ }
904
+ };
905
+ const isAddrInUse = (err) => err?.code === "EADDRINUSE";
906
+ /** Hosts the bridge when the port is free, otherwise joins the existing host. Used by the MCP server. */
907
+ var SharedLink = class {
908
+ current = null;
909
+ connecting = null;
910
+ closed = false;
911
+ port;
912
+ constructor(port = bridgePort()) {
913
+ this.port = port;
914
+ }
915
+ get hosting() {
916
+ return this.current instanceof BridgeHost;
917
+ }
918
+ ensure() {
919
+ if (this.current) return Promise.resolve(this.current);
920
+ this.connecting ??= (async () => {
921
+ try {
922
+ this.current = await BridgeHost.listen(this.port);
923
+ } catch (err) {
924
+ if (!isAddrInUse(err)) throw err;
925
+ const client = await BridgeClient.connect(this.port);
926
+ if (client) client.onClose = () => {
927
+ this.current = null;
928
+ if (!this.closed) setTimeout(() => void this.ensure().catch(() => {}), 250);
929
+ };
930
+ this.current = client;
931
+ }
932
+ return this.current;
933
+ })().finally(() => this.connecting = null);
934
+ return this.connecting;
935
+ }
936
+ async request(op, timeoutMs) {
937
+ const link = await this.ensure();
938
+ if (!link) throw new Error(`Couldn't reach the Haku bridge on port ${this.port}`);
939
+ return link.request(op, timeoutMs);
940
+ }
941
+ async close() {
942
+ this.closed = true;
943
+ await this.current?.close();
944
+ this.current = null;
945
+ }
946
+ };
947
+ // src/guide.ts
948
+ const presets = Object.entries(PROGRESSIONS).map(([id, p]) => `${id} (${p.text})`).join(", ");
949
+ const GUIDE = `Haku turns strokes on a canvas into lofi music. A backing band (chords, bass, drums) plays along, and drawn notes snap to the chords.
950
+
951
+ Where the work happens:
952
+ - With no file argument, tools act on the project open in the Haku browser tab, live. Changes show up there and land in its undo history. If no tab is open, Haku starts its local app server and opens one in the browser.
953
+ - With file set to a .haku.json path, tools read and write that file instead. new_project makes one.
954
+
955
+ The canvas:
956
+ - x is time: 0 is the start of the scene, 1 is the end. A scene is 1 to 16 bars of 4 beats.
957
+ - y is pitch: 0 is the top of the layer's range (high), 1 is the bottom (low). Drum layers use 7 rows instead, bottom to top: kick, tom, snare, side stick, hi-hat, open hat, ride.
958
+ - Brushes set the rhythm grid: pencil (eighths), brush (quarters, soft), chalk (eighths, two-note harmonies), spray (scattered sixteenths), stamp (one hit per sixteenth, good for drums).
959
+ - Faster strokes (lower duration_ms) play louder. draw_melody skips snapping when you want exact notes.
960
+
961
+ A good loop:
962
+ 1. describe to see what's there. set_mood picks a whole vibe in one go: ${MOOD_IDS.join(", ")}.
963
+ 2. add_layer for each part (piano, wurli, fm-ep, glock, marimba, harp, pad, pluck, bass, drums).
964
+ 3. Draw with draw_shape, draw_curve, or draw_line. Use batch to send many commands as one step.
965
+ 4. Check your work: describe (text piano roll), snapshot (a picture), analyze (chord fit, clashes, loudness).
966
+ 5. play to listen, export for WAV, MIDI, or the project file.
967
+
968
+ Chord progressions take a preset id or chords like "ii9 V13 Imaj9 vi9" (":N" sets beats per chord). Presets: ${presets}.
969
+
970
+ Scenes and layers can be named by id or by name.`;
971
+ // src/player.ts
972
+ const PLAYERS = [
973
+ {
974
+ cmd: "pw-play",
975
+ args: (f) => [f]
976
+ },
977
+ {
978
+ cmd: "paplay",
979
+ args: (f) => [f]
980
+ },
981
+ {
982
+ cmd: "aplay",
983
+ args: (f) => ["-q", f]
984
+ },
985
+ {
986
+ cmd: "afplay",
987
+ args: (f) => [f]
988
+ },
989
+ {
990
+ cmd: "ffplay",
991
+ args: (f) => [
992
+ "-nodisp",
993
+ "-autoexit",
994
+ "-loglevel",
995
+ "quiet",
996
+ f
997
+ ]
998
+ }
999
+ ];
1000
+ function onPath(cmd) {
1001
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE").split(";") : [""];
1002
+ return (process.env.PATH ?? "").split(delimiter).some((dir) => exts.some((ext) => existsSync(join(dir, cmd + ext))));
1003
+ }
1004
+ function findPlayer(file) {
1005
+ if (process.platform === "win32") return {
1006
+ cmd: "powershell",
1007
+ args: [
1008
+ "-NoProfile",
1009
+ "-Command",
1010
+ `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`
1011
+ ]
1012
+ };
1013
+ const p = PLAYERS.find((p) => onPath(p.cmd));
1014
+ return p ? {
1015
+ cmd: p.cmd,
1016
+ args: p.args(file)
1017
+ } : null;
1018
+ }
1019
+ var Player = class {
1020
+ child = null;
1021
+ /** Starts playing and returns the player's name. `done` settles when it finishes or is stopped. */
1022
+ play(file) {
1023
+ this.stop();
1024
+ const player = findPlayer(file);
1025
+ if (!player) throw new Error(`No audio player found. Install one of ${PLAYERS.map((p) => p.cmd).join(", ")}, or open Haku in a browser.`);
1026
+ const child = spawn(player.cmd, player.args, { stdio: "ignore" });
1027
+ this.child = child;
1028
+ const done = new Promise((resolve, reject) => {
1029
+ child.once("error", reject);
1030
+ child.once("exit", () => resolve());
1031
+ }).finally(() => {
1032
+ if (this.child === child) this.child = null;
1033
+ });
1034
+ return {
1035
+ name: player.cmd,
1036
+ done
1037
+ };
1038
+ }
1039
+ get playing() {
1040
+ return this.child !== null;
1041
+ }
1042
+ stop() {
1043
+ if (!this.child) return false;
1044
+ this.child.kill();
1045
+ this.child = null;
1046
+ return true;
1047
+ }
1048
+ };
1049
+ // src/analyze.ts
1050
+ const round = (v, places = 2) => Math.round(v * 10 ** places) / 10 ** places;
1051
+ function analyzeScenes(project, sceneIds) {
1052
+ const flats = prefersFlats(project.key);
1053
+ const scale = new Set(scalePcs(project.key));
1054
+ return sceneIds.map((sceneId) => {
1055
+ const scene = findScene(project, sceneId);
1056
+ const score = buildSceneScore(project, scene.id);
1057
+ const beats = scene.bars * 4;
1058
+ const chordAt = (t) => score.chords.find((c) => t >= c.time - 1e-6 && t < c.time + c.duration) ?? score.chords.at(-1);
1059
+ const layers = scene.layers.map((layer) => {
1060
+ const notes = score.events.filter((e) => e.track === layer.id).sort((a, b) => a.time - b.time);
1061
+ let longestRest = notes.length ? Math.max(0, notes[0].time) : beats;
1062
+ let end = notes[0]?.time ?? 0;
1063
+ for (const e of notes) {
1064
+ longestRest = Math.max(longestRest, e.time - end);
1065
+ end = Math.max(end, e.time + e.duration);
1066
+ }
1067
+ if (notes.length) longestRest = Math.max(longestRest, beats - end);
1068
+ const base = {
1069
+ id: layer.id,
1070
+ name: layer.name,
1071
+ instrument: layer.instrument,
1072
+ notes: notes.length,
1073
+ notesPerBar: round(notes.length / scene.bars, 1),
1074
+ averageVelocity: notes.length ? round(notes.reduce((s, e) => s + e.velocity, 0) / notes.length) : 0,
1075
+ longestRest: round(longestRest, 2)
1076
+ };
1077
+ if (INSTRUMENTS[layer.instrument].kind === "drums") {
1078
+ const hits = {};
1079
+ for (const e of notes) hits[e.drum ?? "?"] = (hits[e.drum ?? "?"] ?? 0) + 1;
1080
+ return {
1081
+ ...base,
1082
+ hits
1083
+ };
1084
+ }
1085
+ const strong = notes.filter((e) => {
1086
+ return Math.round(e.time * 4) / 4 % 2 === 0;
1087
+ });
1088
+ const chordTones = strong.filter((e) => chordAt(Math.round(e.time * 4) / 4)?.pcs.includes(mod12(e.midi))).length;
1089
+ const pitches = notes.map((e) => e.midi);
1090
+ return {
1091
+ ...base,
1092
+ range: notes.length ? `${noteName(Math.min(...pitches), flats)} to ${noteName(Math.max(...pitches), flats)}` : "none",
1093
+ strongBeatChordTones: strong.length ? round(chordTones / strong.length) : 1,
1094
+ outOfKey: notes.filter((e) => {
1095
+ const pc = mod12(e.midi);
1096
+ return !scale.has(pc) && !chordAt(Math.round(e.time * 4) / 4)?.pcs.includes(pc);
1097
+ }).length
1098
+ };
1099
+ });
1100
+ const tonal = new Set(scene.layers.filter((l) => INSTRUMENTS[l.instrument].kind === "tonal").map((l) => l.id));
1101
+ const drawn = score.events.filter((e) => tonal.has(e.track));
1102
+ const clashes = /* @__PURE__ */ new Map();
1103
+ for (let i = 0; i < drawn.length; i++) for (let j = i + 1; j < drawn.length; j++) {
1104
+ const a = drawn[i];
1105
+ const b = drawn[j];
1106
+ if (a.track === b.track) continue;
1107
+ const overlap = Math.min(a.time + a.duration, b.time + b.duration) - Math.max(a.time, b.time);
1108
+ const gap = Math.abs(a.midi - b.midi);
1109
+ if (overlap > .1 && (gap === 1 || gap === 13)) {
1110
+ const beat = round(Math.max(a.time, b.time), 2);
1111
+ if (!clashes.has(beat)) clashes.set(beat, `${noteName(a.midi, flats)} against ${noteName(b.midi, flats)}`);
1112
+ }
1113
+ }
1114
+ return {
1115
+ id: scene.id,
1116
+ name: scene.name,
1117
+ bars: scene.bars,
1118
+ layers,
1119
+ clashes: [...clashes].sort((a, b) => a[0] - b[0]).map(([beat, notes]) => ({
1120
+ beat,
1121
+ notes
1122
+ }))
1123
+ };
1124
+ });
1125
+ }
1126
+ const pct = (v) => `${Math.round(v * 100)}%`;
1127
+ const db = (v) => Number.isFinite(v) ? v.toFixed(1) : "-inf";
1128
+ function formatAnalysis(scenes, levels) {
1129
+ const lines = [];
1130
+ for (const scene of scenes) {
1131
+ if (lines.length) lines.push("");
1132
+ lines.push(`Scene ${scene.name} [${scene.id}], ${scene.bars} bars`);
1133
+ if (!scene.layers.length) lines.push(" No layers.");
1134
+ for (const l of scene.layers) {
1135
+ const parts = [`${l.notes} notes (${l.notesPerBar}/bar)`];
1136
+ if (l.hits) parts.push(Object.entries(l.hits).map(([k, v]) => `${k} ${v}`).join(", ") || "no hits");
1137
+ else if (l.notes) parts.push(`range ${l.range}`, `strong-beat chord tones ${pct(l.strongBeatChordTones ?? 1)}`, `out of key ${l.outOfKey}`);
1138
+ if (l.notes) parts.push(`avg velocity ${l.averageVelocity}`, `longest rest ${l.longestRest} beats`);
1139
+ lines.push(` "${l.name}" (${l.instrument}): ${parts.join(", ")}`);
1140
+ }
1141
+ if (scene.clashes.length) {
1142
+ const shown = scene.clashes.slice(0, 8).map((c) => `beat ${c.beat} ${c.notes}`);
1143
+ lines.push(` Half-step clashes between layers: ${shown.join("; ")}${scene.clashes.length > 8 ? `; and ${scene.clashes.length - 8} more` : ""}`);
1144
+ } else lines.push(" No half-step clashes between layers.");
1145
+ }
1146
+ if (levels) {
1147
+ lines.push("");
1148
+ lines.push(`Render: ${levels.seconds.toFixed(1)} s, ${db(levels.lufs)} LUFS integrated, peak ${db(levels.peakDb)} dBFS, RMS ${db(levels.rmsDb)} dBFS`);
1149
+ if (levels.peakDb > -.5) lines.push(" Peaks are close to clipping. Try lowering master or a busy layer.");
1150
+ else if (levels.lufs < -20) lines.push(" Quiet for lofi. Most moods land around -12 to -15 LUFS.");
1151
+ }
1152
+ return lines.join("\n");
1153
+ }
1154
+ // src/paths.ts
1155
+ function samplesDir() {
1156
+ if (process.env.HAKU_SAMPLES) return resolve(process.env.HAKU_SAMPLES);
1157
+ const found = [new URL("./samples", import.meta.url), new URL("../../../assets/samples", import.meta.url)].map((u) => fileURLToPath(u)).find((dir) => existsSync(join(dir, "manifest.json")));
1158
+ if (!found) throw new Error("Can't find Haku's samples. Set HAKU_SAMPLES to the folder that holds manifest.json.");
1159
+ return found;
1160
+ }
1161
+ /** Figtree for snapshots. Loading every system font instead takes seconds on a well-stocked machine. */
1162
+ function fontFiles() {
1163
+ const dir = [new URL("./fonts", import.meta.url), new URL("../assets/fonts", import.meta.url)].map((u) => fileURLToPath(u)).find((d) => existsSync(join(d, "Figtree-Regular.ttf")));
1164
+ return dir ? ["Figtree-Regular.ttf", "Figtree-SemiBold.ttf"].map((f) => join(dir, f)) : [];
1165
+ }
1166
+ /** The built web app. A published build carries it in dist/web; in the repo it's apps/web/dist (run `pnpm --filter @haku/web build`). */
1167
+ function webDir() {
1168
+ if (process.env.HAKU_WEB_DIR) return resolve(process.env.HAKU_WEB_DIR);
1169
+ const found = [new URL("./web", import.meta.url), new URL("../../web/dist", import.meta.url)].map((u) => fileURLToPath(u)).find((dir) => existsSync(join(dir, "index.html")));
1170
+ if (!found) throw new Error("Can't find the built Haku app. In the repo, run `pnpm --filter @haku/web build` first.");
1171
+ return found;
1172
+ }
1173
+ // src/audio.ts
1174
+ let kit = null;
1175
+ function loadAudio() {
1176
+ kit ??= (async () => {
1177
+ const node = await import("./node-CVxAljK8.mjs");
1178
+ node.setupNodeAudio();
1179
+ const audio = await import("./src-CgZeiBJc.mjs");
1180
+ const dir = samplesDir();
1181
+ const bank = await audio.loadSampleBank(await node.readManifest(dir), node.nodeDecoder(dir));
1182
+ return {
1183
+ render: (project, opts) => audio.renderProject(project, bank, opts),
1184
+ wav: (pcm) => audio.encodeWav(pcm),
1185
+ levels: (pcm) => ({
1186
+ seconds: (pcm.channels[0]?.length ?? 0) / pcm.sampleRate,
1187
+ ...audio.measure(pcm),
1188
+ lufs: audio.integratedLoudness(pcm)
1189
+ })
1190
+ };
1191
+ })();
1192
+ kit.catch(() => kit = null);
1193
+ return kit;
1194
+ }
1195
+ // src/version.ts
1196
+ const VERSION = "0.2.0";
1197
+ // src/web.ts
1198
+ const WEB_HOST = "127.0.0.1";
1199
+ const WEB_PORT = 5177;
1200
+ function webPort() {
1201
+ const env = Number(process.env.HAKU_WEB_PORT);
1202
+ return Number.isInteger(env) && env > 0 ? env : WEB_PORT;
1203
+ }
1204
+ /** 127.0.0.1 rather than localhost: projects are stored per origin, so the address has to stay the same. */
1205
+ const webUrl = (port = webPort()) => `http://${WEB_HOST}:${port}/`;
1206
+ const TYPES = {
1207
+ ".html": "text/html; charset=utf-8",
1208
+ ".js": "text/javascript; charset=utf-8",
1209
+ ".css": "text/css; charset=utf-8",
1210
+ ".json": "application/json",
1211
+ ".webmanifest": "application/manifest+json",
1212
+ ".png": "image/png",
1213
+ ".svg": "image/svg+xml",
1214
+ ".woff2": "font/woff2",
1215
+ ".ogg": "audio/ogg",
1216
+ ".flac": "audio/flac",
1217
+ ".wav": "audio/wav"
1218
+ };
1219
+ /** Resolves a URL path inside root, or null if it tries to climb out. */
1220
+ function inside(root, urlPath) {
1221
+ const full = normalize(join(root, urlPath));
1222
+ return full === root || full.startsWith(root + sep) ? full : null;
1223
+ }
1224
+ async function findFile(roots, urlPath) {
1225
+ for (const root of roots) {
1226
+ const full = inside(root, urlPath);
1227
+ if (!full) continue;
1228
+ if ((await stat(full).catch(() => null))?.isFile()) return full;
1229
+ }
1230
+ return null;
1231
+ }
1232
+ /** Starts serving. Rejects with code EADDRINUSE when the port is taken. */
1233
+ function serveWeb(port = webPort()) {
1234
+ const roots = [webDir(), samplesDir()];
1235
+ const server = createServer(async (req, res) => {
1236
+ if (req.method !== "GET" && req.method !== "HEAD") {
1237
+ res.writeHead(405).end();
1238
+ return;
1239
+ }
1240
+ let path;
1241
+ try {
1242
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://haku").pathname);
1243
+ } catch {
1244
+ res.writeHead(400).end();
1245
+ return;
1246
+ }
1247
+ if (path === "/__haku") {
1248
+ res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({
1249
+ haku: true,
1250
+ version: VERSION
1251
+ }));
1252
+ return;
1253
+ }
1254
+ const file = await findFile(roots, path === "/" ? "/index.html" : path);
1255
+ if (!file) {
1256
+ res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
1257
+ return;
1258
+ }
1259
+ res.writeHead(200, {
1260
+ "Content-Type": TYPES[extname(file)] ?? "application/octet-stream",
1261
+ "Cache-Control": path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
1262
+ });
1263
+ if (req.method === "HEAD") res.end();
1264
+ else createReadStream(file).pipe(res);
1265
+ });
1266
+ return new Promise((resolve, reject) => {
1267
+ server.once("error", reject);
1268
+ server.listen(port, WEB_HOST, () => resolve(server));
1269
+ });
1270
+ }
1271
+ /** True when a haku web server already answers on the port. */
1272
+ async function webRunning(port = webPort()) {
1273
+ try {
1274
+ const res = await fetch(`${webUrl(port)}__haku`, { signal: AbortSignal.timeout(1e3) });
1275
+ return res.ok && (await res.json()).haku === true;
1276
+ } catch {
1277
+ return false;
1278
+ }
1279
+ }
1280
+ /** Opens a URL in the default browser. Resolves false when no opener could be started. */
1281
+ function openBrowser(url) {
1282
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["explorer.exe", [url]] : ["xdg-open", [url]];
1283
+ return new Promise((resolve) => {
1284
+ const child = spawn(cmd, args, {
1285
+ stdio: "ignore",
1286
+ detached: true
1287
+ });
1288
+ child.once("error", () => resolve(false));
1289
+ child.once("spawn", () => {
1290
+ child.unref();
1291
+ resolve(true);
1292
+ });
1293
+ });
1294
+ }
1295
+ // src/launch.ts
1296
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1297
+ /** Polls until check returns something truthy, or gives up with null. */
1298
+ async function waitFor(check, timeoutMs) {
1299
+ const end = Date.now() + timeoutMs;
1300
+ for (;;) {
1301
+ const value = await check();
1302
+ if (value) return value;
1303
+ if (Date.now() >= end) return null;
1304
+ await sleep(400);
1305
+ }
1306
+ }
1307
+ async function tabOn(getLink) {
1308
+ try {
1309
+ const link = await getLink();
1310
+ if (!link) return null;
1311
+ return (await link.request({ op: "ping" })).tab ? link : null;
1312
+ } catch {
1313
+ return null;
1314
+ }
1315
+ }
1316
+ async function launchTab(getLink) {
1317
+ const port = webPort();
1318
+ const url = webUrl(port);
1319
+ if (!await webRunning(port)) {
1320
+ spawn(process.execPath, [
1321
+ process.argv[1],
1322
+ "web",
1323
+ "--no-open",
1324
+ "--exit-when-idle"
1325
+ ], {
1326
+ detached: true,
1327
+ stdio: "ignore"
1328
+ }).unref();
1329
+ if (!await waitFor(() => webRunning(port), 1e4)) throw new Error(`Couldn't start haku web on port ${port}. Run "haku web" yourself to see what went wrong.`);
1330
+ }
1331
+ const existing = await waitFor(() => tabOn(getLink), 3e3);
1332
+ if (existing) return existing;
1333
+ const opened = await openBrowser(url);
1334
+ const fresh = await waitFor(() => tabOn(getLink), 45e3);
1335
+ if (fresh) return fresh;
1336
+ throw new Error(opened ? `Opened Haku at ${url}, but no tab connected yet. Check the browser, then try again.` : `No Haku tab is connected and no browser could be opened. Open ${url} yourself, then try again.`);
1337
+ }
1338
+ // src/snapshot.ts
1339
+ /** The web app's walnut palette (tokens.css), as hex because resvg doesn't read oklch. */
1340
+ const C = {
1341
+ bg: "#1b1410",
1342
+ sunken: "#16100c",
1343
+ raised: "#2f2520",
1344
+ border: "#3f342e",
1345
+ strong: "#5b4c44",
1346
+ text: "#f2eee9",
1347
+ muted: "#afa69d",
1348
+ faint: "#7f756e"
1349
+ };
1350
+ const FONT = "Figtree, sans-serif";
1351
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
1352
+ const n = (v) => Math.round(v * 10) / 10;
1353
+ function snapshotSvg(project, opts = {}) {
1354
+ const scene = findScene(project, opts.scene);
1355
+ const score = buildSceneScore(project, scene.id);
1356
+ const flats = prefersFlats(project.key);
1357
+ const W = opts.width ?? 1200;
1358
+ const H = opts.height ?? 640;
1359
+ const pad = 20;
1360
+ const header = 46;
1361
+ const strip = 30;
1362
+ const legend = 40;
1363
+ const x0 = pad;
1364
+ const x1 = W - pad;
1365
+ const top = 76;
1366
+ const bottom = H - legend;
1367
+ const cw = x1 - x0;
1368
+ const ch = bottom - top;
1369
+ const beats = scene.bars * 4;
1370
+ const bx = (b) => x0 + b / beats * cw;
1371
+ const noteCount = (l) => score.events.filter((e) => e.track === l.id).length;
1372
+ const focus = scene.layers.find((l) => l.id === opts.layer || l.name.toLowerCase() === opts.layer?.toLowerCase()) ?? scene.layers.find((l) => noteCount(l) > 0) ?? scene.layers[0] ?? null;
1373
+ const out = [];
1374
+ out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">`);
1375
+ out.push(`<rect width="${W}" height="${H}" fill="${C.bg}"/>`);
1376
+ out.push(`<text x="${x0}" y="29" font-size="17" font-weight="600" fill="${C.text}">${esc(project.name)}</text>`);
1377
+ const info = `Scene ${scene.name} · ${scene.bars} bars · ${keyLabel(project.key)} · ${project.bpm} BPM`;
1378
+ out.push(`<text x="${x1}" y="29" font-size="13" fill="${C.muted}" text-anchor="end">${esc(info)}</text>`);
1379
+ out.push(`<rect x="${x0}" y="${header}" width="${cw}" height="${strip}" fill="${C.sunken}" rx="4"/>`);
1380
+ for (const [i, chord] of score.chords.entries()) {
1381
+ const cx = bx(chord.time);
1382
+ const w = bx(chord.time + chord.duration) - cx;
1383
+ if (i % 2 === 1) out.push(`<rect x="${n(cx)}" y="${header}" width="${n(w)}" height="${strip}" fill="${C.raised}" opacity="0.5"/>`);
1384
+ out.push(`<text x="${n(cx + 9)}" y="66" font-size="12.5" font-weight="600" fill="${C.muted}">${esc(chord.symbol)}</text>`);
1385
+ }
1386
+ out.push(`<rect x="${x0}" y="${top}" width="${cw}" height="${ch}" fill="${C.sunken}" opacity="0.55"/>`);
1387
+ for (let b = 0; b <= beats; b++) {
1388
+ const gx = n(bx(b));
1389
+ const bar = b % 4 === 0;
1390
+ out.push(`<line x1="${gx}" y1="${bar ? header : top}" x2="${gx}" y2="${bottom}" stroke="${bar ? C.strong : C.border}" opacity="${bar ? .9 : .45}"/>`);
1391
+ if (bar && b > 0) out.push(`<text x="${n(gx - 6)}" y="${bottom - 6}" font-size="10" fill="${C.faint}" text-anchor="end">${b / 4}</text>`);
1392
+ }
1393
+ if (focus && INSTRUMENTS[focus.instrument].kind === "drums") {
1394
+ const rows = drumRows(focus.kit);
1395
+ rows.forEach((piece, i) => {
1396
+ const y = n(top + (1 - (i + 1) / rows.length) * ch);
1397
+ out.push(`<line x1="${x0}" y1="${y}" x2="${x1}" y2="${y}" stroke="${C.border}" opacity="0.5"/>`);
1398
+ out.push(`<text x="27" y="${n(y + ch / rows.length / 2 + 4)}" font-size="10.5" fill="${C.faint}">${piece}</text>`);
1399
+ });
1400
+ } else if (focus) {
1401
+ const range = layerRange(focus);
1402
+ for (let m = Math.ceil(range[0] / 12) * 12; m <= range[1]; m += 12) {
1403
+ const y = n(top + pitchToY(m, range) * ch);
1404
+ out.push(`<line x1="${x0}" y1="${y}" x2="${x1}" y2="${y}" stroke="${C.border}" stroke-dasharray="2 4" opacity="0.6"/>`);
1405
+ out.push(`<text x="27" y="${n(y - top < 16 ? y + 12 : y - 5)}" font-size="10.5" fill="${C.faint}">${noteName(m, flats)}</text>`);
1406
+ }
1407
+ }
1408
+ const layers = [...scene.layers].sort((a, b) => Number(a.id === focus?.id) - Number(b.id === focus?.id));
1409
+ for (const layer of layers) {
1410
+ const front = layer.id === focus?.id;
1411
+ const drums = INSTRUMENTS[layer.instrument].kind === "drums";
1412
+ const range = layerRange(layer);
1413
+ const rows = drumRows(layer.kit);
1414
+ out.push(`<g fill="${layer.color}" opacity="${front ? .75 : .32}">`);
1415
+ for (const e of score.events) {
1416
+ if (e.track !== layer.id) continue;
1417
+ let ny;
1418
+ let nh;
1419
+ if (drums) {
1420
+ ny = 1 - (Math.max(0, rows.indexOf(e.drum)) + 1) / rows.length;
1421
+ nh = 1 / rows.length;
1422
+ } else {
1423
+ nh = 1 / (range[1] - range[0]);
1424
+ ny = pitchToY(e.midi, range) - nh / 2;
1425
+ }
1426
+ const rx = bx(e.time);
1427
+ const rw = Math.max(3, bx(e.time + e.duration) - rx - 1.5);
1428
+ const rh = Math.max(4, nh * ch - 1.5);
1429
+ out.push(`<rect x="${n(rx)}" y="${n(top + ny * ch)}" width="${n(rw)}" height="${n(rh)}" rx="${n(Math.min(3, rh / 2))}"/>`);
1430
+ }
1431
+ out.push("</g>");
1432
+ for (const s of layer.strokes) {
1433
+ const width = n(2 + s.size * 10);
1434
+ const opacity = front ? .85 : .2;
1435
+ if (s.points.length === 1) {
1436
+ const p = s.points[0];
1437
+ out.push(`<circle cx="${n(x0 + p.x * cw)}" cy="${n(top + p.y * ch)}" r="${n(width / 2 + 1)}" fill="${layer.color}" opacity="${opacity}"/>`);
1438
+ } else {
1439
+ const pts = s.points.map((p) => `${n(x0 + p.x * cw)},${n(top + p.y * ch)}`).join(" ");
1440
+ out.push(`<polyline points="${pts}" fill="none" stroke="${layer.color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" opacity="${opacity}"/>`);
1441
+ }
1442
+ }
1443
+ }
1444
+ let lx = x0;
1445
+ const ly = H - legend / 2 + 4;
1446
+ if (!scene.layers.length) out.push(`<text x="${lx}" y="${ly}" font-size="12" fill="${C.faint}">No layers yet</text>`);
1447
+ for (const layer of scene.layers) {
1448
+ const flags = [layer.muted && "muted", layer.solo && "solo"].filter(Boolean).join(", ");
1449
+ const label = `${layer.name} · ${INSTRUMENTS[layer.instrument].label} · ${noteCount(layer)} notes${flags ? ` · ${flags}` : ""}`;
1450
+ const weight = layer.id === focus?.id ? 600 : 400;
1451
+ out.push(`<rect x="${lx}" y="${ly - 9}" width="10" height="10" rx="2" fill="${layer.color}"/>`);
1452
+ out.push(`<text x="${lx + 16}" y="${ly}" font-size="12" font-weight="${weight}" fill="${layer.id === focus?.id ? C.text : C.muted}">${esc(label)}</text>`);
1453
+ lx += 16 + label.length * 6.6 + 22;
1454
+ }
1455
+ out.push("</svg>");
1456
+ return out.join("\n");
1457
+ }
1458
+ /** PNG bytes. resvg is a native module, so it's only loaded when a PNG is actually asked for. */
1459
+ async function snapshotPng(svg, width) {
1460
+ const { Resvg } = await import("@resvg/resvg-js");
1461
+ const files = fontFiles();
1462
+ return new Resvg(svg, {
1463
+ fitTo: width ? {
1464
+ mode: "width",
1465
+ value: width
1466
+ } : { mode: "original" },
1467
+ font: files.length ? {
1468
+ loadSystemFonts: false,
1469
+ fontFiles: files,
1470
+ defaultFontFamily: "Figtree",
1471
+ sansSerifFamily: "Figtree"
1472
+ } : { loadSystemFonts: true }
1473
+ }).render().asPng();
1474
+ }
1475
+ // src/target.ts
1476
+ const isReadOnly = (cmd) => READ_ONLY_COMMANDS.includes(cmd?.type);
1477
+ /** Applies commands to a project in memory. Throws on the first bad one, naming which it was. */
1478
+ function applyAll(project, commands) {
1479
+ const messages = [];
1480
+ const data = [];
1481
+ for (const [i, cmd] of commands.entries()) try {
1482
+ const r = executeUnknown(project, cmd);
1483
+ project = r.project;
1484
+ messages.push(r.message);
1485
+ data.push(r.data ?? null);
1486
+ } catch (err) {
1487
+ const msg = err instanceof Error ? err.message : String(err);
1488
+ throw new Error(commands.length > 1 ? `Command ${i + 1} (${cmd?.type ?? "?"}) failed: ${msg}. Nothing was changed.` : msg);
1489
+ }
1490
+ return {
1491
+ project,
1492
+ messages,
1493
+ data
1494
+ };
1495
+ }
1496
+ async function readProjectFile(path) {
1497
+ let text;
1498
+ try {
1499
+ text = await readFile(path, "utf8");
1500
+ } catch (err) {
1501
+ if (err.code === "ENOENT") throw new Error(`No project file at ${path}. Make one with new_project.`);
1502
+ throw err;
1503
+ }
1504
+ let data;
1505
+ try {
1506
+ data = JSON.parse(text);
1507
+ } catch {
1508
+ throw new Error(`${path} isn't valid JSON`);
1509
+ }
1510
+ return parseProject(data);
1511
+ }
1512
+ /** Writes through a temp file and a rename, so a crash never leaves half a project behind. */
1513
+ async function writeProjectFile(path, project) {
1514
+ const tmp = `${path}.${randomUUID().slice(0, 8)}.tmp`;
1515
+ await writeFile(tmp, `${JSON.stringify(project, null, 2)}\n`);
1516
+ await rename(tmp, path);
1517
+ }
1518
+ var FileTarget = class {
1519
+ kind = "file";
1520
+ label;
1521
+ constructor(path) {
1522
+ this.label = path;
1523
+ }
1524
+ read() {
1525
+ return readProjectFile(this.label);
1526
+ }
1527
+ async run(commands) {
1528
+ const result = applyAll(await this.read(), commands);
1529
+ if (!commands.every(isReadOnly)) await writeProjectFile(this.label, result.project);
1530
+ return {
1531
+ messages: result.messages,
1532
+ data: result.data
1533
+ };
1534
+ }
1535
+ };
1536
+ var LiveTarget = class {
1537
+ kind = "live";
1538
+ label = "the Haku tab";
1539
+ link;
1540
+ constructor(link) {
1541
+ this.link = link;
1542
+ }
1543
+ async read() {
1544
+ return parseProject(await this.link.request({ op: "get_project" }));
1545
+ }
1546
+ async run(commands) {
1547
+ if (commands.every(isReadOnly)) {
1548
+ const { messages, data } = applyAll(await this.read(), commands);
1549
+ return {
1550
+ messages,
1551
+ data
1552
+ };
1553
+ }
1554
+ return await this.link.request({
1555
+ op: "execute",
1556
+ commands
1557
+ });
1558
+ }
1559
+ };
1560
+ // src/tools.ts
1561
+ const define = (tool) => tool;
1562
+ const file = z.string().optional().describe("Project file (.haku.json). Leave out to work on the project open in the Haku tab.");
1563
+ const scope = z.enum(["scene", "song"]).optional().describe("One scene, or the whole arrangement. Default scene.");
1564
+ const sceneArg = z.string().optional().describe("Scene id or name. Defaults to the active scene.");
1565
+ const outArg = (what) => z.string().describe(`Where to write the ${what}. Relative paths are from the working directory.`);
1566
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "haku";
1567
+ async function connectedTab(ctx) {
1568
+ const link = await ctx.link();
1569
+ if (!link) return null;
1570
+ return (await link.request({ op: "ping" })).tab ? link : null;
1571
+ }
1572
+ /** The connected tab, opening Haku first when none is connected (unless auto-launch is off). */
1573
+ async function requireTab(ctx) {
1574
+ const tab = await connectedTab(ctx);
1575
+ if (tab) return tab;
1576
+ if (!ctx.autoLaunch) throw new Error(NO_TAB);
1577
+ return launchTab(ctx.link);
1578
+ }
1579
+ /** The file when one is given, otherwise the live tab. */
1580
+ async function targetFor(ctx, path) {
1581
+ if (path) return new FileTarget(resolve(ctx.cwd, path));
1582
+ return new LiveTarget(await requireTab(ctx));
1583
+ }
1584
+ /** The scene a tool works on, resolved to an id so file and live targets agree. */
1585
+ const sceneOf = (project, scene) => findScene(project, scene);
1586
+ const commandTools = CommandSchema.options.map((option) => {
1587
+ const schema = option;
1588
+ const type = schema.shape.type.value;
1589
+ const input = schema.omit({ type: true }).extend({ file });
1590
+ const readOnly = READ_ONLY_COMMANDS.includes(type);
1591
+ return define({
1592
+ name: type,
1593
+ title: type.replace(/_/g, " "),
1594
+ description: COMMAND_HELP[type],
1595
+ input,
1596
+ readOnly,
1597
+ destructive: type === "remove_layer" || type === "remove_scene" || type === "erase",
1598
+ async run(args, ctx) {
1599
+ const { file: path, ...rest } = args;
1600
+ return { text: (await (await targetFor(ctx, path)).run([{
1601
+ type,
1602
+ ...rest
1603
+ }])).messages.join("\n") };
1604
+ }
1605
+ });
1606
+ });
1607
+ const batch = define({
1608
+ name: "batch",
1609
+ title: "Run several commands",
1610
+ description: "Run a list of commands in order as one step (one undo in the tab). If any fails, nothing changes. Each item is a command object with a \"type\" field, like {\"type\":\"draw_line\",...}.",
1611
+ input: z.object({
1612
+ file,
1613
+ commands: z.array(CommandSchema).min(1).max(500)
1614
+ }),
1615
+ async run({ file: path, commands }, ctx) {
1616
+ return { text: (await (await targetFor(ctx, path)).run(commands)).messages.map((m, i) => `${i + 1}. ${m}`).join("\n") };
1617
+ }
1618
+ });
1619
+ const newProject = define({
1620
+ name: "new_project",
1621
+ title: "New project",
1622
+ description: "Start a new project from a mood. With file, writes a new .haku.json. Without it, opens the new project in the Haku tab (the old one stays saved there).",
1623
+ input: z.object({
1624
+ file: z.string().optional().describe("Path for the new .haku.json. Leave out to open it in the Haku tab instead."),
1625
+ name: z.string().min(1).max(80).optional(),
1626
+ mood: z.enum(MOOD_IDS).optional().describe("Default rainy-study"),
1627
+ bars: z.number().int().min(1).max(16).optional().describe("Length of the first scene. Default 4."),
1628
+ seed: z.number().int().optional().describe("Fixes the random humanize and spray, for repeatable renders"),
1629
+ overwrite: z.boolean().optional().describe("Replace the file if it exists")
1630
+ }),
1631
+ async run({ file: path, name, mood, bars, seed, overwrite }, ctx) {
1632
+ const project = createProject({
1633
+ name,
1634
+ mood,
1635
+ bars,
1636
+ seed
1637
+ });
1638
+ if (path) {
1639
+ const full = resolve(ctx.cwd, path);
1640
+ if (existsSync(full) && !overwrite) throw new Error(`${full} already exists. Pass overwrite to replace it.`);
1641
+ await writeProjectFile(full, project);
1642
+ return { text: `Created "${project.name}" (${project.mood}) at ${full}` };
1643
+ }
1644
+ await (await requireTab(ctx)).request({
1645
+ op: "open_project",
1646
+ project
1647
+ });
1648
+ return { text: `Opened a new project "${project.name}" (${project.mood}) in the Haku tab` };
1649
+ }
1650
+ });
1651
+ const openProject = define({
1652
+ name: "open_project",
1653
+ title: "Open a file in the tab",
1654
+ description: "Send a .haku.json file to the Haku tab and open it there, so you can hear and see it live.",
1655
+ input: z.object({ file: z.string().describe("The .haku.json to open") }),
1656
+ async run({ file: path }, ctx) {
1657
+ const full = resolve(ctx.cwd, path);
1658
+ const project = await readProjectFile(full);
1659
+ await (await requireTab(ctx)).request({
1660
+ op: "open_project",
1661
+ project
1662
+ });
1663
+ return { text: `Opened "${project.name}" from ${full} in the Haku tab` };
1664
+ }
1665
+ });
1666
+ const saveProject = define({
1667
+ name: "save_project",
1668
+ title: "Save the tab to a file",
1669
+ description: "Write the project open in the Haku tab to a .haku.json file.",
1670
+ input: z.object({
1671
+ file: outArg(".haku.json"),
1672
+ overwrite: z.boolean().optional().describe("Replace the file if it exists")
1673
+ }),
1674
+ async run({ file: path, overwrite }, ctx) {
1675
+ const full = resolve(ctx.cwd, path);
1676
+ if (existsSync(full) && !overwrite) throw new Error(`${full} already exists. Pass overwrite to replace it.`);
1677
+ const tab = await connectedTab(ctx);
1678
+ if (!tab) throw new Error(NO_TAB);
1679
+ const project = parseProject(await tab.request({ op: "get_project" }));
1680
+ await writeProjectFile(full, project);
1681
+ return { text: `Saved "${project.name}" to ${full}` };
1682
+ }
1683
+ });
1684
+ const play = define({
1685
+ name: "play",
1686
+ title: "Play",
1687
+ description: "Listen. With no file it plays in the Haku tab, opening one if needed. With a file it renders offline and plays through this machine's speakers.",
1688
+ input: z.object({
1689
+ file,
1690
+ scope,
1691
+ scene: sceneArg,
1692
+ loops: z.number().int().min(1).max(8).optional().describe("Times through a scene when rendering offline. Default 2.")
1693
+ }),
1694
+ async run({ file: path, scope = "scene", scene, loops = 2 }, ctx) {
1695
+ if (!path) {
1696
+ const status = await (await requireTab(ctx)).request({
1697
+ op: "play",
1698
+ mode: scope === "song" ? "song" : "loop",
1699
+ scene
1700
+ });
1701
+ return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
1702
+ }
1703
+ const project = await new FileTarget(resolve(ctx.cwd, path)).read();
1704
+ const sceneId = sceneOf(project, scene).id;
1705
+ const audio = await loadAudio();
1706
+ const pcm = await audio.render(project, scope === "song" ? { scope: "song" } : {
1707
+ scope: "scene",
1708
+ sceneId,
1709
+ loops,
1710
+ tail: 2
1711
+ });
1712
+ const wav = join(tmpdir(), `haku-play-${randomUUID().slice(0, 8)}.wav`);
1713
+ await writeFile(wav, audio.wav(pcm));
1714
+ const { name, done } = ctx.player.play(wav);
1715
+ const seconds = (pcm.channels[0].length / pcm.sampleRate).toFixed(1);
1716
+ const what = scope === "song" ? "the song" : `scene ${sceneOf(project, scene).name}`;
1717
+ if (ctx.waitForPlayback) {
1718
+ await done;
1719
+ return { text: `Played ${what} (${seconds} s) through ${name}` };
1720
+ }
1721
+ done.catch(() => {});
1722
+ return { text: `Playing ${what} (${seconds} s) through ${name}. Call stop to end it early.` };
1723
+ }
1724
+ });
1725
+ const stop = define({
1726
+ name: "stop",
1727
+ title: "Stop",
1728
+ description: "Stop playback, in the tab and anything playing offline.",
1729
+ input: z.object({}),
1730
+ async run(_, ctx) {
1731
+ const stopped = [];
1732
+ if (ctx.player.stop()) stopped.push("offline playback");
1733
+ const tab = await connectedTab(ctx);
1734
+ if (tab) {
1735
+ await tab.request({ op: "stop" });
1736
+ stopped.push("the Haku tab");
1737
+ }
1738
+ return { text: stopped.length ? `Stopped ${stopped.join(" and ")}` : "Nothing was playing" };
1739
+ }
1740
+ });
1741
+ const snapshot = define({
1742
+ name: "snapshot",
1743
+ title: "Snapshot",
1744
+ description: "A picture of a scene: chords along the top, each layer's notes and strokes, and a legend. Returns a PNG. Pass out to also save it (.png or .svg).",
1745
+ readOnly: true,
1746
+ input: z.object({
1747
+ file,
1748
+ scene: sceneArg,
1749
+ layer: z.string().optional().describe("Layer to bring to the front, with its pitch guides"),
1750
+ width: z.number().int().min(400).max(2400).optional().describe("Image width in pixels. Default 1200."),
1751
+ out: z.string().optional().describe("Also save the picture here (.png or .svg)")
1752
+ }),
1753
+ async run({ file: path, scene, layer, width = 1200, out }, ctx) {
1754
+ const project = await (await targetFor(ctx, path)).read();
1755
+ const s = sceneOf(project, scene);
1756
+ const svg = snapshotSvg(project, {
1757
+ scene: s.id,
1758
+ layer,
1759
+ width,
1760
+ height: Math.round(width * .53)
1761
+ });
1762
+ const notes = [`Snapshot of scene ${s.name} in "${project.name}"`];
1763
+ if (out?.toLowerCase().endsWith(".svg")) {
1764
+ const full = resolve(ctx.cwd, out);
1765
+ await writeFile(full, svg);
1766
+ notes.push(`saved to ${full}`);
1767
+ }
1768
+ const png = await snapshotPng(svg);
1769
+ if (out && !out.toLowerCase().endsWith(".svg")) {
1770
+ const full = resolve(ctx.cwd, out);
1771
+ await writeFile(full, png);
1772
+ notes.push(`saved to ${full}`);
1773
+ }
1774
+ return {
1775
+ text: notes.join(", "),
1776
+ image: {
1777
+ data: png,
1778
+ mimeType: "image/png"
1779
+ }
1780
+ };
1781
+ }
1782
+ });
1783
+ const analyze = define({
1784
+ name: "analyze",
1785
+ title: "Analyze",
1786
+ description: "Check a scene or the song: notes per layer, how many strong-beat notes are chord tones, notes outside the key, half-step clashes between layers, and (with audio) loudness from an offline render.",
1787
+ readOnly: true,
1788
+ input: z.object({
1789
+ file,
1790
+ scope,
1791
+ scene: sceneArg,
1792
+ audio: z.boolean().optional().describe("Render and measure loudness too. Takes a few seconds. Default true.")
1793
+ }),
1794
+ async run({ file: path, scope = "scene", scene, audio = true }, ctx) {
1795
+ const project = await (await targetFor(ctx, path)).read();
1796
+ const ids = scope === "song" ? [...new Set(project.arrangement.map((a) => a.sceneId))] : [sceneOf(project, scene).id];
1797
+ const scenes = analyzeScenes(project, ids);
1798
+ let levels = null;
1799
+ if (audio) {
1800
+ const kit = await loadAudio();
1801
+ const pcm = await kit.render(project, scope === "song" ? { scope: "song" } : {
1802
+ scope: "scene",
1803
+ sceneId: ids[0],
1804
+ loops: 2
1805
+ });
1806
+ levels = kit.levels(pcm);
1807
+ }
1808
+ return { text: formatAnalysis(scenes, levels) };
1809
+ }
1810
+ });
1811
+ const exportTool = define({
1812
+ name: "export",
1813
+ title: "Export",
1814
+ description: "Write a WAV (rendered offline, same sound as the app), a MIDI file, or the project JSON.",
1815
+ readOnly: true,
1816
+ input: z.object({
1817
+ file,
1818
+ format: z.enum([
1819
+ "wav",
1820
+ "midi",
1821
+ "json"
1822
+ ]),
1823
+ scope,
1824
+ scene: sceneArg,
1825
+ out: z.string().optional().describe("Output path. Defaults to a name made from the project (and scene) in the working directory.")
1826
+ }),
1827
+ async run({ file: path, format, scope = "scene", scene, out }, ctx) {
1828
+ const project = await (await targetFor(ctx, path)).read();
1829
+ const s = sceneOf(project, scene);
1830
+ const base = format === "json" || scope === "song" ? slug(project.name) : `${slug(project.name)}-${slug(s.name)}`;
1831
+ const ext = {
1832
+ wav: ".wav",
1833
+ midi: ".mid",
1834
+ json: ".haku.json"
1835
+ }[format];
1836
+ const full = resolve(ctx.cwd, out ?? base + ext);
1837
+ if (format === "json") await writeProjectFile(full, project);
1838
+ else if (format === "midi") {
1839
+ const score = scope === "song" ? buildArrangementScore(project) : buildSceneScore(project, s.id);
1840
+ await writeFile(full, scoreToMidi(project, score));
1841
+ } else {
1842
+ const kit = await loadAudio();
1843
+ const pcm = await kit.render(project, scope === "song" ? { scope: "song" } : {
1844
+ scope: "scene",
1845
+ sceneId: s.id,
1846
+ loops: 2
1847
+ });
1848
+ await writeFile(full, kit.wav(pcm));
1849
+ const lv = kit.levels(pcm);
1850
+ return { text: `Wrote ${full} (${lv.seconds.toFixed(1)} s, ${lv.lufs.toFixed(1)} LUFS, peak ${lv.peakDb.toFixed(1)} dBFS)` };
1851
+ }
1852
+ return { text: `Wrote ${full}` };
1853
+ }
1854
+ });
1855
+ const status = define({
1856
+ name: "status",
1857
+ title: "Status",
1858
+ description: "Is the bridge running, is a Haku tab connected, and what is it playing.",
1859
+ readOnly: true,
1860
+ input: z.object({}),
1861
+ async run(_, ctx) {
1862
+ const lines = [];
1863
+ const link = await ctx.link();
1864
+ if (!link) lines.push("Bridge: not running. Run \"haku web\" to open Haku with the bridge.");
1865
+ else {
1866
+ const ping = await link.request({ op: "ping" });
1867
+ if (!ping.tab) lines.push("Bridge: running, no Haku tab connected.");
1868
+ else {
1869
+ const s = await link.request({ op: "status" });
1870
+ lines.push(`Bridge: running, ${ping.tabs} tab${ping.tabs === 1 ? "" : "s"} connected.`);
1871
+ lines.push(`Tab: "${s.projectName}", scene ${s.sceneName}, ${s.playing ? `playing (${s.mode})` : "stopped"}, sounds ${s.engine}.`);
1872
+ }
1873
+ }
1874
+ if (ctx.player.playing) lines.push("Offline playback: playing.");
1875
+ return { text: lines.join("\n") };
1876
+ }
1877
+ });
1878
+ const TOOLS = [
1879
+ newProject,
1880
+ openProject,
1881
+ saveProject,
1882
+ ...commandTools,
1883
+ batch,
1884
+ play,
1885
+ stop,
1886
+ snapshot,
1887
+ analyze,
1888
+ exportTool,
1889
+ status
1890
+ ];
1891
+ export { webRunning as a, Player as c, BridgeHost as d, SharedLink as f, webPort as i, GUIDE as l, openBrowser as n, webUrl as o, bridgePort as p, serveWeb as r, VERSION as s, TOOLS as t, BridgeClient as u };