diff --git a/.dockerignore b/.dockerignore index 77d0cd46..a09520f6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,15 @@ env/ ENV/ .env .env.* + +# Mutable operator hotword files must stay out of image layers. Keep only the +# reviewed onboarding example in the source build context. +hot-server.txt +hot.txt +hot-rule.txt +hot-rectify.txt +!hot-server.example.txt + build/ dist/ logs/ diff --git a/.env.example b/.env.example index 75b469d7..5e81d106 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -# Public image -CAPSWRITER_SERVER_IMAGE=ghcr.io/df-wu/capswriter-offline-server:latest +# v1 is released as source. Compose builds this local image from the v1 tree. +# The public ghcr.io/...:latest tag belongs to v2 and must not be used here. +CAPSWRITER_SERVER_IMAGE=capswriter-offline-v1-local:source # Model selection # qwen_asr = 主預設; 對話精度高, HTTP API / 長段轉錄場景的首選 @@ -33,7 +34,10 @@ CAPSWRITER_LOG_LEVEL=INFO # Expose POST /v1/audio/transcriptions in addition to WebSocket. The OpenAI # Python/Node SDKs can use this endpoint by setting base_url. See docs/HTTP_API.md. CAPSWRITER_HTTP_API_ENABLE=false -CAPSWRITER_HTTP_API_BIND=127.0.0.1 +# Inside the container, listen on all interfaces for Docker port forwarding. +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +# Publish the HTTP port on host loopback unless a trusted proxy requires more. +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 CAPSWRITER_HTTP_API_PORT=6017 # Bearer token. Empty disables auth (only safe behind a private bind address). CAPSWRITER_HTTP_API_KEY= diff --git a/.github/workflows/publish-server-image.yml b/.github/workflows/publish-server-image.yml deleted file mode 100644 index dba432bb..00000000 --- a/.github/workflows/publish-server-image.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: publish-server-image - -on: - push: - branches: - - master - workflow_dispatch: - -permissions: - contents: read - packages: write - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository_owner }}/capswriter-offline-server - tags: | - type=raw,value=latest - type=sha - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: docker/server/Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/v1-maintenance.yml b/.github/workflows/v1-maintenance.yml new file mode 100644 index 00000000..16d26b5b --- /dev/null +++ b/.github/workflows/v1-maintenance.yml @@ -0,0 +1,76 @@ +name: v1 maintenance checks + +on: + pull_request: + branches: + - archive/v1-legacy + push: + branches: + - maintenance/v1 + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: v1-maintenance-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + protocol-and-runtime-smoke: + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - ubuntu-24.04 + - windows-2022 + python-version: + - "3.10" + - "3.12" + env: + PYTHONDONTWRITEBYTECODE: "1" + PYTHONNOUSERSITE: "1" + + steps: + - name: Check out legacy maintenance line + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: requirements-maintenance.txt + + - name: Install maintenance test dependencies + run: >- + python -m pip install + --disable-pip-version-check + --only-binary=:all: + --requirement requirements-maintenance.txt + + - name: Run protocol and server regression tests + env: + CAPSWRITER_LOG_DIR: ${{ runner.temp }}/capswriter-maintenance-logs + run: python -m unittest discover -s tests -p "test_*.py" -v + + - name: Compile supported entry points + run: >- + python -m compileall -q + config_client.py config_server.py + core_client.py core_server.py + start_client.py start_server.py + util docker/server + + - name: Validate container entrypoint syntax + if: runner.os == 'Linux' && matrix.python-version == '3.10' + run: bash -n docker/server/entrypoint.sh + + - name: Validate Compose configuration + if: runner.os == 'Linux' && matrix.python-version == '3.10' + run: docker compose --env-file .env.example config --quiet diff --git a/.gitignore b/.gitignore index e22e5037..97bffdb5 100644 --- a/.gitignore +++ b/.gitignore @@ -178,6 +178,8 @@ test_*.py test_*.ipynb test_*.md test_*.txt +!tests/ +!tests/test_*.py stocks.txt example_*.py example_.ipynb @@ -187,4 +189,4 @@ file_*.txt release *.dll -*.exe \ No newline at end of file +*.exe diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8205d72d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Haujet Zhao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..b2a37cc5 --- /dev/null +++ b/README.en.md @@ -0,0 +1,149 @@ +# CapsWriter-Offline fork v1 — Legacy Server + Desktop Client + +> **v1 is an isolated, best-effort maintenance line.** Its primary deliverable +> is the Linux/headless ASR server. The same source retains compatibility with +> the upstream 2.5-alpha-era Windows desktop client. +> +> [繁體中文](readme.md) · English + +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Track](https://img.shields.io/badge/track-fork--v1%20legacy-64748B)](docs/en/maintenance.md) +[![Server](https://img.shields.io/badge/server-Linux%20%7C%20Docker-2496ED?logo=docker&logoColor=white)](docs/docker-server.md) + +## First understand the v1 server/client split + +```mermaid +flowchart LR + C[Legacy Windows desktop client
start_client.py] -->|WebSocket :6016| S[v1 ASR Server
model・FFmpeg・inference] + O[OpenAI SDK / curl] -->|optional HTTP :6017| S + S --> R[transcript] +``` + +| Component | v1 content | Release status | +|---|---|---| +| **Server** | Linux bare-metal/Docker, WebSocket `6016`, optional transcription-only HTTP `6017`, model bootstrap, GPU preference/CPU fallback | Primary maintained v1 path; GitHub Releases provide source for local builds | +| **Desktop client** | Upstream-era `start_client.py`: Windows GUI, tray, hotkeys, microphone, clipboard/text injection | Source compatibility only; no v1 Windows EXE unless a release explicitly attaches a real-Windows-qualified artifact | +| **External API caller** | Compatible SDK/curl code may use the documented `whisper-1` transcription subset | API interface, not a bundled client application | + +**v1 does not contain the v2 Web Console, no-GUI CLI, Textual TUI, or universal +Windows package.** Use v2 when you need those surfaces. + +## Release and image boundary + +- v1 GitHub Releases are **source-only pre-releases**. +- Source archives include both legacy server/API/container code and the + compatibility-preserved Windows desktop client source. +- No v1 container image or Windows executable is currently published. +- `ghcr.io/df-wu/capswriter-offline-server:latest` belongs to **v2** and must + not be used for v1. +- v1 Compose builds `capswriter-offline-v1-local:source` from the current tree. + +## Quick start: v1 Linux server + +Prerequisites: Linux, Docker Engine, the Compose plugin, and model storage. +NVIDIA GPU support is optional; CPU fallback is available. + +```bash +cp .env.example .env +cp hot-server.example.txt hot-server.txt +docker compose build --pull capswriter-server +docker compose up -d capswriter-server +docker compose ps +docker compose logs -f capswriter-server +``` + +Default WebSocket endpoint: + +```text +ws://127.0.0.1:6016 +``` + +See [v1 Docker server](docs/docker-server.md) for models, GPU/CPU selection, +volumes, and troubleshooting. + +## Optional OpenAI-compatible HTTP API + +The HTTP API shares the recognizer with the WebSocket server and is disabled by +default. It implements only the documented file-transcription subset, not +translation or the complete OpenAI Audio API. + +Enable it in `.env` with a token: + +```dotenv +CAPSWRITER_HTTP_API_ENABLE=true +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 +CAPSWRITER_HTTP_API_PORT=6017 +CAPSWRITER_HTTP_API_KEY=replace-with-a-long-random-token +``` + +Recreate the server after changing `.env`. Compose passes these settings into +the container and publishes port `6017` on host loopback by default. Keep +`CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1` unless a trusted reverse proxy with +authentication and TLS requires a wider host bind. Compatible callers may +point their base URL at `http://127.0.0.1:6017/v1`; unsupported fields may be +rejected. + +See the [English HTTP API guide](docs/en/http-api.md) for the exact contract, +security limits, and SDK/curl examples. + +## Legacy Windows desktop client + +v1 source retains the original desktop flow: + +```text +start_server.py --WebSocket :6016--> start_client.py +``` + +The desktop client owns tray, hotkeys, microphone, clipboard, and text +injection. The server loads the model and performs inference. This is not the +v2 universal package, and the current v1 release does not include an EXE. + +A self-built Windows artifact still needs real-host launch/exit, tray, hotkey, +microphone, clipboard, FFmpeg, model, known-audio, and child-cleanup validation. + +## Support scope + +| Path | Status | Automated evidence | Remaining real-host evidence | +|---|---|---|---| +| Linux Docker server | Primary legacy server path | Ubuntu tests, Compose config, entrypoint shell, protocol/API units | Disposable image build, model load, Mandarin/English known audio, GPU/CPU host | +| Linux bare-metal server | Best effort | Python 3.10/3.12 server tests | FFmpeg, native libraries, model, supervision | +| Windows desktop source | Compatibility-preserved | Windows Python 3.10/3.12 syntax/protocol tests | Tray, hotkeys, microphone, clipboard, PyInstaller artifact | +| Optional HTTP API | Legacy compatibility | Auth, upload bound, format, routing tests | Live authenticated model-backed transcription | +| macOS | Not release-qualified | No complete gate | No project-level support claim | + +Passing CI does not certify model quality, a GPU backend, audio hardware, or a +Windows desktop release. + +## Maintenance and branch rules + +- Development branch: `maintenance/v1` +- Standing comparison PR base: `archive/v1-legacy` +- Never merge v1 into `master` or bulk-backport v2 into v1. +- Only critical security, compatibility, model-asset, and contract fixes belong + here. +- v1 tags use `fork-v1..`; pre-releases may add `-rc.`. + +Policies: + +- [English maintenance policy](docs/en/maintenance.md) +- [繁體中文維護政策](docs/zh-TW/maintenance.md) + +## Documentation + +| Document | Covers | +|---|---| +| [v1 Docker server](docs/docker-server.md) | Local source build, models, GPU/CPU, volumes, operations | +| [HTTP API](docs/en/http-api.md) | Transcription subset, auth, limits, SDK/curl | +| [v1 maintenance policy](docs/en/maintenance.md) | Branches, support, qualification, residual risks | +| [v1 release notes](docs/en/release-notes.md) | RC deliverables, server/client boundary, remaining qualification | +| [Upstream release history](https://github.com/HaujetZhao/CapsWriter-Offline/releases) | Upstream-era product history | + +## Upstream and license + +This line derives from the 2.5-alpha-era desktop/recognition code in +[HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline) +and adds the fork's maintained Linux server, Docker, and HTTP API changes. New +feature development belongs to fork v2. + +License: [MIT](LICENSE). diff --git a/config_server.py b/config_server.py index c6a7be5a..7fba3683 100644 --- a/config_server.py +++ b/config_server.py @@ -113,6 +113,12 @@ class ServerConfig: addr = _env_str("CAPSWRITER_SERVER_ADDR", "0.0.0.0") port = _env_str("CAPSWRITER_SERVER_PORT", "6016") + # 单个 WebSocket JSON frame 上限。官方客户端的文件分块约 5.12 MiB + # (Base64 后),默认 8 MiB 可保留既有行为并阻止无限制 frame 分配。 + websocket_max_message_mb = int( + _env_str("CAPSWRITER_WS_MAX_MESSAGE_MB", "8") + ) + # 语音模型选择:'fun_asr_nano', 'sensevoice', 'paraformer', 'qwen_asr' model_type = _env_str("CAPSWRITER_MODEL_TYPE", "qwen_asr") diff --git a/core_server.py b/core_server.py index 3cbf48f5..26b80eee 100644 --- a/core_server.py +++ b/core_server.py @@ -68,7 +68,11 @@ async def run_websocket_server(): # 2. 启动服务器 logger.info(f"WebSocket 服务器正在启动,监听地址: {Config.addr}:{Config.port}") async with websockets.serve( - ws_recv, Config.addr, Config.port, subprotocols=["binary"], max_size=None + ws_recv, + Config.addr, + Config.port, + subprotocols=["binary"], + max_size=max(1, Config.websocket_max_message_mb) * 1024 * 1024, ): send_task = asyncio.create_task(ws_send()) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 2facb04d..36ee5ead 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -1,6 +1,9 @@ services: capswriter-server: - image: ${CAPSWRITER_SERVER_IMAGE:-ghcr.io/df-wu/capswriter-offline-server:latest} + image: ${CAPSWRITER_SERVER_IMAGE:-capswriter-offline-v1-local:source} + build: + context: . + dockerfile: docker/server/Dockerfile restart: unless-stopped deploy: resources: @@ -18,6 +21,15 @@ services: CAPSWRITER_SERVER_ADDR: 0.0.0.0 CAPSWRITER_SERVER_PORT: ${CAPSWRITER_SERVER_PORT:-6016} + # Optional HTTP API. Docker forwarding requires a container-wide bind; + # the published host port remains loopback-only by default. + CAPSWRITER_HTTP_API_ENABLE: ${CAPSWRITER_HTTP_API_ENABLE:-false} + CAPSWRITER_HTTP_API_BIND: ${CAPSWRITER_HTTP_API_BIND:-0.0.0.0} + CAPSWRITER_HTTP_API_PORT: ${CAPSWRITER_HTTP_API_PORT:-6017} + CAPSWRITER_HTTP_API_KEY: ${CAPSWRITER_HTTP_API_KEY:-} + CAPSWRITER_HTTP_API_MAX_UPLOAD_MB: ${CAPSWRITER_HTTP_API_MAX_UPLOAD_MB:-100} + CAPSWRITER_HTTP_API_TASK_TIMEOUT: ${CAPSWRITER_HTTP_API_TASK_TIMEOUT:-600} + CAPSWRITER_ENABLE_TRAY: "false" CAPSWRITER_LOG_LEVEL: ${CAPSWRITER_LOG_LEVEL:-INFO} CAPSWRITER_LOG_DIR: /app/logs @@ -37,6 +49,7 @@ services: NVIDIA_DRIVER_CAPABILITIES: compute,utility,graphics ports: - "${CAPSWRITER_SERVER_PORT:-6016}:${CAPSWRITER_SERVER_PORT:-6016}" + - "${CAPSWRITER_HTTP_API_HOST_BIND:-127.0.0.1}:${CAPSWRITER_HTTP_API_PORT:-6017}:${CAPSWRITER_HTTP_API_PORT:-6017}" volumes: - ./models:/app/models - ./hot-server.txt:/app/hot-server.txt diff --git a/docker-compose.yml b/docker-compose.yml index b80c8a4c..7351da1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ services: capswriter-server: - image: ${CAPSWRITER_SERVER_IMAGE:-ghcr.io/df-wu/capswriter-offline-server:latest} + image: ${CAPSWRITER_SERVER_IMAGE:-capswriter-offline-v1-local:source} + build: + context: . + dockerfile: docker/server/Dockerfile restart: unless-stopped deploy: resources: @@ -27,13 +30,14 @@ services: CAPSWRITER_SERVER_PORT: ${CAPSWRITER_SERVER_PORT:-6016} # ===== OpenAI-Compatible HTTP API (opt-in; see docs/HTTP_API.md) ===== - # Uncomment to expose POST /v1/audio/transcriptions on a second port. - # CAPSWRITER_HTTP_API_ENABLE: "true" - # CAPSWRITER_HTTP_API_BIND: 0.0.0.0 - # CAPSWRITER_HTTP_API_PORT: ${CAPSWRITER_HTTP_API_PORT:-6017} - # CAPSWRITER_HTTP_API_KEY: ${CAPSWRITER_HTTP_API_KEY:-} - # CAPSWRITER_HTTP_API_MAX_UPLOAD_MB: ${CAPSWRITER_HTTP_API_MAX_UPLOAD_MB:-100} - # CAPSWRITER_HTTP_API_TASK_TIMEOUT: ${CAPSWRITER_HTTP_API_TASK_TIMEOUT:-600} + # Disabled by default. The container must listen on all interfaces so + # Docker can forward the host's loopback-only port mapping below. + CAPSWRITER_HTTP_API_ENABLE: ${CAPSWRITER_HTTP_API_ENABLE:-false} + CAPSWRITER_HTTP_API_BIND: ${CAPSWRITER_HTTP_API_BIND:-0.0.0.0} + CAPSWRITER_HTTP_API_PORT: ${CAPSWRITER_HTTP_API_PORT:-6017} + CAPSWRITER_HTTP_API_KEY: ${CAPSWRITER_HTTP_API_KEY:-} + CAPSWRITER_HTTP_API_MAX_UPLOAD_MB: ${CAPSWRITER_HTTP_API_MAX_UPLOAD_MB:-100} + CAPSWRITER_HTTP_API_TASK_TIMEOUT: ${CAPSWRITER_HTTP_API_TASK_TIMEOUT:-600} CAPSWRITER_ENABLE_TRAY: "false" CAPSWRITER_LOG_LEVEL: ${CAPSWRITER_LOG_LEVEL:-INFO} @@ -64,8 +68,9 @@ services: # GGML_VK_DISABLE_F16: ${GGML_VK_DISABLE_F16:-} ports: - "${CAPSWRITER_SERVER_PORT:-6016}:${CAPSWRITER_SERVER_PORT:-6016}" - # Uncomment to also expose the OpenAI-compatible HTTP API: - # - "${CAPSWRITER_HTTP_API_PORT:-6017}:${CAPSWRITER_HTTP_API_PORT:-6017}" + # Published only on host loopback by default; the API itself remains + # disabled until CAPSWRITER_HTTP_API_ENABLE=true. + - "${CAPSWRITER_HTTP_API_HOST_BIND:-127.0.0.1}:${CAPSWRITER_HTTP_API_PORT:-6017}:${CAPSWRITER_HTTP_API_PORT:-6017}" volumes: - ./models:/app/models - ./hot-server.txt:/app/hot-server.txt diff --git a/docker/server/.env.example b/docker/server/.env.example index 8209c888..79c1952a 100644 --- a/docker/server/.env.example +++ b/docker/server/.env.example @@ -1,5 +1,6 @@ -# Public image -CAPSWRITER_SERVER_IMAGE=ghcr.io/df-wu/capswriter-offline-server:latest +# v1 is released as source. Compose builds this local image from the v1 tree. +# The public ghcr.io/...:latest tag belongs to v2 and must not be used here. +CAPSWRITER_SERVER_IMAGE=capswriter-offline-v1-local:source # Model selection # qwen_asr = default, better general path for this fork @@ -26,6 +27,16 @@ CAPSWRITER_GPU_DEVICE_COUNT=all CAPSWRITER_SERVER_PORT=6016 CAPSWRITER_LOG_LEVEL=INFO +# OpenAI-compatible HTTP API (opt-in). The container listens on all interfaces +# for Docker forwarding, while Compose publishes only on host loopback. +CAPSWRITER_HTTP_API_ENABLE=false +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 +CAPSWRITER_HTTP_API_PORT=6017 +CAPSWRITER_HTTP_API_KEY= +CAPSWRITER_HTTP_API_MAX_UPLOAD_MB=100 +CAPSWRITER_HTTP_API_TASK_TIMEOUT=600 + # Shared CPU thread hint for CPU-bound stages # On small hosts, 4 is a safe starting point. CAPSWRITER_NUM_THREADS=4 diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 93625b4b..6df3cddf 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -1,12 +1,34 @@ -# OpenAI-Compatible ASR HTTP API +# v1 Server:OpenAI 相容 ASR HTTP API -CapsWriter-Offline 在 WebSocket 服務之外,可選擇性提供一個與 [OpenAI Whisper Audio API](https://platform.openai.com/docs/api-reference/audio) 同形的 HTTP 端點。任何使用 OpenAI Python/Node SDK 的程式只要把 `base_url` 指向本服務、即可零修改改用本地離線識別。 +> [English](en/http-api.md) · 繁體中文 · [專案 README](../readme.md) -> **核心承諾:** 完整離線、與既有 WebSocket 識別共用同一個模型行程、可丟給任何 OpenAI 相容客戶端使用。 +CapsWriter-Offline v1 除了 WebSocket 服務,也能選擇性啟用一個與 +[OpenAI Whisper Audio API](https://platform.openai.com/docs/api-reference/audio) +同形的檔案轉錄端點。這個端點屬於 **ASR Server**、在 Server process 內執行, +並與 WebSocket 共用 model 與 recognizer queue;預設不啟用。 + +它只實作本文件明列的 OpenAI 相容 subset,不是完整 OpenAI Audio API,也不是 +另一個 desktop Client。 --- -## 1. 為什麼存在這個 API +## 1. 先分清楚 Server 與 Client + +| 角色 | Protocol | 責任 | +|---|---|---| +| **v1 ASR Server** | WebSocket `6016`;選用 HTTP `6017` | 載入 FFmpeg 與 model、接收 audio、執行 inference、回傳逐字稿 | +| **Legacy Windows Desktop Client**(`start_client.py`) | 只使用 WebSocket `6016` | 負責 mic、tray、hotkey、clipboard 與 text injection;不會 host 或呼叫這個 HTTP API | +| **外部 API caller** | HTTP `6017` | 另一個 curl、OpenAI SDK 或相容應用程式,把 audio file 上傳給 Server | + +HTTP 與 WebSocket 共用一個 Server recognizer process。Model memory 不會載入兩次, +但工作會進入同一條 serial queue;大量 HTTP request 可能增加 Desktop Client 的延遲, +反之亦然。 + +v1 Release 只保留 `start_client.py` 的 compatibility Client **source**;它走 +WebSocket,不是 HTTP API wrapper。目前的 v1 source-only Release 不附 Windows +executable。 + +## 2. 為什麼存在這個 API - 多數 ASR 應用都已實作 OpenAI SDK 對接,提供相容端點就能直接接管它們的流量。 - 不重新發明 protocol;不為 HTTP 額外載入模型。 @@ -14,56 +36,94 @@ CapsWriter-Offline 在 WebSocket 服務之外,可選擇性提供一個與 [Ope --- -## 2. 啟用 +## 3. 啟用與網路邊界 -服務預設**關閉** HTTP API。透過以下環境變數啟用: +服務預設**關閉** HTTP API。Python Server 的 native default 與 Compose template +刻意不同: -| 變數 | 預設 | 說明 | -|---|---|---| -| `CAPSWRITER_HTTP_API_ENABLE` | `false` | `true` 啟用 HTTP API | -| `CAPSWRITER_HTTP_API_BIND` | `127.0.0.1` | 監聽位址;要對外請改 `0.0.0.0` 並一定要設 `KEY` | -| `CAPSWRITER_HTTP_API_PORT` | `6017` | 監聽 port(與 WebSocket port 不同) | -| `CAPSWRITER_HTTP_API_KEY` | _(空)_ | Bearer token;空字串視為不啟用認證 | -| `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` | `100` | 單次上傳上限(MB) | -| `CAPSWRITER_HTTP_API_TASK_TIMEOUT` | `600` | 單次轉錄超時(秒) | - -### 2.1 Docker - -[`docker-compose.yml`](../docker-compose.yml) 內已有註解區段。最小啟用: - -```yaml -environment: - CAPSWRITER_HTTP_API_ENABLE: "true" - CAPSWRITER_HTTP_API_BIND: 0.0.0.0 - CAPSWRITER_HTTP_API_PORT: "6017" - CAPSWRITER_HTTP_API_KEY: "sk-your-token" # 對外時必填 -ports: - - "6017:6017" +| 設定 | Native Server default | v1 Compose template | 用途 | +|---|---|---|---| +| `CAPSWRITER_HTTP_API_ENABLE` | `false` | `false` | 設為 `true` 才啟動 HTTP Server | +| `CAPSWRITER_HTTP_API_BIND` | `127.0.0.1` | Container 內 `0.0.0.0` | Python HTTP Server 的 listen address | +| `CAPSWRITER_HTTP_API_HOST_BIND` | 不使用 | Host 上 `127.0.0.1` | 僅供 Compose 控制 container port 發布位址 | +| `CAPSWRITER_HTTP_API_PORT` | `6017` | `6017` | HTTP listen/publish port | +| `CAPSWRITER_HTTP_API_KEY` | 空字串 | 空字串 | Bearer token;空字串會停用 authentication | +| `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` | `100` | `100` | 單次 upload 上限(MiB) | +| `CAPSWRITER_HTTP_API_TASK_TIMEOUT` | `600` | `600` | 單次 recognition timeout(秒) | + +Native default 只監聽 loopback。Docker process 必須在 **container 內** bind +`0.0.0.0`,Docker 才能 forward;Compose 再用 +`CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1` 把 host publish 限制在 loopback。這兩個 +address 保護不同的網路邊界,不可混為一談。 + +Compose 即使在 API disabled 時仍宣告 `6017` port mapping;只有 +`CAPSWRITER_HTTP_API_ENABLE=true` 才會真正啟動 HTTP listener。 + +### 3.1 Docker Compose:只供本機使用 + +[`docker-compose.yml`](../docker-compose.yml) 已傳入 opt-in 設定,並預設只在 host +loopback 發布 HTTP port。最小啟用方式是在 `.env` 設定: + +```dotenv +CAPSWRITER_HTTP_API_ENABLE=true +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 +CAPSWRITER_HTTP_API_PORT=6017 +CAPSWRITER_HTTP_API_KEY=sk-your-token ``` +Container 內必須 bind `0.0.0.0` 才能接受 Docker forwarding;host 端仍由 +`CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1` 限制在 loopback。只有在前方已有可信任、 +具 authentication 與 TLS 的 reverse proxy 時,才應放寬 host bind。 + ```bash docker compose up -d --force-recreate capswriter-server docker compose logs -f capswriter-server | grep "HTTP API 监听" ``` -### 2.2 裸機 +### 3.2 Native Server:只供本機使用 ```bash export CAPSWRITER_HTTP_API_ENABLE=true +export CAPSWRITER_HTTP_API_KEY=replace-with-a-long-random-token python core_server.py ``` 啟動後 log 會出現: ``` -HTTP API 监听 127.0.0.1:6017 (auth=off) +HTTP API 监听 127.0.0.1:6017 (auth=on) ``` +若沒有設定 `CAPSWRITER_HTTP_API_KEY`,才會顯示 `auth=off`;無 key 只適合受 loopback +限制的本機使用。 + +`CAPSWRITER_HTTP_API_HOST_BIND` 只供 Compose 使用,native Server 不會讀取。 + +### 3.3 LAN 或 remote access + +不要把未加密、未驗證的 raw HTTP listener 直接暴露到 LAN 或 Internet。任何 +non-loopback deployment 都必須同時做到: + +1. 設定非空、足夠長且隨機的 `CAPSWRITER_HTTP_API_KEY`。 +2. 由可信任的 reverse proxy terminate TLS,只透過 HTTPS 傳送 Bearer token。 +3. Reverse proxy 與 Server 同 host 時,保留 + `CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1`。若 proxy 必須從另一個 interface + 存取,盡量把 `CAPSWRITER_HTTP_API_HOST_BIND` 設為該 private interface 的精確 + IP,並加上 firewall allowlist,不要無條件使用 `0.0.0.0`。 +4. Compose 內保留 `CAPSWRITER_HTTP_API_BIND=0.0.0.0`;native Server 則只 bind + reverse proxy 實際需要的 interface。 +5. 在 reverse proxy 另外設定 body size、timeout 與 rate limit。 + +內建 Bearer token 只提供 authentication,不提供 encryption;Server 本身不包含 +TLS 或 rate limiter。`GET /health`、`GET /v1/models` 與固定回傳 `501` 的 +translations endpoint 不受 transcription token 保護,需要時應由 proxy 限制。 + --- -## 3. API 端點 +## 4. API 端點 -### 3.1 `POST /v1/audio/transcriptions` +### 4.1 `POST /v1/audio/transcriptions` OpenAI Whisper 規格的多模態 multipart 端點。 @@ -74,7 +134,7 @@ OpenAI Whisper 規格的多模態 multipart 端點。 | `file` | ✅ | — | 音訊檔案;任何 ffmpeg 能解的格式都可(mp3/wav/m4a/flac/ogg/webm/...) | | `model` | — | `whisper-1` | OpenAI 相容占位,**實際模型由 `CAPSWRITER_MODEL_TYPE` 決定**,本欄位被忽略 | | `language` | — | _(無)_ | 不影響識別,僅在 `verbose_json` 回填 | -| `prompt` | — | _(無)_ | 目前僅 log,**未注入** recognizer context(見 §6 已知限制) | +| `prompt` | — | _(無)_ | 目前僅 log,**未注入** recognizer context(見 §7 已知限制) | | `response_format` | — | `json` | 五選一:`json` / `text` / `verbose_json` / `srt` / `vtt` | | `temperature` | — | `0.0` | OpenAI 相容占位,被忽略 | @@ -114,7 +174,10 @@ Authorization: Bearer # 僅在伺服器有設 KEY } ``` -字級時間戳(`words`)一律提供:CapsWriter 內部本來就計算 token + timestamp 序列,這裡無條件帶出。`segments` 依句末標點切分;模型未提供時間戳時退化為單一 segment。 +`verbose_json` 一律包含 `segments` 與 `words` 欄位,但內容取決於 configured model +是否提供對齊的 token/timestamp;資訊不足時 array 可能為空。SRT/VTT 若無法取得 +對齊資料,會退化為涵蓋完整 duration 的單一 segment。不要假設其切段結果與 OpenAI +Whisper 完全相同。 **錯誤回應:** @@ -123,20 +186,22 @@ Authorization: Bearer # 僅在伺服器有設 KEY | `400` | 空檔案、檔案無法解碼(格式損壞、ffmpeg 解碼失敗)、音訊過短(< 0.05s) | | `401` | 已設 `KEY` 但 `Authorization` header 缺失或 token 錯誤 | | `413` | 上傳超過 `MAX_UPLOAD_MB` | +| `415` | Transcription request 不是 `multipart/form-data` | +| `422` | Multipart field 或 value 未通過 request validation | | `500` | 伺服器找不到 `ffmpeg`;或識別子進程異常 | | `504` | 任務超過 `TASK_TIMEOUT` | -### 3.2 `POST /v1/audio/translations` +### 4.2 `POST /v1/audio/translations` 明確回傳 `501 Not Implemented`。CapsWriter 的本地模型不做語種翻譯。 -### 3.3 `GET /health` +### 4.3 `GET /health` ```json {"status": "ok", "model": "qwen_asr", "version": "2.5-alpha"} ``` -### 3.4 `GET /v1/models` +### 4.4 `GET /v1/models` OpenAI SDK 在某些初始化路徑會呼叫此端點。回應一筆「本服務當前 model」紀錄: @@ -151,9 +216,9 @@ OpenAI SDK 在某些初始化路徑會呼叫此端點。回應一筆「本服務 --- -## 4. 整合範例 +## 5. 整合範例 -### 4.1 OpenAI Python SDK +### 5.1 OpenAI Python SDK ```python from openai import OpenAI @@ -175,7 +240,7 @@ for seg in r.segments: print(f"[{seg.start:.2f}-{seg.end:.2f}] {seg.text}") ``` -### 4.2 curl +### 5.2 curl ```bash # 最小:拿 plain text @@ -190,7 +255,7 @@ curl -X POST https://your-host:6017/v1/audio/transcriptions \ -F response_format=verbose_json ``` -### 4.3 Node / TypeScript +### 5.3 Node / TypeScript ```ts import OpenAI from "openai"; @@ -213,7 +278,7 @@ console.log(r); --- -## 5. 架構與並發模型 +## 6. 架構與並發模型 ``` ┌────────────┐ HTTP POST ┌─────────────────────────┐ @@ -243,24 +308,25 @@ console.log(r); └─────────────────────┴───┘ ``` -### 5.1 共用 recognizer +### 6.1 共用 recognizer 整個服務只有**一個** recognizer 子進程,由 [`util/server/service.py`](../util/server/service.py) 啟動。WebSocket 與 HTTP 任務都丟同一個 `multiprocessing.Queue` (`Cosmic.queue_in`)。模型只載入一次,記憶體不重複。 -### 5.2 結果回流 +### 6.2 結果回流 `recognizer` 把 `Result` 丟回 `Cosmic.queue_out`。[`util/server/server_ws_send.py`](../util/server/server_ws_send.py) 從 queue 拉結果時: 1. 先讓 `task_router.try_resolve(result)` 攔截 HTTP 任務(中間/最終結果都會被吸收)。 2. 若不是 HTTP 任務,走原本的 WebSocket 派發路徑。 -這代表 HTTP 與 WebSocket 走在同一條結果通道上,但**互不干擾**。 +HTTP 與 WebSocket 的結果 routing 彼此分開,不會把逐字稿送錯 caller;但兩者共用 +serial recognizer queue,仍可能互相增加等待時間。 -### 5.3 合成 socket_id +### 6.3 合成 socket_id HTTP 任務使用合成 socket_id `http:` 並加入 `Cosmic.sockets_id`(跨進程 `Manager().list()`)。這是因為 recognizer 子進程在處理任務前會檢查 `task.socket_id not in sockets_id` 來判定上游是否還在;合成的 socket_id 滿足這個檢查、讓 HTTP 任務不會被丟棄。任務完成或取消時,TaskRouter 會把這個 socket_id 移除。 -### 5.4 並發 +### 6.4 並發 - **識別本身嚴格串行:** 識別子進程一次處理一個 Task。多個 HTTP 請求 = 多組 Task 進入同一個 queue,自然形成 FIFO backpressure。 - **解碼/格式化可並行:** ffmpeg 解碼(subprocess)與 OpenAI 格式化(純 Python)在 asyncio loop 內可重疊。 @@ -268,7 +334,7 @@ HTTP 任務使用合成 socket_id `http:` 並加入 `Cosmic.sockets_id` --- -## 6. 已知限制 +## 7. 已知限制 | 限制 | 原因 | 影響 | |---|---|---| @@ -281,7 +347,7 @@ HTTP 任務使用合成 socket_id `http:` 並加入 `Cosmic.sockets_id` --- -### 5.5 推薦的模型搭配 +## 8. 推薦的模型搭配 HTTP API 是 batch / 互動式場景,**推薦切到 `fun_asr_nano` 以降低延遲**: @@ -301,13 +367,14 @@ docker compose -f docker-compose.yml -f docker-compose.fun-asr.yml \ --- -## 7. 安全建議 +## 9. 安全建議 | 場景 | 建議 | |---|---| -| 僅本機 IDE/CLI 用 | 預設即可(`BIND=127.0.0.1`、無 KEY) | -| 同 LAN 共享 | 設 `KEY`,`BIND=0.0.0.0`,並用防火牆限制來源 IP | -| 對外公網 | 設 `KEY`、用 reverse proxy(nginx/caddy)加 TLS、限制 IP、設低 `MAX_UPLOAD_MB` 與 `TASK_TIMEOUT` | +| 僅本機 Native Server | 保留 `BIND=127.0.0.1`;仍建議設定 `KEY` | +| 僅本機 Compose | 保留 container `BIND=0.0.0.0` 與 host `CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1`;仍建議設定 `KEY` | +| 同 LAN 共享 | 將 `CAPSWRITER_HTTP_API_HOST_BIND` 設為需要的 private host IP、設定 `KEY`、以 reverse proxy 加 TLS,並用 firewall 限制來源 IP | +| Remote/Internet | 優先保留 `CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1`,只讓同 host reverse proxy 對外提供 TLS;同時設定 `KEY`、IP/rate/body-size/timeout limits | `KEY` 為純 Bearer token 比對,建議使用 ≥ 32 字元的隨機字串: @@ -317,18 +384,18 @@ python -c "import secrets; print(secrets.token_urlsafe(32))" --- -## 8. 故障排除 +## 10. 故障排除 -### 8.1 啟動時看到 `HTTP API 已启用但系统找不到 ffmpeg` +### 10.1 啟動時看到 `HTTP API 已启用但系统找不到 ffmpeg` - 裸機:`apt install ffmpeg` 或 `brew install ffmpeg` - Docker:請確認 image 是 0.x.x 之後的版本(已內建 ffmpeg)。自建 image 請檢查 [`docker/server/Dockerfile`](../docker/server/Dockerfile) 是否包含 `ffmpeg` -### 8.2 `500 Server misconfigured: ffmpeg not found` +### 10.2 `500 Server misconfigured: ffmpeg not found` 同上。ffmpeg 沒安裝;伺服器側問題。 -### 8.3 `400 Audio decode failed` +### 10.3 `400 Audio decode failed` ffmpeg 拒絕解碼。可能原因: @@ -336,7 +403,7 @@ ffmpeg 拒絕解碼。可能原因: - 容器/編碼損壞 - 試試在本機用 `ffmpeg -i ` 看完整錯誤 -### 8.4 `504 Recognition timeout` +### 10.4 `504 Recognition timeout` 任務超過 `CAPSWRITER_HTTP_API_TASK_TIMEOUT` 還沒做完。 @@ -344,11 +411,11 @@ ffmpeg 拒絕解碼。可能原因: - 對 CPU-only 部署:考慮切換到 `fun_asr_nano` 或啟用 GPU - 若整體吞吐才是瓶頸:橫向擴充 -### 8.5 `413 File too large` +### 10.5 `413 File too large` 上傳超過 `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB`。把該上限調高、或客戶端側先分片。 -### 8.6 `401 Missing or invalid Authorization header` +### 10.6 `401 Missing or invalid Authorization header` ```bash # 正確 @@ -361,7 +428,7 @@ curl -H "Authorization: Bearer sk-token" ... --- -## 9. 程式檔案地圖 +## 11. 程式檔案地圖 | 檔案 | 職責 | |---|---| @@ -375,8 +442,8 @@ curl -H "Authorization: Bearer sk-token" ... --- -## 10. 變更與相容性 +## 12. 變更與相容性 -- 端點與回應格式對齊 OpenAI Whisper API 規格;行為偏差皆已列於 §6。 +- 端點與回應格式對齊 OpenAI Whisper API subset;行為偏差皆已列於 §7。 - 環境變數命名前綴 `CAPSWRITER_HTTP_API_` 為穩定承諾;未來不會更名。 - 預設 disable 是穩定承諾;未啟用 HTTP API 時 server 行為與舊版完全一致。 diff --git a/docs/docker-server.md b/docs/docker-server.md index e97d6180..055f3939 100644 --- a/docs/docker-server.md +++ b/docs/docker-server.md @@ -1,6 +1,7 @@ -# CapsWriter Server Docker 部署 +# CapsWriter v1 Server Docker 部署 -這份文件只覆蓋 **Server 端**。Client 仍維持原本 Windows 用法。 +這份文件只覆蓋 **v1 Server 端**。Client 是保留相容性的 upstream-era Windows +`start_client.py` source workflow;v2 的 Web/CLI/TUI 不存在於此維護線。 ## 目標 @@ -23,7 +24,8 @@ - Docker 內強制關閉 tray、DirectML;Vulkan / CPU 由 runtime 自動判斷 - 模型掛載到 `./models`,日誌持久化到 Docker named volume -> 目前公開 image 的優先目標是 **Tesla P4 / Pascal** 這類已驗證硬體。其他 GPU 世代的廣泛相容性仍列為後續 TODO。 +> v1 GitHub Release 是 source-only,沒有公開 v1 container image。公開 +> `ghcr.io/df-wu/capswriter-offline-server:latest` 屬於 v2,不可用於 v1。 這樣做是為了先得到最穩定、最容易在 Linux 上落地的 server 版本。 @@ -37,15 +39,17 @@ cp docker/server/.env.example .env 根目錄 `.env` 只作為本機啟動設定使用,已被 Docker build context 排除,不會被打進 image。 -## 1. 準備公開 image +## 1. 從 v1 source build 本機 image -預設情況下,這份 compose 會直接使用公開 image: +Compose 預設從目前 v1 checkout 的 `docker/server/Dockerfile` build: -```text -ghcr.io/df-wu/capswriter-offline-server:latest +```bash +docker compose build --pull capswriter-server ``` -你可以在 `.env` 裡覆蓋成別的 tag 或私有 image,但大多數情況不需要。 +本機 image 名稱預設為 `capswriter-offline-v1-local:source`。若 operator 自行發布 +經審查的 private v1 image,可在 `.env` 覆蓋 `CAPSWRITER_SERVER_IMAGE`;不要改用 +v2 `latest`。 ## 2. 啟動 Server @@ -96,9 +100,9 @@ Compose 層額外提供 `CAPSWRITER_GPU_DEVICE_COUNT`: 不需要額外的 helper service。啟動 `capswriter-server` 時,容器會自動下載缺失模型與 backend。 -## 3. 直接使用 image +## 3. 直接使用本機 build 的 image -如果你要直接使用 image,也可以: +先執行前述 `docker compose build`,再使用本機 image: ```bash docker run -d --name capswriter-server \ @@ -108,7 +112,7 @@ docker run -d --name capswriter-server \ -p 6016:6016 \ -v "$(pwd)/models:/app/models" \ -v "$(pwd)/hot-server.txt:/app/hot-server.txt" \ - ghcr.io/df-wu/capswriter-offline-server:latest + capswriter-offline-v1-local:source ``` 改成 `fun_asr_nano`: @@ -122,7 +126,7 @@ docker run -d --name capswriter-server \ -p 6016:6016 \ -v "$(pwd)/models:/app/models" \ -v "$(pwd)/hot-server.txt:/app/hot-server.txt" \ - ghcr.io/df-wu/capswriter-offline-server:latest + capswriter-offline-v1-local:source ``` ## 4. 查看狀態 diff --git a/docs/en/http-api.md b/docs/en/http-api.md new file mode 100644 index 00000000..581b8add --- /dev/null +++ b/docs/en/http-api.md @@ -0,0 +1,299 @@ +# v1 Server: OpenAI-Compatible ASR HTTP API + +> English · [Traditional Chinese](../HTTP_API.md) · +> [Project README](../../README.en.md) + +CapsWriter-Offline v1 can expose an optional HTTP transcription endpoint next +to its WebSocket service. The endpoint runs in the **ASR Server** process, +shares that server's model and recognizer queue, and is disabled by default. + +This is a documented compatibility subset of the OpenAI Whisper Audio API. It +is not the complete OpenAI Audio API, and it is not a second desktop Client. + +## 1. Server and Client boundary + +| Role | Protocol | Responsibility | +|---|---|---| +| **v1 ASR Server** | WebSocket `6016`; optional HTTP `6017` | Loads FFmpeg and the selected model, accepts audio, runs inference, and returns transcripts | +| **Legacy Windows desktop Client** (`start_client.py`) | WebSocket `6016` only | Owns microphone capture, tray, hotkeys, clipboard, and text injection; it does not host or call this HTTP API | +| **External API caller** | HTTP `6017` | A separate curl, OpenAI SDK, or compatible application that uploads an audio file to the Server | + +The HTTP and WebSocket paths use one Server recognizer process. Model memory is +not duplicated, but recognition jobs share one serial queue. Heavy HTTP use can +therefore increase latency for the desktop Client, and vice versa. + +In v1 Releases, `start_client.py` is compatibility-preserved Client **source**; +it connects over WebSocket and is not an HTTP API wrapper. The current v1 +source-only release does not attach a Windows executable. + +## 2. Enable the API safely + +The Python Server and the Compose template intentionally have different bind +defaults: + +| Setting | Native Server default | v1 Compose template | Purpose | +|---|---|---|---| +| `CAPSWRITER_HTTP_API_ENABLE` | `false` | `false` | Enables the HTTP server when set to `true` | +| `CAPSWRITER_HTTP_API_BIND` | `127.0.0.1` | `0.0.0.0` inside the container | Address on which the Python HTTP server listens | +| `CAPSWRITER_HTTP_API_HOST_BIND` | Not used | `127.0.0.1` on the host | Compose-only address used to publish the container port | +| `CAPSWRITER_HTTP_API_PORT` | `6017` | `6017` | HTTP listen and published port | +| `CAPSWRITER_HTTP_API_KEY` | Empty | Empty | Bearer token; an empty value disables authentication | +| `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` | `100` | `100` | Maximum uploaded file size in MiB | +| `CAPSWRITER_HTTP_API_TASK_TIMEOUT` | `600` | `600` | Recognition timeout in seconds | + +The native default is loopback-only. In Docker, the process must listen on +`0.0.0.0` **inside the container** so Docker can forward traffic; Compose then +publishes that port only on host loopback with +`CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1`. The two addresses protect different +network boundaries and must not be treated as interchangeable. + +Compose declares the `6017` port mapping even while the feature is disabled. +No HTTP listener starts until `CAPSWRITER_HTTP_API_ENABLE=true`. + +### 2.1 Docker Compose: local access + +Copy `.env.example` to `.env`, then set: + +```dotenv +CAPSWRITER_HTTP_API_ENABLE=true +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 +CAPSWRITER_HTTP_API_PORT=6017 +CAPSWRITER_HTTP_API_KEY=replace-with-a-long-random-token +CAPSWRITER_HTTP_API_MAX_UPLOAD_MB=100 +CAPSWRITER_HTTP_API_TASK_TIMEOUT=600 +``` + +Recreate the Server after changing `.env`: + +```bash +docker compose up -d --force-recreate capswriter-server +docker compose logs -f capswriter-server +``` + +The API base URL is then `http://127.0.0.1:6017/v1` on the Docker host. + +Generate a suitable token with: + +```bash +python -c "import secrets; print(secrets.token_urlsafe(32))" +``` + +### 2.2 Native Server: local access + +The native Server already defaults to `127.0.0.1:6017`: + +```bash +export CAPSWRITER_HTTP_API_ENABLE=true +export CAPSWRITER_HTTP_API_KEY=replace-with-a-long-random-token +python core_server.py +``` + +`CAPSWRITER_HTTP_API_HOST_BIND` has no effect outside Compose. + +### 2.3 LAN or remote access + +Do not expose the raw unauthenticated HTTP listener to a LAN or the Internet. +For every non-loopback deployment: + +1. Set a non-empty, long random `CAPSWRITER_HTTP_API_KEY`. +2. Put the API behind a trusted reverse proxy that terminates TLS, and send the + Bearer token only over HTTPS. +3. Keep `CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1` when the proxy runs on the same + host. If the proxy must reach another host interface, set + `CAPSWRITER_HTTP_API_HOST_BIND` to that exact private interface address where + possible, then restrict it with a firewall allowlist. +4. Keep `CAPSWRITER_HTTP_API_BIND=0.0.0.0` inside Compose. For a native Server, + change this setting only to the interface required by the reverse proxy. +5. Configure proxy-side request-size, timeout, and rate limits in addition to + the application limits. + +The built-in Bearer token is authentication, not encryption. This Server does +not provide TLS or a rate limiter itself. `GET /health`, `GET /v1/models`, and +the `501` translations response are not protected by the transcription token; +restrict them at the proxy when metadata disclosure matters. + +## 3. Supported API subset + +| Endpoint | Status | Contract | +|---|---|---| +| `POST /v1/audio/transcriptions` | Supported | Multipart file transcription with five response formats | +| `POST /v1/audio/translations` | Not implemented | Always returns `501`; v1 transcribes but does not translate | +| `GET /health` | Supported | Server status, configured model, and v1 source version | +| `GET /v1/models` | Supported | One compatibility record for the Server's configured model | + +Only file transcription is compatible. Chat completions, embeddings, +real-time/SSE streaming, translation, model selection per request, and other +OpenAI endpoints are outside this v1 contract. + +### 3.1 `POST /v1/audio/transcriptions` + +Send `multipart/form-data` with these fields: + +| Field | Required | Default | v1 behavior | +|---|---|---|---| +| `file` | Yes | None | Audio in a format that the Server's FFmpeg can decode | +| `model` | No | `whisper-1` | Compatibility placeholder; the Server uses `CAPSWRITER_MODEL_TYPE` | +| `language` | No | None | Does not change recognition; echoed in `verbose_json` | +| `prompt` | No | None | Its length is logged, but it is not injected into recognizer context | +| `response_format` | No | `json` | `json`, `text`, `verbose_json`, `srt`, or `vtt` | +| `temperature` | No | `0.0` | Compatibility placeholder; ignored by the recognizer | + +When `CAPSWRITER_HTTP_API_KEY` is non-empty, include: + +```text +Authorization: Bearer +``` + +Response formats: + +| `response_format` | Content type | Result | +|---|---|---| +| `json` | `application/json` | `{"text":"..."}` | +| `text` | `text/plain` | Transcript only | +| `verbose_json` | `application/json` | Text, duration, segments, and word/token timestamps when available | +| `srt` | `application/x-subrip` | SRT subtitles | +| `vtt` | `text/vtt` | WebVTT subtitles | + +`verbose_json` timing arrays depend on aligned token data from the configured +model and can be empty. Do not assume Whisper-identical segmentation. + +### 3.2 Limits and errors + +| Status | Meaning | +|---|---| +| `400` | Empty, undecodable, corrupt, or shorter-than-0.05-second audio | +| `401` | Missing, malformed, or incorrect Bearer token when a key is configured | +| `413` | Uploaded file exceeds `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` | +| `415` | Transcription request is not `multipart/form-data` | +| `422` | Multipart field or value fails request validation | +| `500` | FFmpeg is unavailable or recognition fails | +| `504` | Recognition exceeds `CAPSWRITER_HTTP_API_TASK_TIMEOUT` | + +Authentication and media-type checks happen before multipart field parsing. +The application reads the uploaded file with its configured size bound, but a +remote deployment should also reject oversized bodies at its reverse proxy. + +## 4. Call the API + +### 4.1 curl + +```bash +curl http://127.0.0.1:6017/v1/audio/transcriptions \ + -H "Authorization: Bearer replace-with-a-long-random-token" \ + -F "file=@meeting.mp3" \ + -F "response_format=text" +``` + +Omit the `Authorization` header only when the Server key is empty and the +listener is restricted to a trusted local boundary. + +### 4.2 OpenAI Python SDK + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://127.0.0.1:6017/v1", + api_key="replace-with-a-long-random-token", +) + +with open("meeting.mp3", "rb") as audio: + transcript = client.audio.transcriptions.create( + model="whisper-1", + file=audio, + response_format="verbose_json", + ) + +print(transcript.text) +``` + +The OpenAI SDK requires a non-empty `api_key` argument. If Server +authentication is disabled, the SDK value may be any non-empty placeholder; +that does not add security to the Server. + +### 4.3 OpenAI Node SDK + +```ts +import fs from "node:fs"; +import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: "http://127.0.0.1:6017/v1", + apiKey: "replace-with-a-long-random-token", +}); + +const transcript = await client.audio.transcriptions.create({ + model: "whisper-1", + file: fs.createReadStream("meeting.mp3"), + response_format: "text", +}); + +console.log(transcript); +``` + +## 5. Operational behavior + +- FFmpeg decodes uploads to the Server's internal PCM format. +- Long audio is split into overlapping tasks and submitted to the same queue as + WebSocket work. +- One recognizer process handles inference serially. Multiple HTTP requests are + queued; they are not processed by a separate worker pool. +- Decode and response formatting can overlap, but this does not make model + inference concurrent. +- Scale with multiple isolated Server instances when a workload needs greater + throughput. Each instance loads its own model and needs its own resource + budget. + +## 6. Known limitations + +| Limitation | Practical effect | +|---|---| +| Transcription subset only | `/v1/audio/translations` returns `501`; other OpenAI APIs do not exist | +| `model` and `temperature` are placeholders | They do not change Server inference | +| `language` does not select or detect a language | It is response metadata only | +| `prompt` is not recognizer context | Use the Server's supported hotword path or Client-side post-processing instead | +| No HTTP streaming | Use the WebSocket Client protocol when incremental recognition is required | +| Shared, serial recognizer queue | HTTP and WebSocket jobs can delay each other | +| Timeout cancellation is not instantaneous | A submitted recognizer task may retain resources until the queue reaches it | +| No built-in TLS or rate limiting | A reverse proxy and firewall are required for non-loopback access | + +CI covers the API contract, authentication, upload bounds, formats, and routing. +It does not certify model quality, real audio, GPU/CPU performance, or a remote +production deployment. + +## 7. Troubleshooting + +### `500 Server misconfigured: ffmpeg not found` + +Install FFmpeg for a native Server. The repository's Server Dockerfile includes +FFmpeg; if a custom image fails, inspect that image rather than the Client. + +### `400 Audio decode failed` + +Confirm that the upload is a real, non-corrupt audio file and that the Server's +FFmpeg build supports its codec. Running `ffmpeg -i ` on the Server host +usually reveals the decoder error. + +### `504 Recognition timeout` + +Increase `CAPSWRITER_HTTP_API_TASK_TIMEOUT` for long files or CPU-only hosts. +If queued work is the bottleneck, use separate Server instances instead of +assuming the one recognizer can process concurrent inference. + +### `401 Missing or invalid Authorization header` + +Confirm the `Bearer ` prefix, token value, and the Server container's effective +environment. Recreate the Compose service after changing `.env`. + +## 8. Implementation map + +| File | Server responsibility | +|---|---| +| [`util/server/http_api.py`](../../util/server/http_api.py) | FastAPI routes, authentication, upload handling, and task submission | +| [`util/server/http_limits.py`](../../util/server/http_limits.py) | Bounded upload reads | +| [`util/server/audio_decoder.py`](../../util/server/audio_decoder.py) | FFmpeg decoding | +| [`util/server/task_router.py`](../../util/server/task_router.py) | HTTP future and recognizer-result routing | +| [`util/server/openai_formatter.py`](../../util/server/openai_formatter.py) | JSON, text, SRT, and VTT responses | +| [`config_server.py`](../../config_server.py) | Native `CAPSWRITER_HTTP_API_*` defaults | +| [`docker-compose.yml`](../../docker-compose.yml) | Container bind and host publish boundary | diff --git a/docs/en/maintenance.md b/docs/en/maintenance.md new file mode 100644 index 00000000..1e404737 --- /dev/null +++ b/docs/en/maintenance.md @@ -0,0 +1,101 @@ +# Legacy v1 Maintenance, Versioning, and Support + +[繁體中文版](../zh-TW/maintenance.md) + +## Status and branch model + +This repository keeps a separate compatibility line for deployments that cannot move to the current v2 line yet. + +- Development branch: `maintenance/v1` +- Pull request base: `archive/v1-legacy` +- Snapshot at the start of maintenance: `b46ca74` +- Safety tag: `fork-pre-reset-20260525-1411` + +The name “v1” identifies the fork's legacy release track. The snapshot itself contains upstream 2.5-alpha-era code plus the fork's Linux server, container, and HTTP API additions, so the internal `__version__` remains `2.5-alpha`. Do not retarget v1 maintenance pull requests to `master`, and do not interpret that internal string as the branch name. + +## Maintenance scope + +Accepted changes are deliberately narrow: + +- security and privacy fixes; +- crash, shutdown, resource-leak, and protocol correctness fixes; +- dependency compatibility required to keep an already-supported path operational; +- regression tests, CI maintenance, and documentation corrections. + +New product surfaces, model-family migrations, broad refactors, and features developed for v2 should remain on v2. A backport should be small enough to review independently and should preserve the established WebSocket wire format and Windows desktop behavior. + +No end-of-life date or response-time SLA is promised. Fixes are provided on a best-effort basis while the line remains maintainable. + +## Support matrix + +| Path | Maintenance status | Automated coverage | Important limits | +| --- | --- | --- | --- | +| Windows desktop client | Compatibility-preserved | Windows syntax and dependency-light protocol tests on Python 3.10 and 3.12 | Global hotkeys, tray, microphone, clipboard, and PyInstaller output require a real Windows validation host before release. | +| Linux Docker server | Primary legacy server path | Ubuntu syntax, protocol/server unit tests, Compose validation, and entrypoint shell validation; image runtime uses Ubuntu 22.04 / Python 3.10 | Model download, GPU providers, and model-backed inference are not exercised by the lightweight PR gate. | +| Linux bare-metal server | Best effort | Same Python 3.10/3.12 dependency-light server tests | Operators own FFmpeg, model files, native libraries, and service supervision. | +| macOS client/server | Not a release-qualified path | Syntax may compile, but there is no macOS CI job | Permissions and input/audio integration are not maintained here. | +| OpenAI-compatible HTTP API | Optional legacy server feature | Upload-limit and response-format helper tests | It is transcription-only; translation is not implemented, and model/token timestamps are approximations rather than a full OpenAI service implementation. | + +Passing CI means the portable protocol and server control paths passed. It does not certify audio hardware, desktop integration, a GPU backend, model accuracy, or a distributable executable. + +“Windows desktop client” means only the upstream-era `start_client.py` source +workflow retained in this tree. This line does not include the v2 Web Console, +no-GUI CLI, Textual TUI, or universal Windows package. Release notes must list +server/API/container deliverables separately from Windows client source/binary +status. Never advertise a Windows download unless the release attaches an +artifact that passed real-Windows qualification. + +The current v1 release path is source-only. Compose builds a local v1 image from +the checkout; the public `ghcr.io/df-wu/capswriter-offline-server:latest` tag is +v2 and must never be presented as a v1 image. + +## Ingress and security baseline + +The maintenance line retains the existing protocol for valid clients and applies these defensive limits: + +- WebSocket JSON messages default to an 8 MiB frame limit (`CAPSWRITER_WS_MAX_MESSAGE_MB`). +- One decoded audio chunk may be at most 4 MiB. The official 60-second float32/16 kHz/mono chunk is 3,840,000 bytes and remains valid. +- `seg_duration` must be greater than zero and no more than 300 seconds; `seg_overlap` must be between 0 and 30 seconds. Their byte geometry must resolve to nonzero float32 sample boundaries. +- A task ID is limited to 128 characters and context to 8,192 characters. +- A connection must finalize its active task before changing task ID or source. +- Recognition state is namespaced by connection, so identical client task IDs cannot merge transcripts across WebSocket sessions. +- HTTP uploads are read in bounded chunks and stop once `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` is exceeded. +- Bearer tokens use constant-time comparison. Bind the HTTP API to loopback unless authentication and a trusted reverse proxy/TLS boundary are configured. +- The transcription middleware checks Bearer authentication and rejects non-multipart request bodies before Starlette invokes its form parser. + +These are application safeguards, not an internet-facing security perimeter. Do not expose the unauthenticated WebSocket service directly to an untrusted network. + +## Verification + +The PR gate runs the following portable checks on Ubuntu and Windows with Python 3.10 and Python 3.12: + +```bash +python -m pip install -r requirements-maintenance.txt +python -m unittest discover -s tests -p "test_*.py" -v +python -m compileall -q config_client.py config_server.py core_client.py core_server.py start_client.py start_server.py util docker/server +``` + +The Linux/Python 3.10 lane additionally validates `docker/server/entrypoint.sh` and the Compose configuration. Release qualification should also include a disposable container build, a model-backed Mandarin and English transcription smoke test, and a real Windows desktop smoke test. Record the model, backend, driver/runtime, sample audio provenance, and result with the release. + +## Backport and release procedure + +1. Create the change from `maintenance/v1` and compare it only with `archive/v1-legacy`. +2. Keep each backport focused and document any behavior change or new limit. +3. Run the portable gate in an isolated environment. Do not use production models, credentials, or writable production volumes. +4. For runtime-affecting changes, perform the release qualification described above. +5. Open the pull request with base `archive/v1-legacy`; never merge the legacy line into v2 as a whole. +6. Tag legacy releases as `fork-v1..` (or add `-rc.` for a + pre-release), include the exact source commit, and state separately whether + the release ships server source, a container image, Windows client source, + or a qualified Windows binary. Change the internal application version only + as part of an intentional release decision. + +## Known residual risks + +- Application middleware authenticates before multipart parsing, but it does not impose a raw-body cap on chunked transfer encoding or multipart overhead. A reverse proxy should enforce request-size and authentication policy when the HTTP API is exposed beyond loopback. +- A small compressed upload can expand into much larger decoded PCM in FFmpeg; upload size alone is not an audio-duration or decoded-memory limit. +- Recognition uses a single worker and multiprocessing queues without a durable job store or strict global pending-job quota. +- Native model libraries, GPU providers, desktop hooks, and packaging have a larger platform-specific dependency surface than the lightweight CI gate covers. +- The legacy dependency set will accumulate upstream end-of-support risk. A dependency jump that requires broad application changes belongs on v2. + +Report a vulnerability privately when possible. Do not include API keys, transcripts, audio, model artifacts, or full production logs in a public issue. diff --git a/docs/en/release-notes.md b/docs/en/release-notes.md new file mode 100644 index 00000000..a7716163 --- /dev/null +++ b/docs/en/release-notes.md @@ -0,0 +1,75 @@ +# fork-v1.0.0-rc.1 release notes + +> [Maintenance policy](maintenance.md) · [繁體中文](../zh-TW/release-notes.md) · [Project README](../../README.en.md) + +Release-candidate date: **2026-07-18**. This is a source-only GitHub +pre-release for the isolated legacy maintenance line. It is not a v2 release +and does not claim completed real-device/model qualification. + +## Deliverables by role + +### Server + +- Legacy Linux bare-metal and Docker server source. +- WebSocket service on port `6016`. +- Optional, transcription-only OpenAI-compatible HTTP API on port `6017`. +- Model bootstrap, GPU preference/CPU fallback, health checks, and persistent + model/hotword/log paths. +- Defensive WebSocket, decoded-audio, task/context, multipart, authentication, + cancellation, routing, and error/logging bounds. + +The release does **not** publish a v1 container image. Compose builds +`capswriter-offline-v1-local:source` from this tagged source. The public +`capswriter-offline-server:latest` image belongs to v2. + +### Client + +- Compatibility-preserved upstream-era Windows desktop source: + `start_client.py`, tray, hotkeys, microphone, clipboard, and text injection. +- The desktop client connects to the v1 server over WebSocket `6016`. + +The release does **not** attach a Windows executable. It also does not contain +the v2 Web Console, no-GUI CLI, Textual TUI, or universal Windows package. + +### External API callers + +Compatible OpenAI SDK/curl callers may repoint their base URL to the documented +`whisper-1` file-transcription subset. This endpoint is a server interface, not +a bundled v1 client application. Translation and the complete OpenAI Audio API +are not implemented. + +## Maintenance changes + +- Bound WebSocket frames, decoded chunks, segmentation geometry, task IDs, and + context without changing valid-client wire format. +- Isolate recognition state by connection so identical task IDs cannot merge + or misroute transcripts. +- Authenticate and media-type-check rejected HTTP requests before multipart + parsing; bound uploads and clean cancellation state. +- Redact reflected secrets and private exception detail from errors/logs. +- Correct subtitle timestamp carry behavior. +- Update security-sensitive runtime dependencies and focused regressions. +- Remove the unsafe v1 documentation/default reference to the v2 `latest` + image; v1 now builds its own local source image. + +## Automated evidence + +- Ubuntu 24.04 and Windows 2022. +- Python 3.10 and 3.12. +- Every matrix leg runs the full maintenance test suite and compile checks. +- Compose and entrypoint validation run in the Ubuntu 24.04 / Python 3.10 + validation job. +- Duplicate push/PR matrices passed at the maintenance source baseline. + +## Still required before a stable v1 release + +- Disposable v1 image build and cold model bootstrap. +- Model-backed Mandarin and English known-audio transcription. +- Target CPU/GPU backend and driver/runtime evidence. +- Real Windows desktop launch/exit, tray, hotkey, microphone, clipboard, FFmpeg, + model, and child-cleanup validation. +- A separately reviewed immutable v1 image workflow before advertising any v1 + container image. + +Use [fork v2](https://github.com/DF-wu/CapsWriter-Offline-Container) for active +development and the Web/CLI/TUI/universal package surfaces. diff --git a/docs/zh-TW/maintenance.md b/docs/zh-TW/maintenance.md new file mode 100644 index 00000000..a56633b5 --- /dev/null +++ b/docs/zh-TW/maintenance.md @@ -0,0 +1,98 @@ +# 舊版 v1 維護、版本與支援政策 + +[English version](../en/maintenance.md) + +## 狀態與分支模型 + +本儲存庫為尚未能遷移至目前 v2 路線的部署,保留一條獨立的相容性維護線。 + +- 開發分支:`maintenance/v1` +- Pull request 基底:`archive/v1-legacy` +- 開始維護時的快照:`b46ca74` +- 安全標籤:`fork-pre-reset-20260525-1411` + +「v1」是此 fork 的舊版發行路線名稱。該快照實際包含 upstream 2.5-alpha 時期的程式碼,以及 fork 的 Linux server、容器與 HTTP API 擴充,因此內部 `__version__` 仍為 `2.5-alpha`。v1 維護 PR 不可改以 `master` 為基底,也不可把內部版本字串誤解為分支名稱。 + +## 維護範圍 + +可接受的變更刻意維持狹窄: + +- 安全性與隱私修正; +- 當機、關閉、資源洩漏與協定正確性修正; +- 為維持既有受支援路徑運作所必需的相依套件相容性修正; +- 迴歸測試、CI 維護與文件勘誤。 + +新的產品介面、模型家族遷移、大型重構,以及為 v2 開發的功能,應留在 v2。每一項 backport 都應能獨立審查,並保留既有 WebSocket wire format 與 Windows 桌面行為。 + +目前不承諾停止維護日期或回應時間 SLA;只要此路線仍可合理維護,修正將以 best-effort 方式提供。 + +## 支援矩陣 + +| 路徑 | 維護狀態 | 自動化涵蓋範圍 | 重要限制 | +| --- | --- | --- | --- | +| Windows 桌面 client | 保留相容性 | Windows 上以 Python 3.10、3.12 執行語法檢查與低相依協定測試 | 全域快捷鍵、系統匣、麥克風、剪貼簿與 PyInstaller 產物,發行前仍須在真實 Windows 主機驗證。 | +| Linux Docker server | 舊版主要 server 路徑 | Ubuntu 語法、協定/server 單元測試、Compose 驗證及 entrypoint shell 驗證;image runtime 為 Ubuntu 22.04/Python 3.10 | 輕量 PR gate 不會下載模型,也不會測 GPU provider 或真實模型推論。 | +| Linux 裸機 server | Best effort | 同一組 Python 3.10/3.12 低相依 server 測試 | FFmpeg、模型檔、原生函式庫與服務監控由部署者負責。 | +| macOS client/server | 未列入發行驗證 | 語法可能可編譯,但沒有 macOS CI job | 本路線不維護其權限、輸入與音訊整合。 | +| OpenAI-compatible HTTP API | 選用的舊版 server 功能 | 上傳限制與回應格式 helper 測試 | 僅支援轉錄;不實作翻譯,模型/token 時間戳亦只是近似值,並非完整 OpenAI 服務。 | + +CI 通過只表示可攜式協定與 server 控制路徑通過;不代表音訊硬體、桌面整合、GPU backend、模型準確度或可發行執行檔已獲驗證。 + +「Windows desktop Client」只指此 tree 保留的 upstream-era `start_client.py` +source workflow;此維護線不含 v2 Web Console、無 GUI CLI、Textual TUI 或 +universal Windows package。Release notes 必須分開列出 Server/API/container +deliverable 與 Windows Client source/binary 狀態。沒有 attached artifact 與真實 +Windows qualification 時,不可宣稱有 Windows download。 + +目前 v1 release 為 source-only。Compose 從 checkout build 本機 v1 image;公開 +`ghcr.io/df-wu/capswriter-offline-server:latest` 是 v2,不可宣稱為 v1 image。 + +## 輸入與安全基線 + +維護線對合法 client 保留既有協定,並套用以下防禦性限制: + +- WebSocket JSON 訊息預設上限為 8 MiB(`CAPSWRITER_WS_MAX_MESSAGE_MB`)。 +- 單一解碼後音訊 chunk 上限為 4 MiB;官方 client 的 60 秒 float32/16 kHz/mono chunk 為 3,840,000 bytes,仍可正常使用。 +- `seg_duration` 必須大於零且不超過 300 秒;`seg_overlap` 必須介於 0 至 30 秒,換算後的 byte geometry 必須落在非零的 float32 sample 邊界。 +- task ID 最長 128 字元,context 最長 8,192 字元。 +- 同一連線必須先 finalize 目前 task,才可更換 task ID 或 source。 +- 辨識狀態會依連線分區,因此不同 WebSocket session 使用相同 client task ID 時,不會互相合併逐字稿。 +- HTTP 上傳採有界分塊讀取,一旦超過 `CAPSWRITER_HTTP_API_MAX_UPLOAD_MB` 即停止。 +- Bearer token 採 constant-time 比對。除非已設定驗證及可信任的 reverse proxy/TLS 邊界,HTTP API 應只綁定 loopback。 +- Transcription middleware 會先檢查 Bearer authentication 並拒絕非 multipart body,之後才讓 Starlette 啟動 form parser。 + +這些是應用程式層防護,不能取代對外服務的安全邊界。請勿把未驗證的 WebSocket 服務直接暴露於不可信任網路。 + +## 驗證方式 + +PR gate 會在 Ubuntu 與 Windows 的 Python 3.10 與 Python 3.12 執行以下可攜式檢查: + +```bash +python -m pip install -r requirements-maintenance.txt +python -m unittest discover -s tests -p "test_*.py" -v +python -m compileall -q config_client.py config_server.py core_client.py core_server.py start_client.py start_server.py util docker/server +``` + +Linux/Python 3.10 lane 另外驗證 `docker/server/entrypoint.sh` 與 Compose 設定。發行驗證還應包含一次性容器 build、具真實模型的中英文音訊 smoke test,以及真實 Windows 桌面 smoke test;發行紀錄須註明模型、backend、driver/runtime、測試音訊來源與結果。 + +## Backport 與發行流程 + +1. 從 `maintenance/v1` 製作變更,且只與 `archive/v1-legacy` 比較。 +2. 每項 backport 保持聚焦,並記錄所有行為變更或新增限制。 +3. 在隔離環境執行可攜式 gate;不可使用 production 模型、憑證或可寫入的 production volume。 +4. 影響 runtime 的變更,須再執行前述發行驗證。 +5. PR base 必須是 `archive/v1-legacy`;不可把整條舊版線合併回 v2。 +6. 舊版發行 tag 使用 `fork-v1..`(pre-release 加 `-rc.`),附上 + exact source commit,並分別說明是否交付 Server source、container image、 + Windows Client source 或經驗證的 Windows binary。只有在有意識的發行決策中, + 才變更應用程式內部版本。 + +## 已知殘餘風險 + +- Application middleware 會在 multipart parsing 前驗證,但不會限制 chunked transfer encoding 或 multipart overhead 的 raw body。HTTP API 若不只對 loopback 開放,reverse proxy 仍應執行 request-size 與驗證政策。 +- 小型壓縮檔可在 FFmpeg 解碼後膨脹成大得多的 PCM;上傳大小限制不等於音訊時長或解碼後記憶體限制。 +- 辨識採單一 worker 與 multiprocessing queue,沒有 durable job store,也沒有嚴格的全域 pending-job 配額。 +- 原生模型函式庫、GPU provider、桌面 hook 與封裝的各平台相依面,遠大於輕量 CI gate 的涵蓋範圍。 +- 舊版相依套件會逐漸累積 upstream 終止支援風險;若升級必須大幅更動應用程式,應在 v2 處理。 + +如需回報弱點,請盡可能採私下管道。公開 issue 不可附上 API key、逐字稿、音訊、模型產物或完整 production log。 diff --git a/docs/zh-TW/release-notes.md b/docs/zh-TW/release-notes.md new file mode 100644 index 00000000..6e117e82 --- /dev/null +++ b/docs/zh-TW/release-notes.md @@ -0,0 +1,71 @@ +# fork-v1.0.0-rc.1 Release notes + +> [維護政策](maintenance.md) · [English](../en/release-notes.md) · [專案 README](../../readme.md) + +Release candidate 日期:**2026-07-18**。這是隔離 legacy maintenance line 的 +source-only GitHub pre-release;不是 v2 release,也不宣稱 real-device/model +qualification 已完成。 + +## 依角色列出的交付內容 + +### Server + +- Legacy Linux bare-metal 與 Docker Server source。 +- Port `6016` 的 WebSocket service。 +- Port `6017` 的選用、transcription-only OpenAI 相容 HTTP API。 +- Model bootstrap、GPU preference/CPU fallback、health check,以及 persistent + model/hotword/log path。 +- WebSocket、decoded audio、task/context、multipart、authentication、 + cancellation、routing、error/logging bounds。 + +此 release **不發布 v1 container image**。Compose 從 tagged source build +`capswriter-offline-v1-local:source`;公開 `capswriter-offline-server:latest` +屬於 v2。 + +### Client + +- 保留相容性的 upstream-era Windows desktop source:`start_client.py`、tray、 + hotkey、麥克風、clipboard 與 text injection。 +- Desktop Client 以 WebSocket `6016` 連接 v1 Server。 + +此 release **不附 Windows executable**,也不包含 v2 Web Console、無 GUI CLI、 +Textual TUI 或 universal Windows package。 + +### 外部 API caller + +相容 OpenAI SDK/curl caller 可以把 base URL 指向文件列出的 `whisper-1` file +transcription subset。這是 Server interface,不是 bundled v1 Client app;不實作 +translation 或完整 OpenAI Audio API。 + +## 維護變更 + +- 限制 WebSocket frame、decoded chunk、segmentation geometry、task ID 與 context, + 不改變合法 Client wire format。 +- 依 connection 隔離 recognition state,避免相同 task ID 合併或誤送逐字稿。 +- 在 multipart parsing 前驗證並檢查 media type;限制 upload 並清理 cancellation。 +- 從 error/log 遮蔽 reflected secret 與 private exception detail。 +- 修正 subtitle timestamp carry。 +- 更新 security-sensitive runtime dependency 與 focused regression。 +- 移除 v1 指向 v2 `latest` image 的危險文件/default;v1 改為 build 自己的 local + source image。 + +## Automated evidence + +- Ubuntu 24.04 與 Windows 2022。 +- Python 3.10 與 3.12。 +- 每個 matrix leg 都執行完整 maintenance test suite 與 compile checks。 +- Compose/entrypoint validation 只在 Ubuntu 24.04/Python 3.10 validation job + 執行。 +- Maintenance source baseline 的 duplicate push/PR matrix 全部通過。 + +## Stable v1 release 前仍需 + +- Disposable v1 image build 與 cold model bootstrap。 +- Model-backed 中文與英文 known-audio transcription。 +- Target CPU/GPU backend 與 driver/runtime evidence。 +- 真實 Windows desktop launch/exit、tray、hotkey、麥克風、clipboard、FFmpeg、 + model 與 child cleanup。 +- 宣稱任何 v1 container image 前,建立獨立審查的 immutable v1 image workflow。 + +Active development、Web/CLI/TUI/universal package 請使用 +[fork v2](https://github.com/DF-wu/CapsWriter-Offline-Container)。 diff --git a/readme.md b/readme.md index 4f4e1296..9435fca9 100644 --- a/readme.md +++ b/readme.md @@ -1,309 +1,144 @@ -# CapsWriter-Offline Linux Server Fork +# CapsWriter-Offline fork v1 — Legacy Server + Desktop Client -> Offline speech recognition that **runs** on Linux servers and **speaks** OpenAI Whisper's API. +> **v1 是隔離的 best-effort 維護線。**主要交付是 Linux/headless ASR Server; +> 同一份 source 保留 upstream 2.5-alpha 時期的 Windows desktop Client 相容性。 +> +> 繁體中文 · [English](README.en.md) -[![License](https://img.shields.io/badge/license-MIT-blue)](#) -[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](docker-compose.yml) -[![OpenAI-Compatible](https://img.shields.io/badge/OpenAI%20Whisper-compatible-10A37F?logo=openai&logoColor=white)](docs/HTTP_API.md) -[![GPU](https://img.shields.io/badge/GPU-NVIDIA%20%2B%20CPU%20fallback-76B900?logo=nvidia&logoColor=white)](#-configuration-that-matters-first) -[![Upstream](https://img.shields.io/badge/upstream-HaujetZhao%2FCapsWriter--Offline-181717?logo=github)](https://github.com/HaujetZhao/CapsWriter-Offline) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Track](https://img.shields.io/badge/track-fork--v1%20legacy-64748B)](docs/zh-TW/maintenance.md) +[![Server](https://img.shields.io/badge/server-Linux%20%7C%20Docker-2496ED?logo=docker&logoColor=white)](docs/docker-server.md) -This repository is a focused fork of [HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline). It keeps the upstream recognition stack, but redesigns the **server deployment path** for Linux hosts, containers, GPU-backed machines, and predictable long-running operation. +## 先理解 v1 的 Server/Client 分工 -> Use the upstream project for the original Windows desktop workflow. Use this fork when you want to run CapsWriter as a Linux-friendly server. - ---- - -## 📑 Table of Contents - -- [🚀 Quick start](#-quick-start) -- [🤖 OpenAI-Compatible ASR API](#-openai-compatible-asr-api) -- [🎯 Supported deployment scope](#-supported-deployment-scope) -- [⚙️ Configuration that matters first](#%EF%B8%8F-configuration-that-matters-first) -- [🧠 Operational model](#-operational-model) -- [📦 Example files](#-example-files) -- [▶️ Common start modes](#%EF%B8%8F-common-start-modes) -- [💾 Persistence](#-persistence) -- [✅ What success looks like](#-what-success-looks-like) -- [📚 Docs and repository map](#-docs-and-repository-map) -- [🔗 Relationship to upstream](#-relationship-to-upstream) -- [🤝 Contributing](#-contributing) -- [🙏 Acknowledgements](#-acknowledgements) - ---- - -## Why this fork exists - -CapsWriter-Offline already solves offline speech input well on Windows. What it did not provide was a clean path for people who want to run the recognition server on Linux, package it in Docker, and operate it as a stable service. - -This fork exists for that deployment target. The goal is straightforward: make the server easier to bootstrap, run, restart, inspect, and recover without changing the core recognition story of the upstream project. - -## What you get here +```mermaid +flowchart LR + C[Legacy Windows desktop Client
start_client.py] -->|WebSocket :6016| S[v1 ASR Server
model・FFmpeg・inference] + O[OpenAI SDK / curl] -->|選用 HTTP :6017| S + S --> R[逐字稿] +``` -| Capability | Detail | -| --- | --- | -| 🐳 **Docker-first** | Linux-oriented Docker image and Compose entry point | -| 📥 **Auto bootstrap** | Models download automatically at container startup | -| 🎮 **GPU-aware** | GPU-first runtime selection with graceful CPU fallback | -| 🖥️ **Headless-safe** | Server defaults tuned for container deployment (no tray, no UI) | -| 🤖 **OpenAI-compatible** | Optional `POST /v1/audio/transcriptions` endpoint — drop-in for any OpenAI SDK | -| 🧪 **Easy onboarding** | Root-level example files (`.env.example`, `docker-compose.example.yml`) | +| 元件 | v1 內容 | 發行狀態 | +|---|---|---| +| **Server** | Linux bare-metal/Docker、WebSocket `6016`、選用 transcription-only HTTP `6017`、model bootstrap、GPU preference/CPU fallback | v1 的主要維護路徑;GitHub Release 提供 source,由使用者在本機 build | +| **Desktop Client** | Upstream-era `start_client.py`:Windows GUI、tray、hotkey、麥克風、剪貼簿/文字注入 | 只保留 source compatibility;沒有 v1 Windows EXE,除非 release 明確附上經真實 Windows 驗證的 artifact | +| **外部 API caller** | 相容 SDK/curl 可使用文件列出的 `whisper-1` transcription subset | API interface,不是本 repository 內附的 Client app | ---- +**v1 不包含 v2 的 Web Console、no-GUI CLI、Textual TUI 或 universal Windows +package。**需要這些功能請使用 v2。 -## 🚀 Quick start +## Release 與 image 邊界 -### Prerequisites +- v1 GitHub Release 是 **source-only pre-release**。 +- Source archive 同時含 legacy Server/API/container code 與相容保留的 Windows + desktop Client source。 +- 目前不發布 v1 container image,也不附 Windows executable。 +- `ghcr.io/df-wu/capswriter-offline-server:latest` 屬於 **v2**;v1 不可使用。 +- v1 Compose 預設從目前 checkout build `capswriter-offline-v1-local:source`。 -- Linux -- Docker Engine -- Docker Compose plugin -- NVIDIA driver and NVIDIA Container Toolkit if you want GPU acceleration +## 快速開始:v1 Linux Server -### 1. Prepare local files +先決條件:Linux、Docker Engine、Compose plugin,以及 model 所需空間。NVIDIA +GPU 為選用;CPU fallback 可用。 ```bash cp .env.example .env cp hot-server.example.txt hot-server.txt -``` - -### 2. Start the server - -```bash +docker compose build --pull capswriter-server docker compose up -d capswriter-server -``` - -### 3. Verify health - -```bash docker compose ps docker compose logs -f capswriter-server ``` -The default WebSocket endpoint is: +預設 WebSocket: ```text ws://127.0.0.1:6016 ``` -### 4. Stop it - -```bash -docker compose down -``` - ---- - -## 🤖 OpenAI-Compatible ASR API - -Any OpenAI Whisper client (Python / Node / curl / your favourite app) can talk to this server with **zero code changes** — just point `base_url` at the local service. The endpoint is opt-in and runs **alongside** the WebSocket server, sharing the same recognition subprocess. - -**Three steps to enable:** - -```bash -# 1. Turn it on in .env (or in docker compose environment) -echo "CAPSWRITER_HTTP_API_ENABLE=true" >> .env - -# 2. Expose port 6017 (uncomment the line in docker-compose.yml under `ports:`) -# Or restart with both ports bound: -docker compose up -d --force-recreate capswriter-server - -# 3. Point the OpenAI SDK at it -python -c " -from openai import OpenAI -client = OpenAI(base_url='http://localhost:6017/v1', api_key='dummy') -with open('sample.mp3', 'rb') as f: - print(client.audio.transcriptions.create(model='whisper-1', file=f).text) -" -``` - -| Endpoint | Purpose | -| --- | --- | -| `POST /v1/audio/transcriptions` | Whisper-compatible transcription (`json` / `text` / `srt` / `vtt` / `verbose_json`) | -| `GET /v1/models` | OpenAI SDK introspection | -| `GET /health` | Liveness probe | - -> 📖 **Full reference, security guidance, OpenAI SDK examples, design notes & troubleshooting → [`docs/HTTP_API.md`](docs/HTTP_API.md)** - ---- - -## 🎯 Supported deployment scope - -This repository is intentionally narrow in scope. - -- **Primary target:** Linux + Docker + server deployment -- **Validated priority path:** NVIDIA Pascal / Tesla P4 class hardware -- **Default model path:** `qwen_asr` -- **Alternate supported path:** `fun_asr_nano` via environment configuration - -This repo is **not** a Linux desktop port of the full project. It is a server-focused deployment fork. - ---- - -## ⚙️ Configuration that matters first - -These are the environment variables most users need first: - -| Variable | Default | Purpose | -| --- | --- | --- | -| `CAPSWRITER_SERVER_IMAGE` | `ghcr.io/df-wu/capswriter-offline-server:latest` | Docker image to run | -| `CAPSWRITER_MODEL_TYPE` | `qwen_asr` | Selects the server model | -| `CAPSWRITER_QWEN_PRESET` | `default` | Qwen runtime preset | -| `CAPSWRITER_INFERENCE_HARDWARE` | `auto` | `auto`, `gpu`, or `cpu` | -| `CAPSWRITER_GPU_DEVICE_COUNT` | `all` | GPU request at the Compose layer | -| `CAPSWRITER_SERVER_PORT` | `6016` | WebSocket port | -| `CAPSWRITER_LOG_LEVEL` | `INFO` | Server log verbosity | -| `CAPSWRITER_NUM_THREADS` | `4` | CPU thread hint for CPU-bound stages | -| `CAPSWRITER_HTTP_API_ENABLE` | `false` | Opt-in OpenAI-compatible REST endpoint (see [docs/HTTP_API.md](docs/HTTP_API.md)) | -| `CAPSWRITER_HTTP_API_PORT` | `6017` | HTTP API port, if enabled | - -See [`.env.example`](.env.example) for the full deployment-oriented template. - ---- - -## 🧠 Operational model - -At startup, the container follows a fixed boot path: - -1. [`docker-compose.yml`](docker-compose.yml) defines the service, port, environment, and volumes. -2. [`docker/server/entrypoint.sh`](docker/server/entrypoint.sh) selects the hardware path. -3. [`docker/server/download_models.py`](docker/server/download_models.py) downloads missing model assets and Linux `llama.cpp` libraries. -4. [`docker/server/probe_backend.py`](docker/server/probe_backend.py) verifies the selected GPU backend when applicable. -5. [`start_server.py`](start_server.py) and [`core_server.py`](core_server.py) bring up the WebSocket service (and HTTP API if enabled). -6. [`util/server/service.py`](util/server/service.py) runs recognition in a separate subprocess so model inference does not block the main server loop. - -The practical outcome is simple: prefer GPU when available, fall back to CPU when necessary, and keep the service start path predictable. - ---- - -## 📦 Example files - -This fork includes root-level examples so onboarding does not depend on nested Docker folders: - -- [`.env.example`](.env.example) -- [`docker-compose.example.yml`](docker-compose.example.yml) -- [`hot-server.example.txt`](hot-server.example.txt) - -If you want a local variant without touching the default compose file: - -```bash -cp .env.example .env -cp hot-server.example.txt hot-server.txt -cp docker-compose.example.yml docker-compose.local.yml -docker compose -f docker-compose.local.yml up -d capswriter-server -``` - ---- - -## ▶️ Common start modes - -### Default `qwen_asr` - -```bash -docker compose up -d capswriter-server -``` - -### Switch to `fun_asr_nano` +Model、GPU/CPU、volume 與故障排查請見 +[v1 Docker Server 指南](docs/docker-server.md)。 -Use the bundled compose override (no `.env` editing needed): +## 選用 OpenAI 相容 HTTP API -```bash -docker compose -f docker-compose.yml -f docker-compose.fun-asr.yml up -d -``` +HTTP API 與 WebSocket Server 共用 recognizer,但預設關閉。它只實作文件列出的 +檔案轉錄 subset,不支援 translation 或完整 OpenAI Audio API。 -Or, set it inline for a one-off: +在 `.env` 啟用並設定 token: -```bash -CAPSWRITER_MODEL_TYPE=fun_asr_nano \ -docker compose up -d --force-recreate capswriter-server +```dotenv +CAPSWRITER_HTTP_API_ENABLE=true +CAPSWRITER_HTTP_API_BIND=0.0.0.0 +CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1 +CAPSWRITER_HTTP_API_PORT=6017 +CAPSWRITER_HTTP_API_KEY=replace-with-a-long-random-token ``` -**When to pick which model:** +修改 `.env` 後重建 Server。Compose 會把設定傳入 container,並預設只在 host +loopback 發布 `6017`。除非前方已有可信任且具 authentication 與 TLS 的 reverse +proxy,否則請保留 `CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1`。相容 SDK caller +可以把 base URL 指向 `http://127.0.0.1:6017/v1`;unsupported field 可能被拒絕, +不能假設所有 OpenAI feature 都存在。 -| Model | Best for | Trade-off | -| --- | --- | --- | -| `qwen_asr` (default) | Long-form transcription, highest accuracy | Slower per request | -| `fun_asr_nano` | HTTP API / real-time / airi / interactive dictation | Lower accuracy on long/complex sentences | +完整 contract、安全限制與 curl/SDK 範例見 +[HTTP API reference](docs/HTTP_API.md)。 -### Force CPU-only startup +## Legacy Windows Desktop Client -```bash -CAPSWRITER_GPU_DEVICE_COUNT=0 \ -CAPSWRITER_INFERENCE_HARDWARE=cpu \ -docker compose up -d --force-recreate capswriter-server -``` - -### Enable OpenAI-compatible API +v1 source 保留原始 desktop 流程: -```bash -CAPSWRITER_HTTP_API_ENABLE=true \ -docker compose up -d --force-recreate capswriter-server -# Remember to also uncomment the second port mapping in docker-compose.yml. +```text +start_server.py --WebSocket :6016--> start_client.py ``` ---- - -## 💾 Persistence - -The default Compose setup mounts: - -- `./models:/app/models` -- `./hot-server.txt:/app/hot-server.txt` -- `capswriter-server-logs:/app/logs` - -In practice: - -- `models/` stores model assets, download cache, and prepared runtime libraries -- `hot-server.txt` provides the server-side hotword file, mainly relevant for `fun_asr_nano` -- `capswriter-server-logs` keeps logs persistent without requiring a host bind mount - ---- - -## ✅ What success looks like - -The deployment is in a good state when all three are true: - -1. `docker compose ps` shows the service as `healthy` -2. `docker compose logs -f capswriter-server` shows model loading and server startup messages -3. Your client or test tool can connect to `ws://127.0.0.1:${CAPSWRITER_SERVER_PORT}` (and, if enabled, `http://127.0.0.1:${CAPSWRITER_HTTP_API_PORT}/health` returns `{"status":"ok"}`) - ---- +Desktop Client 負責 tray、hotkey、mic、clipboard 與 text injection;Server 才會載入 +model 並推論。這不是 v2 universal package,也沒有隨目前 v1 Release 提供 EXE。 -## 📚 Docs and repository map +若自行建立 Windows artifact,發行前必須在真實 Windows 主機驗證 launch/exit、 +tray、configured hotkey、microphone、clipboard、FFmpeg、model load、known audio 與 +child-process cleanup。 -Start with these files if you want to understand or extend the server path: +## 支援範圍 -- [`readme.md`](readme.md), project front page -- [`docs/docker-server.md`](docs/docker-server.md), deeper deployment notes -- [`docs/HTTP_API.md`](docs/HTTP_API.md), OpenAI-compatible HTTP API reference -- [`docker-compose.yml`](docker-compose.yml), default deployment entry point -- [`config_server.py`](config_server.py), runtime configuration surface -- [`core_server.py`](core_server.py), server bootstrap -- [`docker/server/Dockerfile`](docker/server/Dockerfile), image definition -- [`util/server/service.py`](util/server/service.py), recognition subprocess management +| 路徑 | 狀態 | Automated evidence | 仍需實機驗證 | +|---|---|---|---| +| Linux Docker Server | 主要 legacy Server path | Ubuntu tests、Compose config、entrypoint shell、protocol/API units | Disposable image build、model download/load、中英文 known audio、GPU/CPU host | +| Linux bare-metal Server | Best effort | Python 3.10/3.12 server tests | FFmpeg、native library、model、service supervision | +| Windows desktop source | Compatibility-preserved | Windows Python 3.10/3.12 syntax/protocol tests | Tray、hotkey、mic、clipboard、PyInstaller artifact | +| Optional HTTP API | Legacy compatibility | Auth、upload bound、format、routing tests | Live authenticated model-backed transcription | +| macOS | 未列入 release qualification | 無完整 gate | 不做 project-level support claim | ---- +CI 通過不等於 model quality、GPU backend、audio hardware 或 Windows desktop 已通過 +release qualification。 -## 🔗 Relationship to upstream +## 維護與分支規則 -- Upstream project: [HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline) -- Upstream focus: offline speech input on Windows -- This fork: Linux- and Docker-oriented server deployment +- 開發 branch:`maintenance/v1` +- Standing comparison PR base:`archive/v1-legacy` +- 不可把 v1 merge 到 `master`,也不可把 v2 整體 backport 到 v1。 +- 只接受重大 security、compatibility、model asset 與 contract 修正。 +- v1 tag 使用 `fork-v1..`;pre-release 可加 `-rc.`。 -This fork extends the upstream deployment story. It does not replace the upstream project. +詳細政策: ---- +- [English maintenance policy](docs/en/maintenance.md) +- [繁體中文維護政策](docs/zh-TW/maintenance.md) -## 🤝 Contributing +## 文件 -Issues and pull requests that improve the Linux server path are welcome. If you are changing runtime behavior, Docker packaging, or deployment defaults, keep the server-first scope intact and prefer changes that preserve predictable startup and fallback behavior. +| 文件 | 內容 | +|---|---| +| [v1 Docker Server](docs/docker-server.md) | Local source build、models、GPU/CPU、volume、ops | +| [HTTP API](docs/HTTP_API.md) | Transcription subset、auth、limits、SDK/curl | +| [v1 維護政策](docs/zh-TW/maintenance.md) | Branch、support、qualification、residual risk | +| [v1 Release notes](docs/zh-TW/release-notes.md) | RC 交付內容、Server/Client 邊界、剩餘 qualification | +| [Upstream release history](https://github.com/HaujetZhao/CapsWriter-Offline/releases) | Upstream-era product history | ---- +## Upstream 與授權 -## 🙏 Acknowledgements +此維護線來自 +[HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline) +2.5-alpha 時期的 desktop/recognition code,並加入 fork 的 Linux Server、Docker +與 HTTP API 維護修正。新功能開發位於 fork v2。 -- [HaujetZhao/CapsWriter-Offline](https://github.com/HaujetZhao/CapsWriter-Offline) -- [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx) -- [FunASR](https://github.com/alibaba-damo-academy/FunASR) -- [llama.cpp](https://github.com/ggml-org/llama.cpp) -- [FastAPI](https://fastapi.tiangolo.com/) & [uvicorn](https://www.uvicorn.org/) (HTTP API layer) +License:[MIT](LICENSE)。 diff --git a/requirements-maintenance.txt b/requirements-maintenance.txt new file mode 100644 index 00000000..d36ba606 --- /dev/null +++ b/requirements-maintenance.txt @@ -0,0 +1,8 @@ +# Minimal dependencies for protocol/server maintenance tests. +# Keep this separate from model, audio, UI, and GPU runtime dependencies. +rich==14.3.3 +websockets==16.0 +numpy==1.26.4 +fastapi==0.139.0 +uvicorn==0.32.1 +python-multipart==0.0.31 diff --git a/requirements-server-docker.txt b/requirements-server-docker.txt index ebb7cd85..45cf4bd2 100644 --- a/requirements-server-docker.txt +++ b/requirements-server-docker.txt @@ -6,7 +6,12 @@ rich==14.3.3 websockets==16.0 watchdog==6.0.0 pypinyin==0.55.0 -Pillow==12.1.1 +Pillow==12.3.0 markdown==3.10.2 tkhtmlview==0.3.2 srt==3.5.3 + +# OpenAI-compatible HTTP API (optional, gated by CAPSWRITER_HTTP_API_ENABLE) +fastapi==0.139.0 +uvicorn[standard]==0.32.1 +python-multipart==0.0.31 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..45a66c5e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Dependency-light regression tests for the legacy maintenance line.""" diff --git a/tests/test_dependency_manifests.py b/tests/test_dependency_manifests.py new file mode 100644 index 00000000..7cef0295 --- /dev/null +++ b/tests/test_dependency_manifests.py @@ -0,0 +1,51 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class DependencyManifestTests(unittest.TestCase): + def test_docker_runtime_contains_optional_http_api_dependencies(self): + requirements = (ROOT / "requirements-server-docker.txt").read_text( + encoding="utf-8" + ) + + for package in ("fastapi==", "uvicorn[standard]==", "python-multipart=="): + with self.subTest(package=package): + self.assertIn(package, requirements) + + def test_maintenance_dependencies_are_exactly_pinned(self): + lines = (ROOT / "requirements-maintenance.txt").read_text( + encoding="utf-8" + ).splitlines() + packages = [line for line in lines if line and not line.startswith("#")] + + self.assertTrue(packages) + self.assertTrue(all("==" in package for package in packages)) + + def test_maintenance_server_pins_match_docker_runtime(self): + docker = (ROOT / "requirements-server-docker.txt").read_text( + encoding="utf-8" + ) + maintenance = (ROOT / "requirements-maintenance.txt").read_text( + encoding="utf-8" + ) + + for version in ( + "rich==14.3.3", + "websockets==16.0", + "numpy==1.26.4", + "fastapi==0.139.0", + "python-multipart==0.0.31", + ): + with self.subTest(version=version): + self.assertIn(version, docker) + self.assertIn(version, maintenance) + self.assertIn("uvicorn[standard]==0.32.1", docker) + self.assertIn("uvicorn==0.32.1", maintenance) + self.assertIn("Pillow==12.3.0", docker) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http_api.py b/tests/test_http_api.py new file mode 100644 index 00000000..f7b672c3 --- /dev/null +++ b/tests/test_http_api.py @@ -0,0 +1,199 @@ +import asyncio +import json +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi import HTTPException + +from config_server import ServerConfig as Config +from util.constants import AudioFormat +from util.server.http_api import ( + _check_auth, + _check_transcription_content_type, + create_app, +) +from util.server.server_classes import Result +from util.server.task_router import router as task_router + + +class HttpAuthenticationTests(unittest.TestCase): + def test_auth_is_disabled_only_when_configured_key_is_empty(self): + with patch.object(Config, "http_api_key", ""): + _check_auth(None) + + def test_bearer_scheme_is_case_insensitive(self): + with patch.object(Config, "http_api_key", "sëcret"): + _check_auth("bearer sëcret") + + def test_missing_malformed_and_wrong_credentials_are_rejected(self): + with patch.object(Config, "http_api_key", "expected"): + for header in (None, "Basic expected", "Bearer", "Bearer wrong extra"): + with self.subTest(header=header): + with self.assertRaises(HTTPException) as raised: + _check_auth(header) + self.assertEqual(raised.exception.status_code, 401) + + with self.assertRaises(HTTPException) as raised: + _check_auth("Bearer wrong") + self.assertEqual(raised.exception.status_code, 401) + self.assertEqual(raised.exception.detail, "Invalid API key") + + +class HttpApplicationSmokeTests(unittest.TestCase): + def test_only_multipart_transcription_bodies_reach_form_parser(self): + _check_transcription_content_type( + 'Multipart/Form-Data; boundary="capswriter"' + ) + for content_type in (None, "", "application/x-www-form-urlencoded"): + with self.subTest(content_type=content_type): + with self.assertRaises(HTTPException) as raised: + _check_transcription_content_type(content_type) + self.assertEqual(raised.exception.status_code, 415) + + def test_expected_legacy_routes_are_registered(self): + app = create_app() + routes = {(route.path, tuple(sorted(route.methods or ()))) for route in app.routes} + + self.assertIn(("/health", ("GET",)), routes) + self.assertIn(("/v1/models", ("GET",)), routes) + self.assertIn(("/v1/audio/transcriptions", ("POST",)), routes) + self.assertIn(("/v1/audio/translations", ("POST",)), routes) + + +class HttpMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def _request(self, headers): + app = create_app() + receive_calls = [] + sent = [] + + async def receive(): + receive_calls.append(True) + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v1/audio/transcriptions", + "raw_path": b"/v1/audio/transcriptions", + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 6017), + } + await app(scope, receive, send) + response_start = next( + message for message in sent if message["type"] == "http.response.start" + ) + return response_start["status"], receive_calls + + async def test_auth_rejection_happens_before_body_read(self): + with patch.object(Config, "http_api_key", "expected"): + status, receive_calls = await self._request( + [(b"content-type", b"multipart/form-data; boundary=x")] + ) + + self.assertEqual(status, 401) + self.assertEqual(receive_calls, []) + + async def test_non_multipart_rejection_happens_before_body_read(self): + with patch.object(Config, "http_api_key", ""): + status, receive_calls = await self._request( + [(b"content-type", b"application/x-www-form-urlencoded")] + ) + + self.assertEqual(status, 415) + self.assertEqual(receive_calls, []) + + async def test_valid_multipart_request_reaches_endpoint(self): + app = create_app() + boundary = b"capswriter-boundary" + body = b"\r\n".join( + ( + b"--" + boundary, + b'Content-Disposition: form-data; name="file"; filename="audio.wav"', + b"Content-Type: audio/wav", + b"", + b"audio-bytes", + b"--" + boundary + b"--", + b"", + ) + ) + sent = [] + body_sent = False + + async def receive(): + nonlocal body_sent + if body_sent: + return {"type": "http.disconnect"} + body_sent = True + return {"type": "http.request", "body": body, "more_body": False} + + async def send(message): + sent.append(message) + + def register_completed(task_id): + future = asyncio.get_running_loop().create_future() + future.set_result( + Result( + task_id, + f"http:{task_id}", + "file", + duration=0.1, + text="hello", + text_accu="hello", + is_final=True, + ) + ) + return future + + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/v1/audio/transcriptions", + "raw_path": b"/v1/audio/transcriptions", + "query_string": b"", + "headers": [ + ( + b"content-type", + b"multipart/form-data; boundary=" + boundary, + ), + (b"content-length", str(len(body)).encode("ascii")), + ], + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 6017), + } + pcm = b"\x00" * AudioFormat.seconds_to_bytes(0.1) + with ( + patch.object(Config, "http_api_key", ""), + patch( + "util.server.http_api.decode_to_pcm", + new=AsyncMock(return_value=pcm), + ), + patch("util.server.http_api._split_and_submit"), + patch.object(task_router, "register", side_effect=register_completed), + ): + await app(scope, receive, send) + + response_start = next( + message for message in sent if message["type"] == "http.response.start" + ) + response_body = b"".join( + message.get("body", b"") + for message in sent + if message["type"] == "http.response.body" + ) + self.assertEqual(response_start["status"], 200) + self.assertEqual(json.loads(response_body), {"text": "hello"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http_limits.py b/tests/test_http_limits.py new file mode 100644 index 00000000..3624298b --- /dev/null +++ b/tests/test_http_limits.py @@ -0,0 +1,44 @@ +import unittest + +from util.server.http_limits import UploadTooLargeError, read_upload_limited + + +class FakeUpload: + def __init__(self, data: bytes): + self.data = data + self.read_sizes = [] + + async def read(self, size: int = -1) -> bytes: + self.read_sizes.append(size) + if not self.data: + return b"" + chunk = self.data[:size] + self.data = self.data[size:] + return chunk + + +class UploadLimitTests(unittest.IsolatedAsyncioTestCase): + async def test_exact_limit_is_accepted(self): + upload = FakeUpload(b"abcdef") + + body = await read_upload_limited(upload, max_bytes=6, chunk_size=4) + + self.assertEqual(body, b"abcdef") + self.assertEqual(upload.data, b"") + + async def test_oversized_upload_fails_before_reading_to_eof(self): + upload = FakeUpload(b"abcdefghij") + + with self.assertRaises(UploadTooLargeError): + await read_upload_limited(upload, max_bytes=5, chunk_size=4) + + self.assertEqual(upload.read_sizes, [4, 2]) + self.assertEqual(upload.data, b"ghij") + + async def test_invalid_limits_are_rejected(self): + with self.assertRaises(ValueError): + await read_upload_limited(FakeUpload(b"x"), max_bytes=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_maintenance_docs.py b/tests/test_maintenance_docs.py new file mode 100644 index 00000000..43c2eab8 --- /dev/null +++ b/tests/test_maintenance_docs.py @@ -0,0 +1,200 @@ +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +ENGLISH = ROOT / "docs" / "en" / "maintenance.md" +TRADITIONAL_CHINESE = ROOT / "docs" / "zh-TW" / "maintenance.md" +HTTP_API_ENGLISH = ROOT / "docs" / "en" / "http-api.md" +HTTP_API_TRADITIONAL_CHINESE = ROOT / "docs" / "HTTP_API.md" + + +class MaintenanceDocumentationTests(unittest.TestCase): + def test_bilingual_counterparts_and_cross_links_exist(self): + english = ENGLISH.read_text(encoding="utf-8") + traditional_chinese = TRADITIONAL_CHINESE.read_text(encoding="utf-8") + + self.assertIn("[繁體中文版](../zh-TW/maintenance.md)", english) + self.assertIn("[English version](../en/maintenance.md)", traditional_chinese) + + def test_both_languages_record_branch_version_and_support_contract(self): + for path in (ENGLISH, TRADITIONAL_CHINESE): + with self.subTest(path=path): + text = path.read_text(encoding="utf-8") + self.assertIn("maintenance/v1", text) + self.assertIn("archive/v1-legacy", text) + self.assertIn("2.5-alpha", text) + self.assertIn("Python 3.10", text) + self.assertIn("Python 3.12", text) + + def test_readme_links_both_counterparts(self): + readme = (ROOT / "readme.md").read_text(encoding="utf-8") + english_readme = (ROOT / "README.en.md").read_text(encoding="utf-8") + + self.assertIn("docs/en/maintenance.md", readme) + self.assertIn("docs/zh-TW/maintenance.md", readme) + self.assertIn("README.en.md", readme) + self.assertIn("readme.md", english_readme) + self.assertIn("docs/HTTP_API.md", readme) + self.assertIn("docs/en/http-api.md", english_readme) + self.assertNotIn("docs/CHANGELOG.md", readme + english_readme) + self.assertTrue((ROOT / "LICENSE").is_file()) + + def test_http_api_guides_are_bilingual_and_keep_server_client_roles_separate(self): + english = HTTP_API_ENGLISH.read_text(encoding="utf-8") + traditional_chinese = HTTP_API_TRADITIONAL_CHINESE.read_text(encoding="utf-8") + + self.assertIn("[Traditional Chinese](../HTTP_API.md)", english) + self.assertIn("[English](en/http-api.md)", traditional_chinese) + for source, caller_label in ( + (english, "external api caller"), + (traditional_chinese, "外部 api caller"), + ): + with self.subTest(caller_label=caller_label): + folded = source.casefold() + self.assertIn("server", folded) + self.assertIn("start_client.py", folded) + self.assertIn("websocket", folded) + self.assertIn(caller_label, folded) + self.assertIn("source", folded) + + def test_http_api_guides_distinguish_native_bind_from_compose_publish(self): + for path in (HTTP_API_ENGLISH, HTTP_API_TRADITIONAL_CHINESE): + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + self.assertIn("CAPSWRITER_HTTP_API_BIND", source) + self.assertIn("CAPSWRITER_HTTP_API_HOST_BIND", source) + self.assertIn("CAPSWRITER_HTTP_API_KEY", source) + self.assertIn("127.0.0.1", source) + self.assertIn("0.0.0.0", source) + self.assertIn("TLS", source) + + english = HTTP_API_ENGLISH.read_text(encoding="utf-8") + traditional_chinese = HTTP_API_TRADITIONAL_CHINESE.read_text(encoding="utf-8") + self.assertIn( + "| `CAPSWRITER_HTTP_API_BIND` | `127.0.0.1` | " + "`0.0.0.0` inside the container |", + english, + ) + self.assertIn( + "| `CAPSWRITER_HTTP_API_HOST_BIND` | Not used | " + "`127.0.0.1` on the host |", + english, + ) + self.assertIn( + "| `CAPSWRITER_HTTP_API_BIND` | `127.0.0.1` | " + "Container 內 `0.0.0.0` |", + traditional_chinese, + ) + self.assertIn( + "| `CAPSWRITER_HTTP_API_HOST_BIND` | 不使用 | " + "Host 上 `127.0.0.1` |", + traditional_chinese, + ) + + def test_v1_docs_separate_server_and_client_deliverables(self): + for path in ( + ROOT / "readme.md", + ROOT / "README.en.md", + ENGLISH, + TRADITIONAL_CHINESE, + ): + with self.subTest(path=path): + source = path.read_text(encoding="utf-8").casefold() + self.assertIn("server", source) + self.assertIn("client", source) + self.assertIn("start_client.py", source) + self.assertIn("source", source) + + def test_v1_compose_never_defaults_to_v2_latest(self): + paths = ( + ROOT / "docker-compose.yml", + ROOT / "docker-compose.example.yml", + ROOT / ".env.example", + ROOT / "docker" / "server" / ".env.example", + ) + for path in paths: + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + self.assertNotIn( + "CAPSWRITER_SERVER_IMAGE=ghcr.io/df-wu/capswriter-offline-server:latest", + source, + ) + self.assertNotIn( + "${CAPSWRITER_SERVER_IMAGE:-ghcr.io/df-wu/capswriter-offline-server:latest}", + source, + ) + + compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("capswriter-offline-v1-local:source", compose) + self.assertIn("dockerfile: docker/server/Dockerfile", compose) + for path in (ROOT / "readme.md", ROOT / "README.en.md"): + self.assertIn("latest", path.read_text(encoding="utf-8").casefold()) + + def test_compose_passes_http_api_with_safe_network_defaults(self): + compose_paths = ( + ROOT / "docker-compose.yml", + ROOT / "docker-compose.example.yml", + ) + required_compose_lines = ( + "CAPSWRITER_HTTP_API_ENABLE: ${CAPSWRITER_HTTP_API_ENABLE:-false}", + "CAPSWRITER_HTTP_API_BIND: ${CAPSWRITER_HTTP_API_BIND:-0.0.0.0}", + "CAPSWRITER_HTTP_API_PORT: ${CAPSWRITER_HTTP_API_PORT:-6017}", + "CAPSWRITER_HTTP_API_KEY: ${CAPSWRITER_HTTP_API_KEY:-}", + "CAPSWRITER_HTTP_API_MAX_UPLOAD_MB: ${CAPSWRITER_HTTP_API_MAX_UPLOAD_MB:-100}", + "CAPSWRITER_HTTP_API_TASK_TIMEOUT: ${CAPSWRITER_HTTP_API_TASK_TIMEOUT:-600}", + '"${CAPSWRITER_HTTP_API_HOST_BIND:-127.0.0.1}:${CAPSWRITER_HTTP_API_PORT:-6017}:${CAPSWRITER_HTTP_API_PORT:-6017}"', + ) + for path in compose_paths: + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + for line in required_compose_lines: + self.assertIn(line, source) + + for path in ( + ROOT / ".env.example", + ROOT / "docker" / "server" / ".env.example", + ): + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + self.assertIn("CAPSWRITER_HTTP_API_ENABLE=false", source) + self.assertIn("CAPSWRITER_HTTP_API_BIND=0.0.0.0", source) + self.assertIn("CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1", source) + + for path in (ROOT / "readme.md", ROOT / "README.en.md"): + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + self.assertIn("CAPSWRITER_HTTP_API_BIND=0.0.0.0", source) + self.assertIn("CAPSWRITER_HTTP_API_HOST_BIND=127.0.0.1", source) + + def test_local_image_build_excludes_mutable_hotword_files(self): + dockerignore = { + line.strip() + for line in (ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines() + } + for path in ("hot-server.txt", "hot.txt", "hot-rule.txt", "hot-rectify.txt"): + self.assertIn(path, dockerignore) + self.assertIn("!hot-server.example.txt", dockerignore) + + def test_legacy_image_publish_workflow_is_absent(self): + self.assertFalse( + (ROOT / ".github" / "workflows" / "publish-server-image.yml").exists() + ) + + def test_release_notes_do_not_claim_unrecorded_ci_gates(self): + for path in ( + ROOT / "docs" / "en" / "release-notes.md", + ROOT / "docs" / "zh-TW" / "release-notes.md", + ): + with self.subTest(path=path): + source = path.read_text(encoding="utf-8") + folded = source.casefold() + self.assertIn("compose", folded) + self.assertIn("entrypoint", folded) + self.assertNotIn("ruff", folded) + self.assertNotIn("actionlint", folded) + self.assertNotIn("dependency audit", folded) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openai_formatter.py b/tests/test_openai_formatter.py new file mode 100644 index 00000000..db061e82 --- /dev/null +++ b/tests/test_openai_formatter.py @@ -0,0 +1,49 @@ +import unittest + +from util.server.openai_formatter import _fmt_srt_ts, _fmt_vtt_ts, format_response +from util.server.server_classes import Result + + +class TimestampFormattingTests(unittest.TestCase): + def test_rounding_carries_across_minute_and_hour_boundaries(self): + self.assertEqual(_fmt_srt_ts(59.9996), "00:01:00,000") + self.assertEqual(_fmt_vtt_ts(3599.9996), "01:00:00.000") + + def test_negative_timestamp_is_clamped(self): + self.assertEqual(_fmt_srt_ts(-1.0), "00:00:00,000") + + def test_non_finite_timestamp_is_clamped(self): + self.assertEqual(_fmt_srt_ts(float("nan")), "00:00:00,000") + self.assertEqual(_fmt_vtt_ts(float("inf")), "00:00:00.000") + + +class ResponseFormattingTests(unittest.TestCase): + def setUp(self): + self.result = Result( + task_id="task", + socket_id="http:task", + source="file", + duration=1.5, + text="fallback", + text_accu="你好。", + tokens=["你", "好", "。"], + timestamps=[0.0, 0.5, 1.0], + is_final=True, + ) + + def test_json_prefers_accumulated_timestamp_text(self): + body, media_type = format_response(self.result, "json") + + self.assertEqual(body, {"text": "你好。"}) + self.assertEqual(media_type, "application/json") + + def test_verbose_json_preserves_duration_and_monotonic_word_bounds(self): + body, _ = format_response(self.result, "verbose_json", language="zh") + + self.assertEqual(body["duration"], 1.5) + self.assertEqual(body["language"], "zh") + self.assertEqual(body["words"][-1]["end"], 1.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_protocol.py b/tests/test_protocol.py new file mode 100644 index 00000000..79b970e8 --- /dev/null +++ b/tests/test_protocol.py @@ -0,0 +1,61 @@ +import json +import unittest + +from util.protocol import AudioMessage, RecognitionResult + + +class AudioMessageTests(unittest.TestCase): + def test_round_trip_preserves_unicode_and_segment_settings(self): + message = AudioMessage( + task_id="工作-1", + source="mic", + data="AA==", + is_final=False, + time_start=123.5, + seg_duration=60.0, + seg_overlap=4.0, + ) + + restored = AudioMessage.from_dict(json.loads(message.to_json())) + + self.assertEqual(restored, message) + self.assertIn("工作-1", message.to_json()) + + def test_legacy_segment_defaults_remain_compatible(self): + restored = AudioMessage.from_dict( + { + "task_id": "task", + "source": "file", + "data": "", + "is_final": True, + "time_start": 1.0, + } + ) + + self.assertEqual(restored.seg_duration, 15.0) + self.assertEqual(restored.seg_overlap, 2.0) + + +class RecognitionResultTests(unittest.TestCase): + def test_optional_fields_default_to_independent_lists(self): + required = { + "task_id": "task", + "is_final": True, + "duration": 1.0, + "time_start": 1.0, + "time_submit": 2.0, + "time_complete": 3.0, + "text": "hello", + } + + first = RecognitionResult.from_dict(required) + second = RecognitionResult.from_dict(required) + first.tokens.append("x") + + self.assertEqual(second.tokens, []) + self.assertEqual(second.timestamps, []) + self.assertEqual(json.loads(first.to_json())["text"], "hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server_recognize.py b/tests/test_server_recognize.py new file mode 100644 index 00000000..59ef09b5 --- /dev/null +++ b/tests/test_server_recognize.py @@ -0,0 +1,69 @@ +import time +from types import SimpleNamespace +import unittest + +from util.server.server_classes import Task +from util.server.server_recognize import clear_results_by_socket_id, recognize + + +class FakeStream: + def __init__(self, text): + self.result = SimpleNamespace(text=text, tokens=[], timestamps=[]) + + def accept_waveform(self, samplerate, samples): + del samplerate, samples + + +class FakeRecognizer: + def __init__(self, texts): + self._texts = iter(texts) + + def create_stream(self): + return FakeStream(next(self._texts)) + + def decode_stream(self, stream, **kwargs): + del stream, kwargs + + +def audio_task(socket_id, *, is_final): + now = time.time() + return Task( + source="file", + data=b"\x00" * (1600 * 4), + offset=0.0, + overlap=0.0, + task_id="shared-client-id", + socket_id=socket_id, + is_final=is_final, + time_start=now, + time_submit=now, + ) + + +class RecognitionStateIsolationTests(unittest.TestCase): + def tearDown(self): + clear_results_by_socket_id("socket-a") + clear_results_by_socket_id("socket-b") + + def test_same_task_id_on_different_sockets_does_not_merge_or_misroute(self): + recognizer = FakeRecognizer(("alpha", "beta")) + + first = recognize( + recognizer, + None, + audio_task("socket-a", is_final=False), + ) + second = recognize( + recognizer, + None, + audio_task("socket-b", is_final=True), + ) + + self.assertEqual(first.socket_id, "socket-a") + self.assertEqual(second.socket_id, "socket-b") + self.assertEqual(second.task_id, "shared-client-id") + self.assertEqual(second.text, "beta") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server_ws_recv.py b/tests/test_server_ws_recv.py new file mode 100644 index 00000000..6c16dad4 --- /dev/null +++ b/tests/test_server_ws_recv.py @@ -0,0 +1,165 @@ +import base64 +import unittest +from unittest.mock import patch + +from util.constants import AudioFormat +from util.server.server_cosmic import Cosmic +from util.server.server_ws_recv import ( + AudioCache, + InvalidAudioMessage, + MAX_AUDIO_CHUNK_BYTES, + message_handler, + validate_audio_message, +) + + +class FakeQueue: + def __init__(self): + self.items = [] + + def put(self, item): + self.items.append(item) + + +class FakeWebSocket: + id = "socket-1" + + +class QuietStatus: + on = False + + def start(self): + self.on = True + + def stop(self): + self.on = False + + +def audio_message(raw: bytes = b"", **overrides): + message = { + "task_id": "task-1", + "source": "mic", + "data": base64.b64encode(raw).decode("ascii"), + "is_final": False, + "time_start": 123.0, + "seg_duration": 60.0, + "seg_overlap": 4.0, + "context": "", + } + message.update(overrides) + return message + + +class AudioMessageValidationTests(unittest.TestCase): + def test_official_client_parameters_and_chunk_size_are_accepted(self): + raw = b"\x00" * AudioFormat.seconds_to_bytes(60) + + self.assertEqual(validate_audio_message(audio_message(raw)), raw) + + def test_zero_segment_duration_is_rejected(self): + with self.assertRaisesRegex(InvalidAudioMessage, "seg_duration"): + validate_audio_message(audio_message(seg_duration=0)) + + def test_sub_sample_and_unaligned_segment_geometry_are_rejected(self): + with self.assertRaisesRegex(InvalidAudioMessage, "one audio sample"): + validate_audio_message(audio_message(seg_duration=0.000001)) + with self.assertRaisesRegex(InvalidAudioMessage, "sample-aligned"): + validate_audio_message(audio_message(seg_duration=5 / 64000)) + + def test_non_finite_and_negative_segment_values_are_rejected(self): + with self.assertRaisesRegex(InvalidAudioMessage, "seg_duration"): + validate_audio_message(audio_message(seg_duration=float("nan"))) + with self.assertRaisesRegex(InvalidAudioMessage, "seg_overlap"): + validate_audio_message(audio_message(seg_overlap=-1)) + + def test_invalid_base64_and_unaligned_pcm_are_rejected(self): + with self.assertRaisesRegex(InvalidAudioMessage, "Base64"): + validate_audio_message(audio_message(data="%%%")) + with self.assertRaisesRegex(InvalidAudioMessage, "sample-aligned"): + validate_audio_message(audio_message(b"abc")) + + def test_oversized_decoded_chunk_is_rejected(self): + raw = b"\x00" * (MAX_AUDIO_CHUNK_BYTES + AudioFormat.BYTES_PER_SAMPLE) + + with self.assertRaisesRegex(InvalidAudioMessage, "exceeds"): + validate_audio_message(audio_message(raw)) + + def test_message_shape_and_bounded_metadata_are_enforced(self): + with self.assertRaisesRegex(InvalidAudioMessage, "source"): + validate_audio_message(audio_message(source="network")) + with self.assertRaisesRegex(InvalidAudioMessage, "is_final"): + validate_audio_message(audio_message(is_final="false")) + with self.assertRaisesRegex(InvalidAudioMessage, "task_id"): + validate_audio_message(audio_message(task_id="")) + with self.assertRaisesRegex(InvalidAudioMessage, "control"): + validate_audio_message(audio_message(task_id="task\nforged-log")) + with self.assertRaisesRegex(InvalidAudioMessage, "context"): + validate_audio_message(audio_message(context="x" * 8193)) + + +class MessageHandlerTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.original_queue = Cosmic.queue_in + self.queue = FakeQueue() + Cosmic.queue_in = self.queue + self.status_patch = patch( + "util.server.server_ws_recv.status_mic", QuietStatus() + ) + self.status_patch.start() + + async def asyncTearDown(self): + self.status_patch.stop() + Cosmic.queue_in = self.original_queue + + async def test_valid_audio_is_segmented_and_finalized_in_order(self): + cache = AudioCache() + two_seconds = b"\x00" * AudioFormat.seconds_to_bytes(2.0) + + await message_handler( + FakeWebSocket(), + audio_message(two_seconds, seg_duration=1.0, seg_overlap=0.25), + cache, + ) + await message_handler( + FakeWebSocket(), + audio_message( + is_final=True, + seg_duration=1.0, + seg_overlap=0.25, + ), + cache, + ) + + self.assertEqual(len(self.queue.items), 2) + self.assertFalse(self.queue.items[0].is_final) + self.assertTrue(self.queue.items[1].is_final) + self.assertEqual(self.queue.items[0].offset, 0.0) + self.assertEqual(self.queue.items[1].offset, 1.0) + self.assertIsNone(cache.task_id) + + async def test_standard_mic_final_packet_may_use_legacy_15_2_settings(self): + cache = AudioCache() + raw = b"\x00" * AudioFormat.seconds_to_bytes(0.5) + await message_handler(FakeWebSocket(), audio_message(raw), cache) + + await message_handler( + FakeWebSocket(), + audio_message(is_final=True, seg_duration=15.0, seg_overlap=2.0), + cache, + ) + + self.assertTrue(self.queue.items[-1].is_final) + + async def test_task_cannot_change_mid_stream(self): + cache = AudioCache() + raw = b"\x00" * AudioFormat.seconds_to_bytes(0.5) + await message_handler(FakeWebSocket(), audio_message(raw), cache) + + with self.assertRaisesRegex(InvalidAudioMessage, "changed"): + await message_handler( + FakeWebSocket(), audio_message(raw, task_id="task-2"), cache + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_router.py b/tests/test_task_router.py new file mode 100644 index 00000000..da49d32e --- /dev/null +++ b/tests/test_task_router.py @@ -0,0 +1,65 @@ +import asyncio +import unittest + +from util.server.server_classes import Result +from util.server.server_cosmic import Cosmic +from util.server.task_router import TaskRouter + + +class TaskRouterTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.original_socket_ids = Cosmic.sockets_id + Cosmic.sockets_id = [] + self.router = TaskRouter() + self.router.bind_loop(asyncio.get_running_loop()) + + async def asyncTearDown(self): + for task_id in list(self.router._pending): + self.router.cancel(task_id) + Cosmic.sockets_id = self.original_socket_ids + + async def test_final_result_resolves_and_cleans_synthetic_socket(self): + future = self.router.register("task-1") + result = Result("task-1", "http:task-1", "file", is_final=True) + + self.assertTrue(self.router.try_resolve(result)) + await asyncio.sleep(0) + + self.assertIs(await future, result) + self.assertEqual(Cosmic.sockets_id, []) + + async def test_intermediate_result_is_absorbed_until_final(self): + future = self.router.register("task-2") + + handled = self.router.try_resolve( + Result("task-2", "http:task-2", "file", is_final=False) + ) + + self.assertTrue(handled) + self.assertFalse(future.done()) + self.assertEqual(Cosmic.sockets_id, ["http:task-2"]) + + async def test_cancel_removes_all_registration_state(self): + future = self.router.register("task-3") + + self.router.cancel("task-3") + + self.assertTrue(future.cancelled()) + self.assertEqual(Cosmic.sockets_id, []) + + async def test_duplicate_task_id_is_rejected_without_leaking_socket(self): + self.router.register("duplicate") + + with self.assertRaises(ValueError): + self.router.register("duplicate") + + self.assertEqual(Cosmic.sockets_id, ["http:duplicate"]) + + async def test_unknown_result_remains_available_to_websocket_path(self): + result = Result("unknown", "socket", "mic", is_final=True) + + self.assertFalse(self.router.try_resolve(result)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 00000000..263a1965 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,41 @@ +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "v1-maintenance.yml" + + +class MaintenanceWorkflowTests(unittest.TestCase): + def test_ci_uses_pinned_supported_runners_and_actions(self): + source = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("ubuntu-24.04", source) + self.assertIn("windows-2022", source) + self.assertNotIn("-latest", source) + self.assertIn( + "actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5", + source, + ) + self.assertIn( + "actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065", + source, + ) + self.assertIn("persist-credentials: false", source) + self.assertIn("timeout-minutes: 15", source) + self.assertIn('PYTHONDONTWRITEBYTECODE: "1"', source) + self.assertIn('PYTHONNOUSERSITE: "1"', source) + self.assertIn("--only-binary=:all:", source) + self.assertIsNone(re.search(r"uses:\s+[^@\s]+@v\d+", source)) + + def test_ci_covers_both_supported_python_versions(self): + source = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn('- "3.10"', source) + self.assertIn('- "3.12"', source) + self.assertIn("python -m unittest discover -s tests", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/util/server/http_api.py b/util/server/http_api.py index d38feb8f..659d80de 100644 --- a/util/server/http_api.py +++ b/util/server/http_api.py @@ -16,6 +16,7 @@ """ import asyncio +import hmac import shutil import time import uuid @@ -27,6 +28,7 @@ Form, Header, HTTPException, + Request, Response, UploadFile, ) @@ -40,6 +42,7 @@ decode_to_pcm, ) from util.server.openai_formatter import format_response +from util.server.http_limits import UploadTooLargeError, read_upload_limited from util.server.server_classes import Task, Result from util.server.server_cosmic import Cosmic from util.server.task_router import router as task_router @@ -59,13 +62,23 @@ def _check_auth(authorization: Optional[str]) -> None: api_key = Config.http_api_key if not api_key: return - if not authorization or not authorization.startswith("Bearer "): + if not authorization: raise HTTPException(401, "Missing or invalid Authorization header") - token = authorization[len("Bearer "):].strip() - if token != api_key: + parts = authorization.strip().split() + if len(parts) != 2 or parts[0].casefold() != "bearer" or not parts[1]: + raise HTTPException(401, "Missing or invalid Authorization header") + token = parts[1] + if not hmac.compare_digest(token.encode("utf-8"), api_key.encode("utf-8")): raise HTTPException(401, "Invalid API key") +def _check_transcription_content_type(content_type: Optional[str]) -> None: + """Reject parser surfaces that the legacy endpoint never supports.""" + media_type = (content_type or "").partition(";")[0].strip().casefold() + if media_type != "multipart/form-data": + raise HTTPException(415, "Content-Type must be multipart/form-data") + + def _split_and_submit(task_id: str, pcm: bytes) -> None: """ 把 PCM 切分成 60s + 4s overlap 的片段送入 queue_in, @@ -150,6 +163,26 @@ def create_app() -> FastAPI: ), ) + @app.middleware("http") + async def guard_transcription_request(request: Request, call_next): + # Use the ASGI scope path rather than request.url so malformed Host + # headers cannot influence this security decision. Authentication and + # media-type rejection happen before Starlette parses multipart fields. + if ( + request.method == "POST" + and request.scope.get("path") == "/v1/audio/transcriptions" + ): + try: + _check_auth(request.headers.get("authorization")) + _check_transcription_content_type(request.headers.get("content-type")) + except HTTPException as error: + return JSONResponse( + status_code=error.status_code, + content={"detail": error.detail}, + headers=error.headers, + ) + return await call_next(request) + @app.get("/health") async def health(): return { @@ -184,11 +217,12 @@ async def transcriptions( del model, temperature # 仅作 OpenAI 兼容占位, 本地模型由 Config.model_type 决定 max_bytes = Config.http_api_max_upload_mb * 1024 * 1024 - audio_bytes = await file.read() + try: + audio_bytes = await read_upload_limited(file, max_bytes) + except UploadTooLargeError: + raise HTTPException(413, f"File too large (>{Config.http_api_max_upload_mb} MB)") if not audio_bytes: raise HTTPException(400, "Empty file") - if len(audio_bytes) > max_bytes: - raise HTTPException(413, f"File too large (>{Config.http_api_max_upload_mb} MB)") try: pcm = await decode_to_pcm(audio_bytes) @@ -212,12 +246,16 @@ async def transcriptions( try: await asyncio.to_thread(_split_and_submit, task_id, pcm) if prompt: - # 当前 Task.context 是按片段设置的; 这里只 log 一下, + # 当前 Task.context 是按片段设置的; 这里只记录长度,避免逐字稿内容落盘。 # 完整的 prompt-as-context 注入留待 Fun-ASR-Nano 整段 prompt 支援。 - logger.debug(f"[HTTP] task={task_id[:8]} prompt={prompt[:50]!r}") + logger.debug(f"[HTTP] task={task_id[:8]} prompt_chars={len(prompt)}") result: Result = await asyncio.wait_for( future, timeout=Config.http_api_task_timeout ) + except asyncio.CancelledError: + task_router.cancel(task_id) + logger.warning(f"[HTTP] task={task_id[:8]} request cancelled") + raise except asyncio.TimeoutError: task_router.cancel(task_id) logger.error( @@ -227,7 +265,7 @@ async def transcriptions( except Exception as e: task_router.cancel(task_id) logger.error(f"[HTTP] task={task_id[:8]} error: {e}", exc_info=True) - raise HTTPException(500, f"Recognition error: {e}") + raise HTTPException(500, "Recognition failed") body, media_type = format_response(result, response_format, language=language) text = result.text_accu or result.text diff --git a/util/server/http_limits.py b/util/server/http_limits.py new file mode 100644 index 00000000..737f12b8 --- /dev/null +++ b/util/server/http_limits.py @@ -0,0 +1,37 @@ +# coding: utf-8 +"""Dependency-light HTTP upload limit helpers for the legacy server.""" + +from typing import Protocol + + +UPLOAD_READ_CHUNK_BYTES = 1024 * 1024 + + +class UploadTooLargeError(Exception): + """Raised as soon as a streamed upload exceeds its configured limit.""" + + +class AsyncReadable(Protocol): + async def read(self, size: int = -1) -> bytes: + ... + + +async def read_upload_limited( + upload: AsyncReadable, + max_bytes: int, + chunk_size: int = UPLOAD_READ_CHUNK_BYTES, +) -> bytes: + """Read an async upload in bounded chunks, failing before reading to EOF.""" + if max_bytes < 1 or chunk_size < 1: + raise ValueError("max_bytes and chunk_size must be positive") + + chunks = [] + total = 0 + while True: + chunk = await upload.read(min(chunk_size, max_bytes - total + 1)) + if not chunk: + return b"".join(chunks) + total += len(chunk) + if total > max_bytes: + raise UploadTooLargeError + chunks.append(chunk) diff --git a/util/server/openai_formatter.py b/util/server/openai_formatter.py index 3f6308d4..86c429f2 100644 --- a/util/server/openai_formatter.py +++ b/util/server/openai_formatter.py @@ -14,6 +14,7 @@ 退化时退回单一 segment (覆盖整个 duration)。 """ +import math import re from typing import Any, Dict, List, Optional, Tuple @@ -85,26 +86,22 @@ def _words_from_tokens( def _fmt_srt_ts(seconds: float) -> str: - seconds = max(0.0, seconds) - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int(round((seconds - int(seconds)) * 1000)) - if ms == 1000: - ms = 0 - s += 1 + if not math.isfinite(seconds): + seconds = 0.0 + total_ms = max(0, int(round(seconds * 1000))) + h, remainder = divmod(total_ms, 3_600_000) + m, remainder = divmod(remainder, 60_000) + s, ms = divmod(remainder, 1000) return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" def _fmt_vtt_ts(seconds: float) -> str: - seconds = max(0.0, seconds) - h = int(seconds // 3600) - m = int((seconds % 3600) // 60) - s = int(seconds % 60) - ms = int(round((seconds - int(seconds)) * 1000)) - if ms == 1000: - ms = 0 - s += 1 + if not math.isfinite(seconds): + seconds = 0.0 + total_ms = max(0, int(round(seconds * 1000))) + h, remainder = divmod(total_ms, 3_600_000) + m, remainder = divmod(remainder, 60_000) + s, ms = divmod(remainder, 1000) return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" diff --git a/util/server/server_recognize.py b/util/server/server_recognize.py index ae8c1ccb..3bbbf07e 100644 --- a/util/server/server_recognize.py +++ b/util/server/server_recognize.py @@ -31,7 +31,8 @@ from util.server.error_handler import save_error_audio -# 任务结果缓存(按 task_id 索引) +# 任务结果缓存。task_id 由 client 控制,必须同时以 socket_id 分区, +# 避免不同连接复用同一 ID 时互相合并或收到对方的逐字稿。 _results = {} @@ -101,12 +102,13 @@ def recognize(recognizer, punc_model, task: Task) -> Result: """ try: # 1. 初始化/获取结果容器 - is_first_segment = task.task_id not in _results + result_key = (task.socket_id, task.task_id) + is_first_segment = result_key not in _results if is_first_segment: - _results[task.task_id] = Result(task.task_id, task.socket_id, task.source) + _results[result_key] = Result(task.task_id, task.socket_id, task.source) logger.debug(f"新任务: {task.task_id[:8]}...") - result = _results[task.task_id] + result = _results[result_key] # 2. 解码音频 samples = np.frombuffer(task.data, dtype=np.float32) @@ -186,7 +188,7 @@ def recognize(recognizer, punc_model, task: Task) -> Result: result.timestamps = [i * time_per_char for i in range(len(chars))] logger.warning(f"模型无时间戳,使用粗略估计: {len(chars)} 字符, {result.duration:.2f}s") - result = _results.pop(task.task_id) + result = _results.pop(result_key) result.is_final = True process_time = result.time_complete - task.time_submit @@ -213,12 +215,15 @@ def clear_results_by_socket_id(socket_id: str) -> None: 当客户端连接断开时调用,防止内存泄漏。 """ global _results - tasks_to_remove = [ - task_id for task_id, result in _results.items() + result_keys_to_remove = [ + result_key for result_key, result in _results.items() if result.socket_id == socket_id ] - for task_id in tasks_to_remove: - _results.pop(task_id, None) + for result_key in result_keys_to_remove: + _results.pop(result_key, None) - if tasks_to_remove: - logger.debug(f"已清理断开连接相关的缓存: socket_id={socket_id}, 任务数={len(tasks_to_remove)}") + if result_keys_to_remove: + logger.debug( + f"已清理断开连接相关的缓存: socket_id={socket_id}, " + f"任务数={len(result_keys_to_remove)}" + ) diff --git a/util/server/server_ws_recv.py b/util/server/server_ws_recv.py index 937e2180..ce79b96f 100644 --- a/util/server/server_ws_recv.py +++ b/util/server/server_ws_recv.py @@ -6,8 +6,11 @@ """ import json +import math import time from base64 import b64decode +from binascii import Error as Base64Error +from typing import Optional import websockets @@ -23,6 +26,19 @@ status_mic = Status('正在接收音频', spinner='point') +# 这些上限涵盖官方客户端的 15/2 秒麦克风终止包与 60/4 秒文件包, +# 同时避免客户端控制的分段参数造成无限循环或无界缓存。 +MAX_AUDIO_CHUNK_BYTES = 4 * 1024 * 1024 +MAX_SEGMENT_DURATION_SECONDS = 300.0 +MAX_SEGMENT_OVERLAP_SECONDS = 30.0 +MAX_TASK_ID_CHARS = 128 +MAX_CONTEXT_CHARS = 8192 + + +class InvalidAudioMessage(ValueError): + """客户端音频消息不符合受支持协议。""" + + class AudioCache: """ 音频缓冲区 @@ -33,6 +49,8 @@ def __init__(self): self.chunks: bytes = b'' # 音频数据缓冲 self.offset: float = 0.0 # 当前偏移时间(秒) self.byte_count: int = 0 # 累计接收字节数 + self.task_id: Optional[str] = None + self.source: Optional[str] = None @property def duration(self) -> float: @@ -49,6 +67,85 @@ def reset(self) -> None: self.chunks = b'' self.offset = 0.0 self.byte_count = 0 + self.task_id = None + self.source = None + + def bind_stream(self, task_id: str, source: str) -> None: + """一个连接同一时间只允许传送一个顺序音频流。""" + if self.task_id is None: + self.task_id = task_id + self.source = source + return + if task_id != self.task_id or source != self.source: + raise InvalidAudioMessage( + "task_id/source changed before the active stream was finalized" + ) + + +def _finite_number(value, field_name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise InvalidAudioMessage(f"{field_name} must be a finite number") + value = float(value) + if not math.isfinite(value): + raise InvalidAudioMessage(f"{field_name} must be a finite number") + return value + + +def validate_audio_message(message: dict) -> bytes: + """验证消息元数据并严格解码 Base64 音频块。""" + if not isinstance(message, dict): + raise InvalidAudioMessage("message must be a JSON object") + + task_id = message.get("task_id") + if not isinstance(task_id, str) or not task_id or len(task_id) > MAX_TASK_ID_CHARS: + raise InvalidAudioMessage("task_id must be a non-empty bounded string") + if any(ord(char) < 32 or ord(char) == 127 for char in task_id): + raise InvalidAudioMessage("task_id must not contain control characters") + + if message.get("source") not in {"mic", "file"}: + raise InvalidAudioMessage("source must be 'mic' or 'file'") + if not isinstance(message.get("is_final"), bool): + raise InvalidAudioMessage("is_final must be a boolean") + + seg_duration = _finite_number(message.get("seg_duration"), "seg_duration") + seg_overlap = _finite_number(message.get("seg_overlap"), "seg_overlap") + _finite_number(message.get("time_start"), "time_start") + if not 0 < seg_duration <= MAX_SEGMENT_DURATION_SECONDS: + raise InvalidAudioMessage( + f"seg_duration must be > 0 and <= {MAX_SEGMENT_DURATION_SECONDS:g}" + ) + if not 0 <= seg_overlap <= MAX_SEGMENT_OVERLAP_SECONDS: + raise InvalidAudioMessage( + f"seg_overlap must be >= 0 and <= {MAX_SEGMENT_OVERLAP_SECONDS:g}" + ) + stride_bytes = AudioFormat.seconds_to_bytes(seg_duration) + segment_bytes = AudioFormat.seconds_to_bytes(seg_duration + seg_overlap) + if stride_bytes < AudioFormat.BYTES_PER_SAMPLE: + raise InvalidAudioMessage("seg_duration is shorter than one audio sample") + if ( + stride_bytes % AudioFormat.BYTES_PER_SAMPLE + or segment_bytes % AudioFormat.BYTES_PER_SAMPLE + ): + raise InvalidAudioMessage("segment geometry must be float32 sample-aligned") + + context = message.get("context", "") + if not isinstance(context, str) or len(context) > MAX_CONTEXT_CHARS: + raise InvalidAudioMessage("context must be a bounded string") + + encoded = message.get("data") + if not isinstance(encoded, str): + raise InvalidAudioMessage("data must be a Base64 string") + try: + decoded = b64decode(encoded, validate=True) + except (Base64Error, ValueError) as exc: + raise InvalidAudioMessage("data is not valid Base64") from exc + if len(decoded) > MAX_AUDIO_CHUNK_BYTES: + raise InvalidAudioMessage( + f"decoded audio chunk exceeds {MAX_AUDIO_CHUNK_BYTES} bytes" + ) + if len(decoded) % AudioFormat.BYTES_PER_SAMPLE: + raise InvalidAudioMessage("decoded float32 audio is not sample-aligned") + return decoded async def message_handler(websocket, message: dict, cache: AudioCache) -> None: @@ -57,17 +154,19 @@ async def message_handler(websocket, message: dict, cache: AudioCache) -> None: 根据消息中的分段参数,将音频数据分段后提交到识别队列。 """ + data = validate_audio_message(message) queue_in = Cosmic.queue_in global status_mic source = message['source'] is_final = message['is_final'] - is_start = not bool(cache.chunks) + is_start = cache.task_id is None # 获取 id task_id = message['task_id'] socket_id = str(websocket.id) context = message.get('context', '') + cache.bind_stream(task_id, source) # 从消息中获取分段参数(由客户端决定) seg_duration = message['seg_duration'] @@ -75,8 +174,7 @@ async def message_handler(websocket, message: dict, cache: AudioCache) -> None: seg_threshold = seg_duration + seg_overlap * 2 try: - # base64 解码音频数据(float32, 16kHz, mono) - data = b64decode(message['data']) + # Base64 已由 validate_audio_message 严格解码(float32, 16kHz, mono) cache.chunks += data cache.byte_count += len(data) @@ -178,6 +276,12 @@ async def ws_recv(websocket) -> None: console.print("ConnectionClosed...") logger.info(f"客户端正常关闭连接: {socket_id}") + except (json.JSONDecodeError, InvalidAudioMessage) as e: + logger.warning(f"拒绝无效 WebSocket 音频消息,客户端ID {socket_id}: {e}") + try: + await websocket.close(code=1008, reason="Invalid audio message") + except websockets.ConnectionClosed: + pass except websockets.ConnectionClosed: console.print("ConnectionClosed...") logger.warning(f"客户端连接已关闭: {socket_id}") diff --git a/util/server/task_router.py b/util/server/task_router.py index 0594b85b..3a031d56 100644 --- a/util/server/task_router.py +++ b/util/server/task_router.py @@ -34,6 +34,8 @@ def __init__(self) -> None: def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None: """由 HTTP server 启动时绑定运行中的事件循环。""" + if self._pending and self._loop is not loop: + raise RuntimeError("cannot rebind TaskRouter while tasks are pending") self._loop = loop def register(self, task_id: str) -> asyncio.Future: @@ -41,8 +43,13 @@ def register(self, task_id: str) -> asyncio.Future: 注册一个 HTTP 任务, 返回可 await 的 Future。 同时将合成 socket_id 加入 Cosmic.sockets_id 跨进程清单, 让识别子进程不会丢弃任务。 """ - if self._loop is None: - self._loop = asyncio.get_running_loop() + running_loop = asyncio.get_running_loop() + if task_id in self._pending: + raise ValueError(f"task_id is already pending: {task_id}") + if self._loop is None or self._loop.is_closed(): + self._loop = running_loop + elif self._loop is not running_loop: + raise RuntimeError("TaskRouter is bound to a different event loop") fut = self._loop.create_future() self._pending[task_id] = fut @@ -77,10 +84,14 @@ def try_resolve(self, result: Result) -> bool: self._pending.pop(result.task_id, None) self._remove_synthetic(result.task_id) if not fut.done(): + def set_result_if_pending() -> None: + if not fut.done(): + fut.set_result(result) + if self._loop is not None and self._loop.is_running(): - self._loop.call_soon_threadsafe(fut.set_result, result) + self._loop.call_soon_threadsafe(set_result_if_pending) else: - fut.set_result(result) + set_result_if_pending() return True @staticmethod