fix(file-transcription): provision ffmpeg before converting so clean installs stop failing with ENOENT
This commit is contained in:
parent
c5b3bdedb6
commit
d96601a283
3 changed files with 329 additions and 58 deletions
|
|
@ -3,6 +3,7 @@
|
|||
// 오디오/비디오 파일 → ffmpeg PCM 변환 → 30초 청크 순차 STT → 병합
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { spawn } from 'child_process'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
|
@ -12,6 +13,16 @@ 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'
|
||||
|
|
@ -37,7 +48,17 @@ const SUPPORTED_EXTENSIONS = new Set([
|
|||
'.mp4', '.mkv', '.webm', '.avi', '.mov',
|
||||
])
|
||||
|
||||
/** 기본 ffmpeg 위치 포트: 로컬 사본 조회 + 런타임 feed 다운로드 */
|
||||
const defaultFfmpegLocator: FfmpegLocator = {
|
||||
locate: () => getFfmpegPath(),
|
||||
provision: () => getRuntimeProvisioner().ensure('ffmpeg'),
|
||||
}
|
||||
|
||||
class FileTranscriptionService extends EventEmitter {
|
||||
constructor(private readonly _ffmpegLocator: FfmpegLocator = defaultFfmpegLocator) {
|
||||
super()
|
||||
}
|
||||
|
||||
private _state: FileTranscriptionState = 'idle'
|
||||
private _jobId: string | null = null
|
||||
private _cancelled = false
|
||||
|
|
@ -98,18 +119,22 @@ class FileTranscriptionService extends EventEmitter {
|
|||
const fileName = path.basename(filePath)
|
||||
|
||||
try {
|
||||
// Phase 1: ffmpeg 변환 → PCM WAV
|
||||
// Phase 1: ffmpeg 확보(설치본에는 없으므로 필요 시 다운로드) → PCM WAV 변환
|
||||
this._setState('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(filePath, wavPath)
|
||||
await this._convertToWav(ffmpegPath, 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)
|
||||
const totalDurationSec = await this._probeDuration(ffmpegPath, wavPath)
|
||||
const totalChunks = countChunks(totalDurationSec, CHUNK_DURATION_SEC)
|
||||
|
||||
// Phase 2: 청크별 STT
|
||||
this._setState('transcribing')
|
||||
|
|
@ -123,7 +148,7 @@ class FileTranscriptionService extends EventEmitter {
|
|||
}
|
||||
|
||||
const startSec = i * CHUNK_DURATION_SEC
|
||||
const chunkBuffer = await this._extractChunk(wavPath, startSec, CHUNK_DURATION_SEC)
|
||||
const chunkBuffer = await this._extractChunk(ffmpegPath, wavPath, startSec, CHUNK_DURATION_SEC)
|
||||
|
||||
const sttService = getSTTManager()
|
||||
const lang = language ?? (configGet('sttLanguage') as string | undefined) ?? 'auto'
|
||||
|
|
@ -255,30 +280,27 @@ class FileTranscriptionService extends EventEmitter {
|
|||
* ffmpeg 실행 파일을 확보한다. 설치본에는 ffmpeg을 넣지 않으므로
|
||||
* 없으면 feed에서 내려받는다(파일 전사/회의 모드에서만 필요).
|
||||
*/
|
||||
private async _ensureFfmpeg(): Promise<string> {
|
||||
const resolved = getFfmpegPath()
|
||||
if (resolved !== 'ffmpeg') return resolved
|
||||
|
||||
private _ensureFfmpeg(): Promise<string> {
|
||||
return resolveFfmpegExecutable(
|
||||
{
|
||||
locate: () => this._ffmpegLocator.locate(),
|
||||
provision: () => {
|
||||
logger.info('ffmpeg이 없습니다 — 자동 다운로드를 시작합니다')
|
||||
return getRuntimeProvisioner().ensure('ffmpeg')
|
||||
return this._ffmpegLocator.provision()
|
||||
},
|
||||
},
|
||||
(err) => {
|
||||
logger.warn('ffmpeg 자동 다운로드 실패 — 시스템 PATH의 ffmpeg으로 시도합니다:', err)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg로 미디어 파일을 PCM16 16kHz mono WAV로 변환
|
||||
*/
|
||||
private _convertToWav(inputPath: string, outputPath: string): Promise<void> {
|
||||
private _convertToWav(ffmpegPath: string, 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,
|
||||
]
|
||||
const args = buildConvertToWavArgs(inputPath, outputPath)
|
||||
|
||||
logger.info(`ffmpeg convert: ${ffmpegPath} ${args.join(' ')}`)
|
||||
|
||||
|
|
@ -307,17 +329,11 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* ffprobe(ffmpeg)로 오디오 길이 측정 (초)
|
||||
*/
|
||||
private _probeDuration(wavPath: string): Promise<number> {
|
||||
private _probeDuration(ffmpegPath: string, 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',
|
||||
'-',
|
||||
]
|
||||
// 내려받는 런타임은 ffmpeg만 제공 → -i로 duration 추출
|
||||
const args = buildProbeDurationArgs(wavPath)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
let stderr = ''
|
||||
|
|
@ -327,21 +343,13 @@ class FileTranscriptionService extends EventEmitter {
|
|||
})
|
||||
|
||||
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)
|
||||
const parsed = parseFfmpegDuration(stderr)
|
||||
if (parsed !== null) {
|
||||
resolve(parsed)
|
||||
} 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))
|
||||
resolve(estimateWavDurationSec(fs.statSync(wavPath).size))
|
||||
} catch {
|
||||
reject(new D3ROError(ErrorCode.FileTranscriptionFFmpegFailed, 'Cannot determine audio duration'))
|
||||
}
|
||||
|
|
@ -353,21 +361,14 @@ class FileTranscriptionService extends EventEmitter {
|
|||
/**
|
||||
* WAV 파일에서 특정 구간을 PCM16 Buffer로 추출
|
||||
*/
|
||||
private _extractChunk(wavPath: string, startSec: number, durationSec: number): Promise<Buffer> {
|
||||
private _extractChunk(
|
||||
ffmpegPath: string,
|
||||
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 args = buildExtractChunkArgs(wavPath, startSec, durationSec)
|
||||
|
||||
const proc = spawn(ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
|
||||
const chunks: Buffer[] = []
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
// src/main/services/file-transcription/ffmpeg-media.ts
|
||||
// 파일 전사의 ffmpeg 관련 순수 정책: 실행 파일 확보 순서, 인자 구성, 길이 파싱/추정, 청크 분할.
|
||||
// IO(프로세스 실행, 다운로드, 파일 조회)는 호출자가 포트로 주입한다.
|
||||
|
||||
/** 로컬에서 ffmpeg을 찾지 못했을 때 paths.getFfmpegPath()가 돌려주는 PATH 폴백 이름 */
|
||||
export const SYSTEM_FFMPEG = 'ffmpeg'
|
||||
|
||||
/** 전사 입력 PCM 포맷: 16kHz, 16bit, mono */
|
||||
export const PCM_SAMPLE_RATE = 16000
|
||||
const PCM_BYTES_PER_SAMPLE = 2
|
||||
const PCM_CHANNELS = 1
|
||||
const WAV_HEADER_BYTES = 44
|
||||
|
||||
/**
|
||||
* ffmpeg 실행 파일을 찾고, 없으면 내려받는 포트.
|
||||
* - locate: 번들/내려받은 사본/npm 패키지 순으로 찾고, 없으면 SYSTEM_FFMPEG을 돌려준다.
|
||||
* - provision: 런타임 feed에서 내려받아 설치된 경로를 돌려준다.
|
||||
*/
|
||||
export interface FfmpegLocator {
|
||||
locate(): string
|
||||
provision(): Promise<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 스폰할 ffmpeg 경로를 정한다.
|
||||
* 로컬 사본이 있으면 그대로 쓰고, 없으면 내려받는다. 다운로드가 실패하면
|
||||
* 시스템 PATH의 ffmpeg으로 폴백한다 (오프라인이지만 ffmpeg을 직접 설치한 사용자).
|
||||
*/
|
||||
export async function resolveFfmpegExecutable(
|
||||
locator: FfmpegLocator,
|
||||
onProvisionFailed?: (err: unknown) => void,
|
||||
): Promise<string> {
|
||||
const local = locator.locate()
|
||||
if (local !== SYSTEM_FFMPEG) return local
|
||||
|
||||
try {
|
||||
return await locator.provision()
|
||||
} catch (err) {
|
||||
onProvisionFailed?.(err)
|
||||
return SYSTEM_FFMPEG
|
||||
}
|
||||
}
|
||||
|
||||
/** 미디어 파일 → PCM16 16kHz mono WAV 변환 인자 */
|
||||
export function buildConvertToWavArgs(inputPath: string, outputPath: string): string[] {
|
||||
return [
|
||||
'-i', inputPath,
|
||||
'-ar', String(PCM_SAMPLE_RATE),
|
||||
'-ac', String(PCM_CHANNELS),
|
||||
'-sample_fmt', 's16',
|
||||
'-y',
|
||||
outputPath,
|
||||
]
|
||||
}
|
||||
|
||||
/** 길이 측정 인자 (ffprobe 없이 ffmpeg -i 의 stderr Duration 을 읽는다) */
|
||||
export function buildProbeDurationArgs(wavPath: string): string[] {
|
||||
return ['-i', wavPath, '-f', 'null', '-']
|
||||
}
|
||||
|
||||
/** WAV의 [startSec, startSec+durationSec) 구간을 raw PCM16 으로 stdout 에 내보내는 인자 */
|
||||
export function buildExtractChunkArgs(wavPath: string, startSec: number, durationSec: number): string[] {
|
||||
return [
|
||||
'-ss', String(startSec),
|
||||
'-t', String(durationSec),
|
||||
'-i', wavPath,
|
||||
'-ar', String(PCM_SAMPLE_RATE),
|
||||
'-ac', String(PCM_CHANNELS),
|
||||
'-f', 's16le',
|
||||
'-acodec', 'pcm_s16le',
|
||||
'pipe:1',
|
||||
]
|
||||
}
|
||||
|
||||
/** ffmpeg stderr 의 "Duration: HH:MM:SS.cc" 를 초로 바꾼다. 없으면 null. */
|
||||
export function parseFfmpegDuration(stderr: string): number | null {
|
||||
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/)
|
||||
if (!match) return null
|
||||
const hours = parseInt(match[1], 10)
|
||||
const minutes = parseInt(match[2], 10)
|
||||
const seconds = parseInt(match[3], 10)
|
||||
const fraction = parseInt(match[4], 10) / 100
|
||||
return hours * 3600 + minutes * 60 + seconds + fraction
|
||||
}
|
||||
|
||||
/** PCM16 16kHz mono WAV 파일 크기로 길이를 추정한다 (초). */
|
||||
export function estimateWavDurationSec(fileSizeBytes: number): number {
|
||||
const bytesPerSec = PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * PCM_CHANNELS
|
||||
return Math.max(0, (fileSizeBytes - WAV_HEADER_BYTES) / bytesPerSec)
|
||||
}
|
||||
|
||||
/** 전체 길이를 청크 길이로 나눈 청크 수 */
|
||||
export function countChunks(totalDurationSec: number, chunkDurationSec: number): number {
|
||||
return Math.ceil(totalDurationSec / chunkDurationSec)
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// 회귀: 설치본에는 ffmpeg이 없으므로 파일 전사는 시작 시 런타임 ffmpeg을 확보해야 한다.
|
||||
// 이전에는 _ensureFfmpeg()가 호출되지 않아 bare 'ffmpeg'을 스폰 → spawn ENOENT 로 전사가 전부 실패했다.
|
||||
import { EventEmitter } from 'events'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SYSTEM_FFMPEG,
|
||||
buildConvertToWavArgs,
|
||||
buildExtractChunkArgs,
|
||||
buildProbeDurationArgs,
|
||||
countChunks,
|
||||
estimateWavDurationSec,
|
||||
parseFfmpegDuration,
|
||||
resolveFfmpegExecutable,
|
||||
} from '../../../src/main/services/file-transcription/ffmpeg-media'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
getFfmpegPath: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({ spawn: hoisted.spawn }))
|
||||
|
||||
vi.mock('../../../src/main/utils/paths', () => ({
|
||||
getFfmpegPath: hoisted.getFfmpegPath,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({
|
||||
getRuntimeProvisioner: () => ({ ensure: hoisted.ensure }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn(() => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/stt/STTManager', () => ({
|
||||
getSTTManager: () => ({ transcribe: hoisted.transcribe }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/HistoryService', () => ({
|
||||
getHistoryService: () => ({ create: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ canUse: () => ({ allowed: true }), promptUpgrade: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
getMainWindow: () => null,
|
||||
}))
|
||||
|
||||
type FakeProcess = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter }
|
||||
|
||||
/** 실행 파일이 'ffmpeg'(PATH 폴백)이면 ENOENT, 그 밖에는 정상 ffmpeg 처럼 동작하는 가짜 프로세스 */
|
||||
function fakeSpawn(command: string, args: string[]): FakeProcess {
|
||||
const proc = Object.assign(new EventEmitter(), {
|
||||
stdout: new EventEmitter(),
|
||||
stderr: new EventEmitter(),
|
||||
})
|
||||
queueMicrotask(() => {
|
||||
if (command === SYSTEM_FFMPEG) {
|
||||
proc.emit('error', new Error('spawn ffmpeg ENOENT'))
|
||||
return
|
||||
}
|
||||
if (args.includes('null')) proc.stderr.emit('data', Buffer.from('Duration: 00:00:45.00'))
|
||||
if (args.includes('pipe:1')) proc.stdout.emit('data', Buffer.from('pcm'))
|
||||
proc.emit('close', 0)
|
||||
})
|
||||
return proc
|
||||
}
|
||||
|
||||
describe('FileTranscriptionService ffmpeg provisioning', () => {
|
||||
let inputFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
hoisted.spawn.mockImplementation(fakeSpawn)
|
||||
hoisted.transcribe.mockResolvedValue({ text: 'hello', segments: [] })
|
||||
inputFile = path.join(os.tmpdir(), `d3ro-ft-redteam-${Date.now()}.mp3`)
|
||||
fs.writeFileSync(inputFile, 'x')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(inputFile, { force: true })
|
||||
})
|
||||
|
||||
it('downloads ffmpeg on a clean install and spawns the provisioned binary', async () => {
|
||||
hoisted.getFfmpegPath.mockReturnValue(SYSTEM_FFMPEG)
|
||||
hoisted.ensure.mockResolvedValue('/runtime/ffmpeg/ffmpeg.exe')
|
||||
|
||||
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
||||
mod.resetFileTranscriptionServiceForTests()
|
||||
const result = await mod.getFileTranscriptionService().startTranscription(inputFile, 'en')
|
||||
|
||||
expect(hoisted.ensure).toHaveBeenCalledTimes(1)
|
||||
expect(hoisted.ensure).toHaveBeenCalledWith('ffmpeg')
|
||||
const commands = hoisted.spawn.mock.calls.map((call) => call[0])
|
||||
// convert + probe + 2 chunks(45초 / 30초)
|
||||
expect(commands).toEqual([
|
||||
'/runtime/ffmpeg/ffmpeg.exe',
|
||||
'/runtime/ffmpeg/ffmpeg.exe',
|
||||
'/runtime/ffmpeg/ffmpeg.exe',
|
||||
'/runtime/ffmpeg/ffmpeg.exe',
|
||||
])
|
||||
expect(result.fullText).toBe('hello hello')
|
||||
expect(result.totalDurationSec).toBe(45)
|
||||
})
|
||||
|
||||
it('uses a locally available ffmpeg without downloading', async () => {
|
||||
hoisted.getFfmpegPath.mockReturnValue('/bundled/ffmpeg.exe')
|
||||
|
||||
const mod = await import('../../../src/main/services/FileTranscriptionService')
|
||||
mod.resetFileTranscriptionServiceForTests()
|
||||
await mod.getFileTranscriptionService().startTranscription(inputFile, 'en')
|
||||
|
||||
expect(hoisted.ensure).not.toHaveBeenCalled()
|
||||
expect(hoisted.spawn.mock.calls.every((call) => call[0] === '/bundled/ffmpeg.exe')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ffmpeg-media policy', () => {
|
||||
it('resolveFfmpegExecutable prefers the local copy', async () => {
|
||||
const provision = vi.fn()
|
||||
await expect(resolveFfmpegExecutable({ locate: () => '/a/ffmpeg', provision })).resolves.toBe('/a/ffmpeg')
|
||||
expect(provision).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolveFfmpegExecutable provisions when only the PATH fallback is known', async () => {
|
||||
const provision = vi.fn().mockResolvedValue('/rt/ffmpeg')
|
||||
await expect(resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision })).resolves.toBe('/rt/ffmpeg')
|
||||
})
|
||||
|
||||
it('resolveFfmpegExecutable falls back to the system ffmpeg when the download fails', async () => {
|
||||
const failure = new Error('offline')
|
||||
const onFail = vi.fn()
|
||||
const provision = vi.fn().mockRejectedValue(failure)
|
||||
await expect(
|
||||
resolveFfmpegExecutable({ locate: () => SYSTEM_FFMPEG, provision }, onFail),
|
||||
).resolves.toBe(SYSTEM_FFMPEG)
|
||||
expect(onFail).toHaveBeenCalledWith(failure)
|
||||
})
|
||||
|
||||
it('builds the same ffmpeg arguments as before', () => {
|
||||
expect(buildConvertToWavArgs('in.mp4', 'out.wav')).toEqual([
|
||||
'-i', 'in.mp4', '-ar', '16000', '-ac', '1', '-sample_fmt', 's16', '-y', 'out.wav',
|
||||
])
|
||||
expect(buildProbeDurationArgs('a.wav')).toEqual(['-i', 'a.wav', '-f', 'null', '-'])
|
||||
expect(buildExtractChunkArgs('a.wav', 30, 30)).toEqual([
|
||||
'-ss', '30', '-t', '30', '-i', 'a.wav', '-ar', '16000', '-ac', '1',
|
||||
'-f', 's16le', '-acodec', 'pcm_s16le', 'pipe:1',
|
||||
])
|
||||
})
|
||||
|
||||
it('parses ffmpeg duration and estimates WAV length', () => {
|
||||
expect(parseFfmpegDuration(' Duration: 01:02:03.50, start')).toBe(3723.5)
|
||||
expect(parseFfmpegDuration('no duration here')).toBeNull()
|
||||
expect(estimateWavDurationSec(44 + 32000 * 2)).toBe(2)
|
||||
expect(estimateWavDurationSec(10)).toBe(0)
|
||||
})
|
||||
|
||||
it('counts chunks by rounding up', () => {
|
||||
expect(countChunks(45, 30)).toBe(2)
|
||||
expect(countChunks(60, 30)).toBe(2)
|
||||
expect(countChunks(0, 30)).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue