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)
+[](docs/en/maintenance.md)
+[](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)
-[](#)
-[](docker-compose.yml)
-[](docs/HTTP_API.md)
-[](#-configuration-that-matters-first)
-[](https://github.com/HaujetZhao/CapsWriter-Offline)
+[](LICENSE)
+[](docs/zh-TW/maintenance.md)
+[](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