mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
992224f2e8
Generate a synced read-aloud EPUB from a book's EPUB and audiobook through an optional STAlign worker (Docker on CPU, or native with MLX on Apple Silicon), with review, re-sync, replace in place, restart recovery and live progress on the Files tab. Book details also stack the ebook and audiobook covers when both exist. See docs/READ_ALOUD.md.
129 lines
4.2 KiB
JavaScript
129 lines
4.2 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { startWorker } from "../dist/server.js";
|
|
|
|
export const TOKEN = "test-token-0123456789abcdef";
|
|
export const FAKE_PIPELINE = fileURLToPath(new URL("./fixtures/fake-pipeline.mjs", import.meta.url));
|
|
|
|
/** A throwaway library and data folder under the system temp folder. */
|
|
export function makeSandbox() {
|
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "read-aloud-worker-test-")));
|
|
const library = join(root, "library");
|
|
const book = join(library, "Author", "Book");
|
|
const data = join(root, "data", "read-aloud");
|
|
mkdirSync(book, { recursive: true });
|
|
mkdirSync(join(data, "jobs"), { recursive: true });
|
|
writeFileSync(join(book, "book.epub"), "epub-bytes");
|
|
writeFileSync(join(book, "01.mp3"), "a".repeat(1000));
|
|
writeFileSync(join(book, "02.mp3"), "b".repeat(2000));
|
|
return {
|
|
root,
|
|
library,
|
|
book,
|
|
workRoot: join(data, "jobs"),
|
|
tmpDir: join(data, "tmp"),
|
|
logPath: join(root, "fake-pipeline.jsonl"),
|
|
modelDir: join(root, "models"),
|
|
cleanup: () => rmSync(root, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
export async function startTestWorker(sandbox, options = {}) {
|
|
const config = {
|
|
host: "127.0.0.1",
|
|
port: 0,
|
|
token: TOKEN,
|
|
libraryRoots: options.libraryRoots ?? [sandbox.library],
|
|
workRoot: sandbox.workRoot,
|
|
tmpDir: sandbox.tmpDir,
|
|
configuredDevice: "cpu",
|
|
dtype: null,
|
|
batchSize: null,
|
|
};
|
|
mkdirSync(sandbox.tmpDir, { recursive: true });
|
|
const worker = await startWorker({
|
|
config,
|
|
configuredDevice: "cpu",
|
|
device: "cpu",
|
|
deviceCheck: "not_checked",
|
|
version: "0.0.0-test",
|
|
cancelGraceMs: options.cancelGraceMs ?? 5_000,
|
|
freeBytes: options.freeBytes,
|
|
rootChecks: options.rootChecks,
|
|
runnerEnv: {
|
|
READ_ALOUD_RUNNER_PIPELINE: FAKE_PIPELINE,
|
|
FAKE_PIPELINE_MODE: options.mode ?? "ok",
|
|
FAKE_PIPELINE_LOG: sandbox.logPath,
|
|
...(options.modelDir ? { FAKE_MODEL_DIR: options.modelDir } : {}),
|
|
},
|
|
});
|
|
const base = `http://127.0.0.1:${worker.port}`;
|
|
return {
|
|
worker,
|
|
base,
|
|
async call(method, path, body, token = TOKEN) {
|
|
const response = await fetch(`${base}${path}`, {
|
|
method,
|
|
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
},
|
|
close: () => worker.close(),
|
|
};
|
|
}
|
|
|
|
export function jobRequest(sandbox, overrides = {}) {
|
|
const jobId = overrides.jobId ?? randomUUID();
|
|
return {
|
|
jobId,
|
|
epubPath: join(sandbox.book, "book.epub"),
|
|
audioPaths: [join(sandbox.book, "01.mp3"), join(sandbox.book, "02.mp3")],
|
|
workDir: join(sandbox.workRoot, jobId),
|
|
language: "en",
|
|
granularity: "sentence",
|
|
model: "quartznet15x5",
|
|
sourceBytes: 3010,
|
|
audioDurationSeconds: 120,
|
|
maxOutputBytes: 1_000_000,
|
|
decisions: { chapters: {}, audio: [] },
|
|
resumeFrom: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export async function waitForJob(client, jobId, predicate, timeoutMs = 15_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let last;
|
|
while (Date.now() < deadline) {
|
|
last = await client.call("GET", `/v1/jobs/${jobId}`);
|
|
if (last.status === 200 && predicate(last.body)) return last.body;
|
|
await sleep(25);
|
|
}
|
|
throw new Error(`Timed out waiting for job ${jobId}; last state ${JSON.stringify(last?.body)}`);
|
|
}
|
|
|
|
export function stagesRun(sandbox, jobId) {
|
|
let text = "";
|
|
try {
|
|
text = readFileSync(sandbox.logPath, "utf8");
|
|
} catch {
|
|
return [];
|
|
}
|
|
return text
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line))
|
|
.filter((entry) => entry.jobId === jobId);
|
|
}
|
|
|
|
export function resetStageLog(sandbox) {
|
|
rmSync(sandbox.logPath, { force: true });
|
|
}
|
|
|
|
export const TERMINAL = new Set(["completed", "failed", "cancelled"]);
|