TTS-Arena-V2 / apps /web /src /server /admin /test-runner.ts
GitHub Actions
Deploy from github.com/TTS-AGI/TTS-Arena@f49eded
374bcb6
Raw
History Blame Contribute Delete
8.28 kB
/**
* "Test All" runner. An admin kicks off a run; we synthesize one fixed sentence
* for every catalog model, recording pass/fail + latency and writing each clip
* to disk so it can be auditioned while the run is in flight.
*
* Runs in-process in the background (the HF Space is a single Node process — no
* job queue). State lives entirely in the DB (test_runs + test_results) so the
* admin can leave and come back, watch live, and browse history. Resumable: one
* pending row per model is created up front; the loop drains them, and on boot
* any run left "running" (process restarted mid-run) is resumed.
*/
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db, withWriteRetry } from "../db/client";
import { testRuns, testResults } from "../db/schema";
import { getCatalog, ensureModelsSeeded } from "../arena/catalog";
import { synthesize } from "../router-client";
import { errInfo } from "../observability/errors";
const TEST_SENTENCE =
"The quick brown fox jumps over the lazy dog near the riverbank.";
/** Max models synthesized concurrently in a run (bounds memory + provider load). */
const TEST_CONCURRENCY = 16;
const AUDIO_DIR = resolve(
process.env.AUDIO_CACHE_DIR ?? ".audio-cache",
"tests",
);
/** Models currently being processed, to avoid double-running a run. */
const activeRuns = new Set<number>();
function audioFile(runId: number, model: string, ext: string): string {
// model ids can contain slashes? They're arena slugs (safe), but sanitize.
const safe = model.replace(/[^a-zA-Z0-9._-]/g, "_");
return join(AUDIO_DIR, `${runId}-${safe}.${ext}`);
}
/** Relative path stored in the DB + used by the audio route. */
function relAudioPath(runId: number, model: string, ext: string): string {
const safe = model.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${runId}-${safe}.${ext}`;
}
export function testAudioAbsPath(rel: string): string {
// Guard against path traversal — only a bare filename is expected.
const safe = rel.replace(/[/\\]/g, "");
return join(AUDIO_DIR, safe);
}
/**
* Start a new run: snapshot the catalog, create the run + one pending result
* per model, then kick the background drainer. Returns the run id immediately.
*/
export async function startTestRun(startedBy?: string): Promise<number> {
const catalog = await getCatalog();
await ensureModelsSeeded(catalog);
const runId = await withWriteRetry(() =>
db
.insert(testRuns)
.values({
status: "running",
sentence: TEST_SENTENCE,
total: catalog.length,
startedBy: startedBy ?? null,
})
.returning({ id: testRuns.id }),
).then((r) => r[0]!.id);
if (catalog.length > 0) {
await withWriteRetry(() =>
db.insert(testResults).values(
catalog.map((m) => ({
runId,
model: m.id,
modelName: m.name,
provider: m.provider,
status: "pending" as const,
})),
),
);
}
void drainRun(runId);
return runId;
}
/** Synthesize the test sentence for one result row, recording the outcome. */
async function runOne(runId: number, resultId: number): Promise<boolean> {
const row = await db.query.testResults.findFirst({
where: eq(testResults.id, resultId),
});
if (!row) return false;
// Mark running.
await withWriteRetry(() =>
db
.update(testResults)
.set({ status: "running", updatedAt: new Date() })
.where(eq(testResults.id, resultId)),
);
// We need the provider + routerModel; resolve from the live catalog.
const catalog = await getCatalog();
const dto = catalog.find((m) => m.id === row.model);
const start = Date.now();
try {
if (!dto) throw new Error("model no longer in catalog");
const out = await synthesize({
text: TEST_SENTENCE,
provider: dto.provider,
model: dto.routerModel,
});
const rel = relAudioPath(runId, row.model, out.extension);
await mkdir(AUDIO_DIR, { recursive: true });
await writeFile(audioFile(runId, row.model, out.extension), out.audio);
await withWriteRetry(() =>
db
.update(testResults)
.set({
status: "pass",
durationMs: Date.now() - start,
audioPath: rel,
extension: out.extension,
error: null,
updatedAt: new Date(),
})
.where(eq(testResults.id, resultId)),
);
return true;
} catch (err) {
await withWriteRetry(() =>
db
.update(testResults)
.set({
status: "fail",
durationMs: Date.now() - start,
error: errInfo(err).message.slice(0, 1000),
updatedAt: new Date(),
})
.where(eq(testResults.id, resultId)),
);
return false;
}
}
/** Drain all pending/running results for a run, then finalize it. */
async function drainRun(runId: number): Promise<void> {
if (activeRuns.has(runId)) return;
activeRuns.add(runId);
try {
// Reset any "running" rows orphaned by a restart back to pending.
await withWriteRetry(() =>
db
.update(testResults)
.set({ status: "pending" })
.where(
and(eq(testResults.runId, runId), eq(testResults.status, "running")),
),
);
// Snapshot all pending rows and run them with bounded concurrency. Every
// model is synthesized in parallel (up to TEST_CONCURRENCY at a time) rather
// than one-by-one, so a full run finishes in roughly the slowest model's
// time instead of the sum of all of them. The cap keeps peak memory and
// provider load sane on the single-process Space.
const pending = await db
.select({ id: testResults.id })
.from(testResults)
.where(
and(eq(testResults.runId, runId), eq(testResults.status, "pending")),
);
let cursor = 0;
const worker = async () => {
for (;;) {
const i = cursor++;
if (i >= pending.length) return;
await runOne(runId, pending[i]!.id);
await refreshRunCounts(runId);
}
};
const workers = Array.from(
{ length: Math.min(TEST_CONCURRENCY, pending.length) },
() => worker(),
);
await Promise.all(workers);
await finalizeRun(runId);
} finally {
activeRuns.delete(runId);
}
}
/** Recompute completed/passed/failed counts on the run from its results. */
async function refreshRunCounts(runId: number): Promise<void> {
const rows = await db
.select({ status: testResults.status, c: sql<number>`count(*)` })
.from(testResults)
.where(eq(testResults.runId, runId))
.groupBy(testResults.status);
const by = new Map(rows.map((r) => [r.status, r.c]));
const passed = by.get("pass") ?? 0;
const failed = by.get("fail") ?? 0;
await withWriteRetry(() =>
db
.update(testRuns)
.set({ completed: passed + failed, passed, failed })
.where(eq(testRuns.id, runId)),
);
}
async function finalizeRun(runId: number): Promise<void> {
await refreshRunCounts(runId);
await withWriteRetry(() =>
db
.update(testRuns)
.set({ status: "done", finishedAt: new Date() })
.where(eq(testRuns.id, runId)),
);
}
/**
* On boot, resume any run left "running" (the process restarted mid-run). Audio
* written before the restart is gone (temporary by design); rows that were
* mid-flight get re-run. Best-effort.
*/
export async function resumeInterruptedTestRuns(): Promise<void> {
try {
const running = await db
.select({ id: testRuns.id })
.from(testRuns)
.where(eq(testRuns.status, "running"));
for (const r of running) void drainRun(r.id);
} catch {
// Best-effort.
}
}
/** Delete a run's audio files from disk (best-effort). */
export async function deleteRunAudio(runId: number): Promise<void> {
const rows = await db
.select({ audioPath: testResults.audioPath })
.from(testResults)
.where(
and(eq(testResults.runId, runId), inArray(testResults.status, ["pass"])),
);
await Promise.all(
rows
.map((r) => r.audioPath)
.filter((p): p is string => !!p)
.map((p) => rm(testAudioAbsPath(p), { force: true }).catch(() => {})),
);
}