diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 1c297427f4..f432c44988 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -70,6 +70,7 @@ import { stopBackgroundPreview, } from "./previewLifecycle.js"; import { resolveLocalBrowserGpuMode, type BrowserGpuMode } from "../browser/gpuPolicy.js"; +import { stopVstSidecar } from "../vst/sidecar.js"; interface BrowserLaunchOptions { noOpen?: boolean; @@ -896,6 +897,11 @@ function removeSymlinkOnExit(createdSymlink: boolean, symlinkPath: string): void function registerChildTreeShutdown(child: StudioChildProcess): void { const shutdown = (): void => { if (child.pid) killProcessTree(child.pid); + // The VST sidecar (packages/cli/src/vst/sidecar.ts) is started lazily by + // the studio's API route, not spawned here — but whichever mode ends up + // starting it, Ctrl-C during this preview session must still tear it + // down. Safe no-op if no sidecar is running. + stopVstSidecar(); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); @@ -1150,6 +1156,11 @@ async function runEmbeddedMode( const { closeThumbnailBrowser } = await import("../server/studioServer.js"); const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine"); killTrackedProcesses(); + // Embedded mode has no StudioChildProcess to hand to + // registerChildTreeShutdown (it runs an in-process Hono server, not + // a spawned child) — so the VST sidecar, if the API route started + // one during this session, is torn down directly here instead. + stopVstSidecar(); await closeThumbnailBrowser().catch(() => {}); await drainBrowserPool().catch(() => {}); }; @@ -1169,7 +1180,10 @@ async function runEmbeddedMode( import("@hyperframes/engine") .then(({ killTrackedProcesses }) => { process.once("exit", () => { - if (!shuttingDown) killTrackedProcesses(); + if (!shuttingDown) { + killTrackedProcesses(); + stopVstSidecar(); + } }); }) .catch(() => {}); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index e9d50f3cf3..afcbf1c56d 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -34,6 +34,10 @@ import { import { resolveAutoProxy } from "../utils/projectConfig.js"; import { getElementScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; import type { ScreenshotClip } from "@hyperframes/studio-server/screenshot-clip"; +import { + getVstSidecar as getVstSidecarSync, + startVstSidecar, +} from "@hyperframes/studio-server/vst-sidecar"; import type { RenderJob } from "@hyperframes/producer"; import { seekCompositionTimeline } from "../capture/captureCompositionFrame.js"; import { @@ -647,6 +651,16 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }); return { written: relativePaths, block: item }; }, + + startVstSidecar: async () => { + const { port, token } = await startVstSidecar(); + return { port, token }; + }, + + getVstSidecarStatus: () => { + const running = getVstSidecarSync(); + return running ? { running: true, port: running.port } : { running: false, port: null }; + }, }; // ── Build the Hono app ───────────────────────────────────────────────── diff --git a/packages/cli/src/vst/sidecar.ts b/packages/cli/src/vst/sidecar.ts new file mode 100644 index 0000000000..41f52ada1c --- /dev/null +++ b/packages/cli/src/vst/sidecar.ts @@ -0,0 +1,14 @@ +/** + * Re-export of the VST host sidecar lifecycle, relocated to + * `packages/studio-server/src/vstSidecar.ts` so both the CLI's embedded + * studio server and the Studio Vite dev server can import it without + * duplicating the resolver + spawn logic (studio-server has no dependency + * that module doesn't already need — only `node:child_process`/`node:fs`/ + * `node:path` — and the CLI already depends on `@hyperframes/studio-server`, + * so this direction adds no new dependency edge). + * + * Kept as a thin re-export (rather than updating every CLI import site) so + * `hyperframes preview`'s existing `import { stopVstSidecar } from + * "../vst/sidecar.js"` continues to work unchanged. + */ +export * from "@hyperframes/studio-server/vst-sidecar"; diff --git a/packages/studio-server/package-subpaths.json b/packages/studio-server/package-subpaths.json index 9157d4c1cb..a1b970cf9a 100644 --- a/packages/studio-server/package-subpaths.json +++ b/packages/studio-server/package-subpaths.json @@ -67,6 +67,12 @@ "runtime": "./dist/helpers/mediaProxyPreview.js", "types": "./dist/helpers/mediaProxyPreview.d.ts", "environments": ["bun", "node"] + }, + "./vst-sidecar": { + "source": "./src/vstSidecar.ts", + "runtime": "./dist/vstSidecar.js", + "types": "./dist/vstSidecar.d.ts", + "environments": ["bun", "node"] } } } diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 0216de4ab7..8f8cc4ab73 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -74,6 +74,12 @@ "node": "./dist/helpers/mediaProxyPreview.js", "import": "./src/helpers/mediaProxyPreview.ts", "types": "./src/helpers/mediaProxyPreview.ts" + }, + "./vst-sidecar": { + "bun": "./src/vstSidecar.ts", + "node": "./dist/vstSidecar.js", + "import": "./src/vstSidecar.ts", + "types": "./src/vstSidecar.ts" } }, "publishConfig": { @@ -119,6 +125,10 @@ "./media-proxy-preview": { "import": "./dist/helpers/mediaProxyPreview.js", "types": "./dist/helpers/mediaProxyPreview.d.ts" + }, + "./vst-sidecar": { + "import": "./dist/vstSidecar.js", + "types": "./dist/vstSidecar.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 5976ced33a..eb4638eaad 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -13,6 +13,7 @@ import { registerRegistryRoutes } from "./routes/registry.js"; import { registerSelectionRoutes } from "./routes/selection.js"; import { registerMediaRoutes } from "./routes/media.js"; import { registerGlobalAssetRoutes } from "./routes/globalAssets.js"; +import { registerVstRoutes } from "./routes/vst.js"; /** * Create a Hono sub-app with all studio API routes. @@ -36,6 +37,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerFontRoutes(api); registerRegistryRoutes(api, adapter); registerGlobalAssetRoutes(api); + registerVstRoutes(api, adapter); return api; } diff --git a/packages/studio-server/src/routes/vst.test.ts b/packages/studio-server/src/routes/vst.test.ts new file mode 100644 index 0000000000..e082188421 --- /dev/null +++ b/packages/studio-server/src/routes/vst.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Hono } from "hono"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerVstRoutes } from "./vst"; + +function makeApi(adapter: Record): Hono { + const api = new Hono(); + registerVstRoutes(api, adapter as never); + return api; +} + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function createProjectDir(): string { + const projectDir = mkdtempSync(join(tmpdir(), "hf-vst-test-")); + tempDirs.push(projectDir); + return projectDir; +} + +afterEach(() => { + delete process.env.HF_VST_HOST_CMD; +}); + +/** Points HF_VST_HOST_CMD at a fake sidecar shell script for the duration of a test. */ +function installFakeVstHost(projectDir: string, scriptBody: string): void { + const script = join(projectDir, "fake-vst.sh"); + writeFileSync(script, scriptBody); + chmodSync(script, 0o755); + process.env.HF_VST_HOST_CMD = script; +} + +function postCarve(api: Hono, body: Record): Promise { + return api.request("/vst/carve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** Builds an API that resolves `projectId: "p1"` to `projectDir` and POSTs + * `/vst/carve` against it — the shared shape every carve test below needs. */ +function carveViaProjectDir(projectDir: string, body: Record): Promise { + const api = makeApi({ resolveProject: () => Promise.resolve({ dir: projectDir }) }); + return postCarve(api, { projectId: "p1", ...body }); +} + +describe("vst routes", () => { + it("POST /vst/start returns the sidecar port and token", async () => { + const api = makeApi({ + startVstSidecar: () => Promise.resolve({ port: 9555, token: "secret-token" }), + getVstSidecarStatus: () => ({ running: true, port: 9555 }), + }); + const res = await api.request("/vst/start", { method: "POST" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ port: 9555, token: "secret-token" }); + }); + + it("POST /vst/start returns 503 with install hint when unsupported", async () => { + const api = makeApi({}); + const res = await api.request("/vst/start", { method: "POST" }); + expect(res.status).toBe(503); + const body = await res.json(); + expect(body.installHint).toContain("uv tool install"); + }); + + it("GET /vst/status reflects adapter state", async () => { + const api = makeApi({ getVstSidecarStatus: () => ({ running: false, port: null }) }); + const res = await api.request("/vst/status"); + expect(await res.json()).toEqual({ running: false, port: null }); + }); + + it("POST /vst/carve returns bands from the sidecar", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, "media"), { recursive: true }); + writeFileSync(join(projectDir, "media/music.wav"), "RIFF"); + writeFileSync(join(projectDir, "media/vo.wav"), "RIFF"); + // Fake sidecar: print a fixed bands payload for `carve`. + installFakeVstHost( + projectDir, + `#!/bin/sh\necho '{"bands":[{"freq":1000,"gainDb":-4,"q":1.5},{"freq":2500,"gainDb":-2,"q":1.5}]}'\n`, + ); + + const res = await carveViaProjectDir(projectDir, { + musicPath: "media/music.wav", + voicePath: "media/vo.wav", + maxCutDb: 4, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.bands).toHaveLength(2); + expect(body.bands[0]).toEqual({ freq: 1000, gainDb: -4, q: 1.5 }); + }); + + it("POST /vst/carve 404s when a track file is missing", async () => { + const projectDir = createProjectDir(); + const res = await carveViaProjectDir(projectDir, { + musicPath: "media/nope.wav", + voicePath: "media/nope.wav", + maxCutDb: 4, + }); + expect(res.status).toBe(404); + }); + + it("POST /vst/carve 500s when the sidecar fails", async () => { + const projectDir = createProjectDir(); + mkdirSync(join(projectDir, "media"), { recursive: true }); + writeFileSync(join(projectDir, "media/vo.wav"), "RIFF"); + installFakeVstHost(projectDir, `#!/bin/sh\necho "boom" >&2; exit 1\n`); + const res = await carveViaProjectDir(projectDir, { + musicPath: "media/vo.wav", + voicePath: "media/vo.wav", + maxCutDb: 4, + }); + expect(res.status).toBe(500); + }); +}); diff --git a/packages/studio-server/src/routes/vst.ts b/packages/studio-server/src/routes/vst.ts new file mode 100644 index 0000000000..ddecbf92f1 --- /dev/null +++ b/packages/studio-server/src/routes/vst.ts @@ -0,0 +1,115 @@ +import type { Hono } from "hono"; +import { existsSync } from "node:fs"; +import type { StudioApiAdapter } from "../types.js"; +import { resolveWithinProject } from "../helpers/safePath.js"; +import { runCarve } from "../vstCarve.js"; + +const INSTALL_HINT = "Install the VST host: uv tool install hyperframes-vst-host (requires uv)"; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +interface CarveRequestBody { + projectId: string; + musicPath: string; + voicePath: string; + maxCutDb: number; +} + +/** Narrows an unknown JSON body to the shape `/vst/carve` needs, or `null` if malformed. */ +function parseCarveRequestBody(body: unknown): CarveRequestBody | null { + if (!isRecord(body)) return null; + const { projectId, musicPath, voicePath, maxCutDb } = body; + if ( + typeof projectId !== "string" || + typeof musicPath !== "string" || + typeof voicePath !== "string" || + typeof maxCutDb !== "number" + ) { + return null; + } + return { projectId, musicPath, voicePath, maxCutDb }; +} + +/** Resolves a project-relative path and confirms the file exists on disk, or `null`. */ +function resolveExistingProjectFile(projectDir: string, subPath: string): string | null { + const file = resolveWithinProject(projectDir, subPath); + return file && existsSync(file) ? file : null; +} + +export function registerVstRoutes(api: Hono, adapter: StudioApiAdapter): void { + /** + * The VST sidecar is a native Python process on the same host as this + * server — it reads audio via pedalboard's `AudioFile`, which needs a + * real filesystem path, not the `/preview/*` HTTP URL the browser plays + * the dry `