Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터

Phase 1:
- 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier)
- shared 타입 (ipc-channels 113채널, types, errors, constants)
- 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스)
- LoggerService, ConfigService (electron-store ESM dynamic import)
- React 19 + MUI 7 Dashboard, 시스템 트레이

Phase 2:
- AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono)
- HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode)
- LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시)
- VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue)
- Python sidecar (FastAPI: health/load/transcribe/shutdown)
- IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
Yun Chan 2026-04-05 02:01:37 +09:00
parent e24bb8378c
commit 1d152d01a1
46 changed files with 10828 additions and 4 deletions

View file

@ -0,0 +1,729 @@
// src/main/services/LocalSTTService.ts
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
// 상태 머신 및 이중 조건 플러시 패턴 적용.
import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import path from 'path'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
// ── 내부 타입 정의 ────────────────────────────────────────
/** STT 엔진 상태 머신 */
const enum STTState {
Uninitialized = 'uninitialized',
Loading = 'loading',
Ready = 'ready',
Transcribing = 'transcribing',
Error = 'error',
}
/** 전사 결과 세그먼트 */
export interface TranscriptionSegment {
readonly text: string
readonly start: number
readonly end: number
readonly confidence: number
}
/** 전사 결과 */
export interface TranscriptionResult {
readonly text: string
readonly segments: TranscriptionSegment[]
readonly language: string
readonly duration: number
readonly processingTime: number
}
/** 전사 옵션 */
export interface TranscribeOptions {
language?: string
initialPrompt?: string
vadFilter?: boolean
}
/** sidecar /health 응답 */
interface HealthResponse {
status: string
model: string | null
gpu: boolean
}
/** sidecar /load 응답 */
interface LoadResponse {
status: string
model_id: string
load_time_ms: number
}
/** sidecar /transcribe 응답 */
interface TranscribeResponse {
text: string
segments: Array<{
text: string
start: number
end: number
avg_logprob: number
}>
language: string
duration: number
processing_time: number
}
/** 이벤트 페이로드 */
export interface LocalSTTEvents {
'transcription-delta': { text: string; isFinal: boolean }
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'error': { error: D3ROError }
}
// ── 상수 ──────────────────────────────────────────────────
const SIDECAR_PORT = 18765
const HEALTH_CHECK_INTERVAL_MS = 1000
const HEALTH_CHECK_TIMEOUT_MS = 30000
const MAX_RESTART_COUNT = 3
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
/** 알려진 Whisper 모델 카탈로그 */
const MODEL_CATALOG: STTModel[] = [
{
id: 'tiny',
name: 'Tiny',
sizeBytes: 75_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 1,
speed: 5,
},
{
id: 'base',
name: 'Base',
sizeBytes: 141_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 2,
speed: 4,
},
{
id: 'small',
name: 'Small',
sizeBytes: 466_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 3,
speed: 3,
},
{
id: 'medium',
name: 'Medium',
sizeBytes: 1_500_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 4,
speed: 2,
},
{
id: 'large-v3',
name: 'Large V3',
sizeBytes: 3_100_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 5,
speed: 1,
},
]
// ── 서비스 구현 ───────────────────────────────────────────
const logger = getLogger('LocalSTTService')
class LocalSTTService extends EventEmitter {
private _state: STTState = STTState.Uninitialized
private _sidecarProcess: ChildProcess | null = null
private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null
private _restartCount: number = 0
private _disposed: boolean = false
private _gpuAccelerated: boolean = false
// ── 이중 조건 플러시 (Speakly 패턴) ──
private _modelReady: boolean = false
private _audioBuffer: Buffer[] = []
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
private _pendingReject: ((error: D3ROError) => void) | null = null
// errorEmitted 플래그로 이벤트 중복 방지
private _errorEmitted: boolean = false
// ── 상태 접근자 ──
get state(): STTState {
return this._state
}
get currentModelId(): string | null {
return this._currentModelId
}
// ── 공개 메서드 ──
/**
* Whisper sidecar + .
* .
*/
async initialize(modelId?: string): Promise<void> {
const targetModel = modelId ?? configGet('sttModelId')
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
// 이미 같은 모델이 로딩된 상태면 무시
if (
this._state === STTState.Ready &&
this._currentModelId === targetModel
) {
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
return
}
this._setState(STTState.Loading)
this._errorEmitted = false
try {
// sidecar가 아직 실행 중이 아니면 시작
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
// 모델 로딩
await this._loadModel(targetModel)
this._currentModelId = targetModel
this._modelReady = true
this._setState(STTState.Ready)
// 이중 조건 플러시 시도
this._tryFlushAll()
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
} catch (err) {
this._setState(STTState.Error)
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTModelLoadFailed,
`STT 초기화 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this._emitError(d3roErr)
throw d3roErr
}
}
/**
* .
* PCM16 16kHz mono .
* 플러시: 모델 .
*/
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
if (audioBuffer.length === 0) {
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
}
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
if (!this._modelReady) {
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
this._audioBuffer.push(audioBuffer)
return new Promise<TranscriptionResult>((resolve, reject) => {
this._pendingResolve = resolve
this._pendingReject = reject
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
this._tryFlushAll()
})
}
// 모델 준비 완료 상태: 직접 전사
return this._sendToSidecar(audioBuffer, options)
}
/**
* .
* sidecar에 (faster-whisper가 ).
*/
getModels(): STTModel[] {
return MODEL_CATALOG.map((m) => ({
...m,
// 현재 로딩된 모델은 downloaded=true로 표시
downloaded: m.id === this._currentModelId ? true : m.downloaded,
}))
}
/** 현재 상태 조회 */
getStatus(): STTStatus {
const stateMap: Record<STTState, STTEngineState> = {
[STTState.Uninitialized]: 'not-installed' as STTEngineState,
[STTState.Loading]: 'loading' as STTEngineState,
[STTState.Ready]: 'ready' as STTEngineState,
[STTState.Transcribing]: 'processing' as STTEngineState,
[STTState.Error]: 'error' as STTEngineState,
}
return {
engineState: stateMap[this._state],
activeModel: this._currentModelId,
engineVersion: null,
gpuAccelerated: this._gpuAccelerated,
}
}
/** sidecar 프로세스 종료 및 리소스 정리 */
async dispose(): Promise<void> {
if (this._disposed) return
this._disposed = true
logger.info('LocalSTTService dispose 시작')
// pending promise를 reject
if (this._pendingReject) {
this._pendingReject(
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
)
this._pendingResolve = null
this._pendingReject = null
}
this._audioBuffer = []
this._modelReady = false
await this._shutdownSidecar()
this._setState(STTState.Uninitialized)
logger.info('LocalSTTService dispose 완료')
}
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
/**
* .
* () , .
*/
private _tryFlushAll(): void {
if (!this._modelReady) return
if (this._audioBuffer.length === 0) return
if (!this._pendingResolve) return
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
const resolve = this._pendingResolve
const reject = this._pendingReject
this._pendingResolve = null
this._pendingReject = null
this._sendToSidecar(merged)
.then(resolve)
.catch((err: unknown) => {
if (reject) {
reject(
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
})
}
// ── Sidecar 관리 ──
private _getSidecarPath(): string {
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
return path.join(basePath, 'sidecar', 'main.py')
}
private async _spawnSidecar(): Promise<void> {
const sidecarPath = this._getSidecarPath()
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
return new Promise<void>((resolve, reject) => {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
try {
this._sidecarProcess = spawn(
pythonCmd,
[sidecarPath, '--port', String(this._port)],
{
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
},
)
} catch (err) {
const d3roErr = new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
)
reject(d3roErr)
return
}
const sidecarLogger = getLogger('sidecar')
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.info(text)
}
})
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.warn(text)
}
})
this._sidecarProcess.on('error', (err: Error) => {
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
reject(
new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 에러: ${err.message}`,
),
)
})
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
this._sidecarProcess = null
this._modelReady = false
if (!this._disposed) {
this._handleSidecarCrash()
}
})
// spawn 자체는 비동기적이므로 즉시 resolve
// 실제 준비는 _waitForHealth에서 확인
resolve()
})
}
private async _waitForHealth(): Promise<void> {
const startTime = Date.now()
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await fetch(`http://localhost:${this._port}/health`, {
signal: AbortSignal.timeout(2000),
})
if (response.ok) {
const data = (await response.json()) as HealthResponse
this._gpuAccelerated = data.gpu
logger.info(
`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`,
)
return
}
} catch {
// 아직 준비 안 됨, 재시도
}
await this._sleep(HEALTH_CHECK_INTERVAL_MS)
}
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
`Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`,
)
}
private async _loadModel(modelId: string): Promise<void> {
logger.info(`모델 로딩 시작: ${modelId}`)
const startTime = Date.now()
const response = await fetch(`http://localhost:${this._port}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
})
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTModelLoadFailed,
`모델 로딩 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as LoadResponse
const loadTimeMs = Date.now() - startTime
const model = MODEL_CATALOG.find((m) => m.id === modelId)
if (model) {
this.emit('model-loaded', {
model: { ...model, downloaded: true },
loadTimeMs,
})
}
logger.info(
`모델 로딩 완료: ${data.model_id}, ${loadTimeMs}ms`,
)
}
// ── HTTP 전사 요청 ──
private async _sendToSidecar(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
'Sidecar 프로세스가 실행 중이 아닙니다',
)
}
this._setState(STTState.Transcribing)
const startTime = Date.now()
try {
const language = options?.language ?? configGet('sttLanguage')
const vadFilter = options?.vadFilter ?? true
const initialPrompt = options?.initialPrompt ?? ''
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
const formData = new FormData()
// Buffer → ArrayBuffer 복사 후 Blob 생성 (Node/Electron 타입 호환)
const arrayBuf = audioBuffer.buffer.slice(
audioBuffer.byteOffset,
audioBuffer.byteOffset + audioBuffer.byteLength,
) as ArrayBuffer
formData.append(
'audio',
new Blob([arrayBuf], { type: 'application/octet-stream' }),
'audio.pcm',
)
formData.append('language', language)
formData.append('vad_filter', String(vadFilter))
if (initialPrompt) {
formData.append('initial_prompt', initialPrompt)
}
const response = await fetch(
`http://localhost:${this._port}/transcribe`,
{
method: 'POST',
body: formData,
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as TranscribeResponse
const processingTime = Date.now() - startTime
const result: TranscriptionResult = {
text: data.text,
segments: data.segments.map((seg) => ({
text: seg.text,
start: seg.start,
end: seg.end,
confidence: Math.exp(seg.avg_logprob),
})),
language: data.language,
duration: data.duration,
processingTime,
}
// 중간 결과 이벤트 (isFinal=true)
this.emit('transcription-delta', { text: result.text, isFinal: true })
this.emit('transcription-complete', { result })
this._setState(STTState.Ready)
logger.info(
`전사 완료: "${result.text.substring(0, 50)}..." (${processingTime}ms, lang=${result.language})`,
)
return result
} catch (err) {
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
if (err instanceof D3ROError) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
// 타임아웃 구분
if (message.includes('abort') || message.includes('timeout')) {
throw new D3ROError(
ErrorCode.STTTranscriptionTimeout,
`전사 타임아웃: ${message}`,
)
}
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${message}`,
)
}
}
// ── Sidecar Crash 처리 ──
private _handleSidecarCrash(): void {
if (this._disposed) return
this._restartCount++
logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`)
if (this._restartCount > MAX_RESTART_COUNT) {
const err = new D3ROError(
ErrorCode.STTSidecarCrashed,
`Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`,
)
this._setState(STTState.Error)
this._emitError(err)
// pending promise reject
if (this._pendingReject) {
this._pendingReject(err)
this._pendingResolve = null
this._pendingReject = null
}
return
}
// 비동기 재시작
const modelToReload = this._currentModelId
this._currentModelId = null
this._modelReady = false
// setTimeout으로 이벤트 루프에 양보
setTimeout(() => {
if (this._disposed) return
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
logger.error(
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
)
})
}, 1000 * this._restartCount) // 점진적 백오프
}
private async _shutdownSidecar(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
this._sidecarProcess = null
return
}
logger.info('Sidecar 종료 요청')
try {
// POST /shutdown 요청
await fetch(`http://localhost:${this._port}/shutdown`, {
method: 'POST',
signal: AbortSignal.timeout(3000),
})
} catch {
// 이미 종료되었거나 통신 불가 — 무시
}
// 프로세스가 아직 살아있으면 강제 종료
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
this._sidecarProcess.kill('SIGKILL')
}
this._sidecarProcess = null
}
// ── 내부 유틸 ──
private _setState(newState: STTState): void {
if (this._state === newState) return
const prev = this._state
this._state = newState
logger.debug(`STTState: ${prev} -> ${newState}`)
}
private _emitError(error: D3ROError): void {
if (this._errorEmitted) return
this._errorEmitted = true
this.emit('error', { error })
logger.error(`STT 에러: [${error.code}] ${error.message}`)
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
override emit<K extends keyof LocalSTTEvents>(
event: K,
payload: LocalSTTEvents[K],
): boolean {
return super.emit(event, payload)
}
override on<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.on(event, listener)
}
override off<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.off(event, listener)
}
}
// ── 싱글톤 ──
let instance: LocalSTTService | null = null
export function getLocalSTTService(): LocalSTTService {
if (!instance) {
instance = new LocalSTTService()
}
return instance
}
export { LocalSTTService, STTState }