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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
hub:
name: hub (ruff + pytest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install the fake engine venv
# Its only dependency is the local SDK, so the hub's integration test
# can spawn a real worker instead of skipping itself.
run: uv sync --python 3.12
working-directory: engines/fake
- name: Install the hub venv
run: uv sync --python 3.12 --group dev
working-directory: hub
- name: Lint
run: uv run ruff check ../hub ../sdk ../engines ../scripts --exclude ../engines/gpt_sovits/vendor
working-directory: hub
- name: Test
# Includes the docs/api drift guard (tests/test_openapi_export.py).
run: uv run pytest -q
working-directory: hub

webui:
name: webui (node)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm run test:web
working-directory: hub

e2e:
name: e2e (playwright)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install the fake engine venv
run: uv sync --python 3.12
working-directory: engines/fake
- name: Install the hub venv
run: uv sync --python 3.12
working-directory: hub
- run: npm ci
working-directory: hub
- run: npx playwright install --with-deps chromium
working-directory: hub
- run: npm run test:e2e
working-directory: hub
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: hub/playwright-report/
retention-days: 7
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ engines/*/vendor/
engines/indextts/model/
.playwright-mcp/
.impeccable/
node_modules/
hub/test-results/
hub/playwright-report/
26 changes: 23 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,23 @@ instructions are in the file's header comment.
The default `config.toml` binds `0.0.0.0`, so other devices on your LAN
(e.g. your iPhone) can open `http://<mac-hostname>:5050` — the WebUI is
responsive and installable as a PWA. Bind `127.0.0.1` instead if you want it
local-only; there is no authentication.
local-only.

**Securing the hub.** Access control is off by default. Anyone who can reach
the port can generate audio and change settings, so if the hub is exposed
beyond a trusted LAN, set a token:

```toml
[hub]
auth_token = "a-long-random-string" # generate: openssl rand -hex 24
```

Every `/api/*` call then needs `Authorization: Bearer <token>`, and the WebUI
asks for it once per browser. Streams that cannot send a header (the SSE feed,
`<audio>` playback, download links) accept `?token=` instead — which puts the
token in the hub's access log, on the same machine that already stores it in
plaintext. The token is editable only in `config.toml`, never through the API
it protects; the WebUI is never shown its value.

## Using it

Expand Down Expand Up @@ -161,10 +177,14 @@ schema as the `/capabilities` response example. Regenerate after changing
an adapter:

```bash
cd engines/<id> && uv run --no-sync python -m tts_hub_sdk.export_openapi \
"$(grep '^module' engine.toml | cut -d'"' -f2)" > ../../docs/api/<id>.openapi.json
uv run --project hub python scripts/export_openapi.py # rewrite every engine's doc
uv run --project hub python scripts/export_openapi.py --check # what CI enforces
```

Adapters import their model libraries lazily, so this needs no engine venv.
A single worker can still be exported by hand from its own directory with
`uv run --no-sync python -m tts_hub_sdk.export_openapi adapter:MyEngine`.

Engines with legacy clients also mount dialect routes — MOSS-TTS-Nano keeps
the upstream multipart `POST /api/generate` (base64 JSON response), included
in its OpenAPI doc.
Expand Down
23 changes: 20 additions & 3 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,21 @@ Hugging Face 下载。每个引擎首次需要几分钟,仅此一次。

默认 `config.toml` 绑定 `0.0.0.0`,局域网内其他设备(比如你的 iPhone)
可直接打开 `http://<mac主机名>:5050`——WebUI 自适应且可作为 PWA 安装。
只想本机访问就改绑 `127.0.0.1`;本项目没有鉴权。
只想本机访问就改绑 `127.0.0.1`。

**访问控制**默认关闭:能连到这个端口的人都能生成音频、改配置。如果 hub 会
暴露到不完全可信的网络,设一个令牌:

```toml
[hub]
auth_token = "一串足够长的随机字符" # 生成:openssl rand -hex 24
```

此后所有 `/api/*` 都需要 `Authorization: Bearer <令牌>`,WebUI 会在每个
浏览器上要求输入一次。发不了请求头的通道(SSE、`<audio>` 播放、下载链接)
改用 `?token=`——代价是令牌会进 hub 的访问日志,而该日志与明文令牌本来就在
同一台机器上。令牌只能在 `config.toml` 里改,不能通过它所保护的 API 修改,
WebUI 也永远看不到它的值。

## 使用

Expand Down Expand Up @@ -151,10 +165,13 @@ curl -X POST localhost:5077/clone \
的真实参数表。改了适配器后重新生成:

```bash
cd engines/<id> && uv run --no-sync python -m tts_hub_sdk.export_openapi \
"$(grep '^module' engine.toml | cut -d'"' -f2)" > ../../docs/api/<id>.openapi.json
uv run --project hub python scripts/export_openapi.py # 重新生成全部引擎文档
uv run --project hub python scripts/export_openapi.py --check # CI 执行的漂移检查
```

适配器都是懒加载模型库,所以这条命令不需要任何引擎 venv。也可以在单个引擎
目录里手动导出:`uv run --no-sync python -m tts_hub_sdk.export_openapi adapter:MyEngine`。

有历史客户端的引擎还挂了方言路由——MOSS-TTS-Nano 保留上游的 multipart
`POST /api/generate`(base64 JSON 响应),也包含在它的 OpenAPI 文档里。

Expand Down
88 changes: 88 additions & 0 deletions hub/e2e/auth.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from "@playwright/test";

import { startHub } from "./hub-fixture.mjs";

const TOKEN = "e2e-secret-token";

test.describe("a hub with no access token", () => {
let hub;
test.beforeAll(async () => { hub = await startHub(); });
test.afterAll(async () => { await hub.stop(); });

test("loads straight into the WebUI", async ({ page }) => {
const problems = [];
page.on("console", msg => { if (msg.type() === "error") problems.push(msg.text()); });
page.on("pageerror", err => problems.push(String(err)));

await page.goto(hub.baseURL);
await expect(page.getByRole("heading", { name: "Engines" })).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
// The engine list arrives over SSE, proving the stream connected.
await expect(page.getByText("Fake (test tones)")).toBeVisible();

// Every page shares the module graph, so visiting them all catches a
// broken import or a missing translation key.
for (const tab of ["Studio", "Voices", "History", "Settings"]) {
await page.getByRole("button", { name: tab }).first().click();
await expect(page.getByRole("heading", { name: tab })).toBeVisible();
}
expect(problems).toEqual([]);
});
});

test.describe("a hub guarded by an access token", () => {
let hub;
test.beforeAll(async () => { hub = await startHub({ hub: { auth_token: TOKEN } }); });
test.afterAll(async () => { await hub.stop(); });

test("serves the shell but gates the API until the token is entered", async ({ page }) => {
await page.goto(hub.baseURL);
const gate = page.getByRole("dialog");
await expect(gate).toBeVisible();
await expect(gate.getByText("Access token required")).toBeVisible();
// Nothing from the API rendered behind the gate.
await expect(page.getByText("Fake (test tones)")).toHaveCount(0);

await page.getByPlaceholder("Access token").fill(TOKEN);
await page.getByRole("button", { name: "Unlock" }).click();

await expect(gate).toHaveCount(0);
await expect(page.getByText("Fake (test tones)")).toBeVisible();
});

test("keeps the token across reloads", async ({ page }) => {
await page.goto(hub.baseURL);
await page.getByPlaceholder("Access token").fill(TOKEN);
await page.getByRole("button", { name: "Unlock" }).click();
await expect(page.getByText("Fake (test tones)")).toBeVisible();

await page.reload();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText("Fake (test tones)")).toBeVisible();
});

test("re-prompts when the stored token is wrong", async ({ page }) => {
await page.goto(hub.baseURL);
await page.evaluate(() => localStorage.setItem("aviary-token", "stale"));
await page.reload();

await expect(page.getByRole("dialog")).toBeVisible();
await page.getByPlaceholder("Access token").fill(TOKEN);
await page.getByRole("button", { name: "Unlock" }).click();
await expect(page.getByText("Fake (test tones)")).toBeVisible();
});

test("authorises audio and log streams that cannot send a header", async ({ page }) => {
await page.goto(hub.baseURL);
await page.getByPlaceholder("Access token").fill(TOKEN);
await page.getByRole("button", { name: "Unlock" }).click();
await expect(page.getByText("Fake (test tones)")).toBeVisible();

const status = await page.evaluate(async () => {
const url = new URL("/api/engines", location.origin);
url.searchParams.set("token", localStorage.getItem("aviary-token"));
return (await fetch(url)).status;
});
expect(status).toBe(200);
});
});
49 changes: 49 additions & 0 deletions hub/e2e/history.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";

import { startHub } from "./hub-fixture.mjs";

const SEEDED = 230; // more than two pages, so paging has to repeat

let hub;
test.beforeAll(async () => { hub = await startHub({ history: SEEDED }); });
test.afterAll(async () => { await hub.stop(); });

test.beforeEach(async ({ page }) => {
await page.goto(hub.baseURL + "#history");
await expect(page.getByRole("heading", { name: "History" })).toBeVisible();
});

const rows = (page) => page.locator(".card").filter({ hasText: "Seeded line" });

test("shows the first page and offers the rest", async ({ page }) => {
await expect(rows(page)).toHaveCount(100);
await expect(page.getByRole("button", { name: "Load 130 more" })).toBeVisible();
// Newest first: the last seeded row is on top, the oldest is not loaded yet.
await expect(page.getByText(`"Seeded line ${SEEDED - 1}"`)).toBeVisible();
await expect(page.getByText('"Seeded line 0"')).toHaveCount(0);
});

test("load more walks to the oldest row without duplicating any", async ({ page }) => {
await page.getByRole("button", { name: "Load 130 more" }).click();
await expect(rows(page)).toHaveCount(200);
await expect(page.getByRole("button", { name: "Load 30 more" })).toBeVisible();

await page.getByRole("button", { name: "Load 30 more" }).click();
await expect(rows(page)).toHaveCount(SEEDED);
await expect(page.getByRole("button", { name: /Load \d+ more/ })).toHaveCount(0);
await expect(page.getByText('"Seeded line 0"')).toBeVisible();

const ids = await page.locator(".card [download]").evaluateAll(
links => links.map(a => a.getAttribute("download")));
expect(new Set(ids).size).toBe(SEEDED);
});

test("deleting a loaded row updates the remaining count", async ({ page }) => {
await expect(page.getByRole("button", { name: "Load 130 more" })).toBeVisible();
await rows(page).first().getByTitle("Delete").click();
await page.getByRole("dialog").getByRole("button", { name: "Delete" }).click();

await expect(rows(page)).toHaveCount(99);
await expect(page.getByRole("button", { name: "Load 130 more" })).toBeVisible();
await expect(page.getByText(`"Seeded line ${SEEDED - 1}"`)).toHaveCount(0);
});
99 changes: 99 additions & 0 deletions hub/e2e/hub-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Boots a real hub against a throwaway TTS_HUB_ROOT so browser tests exercise
// the actual FastAPI app, SSE stream and static WebUI — not a mock.
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { mkdtempSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const HUB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const REPO = resolve(HUB_DIR, "..");

/** A hub root containing only the engines a test actually needs. */
export function makeRoot({ hub = {}, engines = ["fake"] } = {}) {
const root = mkdtempSync(join(tmpdir(), "aviary-e2e-"));
mkdirSync(join(root, "engines"));
for (const id of engines) {
symlinkSync(join(REPO, "engines", id), join(root, "engines", id));
}
const lines = ["[hub]", 'host = "127.0.0.1"', 'data_dir = "data"'];
for (const [key, value] of Object.entries(hub)) {
lines.push(`${key} = ${typeof value === "string" ? `"${value}"` : value}`);
}
writeFileSync(join(root, "config.toml"), lines.join("\n") + "\n");
return root;
}

async function waitForHub(baseURL, proc, log) {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (proc.exitCode !== null) {
throw new Error(`hub exited early (${proc.exitCode}):\n${log.join("")}`);
}
try {
// 401 also proves it is listening — a guarded hub answers nothing else.
const res = await fetch(baseURL + "/api/engines");
if (res.status === 200 || res.status === 401) return;
} catch { /* not up yet */ }
await new Promise(r => setTimeout(r, 150));
}
throw new Error(`hub did not start:\n${log.join("")}`);
}

function freePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}

/** Write `count` history rows straight into the hub database. */
export function seedHistory(root, count) {
const data = join(root, "data");
mkdirSync(join(data, "audio"), { recursive: true });
const script = `
import datetime, sqlite3, sys
from tts_hub.db import DB
db = DB(sys.argv[1])
base = datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)
for i in range(${count}):
db.execute("INSERT INTO history (id, created_at, engine, text, path,"
" duration, format) VALUES (?,?,?,?,?,?,?)",
(f"seed{i:04d}", (base + datetime.timedelta(minutes=i)).isoformat(),
"fake", f"Seeded line {i}", sys.argv[2] + f"/seed{i:04d}.wav", 1.0, "wav"))
`;
const result = spawnSync(join(HUB_DIR, ".venv", "bin", "python"),
["-c", script, join(data, "hub.db"), join(data, "audio")],
{ cwd: HUB_DIR, encoding: "utf8" });
if (result.status !== 0) throw new Error(`seeding failed: ${result.stderr}`);
}

/** Start a hub on a free port; returns { baseURL, root, stop() }. */
export async function startHub(options = {}) {
const port = await freePort();
const root = makeRoot({ ...options, hub: { ...options.hub, port } });
if (options.history) seedHistory(root, options.history);
const log = [];
const proc = spawn(
join(HUB_DIR, ".venv", "bin", "python"),
["-c", "from tts_hub.main import main; main()"],
{ cwd: HUB_DIR, env: { ...process.env, TTS_HUB_ROOT: root, PYTHONUNBUFFERED: "1" } },
);
proc.stdout.on("data", d => log.push(String(d)));
proc.stderr.on("data", d => log.push(String(d)));

const baseURL = `http://127.0.0.1:${port}`;
await waitForHub(baseURL, proc, log);
return {
baseURL, root, log,
async stop() {
proc.kill("SIGTERM");
await new Promise(r => proc.once("exit", r));
},
};
}
Loading
Loading