Compare commits
12 Commits
7f45387dac
...
v0.0.2
Author | SHA1 | Date | |
---|---|---|---|
12862b4f85 | |||
16d5e834a5 | |||
ea50cf5f77 | |||
749b3e082c | |||
0c3b5c3be5 | |||
e6964315ab | |||
b8d09a6666 | |||
8f8819276f | |||
527de38044 | |||
9668cd80b4 | |||
b51d014c80 | |||
6ab158cee0 |
34
.github/workflows/test.yml
vendored
Normal file
34
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Unit tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
|
||||
npm_test:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Setup nodejs
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -102,3 +102,6 @@ public
|
||||
config/config.json5
|
||||
distribution/
|
||||
.env
|
||||
test/*.js*
|
||||
output.wav
|
||||
output.mp3
|
||||
|
22
.vscode/launch.json
vendored
Normal file
22
.vscode/launch.json
vendored
Normal file
@@ -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": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/src/index.ts",
|
||||
"preLaunchTask": "tsc: build - tsconfig.json",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/distribution/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
@@ -49,17 +49,17 @@
|
||||
"hilo 1": {
|
||||
"tracks": [
|
||||
"audio/hi_01.flac",
|
||||
"%cory hi",
|
||||
"%cory weather.temp.max",
|
||||
"audio/lo_01.flac",
|
||||
"%cory lo"
|
||||
"%cory weather.temp.min"
|
||||
]
|
||||
},
|
||||
"hilo 2": {
|
||||
"tracks": [
|
||||
"audio/hi_02.flac",
|
||||
"%cory hi",
|
||||
"%cory weather.temp.max",
|
||||
"audio/lo_02.flac",
|
||||
"%cory lo"
|
||||
"%cory weather.temp.min"
|
||||
]
|
||||
},
|
||||
"rain 1": {
|
||||
|
2345
package-lock.json
generated
2345
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "morning-report",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Procedurally generates a radio weather report",
|
||||
"keywords": [
|
||||
"weather",
|
||||
@@ -19,16 +19,25 @@
|
||||
},
|
||||
"type": "module",
|
||||
"main": "distribution/index.js",
|
||||
"bin": {
|
||||
"morning-report": "./distribution/src/index.js"
|
||||
},
|
||||
"files": [
|
||||
"distribution"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npx tsc",
|
||||
"start": "node distribution/index.js"
|
||||
"build": "tsc",
|
||||
"start": "node distribution/src/index.js",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": "2.2.3",
|
||||
"openweathermap-ts": "1.2.10"
|
||||
"openweather-api-node": "3.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.9.2",
|
||||
"@types/node": "24.3.0"
|
||||
"@types/node": "24.3.0",
|
||||
"vitest": "3.2.4",
|
||||
"@vitest/coverage-v8": "3.2.4"
|
||||
}
|
||||
}
|
10
src/index.ts
10
src/index.ts
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
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 { WeatherConfig } from './weather.js';
|
||||
import type { Options } from 'openweather-api-node';
|
||||
|
||||
|
||||
interface Config {
|
||||
@@ -12,14 +14,14 @@ interface Config {
|
||||
segments: Segments,
|
||||
sequences: Sequences,
|
||||
voices: Voices,
|
||||
weather: WeatherConfig
|
||||
weather: Options
|
||||
}
|
||||
|
||||
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 = Sequencer(config);
|
||||
const sequence = await Sequencer(config);
|
||||
console.log(sequence.join('\n'));
|
||||
|
||||
await Stitcher(sequence);
|
||||
|
||||
export type { Config };
|
||||
|
@@ -1,11 +1,14 @@
|
||||
import { OpenWeatherAPI, type DailyWeather } from 'openweather-api-node';
|
||||
import { voiceLines } from './voice.js';
|
||||
import type { Config } from './index.js';
|
||||
import type { Voice } from './voice.js';
|
||||
|
||||
type SegmentName = string;
|
||||
type SequenceName = string;
|
||||
type Programs = SegmentName[][];
|
||||
type Segments = { [segment: SegmentName]: SequenceName[] };
|
||||
type Sequence = {
|
||||
condition?: string;
|
||||
conditions?: string[];
|
||||
tracks: string[];
|
||||
}
|
||||
type Sequences = { [sequence: SequenceName]: Sequence };
|
||||
@@ -16,36 +19,90 @@ function selectOne<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
||||
function conditionIsMet(condition: string | undefined = undefined): boolean {
|
||||
function resolveSide(side: string, currentWeather: DailyWeather) {
|
||||
if (!side.startsWith('weather')) {
|
||||
return side.includes('.') ? parseFloat(side) : parseInt(side);
|
||||
}
|
||||
|
||||
const tokens = side.split('.');
|
||||
let w = currentWeather;
|
||||
tokens.forEach(t => w = w[t]);
|
||||
return typeof w === 'object' ? JSON.stringify(w) : w as (string | number);
|
||||
}
|
||||
|
||||
function notNotANumber(something: number | string, defaultVal: string) {
|
||||
if (typeof something === 'string' || !isNaN(something)) {
|
||||
return something;
|
||||
}
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
function conditionIsMet(condition: string | undefined, currentWeather: DailyWeather): boolean {
|
||||
if (typeof condition !== 'string') {
|
||||
return true;
|
||||
}
|
||||
// TODO: parse condition, return bool
|
||||
return false;
|
||||
}
|
||||
|
||||
function processSequence(sequence: Sequence): string[] {
|
||||
const tracks = sequence.tracks;
|
||||
// TODO: process voice macros
|
||||
return tracks;
|
||||
}
|
||||
|
||||
function processSegment(segment: SegmentName): string[] {
|
||||
if (!(segment in config.segments)) {
|
||||
return processSequence(config.sequences[segment]);
|
||||
const [lhs, relational, rhs] = condition.split(' ');
|
||||
if (lhs === undefined || relational === undefined || rhs === undefined) {
|
||||
throw new Error(`Condition "${condition}" is not in the correct format`);
|
||||
}
|
||||
const potentialSequences: SequenceName[] = config.segments[segment].filter(s => conditionIsMet(config.sequences[s].condition));
|
||||
const lhsResolved = notNotANumber(resolveSide(lhs, currentWeather), lhs);
|
||||
const rhsResolved = notNotANumber(resolveSide(rhs, currentWeather), rhs);
|
||||
switch (relational) {
|
||||
case '=':
|
||||
case '==':
|
||||
return lhsResolved == rhsResolved;
|
||||
case '!=':
|
||||
return lhsResolved != rhsResolved;
|
||||
case '<':
|
||||
return lhsResolved < rhsResolved;
|
||||
case '<=':
|
||||
return lhsResolved <= rhsResolved;
|
||||
case '>':
|
||||
return lhsResolved > rhsResolved;
|
||||
case '>=':
|
||||
return lhsResolved >= rhsResolved;
|
||||
default:
|
||||
throw new Error(`Unsupported relational operator: ${relational}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMacro(str: string, currentWeather: DailyWeather): string[] {
|
||||
if (str.startsWith('%')) {
|
||||
const [profile, subject] = str.substring(1).split(' ', 2);
|
||||
const voiceProfile: Voice = config.voices[profile];
|
||||
let resolvedSubject: any = currentWeather;
|
||||
subject.split('.').forEach(t => resolvedSubject = resolvedSubject[t]);
|
||||
return voiceLines(voiceProfile, resolvedSubject);
|
||||
}
|
||||
return [str];
|
||||
}
|
||||
|
||||
function processSequence(sequence: Sequence, currentWeather: DailyWeather): string[] {
|
||||
const tracks = sequence.tracks;
|
||||
return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null);
|
||||
}
|
||||
|
||||
function processSegment(segment: SegmentName, currentWeather: DailyWeather): string[] {
|
||||
if (!(segment in config.segments)) {
|
||||
return (config.sequences[segment].conditions || []).every(c => conditionIsMet(c, currentWeather)) ? processSequence(config.sequences[segment], currentWeather) : [];
|
||||
}
|
||||
const potentialSequences: SequenceName[] = config.segments[segment].filter(s => (config.sequences[s].conditions || []).every(c => conditionIsMet(c, currentWeather)));
|
||||
if (potentialSequences.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return processSequence(config.sequences[selectOne(potentialSequences)]);
|
||||
return processSequence(config.sequences[selectOne(potentialSequences)], currentWeather);
|
||||
}
|
||||
|
||||
function Sequencer(conf: Config): string[] {
|
||||
async function Sequencer(conf: Config): Promise<string[]> {
|
||||
config = conf;
|
||||
const weather = new OpenWeatherAPI(conf.weather);
|
||||
const currentWeather = await weather.getToday();
|
||||
const sequence: string[] = [];
|
||||
const program: SegmentName[] = selectOne(conf.programs);
|
||||
program.forEach(segment => sequence.push(...processSegment(segment)));
|
||||
for (let i = 0; i < program.length; i++) {
|
||||
const segment = program[i];
|
||||
sequence.push(...(processSegment(segment, currentWeather)));
|
||||
}
|
||||
return sequence;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,34 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const ENCTOOL = process.env['ENCTOOL'] || 'ffmpeg';
|
||||
|
||||
function ffmpeg(args: string[], files: number): Promise<void> {
|
||||
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', '-y');
|
||||
await ffmpeg(args, files.length);
|
||||
}
|
||||
|
||||
export default Stitcher;
|
||||
export { Stitcher };
|
||||
|
@@ -130,9 +130,6 @@ function integer(str: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const numGroups = str.split(',');
|
||||
const seperators = [LINES.TRILLION, LINES.BILLION, LINES.MILLION, LINES.THOUSAND];
|
||||
if (numGroups.length > 5) {
|
||||
return digitByDigit(str);
|
||||
}
|
||||
seperators.splice(0, seperators.length - numGroups.length + 1);
|
||||
numGroups.forEach(g => {
|
||||
if (g !== '000') {
|
||||
@@ -165,9 +162,9 @@ function voiceLines(voice: Voice, num: number): string[] {
|
||||
tokens.push(LINES.POINT);
|
||||
tokens.push(...digitByDigit(parts[1]));
|
||||
}
|
||||
return tokens.map(l => path.join(voice.directory, `${l}.${voice.extension}`));
|
||||
return tokens.map(l => path.join(voice.directory, `${l}${voice.extension.length > 0 && voice.extension.charAt(0) !== '.' ? '.' : ''}${voice.extension}`));
|
||||
}
|
||||
|
||||
export default voiceLines;
|
||||
export { voiceLines };
|
||||
export { voiceLines, LINES };
|
||||
export type { Voice, Voices };
|
||||
|
101
src/weather.ts
101
src/weather.ts
@@ -1,101 +0,0 @@
|
||||
import OpenWeatherMap from 'openweathermap-ts';
|
||||
import type { ThreeHourResponse, CurrentResponse, CountryCode } from 'openweathermap-ts/dist/types/index.js';
|
||||
|
||||
interface CityName {
|
||||
cityName: string;
|
||||
state: string;
|
||||
countryCode: CountryCode;
|
||||
}
|
||||
|
||||
interface WeatherConfig {
|
||||
key: string;
|
||||
lang?: string;
|
||||
coordinates?: number[] | string;
|
||||
zip?: number;
|
||||
country?: CountryCode;
|
||||
cityid?: number;
|
||||
city?: CityName;
|
||||
}
|
||||
|
||||
type LocationType = 'coordinates' | 'zip' | 'cityid' | 'city' | null;
|
||||
|
||||
function parseCoords(coords: number[] | string): number[] {
|
||||
if (typeof coords == 'string') {
|
||||
return coords.replace(/\s/g, '').split(',').map(Number.parseFloat);
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
|
||||
class Weather {
|
||||
private openWeather: OpenWeatherMap.default;
|
||||
private locationType: LocationType;
|
||||
private current: CurrentResponse;
|
||||
private threeDay: ThreeHourResponse;
|
||||
|
||||
constructor(options: WeatherConfig) {
|
||||
this.locationType = this.current = this.threeDay = null;
|
||||
this.openWeather = new OpenWeatherMap.default({
|
||||
apiKey: options.key
|
||||
});
|
||||
if ('city' in options && 'cityName' in options.city && 'state' in options.city && 'countryCode' in options.city) {
|
||||
this.openWeather.setCityName(options.city);
|
||||
this.locationType = 'city';
|
||||
}
|
||||
if ('cityid' in options) {
|
||||
this.openWeather.setCityId(options.cityid);
|
||||
this.locationType = 'cityid';
|
||||
}
|
||||
if ('zip' in options && 'country' in options) {
|
||||
this.openWeather.setZipCode(options.zip, options.country)
|
||||
this.locationType = 'zip';
|
||||
}
|
||||
if ('coordinates' in options) {
|
||||
const coords = parseCoords(options.coordinates);
|
||||
if (coords.length >= 2) {
|
||||
this.openWeather.setGeoCoordinates(coords[0], coords[1]);
|
||||
this.locationType = 'coordinates';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentWeather(): Promise<CurrentResponse> {
|
||||
if (this.current) {
|
||||
return this.current;
|
||||
}
|
||||
switch (this.locationType) {
|
||||
case 'city':
|
||||
return this.current = await this.openWeather.getCurrentWeatherByCityName();
|
||||
case 'cityid':
|
||||
return this.current = await this.openWeather.getCurrentWeatherByCityId();
|
||||
case 'zip':
|
||||
return this.current = await this.openWeather.getCurrentWeatherByZipcode();
|
||||
case 'coordinates':
|
||||
return this.current = await this.openWeather.getCurrentWeatherByGeoCoordinates();
|
||||
default:
|
||||
throw new Error(`Can't fetch weather for location type '${this.locationType}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async getThreeHourForecast(): Promise<ThreeHourResponse> {
|
||||
if (this.threeDay) {
|
||||
return this.threeDay;
|
||||
}
|
||||
switch (this.locationType) {
|
||||
case 'city':
|
||||
return this.threeDay = await this.openWeather.getThreeHourForecastByCityName();
|
||||
case 'cityid':
|
||||
return this.threeDay = await this.openWeather.getThreeHourForecastByCityId();
|
||||
case 'zip':
|
||||
return this.threeDay = await this.openWeather.getThreeHourForecastByZipcode();
|
||||
case 'coordinates':
|
||||
return this.threeDay = await this.openWeather.getThreeHourForecastByGeoCoordinates();
|
||||
default:
|
||||
throw new Error(`Can't fetch weather for location type '${this.locationType}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Weather;
|
||||
export { Weather };
|
||||
export type { WeatherConfig, CityName };
|
190
test/sequencer.test.ts
Normal file
190
test/sequencer.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
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';
|
||||
|
||||
const dummyWeather: Options = { key: 'dummy' };
|
||||
|
||||
vi.mock('openweather-api-node', () => {
|
||||
return {
|
||||
OpenWeatherAPI: vi.fn().mockImplementation((_) => {
|
||||
return {
|
||||
getToday: vi.fn(() => {
|
||||
return {
|
||||
"lat": 43.0748,
|
||||
"lon": -89.3838,
|
||||
"dt": "2025-08-29T06:31:05.000Z",
|
||||
"dtRaw": 1756449065,
|
||||
"timezoneOffset": -18000,
|
||||
"astronomical": {
|
||||
"sunrise": "2025-08-29T11:19:05.000Z",
|
||||
"sunriseRaw": 1756466345,
|
||||
"sunset": "2025-08-30T00:38:08.000Z",
|
||||
"sunsetRaw": 1756514288
|
||||
},
|
||||
"weather": {
|
||||
"temp": {
|
||||
"cur": 55.85,
|
||||
"min": 52.99,
|
||||
"max": 58.01
|
||||
},
|
||||
"feelsLike": {
|
||||
"cur": 55.31
|
||||
},
|
||||
"pressure": 1022,
|
||||
"humidity": 89,
|
||||
"clouds": 0,
|
||||
"visibility": 10000,
|
||||
"wind": {
|
||||
"deg": 140,
|
||||
"speed": 5.75
|
||||
},
|
||||
"rain": 0,
|
||||
"snow": 0,
|
||||
"conditionId": 800,
|
||||
"main": "Clear",
|
||||
"description": "clear sky",
|
||||
"icon": {
|
||||
"url": "http://openweathermap.org/img/wn/01n@2x.png",
|
||||
"raw": "01n"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
describe('sequencer', () => {
|
||||
it('can generate a list from a static config', async () => {
|
||||
expect(await Sequencer({
|
||||
programs: [['sequence 1', 'segment 1', 'segment 2']],
|
||||
segments: {
|
||||
'segment 1': ['sequence 1'],
|
||||
'segment 2': ['sequence 2']
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'tracks': [
|
||||
'seq1.flac'
|
||||
]
|
||||
},
|
||||
'sequence 2': {
|
||||
'tracks': [
|
||||
'seq2.flac'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {},
|
||||
weather: dummyWeather
|
||||
})).to.include.ordered.members(['seq1.flac', 'seq1.flac', 'seq2.flac']);
|
||||
});
|
||||
|
||||
it('can include tracks conditionally', async () => {
|
||||
expect(await Sequencer({
|
||||
programs: [['segment 1', 'sequence 1', 'segment 2', 'sequence 2']],
|
||||
segments: {
|
||||
'segment 1': ['sequence 1'],
|
||||
'segment 2': ['sequence 2']
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'conditions': ['1 = 1', '1.1 > 1', '2 >= 1', '1 < 2', '1 <= 1', '-1 != 0', undefined],
|
||||
'tracks': [
|
||||
'seq1.flac'
|
||||
]
|
||||
},
|
||||
'sequence 2': {
|
||||
'conditions': ['weather.lat = -500'],
|
||||
'tracks': [
|
||||
'seq2.flac'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {},
|
||||
weather: dummyWeather
|
||||
})).to.be.ordered.members(['seq1.flac', 'seq1.flac']);
|
||||
});
|
||||
|
||||
it('throws an error on invalid conditions', async () => {
|
||||
await expect(Sequencer({
|
||||
programs: [['sequence 1']],
|
||||
segments: {
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'conditions': ['100'],
|
||||
'tracks': [
|
||||
'seq1.flac'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {},
|
||||
weather: dummyWeather
|
||||
})).rejects.toThrow(/not in the correct format/);
|
||||
|
||||
await expect(Sequencer({
|
||||
programs: [['sequence 1']],
|
||||
segments: {
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'conditions': ['1 ~ 2'],
|
||||
'tracks': [
|
||||
'seq1.flac'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {},
|
||||
weather: dummyWeather
|
||||
})).rejects.toThrow(/Unsupported relational operator/);
|
||||
});
|
||||
|
||||
it('can stringify conditions', async () => {
|
||||
expect(await Sequencer({
|
||||
programs: [['sequence 1']],
|
||||
segments: {
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'conditions': ['weather.feelsLike = {"cur":55.31}'],
|
||||
'tracks': [
|
||||
'seq1.flac'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {},
|
||||
weather: dummyWeather
|
||||
})).to.be.ordered.members(['seq1.flac']);
|
||||
});
|
||||
|
||||
it('can parse voice macros', async () => {
|
||||
expect(await Sequencer({
|
||||
programs: [['sequence 1']],
|
||||
segments: {
|
||||
},
|
||||
sequences: {
|
||||
'sequence 1': {
|
||||
'tracks': [
|
||||
'%alice weather.temp.max'
|
||||
]
|
||||
}
|
||||
},
|
||||
voices: {
|
||||
"alice": {
|
||||
"directory": "alice/",
|
||||
"extension": "flac"
|
||||
}
|
||||
},
|
||||
weather: dummyWeather
|
||||
})).to.be.ordered.members([
|
||||
'alice/fifty.flac',
|
||||
'alice/eight.flac',
|
||||
'alice/point.flac',
|
||||
'alice/zero.flac',
|
||||
'alice/one.flac'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
90
test/stitcher.test.ts
Normal file
90
test/stitcher.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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',
|
||||
'-y'
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
'-y'
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
'-y'
|
||||
]);
|
||||
});
|
||||
});
|
129
test/voice.test.ts
Normal file
129
test/voice.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { voiceLines, LINES } from '../src/voice.js';
|
||||
import type { Voice } from '../src/voice.js';
|
||||
|
||||
const dummyVoice: Voice = {
|
||||
'directory': '',
|
||||
'extension': ''
|
||||
};
|
||||
|
||||
describe('voiceLines', () => {
|
||||
it('handles integers', () => {
|
||||
expect(voiceLines(dummyVoice, 16549872)).to.be.ordered.members(
|
||||
[
|
||||
LINES.SIX, LINES.TEEN, LINES.MILLION, LINES.FIVE, LINES.HUNDRED, LINES.FORTY,
|
||||
LINES.NINE, LINES.THOUSAND, LINES.EIGHT, LINES.HUNDRED, LINES.SEVENTY, LINES.TWO
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('handles floating point', () => {
|
||||
expect(voiceLines(dummyVoice, 672.09435)).to.be.ordered.members(
|
||||
[
|
||||
LINES.SIX, LINES.HUNDRED, LINES.SEVENTY, LINES.TWO, LINES.POINT, LINES.ZERO,
|
||||
LINES.NINE, LINES.FOUR, LINES.THREE, LINES.FIVE
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('handles the negative', () => {
|
||||
expect(voiceLines(dummyVoice, -672.09435)).to.be.ordered.members(
|
||||
[
|
||||
LINES.NEGATIVE, LINES.SIX, LINES.HUNDRED, LINES.SEVENTY, LINES.TWO, LINES.POINT, LINES.ZERO,
|
||||
LINES.NINE, LINES.FOUR, LINES.THREE, LINES.FIVE
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('handles zero', () => {
|
||||
expect(voiceLines(dummyVoice, 0)).to.be.ordered.members(
|
||||
[
|
||||
LINES.ZERO
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('handles large numbers with many zeroes', () => {
|
||||
expect(voiceLines(dummyVoice, 700000000000001)).to.be.ordered.members(
|
||||
[
|
||||
LINES.SEVEN, LINES.HUNDRED, LINES.TRILLION, LINES.ONE
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 1000001)).to.be.ordered.members(
|
||||
[
|
||||
LINES.ONE, LINES.MILLION, LINES.ONE
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 9000000001000)).to.be.ordered.members(
|
||||
[
|
||||
LINES.NINE, LINES.TRILLION, LINES.ONE, LINES.THOUSAND
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 60002000000000.12)).to.be.ordered.members(
|
||||
[
|
||||
LINES.SIXTY, LINES.TRILLION, LINES.TWO, LINES.BILLION, LINES.POINT, LINES.ONE, LINES.TWO
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 100010001)).to.be.ordered.members(
|
||||
[
|
||||
LINES.ONE, LINES.HUNDRED, LINES.MILLION, LINES.TEN, LINES.THOUSAND, LINES.ONE
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('handles irregularly named numbers', () => {
|
||||
expect(voiceLines(dummyVoice, 210)).to.be.ordered.members(
|
||||
[
|
||||
LINES.TWO, LINES.HUNDRED, LINES.TEN
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 311)).to.be.ordered.members(
|
||||
[
|
||||
LINES.THREE, LINES.HUNDRED, LINES.ELEVEN
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 412)).to.be.ordered.members(
|
||||
[
|
||||
LINES.FOUR, LINES.HUNDRED, LINES.TWELVE
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 513)).to.be.ordered.members(
|
||||
[
|
||||
LINES.FIVE, LINES.HUNDRED, LINES.THIRTEEN
|
||||
]
|
||||
);
|
||||
|
||||
expect(voiceLines(dummyVoice, 615)).to.be.ordered.members(
|
||||
[
|
||||
LINES.SIX, LINES.HUNDRED, LINES.FIFTEEN
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty array if value is unsupported', () => {
|
||||
expect(voiceLines(dummyVoice, Infinity)).length.to.be.empty;
|
||||
expect(voiceLines(dummyVoice, -Infinity)).length.to.be.empty;
|
||||
expect(voiceLines(dummyVoice, NaN)).length.to.be.empty;
|
||||
expect(voiceLines(dummyVoice, 1e21)).length.to.be.empty;
|
||||
});
|
||||
|
||||
it('returns the results as paths when voice configuration is provided', () => {
|
||||
const directory = path.join('audio', 'voice', 'someone');
|
||||
const v: Voice = {
|
||||
directory,
|
||||
extension: 'flac'
|
||||
}
|
||||
expect(voiceLines(v, 29)).to.be.ordered.members([
|
||||
path.join(directory, `twenty.flac`),
|
||||
path.join(directory, `nine.flac`),
|
||||
]);
|
||||
});
|
||||
});
|
@@ -9,9 +9,9 @@
|
||||
"moduleResolution": "node16",
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"rootDir": "./src",
|
||||
"rootDir": "./",
|
||||
"outDir": "./distribution"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["**/*.spec.ts"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user