// 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 { 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 { 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', ]) 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 { 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 } this._jobId = crypto.randomUUID() 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 = getSTTManager() 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 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로 * 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요). */ private async _ensureFfmpeg(): Promise { const resolved = getFfmpegPath() if (resolved !== 'ffmpeg') return resolved logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다') return getRuntimeProvisioner().ensure('ffmpeg') } /** * ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환 */ private _convertToWav(inputPath: string, outputPath: string): Promise { 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 { 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 { 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 resetFileTranscriptionServiceForTests(): void { if (instance) instance.removeAllListeners() instance = null } export function getFileTranscriptionService(): FileTranscriptionService { if (!instance) { instance = new FileTranscriptionService() } return instance }