From 749b3e082cf0b8c48857dbeee3a5549d5ed2f70b Mon Sep 17 00:00:00 2001 From: Cory Sanin Date: Fri, 29 Aug 2025 16:09:08 -0500 Subject: [PATCH] stitcher function --- .vscode/launch.json | 22 +++++++++++ src/index.ts | 3 +- src/stitcher.ts | 34 +++++++++++++++++ test/sequencer.test.ts | 2 +- test/stitcher.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 test/stitcher.test.ts diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..631235e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/src/index.ts", + "preLaunchTask": "tsc: build - tsconfig.json", + "outFiles": [ + "${workspaceFolder}/distribution/**/*.js" + ] + } + ] +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 6f4707e..d75e4e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import path from 'path'; import fsp from 'fs/promises'; import json5 from 'json5'; import Sequencer from './sequencer.js'; +import { Stitcher } from './stitcher.js'; import type {Programs, Segments, Sequences} from './sequencer.js'; import type { Voices } from './voice.js'; import type { Options } from 'openweather-api-node'; @@ -20,6 +21,6 @@ console.log('morning-report\nCory Sanin 2025\n'); const config: Config = json5.parse(await fsp.readFile(process.env['CONFIG'] || path.join('config', 'config.json5'), { encoding: 'utf-8' })); const sequence = await Sequencer(config); console.log(sequence.join('\n')); - +await Stitcher(sequence); export type { Config }; diff --git a/src/stitcher.ts b/src/stitcher.ts index e69de29..744a82a 100644 --- a/src/stitcher.ts +++ b/src/stitcher.ts @@ -0,0 +1,34 @@ +import { spawn } from 'child_process'; + +const ENCTOOL = process.env['ENCTOOL'] || 'ffmpeg'; + +function ffmpeg(args: string[], files: number): Promise { + return new Promise((resolve, reject) => { + console.log(`${ENCTOOL} ${args.join(' ')}`); + const process = spawn(ENCTOOL, args); + const to = setTimeout(async () => { + process.kill(); + reject(new Error('timed out')); + }, 5000 * files); + process.on('exit', async (code) => { + clearTimeout(to); + if (code !== 0) { + reject(new Error(`exited with ${code}`)); + } + else { + resolve(); + } + }); + }); +} + +async function Stitcher(files: string[]) { + const args: string[] = []; + files.forEach(f => args.push('-i', f)); + args.push('-filter_complex', `[0:a][1:a][2:a]concat=n=${files.length}:v=0:a=1[out]`); + args.push('-map', '[out]', '-ar', '44100', '-ac', '2', '-c:a', 'pcm_s16le', 'output.wav'); + await ffmpeg(args, files.length); +} + +export default Stitcher; +export { Stitcher }; diff --git a/test/sequencer.test.ts b/test/sequencer.test.ts index 1b5cf6c..60187c6 100644 --- a/test/sequencer.test.ts +++ b/test/sequencer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { type CurrentWeather, type Options } from 'openweather-api-node'; import { Sequencer } from '../src/sequencer.js'; import type { Config } from '../src/index.js'; diff --git a/test/stitcher.test.ts b/test/stitcher.test.ts new file mode 100644 index 0000000..069283f --- /dev/null +++ b/test/stitcher.test.ts @@ -0,0 +1,87 @@ +import { Stitcher } from '../src/stitcher.js'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; +import { spawn } from 'child_process'; + +const mockChildProcess = new (class MockChildProcess + extends EventEmitter { + kill = vi.fn(() => { + return true; + }); +})(); + +vi.mock('child_process', () => { + return { + spawn: vi.fn(() => mockChildProcess) + } +}); + +describe('stitcher', () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('passes the correct arguments to ffmpeg', async () => { + const p = Stitcher(['1.flac', 'dir/2.flac']); + mockChildProcess.emit('exit', 0, null); + await p; + expect(spawn).toBeCalledWith('ffmpeg', [ + "-i", + '1.flac', + '-i', + 'dir/2.flac', + '-filter_complex', + '[0:a][1:a][2:a]concat=n=2:v=0:a=1[out]', + '-map', + '[out]', + '-ar', + '44100', + '-ac', + '2', + '-c:a', + 'pcm_s16le', + 'output.wav' + ]); + }); + + it('throws an error when ffmpeg fails', async () => { + const p = Stitcher(['sound.mp3']); + mockChildProcess.emit('exit', 1, null); + await expect(p).rejects.toThrow('exited with 1'); + expect(spawn).toBeCalledWith('ffmpeg', [ + "-i", + 'sound.mp3', + '-filter_complex', + '[0:a][1:a][2:a]concat=n=1:v=0:a=1[out]', + '-map', + '[out]', + '-ar', + '44100', + '-ac', + '2', + '-c:a', + 'pcm_s16le', + 'output.wav' + ]); + }); + + it('throws an error when ffmpeg takes longer than expected', { timeout: 6000 }, async () => { + await expect(Stitcher(['in.wav'])).rejects.toThrow('timed out'); + expect(spawn).toBeCalledWith('ffmpeg', [ + "-i", + 'in.wav', + '-filter_complex', + '[0:a][1:a][2:a]concat=n=1:v=0:a=1[out]', + '-map', + '[out]', + '-ar', + '44100', + '-ac', + '2', + '-c:a', + 'pcm_s16le', + 'output.wav' + ]); + }); +}); \ No newline at end of file