Procedurally generates a radio weather report
1import { OpenWeatherAPI, type DailyWeather } from 'openweather-api-node';
2import { voiceLines } from './voice.js';
3import type { Config } from './index.js';
4import type { Voice } from './voice.js';
5
6type SegmentName = string;
7type SequenceName = string;
8type Programs = SegmentName[][];
9type Segments = { [segment: SegmentName]: SequenceName[] };
10type Sequence = {
11 conditions?: string[];
12 tracks: string[];
13}
14type Sequences = { [sequence: SequenceName]: Sequence };
15
16let config: Config = null;
17
18function selectOne<T>(arr: T[]): T {
19 return arr[Math.floor(Math.random() * arr.length)];
20}
21
22function resolveSide(side: string, currentWeather: DailyWeather) {
23 if (!side.startsWith('weather')) {
24 return side.includes('.') ? parseFloat(side) : parseInt(side);
25 }
26
27 const tokens = side.split('.');
28 let w = currentWeather;
29 tokens.forEach(t => w = w[t]);
30 return typeof w === 'object' ? JSON.stringify(w) : w as (string | number);
31}
32
33function notNotANumber(something: number | string, defaultVal: string) {
34 if (typeof something === 'string' || !isNaN(something)) {
35 return something;
36 }
37 return defaultVal;
38}
39
40function conditionIsMet(condition: string | undefined, currentWeather: DailyWeather): boolean {
41 if (typeof condition !== 'string') {
42 return true;
43 }
44 const [lhs, relational, rhs] = condition.split(' ');
45 if (lhs === undefined || relational === undefined || rhs === undefined) {
46 throw new Error(`Condition "${condition}" is not in the correct format`);
47 }
48 const lhsResolved = notNotANumber(resolveSide(lhs, currentWeather), lhs);
49 const rhsResolved = notNotANumber(resolveSide(rhs, currentWeather), rhs);
50 switch (relational) {
51 case '=':
52 case '==':
53 return lhsResolved == rhsResolved;
54 case '!=':
55 return lhsResolved != rhsResolved;
56 case '<':
57 return lhsResolved < rhsResolved;
58 case '<=':
59 return lhsResolved <= rhsResolved;
60 case '>':
61 return lhsResolved > rhsResolved;
62 case '>=':
63 return lhsResolved >= rhsResolved;
64 default:
65 throw new Error(`Unsupported relational operator: ${relational}`);
66 }
67}
68
69function resolveMacro(str: string, currentWeather: DailyWeather): string[] {
70 if (str.startsWith('%')) {
71 const [profile, subject] = str.substring(1).split(' ', 2);
72 const voiceProfile: Voice = config.voices[profile];
73 let resolvedSubject: any = currentWeather;
74 subject.split('.').forEach(t => resolvedSubject = resolvedSubject[t]);
75 return voiceLines(voiceProfile, resolvedSubject);
76 }
77 return [str];
78}
79
80function processSequence(sequence: Sequence, currentWeather: DailyWeather): string[] {
81 const tracks = sequence.tracks;
82 return tracks.map(t => resolveMacro(t, currentWeather)).flat().filter(t => t !== null);
83}
84
85function processSegment(segment: SegmentName, currentWeather: DailyWeather): string[] {
86 if (!(segment in config.segments)) {
87 return (config.sequences[segment].conditions || []).every(c => conditionIsMet(c, currentWeather)) ? processSequence(config.sequences[segment], currentWeather) : [];
88 }
89 const potentialSequences: SequenceName[] = config.segments[segment].filter(s => (config.sequences[s].conditions || []).every(c => conditionIsMet(c, currentWeather)));
90 if (potentialSequences.length === 0) {
91 return [];
92 }
93 return processSequence(config.sequences[selectOne(potentialSequences)], currentWeather);
94}
95
96async function Sequencer(conf: Config): Promise<string[]> {
97 config = conf;
98 const weather = new OpenWeatherAPI(conf.weather);
99 const currentWeather = await weather.getToday();
100 const sequence: string[] = [];
101 const program: SegmentName[] = selectOne(conf.programs);
102 for (let i = 0; i < program.length; i++) {
103 const segment = program[i];
104 sequence.push(...(processSegment(segment, currentWeather)));
105 }
106 return sequence;
107}
108
109export default Sequencer;
110export { Sequencer };
111export type { SegmentName, SequenceName, Programs, Segments, Sequence, Sequences };