20 Commits

Author SHA1 Message Date
3a78d49974 bump dependencies
All checks were successful
Unit tests / Unit tests (lts/*) (push) Successful in -1m48s
Unit tests / Unit tests (lts/hydrogen) (push) Successful in -1m56s
Unit tests / Unit tests (latest) (push) Successful in -1m44s
NPM Audit Check / Check NPM audit (push) Successful in -2m11s
2025-10-15 00:25:50 -05:00
0d53a6ccde replace Math.random() with crypto.randomInt() 2025-10-15 00:24:59 -05:00
6ecc03a670 bump vite version (dev dependency)
All checks were successful
Unit tests / Unit tests (latest) (push) Successful in -2m4s
Unit tests / Unit tests (lts/*) (push) Successful in -2m4s
Unit tests / Unit tests (lts/hydrogen) (push) Successful in -2m3s
NPM Audit Check / Check NPM audit (push) Successful in -2m11s
2025-09-09 17:50:56 -05:00
3f0121a9ff mirror actions to tangled
All checks were successful
Unit tests / Unit tests (latest) (push) Successful in -2m3s
Unit tests / Unit tests (lts/*) (push) Successful in -2m4s
Unit tests / Unit tests (lts/hydrogen) (push) Successful in -2m4s
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
2025-09-02 21:06:16 -05:00
021f714b06 add lts/hydrogen to test env
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (latest) (push) Successful in -2m5s
Unit tests / Unit tests (lts/*) (push) Successful in -2m5s
Unit tests / Unit tests (lts/hydrogen) (push) Successful in -1m59s
2025-09-02 20:59:17 -05:00
b0e4a10b3e node version matrix
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (latest) (push) Successful in -1m57s
Unit tests / Unit tests (lts/*) (push) Successful in -1m59s
2025-09-02 00:21:14 -05:00
12862b4f85 no output files pls
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (push) Successful in -2m5s
2025-08-31 12:33:30 -05:00
16d5e834a5 overwrite
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m6s
2025-08-30 11:28:18 -05:00
ea50cf5f77 files and bin
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m5s
2025-08-30 11:12:29 -05:00
749b3e082c stitcher function
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (push) Successful in -2m6s
2025-08-29 16:09:08 -05:00
0c3b5c3be5 exhaustive tests for sequencer function
All checks were successful
Unit tests / Unit tests (push) Successful in -2m10s
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
2025-08-29 11:11:16 -05:00
e6964315ab improve conditions
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m11s
2025-08-29 02:02:00 -05:00
b8d09a6666 test for conditions
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m11s
2025-08-29 01:53:50 -05:00
8f8819276f switch out weather library
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (push) Successful in -2m10s
2025-08-29 01:37:02 -05:00
527de38044 fix build
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
Unit tests / Unit tests (push) Successful in -2m9s
2025-08-29 00:35:21 -05:00
9668cd80b4 tests for weather class
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m12s
2025-08-24 21:29:11 -05:00
b51d014c80 fix test workflow
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m18s
Unit tests / Unit tests (push) Successful in -2m13s
2025-08-24 15:12:34 -05:00
6ab158cee0 tests for voice
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -1m32s
2025-08-24 15:11:11 -05:00
7f45387dac add weather class
All checks were successful
NPM Audit Check / Check NPM audit (push) Successful in -2m17s
2025-08-23 03:04:28 -05:00
3b562116fd let's talk numbers 2025-08-23 03:03:36 -05:00
17 changed files with 3159 additions and 96 deletions

44
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
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
matrix:
nodever:
[
'latest',
'lts/*',
'lts/hydrogen'
]
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
with:
node-version: "${{ matrix.nodever }}"
check-latest: true
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run tests
run: npm test

3
.gitignore vendored
View File

@@ -102,3 +102,6 @@ public
config/config.json5 config/config.json5
distribution/ distribution/
.env .env
test/*.js*
output.wav
output.mp3

View File

@@ -0,0 +1,18 @@
when:
- event: ["push"]
branch: ["master"]
engine: "nixery"
dependencies:
nixpkgs:
- nodejs
steps:
- name: "NPM Audit"
command: "npm audit"
clone:
skip: false
depth: 3
submodules: false

View File

@@ -0,0 +1,24 @@
when:
- event: ["push"]
branch: ["master"]
engine: "nixery"
dependencies:
nixpkgs:
- nodejs
steps:
- name: "Install dependencies"
command: "npm ci"
- name: "tsc"
command: "npm run build && echo 'done.'"
- name: "tests"
command: "npm test"
clone:
skip: false
depth: 3
submodules: false

22
.vscode/launch.json vendored Normal file
View 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"
]
}
]
}

View File

@@ -49,17 +49,17 @@
"hilo 1": { "hilo 1": {
"tracks": [ "tracks": [
"audio/hi_01.flac", "audio/hi_01.flac",
"%cory hi", "%cory weather.temp.max",
"audio/lo_01.flac", "audio/lo_01.flac",
"%cory lo" "%cory weather.temp.min"
] ]
}, },
"hilo 2": { "hilo 2": {
"tracks": [ "tracks": [
"audio/hi_02.flac", "audio/hi_02.flac",
"%cory hi", "%cory weather.temp.max",
"audio/lo_02.flac", "audio/lo_02.flac",
"%cory lo" "%cory weather.temp.min"
] ]
}, },
"rain 1": { "rain 1": {
@@ -114,5 +114,11 @@
"directory": "audio/voice/cory/", "directory": "audio/voice/cory/",
"extension": "flac" "extension": "flac"
} }
},
"weather": {
// Provide an OpenWeatherMap API key
// https://openweathermap.org/price
"key": "not0a0real0key00281f631aef6ad3a1",
"city": "chicago"
} }
} }

2393
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "morning-report", "name": "morning-report",
"version": "0.0.1", "version": "0.0.3",
"description": "Procedurally generates a radio weather report", "description": "Procedurally generates a radio weather report",
"keywords": [ "keywords": [
"weather", "weather",
@@ -19,16 +19,25 @@
}, },
"type": "module", "type": "module",
"main": "distribution/index.js", "main": "distribution/index.js",
"bin": {
"morning-report": "./distribution/src/index.js"
},
"files": [
"distribution"
],
"scripts": { "scripts": {
"build": "npx tsc", "build": "tsc",
"start": "node distribution/index.js" "start": "node distribution/src/index.js",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"json5": "2.2.3", "json5": "2.2.3",
"openweathermap-ts": "1.2.10" "openweather-api-node": "3.1.5"
}, },
"devDependencies": { "devDependencies": {
"typescript": "5.9.2", "typescript": "5.9.3",
"@types/node": "24.3.0" "@types/node": "24.7.2",
"vitest": "3.2.4",
"@vitest/coverage-v8": "3.2.4"
} }
} }

View File

@@ -1,23 +1,27 @@
#!/usr/bin/env node
import path from 'path'; import path from 'path';
import fsp from 'fs/promises'; import fsp from 'fs/promises';
import json5 from 'json5'; import json5 from 'json5';
import Sequencer from './sequencer.js'; import Sequencer from './sequencer.js';
import { Stitcher } from './stitcher.js';
import type {Programs, Segments, Sequences} from './sequencer.js'; import type {Programs, Segments, Sequences} from './sequencer.js';
import type { Voices } from './voice.js'; import type { Voices } from './voice.js';
import type { Options } from 'openweather-api-node';
interface Config { interface Config {
programs: Programs, programs: Programs,
segments: Segments, segments: Segments,
sequences: Sequences, sequences: Sequences,
voices: Voices voices: Voices,
weather: Options
} }
console.log('morning-report\nCory Sanin 2025\n'); 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 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')); console.log(sequence.join('\n'));
await Stitcher(sequence);
export type { Config }; export type { Config };

View File

@@ -1,11 +1,15 @@
import { OpenWeatherAPI, type DailyWeather } from 'openweather-api-node';
import { voiceLines } from './voice.js';
import type { Config } from './index.js'; import type { Config } from './index.js';
import type { Voice } from './voice.js';
import crypto from 'crypto';
type SegmentName = string; type SegmentName = string;
type SequenceName = string; type SequenceName = string;
type Programs = SegmentName[][]; type Programs = SegmentName[][];
type Segments = { [segment: SegmentName]: SequenceName[] }; type Segments = { [segment: SegmentName]: SequenceName[] };
type Sequence = { type Sequence = {
condition?: string; conditions?: string[];
tracks: string[]; tracks: string[];
} }
type Sequences = { [sequence: SequenceName]: Sequence }; type Sequences = { [sequence: SequenceName]: Sequence };
@@ -13,39 +17,93 @@ type Sequences = { [sequence: SequenceName]: Sequence };
let config: Config = null; let config: Config = null;
function selectOne<T>(arr: T[]): T { function selectOne<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)]; return arr[crypto.randomInt(0, 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') { if (typeof condition !== 'string') {
return true; return true;
} }
// TODO: parse condition, return bool const [lhs, relational, rhs] = condition.split(' ');
return false; if (lhs === undefined || relational === undefined || rhs === undefined) {
} throw new Error(`Condition "${condition}" is not in the correct format`);
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 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) { if (potentialSequences.length === 0) {
return []; 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; config = conf;
const weather = new OpenWeatherAPI(conf.weather);
const currentWeather = await weather.getToday();
const sequence: string[] = []; const sequence: string[] = [];
const program: SegmentName[] = selectOne(conf.programs); 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; return sequence;
} }

View File

@@ -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 };

View File

@@ -1,4 +1,4 @@
import path from 'path';
interface Voice { interface Voice {
directory: string; directory: string;
@@ -7,4 +7,164 @@ interface Voice {
type Voices = { [name: string]: Voice }; type Voices = { [name: string]: Voice };
const LINES = {
NEGATIVE: 'negative',
POINT: 'point',
ZERO: 'zero',
ONE: 'one',
TWO: 'two',
THREE: 'three',
FOUR: 'four',
FIVE: 'five',
SIX: 'six',
SEVEN: 'seven',
EIGHT: 'eight',
NINE: 'nine',
TEN: 'ten',
ELEVEN: 'eleven',
TWELVE: 'twelve',
THIRTEEN: 'thirteen',
FIFTEEN: 'fifteen',
TEEN: 'teen',
TWENTY: 'twenty',
THIRTY: 'thirty',
FORTY: 'forty',
FIFTY: 'fifty',
SIXTY: 'sixty',
SEVENTY: 'seventy',
EIGHTY: 'eighty',
NINETY: 'ninety',
HUNDRED: 'hundred',
THOUSAND: 'thousand',
MILLION: 'million',
BILLION: 'billion',
TRILLION: 'trillion'
}
function formatNumber(num: number): string {
const parts = num.toString().split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}
function digitByDigit(str: string): string[] {
const tokens: string[] = [];
const input = str.replaceAll(',', '');
const map = [
LINES.ZERO,
LINES.ONE,
LINES.TWO,
LINES.THREE,
LINES.FOUR,
LINES.FIVE,
LINES.SIX,
LINES.SEVEN,
LINES.EIGHT,
LINES.NINE
]
for (let i = 0; i < input.length; i++) {
tokens.push(map[parseInt(input.charAt(i))]);
}
return tokens;
}
function tens(str: string): string[] {
if (str === '0') {
return [LINES.ZERO];
}
const tokens: string[] = [];
const num = parseInt(str);
const map = {
'2': LINES.TWENTY,
'3': LINES.THIRTY,
'4': LINES.FORTY,
'5': LINES.FIFTY,
'6': LINES.SIXTY,
'7': LINES.SEVENTY,
'8': LINES.EIGHTY,
'9': LINES.NINETY
};
const ones = str.charAt(str.length - 1);
if (num === 0) {
return [];
}
else if (num >= 20) {
tokens.push(map[str.charAt(0)]);
if (ones !== '0') {
tokens.push(...digitByDigit(ones));
}
}
else if (num < 10) {
tokens.push(...digitByDigit(ones));
}
else {
const weirdoNumberMap = [
[LINES.TEN],
[LINES.ELEVEN],
[LINES.TWELVE],
[LINES.THIRTEEN],
[LINES.FOUR, LINES.TEEN],
[LINES.FIFTEEN],
[LINES.SIX, LINES.TEEN],
[LINES.SEVEN, LINES.TEEN],
[LINES.EIGHT, LINES.TEEN],
[LINES.NINE, LINES.TEEN],
]
tokens.push(...weirdoNumberMap[num - 10]);
}
return tokens;
}
function hundreds(str: string): string[] {
const tokens: string[] = [];
if (str.length === 3 && str.charAt(0) !== '0') {
tokens.push(...digitByDigit(str.charAt(0)));
tokens.push(LINES.HUNDRED);
str = str.substring(1);
}
tokens.push(...tens(str));
return tokens;
}
function integer(str: string): string[] {
const tokens: string[] = [];
const numGroups = str.split(',');
const seperators = [LINES.TRILLION, LINES.BILLION, LINES.MILLION, LINES.THOUSAND];
seperators.splice(0, seperators.length - numGroups.length + 1);
numGroups.forEach(g => {
if (g !== '000') {
tokens.push(...hundreds(g));
}
if (seperators.length === 0) {
return;
}
const sep = seperators.shift();
if (g !== '000') {
tokens.push(sep);
}
});
return tokens;
}
function voiceLines(voice: Voice, num: number): string[] {
if (Math.abs(num) > 999999999999999 || isNaN(num)) {
return [];
}
const tokens: string[] = [];
const str = formatNumber(num);
const parts = str.split('.');
if (parts[0].startsWith('-')) {
tokens.push(LINES.NEGATIVE);
parts[0] = parts[0].substring(1);
}
tokens.push(...integer(parts[0]));
if (parts.length > 1) {
tokens.push(LINES.POINT);
tokens.push(...digitByDigit(parts[1]));
}
return tokens.map(l => path.join(voice.directory, `${l}${voice.extension.length > 0 && voice.extension.charAt(0) !== '.' ? '.' : ''}${voice.extension}`));
}
export default voiceLines;
export { voiceLines, LINES };
export type { Voice, Voices }; export type { Voice, Voices };

View File

189
test/sequencer.test.ts Normal file
View File

@@ -0,0 +1,189 @@
import { describe, expect, it, vi } from 'vitest';
import { type Options } from 'openweather-api-node';
import { Sequencer } from '../src/sequencer.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
View 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
View 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`),
]);
});
});

View File

@@ -9,9 +9,9 @@
"moduleResolution": "node16", "moduleResolution": "node16",
"sourceMap": true, "sourceMap": true,
"inlineSources": true, "inlineSources": true,
"rootDir": "./src", "rootDir": "./",
"outDir": "./distribution" "outDir": "./distribution"
}, },
"include": ["src/**/*"], "include": ["src/**/*", "test/**/*"],
"exclude": ["**/*.spec.ts"] "exclude": ["**/*.spec.ts"]
} }