Skip to content

Commit a110094

Browse files
feat: expand speech model catalog and add native GGUF runtime (#53)
1 parent a20ef01 commit a110094

24 files changed

Lines changed: 1027 additions & 1085 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ the model resident in that worker instead of reloading it for every clip; see
157157

158158
Requires Python 3.12+, [uv](https://docs.astral.sh/uv/), and FFmpeg on the host.
159159

160+
Run `just doctor` to check available host tools.
161+
160162
```sh
161163
# Debian / Ubuntu
162164
sudo apt install ffmpeg

app/admin_queries.py

Lines changed: 160 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77
from app import schemas
88
from app.build_info import current_commit
99
from app.catalog import catalog_source_url, language_names, recommended_ids
10+
from app.config import Settings
1011
from app.context import BOOTSTRAP_TOKEN_ID, TOKEN_FILE_HINT, VERSION, GatewayContext
1112
from app.engine_state import active_model_path, available_engines, engine_id
1213
from app.runtime_config import DEFAULT_IDLE_OFFLOAD_MINUTES
1314
from app.serializers import metrics_status, model_covers
14-
from app.system import detect_system
15+
from app.system import SystemInfo, detect_system
1516

1617
PYTHON_PACKAGE_PATH = "Python package"
18+
PYTHON_ENGINE_INSTALL_HINT = "Install vocagateway[engines] or use the Docker image"
19+
# One engine paired with the single runtime it needs.
20+
_EngineRuntime = tuple[str, schemas.DependencyStatus]
21+
1722

1823
SIZE_FILTER_CAPS: MappingProxyType[str, int] = MappingProxyType(
1924
{
@@ -25,6 +30,147 @@
2530
)
2631

2732

33+
class _EngineRuntimes:
34+
"""Per-engine runtime availability: one entry per engine the catalog ships.
35+
36+
Both the Overview "Libraries & tools" tiles and the per-model warning read
37+
this, so a card can never claim a runtime the panel says is installed.
38+
Model weights are deliberately not part of the check: this answers "can this
39+
host run the engine at all", which is what a download decision turns on.
40+
"""
41+
42+
def __init__(self, system: SystemInfo, settings: Settings) -> None:
43+
self.system = system
44+
self.settings = settings
45+
self._cached: dict[str, schemas.DependencyStatus] | None = None
46+
47+
def tiles(self) -> list[schemas.DependencyStatus]:
48+
return list(self._tiles.values())
49+
50+
def missing(self, engine: str) -> schemas.DependencyStatus | None:
51+
"""The unmet runtime behind an engine, or None when it can run here."""
52+
status = self._tiles.get(engine)
53+
return None if status is None or status.available else status
54+
55+
def entry_fields(self, engine: str) -> dict[str, str | None]:
56+
"""The AdminModelEntry warning fields for an engine, empty when it runs."""
57+
unmet = self.missing(engine)
58+
if unmet is None:
59+
return {}
60+
return {"runtime_requirement": unmet.name, "runtime_hint": unmet.install_hint}
61+
62+
@property
63+
def _tiles(self) -> dict[str, schemas.DependencyStatus]:
64+
"""Probe on first use, once per request.
65+
66+
Not cached beyond that on purpose: an operator who pip-installs an
67+
engine and reloads the page has to see it turn from Missing to
68+
Installed, which a process-lifetime cache would prevent.
69+
"""
70+
if self._cached is None:
71+
self._cached = dict(self._build())
72+
return self._cached
73+
74+
def _build(self) -> list[_EngineRuntime]:
75+
is_mac = self.system.os_name == "Darwin"
76+
silicon = self.system.is_apple_silicon
77+
return [
78+
*self._binary_tiles(is_mac),
79+
*self._package_tiles(silicon),
80+
*self._app_tiles(is_mac, silicon),
81+
]
82+
83+
def _binary_tiles(self, is_mac: bool) -> list[_EngineRuntime]:
84+
whisper_hint = (
85+
"brew install whisper-cpp"
86+
if is_mac
87+
else "Included in Docker or build whisper.cpp from source"
88+
)
89+
return [
90+
(
91+
"whisper.cpp",
92+
schemas.DependencyStatus(
93+
name="whisper.cpp CLI",
94+
available=self.system.whisper_cpp_path is not None,
95+
path=self.system.whisper_cpp_path,
96+
install_hint=whisper_hint,
97+
),
98+
),
99+
]
100+
101+
def _package_tiles(self, silicon: bool) -> list[_EngineRuntime]:
102+
mlx_ready = silicon and importlib_util.find_spec("mlx_audio") is not None
103+
mlx_hint = (
104+
"Install vocagateway[apple]" if silicon else "Available only on Apple-silicon Macs"
105+
)
106+
tiles = [
107+
(engine, self._package_status(label, module))
108+
for engine, label, module in (
109+
("faster-whisper", "faster-whisper", "faster_whisper"),
110+
("moonshine", "Moonshine Voice", "moonshine_voice"),
111+
("sherpa-onnx", "sherpa-onnx", "sherpa_onnx"),
112+
)
113+
]
114+
tiles.append(
115+
(
116+
"mlx-audio",
117+
schemas.DependencyStatus(
118+
name="MLX Audio",
119+
available=mlx_ready,
120+
path=PYTHON_PACKAGE_PATH if mlx_ready else None,
121+
install_hint=mlx_hint,
122+
),
123+
)
124+
)
125+
return tiles
126+
127+
def _package_status(self, label: str, module: str) -> schemas.DependencyStatus:
128+
installed = importlib_util.find_spec(module) is not None
129+
return schemas.DependencyStatus(
130+
name=label,
131+
available=installed,
132+
path=PYTHON_PACKAGE_PATH if installed else None,
133+
install_hint=PYTHON_ENGINE_INSTALL_HINT,
134+
)
135+
136+
def _app_tiles(self, is_mac: bool, silicon: bool) -> list[_EngineRuntime]:
137+
wk_hint = "brew install whisperkit-cli" if is_mac else "Available only on Apple platforms"
138+
vocamac_hint = (
139+
"https://github.com/jatinkrmalik/vocamac"
140+
if silicon
141+
else "Available only on Apple silicon Macs"
142+
)
143+
return [
144+
(
145+
"whisperkit",
146+
schemas.DependencyStatus(
147+
name="WhisperKit CLI",
148+
available=self.system.whisperkit_cli_path is not None,
149+
path=self.system.whisperkit_cli_path,
150+
install_hint=wk_hint,
151+
),
152+
),
153+
(
154+
"handy",
155+
schemas.DependencyStatus(
156+
name="Handy app",
157+
available=self.system.handy_installed,
158+
path=str(self.settings.handy_binary) if self.system.handy_installed else None,
159+
install_hint="https://handy.computer" if is_mac else "Available only on macOS",
160+
),
161+
),
162+
(
163+
"vocamac",
164+
schemas.DependencyStatus(
165+
name="VocaMac app",
166+
available=self.system.vocamac_installed,
167+
path=str(self.settings.vocamac_app) if self.system.vocamac_installed else None,
168+
install_hint=vocamac_hint,
169+
),
170+
),
171+
]
172+
173+
28174
class _SystemDependencyHelper:
29175
def __init__(self, ctx: GatewayContext) -> None:
30176
self.ctx = ctx
@@ -35,42 +181,26 @@ def __init__(self, ctx: GatewayContext) -> None:
35181
handy_binary=self.settings.handy_binary,
36182
vocamac_app=self.settings.vocamac_app,
37183
)
184+
self.runtimes = _EngineRuntimes(self.system, self.settings)
38185

39186
def build_dependencies(self) -> list[schemas.DependencyStatus]:
40187
is_mac = self.system.os_name == "Darwin"
41188
ffmpeg_hint = (
42189
"brew install ffmpeg" if is_mac else "Install FFmpeg with your Linux package manager"
43190
)
44-
whisper_hint = (
45-
"brew install whisper-cpp"
46-
if is_mac
47-
else "Included in Docker or build whisper.cpp from source"
191+
ffmpeg = schemas.DependencyStatus(
192+
name="FFmpeg",
193+
available=self.system.ffmpeg_path is not None,
194+
path=self.system.ffmpeg_path,
195+
install_hint=ffmpeg_hint,
48196
)
49-
deps = [
50-
schemas.DependencyStatus(
51-
name="FFmpeg",
52-
available=self.system.ffmpeg_path is not None,
53-
path=self.system.ffmpeg_path,
54-
install_hint=ffmpeg_hint,
55-
),
56-
schemas.DependencyStatus(
57-
name="whisper.cpp CLI",
58-
available=self.system.whisper_cpp_path is not None,
59-
path=self.system.whisper_cpp_path,
60-
install_hint=whisper_hint,
61-
),
62-
]
63-
deps.extend(self._python_dependencies())
64-
deps.extend(self._app_dependencies(is_mac))
65-
return deps
197+
return [ffmpeg, *self.runtimes.tiles()]
66198

67-
def build_checklist(
68-
self, dependencies: list[schemas.DependencyStatus], ready: bool
69-
) -> schemas.SetupChecklist:
199+
def build_checklist(self, ready: bool) -> schemas.SetupChecklist:
70200
return schemas.SetupChecklist(
71201
token_configured=True,
72202
ffmpeg_available=self.system.ffmpeg_path is not None,
73-
engine_binary_available=any(dep.available for dep in dependencies[1:]),
203+
engine_binary_available=any(tile.available for tile in self.runtimes.tiles()),
74204
model_installed=bool(self.ctx.manager.installed())
75205
or (self.ctx.engine_manager is not None and ready),
76206
engine_ready=ready,
@@ -93,68 +223,6 @@ def os_summary(self) -> str:
93223
version = self.system.os_version
94224
return f"{name} {version}"
95225

96-
def _python_dependencies(self) -> list[schemas.DependencyStatus]:
97-
silicon = self.system.is_apple_silicon
98-
mlx_ready = silicon and importlib_util.find_spec("mlx_audio") is not None
99-
mlx_hint = (
100-
"Install vocagateway[apple]" if silicon else "Available only on Apple-silicon Macs"
101-
)
102-
return [
103-
schemas.DependencyStatus(
104-
name="faster-whisper",
105-
available=importlib_util.find_spec("faster_whisper") is not None,
106-
path=PYTHON_PACKAGE_PATH if importlib_util.find_spec("faster_whisper") else None,
107-
install_hint="Install vocagateway[engines] or use the Docker image",
108-
),
109-
schemas.DependencyStatus(
110-
name="Moonshine Voice",
111-
available=importlib_util.find_spec("moonshine_voice") is not None,
112-
path=PYTHON_PACKAGE_PATH if importlib_util.find_spec("moonshine_voice") else None,
113-
install_hint="Install vocagateway[engines] or use the Docker image",
114-
),
115-
schemas.DependencyStatus(
116-
name="sherpa-onnx",
117-
available=importlib_util.find_spec("sherpa_onnx") is not None,
118-
path=PYTHON_PACKAGE_PATH if importlib_util.find_spec("sherpa_onnx") else None,
119-
install_hint="Install vocagateway[engines] or use the Docker image",
120-
),
121-
schemas.DependencyStatus(
122-
name="MLX Audio",
123-
available=mlx_ready,
124-
path=PYTHON_PACKAGE_PATH if mlx_ready else None,
125-
install_hint=mlx_hint,
126-
),
127-
]
128-
129-
def _app_dependencies(self, is_mac: bool) -> list[schemas.DependencyStatus]:
130-
silicon = self.system.is_apple_silicon
131-
wk_hint = "brew install whisperkit-cli" if is_mac else "Available only on Apple platforms"
132-
vocamac_hint = (
133-
"https://github.com/jatinkrmalik/vocamac"
134-
if silicon
135-
else "Available only on Apple silicon Macs"
136-
)
137-
return [
138-
schemas.DependencyStatus(
139-
name="WhisperKit CLI",
140-
available=self.system.whisperkit_cli_path is not None,
141-
path=self.system.whisperkit_cli_path,
142-
install_hint=wk_hint,
143-
),
144-
schemas.DependencyStatus(
145-
name="Handy app",
146-
available=self.system.handy_installed,
147-
path=str(self.settings.handy_binary) if self.system.handy_installed else None,
148-
install_hint="https://handy.computer" if is_mac else "Available only on macOS",
149-
),
150-
schemas.DependencyStatus(
151-
name="VocaMac app",
152-
available=self.system.vocamac_installed,
153-
path=str(self.settings.vocamac_app) if self.system.vocamac_installed else None,
154-
install_hint=vocamac_hint,
155-
),
156-
]
157-
158226

159227
_ModelState = tuple[str, float | None, str | None]
160228

@@ -168,6 +236,7 @@ def __init__(self, ctx: GatewayContext) -> None:
168236
handy_binary=ctx.settings.handy_binary,
169237
vocamac_app=ctx.settings.vocamac_app,
170238
)
239+
self.runtimes = _EngineRuntimes(self.system, ctx.settings)
171240
self.recommended = recommended_ids(self.system)
172241
self.installed = {model.id: model for model in ctx.manager.installed()}
173242
self.active_path = active_model_path(ctx)
@@ -234,6 +303,7 @@ def build_entry(self, model: Any) -> schemas.AdminModelEntry:
234303
retired=model.retired,
235304
replacement_id=model.replacement_id,
236305
retirement_reason=model.retirement_reason,
306+
**self.runtimes.entry_fields(model.engine),
237307
)
238308

239309
def build_custom_entry(self, custom: Any) -> schemas.AdminModelEntry:
@@ -256,6 +326,7 @@ def build_custom_entry(self, custom: Any) -> schemas.AdminModelEntry:
256326
and self.ctx.engine_manager.model_is_offloaded
257327
),
258328
recommended=False,
329+
**self.runtimes.entry_fields(custom.engine),
259330
)
260331

261332
def filter_by_criteria(
@@ -327,7 +398,7 @@ async def status_payload(ctx: GatewayContext) -> schemas.AdminStatusResponse:
327398
),
328399
bind_host=ctx.settings.bind_host,
329400
port=ctx.settings.port,
330-
setup=helper.build_checklist(dependencies, state.ready),
401+
setup=helper.build_checklist(state.ready),
331402
metrics=metrics_status(metrics),
332403
readiness=schemas.ReadinessStatus(
333404
probe_age_seconds=round(readiness_details.checked_age_seconds, 3),

0 commit comments

Comments
 (0)