@pinkpixel/haku 0.1.0 → 1.0.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 (121) hide show
  1. package/README.md +18 -11
  2. package/dist/cli.mjs +38 -4
  3. package/dist/{mcp-DDuNECfs.mjs → mcp-DsSn95Rn.mjs} +3 -2
  4. package/dist/samples/drums/cabasa_v0_r1.ogg +0 -0
  5. package/dist/samples/drums/cabasa_v0_r2.ogg +0 -0
  6. package/dist/samples/drums/cabasa_v1_r1.ogg +0 -0
  7. package/dist/samples/drums/cabasa_v1_r2.ogg +0 -0
  8. package/dist/samples/drums/shaker_v0_r1.ogg +0 -0
  9. package/dist/samples/drums/shaker_v0_r2.ogg +0 -0
  10. package/dist/samples/drums/shaker_v1_r1.ogg +0 -0
  11. package/dist/samples/drums/shaker_v1_r2.ogg +0 -0
  12. package/dist/samples/guitar/v0_31.ogg +0 -0
  13. package/dist/samples/guitar/v0_32.ogg +0 -0
  14. package/dist/samples/guitar/v0_33.ogg +0 -0
  15. package/dist/samples/guitar/v0_34.ogg +0 -0
  16. package/dist/samples/guitar/v0_35.ogg +0 -0
  17. package/dist/samples/guitar/v0_36.ogg +0 -0
  18. package/dist/samples/guitar/v0_37.ogg +0 -0
  19. package/dist/samples/guitar/v0_38.ogg +0 -0
  20. package/dist/samples/guitar/v0_39.ogg +0 -0
  21. package/dist/samples/guitar/v0_40.ogg +0 -0
  22. package/dist/samples/guitar/v0_41.ogg +0 -0
  23. package/dist/samples/guitar/v0_43.ogg +0 -0
  24. package/dist/samples/guitar/v0_45.ogg +0 -0
  25. package/dist/samples/guitar/v0_47.ogg +0 -0
  26. package/dist/samples/guitar/v0_48.ogg +0 -0
  27. package/dist/samples/guitar/v0_50.ogg +0 -0
  28. package/dist/samples/guitar/v0_52.ogg +0 -0
  29. package/dist/samples/guitar/v0_53.ogg +0 -0
  30. package/dist/samples/guitar/v0_54.ogg +0 -0
  31. package/dist/samples/guitar/v0_55.ogg +0 -0
  32. package/dist/samples/guitar/v0_56.ogg +0 -0
  33. package/dist/samples/guitar/v0_57.ogg +0 -0
  34. package/dist/samples/guitar/v0_58.ogg +0 -0
  35. package/dist/samples/guitar/v0_59.ogg +0 -0
  36. package/dist/samples/guitar/v0_60.ogg +0 -0
  37. package/dist/samples/guitar/v0_61.ogg +0 -0
  38. package/dist/samples/guitar/v0_62.ogg +0 -0
  39. package/dist/samples/guitar/v0_63.ogg +0 -0
  40. package/dist/samples/guitar/v0_64.ogg +0 -0
  41. package/dist/samples/guitar/v0_65.ogg +0 -0
  42. package/dist/samples/guitar/v0_66.ogg +0 -0
  43. package/dist/samples/guitar/v0_67.ogg +0 -0
  44. package/dist/samples/guitar/v0_69.ogg +0 -0
  45. package/dist/samples/guitar/v0_70.ogg +0 -0
  46. package/dist/samples/guitar/v0_71.ogg +0 -0
  47. package/dist/samples/guitar/v0_72.ogg +0 -0
  48. package/dist/samples/guitar/v0_73.ogg +0 -0
  49. package/dist/samples/guitar/v0_74.ogg +0 -0
  50. package/dist/samples/guitar/v0_75.ogg +0 -0
  51. package/dist/samples/guitar/v0_76.ogg +0 -0
  52. package/dist/samples/guitar/v0_77.ogg +0 -0
  53. package/dist/samples/guitar/v0_78.ogg +0 -0
  54. package/dist/samples/guitar/v0_79.ogg +0 -0
  55. package/dist/samples/guitar/v0_80.ogg +0 -0
  56. package/dist/samples/guitar/v0_81.ogg +0 -0
  57. package/dist/samples/guitar/v0_82.ogg +0 -0
  58. package/dist/samples/guitar/v0_83.ogg +0 -0
  59. package/dist/samples/guitar/v0_84.ogg +0 -0
  60. package/dist/samples/kalimba/v0_44.ogg +0 -0
  61. package/dist/samples/kalimba/v0_46.ogg +0 -0
  62. package/dist/samples/kalimba/v0_49.ogg +0 -0
  63. package/dist/samples/kalimba/v0_51.ogg +0 -0
  64. package/dist/samples/kalimba/v0_53.ogg +0 -0
  65. package/dist/samples/kalimba/v0_56.ogg +0 -0
  66. package/dist/samples/kalimba/v0_59.ogg +0 -0
  67. package/dist/samples/kalimba/v0_61.ogg +0 -0
  68. package/dist/samples/kalimba/v0_64.ogg +0 -0
  69. package/dist/samples/kalimba/v0_65.ogg +0 -0
  70. package/dist/samples/kalimba/v0_67.ogg +0 -0
  71. package/dist/samples/kalimba/v0_71.ogg +0 -0
  72. package/dist/samples/kalimba/v0_73.ogg +0 -0
  73. package/dist/samples/kalimba/v0_74.ogg +0 -0
  74. package/dist/samples/kalimba/v0_75.ogg +0 -0
  75. package/dist/samples/kalimba/v0_77.ogg +0 -0
  76. package/dist/samples/kalimba/v0_78.ogg +0 -0
  77. package/dist/samples/kalimba/v0_79.ogg +0 -0
  78. package/dist/samples/kalimba/v0_82.ogg +0 -0
  79. package/dist/samples/kalimba/v0_83.ogg +0 -0
  80. package/dist/samples/manifest.json +181 -0
  81. package/dist/samples/vibes/v0_53.ogg +0 -0
  82. package/dist/samples/vibes/v0_57.ogg +0 -0
  83. package/dist/samples/vibes/v0_60.ogg +0 -0
  84. package/dist/samples/vibes/v0_64.ogg +0 -0
  85. package/dist/samples/vibes/v0_67.ogg +0 -0
  86. package/dist/samples/vibes/v0_71.ogg +0 -0
  87. package/dist/samples/vibes/v0_74.ogg +0 -0
  88. package/dist/samples/vibes/v0_77.ogg +0 -0
  89. package/dist/samples/vibes/v0_81.ogg +0 -0
  90. package/dist/samples/vibes/v0_84.ogg +0 -0
  91. package/dist/samples/vibes/v0_88.ogg +0 -0
  92. package/dist/samples/vibes/v1_53.ogg +0 -0
  93. package/dist/samples/vibes/v1_57.ogg +0 -0
  94. package/dist/samples/vibes/v1_60.ogg +0 -0
  95. package/dist/samples/vibes/v1_64.ogg +0 -0
  96. package/dist/samples/vibes/v1_67.ogg +0 -0
  97. package/dist/samples/vibes/v1_71.ogg +0 -0
  98. package/dist/samples/vibes/v1_74.ogg +0 -0
  99. package/dist/samples/vibes/v1_77.ogg +0 -0
  100. package/dist/samples/vibes/v1_81.ogg +0 -0
  101. package/dist/samples/vibes/v1_84.ogg +0 -0
  102. package/dist/samples/vibes/v1_88.ogg +0 -0
  103. package/dist/{src-D5fW7AC1.mjs → src-Bz-0ulVm.mjs} +179 -23
  104. package/dist/{src-JrRbTOJC.mjs → src-DNP73wX3.mjs} +60 -3
  105. package/dist/{version-DoZ2etXG.mjs → tools-CO-tdB7d.mjs} +321 -82
  106. package/dist/web/assets/esm-J-U37_Wq.js +210 -0
  107. package/dist/web/assets/figtree-latin-ext-wght-normal-DCwSJGxG.woff2 +0 -0
  108. package/dist/web/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  109. package/dist/web/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
  110. package/dist/web/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
  111. package/dist/web/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
  112. package/dist/web/assets/index-BkzOde44.js +65 -0
  113. package/dist/web/assets/index-D0m3L8JK.css +1 -0
  114. package/dist/web/assets/rolldown-runtime-BWaJ3C7D.js +1 -0
  115. package/dist/web/assets/src-m48ItyJA.js +1 -0
  116. package/dist/web/icon-192.png +0 -0
  117. package/dist/web/icon-512.png +0 -0
  118. package/dist/web/index.html +19 -0
  119. package/dist/web/manifest.webmanifest +24 -0
  120. package/dist/web/sw.js +37 -0
  121. package/package.json +2 -2
