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:
parent
39b8e7448e
commit
4b0f685941
29 changed files with 222 additions and 234 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue