d3ro-voice/apps/desktop/src/main/services/FileTranscriptionService.ts

500 lines
16 KiB
TypeScript

// 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<ChildProcess>
}
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<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('@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<string> {
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<void> {
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<number> {
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<Buffer> {
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
}