// src/main/services/FileTranscriptionService.ts // Phase 12.1: 파일 전사 서비스 // 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합 import { EventEmitter } from 'events' import { spawn, type ChildProcess } from 'child_process' import path from 'path' import fs from 'fs' import { app } from 'electron' import { getLogger } from './LoggerService' import { getSTTManager } from './stt/STTManager' import { getHistoryService } from './HistoryService' import { configGet } from './ConfigService' import { getFfmpegPath } from '../utils/paths' import { getRuntimeProvisioner } from './RuntimeProvisioner' import { buildConvertToWavArgs, buildExtractChunkArgs, buildProbeDurationArgs, countChunks, estimateWavDurationSec, parseFfmpegDuration, resolveFfmpegExecutable, type FfmpegLocator, } from './file-transcription/ffmpeg-media' import { getMainWindow } from '../windows/WindowManager' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { FileTranscriptionState, FileTranscriptionProgress, FileTranscriptionResult, FileTranscriptionSegment, FileTranscriptionStateInfo, } from '@d3ro/core/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', ]) /** 기본 ffmpeg 위치 포트: 로컬 사본 조회 + 런타임 feed 다운로드 */ const defaultFfmpegLocator: FfmpegLocator = { locate: () => getFfmpegPath(), provision: () => getRuntimeProvisioner().ensure('ffmpeg'), } /** * 전사 작업 한 건의 상태. 싱글톤 필드에 두면 취소된 작업의 늦은 정리(임시 폴더 삭제·idle 복귀)가 * 다음 작업을 덮었다 — 작업마다 따로 갖는다. */ interface TranscriptionJob { readonly id: string readonly tempDir: string cancelled: boolean /** 이 작업이 띄운 ffmpeg 프로세스 — 취소하면 모두 종료한다 */ readonly processes: Set } function cancelledError(): D3ROError { return new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled') } class FileTranscriptionService extends EventEmitter { constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) { super() } private _state: FileTranscriptionState = 'idle' /** 현재 작업. 취소하면 곧바로 null 이 되고, 남은 정리는 그 작업 객체만 건드린다. */ private _job: TranscriptionJob | null = null private _progress: FileTranscriptionProgress | null = null get state(): FileTranscriptionState { return this._state } getStateInfo(): FileTranscriptionStateInfo { return { state: this._state, jobId: this._job?.id ?? null, progress: this._progress, } } async startTranscription(filePath: string, language?: string): Promise { 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('@d3ro/core/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 } const jobId = crypto.randomUUID() const job: TranscriptionJob = { id: jobId, tempDir: path.join(app.getPath('temp'), `d3ro-ft-${jobId}`), cancelled: false, processes: new Set(), } this._job = job this._progress = null fs.mkdirSync(job.tempDir, { recursive: true }) const startTime = Date.now() const fileName = path.basename(filePath) const throwIfCancelled = (): void => { if (job.cancelled) throw cancelledError() } try { // Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환 this._setJobState(job, 'converting') const ffmpegPath = await this._ensureFfmpeg() throwIfCancelled() const wavPath = path.join(job.tempDir, 'audio.wav') await this._convertToWav(job, ffmpegPath, filePath, wavPath) throwIfCancelled() // 오디오 길이 확인 const totalDurationSec = await this._probeDuration(job, ffmpegPath, wavPath) throwIfCancelled() const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC) // Phase 2: 청크별 STT this._setJobState(job, 'transcribing') const allSegments: FileTranscriptionSegment[] = [] const allTexts: string[] = [] let previousContext = '' for (let i = 0; i < totalChunks; i++) { throwIfCancelled() const startSec = i * CHUNK_DURATION_SEC const chunkBuffer = await this._extractChunk(job, ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC) throwIfCancelled() const sttService = getSTTManager() const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto' const result = await sttService.transcribe(chunkBuffer, { language: lang, initialPrompt: previousContext || undefined, vadFilter: true, }) // 인식 중에 취소됐으면 결과를 버린다(STT 호출 자체는 끊을 수 없다) throwIfCancelled() 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, }) } } const progress: FileTranscriptionProgress = { jobId, currentChunk: i + 1, totalChunks, percent: Math.round(((i + 1) / totalChunks) * 100), currentText: result.text?.trim() ?? '', } if (this._job === job) this._progress = progress this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.PROGRESS, progress) this.emit('progress', progress) } const fullText = allTexts.join(' ') const processingTimeMs = Date.now() - startTime const resultData: FileTranscriptionResult = { 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._setJobState(job, 'completed') this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.COMPLETE, resultData) this.emit('complete', resultData) return resultData } catch (err) { // 취소된 작업의 실패(ffmpeg 종료 등)는 모두 취소로 본다 if (job.cancelled || (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled)) { throw cancelledError() } this._setJobState(job, 'error') const errorMsg = err instanceof Error ? err.message : String(err) this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.ERROR, { message: errorMsg }) // 'error' 는 리스너가 없으면 던져진다 — 실패 알림은 'failure' 로 보낸다 this.emit('failure', err) throw err instanceof D3ROError ? err : new D3ROError(ErrorCode.FileTranscriptionChunkFailed, errorMsg) } finally { this._killProcesses(job) this._cleanup(job) // 완료/에러/취소 후 곧바로 idle — 예전엔 1초 뒤 타이머가 작업 확인 없이 idle 로 돌려, 그사이 시작한 // 다음 작업의 상태·jobId·진행률을 지웠다. 이 작업이 아직 현재 작업일 때만 되돌린다. if (this._job === job) { this._job = null this._progress = null this._state = 'idle' } } } /** * 현재 작업을 취소한다 — 실행 중인 ffmpeg 를 곧바로 종료하고, 서비스는 즉시 idle 이 되어 새 작업을 * 받을 수 있다(취소된 작업의 남은 정리는 그 작업의 임시 폴더만 건드린다). */ cancel(): void { const job = this._job if (!job) return job.cancelled = true this._killProcesses(job) this._job = null this._progress = null this._setState('idle') logger.info(`File transcription cancelled: ${job.id}`) } private _setState(state: FileTranscriptionState): void { this._state = state this.emit('state-changed', state) } /** 현재 작업의 상태만 바꾼다 — 취소된 작업이 뒤늦게 상태를 덮지 않게 */ private _setJobState(job: TranscriptionJob, state: FileTranscriptionState): void { if (this._job !== job) return this._setState(state) } /** 작업의 ffmpeg 프로세스를 추적한다(취소 시 종료). 이미 취소된 작업이면 곧바로 종료한다. */ private _trackProcess(job: TranscriptionJob, proc: ChildProcess): ChildProcess { job.processes.add(proc) const forget = (): void => { job.processes.delete(proc) } proc.on('close', forget) proc.on('error', forget) if (job.cancelled) this._killProcess(proc) return proc } private _killProcesses(job: TranscriptionJob): void { for (const proc of job.processes) this._killProcess(proc) job.processes.clear() } private _killProcess(proc: ChildProcess): void { try { if (proc.exitCode === null && !proc.killed) proc.kill() } catch (err) { logger.warn(`ffmpeg kill failed: ${err instanceof Error ? err.message : String(err)}`) } } private _sendToRenderer(channel: string, data: unknown): void { try { const mainWindow = getMainWindow() if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, data) } } catch { // 윈도우 없으면 무시 } } /** * ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로 * 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요). */ private _ensureFfmpeg(): Promise { return resolveFfmpegExecutable( { locate: () => this._ffmpegLocator.locate(), provision: () => { logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다') return this._ffmpegLocator.provision() }, }, (err) => { logger.warn('ffmpeg 자동 다운로드 실패 — 시스템 PATH의 ffmpeg으로 시도합니다:', err) }, ) } /** * ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환 */ private _convertToWav(job: TranscriptionJob, ffmpegPath: string, inputPath: string, outputPath: string): Promise { return new Promise((resolve, reject) => { const args = buildConvertToWavArgs(inputPath, outputPath) logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`) const proc = this._trackProcess( job, spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }), ) 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(job: TranscriptionJob, ffmpegPath: string, wavPath: string): Promise { return new Promise((resolve, reject) => { // ffprobe는 보통 ffmpeg과 같은 디렉토리에 있으나, // 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출 const args = buildProbeDurationArgs(wavPath) const proc = this._trackProcess( job, spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }), ) let stderr = '' proc.stderr?.on('data', (data: Buffer) => { stderr += data.toString() }) proc.on('close', () => { const parsed = parseFfmpegDuration(stderr) if (parsed !== null) { resolve(parsed) } else { // WAV 파일 크기로 폴백 추정 (16kHz 16bit mono = 32000 bytes/sec) try { resolve(estimateWavDurationSec(fs.statSync(wavPath).size)) } catch { reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration')) } } }) }) } /** * WAV 파일에서 특정 구간을 PCM16 Buffer로 추출 */ private _extractChunk( job: TranscriptionJob, ffmpegPath: string, wavPath: string, startSec: number, durationSec: number, ): Promise { return new Promise((resolve, reject) => { const args = buildExtractChunkArgs(wavPath, startSec, durationSec) const proc = this._trackProcess( job, spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }), ) 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(job: TranscriptionJob): void { if (fs.existsSync(job.tempDir)) { try { fs.rmSync(job.tempDir, { recursive: true, force: true }) } catch (err) { logger.warn('Failed to cleanup temp dir:', err) } } } dispose(): void { const job = this._job this.cancel() if (job) this._cleanup(job) this.removeAllListeners() } } // ── 싱글톤 ── let instance: FileTranscriptionService | null = null export function resetFileTranscriptionServiceForTests(): void { if (instance) instance.removeAllListeners() instance = null } export function getFileTranscriptionService(): FileTranscriptionService { if (!instance) { instance = new FileTranscriptionService() } return instance }