fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway
This commit is contained in:
parent
3a46437f28
commit
ddc78546f0
62 changed files with 4786 additions and 648 deletions
|
|
@ -3,7 +3,7 @@
|
|||
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { spawn } from 'child_process'
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
|
@ -54,16 +54,31 @@ const defaultFfmpegLocator: FfmpegLocator = {
|
|||
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'
|
||||
private _jobId: string | null = null
|
||||
private _cancelled = false
|
||||
/** 현재 작업. 취소하면 곧바로 null 이 되고, 남은 정리는 그 작업 객체만 건드린다. */
|
||||
private _job: TranscriptionJob | null = null
|
||||
private _progress: FileTranscriptionProgress | null = null
|
||||
private _tempDir: string | null = null
|
||||
|
||||
get state(): FileTranscriptionState {
|
||||
return this._state
|
||||
|
|
@ -72,7 +87,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
getStateInfo(): FileTranscriptionStateInfo {
|
||||
return {
|
||||
state: this._state,
|
||||
jobId: this._jobId,
|
||||
jobId: this._job?.id ?? null,
|
||||
progress: this._progress,
|
||||
}
|
||||
}
|
||||
|
|
@ -110,45 +125,49 @@ class FileTranscriptionService extends EventEmitter {
|
|||
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 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._setState('converting')
|
||||
this._setJobState(job, 'converting')
|
||||
const ffmpegPath = await this._ensureFfmpeg()
|
||||
if (this._cancelled) {
|
||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
const wavPath = path.join(this._tempDir, 'audio.wav')
|
||||
await this._convertToWav(ffmpegPath, filePath, wavPath)
|
||||
|
||||
if (this._cancelled) {
|
||||
throw new D3ROError(ErrorCode.FileTranscriptionCancelled, 'Transcription cancelled')
|
||||
}
|
||||
throwIfCancelled()
|
||||
const wavPath = path.join(job.tempDir, 'audio.wav')
|
||||
await this._convertToWav(job, ffmpegPath, filePath, wavPath)
|
||||
throwIfCancelled()
|
||||
|
||||
// 오디오 길이 확인
|
||||
const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath)
|
||||
const totalDurationSec = await this._probeDuration(job, ffmpegPath, wavPath)
|
||||
throwIfCancelled()
|
||||
const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC)
|
||||
|
||||
// Phase 2: 청크별 STT
|
||||
this._setState('transcribing')
|
||||
this._setJobState(job, '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')
|
||||
}
|
||||
throwIfCancelled()
|
||||
|
||||
const startSec = i * CHUNK_DURATION_SEC
|
||||
const chunkBuffer = await this._extractChunk(ffmpegPath, wavPath, startSec, 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'
|
||||
|
|
@ -158,6 +177,8 @@ class FileTranscriptionService extends EventEmitter {
|
|||
initialPrompt: previousContext || undefined,
|
||||
vadFilter: true,
|
||||
})
|
||||
// 인식 중에 취소됐으면 결과를 버린다(STT 호출 자체는 끊을 수 없다)
|
||||
throwIfCancelled()
|
||||
|
||||
if (result.text && result.text.trim().length > 0) {
|
||||
allTexts.push(result.text.trim())
|
||||
|
|
@ -173,23 +194,24 @@ class FileTranscriptionService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
this._progress = {
|
||||
jobId: this._jobId!,
|
||||
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, this._progress)
|
||||
this.emit('progress', this._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: this._jobId!,
|
||||
jobId,
|
||||
filePath,
|
||||
fileName,
|
||||
fullText,
|
||||
|
|
@ -225,39 +247,50 @@ class FileTranscriptionService extends EventEmitter {
|
|||
logger.warn('Failed to save file transcription to history:', err)
|
||||
}
|
||||
|
||||
this._setState('completed')
|
||||
this._setJobState(job, '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
|
||||
// 취소된 작업의 실패(ffmpeg 종료 등)는 모두 취소로 본다
|
||||
if (job.cancelled || (err instanceof D3ROError && err.code === ErrorCode.FileTranscriptionCancelled)) {
|
||||
throw cancelledError()
|
||||
}
|
||||
this._setState('error')
|
||||
this._setJobState(job, 'error')
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
this._sendToRenderer(IPC_CHANNELS.FILE_TRANSCRIPTION.ERROR, { message: errorMsg })
|
||||
this.emit('error', err)
|
||||
// 'error' 는 리스너가 없으면 던져진다 — 실패 알림은 'failure' 로 보낸다
|
||||
this.emit('failure', err)
|
||||
throw err instanceof D3ROError
|
||||
? err
|
||||
: new D3ROError(ErrorCode.FileTranscriptionChunkFailed, errorMsg)
|
||||
} finally {
|
||||
this._cleanup()
|
||||
// 완료/에러 후 idle로 복귀
|
||||
setTimeout(() => {
|
||||
this._state = 'idle'
|
||||
this._jobId = null
|
||||
this._killProcesses(job)
|
||||
this._cleanup(job)
|
||||
// 완료/에러/취소 후 곧바로 idle — 예전엔 1초 뒤 타이머가 작업 확인 없이 idle 로 돌려, 그사이 시작한
|
||||
// 다음 작업의 상태·jobId·진행률을 지웠다. 이 작업이 아직 현재 작업일 때만 되돌린다.
|
||||
if (this._job === job) {
|
||||
this._job = null
|
||||
this._progress = null
|
||||
}, 1000)
|
||||
this._state = 'idle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 작업을 취소한다 — 실행 중인 ffmpeg 를 곧바로 종료하고, 서비스는 즉시 idle 이 되어 새 작업을
|
||||
* 받을 수 있다(취소된 작업의 남은 정리는 그 작업의 임시 폴더만 건드린다).
|
||||
*/
|
||||
cancel(): void {
|
||||
if (this._state !== 'idle') {
|
||||
this._cancelled = true
|
||||
logger.info(`File transcription cancelled: ${this._jobId}`)
|
||||
}
|
||||
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 {
|
||||
|
|
@ -265,6 +298,37 @@ class FileTranscriptionService extends EventEmitter {
|
|||
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()
|
||||
|
|
@ -298,13 +362,16 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
|
||||
*/
|
||||
private _convertToWav(ffmpegPath: string, inputPath: string, outputPath: string): Promise<void> {
|
||||
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 = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -329,13 +396,16 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
|
||||
*/
|
||||
private _probeDuration(ffmpegPath: string, wavPath: string): Promise<number> {
|
||||
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 = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
let stderr = ''
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
|
|
@ -362,6 +432,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
* WAV 파일에서 특정 구간을 PCM16 Buffer로 추출
|
||||
*/
|
||||
private _extractChunk(
|
||||
job: TranscriptionJob,
|
||||
ffmpegPath: string,
|
||||
wavPath: string,
|
||||
startSec: number,
|
||||
|
|
@ -370,7 +441,10 @@ class FileTranscriptionService extends EventEmitter {
|
|||
return new Promise((resolve, reject) => {
|
||||
const args = buildExtractChunkArgs(wavPath, startSec, durationSec)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const proc = this._trackProcess(
|
||||
job,
|
||||
spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }),
|
||||
)
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
|
|
@ -391,20 +465,21 @@ class FileTranscriptionService extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
private _cleanup(): void {
|
||||
if (this._tempDir && fs.existsSync(this._tempDir)) {
|
||||
/** 이 작업의 임시 폴더만 지운다 */
|
||||
private _cleanup(job: TranscriptionJob): void {
|
||||
if (fs.existsSync(job.tempDir)) {
|
||||
try {
|
||||
fs.rmSync(this._tempDir, { recursive: true, force: true })
|
||||
fs.rmSync(job.tempDir, { recursive: true, force: true })
|
||||
} catch (err) {
|
||||
logger.warn('Failed to cleanup temp dir:', err)
|
||||
}
|
||||
this._tempDir = null
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
const job = this._job
|
||||
this.cancel()
|
||||
this._cleanup()
|
||||
if (job) this._cleanup(job)
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue