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 519c790..6ea0e1e 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,73 @@ → **Full API reference**: [`docs/API.md`](docs/API.md) +## 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/`. + +Clone with submodules: + +```bash +git clone --recurse-submodules +# or after clone: +git submodule update --init --recursive +``` + +**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" +bun run bundle:acestep # once per machine: fetch ace-lm / ace-synth +bun run daw:build +bun run start +# 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. + +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. + +**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 (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 the **full archive contents** 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, **flattens the full archive** into **`acestep-runtime/bin/`** (every file by basename in one directory — no nested `lib/` tree), compiles `dist/acestep-api`, then copies **`acestep-runtime/`** next to the executable. -The prebuilt archives include executables and all shared libraries needed to run them: +The prebuilt archives include executables and all shared libraries needed to run them. ```text dist/ acestep-api # or acestep-api.exe acestep-runtime/ - bin/ + bin/ # flat: entire prebuild payload ace-lm # 5Hz LM (text + lyrics → audio codes) ace-synth # DiT + VAE (audio codes → audio) ace-server # standalone HTTP server @@ -24,6 +80,7 @@ dist/ quantize # GGUF requantizer libggml*.so / *.dylib # GGML shared libraries (Linux / macOS) *.dll # GGML DLLs (Windows) + (any other files from the release archive) ``` Run the API **from `dist/`** (or anywhere) — the binary resolves siblings via `dirname(execPath)`: @@ -39,25 +96,36 @@ 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. ## Multi-model support (GET /v1/models + per-request `model`) @@ -102,14 +170,12 @@ Generation parameters (`inference_steps`, `guidance_scale`, `bpm`, etc.) are **a ```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" # drop-in GGUFs; roles autodetected bun run start ``` +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 ```bash @@ -128,6 +194,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: @@ -144,6 +220,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:** (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 See [`docs/API.md`](docs/API.md) for the full endpoint reference. **`/format_input`** and **`/create_random_sample`** are shape-compatible stubs (no separate LM HTTP service required). diff --git a/package.json b/package.json index 3842e20..fca6ea4 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 && npx vite build)" }, "devDependencies": { "@types/bun": "latest" diff --git a/scripts/bundle-acestep.ts b/scripts/bundle-acestep.ts index 392e2e9..2cd41f0 100644 --- a/scripts/bundle-acestep.ts +++ b/scripts/bundle-acestep.ts @@ -1,15 +1,16 @@ #!/usr/bin/env bun /** * Downloads acestep.cpp v0.0.3 release binaries for the current OS/arch and - * installs the full archive contents under /acestep-runtime/bin - * (ace-lm, ace-synth, ace-server, ace-understand, neural-codec, mp3-codec, - * quantize, and all shared libraries). + * installs the **full archive** into a **single flat directory**: + * `/acestep-runtime/bin/` (every file by basename — ace-lm, ace-synth, + * ace-server, ace-understand, neural-codec, mp3-codec, quantize, and all + * shared libraries; no nested lib/ tree). * * @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 { join, dirname } from "path"; +import { mkdir, readdir, chmod, rm, copyFile, stat } from "fs/promises"; +import { join, dirname, basename } from "path"; import { existsSync } from "fs"; const TAG = "v0.0.3"; @@ -63,6 +64,41 @@ 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; +} + +/** + * 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 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)); + } + return sources.length; +} + async function main() { const asset = pickAsset(); if (!asset) return; @@ -88,38 +124,39 @@ 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"; - 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 ${extractRoot}`); + throw new Error(`Could not find ${wantLm} / ${wantSynth} under ${packageRoot}`); } - await rm(outBin, { recursive: true, force: true }); - await mkdir(outBin, { recursive: true }); - - // Copy every file from the archive root so that shared libraries - // (libggml*.so / *.dylib / *.dll) and helper binaries are all present. - const installed: string[] = []; - for (const srcPath of all) { - const name = srcPath.split(/[/\\]/).pop() ?? ""; - const destPath = join(outBin, name); - await copyFile(srcPath, destPath); - installed.push(destPath); - } + 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); if (process.platform !== "win32") { - // Make all regular files (not static libs) executable so every binary works. - for (const destPath of installed) { - if (!destPath.endsWith(".a")) { - await chmod(destPath, 0o755); - } + for (const name of await readdir(outBin)) { + if (name.endsWith(".a")) continue; + const p = join(outBin, name); + const st = await stat(p).catch(() => null); + if (st?.isFile()) await chmod(p, 0o755); } } - console.log(`[bundle-acestep] Installed ${installed.length} file(s) to ${outBin}:\n ${installed.map((p) => p.split(/[/\\]/).pop()).join("\n ")}`); + 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 ${n} files into ${outBin}\n` + + ` ${join(outBin, wantLm)}\n` + + ` ${join(outBin, wantSynth)}` + ); } main().catch((e) => { 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/config.ts b/src/config.ts index 171a092..3a1f88f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,13 @@ /** Env-based config. Binaries: https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 */ -import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir, listGgufFiles } from "./paths"; +import { join, resolve } from "path"; +import { scanModelsDirectory, type ModelScanResult } from "./modelScan"; +import { + resolveModelFile, + resolveModelMapPaths, + resolveAcestepBinDir, + toAbsolutePath, + listGgufFiles, +} from "./paths"; function parseModelMap(raw: string): Record { if (!raw.trim()) return {}; @@ -20,6 +28,39 @@ function getModelMapRaw(): Record { return 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), @@ -30,79 +71,99 @@ 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(getModelMapRaw()); + const envMap = resolveModelMapPaths(getModelMapRaw()); + 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() ?? "", - 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), /** * List of available model names shown by GET /v1/models. - * - * Resolution order: - * 1. `ACESTEP_MODEL_MAP` keys — when an explicit name→path map is configured. - * 2. `.gguf` files found in `modelsDir` — discovered at runtime. - * 3. Fallback: `ACESTEP_MODELS` list as-is (no dir to scan), or `[defaultModel]`. - * - * `ACESTEP_MODELS` (comma-separated) acts as a **filter/gate** on the discovered list - * (steps 1 & 2). When set, only names present in that list are returned. + * Uses merged env map + scan logical names, then raw `.gguf` scan, with optional `ACESTEP_MODELS` filter. */ get modelsList(): string[] { const filterRaw = process.env.ACESTEP_MODELS?.trim(); const allowed = filterRaw ? new Set(filterRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null; - // 1. Explicit MODEL_MAP: use map keys - const mapKeys = Object.keys(getModelMapRaw()); - if (mapKeys.length > 0) { - return allowed ? mapKeys.filter((k) => allowed.has(k)) : mapKeys; + const mm = this.modelMap; + const keys = Object.keys(mm).filter((k) => mm[k]?.trim()); + if (keys.length > 0) { + const sorted = [...keys].sort(); + return allowed ? sorted.filter((k) => allowed.has(k)) : sorted; } - // 2. Scan models directory for .gguf files const dir = this.modelsDir; if (dir) { const scanned = listGgufFiles(dir); @@ -111,10 +172,8 @@ export const config = { } } - // 3. Fallback: use ACESTEP_MODELS list directly, or [defaultModel] if (allowed) return [...allowed]; - const def = this.defaultModel; - return def ? [def] : []; + return ["acestep-v15-base", "acestep-v15-turbo"]; }, /** @@ -135,27 +194,51 @@ export const config = { }, /** - * The default model name (used when no `model` is specified per request). - * - * Resolution order: - * 1. `ACESTEP_DEFAULT_MODEL` — explicit override. - * 2. First key of `ACESTEP_MODEL_MAP` — when a map is configured. - * 3. First `.gguf` file in `modelsDir` — when the directory is scanned. - * 4. `"acestep-v15-turbo"` — hardcoded fallback label. + * Default model name when no `model` is specified (lego-safe when base is available). */ get defaultModel(): string { const explicit = process.env.ACESTEP_DEFAULT_MODEL?.trim(); if (explicit) return explicit; - const mapKeys = Object.keys(getModelMapRaw()); - if (mapKeys.length > 0) return mapKeys[0]; + const mm = this.modelMap; + if (mm["acestep-v15-base"]?.trim()) return "acestep-v15-base"; + const envKeys = Object.keys(getModelMapRaw()); + if (envKeys.length > 0) return envKeys[0]!; const dir = this.modelsDir; if (dir) { const scanned = listGgufFiles(dir); - if (scanned.length > 0) return scanned[0]; + const baseFile = scanned.find((f) => /v15-base/i.test(f)); + if (baseFile) return baseFile; + if (scanned.length > 0) return scanned[0]!; } - return "acestep-v15-turbo"; + return "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 new file mode 100644 index 0000000..fd5bb65 --- /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 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, + 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 f7128d0..060c948 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,18 @@ import { mkdir } from "fs/promises"; import { join, resolve } from "path"; import { existsSync } from "fs"; -import { config } from "./config"; +import { config, describeModelAutoconfig } from "./config"; import { requireAuth } from "./auth"; import { jsonRes } from "./res"; 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"; +import { parseFormBoolean } from "./parseBool"; import { isPathWithin } from "./paths"; const AUDIO_PATH_PREFIX = "/"; @@ -131,6 +135,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 = { @@ -152,6 +163,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: { @@ -159,9 +171,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; @@ -170,18 +184,30 @@ 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; + const lm = Boolean(config.lmModelPath?.trim()); const probe = await probeAceSynth(); 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, binary: probe.ok ? "ok" : "unavailable", binary_path: probe.path, binary_hint: probe.hint, @@ -191,11 +217,25 @@ async function handle(req: Request): Promise { 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") { @@ -259,11 +299,19 @@ async function handle(req: Request): Promise { throw e; } - body = mergeMetadata(body); + body = normalizeRepaintingBounds(normalizeDawBody(mergeMetadata(body))); const authErr2 = requireAuth(req.headers.get("Authorization"), body.ai_token as string); if (authErr2) return authErr2; - const sampleMode = Boolean(body.sample_mode ?? body.sampleMode); + 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 = 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); } @@ -378,6 +426,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); } @@ -389,4 +442,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/normalize.ts b/src/normalize.ts index 933baab..e0bfeac 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -1,3 +1,26 @@ +/** + * 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 => { + 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 = -1; + body.repainting_end = -1; + body.repaintingStart = -1; + body.repaintingEnd = -1; + } + 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/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/paths.ts b/src/paths.ts index 2d441ce..5453d4c 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/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 4beeba3..763147a 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -4,7 +4,9 @@ import { join, resolve } from "path"; import { config } from "./config"; import * as store from "./store"; import { mergeMetadata } from "./normalize"; -import { resolveModelFile, resolveReferenceAudioPath, isPathWithin } from "./paths"; +import { parseFormBoolean } from "./parseBool"; +import { resolveModelFile, resolveReferenceAudioPath, toAbsolutePath, isPathWithin } from "./paths"; +import { clampRepaintingToSourceAudio } from "./repaintClamp"; /** API body (snake_case / camelCase) -> acestep.cpp request JSON. */ export function apiToRequestJson(body: Record): Record { @@ -12,13 +14,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 +53,9 @@ export function apiToRequestJson(body: Record): 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; } @@ -114,14 +127,31 @@ 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) { if (config.modelMap[modelName]) return config.modelMap[modelName]; const scanned = config.scannedModelMap; if (scanned[modelName]) return scanned[modelName]; - throw new Error(`Unknown model "${modelName}". Use GET /v1/models to list available models.`); + const known = [...Object.keys(config.modelMap), ...Object.keys(scanned)].sort().join(", ") || "(none)"; + throw new Error( + `Unknown model "${modelName}". Known: ${known}. Use GET /v1/models to list available models.` + ); + } + 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; } @@ -130,12 +160,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)` + ); } } @@ -197,7 +253,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 */ @@ -207,14 +271,22 @@ 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", toAbsolutePath(requestPath), "--lm", toAbsolutePath(lmPath!)], { + taskId, + phase: "ace-lm", + }); } const numbered = await listNumberedRequestJsons(jobDir); @@ -237,25 +309,29 @@ 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) { - const resolvedSrc = resolveReferenceAudioPath(rawSrc); + if (rawSrcForRepaint) { + const resolvedSrc = resolveReferenceAudioPath(rawSrcForRepaint); if ( !isPathWithin(resolvedSrc, resolve(config.tmpDir)) && !isPathWithin(resolvedSrc, resolve(config.audioStorageDir)) ) { - throw new Error( - "Source audio path must be within the configured storage directories" - ); + throw new Error("Source audio path must be within the configured storage directories"); } - synthArgs.push("--src-audio", resolvedSrc); + synthArgs.push("--src-audio", toAbsolutePath(resolvedSrc)); } - 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; @@ -268,7 +344,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/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/config.test.ts b/test/config.test.ts index 993f1d8..c2e354c 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -117,12 +117,15 @@ describe("config modelsList / defaultModel", () => { delete process.env.ACESTEP_MODEL_MAP; }); - test("defaults to [defaultModel] when no map, no dir, no ACESTEP_MODELS", async () => { + test("defaults to logical base+turbo labels when no map, no dir, no ACESTEP_MODELS", async () => { delete process.env.ACESTEP_MODELS; delete process.env.ACESTEP_MODEL_MAP; delete process.env.ACESTEP_DEFAULT_MODEL; + delete process.env.ACESTEP_MODELS_DIR; + delete process.env.ACESTEP_MODEL_PATH; + delete process.env.MODELS_DIR; const { config } = await import("../src/config"); - expect(config.modelsList).toEqual(["acestep-v15-turbo"]); + expect(config.modelsList).toEqual(["acestep-v15-base", "acestep-v15-turbo"]); }); test("ACESTEP_DEFAULT_MODEL is used as the default model name", async () => { @@ -155,10 +158,13 @@ describe("config modelsList / defaultModel", () => { } }); - test("defaultModel falls back to 'acestep-v15-turbo' when nothing is configured", async () => { + test("defaultModel falls back to 'acestep-v15-base' when nothing is configured (lego-safe)", async () => { delete process.env.ACESTEP_DEFAULT_MODEL; delete process.env.ACESTEP_MODEL_MAP; + delete process.env.ACESTEP_MODELS_DIR; + delete process.env.ACESTEP_MODEL_PATH; + delete process.env.MODELS_DIR; const { config } = await import("../src/config"); - expect(config.defaultModel).toBe("acestep-v15-turbo"); + expect(config.defaultModel).toBe("acestep-v15-base"); }); }); diff --git a/test/dawNormalize.test.ts b/test/dawNormalize.test.ts new file mode 100644 index 0000000..fd85472 --- /dev/null +++ b/test/dawNormalize.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeDawBody } from "../src/dawNormalize"; + +describe("normalizeDawBody", () => { + 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"); + }); +}); 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/normalize.test.ts b/test/normalize.test.ts index 7446faf..4b829ac 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("clears to -1 when end equals start", () => { + const b = normalizeRepaintingBounds({ + repainting_start: 0.1, + repainting_end: 0.1, + }); + expect(b.repainting_start).toBe(-1); + expect(b.repainting_end).toBe(-1); + expect(b.repaintingStart).toBe(-1); + expect(b.repaintingEnd).toBe(-1); + }); + + test("clears to -1 when end < start", () => { + const b = normalizeRepaintingBounds({ + repainting_start: 0.5, + repainting_end: 0.2, + }); + expect(b.repainting_start).toBe(-1); + expect(b.repainting_end).toBe(-1); + }); + + 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 }); 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); + }); +}); diff --git a/test/paths.test.ts b/test/paths.test.ts index 7d4a223..0f0dc50 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { resolveModelFile, resolveReferenceAudioPath, isPathWithin } from "../src/paths"; -import { isAbsolute } from "path"; +import { + resolveModelFile, + resolveReferenceAudioPath, + toAbsolutePath, + getResourceRoot, + isPathWithin, +} from "../src/paths"; +import { isAbsolute, resolve } from "path"; import path from "path"; describe("resolveModelFile", () => { @@ -49,6 +55,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(""); 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); + }); +});