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
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:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue