feat(caption): let live captions use their own speech model

The speech engine now keeps an auxiliary model next to the dictation model
and transcribes with whichever the request names, reloading it once if the
engine restarted. Settings > STT gains a live-caption model so captions can
run on large-v3-turbo while dictation keeps its own model. The runtime
minimum rises to 1.7.0 because older engines would silently ignore the
model choice.

Suggestion paging moves to Up/Down: the page follows the selection and the
last item waits while more candidates are being generated. The Left/Right
page shortcuts are removed; they did nothing until a page had filled and
clash with Intel's display-rotation hotkeys.
This commit is contained in:
Yun Chan 2026-09-24 21:46:54 +09:00
parent 39b8e7448e
commit 4b0f685941
29 changed files with 222 additions and 234 deletions

View file

@ -61,6 +61,8 @@ logger = logging.getLogger("sidecar")
_model: "WhisperModel | None" = None
_model_id: str | None = None
# 보조 모델 (실시간 자막 등 받아쓰기와 다른 모델). 최대 1개.
_aux_models: "dict[str, WhisperModel]" = {}
_gpu_available: bool = False
_server: uvicorn.Server | None = None
_models_dir: Path | None = None
@ -322,12 +324,33 @@ async def health() -> JSONResponse:
"status": "ready" if _model is not None else "no_model",
"model": _model_id,
"model_loaded": _model is not None,
"aux_models": list(_aux_models.keys()),
"gpu": _gpu_available,
"device": "cuda" if _gpu_available else "cpu",
}
)
def _create_model(model_id: str) -> "WhisperModel":
"""모델을 올린다. /download 로 받아 둔 로컬 디렉토리가 있으면 그것을 쓴다."""
from faster_whisper import WhisperModel
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
local_dir = _local_model_dir(model_id)
model_source = str(local_dir) if local_dir else model_id
if local_dir:
logger.info("로컬 모델 디렉토리 사용: %s", local_dir)
logger.info("모델 생성: %s (device=%s, compute=%s)", model_id, device, compute_type)
return WhisperModel(
model_source,
device=device,
compute_type=compute_type,
cpu_threads=_cpu_threads(),
num_workers=1,
)
@app.post("/load")
async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
"""Whisper 모델을 로딩한다.
@ -341,12 +364,14 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
global _model, _model_id
model_id: str = body.get("model_id", "large-v3-turbo")
logger.info("모델 로딩 시작: %s", model_id)
# primary = 받아쓰기(기본) 모델, aux = 실시간 자막처럼 따로 고른 보조 모델.
slot: str = body.get("slot", "primary")
logger.info("모델 로딩 시작: %s (slot=%s)", model_id, slot)
start_time = time.monotonic()
# 같은 모델이 이미 로딩되어 있으면 재사용 (재로딩은 수초 지연을 만든다)
if _model is not None and _model_id == model_id:
if (_model is not None and _model_id == model_id) or (slot == "aux" and model_id in _aux_models):
logger.info("이미 로딩된 모델 재사용: %s", model_id)
return JSONResponse(
content={
@ -358,38 +383,20 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
)
try:
from faster_whisper import WhisperModel
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
# /download로 미리 받아둔 로컬 디렉토리가 있으면 우선 사용.
# 없으면 faster-whisper의 HF 자동 다운로드 경로로 폴백.
local_dir = _local_model_dir(model_id)
model_source = str(local_dir) if local_dir else model_id
if local_dir:
logger.info("로컬 모델 디렉토리 사용: %s", local_dir)
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
_model = None
_model = WhisperModel(
model_source,
device=device,
compute_type=compute_type,
cpu_threads=_cpu_threads(),
num_workers=1,
)
_model_id = model_id
if slot == "aux":
# 보조 자리는 하나만 둔다 — 다른 보조 모델은 내려 VRAM 을 돌려받는다.
_aux_models.clear()
_aux_models[model_id] = _create_model(model_id)
else:
# 모델 교체 시 이전 모델을 먼저 해제해 VRAM/RAM을 회수한다.
_model = None
_model = _create_model(model_id)
_model_id = model_id
# 기본 모델이 된 모델은 보조 자리에 중복으로 들고 있지 않는다.
_aux_models.pop(model_id, None)
load_time_ms = int((time.monotonic() - start_time) * 1000)
logger.info(
"모델 로딩 완료: %s (device=%s, compute=%s, %dms)",
model_id,
device,
compute_type,
load_time_ms,
)
logger.info("모델 로딩 완료: %s (slot=%s, %dms)", model_id, slot, load_time_ms)
return JSONResponse(
content={
@ -414,6 +421,7 @@ async def transcribe(
vad_filter: str = Form("true"),
initial_prompt: str = Form(""),
partial: str = Form("false"),
model_id: str = Form(""),
) -> JSONResponse:
"""오디오 파일을 전사한다.
@ -423,8 +431,18 @@ async def transcribe(
vad_filter - VAD 필터 활성화 ('true' / 'false')
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
partial - 녹음 중 미리보기 모드 ('true'면 greedy 디코딩 + 컨텍스트 미사용)
model_id - 쓸 모델 (비우면 기본 모델). 올라가 있지 않으면 409
"""
if _model is None:
if model_id and model_id != _model_id:
model = _aux_models.get(model_id)
if model is None:
return JSONResponse(
status_code=409,
content={"status": "error", "code": "model_not_loaded", "message": f"모델이 로딩되지 않았습니다: {model_id}"},
)
else:
model = _model
if model is None:
return JSONResponse(
status_code=503,
content={"status": "error", "message": "모델이 로딩되지 않았습니다"},
@ -466,12 +484,12 @@ async def transcribe(
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
try:
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
except ValueError as ve:
if "empty sequence" in str(ve) and transcribe_kwargs.get("vad_filter"):
logger.warning("VAD가 전체 오디오를 제거함 → VAD 없이 재시도")
transcribe_kwargs["vad_filter"] = False
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
segments_iter, info = model.transcribe(audio_array, **transcribe_kwargs)
else:
raise