feat(V2-1a): Monorepo 구조 전환 — apps/desktop으로 V1 이동
- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
This commit is contained in:
parent
3a160b9032
commit
45a580878a
178 changed files with 214 additions and 0 deletions
407
apps/desktop/src/main/services/FileTranscriptionService.ts
Normal file
407
apps/desktop/src/main/services/FileTranscriptionService.ts
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
// 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 { nanoid } from 'nanoid'
|
||||
import { app } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getLocalSTTService } from './LocalSTTService'
|
||||
import { getHistoryService } from './HistoryService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { getFfmpegPath } from '../utils/paths'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type {
|
||||
FileTranscriptionState,
|
||||
FileTranscriptionProgress,
|
||||
FileTranscriptionResult,
|
||||
FileTranscriptionSegment,
|
||||
FileTranscriptionStateInfo,
|
||||
} from '@shared/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('@shared/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 = nanoid()
|
||||
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 = getLocalSTTService()
|
||||
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로 미디어 파일을 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 getFileTranscriptionService(): FileTranscriptionService {
|
||||
if (!instance) {
|
||||
instance = new FileTranscriptionService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue