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

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

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

View file

@ -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 - **Captions are polished in context.** Each finished line is corrected by the local
model using the lines before it (spacing, punctuation, misheard words); rewrites model using the lines before it (spacing, punctuation, misheard words); rewrites
that change too much are ignored. It can be turned off in Settings. 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 ### Planned
- macOS / Linux support - macOS / Linux support

View file

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

View file

@ -210,14 +210,12 @@ async function initInputIntelligence(): Promise<void> {
sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state) sendToMainWindow(IPC_CHANNELS.SUGGESTION.STATE_CHANGED, state)
) )
// 제안 수락/순환/페이지/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다. // 제안 수락/순환/닫기는 전역 키바인딩으로만 들어온다 — 오버레이는 포커스를 갖지 않는다.
getKeyBindingService().on('triggered', (payload) => { getKeyBindingService().on('triggered', (payload) => {
if (payload.type !== 'pressed') return if (payload.type !== 'pressed') return
if (payload.actionId === 'suggestion-accept') void suggestion.accept() if (payload.actionId === 'suggestion-accept') void suggestion.accept()
else if (payload.actionId === 'suggestion-next') suggestion.next() else if (payload.actionId === 'suggestion-next') suggestion.next()
else if (payload.actionId === 'suggestion-prev') suggestion.previous() 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') else if (payload.actionId === 'suggestion-dismiss') suggestion.dismiss('dismissed')
}) })

View file

