feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -12,6 +12,7 @@ import { getLogger } from './LoggerService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getLocalSTTService } from './LocalSTTService'
import type { TranscriptionResult } from './LocalSTTService'
import { getSTTManager } from './stt/STTManager'
import { getHotkeyService } from './HotkeyService'
import type { HotkeyConfig } from './HotkeyService'
import { configGet } from './ConfigService'
@ -120,6 +121,8 @@ class VoiceModeService extends EventEmitter {
private _errorEmitted = false
// 에러 popup 3초 hide 예약 타이머 (다음 세션 시작 시 취소해야 현재 recording tip이 살아남음)
private _errorHideTimer: NodeJS.Timeout | null = null
/** 녹음 종료 후 STT 준비 대기 타이머 — 전사 시작 시 반드시 해제 */
private _sttWaitTimer: NodeJS.Timeout | null = null
/** 실시간 부분 전사 루프 */
private _interimTimer: NodeJS.Timeout | null = null
private _interimBusy = false
@ -209,12 +212,22 @@ class VoiceModeService extends EventEmitter {
const license = getLicenseService()
const access = license.canUse(Feature.DICTATION)
if (!access.allowed) {
license.promptUpgrade(Feature.DICTATION, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required')
const reason = access.reason === 'quota_exceeded' ? 'quota_exceeded' : access.reason === 'login_required' ? 'login_required' : 'tier_required'
license.promptUpgrade(Feature.DICTATION, reason)
logger.warn(`Dictation blocked: ${access.reason}`)
const code = access.reason === 'quota_exceeded' ? ErrorCode.QuotaExceeded : ErrorCode.TierRequired
this.emit('error', {
error: new D3ROError(code, `Dictation blocked: ${access.reason}`),
session: null,
})
return
}
license.consumeQuota(Feature.DICTATION)
} catch {
} catch (err) {
if (err instanceof D3ROError) {
this.emit('error', { error: err, session: null })
return
}
// LicenseService 미초기화 시 허용 (graceful)
}
@ -224,6 +237,10 @@ class VoiceModeService extends EventEmitter {
const captionState = getCaptionService().getState()
if (captionState === 'active' || captionState === 'starting') {
logger.warn('Cannot start dictation: caption mode active')
this.emit('error', {
error: new D3ROError(ErrorCode.CaptionAlreadyActive, 'Cannot start dictation: caption mode active'),
session: null,
})
return
}
} catch {
@ -276,19 +293,17 @@ class VoiceModeService extends EventEmitter {
this.emit('session-started', { session: this._session })
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
// 프리플라이트: STT 모델 미설치면 게이지만 돌며 "되는 척"하지 않고 즉시 에러.
// (온보딩 미완료/모델 삭제 상태에서 단축키를 눌렀을 때의 경고 경로)
const stt = getLocalSTTService()
const sttModelId = configGet('sttModelId')
const sttModel = stt.getModels().find((m) => m.id === sttModelId)
if (sttModel && !sttModel.downloaded) {
this._handleError(
new D3ROError(
ErrorCode.STTModelNotFound,
`STT model not installed: ${sttModelId}`,
),
)
return
const provider = configGet('sttProvider') ?? 'local'
if (provider === 'local') {
const stt = getLocalSTTService()
const sttModelId = configGet('sttModelId')
const sttModel = stt.getModels().find((m) => m.id === sttModelId)
if (sttModel && !sttModel.downloaded) {
this._handleError(
new D3ROError(ErrorCode.STTModelNotFound, `STT model not installed: ${sttModelId}`),
)
return
}
}
// Speakly 패턴: 녹음 시작 시 즉시 RecordingTip 표시
@ -329,15 +344,17 @@ class VoiceModeService extends EventEmitter {
if (this._audioBuffer.length > 0 && this._sttReady) {
await this._transcribe()
} else if (this._audioBuffer.length > 0 && !this._sttReady) {
// STT 아직 준비 안 됨 → tryFlushAll이 처리
// STT 아직 준비 안 됨 → _initSTT 완료 후 _tryFlushAll이 전사
logger.info('Waiting for STT to be ready before transcribing')
// 타임아웃 설정
setTimeout(() => {
if (this._session?.id === session.id && !this._isInTerminalState()) {
this._clearSttWaitTimer()
this._sttWaitTimer = setTimeout(() => {
// 전사가 이미 시작됐으면(RECOGNIZING) 취소하지 않는다.
// 예전엔 6초 타이머가 첫 모델 로딩 직후 전사를 잘라 '전사가 안 됨'으로 보였다.
if (this._session?.id === session.id && !this._sttReady && !this._isInTerminalState()) {
logger.warn('STT readiness timeout, cancelling session')
this._cancelSession('timeout')
}
}, TIMING.POST_RECORDING_WAIT_BUFFERED)
}, TIMING.ABSOLUTE_MAX_WAIT)
} else {
// 오디오 없음
logger.warn('No audio buffer, cancelling session')
@ -387,16 +404,19 @@ class VoiceModeService extends EventEmitter {
private async _initSTT(): Promise<void> {
try {
this._setRecognitionState(RecognitionState.CONNECTING)
const stt = getLocalSTTService()
const modelId = configGet('sttModelId')
await stt.initialize(modelId)
const provider = configGet('sttProvider') ?? 'local'
if (provider === 'local') {
const stt = getLocalSTTService()
const modelId = configGet('sttModelId')
await stt.initialize(modelId)
}
if (this._isInTerminalState()) return
this._sttReady = true
this._clearSttWaitTimer()
this._setRecognitionState(RecognitionState.READY)
logger.info('STT ready')
logger.info(`STT ready (provider: ${provider})`)
this._tryFlushAll()
} catch (error) {
@ -475,6 +495,7 @@ class VoiceModeService extends EventEmitter {
audio.off('audio-level', this._audioLevelHandler)
this._audioLevelHandler = null
}
this._audioStarted = false
try {
await audio.stop()
@ -504,53 +525,25 @@ class VoiceModeService extends EventEmitter {
}
private async _runInterimTranscribe(): Promise<void> {
if (!this._session || this._isInTerminalState()) return
if (this._audioState !== AudioState.STREAMING) return
if (!this._sttReady || this._interimBusy) return
// 0.8초 미만 오디오는 스킵 (16kHz 16bit mono)
if (this._audioBufferBytes < 16000 * 2 * 0.8) return
this._interimBusy = true
const sessionId = this._session.id
try {
// 버퍼는 비우지 않고 복사만 (최종 전사가 전체 버퍼 사용)
const merged = Buffer.concat(this._audioBuffer)
const windowBytes = 16000 * 2 * 12
const windowBuf =
merged.length > windowBytes ? merged.subarray(merged.length - windowBytes) : merged
const language = configGet('sttLanguage')
const result = await getLocalSTTService().transcribe(windowBuf, {
language: language === 'auto' ? undefined : language,
})
// 세션이 바뀌었거나 이미 녹음이 끝났으면 폐기
if (this._session?.id !== sessionId) return
if (this._audioState !== AudioState.STREAMING || this._isInTerminalState()) return
const text = result.text.trim()
if (text.length > 0) {
sendPartialTranscriptToTip(text)
this.emit('transcription-update', { text, isFinal: false })
}
} catch {
// interim 실패는 조용히 무시 — 최종 전사가 진실
} finally {
this._interimBusy = false
}
// Cloud STT does not support interim streaming yet
}
// ── 이중 조건 플러시 ───────────────────────────────────
private _tryFlushAll(): void {
if (!this._sttReady || !this._audioStarted) return
if (this._audioBuffer.length === 0) return
// 녹음 진행 중에는 flush 하지 않음 (stopSession에서 _stopAudio 후 처리)
if (this._audioStarted) return
if (!this._sttReady) return
if (this._audioBuffer.length === 0) {
logger.warn(`_tryFlushAll skipped: audioBuffer is empty`)
return
}
if (this._isInTerminalState()) return
// 아직 녹음 중이면 flush 하지 않음 (stopSession에서 처리)
if (this._audioState === AudioState.STREAMING) return
this._transcribe()
this._clearSttWaitTimer()
void this._transcribe().catch((err) => {
logger.error('Unhandled error in _transcribe flush:', err)
})
}
// ── 전사 ───────────────────────────────────────────────
@ -580,7 +573,7 @@ class VoiceModeService extends EventEmitter {
}
try {
const stt = getLocalSTTService()
const stt = getSTTManager()
const language = configGet('sttLanguage')
// Dictionary → STT initialPrompt 주입 (Speakly 패턴)
@ -637,14 +630,11 @@ class VoiceModeService extends EventEmitter {
// VoiceCommandService 미초기화 시 무시
}
// LLM 후처리: none이면 스킵, local backend인데 Ollama 미가용 시도 스킵.
// premium backend는 내부에서 local fallback을 시도하므로 스킵 안 함.
const llmAction = overrideAction ?? configGet('defaultLLMAction')
const backend = configGet('llmBackend')
const ollamaUnavailable = backend === 'local' && !getLocalLLMService().isAvailable()
const skipLLM = llmAction === 'none' || ollamaUnavailable
if (skipLLM) {
// Ollama 미가용으로 후처리를 건너뛰는 경우 사용자에게 경고 (원문 그대로 삽입됨을 알림)
if (llmAction !== 'none' && ollamaUnavailable) {
this.emit('error', {
error: new D3ROError(
@ -672,29 +662,22 @@ class VoiceModeService extends EventEmitter {
// ── LLM 후처리 ─────────────────────────────────────────
/**
* Phase 3.2: llmBackend config + PremiumLLMService 가용성으로
* local/premium 분기. premium 선택 시 처리 도중 실패하면 local로
* silent fallback + 'premium-llm-fallback' 이벤트 emit.
*
* 반환: 실제 사용할 processText 함수 + 사용된 백엔드 이름.
*/
private async _getLLMProcessor(): Promise<{
service: { processText(text: string, action: LLMAction, targetLanguage?: string, customPrompt?: string): Promise<string> }
backend: 'local' | 'premium'
}> {
const backend = configGet('llmBackend')
if (backend === 'premium') {
if (backend === 'online') {
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const premium = getPremiumLLMService()
if (premium.isAvailable()) {
return { service: premium, backend: 'premium' }
}
this._emitPremiumFallback('Premium 사용 불가 — 로그인 또는 네트워크 확인')
this._emitPremiumFallback('Premium unavailable — falling back to local Ollama')
} catch (err) {
this._emitPremiumFallback(
`Premium 초기화 실패: ${err instanceof Error ? err.message : String(err)}`
`Premium init failed: ${err instanceof Error ? err.message : String(err)}`,
)
}
}
@ -706,10 +689,6 @@ class VoiceModeService extends EventEmitter {
this.emit('premium-llm-fallback', { reason })
}
/**
* Phase 3.2: backend 선택 + Premium 실패 시 Local 자동 fallback을 캡슐화한
* processText 호출. 성공 시 결과 텍스트를 반환하고 사용된 backend 로깅.
*/
private async _runProcessorWithFallback(
text: string,
action: LLMAction,
@ -722,9 +701,8 @@ class VoiceModeService extends EventEmitter {
} catch (err) {
if (processor.backend === 'premium') {
this._emitPremiumFallback(
`Premium 호출 실패: ${err instanceof Error ? err.message : String(err)}`
`Premium call failed: ${err instanceof Error ? err.message : String(err)}`,
)
// Local로 재시도
return getLocalLLMService().processText(text, action, targetLanguage, customPrompt)
}
throw err
@ -761,7 +739,13 @@ class VoiceModeService extends EventEmitter {
return
}
} catch (error) {
logger.warn(`Chain execution failed, falling back: ${error instanceof Error ? error.message : String(error)}`)
logger.warn(`Chain execution failed: ${error instanceof Error ? error.message : String(error)}`)
this._handleError(
error instanceof D3ROError
? error
: new D3ROError(ErrorCode.ChainExecutionFailed, `Chain execution failed: ${error instanceof Error ? error.message : String(error)}`),
)
return
}
}
@ -798,8 +782,14 @@ class VoiceModeService extends EventEmitter {
this._completeSession(processedText)
} catch (error) {
if (this._isInTerminalState()) return
logger.warn(`LLM processing failed, using original text: ${error instanceof Error ? error.message : String(error)}`)
this._completeSession(transcribedText)
this._handleError(
error instanceof D3ROError
? error
: new D3ROError(
ErrorCode.LLMProcessingFailed,
`LLM processing failed: ${error instanceof Error ? error.message : String(error)}`,
),
)
}
}
@ -893,8 +883,16 @@ class VoiceModeService extends EventEmitter {
this._resetToIdle()
}
private _clearSttWaitTimer(): void {
if (this._sttWaitTimer) {
clearTimeout(this._sttWaitTimer)
this._sttWaitTimer = null
}
}
private _resetToIdle(): void {
this._stopInterimLoop()
this._clearSttWaitTimer()
this._session = null
this._audioBuffer = []
this._audioBufferBytes = 0
@ -1109,3 +1107,10 @@ export function getVoiceModeService(): VoiceModeService {
}
return instance
}
export function resetVoiceModeServiceForTests(): void {
if (instance) {
instance.removeAllListeners()
}
instance = null
}