diff --git a/config-baseline.json b/config-baseline.json index c280a78365c..510c1e24cd6 100644 --- a/config-baseline.json +++ b/config-baseline.json @@ -2540,7 +2540,8 @@ "mlx", "apple", "parakeet", - "transcribe" + "transcribe", + "faster" ], "defaultValue": "whisper" }, @@ -2567,9 +2568,14 @@ "sensitive": false, "tags": [], "label": "Model", - "help": "Whisper model size.", + "help": "Whisper model size (whisper and faster providers).", "hasChildren": false, "enumValues": [ + "tiny", + "base", + "small", + "medium", + "large-v3", "turbo" ], "defaultValue": "turbo" diff --git a/docs/system-specs/modules/learn-cron-dashboard.md b/docs/system-specs/modules/learn-cron-dashboard.md index 0870889f637..b878492aa88 100644 --- a/docs/system-specs/modules/learn-cron-dashboard.md +++ b/docs/system-specs/modules/learn-cron-dashboard.md @@ -1265,7 +1265,7 @@ React 18 + TypeScript + Vite 5 + Redux Toolkit + React Router v7 + Tailwind CSS - **Lessons tab**: add lesson form + lesson table with Delete actions - **Skills tab (CRUD)**: + New button with create form (name + SKILL.md editor), installed skill list with click-to-view, ✏ Edit button with inline textarea editor + Save, ✕ Delete with confirmation, name sanitized to lowercase + hyphens. AIM Skills section shows skills from `~/.aim/` grouped by package with Uninstall button per package. Skills are fully AIM-managed — no bundled skills; `AIPowerUserCapabilities` installed by default via setup/update. - **MCP Servers tab**: Controls `~/.kiro/settings/mcp.json` (global config that kiro-cli ACP loads at runtime). Server-level enable/disable sets `disabled: true/false` in global config and syncs `@server` to kirocrew.json `tools`/`allowedTools`. Per-tool enable/disable sets `disabledTools` array in global config. Probe All discovers tools per server, preserves enabled/disabledTools state across probes. Enable All / Disable All bulk buttons. Tool chips: green = enabled (clickable to disable), strikethrough = disabled (clickable to enable). Apply & Restart at top bar resets all active sessions. Live server badges (🔌 color-coded by status). - - **Slack tab**: STT (Speech-to-Text) settings card — toggle enabled/disabled, provider selector (`whisper` / `mlx` / `transcribe`), model selector (turbo ~1.6 GB), status badge (ready/not installed), provider-aware install button (`brew install openai-whisper` for `whisper`, `pipx install mlx-whisper` for `mlx`; hidden for `transcribe`, which has no local runtime to install — its availability is "`boto3` + `amazon-transcribe` importable by the gateway process", so the prereq block instead surfaces ` -m pip install 'kirocrew[voice]'` (PowerShell `&`-form on Windows; suppressed once the extra is importable) plus a platform ffmpeg command when ffmpeg is missing and a restart-the-gateway hint, and `POST /api/stt/install` refuses with 400 `code: stt_no_local_install` for it rather than installing a Whisper runtime that cannot change Transcribe's status. When no install channel can make the extra importable — frozen build, the desktop app's code-signed bundled interpreter, pip-less interpreter, or a PEP 668 externally-managed python — the response carries `transcribe_unsupported: true` and the Settings page shows an unsupported notice instead of commands; `bundled_interpreter: true` discriminates the desktop-app cause, whose notice names the actual remedy (install Kiro Crew with pip and run the gateway from that environment) because "run the gateway from a different Python environment" is not actionable inside an app bundle. The response also carries `ffmpeg_missing`, served independently of `available`, because Transcribe's availability check treats ffmpeg as optional (it only affects the non-streaming `.webm` remux path) — the Settings page uses it to show the ffmpeg install command even while the status badge reads ready). The `mlx` provider (Apple Silicon Metal GPU) uses the `mlx_model` config key (default `mlx-community/whisper-large-v3-turbo`). Endpoints: `GET/PUT /api/config/stt`, `POST /api/stt/install`. **Homebrew resolution never relies on `PATH`**: a GUI-launched gateway (desktop app / launchd) inherits `/usr/bin:/bin:/usr/sbin:/sbin`, which contains no Homebrew prefix, so `shutil.which("brew")` reported Homebrew MISSING on machines that have it — the prereq list told a Homebrew user to install Homebrew and the `mlx` install script aborted at its first `command -v brew` with `ERROR: Homebrew required`. The prereq probe goes through `transcribe.find_brew()` (PATH, then the fixed prefixes `/opt/homebrew/bin`, `/usr/local/bin`, Linuxbrew system + per-user), and every install script is prefixed with `_stt_install_path_prelude()` — it prepends `transcribe.BREW_PATH_DIRS` (the same prefixes plus `~/.local/bin`, where pipx puts `mlx_whisper`) and then `eval "$(brew shellenv)"`. The prelude is required because the script runs as `bash -c`, which is neither a login nor an interactive shell, so the user's `~/.zprofile` `brew shellenv` line never executes. + - **Slack tab**: STT (Speech-to-Text) settings card — toggle enabled/disabled, provider selector (`whisper` / `mlx` / `apple` / `parakeet` / `transcribe` / `faster`), model selector (turbo ~1.6 GB; for `whisper`/`faster` the picker offers the six Whisper sizes `tiny`/`base`/`small`/`medium`/`large-v3`/`turbo`), status badge (ready/not installed), provider-aware install button (`brew install openai-whisper` for `whisper`, `pipx install mlx-whisper` for `mlx`; hidden for `transcribe`, which has no local runtime to install — its availability is "`boto3` + `amazon-transcribe` importable by the gateway process", so the prereq block instead surfaces ` -m pip install 'kirocrew[voice]'` (PowerShell `&`-form on Windows; suppressed once the extra is importable) plus a platform ffmpeg command when ffmpeg is missing and a restart-the-gateway hint, and `POST /api/stt/install` refuses with 400 `code: stt_no_local_install` for it rather than installing a Whisper runtime that cannot change Transcribe's status. When no install channel can make the extra importable — frozen build, the desktop app's code-signed bundled interpreter, pip-less interpreter, or a PEP 668 externally-managed python — the response carries `transcribe_unsupported: true` and the Settings page shows an unsupported notice instead of commands; `bundled_interpreter: true` discriminates the desktop-app cause, whose notice names the actual remedy (install Kiro Crew with pip and run the gateway from that environment) because "run the gateway from a different Python environment" is not actionable inside an app bundle. The response also carries `ffmpeg_missing`, served independently of `available`, because Transcribe's availability check treats ffmpeg as optional (it only affects the non-streaming `.webm` remux path) — the Settings page uses it to show the ffmpeg install command even while the status badge reads ready). The `mlx` provider (Apple Silicon Metal GPU) uses the `mlx_model` config key (default `mlx-community/whisper-large-v3-turbo`). The `faster` provider (faster-whisper / CTranslate2) installs **in-process** via `POST /api/stt/install` — `sys.executable -m pip install faster-whisper`, deliberately the gateway's own interpreter because the import has to succeed in THIS process — and needs no system ffmpeg, since it decodes through PyAV's bundled FFmpeg. Two refusals are specific to it: `GET /api/config/stt` serves `faster_unsupported: true` on native Windows-on-ARM (CTranslate2 publishes no `win_arm64` wheel and no sdist, so pip fails at resolution), and the install endpoint returns 400 `code: stt_no_install_channel` when the gateway python has no usable pip channel. Availability is LOCATED, never imported, on the request path (`importlib.util.find_spec`), because linking CTranslate2's native extension on the event loop would stall every gateway task. Endpoints: `GET/PUT /api/config/stt`, `POST /api/stt/install`. **Homebrew resolution never relies on `PATH`**: a GUI-launched gateway (desktop app / launchd) inherits `/usr/bin:/bin:/usr/sbin:/sbin`, which contains no Homebrew prefix, so `shutil.which("brew")` reported Homebrew MISSING on machines that have it — the prereq list told a Homebrew user to install Homebrew and the `mlx` install script aborted at its first `command -v brew` with `ERROR: Homebrew required`. The prereq probe goes through `transcribe.find_brew()` (PATH, then the fixed prefixes `/opt/homebrew/bin`, `/usr/local/bin`, Linuxbrew system + per-user), and every install script is prefixed with `_stt_install_path_prelude()` — it prepends `transcribe.BREW_PATH_DIRS` (the same prefixes plus `~/.local/bin`, where pipx puts `mlx_whisper`) and then `eval "$(brew shellenv)"`. The prelude is required because the script runs as `bash -c`, which is neither a login nor an interactive shell, so the user's `~/.zprofile` `brew shellenv` line never executes. - **Agent Config tab**: JSON editor with Save + warning about `kirocrew setup --agent-only` - **System** (`/system`) — live metrics (1s refresh): CPU %, memory used/total, network RX/TX stat cards; host info with correct Apple Silicon arch detection, load averages; memory, process, network, storage detail cards; uptime ticking every 1s via `useUptime` hook (client-side from `start_time`) - **Agent Capabilities** (`/capabilities`; `/agents` redirects here) — merged Agents + Capabilities destination, bottom-pinned in the nav. `SidePanelLayout` tabs in order: **Agents** (agent → workspace → memory store bindings, `KiroCrewAgentsPage` embedded), **Agent Templates** (installed agent configurations/packages, `AgentsPage` embedded), **Integrations (MCP)**, **Skills**, **Hooks**, **Prompts**. The templates tab keeps the side-by-side layout: installed agents list (left) with detail panel (right, height from `LAYOUT.AGENT_LIST_HEIGHT`). Installed agents card shows each agent with name, `SourceBadge` source pill (neutral `package`/`kirocrew`/`project`/`local` sources — the former `aim` source was generalized to `package`), model, description, skill count, MCP server count. Click to view full agent config in detail panel: system prompt, tools, auto-approved tools, MCP servers with `--aim` token colors (hover tooltip showing tool list), expandable denied commands list (`
` with all patterns). **Agent-package install / uninstall now routes through the capability seam** (`installPlugin`/`uninstallPlugin` in `providers/adapters/acp.ts` dispatch `type === 'agent'` to `/api/capability/agents/{install,uninstall}`). This replaced the earlier "intentionally NOT offered" stance: the pure-AIM `/api/aim/agents/*` + `/api/aim/update` routes were removed, but leaving `GET /api/capability/agents` as list-only meant the seam could SHOW installed agent packages and never manage them — an asymmetry with skills/MCP that forced an edition to shadow the core or mount its own routes. Bulk "Update All" remains unoffered (no `update_*` op on the seam). Skills/MCP install/uninstall still route through the capability seam (see Capability Integration); the manager returns human-friendly errors for invalid packages. The MCP registry browser is hidden entirely when no external capability manager is configured (registry → 503); when present it offers click-to-expand descriptions with all detail lines, clickable URLs (DOMPurify-sanitized), a direct Install button, and tier badges at `text-[11px]` minimum. Striped subagent table with `EmptyState` when empty, kiro credit usage card, context window usage bars per session (agent name in `--aim` color). diff --git a/src/kiro_crew/cli_doctor.py b/src/kiro_crew/cli_doctor.py index d425d8c0f88..6719cb1395a 100644 --- a/src/kiro_crew/cli_doctor.py +++ b/src/kiro_crew/cli_doctor.py @@ -93,7 +93,12 @@ from kiro_crew.service import linux as service_linux from kiro_crew.session_pid_sig import signing_health from kiro_crew.subprocess_utf8 import UTF8_TEXT -from kiro_crew.transcribe import _find_parakeet_mlx, _find_whisper, ensure_ffmpeg_in_path +from kiro_crew.transcribe import ( + _faster_whisper_model, + _find_parakeet_mlx, + _find_whisper, + ensure_ffmpeg_in_path, +) from kiro_crew.validation import _AGENT_NAME_RE logger = logging.getLogger(__name__) @@ -2500,7 +2505,11 @@ def _doctor(platform_boot_error: "Exception | None" = None, bundle: bool = False print("\nSpeech-to-Text") stt_active = cfg.stt.enabled needs_whisper = stt_active and cfg.stt.provider == "whisper" - needs_ffmpeg = stt_active # both providers use ffmpeg + # Every provider but ``faster`` shells out to something that needs the system + # ffmpeg; faster-whisper decodes in-process through PyAV's bundled copy, so + # reporting a missing ffmpeg as an ISSUE there would send the user to install a + # binary their configuration never calls. + needs_ffmpeg = stt_active and cfg.stt.provider != "faster" if not stt_active: print(" status: ⏹ disabled (enable from dashboard → Overview → Slack)") @@ -2589,6 +2598,40 @@ def _doctor(platform_boot_error: "Exception | None" = None, bundle: bool = False if stt_fatal: issues.append("parakeet-mlx") + # faster-whisper (CTranslate2) is installed on demand, not as a declared extra, + # so an unavailable library is the expected first-run state rather than a broken + # install. Windows on ARM is called out separately because no CTranslate2 wheel + # exists there at all — the install button cannot fix it, and telling the user to + # retry would waste their time instead of naming a provider that does work. + if stt_active and cfg.stt.provider == "faster": + if _faster_whisper_model() is not None: + print(" faster: ✅ faster-whisper importable") + elif platform_compat.is_windows_on_arm(): + # Deliberately NOT routed through ``stt_mark``/``stt_fatal``. That + # Windows downgrade exists because whisper and ffmpeg are absent from a + # stock Windows box yet trivially installable, so failing a first-run + # doctor over them is noise. This is the opposite case: ``faster`` is + # never the default, so reaching here means the user explicitly selected + # a provider that CANNOT be made to work on this machine. That is a real + # configuration fault, and the whole point of naming the alternatives is + # that the run should not exit 0 as if nothing were wrong. + print(" faster: ❌ not available (Windows on ARM — no CTranslate2 wheel)") + print( + " Alternatives: set stt.provider to 'whisper' " + "(local) or 'transcribe' (AWS)" + ) + issues.append("faster-whisper: unavailable on Windows ARM") + else: + # The ordinary not-yet-installed state, which the install button DOES + # fix — so this one follows the platform convention like whisper above. + print(f" faster: {stt_mark} not installed") + print( + " Install from dashboard → Settings → " + "Speech-to-Text, or: pip install faster-whisper" + ) + if stt_fatal: + issues.append("faster-whisper") + # ── Slack (optional) ── print("\nSlack Integration") if _has_slack: diff --git a/src/kiro_crew/config/loader.py b/src/kiro_crew/config/loader.py index 543bcb27a5f..2e46a7f038a 100644 --- a/src/kiro_crew/config/loader.py +++ b/src/kiro_crew/config/loader.py @@ -4364,7 +4364,19 @@ def from_dict(cls, data: dict) -> ChannelConfig: ) -_VALID_STT_PROVIDERS = ("whisper", "mlx", "apple", "parakeet", "transcribe") +_VALID_STT_PROVIDERS = ("whisper", "mlx", "apple", "parakeet", "transcribe", "faster") + +#: Whisper model sizes accepted for ``stt.model``. +#: +#: Shared by the ``whisper`` and ``faster`` providers, which both name models this +#: way. (``mlx`` uses ``stt.mlx_model`` and ``parakeet`` uses +#: ``stt.parakeet_model``, HuggingFace repo ids, instead.) +#: +#: ``turbo`` stays the default: it is the only entry the dashboard offered before, +#: and it is the best accuracy-per-second of the set. The smaller sizes exist +#: because they are the difference between usable and unusable on a machine +#: without much RAM, and ``large-v3`` because it is the accuracy ceiling. +_VALID_STT_MODELS = ("tiny", "base", "small", "medium", "large-v3", "turbo") _VALID_CHANNEL_PREFIXES = ("C", "D", "G") @@ -4376,6 +4388,32 @@ def _validated_stt_provider(value: str) -> str: return "whisper" +def _validated_stt_model(value: object) -> str: + """Return *value* as the STT model name, warning when it is off the menu. + + Unknown STRINGS pass through with a warning rather than being coerced: the + old loader accepted any string, and openai-whisper legitimately takes names + outside the dashboard's size menu (``tiny.en``/``base.en``/``small.en``/ + ``medium.en``/``large-v2``), so coercing a hand-edited config to ``turbo`` + would silently remove a real capability. Providers degrade safely on a bad + name anyway: the whisper CLI errors per-recording, and faster-whisper + resolves an unknown name to a download error, both logged, neither fatal. + Only a NON-STRING (numbers, null, nested json from a mangled edit) falls + back to ``turbo``, since it cannot be passed to any provider at all. + """ + if isinstance(value, str) and value: + if value not in _VALID_STT_MODELS: + logger.warning( + "STT model '%s' is not in the dashboard menu %s; passing it through" + " — the provider will reject it per-recording if it is invalid", + value, + list(_VALID_STT_MODELS), + ) + return value + logger.warning("Non-string STT model %r, falling back to turbo", value) + return "turbo" + + _VALID_COMPLETION_KEEP = ("head", "tail", "both") @@ -4678,7 +4716,11 @@ class SttConfig: ) model: str = field( default="turbo", - metadata=_meta("Model", "Whisper model size.", enum=["turbo"]), + metadata=_meta( + "Model", + "Whisper model size (whisper and faster providers).", + enum=list(_VALID_STT_MODELS), + ), ) mlx_model: str = field( default="mlx-community/whisper-large-v3-turbo", @@ -7764,7 +7806,7 @@ def _load_resolved(cls) -> KiroCrewConfig: whisper_path=stt_data.get("whisper_path", ""), # Default "turbo" — faster and recommended for most users # (809M vs 74M, but much better latency). - model=stt_data.get("model", "turbo"), + model=_validated_stt_model(stt_data.get("model", "turbo")), mlx_model=stt_data.get("mlx_model", "mlx-community/whisper-large-v3-turbo"), parakeet_model=stt_data.get("parakeet_model", "mlx-community/parakeet-tdt-0.6b-v3"), device=stt_data.get("device", "cpu"), diff --git a/src/kiro_crew/dashboard/handlers/core.py b/src/kiro_crew/dashboard/handlers/core.py index 216618dfca0..e8e564c5f40 100644 --- a/src/kiro_crew/dashboard/handlers/core.py +++ b/src/kiro_crew/dashboard/handlers/core.py @@ -46,7 +46,13 @@ from kiro_crew.executors import discovery_executor from kiro_crew.metrics import provider as _metrics_provider from kiro_crew.security_posture import build_posture_snapshot_async, posture_counts_async -from kiro_crew.transcribe import BREW_PATH_DIRS, ensure_ffmpeg_in_path, find_brew, is_available +from kiro_crew.transcribe import ( + BREW_PATH_DIRS, + _faster_whisper_model, + ensure_ffmpeg_in_path, + find_brew, + is_available, +) logger = logging.getLogger(__name__) @@ -476,7 +482,19 @@ async def pwa_file(request: web.Request) -> web.StreamResponse: # ── STT (Speech-to-Text) ── +#: Whisper model sizes offered in the STT picker and accepted on PUT. +#: +#: Maps model -> approximate on-disk download size, which is the number that +#: actually decides the choice on a laptop. Keys MUST stay in step with +#: ``_VALID_STT_MODELS`` in the config loader: this dict is the PUT allowlist, so a +#: model the loader accepts but this omits would be silently rejected by the API. +#: ``test_stt_model_sizes_cover_valid_models`` pins that. _STT_MODEL_SIZES: dict[str, str] = { + "tiny": "~75 MB", + "base": "~145 MB", + "small": "~484 MB", + "medium": "~1.5 GB", + "large-v3": "~3.1 GB", "turbo": "~1.6 GB", } @@ -638,16 +656,20 @@ async def api_stt_config(request: web.Request) -> web.Response: cfg = KiroCrewConfig.load() provider = cfg.stt.provider - available = is_available(cfg.stt) # _stt_prereq_commands probes for a system python/brew via subprocess; run it # off the event loop so a slow/again-spawned interpreter check can't stall the # gateway (observed as "event-loop heartbeat: lag" on Windows where the probe # is heavier). The GET is read-only, so threading it is safe. The ffmpeg and # install-channel probes ride in the same thread: ensure_ffmpeg_in_path, # find_spec and the PEP 668 marker check all touch the filesystem, which - # does not belong on the loop either. - - def _prereqs_and_probes() -> tuple[list[str], bool, bool, bool]: + # does not belong on the loop either. is_available() rides here too for the + # same reason and not as an afterthought: EVERY provider branch of it reaches + # the filesystem — a real `import amazon_transcribe` plus shutil.which for + # `transcribe`, find_spec for `faster`, and a stats-only lookup for `mlx`, + # `parakeet` and `apple` — so leaving it on the loop would put the heaviest + # probe of the set outside the thread that exists to hold the lighter ones. + + def _prereqs_and_probes() -> tuple[list[str], bool, bool, bool, bool]: cmds = _stt_prereq_commands(provider) ensure_ffmpeg_in_path() no_ffmpeg = shutil.which("ffmpeg") is None @@ -656,11 +678,15 @@ def _prereqs_and_probes() -> tuple[list[str], bool, bool, bool]: # user guidance (no Python environment of the user's own to fix), so # the UI needs to distinguish it from the pip-less/PEP 668 causes. bundled = platform_compat.is_bundled_interpreter() - return cmds, no_ffmpeg, unsupported, bundled - - prereqs, ffmpeg_missing, transcribe_unsupported, bundled_app = await asyncio.to_thread( - _prereqs_and_probes - ) + return cmds, no_ffmpeg, unsupported, bundled, is_available(cfg.stt) + + ( + prereqs, + ffmpeg_missing, + transcribe_unsupported, + bundled_app, + available, + ) = await asyncio.to_thread(_prereqs_and_probes) return web.json_response( { "enabled": cfg.stt.enabled, @@ -697,6 +723,15 @@ def _prereqs_and_probes() -> tuple[list[str], bool, bool, bool]: # threaded probe above: find_spec and the marker check touch the # filesystem. "transcribe_unsupported": transcribe_unsupported, + # True when this platform has no CTranslate2 wheel at all, so the + # faster-whisper install can only ever fail. Mirrors + # `transcribe_unsupported` so the Settings card can show the notice and + # the alternatives BEFORE the user presses Install, rather than turning + # every press into an identical 400. Deliberately NOT in the threaded + # probe above: `is_windows_on_arm()` short-circuits on a module constant + # off Windows and otherwise reads a cached `platform.uname()` — no + # filesystem, no subprocess, so it is loop-safe as the probes are not. + "faster_unsupported": platform_compat.is_windows_on_arm(), "bundled_interpreter": bundled_app, # ffmpeg is required to remux the browser's .webm for the # non-streaming path, but is_available() only logs a warning when @@ -791,7 +826,15 @@ def _stt_prereq_commands(provider: str = "whisper") -> list[str]: The ``mlx`` and ``parakeet`` providers have their own lightweight prerequisite (``pipx install mlx-whisper`` / ``pipx install parakeet-mlx``) and only need ffmpeg beyond that — they do not require the system-python/whisper toolchain. + The ``faster`` provider needs nothing manual at all. """ + if provider == "faster": + # Nothing manual: faster-whisper is a pip install of prebuilt wheels, and it + # decodes audio through PyAV's bundled FFmpeg — so neither the system ffmpeg + # nor the brew/Xcode toolchain the CLI providers need applies here. Returned + # before ensure_ffmpeg_in_path() so this path does no filesystem probing for + # a binary it will not use. + return [] if provider == "transcribe": # AWS Transcribe's availability is "boto3 + amazon-transcribe importable # by THIS gateway process" (see kiro_crew.transcribe.is_available); the @@ -970,6 +1013,13 @@ async def api_stt_install(request: web.Request) -> web.Response: {"error": f"Install already in progress: {_stt_install_status['step']}"}, status=409 ) + # RESERVE the slot before anything can yield: the busy check above and the + # probes below would otherwise race — an `await` between check and set lets + # two concurrent requests both pass the 409 gate and launch pip twice + # against the same environment. Every rejection path below must roll this + # back to idle. + _stt_install_status = {"step": "starting", "detail": "", "error": ""} + # Native install via shell script, tailored to the configured provider. # Transcribe has no local runtime to install (its requirement is the # ``voice`` extra importable by this process, surfaced as a prerequisite @@ -977,6 +1027,7 @@ async def api_stt_install(request: web.Request) -> web.Response: # change Transcribe's availability. provider = KiroCrewConfig.load().stt.provider if provider == "transcribe": + _stt_install_status = {"step": "idle", "detail": "", "error": ""} _sel().log_api_access( caller=caller, operation="stt.install", @@ -994,7 +1045,67 @@ async def api_stt_install(request: web.Request) -> web.Response: status=400, ) - _stt_install_status = {"step": "starting", "detail": "", "error": ""} + # Windows on ARM has NO CTranslate2 wheel — faster-whisper's inference backend — + # and no sdist either, so pip fails while RESOLVING rather than while building. + # That distinction is why this check exists: the failure surfaces as a resolver + # error naming ``ctranslate2``, a package the user never asked for, which reads + # like a transient registry problem and invites retrying forever. Nothing about + # the machine can change the outcome, so refuse up front and name the two + # providers that do work here — the same alternatives ``cli_doctor`` prints, + # which a dashboard user never sees. + # + # Checked SERVER-SIDE rather than inside the generated shell script, and that is + # not a style preference: ``api_stt_install`` launches the script through + # ``bash -c``, so on a stock native-Windows gateway the run dies with + # ``FileNotFoundError`` ("bash not found") before any line of it executes. An + # in-script guard would be unreachable on precisely the platform it is for. + # + # ``is_windows_on_arm()`` keys off the PROCESS architecture, so an x86-64 + # interpreter under emulation is correctly left alone: it installs the + # ``win_amd64`` wheel and works. + if provider == "faster" and platform_compat.is_windows_on_arm(): + _stt_install_status = {"step": "idle", "detail": "", "error": ""} + _sel().log_api_access( + caller=caller, + operation="stt.install", + outcome="denied", + error="no ctranslate2 wheel for provider=faster on windows-arm64", + ) + return web.json_response( + { + "code": "stt_unsupported_platform", + "error": ( + "faster-whisper is not available on Windows on ARM" + " (no CTranslate2 wheel exists for this platform)." + " Alternatives: set stt.provider to 'whisper' (local)" + " or 'transcribe' (AWS)." + ), + }, + status=400, + ) + + # ``faster`` is imported in-process, so its install must land in the + # gateway's own interpreter (see _build_stt_install_script). Where no pip + # channel into that interpreter exists — frozen build, bundled desktop + # interpreter, pip-less python — the script below cannot succeed, and + # running it anyway recreates the press-and-nothing-changes failure. + if provider == "faster" and not await asyncio.to_thread(_pip_install_channel_available): + _stt_install_status = {"step": "idle", "detail": "", "error": ""} + _sel().log_api_access( + caller=caller, + operation="stt.install", + outcome="denied", + error="no pip install channel for provider=faster", + ) + return web.json_response( + { + "code": "stt_no_install_channel", + "error": "This gateway's Python can't install extra packages, so" + " faster-whisper can't be enabled here. Run the gateway from a" + " Python environment where pip can install faster-whisper.", + }, + status=400, + ) _sel().log_api_access( caller=caller, @@ -1036,6 +1147,8 @@ async def api_stt_install(request: web.Request) -> web.Response: # The detail line carries the accurate "parakeet-mlx" text, so a # dedicated step (and its 14-locale i18n key) is not warranted. _stt_install_status = {"step": "installing_mlx", "detail": line, "error": ""} + elif "Installing faster-whisper" in line: + _stt_install_status = {"step": "installing_faster", "detail": line, "error": ""} elif "No suitable python3" in line: _stt_install_status = {"step": "installing_python", "detail": line, "error": ""} elif "Using:" in line: @@ -1059,6 +1172,12 @@ async def api_stt_install(request: web.Request) -> web.Response: return web.json_response({"ok": False, "error": output[-500:]}, status=500) _stt_install_status = {"step": "done", "detail": "Whisper ready", "error": ""} + if provider == "faster": + # Warm the import cache OFF the event loop so the next is_available() + # (a cached read, loop-safe) reports ready without a gateway restart. + # Importing here would load CTranslate2's native extension on the + # loop, which is exactly what the cached-read design avoids. + await asyncio.to_thread(_faster_whisper_model) _sel().log_api_access( caller=caller, operation="stt.install", @@ -1068,10 +1187,14 @@ async def api_stt_install(request: web.Request) -> web.Response: return web.json_response( { "ok": True, - "ffmpeg": ( - shutil.which("ffmpeg") is not None - or os.path.isfile(os.path.expanduser("~/ffmpeg/ffmpeg")) - ), + # `ffmpeg: false` makes the Settings page show an + # "installed but ffmpeg missing" error toast. The faster + # provider decodes through PyAV's bundled FFmpeg and never + # uses the system binary, so a missing system ffmpeg is not an + # error for it — always report True to keep the toast away. + "ffmpeg": provider == "faster" + or shutil.which("ffmpeg") is not None + or os.path.isfile(os.path.expanduser("~/ffmpeg/ffmpeg")), } ) except asyncio.TimeoutError: @@ -1131,19 +1254,56 @@ def _stt_install_path_prelude() -> str: def _build_stt_install_script(provider: str = "whisper") -> str: """Shell script that installs the runtime for the selected STT provider. + - ``faster``: installs faster-whisper via pip (CTranslate2, no system ffmpeg) + into the GATEWAY'S OWN interpreter — unlike the CLI providers below, the + library is imported in-process by ``kiro_crew.transcribe``, so a system + python's user-site would be invisible here and the install would report + "Done" while transcription stayed unavailable. - ``mlx``: installs mlx-whisper via pipx (Apple Silicon only) plus ffmpeg. - ``parakeet``: installs parakeet-mlx via pipx (Apple Silicon only) plus ffmpeg. - ``whisper`` (default): installs openai-whisper + ffmpeg via brew or pip. - The pip fallback deliberately targets a SYSTEM python with ``--user`` (never - the gateway's own venv, which is replaced on every upgrade). ``--user`` lands - in ``~/.local/bin``, which :func:`kiro_crew.transcribe._find_whisper` probes - via its ``_WHISPER_SEARCH_PATHS`` (and via ``shutil.which`` when that dir is - on PATH). It also constrains the resolve so pip can never drop into a source + The ``whisper`` pip fallback deliberately targets a SYSTEM python with + ``--user`` (never the gateway's own venv, which is replaced on every + upgrade): the CLI binary lands in ``~/.local/bin``, which + :func:`kiro_crew.transcribe._find_whisper` probes via its + ``_WHISPER_SEARCH_PATHS`` (and via ``shutil.which`` when that dir is on + PATH). It also constrains the resolve so pip can never drop into a source build — see the ``BINARY_ONLY`` comment in the script for why an incompatible wheel otherwise reports itself as a compiler error. """ prelude = _stt_install_path_prelude() + if provider == "faster": + # No $PY probe and no --user: the import happens in THIS process, so the + # one interpreter whose environment matters is sys.executable. --user is + # doubly wrong for it — inside a venv pip refuses the flag outright + # ("Can not perform a '--user' install ..."), and outside one it lands in + # a user-site this gateway may not even scan. api_stt_install gates this + # provider on _pip_install_channel_available(), so the command below is + # only reached where a pip install into sys.executable can succeed. + gateway_py = shlex.quote(sys.executable) + return prelude + f""" +# faster-whisper (CTranslate2 backend) — no system ffmpeg required, because audio +# is decoded in-process through PyAV's bundled FFmpeg. +# CTranslate2 publishes wheels for Linux x86-64/AArch64, macOS x86-64/ARM64 and +# Windows x86-64. Windows on ARM has NO wheel, so this script is unreachable +# there: api_stt_install refuses the request before building it (see the +# is_windows_on_arm gate) rather than letting pip fail with a resolver error that +# names ctranslate2 and reads as a transient problem worth retrying. +PY={gateway_py} +echo "Using: $PY ($($PY --version))" +echo "Installing faster-whisper..." +"$PY" -m pip install -q faster-whisper || {{ echo "ERROR: pip install faster-whisper failed"; exit 1; }} +# The import is the real check, so it must be able to FAIL this script: pip can +# report success while the package is unusable, and a CTranslate2 wheel whose +# native extension will not load is the common case. +if ! FW_PATH=$("$PY" -c "import faster_whisper; print(faster_whisper.__file__)" 2>&1); then + echo "ERROR: faster-whisper installed but is not importable:" + echo "$FW_PATH" + exit 1 +fi +echo "Done. faster_whisper=$FW_PATH" +""" if provider in ("mlx", "parakeet"): pipx_pkg = "parakeet-mlx" if provider == "parakeet" else "mlx-whisper" verify_bin = "parakeet-mlx" if provider == "parakeet" else "mlx_whisper" diff --git a/src/kiro_crew/executors.py b/src/kiro_crew/executors.py index 0f59b7161d2..791f07e6cb1 100644 --- a/src/kiro_crew/executors.py +++ b/src/kiro_crew/executors.py @@ -46,6 +46,16 @@ work gets its OWN small pool so a burst of screenshots queues among ITSELF and can never occupy the :func:`maintenance_executor` workers the orphan sweeps need to recover from a wedge. +* :func:`stt_executor` -- in-process speech-to-text inference + (:func:`kiro_crew.transcribe._run_faster_whisper_sync`). Minutes of CPU per + call on a long recording, and the first call for a model size may block on a + multi-GB weight download inside the library's constructor. It cannot share + :func:`subprocess_executor`: a ``run_in_executor`` future cannot be + cancelled, so a wedged model load would hold one of the eight PTY-teardown + workers indefinitely -- and those exist precisely so a teardown storm has + somewhere to go. A caller that gives up on a timeout does NOT free the + thread, which is the whole reason this work needs a pool it can only starve + for itself. Long-term direction: this blocking work should move into a dedicated supervised process (the VS Code extension-host model), so a wedge there cannot @@ -72,6 +82,7 @@ "discovery_executor", "embed_executor", "image_executor", + "stt_executor", "governance_executor", "cron_gate_executor", "CronGateTimeout", @@ -211,6 +222,18 @@ # maintenance sweeps or head-of-line blocking any other pool's work. _MAX_IMAGE_WORKERS = 2 +# In-process STT inference is the longest-running work in this module: minutes of +# CPU on a meeting-length recording, and the first call for a model size can block +# on a multi-GB weight download inside the library's constructor. TWO workers, +# deliberately small for a reason the other pools do not share -- each in-flight +# call holds a fully quantised model in RAM (up to ~GBs for large-v3), so the +# worker count is a MEMORY ceiling, not just a CPU one. Two lets a queued +# recording start while one finishes; more would let concurrent dictations OOM a +# small host. Sizing it here rather than reusing the 8-worker subprocess pool is +# the point: a wedged model load cannot be cancelled, so it must only ever be able +# to starve other STT work. +_MAX_STT_WORKERS = 2 + _lock = threading.Lock() _pool: ThreadPoolExecutor | None = None _subprocess_pool: ThreadPoolExecutor | None = None @@ -218,6 +241,7 @@ _discovery_pool: ThreadPoolExecutor | None = None _embed_pool: ThreadPoolExecutor | None = None _image_pool: ThreadPoolExecutor | None = None +_stt_pool: ThreadPoolExecutor | None = None _governance_pool: ThreadPoolExecutor | None = None _cron_gate_pool: ThreadPoolExecutor | None = None @@ -321,6 +345,31 @@ def image_executor() -> ThreadPoolExecutor: return _image_pool +def stt_executor() -> ThreadPoolExecutor: + """Return the process-wide STT inference pool, creating it on first use. + + Threads are named ``mc-stt``. Separate from :func:`subprocess_executor` for the + reason a started ``run_in_executor`` future cannot be cancelled: a wedged model + load (or a first-run weight download inside the library constructor) holds its + worker until the process exits, and on the PTY-teardown pool that would consume + one of the eight workers whose whole purpose is absorbing a teardown storm. + Here it can only starve other STT work, which is the containment we want. + + Callers bound their own wait (``stt.timeout_secs``); that releases the CALLER, + never the thread — see :func:`kiro_crew.transcribe._transcribe_faster`. + """ + global _stt_pool + if _stt_pool is None: + with _lock: + if _stt_pool is None: + _stt_pool = ThreadPoolExecutor( + max_workers=_MAX_STT_WORKERS, + thread_name_prefix="mc-stt", + ) + atexit.register(shutdown_maintenance_executor) + return _stt_pool + + def embed_executor() -> ThreadPoolExecutor: """Return the process-wide Ollama embed/probe pool, creating it on first use. @@ -679,7 +728,7 @@ async def run_in_embed_pool(func: Callable[..., _T], /, *args: Any, **kwargs: An def shutdown_maintenance_executor() -> None: """Shut down all maintenance pools if they were created. Idempotent.""" global _pool, _subprocess_pool, _cron_pool, _discovery_pool, _embed_pool - global _governance_pool, _image_pool, _cron_gate_pool + global _governance_pool, _image_pool, _cron_gate_pool, _stt_pool with _lock: pool, _pool = _pool, None subprocess_pool, _subprocess_pool = _subprocess_pool, None @@ -689,6 +738,7 @@ def shutdown_maintenance_executor() -> None: governance_pool, _governance_pool = _governance_pool, None image_pool, _image_pool = _image_pool, None cron_gate_pool, _cron_gate_pool = _cron_gate_pool, None + stt_pool, _stt_pool = _stt_pool, None if pool is not None: pool.shutdown(wait=False, cancel_futures=True) if subprocess_pool is not None: @@ -705,3 +755,5 @@ def shutdown_maintenance_executor() -> None: image_pool.shutdown(wait=False, cancel_futures=True) if cron_gate_pool is not None: cron_gate_pool.shutdown(wait=False, cancel_futures=True) + if stt_pool is not None: + stt_pool.shutdown(wait=False, cancel_futures=True) diff --git a/src/kiro_crew/platform_compat.py b/src/kiro_crew/platform_compat.py index f957bf7a0ae..6e855e58e31 100644 --- a/src/kiro_crew/platform_compat.py +++ b/src/kiro_crew/platform_compat.py @@ -17,6 +17,7 @@ import logging import ntpath import os +import platform import shutil import signal import stat @@ -107,6 +108,29 @@ def reexec_python_module(module: str, args: Sequence[str]) -> None: RENAME_NOREPLACE_AVAILABLE: bool = _RENAME_NOREPLACE_FN is not None +#: ARM machine strings as ``platform.machine()`` spells them on Windows. +#: ``ARM64`` is what a native arm64 interpreter reports; ``AARCH64`` is accepted +#: because that spelling reaches Windows through cross-built and MSYS/Cygwin +#: Pythons. Compared case-folded, so the casing here is documentation only. +_WINDOWS_ARM_MACHINES: frozenset[str] = frozenset({"arm64", "aarch64"}) + + +def is_windows_on_arm() -> bool: + """True when this interpreter is a NATIVE ARM64 process on Windows. + + Deliberately a property of the running PROCESS, not of the host CPU, because + every caller cares about which wheel tags pip will accept here. Windows on ARM + runs x86-64 processes under emulation, and in one of those ``platform.machine()`` + reports ``AMD64`` — correctly, since such an interpreter installs ``win_amd64`` + wheels and works fine. A host-architecture probe would report ARM for that same + process and wrongly refuse a package that installs. + + Keyed off :data:`IS_WINDOWS` rather than ``platform.system()`` so there is one + canonical Windows predicate in this module instead of two that can drift. + """ + return IS_WINDOWS and platform.machine().casefold() in _WINDOWS_ARM_MACHINES + + # Portable signal constants — signal.SIGKILL is undefined on Windows. SIGKILL: int = getattr(signal, "SIGKILL", 9) diff --git a/src/kiro_crew/transcribe.py b/src/kiro_crew/transcribe.py index fb3dadcbc29..4aa1ce2fd66 100644 --- a/src/kiro_crew/transcribe.py +++ b/src/kiro_crew/transcribe.py @@ -7,16 +7,20 @@ from __future__ import annotations import asyncio +import importlib +import importlib.util import logging import os import re import shutil import sys import tempfile +import threading from pathlib import Path from typing import Any from kiro_crew import aws_consent, dep_sync, platform_compat +from kiro_crew.executors import stt_executor from kiro_crew.sandbox import _PYTHON_ENV_PREFIXES # Transcribe-path deps are an OPTIONAL 'aws' extra (amazon-transcribe + boto3). @@ -34,9 +38,210 @@ CredentialResolver = object # type: ignore[assignment,misc] Credentials = None # type: ignore[assignment,misc] +# faster-whisper is an optional runtime installed on demand via /api/stt/install, +# NOT a declared extra. The module MUST stay importable when it is absent so the +# Gateway starts without the library and ``cli_doctor`` can report the gap. +# +# Deliberately NOT imported here. ``transcribe`` is reached from the gateway boot +# path (``dashboard.handlers.core`` imports it at module scope), and importing +# faster_whisper links CTranslate2's native extension — hundreds of ms of disk and +# dynamic linking paid by every launch of an install that has the library, before +# the dashboard socket accepts requests. The cache below is filled off-loop on +# first use instead; :func:`is_available` locates the library without executing it. +_FasterWhisperModel: Any = None + + +def _faster_whisper_model() -> Any: + """Return the faster-whisper ``WhisperModel`` class, or ``None`` if absent. + + Imports on first use and caches the class in the module global. Nothing + imports the library at module load, so this is the only place the native + extension is ever linked — and because the cache starts empty, a gateway that + booted before the on-demand install from Settings picks the library up without + a restart. While it stays absent the call costs one failed import each time. + + NEVER call on the event loop: importing faster_whisper loads CTranslate2's + native extension synchronously (hundreds of ms of disk and dynamic linking), + which would stall every gateway task. Call sites are the STT executor thread + (:func:`_run_faster_whisper_sync`), the install handler's ``asyncio.to_thread`` + warm-up, and ``cli_doctor`` (its own process, no loop). :func:`is_available` + runs on the loop and so must never call this — it locates the library with + ``importlib.util.find_spec``, which stats the import path without executing it. + """ + global _FasterWhisperModel + if _FasterWhisperModel is None: + try: + _FasterWhisperModel = importlib.import_module("faster_whisper").WhisperModel + except ImportError: + return None + return _FasterWhisperModel + + logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Hallucination filter — suppress Whisper-family transcription artefacts. +# +# Whisper models fed silence or low-energy audio produce two recognisable +# artefacts, and both are worse than an empty transcript in this app: the text +# goes to agents, so a hallucinated sign-off becomes a meeting note, and a phrase +# repeated forty times becomes forty note lines. +# +# 1. One phrase repeating ("Thank you. Thank you. Thank you. …") +# 2. Boilerplate unrelated to the audio — subtitle credits, sign-offs, stock +# phrases memorised from the training set's video captions. +# +# Applied to every Whisper-family provider. The repetition collapse below is +# pure text logic and language-independent; the boilerplate list is English-only +# and matches nothing in a zh-CN or de-DE transcript, so a non-English recording +# gets the repetition half of this filter and none of the phrase half. +# AWS Transcribe uses a different decoder and does not produce these, +# so it is deliberately excluded rather than filtered "just in case" — running +# the filter there could only ever delete genuine speech. +# --------------------------------------------------------------------------- + +# Boilerplate phrases Whisper hallucinates on silence. Compared case-insensitively. +# LIST DISCIPLINE: an entry must be a CAPTION ARTEFACT — text that exists because +# a transcript was produced, not because anyone spoke. "Implausible as dictated +# speech" was the earlier bar and it was not strict enough: it admitted sign-offs +# and subscribe CTAs ("thank you for watching", "don't forget to subscribe", +# "hit the bell", "see you in the next video"), which anyone recording a demo or +# dictating a video script says out loud. Because the match is whole-sentence and +# a transcript filtered down to nothing returns None, such an entry can delete the +# only sentence a recording had — the speaker's own words, unrecoverable. Those +# entries are gone, along with the ordinary-speech phrases dropped before them +# ("goodbye", "copyright", "thanks for listening", "thanks for joining", "see you +# next time", "all rights reserved"). +# +# What remains is attribution text a caption track carries about itself. Nobody +# utters "Subtitles by the Amara.org community" into a voice memo, so no reading of +# these deletes speech. +# +# Residual, accepted deliberately: a single un-repeated hallucinated sign-off now +# survives into the transcript. That is the safe direction of the trade — one stray +# line a reader can see and ignore, versus silently destroying real speech — and the +# repetition collapse below still removes the far more common form of this artefact, +# where the model emits the same sign-off for the rest of the decode window. +_WHISPER_BOILERPLATE: tuple[str, ...] = ( + "subtitles by", + "subtitles by amara.org", + "subtitles by the amara.org community", + "subtitles created by", + "subtitled by", + "translated by", + "transcribed by", + "captioned by", + "amara.org", + "www.mooji.org", +) + +# How many consecutive identical sentences count as a repetition artefact rather +# than emphasis. Humans genuinely say a sentence two, three, even five times +# ("No. No. No.", a counted beat, an insistent refusal), so a low threshold +# rewrites real speech. The Whisper failure mode this targets repeats a phrase +# for the remainder of the decode window — typically dozens of times — so six +# is still far below the artefact and comfortably above plausible emphasis. +_REPEAT_THRESHOLD = 6 + +# Boilerplate matches the WHOLE sentence only, never a substring and never a +# word-count neighbourhood. Anything looser deletes real speech: a bare +# substring rule drops "Thanks for joining today's standup, let's start", and +# even a one-word slack drops "Thanks for joining, everyone." — a normal +# meeting opener. This filter runs on every Whisper-family transcript, so a +# false positive is silent loss of genuine speech; a false negative is one +# stray boilerplate line, which the repetition collapse usually removes anyway. +# Known multi-word artefact shapes ("Subtitles by Amara.org") are covered by +# listing the full phrase in _WHISPER_BOILERPLATE, not by loosening the match. + + +def _is_boilerplate_line(line: str) -> bool: + """Return True if *line* is exactly (case/punctuation aside) known boilerplate.""" + stripped = line.strip().rstrip(".!?,;:").strip().lower() + if not stripped: + return False + return stripped in _WHISPER_BOILERPLATE + + +def _collapse_repeated_phrases(text: str) -> str: + """Collapse runs of >= :data:`_REPEAT_THRESHOLD` identical sentences to one. + + Splits on sentence boundaries, keeping each sentence's trailing punctuation. + Only CONSECUTIVE runs collapse: the same sentence recurring later in a + meeting is ordinary speech, not an artefact. + """ + sentences = re.split(r"(?<=[.!?])\s+", text) + if len(sentences) <= 1: + return text + output: list[str] = [] + i = 0 + while i < len(sentences): + current_norm = sentences[i].strip().lower() + j = i + 1 + while j < len(sentences) and sentences[j].strip().lower() == current_norm: + j += 1 + if j - i >= _REPEAT_THRESHOLD: + output.append(sentences[i]) + else: + output.extend(sentences[i:j]) + i = j + return " ".join(output) + + +def filter_hallucinations(text: str) -> str: + """Remove Whisper hallucination artefacts from a transcript. + + May return ``""`` when the whole transcript was hallucinated — which is the + honest answer for a recording of silence, and is why callers treat an empty + result as "no transcript" rather than passing it on. + + Every removal is logged, because this is the one step in the pipeline that + can delete words the speaker actually said, and a silent deletion is + indistinguishable from the model never having heard them. What each log line + carries is deliberate: matched boilerplate is named verbatim, since it comes + from the fixed :data:`_WHISPER_BOILERPLATE` vocabulary and so reveals nothing + about the recording, whereas a collapsed repetition is reported only as a + COUNT — that text is ordinary speech and belongs in the transcript, not in + the log. Discarding the transcript outright is a warning rather than an info + line, because the caller then reports "no transcript" and the recording is + gone with no other trace. + """ + if not text: + return text + before = len(re.split(r"(?<=[.!?])\s+", text)) + text = _collapse_repeated_phrases(text) + sentences = re.split(r"(?<=[.!?])\s+", text) + collapsed = before - len(sentences) + + kept: list[str] = [] + dropped: list[str] = [] + for sentence in sentences: + (dropped if _is_boilerplate_line(sentence) else kept).append(sentence) + + if collapsed or dropped: + logger.info( + "stt hallucination filter: collapsed %d repeated sentence(s), " + "dropped %d boilerplate line(s)%s", + collapsed, + len(dropped), + (": " + "; ".join(sorted(set(dropped)))) if dropped else "", + ) + + result = " ".join(kept).strip() + if not result: + logger.warning( + "stt hallucination filter: discarded the entire transcript as " + "hallucinated (%d sentence(s) in, none kept); the caller will " + "report no transcript for this recording", + len(sentences), + ) + return result + + +#: Providers whose output passes through :func:`filter_hallucinations`. +_WHISPER_FAMILY_PROVIDERS = frozenset(("whisper", "mlx", "faster")) + + def _ffmpeg_candidate_dirs() -> list[str]: """Build the ordered directory list to probe for an ffmpeg install. @@ -339,6 +544,27 @@ def is_available(stt_config=None) -> bool: # type: ignore[no-untyped-def] if not shutil.which("ffmpeg"): logger.warning("ffmpeg not found; .webm transcription will be unavailable") return True + if provider == "faster": + # No ffmpeg probe: faster-whisper decodes audio itself through PyAV's + # bundled FFmpeg, so the system binary the CLI providers need is irrelevant. + # LOCATE, NEVER IMPORT — this function runs on the event loop (config GET, + # Slack voice), and importing faster_whisper links CTranslate2's native + # extension synchronously, which would stall every gateway task. find_spec + # only walks and stats the import path, so the answer stays correct + # immediately after a plain restart of an already-installed gateway without + # putting that load on the boot path. Once a transcription has warmed the + # cache, the class answers directly and even the stat is skipped. + if _FasterWhisperModel is not None: + return True + try: + return importlib.util.find_spec("faster_whisper") is not None + except (ImportError, ValueError): + # A partially-removed install can leave the name in sys.modules with + # no spec (ValueError) or a finder that raises (ImportError). Either + # way the library is not usable, and this runs on the loop serving + # /api/config/stt — reporting unavailable is correct and keeps the + # endpoint from 500ing on a broken environment. + return False if provider == "mlx": ensure_ffmpeg_in_path() return _find_mlx_whisper() is not None @@ -396,6 +622,9 @@ async def transcribe_audio(audio_path: str, stt_config=None) -> str | None: # t provider = stt_config.provider if provider == "transcribe": result = await _transcribe_aws(audio_path, stt_config) + elif provider == "faster": + # No ensure_ffmpeg_in_path: faster-whisper decodes in-process via PyAV. + result = await _transcribe_faster(audio_path, stt_config) elif provider == "mlx": await asyncio.to_thread(ensure_ffmpeg_in_path) result = await _transcribe_mlx(audio_path, stt_config) @@ -409,6 +638,13 @@ async def transcribe_audio(audio_path: str, stt_config=None) -> str | None: # t result = await _transcribe_native(audio_path, stt_config) if result: + # Before redaction, and before the caller sees anything: a transcript that + # is entirely hallucinated must come back as None, not as boilerplate for + # an agent to write into the notes. + if provider in _WHISPER_FAMILY_PROVIDERS: + result = await asyncio.to_thread(filter_hallucinations, result) + if not result: + return None result = await asyncio.to_thread(_redact_transcript, result) return result @@ -884,6 +1120,108 @@ async def _run_whisper_cli( _MLX_MODEL_RE = re.compile(r"^[A-Za-z0-9_-]+/[A-Za-z0-9._-]+$") +#: The one constructed WhisperModel, stored with its ``(model, device)`` key. +#: Loading a model re-reads and re-quantizes the weights (tens of MB for +#: ``tiny`` up to ~GBs for ``large-v3``), so constructing one per recording adds +#: multi-second latency — and CONCURRENT recordings would each hold a full +#: copy, compounding to RAM exhaustion on the 8-thread executor pool. The cache +#: is deliberately SINGLE-SLOT: the gateway serves one configured model at a +#: time, so switching sizes evicts the previous instance instead of keeping +#: every size ever selected resident (which would itself OOM a small host). +#: The lock serializes construction only; ``WhisperModel.transcribe`` is safe +#: to call from multiple threads on one instance. +_FW_MODEL_CACHE: dict[tuple[str, str], Any] = {} +_FW_MODEL_LOCK = threading.Lock() + + +def _cached_fw_model(model_cls: Any, model: str, device: str) -> Any: + """Return the shared WhisperModel for ``(model, device)``, single-slot.""" + key = (model, device) + with _FW_MODEL_LOCK: + fw_model = _FW_MODEL_CACHE.get(key) + if fw_model is None: + # Evict any other-size instance BEFORE constructing the new one, so + # peak residency during a switch is one model plus the one being + # built, never an unbounded accumulation of every size selected. + _FW_MODEL_CACHE.clear() + fw_model = model_cls(model, device=device, compute_type="int8") + _FW_MODEL_CACHE[key] = fw_model + return fw_model + + +def _run_faster_whisper_sync(audio_path: str, model: str, device: str) -> str | None: + """Run faster-whisper inference synchronously. NEVER call on the event loop. + + ``compute_type="int8"`` is what makes CPU inference practical — the models are + quantised on load, trading a little accuracy for the several-fold speedup that + keeps a meeting-length recording from taking longer than the meeting. + """ + model_cls = _faster_whisper_model() + if model_cls is None: + logger.error("faster-whisper not available — install: pip install faster-whisper") + return None + try: + fw_model = _cached_fw_model(model_cls, model, device) + segments, _info = fw_model.transcribe(audio_path, beam_size=5) + # `segments` is a GENERATOR: inference happens as it is consumed, which is + # precisely why this whole function belongs off the loop. + parts = [text for segment in segments if (text := segment.text.strip())] + return " ".join(parts).strip() or None + except Exception: + # Same contract as every other provider here: log and return None rather + # than raise, so one bad recording cannot take a caller down. + logger.exception("faster-whisper transcription failed") + return None + + +async def _transcribe_faster(audio_path: str, stt_config) -> str | None: # type: ignore[no-untyped-def] + """Transcribe with faster-whisper (CTranslate2), in-process. + + Unlike the ``whisper`` and ``mlx`` providers there is no subprocess and no + system ffmpeg: faster-whisper links CTranslate2 and decodes audio through + PyAV's bundled FFmpeg. That removes the whole binary-discovery problem, and is + why this provider is worth having on machines where installing the CLI + toolchain is the hard part. + + The model name needs no regex guard of the kind ``_MLX_MODEL_RE`` provides: + nothing is passed to a shell here, and faster-whisper resolves an unknown name + to a download or an error rather than executing it. + """ + # No availability guard HERE: this coroutine runs on the event loop, and the + # lazy import retry loads a native library. _run_faster_whisper_sync performs + # the same check (with the retry) inside the executor thread and returns + # None with a log line when the library is absent. + loop = asyncio.get_running_loop() + # stt_executor(), not subprocess_executor() and not asyncio.to_thread: + # inference is CPU-bound and minutes long, and the first call for a model size + # can block inside the library's constructor downloading weights. Its own pool + # means that cost can only ever queue behind OTHER STT work — see + # kiro_crew.executors.stt_executor for why sharing the PTY-teardown pool was + # the wrong bulkhead. + fut = loop.run_in_executor( + stt_executor(), + _run_faster_whisper_sync, + audio_path, + stt_config.model, + stt_config.device, + ) + try: + return await asyncio.wait_for(fut, timeout=stt_config.timeout_secs) + except asyncio.TimeoutError: + # The timeout releases the CALLER, not the thread. A running + # run_in_executor future cannot be interrupted, so the inference (or the + # weight download it is stuck in) continues to completion and its worker + # stays occupied until then; what this bound buys is that the dictation + # request itself fails fast instead of hanging forever. Matching the CLI + # providers, which also log and return None on timeout rather than raise. + logger.error( + "faster-whisper transcription timed out after %ds " + "(worker still running; it cannot be cancelled)", + stt_config.timeout_secs, + ) + return None + + async def _transcribe_mlx(audio_path: str, stt_config) -> str | None: # type: ignore[no-untyped-def] """Transcribe using the mlx_whisper CLI (Apple Silicon, Metal GPU). diff --git a/temp-screenshots/stt-faster-whisper/01-faster-not-installed-dark.png b/temp-screenshots/stt-faster-whisper/01-faster-not-installed-dark.png new file mode 100644 index 00000000000..55a48e35aa6 Binary files /dev/null and b/temp-screenshots/stt-faster-whisper/01-faster-not-installed-dark.png differ diff --git a/temp-screenshots/stt-faster-whisper/02-faster-installing-dark.png b/temp-screenshots/stt-faster-whisper/02-faster-installing-dark.png new file mode 100644 index 00000000000..1c241e39b2c Binary files /dev/null and b/temp-screenshots/stt-faster-whisper/02-faster-installing-dark.png differ diff --git a/temp-screenshots/stt-faster-whisper/03-faster-ready-light.png b/temp-screenshots/stt-faster-whisper/03-faster-ready-light.png new file mode 100644 index 00000000000..4397647f78e Binary files /dev/null and b/temp-screenshots/stt-faster-whisper/03-faster-ready-light.png differ diff --git a/test/test_cli.py b/test/test_cli.py index 765860b9677..5a1e02d6ea3 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -258,6 +258,75 @@ def _which(binary, **_kw): assert f" whisper: {expected_mark} not found" in out assert f" ffmpeg: {expected_mark} not found" in out + @pytest.mark.parametrize( + "win_arm, expect_fatal_glyph, expect_alternatives", + [(True, True, True), (False, False, False)], + ids=["windows-arm-unfixable", "windows-x64-installable"], + ) + def test_doctor_faster_windows_arm_is_fatal_even_on_windows( + self, tmp_path, capsys, monkeypatch, win_arm, expect_fatal_glyph, expect_alternatives + ): + """The `faster` win-arm arm deliberately does NOT follow ``stt_mark``. + + That Windows downgrade exists because whisper and ffmpeg are absent from a + stock Windows box yet trivially installable, so failing a first-run doctor + over them is noise. This is the opposite case: `faster` is never the default, + so reaching it means the user explicitly selected a provider that CANNOT be + made to work here, and the run must not exit 0 as if nothing were wrong. The + ordinary not-installed arm (win_arm=False) still follows the convention, + because the install button does fix that one. + """ + import kiro_crew.cli_doctor as _doc + from kiro_crew.config.loader import KiroCrewConfig + + agent_file = tmp_path / "kirocrew.json" + _healthy_agent_file(agent_file) + mock_run = MagicMock(returncode=0, stdout="kiro-cli 1.0.0", stderr="") + # Windows in BOTH arms: the point is what differs while IS_WINDOWS is true. + monkeypatch.setattr(_doc.platform_compat, "IS_WINDOWS", True) + monkeypatch.setattr(_doc.platform_compat, "is_windows_on_arm", lambda: win_arm) + + def _cfg_with_faster() -> KiroCrewConfig: + cfg = KiroCrewConfig() + cfg.stt.enabled = True + cfg.stt.provider = "faster" + return cfg + + monkeypatch.setattr(KiroCrewConfig, "load", classmethod(lambda cls: _cfg_with_faster())) + monkeypatch.setattr(_doc, "_find_whisper", lambda path=None: None) + monkeypatch.setattr(_doc, "ensure_ffmpeg_in_path", lambda: None) + # The library is absent, which is what routes into the two arms. + monkeypatch.setattr(_doc, "_faster_whisper_model", lambda: None) + + with ( + patch("kiro_crew.cli_doctor.shutil.which", side_effect=lambda b, **_k: f"/bin/{b}"), + patch("kiro_crew.cli_doctor.KIRO_AGENTS_DIR", tmp_path), + patch("kiro_crew.cli_doctor.subprocess.run", return_value=mock_run), + patch("urllib.request.urlopen", side_effect=urllib.error.URLError("no gateway")), + patch("kiro_crew.cli_doctor.is_local_only", return_value=True), + patch("kiro_crew.cli_doctor.config_dir", return_value=tmp_path), + patch("kiro_crew.cli_doctor.probe_server", side_effect=_noop_probe_server), + ): + exited = False + try: + _doctor() + except SystemExit as e: + exited = bool(e.code) + out = capsys.readouterr().out + if expect_fatal_glyph: + assert " faster: ❌ not available (Windows on ARM" in out + # An unfixable configuration must fail the run even on Windows. + assert exited is True + else: + assert " faster: ⚠️ not installed" in out + if expect_alternatives: + # Naming the working providers IS the deliverable — a bare refusal + # leaves the user with no next step. + assert "stt.provider to 'whisper'" in out + assert "'transcribe'" in out + else: + assert "stt.provider to 'whisper'" not in out + def test_doctor_reports_platform_boot_error_without_crashing(self, tmp_path, capsys): """A PlatformCompositionError from boot must be REPORTED by the doctor, not crash it — the doctor is the tool that diagnoses a broken setup, so diff --git a/test/test_dashboard_handlers_core_coverage.py b/test/test_dashboard_handlers_core_coverage.py index f05a63f41cc..dd01cbd3ec9 100644 --- a/test/test_dashboard_handlers_core_coverage.py +++ b/test/test_dashboard_handlers_core_coverage.py @@ -754,7 +754,12 @@ async def test_get_advertises_capabilities(self, seeded_config) -> None: # Streaming capability is served from the backend's own set so the # Settings UI gates on a CAPABILITY rather than a provider name. assert body["streaming_providers"] == ["transcribe", "apple"] - assert body["models"] == {"turbo": "~1.6 GB"} + # Tracks _STT_MODEL_SIZES (the PUT allowlist) rather than pinning one + # literal: the faster-whisper work widened the enum from `turbo` alone + # to the full Whisper size ladder, and a test pinned to yesterday's + # ladder fails on every legitimate widening. + assert body["models"] == core_mod._STT_MODEL_SIZES + assert "turbo" in body["models"] assert body["language_codes"][0] == "en-US" assert body["available"] is False assert body["prereqs"] == [] @@ -770,6 +775,21 @@ async def test_get_advertises_capabilities(self, seeded_config) -> None: # remux gap even when the provider reads ready. assert isinstance(body["ffmpeg_missing"], bool) + @pytest.mark.asyncio + async def test_get_serves_faster_unsupported_for_pre_click_gating(self, monkeypatch): + """Mirrors `transcribe_unsupported`. Without this the Settings card can only + learn the platform is unsupported by pressing Install and reading a 400 — the + same dead end on every press, which is the failure this flag removes.""" + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: True) + resp = await core_mod.api_stt_config(_req()) + assert json.loads(resp.body)["faster_unsupported"] is True + + @pytest.mark.asyncio + async def test_get_reports_faster_supported_elsewhere(self, monkeypatch): + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: False) + resp = await core_mod.api_stt_config(_req()) + assert json.loads(resp.body)["faster_unsupported"] is False + # ── STT install endpoint ──────────────────────────────────────────────── @@ -863,6 +883,130 @@ async def _spawn(*_a, **_k): assert core_mod._stt_install_status["step"] == "error" assert fake_sel.log_api_access.call_args.kwargs["outcome"] == "failed" + @pytest.mark.asyncio + async def test_faster_is_refused_on_windows_on_arm(self, monkeypatch, fake_sel, stt_status): + """No CTranslate2 wheel exists for win-arm64 and there is no sdist either, so + pip fails while RESOLVING — naming a package the user never asked for. Refuse + up front with the alternatives instead of letting every press repeat it.""" + core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"stt": {"provider": "faster"}}) + "\n", + encoding="utf-8", + newline="\n", + ) + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: True) + resp = await core_mod.api_stt_install(_req()) + assert resp.status == 400 + body = json.loads(resp.body) + assert body["code"] == "stt_unsupported_platform" + # The alternatives are the whole point — a bare refusal leaves the user stuck. + assert "whisper" in body["error"] and "transcribe" in body["error"] + # Must not strand the status machine in `starting`, or the 409 gate deadlocks. + assert core_mod._stt_install_status["step"] == "idle" + assert fake_sel.log_api_access.call_args.kwargs["outcome"] == "denied" + + @pytest.mark.asyncio + async def test_windows_arm_refusal_never_spawns_the_shell( + self, monkeypatch, fake_sel, stt_status + ): + """The refusal has to be SERVER-SIDE, not a guard inside the generated script. + + ``api_stt_install`` launches the script through ``bash -c``, which does not + exist on a stock native-Windows gateway — the run would die with + FileNotFoundError before executing a line. An in-script guard would therefore + be unreachable on precisely the platform it exists for, so assert no spawn is + even attempted. + """ + core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"stt": {"provider": "faster"}}) + "\n", + encoding="utf-8", + newline="\n", + ) + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: True) + spawned = [] + + async def _spawn(*a, **_k): + spawned.append(a) + return _proc([b"Done.\n"]) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn) + resp = await core_mod.api_stt_install(_req()) + assert resp.status == 400 + assert spawned == [] + + @pytest.mark.asyncio + async def test_platform_refusal_precedes_the_pip_channel_check( + self, monkeypatch, fake_sel, stt_status + ): + """Ordering is load-bearing: on win-arm a healthy pip channel would pass the + sibling gate and let the install proceed to a guaranteed failure. The + platform verdict must win, and its message is also the more actionable one.""" + core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"stt": {"provider": "faster"}}) + "\n", + encoding="utf-8", + newline="\n", + ) + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: True) + # A working channel: without correct ordering this would fall through. + monkeypatch.setattr(core_mod, "_pip_install_channel_available", lambda: True) + resp = await core_mod.api_stt_install(_req()) + assert json.loads(resp.body)["code"] == "stt_unsupported_platform" + + @pytest.mark.asyncio + async def test_other_providers_are_unaffected_on_windows_on_arm( + self, monkeypatch, fake_sel, stt_status + ): + """The gate is scoped to `faster`. `whisper` has no CTranslate2 dependency, so + refusing it on win-arm would break a provider that works.""" + core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"stt": {"provider": "whisper"}}) + "\n", + encoding="utf-8", + newline="\n", + ) + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: True) + + async def _spawn(*_a, **_k): + return _proc([b"Done.\n"]) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn) + resp = await core_mod.api_stt_install(_req()) + assert resp.status == 200 + + @pytest.mark.asyncio + async def test_faster_install_proceeds_on_a_supported_platform( + self, monkeypatch, fake_sel, stt_status + ): + core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"stt": {"provider": "faster"}}) + "\n", + encoding="utf-8", + newline="\n", + ) + monkeypatch.setattr(core_mod.platform_compat, "is_windows_on_arm", lambda: False) + monkeypatch.setattr(core_mod, "_pip_install_channel_available", lambda: True) + monkeypatch.setattr(core_mod, "_faster_whisper_model", lambda: object()) + + async def _spawn(*_a, **_k): + return _proc([b"Installing faster-whisper\n", b"Done.\n"]) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _spawn) + resp = await core_mod.api_stt_install(_req()) + assert resp.status == 200 + assert core_mod._stt_install_status["step"] == "done" + @pytest.mark.asyncio async def test_install_timeout_kills_the_child(self, monkeypatch, fake_sel, stt_status) -> None: core_mod._stt_install_status = {"step": "idle", "detail": "", "error": ""} diff --git a/test/test_platform_compat.py b/test/test_platform_compat.py index 09e1362a11e..19eff85a0c6 100644 --- a/test/test_platform_compat.py +++ b/test/test_platform_compat.py @@ -140,6 +140,79 @@ def test_reexec_successor_survives_hostile_parent_encoding(self, tmp_path): assert "👻 restarted".encode() in result.stdout +class TestWindowsOnArm: + """``is_windows_on_arm`` answers "will pip accept a win_amd64 wheel here?". + + Callers use it to refuse a package that publishes no win-arm64 wheel, so the + predicate has to be a property of the running PROCESS rather than of the host + CPU — the two disagree under Windows' x86-64 emulation, and only the process + answer matches what pip does. + """ + + def test_true_for_a_native_arm64_interpreter(self, monkeypatch): + monkeypatch.setattr(pc, "IS_WINDOWS", True) + monkeypatch.setattr(pc.platform, "machine", lambda: "ARM64") + assert pc.is_windows_on_arm() is True + + def test_accepts_the_aarch64_spelling(self, monkeypatch): + # Reaches Windows through cross-built and MSYS/Cygwin interpreters. + monkeypatch.setattr(pc, "IS_WINDOWS", True) + monkeypatch.setattr(pc.platform, "machine", lambda: "aarch64") + assert pc.is_windows_on_arm() is True + + def test_case_is_irrelevant(self, monkeypatch): + monkeypatch.setattr(pc, "IS_WINDOWS", True) + monkeypatch.setattr(pc.platform, "machine", lambda: "aRm64") + assert pc.is_windows_on_arm() is True + + def test_false_for_an_emulated_x86_64_interpreter(self, monkeypatch): + """The case a host-architecture probe would get WRONG. + + Windows on ARM runs x86-64 processes under emulation, and such an + interpreter reports AMD64 and installs win_amd64 wheels perfectly well. + Reporting it as ARM would refuse a package that works. + """ + monkeypatch.setattr(pc, "IS_WINDOWS", True) + monkeypatch.setattr(pc.platform, "machine", lambda: "AMD64") + assert pc.is_windows_on_arm() is False + + def test_false_on_apple_silicon(self, monkeypatch): + # arm64 alone must not trip it: macOS and Linux both publish arm64 wheels + # for the packages this gate exists to refuse on Windows. + monkeypatch.setattr(pc, "IS_WINDOWS", False) + monkeypatch.setattr(pc.platform, "machine", lambda: "arm64") + assert pc.is_windows_on_arm() is False + + def test_does_not_consult_machine_off_windows(self, monkeypatch): + """Short-circuits on the platform constant. + + Keeps the predicate loop-safe for the dashboard's STT config GET, which + calls it inline rather than from the threaded probe block. + """ + monkeypatch.setattr(pc, "IS_WINDOWS", False) + calls = [] + + def _machine(): + calls.append(1) + return "arm64" + + monkeypatch.setattr(pc.platform, "machine", _machine) + assert pc.is_windows_on_arm() is False + assert calls == [] + + def test_uses_the_modules_own_windows_predicate(self, monkeypatch): + """Keyed off IS_WINDOWS, not a second platform.system() call. + + Two Windows predicates in one module can drift; this pins that there is + one. Flipping only IS_WINDOWS must flip the answer. + """ + monkeypatch.setattr(pc.platform, "machine", lambda: "arm64") + monkeypatch.setattr(pc, "IS_WINDOWS", True) + assert pc.is_windows_on_arm() is True + monkeypatch.setattr(pc, "IS_WINDOWS", False) + assert pc.is_windows_on_arm() is False + + class TestFileLock: def test_exclusive_lock_round_trips(self, tmp_path): # The lock must acquire + release cleanly and run the body, on whatever diff --git a/test/test_stt_stream.py b/test/test_stt_stream.py index 939029c4c72..10eed131652 100644 --- a/test/test_stt_stream.py +++ b/test/test_stt_stream.py @@ -1106,19 +1106,31 @@ def fake_run(*_a, **_kw): monkeypatch.setattr("subprocess.run", fake_run) assert core._is_apple_silicon() is False + # Both lists below are exact on purpose: they pin ORDER as well as membership, + # since the dashboard renders them in this sequence. They must track + # ``_VALID_STT_PROVIDERS`` in the config loader — adding a provider there + # deliberately fails these until the expectation is updated too. def test_providers_include_mlx_on_apple_silicon(self, monkeypatch): from kiro_crew import apple_speech from kiro_crew.dashboard.handlers import core monkeypatch.setattr(core, "_is_apple_silicon", lambda: True) # `apple` has its own gate (macOS 26 + Swift toolchain); pin it off here so - # this test measures only the Apple-Silicon gate. + # this test measures only the Apple-Silicon gate. `faster` has no gate — it + # is a pip extra, so it is always advertised and stays in the expectation. monkeypatch.setattr( apple_speech, "availability", lambda: apple_speech.Availability(False, "pinned off") ) # `parakeet` is gated the same way as `mlx` (Apple-Silicon-only), so both - # are present here. - assert core._stt_providers() == ["whisper", "mlx", "parakeet", "transcribe"] + # are present here. `faster` is a plain pip install with no platform gate + # in _stt_providers, so it is always advertised. + assert core._stt_providers() == [ + "whisper", + "mlx", + "parakeet", + "transcribe", + "faster", + ] def test_stt_providers_calls_is_apple_silicon_exactly_once(self, monkeypatch): """`parakeet` reuses the `mlx` gate's already-computed Apple-Silicon @@ -1153,7 +1165,7 @@ def test_providers_exclude_mlx_off_apple_silicon(self, monkeypatch): ) providers = core._stt_providers() assert "mlx" not in providers - assert providers == ["whisper", "transcribe"] + assert providers == ["whisper", "transcribe", "faster"] def test_providers_include_apple_when_supported(self, monkeypatch): """`apple` is advertised only where SpeechAnalyzer can actually run.""" @@ -1162,7 +1174,16 @@ def test_providers_include_apple_when_supported(self, monkeypatch): monkeypatch.setattr(core, "_is_apple_silicon", lambda: True) monkeypatch.setattr(apple_speech, "availability", lambda: apple_speech.Availability(True)) - assert core._stt_providers() == ["whisper", "mlx", "apple", "parakeet", "transcribe"] + # `faster` is ungated — a pip extra, not a platform capability — so it is + # present in every expectation here regardless of what is being gated. + assert core._stt_providers() == [ + "whisper", + "mlx", + "apple", + "parakeet", + "transcribe", + "faster", + ] def test_providers_exclude_apple_when_toolchain_missing(self, monkeypatch): """A host that could run the framework but has no Swift toolchain must not be diff --git a/test/test_transcribe_faster.py b/test/test_transcribe_faster.py new file mode 100644 index 00000000000..de58062febe --- /dev/null +++ b/test/test_transcribe_faster.py @@ -0,0 +1,768 @@ +"""The ``faster`` (faster-whisper) STT provider, and the hallucination filter. + +Two things under test, and they are separable: + +* The provider — dispatch, availability, and the fact that it needs neither a + subprocess nor the system ffmpeg the CLI providers depend on. +* The hallucination filter — pure text logic applied to every Whisper-family + provider. It matters because transcripts here go to agents: a hallucinated + sign-off becomes a meeting note, and a phrase repeated forty times becomes + forty note lines. + +``faster_whisper`` is not installed (it is an on-demand runtime, not a declared +extra), so the library itself is always patched. That is the same situation CI is +in, which is the point. +""" + +from __future__ import annotations + +import logging +import sys +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + +from kiro_crew.config.loader import ( + _VALID_STT_MODELS, + _VALID_STT_PROVIDERS, + SttConfig, + _validated_stt_model, +) +from kiro_crew.dashboard.handlers.core import ( + _STT_MODEL_SIZES, + _build_stt_install_script, + _stt_prereq_commands, +) +from kiro_crew.transcribe import ( + _WHISPER_FAMILY_PROVIDERS, + _collapse_repeated_phrases, + _faster_whisper_model, + _is_boilerplate_line, + _run_faster_whisper_sync, + filter_hallucinations, + is_available, + transcribe_audio, +) + + +@contextmanager +def _library_absent(): + """Simulate faster-whisper being uninstalled. + + Patching the cached class alone is not enough since the lazy helper retries + the import — on a dev machine that happens to have the library, the retry + would succeed and the "absent" test would silently test presence. Poisoning + ``sys.modules`` makes the retry raise ImportError everywhere. + """ + with patch.dict(sys.modules, {"faster_whisper": None}): + with patch("kiro_crew.transcribe._FasterWhisperModel", None): + yield + + +@pytest.fixture(autouse=True) +def _clear_fw_model_cache(): + """Isolate the per-(model, device) instance cache between tests. + + The cache is a module global keyed on config values most tests share + (turbo/cpu), so without clearing, one test's MagicMock model leaks into the + next test's dispatch and every assertion after the first tests the cache, + not the code. + """ + from kiro_crew import transcribe + + transcribe._FW_MODEL_CACHE.clear() + yield + transcribe._FW_MODEL_CACHE.clear() + + +def _fake_model(text_segments: list[str]) -> MagicMock: + """A stand-in for ``faster_whisper.WhisperModel`` yielding *text_segments*.""" + model = MagicMock() + model.transcribe.return_value = ( + iter([MagicMock(text=t) for t in text_segments]), + MagicMock(), + ) + return model + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + + +class TestProviderRegistration: + def test_faster_is_a_valid_provider(self): + assert "faster" in _VALID_STT_PROVIDERS + + def test_faster_is_in_the_whisper_family(self): + # Which is what subjects it to the hallucination filter. + assert "faster" in _WHISPER_FAMILY_PROVIDERS + + def test_transcribe_is_not_in_the_whisper_family(self): + # AWS Transcribe uses a different decoder and does not produce these + # artefacts, so filtering it could only ever delete real speech. + assert "transcribe" not in _WHISPER_FAMILY_PROVIDERS + + +# --------------------------------------------------------------------------- +# Model enum +# --------------------------------------------------------------------------- + + +class TestModelEnum: + def test_turbo_remains_the_default(self): + assert SttConfig().model == "turbo" + + def test_every_size_is_accepted(self): + for model in _VALID_STT_MODELS: + assert _validated_stt_model(model) == model + + @pytest.mark.parametrize("offmenu", ["tiny.en", "base.en", "small.en", "medium.en", "large-v2"]) + def test_offmenu_string_models_pass_through_with_a_warning(self, offmenu): + # openai-whisper legitimately accepts names outside the dashboard's size + # menu; a hand-edited config holding one must NOT be silently coerced to + # turbo — that would remove a real capability the old loader allowed. + assert _validated_stt_model(offmenu) == offmenu + + def test_unknown_string_passes_through_rather_than_coercing(self): + # Providers degrade safely per-recording on a bad name (logged, non-fatal), + # so the loader's job is to warn, not to rewrite the user's config. + assert _validated_stt_model("large-v9") == "large-v9" + + @pytest.mark.parametrize("bad", ["", None, 42, ["small"]]) + def test_non_string_or_empty_model_falls_back_instead_of_raising(self, bad): + # A mangled config field must not stop the Gateway from starting, and a + # non-string cannot be handed to any provider at all. + assert _validated_stt_model(bad) == "turbo" + + def test_dashboard_offers_a_size_for_every_valid_model(self): + # `_STT_MODEL_SIZES` is the dashboard's PUT allowlist, so a model the config + # loader accepts but this dict omits would be silently rejected by the API. + assert set(_STT_MODEL_SIZES) == set(_VALID_STT_MODELS) + + def test_every_size_is_human_readable(self): + for model, size in _STT_MODEL_SIZES.items(): + assert size.startswith("~"), model + assert size.endswith(("MB", "GB")), model + + +# --------------------------------------------------------------------------- +# Availability +# --------------------------------------------------------------------------- + + +class TestIsAvailable: + def test_available_when_the_library_imports(self): + cfg = SttConfig(enabled=True, provider="faster") + with patch("kiro_crew.transcribe._FasterWhisperModel", MagicMock()): + assert is_available(cfg) is True + + def test_unavailable_when_the_library_is_missing(self): + cfg = SttConfig(enabled=True, provider="faster") + with _library_absent(): + assert is_available(cfg) is False + + def test_does_not_probe_for_ffmpeg(self): + # faster-whisper decodes in-process through PyAV's bundled FFmpeg, so the + # system binary is irrelevant. Probing for it would make availability depend + # on something this provider never calls. + cfg = SttConfig(enabled=True, provider="faster") + with patch("kiro_crew.transcribe._FasterWhisperModel", MagicMock()): + with patch("kiro_crew.transcribe.ensure_ffmpeg_in_path") as ensure: + assert is_available(cfg) is True + ensure.assert_not_called() + + def test_disabled_beats_available(self): + cfg = SttConfig(enabled=False, provider="faster") + with patch("kiro_crew.transcribe._FasterWhisperModel", MagicMock()): + assert is_available(cfg) is False + + def test_available_from_disk_before_anything_has_imported_it(self): + # The regression this guards: a plain restart of an already-installed + # gateway begins with an empty cache. While availability was a cached read, + # Settings reported faster-whisper missing until something happened to run a + # transcription. Locating the library on the import path answers correctly + # from the first request. + cfg = SttConfig(enabled=True, provider="faster") + with patch("kiro_crew.transcribe._FasterWhisperModel", None): + with patch("importlib.util.find_spec", return_value=MagicMock()) as find: + assert is_available(cfg) is True + find.assert_called_once_with("faster_whisper") + + def test_never_imports_the_library(self): + # This function runs on the event loop (config GET, Slack voice) and + # importing faster_whisper links CTranslate2's native extension + # synchronously, stalling every gateway task. The import must therefore be + # unreachable from here whatever the cache holds — asserting "answers + # correctly" is not enough, since the wrong implementation also answers + # correctly and merely blocks the loop while doing it. + cfg = SttConfig(enabled=True, provider="faster") + + def _fail_on_import(*_args, **_kwargs): + pytest.fail("is_available imported faster_whisper on the event loop") + + with patch("kiro_crew.transcribe._FasterWhisperModel", None): + with patch("importlib.util.find_spec", return_value=MagicMock()): + with patch("importlib.import_module", _fail_on_import): + assert is_available(cfg) is True + + def test_a_broken_install_reports_unavailable_rather_than_raising(self): + # A half-removed install can leave the name in sys.modules with no spec, so + # find_spec raises instead of answering. This runs on the loop serving + # /api/config/stt, where an exception is a 500 rather than a verdict. + cfg = SttConfig(enabled=True, provider="faster") + with patch("kiro_crew.transcribe._FasterWhisperModel", None): + with patch("importlib.util.find_spec", side_effect=ValueError("no spec")): + assert is_available(cfg) is False + + +class TestLazyImportRetry: + def test_helper_retries_the_import_after_an_on_demand_install(self): + # The Settings install lands the library in this interpreter AFTER module + # load cached None. Without a retry, the button reports "Done" while + # availability stays False until a gateway restart. + sentinel = MagicMock() + fake_module = MagicMock(WhisperModel=sentinel) + with patch("kiro_crew.transcribe._FasterWhisperModel", None): + with patch.dict(sys.modules, {"faster_whisper": fake_module}): + assert _faster_whisper_model() is sentinel + + def test_helper_returns_none_while_the_library_is_absent(self): + with _library_absent(): + assert _faster_whisper_model() is None + + def test_helper_prefers_the_cached_class(self): + cached = MagicMock() + with patch("kiro_crew.transcribe._FasterWhisperModel", cached): + assert _faster_whisper_model() is cached + + +class TestModelMemoization: + def test_same_model_and_device_constructs_once(self): + # Constructing a WhisperModel re-loads and re-quantizes the weights; + # concurrent recordings each holding a copy compounds to RAM exhaustion. + model_cls = MagicMock(return_value=_fake_model(["one"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") + _run_faster_whisper_sync("/tmp/b.wav", "turbo", "cpu") + assert model_cls.call_count == 1 + + def test_distinct_keys_get_distinct_instances(self): + model_cls = MagicMock(side_effect=lambda *a, **k: _fake_model(["x"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") + _run_faster_whisper_sync("/tmp/a.wav", "small", "cpu") + assert model_cls.call_count == 2 + + def test_switching_models_evicts_the_previous_instance(self): + # SINGLE-SLOT on purpose: keeping every size ever selected resident + # would accumulate multi-GB native models and OOM a small gateway host. + from kiro_crew import transcribe + + model_cls = MagicMock(side_effect=lambda *a, **k: _fake_model(["x"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") + _run_faster_whisper_sync("/tmp/a.wav", "large-v3", "cpu") + assert list(transcribe._FW_MODEL_CACHE) == [("large-v3", "cpu")] + # Switching BACK constructs again — correctness over reload cost. + _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") + assert list(transcribe._FW_MODEL_CACHE) == [("turbo", "cpu")] + assert model_cls.call_count == 3 + + def test_a_failed_construction_is_not_cached(self): + # One bad load (e.g. interrupted download) must not poison every later + # recording with a cached broken instance or a cached None. + model_cls = MagicMock(side_effect=RuntimeError("load failed")) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") is None + ok_cls = MagicMock(return_value=_fake_model(["recovered"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", ok_cls): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") == "recovered" + + +# --------------------------------------------------------------------------- +# Inference +# --------------------------------------------------------------------------- + + +class TestRunFasterWhisperSync: + def test_joins_segment_text(self): + model_cls = MagicMock(return_value=_fake_model([" Hello ", "world. ", " "])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") == "Hello world." + + def test_quantises_to_int8_on_the_configured_device(self): + # int8 is what makes CPU inference fast enough to be usable on a + # meeting-length recording. + model_cls = MagicMock(return_value=_fake_model(["hi"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + _run_faster_whisper_sync("/tmp/a.wav", "small", "cuda") + model_cls.assert_called_once_with("small", device="cuda", compute_type="int8") + + def test_empty_output_is_none_not_empty_string(self): + model_cls = MagicMock(return_value=_fake_model([" ", ""])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") is None + + def test_returns_none_when_the_library_is_missing(self): + with _library_absent(): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") is None + + def test_an_inference_failure_is_logged_not_raised(self): + # Same contract as every other provider: one bad recording must not take a + # caller down. + model_cls = MagicMock(side_effect=RuntimeError("model load failed")) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert _run_faster_whisper_sync("/tmp/a.wav", "turbo", "cpu") is None + + +class TestDispatch: + @pytest.mark.asyncio + async def test_faster_provider_is_dispatched(self, tmp_path): + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster", model="small", device="cpu") + model_cls = MagicMock(return_value=_fake_model(["Real speech here."])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + result = await transcribe_audio(str(audio), cfg) + assert result == "Real speech here." + + @pytest.mark.asyncio + async def test_does_not_shell_out_or_need_ffmpeg(self, tmp_path): + # The reason this provider is worth having: no binary discovery at all. + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster") + model_cls = MagicMock(return_value=_fake_model(["ok"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + with patch("kiro_crew.transcribe.ensure_ffmpeg_in_path") as ensure: + with patch("kiro_crew.transcribe._run_whisper_cli") as cli: + assert await transcribe_audio(str(audio), cfg) == "ok" + ensure.assert_not_called() + cli.assert_not_called() + + @pytest.mark.asyncio + async def test_missing_library_returns_none(self, tmp_path): + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster") + with _library_absent(): + assert await transcribe_audio(str(audio), cfg) is None + + @pytest.mark.asyncio + async def test_hallucinated_output_becomes_none(self, tmp_path): + # The whole point of the filter being inside transcribe_audio: a recording of + # silence must come back as "no transcript", not as boilerplate for an agent + # to write into the meeting notes. + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster") + model_cls = MagicMock(return_value=_fake_model(["Subtitles by Amara.org."])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert await transcribe_audio(str(audio), cfg) is None + + +# --------------------------------------------------------------------------- +# Hallucination filter +# --------------------------------------------------------------------------- + + +class TestBoilerplateDetection: + @pytest.mark.parametrize( + "line", + [ + "Subtitles by", + "subtitles by amara.org.", + " Subtitled by! ", + "Captioned by.", + "Subtitles by Amara.org", + ], + ) + def test_detects_boilerplate(self, line): + assert _is_boilerplate_line(line) is True + + @pytest.mark.parametrize( + "line", + [ + "", + " ", + "Let's ship the recording change on Friday.", + "The copyright review is blocked on legal.", + "I said goodbye to the old design.", + # A sentence CONTAINING a boilerplate phrase is not boilerplate: these + # are real speech on the normal path, and deleting them is silent + # content loss (the blocking finding this rule exists to prevent). + "Thanks for joining today's standup, let's start with Priya.", + "I really do thank you for watching over the rollout last week.", + "The transcript is available in the shared drive for everyone.", + "See you next time we meet in Boston, bring the roadmap.", + # Even ONE extra word must spare the sentence — "Thanks for joining, + # everyone." is a normal meeting opener, not an artefact. + "Thanks for joining, everyone.", + "Thanks for watching this, team.", + # Ordinary-speech phrases were REMOVED from the list entirely: a + # dictated farewell or rights notice is plausible real speech even + # as a complete utterance, so it must never be filtered. + "Goodbye.", + "goodbye", + "Copyright", + "All rights reserved.", + "Thanks for listening.", + "Thanks for joining.", + "See you next time!", + "The transcript is available.", + # Sign-offs and subscribe CTAs were REMOVED from the list: each is a + # sentence someone recording a demo or dictating a video script says + # out loud, and a whole-transcript match discarded the recording. + "Thank you for watching.", + "Thanks for watching!", + "Please subscribe.", + "Like and subscribe.", + "Please like and subscribe.", + "Don't forget to subscribe.", + "Hit the bell.", + "Click the subscribe button.", + "See you in the next video.", + ], + ) + def test_keeps_real_speech(self, line): + assert _is_boilerplate_line(line) is False + + def test_all_phrases_require_a_whole_line_match(self): + # Substring or word-count-proximity matching deletes real sentences that + # merely mention (or lightly extend) a phrase — the cases above. + assert _is_boilerplate_line("Transcribed by") is True + assert _is_boilerplate_line("We kept transcribed by in the caption doc") is False + + def test_every_listed_phrase_is_a_caption_artefact(self): + # LIST DISCIPLINE, tightened: an entry must be attribution text a caption + # track carries ABOUT ITSELF, not merely "video-flavoured". The looser + # caption-domain rule is what admitted "thank you for watching" and + # "hit the bell" — sentences a human genuinely records, which the + # whole-transcript path then deleted. Required direction: + import re as _re + + attribution_markers = _re.compile(r"subtitle|caption|transcri|translat|amara|mooji") + from kiro_crew.transcribe import _WHISPER_BOILERPLATE as phrases + + for phrase in phrases: + assert attribution_markers.search(phrase), ( + f"'{phrase}' is not caption self-attribution — it may be real" + " dictated speech, so it must not be on the filter list" + ) + + def test_no_listed_phrase_is_a_spoken_sign_off(self): + # Forbidden direction, and the half that actually holds the line: the + # required-marker test above passes for "subscribe to my subtitles too", + # so the vocabulary of speech a presenter utters is banned outright. This + # fails if a future edit re-adds any entry of the deleted class. + import re as _re + + speech_markers = _re.compile(r"watch|subscribe|bell|video|thank|see you|like and") + from kiro_crew.transcribe import _WHISPER_BOILERPLATE as phrases + + for phrase in phrases: + assert not speech_markers.search(phrase), ( + f"'{phrase}' reads as something a speaker says on a recording;" + " filtering it can delete the only words a transcript had" + ) + + def test_known_artefact_variants_are_listed_as_full_phrases(self): + # "Subtitles by Amara.org" is the canonical artefact shape; it matches by + # being IN the phrase list, not by loosening the match rule. + assert _is_boilerplate_line("Subtitles by Amara.org") is True + assert _is_boilerplate_line("Subtitles by the Amara.org community") is True + + +class TestCollapseRepeatedPhrases: + def test_collapses_a_long_run_to_one(self): + text = " ".join(["Thank you."] * 12) + assert _collapse_repeated_phrases(text) == "Thank you." + + def test_leaves_a_short_run_alone(self): + # Real emphasis reaches well past two — "No. No. No." is ordinary + # insistence, and even five repeats is plausible counted speech. Only + # dozens-long runs are the Whisper artefact. + for n in range(2, 6): + text = " ".join(["No."] * n) + assert _collapse_repeated_phrases(text) == text, n + + def test_only_consecutive_runs_collapse(self): + # The same sentence recurring later in a meeting is ordinary speech. + text = "Okay. Next item. Okay." + assert _collapse_repeated_phrases(text) == "Okay. Next item. Okay." + + def test_preserves_surrounding_speech(self): + run = " ".join(["Uh huh."] * 8) + text = f"We start now. {run} Then we ship." + assert _collapse_repeated_phrases(text) == "We start now. Uh huh. Then we ship." + + def test_single_sentence_is_untouched(self): + assert _collapse_repeated_phrases("Just one sentence") == "Just one sentence" + + +class TestFilterHallucinations: + def test_empty_input_is_returned_as_is(self): + assert filter_hallucinations("") == "" + + def test_real_speech_survives_intact(self): + text = "We agreed to ship on Friday. Priya owns the rollout." + assert filter_hallucinations(text) == text + + def test_a_fully_hallucinated_transcript_becomes_empty(self): + # Which the caller turns into None. An empty string is the honest answer for + # a recording of silence. + assert filter_hallucinations("Subtitles by Amara.org. Transcribed by.") == "" + + def test_strips_boilerplate_but_keeps_the_meeting(self): + text = "Priya owns the rollout. Subtitles by Amara.org. We ship Friday." + assert filter_hallucinations(text) == "Priya owns the rollout. We ship Friday." + + def test_a_dictated_sign_off_survives_whole(self): + """The GPT 5.6 blocking finding, pinned. + + Each of these is a complete sentence a human records — a demo outro, a + dictated video script — and each was previously deleted by an exact + whole-sentence match. When it was the entire transcript the filter + returned "", which ``transcribe_audio`` turns into ``None``: the only + words the recording held, gone, with a log line as the sole trace. + """ + for text in ( + "Thank you for watching.", + "Thanks for watching!", + "Please subscribe.", + "Don't forget to subscribe.", + "Hit the bell.", + "See you in the next video.", + ): + assert filter_hallucinations(text) == text, text + + def test_handles_both_artefacts_together(self): + run = " ".join(["Okay."] * 10) + text = f"{run} Ship it. Transcribed by." + assert filter_hallucinations(text) == "Okay. Ship it." + + +class TestFilterHallucinationsVisibility: + """The filter is the one step that can delete words the speaker said. + + A silent deletion is indistinguishable from the model never having heard the + words, so every removal has to leave a trace an operator can find after the + fact. These tests pin what the trace says, not merely that one exists. + """ + + def test_dropped_boilerplate_is_named_in_the_log(self, caplog): + with caplog.at_level(logging.INFO, logger="kiro_crew.transcribe"): + filter_hallucinations("Priya owns the rollout. Subtitles by Amara.org.") + assert "dropped 1 boilerplate line(s)" in caplog.text + assert "Subtitles by Amara.org." in caplog.text + + def test_collapsed_repetitions_are_counted_but_not_quoted(self, caplog): + # A repeated sentence is ordinary speech; its text belongs in the + # transcript, not in the log, so only the count is recorded. + with caplog.at_level(logging.INFO, logger="kiro_crew.transcribe"): + filter_hallucinations(" ".join(["Ship the thing."] * 8)) + assert "collapsed 7 repeated sentence(s)" in caplog.text + assert "Ship the thing" not in caplog.text + + def test_discarding_the_whole_transcript_warns(self, caplog): + # The caller turns "" into None and the recording is gone with no other + # trace, so this case is a warning rather than an info line. + with caplog.at_level(logging.INFO, logger="kiro_crew.transcribe"): + assert filter_hallucinations("Subtitles by Amara.org. Transcribed by.") == "" + assert "discarded the entire transcript" in caplog.text + assert any(r.levelno == logging.WARNING for r in caplog.records) + + def test_an_untouched_transcript_logs_nothing(self, caplog): + # Every recording passes through here. A line per transcription would bury + # the removals this logging exists to surface. + with caplog.at_level(logging.INFO, logger="kiro_crew.transcribe"): + filter_hallucinations("We agreed to ship on Friday.") + assert caplog.records == [] + + +# --------------------------------------------------------------------------- +# Install path +# --------------------------------------------------------------------------- + + +class TestInstallScript: + def test_installs_into_the_gateways_own_interpreter(self): + # The library is imported IN-PROCESS by kiro_crew.transcribe, so the one + # environment that matters is sys.executable's. A system python's + # user-site would be invisible here — and inside a venv pip refuses + # `--user` outright — so the script must target the gateway interpreter + # and must not pass `--user`. + script = _build_stt_install_script("faster") + assert "pip install -q faster-whisper" in script + assert "--user" not in script + assert sys.executable in script + + def test_does_not_probe_for_a_system_python(self): + # The $PY probe belongs to the CLI providers, whose binary any python can + # own. Probing here risks installing into an interpreter the gateway + # never imports from. + assert "for py in" not in _build_stt_install_script("faster") + + def test_does_not_install_ffmpeg(self): + # It is not needed, and installing it would make the button slower and more + # failure-prone for no benefit. + script = _build_stt_install_script("faster") + assert "brew install ffmpeg" not in script + assert "openai-whisper" not in script + + def test_documents_the_windows_arm_gap(self): + # CTranslate2 publishes no wheel there, so the install cannot succeed and the + # script should say why rather than fail opaquely. + assert "Windows on ARM" in _build_stt_install_script("faster") + + def test_includes_the_path_prelude(self): + # A brew-installed python3 is common on macOS, and the gateway's inherited + # PATH does not contain the Homebrew prefix. + assert "brew shellenv" in _build_stt_install_script("faster") + + def test_emits_the_progress_line_the_status_parser_matches(self): + # `_stt_install_status` keys the `installing_faster` step off this exact text. + assert "Installing faster-whisper" in _build_stt_install_script("faster") + + def test_requires_no_manual_prerequisites(self): + assert _stt_prereq_commands("faster") == [] + + def test_a_non_importable_install_fails_the_script(self): + # pip can report success while the package is unusable — a CTranslate2 + # wheel whose native extension will not load is the common case. The + # verification import therefore has to be able to fail the script: while + # its failure was swallowed into a "check install" note, the script exited + # 0 and the caller reported the provider ready. + script = _build_stt_install_script("faster") + assert "check install" not in script + assert "is not importable" in script + assert "exit 1" in script.split("Installing faster-whisper")[1] + + +# --------------------------------------------------------------------------- +# Inference bulkhead + timeout +# --------------------------------------------------------------------------- + + +class TestInferenceExecutor: + """Inference runs on its OWN pool, not the PTY-teardown one. + + A started ``run_in_executor`` future cannot be cancelled, so a wedged model load + (or a first-run multi-GB weight download inside the library's constructor) holds + its worker until the process exits. On ``subprocess_executor`` that would consume + one of the eight workers whose whole purpose is absorbing a teardown storm — the + recovery path would be starved by the thing it recovers from. + """ + + def test_transcribe_binds_the_stt_pool(self): + from kiro_crew import executors, transcribe + + assert transcribe.stt_executor is executors.stt_executor + + def test_stt_pool_is_distinct_from_the_teardown_pool(self): + from kiro_crew import executors + + assert executors.stt_executor() is not executors.subprocess_executor() + + def test_stt_pool_threads_are_identifiable_in_a_stack_dump(self): + from kiro_crew import executors + + assert executors.stt_executor()._thread_name_prefix == "mc-stt" + + def test_pool_is_bounded_because_each_worker_holds_a_model(self): + # The worker count is a MEMORY ceiling, not just a CPU one: every in-flight + # call keeps a fully quantised model resident (up to ~GBs for large-v3). + from kiro_crew import executors + + assert executors.stt_executor()._max_workers == 2 + + @pytest.mark.asyncio + async def test_inference_is_submitted_to_the_stt_pool(self, tmp_path): + from concurrent.futures import ThreadPoolExecutor + + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster") + pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix="probe-stt") + try: + model_cls = MagicMock(return_value=_fake_model(["ok"])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + with patch("kiro_crew.transcribe.stt_executor", return_value=pool) as chosen: + assert await transcribe_audio(str(audio), cfg) == "ok" + chosen.assert_called_once() + finally: + pool.shutdown(wait=True) + + +class TestInferenceTimeout: + """``stt.timeout_secs`` bounds the faster path like it bounds the CLI providers. + + Before this, the future was unbounded: a wedged inference left the dictation + request hanging with no ceiling at all. + """ + + @pytest.mark.asyncio + async def test_a_wedged_inference_returns_none_instead_of_hanging(self, tmp_path): + import threading + + from kiro_crew import transcribe + + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster", timeout_secs=1) + release = threading.Event() + + def _wedged(*_a, **_k): + # Bounded so a failed assertion cannot leak a thread for the whole run; + # the test releases it explicitly below. + release.wait(timeout=30) + return "arrived too late" + + try: + with patch("kiro_crew.transcribe._run_faster_whisper_sync", _wedged): + assert await transcribe.transcribe_audio(str(audio), cfg) is None + finally: + release.set() + + @pytest.mark.asyncio + async def test_the_timeout_is_logged_as_releasing_the_caller_only(self, tmp_path, caplog): + """The log line must not imply the work was cancelled. + + ``asyncio.wait_for`` cannot interrupt a running thread, so the inference (or + the download it is stuck in) continues and its worker stays occupied. An + operator reading "timed out" would otherwise assume the slot was freed. + """ + import threading + + from kiro_crew import transcribe + + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster", timeout_secs=1) + release = threading.Event() + + def _wedged(*_a, **_k): + release.wait(timeout=30) + return None + + try: + with caplog.at_level("ERROR", logger="kiro_crew.transcribe"): + with patch("kiro_crew.transcribe._run_faster_whisper_sync", _wedged): + await transcribe.transcribe_audio(str(audio), cfg) + finally: + release.set() + assert "timed out" in caplog.text + assert "cannot be cancelled" in caplog.text + + @pytest.mark.asyncio + async def test_a_prompt_transcription_is_unaffected(self, tmp_path): + # The bound must not clip normal work: the same generous default every other + # provider uses applies here. + audio = tmp_path / "a.wav" + audio.write_bytes(b"RIFF") + cfg = SttConfig(enabled=True, provider="faster") + model_cls = MagicMock(return_value=_fake_model(["Real speech here."])) + with patch("kiro_crew.transcribe._FasterWhisperModel", model_cls): + assert await transcribe_audio(str(audio), cfg) == "Real speech here." diff --git a/website/scripts/capture-stt-faster.mjs b/website/scripts/capture-stt-faster.mjs new file mode 100644 index 00000000000..6d8a205e632 --- /dev/null +++ b/website/scripts/capture-stt-faster.mjs @@ -0,0 +1,184 @@ +/** + * Screenshot harness for the `faster` (faster-whisper) STT provider UI (#2192). + * + * Runs the REAL built SPA (website/dist) behind the in-process static server, + * answering every /api/** call from fixtures via Playwright route interception — + * no gateway, no token. The client code under test is unmodified, so + * Settings → Voice → Speech-to-Text renders exactly as in production. + * + * ## What the shots prove + * + * 1. `not-installed`: choosing the new `faster` provider shows its localized + * dropdown label, the MODEL picker (previously gated on `whisper` alone — + * the PR's WHISPER_MODEL_PROVIDERS fix), the "Install faster-whisper" + * button and its "no separate ffmpeg install" blurb. + * 2. `installing`: the progress bar and the `step_installing_faster` label the + * status parser drives off the script's "Installing faster-whisper..." line. + * 3. `ready`: the available state once the library imports. + * + * Each scene ASSERTS the strings programmatically before shooting, so a broken + * render fails the harness loudly instead of producing a misleading frame. + * + * Usage: node scripts/capture-stt-faster.mjs [outDir] + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' +import { serveDist } from './lib/serve-dist.mjs' +import { json, handleBootRoute } from './lib/boot-api.mjs' + +const OUT = process.argv[2] || '../temp-screenshots/stt-faster-whisper' +const PROJECT = '/home/user/workspace/KiroCrew' + +mkdirSync(OUT, { recursive: true }) + +/** Mutated per scene: what /api/config/stt returns. */ +const stt = { + enabled: true, + provider: 'faster', + model: 'turbo', + mlx_model: '', + available: false, + streaming: false, + endpointing: false, + dictation_panel: false, + transcribe_region: 'us-east-1', + transcribe_profile: '', + language_code: 'en-US', + models: { + tiny: '~75 MB', base: '~145 MB', small: '~484 MB', + medium: '~1.5 GB', 'large-v3': '~3.1 GB', turbo: '~1.6 GB', + }, + mlx_models: {}, + providers: ['whisper', 'mlx', 'transcribe', 'faster'], + streaming_providers: ['transcribe', 'apple'], + language_codes: ['en-US', 'zh-CN', 'de-DE'], + install_step: 'idle', + install_detail: '', + install_error: '', + prereqs: [], + transcribe_unsupported: false, + bundled_interpreter: false, + ffmpeg_missing: false, +} + +const scene = { theme: 'dark' } + +async function main() { + const { srv, base } = await serveDist() + const browser = await chromium.launch() + const context = await browser.newContext({ + viewport: { width: 1760, height: 1400 }, + // Settings rows are 12–13px type; a 1x shot renders soft on GitHub. + deviceScaleFactor: 2, + }) + const page = await context.newPage() + + await page.routeWebSocket(/\/api\/ws/, () => {}) + + await page.route('**/api/**', async route => { + const path = new URL(route.request().url()).pathname + // Scene-specific routes first; everything else is the shared boot fixture. + if (path === '/api/config/stt') return json(route, stt) + if (path === '/api/config/kirocrew') { + return json(route, { + agent: { model: 'claude-opus-4.8', reasoning_effort: 'high' }, + session: { autocompact_pct: 90 }, + dashboard: { user_role: '', user_technical_level: '' }, + }) + } + if (path === '/api/chat/slots') return json(route, []) + return handleBootRoute(route, path, { project: PROJECT, theme: scene.theme }) + }) + + page.on('pageerror', err => console.log('PAGEERROR:', String(err).slice(0, 300))) + page.on('console', msg => { if (msg.type() === 'error') console.log('CONSOLE:', msg.text().slice(0, 300)) }) + + async function load(theme = 'dark') { + scene.theme = theme + await page.addInitScript(s => { + localStorage.clear() + localStorage.setItem('mc-theme', s.theme) + localStorage.setItem('mc-onboarded', '1') + }, scene) + await page.goto(base + '/settings?tab=voice', { waitUntil: 'domcontentloaded' }) + await page.waitForTimeout(2600) + } + + /** Must-see strings per scene; a miss fails the harness before any shot. */ + async function mustSee(...texts) { + for (const t of texts) { + const n = await page.getByText(t, { exact: false }).count() + if (!n) throw new Error(`ASSERT FAILED: "${t}" not rendered`) + } + } + + /** Crop from the Provider select down through the scene's distinguishing + * element (install button / progress label / ready row). Anchoring only on + * the provider label once produced two byte-identical "different" scenes — + * the install block sat below the fixed-height window — so the crop is now + * the UNION of the anchor and the element that makes the scene the scene. */ + async function sttCard(name, sceneText) { + const anchor = page.getByText('faster-whisper (local — no separate ffmpeg install)').first() + const marker = page.getByText(sceneText, { exact: false }).first() + const a = await anchor.boundingBox() + const m = await marker.boundingBox() + if (!a) throw new Error('ASSERT FAILED: provider label has no box') + if (!m) throw new Error(`ASSERT FAILED: scene marker "${sceneText}" has no box`) + const pad = 24 + const x0 = Math.max(0, Math.min(a.x, m.x) - 340) + const y0 = Math.max(0, Math.min(a.y, m.y) - 150) + const y1 = Math.max(a.y + a.height, m.y + m.height) + 120 + const clip = { x: x0, width: Math.min(1180, 1760 - x0), y: y0, height: Math.min(y1 - y0 + pad, 1400 - y0) } + await page.screenshot({ path: `${OUT}/${name}.png`, clip }) + console.log('wrote', `${OUT}/${name}.png`) + } + + // Scene 1 — provider selected, library not installed: dropdown label, model + // picker (WHISPER_MODEL_PROVIDERS gating), install button + blurb. + stt.provider = 'faster' + stt.available = false + stt.install_step = 'idle' + await load('dark') + await mustSee( + 'faster-whisper (local — no separate ffmpeg install)', + 'Install faster-whisper', + 'Installs faster-whisper via pip', + 'turbo (~1.6 GB)', + ) + await sttCard('01-faster-not-installed-dark', 'Installs faster-whisper via pip') + + // Scene 2 — install in flight: progress bar + step label from the exact + // "Installing faster-whisper..." line the status parser matches. + stt.install_step = 'installing_faster' + stt.install_detail = 'Installing faster-whisper...' + await load('dark') + await mustSee('Installing faster-whisper…') + await sttCard('02-faster-installing-dark', 'Installing faster-whisper…') + + // Scene 3 — ready: the library imports, model picker still visible. + stt.install_step = 'done' + stt.install_detail = '' + stt.available = true + await load('light') + await mustSee('faster-whisper (local — no separate ffmpeg install)') + await sttCard('03-faster-ready-light', 'faster-whisper (local — no separate ffmpeg install)') + + // Evidence integrity: two scenes producing the same bytes means the crop + // missed the distinguishing element and the "proof" proves nothing. + const { readFileSync } = await import('node:fs') + const { createHash } = await import('node:crypto') + const digest = f => createHash('sha256').update(readFileSync(`${OUT}/${f}.png`)).digest('hex') + const frames = ['01-faster-not-installed-dark', '02-faster-installing-dark', '03-faster-ready-light'] + const seen = new Map() + for (const f of frames) { + const d = digest(f) + if (seen.has(d)) throw new Error(`ASSERT FAILED: ${f}.png is byte-identical to ${seen.get(d)}.png`) + seen.set(d, f) + } + console.log('frames verified distinct') + + await browser.close() + srv.close() +} + +main().catch(err => { console.error(err); process.exit(1) }) diff --git a/website/src/i18n/locales/bn.json b/website/src/i18n/locales/bn.json index 6511867e8c8..6fe8c49c5b3 100644 --- a/website/src/i18n/locales/bn.json +++ b/website/src/i18n/locales/bn.json @@ -12024,8 +12024,10 @@ "failed_to_save_stt_config": "STT কনফিগ সেভ করা যায়নি", "input_device_used_to_capture_your_voice": "আপনার কণ্ঠস্বর ধারণ করতে ব্যবহৃত ইনপুট ডিভাইস", "install_failed": "ইনস্টল করা যায়নি", + "install_faster_whisper": "faster-whisper ইনস্টল করুন", "install_mlx_whisper": "MLX Whisper ইনস্টল করুন", "install_whisper": "Whisper ইনস্টল করুন", + "installs_faster_whisper_no_ffmpeg_needed": "pip দিয়ে faster-whisper ইনস্টল করে। আলাদা করে ffmpeg ইনস্টল করতে হয় না: PyAV-এর সঙ্গে দেওয়া FFmpeg দিয়ে একই প্রসেসে ডিকোড করে। Windows on ARM-এ পাওয়া যায় না।", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "pipx দিয়ে mlx-whisper ও ffmpeg ইনস্টল করে। শুধু Apple Silicon (arm64)।", "installs_openai_whisper_ffmpeg_uses_system_pytho": "openai-whisper ও ffmpeg ইনস্টল করে। সিস্টেমের python3 (≥ 3.10) ব্যবহার করে।", "language": "ভাষা", @@ -12037,6 +12039,7 @@ "not_installed": "ইনস্টল করা নেই", "provider": "প্রোভাইডার", "provider_apple": "Apple Speech (ডিভাইসে — macOS 26+)", + "provider_faster": "faster-whisper (লোকাল — আলাদা করে ffmpeg ইনস্টল লাগে না)", "provider_mlx": "Whisper MLX (লোকাল — শুধু Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (লোকাল)", @@ -12048,6 +12051,8 @@ "step_done": "সম্পন্ন!", "step_error": "ব্যর্থ", "step_installing_brew": "Homebrew ইনস্টল হচ্ছে…", + "step_installing_faster": "faster-whisper ইনস্টল হচ্ছে…", + "faster_unsupported_windows_arm": "faster-whisper, Windows on ARM-এ উপলব্ধ নয় — এই প্ল্যাটফর্মের জন্য কোনো CTranslate2 হুইল নেই। পরিবর্তে whisper (লোকাল) বা AWS Transcribe প্রোভাইডার ব্যবহার করুন।", "step_installing_ffmpeg": "ffmpeg ইনস্টল হচ্ছে…", "step_installing_mlx": "mlx-whisper ইনস্টল হচ্ছে…", "step_installing_python": "Python ইনস্টল হচ্ছে…", diff --git a/website/src/i18n/locales/de.json b/website/src/i18n/locales/de.json index d57b53fa3a4..d159afa36fa 100644 --- a/website/src/i18n/locales/de.json +++ b/website/src/i18n/locales/de.json @@ -12023,8 +12023,10 @@ "failed_to_save_stt_config": "STT-Konfiguration konnte nicht gespeichert werden", "input_device_used_to_capture_your_voice": "Eingabegerät zur Aufnahme Ihrer Stimme", "install_failed": "Installation fehlgeschlagen", + "install_faster_whisper": "faster-whisper installieren", "install_mlx_whisper": "MLX Whisper installieren", "install_whisper": "Whisper installieren", + "installs_faster_whisper_no_ffmpeg_needed": "Installiert faster-whisper über pip. Keine separate ffmpeg-Installation: die Dekodierung läuft prozessintern über das in PyAV mitgelieferte FFmpeg. Nicht verfügbar unter Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Installiert mlx-whisper über pipx + ffmpeg. Nur Apple Silicon (arm64).", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Installiert openai-whisper + ffmpeg. Nutzt das System-python3 (≥ 3.10).", "language": "Sprache", @@ -12036,6 +12038,7 @@ "not_installed": "nicht installiert", "provider": "Anbieter", "provider_apple": "Apple Speech (auf dem Gerät — macOS 26+)", + "provider_faster": "faster-whisper (lokal — keine separate ffmpeg-Installation)", "provider_mlx": "Whisper MLX (lokal — nur Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (lokal)", @@ -12047,6 +12050,8 @@ "step_done": "Fertig!", "step_error": "Fehlgeschlagen", "step_installing_brew": "Homebrew wird installiert…", + "step_installing_faster": "faster-whisper wird installiert…", + "faster_unsupported_windows_arm": "faster-whisper ist unter Windows on ARM nicht verfügbar – für diese Plattform existiert kein CTranslate2-Wheel. Verwenden Sie stattdessen den Anbieter whisper (lokal) oder AWS Transcribe.", "step_installing_ffmpeg": "ffmpeg wird installiert…", "step_installing_mlx": "mlx-whisper wird installiert…", "step_installing_python": "Python wird installiert…", diff --git a/website/src/i18n/locales/en-XA.json b/website/src/i18n/locales/en-XA.json index 8a4adb8bbf4..415dd29d011 100644 --- a/website/src/i18n/locales/en-XA.json +++ b/website/src/i18n/locales/en-XA.json @@ -11674,7 +11674,12 @@ "step_installing_whisper": "[Ìñşţàĺĺìñğ ẁĥìşþèŕ (~1.6 ĞƁ)… ····················]", "step_installing_xcode": "[Ìñşţàĺĺìñğ Ẋçøðè ÇĹÌ Ţøøĺş… ···················]", "step_starting": "[Şţàŕţìñğ… ··············]", - "voice_input_remote_instance_note": "[Ṽøìçè ìñþùţ øñ à ŕèɱøţè ìñşţàñçè ŕèǫùìŕèş ɱìçŕøþĥøñè þèŕɱìşşìøñ ðèĺèğàţìøñ àçŕøşş ţĥè ìƒŕàɱè ƀøùñðàŕý. ̃ ţĥè ɱìç ðøèşñ'ţ ŕèşþøñð ĥèŕè, ùşè ţĥè ĺøçàĺ (þàŕèñţ) ðàşĥƀøàŕð ìñşţèàð. ·····················································]" + "voice_input_remote_instance_note": "[Ṽøìçè ìñþùţ øñ à ŕèɱøţè ìñşţàñçè ŕèǫùìŕèş ɱìçŕøþĥøñè þèŕɱìşşìøñ ðèĺèğàţìøñ àçŕøşş ţĥè ìƒŕàɱè ƀøùñðàŕý. ̃ ţĥè ɱìç ðøèşñ'ţ ŕèşþøñð ĥèŕè, ùşè ţĥè ĺøçàĺ (þàŕèñţ) ðàşĥƀøàŕð ìñşţèàð. ·····················································]", + "provider_faster": "[ƒàşţèŕ-ẁĥìşþèŕ (ĺøçàĺ — ñø şèþàŕàţè ƒƒɱþèğ ìñşţàĺĺ) ··················]", + "install_faster_whisper": "[Ìñşţàĺĺ ƒàşţèŕ-ẁĥìşþèŕ ···············]", + "installs_faster_whisper_no_ffmpeg_needed": "[Ìñşţàĺĺş ƒàşţèŕ-ẁĥìşþèŕ ṽìà þìþ. Ñø şèþàŕàţè ƒƒɱþèğ ìñşţàĺĺ: ìţ ðèçøðèş àùðìø ìñ-þŕøçèşş ţĥŕøùğĥ ÞýÀṼ'ş ƀùñðĺèð ƑƑɱþèğ. Ñøţ àṽàìĺàƀĺè øñ Ẁìñðøẁş øñ ÀŔṀ. ··············································]", + "step_installing_faster": "[Ìñşţàĺĺìñğ ƒàşţèŕ-ẁĥìşþèŕ… ··················]", + "faster_unsupported_windows_arm": "[ƒàşţèŕ-ẁĥìşþèŕ ìş ñøţ àṽàìĺàƀĺè øñ Ẁìñðøẁş øñ ÀŔṀ — ñø ÇŢŕàñşĺàţè2 ẁĥèèĺ èẋìşţş ƒøŕ ţĥìş þĺàţƒøŕɱ. Ùşè ţĥè ẁĥìşþèŕ (ĺøçàĺ) øŕ ÀẀŞ Ţŕàñşçŕìƀè þŕøṽìðèŕ ìñşţèàð. ···············································]" }, "teamsPanel": { "above_unlike_slack_the_bot_framework_has_no_outb": "[àƀøṽè. Ùñĺìķè Şĺàçķ, ţĥè Ɓøţ Ƒŕàɱèẁøŕķ ĥàş ñø\n øùţƀøùñð-øñĺý ɱøðè. ························]", diff --git a/website/src/i18n/locales/en.manual.json b/website/src/i18n/locales/en.manual.json index 3a27280f8b2..15e3897c975 100644 --- a/website/src/i18n/locales/en.manual.json +++ b/website/src/i18n/locales/en.manual.json @@ -4652,7 +4652,12 @@ "step_installing_whisper": "Installing whisper (~1.6 GB)…", "step_installing_xcode": "Installing Xcode CLI Tools…", "step_starting": "Starting…", - "voice_input_remote_instance_note": "Voice input on a remote instance requires microphone permission delegation across the iframe boundary. If the mic doesn't respond here, use the local (parent) dashboard instead." + "voice_input_remote_instance_note": "Voice input on a remote instance requires microphone permission delegation across the iframe boundary. If the mic doesn't respond here, use the local (parent) dashboard instead.", + "provider_faster": "faster-whisper (local — no separate ffmpeg install)", + "install_faster_whisper": "Install faster-whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Installs faster-whisper via pip. No separate ffmpeg install: it decodes audio in-process through PyAV's bundled FFmpeg. Not available on Windows on ARM.", + "step_installing_faster": "Installing faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper is not available on Windows on ARM — no CTranslate2 wheel exists for this platform. Use the whisper (local) or AWS Transcribe provider instead." }, "teamsPanel": { "active": "Active", diff --git a/website/src/i18n/locales/es.json b/website/src/i18n/locales/es.json index a12322144ca..b3da51322d5 100644 --- a/website/src/i18n/locales/es.json +++ b/website/src/i18n/locales/es.json @@ -12183,8 +12183,10 @@ "failed_to_save_stt_config": "No se pudo guardar la configuración de STT", "input_device_used_to_capture_your_voice": "Dispositivo de entrada usado para capturar tu voz", "install_failed": "Instalación fallida", + "install_faster_whisper": "Instalar faster-whisper", "install_mlx_whisper": "Instalar MLX Whisper", "install_whisper": "Instalar Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Instala faster-whisper mediante pip. No hay que instalar ffmpeg aparte: decodifica en el mismo proceso con el FFmpeg incluido en PyAV. No está disponible en Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Instala mlx-whisper mediante pipx + ffmpeg. Solo para Apple Silicon (arm64).", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Instala openai-whisper + ffmpeg. Usa el python3 del sistema (≥ 3.10).", "language": "Idioma", @@ -12196,6 +12198,7 @@ "not_installed": "no instalado", "provider": "Proveedor", "provider_apple": "Apple Speech (en el dispositivo — macOS 26+)", + "provider_faster": "faster-whisper (local — sin instalar ffmpeg aparte)", "provider_mlx": "Whisper MLX (local — solo Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (local)", @@ -12207,6 +12210,8 @@ "step_done": "¡Listo!", "step_error": "Falló", "step_installing_brew": "Instalando Homebrew…", + "step_installing_faster": "Instalando faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper no está disponible en Windows on ARM: no existe ningún wheel de CTranslate2 para esta plataforma. Usa el proveedor whisper (local) o AWS Transcribe en su lugar.", "step_installing_ffmpeg": "Instalando ffmpeg…", "step_installing_mlx": "Instalando mlx-whisper…", "step_installing_python": "Instalando Python…", diff --git a/website/src/i18n/locales/fr.json b/website/src/i18n/locales/fr.json index 038b65f7884..55786352ad9 100644 --- a/website/src/i18n/locales/fr.json +++ b/website/src/i18n/locales/fr.json @@ -12183,8 +12183,10 @@ "failed_to_save_stt_config": "Échec de l’enregistrement de la configuration STT", "input_device_used_to_capture_your_voice": "Périphérique d'entrée utilisé pour capter votre voix", "install_failed": "Échec de l’installation", + "install_faster_whisper": "Installer faster-whisper", "install_mlx_whisper": "Installer MLX Whisper", "install_whisper": "Installer Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Installe faster-whisper via pip. Aucune installation séparée de ffmpeg : le décodage se fait dans le processus, via le FFmpeg fourni avec PyAV. Non disponible sous Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Installe mlx-whisper via pipx + ffmpeg. Apple Silicon (arm64) uniquement.", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Installe openai-whisper + ffmpeg. Utilise le python3 du système (≥ 3.10).", "language": "Langue", @@ -12196,6 +12198,7 @@ "not_installed": "non installé", "provider": "Fournisseur", "provider_apple": "Apple Speech (sur l’appareil — macOS 26+)", + "provider_faster": "faster-whisper (local — sans installer ffmpeg séparément)", "provider_mlx": "Whisper MLX (local — Apple Silicon uniquement)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (local)", @@ -12207,6 +12210,8 @@ "step_done": "Terminé !", "step_error": "Échec", "step_installing_brew": "Installation de Homebrew…", + "step_installing_faster": "Installation de faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper n'est pas disponible sur Windows on ARM : aucun wheel CTranslate2 n'existe pour cette plateforme. Utilisez plutôt le fournisseur whisper (local) ou AWS Transcribe.", "step_installing_ffmpeg": "Installation de ffmpeg…", "step_installing_mlx": "Installation de mlx-whisper…", "step_installing_python": "Installation de Python…", diff --git a/website/src/i18n/locales/hi.json b/website/src/i18n/locales/hi.json index 8a2648aa221..9f6b0f0112f 100644 --- a/website/src/i18n/locales/hi.json +++ b/website/src/i18n/locales/hi.json @@ -12024,8 +12024,10 @@ "failed_to_save_stt_config": "STT कॉन्फ़िग सहेजने में विफल", "input_device_used_to_capture_your_voice": "आपकी आवाज़ कैप्चर करने के लिए उपयोग होने वाला इनपुट डिवाइस", "install_failed": "इंस्टॉल विफल", + "install_faster_whisper": "faster-whisper इंस्टॉल करें", "install_mlx_whisper": "MLX Whisper इंस्टॉल करें", "install_whisper": "Whisper इंस्टॉल करें", + "installs_faster_whisper_no_ffmpeg_needed": "pip के ज़रिए faster-whisper इंस्टॉल करता है। ffmpeg अलग से इंस्टॉल करने की ज़रूरत नहीं: यह PyAV में शामिल FFmpeg से उसी प्रोसेस में डिकोड करता है। Windows on ARM पर उपलब्ध नहीं है।", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "pipx के ज़रिए mlx-whisper + ffmpeg इंस्टॉल करता है। सिर्फ़ Apple Silicon (arm64) पर।", "installs_openai_whisper_ffmpeg_uses_system_pytho": "openai-whisper + ffmpeg इंस्टॉल करता है। सिस्टम python3 (≥ 3.10) इस्तेमाल करता है।", "language": "भाषा", @@ -12037,6 +12039,7 @@ "not_installed": "इंस्टॉल नहीं है", "provider": "प्रदाता", "provider_apple": "Apple Speech (डिवाइस पर — macOS 26+)", + "provider_faster": "faster-whisper (स्थानीय — ffmpeg अलग से इंस्टॉल करने की ज़रूरत नहीं)", "provider_mlx": "Whisper MLX (स्थानीय — केवल Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (स्थानीय)", @@ -12048,6 +12051,8 @@ "step_done": "पूर्ण!", "step_error": "विफल", "step_installing_brew": "Homebrew इंस्टॉल हो रहा है…", + "step_installing_faster": "faster-whisper इंस्टॉल हो रहा है…", + "faster_unsupported_windows_arm": "faster-whisper, Windows on ARM पर उपलब्ध नहीं है — इस प्लेटफ़ॉर्म के लिए कोई CTranslate2 व्हील मौजूद नहीं है। इसके बजाय whisper (लोकल) या AWS Transcribe प्रदाता का उपयोग करें।", "step_installing_ffmpeg": "ffmpeg इंस्टॉल हो रहा है…", "step_installing_mlx": "mlx-whisper इंस्टॉल हो रहा है…", "step_installing_python": "Python इंस्टॉल हो रहा है…", diff --git a/website/src/i18n/locales/it.json b/website/src/i18n/locales/it.json index 226b53fa646..b183c4e289f 100644 --- a/website/src/i18n/locales/it.json +++ b/website/src/i18n/locales/it.json @@ -12182,8 +12182,10 @@ "failed_to_save_stt_config": "Impossibile salvare la configurazione STT", "input_device_used_to_capture_your_voice": "Dispositivo di input usato per acquisire la tua voce", "install_failed": "Installazione non riuscita", + "install_faster_whisper": "Installa faster-whisper", "install_mlx_whisper": "Installa MLX Whisper", "install_whisper": "Installa Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Installa faster-whisper tramite pip. Nessuna installazione separata di ffmpeg: decodifica nello stesso processo con il FFmpeg incluso in PyAV. Non disponibile su Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Installa mlx-whisper tramite pipx + ffmpeg. Solo Apple Silicon (arm64).", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Installa openai-whisper + ffmpeg. Usa il python3 di sistema (≥ 3.10).", "language": "Lingua", @@ -12195,6 +12197,7 @@ "not_installed": "non installato", "provider": "Provider", "provider_apple": "Apple Speech (sul dispositivo — macOS 26+)", + "provider_faster": "faster-whisper (locale — senza installare ffmpeg a parte)", "provider_mlx": "Whisper MLX (locale — solo Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (locale)", @@ -12206,6 +12209,8 @@ "step_done": "Fatto!", "step_error": "Non riuscito", "step_installing_brew": "Installazione di Homebrew…", + "step_installing_faster": "Installazione di faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper non è disponibile su Windows on ARM: per questa piattaforma non esiste alcun wheel CTranslate2. Usa invece il provider whisper (locale) o AWS Transcribe.", "step_installing_ffmpeg": "Installazione di ffmpeg…", "step_installing_mlx": "Installazione di mlx-whisper…", "step_installing_python": "Installazione di Python…", diff --git a/website/src/i18n/locales/ja.json b/website/src/i18n/locales/ja.json index d61bbb07716..3082c2b7489 100644 --- a/website/src/i18n/locales/ja.json +++ b/website/src/i18n/locales/ja.json @@ -11864,8 +11864,10 @@ "failed_to_save_stt_config": "STT 設定を保存できませんでした", "input_device_used_to_capture_your_voice": "音声をキャプチャするために使用される入力デバイス", "install_failed": "インストール失敗", + "install_faster_whisper": "faster-whisper をインストール", "install_mlx_whisper": "MLX Whisperをインストール", "install_whisper": "Whisperをインストール", + "installs_faster_whisper_no_ffmpeg_needed": "pip で faster-whisper をインストールします。ffmpeg を個別に導入する必要はありません(PyAV に同梱の FFmpeg でプロセス内でデコードします)。Windows on ARM では利用できません。", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "mlx-whisper を pipx + ffmpeg でインストールします。Apple Silicon(arm64)のみ。", "installs_openai_whisper_ffmpeg_uses_system_pytho": "openai-whisper + ffmpeg をインストールします。システムの python3(3.10 以上)を使用します。", "language": "言語", @@ -11877,6 +11879,7 @@ "not_installed": "インストール済みではありません", "provider": "プロバイダー", "provider_apple": "Apple Speech (オンデバイス — macOS 26+)", + "provider_faster": "faster-whisper(ローカル — ffmpeg の個別インストール不要)", "provider_mlx": "Whisper MLX (ローカル — Apple Silicon のみ)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (ローカル)", @@ -11888,6 +11891,8 @@ "step_done": "完了!", "step_error": "失敗", "step_installing_brew": "Homebrewをインストール中…", + "step_installing_faster": "faster-whisper をインストール中…", + "faster_unsupported_windows_arm": "faster-whisper は Windows on ARM では利用できません。このプラットフォーム向けの CTranslate2 ホイールが存在しないためです。代わりに whisper(ローカル)または AWS Transcribe プロバイダーをご利用ください。", "step_installing_ffmpeg": "ffmpeg をインストール中…", "step_installing_mlx": "mlx-whisperをインストール中…", "step_installing_python": "Python をインストール中…", diff --git a/website/src/i18n/locales/ko.json b/website/src/i18n/locales/ko.json index ba990db3302..c5529d77eb4 100644 --- a/website/src/i18n/locales/ko.json +++ b/website/src/i18n/locales/ko.json @@ -11864,8 +11864,10 @@ "failed_to_save_stt_config": "STT 설정을 저장하지 못했습니다", "input_device_used_to_capture_your_voice": "음성을 캡처하는 데 사용할 입력 장치", "install_failed": "설치 실패", + "install_faster_whisper": "faster-whisper 설치", "install_mlx_whisper": "MLX Whisper 설치", "install_whisper": "Whisper 설치", + "installs_faster_whisper_no_ffmpeg_needed": "pip로 faster-whisper를 설치합니다. ffmpeg를 별도로 설치할 필요가 없습니다. PyAV에 번들된 FFmpeg로 프로세스 내에서 오디오를 디코딩합니다. Windows on ARM에서는 사용할 수 없습니다.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "pipx로 mlx-whisper와 ffmpeg를 설치합니다. Apple Silicon(arm64)만 지원합니다.", "installs_openai_whisper_ffmpeg_uses_system_pytho": "openai-whisper와 ffmpeg를 설치합니다. 시스템 python3(3.10 이상)을 사용합니다.", "language": "언어", @@ -11877,6 +11879,7 @@ "not_installed": "설치되지 않음", "provider": "제공자", "provider_apple": "Apple Speech (온디바이스 — macOS 26+)", + "provider_faster": "faster-whisper (로컬 — 별도 ffmpeg 설치 불필요)", "provider_mlx": "Whisper MLX (로컬 — Apple Silicon 전용)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (로컬)", @@ -11888,6 +11891,8 @@ "step_done": "완료!", "step_error": "실패", "step_installing_brew": "Homebrew를 설치하는 중…", + "step_installing_faster": "faster-whisper를 설치하는 중…", + "faster_unsupported_windows_arm": "faster-whisper는 Windows on ARM에서 사용할 수 없습니다. 이 플랫폼용 CTranslate2 휠이 없습니다. 대신 whisper(로컬) 또는 AWS Transcribe 공급자를 사용하십시오.", "step_installing_ffmpeg": "ffmpeg를 설치하는 중…", "step_installing_mlx": "mlx-whisper를 설치하는 중…", "step_installing_python": "Python을 설치하는 중…", diff --git a/website/src/i18n/locales/pt.json b/website/src/i18n/locales/pt.json index b578dc110c2..51a5a68f02b 100644 --- a/website/src/i18n/locales/pt.json +++ b/website/src/i18n/locales/pt.json @@ -12182,8 +12182,10 @@ "failed_to_save_stt_config": "Falha ao salvar a configuração de STT", "input_device_used_to_capture_your_voice": "Dispositivo de entrada usado para capturar sua voz", "install_failed": "Falha na instalação", + "install_faster_whisper": "Instalar faster-whisper", "install_mlx_whisper": "Instalar MLX Whisper", "install_whisper": "Instalar Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Instala o faster-whisper via pip. Não é preciso instalar o ffmpeg à parte: a decodificação ocorre no próprio processo, com o FFmpeg incluído no PyAV. Não está disponível no Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Instala o mlx-whisper via pipx + ffmpeg. Apenas Apple Silicon (arm64).", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Instala o openai-whisper + ffmpeg. Usa o python3 do sistema (≥ 3.10).", "language": "Idioma", @@ -12195,6 +12197,7 @@ "not_installed": "não instalado", "provider": "Provedor", "provider_apple": "Apple Speech (no dispositivo — macOS 26+)", + "provider_faster": "faster-whisper (local — sem instalar o ffmpeg à parte)", "provider_mlx": "Whisper MLX (local — apenas Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (local)", @@ -12206,6 +12209,8 @@ "step_done": "Concluído!", "step_error": "Falhou", "step_installing_brew": "Instalando o Homebrew…", + "step_installing_faster": "Instalando o faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper não está disponível no Windows on ARM — não existe wheel do CTranslate2 para esta plataforma. Use o provedor whisper (local) ou AWS Transcribe.", "step_installing_ffmpeg": "Instalando o ffmpeg…", "step_installing_mlx": "Instalando o mlx-whisper…", "step_installing_python": "Instalando o Python…", diff --git a/website/src/i18n/locales/ru.json b/website/src/i18n/locales/ru.json index ce74eacfd61..46fc263a651 100644 --- a/website/src/i18n/locales/ru.json +++ b/website/src/i18n/locales/ru.json @@ -12342,8 +12342,10 @@ "failed_to_save_stt_config": "Не удалось сохранить настройки STT", "input_device_used_to_capture_your_voice": "Устройство ввода для записи вашего голоса", "install_failed": "Установка не удалась", + "install_faster_whisper": "Установить faster-whisper", "install_mlx_whisper": "Установить MLX Whisper", "install_whisper": "Установить Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "Устанавливает faster-whisper через pip. Отдельно устанавливать ffmpeg не нужно: декодирование идёт внутри процесса через FFmpeg, входящий в PyAV. Недоступно в Windows on ARM.", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "Устанавливает mlx-whisper через pipx и ffmpeg. Только Apple Silicon (arm64).", "installs_openai_whisper_ffmpeg_uses_system_pytho": "Устанавливает openai-whisper и ffmpeg. Использует системный python3 (≥ 3.10).", "language": "Язык", @@ -12355,6 +12357,7 @@ "not_installed": "не установлено", "provider": "Провайдер", "provider_apple": "Apple Speech (на устройстве — macOS 26+)", + "provider_faster": "faster-whisper (локально — без отдельной установки ffmpeg)", "provider_mlx": "Whisper MLX (локально — только Apple Silicon)", "provider_transcribe": "Transcribe (AWS)", "provider_whisper": "Whisper (локально)", @@ -12366,6 +12369,8 @@ "step_done": "Готово!", "step_error": "Ошибка", "step_installing_brew": "Установка Homebrew...", + "step_installing_faster": "Установка faster-whisper...", + "faster_unsupported_windows_arm": "faster-whisper недоступен в Windows on ARM — для этой платформы нет wheel-пакета CTranslate2. Используйте вместо него провайдер whisper (локальный) или AWS Transcribe.", "step_installing_ffmpeg": "Установка ffmpeg...", "step_installing_mlx": "Установка mlx-whisper...", "step_installing_python": "Установка Python...", diff --git a/website/src/i18n/locales/zh-CN.json b/website/src/i18n/locales/zh-CN.json index 409c8dd1805..c8c4329aa21 100644 --- a/website/src/i18n/locales/zh-CN.json +++ b/website/src/i18n/locales/zh-CN.json @@ -11864,8 +11864,10 @@ "failed_to_save_stt_config": "保存 STT 配置失败", "input_device_used_to_capture_your_voice": "用于采集语音的输入设备", "install_failed": "安装失败", + "install_faster_whisper": "安装 faster-whisper", "install_mlx_whisper": "安装 MLX Whisper", "install_whisper": "安装 Whisper", + "installs_faster_whisper_no_ffmpeg_needed": "通过 pip 安装 faster-whisper。无需单独安装 ffmpeg:它使用 PyAV 内置的 FFmpeg 在进程内解码。不支持 Windows on ARM。", "installs_mlx_whisper_via_pipx_ffmpeg_apple_silic": "通过 pipx 安装 mlx-whisper 和 ffmpeg。仅支持 Apple Silicon(arm64)。", "installs_openai_whisper_ffmpeg_uses_system_pytho": "安装 openai-whisper 和 ffmpeg。使用系统 python3(≥ 3.10)。", "language": "语言", @@ -11877,6 +11879,7 @@ "not_installed": "未安装", "provider": "提供方", "provider_apple": "Apple 语音(设备端 — 需 macOS 26 及以上)", + "provider_faster": "faster-whisper(本地 — 无需单独安装 ffmpeg)", "provider_mlx": "Whisper MLX(本地 — 仅 Apple Silicon)", "provider_transcribe": "Transcribe(AWS)", "provider_whisper": "Whisper(本地)", @@ -11888,6 +11891,8 @@ "step_done": "完成!", "step_error": "失败", "step_installing_brew": "正在安装 Homebrew…", + "step_installing_faster": "正在安装 faster-whisper…", + "faster_unsupported_windows_arm": "faster-whisper 在 Windows on ARM 上不可用:该平台没有 CTranslate2 的 wheel 包。请改用 whisper(本地)或 AWS Transcribe 提供程序。", "step_installing_ffmpeg": "正在安装 ffmpeg…", "step_installing_mlx": "正在安装 mlx-whisper…", "step_installing_python": "正在安装 Python…", diff --git a/website/src/pages/settings/SttSettings.tsx b/website/src/pages/settings/SttSettings.tsx index 84332104dfd..1e26959639e 100644 --- a/website/src/pages/settings/SttSettings.tsx +++ b/website/src/pages/settings/SttSettings.tsx @@ -49,6 +49,7 @@ interface SttConfig { install_error: string prereqs: string[] transcribe_unsupported?: boolean + faster_unsupported?: boolean bundled_interpreter?: boolean ffmpeg_missing?: boolean } @@ -63,7 +64,7 @@ interface SttConfig { * full literal keys indexed inline at the `i18nT()` call — the only shape * `scripts/check-i18n-keys.mjs` can resolve statically. */ -const STEP_LABEL_KEY: Record = { +export const STEP_LABEL_KEY: Record = { starting: 'pages.settings.sttSettings.step_starting', checking: 'pages.settings.sttSettings.step_checking', installing_xcode: 'pages.settings.sttSettings.step_installing_xcode', @@ -72,6 +73,7 @@ const STEP_LABEL_KEY: Record = { installing_ffmpeg: 'pages.settings.sttSettings.step_installing_ffmpeg', installing_whisper: 'pages.settings.sttSettings.step_installing_whisper', installing_mlx: 'pages.settings.sttSettings.step_installing_mlx', + installing_faster: 'pages.settings.sttSettings.step_installing_faster', done: 'pages.settings.sttSettings.step_done', error: 'pages.settings.sttSettings.step_error', } @@ -92,13 +94,23 @@ function stepLabel(step: string): string { * The provider *names* (Whisper, MLX, Transcribe) are DNT — only the * parenthetical qualifier is copy. */ -const PROVIDER_LABEL_KEY: Record = { +export const PROVIDER_LABEL_KEY: Record = { whisper: 'pages.settings.sttSettings.provider_whisper', mlx: 'pages.settings.sttSettings.provider_mlx', apple: 'pages.settings.sttSettings.provider_apple', transcribe: 'pages.settings.sttSettings.provider_transcribe', + faster: 'pages.settings.sttSettings.provider_faster', } +/** + * Providers that name their model with a Whisper size (`turbo`, `small`, …) and so + * share the `model` field and its picker. `mlx` is excluded: it takes a + * HuggingFace repo id in `mlx_model` instead, which is a different control. + * + * File scope so `check-i18n-keys.mjs` can resolve it, same as `PROVIDER_LABEL_KEY`. + */ +export const WHISPER_MODEL_PROVIDERS = ['whisper', 'faster'] + /** Localised dropdown label for a provider id, falling back to the raw id. */ function providerLabel(provider: string): string { // `hasOwnProperty`, not `in`: the id list comes from `SttConfig.providers`. @@ -364,6 +376,11 @@ export default function SttSettings({ cardIndex }: { const installing = isInstalling(stt) const isTranscribe = stt.provider === 'transcribe' const provider = stt.provider || 'whisper' + // `faster` on a platform with no CTranslate2 wheel: the Install button can only + // ever fail here (the backend refuses the request outright), so it is hidden and + // replaced with the alternatives — the same reasoning that hides it for + // Transcribe, whose requirement the button also cannot satisfy. + const fasterUnsupported = provider === 'faster' && !!stt.faster_unsupported const providerOptions = stt.providers?.length ? stt.providers : ['whisper', 'transcribe'] // Gate the streaming controls on the CAPABILITY, not on a provider name. The // backend owns the list (`stt_stream._STREAMING_PROVIDERS`) and serves it, so @@ -430,7 +447,7 @@ export default function SttSettings({ cardIndex }: { - {provider === 'whisper' && ( + {WHISPER_MODEL_PROVIDERS.includes(provider) && ( `${n} (${s})`)} onChange={v => set({ model: v })} disabled={saving} /> )} @@ -479,6 +496,18 @@ export default function SttSettings({ cardIndex }: {

)} + {fasterUnsupported && ( + // No CTranslate2 wheel exists for this platform and there is no + // sdist to build from, so pip cannot resolve the dependency at all. + // Nothing the user does to this machine changes that, so name the + // providers that DO work instead of leaving an Install button whose + // every press returns the same 400. +
+

+ {i18nT('pages.settings.sttSettings.faster_unsupported_windows_arm')} +

+
+ )} {stt.prereqs?.length > 0 && !installing && (

{i18nT('pages.settings.sttSettings.run_these_commands_in_your_terminal_first')}

@@ -511,24 +540,31 @@ export default function SttSettings({ cardIndex }: { {stt.install_detail &&

{stt.install_detail}

}
+ style={{ width: stt.install_step === 'checking' ? '10%' : stt.install_step === 'installing_xcode' ? '15%' : stt.install_step === 'installing_brew' ? '25%' : stt.install_step === 'installing_python' ? '35%' : stt.install_step === 'installing_ffmpeg' ? '50%' : stt.install_step === 'installing_whisper' || stt.install_step === 'installing_faster' ? '70%' : '5%' }} />
- ) : !isTranscribe && ( + ) : !isTranscribe && !fasterUnsupported && ( // Hidden for Transcribe: the button installs a local Whisper // runtime, which cannot change Transcribe's availability — its // requirement is the `voice` extra surfaced in the prereq block // above, and the backend rejects the install for this provider. + // Hidden for an unsupported `faster` platform for the same reason: + // the backend refuses that request too, so the button could only + // ever produce the notice already shown above. <> installMut.mutate()}> {provider === 'mlx' ? <> {i18nT('pages.settings.sttSettings.install_mlx_whisper')} - : <> {i18nT('pages.settings.sttSettings.install_whisper')}} + : provider === 'faster' + ? <> {i18nT('pages.settings.sttSettings.install_faster_whisper')} + : <> {i18nT('pages.settings.sttSettings.install_whisper')}}

{provider === 'mlx' ? i18nT('pages.settings.sttSettings.installs_mlx_whisper_via_pipx_ffmpeg_apple_silic') - : i18nT('pages.settings.sttSettings.installs_openai_whisper_ffmpeg_uses_system_pytho')} + : provider === 'faster' + ? i18nT('pages.settings.sttSettings.installs_faster_whisper_no_ffmpeg_needed') + : i18nT('pages.settings.sttSettings.installs_openai_whisper_ffmpeg_uses_system_pytho')}

)} diff --git a/website/src/test/SttSettingsFaster.test.ts b/website/src/test/SttSettingsFaster.test.ts new file mode 100644 index 00000000000..1d9a84659b0 --- /dev/null +++ b/website/src/test/SttSettingsFaster.test.ts @@ -0,0 +1,146 @@ +// The faster-whisper provider's presence in the STT settings UI. +// +// The maps under test are the UI's whole contract with the backend's provider and +// install-step vocabularies: a provider missing from `PROVIDER_LABEL_KEY` renders +// as a bare id in the dropdown, a step missing from `STEP_LABEL_KEY` renders as an +// empty progress label, and a provider missing from `WHISPER_MODEL_PROVIDERS` gets +// no model picker at all despite the backend reading `stt.model` for it. All three +// are silent failures, which is why they are pinned rather than left to a +// screenshot. +// +// They are exported from the shipping module for the same reason +// `useMeetingSession`'s pure helpers are: so the test binds to the real values +// instead of a copy that can drift. + +import { describe, it, expect } from 'vitest' + +import { CATALOGS as RUNTIME_CATALOGS } from '../i18n/catalogs' +import { SUPPORTED_LANGUAGES } from '../i18n/languages' +import { + PROVIDER_LABEL_KEY, + STEP_LABEL_KEY, + WHISPER_MODEL_PROVIDERS, +} from '../pages/settings/SttSettings' +import EN_MANUAL from '../i18n/locales/en.manual.json' + +const manualStt = (EN_MANUAL as { pages: { settings: { sttSettings: Record } } }) + .pages.settings.sttSettings + +describe('provider labels', () => { + it('labels every provider the backend can advertise', () => { + // `_VALID_STT_PROVIDERS` in the config loader, mirrored by hand because it is + // Python. An id absent here falls back to the raw string, so the dropdown would + // read "faster". Keeping `apple` listed matters as much as `faster`: this list + // is the mirror, so an omission here is the failure it is meant to catch. + expect(Object.keys(PROVIDER_LABEL_KEY).sort()).toEqual( + ['apple', 'faster', 'mlx', 'transcribe', 'whisper'], + ) + }) + + it('has a catalog string behind each label key', () => { + for (const [provider, key] of Object.entries(PROVIDER_LABEL_KEY)) { + const leaf = key.replace('pages.settings.sttSettings.', '') + expect(manualStt[leaf], provider).toBeTruthy() + } + }) +}) + +describe('the model picker gate', () => { + it('covers the providers that name models by Whisper size', () => { + // Both read `stt.model`, so both need the picker. Before this, selecting + // `faster` showed no model control while the backend still used the field. + expect(WHISPER_MODEL_PROVIDERS).toContain('whisper') + expect(WHISPER_MODEL_PROVIDERS).toContain('faster') + }) + + it('excludes providers that do not', () => { + // `mlx` takes a HuggingFace repo id in `mlx_model` — a different control. + // `transcribe` runs server-side and has no local model at all. + expect(WHISPER_MODEL_PROVIDERS).not.toContain('mlx') + expect(WHISPER_MODEL_PROVIDERS).not.toContain('transcribe') + }) +}) + +describe('install progress steps', () => { + it('labels the faster-whisper install step', () => { + // The backend emits `installing_faster`; an unmapped step renders blank. + expect(STEP_LABEL_KEY.installing_faster).toBe( + 'pages.settings.sttSettings.step_installing_faster', + ) + }) + + it('has a catalog string behind each step key', () => { + for (const [step, key] of Object.entries(STEP_LABEL_KEY)) { + const leaf = key.replace('pages.settings.sttSettings.', '') + expect(manualStt[leaf], step).toBeTruthy() + } + }) +}) + +describe('the faster-whisper install copy', () => { + it('promises no SEPARATE ffmpeg install, not the absence of ffmpeg', () => { + // The distinction is the whole accuracy of this string. FFmpeg is not gone -- + // it arrives inside PyAV's wheel and faster-whisper decodes through it + // in-process. What is true, and what makes this provider worth choosing on a + // machine where the CLI toolchain is the hard part, is that there is no + // separate ffmpeg to install. Claiming "no ffmpeg needed" would tell the user + // something false about what lands on their machine. + const blurb = manualStt.installs_faster_whisper_no_ffmpeg_needed + expect(blurb).toBeTruthy() + expect(blurb.toLowerCase()).toContain('ffmpeg') + expect(blurb.toLowerCase()).toContain('separate') + expect(blurb).toMatch(/PyAV/i) + }) + + it('warns about the platform with no CTranslate2 wheel', () => { + expect(manualStt.installs_faster_whisper_no_ffmpeg_needed).toContain('Windows on ARM') + }) + + it('has a button label', () => { + expect(manualStt.install_faster_whisper).toBeTruthy() + }) +}) + +describe('the unsupported-platform notice', () => { + // Pre-click gating. Without a served `faster_unsupported` flag the card could only + // discover the platform is unsupported by pressing Install and reading a 400 -- + // the same dead end on every press, which is the failure this notice removes. + it('exists in the catalog', () => { + expect(manualStt.faster_unsupported_windows_arm).toBeTruthy() + }) + + it('names the platform and the reason, not just a failure', () => { + const notice = manualStt.faster_unsupported_windows_arm + expect(notice).toContain('Windows on ARM') + // The reason matters: it is what tells the user retrying cannot help. + expect(notice).toContain('CTranslate2') + }) + + it('names both working alternatives', () => { + // A bare refusal leaves the user stuck. These two are the providers that do + // work here, and they are the same pair cli_doctor prints. + const notice = manualStt.faster_unsupported_windows_arm + expect(notice.toLowerCase()).toContain('whisper') + expect(notice).toMatch(/transcribe/i) + }) + + it('is translated into every shipped catalog', () => { + // Read the catalogs exactly as the runtime composes them — including English's + // generated (`en.json`) + manual (`en.manual.json`) merge. Globbing the locale + // JSON directly would flag `en.json`, which is regenerated wholesale from source + // scanning and legitimately never carries a hand-authored key. + const seen: string[] = [] + for (const [code, bundle] of Object.entries(RUNTIME_CATALOGS)) { + const root = (bundle as { translation: unknown }).translation as { + pages?: { settings?: { sttSettings?: Record } } + } + const value = root.pages?.settings?.sttSettings?.faster_unsupported_windows_arm + // A missing key renders the dotted path into the UI, and this notice is the + // only thing standing between a win-arm user and an unexplained dead end. + expect(value, code).toBeTruthy() + seen.push(code) + } + // Guard the guard: an empty catalog map would make the loop vacuously pass. + expect(seen.length).toBeGreaterThanOrEqual(SUPPORTED_LANGUAGES.length) + }) +})