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)
+ })
+})