@ -75,6 +75,8 @@ class CaptionService extends EventEmitter {
private _systemTrack: StreamingCaptionTrack | null = null private _systemTrack: StreamingCaptionTrack | null = null
/** 'auto' 언어일 때 첫 확정 결과로 고정한다 — 중간 결과마다 언어가 흔들리지 않게 */ /** 'auto' 언어일 때 첫 확정 결과로 고정한다 — 중간 결과마다 언어가 흔들리지 않게 */
private _sessionLanguage: string | null = null private _sessionLanguage: string | null = null
/** 자막 전용 모델 (null 이면 받아쓰기 모델) */
private _captionModelId: string | null = null
private _refineQueue: CaptionSegment[] = [] private _refineQueue: CaptionSegment[] = []
private _refining = false private _refining = false
@ -126,6 +128,9 @@ class CaptionService extends EventEmitter {
// STT 초기화 (모델 로딩 — 시간 소요) // STT 초기화 (모델 로딩 — 시간 소요)
const sttService = getLocalSTTService() const sttService = getLocalSTTService()
await sttService.initialize() await sttService.initialize()
// 자막 전용 모델을 골랐으면 받아쓰기 모델과 별도로 올려 둔다(보조 자리).
this._captionModelId = configGet('captionSttModelId') ?? null
if (this._captionModelId) await sttService.ensureAuxModel(this._captionModelId)
// 세션 초기화 // 세션 초기화
this._sessionId = crypto.randomUUID() this._sessionId = crypto.randomUUID()
@ -318,6 +323,7 @@ class CaptionService extends EventEmitter {
initialPrompt: opts.initialPrompt, initialPrompt: opts.initialPrompt,
vadFilter: true, vadFilter: true,
partial: opts.partial, partial: opts.partial,
modelId: this._captionModelId ?? undefined,
}) })
if (!opts.partial && configured === 'auto' && !this._sessionLanguage && result.text.trim() && result.language) { if (!opts.partial && configured === 'auto' && !this._sessionLanguage && result.text.trim() && result.language) {
this._sessionLanguage = result.language this._sessionLanguage = result.language

View file

@ -6,7 +6,6 @@ import type { AppConfig, ConfigChangedEvent, KeyBinding, KeyBindingActionId, Key
import { import {
bindingsEqual, bindingsEqual,
createDefaultBindingMap, createDefaultBindingMap,
detectBindingConflicts,
findActionSpec, findActionSpec,
kb, kb,
normalizeBinding, normalizeBinding,
@ -76,14 +75,12 @@ function bindingListEquals(a: readonly KeyBinding[], b: readonly KeyBinding[]):
} }
/** /**
* 다음 문장 페이지 넘기기(최대 12개 순차 생성) 도입에 맞춰 제안 단축키를 옮긴다. * 다음 문장 제안 단축키를 1.6.0 배치로 옮긴다.
* *
* - 수락은 화살표(→)에서 Enter 로 옮긴다 — 화살표를 페이지 이동에 내주기 위해서다. * - 수락은 화살표(→)에서 Enter 로, 닫기는 화살표(←)에서 Backspace 로 옮긴다
* - 닫기는 화살표(←)에서 Backspace 로 옮긴다(평범한 Esc 가 주 수단이 됐다). * (평범한 Esc 가 주 수단이 됐다).
* - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면 * - 사용자가 정확히 옛 기본값 그대로일 때만 옮긴다. 다른 키로 커스터마이즈했다면
* 절대 건드리지 않는다(설계 요구사항). * 절대 건드리지 않는다.
* - 새 페이지 이동 액션(suggestion-page-next/prev)의 기본값은, 위 이관 뒤에도 다른
* 액션과 충돌하지 않을 때만 켠다. 충돌하면 바인딩 없이 두고 경고를 남긴다.
*/ */
function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void { function migrateSuggestionOverlayBindings(activeStore: ElectronStore<AppConfig>): void {
const raw = activeStore.store as unknown as Record<string, unknown> 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-accept', OLD_SUGGESTION_ACCEPT_DEFAULT)
migrateIfOldDefault('suggestion-dismiss', OLD_SUGGESTION_DISMISS_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) activeStore.set('keyBindings', bindings)
} }
@ -187,6 +171,7 @@ const CONFIG_DEFAULTS: AppConfig = {
captionAudioSource: 'mic', captionAudioSource: 'mic',
captionOverlayPosition: null, captionOverlayPosition: null,
captionRefineEnabled: true, captionRefineEnabled: true,
captionSttModelId: null,
updateChannel: 'latest', updateChannel: 'latest',
updateDeviceId: '', updateDeviceId: '',
skippedUpdateVersion: null, skippedUpdateVersion: null,

View file

@ -55,6 +55,10 @@ export interface TranscribeOptions {
vadFilter?: boolean vadFilter?: boolean
/** 음 중 실시간 미리보기 요청 — 상태/이벤트를 건드리지 않고 greedy 디코딩을 사용한다. */ /** 음 중 실시간 미리보기 요청 — 상태/이벤트를 건드리지 않고 greedy 디코딩을 사용한다. */
partial?: boolean partial?: boolean
/** 받아쓰기 모델 대신 쓸 모델 (ensureAuxModel 로 올려 둔 것) */
modelId?: string
/** 내부용 — 409 후 모델을 다시 올리고 재시도한 요청인지 */
retriedAfterLoad?: boolean
} }
/** sidecar /health 응답 */ /** sidecar /health 응답 */
@ -204,6 +208,8 @@ class LocalSTTService extends EventEmitter {
private _sidecarStarting: Promise<void> | null = null private _sidecarStarting: Promise<void> | null = null
private _port: number = SIDECAR_PORT private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null private _currentModelId: string | null = null
/** 보조 자리에 올린 모델 (실시간 자막 전용 모델 등). 사이드카가 죽으면 함께 사라진다 */
private _auxModelId: string | null = null
private _restartCount: number = 0 private _restartCount: number = 0
private _disposed: boolean = false private _disposed: boolean = false
private _gpuAccelerated: boolean = false private _gpuAccelerated: boolean = false
@ -547,6 +553,7 @@ class LocalSTTService extends EventEmitter {
this._audioBuffer = [] this._audioBuffer = []
this._modelReady = false this._modelReady = false
this._auxModelId = null
await this._shutdownSidecar() await this._shutdownSidecar()
this._setState(STTState.Uninitialized) this._setState(STTState.Uninitialized)
@ -731,6 +738,7 @@ class LocalSTTService extends EventEmitter {
this._sidecarProcess = null this._sidecarProcess = null
} }
this._modelReady = false this._modelReady = false
this._auxModelId = null
if (!this._disposed) { if (!this._disposed) {
this._handleSidecarCrash() 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 startTime = Date.now()
const response = await fetch(`${this._baseUrl}/load`, { const response = await fetch(`${this._baseUrl}/load`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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), signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
}) })
@ -881,7 +900,7 @@ class LocalSTTService extends EventEmitter {
const loadTimeMs = Date.now() - startTime const loadTimeMs = Date.now() - startTime
const model = MODEL_CATALOG.find((m) => m.id === modelId) const model = MODEL_CATALOG.find((m) => m.id === modelId)
if (model) { if (model && slot === 'primary') {
this.emit('model-loaded', { this.emit('model-loaded', {
model: { ...model, downloaded: true }, model: { ...model, downloaded: true },
loadTimeMs, loadTimeMs,
@ -937,6 +956,10 @@ class LocalSTTService extends EventEmitter {
if (initialPrompt) { if (initialPrompt) {
formData.append('initial_prompt', 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`, { const response = await fetch(`${this._baseUrl}/transcribe`, {
method: 'POST', 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) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
throw new D3ROError( throw new D3ROError(
@ -1042,6 +1072,7 @@ class LocalSTTService extends EventEmitter {
const modelToReload = this._currentModelId const modelToReload = this._currentModelId
this._currentModelId = null this._currentModelId = null
this._modelReady = false this._modelReady = false
this._auxModelId = null
// setTimeout으로 이벤트 루프에 양보 // setTimeout으로 이벤트 루프에 양보
setTimeout(() => { setTimeout(() => {

View file

@ -74,10 +74,11 @@ const RUNTIME_VERSION_FILE = '.runtime-version'
/** /**
* 이 앱이 요구하는 런타임 최소 버전. 사이드카 API 가 바뀔 때만 올린다. * 이 앱이 요구하는 런타임 최소 버전. 사이드카 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> = { const RUNTIME_MIN_VERSION: Record<RuntimeComponent, string | null> = {
sidecar: '1.5.0', sidecar: '1.7.0',
ffmpeg: null ffmpeg: null
} }
const DOWNLOAD_TIMEOUT_MS = 120_000 const DOWNLOAD_TIMEOUT_MS = 120_000

View file

@ -1000,6 +1000,7 @@ class SuggestionService extends EventEmitter {
if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false if (token !== this._generationToken || this._isDuplicateCandidate(candidateText)) return false
this._candidates.push({ text: candidateText, rank: this._candidates.length }) this._candidates.push({ text: candidateText, rank: this._candidates.length })
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
this._armVisibleTtl() this._armVisibleTtl()
this.emit('updated', this.getState()) this.emit('updated', this.getState())
this._record({ this._record({
@ -1176,26 +1177,17 @@ class SuggestionService extends EventEmitter {
return this.getState() return this.getState()
} }
/**
* 다음 후보로 — 페이지는 활성 후보를 따라 넘어간다.
*
* 마지막 후보에서 아직 더 만드는 중이면 그 자리에 머문다: 처음으로 돌아가 버리면
* 곧 도착할 후보를 보려던 사용자가 길을 잃는다. 다 만들었으면 처음으로 돌아간다.
*/
next(): SuggestionState { next(): SuggestionState {
if (this._candidates.length > 1) { const last = this._candidates.length - 1
this._moveActive((this._activeIndex + 1) % this._candidates.length) if (last < 1) return this.getState()
} if (this._activeIndex < last) this._moveActive(this._activeIndex + 1)
return this.getState() else if (!this._filling) this._moveActive(0)
}
/** 다음 페이지의 첫 항목으로 — 그 페이지에 후보가 없으면 그대로 둔다. */
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)
return this.getState() return this.getState()
} }

View file

@ -84,7 +84,6 @@ function getPopupI18nStrings(): Record<string, string> {
suggestionAppPhrases: t('popup.suggestion.appPhrases'), suggestionAppPhrases: t('popup.suggestion.appPhrases'),
// 키 힌트 줄 (동작 낱말 — 실제 키는 사용자 바인딩에서 매번 계산해 붙인다) // 키 힌트 줄 (동작 낱말 — 실제 키는 사용자 바인딩에서 매번 계산해 붙인다)
suggestionHintMoveLabel: t('popup.suggestion.hintMove'), suggestionHintMoveLabel: t('popup.suggestion.hintMove'),
suggestionHintPageLabel: t('popup.suggestion.hintPage'),
suggestionHintAcceptLabel: t('popup.suggestion.hintAccept'), suggestionHintAcceptLabel: t('popup.suggestion.hintAccept'),
suggestionHintCloseLabel: t('popup.suggestion.hintDismiss'), suggestionHintCloseLabel: t('popup.suggestion.hintDismiss'),
suggestionHintGeneratingMore: t('popup.suggestion.hintGeneratingMore'), suggestionHintGeneratingMore: t('popup.suggestion.hintGeneratingMore'),
@ -113,10 +112,9 @@ function sameModifiers(a: readonly string[], b: readonly string[]): boolean {
} }
export interface SuggestionKeyHints { export interface SuggestionKeyHints {
/** 이동·페이지·수락이 모두 같은 수정자를 쓰면 그 수정자(예: "Ctrl+Alt") — 한 번만 보여준다 */ /** 이동·수락이 모두 같은 수정자를 쓰면 그 수정자(예: "Ctrl+Alt") — 한 번만 보여준다 */
shared: string | null shared: string | null
move: string | null move: string | null
page: string | null
accept: string | null accept: string | null
close: string close: string
} }
@ -139,11 +137,9 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
const prev = splitOf('suggestion-prev') const prev = splitOf('suggestion-prev')
const next = splitOf('suggestion-next') const next = splitOf('suggestion-next')
const pagePrev = splitOf('suggestion-page-prev')
const pageNext = splitOf('suggestion-page-next')
const accept = splitOf('suggestion-accept') 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 common = present[0]?.modifiers ?? []
const shareAll = const shareAll =
present.length > 0 && common.length > 0 && present.every((b) => sameModifiers(b.modifiers, common)) present.length > 0 && common.length > 0 && present.every((b) => sameModifiers(b.modifiers, common))
@ -159,7 +155,6 @@ function buildSuggestionKeyHints(): SuggestionKeyHints {
return { return {
shared: shareAll ? joinBindingSegments(common, platform) : null, shared: shareAll ? joinBindingSegments(common, platform) : null,
move: pair(prev, next), move: pair(prev, next),
page: pair(pagePrev, pageNext),
accept: accept ? label(accept) : null, accept: accept ? label(accept) : null,
close: 'Esc' close: 'Esc'
} }

View file

@ -51,6 +51,9 @@ import type {
} from '@d3ro/core/types' } from '@d3ro/core/types'
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal' import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
/** 자막 모델 선택지 "받아쓰기와 같게" — 설정에는 null 로 저장한다 */
const SAME_AS_DICTATION = '__same__'
interface STTTabProps { interface STTTabProps {
config: Partial<AppConfig> config: Partial<AppConfig>
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
@ -362,6 +365,30 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
</Select> </Select>
</FormControl> </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')) const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))

View file

@ -143,7 +143,7 @@
keyHintsContainer.textContent = '' keyHintsContainer.textContent = ''
if (!keyHints) return if (!keyHints) return
// 이동·페이지·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +"). // 이동·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +").
if (keyHints.shared) { if (keyHints.shared) {
var shared = document.createElement('span') var shared = document.createElement('span')
shared.className = 'key-hint shared' shared.className = 'key-hint shared'
@ -154,7 +154,6 @@
var entries = [ var entries = [
[keyHints.move, i18nStrings.suggestionHintMoveLabel, ''], [keyHints.move, i18nStrings.suggestionHintMoveLabel, ''],
[keyHints.page, i18nStrings.suggestionHintPageLabel, ''],
[keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''], [keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''],
[keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint'] [keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint']
] ]

View file

@ -114,13 +114,6 @@ describe('ConfigService suggestion tuning migration', () => {
// 옮기지 않는 액션은 그대로 남는다. // 옮기지 않는 액션은 그대로 남는다.
expect(bindings['suggestion-next']).toEqual(OLD_NEXT) expect(bindings['suggestion-next']).toEqual(OLD_NEXT)
expect(bindings['suggestion-prev']).toEqual(OLD_PREV) 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) expect(configGet('suggestionTuningRevision')).toBe(5)
resetInMemoryConfig() resetInMemoryConfig()
@ -151,31 +144,4 @@ describe('ConfigService suggestion tuning migration', () => {
resetInMemoryConfig() 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()
})
}) })

View file

@ -346,21 +346,25 @@ describe('SuggestionService warm-up', () => {
expect(internal._lastSkipReason).not.toBe('rate-limited') expect(internal._lastSkipReason).not.toBe('rate-limited')
}) })
it('pageNext는 다음 페이지 첫 항목으로, 후보가 없는 페이지면 그대로 둔다', async () => { it('마지막 후보에서 아직 더 만드는 중이면 next 는 제자리, 다 만들었으면 처음으로 돌아간다', async () => {
const { getSuggestionService } = await import('../../../src/main/services/SuggestionService') const { getSuggestionService } = await import('../../../src/main/services/SuggestionService')
const service = getSuggestionService() const service = getSuggestionService()
const internal = service as unknown as { _candidates: Array<{ text: string; rank: number }> } const internal = service as unknown as {
internal._candidates = Array.from({ length: 5 }, (_v, i) => ({ text: `후보${i}`, rank: i })) _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) for (let i = 0; i < 3; i += 1) service.next()
service.pageNext() // 3번째(0-based 3)에서 ↓ — 4번째 칸이 곧 페이지 2 로 넘어간 상태다.
expect(service.getState().activeIndex).toBe(3) expect(service.getState().activeIndex).toBe(3)
service.pageNext()
// 5개뿐이라 다음 페이지가 없다 — 그대로 둔다. internal._filling = true
service.next()
expect(service.getState().activeIndex).toBe(3) expect(service.getState().activeIndex).toBe(3)
service.pagePrev()
expect(service.getState().activeIndex).toBe(0) internal._filling = false
service.pagePrev() service.next()
expect(service.getState().activeIndex).toBe(0) expect(service.getState().activeIndex).toBe(0)
}) })

View file

@ -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-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-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-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-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-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. | | 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-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-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-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-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-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. | | INPUT-10 | Edit Friction | [~] | [-] | [-] | [-] | `calculateFrictionInsight` derives friction from char/backspace quantities and reports edits per 100 chars; it does not infer sentiment or productivity. |

View file

@ -1235,8 +1235,6 @@ export const SUGGESTION_DEFAULTS = {
* 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다. * 한 번에 요청하면 느리다(사용자 요청) — 1개씩 순차 요청해 채운다.
*/ */
maxCandidatesTotal: 12, maxCandidatesTotal: 12,
/** 오버레이 한 페이지에 보여줄 후보 수. */
pageSize: 3,
maxOutputTokens: 64, maxOutputTokens: 64,
/** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */ /** 표시된 제안의 연속 접두가 이만큼 자랐을 때만 재생성한다. */
regenerateAfterChars: 12, regenerateAfterChars: 12,

View file

@ -681,8 +681,6 @@ export type KeyBindingActionId =
| 'suggestion-next' | 'suggestion-next'
| 'suggestion-prev' | 'suggestion-prev'
| 'suggestion-dismiss' | 'suggestion-dismiss'
| 'suggestion-page-next'
| 'suggestion-page-prev'
/** 액션 그룹 (설정 화면 섹션) */ /** 액션 그룹 (설정 화면 섹션) */
export type KeyBindingActionGroup = 'voice' | 'window' | 'input' export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
@ -786,8 +784,8 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
descriptionKey: 'keybinding.action.suggestionAccept.desc', descriptionKey: 'keybinding.action.suggestionAccept.desc',
holdMode: false, holdMode: false,
doublePress: false, doublePress: false,
// Ctrl+Alt+화살표 계열: 아래/위로 후보 순환, 좌우로 페이지 이동(12개까지 // Ctrl+Alt+↑/↓ 로 후보를 오가고(페이지는 따라 넘어간다), 수락은 Enter.
// 순차 생성 — 사용자 요청). 화살표를 페이지 이동에 내주기 위해 수락은 Enter로 옮겼다. // 좌우 화살표는 쓰지 않는다 — Intel 그래픽 드라이버의 화면 회전 단축키와 겹친다.
defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })] defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })]
}, },
{ {
@ -808,24 +806,6 @@ export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze
doublePress: false, doublePress: false,
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })] 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', id: 'suggestion-dismiss',
group: 'input', group: 'input',

View file

@ -499,6 +499,8 @@ export interface AppConfig {
captionOverlayPosition: { x: number; y: number } | null captionOverlayPosition: { x: number; y: number } | null
/** 확정된 자막 줄을 로컬 LLM으로 문맥에 맞게 다듬는다 */ /** 확정된 자막 줄을 로컬 LLM으로 문맥에 맞게 다듬는다 */
captionRefineEnabled: boolean captionRefineEnabled: boolean
/** 실시간 자막 전용 로컬 Whisper 모델. null 이면 받아쓰기 모델(sttModelId)을 쓴다 */
captionSttModelId: string | null
/** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */ /** Auto-update 채널 (latest=stable / beta / alpha). UpdateService */
updateChannel: 'latest' | 'beta' | 'alpha' updateChannel: 'latest' | 'beta' | 'alpha'
/** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */ /** staged rollout용 영구 기기 식별자 (개인정보 아님, 최초 실행 시 생성) */

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Weiter", "popup.suggestion.hintNext": "Weiter",
"popup.suggestion.hintDismiss": "Schließen", "popup.suggestion.hintDismiss": "Schließen",
"popup.suggestion.hintMove": "Bewegen", "popup.suggestion.hintMove": "Bewegen",
"popup.suggestion.hintPage": "Seite",
"popup.suggestion.loading": "Wird erzeugt…", "popup.suggestion.loading": "Wird erzeugt…",
"keybinding.ui.sectionInput": "Eingabevorschläge", "keybinding.ui.sectionInput": "Eingabevorschläge",
"keybinding.action.suggestionAccept": "Vorschlag übernehmen", "keybinding.action.suggestionAccept": "Vorschlag übernehmen",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "Mehr wird erzeugt… (bis zu {{max}})", "popup.suggestion.hintGeneratingMore": "Mehr wird erzeugt… (bis zu {{max}})",
"keybinding.action.suggestionPrev": "Vorheriger Vorschlag", "keybinding.action.suggestionPrev": "Vorheriger Vorschlag",
"keybinding.action.suggestionPrev.desc": "Zum vorherigen Kandidaten wechseln.", "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.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.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", "input.graph.nodes": "Satz-Knoten",
@ -524,5 +519,8 @@
"popup.caption.waiting": "Hört zu… der erste Untertitel kann einige Sekunden dauern", "popup.caption.waiting": "Hört zu… der erste Untertitel kann einige Sekunden dauern",
"popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück", "popup.caption.dragHint": "Ziehen zum Verschieben · Doppelklick setzt zurück",
"settings.captionRefine": "Untertitel im Kontext glätten", "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."
} }

View file

@ -1757,7 +1757,6 @@
"popup.suggestion.hintNext": "Next", "popup.suggestion.hintNext": "Next",
"popup.suggestion.hintDismiss": "Dismiss", "popup.suggestion.hintDismiss": "Dismiss",
"popup.suggestion.hintMove": "Move", "popup.suggestion.hintMove": "Move",
"popup.suggestion.hintPage": "Page",
"popup.suggestion.loading": "Generating…", "popup.suggestion.loading": "Generating…",
"keybinding.ui.sectionInput": "Input suggestions", "keybinding.ui.sectionInput": "Input suggestions",
"keybinding.action.suggestionAccept": "Accept suggestion", "keybinding.action.suggestionAccept": "Accept suggestion",
@ -1836,10 +1835,6 @@
"popup.suggestion.hintGeneratingMore": "More coming… (up to {{max}})", "popup.suggestion.hintGeneratingMore": "More coming… (up to {{max}})",
"keybinding.action.suggestionPrev": "Previous suggestion", "keybinding.action.suggestionPrev": "Previous suggestion",
"keybinding.action.suggestionPrev.desc": "Move to the previous suggestion candidate.", "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.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.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", "input.graph.nodes": "Sentence nodes",
@ -1905,5 +1900,8 @@
"popup.caption.waiting": "Listening… the first caption can take a few seconds", "popup.caption.waiting": "Listening… the first caption can take a few seconds",
"popup.caption.dragHint": "Drag to move · double-click to reset", "popup.caption.dragHint": "Drag to move · double-click to reset",
"settings.captionRefine": "Polish captions with context", "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."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Siguiente", "popup.suggestion.hintNext": "Siguiente",
"popup.suggestion.hintDismiss": "Descartar", "popup.suggestion.hintDismiss": "Descartar",
"popup.suggestion.hintMove": "Mover", "popup.suggestion.hintMove": "Mover",
"popup.suggestion.hintPage": "Página",
"popup.suggestion.loading": "Generando…", "popup.suggestion.loading": "Generando…",
"keybinding.ui.sectionInput": "Sugerencias de entrada", "keybinding.ui.sectionInput": "Sugerencias de entrada",
"keybinding.action.suggestionAccept": "Aceptar sugerencia", "keybinding.action.suggestionAccept": "Aceptar sugerencia",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "Generando más… (hasta {{max}})", "popup.suggestion.hintGeneratingMore": "Generando más… (hasta {{max}})",
"keybinding.action.suggestionPrev": "Sugerencia anterior", "keybinding.action.suggestionPrev": "Sugerencia anterior",
"keybinding.action.suggestionPrev.desc": "Pasa al candidato 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.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.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", "input.graph.nodes": "Nodos de frase",
@ -524,5 +519,8 @@
"popup.caption.waiting": "Escuchando… el primer subtítulo puede tardar unos segundos", "popup.caption.waiting": "Escuchando… el primer subtítulo puede tardar unos segundos",
"popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer", "popup.caption.dragHint": "Arrastra para mover · doble clic para restablecer",
"settings.captionRefine": "Pulir subtítulos con contexto", "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."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Suivant", "popup.suggestion.hintNext": "Suivant",
"popup.suggestion.hintDismiss": "Fermer", "popup.suggestion.hintDismiss": "Fermer",
"popup.suggestion.hintMove": "Déplacer", "popup.suggestion.hintMove": "Déplacer",
"popup.suggestion.hintPage": "Page",
"popup.suggestion.loading": "Génération…", "popup.suggestion.loading": "Génération…",
"keybinding.ui.sectionInput": "Suggestions de saisie", "keybinding.ui.sectionInput": "Suggestions de saisie",
"keybinding.action.suggestionAccept": "Accepter la suggestion", "keybinding.action.suggestionAccept": "Accepter la suggestion",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "Génération en cours… (jusqu'à {{max}})", "popup.suggestion.hintGeneratingMore": "Génération en cours… (jusqu'à {{max}})",
"keybinding.action.suggestionPrev": "Suggestion précédente", "keybinding.action.suggestionPrev": "Suggestion précédente",
"keybinding.action.suggestionPrev.desc": "Passe au candidat précédent.", "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.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.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", "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.waiting": "Écoute… le premier sous-titre peut prendre quelques secondes",
"popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser", "popup.caption.dragHint": "Glisser pour déplacer · double-clic pour réinitialiser",
"settings.captionRefine": "Affiner les sous-titres selon le contexte", "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."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "次へ", "popup.suggestion.hintNext": "次へ",
"popup.suggestion.hintDismiss": "閉じる", "popup.suggestion.hintDismiss": "閉じる",
"popup.suggestion.hintMove": "移動", "popup.suggestion.hintMove": "移動",
"popup.suggestion.hintPage": "ページ",
"popup.suggestion.loading": "生成中…", "popup.suggestion.loading": "生成中…",
"keybinding.ui.sectionInput": "入力サジェスト", "keybinding.ui.sectionInput": "入力サジェスト",
"keybinding.action.suggestionAccept": "サジェストを承認", "keybinding.action.suggestionAccept": "サジェストを承認",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "さらに生成中…(最大{{max}}件)", "popup.suggestion.hintGeneratingMore": "さらに生成中…(最大{{max}}件)",
"keybinding.action.suggestionPrev": "前の候補", "keybinding.action.suggestionPrev": "前の候補",
"keybinding.action.suggestionPrev.desc": "前の候補に移動します。", "keybinding.action.suggestionPrev.desc": "前の候補に移動します。",
"keybinding.action.suggestionPageNext": "次のページ",
"keybinding.action.suggestionPageNext.desc": "次の提案ページを表示します(最大12件まで)。",
"keybinding.action.suggestionPagePrev": "前のページ",
"keybinding.action.suggestionPagePrev.desc": "前の提案ページに戻ります。",
"input.insights.tabs.graph": "グラフ", "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.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": "文ノード", "input.graph.nodes": "文ノード",
@ -524,5 +519,8 @@
"popup.caption.waiting": "聞き取り中… 最初の字幕まで数秒かかることがあります", "popup.caption.waiting": "聞き取り中… 最初の字幕まで数秒かかることがあります",
"popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置", "popup.caption.dragHint": "ドラッグで移動 · ダブルクリックで元の位置",
"settings.captionRefine": "文脈で字幕を整える", "settings.captionRefine": "文脈で字幕を整える",
"settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。" "settings.captionRefine.desc": "確定した字幕の区切り・句読点・聞き間違いを、ローカルAIが前後の文脈に合わせて直します。",
"settings.captionModel": "リアルタイム字幕モデル",
"settings.captionModel.same": "音声入力と同じ",
"settings.captionModel.desc": "リアルタイム字幕だけを別のモデルで認識します。音声入力モデルと同時に読み込まれます。"
} }

View file

@ -1764,7 +1764,6 @@
"popup.suggestion.hintNext": "다음", "popup.suggestion.hintNext": "다음",
"popup.suggestion.hintDismiss": "닫기", "popup.suggestion.hintDismiss": "닫기",
"popup.suggestion.hintMove": "이동", "popup.suggestion.hintMove": "이동",
"popup.suggestion.hintPage": "페이지",
"popup.suggestion.loading": "생성 중…", "popup.suggestion.loading": "생성 중…",
"keybinding.ui.sectionInput": "입력 제안", "keybinding.ui.sectionInput": "입력 제안",
"keybinding.action.suggestionAccept": "제안 수락", "keybinding.action.suggestionAccept": "제안 수락",
@ -1843,10 +1842,6 @@
"popup.suggestion.hintGeneratingMore": "더 생성 중… (최대 {{max}}개)", "popup.suggestion.hintGeneratingMore": "더 생성 중… (최대 {{max}}개)",
"keybinding.action.suggestionPrev": "이전 제안", "keybinding.action.suggestionPrev": "이전 제안",
"keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.", "keybinding.action.suggestionPrev.desc": "이전 제안 후보로 이동합니다.",
"keybinding.action.suggestionPageNext": "다음 페이지",
"keybinding.action.suggestionPageNext.desc": "다음 제안 페이지를 봅니다 (최대 12개까지).",
"keybinding.action.suggestionPagePrev": "이전 페이지",
"keybinding.action.suggestionPagePrev.desc": "이전 제안 페이지로 이동합니다.",
"input.insights.tabs.graph": "그래프", "input.insights.tabs.graph": "그래프",
"input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.", "input.graph.description": "내 문장을 노드로, 문장 사이의 관계(무엇이 무엇 뒤에 오는지, 어떤 용어를 공유하는지)를 엣지로 저장해 제안에 개인 문맥을 끌어옵니다. 전부 로컬입니다.",
"input.graph.nodes": "문장 노드", "input.graph.nodes": "문장 노드",
@ -1912,5 +1907,8 @@
"popup.caption.waiting": "듣는 중… 첫 자막까지 몇 초 걸릴 수 있어요", "popup.caption.waiting": "듣는 중… 첫 자막까지 몇 초 걸릴 수 있어요",
"popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치", "popup.caption.dragHint": "끌어서 이동 · 더블클릭하면 원위치",
"settings.captionRefine": "자막 문맥 다듬기", "settings.captionRefine": "자막 문맥 다듬기",
"settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다." "settings.captionRefine.desc": "확정된 자막을 로컬 AI가 앞뒤 문맥에 맞게 띄어쓰기·문장부호·잘못 들은 단어를 고칩니다.",
"settings.captionModel": "실시간 자막 모델",
"settings.captionModel.same": "받아쓰기와 같게",
"settings.captionModel.desc": "실시간 자막만 다른 모델로 인식합니다. 받아쓰기 모델과 함께 GPU 메모리에 올라갑니다."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Próxima", "popup.suggestion.hintNext": "Próxima",
"popup.suggestion.hintDismiss": "Descartar", "popup.suggestion.hintDismiss": "Descartar",
"popup.suggestion.hintMove": "Mover", "popup.suggestion.hintMove": "Mover",
"popup.suggestion.hintPage": "Página",
"popup.suggestion.loading": "Gerando…", "popup.suggestion.loading": "Gerando…",
"keybinding.ui.sectionInput": "Sugestões de entrada", "keybinding.ui.sectionInput": "Sugestões de entrada",
"keybinding.action.suggestionAccept": "Aceitar sugestão", "keybinding.action.suggestionAccept": "Aceitar sugestão",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "Gerando mais… (até {{max}})", "popup.suggestion.hintGeneratingMore": "Gerando mais… (até {{max}})",
"keybinding.action.suggestionPrev": "Sugestão anterior", "keybinding.action.suggestionPrev": "Sugestão anterior",
"keybinding.action.suggestionPrev.desc": "Vai para o candidato 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.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.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", "input.graph.nodes": "Nós de frase",
@ -524,5 +519,8 @@
"popup.caption.waiting": "Ouvindo… a primeira legenda pode levar alguns segundos", "popup.caption.waiting": "Ouvindo… a primeira legenda pode levar alguns segundos",
"popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar", "popup.caption.dragHint": "Arraste para mover · clique duplo para restaurar",
"settings.captionRefine": "Refinar legendas pelo contexto", "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."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Далее", "popup.suggestion.hintNext": "Далее",
"popup.suggestion.hintDismiss": "Закрыть", "popup.suggestion.hintDismiss": "Закрыть",
"popup.suggestion.hintMove": "Перемещение", "popup.suggestion.hintMove": "Перемещение",
"popup.suggestion.hintPage": "Страница",
"popup.suggestion.loading": "Генерация…", "popup.suggestion.loading": "Генерация…",
"keybinding.ui.sectionInput": "Подсказки ввода", "keybinding.ui.sectionInput": "Подсказки ввода",
"keybinding.action.suggestionAccept": "Принять подсказку", "keybinding.action.suggestionAccept": "Принять подсказку",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "Создаётся ещё… (до {{max}})", "popup.suggestion.hintGeneratingMore": "Создаётся ещё… (до {{max}})",
"keybinding.action.suggestionPrev": "Предыдущая подсказка", "keybinding.action.suggestionPrev": "Предыдущая подсказка",
"keybinding.action.suggestionPrev.desc": "Перейти к предыдущему варианту.", "keybinding.action.suggestionPrev.desc": "Перейти к предыдущему варианту.",
"keybinding.action.suggestionPageNext": "Следующая страница",
"keybinding.action.suggestionPageNext.desc": "Показывает следующую страницу подсказок (до 12 всего).",
"keybinding.action.suggestionPagePrev": "Предыдущая страница",
"keybinding.action.suggestionPagePrev.desc": "Показывает предыдущую страницу подсказок.",
"input.insights.tabs.graph": "Граф", "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.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": "Узлы-предложения", "input.graph.nodes": "Узлы-предложения",
@ -524,5 +519,8 @@
"popup.caption.waiting": "Слушаю… первый субтитр может появиться через несколько секунд", "popup.caption.waiting": "Слушаю… первый субтитр может появиться через несколько секунд",
"popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс", "popup.caption.dragHint": "Перетащите, чтобы переместить · двойной щелчок — сброс",
"settings.captionRefine": "Уточнять субтитры по контексту", "settings.captionRefine": "Уточнять субтитры по контексту",
"settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам." "settings.captionRefine.desc": "Локальный ИИ исправляет пробелы, пунктуацию и ослышки в готовых субтитрах по соседним строкам.",
"settings.captionModel": "Модель живых субтитров",
"settings.captionModel.same": "Как для диктовки",
"settings.captionModel.desc": "Живые субтитры распознаются отдельной моделью, которая загружается вместе с моделью диктовки."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "ถัดไป", "popup.suggestion.hintNext": "ถัดไป",
"popup.suggestion.hintDismiss": "ปิด", "popup.suggestion.hintDismiss": "ปิด",
"popup.suggestion.hintMove": "ย้าย", "popup.suggestion.hintMove": "ย้าย",
"popup.suggestion.hintPage": "หน้า",
"popup.suggestion.loading": "กำลังสร้าง…", "popup.suggestion.loading": "กำลังสร้าง…",
"keybinding.ui.sectionInput": "คำแนะนำการป้อน", "keybinding.ui.sectionInput": "คำแนะนำการป้อน",
"keybinding.action.suggestionAccept": "ยอมรับคำแนะนำ", "keybinding.action.suggestionAccept": "ยอมรับคำแนะนำ",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "กำลังสร้างเพิ่ม… (สูงสุด {{max}})", "popup.suggestion.hintGeneratingMore": "กำลังสร้างเพิ่ม… (สูงสุด {{max}})",
"keybinding.action.suggestionPrev": "คำแนะนำก่อนหน้า", "keybinding.action.suggestionPrev": "คำแนะนำก่อนหน้า",
"keybinding.action.suggestionPrev.desc": "ย้ายไปยังตัวเลือกก่อนหน้า", "keybinding.action.suggestionPrev.desc": "ย้ายไปยังตัวเลือกก่อนหน้า",
"keybinding.action.suggestionPageNext": "หน้าถัดไป",
"keybinding.action.suggestionPageNext.desc": "แสดงคำแนะนำหน้าถัดไป (สูงสุด 12 รายการ)",
"keybinding.action.suggestionPagePrev": "หน้าก่อนหน้า",
"keybinding.action.suggestionPagePrev.desc": "แสดงคำแนะนำหน้าก่อนหน้า",
"input.insights.tabs.graph": "กราฟ", "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.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": "โหนดประโยค", "input.graph.nodes": "โหนดประโยค",
@ -524,5 +519,8 @@
"popup.caption.waiting": "กำลังฟัง… คำบรรยายแรกอาจใช้เวลาสักครู่", "popup.caption.waiting": "กำลังฟัง… คำบรรยายแรกอาจใช้เวลาสักครู่",
"popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต", "popup.caption.dragHint": "ลากเพื่อย้าย · ดับเบิลคลิกเพื่อรีเซ็ต",
"settings.captionRefine": "ขัดเกลาคำบรรยายตามบริบท", "settings.captionRefine": "ขัดเกลาคำบรรยายตามบริบท",
"settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง" "settings.captionRefine.desc": "AI ในเครื่องจะแก้เว้นวรรค เครื่องหมายวรรคตอน และคำที่ได้ยินผิด โดยดูจากบรรทัดรอบข้าง",
"settings.captionModel": "โมเดลคำบรรยายสด",
"settings.captionModel.same": "เหมือนการพิมพ์ด้วยเสียง",
"settings.captionModel.desc": "ใช้โมเดลแยกสำหรับคำบรรยายสด โดยโหลดคู่กับโมเดลพิมพ์ด้วยเสียง"
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "Tiếp", "popup.suggestion.hintNext": "Tiếp",
"popup.suggestion.hintDismiss": "Đóng", "popup.suggestion.hintDismiss": "Đóng",
"popup.suggestion.hintMove": "Di chuyển", "popup.suggestion.hintMove": "Di chuyển",
"popup.suggestion.hintPage": "Trang",
"popup.suggestion.loading": "Đang tạo…", "popup.suggestion.loading": "Đang tạo…",
"keybinding.ui.sectionInput": "Gợi ý nhập liệu", "keybinding.ui.sectionInput": "Gợi ý nhập liệu",
"keybinding.action.suggestionAccept": "Chấp nhận gợi ý", "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}})", "popup.suggestion.hintGeneratingMore": "Đang tạo thêm… (tối đa {{max}})",
"keybinding.action.suggestionPrev": "Gợi ý trước", "keybinding.action.suggestionPrev": "Gợi ý trước",
"keybinding.action.suggestionPrev.desc": "Chuyển về ứng viên 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.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.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", "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.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", "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": "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ả."
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "下一個", "popup.suggestion.hintNext": "下一個",
"popup.suggestion.hintDismiss": "關閉", "popup.suggestion.hintDismiss": "關閉",
"popup.suggestion.hintMove": "移動", "popup.suggestion.hintMove": "移動",
"popup.suggestion.hintPage": "翻頁",
"popup.suggestion.loading": "產生中…", "popup.suggestion.loading": "產生中…",
"keybinding.ui.sectionInput": "輸入建議", "keybinding.ui.sectionInput": "輸入建議",
"keybinding.action.suggestionAccept": "接受建議", "keybinding.action.suggestionAccept": "接受建議",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "正在產生更多…(最多 {{max}} 則)", "popup.suggestion.hintGeneratingMore": "正在產生更多…(最多 {{max}} 則)",
"keybinding.action.suggestionPrev": "上一則建議", "keybinding.action.suggestionPrev": "上一則建議",
"keybinding.action.suggestionPrev.desc": "移動到上一則候選。", "keybinding.action.suggestionPrev.desc": "移動到上一則候選。",
"keybinding.action.suggestionPageNext": "下一頁",
"keybinding.action.suggestionPageNext.desc": "顯示下一頁建議(最多 12 則)。",
"keybinding.action.suggestionPagePrev": "上一頁",
"keybinding.action.suggestionPagePrev.desc": "顯示上一頁建議。",
"input.insights.tabs.graph": "圖譜", "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.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": "句子節點", "input.graph.nodes": "句子節點",
@ -524,5 +519,8 @@
"popup.caption.waiting": "聆聽中… 第一則字幕可能需要幾秒鐘", "popup.caption.waiting": "聆聽中… 第一則字幕可能需要幾秒鐘",
"popup.caption.dragHint": "拖曳以移動 · 按兩下還原", "popup.caption.dragHint": "拖曳以移動 · 按兩下還原",
"settings.captionRefine": "依上下文潤飾字幕", "settings.captionRefine": "依上下文潤飾字幕",
"settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。" "settings.captionRefine.desc": "本機 AI 會依上下文修正已確定字幕的空格、標點與聽錯的詞。",
"settings.captionModel": "即時字幕模型",
"settings.captionModel.same": "與聽寫相同",
"settings.captionModel.desc": "僅即時字幕使用獨立模型辨識,會與聽寫模型一同載入。"
} }

View file

@ -375,7 +375,6 @@
"popup.suggestion.hintNext": "下一个", "popup.suggestion.hintNext": "下一个",
"popup.suggestion.hintDismiss": "关闭", "popup.suggestion.hintDismiss": "关闭",
"popup.suggestion.hintMove": "移动", "popup.suggestion.hintMove": "移动",
"popup.suggestion.hintPage": "翻页",
"popup.suggestion.loading": "生成中…", "popup.suggestion.loading": "生成中…",
"keybinding.ui.sectionInput": "输入建议", "keybinding.ui.sectionInput": "输入建议",
"keybinding.action.suggestionAccept": "接受建议", "keybinding.action.suggestionAccept": "接受建议",
@ -454,10 +453,6 @@
"popup.suggestion.hintGeneratingMore": "正在生成更多…(最多 {{max}} 条)", "popup.suggestion.hintGeneratingMore": "正在生成更多…(最多 {{max}} 条)",
"keybinding.action.suggestionPrev": "上一条建议", "keybinding.action.suggestionPrev": "上一条建议",
"keybinding.action.suggestionPrev.desc": "移动到上一条候选。", "keybinding.action.suggestionPrev.desc": "移动到上一条候选。",
"keybinding.action.suggestionPageNext": "下一页",
"keybinding.action.suggestionPageNext.desc": "显示下一页建议(最多 12 条)。",
"keybinding.action.suggestionPagePrev": "上一页",
"keybinding.action.suggestionPagePrev.desc": "显示上一页建议。",
"input.insights.tabs.graph": "图谱", "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.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": "句子节点", "input.graph.nodes": "句子节点",
@ -524,5 +519,8 @@
"popup.caption.waiting": "正在聆听… 第一条字幕可能需要几秒钟", "popup.caption.waiting": "正在聆听… 第一条字幕可能需要几秒钟",
"popup.caption.dragHint": "拖动以移动 · 双击复位", "popup.caption.dragHint": "拖动以移动 · 双击复位",
"settings.captionRefine": "按上下文润色字幕", "settings.captionRefine": "按上下文润色字幕",
"settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。" "settings.captionRefine.desc": "本地 AI 会根据上下文修正已确定字幕的空格、标点和听错的词。",
"settings.captionModel": "实时字幕模型",
"settings.captionModel.same": "与听写相同",
"settings.captionModel.desc": "仅实时字幕使用单独的模型识别,会与听写模型一同加载。"
} }