Procedurally generates a radio weather report

stitcher function

+22
.vscode/launch.json
··· 1 + { 2 + // Use IntelliSense to learn about possible attributes. 3 + // Hover to view descriptions of existing attributes. 4 + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 + "version": "0.2.0", 6 + "configurations": [ 7 + 8 + { 9 + "type": "node", 10 + "request": "launch", 11 + "name": "Launch Program", 12 + "skipFiles": [ 13 + "<node_internals>/**" 14 + ], 15 + "program": "${workspaceFolder}/src/index.ts", 16 + "preLaunchTask": "tsc: build - tsconfig.json", 17 + "outFiles": [ 18 + "${workspaceFolder}/distribution/**/*.js" 19 + ] 20 + } 21 + ] 22 + }
+2 -1
src/index.ts
··· 2 2 import fsp from 'fs/promises'; 3 3 import json5 from 'json5'; 4 4 import Sequencer from './sequencer.js'; 5 + import { Stitcher } from './stitcher.js'; 5 6 import type {Programs, Segments, Sequences} from './sequencer.js'; 6 7 import type { Voices } from './voice.js'; 7 8 import type { Options } from 'openweather-api-node'; ··· 20 21 const config: Config = json5.parse(await fsp.readFile(process.env['CONFIG'] || path.join('config', 'config.json5'), { encoding: 'utf-8' })); 21 22 const sequence = await Sequencer(config); 22 23 console.log(sequence.join('\n')); 23 - 24 + await Stitcher(sequence); 24 25 25 26 export type { Config };
+34
src/stitcher.ts
··· 1 + import { spawn } from 'child_process'; 2 + 3 + const ENCTOOL = process.env['ENCTOOL'] || 'ffmpeg'; 4 + 5 + function ffmpeg(args: string[], files: number): Promise<void> { 6 + return new Promise((resolve, reject) => { 7 + console.log(`${ENCTOOL} ${args.join(' ')}`); 8 + const process = spawn(ENCTOOL, args); 9 + const to = setTimeout(async () => { 10 + process.kill(); 11 + reject(new Error('timed out')); 12 + }, 5000 * files); 13 + process.on('exit', async (code) => { 14 + clearTimeout(to); 15 + if (code !== 0) { 16 + reject(new Error(`exited with ${code}`)); 17 + } 18 + else { 19 + resolve(); 20 + } 21 + }); 22 + }); 23 + } 24 + 25 + async function Stitcher(files: string[]) { 26 + const args: string[] = []; 27 + files.forEach(f => args.push('-i', f)); 28 + args.push('-filter_complex', `[0:a][1:a][2:a]concat=n=${files.length}:v=0:a=1[out]`); 29 + args.push('-map', '[out]', '-ar', '44100', '-ac', '2', '-c:a', 'pcm_s16le', 'output.wav'); 30 + await ffmpeg(args, files.length); 31 + } 32 + 33 + export default Stitcher; 34 + export { Stitcher };
+1 -1
test/sequencer.test.ts
··· 1 - import { describe, expect, it, vi, beforeEach } from 'vitest'; 1 + import { describe, expect, it, vi } from 'vitest'; 2 2 import { type CurrentWeather, type Options } from 'openweather-api-node'; 3 3 import { Sequencer } from '../src/sequencer.js'; 4 4 import type { Config } from '../src/index.js';
+87
test/stitcher.test.ts
··· 1 + import { Stitcher } from '../src/stitcher.js'; 2 + import { describe, expect, it, vi, beforeEach } from 'vitest'; 3 + import { EventEmitter } from 'events'; 4 + import { spawn } from 'child_process'; 5 + 6 + const mockChildProcess = new (class MockChildProcess 7 + extends EventEmitter { 8 + kill = vi.fn(() => { 9 + return true; 10 + }); 11 + })(); 12 + 13 + vi.mock('child_process', () => { 14 + return { 15 + spawn: vi.fn(() => mockChildProcess) 16 + } 17 + }); 18 + 19 + describe('stitcher', () => { 20 + 21 + beforeEach(() => { 22 + vi.clearAllMocks(); 23 + }); 24 + 25 + it('passes the correct arguments to ffmpeg', async () => { 26 + const p = Stitcher(['1.flac', 'dir/2.flac']); 27 + mockChildProcess.emit('exit', 0, null); 28 + await p; 29 + expect(spawn).toBeCalledWith('ffmpeg', [ 30 + "-i", 31 + '1.flac', 32 + '-i', 33 + 'dir/2.flac', 34 + '-filter_complex', 35 + '[0:a][1:a][2:a]concat=n=2:v=0:a=1[out]', 36 + '-map', 37 + '[out]', 38 + '-ar', 39 + '44100', 40 + '-ac', 41 + '2', 42 + '-c:a', 43 + 'pcm_s16le', 44 + 'output.wav' 45 + ]); 46 + }); 47 + 48 + it('throws an error when ffmpeg fails', async () => { 49 + const p = Stitcher(['sound.mp3']); 50 + mockChildProcess.emit('exit', 1, null); 51 + await expect(p).rejects.toThrow('exited with 1'); 52 + expect(spawn).toBeCalledWith('ffmpeg', [ 53 + "-i", 54 + 'sound.mp3', 55 + '-filter_complex', 56 + '[0:a][1:a][2:a]concat=n=1:v=0:a=1[out]', 57 + '-map', 58 + '[out]', 59 + '-ar', 60 + '44100', 61 + '-ac', 62 + '2', 63 + '-c:a', 64 + 'pcm_s16le', 65 + 'output.wav' 66 + ]); 67 + }); 68 + 69 + it('throws an error when ffmpeg takes longer than expected', { timeout: 6000 }, async () => { 70 + await expect(Stitcher(['in.wav'])).rejects.toThrow('timed out'); 71 + expect(spawn).toBeCalledWith('ffmpeg', [ 72 + "-i", 73 + 'in.wav', 74 + '-filter_complex', 75 + '[0:a][1:a][2:a]concat=n=1:v=0:a=1[out]', 76 + '-map', 77 + '[out]', 78 + '-ar', 79 + '44100', 80 + '-ac', 81 + '2', 82 + '-c:a', 83 + 'pcm_s16le', 84 + 'output.wav' 85 + ]); 86 + }); 87 + });