@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.
- package/README.md +18 -11
- package/dist/cli.mjs +38 -4
- package/dist/{mcp-DDuNECfs.mjs → mcp-DsSn95Rn.mjs} +3 -2
- package/dist/samples/drums/cabasa_v0_r1.ogg +0 -0
- package/dist/samples/drums/cabasa_v0_r2.ogg +0 -0
- package/dist/samples/drums/cabasa_v1_r1.ogg +0 -0
- package/dist/samples/drums/cabasa_v1_r2.ogg +0 -0
- package/dist/samples/drums/shaker_v0_r1.ogg +0 -0
- package/dist/samples/drums/shaker_v0_r2.ogg +0 -0
- package/dist/samples/drums/shaker_v1_r1.ogg +0 -0
- package/dist/samples/drums/shaker_v1_r2.ogg +0 -0
- package/dist/samples/guitar/v0_31.ogg +0 -0
- package/dist/samples/guitar/v0_32.ogg +0 -0
- package/dist/samples/guitar/v0_33.ogg +0 -0
- package/dist/samples/guitar/v0_34.ogg +0 -0
- package/dist/samples/guitar/v0_35.ogg +0 -0
- package/dist/samples/guitar/v0_36.ogg +0 -0
- package/dist/samples/guitar/v0_37.ogg +0 -0
- package/dist/samples/guitar/v0_38.ogg +0 -0
- package/dist/samples/guitar/v0_39.ogg +0 -0
- package/dist/samples/guitar/v0_40.ogg +0 -0
- package/dist/samples/guitar/v0_41.ogg +0 -0
- package/dist/samples/guitar/v0_43.ogg +0 -0
- package/dist/samples/guitar/v0_45.ogg +0 -0
- package/dist/samples/guitar/v0_47.ogg +0 -0
- package/dist/samples/guitar/v0_48.ogg +0 -0
- package/dist/samples/guitar/v0_50.ogg +0 -0
- package/dist/samples/guitar/v0_52.ogg +0 -0
- package/dist/samples/guitar/v0_53.ogg +0 -0
- package/dist/samples/guitar/v0_54.ogg +0 -0
- package/dist/samples/guitar/v0_55.ogg +0 -0
- package/dist/samples/guitar/v0_56.ogg +0 -0
- package/dist/samples/guitar/v0_57.ogg +0 -0
- package/dist/samples/guitar/v0_58.ogg +0 -0
- package/dist/samples/guitar/v0_59.ogg +0 -0
- package/dist/samples/guitar/v0_60.ogg +0 -0
- package/dist/samples/guitar/v0_61.ogg +0 -0
- package/dist/samples/guitar/v0_62.ogg +0 -0
- package/dist/samples/guitar/v0_63.ogg +0 -0
- package/dist/samples/guitar/v0_64.ogg +0 -0
- package/dist/samples/guitar/v0_65.ogg +0 -0
- package/dist/samples/guitar/v0_66.ogg +0 -0
- package/dist/samples/guitar/v0_67.ogg +0 -0
- package/dist/samples/guitar/v0_69.ogg +0 -0
- package/dist/samples/guitar/v0_70.ogg +0 -0
- package/dist/samples/guitar/v0_71.ogg +0 -0
- package/dist/samples/guitar/v0_72.ogg +0 -0
- package/dist/samples/guitar/v0_73.ogg +0 -0
- package/dist/samples/guitar/v0_74.ogg +0 -0
- package/dist/samples/guitar/v0_75.ogg +0 -0
- package/dist/samples/guitar/v0_76.ogg +0 -0
- package/dist/samples/guitar/v0_77.ogg +0 -0
- package/dist/samples/guitar/v0_78.ogg +0 -0
- package/dist/samples/guitar/v0_79.ogg +0 -0
- package/dist/samples/guitar/v0_80.ogg +0 -0
- package/dist/samples/guitar/v0_81.ogg +0 -0
- package/dist/samples/guitar/v0_82.ogg +0 -0
- package/dist/samples/guitar/v0_83.ogg +0 -0
- package/dist/samples/guitar/v0_84.ogg +0 -0
- package/dist/samples/kalimba/v0_44.ogg +0 -0
- package/dist/samples/kalimba/v0_46.ogg +0 -0
- package/dist/samples/kalimba/v0_49.ogg +0 -0
- package/dist/samples/kalimba/v0_51.ogg +0 -0
- package/dist/samples/kalimba/v0_53.ogg +0 -0
- package/dist/samples/kalimba/v0_56.ogg +0 -0
- package/dist/samples/kalimba/v0_59.ogg +0 -0
- package/dist/samples/kalimba/v0_61.ogg +0 -0
- package/dist/samples/kalimba/v0_64.ogg +0 -0
- package/dist/samples/kalimba/v0_65.ogg +0 -0
- package/dist/samples/kalimba/v0_67.ogg +0 -0
- package/dist/samples/kalimba/v0_71.ogg +0 -0
- package/dist/samples/kalimba/v0_73.ogg +0 -0
- package/dist/samples/kalimba/v0_74.ogg +0 -0
- package/dist/samples/kalimba/v0_75.ogg +0 -0
- package/dist/samples/kalimba/v0_77.ogg +0 -0
- package/dist/samples/kalimba/v0_78.ogg +0 -0
- package/dist/samples/kalimba/v0_79.ogg +0 -0
- package/dist/samples/kalimba/v0_82.ogg +0 -0
- package/dist/samples/kalimba/v0_83.ogg +0 -0
- package/dist/samples/manifest.json +181 -0
- package/dist/samples/vibes/v0_53.ogg +0 -0
- package/dist/samples/vibes/v0_57.ogg +0 -0
- package/dist/samples/vibes/v0_60.ogg +0 -0
- package/dist/samples/vibes/v0_64.ogg +0 -0
- package/dist/samples/vibes/v0_67.ogg +0 -0
- package/dist/samples/vibes/v0_71.ogg +0 -0
- package/dist/samples/vibes/v0_74.ogg +0 -0
- package/dist/samples/vibes/v0_77.ogg +0 -0
- package/dist/samples/vibes/v0_81.ogg +0 -0
- package/dist/samples/vibes/v0_84.ogg +0 -0
- package/dist/samples/vibes/v0_88.ogg +0 -0
- package/dist/samples/vibes/v1_53.ogg +0 -0
- package/dist/samples/vibes/v1_57.ogg +0 -0
- package/dist/samples/vibes/v1_60.ogg +0 -0
- package/dist/samples/vibes/v1_64.ogg +0 -0
- package/dist/samples/vibes/v1_67.ogg +0 -0
- package/dist/samples/vibes/v1_71.ogg +0 -0
- package/dist/samples/vibes/v1_74.ogg +0 -0
- package/dist/samples/vibes/v1_77.ogg +0 -0
- package/dist/samples/vibes/v1_81.ogg +0 -0
- package/dist/samples/vibes/v1_84.ogg +0 -0
- package/dist/samples/vibes/v1_88.ogg +0 -0
- package/dist/{src-D5fW7AC1.mjs → src-Bz-0ulVm.mjs} +179 -23
- package/dist/{src-JrRbTOJC.mjs → src-DNP73wX3.mjs} +60 -3
- package/dist/{version-DoZ2etXG.mjs → tools-CO-tdB7d.mjs} +321 -82
- package/dist/web/assets/esm-J-U37_Wq.js +210 -0
- 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-BkzOde44.js +65 -0
- package/dist/web/assets/index-D0m3L8JK.css +1 -0
- package/dist/web/assets/rolldown-runtime-BWaJ3C7D.js +1 -0
- package/dist/web/assets/src-m48ItyJA.js +1 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +19 -0
- package/dist/web/manifest.webmanifest +24 -0
- package/dist/web/sw.js +37 -0
- 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-
|
|
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.
|
|
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) =>
|
|
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-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
|
|
1226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
1605
|
-
const
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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.
|
|
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
|
-
|
|
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 };
|