Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(() => {});
};
Expand All @@ -1169,7 +1180,10 @@ async function runEmbeddedMode(
import("@hyperframes/engine")
.then(({ killTrackedProcesses }) => {
process.once("exit", () => {
if (!shuttingDown) killTrackedProcesses();
if (!shuttingDown) {
killTrackedProcesses();
stopVstSidecar();
}
});
})
.catch(() => {});
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/vst/sidecar.ts
Original file line number Diff line number Diff line change
@@ -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";
6 changes: 6 additions & 0 deletions packages/studio-server/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
}
10 changes: 10 additions & 0 deletions packages/studio-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/studio-server/src/createStudioApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -36,6 +37,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono {
registerFontRoutes(api);
registerRegistryRoutes(api, adapter);
registerGlobalAssetRoutes(api);
registerVstRoutes(api, adapter);

return api;
}
124 changes: 124 additions & 0 deletions packages/studio-server/src/routes/vst.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<string, unknown>): Promise<Response> {
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<string, unknown>): Promise<Response> {
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);
});
});
115 changes: 115 additions & 0 deletions packages/studio-server/src/routes/vst.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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 `<audio>` element from. This resolves a project-relative asset
* path (the part of that URL after `/preview/`) to its absolute path on
* disk, the same way the `/preview/*` static route does, so a client that
* already has a working playback URL can hand the sidecar something it
* can actually open.
*/
api.get("/vst/wav-path", async (c) => {
const projectId = c.req.query("projectId");
const subPath = c.req.query("path");
if (!projectId || !subPath) {
return c.json({ error: "projectId and path query params required" }, 400);
}
const project = await adapter.resolveProject(projectId);
if (!project) return c.json({ error: "not found" }, 404);
const file = resolveWithinProject(project.dir, subPath);
if (!file || !existsSync(file)) return c.json({ error: "not found" }, 404);
return c.json({ path: file });
});

/**
* Analyze a voiceover track and return complementary PeakFilter carve bands
* for a music track (the "vocal pocket"). Both paths are project-relative,
* resolved against the project dir here so no absolute paths cross the
* wire. V1 only analyzes the voice; the music path is validated-to-exist
* for symmetry and future dynamic ducking.
*/
api.post("/vst/carve", async (c) => {
const body: unknown = await c.req.json().catch(() => null);
const parsed = parseCarveRequestBody(body);
if (!parsed) {
return c.json({ error: "projectId, musicPath, voicePath, maxCutDb required" }, 400);
}
const { projectId, musicPath, voicePath, maxCutDb } = parsed;
const project = await adapter.resolveProject(projectId);
if (!project) return c.json({ error: "not found" }, 404);
const musicFile = resolveExistingProjectFile(project.dir, musicPath);
const voiceFile = resolveExistingProjectFile(project.dir, voicePath);
if (!musicFile || !voiceFile) return c.json({ error: "not found" }, 404);
try {
const { bands } = await runCarve(voiceFile, maxCutDb);
return c.json({ bands });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message }, 500);
}
});

api.post("/vst/start", async (c) => {
if (!adapter.startVstSidecar) {
return c.json(
{ error: "VST host not available in this studio mode", installHint: INSTALL_HINT },
503,
);
}
try {
const { port, token } = await adapter.startVstSidecar();
return c.json({ port, token });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return c.json({ error: message, installHint: INSTALL_HINT }, 503);
}
});

api.get("/vst/status", (c) => {
if (!adapter.getVstSidecarStatus) {
return c.json({ running: false, port: null });
}
return c.json(adapter.getVstSidecarStatus());
});
}
Loading
Loading