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
|
||||
|
||||
|
|
|
|||
|
|
@ -210,14 +210,12 @@ async function initInputIntelligence(): Promise<void> {
|
|||
sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state)
|
||||
)
|
||||
|
||||
// 제안 수락/순환/페이지/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
|
||||
// 제안 수락/순환/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
|
||||
getKeyBindingService().on('triggered', (payload) => {
|
||||
if (payload.type !== 'pressed') return
|
||||
if (payload.actionId === 'suggestion-accept') void suggestion.accept()
|
||||
else if (payload.actionId === 'suggestion-next') suggestion.next()
|
||||
else if (payload.actionId === 'suggestion-prev') suggestion.previous()
|
||||
else if (payload.actionId === 'suggestion-page-next') suggestion.pageNext()
|
||||
else if (payload.actionId === 'suggestion-page-prev') suggestion.pagePrev()
|
||||
else if (payload.actionId === 'suggestion-dismiss') suggestion.dismiss('dismissed')
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ class CaptionService extends EventEmitter {
|
|||
private _systemTrack: StreamingCaptionTrack | null = null
|
||||
/** 'auto' 언어일 때 첫 확정 결과로 고정한다 — 중간 결과마다 언어가 흔들리지 않게 */
|
||||
private _sessionLanguage: string | null = null
|
||||
/** 자막 전용 모델 (null 이면 받아쓰기 모델) */
|
||||
private _captionModelId: string | null = null
|
||||
private _refineQueue: CaptionSegment[] = []
|
||||
private _refining = false
|
||||
|
||||
|
|
@ -126,6 +128,9 @@ class CaptionService extends EventEmitter {
|
|||
// STT 초기화 (모델 로딩 — 시간 소요)
|
||||
const sttService = getLocalSTTService()
|
||||
await sttService.initialize()
|
||||
// 자막 전용 모델을 골랐으면 받아쓰기 모델과 별도로 올려 둔다(보조 자리).
|
||||
this._captionModelId = configGet('captionSttModelId') ?? null
|
||||
if (this._captionModelId) await sttService.ensureAuxModel(this._captionModelId)
|
||||
|
||||
// 세션 초기화
|
||||
this._sessionId = crypto.randomUUID()
|
||||
|
|
@ -318,6 +323,7 @@ class CaptionService extends EventEmitter {
|
|||
initialPrompt: opts.initialPrompt,
|
||||
vadFilter: true,
|
||||
partial: opts.partial,
|
||||
modelId: this._captionModelId ?? undefined,
|
||||
})
|
||||
if (!opts.partial && configured === 'auto' && !this._sessionLanguage && result.text.trim() && result.language) {
|
||||
this._sessionLanguage = result.language
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, Key
|
|||
import {
|
||||
bindingsEqual,
|
||||
createDefaultBindingMap,
|
||||
detectBindingConflicts,
|
||||
findActionSpec,
|
||||
kb,
|
||||
normalizeBinding,
|
||||
|
|
@ -76,14 +75,12 @@ function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]):
|
|||
}
|
||||
|
||||
/**
|
||||
* 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다.
|
||||
* 다음 문장 제안 단축키를 1.6.0 배치로 옮긴다.
|
||||
*
|
||||
* - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다.
|
||||
* - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다).
|
||||
* - 수락은 화살표(→)에서 Enter 로, 닫기는 화살표(←)에서 Backspace 로 옮긴다
|
||||
* (평범한 Esc 가 주 수단이 됐다).
|
||||
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
|
||||
* 절대 건드리지 않는다(설계 요구사항).
|
||||
* - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른
|
||||
* 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다.
|
||||
* 절대 건드리지 않는다.
|
||||
*/
|
||||
function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void {
|
||||
const raw = activeStore.store as unknown as Record<string, unknown>
|
||||
|
|
@ -99,19 +96,6 @@ function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>)
|
|||
migrateIfOldDefault('suggestion-accept', OLD_SUGGESTION_ACCEPT_DEFAULT)
|
||||
migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_DEFAULT)
|
||||
|
||||
for (const actionId of ['suggestion-page-next', 'suggestion-page-prev'] as const) {
|
||||
const candidate = bindings[actionId] ?? []
|
||||
const hasConflict = candidate.some(
|
||||
(binding) => detectBindingConflicts(actionId, binding, bindings).length > 0
|
||||
)
|
||||
if (hasConflict) {
|
||||
logger.warn(
|
||||
`${actionId} 기본 바인딩이 다른 액션과 충돌해 바인딩 없이 둔다 (사용자가 설정에서 직접 지정할 수 있다)`
|
||||
)
|
||||
bindings[actionId] = []
|
||||
}
|
||||
}
|
||||
|
||||
activeStore.set('keyBindings', bindings)
|
||||
}
|
||||
|
||||
|
|
@ -187,6 +171,7 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
captionAudioSource: 'mic',
|
||||
captionOverlayPosition: null,
|
||||
captionRefineEnabled: true,
|
||||
captionSttModelId: null,
|
||||
updateChannel: 'latest',
|
||||
updateDeviceId: '',
|
||||
skippedUpdateVersion: null,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export interface TranscribeOptions {
|
|||
vadFilter?: boolean
|
||||
/** 음 중 실시간 미리보기 요청 — 상태/이벤트를 건드리지 않고 greedy 디코딩을 사용한다. */
|
||||
partial?: boolean
|
||||
/** 받아쓰기 모델 대신 쓸 모델 (ensureAuxModel 로 올려 둔 것) */
|
||||
modelId?: string
|
||||
/** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */
|
||||
retriedAfterLoad?: boolean
|
||||
}
|
||||
|
||||
/** sidecar /health 응답 */
|
||||
|
|
@ -204,6 +208,8 @@ class LocalSTTService extends EventEmitter {
|
|||
private _sidecarStarting: Promise<void> | null = null
|
||||
private _port: number = SIDECAR_PORT
|
||||
private _currentModelId: string | null = null
|
||||
/** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */
|
||||
private _auxModelId: string | null = null
|
||||
private _restartCount: number = 0
|
||||
private _disposed: boolean = false
|
||||
private _gpuAccelerated: boolean = false
|
||||
|
|
@ -547,6 +553,7 @@ class LocalSTTService extends EventEmitter {
|
|||
|
||||
this._audioBuffer = []
|
||||
this._modelReady = false
|
||||
this._auxModelId = null
|
||||
|
||||
await this._shutdownSidecar()
|
||||
this._setState(STTState.Uninitialized)
|
||||
|
|
@ -731,6 +738,7 @@ class LocalSTTService extends EventEmitter {
|
|||
this._sidecarProcess = null
|
||||
}
|
||||
this._modelReady = false
|
||||
this._auxModelId = null
|
||||
|
||||
if (!this._disposed) {
|
||||
this._handleSidecarCrash()
|
||||
|
|
@ -858,14 +866,25 @@ class LocalSTTService extends EventEmitter {
|
|||
)
|
||||
}
|
||||
|
||||
private async _loadModel(modelId: string): Promise<void> {
|
||||
logger.info(`모델 로딩 시작: ${modelId}`)
|
||||
/**
|
||||
* 받아쓰기(기본) 모델과 다른 모델을 보조 자리에 올린다 — 실시간 자막처럼 따로 고른 모델용.
|
||||
* 기본 모델과 같으면 아무것도 하지 않는다.
|
||||
*/
|
||||
async ensureAuxModel(modelId: string): Promise<void> {
|
||||
await this._ensureSidecarRunning()
|
||||
if (modelId === this._currentModelId || modelId === this._auxModelId) return
|
||||
await this._loadModel(modelId, 'aux')
|
||||
this._auxModelId = modelId
|
||||
}
|
||||
|
||||
private async _loadModel(modelId: string, slot: 'primary' | 'aux' = 'primary'): Promise<void> {
|
||||
logger.info(`모델 로딩 시작: ${modelId} (${slot})`)
|
||||
const startTime = Date.now()
|
||||
|
||||
const response = await fetch(`${this._baseUrl}/load`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model_id: modelId }),
|
||||
body: JSON.stringify({ model_id: modelId, slot }),
|
||||
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
|
|
@ -881,7 +900,7 @@ class LocalSTTService extends EventEmitter {
|
|||
const loadTimeMs = Date.now() - startTime
|
||||
|
||||
const model = MODEL_CATALOG.find((m) => m.id === modelId)
|
||||
if (model) {
|
||||
if (model && slot === 'primary') {
|
||||
this.emit('model-loaded', {
|
||||
model: { ...model, downloaded: true },
|
||||
loadTimeMs,
|
||||
|
|
@ -937,6 +956,10 @@ class LocalSTTService extends EventEmitter {
|
|||
if (initialPrompt) {
|
||||
formData.append('initial_prompt', initialPrompt)
|
||||
}
|
||||
const modelId = options?.modelId
|
||||
if (modelId && modelId !== this._currentModelId) {
|
||||
formData.append('model_id', modelId)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this._baseUrl}/transcribe`, {
|
||||
method: 'POST',
|
||||
|
|
@ -946,6 +969,13 @@ class LocalSTTService extends EventEmitter {
|
|||
),
|
||||
})
|
||||
|
||||
// 사이드카가 재시작되며 보조 모델을 잃었으면 다시 올리고 한 번만 재시도한다.
|
||||
if (response.status === 409 && modelId && !options?.retriedAfterLoad) {
|
||||
this._auxModelId = null
|
||||
await this.ensureAuxModel(modelId)
|
||||
return this._sendToSidecar(audioBuffer, { ...options, retriedAfterLoad: true })
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new D3ROError(
|
||||
|
|
@ -1042,6 +1072,7 @@ class LocalSTTService extends EventEmitter {
|
|||
const modelToReload = this._currentModelId
|
||||
this._currentModelId = null
|
||||
this._modelReady = false
|
||||
this._auxModelId = null
|
||||
|
||||
// setTimeout으로 이벤트 루프에 양보
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -74,10 +74,11 @@ const RUNTIME_VERSION_FILE = '.runtime-version'
|
|||
|
||||
/**
|
||||
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다.
|
||||
* 1.5.0 — UIA 브리지(`/uia/focus`)가 처음 들어간 사이드카. ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
|
||||
* 1.5.0 — UIA 브리지(`/uia/focus`). 1.7.0 — 보조 모델 자리(`/load` slot, `/transcribe` model_id).
|
||||
* ffmpeg 는 CLI 가 안정적이라 확인하지 않는다.
|
||||
*/
|
||||
const RUNTIME_MIN_VERSION: Record<RuntimeComponent, string | null> = {
|
||||
sidecar: '1.5.0',
|
||||
sidecar: '1.7.0',
|
||||
ffmpeg: null
|
||||
}
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000
|
||||
|
|
|
|||
|
|
@ -1000,6 +1000,7 @@ class SuggestionService extends EventEmitter {
|
|||
if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false
|
||||
|
||||
this._candidates.push({ text: candidateText, rank: this._candidates.length })
|
||||
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._record({
|
||||
|
|
@ -1176,26 +1177,17 @@ class SuggestionService extends EventEmitter {
|
|||
return this.getState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 후보로 — 페이지는 활성 후보를 따라 넘어간다.
|
||||
*
|
||||
* 마지막 후보에서 아직 더 만드는 중이면 그 자리에 머문다: 처음으로 돌아가 버리면
|
||||
* 곧 도착할 후보를 보려던 사용자가 길을 잃는다. 다 만들었으면 처음으로 돌아간다.
|
||||
*/
|
||||
next(): SuggestionState {
|
||||
if (this._candidates.length > 1) {
|
||||
this._moveActive((this._activeIndex + 1) % this._candidates.length)
|
||||
}
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/** 다음 페이지의 첫 항목으로 — 그 페이지에 후보가 없으면 그대로 둔다. */
|
||||
pageNext(): SuggestionState {
|
||||
const pageSize = SUGGESTION_DEFAULTS.pageSize
|
||||
const totalPages = Math.ceil(this._candidates.length / pageSize)
|
||||
const currentPage = Math.floor(this._activeIndex / pageSize)
|
||||
if (currentPage < totalPages - 1) this._moveActive((currentPage + 1) * pageSize)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
/** 이전 페이지의 첫 항목으로 — 이미 첫 페이지면 그대로 둔다. */
|
||||
pagePrev(): SuggestionState {
|
||||
const currentPage = Math.floor(this._activeIndex / SUGGESTION_DEFAULTS.pageSize)
|
||||
if (currentPage > 0) this._moveActive((currentPage - 1) * SUGGESTION_DEFAULTS.pageSize)
|
||||
const last = this._candidates.length - 1
|
||||
if (last < 1) return this.getState()
|
||||
if (this._activeIndex < last) this._moveActive(this._activeIndex + 1)
|
||||
else if (!this._filling) this._moveActive(0)
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,7 +84,6 @@ function getPopupI18nStrings(): Record<string, string> {
|
|||
suggestionAppPhrases: t('popup.suggestion.appPhrases'),
|
||||
// 키 힌트 줄 (동작 낱말 — 실제 키는 사용자 바인딩에서 매번 계산해 붙인다)
|
||||
suggestionHintMoveLabel: t('popup.suggestion.hintMove'),
|
||||
suggestionHintPageLabel: t('popup.suggestion.hintPage'),
|
||||
suggestionHintAcceptLabel: t('popup.suggestion.hintAccept'),
|
||||
suggestionHintCloseLabel: t('popup.suggestion.hintDismiss'),
|
||||
suggestionHintGeneratingMore: t('popup.suggestion.hintGeneratingMore'),
|
||||
|
|
@ -113,10 +112,9 @@ function sameModifiers(a: readonly string[], b: readonly string[]): boolean {
|
|||
}
|
||||
|
||||
export interface SuggestionKeyHints {
|
||||
/** 이동·페이지·수락이 모두 같은 수정자를 쓰면 그 수정자(예: "Ctrl+Alt") — 한 번만 보여준다 */
|
||||
/** 이동·수락이 모두 같은 수정자를 쓰면 그 수정자(예: "Ctrl+Alt") — 한 번만 보여준다 */
|
||||
shared: string | null
|
||||
move: string | null
|
||||
page: string | null
|
||||
accept: string | null
|
||||
close: string
|
||||
}
|
||||
|
|
@ -139,11 +137,9 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
|
|||
|
||||
const prev = splitOf('suggestion-prev')
|
||||
const next = splitOf('suggestion-next')
|
||||
const pagePrev = splitOf('suggestion-page-prev')
|
||||
const pageNext = splitOf('suggestion-page-next')
|
||||
const accept = splitOf('suggestion-accept')
|
||||
|
||||
const present = [prev, next, pagePrev, pageNext, accept].filter((b): b is SplitBinding => b !== null)
|
||||
const present = [prev, next, accept].filter((b): b is SplitBinding => b !== null)
|
||||
const common = present[0]?.modifiers ?? []
|
||||
const shareAll =
|
||||
present.length > 0 && common.length > 0 && present.every((b) => sameModifiers(b.modifiers, common))
|
||||
|
|
@ -159,7 +155,6 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
|
|||
return {
|
||||
shared: shareAll ? joinBindingSegments(common, platform) : null,
|
||||
move: pair(prev, next),
|
||||
page: pair(pagePrev, pageNext),
|
||||
accept: accept ? label(accept) : null,
|
||||
close: 'Esc'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ import type {
|
|||
} from '@d3ro/core/types'
|
||||
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
|
||||
|
||||
/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */
|
||||
const SAME_AS_DICTATION = '__same__'
|
||||
|
||||
interface STTTabProps {
|
||||
config: Partial<AppConfig>
|
||||
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
|
||||
|
|
@ -362,6 +365,30 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 실시간 자막 전용 모델 — 1초마다 다시 인식하므로 받아쓰기와 다른 모델이 나을 수 있다 */}
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel>{t('settings.captionModel')}</InputLabel>
|
||||
<Select
|
||||
label={t('settings.captionModel')}
|
||||
value={config.captionSttModelId ?? SAME_AS_DICTATION}
|
||||
onChange={(e) =>
|
||||
updateConfig('captionSttModelId', e.target.value === SAME_AS_DICTATION ? null : e.target.value)
|
||||
}
|
||||
>
|
||||
<MenuItem value={SAME_AS_DICTATION}>{t('settings.captionModel.same')}</MenuItem>
|
||||
{localModels
|
||||
.filter((m) => m.downloaded)
|
||||
.map((m) => (
|
||||
<MenuItem key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<Typography sx={{ mt: 0.5, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
|
||||
{t('settings.captionModel.desc')}
|
||||
</Typography>
|
||||
</FormControl>
|
||||
|
||||
{/* 모델 다운로드 진행 바 또는 다운로드 버튼 */}
|
||||
{(() => {
|
||||
const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@
|
|||
keyHintsContainer.textContent = ''
|
||||
if (!keyHints) return
|
||||
|
||||
// 이동·페이지·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +").
|
||||
// 이동·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +").
|
||||
if (keyHints.shared) {
|
||||
var shared = document.createElement('span')
|
||||
shared.className = 'key-hint shared'
|
||||
|
|
@ -154,7 +154,6 @@
|
|||
|
||||
var entries = [
|
||||
[keyHints.move, i18nStrings.suggestionHintMoveLabel, ''],
|
||||
[keyHints.page, i18nStrings.suggestionHintPageLabel, ''],
|
||||
[keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''],
|
||||
[keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint']
|
||||
]
|
||||
|
|
|
|||
|
|
@ -114,13 +114,6 @@ describe('ConfigService suggestion tuning migration', () => {
|
|||
// 옮기지 않는 액션은 그대로 남는다.
|
||||
expect(bindings['suggestion-next']).toEqual(OLD_NEXT)
|
||||
expect(bindings['suggestion-prev']).toEqual(OLD_PREV)
|
||||
// accept/dismiss 가 비켜난 자리를 새 페이지 이동 액션이 충돌 없이 채운다.
|
||||
expect(bindings['suggestion-page-next']).toEqual([
|
||||
{ device: 'keyboard', code: 0x27, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(bindings['suggestion-page-prev']).toEqual([
|
||||
{ device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(configGet('suggestionTuningRevision')).toBe(5)
|
||||
|
||||
resetInMemoryConfig()
|
||||
|
|
@ -151,31 +144,4 @@ describe('ConfigService suggestion tuning migration', () => {
|
|||
resetInMemoryConfig()
|
||||
})
|
||||
|
||||
it('revision 5: 새 페이지 이동 기본값이 다른 액션과 충돌하면 바인딩 없이 둔다', async () => {
|
||||
// suggestion-next 를 페이지-다음의 새 기본값(Ctrl+Alt+→)과 똑같이 커스터마이즈해 충돌을 만든다.
|
||||
const CONFLICTING_NEXT = [{ device: 'keyboard' as const, code: 0x27, ctrl: true, alt: true, shift: false, meta: false }]
|
||||
|
||||
vi.doMock('electron-store', () => ({
|
||||
default: makeTestStore({
|
||||
suggestionTuningRevision: 4,
|
||||
keyBindings: {
|
||||
'suggestion-next': CONFLICTING_NEXT
|
||||
}
|
||||
})
|
||||
}))
|
||||
const { configGet, initConfigService, resetInMemoryConfig } = await import(
|
||||
'../../../src/main/services/ConfigService'
|
||||
)
|
||||
await initConfigService()
|
||||
|
||||
const bindings = configGet('keyBindings')
|
||||
expect(bindings['suggestion-page-next']).toEqual([])
|
||||
// 충돌이 없는 page-prev 는 정상적으로 기본값을 받는다.
|
||||
expect(bindings['suggestion-page-prev']).toEqual([
|
||||
{ device: 'keyboard', code: 0x25, ctrl: true, alt: true, shift: false, meta: false }
|
||||
])
|
||||
expect(bindings['suggestion-next']).toEqual(CONFLICTING_NEXT)
|
||||
|
||||
resetInMemoryConfig()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -346,21 +346,25 @@ describe('SuggestionService warm-up', () => {
|
|||
expect(internal._lastSkipReason).not.toBe('rate-limited')
|
||||
})
|
||||
|
||||
it('pageNext는 다음 페이지 첫 항목으로, 후보가 없는 페이지면 그대로 둔다', async () => {
|
||||
it('마지막 후보에서 아직 더 만드는 중이면 next 는 제자리, 다 만들었으면 처음으로 돌아간다', async () => {
|
||||
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
|
||||
const service = getSuggestionService()
|
||||
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> }
|
||||
internal._candidates = Array.from({ length: 5 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
|
||||
const internal = service as unknown as {
|
||||
_candidates: Array<{ text: string; rank: number }>
|
||||
_filling: boolean
|
||||
}
|
||||
internal._candidates = Array.from({ length: 4 }, (_v, i) => ({ text: `후보${i}`, rank: i }))
|
||||
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
service.pageNext()
|
||||
for (let i = 0; i < 3; i += 1) service.next()
|
||||
// 3번째(0-based 3)에서 ↓ — 4번째 칸이 곧 페이지 2 로 넘어간 상태다.
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
service.pageNext()
|
||||
// 5개뿐이라 다음 페이지가 없다 — 그대로 둔다.
|
||||
|
||||
internal._filling = true
|
||||
service.next()
|
||||
expect(service.getState().activeIndex).toBe(3)
|
||||
service.pagePrev()
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
service.pagePrev()
|
||||
|
||||
internal._filling = false
|
||||
service.next()
|
||||
expect(service.getState().activeIndex).toBe(0)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue