Compare commits
20 Commits
4a260f112f
...
master
Author | SHA1 | Date | |
---|---|---|---|
3a78d49974 | |||
0d53a6ccde | |||
6ecc03a670 | |||
3f0121a9ff | |||
021f714b06 | |||
b0e4a10b3e | |||
12862b4f85 | |||
16d5e834a5 | |||
ea50cf5f77 | |||
749b3e082c | |||
0c3b5c3be5 | |||
e6964315ab | |||
b8d09a6666 | |||
8f8819276f | |||
527de38044 | |||
9668cd80b4 | |||
b51d014c80 | |||
6ab158cee0 | |||
7f45387dac | |||
3b562116fd |
44
.github/workflows/test.yml
vendored
Normal file
44
.github/workflows/test.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -102,3 +102,6 @@ public
|
||||
config/config.json5
|
||||
distribution/
|
||||
.env
|
||||
test/*.js*
|
||||
output.wav
|
||||
output.mp3
|
||||
|
18
.tangled/workflows/audit.yml
Normal file
18
.tangled/workflows/audit.yml
Normal 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
|
24
.tangled/workflows/test.yml
Normal file
24
.tangled/workflows/test.yml
Normal 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
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": {
|
||||
@@ -114,5 +114,11 @@
|
||||
"directory": "audio/voice/cory/",
|
||||
"extension": "flac"
|
||||
}
|
||||
},
|
||||
"weather": {
|
||||
// Provide an OpenWeatherMap API key
|
||||
// https://openweathermap.org/price
|
||||
"key": "not0a0real0key00281f631aef6ad3a1",
|
||||
"city": "chicago"
|
||||
}
|
||||
}
|
||||
|
2393
package-lock.json
generated
2393
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "morning-report",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.3",
|
||||
"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"
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "24.7.2",
|
||||
"vitest": "3.2.4",
|
||||
"@vitest/coverage-v8": "3.2.4"
|
||||
}
|
||||
}
|
10
src/index.ts
10
src/index.ts
@@ -1,23 +1,27 @@
|
||||
#!/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 { Options } from 'openweather-api-node';
|
||||
|
||||
|
||||
interface Config {
|
||||
programs: Programs,
|
||||
segments: Segments,
|
||||
sequences: Sequences,
|
||||
voices: Voices
|
||||
voices: Voices,
|
||||
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,15 @@
|
||||
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';
|
||||
import crypto from 'crypto';
|
||||
|
||||
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 };
|
||||
@@ -13,39 +17,93 @@ type Sequences = { [sequence: SequenceName]: Sequence };
|
||||
let config: Config = null;
|
||||
|
||||
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') {
|
||||
return true;
|
||||
}
|
||||
// TODO: parse condition, return bool
|
||||
return false;
|
||||
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 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 processSequence(sequence: Sequence): string[] {
|
||||
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;
|
||||
// TODO: process voice macros
|
||||
return tracks;
|
||||
return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null);
|
||||
}
|
||||
|
||||
function processSegment(segment: SegmentName): string[] {
|
||||
function processSegment(segment: SegmentName, currentWeather: DailyWeather): string[] {
|
||||
if (!(segment in config.segments)) {
|
||||
return processSequence(config.sequences[segment]);
|
||||
return (config.sequences[segment].conditions || []).every(c => conditionIsMet(c, currentWeather)) ? processSequence(config.sequences[segment], currentWeather) : [];
|
||||
}
|
||||
const potentialSequences: SequenceName[] = config.segments[segment].filter(s => conditionIsMet(config.sequences[s].condition));
|
||||
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 };
|
||||
|
162
src/voice.ts
162
src/voice.ts
@@ -1,4 +1,4 @@
|
||||
|
||||
import path from 'path';
|
||||
|
||||
interface Voice {
|
||||
directory: string;
|
||||
@@ -7,4 +7,164 @@ interface 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 };
|
||||
|
189
test/sequencer.test.ts
Normal file
189
test/sequencer.test.ts
Normal 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
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