@@ -1,13 +1,14 @@
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-D5fW7AC1.mjs";
2
- import { readFile, rename, writeFile } from "node:fs/promises";
3
- import { delimiter, join, resolve } from "node:path";
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-Bz-0ulVm.mjs";
2
+ import { readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { delimiter, extname, join, normalize, resolve, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { randomUUID } from "node:crypto";
6
6
  import { WebSocket, WebSocketServer } from "ws";
7
7
  import { spawn } from "node:child_process";
8
- import { existsSync } from "node:fs";
8
+ import { createReadStream, existsSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { createServer } from "node:http";
11
12
  // ../../packages/core/src/shapes.ts
12
13
  const SHAPES = [
13
14
  "wave",
@@ -714,7 +715,7 @@ function parseBridgeMessage(raw) {
714
715
  return parsed.success ? parsed.data : null;
715
716
  }
716
717
  // src/bridge.ts
717
- const NO_TAB = "No Haku tab is connected. Open Haku in a browser (it connects on its own), or pass a project file.";
718
+ const NO_TAB = "No Haku tab is connected. Run \"haku web\" to open one, or pass a project file.";
718
719
  const DEFAULT_TIMEOUT = 2e4;
719
720
  function bridgePort() {
720
721
  const env = Number(process.env.HAKU_BRIDGE_PORT);
@@ -948,7 +949,7 @@ const presets = Object.entries(PROGRESSIONS).map(([id, p]) => `${id} (${p.text})
948
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.
949
950
 
950
951
  Where the work happens:
951
- - 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.
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.
952
953
  - With file set to a .haku.json path, tools read and write that file instead. new_project makes one.
953
954
 
954
955
  The canvas:
@@ -959,7 +960,7 @@ The canvas:
959
960
 
960
961
  A good loop:
961
962
  1. describe to see what's there. set_mood picks a whole vibe in one go: ${MOOD_IDS.join(", ")}.
962
- 2. add_layer for each part (piano, wurli, fm-ep, glock, marimba, harp, pad, pluck, bass, drums).
963
+ 2. add_layer for each part (piano, wurli, fm-ep, glock, vibes, marimba, kalimba, harp, guitar, pad, pluck, celesta, musicbox, bass, drums).
963
964
  3. Draw with draw_shape, draw_curve, or draw_line. Use batch to send many commands as one step.
964
965
  4. Check your work: describe (text piano roll), snapshot (a picture), analyze (chord fit, clashes, loudness).
965
966
  5. play to listen, export for WAV, MIDI, or the project file.
@@ -1090,7 +1091,10 @@ function analyzeScenes(project, sceneIds) {
1090
1091
  ...base,
1091
1092
  range: notes.length ? `${noteName(Math.min(...pitches), flats)} to ${noteName(Math.max(...pitches), flats)}` : "none",
1092
1093
  strongBeatChordTones: strong.length ? round(chordTones / strong.length) : 1,
1093
- outOfKey: notes.filter((e) => !scale.has(mod12(e.midi))).length
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
1094
1098
  };
1095
1099
  });
1096
1100
  const tonal = new Set(scene.layers.filter((l) => INSTRUMENTS[l.instrument].kind === "tonal").map((l) => l.id));
@@ -1101,8 +1105,8 @@ function analyzeScenes(project, sceneIds) {
1101
1105
  const b = drawn[j];
1102
1106
  if (a.track === b.track) continue;
1103
1107
  const overlap = Math.min(a.time + a.duration, b.time + b.duration) - Math.max(a.time, b.time);
1104
- const interval = mod12(a.midi - b.midi);
1105
- if (overlap > .1 && (interval === 1 || interval === 11)) {
1108
+ const gap = Math.abs(a.midi - b.midi);
1109
+ if (overlap > .1 && (gap === 1 || gap === 13)) {
1106
1110
  const beat = round(Math.max(a.time, b.time), 2);
1107
1111
  if (!clashes.has(beat)) clashes.set(beat, `${noteName(a.midi, flats)} against ${noteName(b.midi, flats)}`);
1108
1112
  }
@@ -1159,13 +1163,20 @@ function fontFiles() {
1159
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")));
1160
1164
  return dir ? ["Figtree-Regular.ttf", "Figtree-SemiBold.ttf"].map((f) => join(dir, f)) : [];
1161
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
+ }
1162
1173
  // src/audio.ts
1163
1174
  let kit = null;
1164
1175
  function loadAudio() {
1165
1176
  kit ??= (async () => {
1166
1177
  const node = await import("./node-CVxAljK8.mjs");
1167
1178
  node.setupNodeAudio();
1168
- const audio = await import("./src-JrRbTOJC.mjs");
1179
+ const audio = await import("./src-DNP73wX3.mjs");
1169
1180
  const dir = samplesDir();
1170
1181
  const bank = await audio.loadSampleBank(await node.readManifest(dir), node.nodeDecoder(dir));
1171
1182
  return {
@@ -1181,6 +1192,149 @@ function loadAudio() {
1181
1192
  kit.catch(() => kit = null);
1182
1193
  return kit;
1183
1194
  }
1195
+ // src/version.ts
1196
+ const VERSION = "1.0.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
+ }
1184
1338
  // src/snapshot.ts
1185
1339
  /** The web app's walnut palette (tokens.css), as hex because resvg doesn't read oklch. */
1186
1340
  const C = {
@@ -1222,20 +1376,8 @@ function snapshotSvg(project, opts = {}) {
1222
1376
  out.push(`<text x="${x0}" y="29" font-size="17" font-weight="600" fill="${C.text}">${esc(project.name)}</text>`);
1223
1377
  const info = `Scene ${scene.name} · ${scene.bars} bars · ${keyLabel(project.key)} · ${project.bpm} BPM`;
1224
1378
  out.push(`<text x="${x1}" y="29" font-size="13" fill="${C.muted}" text-anchor="end">${esc(info)}</text>`);
1225
- out.push(`<rect x="${x0}" y="${header}" width="${cw}" height="${strip}" fill="${C.sunken}" rx="4"/>`);
1226
- for (const [i, chord] of score.chords.entries()) {
1227
- const cx = bx(chord.time);
1228
- const w = bx(chord.time + chord.duration) - cx;
1229
- if (i % 2 === 1) out.push(`<rect x="${n(cx)}" y="${header}" width="${n(w)}" height="${strip}" fill="${C.raised}" opacity="0.5"/>`);
1230
- out.push(`<text x="${n(cx + 9)}" y="66" font-size="12.5" font-weight="600" fill="${C.muted}">${esc(chord.symbol)}</text>`);
1231
- }
1232
- out.push(`<rect x="${x0}" y="${top}" width="${cw}" height="${ch}" fill="${C.sunken}" opacity="0.55"/>`);
1233
- for (let b = 0; b <= beats; b++) {
1234
- const gx = n(bx(b));
1235
- const bar = b % 4 === 0;
1236
- out.push(`<line x1="${gx}" y1="${bar ? header : top}" x2="${gx}" y2="${bottom}" stroke="${bar ? C.strong : C.border}" opacity="${bar ? .9 : .45}"/>`);
1237
- 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>`);
1238
- }
1379
+ chordStrip(out, score, bx, x0, cw, header, strip);
1380
+ grid(out, beats, bx, x0, cw, header, top, bottom);
1239
1381
  if (focus && INSTRUMENTS[focus.instrument].kind === "drums") {
1240
1382
  const rows = drumRows(focus.kit);
1241
1383
  rows.forEach((piece, i) => {
@@ -1254,27 +1396,7 @@ function snapshotSvg(project, opts = {}) {
1254
1396
  const layers = [...scene.layers].sort((a, b) => Number(a.id === focus?.id) - Number(b.id === focus?.id));
1255
1397
  for (const layer of layers) {
1256
1398
  const front = layer.id === focus?.id;
1257
- const drums = INSTRUMENTS[layer.instrument].kind === "drums";
1258
- const range = layerRange(layer);
1259
- const rows = drumRows(layer.kit);
1260
- out.push(`<g fill="${layer.color}" opacity="${front ? .75 : .32}">`);
1261
- for (const e of score.events) {
1262
- if (e.track !== layer.id) continue;
1263
- let ny;
1264
- let nh;
1265
- if (drums) {
1266
- ny = 1 - (Math.max(0, rows.indexOf(e.drum)) + 1) / rows.length;
1267
- nh = 1 / rows.length;
1268
- } else {
1269
- nh = 1 / (range[1] - range[0]);
1270
- ny = pitchToY(e.midi, range) - nh / 2;
1271
- }
1272
- const rx = bx(e.time);
1273
- const rw = Math.max(3, bx(e.time + e.duration) - rx - 1.5);
1274
- const rh = Math.max(4, nh * ch - 1.5);
1275
- out.push(`<rect x="${n(rx)}" y="${n(top + ny * ch)}" width="${n(rw)}" height="${n(rh)}" rx="${n(Math.min(3, rh / 2))}"/>`);
1276
- }
1277
- out.push("</g>");
1399
+ notes(out, layer, score, bx, top, ch, front ? .75 : .32);
1278
1400
  for (const s of layer.strokes) {
1279
1401
  const width = n(2 + s.size * 10);
1280
1402
  const opacity = front ? .85 : .2;
@@ -1301,6 +1423,118 @@ function snapshotSvg(project, opts = {}) {
1301
1423
  out.push("</svg>");
1302
1424
  return out.join("\n");
1303
1425
  }
1426
+ /** The whole arrangement: song order on top, then one panel per scene, all on the same time scale. */
1427
+ function songSnapshotSvg(project, opts = {}) {
1428
+ const W = opts.width ?? 1200;
1429
+ const pad = 20;
1430
+ const header = 46;
1431
+ const order = 34;
1432
+ const label = 26;
1433
+ const strip = 24;
1434
+ const canvas = 120;
1435
+ const gap = 16;
1436
+ const x0 = pad;
1437
+ const x1 = W - pad;
1438
+ const cw = x1 - x0;
1439
+ const items = project.arrangement.flatMap((item) => {
1440
+ const scene = project.scenes.find((s) => s.id === item.sceneId);
1441
+ return scene ? [{
1442
+ scene,
1443
+ repeat: item.repeat
1444
+ }] : [];
1445
+ });
1446
+ const scenes = [...new Map(items.map((i) => [i.scene.id, i.scene])).values()];
1447
+ const unused = project.scenes.filter((s) => !scenes.includes(s));
1448
+ const plays = (s) => items.filter((i) => i.scene.id === s.id).reduce((sum, i) => sum + i.repeat, 0);
1449
+ const totalBars = items.reduce((sum, i) => sum + i.scene.bars * i.repeat, 0);
1450
+ const maxBeats = Math.max(4, ...scenes.map((s) => s.bars * 4));
1451
+ const bx = (b) => x0 + b / maxBeats * cw;
1452
+ const panelTop = 102;
1453
+ const panel = 186;
1454
+ const H = panelTop + scenes.length * panel + pad - gap;
1455
+ const out = [];
1456
+ out.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="${FONT}">`);
1457
+ out.push(`<rect width="${W}" height="${H}" fill="${C.bg}"/>`);
1458
+ out.push(`<text x="${x0}" y="29" font-size="17" font-weight="600" fill="${C.text}">${esc(project.name)}</text>`);
1459
+ const info = `Song · ${`${scenes.length} ${scenes.length === 1 ? "scene" : "scenes"}`} · ${totalBars} bars · ${keyLabel(project.key)} · ${project.bpm} BPM`;
1460
+ out.push(`<text x="${x1}" y="29" font-size="13" fill="${C.muted}" text-anchor="end">${esc(info)}</text>`);
1461
+ let ox = x0;
1462
+ for (const [i, item] of items.entries()) {
1463
+ const w = item.scene.bars * item.repeat * cw / Math.max(1, totalBars);
1464
+ out.push(`<rect x="${n(ox)}" y="${header}" width="${n(Math.max(1, w - 2))}" height="${order}" rx="4" fill="${i % 2 ? C.raised : C.sunken}" stroke="${C.border}"/>`);
1465
+ const text = item.repeat > 1 ? `${item.scene.name} ×${item.repeat}` : item.scene.name;
1466
+ if (w > text.length * 7.5 + 14) out.push(`<text x="${n(ox + 9)}" y="68" font-size="12.5" font-weight="600" fill="${C.text}">${esc(text)}</text>`);
1467
+ ox += w;
1468
+ }
1469
+ for (const [i, scene] of scenes.entries()) {
1470
+ const y = panelTop + i * panel;
1471
+ const score = buildSceneScore(project, scene.id);
1472
+ const beats = scene.bars * 4;
1473
+ const sx1 = bx(beats);
1474
+ const top = y + label + strip;
1475
+ const bottom = top + canvas;
1476
+ const times = plays(scene);
1477
+ const ids = new Set(scene.layers.map((l) => l.id));
1478
+ const noteTotal = score.events.filter((e) => ids.has(e.track)).length;
1479
+ const head = `Scene ${scene.name} · ${scene.bars} ${scene.bars === 1 ? "bar" : "bars"} · plays ${times}×`;
1480
+ const tail = `${scene.layers.length} ${scene.layers.length === 1 ? "layer" : "layers"} · ${noteTotal} notes`;
1481
+ out.push(`<text x="${x0}" y="${y + 17}" font-size="13" font-weight="600" fill="${C.text}">${esc(head)}</text>`);
1482
+ out.push(`<text x="${x1}" y="${y + 17}" font-size="12" fill="${C.muted}" text-anchor="end">${esc(tail)}</text>`);
1483
+ chordStrip(out, score, bx, x0, sx1 - x0, y + label, strip);
1484
+ grid(out, beats, bx, x0, sx1 - x0, y + label, top, bottom);
1485
+ for (const layer of scene.layers) notes(out, layer, score, bx, top, canvas, layer.muted ? .25 : .7);
1486
+ if (!scene.layers.length) out.push(`<text x="30" y="${top + 22}" font-size="12" fill="${C.faint}">No layers yet</text>`);
1487
+ }
1488
+ out.push("</svg>");
1489
+ return {
1490
+ svg: out.join("\n"),
1491
+ scenes,
1492
+ unused
1493
+ };
1494
+ }
1495
+ function chordStrip(out, score, bx, x0, width, y, h) {
1496
+ out.push(`<rect x="${x0}" y="${y}" width="${n(width)}" height="${h}" fill="${C.sunken}" rx="4"/>`);
1497
+ for (const [i, chord] of score.chords.entries()) {
1498
+ const cx = bx(chord.time);
1499
+ const w = bx(chord.time + chord.duration) - cx;
1500
+ if (i % 2 === 1) out.push(`<rect x="${n(cx)}" y="${y}" width="${n(w)}" height="${h}" fill="${C.raised}" opacity="0.5"/>`);
1501
+ out.push(`<text x="${n(cx + 9)}" y="${n(y + h / 2 + 5)}" font-size="12.5" font-weight="600" fill="${C.muted}">${esc(chord.symbol)}</text>`);
1502
+ }
1503
+ }
1504
+ /** The canvas background and beat lines. Bar lines run up through the chord strip from stripTop. */
1505
+ function grid(out, beats, bx, x0, width, stripTop, top, bottom) {
1506
+ out.push(`<rect x="${x0}" y="${top}" width="${n(width)}" height="${bottom - top}" fill="${C.sunken}" opacity="0.55"/>`);
1507
+ for (let b = 0; b <= beats; b++) {
1508
+ const gx = n(bx(b));
1509
+ const bar = b % 4 === 0;
1510
+ out.push(`<line x1="${gx}" y1="${bar ? stripTop : top}" x2="${gx}" y2="${bottom}" stroke="${bar ? C.strong : C.border}" opacity="${bar ? .9 : .45}"/>`);
1511
+ 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>`);
1512
+ }
1513
+ }
1514
+ /** One layer's notes as pills: drums on their rows, tonal parts by pitch across the layer's range. */
1515
+ function notes(out, layer, score, bx, top, ch, opacity) {
1516
+ const drums = INSTRUMENTS[layer.instrument].kind === "drums";
1517
+ const range = layerRange(layer);
1518
+ const rows = drumRows(layer.kit);
1519
+ out.push(`<g fill="${layer.color}" opacity="${opacity}">`);
1520
+ for (const e of score.events) {
1521
+ if (e.track !== layer.id) continue;
1522
+ let ny;
1523
+ let nh;
1524
+ if (drums) {
1525
+ ny = 1 - (Math.max(0, rows.indexOf(e.drum)) + 1) / rows.length;
1526
+ nh = 1 / rows.length;
1527
+ } else {
1528
+ nh = 1 / (range[1] - range[0]);
1529
+ ny = pitchToY(e.midi, range) - nh / 2;
1530
+ }
1531
+ const rx = bx(e.time);
1532
+ const rw = Math.max(3, bx(e.time + e.duration) - rx - 1.5);
1533
+ const rh = Math.max(4, nh * ch - 1.5);
1534
+ out.push(`<rect x="${n(rx)}" y="${n(top + ny * ch)}" width="${n(rw)}" height="${n(rh)}" rx="${n(Math.min(3, rh / 2))}"/>`);
1535
+ }
1536
+ out.push("</g>");
1537
+ }
1304
1538
  /** PNG bytes. resvg is a native module, so it's only loaded when a PNG is actually asked for. */
1305
1539
  async function snapshotPng(svg, width) {
1306
1540
  const { Resvg } = await import("@resvg/resvg-js");
@@ -1415,13 +1649,17 @@ async function connectedTab(ctx) {
1415
1649
  if (!link) return null;
1416
1650
  return (await link.request({ op: "ping" })).tab ? link : null;
1417
1651
  }
1652
+ /** The connected tab, opening Haku first when none is connected (unless auto-launch is off). */
1653
+ async function requireTab(ctx) {
1654
+ const tab = await connectedTab(ctx);
1655
+ if (tab) return tab;
1656
+ if (!ctx.autoLaunch) throw new Error(NO_TAB);
1657
+ return launchTab(ctx.link);
1658
+ }
1418
1659
  /** The file when one is given, otherwise the live tab. */
1419
1660
  async function targetFor(ctx, path) {
1420
1661
  if (path) return new FileTarget(resolve(ctx.cwd, path));
1421
- const link = await ctx.link();
1422
- if (!link) throw new Error("No project file given, and the Haku bridge isn't running. Pass a file, or start \"haku bridge\" (or the MCP server) and open Haku.");
1423
- if (!await connectedTab(ctx)) throw new Error(NO_TAB);
1424
- return new LiveTarget(link);
1662
+ return new LiveTarget(await requireTab(ctx));
1425
1663
  }
1426
1664
  /** The scene a tool works on, resolved to an id so file and live targets agree. */
1427
1665
  const sceneOf = (project, scene) => findScene(project, scene);
@@ -1483,9 +1721,7 @@ const newProject = define({
1483
1721
  await writeProjectFile(full, project);
1484
1722
  return { text: `Created "${project.name}" (${project.mood}) at ${full}` };
1485
1723
  }
1486
- const tab = await connectedTab(ctx);
1487
- if (!tab) throw new Error(NO_TAB);
1488
- await tab.request({
1724
+ await (await requireTab(ctx)).request({
1489
1725
  op: "open_project",
1490
1726
  project
1491
1727
  });
@@ -1500,9 +1736,7 @@ const openProject = define({
1500
1736
  async run({ file: path }, ctx) {
1501
1737
  const full = resolve(ctx.cwd, path);
1502
1738
  const project = await readProjectFile(full);
1503
- const tab = await connectedTab(ctx);
1504
- if (!tab) throw new Error(NO_TAB);
1505
- await tab.request({
1739
+ await (await requireTab(ctx)).request({
1506
1740
  op: "open_project",
1507
1741
  project
1508
1742
  });
@@ -1530,7 +1764,7 @@ const saveProject = define({
1530
1764
  const play = define({
1531
1765
  name: "play",
1532
1766
  title: "Play",
1533
- description: "Listen. With the Haku tab connected (and no file), it plays there. Otherwise it renders offline and plays through this machine's speakers.",
1767
+ 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.",
1534
1768
  input: z.object({
1535
1769
  file,
1536
1770
  scope,
@@ -1539,17 +1773,14 @@ const play = define({
1539
1773
  }),
1540
1774
  async run({ file: path, scope = "scene", scene, loops = 2 }, ctx) {
1541
1775
  if (!path) {
1542
- const tab = await connectedTab(ctx);
1543
- if (tab) {
1544
- const status = await tab.request({
1545
- op: "play",
1546
- mode: scope === "song" ? "song" : "loop",
1547
- scene
1548
- });
1549
- return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
1550
- }
1776
+ const status = await (await requireTab(ctx)).request({
1777
+ op: "play",
1778
+ mode: scope === "song" ? "song" : "loop",
1779
+ scene
1780
+ });
1781
+ return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
1551
1782
  }
1552
- const project = await (await targetFor(ctx, path)).read();
1783
+ const project = await new FileTarget(resolve(ctx.cwd, path)).read();
1553
1784
  const sceneId = sceneOf(project, scene).id;
1554
1785
  const audio = await loadAudio();
1555
1786
  const pcm = await audio.render(project, scope === "song" ? { scope: "song" } : {
@@ -1590,25 +1821,35 @@ const stop = define({
1590
1821
  const snapshot = define({
1591
1822
  name: "snapshot",
1592
1823
  title: "Snapshot",
1593
- 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).",
1824
+ description: "A picture of a scene: chords along the top, each layer's notes and strokes, and a legend. With scope song, the whole arrangement instead: the song order on top, then the chords and notes of each scene in it. Returns a PNG. Pass out to also save it (.png or .svg).",
1594
1825
  readOnly: true,
1595
1826
  input: z.object({
1596
1827
  file,
1828
+ scope,
1597
1829
  scene: sceneArg,
1598
- layer: z.string().optional().describe("Layer to bring to the front, with its pitch guides"),
1830
+ layer: z.string().optional().describe("Layer to bring to the front, with its pitch guides. Scene scope only."),
1599
1831
  width: z.number().int().min(400).max(2400).optional().describe("Image width in pixels. Default 1200."),
1600
1832
  out: z.string().optional().describe("Also save the picture here (.png or .svg)")
1601
1833
  }),
1602
- async run({ file: path, scene, layer, width = 1200, out }, ctx) {
1834
+ async run({ file: path, scope = "scene", scene, layer, width = 1200, out }, ctx) {
1603
1835
  const project = await (await targetFor(ctx, path)).read();
1604
- const s = sceneOf(project, scene);
1605
- const svg = snapshotSvg(project, {
1606
- scene: s.id,
1607
- layer,
1608
- width,
1609
- height: Math.round(width * .53)
1610
- });
1611
- const notes = [`Snapshot of scene ${s.name} in "${project.name}"`];
1836
+ let svg;
1837
+ const notes = [];
1838
+ if (scope === "song") {
1839
+ const shot = songSnapshotSvg(project, { width });
1840
+ svg = shot.svg;
1841
+ notes.push(`Snapshot of the song in "${project.name}" (${shot.scenes.map((s) => s.name).join(", ")})`);
1842
+ if (shot.unused.length) notes.push(`not in the song: ${shot.unused.map((s) => s.name).join(", ")}`);
1843
+ } else {
1844
+ const s = sceneOf(project, scene);
1845
+ svg = snapshotSvg(project, {
1846
+ scene: s.id,
1847
+ layer,
1848
+ width,
1849
+ height: Math.round(width * .53)
1850
+ });
1851
+ notes.push(`Snapshot of scene ${s.name} in "${project.name}"`);
1852
+ }
1612
1853
  if (out?.toLowerCase().endsWith(".svg")) {
1613
1854
  const full = resolve(ctx.cwd, out);
1614
1855
  await writeFile(full, svg);
@@ -1710,7 +1951,7 @@ const status = define({
1710
1951
  async run(_, ctx) {
1711
1952
  const lines = [];
1712
1953
  const link = await ctx.link();
1713
- if (!link) lines.push("Bridge: not running. Start \"haku bridge\" or the MCP server, then open Haku.");
1954
+ if (!link) lines.push("Bridge: not running. Run \"haku web\" to open Haku with the bridge.");
1714
1955
  else {
1715
1956
  const ping = await link.request({ op: "ping" });
1716
1957
  if (!ping.tab) lines.push("Bridge: running, no Haku tab connected.");
@@ -1737,6 +1978,4 @@ const TOOLS = [
1737
1978
  exportTool,
1738
1979
  status
1739
1980
  ];
1740
- // src/version.ts
1741
- const VERSION = "0.1.0";
1742
- export { BridgeClient as a, bridgePort as c, GUIDE as i, TOOLS as n, BridgeHost as o, Player as r, SharedLink as s, VERSION as t };
1981
+ 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 };