@pinkpixel/haku 0.1.0 → 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.
- package/README.md +13 -9
- package/dist/cli.mjs +38 -4
- package/dist/{mcp-DDuNECfs.mjs → mcp-DG9U-P0N.mjs} +3 -2
- package/dist/{src-JrRbTOJC.mjs → src-CgZeiBJc.mjs} +12 -2
- package/dist/{src-D5fW7AC1.mjs → src-dP0jXhPP.mjs} +10 -1
- package/dist/{version-DoZ2etXG.mjs → tools-_xkF2Yfv.mjs} +184 -35
- package/dist/web/assets/figtree-latin-ext-wght-normal-DCwSJGxG.woff2 +0 -0
- package/dist/web/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
- package/dist/web/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
- package/dist/web/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
- package/dist/web/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
- package/dist/web/assets/index-BmW6jd5Q.js +272 -0
- package/dist/web/assets/index-C7U7z4um.css +1 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +18 -0
- package/dist/web/manifest.webmanifest +24 -0
- package/dist/web/sw.js +37 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ The `haku` command gives you (or your agent) a tool for every drawing command, p
|
|
|
11
11
|
- Node 22.18 or newer
|
|
12
12
|
- For listening without the browser: one of `pw-play`, `paplay`, `aplay`, `afplay`, or `ffplay` (Windows uses PowerShell, so nothing extra there)
|
|
13
13
|
|
|
14
|
-
The package is about
|
|
14
|
+
The package is about 9 MB, mostly because the instrument samples come with it. The web app is in there too.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -102,21 +102,23 @@ Every drawing and editing command is its own tool (`draw_line`, `set_mood`, `add
|
|
|
102
102
|
|
|
103
103
|
## Working live in the browser
|
|
104
104
|
|
|
105
|
-
Haku also has a web app with the actual drawing canvas
|
|
106
|
-
|
|
107
|
-
Right now the web app isn't hosted anywhere, so live mode means running it from the repo:
|
|
105
|
+
Haku also has a web app with the actual drawing canvas, and it ships in this package. Open it with:
|
|
108
106
|
|
|
109
107
|
```bash
|
|
110
|
-
|
|
111
|
-
cd haku
|
|
112
|
-
pnpm install
|
|
113
|
-
pnpm --filter @haku/web dev
|
|
108
|
+
haku web
|
|
114
109
|
```
|
|
115
110
|
|
|
116
|
-
|
|
111
|
+
That serves the app on http://127.0.0.1:5177, hosts the agent bridge, and opens your default browser. Leave out `-f` and the tools work on that tab instead of a file, so you see and hear every change, and Ctrl+Z still undoes it. The MCP server also hosts the bridge on `127.0.0.1:5179`, and the tab connects to whichever one is running on its own.
|
|
112
|
+
|
|
113
|
+
You don't have to open it first. When a tool needs the tab and none is connected, Haku starts `haku web` in the background (it quits after 30 minutes with no tab connected), opens the browser, and waits for the tab. Set `HAKU_NO_LAUNCH=1` if you'd rather get an error instead.
|
|
114
|
+
|
|
115
|
+
In Chrome or Edge you can install Haku as an app from the install icon in the address bar. It gets its own window, and since the app and samples are cached after the first visit, it opens fine even when `haku web` isn't running. Agents only reach it while a bridge is up, though, so start `haku web` or your MCP client first if you want that.
|
|
116
|
+
|
|
117
|
+
A few things to know:
|
|
117
118
|
|
|
118
119
|
- Browsers won't let a page make sound until you've clicked in it once. If `play` says so, click anywhere in the tab and try again.
|
|
119
120
|
- With more than one Haku tab open, tools drive the one you clicked into most recently.
|
|
121
|
+
- Your projects are saved in the browser for `127.0.0.1:5177`. Changing `HAKU_WEB_PORT` gives you a separate, empty project list, so use the folder button to export and import if you move.
|
|
120
122
|
- The bridge only accepts tabs from `localhost` pages and only accepts tools from outside the browser. `HAKU_ALLOW_ORIGINS` (comma separated) adds other page origins, and `HAKU_BRIDGE_PORT` changes the port.
|
|
121
123
|
|
|
122
124
|
## Environment variables
|
|
@@ -125,6 +127,8 @@ Then open http://localhost:5178. A few things to know:
|
|
|
125
127
|
|---|---|
|
|
126
128
|
| `HAKU_PROJECT` | Default project file for commands that take `-f` |
|
|
127
129
|
| `HAKU_BRIDGE_PORT` | Bridge port. Default `5179` |
|
|
130
|
+
| `HAKU_WEB_PORT` | Port `haku web` serves the app on. Default `5177` |
|
|
131
|
+
| `HAKU_NO_LAUNCH` | Set it to stop tools from opening Haku when no tab is connected |
|
|
128
132
|
| `HAKU_ALLOW_ORIGINS` | Extra page origins allowed to connect as a Haku tab |
|
|
129
133
|
| `HAKU_SAMPLES` | Use a different sample folder (one with a `manifest.json`) |
|
|
130
134
|
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
2
|
+
import { a as webRunning, c as Player, d as BridgeHost, f as SharedLink, i as webPort, l as GUIDE, n as openBrowser, o as webUrl, p as bridgePort, r as serveWeb, s as VERSION, t as TOOLS, u as BridgeClient } from "./tools-_xkF2Yfv.mjs";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { Command, Option } from "commander";
|
|
@@ -8,10 +8,11 @@ import { z } from "zod";
|
|
|
8
8
|
const kebab = (s) => s.replace(/_/g, "-");
|
|
9
9
|
let client = null;
|
|
10
10
|
const ctx = {
|
|
11
|
-
link: () => client
|
|
11
|
+
link: async () => await client ?? await (client = BridgeClient.connect()),
|
|
12
12
|
player: new Player(),
|
|
13
13
|
waitForPlayback: true,
|
|
14
|
-
cwd: process.env.INIT_CWD ?? process.cwd()
|
|
14
|
+
cwd: process.env.INIT_CWD ?? process.cwd(),
|
|
15
|
+
autoLaunch: !process.env.HAKU_NO_LAUNCH
|
|
15
16
|
};
|
|
16
17
|
function fail(message) {
|
|
17
18
|
console.error(`haku: ${message}`);
|
|
@@ -110,6 +111,39 @@ program.command("exec").argument("[commands]", "JSON file of commands (an array,
|
|
|
110
111
|
}
|
|
111
112
|
await runTool(batch, parsed.data);
|
|
112
113
|
});
|
|
114
|
+
/** How long an auto-launched `haku web` keeps running with no tab connected. */
|
|
115
|
+
const IDLE_MS = 18e5;
|
|
116
|
+
program.command("web").description("Open Haku in your browser. Serves the app on localhost and hosts the bridge, so agents can drive it too.").option("--no-open", "Just serve, don't open a browser").addOption(new Option("--exit-when-idle").hideHelp()).action(async (opts) => {
|
|
117
|
+
const port = webPort();
|
|
118
|
+
const url = webUrl(port);
|
|
119
|
+
let server;
|
|
120
|
+
try {
|
|
121
|
+
server = await serveWeb(port);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err.code !== "EADDRINUSE") fail(err instanceof Error ? err.message : String(err));
|
|
124
|
+
if (!await webRunning(port)) fail(`port ${port} is taken by something else. Set HAKU_WEB_PORT to use another one.`);
|
|
125
|
+
console.log(`Haku is already running at ${url}`);
|
|
126
|
+
if (opts.open) await openBrowser(url);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const link = new SharedLink();
|
|
130
|
+
await link.ensure().catch(() => null);
|
|
131
|
+
console.log(`Haku is running at ${url}. Ctrl+C to stop.`);
|
|
132
|
+
if (opts.open && !await openBrowser(url)) console.log(`Couldn't open a browser. Open ${url} yourself.`);
|
|
133
|
+
const shutdown = () => {
|
|
134
|
+
server.close();
|
|
135
|
+
link.close().finally(() => process.exit(0));
|
|
136
|
+
};
|
|
137
|
+
process.once("SIGINT", shutdown);
|
|
138
|
+
process.once("SIGTERM", shutdown);
|
|
139
|
+
if (opts.exitWhenIdle) {
|
|
140
|
+
let lastTab = Date.now();
|
|
141
|
+
setInterval(async () => {
|
|
142
|
+
if ((await link.request({ op: "ping" }).catch(() => null))?.tab) lastTab = Date.now();
|
|
143
|
+
else if (Date.now() - lastTab > IDLE_MS) shutdown();
|
|
144
|
+
}, 6e4);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
113
147
|
program.command("bridge").description("Run the localhost bridge on its own, so the CLI can reach the open Haku tab.").action(async () => {
|
|
114
148
|
const port = bridgePort();
|
|
115
149
|
let host;
|
|
@@ -124,7 +158,7 @@ program.command("bridge").description("Run the localhost bridge on its own, so t
|
|
|
124
158
|
process.once("SIGINT", () => void host.close().then(() => process.exit(0)));
|
|
125
159
|
});
|
|
126
160
|
program.command("mcp").description("Start the MCP server on stdio. It also hosts the bridge to the Haku tab.").action(async () => {
|
|
127
|
-
const { startMcp } = await import("./mcp-
|
|
161
|
+
const { startMcp } = await import("./mcp-DG9U-P0N.mjs");
|
|
128
162
|
await startMcp();
|
|
129
163
|
});
|
|
130
164
|
const argv = [...process.argv];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as Player, f as SharedLink, l as GUIDE, s as VERSION, t as TOOLS } from "./tools-_xkF2Yfv.mjs";
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
3
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
4
|
// src/mcp.ts
|
|
@@ -17,7 +17,8 @@ async function startMcp() {
|
|
|
17
17
|
link: () => link.ensure().catch(() => null),
|
|
18
18
|
player,
|
|
19
19
|
waitForPlayback: false,
|
|
20
|
-
cwd: process.cwd()
|
|
20
|
+
cwd: process.cwd(),
|
|
21
|
+
autoLaunch: !process.env.HAKU_NO_LAUNCH
|
|
21
22
|
};
|
|
22
23
|
const handle = serveStdio(() => {
|
|
23
24
|
const server = new McpServer({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as DRUM_PIECE_IDS, T as INSTRUMENTS, i as buildSceneScore, p as createRng, r as buildArrangementScore } from "./src-
|
|
1
|
+
import { S as DRUM_PIECE_IDS, T as INSTRUMENTS, i as buildSceneScore, p as createRng, r as buildArrangementScore } from "./src-dP0jXhPP.mjs";
|
|
2
2
|
import * as Tone from "tone";
|
|
3
3
|
// ../../packages/core/src/engine/types.ts
|
|
4
4
|
function beatsToSeconds(beats, bpm) {
|
|
@@ -550,7 +550,17 @@ function loopScore(score, loops) {
|
|
|
550
550
|
function scoreFor(project, opts = {}) {
|
|
551
551
|
return opts.scope === "song" ? buildArrangementScore(project) : loopScore(buildSceneScore(project, opts.sceneId), opts.loops ?? 1);
|
|
552
552
|
}
|
|
553
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Tone.Offline swaps Tone's global context while it runs, so two overlapping renders would build
|
|
555
|
+
* nodes on each other's context. Renders wait their turn instead.
|
|
556
|
+
*/
|
|
557
|
+
let queue = Promise.resolve();
|
|
558
|
+
function renderProject(project, bank, opts = {}) {
|
|
559
|
+
const run = queue.then(() => renderNow(project, bank, opts));
|
|
560
|
+
queue = run.catch(() => {});
|
|
561
|
+
return run;
|
|
562
|
+
}
|
|
563
|
+
async function renderNow(project, bank, opts) {
|
|
554
564
|
const score = scoreFor(project, opts);
|
|
555
565
|
const sampleRate = opts.sampleRate ?? 44100;
|
|
556
566
|
const seconds = LEAD_IN + beatsToSeconds(score.beats, score.bpm) + (opts.tail ?? 3);
|
|
@@ -1412,11 +1412,20 @@ function beatStrength(beat) {
|
|
|
1412
1412
|
if (Math.abs(inBar - Math.round(inBar)) > 1e-6) return "weak";
|
|
1413
1413
|
return Math.round(inBar) % 2 === 0 ? "strong" : "medium";
|
|
1414
1414
|
}
|
|
1415
|
+
/**
|
|
1416
|
+
* The key's scale bent to fit the chord. When a chord brings in a note from outside the key (G# in
|
|
1417
|
+
* E7 over A minor), scale tones a half step from it drop out, so G and G# never both show up.
|
|
1418
|
+
* Chord tones always stay.
|
|
1419
|
+
*/
|
|
1420
|
+
function fittedScale(scale, chord) {
|
|
1421
|
+
const outside = chord.filter((pc) => !scale.includes(pc));
|
|
1422
|
+
return scale.filter((pc) => chord.includes(pc) || !outside.some((o) => mod12(pc - o) === 1 || mod12(o - pc) === 1));
|
|
1423
|
+
}
|
|
1415
1424
|
/** The pitch classes a note is allowed to land on in this context. */
|
|
1416
1425
|
function allowedPcs(ctx) {
|
|
1417
1426
|
const tonic = tonicPc(ctx.key);
|
|
1418
|
-
const scale = scalePcs(ctx.key);
|
|
1419
1427
|
const chord = chordPcs(mod12(tonic + ctx.chord.root), ctx.chord.quality);
|
|
1428
|
+
const scale = ctx.mode === "smart" ? fittedScale(scalePcs(ctx.key), chord) : scalePcs(ctx.key);
|
|
1420
1429
|
switch (ctx.mode) {
|
|
1421
1430
|
case "chromatic": return [
|
|
1422
1431
|
0,
|
|
@@ -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-
|
|
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-dP0jXhPP.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.
|
|
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:
|
|
@@ -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) =>
|
|
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
|
|
1105
|
-
if (overlap > .1 && (
|
|
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-
|
|
1179
|
+
const audio = await import("./src-CgZeiBJc.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 = "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
|
+
}
|
|
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 = {
|
|
@@ -1415,13 +1569,17 @@ async function connectedTab(ctx) {
|
|
|
1415
1569
|
if (!link) return null;
|
|
1416
1570
|
return (await link.request({ op: "ping" })).tab ? link : null;
|
|
1417
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
|
+
}
|
|
1418
1579
|
/** The file when one is given, otherwise the live tab. */
|
|
1419
1580
|
async function targetFor(ctx, path) {
|
|
1420
1581
|
if (path) return new FileTarget(resolve(ctx.cwd, path));
|
|
1421
|
-
|
|
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);
|
|
1582
|
+
return new LiveTarget(await requireTab(ctx));
|
|
1425
1583
|
}
|
|
1426
1584
|
/** The scene a tool works on, resolved to an id so file and live targets agree. */
|
|
1427
1585
|
const sceneOf = (project, scene) => findScene(project, scene);
|
|
@@ -1483,9 +1641,7 @@ const newProject = define({
|
|
|
1483
1641
|
await writeProjectFile(full, project);
|
|
1484
1642
|
return { text: `Created "${project.name}" (${project.mood}) at ${full}` };
|
|
1485
1643
|
}
|
|
1486
|
-
|
|
1487
|
-
if (!tab) throw new Error(NO_TAB);
|
|
1488
|
-
await tab.request({
|
|
1644
|
+
await (await requireTab(ctx)).request({
|
|
1489
1645
|
op: "open_project",
|
|
1490
1646
|
project
|
|
1491
1647
|
});
|
|
@@ -1500,9 +1656,7 @@ const openProject = define({
|
|
|
1500
1656
|
async run({ file: path }, ctx) {
|
|
1501
1657
|
const full = resolve(ctx.cwd, path);
|
|
1502
1658
|
const project = await readProjectFile(full);
|
|
1503
|
-
|
|
1504
|
-
if (!tab) throw new Error(NO_TAB);
|
|
1505
|
-
await tab.request({
|
|
1659
|
+
await (await requireTab(ctx)).request({
|
|
1506
1660
|
op: "open_project",
|
|
1507
1661
|
project
|
|
1508
1662
|
});
|
|
@@ -1530,7 +1684,7 @@ const saveProject = define({
|
|
|
1530
1684
|
const play = define({
|
|
1531
1685
|
name: "play",
|
|
1532
1686
|
title: "Play",
|
|
1533
|
-
description: "Listen. With the Haku tab
|
|
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.",
|
|
1534
1688
|
input: z.object({
|
|
1535
1689
|
file,
|
|
1536
1690
|
scope,
|
|
@@ -1539,17 +1693,14 @@ const play = define({
|
|
|
1539
1693
|
}),
|
|
1540
1694
|
async run({ file: path, scope = "scene", scene, loops = 2 }, ctx) {
|
|
1541
1695
|
if (!path) {
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
});
|
|
1549
|
-
return { text: `Playing ${scope === "song" ? "the song" : `scene ${status.sceneName} on a loop`} in the Haku tab` };
|
|
1550
|
-
}
|
|
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` };
|
|
1551
1702
|
}
|
|
1552
|
-
const project = await
|
|
1703
|
+
const project = await new FileTarget(resolve(ctx.cwd, path)).read();
|
|
1553
1704
|
const sceneId = sceneOf(project, scene).id;
|
|
1554
1705
|
const audio = await loadAudio();
|
|
1555
1706
|
const pcm = await audio.render(project, scope === "song" ? { scope: "song" } : {
|
|
@@ -1710,7 +1861,7 @@ const status = define({
|
|
|
1710
1861
|
async run(_, ctx) {
|
|
1711
1862
|
const lines = [];
|
|
1712
1863
|
const link = await ctx.link();
|
|
1713
|
-
if (!link) lines.push("Bridge: not running.
|
|
1864
|
+
if (!link) lines.push("Bridge: not running. Run \"haku web\" to open Haku with the bridge.");
|
|
1714
1865
|
else {
|
|
1715
1866
|
const ping = await link.request({ op: "ping" });
|
|
1716
1867
|
if (!ping.tab) lines.push("Bridge: running, no Haku tab connected.");
|
|
@@ -1737,6 +1888,4 @@ const TOOLS = [
|
|
|
1737
1888
|
exportTool,
|
|
1738
1889
|
status
|
|
1739
1890
|
];
|
|
1740
|
-
|
|
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 };
|
|
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 };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|