diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3e5e5..3d18d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Captions are polished in context.** Each finished line is corrected by the local model using the lines before it (spacing, punctuation, misheard words); rewrites that change too much are ignored. It can be turned off in Settings. +- **Live captions can use their own speech model.** Settings > STT now has a separate + live-caption model, loaded next to the dictation model, so captions can run on a + faster or more accurate model without changing dictation. The local speech engine + is updated for this and is downloaded again once. + +### Changed +- **Suggestions page with Up/Down only.** Moving past the third candidate turns the + page by itself, and at the last candidate the selection waits while more are still + being generated. The separate Ctrl+Alt+Left/Right page shortcuts are gone; they also + collided with the display-rotation shortcut of Intel graphics drivers. ### Planned - macOS / Linux support diff --git a/apps/desktop/sidecar/main.py b/apps/desktop/sidecar/main.py index 9bfe944..5d3d5fc 100644 --- a/apps/desktop/sidecar/main.py +++ b/apps/desktop/sidecar/main.py @@ -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 diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index a5b9643..98f0265 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -210,14 +210,12 @@ async function initInputIntelligence(): Promise { 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') }) diff --git a/apps/desktop/src/main/services/CaptionService.ts b/apps/desktop/src/main/services/CaptionService.ts index e1169b3..6e36740 100644 --- a/apps/desktop/src/main/services/CaptionService.ts +++ b/apps/desktop/src/main/services/CaptionService.ts @@ -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 diff --git a/apps/desktop/src/main/services/ConfigService.ts b/apps/desktop/src/main/services/ConfigService.ts index 4180f52..58de637 100644 --- a/apps/desktop/src/main/services/ConfigService.ts +++ b/apps/desktop/src/main/services/ConfigService.ts @@ -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): void { const raw = activeStore.store as unknown as Record @@ -99,19 +96,6 @@ function migrateSuggestionOverlayBindings(activeStore: ElectronStore) 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, diff --git a/apps/desktop/src/main/services/LocalSTTService.ts b/apps/desktop/src/main/services/LocalSTTService.ts index 0d8abeb..9e338ec 100644 --- a/apps/desktop/src/main/services/LocalSTTService.ts +++ b/apps/desktop/src/main/services/LocalSTTService.ts @@ -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 | 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 { - logger.info(`모델 로딩 시작: ${modelId}`) + /** + * 받아쓰기(기본) 모델과 다른 모델을 보조 자리에 올린다 — 실시간 자막처럼 따로 고른 모델용. + * 기본 모델과 같으면 아무것도 하지 않는다. + */ + async ensureAuxModel(modelId: string): Promise { + 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 { + 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(() => { diff --git a/apps/desktop/src/main/services/RuntimeProvisioner.ts b/apps/desktop/src/main/services/RuntimeProvisioner.ts index 2982fe7..f63912d 100644 --- a/apps/desktop/src/main/services/RuntimeProvisioner.ts +++ b/apps/desktop/src/main/services/RuntimeProvisioner.ts @@ -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 = { - sidecar: '1.5.0', + sidecar: '1.7.0', ffmpeg: null } const DOWNLOAD_TIMEOUT_MS = 120_000 diff --git a/apps/desktop/src/main/services/SuggestionService.ts b/apps/desktop/src/main/services/SuggestionService.ts index 349bbd4..29e1a1e 100644 --- a/apps/desktop/src/main/services/SuggestionService.ts +++ b/apps/desktop/src/main/services/SuggestionService.ts @@ -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() } diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 0fd397c..43d7620 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -84,7 +84,6 @@ function getPopupI18nStrings(): Record { 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' } diff --git a/apps/desktop/src/renderer/components/STTTab.tsx b/apps/desktop/src/renderer/components/STTTab.tsx index 3c2f53e..5e038f7 100644 --- a/apps/desktop/src/renderer/components/STTTab.tsx +++ b/apps/desktop/src/renderer/components/STTTab.tsx @@ -51,6 +51,9 @@ import type { } from '@d3ro/core/types' import { CodexOAuthGuideModal } from './CodexOAuthGuideModal' +/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */ +const SAME_AS_DICTATION = '__same__' + interface STTTabProps { config: Partial updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void @@ -362,6 +365,30 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen + {/* 실시간 자막 전용 모델 — 1초마다 다시 인식하므로 받아쓰기와 다른 모델이 나을 수 있다 */} + + {t('settings.captionModel')} + + + {t('settings.captionModel.desc')} + + + {/* 모델 다운로드 진행 바 또는 다운로드 버튼 */} {(() => { const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo')) diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js index d490a2b..f1d80f8 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js @@ -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'] ] diff --git a/apps/desktop/tests/main/services/ConfigService.test.ts b/apps/desktop/tests/main/services/ConfigService.test.ts index db001b3..03e839e 100644 --- a/apps/desktop/tests/main/services/ConfigService.test.ts +++ b/apps/desktop/tests/main/services/ConfigService.test.ts @@ -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() - }) }) diff --git a/apps/desktop/tests/main/services/SuggestionService.test.ts b/apps/desktop/tests/main/services/SuggestionService.test.ts index 7bbd6b2..cc8c03c 100644 --- a/apps/desktop/tests/main/services/SuggestionService.test.ts +++ b/apps/desktop/tests/main/services/SuggestionService.test.ts @@ -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) }) diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index 994a9e0..bfc2921 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -26,7 +26,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | CAP-10 | STT model download/management UI | [x] | [-] | [~] | [-] | Desktop model manager + onboarding; mobile bundled model | | CAP-11 | File transcription (audio/video) | [x] | [ ] | [x] | [~] | Desktop ffmpeg chunking; mobile import picker; web deferred | | CAP-12 | Audio import from other apps (share intent) | [-] | [-] | [x] | [-] | Mobile Android `ACTION_SEND`/`ACTION_VIEW` (SSOT R-016 GREEN) | -| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 **2026-09-24 (unreleased, 1.7.0 candidate):** fixed 6 s batches replaced by streaming (`StreamingCaptionTrack` + core `caption-streaming.ts`): the uncommitted buffer is re-recognised every 1 s (greedy `partial`) and sent as `caption:delta` {text, stable} where `stable` is the LocalAgreement-2 prefix; 0.7 s of silence finalises the line with a full pass; unbroken audio over 12 s commits segments ending before the last 1.5 s using Whisper segment times; idle audio is trimmed to 1.5 s. Captions always use the local engine; `auto` language is pinned after the first final. Finished lines are refined by the local LLM (`buildCaptionRefinePrompt`, `acceptCaptionRefinement` rejects >35 % change) and replaced via `caption:segmentUpdated`; toggle `captionRefineEnabled` (Settings). Overlay: draggable handle with remembered position, waiting notice until the first caption. | +| CAP-13 | Live captions overlay | [x] | [-] | [-] | [-] | Desktop `CaptionService` + caption-overlay popup; popup assets verified in the packaged build (`scripts/ci/verify-desktop-renderer-bundles.mjs`); GAP-INFRA-05 **2026-09-24 (unreleased, 1.7.0 candidate):** fixed 6 s batches replaced by streaming (`StreamingCaptionTrack` + core `caption-streaming.ts`): the uncommitted buffer is re-recognised every 1 s (greedy `partial`) and sent as `caption:delta` {text, stable} where `stable` is the LocalAgreement-2 prefix; 0.7 s of silence finalises the line with a full pass; unbroken audio over 12 s commits segments ending before the last 1.5 s using Whisper segment times; idle audio is trimmed to 1.5 s. Captions always use the local engine; `auto` language is pinned after the first final. Finished lines are refined by the local LLM (`buildCaptionRefinePrompt`, `acceptCaptionRefinement` rejects >35 % change) and replaced via `caption:segmentUpdated`; toggle `captionRefineEnabled` (Settings). Overlay: draggable handle with remembered position, waiting notice until the first caption. Separate caption model `captionSttModelId` (null = dictation model): the sidecar keeps one aux model next to the primary (`/load` `slot:"aux"`, `/transcribe` `model_id`, 409 `model_not_loaded` → reload + one retry); runtime minimum raised to 1.7.0. | | CAP-14 | Recording persistence / crash recovery | [x] | [ ] | [x] | [-] | Desktop WAV persist; mobile durable queue + process-kill WAV recovery | | CAP-15 | Android foreground recording service | [-] | [-] | [x] | [-] | Mobile API 34 FGS + persistent notification (SSOT R-005 GREEN) | | CAP-16 | Rebindable global key bindings (keyboard + mouse) | [x] | [-] | [-] | [-] | Contract SSOT `packages/core/src/keybinding.ts`: `KEY_CATALOG` (10 groups, `:615`), `KEYBINDING_ACTIONS` (6 actions, `:719`), `validateBinding` (`:953`), `detectBindingConflicts` (`:1016`). Multiple bindings per action persist as one `AppConfig.keyBindings` map (`packages/core/src/types.ts:459`), replacing the four singular `*Shortcut` fields; `ConfigService` migrates legacy values once (`ConfigService.ts:142`). `KeyBindingService` hooks keyboard **and** mouse via uiohook (`KeyBindingService.ts:387`) — MB1 is not bindable, MB2/MB3 need a modifier, MB4/MB5 are free, and no mouse button can be suppressed, so the original click still fires (warning surfaced in the UI). Selection is either key-recording or a searchable grouped dropdown (`KeyBindingPicker.tsx:536`). `history-popup`/`command-popup` were hardcoded in `bootstrap.ts` and are now rebindable actions (`bootstrap.ts:159`). **Verified 2026-09-21 on Windows by a manual run** (`%APPDATA%/d3ro-voice/logs/main.log`, 12:53–13:06): `ConfigService` migrated the four legacy shortcuts with the user's non-default values preserved exactly, `KeyBindingService` loaded 6 bindings for 6 actions and started the uiohook keyboard **and** mouse hook with zero boot errors, and keyboard plus mouse (MB4/MB5) bindings were exercised through the UI. A `Loaded 7 key binding(s) … for 6 action(s)` line later in the same session shows multi-binding working end to end. The migrated map was read back from `d3ro-voice-config.json`: legacy `*Shortcut` fields gone, no `displayLabel` left. Contract evidence: `packages/core` 117 tests GREEN, no renderer type errors in the key-binding files. **Still open:** `KeyBindingService` has no unit test of its own, macOS/Linux mouse behavior is unconfirmed (`11` GAP-KEY-02), and `command` still falls back to the dictation pipeline (GAP-KEY-03). W/M `[-]`: no OS-level global binding surface exists there (browser sandbox; mobile has no global hotkey, see CAP-01). B `[-]`: device-local setting, nothing server-side. See `11` GAP-KEY-02/03 (open), GAP-KEY-01 (`[x]`), and `11` §7 CONSTRAINT-I18N-01. | @@ -170,7 +170,7 @@ end-to-end behaviour has **not been verified by typing in a real app** (`11` GAP | INPUT-04 | Typed-text learning (UIA, password-excluded) | [~] | [-] | [-] | [-] | Text is read from the focused field via `GET /uia/focus` (sidecar UIA bridge) and diffed longest-common-prefix/suffix, so **IME-committed Hangul/kana is counted correctly** — keycodes cannot reconstruct CJK text. UIA sends `hasSelection` only, derived by TextPattern range Start/End comparison without calling `GetText` on the selection range or adding a selected-text payload; the existing focused-field text can still include a selection. A non-collapsed selection immediately clears suggestions as `selection-active`. `IsPassword` is checked before any read (fail-closed); IME composition suppresses both stats and suggestions. **2026-09-24:** text equal to the control name (empty-field placeholder, e.g. "메시지 입력") is treated as empty (`withoutPlaceholderText`); a suggestion requires `editedSinceFocus` + typing within `recentTypingWindowMs` (8 s), otherwise `not-typing` — clicking into a pre-filled field no longer triggers suggestions. | | INPUT-05 | Personal phrase corpus (typed + voice) | [~] | [-] | [-] | [-] | Sentence-level phrases from typed text and from voice history (`HistoryService.create` feeds `recordExternalText`), ranked by frequency/recency as prompt hints; users can delete individual phrases. **2026-09-24:** typed text from `LEARNING_EXCLUDED_APPS` (terminals, code editors, Agent Switchboard) is never learned, and every phrase must pass `isLearnablePhrase` (no box/block/geometric glyphs, ≥60 % letters). `_pruneUnlearnableCorpus` re-applies both rules to existing phrases/samples/edges on start and every retention cycle (first run on the author machine removed 69 phrases / 91 samples of terminal status lines and agent chats). | | INPUT-06 | Next-sentence suggestion (ghost text) | [~] | [-] | [-] | [-] | `SuggestionService` + `buildSuggestionPrompt` (`llm-prompts.ts` SSOT, instruction stays in the system prompt). The 2026-09-22 guard contract is 600 ms debounce, ≥5 s between requests, 6 requests/min by default (hard-config maximum 12), 3 candidates, 64 output tokens, 12-character growth before regeneration, 8 s request timeout and `keep_alive: 2m`; boot warmup is removed. Each request has its own cancellation signal. Presentation-active includes candidates, `generating`, `warmingUp` and `partialText`; clear/dismiss aborts, invalidates the generation token, clears TTL state and emits `cleared`/hide, and a final success resets `generating=false`/`partialText=null`. Focused evidence for the lifecycle and Windows-child-process change: five test files / 80 tests passed; desktop typecheck/lint, Python `py_compile`, and `git diff --check` exited 0 (core 131-test pass was independently verified earlier). Status remains `[~]`: this is not app-restart, GUI overlay, or real automatic-typing evidence. **2026-09-24 (supersedes the numbers above):** `keep_alive: 10m`, warm-up on boot, on Ollama reconnect and whenever the model is presumed cold (cold reload measured 11.4 s > 8 s timeout); a session asks for 1 candidate, then a sequential fill loop appends one unique candidate at a time up to `maxCandidatesTotal` 12 (avoid-list in the prompt, stops after 2 empty fills); only the first request consumes rate/daily budget; stale aborts refund the budget; any further typing ends the session (`matchesSessionPrefix`, IME last-syllable tolerant); prompt forbids assistant-style questions. Terminals (`TERMINAL_APPS`) never get suggestions. | -| INPUT-07 | Caret-anchored suggestion overlay | [~] | [-] | [-] | [-] | `suggestion-overlay` popup placed by `anchorFloatingPanel` (caret → element → cursor fallback, flip above when the caret is near the bottom, clamped to the work area). Non-focusable; click-through unless `suggestionOverlayInteractive`. While actually visible it continues periodic UIA validation after 5 s and revalidates 120 ms after mouse-up; unavailable/non-editable focus or a non-collapsed selection aborts and hides it. X first hides the renderer panel, then main IPC directly hides `BrowserWindow` and dismisses the service, so late tokened results cannot revive it. Accept/next/prev/dismiss are four global key bindings (`suggestion-accept`/`next`/`prev`/`dismiss`, default `Ctrl+Alt+→/↓/↑/←`), and the overlay has a mouse close button. Up to three candidates are shown in a scrollable list with a warm-up/generating spinner. **2026-09-24:** one page of 3 fixed-height (2-line) numbered items, footer = range/progress + source + key guide derived from the live bindings (`buildSuggestionKeyHints`, shared modifier shown once). Keys: Ctrl+Alt+↑/↓ move, Ctrl+Alt+←/→ page (`suggestion-page-next/prev`), Ctrl+Alt+Enter accept, plain Esc closes while visible (overlay-scoped, not a binding), Ctrl+Alt+Backspace secondary dismiss; old default bindings migrate at tuning revision 5. Streaming updates never reposition/re-present the window (`decideSuggestionOverlayAction`); placement uses `anchorKind` — caret: below the line, element: outside the element (below → above → right → left → inner bottom-right). Navigation re-arms the visible TTL. Window 460×208. | +| INPUT-07 | Caret-anchored suggestion overlay | [~] | [-] | [-] | [-] | `suggestion-overlay` popup placed by `anchorFloatingPanel` (caret → element → cursor fallback, flip above when the caret is near the bottom, clamped to the work area). Non-focusable; click-through unless `suggestionOverlayInteractive`. While actually visible it continues periodic UIA validation after 5 s and revalidates 120 ms after mouse-up; unavailable/non-editable focus or a non-collapsed selection aborts and hides it. X first hides the renderer panel, then main IPC directly hides `BrowserWindow` and dismisses the service, so late tokened results cannot revive it. Accept/next/prev/dismiss are four global key bindings (`suggestion-accept`/`next`/`prev`/`dismiss`, default `Ctrl+Alt+→/↓/↑/←`), and the overlay has a mouse close button. Up to three candidates are shown in a scrollable list with a warm-up/generating spinner. **2026-09-24:** one page of 3 fixed-height (2-line) numbered items, footer = range/progress + source + key guide derived from the live bindings (`buildSuggestionKeyHints`, shared modifier shown once). Keys: Ctrl+Alt+↑/↓ move (the page follows the active item; at the last item `next` waits while filling, otherwise wraps; the 1.6.0 Left/Right page actions were removed as unreachable before a page filled and colliding with Intel display rotation), Ctrl+Alt+Enter accept, plain Esc closes while visible (overlay-scoped, not a binding), Ctrl+Alt+Backspace secondary dismiss; old default bindings migrate at tuning revision 5. Streaming updates never reposition/re-present the window (`decideSuggestionOverlayAction`); placement uses `anchorKind` — caret: below the line, element: outside the element (below → above → right → left → inner bottom-right). Navigation re-arms the visible TTL. Window 460×208. | | INPUT-08 | Per-app exclusions & consent controls | [~] | [-] | [-] | [-] | `inputExcludedApps` (executable names, case-insensitive) blocks both collection context and suggestions; telemetry master switch, pause, text-learning toggle and "delete collected data" all live in Settings → Input. 30-day retention prune runs on start. | | INPUT-09 | Flow Radar | [~] | [-] | [-] | [-] | `rankFlowWindows` ranks hourly aggregate activity density, character volume and edit stability into potential-focus time windows. It is not a real-session detector or session record. | | INPUT-10 | Edit Friction | [~] | [-] | [-] | [-] | `calculateFrictionInsight` derives friction from char/backspace quantities and reports edits per 100 chars; it does not infer sentiment or productivity. | diff --git a/packages/core/src/input-intelligence.ts b/packages/core/src/input-intelligence.ts index f2a99b7..a7e797e 100644 --- a/packages/core/src/input-intelligence.ts +++ b/packages/core/src/input-intelligence.ts @@ -1235,8 +1235,6 @@ export const SUGGESTION_DEFAULTS = { * 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다. */ maxCandidatesTotal: 12, - /** 오버레이 한 페이지에 보여줄 후보 수. */ - pageSize: 3, maxOutputTokens: 64, /** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */ regenerateAfterChars: 12, diff --git a/packages/core/src/keybinding.ts b/packages/core/src/keybinding.ts index d5c9660..94f5fbe 100644 --- a/packages/core/src/keybinding.ts +++ b/packages/core/src/keybinding.ts @@ -681,8 +681,6 @@ export type KeyBindingActionId = | 'suggestion-next' | 'suggestion-prev' | 'suggestion-dismiss' - | 'suggestion-page-next' - | 'suggestion-page-prev' /** 액션 그룹 (설정 화면 섹션) */ export type KeyBindingActionGroup = 'voice' | 'window' | 'input' @@ -786,8 +784,8 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze descriptionKey: 'keybinding.action.suggestionAccept.desc', holdMode: false, doublePress: false, - // Ctrl+Alt+화살표 계열: 아래/위로 후보 순환, 좌우로 페이지 이동(12개까지 - // 순차 생성 — 사용자 요청). 화살표를 페이지 이동에 내주기 위해 수락은 Enter로 옮겼다. + // Ctrl+Alt+↑/↓ 로 후보를 오가고(페이지는 따라 넘어간다), 수락은 Enter. + // 좌우 화살표는 쓰지 않는다 — Intel 그래픽 드라이버의 화면 회전 단축키와 겹친다. defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })] }, { @@ -808,24 +806,6 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze doublePress: false, defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })] }, - { - id: 'suggestion-page-next', - group: 'input', - labelKey: 'keybinding.action.suggestionPageNext', - descriptionKey: 'keybinding.action.suggestionPageNext.desc', - holdMode: false, - doublePress: false, - defaultBindings: [kb(VK.ArrowRight, { ctrl: true, alt: true })] - }, - { - id: 'suggestion-page-prev', - group: 'input', - labelKey: 'keybinding.action.suggestionPagePrev', - descriptionKey: 'keybinding.action.suggestionPagePrev.desc', - holdMode: false, - doublePress: false, - defaultBindings: [kb(VK.ArrowLeft, { ctrl: true, alt: true })] - }, { id: 'suggestion-dismiss', group: 'input', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 993842f..d881a13 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -499,6 +499,8 @@ export interface AppConfig { captionOverlayPosition: { x: number; y: number } | null /** 확정된 자막 줄을 로컬 LLM으로 문맥에 맞게 다듬는다 */ captionRefineEnabled: boolean + /** 실시간 자막 전용 로컬 Whisper 모델. null 이면 받아쓰기 모델(sttModelId)을 쓴다 */ + captionSttModelId: string | null /** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */ updateChannel: 'latest' | 'beta' | 'alpha' /** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */ diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index c2c0fba..ea615ea 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Weiter", "popup.suggestion.hintDismiss": "Schließen", "popup.suggestion.hintMove": "Bewegen", - "popup.suggestion.hintPage": "Seite", "popup.suggestion.loading": "Wird erzeugt…", "keybinding.ui.sectionInput": "Eingabevorschläge", "keybinding.action.suggestionAccept": "Vorschlag übernehmen", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Mehr wird erzeugt… (bis zu {{max}})", "keybinding.action.suggestionPrev": "Vorheriger Vorschlag", "keybinding.action.suggestionPrev.desc": "Zum vorherigen Kandidaten wechseln.", - "keybinding.action.suggestionPageNext": "Nächste Seite", - "keybinding.action.suggestionPageNext.desc": "Zeigt die nächste Seite der Vorschläge (bis zu 12 insgesamt).", - "keybinding.action.suggestionPagePrev": "Vorherige Seite", - "keybinding.action.suggestionPagePrev.desc": "Zeigt die vorherige Seite der Vorschläge.", "input.insights.tabs.graph": "Graph", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Satz-Knoten", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Hört zu… der erste Untertitel kann einige Sekunden dauern", "popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück", "settings.captionRefine": "Untertitel im Kontext glätten", - "settings.captionRefine.desc": "Die lokale KI korrigiert Leerzeichen, Satzzeichen und falsch verstandene Wörter anhand der umgebenden Zeilen." + "settings.captionRefine.desc": "Die lokale KI korrigiert Leerzeichen, Satzzeichen und falsch verstandene Wörter anhand der umgebenden Zeilen.", + "settings.captionModel": "Modell für Live-Untertitel", + "settings.captionModel.same": "Wie beim Diktieren", + "settings.captionModel.desc": "Live-Untertitel werden mit einem eigenen Modell erkannt, das zusätzlich zum Diktiermodell geladen wird." } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index c2dc21f..830c4b3 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -1757,7 +1757,6 @@ "popup.suggestion.hintNext": "Next", "popup.suggestion.hintDismiss": "Dismiss", "popup.suggestion.hintMove": "Move", - "popup.suggestion.hintPage": "Page", "popup.suggestion.loading": "Generating…", "keybinding.ui.sectionInput": "Input suggestions", "keybinding.action.suggestionAccept": "Accept suggestion", @@ -1836,10 +1835,6 @@ "popup.suggestion.hintGeneratingMore": "More coming… (up to {{max}})", "keybinding.action.suggestionPrev": "Previous suggestion", "keybinding.action.suggestionPrev.desc": "Move to the previous suggestion candidate.", - "keybinding.action.suggestionPageNext": "Next page", - "keybinding.action.suggestionPageNext.desc": "Show the next page of suggestions (up to 12 total).", - "keybinding.action.suggestionPagePrev": "Previous page", - "keybinding.action.suggestionPagePrev.desc": "Show the previous page of suggestions.", "input.insights.tabs.graph": "Graph", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Sentence nodes", @@ -1905,5 +1900,8 @@ "popup.caption.waiting": "Listening… the first caption can take a few seconds", "popup.caption.dragHint": "Drag to move · double-click to reset", "settings.captionRefine": "Polish captions with context", - "settings.captionRefine.desc": "The local AI fixes spacing, punctuation and misheard words in finished captions using the surrounding lines." + "settings.captionRefine.desc": "The local AI fixes spacing, punctuation and misheard words in finished captions using the surrounding lines.", + "settings.captionModel": "Live caption model", + "settings.captionModel.same": "Same as dictation", + "settings.captionModel.desc": "Recognises live captions with a separate model, loaded alongside the dictation model." } diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 985db3d..b1211c9 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Siguiente", "popup.suggestion.hintDismiss": "Descartar", "popup.suggestion.hintMove": "Mover", - "popup.suggestion.hintPage": "Página", "popup.suggestion.loading": "Generando…", "keybinding.ui.sectionInput": "Sugerencias de entrada", "keybinding.action.suggestionAccept": "Aceptar sugerencia", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Generando más… (hasta {{max}})", "keybinding.action.suggestionPrev": "Sugerencia anterior", "keybinding.action.suggestionPrev.desc": "Pasa al candidato anterior.", - "keybinding.action.suggestionPageNext": "Página siguiente", - "keybinding.action.suggestionPageNext.desc": "Muestra la siguiente página de sugerencias (hasta 12 en total).", - "keybinding.action.suggestionPagePrev": "Página anterior", - "keybinding.action.suggestionPagePrev.desc": "Muestra la página anterior de sugerencias.", "input.insights.tabs.graph": "Grafo", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nodos de frase", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Escuchando… el primer subtítulo puede tardar unos segundos", "popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer", "settings.captionRefine": "Pulir subtítulos con contexto", - "settings.captionRefine.desc": "La IA local corrige espacios, puntuación y palabras mal oídas usando las líneas cercanas." + "settings.captionRefine.desc": "La IA local corrige espacios, puntuación y palabras mal oídas usando las líneas cercanas.", + "settings.captionModel": "Modelo de subtítulos en vivo", + "settings.captionModel.same": "Igual que el dictado", + "settings.captionModel.desc": "Reconoce los subtítulos en vivo con un modelo aparte, cargado junto al de dictado." } diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index fb0f936..61648f6 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Suivant", "popup.suggestion.hintDismiss": "Fermer", "popup.suggestion.hintMove": "Déplacer", - "popup.suggestion.hintPage": "Page", "popup.suggestion.loading": "Génération…", "keybinding.ui.sectionInput": "Suggestions de saisie", "keybinding.action.suggestionAccept": "Accepter la suggestion", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Génération en cours… (jusqu'à {{max}})", "keybinding.action.suggestionPrev": "Suggestion précédente", "keybinding.action.suggestionPrev.desc": "Passe au candidat précédent.", - "keybinding.action.suggestionPageNext": "Page suivante", - "keybinding.action.suggestionPageNext.desc": "Affiche la page suivante de suggestions (jusqu'à 12 au total).", - "keybinding.action.suggestionPagePrev": "Page précédente", - "keybinding.action.suggestionPagePrev.desc": "Affiche la page précédente de suggestions.", "input.insights.tabs.graph": "Graphe", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nœuds de phrase", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Écoute… le premier sous-titre peut prendre quelques secondes", "popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser", "settings.captionRefine": "Affiner les sous-titres selon le contexte", - "settings.captionRefine.desc": "L’IA locale corrige les espaces, la ponctuation et les mots mal entendus d’après les lignes voisines." + "settings.captionRefine.desc": "L’IA locale corrige les espaces, la ponctuation et les mots mal entendus d’après les lignes voisines.", + "settings.captionModel": "Modèle des sous-titres en direct", + "settings.captionModel.same": "Identique à la dictée", + "settings.captionModel.desc": "Les sous-titres en direct utilisent un modèle distinct, chargé en plus de celui de la dictée." } diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index 65da06e..5ace182 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "次へ", "popup.suggestion.hintDismiss": "閉じる", "popup.suggestion.hintMove": "移動", - "popup.suggestion.hintPage": "ページ", "popup.suggestion.loading": "生成中…", "keybinding.ui.sectionInput": "入力サジェスト", "keybinding.action.suggestionAccept": "サジェストを承認", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "さらに生成中…(最大{{max}}件)", "keybinding.action.suggestionPrev": "前の候補", "keybinding.action.suggestionPrev.desc": "前の候補に移動します。", - "keybinding.action.suggestionPageNext": "次のページ", - "keybinding.action.suggestionPageNext.desc": "次の提案ページを表示します(最大12件まで)。", - "keybinding.action.suggestionPagePrev": "前のページ", - "keybinding.action.suggestionPagePrev.desc": "前の提案ページに戻ります。", "input.insights.tabs.graph": "グラフ", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "文ノード", @@ -524,5 +519,8 @@ "popup.caption.waiting": "聞き取り中… 最初の字幕まで数秒かかることがあります", "popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置", "settings.captionRefine": "文脈で字幕を整える", - "settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。" + "settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。", + "settings.captionModel": "リアルタイム字幕モデル", + "settings.captionModel.same": "音声入力と同じ", + "settings.captionModel.desc": "リアルタイム字幕だけを別のモデルで認識します。音声入力モデルと同時に読み込まれます。" } diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index c473cb9..288b46d 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -1764,7 +1764,6 @@ "popup.suggestion.hintNext": "다음", "popup.suggestion.hintDismiss": "닫기", "popup.suggestion.hintMove": "이동", - "popup.suggestion.hintPage": "페이지", "popup.suggestion.loading": "생성 중…", "keybinding.ui.sectionInput": "입력 제안", "keybinding.action.suggestionAccept": "제안 수락", @@ -1843,10 +1842,6 @@ "popup.suggestion.hintGeneratingMore": "더 생성 중… (최대 {{max}}개)", "keybinding.action.suggestionPrev": "이전 제안", "keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.", - "keybinding.action.suggestionPageNext": "다음 페이지", - "keybinding.action.suggestionPageNext.desc": "다음 제안 페이지를 봅니다 (최대 12개까지).", - "keybinding.action.suggestionPagePrev": "이전 페이지", - "keybinding.action.suggestionPagePrev.desc": "이전 제안 페이지로 이동합니다.", "input.insights.tabs.graph": "그래프", "input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.", "input.graph.nodes": "문장 노드", @@ -1912,5 +1907,8 @@ "popup.caption.waiting": "듣는 중… 첫 자막까지 몇 초 걸릴 수 있어요", "popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치", "settings.captionRefine": "자막 문맥 다듬기", - "settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다." + "settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다.", + "settings.captionModel": "실시간 자막 모델", + "settings.captionModel.same": "받아쓰기와 같게", + "settings.captionModel.desc": "실시간 자막만 다른 모델로 인식합니다. 받아쓰기 모델과 함께 GPU 메모리에 올라갑니다." } diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index c825a19..ae967f2 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Próxima", "popup.suggestion.hintDismiss": "Descartar", "popup.suggestion.hintMove": "Mover", - "popup.suggestion.hintPage": "Página", "popup.suggestion.loading": "Gerando…", "keybinding.ui.sectionInput": "Sugestões de entrada", "keybinding.action.suggestionAccept": "Aceitar sugestão", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Gerando mais… (até {{max}})", "keybinding.action.suggestionPrev": "Sugestão anterior", "keybinding.action.suggestionPrev.desc": "Vai para o candidato anterior.", - "keybinding.action.suggestionPageNext": "Próxima página", - "keybinding.action.suggestionPageNext.desc": "Mostra a próxima página de sugestões (até 12 no total).", - "keybinding.action.suggestionPagePrev": "Página anterior", - "keybinding.action.suggestionPagePrev.desc": "Mostra a página anterior de sugestões.", "input.insights.tabs.graph": "Grafo", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nós de frase", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Ouvindo… a primeira legenda pode levar alguns segundos", "popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar", "settings.captionRefine": "Refinar legendas pelo contexto", - "settings.captionRefine.desc": "A IA local corrige espaços, pontuação e palavras mal ouvidas usando as linhas próximas." + "settings.captionRefine.desc": "A IA local corrige espaços, pontuação e palavras mal ouvidas usando as linhas próximas.", + "settings.captionModel": "Modelo de legendas ao vivo", + "settings.captionModel.same": "Igual ao ditado", + "settings.captionModel.desc": "Reconhece as legendas ao vivo com um modelo separado, carregado junto com o do ditado." } diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index be3d53a..6df5592 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Далее", "popup.suggestion.hintDismiss": "Закрыть", "popup.suggestion.hintMove": "Перемещение", - "popup.suggestion.hintPage": "Страница", "popup.suggestion.loading": "Генерация…", "keybinding.ui.sectionInput": "Подсказки ввода", "keybinding.action.suggestionAccept": "Принять подсказку", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Создаётся ещё… (до {{max}})", "keybinding.action.suggestionPrev": "Предыдущая подсказка", "keybinding.action.suggestionPrev.desc": "Перейти к предыдущему варианту.", - "keybinding.action.suggestionPageNext": "Следующая страница", - "keybinding.action.suggestionPageNext.desc": "Показывает следующую страницу подсказок (до 12 всего).", - "keybinding.action.suggestionPagePrev": "Предыдущая страница", - "keybinding.action.suggestionPagePrev.desc": "Показывает предыдущую страницу подсказок.", "input.insights.tabs.graph": "Граф", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Узлы-предложения", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Слушаю… первый субтитр может появиться через несколько секунд", "popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс", "settings.captionRefine": "Уточнять субтитры по контексту", - "settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам." + "settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам.", + "settings.captionModel": "Модель живых субтитров", + "settings.captionModel.same": "Как для диктовки", + "settings.captionModel.desc": "Живые субтитры распознаются отдельной моделью, которая загружается вместе с моделью диктовки." } diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index bd55485..0cad497 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "ถัดไป", "popup.suggestion.hintDismiss": "ปิด", "popup.suggestion.hintMove": "ย้าย", - "popup.suggestion.hintPage": "หน้า", "popup.suggestion.loading": "กำลังสร้าง…", "keybinding.ui.sectionInput": "คำแนะนำการป้อน", "keybinding.action.suggestionAccept": "ยอมรับคำแนะนำ", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "กำลังสร้างเพิ่ม… (สูงสุด {{max}})", "keybinding.action.suggestionPrev": "คำแนะนำก่อนหน้า", "keybinding.action.suggestionPrev.desc": "ย้ายไปยังตัวเลือกก่อนหน้า", - "keybinding.action.suggestionPageNext": "หน้าถัดไป", - "keybinding.action.suggestionPageNext.desc": "แสดงคำแนะนำหน้าถัดไป (สูงสุด 12 รายการ)", - "keybinding.action.suggestionPagePrev": "หน้าก่อนหน้า", - "keybinding.action.suggestionPagePrev.desc": "แสดงคำแนะนำหน้าก่อนหน้า", "input.insights.tabs.graph": "กราฟ", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "โหนดประโยค", @@ -524,5 +519,8 @@ "popup.caption.waiting": "กำลังฟัง… คำบรรยายแรกอาจใช้เวลาสักครู่", "popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต", "settings.captionRefine": "ขัดเกลาคำบรรยายตามบริบท", - "settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง" + "settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง", + "settings.captionModel": "โมเดลคำบรรยายสด", + "settings.captionModel.same": "เหมือนการพิมพ์ด้วยเสียง", + "settings.captionModel.desc": "ใช้โมเดลแยกสำหรับคำบรรยายสด โดยโหลดคู่กับโมเดลพิมพ์ด้วยเสียง" } diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index eb35a60..b993ca7 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "Tiếp", "popup.suggestion.hintDismiss": "Đóng", "popup.suggestion.hintMove": "Di chuyển", - "popup.suggestion.hintPage": "Trang", "popup.suggestion.loading": "Đang tạo…", "keybinding.ui.sectionInput": "Gợi ý nhập liệu", "keybinding.action.suggestionAccept": "Chấp nhận gợi ý", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "Đang tạo thêm… (tối đa {{max}})", "keybinding.action.suggestionPrev": "Gợi ý trước", "keybinding.action.suggestionPrev.desc": "Chuyển về ứng viên trước đó.", - "keybinding.action.suggestionPageNext": "Trang tiếp theo", - "keybinding.action.suggestionPageNext.desc": "Hiển thị trang gợi ý tiếp theo (tối đa 12 gợi ý).", - "keybinding.action.suggestionPagePrev": "Trang trước", - "keybinding.action.suggestionPagePrev.desc": "Hiển thị trang gợi ý trước đó.", "input.insights.tabs.graph": "Đồ thị", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "Nút câu", @@ -524,5 +519,8 @@ "popup.caption.waiting": "Đang nghe… phụ đề đầu tiên có thể mất vài giây", "popup.caption.dragHint": "Kéo để di chuyển · nhấp đúp để đặt lại", "settings.captionRefine": "Chỉnh phụ đề theo ngữ cảnh", - "settings.captionRefine.desc": "AI cục bộ sửa khoảng trắng, dấu câu và từ nghe nhầm dựa trên các dòng xung quanh." + "settings.captionRefine.desc": "AI cục bộ sửa khoảng trắng, dấu câu và từ nghe nhầm dựa trên các dòng xung quanh.", + "settings.captionModel": "Mô hình phụ đề trực tiếp", + "settings.captionModel.same": "Giống đọc chính tả", + "settings.captionModel.desc": "Phụ đề trực tiếp dùng mô hình riêng, được tải cùng mô hình đọc chính tả." } diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index b0e0668..446cb9f 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "下一個", "popup.suggestion.hintDismiss": "關閉", "popup.suggestion.hintMove": "移動", - "popup.suggestion.hintPage": "翻頁", "popup.suggestion.loading": "產生中…", "keybinding.ui.sectionInput": "輸入建議", "keybinding.action.suggestionAccept": "接受建議", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "正在產生更多…(最多 {{max}} 則)", "keybinding.action.suggestionPrev": "上一則建議", "keybinding.action.suggestionPrev.desc": "移動到上一則候選。", - "keybinding.action.suggestionPageNext": "下一頁", - "keybinding.action.suggestionPageNext.desc": "顯示下一頁建議(最多 12 則)。", - "keybinding.action.suggestionPagePrev": "上一頁", - "keybinding.action.suggestionPagePrev.desc": "顯示上一頁建議。", "input.insights.tabs.graph": "圖譜", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "句子節點", @@ -524,5 +519,8 @@ "popup.caption.waiting": "聆聽中… 第一則字幕可能需要幾秒鐘", "popup.caption.dragHint": "拖曳以移動 · 按兩下還原", "settings.captionRefine": "依上下文潤飾字幕", - "settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。" + "settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。", + "settings.captionModel": "即時字幕模型", + "settings.captionModel.same": "與聽寫相同", + "settings.captionModel.desc": "僅即時字幕使用獨立模型辨識,會與聽寫模型一同載入。" } diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index e2f2883..7acb49d 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -375,7 +375,6 @@ "popup.suggestion.hintNext": "下一个", "popup.suggestion.hintDismiss": "关闭", "popup.suggestion.hintMove": "移动", - "popup.suggestion.hintPage": "翻页", "popup.suggestion.loading": "生成中…", "keybinding.ui.sectionInput": "输入建议", "keybinding.action.suggestionAccept": "接受建议", @@ -454,10 +453,6 @@ "popup.suggestion.hintGeneratingMore": "正在生成更多…(最多 {{max}} 条)", "keybinding.action.suggestionPrev": "上一条建议", "keybinding.action.suggestionPrev.desc": "移动到上一条候选。", - "keybinding.action.suggestionPageNext": "下一页", - "keybinding.action.suggestionPageNext.desc": "显示下一页建议(最多 12 条)。", - "keybinding.action.suggestionPagePrev": "上一页", - "keybinding.action.suggestionPagePrev.desc": "显示上一页建议。", "input.insights.tabs.graph": "图谱", "input.graph.description": "Your sentences are stored as nodes and their relations as edges (what follows what, which share terms) so suggestions can pull personal context. Local only.", "input.graph.nodes": "句子节点", @@ -524,5 +519,8 @@ "popup.caption.waiting": "正在聆听… 第一条字幕可能需要几秒钟", "popup.caption.dragHint": "拖动以移动 · 双击复位", "settings.captionRefine": "按上下文润色字幕", - "settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。" + "settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。", + "settings.captionModel": "实时字幕模型", + "settings.captionModel.same": "与听写相同", + "settings.captionModel.desc": "仅实时字幕使用单独的模型识别,会与听写模型一同加载。" }