Some checks failed
deploy-site / deploy (push) Failing after 1m15s
Auto-update could not work at all: the installer was 189 MB because it carried the local speech engine and ffmpeg, and the download feed rejects uploads over about 100 MiB, so update metadata could never be published. The installer now leaves those components out and the app fetches them the first time they are needed, verifying every part and the joined archive before installing. The installer is 90.6 MiB, the update feed is published again, and updates stay small because the engine is not re-sent on every release. The fetch is visible and recoverable: the download runs with progress, a failed install cleans up after itself, and Settings > STT shows the runtime status with a manual download action for when the automatic one cannot run.
424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
// 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<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
|
|
}
|
|
|
|
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<string> {
|
|
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<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 resetFileTranscriptionServiceForTests(): void {
|
|
if (instance) instance.removeAllListeners()
|
|
instance = null
|
|
}
|
|
|
|
export function getFileTranscriptionService(): FileTranscriptionService {
|
|
if (!instance) {
|
|
instance = new FileTranscriptionService()
|
|
}
|
|
return instance
|
|
}
|