Phase 12~13 전체 구현: Pro+ 피처 6종 + 음성 대화 + RAG + OS 자동화

Phase 12:
- FileTranscriptionService: ffmpeg PCM 변환 + 30초 청크 순차 STT
- MeetingSummaryService: 자막 세션 → LLM 자동 요약 + DB summaryText
- DictationTemplateService: 필드별 음성 입력 상태 머신 + 프리셋 3개

Phase 13.1:
- VoiceConversationService: STT→Ollama /api/chat→TTS 대화 루프 (10턴)
- TTSPlaybackService: Windows SAPI 문장 단위 큐 재생
- LocalLLMService.chatStream: Ollama /api/chat 스트리밍

Phase 13.2:
- RAGService: Ollama 임베딩 + SQLite 벡터 + 코사인 유사도 검색
- KnowledgeBasePage: 문서 관리 + 질문/답변 UI
- PDF 파서: zlib FlateDecode 해제 + BT/ET 텍스트 추출

Phase 13.3:
- VoiceActionService: LLM JSON 액션 플랜 생성 + 실행
- 프리셋 6개 (크롬/메모장/탐색기/볼륨), 위험 명령 차단

공통: IPC ~70채널, 에러코드 780-878, i18n 100+키
버그픽스: 라이선스 로컬 키 우선, i18n featureLabel, DOM 중첩
This commit is contained in:
Yun Chan 2026-04-05 23:52:14 +09:00
parent a31f96bbb8
commit eb83682269
38 changed files with 5678 additions and 19 deletions

View file

@ -0,0 +1,407 @@
// src/main/services/FileTranscriptionService.ts
// Phase 12.1: 파일 전사 서비스
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
import { EventEmitter } from 'events'
import path from 'path'
import fs from 'fs'
import { nanoid } from 'nanoid'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { getLocalSTTService } from './LocalSTTService'
import { getHistoryService } from './HistoryService'
import { configGet } from './ConfigService'
import { getFfmpegPath } from '../utils/paths'
import { getMainWindow } from '../windows/WindowManager'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { D3ROError, ErrorCode } from '@shared/errors'
import type {
FileTranscriptionState,
FileTranscriptionProgress,
FileTranscriptionResult,
FileTranscriptionSegment,
FileTranscriptionStateInfo,
} from '@shared/types'
const logger = getLogger('FileTranscriptionService')
/** 청크 길이 (초) */
const CHUNK_DURATION_SEC = 30
/** 최대 파일 크기 (2GB) */
const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024 * 1024
/** initialPrompt 컨텍스트 윈도우 (자) */
const CONTEXT_WINDOW_SIZE = 300
/** 지원 확장자 */
const SUPPORTED_EXTENSIONS = new Set([
'.mp3', '.wav', '.m4a', '.ogg', '.flac', '.wma', '.aac',
'.mp4', '.mkv', '.webm', '.avi', '.mov',
])
class FileTranscriptionService extends EventEmitter {
private _state: FileTranscriptionState = 'idle'
private _jobId: string | null = null
private _cancelled = false
private _progress: FileTranscriptionProgress | null = null
private _tempDir: string | null = null
get state(): FileTranscriptionState {
return this._state
}
getStateInfo(): FileTranscriptionStateInfo {
return {
state: this._state,
jobId: this._jobId,
progress: this._progress,
}
}
async startTranscription(filePath: string, language?: string): Promise<FileTranscriptionResult> {
if (this._state !== 'idle') {
throw new D3ROError(ErrorCode.FileTranscriptionChunkFailed, 'Transcription already in progress')
}
// 파일 검증
const ext = path.extname(filePath).toLowerCase()
if (!SUPPORTED_EXTENSIONS.has(ext)) {
throw new D3ROError(ErrorCode.FileTranscriptionInvalidFormat, `Unsupported format: ${ext}`)
}
const stat = fs.statSync(filePath)
if (stat.size > MAX_FILE_SIZE_BYTES) {
throw new D3ROError(ErrorCode.FileTranscriptionFileTooLarge, 'File exceeds 2GB limit')
}
// 라이센스 체크
try {
const { getLicenseService } = await import('./LicenseService')
const { Feature } = await import('@shared/types')
const license = getLicenseService()
const access = license.canUse(Feature.FILE_TRANSCRIPTION)
if (!access.allowed) {
license.promptUpgrade(
Feature.FILE_TRANSCRIPTION,
access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required',
)
throw new D3ROError(ErrorCode.FeatureNotAvailable, 'Pro+ required for file transcription')
}
} catch (err) {
if (err instanceof D3ROError) throw err
}
this._jobId = nanoid()
this._cancelled = false
this._tempDir = path.join(app.getPath('temp'), `d3ro-ft-${this._jobId}`)
fs.mkdirSync(this._tempDir, { recursive: true })
const startTime = Date.now()
const fileName = path.basename(filePath)
try {
// Phase 1: ffmpeg 변환 → PCM WAV
this._setState('converting')
const wavPath = path.join(this._tempDir, 'audio.wav')
await this._convertToWav(filePath, wavPath)
if (this._cancelled) {
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
// 오디오 길이 확인
const totalDurationSec = await this._probeDuration(wavPath)
const totalChunks = Math.ceil(totalDurationSec / CHUNK_DURATION_SEC)
// Phase 2: 청크별 STT
this._setState('transcribing')
const allSegments: FileTranscriptionSegment[] = []
const allTexts: string[] = []
let previousContext = ''
for (let i = 0; i < totalChunks; i++) {
if (this._cancelled) {
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
}
const startSec = i * CHUNK_DURATION_SEC
const chunkBuffer = await this._extractChunk(wavPath, startSec, CHUNK_DURATION_SEC)
const sttService = getLocalSTTService()
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
const result = await sttService.transcribe(chunkBuffer, {
language: lang,
initialPrompt: previousContext || undefined,
vadFilter: true,
})
if (result.text && result.text.trim().length > 0) {
allTexts.push(result.text.trim())
previousContext = result.text.trim().slice(-CONTEXT_WINDOW_SIZE)
for (const seg of result.segments) {
allSegments.push({
text: seg.text,
start: seg.start + startSec,
end: seg.end + startSec,
confidence: seg.confidence,
})
}
}
this._progress = {
jobId: this._jobId!,
currentChunk: i + 1,
totalChunks,
percent: Math.round(((i + 1) / totalChunks) * 100),
currentText: result.text?.trim() ?? '',
}
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, this._progress)
this.emit('progress', this._progress)
}
const fullText = allTexts.join(' ')
const processingTimeMs = Date.now() - startTime
const resultData: FileTranscriptionResult = {
jobId: this._jobId!,
filePath,
fileName,
fullText,
segments: allSegments,
totalDurationSec,
processingTimeMs,
}
// 히스토리에 저장
try {
const wordCount = fullText.split(/\s+/).filter((w) => w.length > 0).length
getHistoryService().create({
originalText: fullText,
polishedText: null,
focusedApp: null,
focusedAppName: null,
focusedAppWindowTitle: null,
mode: 'file-transcription',
status: 'completed',
errorCode: null,
audioLocalPath: filePath,
duration: totalDurationSec,
detectedLanguage: null,
micDevice: null,
wordCount,
sttModel: configGet('sttModelId') as string | null,
llmModel: null,
sttLatencyMs: processingTimeMs,
llmLatencyMs: null,
appVersion: app.getVersion(),
})
} catch (err) {
logger.warn('Failed to save file transcription to history:', err)
}
this._setState('completed')
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.COMPLETE, resultData)
this.emit('complete', resultData)
return resultData
} catch (err) {
if (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled) {
this._setState('idle')
throw err
}
this._setState('error')
const errorMsg = err instanceof Error ? err.message : String(err)
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.ERROR, { message: errorMsg })
this.emit('error', err)
throw err instanceof D3ROError
? err
: new D3ROError(ErrorCode.FileTranscriptionChunkFailed, errorMsg)
} finally {
this._cleanup()
// 완료/에러 후 idle로 복귀
setTimeout(() => {
this._state = 'idle'
this._jobId = null
this._progress = null
}, 1000)
}
}
cancel(): void {
if (this._state !== 'idle') {
this._cancelled = true
logger.info(`File transcription cancelled: ${this._jobId}`)
}
}
private _setState(state: FileTranscriptionState): void {
this._state = state
this.emit('state-changed', state)
}
private _sendToRenderer(channel: string, data: unknown): void {
try {
const mainWindow = getMainWindow()
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, data)
}
} catch {
// 윈도우 없으면 무시
}
}
/**
* ffmpeg로 PCM16 16kHz mono WAV로
*/
private _convertToWav(inputPath: string, outputPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process') as typeof import('child_process')
const ffmpegPath = getFfmpegPath()
const args = [
'-i', inputPath,
'-ar', '16000',
'-ac', '1',
'-sample_fmt', 's16',
'-y',
outputPath,
]
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
})
proc.on('close', (code: number) => {
if (code === 0) {
resolve()
} else {
logger.error(`ffmpeg failed (code ${code}):`, stderr.slice(-500))
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, `ffmpeg exited with code ${code}`))
}
})
proc.on('error', (err: Error) => {
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, `ffmpeg error: ${err.message}`))
})
})
}
/**
* ffprobe(ffmpeg) ()
*/
private _probeDuration(wavPath: string): Promise<number> {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process') as typeof import('child_process')
const ffmpegPath = getFfmpegPath()
// ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나,
// @ffmpeg-installer는 ffmpeg만 제공 → -i로 duration 추출
const args = [
'-i', wavPath,
'-f', 'null',
'-',
]
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
let stderr = ''
proc.stderr?.on('data', (data: Buffer) => {
stderr += data.toString()
})
proc.on('close', () => {
// "Duration: HH:MM:SS.ms" 패턴 파싱
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/)
if (match) {
const hours = parseInt(match[1], 10)
const minutes = parseInt(match[2], 10)
const seconds = parseInt(match[3], 10)
const ms = parseInt(match[4], 10) / 100
resolve(hours * 3600 + minutes * 60 + seconds + ms)
} else {
// WAV 파일 크기로 폴백 추정 (16kHz 16bit mono = 32000 bytes/sec)
try {
const stat = fs.statSync(wavPath)
const headerSize = 44
const bytesPerSec = 16000 * 2 * 1
resolve(Math.max(0, (stat.size - headerSize) / bytesPerSec))
} catch {
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration'))
}
}
})
})
}
/**
* WAV PCM16 Buffer로
*/
private _extractChunk(wavPath: string, startSec: number, durationSec: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process') as typeof import('child_process')
const ffmpegPath = getFfmpegPath()
const args = [
'-ss', String(startSec),
'-t', String(durationSec),
'-i', wavPath,
'-ar', '16000',
'-ac', '1',
'-f', 's16le',
'-acodec', 'pcm_s16le',
'pipe:1',
]
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
const chunks: Buffer[] = []
proc.stdout?.on('data', (data: Buffer) => {
chunks.push(data)
})
proc.on('close', (code: number) => {
if (code === 0 || chunks.length > 0) {
resolve(Buffer.concat(chunks))
} else {
reject(new D3ROError(ErrorCode.FileTranscriptionChunkFailed, `Chunk extraction failed at ${startSec}s`))
}
})
proc.on('error', (err: Error) => {
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, `ffmpeg chunk error: ${err.message}`))
})
})
}
private _cleanup(): void {
if (this._tempDir && fs.existsSync(this._tempDir)) {
try {
fs.rmSync(this._tempDir, { recursive: true, force: true })
} catch (err) {
logger.warn('Failed to cleanup temp dir:', err)
}
this._tempDir = null
}
}
dispose(): void {
this.cancel()
this._cleanup()
this.removeAllListeners()
}
}
// ── 싱글톤 ──
let instance: FileTranscriptionService | null = null
export function getFileTranscriptionService(): FileTranscriptionService {
if (!instance) {
instance = new FileTranscriptionService()
}
return instance
}