From 35c528236f8e6a03c35e6676752621faab506c63 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:14:46 +0100 Subject: [PATCH 1/9] feat: ACE-Step-DAW submodule, /api prefix, DAW API compat, static UI - Add ACE-Step-DAW git submodule - Strip /api path prefix; model_inventory, init, health fields for DAW - normalizeDawBody, stem_separation 501, query_result progress_text - Serve DAW dist via ACESTEP_DAW_DIST / ACE-Step-DAW/dist - README demo with ACESTEP_MODELS_DIR; CI submodules; test scope ./test Made-with: Cursor --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 2 + .gitmodules | 3 ++ ACE-Step-DAW | 1 + README.md | 50 +++++++++++++++++++++--- package.json | 4 +- src/dawCompat.ts | 54 ++++++++++++++++++++++++++ src/dawNormalize.ts | 43 +++++++++++++++++++++ src/dawStatic.ts | 62 +++++++++++++++++++++++++++++ src/index.ts | 73 +++++++++++++++++++++++++++++++---- src/worker.ts | 7 +++- test/dawNormalize.test.ts | 23 +++++++++++ 12 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 .gitmodules create mode 160000 ACE-Step-DAW create mode 100644 src/dawCompat.ts create mode 100644 src/dawNormalize.ts create mode 100644 src/dawStatic.ts create mode 100644 test/dawNormalize.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00344db..4a82237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ccfcd56..52b6cea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..64feaec --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ACE-Step-DAW"] + path = ACE-Step-DAW + url = https://github.com/ace-step/ACE-Step-DAW.git diff --git a/ACE-Step-DAW b/ACE-Step-DAW new file mode 160000 index 0000000..da9c7e5 --- /dev/null +++ b/ACE-Step-DAW @@ -0,0 +1 @@ +Subproject commit da9c7e522ca8c70f2018a07a4584c0c83d3e35a0 diff --git a/README.md b/README.md index 290dfc6..d6cf447 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,44 @@ [ACE-Step 1.5 HTTP API](https://github.com/ace-step/ACE-Step-1.5/blob/main/docs/en/API.md) proxy backed by [acestep.cpp](https://github.com/audiohacking/acestep.cpp) (`ace-lm` + `ace-synth`). Built with **[Bun](https://bun.sh)**. +## ACE-Step-DAW (submodule + same-origin UI) + +This repo includes **[ACE-Step-DAW](https://github.com/ace-step/ACE-Step-DAW)** as a **git submodule** at `ACE-Step-DAW/`. + +Clone with submodules: + +```bash +git clone --recurse-submodules +# or after clone: +git submodule update --init --recursive +``` + +**Demo (DAW UI + API):** point **`ACESTEP_MODELS_DIR`** at the folder that contains your GGUF files, set the usual model filenames (same as [Models directory](#models-directory-always-via-env) below), bundle binaries once, build the DAW, then start: + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_LM_MODEL=acestep-5Hz-lm-4B-Q8_0.gguf +export ACESTEP_EMBEDDING_MODEL=Qwen3-Embedding-0.6B-Q8_0.gguf +export ACESTEP_DIT_MODEL=acestep-v15-turbo-Q8_0.gguf +export ACESTEP_VAE_MODEL=vae-BF16.gguf +bun run bundle:acestep # once per machine: fetch ace-lm / ace-synth +bun run daw:build +bun run start +# Open http://127.0.0.1:/ (default port from env / config) +``` + +Static files are served from `ACE-Step-DAW/dist` unless you override **`ACESTEP_DAW_DIST`**. + +The DAW’s production client calls **`/api/...`**. This server accepts the **same routes with or without the `/api` prefix** (e.g. `/api/health` and `/health` both work), so you can use the built UI on the **same origin** without Vite’s dev proxy. + +Optional: set backend URL in the DAW to **`http://127.0.0.1:`** (no `/api`) in Settings / `localStorage['ace-step-daw-backend-url']` — then requests go to `/release_task`, `/health`, etc. directly. + +| Env | Purpose | +|-----|---------| +| **`ACESTEP_DAW_DIST`** | Absolute path to a Vite `dist/` folder (defaults to `/ACE-Step-DAW/dist`) | + +**Not supported here:** `task_type: stem_separation` (returns **501** — needs the full Python ACE-Step stack). **`/format_input`** / **`/create_random_sample`** remain stubs for API shape compatibility. + CLI usage matches the upstream [acestep.cpp README](https://github.com/audiohacking/acestep.cpp/blob/master/README.md): **MP3 by default** (128 kbps, overridable), **`--wav`** for stereo 48 kHz WAV, plus optional **`--lora`**, **`--lora-scale`**, **`--vae-chunk`**, **`--vae-overlap`**, **`--mp3-bitrate`**. ## Bundled acestep.cpp binaries (v0.0.3) @@ -55,14 +93,16 @@ Per-request `lm_model_path` and **`ACESTEP_MODEL_MAP`** values use the same reso ```bash bun install bun run bundle:acestep # once: fetch v0.0.3 binaries for this machine -export ACESTEP_MODELS_DIR=... -export ACESTEP_LM_MODEL=... -export ACESTEP_EMBEDDING_MODEL=... -export ACESTEP_DIT_MODEL=... -export ACESTEP_VAE_MODEL=... +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_LM_MODEL=acestep-5Hz-lm-4B-Q8_0.gguf +export ACESTEP_EMBEDDING_MODEL=Qwen3-Embedding-0.6B-Q8_0.gguf +export ACESTEP_DIT_MODEL=acestep-v15-turbo-Q8_0.gguf +export ACESTEP_VAE_MODEL=vae-BF16.gguf bun run start ``` +Change **`ACESTEP_MODELS_DIR`** to your real models directory; filenames must exist under that path (or use absolute paths in the `ACESTEP_*_MODEL` vars per [Models directory](#models-directory-always-via-env)). + ## Build ```bash diff --git a/package.json b/package.json index 3842e20..b799d6d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "build:windows": "bun run bundle:acestep && bun build ./src/index.ts --compile --minify --sourcemap=external --outfile ./dist/acestep-api.exe && bun run sync:runtime", "build:binary-only": "bun build ./src/index.ts --compile --minify --sourcemap=external --outfile ./dist/acestep-api && bun run sync:runtime", "build:all": "bun run build && bun run build:windows", - "test": "bun test" + "test": "bun test ./test", + "daw:install": "(cd ACE-Step-DAW && npm install)", + "daw:build": "(cd ACE-Step-DAW && npm install && npm run build)" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/dawCompat.ts b/src/dawCompat.ts new file mode 100644 index 0000000..3578143 --- /dev/null +++ b/src/dawCompat.ts @@ -0,0 +1,54 @@ +import { basename } from "path"; +import { config } from "./config"; + +/** ACE-Step-DAW `ModelsListResponse` (listModels → /v1/model_inventory or /v1/models). */ +export function modelInventoryData() { + const lmPath = config.lmModelPath; + const lmConfigured = Boolean(lmPath?.trim()); + const lmName = lmConfigured ? basename(lmPath) || "lm" : ""; + + return { + models: config.modelsList.map((name) => ({ + name, + is_default: name === config.defaultModel, + is_loaded: true, + supported_task_types: ["lego", "cover", "repaint"], + })), + default_model: config.defaultModel, + lm_models: lmConfigured ? [{ name: lmName, is_loaded: true }] : [], + loaded_lm_model: lmConfigured ? lmName : null, + llm_initialized: lmConfigured, + }; +} + +/** Stub for DAW Settings “Init model” — acestep-cpp loads GGUF from env, not at runtime. */ +export function initModelResponse(body: Record) { + const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : config.defaultModel; + const initLlm = body.init_llm === true || body.initLlm === true; + const lmPath = + typeof body.lm_model_path === "string" && body.lm_model_path.trim() + ? body.lm_model_path.trim() + : typeof body.lmModelPath === "string" && body.lmModelPath.trim() + ? body.lmModelPath.trim() + : config.lmModelPath; + + const lmConfigured = Boolean(lmPath?.trim()); + const lmName = lmConfigured ? basename(lmPath) : null; + + const models = config.modelsList.map((name) => ({ + name, + is_default: name === config.defaultModel, + is_loaded: true, + supported_task_types: ["lego", "cover", "repaint"], + })); + + return { + message: + "acestep-cpp-api: DiT/LM paths are configured via environment variables; this endpoint is a no-op for UI compatibility.", + loaded_model: model, + loaded_lm_model: initLlm || lmConfigured ? lmName : null, + models, + lm_models: lmConfigured ? [{ name: lmName!, is_loaded: true }] : [], + llm_initialized: lmConfigured, + }; +} diff --git a/src/dawNormalize.ts b/src/dawNormalize.ts new file mode 100644 index 0000000..5038763 --- /dev/null +++ b/src/dawNormalize.ts @@ -0,0 +1,43 @@ +/** + * Map ACE-Step-DAW request fields (FormData / JSON) to AceStep API + acestep.cpp request shape. + * @see https://github.com/ace-step/ACE-Step-DAW — generationPipeline, types/api.ts + */ +export function normalizeDawBody(body: Record): Record { + const out: Record = { ...body }; + const tt = String(out.task_type ?? out.taskType ?? "").toLowerCase(); + + if (tt === "lego") { + const tn = out.track_name ?? out.trackName; + if (tn != null && String(tn).trim() !== "" && !out.lego) { + out.lego = String(tn) + .trim() + .toLowerCase() + .replace(/\s+/g, "_"); + } + const gc = String(out.global_caption ?? out.globalCaption ?? "").trim(); + const pr = String(out.prompt ?? "").trim(); + const cap = String(out.caption ?? "").trim(); + if (!cap) { + out.caption = [gc, pr].filter(Boolean).join("\n") || pr || gc; + } + } + + if (tt === "repaint") { + const gc = String(out.global_caption ?? out.globalCaption ?? "").trim(); + const pr = String(out.prompt ?? "").trim(); + const cap = String(out.caption ?? "").trim(); + if (!cap) { + out.caption = gc || pr; + } + } + + if (tt === "cover") { + const cap = String(out.caption ?? "").trim(); + if (!cap) { + const gc = String(out.global_caption ?? out.globalCaption ?? "").trim(); + if (gc) out.caption = gc; + } + } + + return out; +} diff --git a/src/dawStatic.ts b/src/dawStatic.ts new file mode 100644 index 0000000..35f2e77 --- /dev/null +++ b/src/dawStatic.ts @@ -0,0 +1,62 @@ +import { existsSync } from "fs"; +import { extname, join, normalize, resolve } from "path"; +import { getResourceRoot } from "./paths"; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".map": "application/json", + ".wasm": "application/wasm", +}; + +/** Vite build output directory for ACE-Step-DAW submodule. */ +export function dawDistRoot(): string { + const env = process.env.ACESTEP_DAW_DIST?.trim(); + if (env) return resolve(env); + return join(getResourceRoot(), "ACE-Step-DAW", "dist"); +} + +/** + * Serve a file from the DAW `dist/` folder, or `index.html` for SPA routes. + * Returns null if dist is missing or path escapes root. + */ +export async function tryServeDawStatic(pathname: string): Promise { + const root = normalize(dawDistRoot()); + if (!existsSync(root)) return null; + + let rel = decodeURIComponent(pathname).replace(/^\/+/, ""); + if (rel.includes("..")) return null; + + const candidate = normalize(join(root, rel)); + if (!candidate.startsWith(root)) return null; + + let filePath = candidate; + let file = Bun.file(filePath); + if (!(await file.exists())) { + filePath = join(root, "index.html"); + file = Bun.file(filePath); + if (!(await file.exists())) return null; + } + + const ext = extname(filePath).toLowerCase(); + const type = MIME[ext] ?? "application/octet-stream"; + return new Response(file, { + headers: { + "Content-Type": type, + "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable", + }, + }); +} diff --git a/src/index.ts b/src/index.ts index 8024c68..b2cb2f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,9 @@ import * as store from "./store"; import * as queue from "./queue"; import { generateTaskId } from "./worker"; import { mergeMetadata, parseParamObj } from "./normalize"; +import { normalizeDawBody } from "./dawNormalize"; +import { modelInventoryData, initModelResponse } from "./dawCompat"; +import { tryServeDawStatic, dawDistRoot } from "./dawStatic"; const AUDIO_PATH_PREFIX = "/"; @@ -110,6 +113,13 @@ const SAMPLE_CUSTOM = { vocal_language: "en", } as const; +function progressTextForTask(t: store.Task | undefined): string { + if (!t) return ""; + if (t.status === 0) return t.queue_position != null ? "Queued…" : "Generating…"; + if (t.status === 1) return "Complete"; + return t.error ?? "Failed"; +} + function queryResultRow(taskId: string, t: store.Task | undefined) { if (!t) { const fail = { @@ -131,6 +141,7 @@ function queryResultRow(taskId: string, t: store.Task | undefined) { status: 2 as const, result: JSON.stringify([fail]), error: "unknown task_id", + progress_text: "", }; } const row: { @@ -138,9 +149,11 @@ function queryResultRow(taskId: string, t: store.Task | undefined) { status: number; result?: string; error?: string; + progress_text: string; } = { task_id: t.task_id, status: t.status, + progress_text: progressTextForTask(t), }; if (t.status === 1 || t.status === 2) { if (t.result != null) row.result = t.result; @@ -149,24 +162,54 @@ function queryResultRow(taskId: string, t: store.Task | undefined) { return row; } +/** Same-origin DAW uses `fetch('/api/...')`; strip prefix so one server can serve UI + API. */ +function stripApiPathPrefix(pathname: string): string { + if (pathname === "/api") return "/"; + if (pathname.startsWith("/api/")) return pathname.slice(4) || "/"; + return pathname; +} + async function handle(req: Request): Promise { const url = new URL(req.url); - const path = url.pathname; + const path = stripApiPathPrefix(url.pathname); if (path === "/health" && req.method === "GET") { const authErr = requireAuth(req.headers.get("Authorization"), undefined); if (authErr) return authErr; - return jsonRes({ status: "ok", service: "ACE-Step API", version: "1.0" }); + const lm = Boolean(config.lmModelPath?.trim()); + return jsonRes({ + status: "ok", + service: "ACE-Step API", + version: "1.0", + models_initialized: true, + llm_initialized: lm, + loaded_model: config.defaultModel, + loaded_lm_model: lm ? config.lmModelPath : null, + }); } if (path === "/v1/models" && req.method === "GET") { const authErr = requireAuth(req.headers.get("Authorization"), undefined); if (authErr) return authErr; - const models = config.modelsList.map((name) => ({ - name, - is_default: name === config.defaultModel, - })); - return jsonRes({ models, default_model: config.defaultModel }); + return jsonRes(modelInventoryData()); + } + + if (path === "/v1/model_inventory" && req.method === "GET") { + const authErr = requireAuth(req.headers.get("Authorization"), undefined); + if (authErr) return authErr; + return jsonRes(modelInventoryData()); + } + + if (path === "/v1/init" && req.method === "POST") { + let body: Record = {}; + try { + body = (await req.json()) as Record; + } catch { + return detailRes("Invalid JSON body", 400); + } + const authErr2 = requireAuth(req.headers.get("Authorization"), body.ai_token as string); + if (authErr2) return authErr2; + return jsonRes(initModelResponse(body)); } if (path === "/v1/stats" && req.method === "GET") { @@ -227,10 +270,18 @@ async function handle(req: Request): Promise { throw e; } - body = mergeMetadata(body); + body = normalizeDawBody(mergeMetadata(body)); const authErr2 = requireAuth(req.headers.get("Authorization"), body.ai_token as string); if (authErr2) return authErr2; + const taskTypeEarly = getTaskType(body); + if (taskTypeEarly === "stem_separation") { + return detailRes( + "task_type stem_separation is not supported by acestep-cpp-api (requires full ACE-Step Python server)", + 501 + ); + } + const sampleMode = Boolean(body.sample_mode ?? body.sampleMode); if (!hasTextPrompt(body) && !sampleMode) { return detailRes("prompt, caption, or sample_query is required (or enable sample_mode)", 400); @@ -346,6 +397,11 @@ async function handle(req: Request): Promise { return jsonRes(data); } + if (req.method === "GET") { + const staticRes = await tryServeDawStatic(path); + if (staticRes) return staticRes; + } + return detailRes("Not Found", 404); } @@ -358,3 +414,4 @@ const server = Bun.serve({ console.log(`acestep-cpp-api listening on http://${server.hostname}:${server.port}`); console.log(` acestep binaries: ${config.acestepBinDir}`); if (config.modelsDir) console.log(` ACESTEP_MODELS_DIR: ${config.modelsDir}`); +console.log(` ACE-Step-DAW static (if built): ${dawDistRoot()}`); diff --git a/src/worker.ts b/src/worker.ts index 8b0ebee..023fa0c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -55,9 +55,14 @@ export function apiToRequestJson(body: Record): Record { + test("lego maps track_name to lego and builds caption", () => { + const b = normalizeDawBody({ + task_type: "lego", + track_name: "Drums 1", + global_caption: "Keep groove", + prompt: "add hi-hats", + }); + expect(b.lego).toBe("drums_1"); + expect(b.caption).toBe("Keep groove\nadd hi-hats"); + }); + + test("cover uses global_caption when caption empty", () => { + const b = normalizeDawBody({ + task_type: "cover", + global_caption: "Jazz ballad", + }); + expect(b.caption).toBe("Jazz ballad"); + }); +}); From 36bf4f4bf39e0c86aaf6a5400b0a8fbb406a7cf3 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:20:55 +0100 Subject: [PATCH 2/9] =?UTF-8?q?fix(daw):=20build=20UI=20with=20vite=20only?= =?UTF-8?q?=20=E2=80=94=20no=20submodule=20patches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - daw:run npm install + npx vite build (skip upstream tsc -b) - README: document pristine submodule, optional npm run build inside DAW Made-with: Cursor --- README.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d6cf447..73656e4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Optional: set backend URL in the DAW to **`http://127.0.0.1:`** (no `/api` **Not supported here:** `task_type: stem_separation` (returns **501** — needs the full Python ACE-Step stack). **`/format_input`** / **`/create_random_sample`** remain stubs for API shape compatibility. +**Building the DAW (no submodule edits):** **`bun run daw:build`** runs **`vite build`** inside **`ACE-Step-DAW/`** only. We intentionally do **not** run the submodule’s **`tsc -b`** step here, so vendored **ACE-Step-DAW** stays a pristine upstream checkout while still producing a usable **`dist/`** for this API server. For the full upstream pipeline (typecheck + Vite), run **`npm run build`** inside the submodule yourself when you need it. + CLI usage matches the upstream [acestep.cpp README](https://github.com/audiohacking/acestep.cpp/blob/master/README.md): **MP3 by default** (128 kbps, overridable), **`--wav`** for stereo 48 kHz WAV, plus optional **`--lora`**, **`--lora-scale`**, **`--vae-chunk`**, **`--vae-overlap`**, **`--mp3-bitrate`**. ## Bundled acestep.cpp binaries (v0.0.3) diff --git a/package.json b/package.json index b799d6d..fca6ea4 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:all": "bun run build && bun run build:windows", "test": "bun test ./test", "daw:install": "(cd ACE-Step-DAW && npm install)", - "daw:build": "(cd ACE-Step-DAW && npm install && npm run build)" + "daw:build": "(cd ACE-Step-DAW && npm install && npx vite build)" }, "devDependencies": { "@types/bun": "latest" From 559acbeb40702114a7977fa8e9551051fbb5925d Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:37:32 +0100 Subject: [PATCH 3/9] feat(models): scan ACESTEP_MODELS_DIR, base default, lego DiT + defaults - modelScan: infer LM, embedding, VAE, DiT base/turbo/shift from .gguf names - config: merge ACESTEP_MODEL_MAP with autofill; defaultModel acestep-v15-base; ditModelPath prefers base; legoDitPath for lego-only synth - worker: lego task_type always uses base DiT (acestep.cpp lego.sh); lego.json defaults for inference/guidance/shift; parseFormBoolean for multipart flags - index: describeModelAutoconfig + effective path startup logs - parseBool + tests; modelScan tests; README autoconfig + lego docs Made-with: Cursor --- README.md | 71 ++++++++++++++------ src/config.ts | 144 ++++++++++++++++++++++++++++++++++------- src/dawCompat.ts | 2 +- src/index.ts | 15 ++++- src/modelScan.ts | 73 +++++++++++++++++++++ src/parseBool.ts | 16 +++++ src/worker.ts | 95 +++++++++++++++++++++------ test/modelScan.test.ts | 33 ++++++++++ test/parseBool.test.ts | 25 +++++++ 9 files changed, 406 insertions(+), 68 deletions(-) create mode 100644 src/modelScan.ts create mode 100644 src/parseBool.ts create mode 100644 test/modelScan.test.ts create mode 100644 test/parseBool.test.ts diff --git a/README.md b/README.md index 73656e4..3a8d864 100644 --- a/README.md +++ b/README.md @@ -14,20 +14,32 @@ git clone --recurse-submodules git submodule update --init --recursive ``` -**Demo (DAW UI + API):** point **`ACESTEP_MODELS_DIR`** at the folder that contains your GGUF files, set the usual model filenames (same as [Models directory](#models-directory-always-via-env) below), bundle binaries once, build the DAW, then start: +**Demo (DAW UI + API):** set **`ACESTEP_MODELS_DIR`** to a folder containing the usual Hugging Face / acestep.cpp **`.gguf`** files (flat directory). The server **auto-detects** LM, embedding, VAE, DiT **base**, DiT **turbo**, and turbo+**shift** by filename (see [Models directory](#models-directory-always-via-env)). You can still override any path with **`ACESTEP_LM_MODEL`**, etc. Then bundle binaries, build the DAW, start: ```bash export ACESTEP_MODELS_DIR="$HOME/models/acestep" -export ACESTEP_LM_MODEL=acestep-5Hz-lm-4B-Q8_0.gguf -export ACESTEP_EMBEDDING_MODEL=Qwen3-Embedding-0.6B-Q8_0.gguf -export ACESTEP_DIT_MODEL=acestep-v15-turbo-Q8_0.gguf -export ACESTEP_VAE_MODEL=vae-BF16.gguf bun run bundle:acestep # once per machine: fetch ace-lm / ace-synth bun run daw:build bun run start -# Open http://127.0.0.1:/ (default port from env / config) +# Startup logs list scanned roles + effective paths. ``` +### How to open the DAW UI + +The API and the built DAW share **one HTTP server**. There is no separate “DAW port.” + +1. **`bun run daw:build`** must have run successfully so **`ACE-Step-DAW/dist/index.html`** exists (the log line `ACE-Step-DAW static (if built): …` should point at that folder). +2. Start the server (**`bun run start`** or **`bun run src/index.ts`**). +3. In a browser open the **root URL** of that server — by default: + + **http://127.0.0.1:8001/** + + If you set **`ACESTEP_API_HOST`** / **`ACESTEP_API_PORT`**, use those instead (e.g. `http://127.0.0.1:9000/`). + +The server serves the Vite **`dist/`** for ordinary **`GET`** requests (e.g. `/`, `/assets/…`). Deep links to client routes still work because unknown paths fall back to **`index.html`**. + +If **`GET /`** returns JSON **`Not Found`**, `dist/` is missing or empty — run **`bun run daw:build`** again or set **`ACESTEP_DAW_DIST`** to a folder that contains a production **`index.html`**. + Static files are served from `ACE-Step-DAW/dist` unless you override **`ACESTEP_DAW_DIST`**. The DAW’s production client calls **`/api/...`**. This server accepts the **same routes with or without the `/api` prefix** (e.g. `/api/health` and `/health` both work), so you can use the built UI on the **same origin** without Vite’s dev proxy. @@ -70,40 +82,47 @@ Override layout with **`ACESTEP_APP_ROOT`** (directory that should contain `aces ## Models directory (always via env) -GGUF paths can be **absolute**, **relative to the app root** (`./models/...`), or **bare filenames** resolved under a models directory: +Set **`ACESTEP_MODELS_DIR`** (or **`ACESTEP_MODEL_PATH`** / **`MODELS_DIR`**) to a directory containing **`.gguf`** files. The API **scans that directory** (non-recursive) and assigns: -| Variable | Purpose | -|----------|---------| -| **`ACESTEP_MODELS_DIR`** | Base directory for default LM / embedding / DiT / VAE **filenames** | -| **`ACESTEP_MODEL_PATH`** | Alias (same as above) | -| **`MODELS_DIR`** | Extra alias | +| Detected role | Typical filename hints | +|---------------|-------------------------| +| **LM (5Hz)** | `*5Hz*lm*` / acestep LM gguf | +| **Embedding** | `*Embedding*` (e.g. Qwen3-Embedding) | +| **VAE** | `*vae*` (excluding embedding) | +| **DiT base** | `*v15-base*` — **required for [lego mode](https://github.com/audiohacking/acestep.cpp/blob/master/examples/lego.sh)** (turbo does not support lego) | +| **DiT turbo** | `*v15-turbo*` without `shift` | +| **DiT turbo + shift** | `*v15-turbo*` with `shift` | + +**Overrides (optional):** if set, these win over scan — **`ACESTEP_LM_MODEL`**, **`ACESTEP_EMBEDDING_MODEL`**, **`ACESTEP_DIT_MODEL`**, **`ACESTEP_VAE_MODEL`**. Paths can be **absolute**, **relative to app root**, or **basenames** under the models directory. -Example (paths from [Hugging Face ACE-Step-1.5-GGUF](https://huggingface.co/Serveurperso/ACE-Step-1.5-GGUF)): +**Logical DiT names** (for `model` / DAW picker): auto-filled from scan into **`ACESTEP_MODEL_MAP`** unless you pass your own JSON in **`ACESTEP_MODEL_MAP`**: `acestep-v15-base`, `acestep-v15-turbo`, `acestep-v15-turbo-shift3`. + +- **Default logical model:** **`acestep-v15-base`** (lego-safe). Override with **`ACESTEP_DEFAULT_MODEL`**. +- **Default `model` when none selected:** resolves to **base** DiT if present, else turbo. +- **`task_type: lego`:** always uses **base** DiT, matching [examples/lego.sh](https://github.com/audiohacking/acestep.cpp/blob/master/examples/lego.sh) (phase 2). Request JSON defaults for lego follow [examples/lego.json](https://github.com/audiohacking/acestep.cpp/blob/master/examples/lego.json): **inference_steps 50**, **guidance_scale 1.0**, **shift 1.0** when the client omits them. + +Explicit example (same files as [Hugging Face ACE-Step-1.5-GGUF](https://huggingface.co/Serveurperso/ACE-Step-1.5-GGUF)) — optional if autodetect already finds them: ```bash export ACESTEP_MODELS_DIR="$HOME/models/acestep" export ACESTEP_LM_MODEL=acestep-5Hz-lm-4B-Q8_0.gguf export ACESTEP_EMBEDDING_MODEL=Qwen3-Embedding-0.6B-Q8_0.gguf -export ACESTEP_DIT_MODEL=acestep-v15-turbo-Q8_0.gguf +export ACESTEP_DIT_MODEL=acestep-v15-base-Q8_0.gguf # optional; scan prefers base as default DiT export ACESTEP_VAE_MODEL=vae-BF16.gguf ``` -Per-request `lm_model_path` and **`ACESTEP_MODEL_MAP`** values use the same resolution rules. +Per-request **`lm_model_path`** and **`ACESTEP_MODEL_MAP`** still use the same path resolution rules. ## Run (source) ```bash bun install bun run bundle:acestep # once: fetch v0.0.3 binaries for this machine -export ACESTEP_MODELS_DIR="$HOME/models/acestep" -export ACESTEP_LM_MODEL=acestep-5Hz-lm-4B-Q8_0.gguf -export ACESTEP_EMBEDDING_MODEL=Qwen3-Embedding-0.6B-Q8_0.gguf -export ACESTEP_DIT_MODEL=acestep-v15-turbo-Q8_0.gguf -export ACESTEP_VAE_MODEL=vae-BF16.gguf +export ACESTEP_MODELS_DIR="$HOME/models/acestep" # drop-in GGUFs; roles autodetected bun run start ``` -Change **`ACESTEP_MODELS_DIR`** to your real models directory; filenames must exist under that path (or use absolute paths in the `ACESTEP_*_MODEL` vars per [Models directory](#models-directory-always-via-env)). +Add **`ACESTEP_*_MODEL`** overrides only if a file is not detected. For lego, ensure a **`*v15-base*.gguf`** is in that folder (or map it — see [Models directory](#models-directory-always-via-env)). ## Build @@ -123,6 +142,16 @@ bun run build:binary-only # compile only (reuse existing acestep-runtime/) API `audio_format: "wav"` adds **`--wav`** (no `--mp3-bitrate`). +## Generation / subprocess logs + +While a task runs, **`ace-lm`** and **`ace-synth`** **stdout/stderr** are forwarded to the **same terminal** as the API server (each line is interleaved with Bun logs). The server also logs one line per task with parsed flags: `thinking`, `use_format`, `sample_mode`, `needLm`, `lmConfigured`. + +| Variable | Purpose | +|----------|---------| +| **`ACESTEP_QUIET_SUBPROCESS`** | Set to **`1`** to stop inheriting child output (logs are captured only on failure; use for CI or noisy runs). | + +**DAW + multipart note:** form fields like `thinking=false` arrive as the string **`"false"`**. The API parses those explicitly so **`"false"` does not enable** the LM path (unlike `Boolean("false")` in JavaScript). + ## Reference / source audio (cover, repaint, lego) Modes that need a reference or source track (**cover**, **repaint**, **lego**) require one of: diff --git a/src/config.ts b/src/config.ts index edeadbc..7749dd2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,6 @@ /** Env-based config. Binaries: https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 */ +import { join, resolve } from "path"; +import { scanModelsDirectory, type ModelScanResult } from "./modelScan"; import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir } from "./paths"; function parseModelMap(raw: string): Record { @@ -17,6 +19,39 @@ function parseModelMap(raw: string): Record { const modelMapRaw = parseModelMap(process.env.ACESTEP_MODEL_MAP ?? ""); +function modelsDirRaw(): string { + return ( + process.env.ACESTEP_MODELS_DIR?.trim() || + process.env.ACESTEP_MODEL_PATH?.trim() || + process.env.MODELS_DIR?.trim() || + "" + ); +} + +let scanCacheKey = ""; +let scanCache: ModelScanResult = { allGgufs: [] }; + +function getScan(): ModelScanResult { + const raw = modelsDirRaw(); + if (!raw) { + scanCacheKey = ""; + scanCache = { allGgufs: [] }; + return scanCache; + } + const key = resolve(raw); + if (scanCacheKey === key) return scanCache; + scanCacheKey = key; + scanCache = scanModelsDirectory(key); + return scanCache; +} + +function joinIfDir(basename: string | undefined): string { + if (!basename) return ""; + const dir = modelsDirRaw(); + if (!dir) return basename; + return join(resolve(dir), basename); +} + export const config = { host: process.env.ACESTEP_API_HOST ?? "127.0.0.1", port: parseInt(process.env.ACESTEP_API_PORT ?? "8001", 10), @@ -27,49 +62,71 @@ export const config = { return resolveAcestepBinDir(); }, - /** Default LM GGUF (basename OK if `ACESTEP_MODELS_DIR` set). */ + /** LM GGUF: env, else best match in `ACESTEP_MODELS_DIR` (e.g. *5Hz*lm*.gguf). */ get lmModelPath() { - return resolveModelFile(process.env.ACESTEP_LM_MODEL ?? process.env.ACESTEP_LM_MODEL_PATH ?? ""); + const e = process.env.ACESTEP_LM_MODEL ?? process.env.ACESTEP_LM_MODEL_PATH ?? ""; + if (e.trim()) return resolveModelFile(e); + return joinIfDir(getScan().lm); }, get embeddingModelPath() { - return resolveModelFile(process.env.ACESTEP_EMBEDDING_MODEL ?? ""); + const e = process.env.ACESTEP_EMBEDDING_MODEL ?? ""; + if (e.trim()) return resolveModelFile(e); + return joinIfDir(getScan().embedding); }, + /** + * Default DiT when no `model` name: **base** first (lego-safe; see examples/lego.sh). + * Override with ACESTEP_DIT_MODEL. + */ get ditModelPath() { - return resolveModelFile(process.env.ACESTEP_DIT_MODEL ?? process.env.ACESTEP_CONFIG_PATH ?? ""); + const e = process.env.ACESTEP_DIT_MODEL ?? process.env.ACESTEP_CONFIG_PATH ?? ""; + if (e.trim()) return resolveModelFile(e); + const s = getScan(); + if (s.ditBase) return joinIfDir(s.ditBase); + if (s.ditTurbo) return joinIfDir(s.ditTurbo); + return ""; }, get vaeModelPath() { - return resolveModelFile(process.env.ACESTEP_VAE_MODEL ?? ""); + const e = process.env.ACESTEP_VAE_MODEL ?? ""; + if (e.trim()) return resolveModelFile(e); + return joinIfDir(getScan().vae); }, - /** Logical map with paths resolved against `ACESTEP_MODELS_DIR`. */ + /** + * Env `ACESTEP_MODEL_MAP` wins; then autofill `acestep-v15-base` / `acestep-v15-turbo` / `acestep-v15-turbo-shift3` + * from scanned basenames. + */ get modelMap(): Record { - return resolveModelMapPaths(modelMapRaw); + const envMap = resolveModelMapPaths(modelMapRaw); + const dir = modelsDirRaw(); + if (!dir) return envMap; + const abs = resolve(dir); + const s = getScan(); + const out: Record = { ...envMap }; + const add = (logical: string, basename: string | undefined) => { + if (!basename || out[logical]) return; + out[logical] = join(abs, basename); + }; + add("acestep-v15-base", s.ditBase); + add("acestep-v15-turbo", s.ditTurbo); + add("acestep-v15-turbo-shift3", s.ditTurboShift3); + return out; }, - /** Base models directory (informative). */ get modelsDir() { - return ( - process.env.ACESTEP_MODELS_DIR?.trim() || - process.env.ACESTEP_MODEL_PATH?.trim() || - process.env.MODELS_DIR?.trim() || - "" - ); + return modelsDirRaw(); }, - /** MP3 encoder bitrate for ace-synth (default 128 per acestep.cpp README). */ mp3Bitrate: parseInt(process.env.ACESTEP_MP3_BITRATE ?? "128", 10), - /** Optional LoRA for ace-synth (`--lora` / `--lora-scale`). */ get loraPath() { const p = process.env.ACESTEP_LORA?.trim() ?? ""; return p ? resolveModelFile(p) : ""; }, loraScale: parseFloat(process.env.ACESTEP_LORA_SCALE ?? "1.0"), - /** VAE tiling (`--vae-chunk` / `--vae-overlap`). */ vaeChunk: process.env.ACESTEP_VAE_CHUNK?.trim() ?? "", vaeOverlap: process.env.ACESTEP_VAE_OVERLAP?.trim() ?? "", @@ -77,11 +134,54 @@ export const config = { tmpDir: process.env.ACESTEP_TMPDIR ?? "./storage/tmp", queueMaxSize: parseInt(process.env.ACESTEP_QUEUE_MAXSIZE ?? "200", 10), queueWorkers: parseInt(process.env.ACESTEP_QUEUE_WORKERS ?? process.env.ACESTEP_API_WORKERS ?? "1", 10), - modelsList: (process.env.ACESTEP_MODELS ?? "acestep-v15-turbo,acestep-v15-turbo-shift3") - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - defaultModel: process.env.ACESTEP_DEFAULT_MODEL ?? "acestep-v15-turbo", + + /** Logical names present in modelMap (env + scan). */ + get modelsList() { + const env = process.env.ACESTEP_MODELS?.trim(); + if (env) { + return env + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + const mm = config.modelMap; + const order = ["acestep-v15-base", "acestep-v15-turbo", "acestep-v15-turbo-shift3"] as const; + const found = order.filter((k) => Boolean(mm[k]?.trim())); + if (found.length) return [...found]; + return ["acestep-v15-base", "acestep-v15-turbo"]; + }, + + /** Default DiT logical name: **base** for lego compatibility (acestep.cpp examples/lego.sh). */ + get defaultModel() { + return process.env.ACESTEP_DEFAULT_MODEL?.trim() || "acestep-v15-base"; + }, + + /** Resolved path for lego DiT (base only; turbo does not support lego per acestep.cpp examples). */ + get legoDitPath() { + const m = config.modelMap["acestep-v15-base"]; + if (m?.trim()) return m; + return joinIfDir(getScan().ditBase); + }, + avgJobWindow: parseInt(process.env.ACESTEP_AVG_WINDOW ?? "50", 10), avgJobSecondsDefault: parseFloat(process.env.ACESTEP_AVG_JOB_SECONDS ?? "5.0"), }; + +/** Lines to print at startup (model autodetect + effective paths). */ +export function describeModelAutoconfig(): string[] { + const raw = modelsDirRaw(); + if (!raw) { + return [" Model autoconfig: ACESTEP_MODELS_DIR not set (set it to enable scanning)"]; + } + const abs = resolve(raw); + const s = getScan(); + const lines: string[] = [` Model scan directory: ${abs}`]; + lines.push(` LM (5Hz): ${s.lm ?? "— (set ACESTEP_LM_MODEL if missing)"}`); + lines.push(` Embedding: ${s.embedding ?? "—"}`); + lines.push(` VAE: ${s.vae ?? "—"}`); + lines.push(` DiT base (lego): ${s.ditBase ?? "— (required for lego; see acestep.cpp examples/lego.sh)"}`); + lines.push(` DiT turbo: ${s.ditTurbo ?? "—"}`); + lines.push(` DiT turbo + shift: ${s.ditTurboShift3 ?? "—"}`); + lines.push(` Default logical model: ${config.defaultModel} (override: ACESTEP_DEFAULT_MODEL)`); + return lines; +} diff --git a/src/dawCompat.ts b/src/dawCompat.ts index 3578143..fd5bb65 100644 --- a/src/dawCompat.ts +++ b/src/dawCompat.ts @@ -44,7 +44,7 @@ export function initModelResponse(body: Record) { return { message: - "acestep-cpp-api: DiT/LM paths are configured via environment variables; this endpoint is a no-op for UI compatibility.", + "acestep-cpp-api: DiT/LM paths come from ACESTEP_* env vars and/or scanning ACESTEP_MODELS_DIR; this endpoint is a no-op for UI compatibility.", loaded_model: model, loaded_lm_model: initLlm || lmConfigured ? lmName : null, models, diff --git a/src/index.ts b/src/index.ts index b2cb2f9..f2321db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { mkdir } from "fs/promises"; import { join } from "path"; -import { config } from "./config"; +import { config, describeModelAutoconfig } from "./config"; import { requireAuth } from "./auth"; import { jsonRes } from "./res"; import { detailRes } from "./detail"; @@ -11,6 +11,7 @@ import { mergeMetadata, parseParamObj } from "./normalize"; import { normalizeDawBody } from "./dawNormalize"; import { modelInventoryData, initModelResponse } from "./dawCompat"; import { tryServeDawStatic, dawDistRoot } from "./dawStatic"; +import { parseFormBoolean } from "./parseBool"; const AUDIO_PATH_PREFIX = "/"; @@ -282,7 +283,7 @@ async function handle(req: Request): Promise { ); } - const sampleMode = Boolean(body.sample_mode ?? body.sampleMode); + const sampleMode = parseFormBoolean(body.sample_mode ?? body.sampleMode, false); if (!hasTextPrompt(body) && !sampleMode) { return detailRes("prompt, caption, or sample_query is required (or enable sample_mode)", 400); } @@ -413,5 +414,13 @@ const server = Bun.serve({ console.log(`acestep-cpp-api listening on http://${server.hostname}:${server.port}`); console.log(` acestep binaries: ${config.acestepBinDir}`); -if (config.modelsDir) console.log(` ACESTEP_MODELS_DIR: ${config.modelsDir}`); +for (const line of describeModelAutoconfig()) console.log(line); +if (config.modelsDir) { + console.log(` Effective LM path: ${config.lmModelPath || "(none)"}`); + console.log(` Effective embedding path: ${config.embeddingModelPath || "(none)"}`); + console.log(` Effective DiT (default): ${config.ditModelPath || "(none)"}`); + console.log(` Effective VAE path: ${config.vaeModelPath || "(none)"}`); + console.log(` Lego DiT (base): ${config.legoDitPath || "(none)"}`); + console.log(` Logical models: ${config.modelsList.join(", ")}`); +} console.log(` ACE-Step-DAW static (if built): ${dawDistRoot()}`); diff --git a/src/modelScan.ts b/src/modelScan.ts new file mode 100644 index 0000000..0019ecf --- /dev/null +++ b/src/modelScan.ts @@ -0,0 +1,73 @@ +/** + * Scan a flat directory of .gguf files and infer acestep.cpp roles + * (see https://github.com/audiohacking/acestep.cpp examples + README). + */ +import { existsSync, readdirSync } from "fs"; +import { resolve } from "path"; + +export type ModelScanResult = { + /** Basenames only (exist under scanned dir). */ + lm?: string; + embedding?: string; + vae?: string; + ditBase?: string; + ditTurbo?: string; + ditTurboShift3?: string; + allGgufs: string[]; +}; + +function pickLm(files: string[]): string | undefined { + const by5hzLm = files.find((f) => /5hz.*lm|lm.*5hz/i.test(f)); + if (by5hzLm) return by5hzLm; + return files.find((f) => /acestep.*lm.*\.gguf$/i.test(f)); +} + +function pickEmbedding(files: string[]): string | undefined { + return files.find((f) => /embedding/i.test(f)); +} + +function pickVae(files: string[]): string | undefined { + const candidates = files.filter((f) => { + const l = f.toLowerCase(); + return l.includes("vae") && !l.includes("embedding"); + }); + return candidates.sort((a, b) => a.length - b.length)[0]; +} + +function pickDitBase(files: string[]): string | undefined { + return files.find((f) => /v15-base|v15_base/i.test(f)); +} + +function pickDitTurboShift3(files: string[]): string | undefined { + return files.find((f) => /v15-turbo/i.test(f) && /shift/i.test(f)); +} + +function pickDitTurbo(files: string[]): string | undefined { + return files.find((f) => /v15-turbo/i.test(f) && !/shift/i.test(f)); +} + +/** + * List *.gguf in `absDir` (non-recursive) and assign best-effort roles. + */ +export function scanModelsDirectory(absDir: string): ModelScanResult { + const dir = resolve(absDir); + if (!existsSync(dir)) { + return { allGgufs: [] }; + } + const files = readdirSync(dir).filter((name) => name.endsWith(".gguf")); + if (!files.length) { + return { allGgufs: [] }; + } + const shift3 = pickDitTurboShift3(files); + const turbo = pickDitTurbo(files); + const base = pickDitBase(files); + return { + allGgufs: files, + lm: pickLm(files), + embedding: pickEmbedding(files), + vae: pickVae(files), + ditBase: base, + ditTurbo: turbo, + ditTurboShift3: shift3, + }; +} diff --git a/src/parseBool.ts b/src/parseBool.ts new file mode 100644 index 0000000..befd1e9 --- /dev/null +++ b/src/parseBool.ts @@ -0,0 +1,16 @@ +/** + * Multipart/form and URL-encoded bodies send booleans as strings. + * `Boolean("false")` is true in JS — use this for API flags instead. + */ +export function parseFormBoolean(value: unknown, defaultValue = false): boolean { + if (value === true || value === 1) return true; + if (value === false || value === 0) return false; + if (value == null || value === "") return defaultValue; + if (typeof value === "string") { + const s = value.trim().toLowerCase(); + if (s === "true" || s === "1" || s === "yes" || s === "on") return true; + if (s === "false" || s === "0" || s === "no" || s === "off") return false; + return defaultValue; + } + return defaultValue; +} diff --git a/src/worker.ts b/src/worker.ts index 023fa0c..d1bddb4 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,6 +4,7 @@ import { join } from "path"; import { config } from "./config"; import * as store from "./store"; import { mergeMetadata } from "./normalize"; +import { parseFormBoolean } from "./parseBool"; import { resolveModelFile, resolveReferenceAudioPath } from "./paths"; /** API body (snake_case / camelCase) -> acestep.cpp request JSON. */ @@ -12,13 +13,20 @@ export function apiToRequestJson(body: Record): Record (v == null || v === "" ? def : Number(v)); const prompt = str(body.prompt ?? body.caption ?? ""); const lyrics = str(body.lyrics ?? ""); - const useFormat = Boolean(body.use_format ?? body.useFormat ?? body.format ?? false); + const taskType = str(body.task_type ?? body.taskType ?? "text2music").toLowerCase(); + /** examples/lego.json baseline */ + const legoJsonDefaults = taskType === "lego"; + const defaultInference = legoJsonDefaults ? 50 : 8; + const defaultGuidance = legoJsonDefaults ? 1.0 : 7.0; + const defaultShift = legoJsonDefaults ? 1.0 : 3.0; /** API default `thinking` is false (ACE-Step 1.5 API.md). */ - const thinking = body.thinking === true; - const sampleMode = Boolean(body.sample_mode ?? body.sampleMode ?? false); + const thinking = parseFormBoolean(body.thinking, false); + const sampleMode = parseFormBoolean(body.sample_mode ?? body.sampleMode, false); const batchSize = Math.min(8, Math.max(1, num(body.batch_size ?? body.batchSize, 2))); const seed = num(body.seed, -1); - const useRandomSeed = body.use_random_seed !== false && body.useRandomSeed !== false; + /** Default on; multipart `"false"` must not stay truthy. */ + const useRandomSeed = + parseFormBoolean(body.use_random_seed, true) && parseFormBoolean(body.useRandomSeed, true); let audioCodes = ""; const ac = body.audio_code_string ?? body.audioCodeString; @@ -44,9 +52,9 @@ export function apiToRequestJson(body: Record): Record): Record): Record, reqJson: Record): boolean { const codes = String(reqJson.audio_codes ?? "").trim(); if (codes.length > 0) return false; - const thinking = Boolean(body.thinking ?? false); - const useFormat = Boolean(body.use_format ?? body.useFormat ?? body.format ?? false); - const sampleMode = Boolean(body.sample_mode ?? body.sampleMode ?? false); + const thinking = parseFormBoolean(body.thinking, false); + const useFormat = parseFormBoolean(body.use_format ?? body.useFormat ?? body.format, false); + const sampleMode = parseFormBoolean(body.sample_mode ?? body.sampleMode, false); return thinking || useFormat || sampleMode; } @@ -110,12 +117,27 @@ export function resolveLmPath(body: Record): string { } export function resolveDitPath(body: Record): string { + const taskType = String(body.task_type ?? body.taskType ?? "").toLowerCase(); + /** Lego phase must use DiT base; turbo/SFT do not support lego (acestep.cpp examples/lego.sh). */ + if (taskType === "lego") { + const basePath = config.legoDitPath; + if (basePath) return basePath; + throw new Error( + 'Lego requires acestep-v15-base DiT (*.gguf with "v15-base" in the name). Add it to ACESTEP_MODELS_DIR or set ACESTEP_MODEL_MAP JSON with "acestep-v15-base": "".' + ); + } + const modelName = typeof body.model === "string" ? body.model.trim() : ""; if (modelName && config.modelMap[modelName]) return config.modelMap[modelName]; if (modelName && !config.modelMap[modelName]) { - throw new Error(`Unknown model "${modelName}". Set ACESTEP_MODEL_MAP JSON or use a configured name.`); + const known = Object.keys(config.modelMap).join(", ") || "(none — check ACESTEP_MODELS_DIR scan)"; + throw new Error(`Unknown model "${modelName}". Known logical names: ${known}`); + } + if (!config.ditModelPath) { + throw new Error( + "No DiT model: set ACESTEP_DIT_MODEL or ACESTEP_MODELS_DIR with a v15-base or v15-turbo .gguf" + ); } - if (!config.ditModelPath) throw new Error("ACESTEP_DIT_MODEL or ACESTEP_CONFIG_PATH not set"); return config.ditModelPath; } @@ -124,12 +146,38 @@ export function resolvedModelName(body: Record): string { return modelName || config.defaultModel; } -async function exec(cwd: string, cmd: string, args: string[]): Promise { - const proc = Bun.spawn([cmd, ...args], { cwd, stdout: "pipe", stderr: "pipe" }); - const [stdout, stderr] = await Promise.all([proc.stdout.text(), proc.stderr.text()]); +async function exec( + cwd: string, + cmd: string, + args: string[], + meta: { taskId: string; phase: "ace-lm" | "ace-synth" } +): Promise { + const quiet = process.env.ACESTEP_QUIET_SUBPROCESS === "1"; + const tag = `[acestep-api] ${meta.taskId} ${meta.phase}`; + if (!quiet) { + const quoted = args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)); + console.log(`${tag} $ ${cmd} ${quoted.join(" ")}`); + } + const proc = Bun.spawn([cmd, ...args], { + cwd, + stdout: quiet ? "pipe" : "inherit", + stderr: quiet ? "pipe" : "inherit", + }); + let captured = ""; + if (quiet) { + const [out, err] = await Promise.all([proc.stdout!.text(), proc.stderr!.text()]); + captured = (err || out).trim(); + } const code = await proc.exited; + if (!quiet && code !== 0) { + console.error(`${tag} exited with code ${code}`); + } if (code !== 0) { - throw new Error(`exit ${code}: ${stderr || stdout}`); + throw new Error( + captured + ? `exit ${code}: ${captured}` + : `exit ${code} (unset ACESTEP_QUIET_SUBPROCESS or set to 0 to stream ace-lm/ace-synth logs)` + ); } } @@ -201,14 +249,19 @@ export async function runPipeline(taskId: string): Promise { const aceSynth = join(binDir, process.platform === "win32" ? "ace-synth.exe" : "ace-synth"); const lmPath = resolveLmPath(body); - const runLm = Boolean(lmPath && shouldRunAceLm(body, reqJson)); + const needLm = shouldRunAceLm(body, reqJson); + const runLm = Boolean(lmPath && needLm); + + console.log( + `[acestep-api] ${taskId} request: thinking=${parseFormBoolean(body.thinking, false)} use_format=${parseFormBoolean(body.use_format ?? body.useFormat ?? body.format, false)} sample_mode=${parseFormBoolean(body.sample_mode ?? body.sampleMode, false)} needLm=${needLm} lmConfigured=${Boolean(lmPath?.trim())}` + ); - if (shouldRunAceLm(body, reqJson) && !lmPath) { + if (needLm && !lmPath) { throw new Error("ACESTEP_LM_MODEL (or lm_model_path) required when thinking, use_format, or sample_mode is enabled"); } if (runLm) { - await exec(jobDir, aceLm, ["--request", requestPath, "--lm", lmPath]); + await exec(jobDir, aceLm, ["--request", requestPath, "--lm", lmPath!], { taskId, phase: "ace-lm" }); } const numbered = await listNumberedRequestJsons(jobDir); @@ -253,7 +306,7 @@ export async function runPipeline(taskId: string): Promise { synthArgs.push("--mp3-bitrate", String(config.mp3Bitrate)); } - await exec(jobDir, aceSynth, synthArgs); + await exec(jobDir, aceSynth, synthArgs, { taskId, phase: "ace-synth" }); const ext = wantWav ? "wav" : "mp3"; let outs = await listSynthOutputs(jobDir, ext); diff --git a/test/modelScan.test.ts b/test/modelScan.test.ts new file mode 100644 index 0000000..b84c878 --- /dev/null +++ b/test/modelScan.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { scanModelsDirectory } from "../src/modelScan"; + +describe("scanModelsDirectory", () => { + test("classifies acestep.cpp-style filenames", () => { + const dir = mkdtempSync(join(tmpdir(), "acestep-models-")); + try { + writeFileSync(join(dir, "acestep-5Hz-lm-4B-Q8_0.gguf"), ""); + writeFileSync(join(dir, "Qwen3-Embedding-0.6B-Q8_0.gguf"), ""); + writeFileSync(join(dir, "vae-BF16.gguf"), ""); + writeFileSync(join(dir, "acestep-v15-base-Q8_0.gguf"), ""); + writeFileSync(join(dir, "acestep-v15-turbo-Q8_0.gguf"), ""); + writeFileSync(join(dir, "acestep-v15-turbo-shift3-Q8_0.gguf"), ""); + + const s = scanModelsDirectory(dir); + expect(s.lm).toBe("acestep-5Hz-lm-4B-Q8_0.gguf"); + expect(s.embedding).toBe("Qwen3-Embedding-0.6B-Q8_0.gguf"); + expect(s.vae).toBe("vae-BF16.gguf"); + expect(s.ditBase).toBe("acestep-v15-base-Q8_0.gguf"); + expect(s.ditTurbo).toBe("acestep-v15-turbo-Q8_0.gguf"); + expect(s.ditTurboShift3).toBe("acestep-v15-turbo-shift3-Q8_0.gguf"); + } finally { + rmSync(dir, { recursive: true }); + } + }); + + test("missing directory returns empty", () => { + expect(scanModelsDirectory(join(tmpdir(), "nonexistent-acestep-xyz")).allGgufs).toEqual([]); + }); +}); diff --git a/test/parseBool.test.ts b/test/parseBool.test.ts new file mode 100644 index 0000000..195dc9a --- /dev/null +++ b/test/parseBool.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { parseFormBoolean } from "../src/parseBool"; + +describe("parseFormBoolean", () => { + test("multipart false string is false", () => { + expect(parseFormBoolean("false", false)).toBe(false); + expect(parseFormBoolean("False", false)).toBe(false); + expect(parseFormBoolean("0", false)).toBe(false); + }); + + test("multipart true string is true", () => { + expect(parseFormBoolean("true", false)).toBe(true); + expect(parseFormBoolean("1", false)).toBe(true); + }); + + test("Boolean() would be wrong for false string", () => { + expect(Boolean("false")).toBe(true); + expect(parseFormBoolean("false", false)).toBe(false); + }); + + test("default when missing", () => { + expect(parseFormBoolean(undefined, false)).toBe(false); + expect(parseFormBoolean(undefined, true)).toBe(true); + }); +}); From fc0a6272427126f6cfafb72826b085b16c850b76 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:42:07 +0100 Subject: [PATCH 4/9] fix(bundle): install full acestep release into acestep-runtime - Copy entire extracted archive (bin + lib + dylibs), not only ace-lm/ace-synth - Flat layouts: move executables and shared libs into bin/; keep lib/ sibling - README: document full dist/acestep-runtime layout Made-with: Cursor --- README.md | 5 +- scripts/bundle-acestep.ts | 98 ++++++++++++++++++++++++++++++++------- 2 files changed, 85 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3a8d864..0037270 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ CLI usage matches the upstream [acestep.cpp README](https://github.com/audiohack ## Bundled acestep.cpp binaries (v0.0.3) -`bun run build` downloads the correct asset from **[acestep.cpp releases v0.0.3](https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3)** for the **current** OS/arch, installs them under `acestep-runtime/bin/`, compiles `dist/acestep-api`, then copies `acestep-runtime` next to the executable: +`bun run build` downloads the correct asset from **[acestep.cpp releases v0.0.3](https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3)** for the **current** OS/arch, unpacks the **entire** release archive into **`acestep-runtime/`** (shared libraries under **`lib/`**, **`bin/ace-lm`** + **`bin/ace-synth`**, and any other bundled files — not just the two executables), compiles `dist/acestep-api`, then copies **`acestep-runtime/`** next to the executable: ```text dist/ @@ -67,6 +67,9 @@ dist/ bin/ ace-lm ace-synth + *.dylib / *.dll / … # as shipped in the release (RPATH expects these) + lib/ # present on some platforms + … ``` Run the API **from `dist/`** (or anywhere) — the binary resolves siblings via `dirname(execPath)`: diff --git a/scripts/bundle-acestep.ts b/scripts/bundle-acestep.ts index 90779c5..345f29c 100644 --- a/scripts/bundle-acestep.ts +++ b/scripts/bundle-acestep.ts @@ -1,12 +1,13 @@ #!/usr/bin/env bun /** * Downloads acestep.cpp v0.0.3 release binaries for the current OS/arch and - * installs them under /acestep-runtime/bin (ace-lm, ace-synth). + * installs the **full** extracted tree under /acestep-runtime/ (bin/, lib/, + * and any other bundled shared libraries — not only ace-lm + ace-synth). * * @see https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 * @see https://github.com/audiohacking/acestep.cpp/blob/master/README.md */ -import { mkdir, readdir, copyFile, chmod, rm } from "fs/promises"; +import { mkdir, readdir, chmod, rm, cp, rename } from "fs/promises"; import { join, dirname } from "path"; import { existsSync } from "fs"; @@ -16,7 +17,7 @@ const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/${TAG}`; const root = join(dirname(import.meta.path), ".."); const cacheDir = join(root, "bundled", ".cache"); -const outBin = join(root, "acestep-runtime", "bin"); +const outRuntime = join(root, "acestep-runtime"); type Asset = { name: string }; @@ -61,6 +62,60 @@ async function extractArchive(archivePath: string, destDir: string): Promise { + const entries = await readdir(extractRoot, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()); + const files = entries.filter((e) => e.isFile()); + if (dirs.length === 1 && files.length === 0) { + return join(extractRoot, dirs[0]!.name); + } + return extractRoot; +} + +/** + * This app resolves binaries at acestep-runtime/bin/ace-lm. If the bundle used a flat + * layout (executables + .dylib/.dll next to each other at package root), move those into bin/. + * Preserves a sibling lib/ directory (common RPATH: @loader_path/../lib). + */ +async function ensureBinLayout(runtimeDir: string, wantLm: string, wantSynth: string): Promise { + const binDir = join(runtimeDir, "bin"); + if (existsSync(join(binDir, wantLm)) && existsSync(join(binDir, wantSynth))) { + return; + } + + await mkdir(binDir, { recursive: true }); + const top = await readdir(runtimeDir, { withFileTypes: true }); + for (const ent of top) { + const src = join(runtimeDir, ent.name); + if (ent.name === "bin") continue; + if (ent.name === "lib" && ent.isDirectory()) continue; + if (!ent.isFile()) continue; + + const n = ent.name; + const lower = n.toLowerCase(); + const move = + n === wantLm || + n === wantSynth || + lower.endsWith(".dylib") || + lower.endsWith(".so") || + lower.endsWith(".dll") || + lower.endsWith(".node"); + if (move) { + await rename(src, join(binDir, n)); + } + } +} + +async function chmodMainBinaries(runtimeDir: string, wantLm: string, wantSynth: string): Promise { + if (process.platform === "win32") return; + const binDir = join(runtimeDir, "bin"); + for (const name of [wantLm, wantSynth]) { + const p = join(binDir, name); + if (existsSync(p)) await chmod(p, 0o755); + } +} + async function main() { const asset = pickAsset(); if (!asset) return; @@ -86,28 +141,37 @@ async function main() { console.log(`[bundle-acestep] Extracting to ${extractRoot}`); await extractArchive(archivePath, extractRoot); - const all = await walkFiles(extractRoot); + const packageRoot = await resolvePackageRoot(extractRoot); + const all = await walkFiles(packageRoot); const wantLm = process.platform === "win32" ? "ace-lm.exe" : "ace-lm"; const wantSynth = process.platform === "win32" ? "ace-synth.exe" : "ace-synth"; - let lm = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantLm); - let synth = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantSynth); + const lm = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantLm); + const synth = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantSynth); if (!lm || !synth) { - throw new Error(`Could not find ${wantLm} / ${wantSynth} under ${extractRoot}`); + throw new Error(`Could not find ${wantLm} / ${wantSynth} under ${packageRoot}`); } - await rm(outBin, { recursive: true, force: true }); - await mkdir(outBin, { recursive: true }); - const outLm = join(outBin, wantLm); - const outSynth = join(outBin, wantSynth); - await copyFile(lm, outLm); - await copyFile(synth, outSynth); + await rm(outRuntime, { recursive: true, force: true }); + console.log(`[bundle-acestep] Copying full bundle ${packageRoot} → ${outRuntime}`); + await cp(packageRoot, outRuntime, { recursive: true }); - if (process.platform !== "win32") { - await chmod(outLm, 0o755); - await chmod(outSynth, 0o755); + await ensureBinLayout(outRuntime, wantLm, wantSynth); + await chmodMainBinaries(outRuntime, wantLm, wantSynth); + + const installed = await walkFiles(outRuntime); + const binDir = join(outRuntime, "bin"); + if (!existsSync(join(binDir, wantLm)) || !existsSync(join(binDir, wantSynth))) { + throw new Error( + `After install, expected ${wantLm} and ${wantSynth} under ${binDir}. ` + + `Open an issue with your archive layout (file names under ${packageRoot}).` + ); } - console.log(`[bundle-acestep] Installed:\n ${outLm}\n ${outSynth}`); + console.log( + `[bundle-acestep] Installed acestep-runtime: ${installed.length} files (bin + lib + deps).\n` + + ` ${join(binDir, wantLm)}\n` + + ` ${join(binDir, wantSynth)}` + ); } main().catch((e) => { From a4a145a3940e04b9db548cfc50ebf5c21c7dd340 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:45:46 +0100 Subject: [PATCH 5/9] fix(bundle): flatten full prebuild into acestep-runtime/bin only - Copy every archive file by basename into a single bin/ directory - Drop lib/ or nested tree layout; detect basename collisions - README: document flat runtime layout Made-with: Cursor --- README.md | 8 ++- scripts/bundle-acestep.ts | 100 ++++++++++++++++---------------------- 2 files changed, 44 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 0037270..ef3841a 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,16 @@ CLI usage matches the upstream [acestep.cpp README](https://github.com/audiohack ## Bundled acestep.cpp binaries (v0.0.3) -`bun run build` downloads the correct asset from **[acestep.cpp releases v0.0.3](https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3)** for the **current** OS/arch, unpacks the **entire** release archive into **`acestep-runtime/`** (shared libraries under **`lib/`**, **`bin/ace-lm`** + **`bin/ace-synth`**, and any other bundled files — not just the two executables), compiles `dist/acestep-api`, then copies **`acestep-runtime/`** next to the executable: +`bun run build` downloads the correct asset from **[acestep.cpp releases v0.0.3](https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3)** for the **current** OS/arch, then copies **every file** from that archive into **one folder**: **`acestep-runtime/bin/`** (flat — `ace-lm`, `ace-synth`, all `.dylib` / `.dll` / `.so`, etc.; no separate `lib/` tree). Then it compiles `dist/acestep-api` and copies **`acestep-runtime/`** next to the executable. ```text dist/ acestep-api # or acestep-api.exe acestep-runtime/ - bin/ + bin/ # entire prebuild payload, single directory ace-lm ace-synth - *.dylib / *.dll / … # as shipped in the release (RPATH expects these) - lib/ # present on some platforms - … + (all other files from the release archive) ``` Run the API **from `dist/`** (or anywhere) — the binary resolves siblings via `dirname(execPath)`: diff --git a/scripts/bundle-acestep.ts b/scripts/bundle-acestep.ts index 345f29c..c5444c7 100644 --- a/scripts/bundle-acestep.ts +++ b/scripts/bundle-acestep.ts @@ -1,14 +1,15 @@ #!/usr/bin/env bun /** * Downloads acestep.cpp v0.0.3 release binaries for the current OS/arch and - * installs the **full** extracted tree under /acestep-runtime/ (bin/, lib/, - * and any other bundled shared libraries — not only ace-lm + ace-synth). + * installs **every file** from the archive into a **single directory**: + * `/acestep-runtime/bin/` (flat — executables, .dylib/.so/.dll, and any + * other payload; no lib/ subtree or nested bin/). * * @see https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 * @see https://github.com/audiohacking/acestep.cpp/blob/master/README.md */ -import { mkdir, readdir, chmod, rm, cp, rename } from "fs/promises"; -import { join, dirname } from "path"; +import { mkdir, readdir, chmod, rm, copyFile } from "fs/promises"; +import { join, dirname, basename } from "path"; import { existsSync } from "fs"; const TAG = "v0.0.3"; @@ -17,7 +18,7 @@ const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/${TAG}`; const root = join(dirname(import.meta.path), ".."); const cacheDir = join(root, "bundled", ".cache"); -const outRuntime = join(root, "acestep-runtime"); +const outBin = join(root, "acestep-runtime", "bin"); type Asset = { name: string }; @@ -74,46 +75,27 @@ async function resolvePackageRoot(extractRoot: string): Promise { } /** - * This app resolves binaries at acestep-runtime/bin/ace-lm. If the bundle used a flat - * layout (executables + .dylib/.dll next to each other at package root), move those into bin/. - * Preserves a sibling lib/ directory (common RPATH: @loader_path/../lib). + * Copy every file from the extracted tree into `outBin` using **basename only** + * (flat layout so loaders find libs next to ace-lm / ace-synth). */ -async function ensureBinLayout(runtimeDir: string, wantLm: string, wantSynth: string): Promise { - const binDir = join(runtimeDir, "bin"); - if (existsSync(join(binDir, wantLm)) && existsSync(join(binDir, wantSynth))) { - return; - } - - await mkdir(binDir, { recursive: true }); - const top = await readdir(runtimeDir, { withFileTypes: true }); - for (const ent of top) { - const src = join(runtimeDir, ent.name); - if (ent.name === "bin") continue; - if (ent.name === "lib" && ent.isDirectory()) continue; - if (!ent.isFile()) continue; - - const n = ent.name; - const lower = n.toLowerCase(); - const move = - n === wantLm || - n === wantSynth || - lower.endsWith(".dylib") || - lower.endsWith(".so") || - lower.endsWith(".dll") || - lower.endsWith(".node"); - if (move) { - await rename(src, join(binDir, n)); +async function flattenIntoBin(packageRoot: string, outBinDir: string): Promise { + await mkdir(outBinDir, { recursive: true }); + const sources = await walkFiles(packageRoot); + const seen = new Map(); + + for (const src of sources) { + const name = basename(src); + const prev = seen.get(name); + if (prev && prev !== src) { + throw new Error( + `[bundle-acestep] Duplicate basename "${name}" in archive:\n ${prev}\n ${src}\n` + + "Cannot flatten to a single directory; report this layout." + ); } + seen.set(name, src); + await copyFile(src, join(outBinDir, name)); } -} - -async function chmodMainBinaries(runtimeDir: string, wantLm: string, wantSynth: string): Promise { - if (process.platform === "win32") return; - const binDir = join(runtimeDir, "bin"); - for (const name of [wantLm, wantSynth]) { - const p = join(binDir, name); - if (existsSync(p)) await chmod(p, 0o755); - } + return sources.length; } async function main() { @@ -145,32 +127,32 @@ async function main() { const all = await walkFiles(packageRoot); const wantLm = process.platform === "win32" ? "ace-lm.exe" : "ace-lm"; const wantSynth = process.platform === "win32" ? "ace-synth.exe" : "ace-synth"; - const lm = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantLm); - const synth = all.find((p) => (p.split(/[/\\]/).pop() ?? "") === wantSynth); + const lm = all.find((p) => basename(p) === wantLm); + const synth = all.find((p) => basename(p) === wantSynth); if (!lm || !synth) { throw new Error(`Could not find ${wantLm} / ${wantSynth} under ${packageRoot}`); } - await rm(outRuntime, { recursive: true, force: true }); - console.log(`[bundle-acestep] Copying full bundle ${packageRoot} → ${outRuntime}`); - await cp(packageRoot, outRuntime, { recursive: true }); + const runtimeDir = join(root, "acestep-runtime"); + await rm(runtimeDir, { recursive: true, force: true }); + console.log(`[bundle-acestep] Flattening ${packageRoot} → ${outBin}`); + const n = await flattenIntoBin(packageRoot, outBin); - await ensureBinLayout(outRuntime, wantLm, wantSynth); - await chmodMainBinaries(outRuntime, wantLm, wantSynth); + if (process.platform !== "win32") { + for (const name of [wantLm, wantSynth]) { + const p = join(outBin, name); + if (existsSync(p)) await chmod(p, 0o755); + } + } - const installed = await walkFiles(outRuntime); - const binDir = join(outRuntime, "bin"); - if (!existsSync(join(binDir, wantLm)) || !existsSync(join(binDir, wantSynth))) { - throw new Error( - `After install, expected ${wantLm} and ${wantSynth} under ${binDir}. ` + - `Open an issue with your archive layout (file names under ${packageRoot}).` - ); + if (!existsSync(join(outBin, wantLm)) || !existsSync(join(outBin, wantSynth))) { + throw new Error(`After install, missing ${wantLm} or ${wantSynth} under ${outBin}`); } console.log( - `[bundle-acestep] Installed acestep-runtime: ${installed.length} files (bin + lib + deps).\n` + - ` ${join(binDir, wantLm)}\n` + - ` ${join(binDir, wantSynth)}` + `[bundle-acestep] Installed ${n} files into ${outBin}\n` + + ` ${join(outBin, wantLm)}\n` + + ` ${join(outBin, wantSynth)}` ); } From 872348abc63527d986bd85b6dd1e15af84abc6ee Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:48:50 +0100 Subject: [PATCH 6/9] fix(paths): absolute tmp/storage and subprocess args for ace-synth - toAbsolutePath() under getResourceRoot; tmpDir/audioStorageDir always absolute - Pass absolute --request/--lm/--embedding/--dit/--vae/--lora/--src-audio (cwd is job dir) - tests for toAbsolutePath Made-with: Cursor --- src/config.ts | 11 ++++++++--- src/paths.ts | 11 +++++++++++ src/worker.ts | 22 ++++++++++++++++------ test/paths.test.ts | 16 ++++++++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7749dd2..12f9b7e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ /** Env-based config. Binaries: https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 */ import { join, resolve } from "path"; import { scanModelsDirectory, type ModelScanResult } from "./modelScan"; -import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir } from "./paths"; +import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir, toAbsolutePath } from "./paths"; function parseModelMap(raw: string): Record { if (!raw.trim()) return {}; @@ -130,8 +130,13 @@ export const config = { vaeChunk: process.env.ACESTEP_VAE_CHUNK?.trim() ?? "", vaeOverlap: process.env.ACESTEP_VAE_OVERLAP?.trim() ?? "", - audioStorageDir: process.env.ACESTEP_AUDIO_STORAGE ?? "./storage/audio", - tmpDir: process.env.ACESTEP_TMPDIR ?? "./storage/tmp", + /** Always absolute — ace-synth is spawned with `cwd` = job dir; relative JSON paths must not depend on cwd. */ + get audioStorageDir() { + return toAbsolutePath(process.env.ACESTEP_AUDIO_STORAGE ?? "./storage/audio"); + }, + get tmpDir() { + return toAbsolutePath(process.env.ACESTEP_TMPDIR ?? "./storage/tmp"); + }, queueMaxSize: parseInt(process.env.ACESTEP_QUEUE_MAXSIZE ?? "200", 10), queueWorkers: parseInt(process.env.ACESTEP_QUEUE_WORKERS ?? process.env.ACESTEP_API_WORKERS ?? "1", 10), diff --git a/src/paths.ts b/src/paths.ts index d45b6fa..41fd959 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -19,6 +19,17 @@ export function getResourceRoot(): string { return resolve(dirname(here), ".."); } +/** + * Make paths absolute under {@link getResourceRoot} so subprocesses (ace-lm / ace-synth) can open them + * regardless of `cwd`. Relative `ACESTEP_TMPDIR` request JSON paths were breaking when `cwd` was the job dir. + */ +export function toAbsolutePath(pathOrEmpty: string): string { + const p = pathOrEmpty.trim(); + if (!p) return p; + if (isAbsolute(p)) return resolve(p); + return resolve(getResourceRoot(), p); +} + /** Default bundled acestep.cpp binaries (from `scripts/bundle-acestep.ts`). */ export function defaultBundledBinDir(root: string): string { return join(root, "acestep-runtime", "bin"); diff --git a/src/worker.ts b/src/worker.ts index d1bddb4..85a1a5b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -5,7 +5,7 @@ import { config } from "./config"; import * as store from "./store"; import { mergeMetadata } from "./normalize"; import { parseFormBoolean } from "./parseBool"; -import { resolveModelFile, resolveReferenceAudioPath } from "./paths"; +import { resolveModelFile, resolveReferenceAudioPath, toAbsolutePath } from "./paths"; /** API body (snake_case / camelCase) -> acestep.cpp request JSON. */ export function apiToRequestJson(body: Record): Record { @@ -261,7 +261,10 @@ export async function runPipeline(taskId: string): Promise { } if (runLm) { - await exec(jobDir, aceLm, ["--request", requestPath, "--lm", lmPath!], { taskId, phase: "ace-lm" }); + await exec(jobDir, aceLm, ["--request", toAbsolutePath(requestPath), "--lm", toAbsolutePath(lmPath!)], { + taskId, + phase: "ace-lm", + }); } const numbered = await listNumberedRequestJsons(jobDir); @@ -286,14 +289,21 @@ export async function runPipeline(taskId: string): Promise { const synthArgs: string[] = []; const rawSrc = String(body.src_audio_path ?? body.reference_audio_path ?? "").trim(); if (rawSrc) { - synthArgs.push("--src-audio", resolveReferenceAudioPath(rawSrc)); + synthArgs.push("--src-audio", toAbsolutePath(resolveReferenceAudioPath(rawSrc))); } - synthArgs.push("--request", ...numbered); - synthArgs.push("--embedding", embedding, "--dit", ditPath, "--vae", vae); + synthArgs.push("--request", ...numbered.map(toAbsolutePath)); + synthArgs.push( + "--embedding", + toAbsolutePath(embedding), + "--dit", + toAbsolutePath(ditPath), + "--vae", + toAbsolutePath(vae) + ); const lora = config.loraPath; if (lora) { - synthArgs.push("--lora", lora, "--lora-scale", String(config.loraScale)); + synthArgs.push("--lora", toAbsolutePath(lora), "--lora-scale", String(config.loraScale)); } const vc = config.vaeChunk; const vo = config.vaeOverlap; diff --git a/test/paths.test.ts b/test/paths.test.ts index ed1b052..598d806 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { resolveModelFile, resolveReferenceAudioPath } from "../src/paths"; -import { isAbsolute } from "path"; +import { resolveModelFile, resolveReferenceAudioPath, toAbsolutePath, getResourceRoot } from "../src/paths"; +import { isAbsolute, resolve } from "path"; describe("resolveModelFile", () => { const saved: Record = {}; @@ -32,6 +32,18 @@ describe("resolveModelFile", () => { }); }); +describe("toAbsolutePath", () => { + test("relative path becomes absolute under resource root", () => { + const out = toAbsolutePath("storage/tmp/job/request0.json"); + expect(out).toBe(resolve(getResourceRoot(), "storage/tmp/job/request0.json")); + }); + + test("absolute path stays absolute", () => { + const p = process.platform === "win32" ? "C:\\\\x\\\\y.json" : "/x/y.json"; + expect(isAbsolute(toAbsolutePath(p))).toBe(true); + }); +}); + describe("resolveReferenceAudioPath", () => { test("empty string", () => { expect(resolveReferenceAudioPath("")).toBe(""); From 6eea7996621cd2b1b4db726a8a65b0abc3e26600 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:53:21 +0100 Subject: [PATCH 7/9] fix(repaint): normalize invalid repainting_start/end to 0 before enqueue - normalizeRepaintingBounds when end <= start (incl. equal) - release_task pipeline; tests + README Made-with: Cursor --- README.md | 2 ++ src/index.ts | 4 ++-- src/normalize.ts | 22 ++++++++++++++++++++++ test/normalize.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ef3841a..c2a33ec 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ If **`task_type`** is `cover`, `repaint`, or `lego` and neither a path nor an up Worker uses **`src_audio_path`** when set, otherwise **`reference_audio_path`**; a single `--src-audio` is passed to ace-synth. Request JSON already supports **`audio_cover_strength`**, **`repainting_start`** / **`repainting_end`**, and **`lego`** (track name) per [acestep.cpp README](https://github.com/audiohacking/acestep.cpp/blob/master/README.md). +**Repaint bounds:** if both **`repainting_start`** and **`repainting_end`** are set and **`repainting_end` ≤ `repainting_start`** (ace-synth would error), the API normalizes both to **`0`** (auto / full-track) before enqueue. + ## API emulation notes See earlier revisions for full AceStep 1.5 route mapping. **`/format_input`** and **`/create_random_sample`** remain shape-compatible stubs (no separate LM HTTP service). diff --git a/src/index.ts b/src/index.ts index f2321db..3d32867 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { detailRes } from "./detail"; import * as store from "./store"; import * as queue from "./queue"; import { generateTaskId } from "./worker"; -import { mergeMetadata, parseParamObj } from "./normalize"; +import { mergeMetadata, normalizeRepaintingBounds, parseParamObj } from "./normalize"; import { normalizeDawBody } from "./dawNormalize"; import { modelInventoryData, initModelResponse } from "./dawCompat"; import { tryServeDawStatic, dawDistRoot } from "./dawStatic"; @@ -271,7 +271,7 @@ async function handle(req: Request): Promise { throw e; } - body = normalizeDawBody(mergeMetadata(body)); + body = normalizeRepaintingBounds(normalizeDawBody(mergeMetadata(body))); const authErr2 = requireAuth(req.headers.get("Authorization"), body.ai_token as string); if (authErr2) return authErr2; diff --git a/src/normalize.ts b/src/normalize.ts index 933baab..695615c 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -1,3 +1,25 @@ +/** + * acestep.cpp rejects repaint when repainting_end <= repainting_start. + * If both bounds are set and invalid, reset to 0/0 (auto / full-track behavior per API convention). + */ +export function normalizeRepaintingBounds(body: Record): Record { + const toNum = (v: unknown): number | null => { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; + }; + const rs = toNum(body.repainting_start ?? body.repaintingStart); + const re = toNum(body.repainting_end ?? body.repaintingEnd); + if (rs == null || re == null) return body; + if (re <= rs) { + body.repainting_start = 0; + body.repainting_end = 0; + body.repaintingStart = 0; + body.repaintingEnd = 0; + } + return body; +} + /** Flatten AceStep API metas / metadata / user_metadata into the root body. */ export function mergeMetadata(body: Record): Record { const out = { ...body }; diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 7446faf..7fb69ae 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mergeMetadata, parseParamObj } from "../src/normalize"; +import { mergeMetadata, normalizeRepaintingBounds, parseParamObj } from "../src/normalize"; describe("mergeMetadata", () => { test("flattens metas into root", () => { @@ -14,6 +14,43 @@ describe("mergeMetadata", () => { }); }); +describe("normalizeRepaintingBounds", () => { + test("zeros when end equals start (acestep rejects end <= start)", () => { + const b = normalizeRepaintingBounds({ + repainting_start: 0.1, + repainting_end: 0.1, + }); + expect(b.repainting_start).toBe(0); + expect(b.repainting_end).toBe(0); + expect(b.repaintingStart).toBe(0); + expect(b.repaintingEnd).toBe(0); + }); + + test("zeros when end < start", () => { + const b = normalizeRepaintingBounds({ + repainting_start: 0.5, + repainting_end: 0.2, + }); + expect(b.repainting_start).toBe(0); + expect(b.repainting_end).toBe(0); + }); + + test("leaves valid range unchanged", () => { + const b = normalizeRepaintingBounds({ + repainting_start: 0.1, + repainting_end: 0.5, + }); + expect(b.repainting_start).toBe(0.1); + expect(b.repainting_end).toBe(0.5); + }); + + test("ignores when one bound missing", () => { + const b = normalizeRepaintingBounds({ repainting_start: 0.2 }); + expect(b.repainting_start).toBe(0.2); + expect(b.repainting_end).toBeUndefined(); + }); +}); + describe("parseParamObj", () => { test("parses JSON string", () => { expect(parseParamObj('{"duration": 120}')).toEqual({ duration: 120 }); From a969055dece027cb20a42a2eccfbd1c5f27d7492 Mon Sep 17 00:00:00 2001 From: qxip Date: Sat, 21 Mar 2026 14:58:54 +0100 Subject: [PATCH 8/9] fix(repaint): clamp DAW beat ranges to WAV duration for ace-synth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - readWavDurationSeconds + clampRepaintingToSourceAudio in worker before request JSON - Beat heuristic when bounds > file length (60/bpm), then clamp; collapse → -1 - normalizeRepaintingBounds: use -1 not 0 when end <= start - tests + README Made-with: Cursor --- README.md | 2 +- src/audioDuration.ts | 40 +++++++++++++++++++++++++++ src/normalize.ts | 13 ++++----- src/repaintClamp.ts | 55 ++++++++++++++++++++++++++++++++++++++ src/worker.ts | 14 +++++++--- test/audioDuration.test.ts | 45 +++++++++++++++++++++++++++++++ test/normalize.test.ts | 16 +++++------ test/repaintClamp.test.ts | 38 ++++++++++++++++++++++++++ 8 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 src/audioDuration.ts create mode 100644 src/repaintClamp.ts create mode 100644 test/audioDuration.test.ts create mode 100644 test/repaintClamp.test.ts diff --git a/README.md b/README.md index c2a33ec..43089b8 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ If **`task_type`** is `cover`, `repaint`, or `lego` and neither a path nor an up Worker uses **`src_audio_path`** when set, otherwise **`reference_audio_path`**; a single `--src-audio` is passed to ace-synth. Request JSON already supports **`audio_cover_strength`**, **`repainting_start`** / **`repainting_end`**, and **`lego`** (track name) per [acestep.cpp README](https://github.com/audiohacking/acestep.cpp/blob/master/README.md). -**Repaint bounds:** if both **`repainting_start`** and **`repainting_end`** are set and **`repainting_end` ≤ `repainting_start`** (ace-synth would error), the API normalizes both to **`0`** (auto / full-track) before enqueue. +**Repaint bounds:** (1) If both are set and **`repainting_end` ≤ `repainting_start`**, they are cleared to **`-1`** before enqueue. (2) When **`--src-audio`** is a **WAV**, the worker measures its duration and reclamps repaint to **seconds on that file**; values larger than the file length are treated as **beats** using **`bpm`**, then clamped. If the window still collapses (**`end` ≤ `start`**), both are set to **`-1`** so ace-synth does not error on short context clips. ## API emulation notes diff --git a/src/audioDuration.ts b/src/audioDuration.ts new file mode 100644 index 0000000..228001b --- /dev/null +++ b/src/audioDuration.ts @@ -0,0 +1,40 @@ +import { readFile } from "fs/promises"; + +/** Parse a PCM WAV file and return duration in seconds, or null if not a readable WAV. */ +export async function readWavDurationSeconds(filePath: string): Promise { + try { + const buf = await readFile(filePath); + if (buf.length < 44) return null; + if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WAVE") return null; + + let off = 12; + let sampleRate = 44100; + let dataSize = 0; + let bitsPerSample = 16; + let numChannels = 1; + + while (off + 8 <= buf.length) { + const id = buf.toString("ascii", off, off + 4); + const size = buf.readUInt32LE(off + 4); + const chunkStart = off + 8; + if (chunkStart + size > buf.length) break; + + if (id === "fmt ") { + numChannels = buf.readUInt16LE(chunkStart + 2); + sampleRate = buf.readUInt32LE(chunkStart + 4); + bitsPerSample = buf.readUInt16LE(chunkStart + 14); + } else if (id === "data") { + dataSize = size; + break; + } + off = chunkStart + size + (size % 2); + } + + const bytesPerFrame = numChannels * (bitsPerSample / 8); + if (bytesPerFrame <= 0 || sampleRate <= 0 || dataSize <= 0) return null; + const numSamples = dataSize / bytesPerFrame; + return numSamples / sampleRate; + } catch { + return null; + } +} diff --git a/src/normalize.ts b/src/normalize.ts index 695615c..e0bfeac 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -1,6 +1,7 @@ /** - * acestep.cpp rejects repaint when repainting_end <= repainting_start. - * If both bounds are set and invalid, reset to 0/0 (auto / full-track behavior per API convention). + * acestep.cpp rejects repaint when repainting_end <= repainting_start (in resolved seconds). + * If both bounds are set in the request and end <= start, clear to **-1** (ace-synth “unset” default). + * DAW beat vs second mismatches on short audio are fixed in `clampRepaintingToSourceAudio` (worker). */ export function normalizeRepaintingBounds(body: Record): Record { const toNum = (v: unknown): number | null => { @@ -12,10 +13,10 @@ export function normalizeRepaintingBounds(body: Record): Record const re = toNum(body.repainting_end ?? body.repaintingEnd); if (rs == null || re == null) return body; if (re <= rs) { - body.repainting_start = 0; - body.repainting_end = 0; - body.repaintingStart = 0; - body.repaintingEnd = 0; + body.repainting_start = -1; + body.repainting_end = -1; + body.repaintingStart = -1; + body.repaintingEnd = -1; } return body; } diff --git a/src/repaintClamp.ts b/src/repaintClamp.ts new file mode 100644 index 0000000..de2fad2 --- /dev/null +++ b/src/repaintClamp.ts @@ -0,0 +1,55 @@ +import { readWavDurationSeconds } from "./audioDuration"; + +/** + * Core math: ace-synth uses **seconds** on source audio; DAW may send **beat** positions. + * Only adjusts when **both** bounds are >= 0 (explicit window). Otherwise leaves values as-is. + * @returns repainting_start / repainting_end for request JSON (-1 = unset / full auto). + */ +export function clampRepaintingSeconds( + rsIn: number, + reIn: number, + sourceDuration: number, + bpm: number +): { start: number; end: number } { + if (!(sourceDuration > 0)) return { start: rsIn, end: reIn }; + + const rs = Number.isFinite(rsIn) ? rsIn : -1; + const re = Number.isFinite(reIn) ? reIn : -1; + + if (rs < 0 || re < 0) return { start: rs, end: re }; + + let rsSec = rs; + let reSec = re; + const spb = bpm > 0 ? 60 / bpm : 0; + if (spb > 0 && (rsSec > sourceDuration || reSec > sourceDuration)) { + rsSec *= spb; + reSec *= spb; + } + + rsSec = Math.max(0, Math.min(rsSec, sourceDuration)); + reSec = Math.max(0, Math.min(reSec, sourceDuration)); + + if (reSec <= rsSec) return { start: -1, end: -1 }; + return { start: rsSec, end: reSec }; +} + +/** + * ace-synth interprets repainting_start/end as **seconds along --src-audio**. + * Short context WAV + DAW beat coordinates → both clamp to clip end → engine error; we fix here. + */ +export async function clampRepaintingToSourceAudio( + reqJson: Record, + sourceAudioPath: string, + body: Record +): Promise { + const duration = await readWavDurationSeconds(sourceAudioPath); + if (duration == null || !(duration > 0)) return; + + const rs0 = Number(reqJson.repainting_start); + const re0 = Number(reqJson.repainting_end); + const bpm = Number(body.bpm ?? reqJson.bpm ?? 0); + + const { start, end } = clampRepaintingSeconds(rs0, re0, duration, bpm); + reqJson.repainting_start = start; + reqJson.repainting_end = end; +} diff --git a/src/worker.ts b/src/worker.ts index 85a1a5b..850ce4d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,6 +6,7 @@ import * as store from "./store"; import { mergeMetadata } from "./normalize"; import { parseFormBoolean } from "./parseBool"; import { resolveModelFile, resolveReferenceAudioPath, toAbsolutePath } from "./paths"; +import { clampRepaintingToSourceAudio } from "./repaintClamp"; /** API body (snake_case / camelCase) -> acestep.cpp request JSON. */ export function apiToRequestJson(body: Record): Record { @@ -239,7 +240,15 @@ export async function runPipeline(taskId: string): Promise { try { await mkdir(jobDir, { recursive: true }); + const rawSrcForRepaint = String(body.src_audio_path ?? body.reference_audio_path ?? "").trim(); const reqJson = apiToRequestJson(body); + if (rawSrcForRepaint) { + await clampRepaintingToSourceAudio( + reqJson, + toAbsolutePath(resolveReferenceAudioPath(rawSrcForRepaint)), + body + ); + } await writeFile(requestPath, JSON.stringify(reqJson, null, 0)); /** ace-lm: `--request --lm ` per acestep.cpp README */ @@ -287,9 +296,8 @@ export async function runPipeline(taskId: string): Promise { const wantWav = audioFmt === "wav"; const synthArgs: string[] = []; - const rawSrc = String(body.src_audio_path ?? body.reference_audio_path ?? "").trim(); - if (rawSrc) { - synthArgs.push("--src-audio", toAbsolutePath(resolveReferenceAudioPath(rawSrc))); + if (rawSrcForRepaint) { + synthArgs.push("--src-audio", toAbsolutePath(resolveReferenceAudioPath(rawSrcForRepaint))); } synthArgs.push("--request", ...numbered.map(toAbsolutePath)); synthArgs.push( diff --git a/test/audioDuration.test.ts b/test/audioDuration.test.ts new file mode 100644 index 0000000..d9e065a --- /dev/null +++ b/test/audioDuration.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { readWavDurationSeconds } from "../src/audioDuration"; + +/** Minimal mono PCM16 8000 Hz, 80 samples = 0.01 s */ +function tinyWavBytes(): Buffer { + const numChannels = 1; + const sampleRate = 8000; + const bitsPerSample = 16; + const numSamples = 80; + const blockAlign = (numChannels * bitsPerSample) / 8; + const byteRate = sampleRate * blockAlign; + const dataSize = numSamples * blockAlign; + const buf = Buffer.alloc(44 + dataSize); + buf.write("RIFF", 0); + buf.writeUInt32LE(36 + dataSize, 4); + buf.write("WAVE", 8); + buf.write("fmt ", 12); + buf.writeUInt32LE(16, 16); + buf.writeUInt16LE(1, 20); + buf.writeUInt16LE(numChannels, 22); + buf.writeUInt32LE(sampleRate, 24); + buf.writeUInt32LE(byteRate, 28); + buf.writeUInt16LE(blockAlign, 32); + buf.writeUInt16LE(bitsPerSample, 34); + buf.write("data", 36); + buf.writeUInt32LE(dataSize, 40); + return buf; +} + +describe("readWavDurationSeconds", () => { + test("reads duration from tiny wav", async () => { + const dir = mkdtempSync(join(tmpdir(), "acestep-wav-")); + try { + const p = join(dir, "t.wav"); + writeFileSync(p, tinyWavBytes()); + const d = await readWavDurationSeconds(p); + expect(d).toBeCloseTo(0.01, 5); + } finally { + rmSync(dir, { recursive: true }); + } + }); +}); diff --git a/test/normalize.test.ts b/test/normalize.test.ts index 7fb69ae..4b829ac 100644 --- a/test/normalize.test.ts +++ b/test/normalize.test.ts @@ -15,24 +15,24 @@ describe("mergeMetadata", () => { }); describe("normalizeRepaintingBounds", () => { - test("zeros when end equals start (acestep rejects end <= start)", () => { + test("clears to -1 when end equals start", () => { const b = normalizeRepaintingBounds({ repainting_start: 0.1, repainting_end: 0.1, }); - expect(b.repainting_start).toBe(0); - expect(b.repainting_end).toBe(0); - expect(b.repaintingStart).toBe(0); - expect(b.repaintingEnd).toBe(0); + expect(b.repainting_start).toBe(-1); + expect(b.repainting_end).toBe(-1); + expect(b.repaintingStart).toBe(-1); + expect(b.repaintingEnd).toBe(-1); }); - test("zeros when end < start", () => { + test("clears to -1 when end < start", () => { const b = normalizeRepaintingBounds({ repainting_start: 0.5, repainting_end: 0.2, }); - expect(b.repainting_start).toBe(0); - expect(b.repainting_end).toBe(0); + expect(b.repainting_start).toBe(-1); + expect(b.repainting_end).toBe(-1); }); test("leaves valid range unchanged", () => { diff --git a/test/repaintClamp.test.ts b/test/repaintClamp.test.ts new file mode 100644 index 0000000..6be7ed2 --- /dev/null +++ b/test/repaintClamp.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { clampRepaintingSeconds } from "../src/repaintClamp"; + +describe("clampRepaintingSeconds", () => { + test("DAW beats 4–8 on 0.1s clip: after beat→sec still collapses → -1", () => { + const d = 0.1; + const bpm = 120; + const { start, end } = clampRepaintingSeconds(4, 8, d, bpm); + expect(start).toBe(-1); + expect(end).toBe(-1); + }); + + test("seconds 4–8 on 10s clip unchanged after clamp", () => { + const { start, end } = clampRepaintingSeconds(4, 8, 10, 120); + expect(start).toBe(4); + expect(end).toBe(8); + }); + + test("beats 4–8 on 10s at 120bpm → 2s–4s", () => { + const { start, end } = clampRepaintingSeconds(4, 8, 10, 120); + // 4 and 8 are NOT > 10, so no beat conversion + expect(start).toBe(4); + expect(end).toBe(8); + }); + + test("beats 40–80 on 10s at 120bpm converts then clamps", () => { + const { start, end } = clampRepaintingSeconds(40, 80, 10, 120); + // 40>10 → seconds 20, 40 → clamp to 10,10 → invalid → -1 + expect(start).toBe(-1); + expect(end).toBe(-1); + }); + + test("leaves -1 sentinel alone", () => { + const { start, end } = clampRepaintingSeconds(-1, -1, 5, 120); + expect(start).toBe(-1); + expect(end).toBe(-1); + }); +}); From b16a15850feb62e4b3accd20fa98e380c2a37e41 Mon Sep 17 00:00:00 2001 From: Lorenzo Mangani Date: Sat, 21 Mar 2026 16:05:19 +0100 Subject: [PATCH 9/9] Update README to include ACE-Step-DAW details Added information about ACE-Step-DAW as a submodule. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 61f79e6..6ea0e1e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ → **Full API reference**: [`docs/API.md`](docs/API.md) -## ACE-Step-DAW (submodule + same-origin UI) +## ACE-Step-DAW (submodule + same-origin UI) for Acestep.cpp + +![acestep-daw-demo-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/1ef3a031-8a84-4bee-8a29-567f620ffa59) This repo includes **[ACE-Step-DAW](https://github.com/ace-step/ACE-Step-DAW)** as a **git submodule** at `ACE-Step-DAW/`.