diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66c1c2b..b77153f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,12 @@ jobs: - uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} + - name: Install runtime dependencies + run: | + python -m pip install -r launcher/requirements.txt + python -m pip check + - name: Import runtime + run: python -c "import launcher.wake_launcher" - name: Compile Python run: python -m compileall -q launcher scripts - name: Run state and configuration tests diff --git a/.gitignore b/.gitignore index 73a3cfd..100ae30 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,11 @@ __pycache__/ *.pyc +# Model weights and downloaded archives must never enter source or Releases. +models/ +*.onnx +*.tar.bz2 + # User-specific local configuration. config.json .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06fac6f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +## [0.2.0] - 2026-07-25 + +First public release of Wake for Codex. + +### Included + +- Local Chinese and English keyword spotting on Windows and macOS. +- Microphone release before the user-configured Codex Voice Chat hotkey. +- Voice-session lifecycle observation and automatic listener re-arm. +- Optional, merge-safe Codex `SessionStart` Hook. +- Read-only diagnostics, strict configuration validation, bounded JSON logs, + and single-instance protection. +- Source-only distribution under Apache-2.0. Model weights remain upstream-only + and are downloaded with a pinned SHA-256 check. + +### Release hardening + +- CI installs and imports the exact runtime dependencies on Windows and macOS + with Python 3.11 and 3.12. +- The dependency set uses a NumPy version compatible with both supported Python + versions. +- `doctor` uses the effective repository configuration, validates the KWS + runtime and microphone stream format, and rejects a stale Hook path. +- Unknown configuration fields fail with their full path. +- Background startup failures are written to the structured event log. diff --git a/README.md b/README.md index 4d57241..4e160df 100644 --- a/README.md +++ b/README.md @@ -36,19 +36,19 @@ missing hands-free entry point: ## Status -The target release is `v0.2.0-alpha.1`. +The current release is `v0.2.0`. See [CHANGELOG.md](CHANGELOG.md). | Platform | Status | Verified scope | |---|---|---| -| Windows 10/11 | alpha | Local KWS, hotkey handoff, Codex microphone lifecycle, re-arm, singleton, diagnostics, optional Hook | -| macOS | alpha | Local KWS, CGEvent hotkey handoff, CoreAudio lifecycle, re-arm, diagnostics, optional cooldown | +| Windows 10/11 | v0.2.0 | Local KWS, hotkey handoff, Codex microphone lifecycle, re-arm, singleton, diagnostics, optional Hook | +| macOS | v0.2.0 | Local KWS, CGEvent hotkey handoff, CoreAudio lifecycle, re-arm, diagnostics, optional cooldown | The legacy TypeWhisper Voice Bridge is not part of this project. ## Requirements - Codex desktop with a Voice Chat shortcut configured in **Settings → Voice**; -- Python 3.11 or newer; +- Python 3.11 or 3.12 (the versions exercised by CI); - PowerShell 7 on Windows; - Microphone permission; - Accessibility permission on macOS for the process that starts the launcher. @@ -61,10 +61,9 @@ Codex. ### Windows ```powershell -git clone https://github.com/playing/wake-for-codex.git +git clone --branch v0.2.0 https://github.com/playing/wake-for-codex.git Set-Location .\wake-for-codex pwsh -NoProfile -File .\scripts\install-windows.ps1 -DownloadModel -Copy-Item .\config.example.json .\config.json pwsh -NoProfile -File .\run-wake-launcher.ps1 -Check pwsh -NoProfile -File .\run-wake-launcher.ps1 ``` @@ -72,19 +71,34 @@ pwsh -NoProfile -File .\run-wake-launcher.ps1 ### macOS ```sh -git clone https://github.com/playing/wake-for-codex.git +git clone --branch v0.2.0 https://github.com/playing/wake-for-codex.git cd wake-for-codex /bin/sh ./scripts/install-macos.sh --download-model -cp ./config.example.json ./config.json /bin/sh ./run-wake-launcher.sh --check /bin/sh ./run-wake-launcher.sh ``` +The defaults work without `config.json`. Copy `config.example.json` only when +you want to customize the hotkey, microphone, sensitivity, lifecycle, or paths. + Before the first macOS run, allow the terminal or launcher process under **System Settings → Privacy & Security → Microphone** and **Accessibility**. The read-only doctor reports missing Accessibility permission but never changes it. +## Upgrade + +Stop the background launcher, fetch the desired release tag, rerun the platform +installer, and start it again: + +```text +git fetch --tags +git checkout v0.2.0 +``` + +The installer reuses an existing verified model directory. Reinstall the +optional Hook only if the repository path changed. + ## Default wake phrases - `Hey Codex` @@ -151,6 +165,10 @@ Read-only environment check: ./.venv-kws/bin/python -m launcher.doctor ``` +`doctor` automatically uses the repository's `config.json` when present and +reports the effective file, runtime imports, exact microphone stream format, +platform lifecycle signal, and optional Hook path. + Detect one wake phrase without sending the hotkey: ```powershell @@ -258,6 +276,7 @@ unclear; review [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) before use. ## Development ```powershell +.\.venv-kws\Scripts\python.exe -m pip install -r launcher\requirements.txt .\.venv-kws\Scripts\python.exe -m compileall -q launcher scripts .\.venv-kws\Scripts\python.exe -m launcher.self_test .\.venv-kws\Scripts\python.exe -m launcher.sync_phrase_manifest diff --git a/README.zh-CN.md b/README.zh-CN.md index a133fce..4562cea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,19 +34,19 @@ Codex 已经负责完整的实时语音对话,本项目只补充缺少的免 ## 当前状态 -目标版本为 `v0.2.0-alpha.1`。 +当前版本为 `v0.2.0`,变更记录见 [CHANGELOG.md](CHANGELOG.md)。 | 平台 | 状态 | 已验证范围 | |---|---|---| -| Windows 10/11 | alpha | 本地 KWS、热键交接、Codex 麦克风生命周期、恢复监听、单实例、诊断、可选 Hook | -| macOS | alpha | 本地 KWS、CGEvent 热键交接、CoreAudio 生命周期、恢复监听、诊断、可选 cooldown | +| Windows 10/11 | v0.2.0 | 本地 KWS、热键交接、Codex 麦克风生命周期、恢复监听、单实例、诊断、可选 Hook | +| macOS | v0.2.0 | 本地 KWS、CGEvent 热键交接、CoreAudio 生命周期、恢复监听、诊断、可选 cooldown | 旧 TypeWhisper Voice Bridge 不属于本项目。 ## 环境要求 - Codex 桌面端,并在 **Settings → Voice** 中配置 Voice Chat 热键; -- Python 3.11 或更高版本; +- Python 3.11 或 3.12(CI 实际覆盖的版本); - Windows 需要 PowerShell 7; - 麦克风权限; - macOS 需要为启动 Launcher 的进程授予辅助功能权限。 @@ -58,10 +58,9 @@ Wake for Codex 中配置的热键必须与 Codex Voice Chat 热键一致。 ### Windows ```powershell -git clone https://github.com/playing/wake-for-codex.git +git clone --branch v0.2.0 https://github.com/playing/wake-for-codex.git Set-Location .\wake-for-codex pwsh -NoProfile -File .\scripts\install-windows.ps1 -DownloadModel -Copy-Item .\config.example.json .\config.json pwsh -NoProfile -File .\run-wake-launcher.ps1 -Check pwsh -NoProfile -File .\run-wake-launcher.ps1 ``` @@ -69,18 +68,32 @@ pwsh -NoProfile -File .\run-wake-launcher.ps1 ### macOS ```sh -git clone https://github.com/playing/wake-for-codex.git +git clone --branch v0.2.0 https://github.com/playing/wake-for-codex.git cd wake-for-codex /bin/sh ./scripts/install-macos.sh --download-model -cp ./config.example.json ./config.json /bin/sh ./run-wake-launcher.sh --check /bin/sh ./run-wake-launcher.sh ``` +默认配置无需 `config.json` 即可运行。只有需要修改热键、麦克风、灵敏度、生命周期或 +路径时,才复制 `config.example.json`。 + macOS 首次运行前,请在 **系统设置 → 隐私与安全性 → 麦克风/辅助功能** 中允许启动脚本所使用的终端或 Launcher 进程。只读 `doctor` 会报告缺失的辅助功能授权,但不会替你修改系统权限。 +## 升级 + +先停止后台 Launcher,获取并切换到目标 Release tag,重新运行对应平台安装器,再次 +启动: + +```text +git fetch --tags +git checkout v0.2.0 +``` + +安装器会复用已有模型目录。只有仓库路径变化时,才需要重新安装可选 Hook。 + ## 默认唤醒词 - `Hey Codex` @@ -144,6 +157,9 @@ sherpa-onnx 使用声学关键词检测,修改后必须重新编译: ./.venv-kws/bin/python -m launcher.doctor ``` +存在 `config.json` 时,`doctor` 会自动使用它,并报告实际配置文件、runtime import、 +精确麦克风流格式、平台生命周期信号和可选 Hook 路径。 + 只检测一次唤醒词,不发送热键: ```powershell @@ -244,6 +260,7 @@ sherpa-onnx 上游下载 KWS 模型并校验固定 SHA-256。上游尚未明确 ## 开发验证 ```powershell +.\.venv-kws\Scripts\python.exe -m pip install -r launcher\requirements.txt .\.venv-kws\Scripts\python.exe -m compileall -q launcher scripts .\.venv-kws\Scripts\python.exe -m launcher.self_test .\.venv-kws\Scripts\python.exe -m launcher.sync_phrase_manifest diff --git a/docs/configuration.md b/docs/configuration.md index b201897..ca19374 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,10 @@ Wake for Codex works without a configuration file. To customize it, copy `config.example.json` to the repository root as `config.json`. The local file is ignored by Git. +Unknown fields are rejected with their full configuration path instead of being +silently ignored. `doctor` automatically uses the repository's `config.json` +when it is present. + ## Precedence Values are resolved in this order: diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 988597e..97b26fe 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -6,6 +6,9 @@ Wake for Codex 不需要配置文件也能运行。需要自定义时,将 `config.example.json` 复制到仓库根目录并命名为 `config.json`。该本地文件已被 Git 忽略。 +未知字段会报告完整配置路径并终止,不会被静默忽略。仓库中存在 `config.json` 时, +`doctor` 会自动使用它。 + ## 优先级 配置按以下顺序覆盖: diff --git a/docs/research/2026-07-25-platform-upstream-risk-scan.md b/docs/research/2026-07-25-platform-upstream-risk-scan.md new file mode 100644 index 0000000..66857ac --- /dev/null +++ b/docs/research/2026-07-25-platform-upstream-risk-scan.md @@ -0,0 +1,226 @@ +# Platform upstream risk scan + +Date: 2026-07-25 + +Scope: a bounded primary-source review of the upstream contracts that Wake for +Codex currently depends on. This is not a proposal to expand the product beyond +local wake word → microphone release → configured Voice hotkey → listening +resume. + +## Verified facts + +### sherpa-onnx runtime and KWS model + +- The `sherpa-onnx` source repository is Apache-2.0, but its own KWS + documentation separately tells users to check the license of the selected + model. The source-code license therefore should not be treated as an explicit + license grant for every pretrained model artifact + ([repository license](https://github.com/k2-fsa/sherpa-onnx/blob/master/LICENSE), + [KWS model-license note](https://k2-fsa.github.io/sherpa/onnx/kws/apk.html)). +- Upstream documents the exact + `sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20` archive and download URL used + by this project. The documented archive listing contains model, vocabulary, + and test-audio files but no model-specific license + ([model documentation](https://k2-fsa.github.io/sherpa/onnx/kws/pretrained_models/index.html#sherpa-onnx-kws-zipformer-zh-en-3m-2025-12-20-chinese-english)). +- In this scan, the archive downloaded directly from the upstream GitHub + Release had SHA-256 + `68447f4fbc67e70eee3a93961f36e81e98f47aef73ce7e7ca00885c6cd3616a6`, + matching both the project's pinned value and the digest exposed by the + [GitHub Release API](https://api.github.com/repos/k2-fsa/sherpa-onnx/releases/tags/kws-models). + Its 26 archive entries contained no `LICENSE`, `LICENCE`, `COPYING`, or + `NOTICE` file. +- The upstream clarification request covering this exact bilingual model is + still open and has no maintainer response as of the scan date + ([k2-fsa/sherpa-onnx#3760](https://github.com/k2-fsa/sherpa-onnx/issues/3760)). + Consequently, commercial-use, modification, and redistribution permissions + for the weights and accompanying vocabulary files remain unverified. + +### sherpa-onnx, sounddevice, and PortAudio platform constraints + +- Upstream's CPU-only Python installation path lists macOS `x64` and `arm64`, + and Windows `x64` and `x86`. It does not list Windows ARM64 for the Python + wheel path used by this project + ([sherpa-onnx Python installation](https://k2-fsa.github.io/sherpa/onnx/python/install.html)). + Upstream has separate native Windows ARM64 libraries/build instructions, but + that is not the same as a supported `pip install sherpa-onnx==1.13.4` path + ([Windows ARM64 native build](https://k2-fsa.github.io/sherpa/onnx/install/windows/generated/build_cpu/windows_arm64_cpu_build.html)). +- At the start of this scan, the project pinned `numpy==2.5.1`, whose PyPI + metadata requires Python `>=3.12` + ([NumPy 2.5.1 PyPI JSON](https://pypi.org/pypi/numpy/2.5.1/json)). + This conflicts with the README's “Python 3.11 or newer” claim and the CI + matrix's Python 3.11 row + ([requirements](../../launcher/requirements.txt), + [CI workflow](../../.github/workflows/ci.yml), + [README](../../README.md)). +- The initial CI workflow did not install `launcher/requirements.txt`; it + compiled source and ran tests whose import paths avoided the runtime + audio/KWS dependencies. Therefore the green Python 3.11 jobs did not verify + that a Python 3.11 user could install or start the pinned runtime. For + comparison, NumPy 2.4.2 advertises Python `>=3.11` + ([NumPy 2.4.2 PyPI JSON](https://pypi.org/pypi/numpy/2.4.2/json)); this is + evidence that a compatible pin is possible, not a recommendation of that + exact version. +- `sounddevice` 0.5.5 installs PortAudio automatically when installed with + `pip` on macOS or Windows. A package-manager or custom PortAudio library can + override that bundled library, so the actually loaded backend is partly an + environment property + ([sounddevice installation](https://python-sounddevice.readthedocs.io/en/0.5.5/installation.html)). +- PortAudio stream sharing is host-API dependent. The `sounddevice` contract + says portable applications should assume a device can be used by at most one + stream at a time + ([stream API](https://python-sounddevice.readthedocs.io/en/0.5.5/api/streams.html)). + Releasing Wake for Codex's input stream before triggering Voice is therefore + the correct portable boundary. +- The PortAudio callback runs at high or real-time priority. Its documented + constraints say not to allocate memory or call operations that may block or + take unpredictable time. The same documentation recommends `blocksize=0` + unless an algorithm requires fixed-size callback blocks + ([callback and block-size contract](https://python-sounddevice.readthedocs.io/en/0.5.5/api/streams.html)). +- `sounddevice` exposes `check_input_settings()` specifically to validate a + device, channel count, sample type, and sample rate, raising when the + combination is unsupported + ([official module API](https://python-sounddevice.readthedocs.io/en/0.5.5/api/checking-hardware.html#sounddevice.check_input_settings)). + +### Windows microphone lifecycle signal + +- Microsoft's supported user-facing contract is that Windows shows microphone + use in the system tray and exposes a seven-day resource-access history in + Settings + ([Windows privacy controls](https://learn.microsoft.com/en-us/windows/security/book/privacy-controls)). +- This review found no official Microsoft API or registry contract defining + `HKCU\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone`, + `LastUsedTimeStart`, `LastUsedTimeStop`, their update timing, or application + key naming as a stable programmatic interface. Microsoft documents + microphone privacy policy controls, but not those usage-history values + ([Privacy Policy CSP](https://learn.microsoft.com/en-us/windows/client-management/mdm/policy-csp-privacy)). +- Wake for Codex currently depends on those undocumented values and an + `OpenAI.Codex_` registry-key prefix. This is observable Windows behavior, not + a Microsoft-supported lifecycle API. + +### macOS CoreAudio and synthetic hotkey signal + +- Apple currently publishes + `kAudioDevicePropertyDeviceIsRunningSomewhere` as a CoreAudio property + selector for macOS and Mac Catalyst, without a deprecation marker + ([Apple API reference](https://developer.apple.com/documentation/coreaudio/kaudiodevicepropertydeviceisrunningsomewhere)). + It is a device-level “running somewhere” signal: it reports that the audio + device is running in at least one process, not which application owns the + input. +- Apple currently publishes `CGEvent` keyboard-event creation and event + posting APIs without deprecation markers + ([keyboard-event creation](https://developer.apple.com/documentation/coregraphics/cgevent/init%28keyboardeventsource%3Avirtualkey%3Akeydown%3A%29), + [event posting](https://developer.apple.com/documentation/coregraphics/cgevent/post%28tap%3A%29)). +- `AXIsProcessTrusted()` reports whether the current process is a trusted + Accessibility client + ([Apple API reference](https://developer.apple.com/documentation/applicationservices/1460720-axisprocesstrusted)). + Apple Developer Technical Support now describes posting `CGEvent`s as a + distinct `PostEvent` TCC privilege and points developers to + `CGPreflightPostEventAccess` and `CGRequestPostEventAccess`; the permission + still appears under Accessibility in System Settings + ([Apple DTS answer](https://developer.apple.com/forums/thread/789896)). + Therefore `AXIsProcessTrusted()` is not the most precise current preflight + for the action Wake for Codex performs. +- Apple requires explicit user authorization when a third-party process + controls the Mac through Accessibility-related capabilities + ([Apple Support](https://support.apple.com/guide/mac-help/mh43185/mac)). + +### Codex Hook and Voice shortcut + +- OpenAI publicly documents user-level `~/.codex/hooks.json`, + `SessionStart`, `commandWindows`, hook trust review, and the + `startup|resume|clear|compact` matcher values. The project's optional + `SessionStart` hook using `startup|resume`, an absolute launcher path, and a + Windows override conforms to that published shape + ([OpenAI Codex Hooks](https://learn.chatgpt.com/docs/hooks.md)). +- OpenAI publicly documents the user-configured + **Settings > Voice > Voice chat hotkey** + ([OpenAI ChatGPT Voice](https://learn.chatgpt.com/docs/features/voice.md)). + The documented integration seam is a user-selected shortcut; Wake for Codex + does not need to inspect or mutate Codex settings. + +## Inferences + +- Direct upstream download plus a pinned SHA-256 materially reduces artifact + substitution risk, but it does not resolve copyright or redistribution + permission. Continuing to exclude the model from the repository and Release + assets is the narrowest defensible posture while the license is unknown. +- The current Python 3.11 support claim is a false positive at the installation + boundary: source-only tests pass, while the exact dependency set cannot be + resolved on 3.11 because of the NumPy `Requires-Python` constraint. +- The current audio callback calls `indata[:, 0].copy()` and performs queue + operations. The copy is an allocation inside the real-time callback, contrary + to PortAudio's general callback guidance. The bounded queue prevents + unbounded memory growth and drops old audio under pressure, so this is a + potential glitch/false-negative risk under load rather than evidence of a + current correctness failure. +- `doctor` currently confirms that an input device can be queried, but does not + validate the launcher's exact `16 kHz / mono / float32` stream format. + `sd.check_input_settings(...)` would close that diagnostic gap without + opening the microphone or adding a subsystem. +- Windows lifecycle detection can break after a Codex packaging identity + change, a Windows registry-layout change, privacy-history cleanup, or on a + machine where Codex has never yet produced a matching entry. Because no + supported Microsoft contract exists, more unit tests around the current + registry shape cannot turn the heuristic into a stable API. +- On macOS, any process using the default input device can satisfy + `DeviceIsRunningSomewhere`; a device switch can also change what “default + input” means during polling. The current pre-launch “wait until inactive” + check is conservative and prevents an already-busy device from being + misattributed to Codex, but the signal still cannot prove Codex ownership. +- The current Codex integration is appropriately shallow: both the hook and + hotkey are documented user-facing seams, while microphone lifecycle remains + an OS heuristic. Adding Codex process inspection or UI automation would not + make either OS signal an official Voice-session API. + +## Unknowns + +- The license, commercial-use rights, modification rights, redistribution + rights, and training-data provenance of + `sherpa-onnx-kws-zipformer-zh-en-3M-2025-12-20` remain unknown. +- Microsoft does not publicly commit to the current CapabilityAccessManager + usage-history registry schema, application-key prefix, timestamp units, or + update latency. +- Apple documents the CoreAudio selector but does not promise that every + built-in, USB, Bluetooth, aggregate, or virtual input device reports + `DeviceIsRunningSomewhere` with identical timing or reliability. +- It is not established from public Apple documentation that + `AXIsProcessTrusted()` alone will remain a sufficient preflight for event + posting on every supported macOS release. The current specific API is the + PostEvent access check. +- This scan found no public OpenAI API for reading the configured Voice hotkey + or receiving a machine-readable Voice-session started/ended event. Absence + from the current public documentation is not proof that no internal mechanism + exists; it means the project should not depend on one. + +## Lightweight implications + +1. **Keep the model policy unchanged for the v0.2.0 Release.** Do not commit, + mirror, modify, or attach the model to GitHub Releases. Keep the direct + upstream URL, pinned SHA-256, warning, and upstream clarification link. +2. **Resolve the Python 3.11 contract before calling the matrix green.** Choose + one small, explicit path: pin a NumPy release compatible with Python 3.11, + or drop Python 3.11 and align the README and CI matrix to Python 3.12+. + Whichever path is chosen, make at least one CI path install the exact runtime + requirements so dependency compatibility is actually exercised. + **v0.2.0 status:** resolved with the compatible NumPy 2.4.2 pin and runtime + dependency installation in every supported CI combination. +3. **Tighten `doctor`, not the architecture.** Add a read-only + `sd.check_input_settings(device, channels=1, dtype="float32", + samplerate=16000)` check. Report Windows ARM64 as unsupported by the current + pinned Python-wheel install path instead of attempting a native-build + subsystem. +4. **Label Windows lifecycle detection as a 0.x compatibility heuristic.** Preserve its + bounded timeouts and explicit failure. Improve the error for “no matching + Codex microphone history” and include one manual smoke check after material + Codex Desktop or Windows updates; do not build a registry abstraction layer. +5. **Use the precise macOS permission preflight when making the next small + platform patch.** Prefer `CGPreflightPostEventAccess` where available and + keep the existing user-granted permission flow. Retain `cooldown` as the + simple fallback for unreliable CoreAudio device state. +6. **Treat callback tuning as evidence-driven.** Existing `audio_status` + logging already exposes overflow/underflow symptoms. Only if field evidence + shows dropped callbacks, test `blocksize=0` and a lower-allocation handoff; + avoid introducing a new audio engine. +7. **Keep Codex integration optional and user-owned.** The existing Hook and + configurable hotkey match public OpenAI documentation. Do not add setting + discovery, Voice UI automation, or additional lifecycle hooks. diff --git a/launcher/README.md b/launcher/README.md index 9b7ff70..647bd77 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -62,5 +62,5 @@ activate_and_wait(event_sink) -> None Windows 与 macOS 均已完成本地唤醒、热键交接、Voice Chat 生命周期和恢复监听的 真人闭环。Python 回归、短语一致性、模型探针、单实例和 `doctor` 检查通过。 -本项目是小型 alpha 工具,不把长时间压力、所有声卡组合或所有 Codex 版本列为首发 +本项目是小型 0.x 工具,不把长时间压力、所有声卡组合或所有 Codex 版本列为首发 门槛。睡眠恢复、设备切换和 Codex 更新后的行为作为已知兼容性边界持续回归。 diff --git a/launcher/doctor.py b/launcher/doctor.py index 36b34eb..0adb86f 100644 --- a/launcher/doctor.py +++ b/launcher/doctor.py @@ -3,11 +3,14 @@ from __future__ import annotations import argparse +import importlib import json import sys +from collections.abc import Callable, Mapping from pathlib import Path import sounddevice as sd +from scripts.codex_hook import is_launcher_command, launcher_hook from .model_config import ModelLayout from .phrase_manifest import load_phrase_manifest, validate_keyword_artifacts @@ -19,21 +22,124 @@ def result(name: str, status: str, detail: object) -> dict[str, object]: return {"name": name, "status": status, "detail": detail} +def resolve_config_path(repo_root: Path, requested: Path | None) -> Path | None: + if requested is not None: + return requested.expanduser().resolve() + default = repo_root / "config.json" + return default.resolve() if default.is_file() else None + + +def inspect_hook(hook_path: Path, repo_root: Path) -> dict[str, object]: + if not hook_path.is_file(): + return result("hook", "optional", "not installed") + try: + document = json.loads(hook_path.read_text(encoding="utf-8")) + groups = list((document.get("hooks") or {}).get("SessionStart") or []) + commands = [ + command + for group in groups + if isinstance(group, dict) + for command in list(group.get("hooks") or []) + if isinstance(command, dict) + ] + except Exception as error: + return result( + "hook", + "fail", + { + "file": str(hook_path), + "reason": "invalid_hook_file", + "error": str(error), + }, + ) + + expected = launcher_hook(repo_root.resolve())["hooks"][0] + if any( + command.get("command") == expected["command"] + and command.get("commandWindows") == expected["commandWindows"] + for command in commands + ): + return result("hook", "pass", {"file": str(hook_path)}) + if any(is_launcher_command(command) for command in commands): + return result( + "hook", + "fail", + {"file": str(hook_path), "reason": "stale_launcher_path"}, + ) + return result("hook", "optional", "not installed") + + +def inspect_microphone( + device: str | int | None, + *, + format_checker: Callable[..., None] = sd.check_input_settings, + device_reader: Callable[..., Mapping[str, object]] = sd.query_devices, +) -> dict[str, object]: + try: + format_checker( + device=device, + channels=1, + dtype="float32", + samplerate=16_000, + ) + input_device = device_reader(device, "input") + name = str(input_device["name"]) + return result( + "microphone", + "pass", + { + "device": name, + "samplerate": 16_000, + "channels": 1, + "dtype": "float32", + }, + ) + except Exception as error: + return result("microphone", "fail", str(error)) + + +def inspect_runtime( + *, + importer: Callable[[str], object] = importlib.import_module, +) -> dict[str, object]: + try: + versions: dict[str, str] = {} + for name in ("sherpa_onnx", "numpy", "sounddevice"): + module = importer(name) + versions[name] = str(getattr(module, "__version__", "unknown")) + return result("runtime", "pass", versions) + except Exception as error: + return result("runtime", "fail", str(error)) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--config", type=Path) args = parser.parse_args() root = Path(__file__).resolve().parent + repo_root = root.parent + config_path = resolve_config_path(repo_root, args.config) checks: list[dict[str, object]] = [] try: - settings = load_settings(root=root, config_path=args.config) - checks.append(result("config", "pass", settings.hotkey.label)) + settings = load_settings(root=root, config_path=config_path) + checks.append( + result( + "config", + "pass", + { + "file": str(config_path) if config_path else "defaults", + "hotkey": settings.hotkey.label, + }, + ) + ) except Exception as error: checks.append(result("config", "fail", str(error))) print(json.dumps({"status": "fail", "checks": checks}, ensure_ascii=True)) return 1 + checks.append(inspect_runtime()) + model = ModelLayout(settings.model_dir) required_model_files = [ model.tokens, @@ -68,11 +174,7 @@ def main() -> int: except Exception as error: checks.append(result("phrases", "fail", str(error))) - try: - input_device = sd.query_devices(settings.device, "input") - checks.append(result("microphone", "pass", input_device["name"])) - except Exception as error: - checks.append(result("microphone", "fail", str(error))) + checks.append(inspect_microphone(settings.device)) try: platform = create_platform_adapter( @@ -94,17 +196,7 @@ def main() -> int: checks.append(result("platform", "fail", str(error))) hook_path = Path.home() / ".codex" / "hooks.json" - if not hook_path.is_file(): - checks.append(result("hook", "optional", "not installed")) - else: - text = hook_path.read_text(encoding="utf-8") - checks.append( - result( - "hook", - "pass" if "start-wake-launcher" in text else "optional", - str(hook_path), - ) - ) + checks.append(inspect_hook(hook_path, repo_root)) failed = any(check["status"] == "fail" for check in checks) print( diff --git a/launcher/requirements.txt b/launcher/requirements.txt index 103597d..88896ca 100644 --- a/launcher/requirements.txt +++ b/launcher/requirements.txt @@ -1,5 +1,5 @@ click==8.4.2 -numpy==2.5.1 +numpy==2.4.2 pypinyin==0.55.0 sentencepiece==0.2.2 sherpa-onnx==1.13.4 diff --git a/launcher/self_test.py b/launcher/self_test.py index 8e1cc04..b767f72 100644 --- a/launcher/self_test.py +++ b/launcher/self_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import subprocess import sys import tempfile import unittest @@ -197,6 +198,51 @@ def test_unknown_config_version_is_rejected(self) -> None: with self.assertRaises(ValueError): load_settings(root=root, config_path=config) + def test_unknown_config_field_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + config = root / "config.json" + config.write_text( + json.dumps( + { + "version": 1, + "listener": {"threshhold": 0.9}, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex( + ValueError, + r"Unknown configuration field: listener\.threshhold", + ): + load_settings(root=root, config_path=config) + + def test_unknown_fields_are_rejected_in_every_config_section(self) -> None: + cases = [ + ({"mystery": True}, "mystery"), + ({"hotkeys": {"linux": "ctrl+g"}}, "hotkeys.linux"), + ( + {"lifecycle": {"launchTimeout": 10}}, + "lifecycle.launchTimeout", + ), + ({"paths": {"modelDirectory": "model"}}, "paths.modelDirectory"), + ({"macos": {"cooldown": 30}}, "macos.cooldown"), + ] + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + config = root / "config.json" + for fields, expected in cases: + with self.subTest(field=expected): + config.write_text( + json.dumps({"version": 1, **fields}), + encoding="utf-8", + ) + with self.assertRaisesRegex( + ValueError, + rf"Unknown configuration field: {expected}", + ): + load_settings(root=root, config_path=config) + class EventLogTests(unittest.TestCase): def test_log_rotates_before_exceeding_bound(self) -> None: @@ -209,6 +255,94 @@ def test_log_rotates_before_exceeding_bound(self) -> None: self.assertTrue(path.with_name("events.jsonl.1").is_file()) +class LauncherCliTests(unittest.TestCase): + def test_startup_failure_is_written_to_the_event_log(self) -> None: + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + config = root / "config.json" + log_path = root / "events.jsonl" + config.write_text('{"version": 999}', encoding="utf-8") + + completed = subprocess.run( + [ + sys.executable, + "-m", + "launcher.wake_launcher", + "--config", + str(config), + "--log-file", + str(log_path), + "--once", + ], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + self.assertEqual(completed.returncode, 1) + event = json.loads(log_path.read_text(encoding="utf-8")) + self.assertEqual(event["event"], "error") + self.assertEqual(event["kind"], "startup_failed") + + +class DoctorTests(unittest.TestCase): + def test_repository_config_is_used_by_default(self) -> None: + from .doctor import resolve_config_path + + with tempfile.TemporaryDirectory() as folder: + repo_root = Path(folder) + config = repo_root / "config.json" + config.write_text('{"version": 1}', encoding="utf-8") + self.assertEqual(resolve_config_path(repo_root, None), config.resolve()) + + def test_hook_check_rejects_a_stale_launcher_path(self) -> None: + from .doctor import inspect_hook + + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + hook_path = root / "hooks.json" + document = {"hooks": {}} + install_launcher_hook(document, root / "old-clone") + hook_path.write_text(json.dumps(document), encoding="utf-8") + + check = inspect_hook(hook_path, root / "current-clone") + + self.assertEqual(check["status"], "fail") + self.assertEqual(check["detail"]["reason"], "stale_launcher_path") + + def test_microphone_check_rejects_an_unsupported_runtime_format(self) -> None: + from .doctor import inspect_microphone + + def reject_format(**_settings: object) -> None: + raise ValueError("unsupported 16 kHz format") + + check = inspect_microphone( + None, + format_checker=reject_format, + device_reader=lambda *_args: {"name": "Test microphone"}, + ) + + self.assertEqual(check["status"], "fail") + self.assertIn("unsupported 16 kHz format", str(check["detail"])) + + def test_runtime_check_reports_a_broken_kws_import(self) -> None: + from .doctor import inspect_runtime + + def import_module(name: str) -> object: + if name == "sherpa_onnx": + raise ImportError("missing native runtime") + return object() + + check = inspect_runtime(importer=import_module) + + self.assertEqual(check["status"], "fail") + self.assertIn("missing native runtime", str(check["detail"])) + + class CodexHookTests(unittest.TestCase): def test_install_is_idempotent_and_preserves_other_hooks(self) -> None: document = { diff --git a/launcher/settings.py b/launcher/settings.py index d093e48..f1ed65f 100644 --- a/launcher/settings.py +++ b/launcher/settings.py @@ -57,6 +57,18 @@ def _resolve_path(value: Any, base: Path, default: Path) -> Path: return path if path.is_absolute() else (base / path).resolve() +def _reject_unknown_fields( + values: dict[str, Any], + *, + section: str | None, + allowed: set[str], +) -> None: + unknown = sorted(set(values) - allowed) + if unknown: + name = f"{section}.{unknown[0]}" if section else unknown[0] + raise ValueError(f"Unknown configuration field: {name}") + + def load_settings( *, root: Path, @@ -70,13 +82,55 @@ def load_settings( document = json.loads(resolved_config.read_text(encoding="utf-8")) if document.get("version") != 1: raise ValueError("Unsupported config version") + _reject_unknown_fields( + document, + section=None, + allowed={ + "version", + "hotkeys", + "listener", + "lifecycle", + "macos", + "paths", + }, + ) base = resolved_config.parent listener = dict(document.get("listener") or {}) + _reject_unknown_fields( + listener, + section="listener", + allowed={"device", "threshold", "score"}, + ) lifecycle = dict(document.get("lifecycle") or {}) + _reject_unknown_fields( + lifecycle, + section="lifecycle", + allowed={ + "launchTimeoutSeconds", + "sessionTimeoutSeconds", + "rearmDelaySeconds", + "errorRetryDelaySeconds", + }, + ) paths = dict(document.get("paths") or {}) + _reject_unknown_fields( + paths, + section="paths", + allowed={"modelDir", "keywordsFile", "phraseManifest"}, + ) hotkeys = dict(document.get("hotkeys") or {}) + _reject_unknown_fields( + hotkeys, + section="hotkeys", + allowed={"windows", "macos"}, + ) macos = dict(document.get("macos") or {}) + _reject_unknown_fields( + macos, + section="macos", + allowed={"lifecycleMode", "cooldownSeconds"}, + ) values = dict(overrides or {}) platform_name = _platform_name() diff --git a/launcher/wake_launcher.py b/launcher/wake_launcher.py index b49a5d3..6ef5fa8 100644 --- a/launcher/wake_launcher.py +++ b/launcher/wake_launcher.py @@ -201,22 +201,26 @@ def main() -> int: "session_timeout", "threshold", } - settings = load_settings( - root=root, - config_path=args.config, - overrides={ - name: getattr(args, name) - for name in override_names - if getattr(args, name) is not None - }, - ) - platform = create_platform_adapter( - hotkey=settings.hotkey, - launch_timeout_seconds=settings.launch_timeout_seconds, - session_timeout_seconds=settings.session_timeout_seconds, - macos_lifecycle_mode=settings.macos_lifecycle_mode, - macos_cooldown_seconds=settings.macos_cooldown_seconds, - ) + try: + settings = load_settings( + root=root, + config_path=args.config, + overrides={ + name: getattr(args, name) + for name in override_names + if getattr(args, name) is not None + }, + ) + platform = create_platform_adapter( + hotkey=settings.hotkey, + launch_timeout_seconds=settings.launch_timeout_seconds, + session_timeout_seconds=settings.session_timeout_seconds, + macos_lifecycle_mode=settings.macos_lifecycle_mode, + macos_cooldown_seconds=settings.macos_cooldown_seconds, + ) + except Exception as error: + emit("error", kind="startup_failed", detail=str(error)) + return 1 if args.check: _listener, platform_details, input_device = initialize_listener( settings,