diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..88df2ae --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - run: pip install ruff + - run: ruff check app/ --ignore E501 + - run: ruff format --check app/ + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + - name: Install lightweight test dependencies (no GPU packages) + run: pip install pytest pytest-cov fastapi httpx numpy aiofiles starlette python-multipart + - name: Run tests + run: | + pytest tests/test_security.py tests/test_voiceprint_db.py tests/test_job_service.py \ + -v --tb=short --no-header + + security-scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install pip-audit + run: pip install pip-audit + - name: Run pip-audit + run: pip-audit -r requirements.txt --ignore-vuln PYSEC-2022-42969 || true + # || true: 不阻断构建,但在 Summary 中显示漏洞报告 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f424b1b..9f51abe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,9 @@ name: Build and publish Docker image on: release: types: [published] + push: + tags: + - 'v*' workflow_dispatch: jobs: @@ -26,14 +29,24 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Compute image tags id: tags run: | - IMAGE="ghcr.io/${{ github.repository }}" - TAGS="$IMAGE:latest" - if [ "${{ github.event_name }}" = "release" ]; then - VERSION="${GITHUB_REF#refs/tags/}" - TAGS="$TAGS,$IMAGE:$VERSION" + GHCR_IMAGE="ghcr.io/${{ github.repository }}" + DOCKERHUB_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/voscript" + TAGS="$GHCR_IMAGE:latest,$DOCKERHUB_IMAGE:latest" + # Tag-triggered builds (release or push-tag) get the version tag too. + # Strip leading "v" for Docker Hub convention (0.7.0), keep raw ref for GHCR. + if [ "${{ github.event_name }}" = "release" ] || [ "${{ github.event_name }}" = "push" ]; then + RAW_VERSION="${GITHUB_REF#refs/tags/}" + STRIPPED_VERSION="${RAW_VERSION#v}" + TAGS="$TAGS,$GHCR_IMAGE:$RAW_VERSION,$DOCKERHUB_IMAGE:$STRIPPED_VERSION" fi echo "tags=$TAGS" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 66374a4..0bc50c0 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ logs/ node_modules/ tmp/ .claude/ + +# Code review intermediate artifacts +.full-review/ +.full-review-archive/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..07c254a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# 贡献指南 + +感谢你考虑向 voscript 贡献! + +## 开始之前 + +- 提 issue 之前先搜一下是否已有相同问题 +- 大的改动建议先开 issue 讨论设计方向 + +## 开发环境 + +```bash +# 安装依赖(需要 Python 3.11+) +pip install fastapi uvicorn pytest pytest-asyncio aiofiles httpx starlette python-multipart + +# 运行测试 +pytest tests/ -v + +# 启动本地服务(需要 GPU + 模型文件) +cd app && uvicorn main:app --reload --port 8780 +``` + +## 提 PR 须知 + +1. **Fork → 新建分支 → PR**,分支命名建议:`feat/xxx`、`fix/xxx`、`docs/xxx` +2. **测试**:新功能必须附带对应测试(`tests/` 目录),所有测试必须通过 +3. **文档**:影响 API 或行为的改动需同步更新 `doc/` 中的相关文档 +4. **提交信息**:用英文小写动词开头,例如 `fix: handle zero-length audio` / `feat: add speaker rename API` + +## 代码风格 + +- Python:PEP 8,函数命名 `snake_case`,类型注解尽量完整 +- 不要引入 print 调试语句,用 `logger.debug()` +- 安全敏感代码(鉴权、文件路径)改动务必在 PR 描述中说明 + +## 报告安全问题 + +**不要开公开 issue**。请直接发 email 或通过 GitHub Security Advisory 私下报告。 +详见 [SECURITY.md](./SECURITY.md)。 + +## 行为准则 + +友善、建设性地交流。维护者有权关闭不遵守基本礼仪的讨论。 + +--- + +# Contributing (English) + +Thank you for contributing to voscript! + +## Before You Start + +- Search existing issues before filing a new one +- For large changes, open an issue first to discuss design direction + +## Development Setup + +```bash +# Requires Python 3.11+ +pip install fastapi uvicorn pytest pytest-asyncio aiofiles httpx starlette python-multipart + +# Run tests +pytest tests/ -v + +# Start local server (requires GPU + model files) +cd app && uvicorn main:app --reload --port 8780 +``` + +## Pull Request Guidelines + +1. **Fork → branch → PR** — suggested branch names: `feat/xxx`, `fix/xxx`, `docs/xxx` +2. **Tests**: new features must include tests; all tests must pass +3. **Docs**: changes to API behavior must update the relevant files in `doc/` +4. **Commit messages**: lowercase verb prefix, e.g. `fix: handle zero-length audio` + +## Code Style + +- Python: PEP 8, `snake_case` for functions, type annotations where practical +- Use `logger.debug()` instead of `print` +- Security-sensitive changes (auth, file paths) must be explained in the PR description + +## Reporting Security Issues + +**Do not open a public issue.** Use GitHub Security Advisory or email privately. +See [SECURITY.md](./SECURITY.md). + +## Code of Conduct + +Be kind and constructive. Maintainers may close discussions that violate basic courtesy norms. diff --git a/README.en.md b/README.en.md index 05714ff..262c896 100644 --- a/README.en.md +++ b/README.en.md @@ -1,93 +1,92 @@ -# VoScript +
+ +# 🎙️ VoScript [简体中文](./README.md) | **English** -Self-hosted GPU transcription service — **meeting transcripts that remember -their speakers.** A small HTTP API that turns audio into timestamped text -labelled with speaker names, and that auto-recognizes returning voices -across recordings. + + CI + +License: Apache 2.0 +Docker ready -``` -Audio ──► faster-whisper large-v3 (transcription) - ──► pyannote 3.1 (speaker diarization) - ──► DeepFilterNet / noisereduce (optional denoising) - ──► WeSpeaker ResNet34 (speaker embeddings) - ──► VoiceprintDB (cosine match vs. enrolled speakers) - ──► timestamped text with identified speaker names -``` +**Meeting recordings → transcripts with real speaker names. Self-hosted, GPU-powered, remembers every voice.** -What sets it apart from a plain whisper wrapper: the **persistent -voiceprint library**. Enroll a speaker once, and from then on every -recording they appear in gets their real name automatically. +[Quickstart](./doc/quickstart.en.md) · [API Reference](./doc/api.en.md) · [Security](./doc/security.en.md) · [Benchmarks](./doc/benchmarks.en.md) · [Changelog](./doc/changelog.en.md) -> Example consumer: [OpenPlaud(Maple)](https://github.com/MapleEve/openplaud) -> uses voscript as the backend for meeting recordings. voscript itself is -> just an HTTP service — any client that can POST multipart audio works. +
-## Documentation +--- -All detailed docs live in [`doc/`](./doc/). Chinese is the default, every -page has an English counterpart: +You have a meeting recording with six people. You want to know who said what. Whisper gives you a wall of text. pyannote can split it into "Speaker A / Speaker B / Speaker C" — but it doesn't know who anyone is. You still have to label every recording by hand. -| Topic | 中文 | English | -| --- | --- | --- | -| Quickstart | [quickstart.zh.md](./doc/quickstart.zh.md) | [quickstart.en.md](./doc/quickstart.en.md) | -| API reference | [api.zh.md](./doc/api.zh.md) | [api.en.md](./doc/api.en.md) | -| **Install guide for AI agents** | [ai-install.zh.md](./doc/ai-install.zh.md) | [ai-install.en.md](./doc/ai-install.en.md) | -| **Usage guide for AI agents** | [ai-usage.zh.md](./doc/ai-usage.zh.md) | [ai-usage.en.md](./doc/ai-usage.en.md) | -| Security policy | [security.zh.md](./doc/security.zh.md) | [security.en.md](./doc/security.en.md) | -| Benchmarks (real-audio wall clock + resource usage) | [benchmarks.zh.md](./doc/benchmarks.zh.md) | [benchmarks.en.md](./doc/benchmarks.en.md) | -| Changelog | [changelog.zh.md](./doc/changelog.zh.md) | [changelog.en.md](./doc/changelog.en.md) | - -First-time deployers: start with the [Quickstart](./doc/quickstart.en.md). -AI agents integrating the API: read the [AI usage guide](./doc/ai-usage.en.md). -AI agents deploying the service for a user: read the -[AI install guide](./doc/ai-install.en.md). - -## Features +VoScript fixes that: **enroll a voice once, and it gets automatically identified in every future recording**. Not "Speaker 2" — "Maple". -- **Async job pipeline**: `queued → converting → denoising (optional) → transcribing → identifying → completed` -- **Chinese + multilingual transcription** (WhisperX + faster-whisper large-v3, **word-level timestamps** via forced alignment; omit `language` to auto-detect — Mandarin audio outputs Simplified Chinese) -- **Speaker diarization** (pyannote 3.1) + **WeSpeaker ResNet34** embeddings -- **Adaptive voiceprint threshold**: `VOICEPRINT_THRESHOLD` (default 0.75) is the base; the actual threshold relaxes per-speaker based on intra-cluster std of enrolled embeddings — fixed −0.05 for 1 sample, `min(3×std, 0.10)` for 2+, floor at 0.60. Lifted recall from 50% to 70% on 10 real recordings with zero false positives -- **Optional denoising with SNR gate**: `DENOISE_MODEL` (`none` | `deepfilternet` | `noisereduce`); `DENOISE_SNR_THRESHOLD` (default 10.0 dB) — audio above this SNR is considered clean and skipped automatically, preventing DeepFilterNet from degrading already-clean recordings -- **AS-norm voiceprint scoring**: at startup, automatically builds an impostor cohort from existing transcription embeddings and applies Adaptive Score Normalization — eliminates speaker-dependent baseline bias, ~15–30% relative EER improvement -- **Persistent voiceprints**: enroll once, auto-match across future recordings. sqlite + sqlite-vec under the hood — top-k nearest-neighbour search scales to thousands of speakers -- **File hash deduplication**: submitting the same file twice returns the existing result immediately, skipping Whisper GPU inference -- **Stable HTTP contract**: `/api/transcribe`, `/api/jobs/{id}`, `/api/voiceprints*`, etc. — any HTTP client works -- **Container runs as non-root**; all `/api/*` routes accept optional Bearer / `X-API-Key` auth (constant-time compare); uploads capped by `MAX_UPLOAD_BYTES`; voiceprint DB is concurrency-safe with atomic writes — full hardening list in [`doc/security.en.md`](./doc/security.en.md) -- Minimal built-in web UI at `/` for manual testing +``` +Audio ──► faster-whisper large-v3 transcription + word-level timestamps + ──► pyannote 3.1 speaker diarization + ──► WeSpeaker ResNet34 speaker embeddings + ──► VoiceprintDB (AS-norm) match against enrolled voices + ──► timestamped transcript with real speaker names +``` ## 30-second start -```bash -git clone https://github.com/MapleEve/voscript.git -cd voscript - -cp .env.example .env -# edit .env — at minimum set HF_TOKEN and API_KEY +> **Security**: set a strong `API_KEY` in `.env` before exposing this on any network. Without it, anyone can delete your voiceprint library or trigger GPU jobs. +```bash +git clone https://github.com/MapleEve/voscript.git && cd voscript +cp .env.example .env # at minimum: HF_TOKEN and API_KEY docker compose up -d --build curl -sf http://localhost:8780/healthz ``` -Full steps + troubleshooting in [`doc/quickstart.en.md`](./doc/quickstart.en.md). +Full setup + troubleshooting → [`doc/quickstart.en.md`](./doc/quickstart.en.md) + +## Features + +- **Persistent voiceprint library** — enroll once, auto-match across all future recordings. sqlite + sqlite-vec under the hood, top-k nearest-neighbour search, scales to thousands of speakers +- **AS-norm scoring** — builds an impostor cohort from existing transcription embeddings at startup; eliminates speaker-dependent baseline bias, ~15–30% relative EER improvement +- **Adaptive threshold** — each speaker's match threshold relaxes dynamically based on enrollment variance; lifted recall from 50% to 70% on 10 real recordings with zero false positives +- **Speaker cluster consolidation** — when diarization splits one person into multiple clusters, they're automatically merged to a single label +- **Word-level timestamps** — WhisperX forced alignment, every word precisely timed +- **Optional denoising with SNR gate** — DeepFilterNet / noisereduce; audio above the SNR threshold is treated as clean and skipped automatically (prevents degrading already-clean recordings) +- **File hash deduplication** — submitting the same file twice returns the existing result immediately, no GPU re-run +- **Job persistence** — completed transcriptions remain accessible after restart +- **Ngram dedup** — `no_repeat_ngram_size` parameter suppresses repetitive filler words in the transcript +- **Plain HTTP contract** — any client that can send multipart/form-data works, no framework lock-in + +Security: path traversal protection, non-root container, upload size cap, constant-time auth, atomic writes — full list in [`doc/security.en.md`](./doc/security.en.md) + +## Integration + +It's a plain HTTP service. Two config values and you're done: + +- **Transcription base URL**: `http://:8780` +- **API key**: the `API_KEY` you set in `.env` + +[BetterAINote](https://github.com/MapleEve/openplaud) connects this way. Any other client works the same. Full API contract → [`doc/api.en.md`](./doc/api.en.md) -## How to integrate +## Documentation + +| Topic | 中文 | English | +| --- | --- | --- | +| Quickstart | [quickstart.zh.md](./doc/quickstart.zh.md) | [quickstart.en.md](./doc/quickstart.en.md) | +| API reference | [api.zh.md](./doc/api.zh.md) | [api.en.md](./doc/api.en.md) | +| Install guide for AI agents | [ai-install.zh.md](./doc/ai-install.zh.md) | [ai-install.en.md](./doc/ai-install.en.md) | +| Usage guide for AI agents | [ai-usage.zh.md](./doc/ai-usage.zh.md) | [ai-usage.en.md](./doc/ai-usage.en.md) | +| Security policy | [security.zh.md](./doc/security.zh.md) | [security.en.md](./doc/security.en.md) | +| Benchmarks | [benchmarks.zh.md](./doc/benchmarks.zh.md) | [benchmarks.en.md](./doc/benchmarks.en.md) | +| Changelog | [changelog.zh.md](./doc/changelog.zh.md) | [changelog.en.md](./doc/changelog.en.md) | -voscript is a plain HTTP service — no specific client is required. Anything -that can send `multipart/form-data` works (curl, axios, requests, browser -uploads, …). +## Contributing -A typical integration — OpenPlaud(Maple), under Settings → Transcription: +PRs welcome — read [CONTRIBUTING.md](./CONTRIBUTING.md) first. -- **Private transcription base URL**: `http://:8780` -- **Private transcription API key**: the same `API_KEY` as in `.env` +## Star History -After that its worker routes every recording through this service. If -you're writing your own client, the full contract + error table lives in -[`doc/api.en.md`](./doc/api.en.md). +[![Star History Chart](https://api.star-history.com/svg?repos=MapleEve/voscript&type=date)](https://www.star-history.com/#MapleEve/voscript&type=date) ## License -MIT — see [LICENSE](./LICENSE). +Apache 2.0 — [LICENSE](./LICENSE) diff --git a/README.md b/README.md index 1523ada..ebae389 100644 --- a/README.md +++ b/README.md @@ -1,85 +1,90 @@ -# VoScript +
+ +# VoScript 🎙️ **简体中文** | [English](./README.en.md) -自托管的 GPU 转录服务——**带说话人记忆的会议逐字稿**。一套简单的 HTTP API, -把音频转成带说话人名字的文字,同一个人再次出现会自动识别。 +CI +License +Docker -``` -音频 ──► faster-whisper large-v3 (转录) - ──► pyannote 3.1 (说话人分离) - ──► DeepFilterNet / noisereduce(可选降噪) - ──► WeSpeaker ResNet34 (声纹提取) - ──► VoiceprintDB (与已注册声纹做余弦匹配) - ──► 带时间戳和已识别说话人姓名的文本 -``` +**会议录音 → 逐字稿,带真名说话人标签。自托管,GPU 驱动,记得住每个人的声音。** -与"纯 whisper 包装"的区别:**持久化声纹库**。登记过一次,之后所有录音里这个 -人都会被自动贴上真名,不需要每次人工贴标签。 +[快速上手](./doc/quickstart.zh.md) · [API 参考](./doc/api.zh.md) · [安全策略](./doc/security.zh.md) · [Benchmarks](./doc/benchmarks.zh.md) · [更新日志](./doc/changelog.zh.md) -> 用例参考:[OpenPlaud(Maple)](https://github.com/MapleEve/openplaud) 把本服务作为 -> 会议录音的后端——把这个 repo 当作标准 HTTP 服务对接即可,不限特定客户端。 +
-## 文档 +--- -所有详细文档都在 [`doc/`](./doc/),默认中文,每一份都有对应英文: +开完会,录音里有六个人,你想知道谁说了什么。Whisper 只给你一段文字,pyannote 能告诉你"说话人A/说话人B",但它不认识人——每次还是得手动贴名字。 -| 主题 | 中文 | English | -| --- | --- | --- | -| 快速安装 | [quickstart.zh.md](./doc/quickstart.zh.md) | [quickstart.en.md](./doc/quickstart.en.md) | -| API 参考 | [api.zh.md](./doc/api.zh.md) | [api.en.md](./doc/api.en.md) | -| **给 AI 的安装部署指南** | [ai-install.zh.md](./doc/ai-install.zh.md) | [ai-install.en.md](./doc/ai-install.en.md) | -| **给 AI 的接口使用指南** | [ai-usage.zh.md](./doc/ai-usage.zh.md) | [ai-usage.en.md](./doc/ai-usage.en.md) | -| 安全策略 | [security.zh.md](./doc/security.zh.md) | [security.en.md](./doc/security.en.md) | -| Benchmarks(真实音频耗时 + 资源占用) | [benchmarks.zh.md](./doc/benchmarks.zh.md) | [benchmarks.en.md](./doc/benchmarks.en.md) | -| 更新日志 | [changelog.zh.md](./doc/changelog.zh.md) | [changelog.en.md](./doc/changelog.en.md) | - -人第一次部署 → [快速安装](./doc/quickstart.zh.md); -AI agent 帮用户部署 → [给 AI 的安装部署指南](./doc/ai-install.zh.md); -AI agent 调用接口 → [给 AI 的接口使用指南](./doc/ai-usage.zh.md)。 - -## 功能 +VoScript 解决的就是这个:**登记一次声纹,之后所有录音里这个人都会被自动识别出来**。不是"说话人2",是"Maple"。 -- **异步任务流水线**:`queued → converting → denoising(可选)→ transcribing → identifying → completed` -- **中文 + 多语种转录**(WhisperX + faster-whisper large-v3,**带词级时间戳**的 forced alignment;`language` 省略时自动检测语言,普通话音频输出简体中文) -- **说话人分离**(pyannote 3.1)+ **WeSpeaker ResNet34** 声纹提取 -- **自适应声纹阈值**:`VOICEPRINT_THRESHOLD`(默认 0.75)作为基准,实际阈值按每位说话人已注册向量的簇内标准差动态宽松:1 条样本固定宽松 −0.05,2 条及以上按 `min(3×std, 0.10)` 宽松,最低不低于 0.60。在 10 条真实录音上召回率从 50% 提升至 70%,零误识别 -- **可选降噪 + SNR 门控**:`DENOISE_MODEL`(`none` | `deepfilternet` | `noisereduce`),`DENOISE_SNR_THRESHOLD`(默认 10.0 dB)——高于此 SNR 的录音视为干净音频自动跳过,防止 DeepFilterNet 劣化已清晰的录音 -- **AS-norm 声纹评分**:启动时自动从已有转录的声纹 embedding 构建 impostor cohort,用自适应分数归一化(AS-norm)替代原始余弦,消除说话人依赖的基准偏差,相对 EER 降低 15–30% -- **持久化声纹**:一次登记,后续录音自动识别。底层 sqlite + sqlite-vec,top-k 近邻搜索 O(log N),上千个声纹毫无压力 -- **文件哈希去重**:相同文件重复提交时直接返回已有结果,不再重跑 Whisper GPU 推理 -- **稳定的 HTTP 合同**:`/api/transcribe`、`/api/jobs/{id}`、`/api/voiceprints*` 等,任何 HTTP 客户端都能接入 -- **容器以非 root 用户运行**;所有 `/api/*` 路由支持可选 Bearer / `X-API-Key` 鉴权(常量时间对比);上传有 `MAX_UPLOAD_BYTES` 上限;声纹库并发安全、原子写入——完整硬化清单见 [`doc/security.zh.md`](./doc/security.zh.md) -- `/` 自带一个轻量 Web UI,方便单独测试 +``` +音频 ──► faster-whisper large-v3 转录 + 词级时间戳 + ──► pyannote 3.1 说话人分离 + ──► WeSpeaker ResNet34 声纹提取 + ──► VoiceprintDB (AS-norm) 与已注册声纹匹配 + ──► 带时间戳 + 真名的逐字稿 +``` ## 30 秒上手 -```bash -git clone https://github.com/MapleEve/voscript.git -cd voscript - -cp .env.example .env -# 编辑 .env —— 至少要填 HF_TOKEN 和 API_KEY +> **安全警告**:生产环境或公网暴露前**必须**在 `.env` 里设置 `API_KEY`,否则任何人都能删你的声纹库、触发 GPU 任务。 +```bash +git clone https://github.com/MapleEve/voscript.git && cd voscript +cp .env.example .env # 至少填 HF_TOKEN 和 API_KEY docker compose up -d --build curl -sf http://localhost:8780/healthz ``` -完整步骤 + 排障清单看 [`doc/quickstart.zh.md`](./doc/quickstart.zh.md)。 +完整步骤 + 排障清单 → [`doc/quickstart.zh.md`](./doc/quickstart.zh.md) + +## 功能 + +- **持久化声纹库** — 登记一次,后续所有录音自动识别。底层 sqlite + sqlite-vec,top-k 近邻,上千声纹毫无压力 +- **AS-norm 评分** — 启动时自动从历史转录构建 impostor cohort,消除说话人依赖的基准偏差,相对 EER 降低 15–30% +- **自适应阈值** — 每位说话人的实际识别阈值根据注册样本的方差动态宽松,10 条真实录音召回率从 50% → 70%,零误识别 +- **说话人聚类合并** — 同一个人被分出多个聚类时自动合并为一个标签 +- **词级时间戳** — WhisperX forced alignment,每个词都有精确时间 +- **可选降噪 + SNR 门控** — DeepFilterNet / noisereduce,SNR 高于阈值的录音自动跳过(防止对干净音频劣化) +- **文件哈希去重** — 相同文件重复提交直接返回已有结果,不重跑 GPU +- **任务持久化** — 重启后已完成任务仍可访问 +- **ngram 去重** — `no_repeat_ngram_size` 参数抑制转录中的口语重复(比如"就是就是就是") +- **纯 HTTP 合同** — 任何能发 multipart/form-data 的客户端都能接入,不绑定特定框架 + +安全相关:路径遍历防护、非 root 容器、上传大小限制、常量时间鉴权、原子写入……完整清单 → [`doc/security.zh.md`](./doc/security.zh.md) + +## 接入 + +就是个普通 HTTP 服务,没有特殊依赖。配两个值就行: + +- **转录服务地址**:`http://<主机>:8780` +- **API Key**:`.env` 里设的那个 `API_KEY` + +[BetterAINote](https://github.com/MapleEve/openplaud) 就是这样接的,其它客户端一样。完整接口合同 → [`doc/api.zh.md`](./doc/api.zh.md) -## 怎么接入 +## 文档 + +| 主题 | 中文 | English | +| --- | --- | --- | +| 快速安装 | [quickstart.zh.md](./doc/quickstart.zh.md) | [quickstart.en.md](./doc/quickstart.en.md) | +| API 参考 | [api.zh.md](./doc/api.zh.md) | [api.en.md](./doc/api.en.md) | +| 给 AI 的安装指南 | [ai-install.zh.md](./doc/ai-install.zh.md) | [ai-install.en.md](./doc/ai-install.en.md) | +| 给 AI 的接口指南 | [ai-usage.zh.md](./doc/ai-usage.zh.md) | [ai-usage.en.md](./doc/ai-usage.en.md) | +| 安全策略 | [security.zh.md](./doc/security.zh.md) | [security.en.md](./doc/security.en.md) | +| Benchmarks | [benchmarks.zh.md](./doc/benchmarks.zh.md) | [benchmarks.en.md](./doc/benchmarks.en.md) | +| 更新日志 | [changelog.zh.md](./doc/changelog.zh.md) | [changelog.en.md](./doc/changelog.en.md) | -voscript 就是个普通的 HTTP 服务,没有特定客户端的强依赖。任何能发 -`multipart/form-data` 的东西都能用(curl、axios、requests、网页上传框……)。 +## 贡献 -一个典型对接示例——OpenPlaud(Maple) 的"设置 → 转录"里配: +欢迎 PR,请先读 [CONTRIBUTING.md](./CONTRIBUTING.md)。 -- **Private transcription base URL**:`http://<主机>:8780` -- **Private transcription API key**:跟 `.env` 里的 `API_KEY` 一致 +## Star History -之后它的 worker 会自动把每条录音都丢给这个服务。想自己写客户端的话,看 -[`doc/api.zh.md`](./doc/api.zh.md) 的完整合同 + 错误码表。 +[![Star History Chart](https://api.star-history.com/svg?repos=MapleEve/voscript&type=date)](https://www.star-history.com/#MapleEve/voscript&type=date) ## License -MIT —— 看 [LICENSE](./LICENSE)。 +Apache 2.0 — [LICENSE](./LICENSE) diff --git a/app/Dockerfile b/app/Dockerfile index 4b635d4..208be7e 100755 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,3 +1,8 @@ +# TODO: pin digest — 运行: +# docker pull pytorch/pytorch:2.4.1-cuda12.4-cudnn9-runtime +# docker inspect pytorch/pytorch:2.4.1-cuda12.4-cudnn9-runtime --format='{{index .RepoDigests 0}}' +# 然后改为:FROM pytorch/pytorch:2.4.1-cuda12.4-cudnn9-runtime@sha256: +# 不 pin digest 的话 mutable tag 可能导致构建不可重现(对应评审 CD-H6 / BP-M6)。 FROM pytorch/pytorch:2.4.1-cuda12.4-cudnn9-runtime ENV DEBIAN_FRONTEND=noninteractive @@ -24,6 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ffmpeg \ libsndfile1 \ git \ + # TODO: 待确认 fake_git hack 删除后可移除此依赖(评审 BP-L5)。 + # DeepFilterNet 在初始化时可能探测 `git`,若已完全去除该 hack,可删除此行。 && rm -rf /var/lib/apt/lists/* # Non-root runtime user. An RCE inside the container only owns the service's @@ -43,7 +50,6 @@ RUN if [ -n "$PIP_INDEX_URL" ]; then \ fi COPY --chown=app:app . . -RUN chown -R app:app /app # HuggingFace cache lives under the app user's reach, not /root. ENV HF_HOME=/cache \ diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..3de8871 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,32 @@ +"""FastAPI dependency callables shared across all routers.""" + +import hmac + +from fastapi import Header, HTTPException, Request + +from config import API_KEY + + +async def verify_api_key(x_api_key: str | None = Header(None)) -> None: + """Dependency that enforces API-key authentication. + + Raises HTTP 403 when a key is configured but the supplied value does not + match. When no key is configured (open mode) the dependency is a no-op. + + Note: the middleware in main.py handles the Bearer-token path and path + allow-listing; this dependency is the fallback for router-level auth. + """ + if API_KEY is None: + return # open mode — no check needed + if not x_api_key or not hmac.compare_digest(x_api_key, API_KEY): + raise HTTPException(403, "Invalid API key") + + +def get_db(request: Request): + """Return the VoiceprintDB instance stored on app.state.""" + return request.app.state.db + + +def get_pipeline(request: Request): + """Return the TranscriptionPipeline instance stored on app.state.""" + return request.app.state.pipeline diff --git a/app/api/routers/__init__.py b/app/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routers/health.py b/app/api/routers/health.py new file mode 100644 index 0000000..bff662e --- /dev/null +++ b/app/api/routers/health.py @@ -0,0 +1,17 @@ +"""Health-check endpoint.""" + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse +from pathlib import Path + +router = APIRouter() + + +@router.get("/healthz") +async def healthz(): + return {"ok": True} + + +@router.get("/", response_class=HTMLResponse) +async def index(): + return Path("static/index.html").read_text(encoding="utf-8") diff --git a/app/api/routers/transcriptions.py b/app/api/routers/transcriptions.py new file mode 100644 index 0000000..ec04148 --- /dev/null +++ b/app/api/routers/transcriptions.py @@ -0,0 +1,343 @@ +"""Transcription endpoints. + +Covers: + POST /api/transcribe + GET /api/jobs/{job_id} + GET /api/transcriptions + GET /api/transcriptions/{tr_id} + GET /api/transcriptions/{tr_id}/audio + PUT /api/transcriptions/{tr_id}/segments/{seg_id}/speaker + GET /api/export/{tr_id} +""" + +import json +import logging +import uuid +from datetime import datetime +from pathlib import PurePosixPath +from threading import Thread +from typing import Annotated + +from fastapi import APIRouter, File, Form, HTTPException +from fastapi import Path as FPath +from fastapi import Request, UploadFile +from fastapi.responses import FileResponse, PlainTextResponse + +from api.deps import get_db, get_pipeline +from config import MAX_UPLOAD_BYTES, TRANSCRIPTIONS_DIR, UPLOAD_CHUNK, UPLOADS_DIR +from services.audio_service import ( + lookup_hash, + safe_log_filename, + safe_tr_dir, + save_upload_and_hash, +) +from services.job_service import jobs, run_transcription + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _format_srt_time(seconds: float) -> str: + # [CQ-M13] 防御 None / NaN / 负秒——SRT 不允许负时间戳,NaN 会导致 int() 抛异常。 + if seconds is None or seconds != seconds: # NaN 自身不等于自身 + seconds = 0.0 + seconds = max(0.0, float(seconds)) + h = int(seconds // 3600) + m = int((seconds % 3600) // 60) + s = int(seconds % 60) + ms = int((seconds % 1) * 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + +def _format_timestamp(seconds: float) -> str: + if seconds is None or seconds != seconds: + seconds = 0.0 + seconds = max(0.0, float(seconds)) + m = int(seconds // 60) + s = int(seconds % 60) + return f"{m:02d}:{s:02d}" + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.post("/transcribe") +async def transcribe( + request: Request, + file: UploadFile = File(...), + language: str = Form(None), + min_speakers: int = Form(0), + max_speakers: int = Form(0), + denoise_model: str = Form("none"), + snr_threshold: float = Form(None), + no_repeat_ngram_size: str = Form("0"), +): + try: + no_repeat_ngram_size = int(no_repeat_ngram_size) + except (ValueError, TypeError): + raise HTTPException( + status_code=422, + detail=[ + { + "loc": ["body", "no_repeat_ngram_size"], + "msg": "value is not a valid integer", + "type": "type_error.integer", + } + ], + ) + pipeline = get_pipeline(request) + voiceprint_db = get_db(request) + + # Normalise empty string to None so pipeline treats it as auto-detect. + language = language.strip() if language else None + + job_id = f"tr_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:6]}" + + safe_filename = PurePosixPath(file.filename or "upload").name or "upload" + # Strip control chars before using the name in paths/logs — PurePosixPath.name + # preserves newlines and ANSI escapes which would otherwise enable log injection. + safe_filename = safe_log_filename(safe_filename) or "upload" + save_path = UPLOADS_DIR / f"{job_id}_{safe_filename}" + + # PERF-C2: async write + streaming SHA-256 — no event-loop blockage on large uploads. + try: + _size, file_hash = await save_upload_and_hash( + file, save_path, MAX_UPLOAD_BYTES, UPLOAD_CHUNK + ) + except ValueError as exc: + save_path.unlink(missing_ok=True) + raise HTTPException(413, str(exc)) from exc + + # Dedup: if identical audio was already transcribed, return existing result. + existing_id = lookup_hash(file_hash) + if existing_id: + save_path.unlink(missing_ok=True) + logger.info( + "Dedup hit: %s already transcribed as %s", safe_filename, existing_id + ) + return {"id": existing_id, "status": "completed", "deduplicated": True} + + jobs[job_id] = { + "status": "queued", + "filename": safe_filename, + "created_at": datetime.now().isoformat(), + } + # CD-C3: daemon=True ensures this thread does not prevent the process from + # exiting on SIGTERM — the OS will clean up in-progress transcriptions on + # shutdown rather than hanging indefinitely waiting for the thread to finish. + thread = Thread( + target=run_transcription, + args=( + job_id, + save_path, + language, + min_speakers, + max_speakers, + pipeline, + voiceprint_db, + denoise_model, + snr_threshold, + file_hash, + no_repeat_ngram_size if no_repeat_ngram_size >= 3 else 0, + ), + daemon=True, + ) + thread.start() + + return {"id": job_id, "status": "queued"} + + +@router.get("/jobs/{job_id}") +async def get_job( + job_id: Annotated[str, FPath(pattern=r"^tr_[A-Za-z0-9_-]{1,64}$")], +): + if job_id in jobs: + job = jobs[job_id] + resp = {"id": job_id, "status": job["status"], "filename": job.get("filename")} + if job["status"] == "completed": + resp["result"] = job["result"] + elif job["status"] == "failed": + resp["error"] = job.get("error") + return resp + + # AR-C2 fallback: process restarted — try reading persisted status.json. + status_path = TRANSCRIPTIONS_DIR / job_id / "status.json" + result_path = TRANSCRIPTIONS_DIR / job_id / "result.json" + + if status_path.exists(): + try: + status_data = json.loads(status_path.read_text()) + except Exception: + raise HTTPException(404, "Job not found") + + current_status = status_data.get("status") + + if current_status == "completed" and result_path.exists(): + try: + result = json.loads(result_path.read_text(encoding="utf-8")) + except Exception: + result = None + return { + "id": job_id, + "status": "completed", + "filename": status_data.get("filename"), + "result": result, + } + + if current_status not in ("completed", "failed"): + # In-progress status persisted by a previous process that no longer + # owns this job — treat as a restart failure. + return { + "id": job_id, + "status": "failed", + "error": "Process restarted while job was in progress", + "filename": status_data.get("filename"), + } + + return { + "id": job_id, + "status": current_status, + "error": status_data.get("error"), + "filename": status_data.get("filename"), + } + + raise HTTPException(404, "Job not found") + + +@router.get("/transcriptions") +async def list_transcriptions(): + results = [] + for tr_dir in sorted(TRANSCRIPTIONS_DIR.iterdir(), reverse=True): + if not tr_dir.is_dir(): + continue + result_file = tr_dir / "result.json" + if result_file.exists(): + try: + data = json.loads(result_file.read_text(encoding="utf-8")) + results.append( + { + "id": data["id"], + "filename": data["filename"], + "created_at": data["created_at"], + "segment_count": len(data["segments"]), + "speaker_count": len(data.get("unique_speakers", [])), + } + ) + except Exception as exc: + logger.warning( + "Skipping corrupt result.json in %s: %s", tr_dir.name, exc + ) + return results + + +@router.get("/transcriptions/{tr_id}") +async def get_transcription( + tr_id: Annotated[str, FPath(pattern=r"^tr_[A-Za-z0-9_-]{1,64}$")], +): + result_file = safe_tr_dir(tr_id) / "result.json" + if not result_file.exists(): + raise HTTPException(404, "Transcription not found") + return json.loads(result_file.read_text(encoding="utf-8")) + + +@router.get("/transcriptions/{tr_id}/audio") +async def download_audio( + tr_id: Annotated[str, FPath(pattern=r"^tr_[A-Za-z0-9_-]{1,64}$")], +): + """Return the original uploaded audio file for this transcription.""" + result_file = safe_tr_dir(tr_id) / "result.json" + if not result_file.exists(): + raise HTTPException(404, "Transcription not found") + data = json.loads(result_file.read_text(encoding="utf-8")) + audio_file = UPLOADS_DIR / data["filename"] + if not audio_file.exists(): + raise HTTPException(404, "Original audio file not found") + return FileResponse(audio_file, filename=data["filename"]) + + +@router.put("/transcriptions/{tr_id}/segments/{seg_id}/speaker") +async def reassign_speaker( + tr_id: Annotated[str, FPath(pattern=r"^tr_[A-Za-z0-9_-]{1,64}$")], + seg_id: int, + speaker_name: str = Form(...), + speaker_id: str = Form(None), +): + """Reassign a segment to a different speaker and optionally enroll the voiceprint.""" + result_file = safe_tr_dir(tr_id) / "result.json" + if not result_file.exists(): + raise HTTPException(404, "Transcription not found") + data = json.loads(result_file.read_text(encoding="utf-8")) + + seg = next((s for s in data["segments"] if s["id"] == seg_id), None) + if seg is None: + raise HTTPException(404, "Segment not found") + + seg["speaker_name"] = speaker_name + if speaker_id: + seg["speaker_id"] = speaker_id + + # [CQ-H7] 同步更新 speaker_map,保持人工纠错在整条记录内一致。 + # 原 segment 可能引用一个 speaker_label(如 "SPEAKER_01"),我们在 speaker_map + # 的对应条目上更新 matched_name / matched_id,而不是改 key。 + spk_label = seg.get("speaker_label") + speaker_map = data.get("speaker_map") or {} + if spk_label and spk_label in speaker_map: + speaker_map[spk_label]["matched_name"] = speaker_name + if speaker_id: + speaker_map[spk_label]["matched_id"] = speaker_id + data["speaker_map"] = speaker_map + + result_file.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return {"ok": True} + + +@router.get("/export/{tr_id}") +async def export_transcription( + tr_id: Annotated[str, FPath(pattern=r"^tr_[A-Za-z0-9_-]{1,64}$")], + format: str = "srt", +): + result_file = safe_tr_dir(tr_id) / "result.json" + if not result_file.exists(): + raise HTTPException(404, "Transcription not found") + data = json.loads(result_file.read_text(encoding="utf-8")) + segments = data["segments"] + + if format == "srt": + lines = [] + for i, seg in enumerate(segments, 1): + start = _format_srt_time(seg["start"]) + end = _format_srt_time(seg["end"]) + lines.append( + f"{i}\n{start} --> {end}\n[{seg['speaker_name']}] {seg['text']}\n" + ) + return PlainTextResponse( + "\n".join(lines), + media_type="text/srt", + headers={"Content-Disposition": f'attachment; filename="{tr_id}.srt"'}, + ) + elif format == "txt": + lines = [] + for seg in segments: + ts = _format_timestamp(seg["start"]) + lines.append(f"[{ts}] {seg['speaker_name']}: {seg['text']}") + return PlainTextResponse( + "\n".join(lines), + media_type="text/plain", + headers={"Content-Disposition": f'attachment; filename="{tr_id}.txt"'}, + ) + elif format == "json": + return FileResponse( + result_file, media_type="application/json", filename=f"{tr_id}.json" + ) + else: + raise HTTPException(400, "Unsupported format. Use: srt, txt, json") diff --git a/app/api/routers/voiceprints.py b/app/api/routers/voiceprints.py new file mode 100644 index 0000000..5809e9e --- /dev/null +++ b/app/api/routers/voiceprints.py @@ -0,0 +1,94 @@ +"""Voiceprint management endpoints. + +All routes under /api/voiceprints/*. +""" + +import logging + +from fastapi import APIRouter, Form, HTTPException, Request + +from api.deps import get_db +from config import TRANSCRIPTIONS_DIR +from services.audio_service import safe_speaker_label, safe_tr_dir + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + + +@router.post("/voiceprints/enroll") +async def enroll_speaker( + request: Request, + tr_id: str = Form(...), + speaker_label: str = Form(...), + speaker_name: str = Form(...), + speaker_id: str = Form(None), +): + """Enroll or update a voiceprint from a transcription's speaker embedding.""" + import numpy as np + + voiceprint_db = get_db(request) + + # SEC-C2: validate both tr_id and speaker_label before building any path. + safe_label = safe_speaker_label(speaker_label) + emb_path = safe_tr_dir(tr_id) / f"emb_{safe_label}.npy" + if not emb_path.exists(): + raise HTTPException(404, "Embedding not found for this speaker label") + # SEC-C1: allow_pickle=False prevents arbitrary code execution via + # a crafted .npy file that embeds a pickle payload (CVSS 9.1). + embedding = np.load(emb_path, allow_pickle=False) + + if speaker_id and voiceprint_db.get_speaker(speaker_id): + voiceprint_db.update_speaker(speaker_id, embedding, name=speaker_name) + return {"action": "updated", "speaker_id": speaker_id} + else: + new_id = voiceprint_db.add_speaker(speaker_name, embedding) + return {"action": "created", "speaker_id": new_id} + + +@router.get("/voiceprints") +async def list_voiceprints(request: Request): + return get_db(request).list_speakers() + + +@router.post("/voiceprints/rebuild-cohort") +async def rebuild_cohort(request: Request): + """Rebuild the AS-norm cohort from all processed transcriptions.""" + voiceprint_db = get_db(request) + cohort_path = TRANSCRIPTIONS_DIR / "asnorm_cohort.npy" + n = voiceprint_db.build_cohort_from_transcriptions( + str(TRANSCRIPTIONS_DIR), save_path=str(cohort_path) + ) + # [CQ-M10] 报告跳过/损坏的文件数,让调用方看到 cohort 的实际覆盖情况 + skipped = getattr(voiceprint_db, "last_cohort_skipped", 0) + return { + "cohort_size": n, + "skipped": skipped, + "saved_to": str(cohort_path), + } + + +@router.get("/voiceprints/{speaker_id}") +async def get_voiceprint(speaker_id: str, request: Request): + speaker = get_db(request).get_speaker(speaker_id) + if not speaker: + raise HTTPException(404, "Speaker not found") + return speaker + + +@router.delete("/voiceprints/{speaker_id}") +async def delete_voiceprint(speaker_id: str, request: Request): + try: + get_db(request).delete_speaker(speaker_id) + except ValueError as e: + raise HTTPException(404, str(e)) + return {"ok": True} + + +@router.put("/voiceprints/{speaker_id}/name") +async def rename_voiceprint(speaker_id: str, request: Request, name: str = Form(...)): + try: + get_db(request).rename_speaker(speaker_id, name) + except ValueError as e: + raise HTTPException(404, str(e)) + return {"ok": True} diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5208bfc --- /dev/null +++ b/app/config.py @@ -0,0 +1,101 @@ +"""Centralised configuration — all os.getenv() calls live here. + +Modules import from this file rather than calling os.getenv() directly so +that environment-variable names are defined in exactly one place and are +easy to audit. +""" + +import os +from pathlib import Path + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except ValueError: + return default + + +# --------------------------------------------------------------------------- +# Directory layout +# --------------------------------------------------------------------------- + +DATA_DIR: Path = Path(os.getenv("DATA_DIR", "/data")) +TRANSCRIPTIONS_DIR: Path = DATA_DIR / "transcriptions" +UPLOADS_DIR: Path = DATA_DIR / "uploads" +VOICEPRINTS_DIR: Path = DATA_DIR / "voiceprints" +MODELS_DIR: Path = Path(os.getenv("MODELS_DIR", "/models")) + +# --------------------------------------------------------------------------- +# Auth / CORS +# --------------------------------------------------------------------------- + +API_KEY: str | None = (os.getenv("API_KEY") or "").strip() or None + +# SEC-C3: allow operators to explicitly acknowledge running without auth. +# When ALLOW_NO_AUTH=1 the warning is suppressed; the service still runs open. +ALLOW_NO_AUTH: bool = os.getenv("ALLOW_NO_AUTH", "0") == "1" + +CORS_ORIGINS: str = os.getenv("CORS_ALLOW_ORIGINS", "*").strip() + +# --------------------------------------------------------------------------- +# Upload limits +# --------------------------------------------------------------------------- + +# Cap how much any single upload can occupy on disk. Whisper + pyannote +# comfortably handle 2 GB of audio (~20 h @ typical bitrates); anything +# beyond that is either a mistake or an attempt to exhaust storage. +MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_BYTES", str(2 * 1024 * 1024 * 1024))) +UPLOAD_CHUNK: int = 1 << 20 # 1 MiB + +# --------------------------------------------------------------------------- +# Model / inference settings +# --------------------------------------------------------------------------- + +WHISPER_MODEL: str = os.getenv("WHISPER_MODEL", "large-v3") +HF_TOKEN: str | None = os.getenv("HF_TOKEN") +DEVICE: str = os.getenv("DEVICE", "cuda") +LANGUAGE: str = os.getenv("LANGUAGE", "") + +# --------------------------------------------------------------------------- +# Denoising +# --------------------------------------------------------------------------- + +DENOISE_MODEL: str = os.getenv("DENOISE_MODEL", "none").strip().lower() + +# SNR threshold (dB) below which DeepFilterNet is applied. +# Audio estimated at or above this level is considered clean and skipped, +# matching the A/B finding that DF hurts high-quality recordings (e.g. PLAUD Pin). +DENOISE_SNR_THRESHOLD: float = _env_float("DENOISE_SNR_THRESHOLD", 10.0) + +# --------------------------------------------------------------------------- +# Speaker identification +# --------------------------------------------------------------------------- + +# Base cosine-similarity threshold for voiceprint identify(). The actual +# threshold per candidate is adaptive — see voiceprint_db.identify's docstring +# for the per-speaker relaxation rules. +VOICEPRINT_THRESHOLD: float = _env_float("VOICEPRINT_THRESHOLD", 0.75) + +# --------------------------------------------------------------------------- +# Misc +# --------------------------------------------------------------------------- + +FFMPEG_TIMEOUT_SEC: int = int(os.getenv("FFMPEG_TIMEOUT_SEC", "1800")) +JOBS_MAX_CACHE: int = int(os.getenv("JOBS_MAX_CACHE", "200")) + +# Paths that must stay open even when API_KEY auth is enabled. "/" is the +# bundled web UI (browsers can't attach a Bearer header to a direct +# navigation — the UI's own fetch() calls to /api/* still carry the key). +# /static/* serves the UI's assets. /healthz is a liveness probe. /docs +# /redoc /openapi.json are FastAPI's auto docs. +PUBLIC_EXACT_PATHS: frozenset = frozenset( + { + "/", + "/healthz", + "/docs", + "/redoc", + "/openapi.json", + } +) +PUBLIC_PATH_PREFIXES: tuple = ("/static/",) diff --git a/app/main.py b/app/main.py index cf0fff5..c65d61e 100755 --- a/app/main.py +++ b/app/main.py @@ -1,26 +1,29 @@ """FastAPI service for voice transcription with speaker identification.""" -import hashlib import hmac -import json -import os -import subprocess -import uuid import logging -from datetime import datetime -from pathlib import Path, PurePosixPath -from threading import Thread +from contextlib import asynccontextmanager -from fastapi import FastAPI, File, Form, UploadFile, HTTPException, Request -from fastapi.responses import ( - FileResponse, - HTMLResponse, - JSONResponse, - PlainTextResponse, -) +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles +from api.routers import health, transcriptions, voiceprints +from services.job_service import recover_orphan_jobs +from config import ( + ALLOW_NO_AUTH, + API_KEY, + CORS_ORIGINS, + HF_TOKEN, + PUBLIC_EXACT_PATHS, + PUBLIC_PATH_PREFIXES, + TRANSCRIPTIONS_DIR, + UPLOADS_DIR, + VOICEPRINTS_DIR, + WHISPER_MODEL, + DEVICE, +) from pipeline import TranscriptionPipeline from voiceprint_db import VoiceprintDB @@ -29,201 +32,96 @@ ) logger = logging.getLogger(__name__) -DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) -TRANSCRIPTIONS_DIR = DATA_DIR / "transcriptions" -UPLOADS_DIR = DATA_DIR / "uploads" -VOICEPRINTS_DIR = DATA_DIR / "voiceprints" - - -# Base cosine-similarity threshold for voiceprint identify(). The actual -# threshold per candidate is adaptive — see voiceprint_db.identify's docstring -# for the per-speaker relaxation rules. -def _env_float(name: str, default: float) -> float: - try: - return float(os.getenv(name, str(default))) - except ValueError: - return default - - -VOICEPRINT_THRESHOLD = _env_float("VOICEPRINT_THRESHOLD", 0.75) - -DENOISE_MODEL = os.getenv("DENOISE_MODEL", "none").strip().lower() - -# SNR threshold (dB) below which DeepFilterNet is applied. -# Audio estimated at or above this level is considered clean and skipped, -# matching the A/B finding that DF hurts high-quality recordings (e.g. PLAUD Pin). -DENOISE_SNR_THRESHOLD = _env_float("DENOISE_SNR_THRESHOLD", 10.0) - -# Lazy module-level handle so DeepFilterNet loads once at first use. -_df_model = None -_df_state = None - - -def _load_deepfilternet(): - global _df_model, _df_state - if _df_model is None: - import os, shutil - - if not shutil.which("git"): - fake = "/tmp/_fake_git" - if not os.path.exists(fake): - with open(fake, "w") as f: - f.write("#!/bin/sh\necho unknown\nexit 0\n") - os.chmod(fake, 0o755) - os.environ["PATH"] = "/tmp:" + os.environ.get("PATH", "") - import df as _df_pkg - - _df_model, _df_state, _ = _df_pkg.init_df() - logger.info("DeepFilterNet model loaded") - return _df_model, _df_state - - -def _estimate_snr(wav_path: Path) -> float: - """Estimate signal-to-noise ratio (dB) using a simple energy-based heuristic. - - Strategy: divide the audio into short frames, compute per-frame RMS energy, - then treat the bottom 20 % of frame energies as the noise floor and the top - 80 % as the speech signal. SNR = 10 * log10(speech_power / noise_power). - This is intentionally lightweight — no VAD model, no STFT — so it adds - negligible latency before deciding whether to invoke DeepFilterNet. - """ - import math - import torchaudio +# --------------------------------------------------------------------------- +# Lifespan: startup / teardown +# --------------------------------------------------------------------------- - waveform, sr = torchaudio.load(str(wav_path)) - # Flatten to mono - if waveform.shape[0] > 1: - waveform = waveform.mean(dim=0, keepdim=True) - waveform = waveform.squeeze(0) # shape: (num_samples,) - # 30 ms frames - frame_len = max(1, int(sr * 0.03)) - num_frames = len(waveform) // frame_len - if num_frames < 5: - # Too short to estimate reliably — assume clean - return float("inf") +@asynccontextmanager +async def lifespan(app: FastAPI): + # Ensure data directories exist + for d in [TRANSCRIPTIONS_DIR, UPLOADS_DIR, VOICEPRINTS_DIR]: + d.mkdir(parents=True, exist_ok=True) - frames = waveform[: num_frames * frame_len].reshape(num_frames, frame_len) - frame_rms = frames.pow(2).mean(dim=1).sqrt() # shape: (num_frames,) + # AR-C2: mark any in-progress jobs from a previous process as failed so + # frontend polls receive a definitive terminal state on restart. + recover_orphan_jobs() - sorted_rms, _ = frame_rms.sort() - noise_cutoff = max(1, int(num_frames * 0.20)) - noise_rms = sorted_rms[:noise_cutoff].mean().item() - speech_rms = sorted_rms[noise_cutoff:].mean().item() - - if noise_rms < 1e-9: - return float("inf") # Silent noise floor — effectively infinite SNR - - snr_db = 10.0 * math.log10((speech_rms / noise_rms) ** 2) - return snr_db - - -def _maybe_denoise( - wav_path: Path, model: str = None, snr_threshold: float = None -) -> Path: - """Return denoised WAV path if DENOISE_MODEL is set; otherwise return wav_path unchanged.""" - effective_model = (model or DENOISE_MODEL).strip().lower() - if effective_model == "none": - return wav_path - - threshold = snr_threshold if snr_threshold is not None else DENOISE_SNR_THRESHOLD - out_path = wav_path.with_suffix(".denoised.wav") - - if effective_model == "deepfilternet": - import torch, torchaudio + # Initialise voiceprint DB and AS-norm cohort + db = VoiceprintDB(str(VOICEPRINTS_DIR)) + try: + _cohort_path = TRANSCRIPTIONS_DIR / "asnorm_cohort.npy" + if _cohort_path.exists(): + db.load_cohort(str(_cohort_path)) + logger.info("AS-norm cohort loaded from %s", _cohort_path) + else: + _n = db.build_cohort_from_transcriptions( + str(TRANSCRIPTIONS_DIR), save_path=str(_cohort_path) + ) + logger.info("AS-norm cohort built: %d embeddings", _n) + except Exception as exc: + logger.warning( + "AS-norm cohort init failed (identify will use raw cosine): %s", exc + ) + app.state.db = db - snr_db = _estimate_snr(wav_path) - if snr_db >= threshold: - logger.info("DeepFilterNet skipped (SNR=%.1fdB, clean audio)", snr_db) - return wav_path + # Initialise transcription pipeline + app.state.pipeline = TranscriptionPipeline(WHISPER_MODEL, DEVICE, HF_TOKEN) - logger.info( - "DeepFilterNet applying (SNR=%.1fdB < %.1fdB threshold)", - snr_db, - threshold, + # Auth mode warning + if API_KEY is None and not ALLOW_NO_AUTH: + logger.warning( + "API_KEY is not set. Service is OPEN to all requests. " + "Set API_KEY env var or set ALLOW_NO_AUTH=1 to suppress this warning." ) - model, df_state = _load_deepfilternet() - import df as _df_pkg - - audio, sr = torchaudio.load(str(wav_path)) - if sr != df_state.sr(): - audio = torchaudio.functional.resample(audio, sr, df_state.sr()) - audio = audio.contiguous() - with torch.backends.cudnn.flags(enabled=False): - enhanced = _df_pkg.enhance(model, df_state, audio) - torchaudio.save( - str(out_path), - enhanced.unsqueeze(0) if enhanced.dim() == 1 else enhanced, - df_state.sr(), + elif API_KEY is None: + logger.warning( + "API_KEY is not set and ALLOW_NO_AUTH=1. " + "The service is accepting unauthenticated requests intentionally." ) - logger.info("DeepFilterNet: denoised %s → %s", wav_path.name, out_path.name) - - elif effective_model == "noisereduce": - import numpy as np, soundfile as sf, noisereduce as nr - - data, sr = sf.read(str(wav_path), dtype="float32") - reduced = nr.reduce_noise(y=data, sr=sr, stationary=True) - sf.write(str(out_path), reduced, sr) - logger.info("noisereduce: denoised %s → %s", wav_path.name, out_path.name) - else: - logger.warning("Unknown DENOISE_MODEL=%r — skipping denoising", effective_model) - return wav_path - - return out_path - - -API_KEY = (os.getenv("API_KEY") or "").strip() or None -# Paths that must stay open even when API_KEY auth is enabled. "/" is the -# bundled web UI (browsers can't attach a Bearer header to a direct -# navigation — the UI's own fetch() calls to /api/* still carry the key). -# /static/* serves the UI's assets. /healthz is a liveness probe. /docs -# /redoc /openapi.json are FastAPI's auto docs. -# We match exact strings for everything except /static/ to avoid a -# startswith("/docs") bypass like /docsXYZ. -PUBLIC_EXACT_PATHS = { - "/", - "/healthz", - "/docs", - "/redoc", - "/openapi.json", -} -PUBLIC_PATH_PREFIXES = ("/static/",) + logger.info("API_KEY auth enabled for /api/* and / (Bearer or X-API-Key).") -# Cap how much any single upload can occupy on disk. Whisper + pyannote -# comfortably handle 2 GB of audio (~20 h @ typical bitrates); anything -# beyond that is either a mistake or an attempt to exhaust storage. -MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(2 * 1024 * 1024 * 1024))) -UPLOAD_CHUNK = 1 << 20 # 1 MiB + yield + # No teardown required; daemon threads finish on process exit. -for d in [TRANSCRIPTIONS_DIR, UPLOADS_DIR, VOICEPRINTS_DIR]: - d.mkdir(parents=True, exist_ok=True) -if API_KEY is None: - logger.warning( - "API_KEY is not set. The service is accepting unauthenticated requests. " - "Do not expose this port to untrusted networks." - ) -else: - logger.info("API_KEY auth enabled for /api/* and / (Bearer or X-API-Key).") +# --------------------------------------------------------------------------- +# Application +# --------------------------------------------------------------------------- -app = FastAPI(title="Voice Transcribe", version="1.0.0") +app = FastAPI(title="VoScript", version="0.7.0", lifespan=lifespan) -_cors_origins_env = os.getenv("CORS_ALLOW_ORIGINS", "*").strip() -_cors_origins = [o.strip() for o in _cors_origins_env.split(",") if o.strip()] or ["*"] +# CORS +_cors_origins = [o.strip() for o in CORS_ORIGINS.split(",") if o.strip()] or ["*"] app.add_middleware( CORSMiddleware, allow_origins=_cors_origins, allow_credentials=False, allow_methods=["*"], - allow_headers=["*"], + allow_headers=[ + "Authorization", + "Content-Type", + "X-API-Key", + "X-Request-Id", + ], expose_headers=["*"], ) app.mount("/static", StaticFiles(directory="static"), name="static") +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.headers.setdefault("X-XSS-Protection", "1; mode=block") + return response + + @app.middleware("http") async def require_api_key(request: Request, call_next): if API_KEY is None: @@ -259,505 +157,10 @@ async def require_api_key(request: Request, call_next): return await call_next(request) -@app.get("/healthz") -async def healthz(): - return {"ok": True} - - -pipeline = TranscriptionPipeline() -voiceprint_db = VoiceprintDB(str(VOICEPRINTS_DIR)) - -# Auto-build or load AS-norm cohort from existing transcriptions. This lets -# identify() use normalized scores instead of raw cosine against speaker- -# dependent baselines. Failure is non-fatal — we fall back to raw cosine. -try: - _cohort_path = TRANSCRIPTIONS_DIR / "asnorm_cohort.npy" - if _cohort_path.exists(): - voiceprint_db.load_cohort(str(_cohort_path)) - logger.info("AS-norm cohort loaded from %s", _cohort_path) - else: - _n = voiceprint_db.build_cohort_from_transcriptions( - str(TRANSCRIPTIONS_DIR), save_path=str(_cohort_path) - ) - logger.info("AS-norm cohort built: %d embeddings", _n) -except Exception as _exc: - logger.warning( - "AS-norm cohort init failed (identify will use raw cosine): %s", _exc - ) - -# In-memory job status -jobs: dict[str, dict] = {} - -# Serialise GPU work: only one transcription runs at a time. -# Concurrent HTTP uploads are fine; they queue here before touching the GPU. -import threading as _threading - -_gpu_sem = _threading.Semaphore(1) - - -def _convert_to_wav(input_path: Path) -> Path: - """Convert any audio format to 16 kHz mono WAV via ffmpeg. - - We shell out to ffmpeg directly instead of using pydub because pydub's - mediainfo_json() raises KeyError('codec_type') on newer ffmpeg output - for some Opus/container combinations (see jiaaro/pydub#638). ffmpeg - itself handles every format faster-whisper / pyannote ingest, so this - is the simpler and more robust path. - """ - wav_path = input_path.with_suffix(".wav") - if input_path.suffix.lower() == ".wav": - return input_path - # "--" closes ffmpeg's option parsing so a filename like `-foo.mp4` - # can't be interpreted as a flag. Defense in depth — the upload path - # already strips client-side directory components and prefixes the - # job_id, so input_path always starts with /data/uploads/tr_... - subprocess.run( - [ - "ffmpeg", - "-y", - "-v", - "error", - "-i", - str(input_path), - "-ar", - "16000", - "-ac", - "1", - "-f", - "wav", - "--", - str(wav_path), - ], - check=True, - ) - return wav_path - - -_HASH_INDEX_FILE = TRANSCRIPTIONS_DIR / "hash_index.json" -_hash_index_lock = __import__("threading").Lock() - - -def _compute_file_hash(path: Path) -> str: - sha256 = hashlib.sha256() - with open(path, "rb") as f: - while chunk := f.read(1 << 20): - sha256.update(chunk) - return sha256.hexdigest() - - -def _lookup_hash(file_hash: str) -> str | None: - """Return existing tr_id if hash is already transcribed and result exists.""" - with _hash_index_lock: - if not _HASH_INDEX_FILE.exists(): - return None - index = json.loads(_HASH_INDEX_FILE.read_text()) - tr_id = index.get(file_hash) - if tr_id and (TRANSCRIPTIONS_DIR / tr_id / "result.json").exists(): - return tr_id - return None - - -def _register_hash(file_hash: str, tr_id: str) -> None: - with _hash_index_lock: - index = ( - json.loads(_HASH_INDEX_FILE.read_text()) - if _HASH_INDEX_FILE.exists() - else {} - ) - index[file_hash] = tr_id - _HASH_INDEX_FILE.write_text(json.dumps(index, indent=2)) - - -def _run_transcription( - job_id: str, - audio_path: Path, - language: str, - min_speakers: int, - max_speakers: int, - denoise_model: str = None, - snr_threshold: float = None, - file_hash: str = None, -): - """Background transcription worker.""" - try: - jobs[job_id]["status"] = "converting" - wav_path = _convert_to_wav(audio_path) - - jobs[job_id]["status"] = "queued" - with _gpu_sem: - jobs[job_id]["status"] = ( - "denoising" - if (denoise_model or DENOISE_MODEL) != "none" - else "transcribing" - ) - clean_path = _maybe_denoise(wav_path, denoise_model, snr_threshold) - - # DF peaks at ~15 GB reserved in PyTorch's CUDA cache. - # ctranslate2 (Whisper) calls cudaMalloc directly and sees the OS - # free memory — not PyTorch's allocator pool — so it OOMs unless we - # explicitly flush the cache before Whisper cold-loads. - try: - import torch as _torch - import gc as _gc - - _gc.collect() - if _torch.cuda.is_available(): - _torch.cuda.empty_cache() - except Exception: - pass - - jobs[job_id]["status"] = "transcribing" - result = pipeline.process( - str(clean_path), - raw_audio_path=str(wav_path), - language=language, - min_speakers=min_speakers or None, - max_speakers=max_speakers or None, - ) - - # Release cached CUDA memory so the next queued job has headroom - try: - import torch as _torch - import gc as _gc - - _gc.collect() - if _torch.cuda.is_available(): - _torch.cuda.empty_cache() - except Exception: - pass - - # Match speakers against voiceprint DB - jobs[job_id]["status"] = "identifying" - speaker_map = {} - for spk_label, embedding in result["speaker_embeddings"].items(): - spk_id, spk_name, sim = voiceprint_db.identify( - embedding, threshold=VOICEPRINT_THRESHOLD - ) - speaker_map[spk_label] = { - "matched_id": spk_id, - "matched_name": spk_name or spk_label, - "similarity": round(sim, 4), - "embedding_key": spk_label, - } - - # Build final segments - segments = [] - for i, seg in enumerate(result["segments"]): - spk_label = seg["speaker"] - match = speaker_map.get(spk_label, {}) - out = { - "id": i, - "start": seg["start"], - "end": seg["end"], - "text": seg["text"], - "speaker_label": spk_label, - "speaker_id": match.get("matched_id"), - "speaker_name": match.get("matched_name", spk_label), - "similarity": match.get("similarity", 0), - } - # Forward word-level timestamps when forced alignment produced them - # (0.3.0+). Absent when the language has no alignment model or - # alignment failed — clients must treat the key as optional. - if seg.get("words"): - out["words"] = seg["words"] - segments.append(out) - - # Save transcription result - effective_denoise = (denoise_model or DENOISE_MODEL).strip().lower() - effective_snr = ( - snr_threshold if snr_threshold is not None else DENOISE_SNR_THRESHOLD - ) - tr = { - "id": job_id, - "filename": audio_path.name, - "created_at": datetime.now().isoformat(), - "status": "completed", - "language": language, - "segments": segments, - "speaker_map": speaker_map, - "unique_speakers": result["unique_speakers"], - "params": { - "language": language or "auto", - "denoise_model": effective_denoise, - "snr_threshold": effective_snr, - "voiceprint_threshold": VOICEPRINT_THRESHOLD, - "min_speakers": min_speakers, - "max_speakers": max_speakers, - }, - } - - tr_dir = TRANSCRIPTIONS_DIR / job_id - tr_dir.mkdir(exist_ok=True) - (tr_dir / "result.json").write_text( - json.dumps(tr, ensure_ascii=False, indent=2), encoding="utf-8" - ) - - # Save raw embeddings for later enrollment - import numpy as np - - for spk_label, emb in result["speaker_embeddings"].items(): - np.save(tr_dir / f"emb_{spk_label}.npy", emb) - - if file_hash: - _register_hash(file_hash, job_id) - - jobs[job_id]["status"] = "completed" - jobs[job_id]["result"] = tr - logger.info( - "Job %s completed: %d segments, %d speakers", - job_id, - len(segments), - len(speaker_map), - ) - - except Exception as e: - logger.exception("Job %s failed", job_id) - jobs[job_id]["status"] = "failed" - jobs[job_id]["error"] = str(e) - - -# --- Routes --- - - -@app.get("/", response_class=HTMLResponse) -async def index(): - return (Path("static/index.html")).read_text(encoding="utf-8") - - -@app.post("/api/transcribe") -async def transcribe( - file: UploadFile = File(...), - language: str = Form(None), - min_speakers: int = Form(0), - max_speakers: int = Form(0), - denoise_model: str = Form("none"), - snr_threshold: float = Form(None), -): - # Normalise empty string to None so pipeline treats it as auto-detect. - language = language.strip() if language else None - - job_id = f"tr_{datetime.now():%Y%m%d_%H%M%S}_{uuid.uuid4().hex[:6]}" - - safe_filename = PurePosixPath(file.filename or "upload").name or "upload" - save_path = UPLOADS_DIR / f"{job_id}_{safe_filename}" - - size = 0 - with open(save_path, "wb") as f: - while True: - chunk = file.file.read(UPLOAD_CHUNK) - if not chunk: - break - size += len(chunk) - if size > MAX_UPLOAD_BYTES: - f.close() - save_path.unlink(missing_ok=True) - raise HTTPException( - 413, - f"Upload exceeds MAX_UPLOAD_BYTES ({MAX_UPLOAD_BYTES} bytes)", - ) - f.write(chunk) - - # Dedup: if identical audio was already transcribed, return existing result. - file_hash = _compute_file_hash(save_path) - existing_id = _lookup_hash(file_hash) - if existing_id: - save_path.unlink(missing_ok=True) - logger.info( - "Dedup hit: %s already transcribed as %s", safe_filename, existing_id - ) - return {"id": existing_id, "status": "completed", "deduplicated": True} - - jobs[job_id] = { - "status": "queued", - "filename": safe_filename, - "created_at": datetime.now().isoformat(), - } - thread = Thread( - target=_run_transcription, - args=( - job_id, - save_path, - language, - min_speakers, - max_speakers, - denoise_model, - snr_threshold, - file_hash, - ), - ) - thread.start() - - return {"id": job_id, "status": "queued"} - - -@app.get("/api/jobs/{job_id}") -async def get_job(job_id: str): - if job_id not in jobs: - raise HTTPException(404, "Job not found") - job = jobs[job_id] - resp = {"id": job_id, "status": job["status"], "filename": job.get("filename")} - if job["status"] == "completed": - resp["result"] = job["result"] - elif job["status"] == "failed": - resp["error"] = job.get("error") - return resp - - -@app.get("/api/transcriptions") -async def list_transcriptions(): - results = [] - for tr_dir in sorted(TRANSCRIPTIONS_DIR.iterdir(), reverse=True): - result_file = tr_dir / "result.json" - if result_file.exists(): - data = json.loads(result_file.read_text(encoding="utf-8")) - results.append( - { - "id": data["id"], - "filename": data["filename"], - "created_at": data["created_at"], - "segment_count": len(data["segments"]), - "speaker_count": len(data.get("unique_speakers", [])), - } - ) - return results - - -@app.get("/api/transcriptions/{tr_id}") -async def get_transcription(tr_id: str): - result_file = TRANSCRIPTIONS_DIR / tr_id / "result.json" - if not result_file.exists(): - raise HTTPException(404, "Transcription not found") - return json.loads(result_file.read_text(encoding="utf-8")) - - -@app.put("/api/transcriptions/{tr_id}/segments/{seg_id}/speaker") -async def reassign_speaker( - tr_id: str, seg_id: int, speaker_name: str = Form(...), speaker_id: str = Form(None) -): - """Reassign a segment to a different speaker and optionally enroll the voiceprint.""" - result_file = TRANSCRIPTIONS_DIR / tr_id / "result.json" - if not result_file.exists(): - raise HTTPException(404, "Transcription not found") - data = json.loads(result_file.read_text(encoding="utf-8")) - - seg = next((s for s in data["segments"] if s["id"] == seg_id), None) - if seg is None: - raise HTTPException(404, "Segment not found") - - seg["speaker_name"] = speaker_name - if speaker_id: - seg["speaker_id"] = speaker_id - - result_file.write_text( - json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" - ) - return {"ok": True} - - -@app.post("/api/voiceprints/enroll") -async def enroll_speaker( - tr_id: str = Form(...), - speaker_label: str = Form(...), - speaker_name: str = Form(...), - speaker_id: str = Form(None), -): - """Enroll or update a voiceprint from a transcription's speaker embedding.""" - import numpy as np - - emb_path = TRANSCRIPTIONS_DIR / tr_id / f"emb_{speaker_label}.npy" - if not emb_path.exists(): - raise HTTPException(404, "Embedding not found for this speaker label") - embedding = np.load(emb_path) - - if speaker_id and voiceprint_db.get_speaker(speaker_id): - voiceprint_db.update_speaker(speaker_id, embedding, name=speaker_name) - return {"action": "updated", "speaker_id": speaker_id} - else: - new_id = voiceprint_db.add_speaker(speaker_name, embedding) - return {"action": "created", "speaker_id": new_id} - - -@app.get("/api/voiceprints") -async def list_voiceprints(): - return voiceprint_db.list_speakers() - - -@app.post("/api/voiceprints/rebuild-cohort") -async def rebuild_cohort(): - """Rebuild the AS-norm cohort from all processed transcriptions.""" - cohort_path = TRANSCRIPTIONS_DIR / "asnorm_cohort.npy" - n = voiceprint_db.build_cohort_from_transcriptions( - str(TRANSCRIPTIONS_DIR), save_path=str(cohort_path) - ) - return {"cohort_size": n, "saved_to": str(cohort_path)} - - -@app.delete("/api/voiceprints/{speaker_id}") -async def delete_voiceprint(speaker_id: str): - try: - voiceprint_db.delete_speaker(speaker_id) - except ValueError as e: - raise HTTPException(404, str(e)) - return {"ok": True} - - -@app.put("/api/voiceprints/{speaker_id}/name") -async def rename_voiceprint(speaker_id: str, name: str = Form(...)): - try: - voiceprint_db.rename_speaker(speaker_id, name) - except ValueError as e: - raise HTTPException(404, str(e)) - return {"ok": True} - - -@app.get("/api/export/{tr_id}") -async def export_transcription(tr_id: str, format: str = "srt"): - result_file = TRANSCRIPTIONS_DIR / tr_id / "result.json" - if not result_file.exists(): - raise HTTPException(404, "Transcription not found") - data = json.loads(result_file.read_text(encoding="utf-8")) - segments = data["segments"] - - if format == "srt": - lines = [] - for i, seg in enumerate(segments, 1): - start = _format_srt_time(seg["start"]) - end = _format_srt_time(seg["end"]) - lines.append( - f"{i}\n{start} --> {end}\n[{seg['speaker_name']}] {seg['text']}\n" - ) - return PlainTextResponse( - "\n".join(lines), - media_type="text/srt", - headers={"Content-Disposition": f"attachment; filename={tr_id}.srt"}, - ) - elif format == "txt": - lines = [] - for seg in segments: - ts = _format_timestamp(seg["start"]) - lines.append(f"[{ts}] {seg['speaker_name']}: {seg['text']}") - return PlainTextResponse( - "\n".join(lines), - media_type="text/plain", - headers={"Content-Disposition": f"attachment; filename={tr_id}.txt"}, - ) - elif format == "json": - return FileResponse( - result_file, media_type="application/json", filename=f"{tr_id}.json" - ) - else: - raise HTTPException(400, "Unsupported format. Use: srt, txt, json") - - -def _format_srt_time(seconds: float) -> str: - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int((seconds % 1) * 1000) - return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" - +# --------------------------------------------------------------------------- +# Routers +# --------------------------------------------------------------------------- -def _format_timestamp(seconds: float) -> str: - m = int(seconds // 60) - s = int(seconds % 60) - return f"{m:02d}:{s:02d}" +app.include_router(health.router) +app.include_router(transcriptions.router) +app.include_router(voiceprints.router) diff --git a/app/pipeline.py b/app/pipeline.py index bff48f7..5761cec 100755 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -11,12 +11,19 @@ import os import logging +from pathlib import Path + import numpy as np import torch import torchaudio logger = logging.getLogger(__name__) +# WeSpeaker ResNet34 推荐输入 ≥1.5s;过短的 chunk 嵌入方差显著放大会污染 speaker_avg。 +# 上限避免超长 chunk 带来的显存浪费。两者均可通过环境变量覆盖。 +MIN_EMBED_DURATION = float(os.getenv("MIN_EMBED_DURATION", "1.5")) +MAX_EMBED_DURATION = float(os.getenv("MAX_EMBED_DURATION", "10.0")) + class TranscriptionPipeline: def __init__( @@ -44,7 +51,7 @@ def whisper(self): decoupled from the transcriber. """ if self._whisper is None: - from pathlib import Path + # faster_whisper 按需 lazy import,避免在不使用 whisper 的进程里加载 GPU 库 from faster_whisper import WhisperModel compute_type = "float16" if self.device == "cuda" else "int8" @@ -104,7 +111,9 @@ def embedding_model(self): self._embedding_model = Inference(model, window="whole") return self._embedding_model - def transcribe(self, audio_path: str, language: str = None) -> dict: + def transcribe( + self, audio_path: str, language: str = None, no_repeat_ngram_size: int = None + ) -> dict: """Run faster-whisper and return a whisperx-compatible result dict. whisperx.align expects ``{"segments": [...], "language": "..."}`` with @@ -124,14 +133,16 @@ def transcribe(self, audio_path: str, language: str = None) -> dict: lang_arg or "auto", ) - segments_iter, info = self.whisper.transcribe( - audio_path, + whisper_kwargs = dict( language=lang_arg, beam_size=5, vad_filter=True, vad_parameters=dict(min_silence_duration_ms=500), initial_prompt=initial_prompt, ) + if no_repeat_ngram_size and no_repeat_ngram_size >= 3: + whisper_kwargs["no_repeat_ngram_size"] = no_repeat_ngram_size + segments_iter, info = self.whisper.transcribe(audio_path, **whisper_kwargs) segments = [ { "start": round(float(s.start), 3), @@ -178,22 +189,59 @@ def extract_speaker_embeddings( WeSpeaker ResNet34 produces ~256-dim embeddings (vs ECAPA-TDNN 192-dim). The downstream VoiceprintDB is dim-agnostic and infers the dimension on first insert, so no other changes are required. + + PERF-H1: segments are loaded on-demand via torchaudio.load(frame_offset, + num_frames) instead of loading the entire file into memory. A 2-hour + WAV at 16 kHz mono is ~900 MB–2 GB; with segment-level loading the peak + allocation per iteration is bounded by MAX_EMBED_DURATION * sr * 4 bytes + (~640 KB at 16 kHz / 10 s), a >1000x reduction for long recordings. """ - waveform, sr = torchaudio.load(audio_path) - if sr != 16000: - waveform = torchaudio.functional.resample(waveform, sr, 16000) - sr = 16000 - if waveform.shape[0] > 1: - waveform = waveform.mean(dim=0, keepdim=True) + # Obtain file metadata without decoding audio data (torchaudio >= 0.9). + info = torchaudio.info(audio_path) + native_sr = info.sample_rate + target_sr = 16000 + + min_samples = int(MIN_EMBED_DURATION * native_sr) + max_samples = int(MAX_EMBED_DURATION * native_sr) speaker_segments: dict[str, list] = {} for t in turns: spk = t["speaker"] - start_sample = int(t["start"] * sr) - end_sample = int(t["end"] * sr) - chunk = waveform[:, start_sample:end_sample] - if chunk.shape[1] < sr: # skip segments shorter than 1s + start_sample = int(t["start"] * native_sr) + end_sample = int(t["end"] * native_sr) + num_frames = end_sample - start_sample + + if num_frames < min_samples: continue + # 截断过长 chunk 以控制显存占用 + if num_frames > max_samples: + num_frames = max_samples + + # Load only the required segment — no whole-file decode. + try: + chunk, chunk_sr = torchaudio.load( + audio_path, + frame_offset=start_sample, + num_frames=num_frames, + ) + except Exception as e: + logger.warning( + "Failed to load segment %s [%d:%d]: %s", + spk, + start_sample, + end_sample, + e, + ) + continue + + # Resample to 16 kHz when the file's native rate differs. + if chunk_sr != target_sr: + chunk = torchaudio.functional.resample(chunk, chunk_sr, target_sr) + + # Downmix multi-channel audio to mono. + if chunk.shape[0] > 1: + chunk = chunk.mean(dim=0, keepdim=True) + speaker_segments.setdefault(spk, []).append(chunk) embeddings = {} @@ -205,7 +253,7 @@ def extract_speaker_embeddings( # Inference.__call__ accepts a dict with waveform (1, T) tensor # and sample_rate; window="whole" returns one ndarray per chunk. emb = self.embedding_model( - {"waveform": chunk.to(self.device), "sample_rate": 16000} + {"waveform": chunk.to(self.device), "sample_rate": target_sr} ) emb_list.append(np.asarray(emb)) if emb_list: @@ -338,6 +386,7 @@ def process( language: str = None, min_speakers: int = None, max_speakers: int = None, + no_repeat_ngram_size: int = None, ) -> dict: """Full pipeline: transcribe → diarize → forced-align → extract embeddings. @@ -348,7 +397,9 @@ def process( embed_path = raw_audio_path or audio_path logger.info("Starting transcription: %s", audio_path) - transcription_result = self.transcribe(audio_path, language=language) + transcription_result = self.transcribe( + audio_path, language=language, no_repeat_ngram_size=no_repeat_ngram_size + ) logger.info( "Transcription done: %d segments", len(transcription_result.get("segments", [])), diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/audio_service.py b/app/services/audio_service.py new file mode 100644 index 0000000..a56b6ea --- /dev/null +++ b/app/services/audio_service.py @@ -0,0 +1,330 @@ +"""Audio processing utilities. + +Covers format conversion (ffmpeg), content-addressed deduplication via a +SHA-256 hash index, and optional noise reduction (DeepFilterNet / noisereduce). +""" + +import fcntl +import hashlib +import json +import logging +import re +import subprocess +import threading +from pathlib import Path + +from fastapi import HTTPException + +from config import ( + DENOISE_MODEL, + DENOISE_SNR_THRESHOLD, + FFMPEG_TIMEOUT_SEC, + TRANSCRIPTIONS_DIR, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Input-validation helpers (SEC-C2 / BP-C2) +# --------------------------------------------------------------------------- + +_TR_ID_RE = re.compile(r"^tr_[A-Za-z0-9_-]{1,64}$") +_SPEAKER_LABEL_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$") +_CTRL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]") + + +def safe_log_filename(name: str | None) -> str: + """Strip control chars (incl. CR/LF, ANSI escapes) from user-supplied names + before writing them to logs, so attackers can't inject fake log lines. + """ + if not name: + return "" + return _CTRL_CHAR_RE.sub("?", name) + + +def safe_tr_dir(tr_id: str) -> Path: + """Validate tr_id and return the transcription directory path. + + Raises HTTPException(400) if tr_id contains path traversal characters. + """ + if not _TR_ID_RE.match(tr_id): + raise HTTPException(400, f"Invalid transcription ID format: {tr_id!r}") + path = (TRANSCRIPTIONS_DIR / tr_id).resolve() + if not str(path).startswith(str(TRANSCRIPTIONS_DIR.resolve())): + raise HTTPException(400, "Path traversal detected") + return path + + +def safe_speaker_label(label: str) -> str: + """Validate speaker_label to prevent path traversal via filename injection.""" + if not _SPEAKER_LABEL_RE.match(label): + raise HTTPException(400, f"Invalid speaker label: {label!r}") + return label + + +# --------------------------------------------------------------------------- +# ffmpeg conversion +# --------------------------------------------------------------------------- + + +def convert_to_wav(input_path: Path) -> Path: + """Convert any audio format to 16 kHz mono WAV via ffmpeg. + + We shell out to ffmpeg directly instead of using pydub because pydub's + mediainfo_json() raises KeyError('codec_type') on newer ffmpeg output + for some Opus/container combinations (see jiaaro/pydub#638). ffmpeg + itself handles every format faster-whisper / pyannote ingest, so this + is the simpler and more robust path. + """ + wav_path = input_path.with_suffix(".wav") + if input_path.suffix.lower() == ".wav": + return input_path + # "--" closes ffmpeg's option parsing so a filename like `-foo.mp4` + # can't be interpreted as a flag. Defense in depth — the upload path + # already strips client-side directory components and prefixes the + # job_id, so input_path always starts with /data/uploads/tr_... + try: + subprocess.run( + [ + "ffmpeg", + "-y", + "-v", + "error", + "-i", + str(input_path), + "-ar", + "16000", + "-ac", + "1", + "-f", + "wav", + "--", + str(wav_path), + ], + check=True, + timeout=FFMPEG_TIMEOUT_SEC, + ) + except subprocess.TimeoutExpired: + wav_path.unlink(missing_ok=True) + logger.error( + "ffmpeg timed out after %ds on %s", FFMPEG_TIMEOUT_SEC, input_path.name + ) + raise HTTPException(504, f"ffmpeg timed out after {FFMPEG_TIMEOUT_SEC}s") + return wav_path + + +# --------------------------------------------------------------------------- +# Content-addressed hash index +# --------------------------------------------------------------------------- + +_HASH_INDEX_FILE = TRANSCRIPTIONS_DIR / "hash_index.json" +# CQ-H5: threading.Lock only works within a single process. Replace with an +# fcntl-based file lock so multiple uvicorn workers can safely share the index. +_hash_index_thread_lock = threading.Lock() # intra-process guard (belt) + + +def _with_file_lock(path: Path, func): + """Execute *func* while holding an exclusive fcntl lock on *path*.lock. + + Falls back to the in-process threading lock on platforms without fcntl + (e.g. Windows). The thread lock is always acquired first so that two + threads in the same process don't race through the fcntl acquire. + """ + lock_path = str(path) + ".lock" + with _hash_index_thread_lock: + try: + with open(lock_path, "w") as lock_f: + try: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) + return func() + finally: + fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) + except (AttributeError, OSError): + # fcntl unavailable (Windows) or lock file can't be opened — the + # thread lock we already hold is sufficient for single-process use. + return func() + + +def compute_file_hash(path: Path) -> str: + sha256 = hashlib.sha256() + with open(path, "rb") as f: + while chunk := f.read(1 << 20): + sha256.update(chunk) + return sha256.hexdigest() + + +async def save_upload_and_hash( + file, save_path: Path, max_bytes: int, chunk_size: int +) -> tuple[int, str]: + """Save an uploaded UploadFile to *save_path* using async I/O and compute + its SHA-256 digest on the fly, avoiding a second full-file read. + + Returns (total_bytes_written, hex_digest). + + Raises ValueError when the upload exceeds *max_bytes* — the caller is + responsible for unlinking *save_path* and returning HTTP 413. + + PERF-C2: replaces the former synchronous open()+write() loop and the + separate compute_file_hash() call, both of which blocked the asyncio + event loop for several seconds on large files. + """ + import aiofiles + + sha256 = hashlib.sha256() + size = 0 + async with aiofiles.open(save_path, "wb") as f: + while True: + chunk = await file.read(chunk_size) + if not chunk: + break + size += len(chunk) + if size > max_bytes: + raise ValueError(f"Upload exceeds MAX_UPLOAD_BYTES ({max_bytes} bytes)") + await f.write(chunk) + sha256.update(chunk) + return size, sha256.hexdigest() + + +def lookup_hash(file_hash: str) -> str | None: + """Return existing tr_id if hash is already transcribed and result exists.""" + + def _do(): + if not _HASH_INDEX_FILE.exists(): + return None + return json.loads(_HASH_INDEX_FILE.read_text()).get(file_hash) + + tr_id = _with_file_lock(_HASH_INDEX_FILE, _do) + if tr_id and (TRANSCRIPTIONS_DIR / tr_id / "result.json").exists(): + return tr_id + return None + + +def register_hash(file_hash: str, tr_id: str) -> None: + def _do(): + index = ( + json.loads(_HASH_INDEX_FILE.read_text()) + if _HASH_INDEX_FILE.exists() + else {} + ) + index[file_hash] = tr_id + _HASH_INDEX_FILE.write_text(json.dumps(index, indent=2)) + + _with_file_lock(_HASH_INDEX_FILE, _do) + + +# --------------------------------------------------------------------------- +# DeepFilterNet / noise reduction +# --------------------------------------------------------------------------- + +# Lazy module-level handle so DeepFilterNet loads once at first use. +_df_model = None +_df_state = None + + +def _load_deepfilternet(): + global _df_model, _df_state + if _df_model is None: + import df as _df_pkg + + _df_model, _df_state, _ = _df_pkg.init_df() + logger.info("DeepFilterNet model loaded") + return _df_model, _df_state + + +def _estimate_snr(wav_path: Path) -> float: + """Estimate signal-to-noise ratio (dB) using a simple energy-based heuristic. + + Strategy: divide the audio into short frames, compute per-frame RMS energy, + then treat the bottom 20 % of frame energies as the noise floor and the top + 80 % as the speech signal. SNR = 10 * log10(speech_power / noise_power). + + This is intentionally lightweight — no VAD model, no STFT — so it adds + negligible latency before deciding whether to invoke DeepFilterNet. + """ + import math + + import torchaudio + + waveform, sr = torchaudio.load(str(wav_path)) + # Flatten to mono + if waveform.shape[0] > 1: + waveform = waveform.mean(dim=0, keepdim=True) + waveform = waveform.squeeze(0) # shape: (num_samples,) + + # 30 ms frames + frame_len = max(1, int(sr * 0.03)) + num_frames = len(waveform) // frame_len + if num_frames < 5: + # Too short to estimate reliably — assume clean + return float("inf") + + frames = waveform[: num_frames * frame_len].reshape(num_frames, frame_len) + frame_rms = frames.pow(2).mean(dim=1).sqrt() # shape: (num_frames,) + + sorted_rms, _ = frame_rms.sort() + noise_cutoff = max(1, int(num_frames * 0.20)) + noise_rms = sorted_rms[:noise_cutoff].mean().item() + speech_rms = sorted_rms[noise_cutoff:].mean().item() + + if noise_rms < 1e-9: + return float("inf") # Silent noise floor — effectively infinite SNR + + snr_db = 10.0 * math.log10((speech_rms / noise_rms) ** 2) + return snr_db + + +def maybe_denoise( + wav_path: Path, model: str = None, snr_threshold: float = None +) -> Path: + """Return denoised WAV path if DENOISE_MODEL is set; otherwise return wav_path unchanged.""" + effective_model = (model or DENOISE_MODEL).strip().lower() + if effective_model == "none": + return wav_path + + threshold = snr_threshold if snr_threshold is not None else DENOISE_SNR_THRESHOLD + out_path = wav_path.with_suffix(".denoised.wav") + + if effective_model == "deepfilternet": + import torch + import torchaudio + + snr_db = _estimate_snr(wav_path) + if snr_db >= threshold: + logger.info("DeepFilterNet skipped (SNR=%.1fdB, clean audio)", snr_db) + return wav_path + + logger.info( + "DeepFilterNet applying (SNR=%.1fdB < %.1fdB threshold)", + snr_db, + threshold, + ) + df_model, df_state = _load_deepfilternet() + import df as _df_pkg + + audio, sr = torchaudio.load(str(wav_path)) + if sr != df_state.sr(): + audio = torchaudio.functional.resample(audio, sr, df_state.sr()) + audio = audio.contiguous() + with torch.backends.cudnn.flags(enabled=False): + enhanced = _df_pkg.enhance(df_model, df_state, audio) + torchaudio.save( + str(out_path), + enhanced.unsqueeze(0) if enhanced.dim() == 1 else enhanced, + df_state.sr(), + ) + logger.info("DeepFilterNet: denoised %s → %s", wav_path.name, out_path.name) + + elif effective_model == "noisereduce": + import soundfile as sf + import noisereduce as nr + + data, sr = sf.read(str(wav_path), dtype="float32") + reduced = nr.reduce_noise(y=data, sr=sr, stationary=True) + sf.write(str(out_path), reduced, sr) + logger.info("noisereduce: denoised %s → %s", wav_path.name, out_path.name) + + else: + logger.warning("Unknown DENOISE_MODEL=%r — skipping denoising", effective_model) + return wav_path + + return out_path diff --git a/app/services/job_service.py b/app/services/job_service.py new file mode 100644 index 0000000..08673d4 --- /dev/null +++ b/app/services/job_service.py @@ -0,0 +1,383 @@ +"""Job management and background transcription worker. + +Owns: +- _LRUJobsDict: bounded thread-safe LRU store for job states +- jobs: the singleton in-memory job registry +- _gpu_sem: semaphore that serialises GPU access to one transcription at a time +- run_transcription: the background worker function +""" + +import json +import logging +import threading +from collections import OrderedDict +from datetime import datetime +from pathlib import Path + +from config import ( + DENOISE_MODEL, + DENOISE_SNR_THRESHOLD, + JOBS_MAX_CACHE, + TRANSCRIPTIONS_DIR, + VOICEPRINT_THRESHOLD, +) +from services.audio_service import convert_to_wav, maybe_denoise, register_hash + +logger = logging.getLogger(__name__) + +# CQ-C1: counter used to periodically rebuild AS-norm cohort inside the +# transcription worker so it becomes active without requiring a server restart. +_cohort_rebuild_counter: dict = {} + + +# --------------------------------------------------------------------------- +# Status persistence helpers (AR-C2) +# --------------------------------------------------------------------------- + + +def _write_status( + job_id: str, + status: str, + error: str | None = None, + filename: str | None = None, +) -> None: + """Write job status to disk for persistence across process restarts.""" + status_path = TRANSCRIPTIONS_DIR / job_id / "status.json" + status_path.parent.mkdir(parents=True, exist_ok=True) + try: + payload = { + "status": status, + "updated_at": datetime.now().isoformat(), + "error": error, + } + if filename is not None: + payload["filename"] = filename + status_path.write_text(json.dumps(payload)) + except Exception as exc: + logger.warning("Failed to write status.json for %s: %s", job_id, exc) + + +def recover_orphan_jobs() -> None: + """Mark any in-progress jobs as failed if the process was restarted. + + Called once during application lifespan startup so that frontend polls + receive a definitive terminal state instead of hanging on stale + 'transcribing'/'queued' statuses written by a previous process. + """ + try: + for status_path in TRANSCRIPTIONS_DIR.glob("*/status.json"): + try: + data = json.loads(status_path.read_text()) + if data.get("status") not in ("completed", "failed"): + data["status"] = "failed" + data["error"] = "Process restarted while job was in progress" + data["updated_at"] = datetime.now().isoformat() + status_path.write_text(json.dumps(data)) + logger.info( + "AR-C2: marked orphan job %s as failed", + status_path.parent.name, + ) + except Exception as exc: + logger.warning( + "AR-C2: could not recover orphan job at %s: %s", status_path, exc + ) + except Exception as exc: + logger.warning("AR-C2: orphan job recovery scan failed: %s", exc) + + +# --------------------------------------------------------------------------- +# Bounded LRU job store (CQ-H2 / PERF-C1) +# --------------------------------------------------------------------------- + + +class _LRUJobsDict: + """Thread-safe LRU dict for job states with bounded size.""" + + def __init__(self, maxsize: int = 200): + self._d: OrderedDict = OrderedDict() + self._lock = threading.Lock() + self._maxsize = maxsize + + def __setitem__(self, key, value): + with self._lock: + if key in self._d: + self._d.move_to_end(key) + self._d[key] = value + if len(self._d) > self._maxsize: + self._d.popitem(last=False) + + def __getitem__(self, key): + with self._lock: + return self._d[key] + + def __contains__(self, key): + with self._lock: + return key in self._d + + def get(self, key, default=None): + with self._lock: + return self._d.get(key, default) + + +# In-memory job status — bounded LRU (CQ-H2 / PERF-C1) +jobs: _LRUJobsDict = _LRUJobsDict(maxsize=JOBS_MAX_CACHE) + +# Serialise GPU work: only one transcription runs at a time. +# Concurrent HTTP uploads are fine; they queue here before touching the GPU. +_gpu_sem = threading.Semaphore(1) + + +# --------------------------------------------------------------------------- +# Background worker +# --------------------------------------------------------------------------- + + +def run_transcription( + job_id: str, + audio_path: Path, + language: str, + min_speakers: int, + max_speakers: int, + pipeline, + voiceprint_db, + denoise_model: str = None, + snr_threshold: float = None, + file_hash: str = None, + no_repeat_ngram_size: int = 0, +): + """Background transcription worker. + + Accepts *pipeline* and *voiceprint_db* as explicit arguments (injected by + the route handler from app.state) to avoid global-state coupling and make + the function testable in isolation. + """ + # Track intermediate files so they can be cleaned up on both success and + # failure. Initialise to audio_path so the cleanup guard (path != audio_path) + # is safe even if an exception fires before the variables are reassigned. + wav_path: Path = audio_path + clean_path: Path = audio_path + try: + jobs[job_id]["status"] = "converting" + _write_status(job_id, "converting", filename=audio_path.name) + wav_path = convert_to_wav(audio_path) + + jobs[job_id]["status"] = "queued" + _write_status(job_id, "queued") + with _gpu_sem: + _intermediate = ( + "denoising" + if (denoise_model or DENOISE_MODEL) != "none" + else "transcribing" + ) + jobs[job_id]["status"] = _intermediate + _write_status(job_id, _intermediate) + clean_path = maybe_denoise(wav_path, denoise_model, snr_threshold) + + # DF peaks at ~15 GB reserved in PyTorch's CUDA cache. + # ctranslate2 (Whisper) calls cudaMalloc directly and sees the OS + # free memory — not PyTorch's allocator pool — so it OOMs unless we + # explicitly flush the cache before Whisper cold-loads. + try: + import gc as _gc + + import torch as _torch + + _gc.collect() + if _torch.cuda.is_available(): + _torch.cuda.empty_cache() + except Exception as exc: + logger.warning("pre-whisper CUDA cache flush failed: %s", exc) + + jobs[job_id]["status"] = "transcribing" + _write_status(job_id, "transcribing") + result = pipeline.process( + str(clean_path), + raw_audio_path=str(wav_path), + language=language, + min_speakers=min_speakers or None, + max_speakers=max_speakers or None, + no_repeat_ngram_size=no_repeat_ngram_size or None, + ) + + # Release cached CUDA memory so the next queued job has headroom + try: + import gc as _gc + + import torch as _torch + + _gc.collect() + if _torch.cuda.is_available(): + _torch.cuda.empty_cache() + except Exception as exc: + logger.warning("post-pipeline CUDA cache flush failed: %s", exc) + + # Delete intermediate files — keep only the original uploaded file. + # clean_path is the denoised WAV (may equal wav_path if denoising was skipped). + # wav_path is the converted WAV (may equal audio_path if input was already WAV). + if clean_path != wav_path: + clean_path.unlink(missing_ok=True) + if wav_path != audio_path: + wav_path.unlink(missing_ok=True) + + # Match speakers against voiceprint DB + jobs[job_id]["status"] = "identifying" + _write_status(job_id, "identifying") + speaker_map = {} + for spk_label, embedding in result["speaker_embeddings"].items(): + spk_id, spk_name, sim = voiceprint_db.identify( + embedding, threshold=VOICEPRINT_THRESHOLD + ) + speaker_map[spk_label] = { + "matched_id": spk_id, + "matched_name": spk_name or spk_label, + "similarity": round(sim, 4), + "embedding_key": spk_label, + } + + # [CQ-H6] 若所有 turn 均短于 MIN_EMBED_DURATION,embeddings 为空 → 不产生 speaker_map。 + # 记录明确 warning,让前端可以区分"无可登记 speaker"并避免传 'undefined' 字符串。 + warning = None + if not speaker_map: + warning = "no_speakers_detected" + logger.warning( + "Job %s produced no speaker embeddings (all turns < min duration)", + job_id, + ) + + # Consolidate multiple diarization clusters that resolved to the same + # enrolled speaker. Pick the cluster with the highest similarity as the + # canonical label; remap all others to it so one person appears under a + # single label rather than as separate SPEAKER_XX entries. + _id_to_clusters: dict = {} + for _lbl, _info in speaker_map.items(): + _mid = _info["matched_id"] + if _mid is not None: + _id_to_clusters.setdefault(_mid, []).append((_lbl, _info["similarity"])) + + _cluster_remap: dict[str, str] = {} + for _mid, _cluster_list in _id_to_clusters.items(): + _cluster_list.sort(key=lambda x: x[1], reverse=True) + _canonical_lbl = _cluster_list[0][0] + for _lbl, _ in _cluster_list[1:]: + _cluster_remap[_lbl] = _canonical_lbl + logger.info( + "Job %s: merged cluster %s → %s (same enrolled speaker %s)", + job_id, + _lbl, + _canonical_lbl, + _mid, + ) + + # Build final segments with remapped speaker labels + segments = [] + for i, seg in enumerate(result["segments"]): + spk_label = seg["speaker"] + canonical_label = _cluster_remap.get(spk_label, spk_label) + match = speaker_map.get(canonical_label, speaker_map.get(spk_label, {})) + out = { + "id": i, + "start": seg["start"], + "end": seg["end"], + "text": seg["text"], + "speaker_label": canonical_label, + "speaker_id": match.get("matched_id"), + "speaker_name": match.get("matched_name", canonical_label), + "similarity": match.get("similarity", 0), + } + # Forward word-level timestamps when forced alignment produced them + # (0.3.0+). Absent when the language has no alignment model or + # alignment failed — clients must treat the key as optional. + if seg.get("words"): + out["words"] = seg["words"] + segments.append(out) + + # Derive unique_speakers from resolved speaker names (ordered by first + # appearance in the transcript, deduplicated). Enrolled speakers appear + # under their enrolled name; unidentified clusters keep their raw label. + _seen_spk: set = set() + resolved_unique_speakers: list = [] + for seg in segments: + name = seg["speaker_name"] + if name not in _seen_spk: + _seen_spk.add(name) + resolved_unique_speakers.append(name) + + # Save transcription result + effective_denoise = (denoise_model or DENOISE_MODEL).strip().lower() + effective_snr = ( + snr_threshold if snr_threshold is not None else DENOISE_SNR_THRESHOLD + ) + tr = { + "id": job_id, + "filename": audio_path.name, + "created_at": datetime.now().isoformat(), + "status": "completed", + "language": language, + "segments": segments, + "speaker_map": speaker_map, + "unique_speakers": resolved_unique_speakers, + "params": { + "language": language or "auto", + "denoise_model": effective_denoise, + "snr_threshold": effective_snr, + "voiceprint_threshold": VOICEPRINT_THRESHOLD, + "min_speakers": min_speakers, + "max_speakers": max_speakers, + "no_repeat_ngram_size": no_repeat_ngram_size or 0, + }, + } + if warning is not None: + tr["warning"] = warning + + tr_dir = TRANSCRIPTIONS_DIR / job_id + tr_dir.mkdir(exist_ok=True) + (tr_dir / "result.json").write_text( + json.dumps(tr, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # Save raw embeddings for later enrollment + import numpy as np + + for spk_label, emb in result["speaker_embeddings"].items(): + np.save(tr_dir / f"emb_{spk_label}.npy", emb) + + if file_hash: + register_hash(file_hash, job_id) + + # CQ-C1: After each successful transcription, check if AS-norm cohort + # should be rebuilt. Every 10th job (or when cohort is absent) we rebuild + # so that newly enrolled speakers contribute to normalization without + # requiring a server restart. + try: + _cohort_rebuild_counter[0] = _cohort_rebuild_counter.get(0, 0) + 1 + if voiceprint_db.cohort_size == 0 or _cohort_rebuild_counter[0] % 10 == 0: + voiceprint_db.build_cohort_from_transcriptions(str(TRANSCRIPTIONS_DIR)) + logger.info( + "AS-norm cohort rebuilt: size=%d", voiceprint_db.cohort_size + ) + except Exception as exc: + logger.warning("cohort rebuild failed: %s", exc) + + jobs[job_id]["status"] = "completed" + jobs[job_id]["result"] = tr + _write_status(job_id, "completed") + logger.info( + "Job %s completed: %d segments, %d speakers", + job_id, + len(segments), + len(speaker_map), + ) + + except Exception as e: + logger.exception("Job %s failed", job_id) + jobs[job_id]["status"] = "failed" + jobs[job_id]["error"] = str(e) + _write_status(job_id, "failed", error=str(e)) + # Best-effort cleanup of intermediate files on failure. + try: + if clean_path != wav_path: + clean_path.unlink(missing_ok=True) + if wav_path != audio_path: + wav_path.unlink(missing_ok=True) + except Exception: + pass diff --git a/app/static/index.html b/app/static/index.html index 6a46f95..4c44902 100755 --- a/app/static/index.html +++ b/app/static/index.html @@ -3,8 +3,13 @@ + voscript +