atmosx-nwws-parser 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +6 -0
- package/dist/cjs/bootstrap.cjs +1009 -0
- package/dist/cjs/database.cjs +1114 -0
- package/dist/cjs/dictionaries/awips.cjs +379 -0
- package/dist/cjs/dictionaries/events.cjs +139 -0
- package/dist/cjs/dictionaries/icao.cjs +265 -0
- package/dist/cjs/dictionaries/offshore.cjs +40 -0
- package/dist/cjs/dictionaries/signatures.cjs +132 -0
- package/dist/cjs/eas.cjs +2857 -0
- package/dist/cjs/helper.cjs +3014 -0
- package/dist/cjs/parsers/events.cjs +2857 -0
- package/dist/cjs/parsers/stanza.cjs +1108 -0
- package/dist/cjs/parsers/text.cjs +1142 -0
- package/dist/cjs/parsers/types/api.cjs +2857 -0
- package/dist/cjs/parsers/types/cap.cjs +2857 -0
- package/dist/cjs/parsers/types/text.cjs +2857 -0
- package/dist/cjs/parsers/types/ugc.cjs +2857 -0
- package/dist/cjs/parsers/types/vtec.cjs +2857 -0
- package/dist/cjs/parsers/ugc.cjs +1139 -0
- package/dist/cjs/parsers/vtec.cjs +1060 -0
- package/dist/cjs/types.cjs +17 -0
- package/dist/cjs/utils.cjs +2857 -0
- package/dist/cjs/xmpp.cjs +2857 -0
- package/dist/esm/bootstrap.mjs +972 -0
- package/dist/esm/database.mjs +1079 -0
- package/dist/esm/dictionaries/awips.mjs +355 -0
- package/dist/esm/dictionaries/events.mjs +111 -0
- package/dist/esm/dictionaries/icao.mjs +241 -0
- package/dist/esm/dictionaries/offshore.mjs +16 -0
- package/dist/esm/dictionaries/signatures.mjs +106 -0
- package/dist/esm/eas.mjs +2824 -0
- package/dist/esm/helper.mjs +2974 -0
- package/dist/esm/parsers/events.mjs +2824 -0
- package/dist/esm/parsers/stanza.mjs +1072 -0
- package/dist/esm/parsers/text.mjs +1106 -0
- package/dist/esm/parsers/types/api.mjs +2824 -0
- package/dist/esm/parsers/types/cap.mjs +2824 -0
- package/dist/esm/parsers/types/text.mjs +2824 -0
- package/dist/esm/parsers/types/ugc.mjs +2824 -0
- package/dist/esm/parsers/types/vtec.mjs +2824 -0
- package/dist/esm/parsers/ugc.mjs +1104 -0
- package/dist/esm/parsers/vtec.mjs +1025 -0
- package/dist/esm/types.mjs +0 -0
- package/dist/esm/utils.mjs +2824 -0
- package/dist/esm/xmpp.mjs +2824 -0
- package/package.json +47 -0
- package/shapefiles/FireCounties.dbf +0 -0
- package/shapefiles/FireCounties.shp +0 -0
- package/shapefiles/FireZones.dbf +0 -0
- package/shapefiles/FireZones.shp +0 -0
- package/shapefiles/ForecastZones.dbf +0 -0
- package/shapefiles/ForecastZones.shp +0 -0
- package/shapefiles/Marine.dbf +0 -0
- package/shapefiles/Marine.shp +0 -0
- package/shapefiles/OffShoreZones.dbf +0 -0
- package/shapefiles/OffShoreZones.shp +0 -0
- package/shapefiles/USCounties.dbf +0 -0
- package/shapefiles/USCounties.shp +0 -0
- package/src/bootstrap.ts +171 -0
- package/src/database.ts +99 -0
- package/src/dictionaries/awips.ts +351 -0
- package/src/dictionaries/events.ts +109 -0
- package/src/dictionaries/icao.ts +237 -0
- package/src/dictionaries/offshore.ts +12 -0
- package/src/dictionaries/signatures.ts +103 -0
- package/src/eas.ts +428 -0
- package/src/helper.ts +167 -0
- package/src/parsers/events.ts +289 -0
- package/src/parsers/stanza.ts +103 -0
- package/src/parsers/text.ts +167 -0
- package/src/parsers/types/api.ts +94 -0
- package/src/parsers/types/cap.ts +89 -0
- package/src/parsers/types/text.ts +54 -0
- package/src/parsers/types/ugc.ts +85 -0
- package/src/parsers/types/vtec.ts +60 -0
- package/src/parsers/ugc.ts +148 -0
- package/src/parsers/vtec.ts +66 -0
- package/src/types.ts +187 -0
- package/src/utils.ts +216 -0
- package/src/xmpp.ts +123 -0
- package/test.js +1 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +11 -0
package/src/eas.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: KiyoWx (k3yomi)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import * as loader from './bootstrap';
|
|
16
|
+
import * as types from './types';
|
|
17
|
+
import Utils from './utils';
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
export class EAS {
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* generateEASAudio creates an EAS-compliant audio file in WAV format containing the provided message and VTEC header.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
* @static
|
|
28
|
+
* @param {string} message
|
|
29
|
+
* @param {string} vtec
|
|
30
|
+
* @returns {*}
|
|
31
|
+
*/
|
|
32
|
+
public static generateEASAudio(message: string, vtec: string): Promise<string> {
|
|
33
|
+
return new Promise(async (resolve) => {
|
|
34
|
+
const settings = loader.settings as types.ClientSettings;
|
|
35
|
+
for (const { regex, replacement } of loader.definitions.messageSignatures) { message = message.replace(regex, replacement); }
|
|
36
|
+
const assetsDir = settings.global.easSettings.easDirectory;
|
|
37
|
+
if (!assetsDir) { console.warn(loader.definitions.messages.eas_no_directory); return resolve(null); }
|
|
38
|
+
const rngFile = `${vtec.replace(/[^a-zA-Z0-9]/g, `_`)}`.substring(0, 32).replace(/^_+|_+$/g, '');
|
|
39
|
+
if (!loader.packages.fs.existsSync(assetsDir)) { loader.packages.fs.mkdirSync(assetsDir); }
|
|
40
|
+
|
|
41
|
+
const tmpTTS = loader.packages.path.join(assetsDir, `/tmp/${rngFile}.wav`);
|
|
42
|
+
const outTTS = loader.packages.path.join(assetsDir, `/output/${rngFile}.wav`);
|
|
43
|
+
const voice = process.platform === 'win32' ? 'Microsoft David Desktop' : 'en-US-GuyNeural';
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if (!loader.packages.fs.existsSync(loader.packages.path.join(assetsDir, `/tmp`))) { loader.packages.fs.mkdirSync(loader.packages.path.join(assetsDir, `/tmp`), { recursive: true }); }
|
|
47
|
+
if (!loader.packages.fs.existsSync(loader.packages.path.join(assetsDir, `/output`))) { loader.packages.fs.mkdirSync(loader.packages.path.join(assetsDir, `/output`), { recursive: true }); }
|
|
48
|
+
|
|
49
|
+
loader.packages.say.export(message, voice, 1.0, tmpTTS);
|
|
50
|
+
await Utils.sleep(2500);
|
|
51
|
+
|
|
52
|
+
let ttsBuffer: Buffer = null;
|
|
53
|
+
while (!loader.packages.fs.existsSync(tmpTTS) || (ttsBuffer = loader.packages.fs.readFileSync(tmpTTS)).length === 0) {
|
|
54
|
+
await Utils.sleep(500); // Wait for 500ms before retrying
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const ttsWav = this.parseWavPCM16(ttsBuffer);
|
|
58
|
+
const ttsSamples = this.resamplePCM16(ttsWav.samples, ttsWav.sampleRate, 8000);
|
|
59
|
+
const ttsRadio = this.applyNWREffect(ttsSamples, 8000);
|
|
60
|
+
let toneRadio = null;
|
|
61
|
+
|
|
62
|
+
if (loader.packages.fs.existsSync(settings.global.easSettings.easIntroWav)) {
|
|
63
|
+
const toneBuffer = loader.packages.fs.readFileSync(settings.global.easSettings.easIntroWav);
|
|
64
|
+
const toneWav = this.parseWavPCM16(toneBuffer);
|
|
65
|
+
const toneSamples = (toneWav.sampleRate !== 8000 ? this.resamplePCM16(toneWav.samples, toneWav.sampleRate, 8000) : toneWav.samples);
|
|
66
|
+
toneRadio = this.applyNWREffect(toneSamples, 8000);
|
|
67
|
+
}
|
|
68
|
+
let build = toneRadio != null ? [toneRadio, this.generateSilence(0.5, 8000)] : [];
|
|
69
|
+
build.push( this.generateSAMEHeader(vtec, 3, 8000, { preMarkSec: 1.1, gapSec: 0.5 }), this.generateSilence(0.5, 8000), this.generateAttentionTone(8, 8000), this.generateSilence(0.5, 8000), ttsRadio);
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
build.push(this.generateSAMEHeader(vtec, 1, 8000, { preMarkSec: 0.5, gapSec: 0.1 }));
|
|
73
|
+
build.push(this.generateSilence(0.5, 8000));
|
|
74
|
+
}
|
|
75
|
+
const allSamples = this.concatPCM16(build);
|
|
76
|
+
const finalSamples = this.addNoise(allSamples, 0.002);
|
|
77
|
+
const outBuffer = this.encodeWavPCM16(Array.from(finalSamples).map(v => ({ value: v })), 8000);
|
|
78
|
+
loader.packages.fs.writeFileSync(outTTS, outBuffer);
|
|
79
|
+
try {
|
|
80
|
+
loader.packages.fs.unlinkSync(tmpTTS);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'EBUSY') { throw error; }
|
|
83
|
+
}
|
|
84
|
+
return Promise.resolve(outTTS);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* encodeWavPCM16 encodes an array of samples into a WAV PCM 16-bit Buffer.
|
|
90
|
+
*
|
|
91
|
+
* @private
|
|
92
|
+
* @static
|
|
93
|
+
* @param {Record<string, number>[]} samples
|
|
94
|
+
* @param {number} [sampleRate=8000]
|
|
95
|
+
* @returns {Buffer}
|
|
96
|
+
*/
|
|
97
|
+
private static encodeWavPCM16(samples: Record<string, number>[], sampleRate: number = 8000): Buffer {
|
|
98
|
+
const bytesPerSample = 2;
|
|
99
|
+
const blockAlign = 1 * bytesPerSample;
|
|
100
|
+
const byteRate = sampleRate * blockAlign;
|
|
101
|
+
const subchunk2Size = samples.length * bytesPerSample;
|
|
102
|
+
const chunkSize = 36 + subchunk2Size;
|
|
103
|
+
|
|
104
|
+
const buffer = Buffer.alloc(44 + subchunk2Size);
|
|
105
|
+
let o = 0;
|
|
106
|
+
buffer.write("RIFF", o); o += 4;
|
|
107
|
+
buffer.writeUInt32LE(chunkSize, o); o += 4;
|
|
108
|
+
buffer.write("WAVE", o); o += 4;
|
|
109
|
+
|
|
110
|
+
buffer.write("fmt ", o); o += 4;
|
|
111
|
+
buffer.writeUInt32LE(16, o); o += 4;
|
|
112
|
+
buffer.writeUInt16LE(1, o); o += 2;
|
|
113
|
+
buffer.writeUInt16LE(1, o); o += 2;
|
|
114
|
+
buffer.writeUInt32LE(sampleRate, o); o += 4;
|
|
115
|
+
buffer.writeUInt32LE(byteRate, o); o += 4;
|
|
116
|
+
buffer.writeUInt16LE(blockAlign, o); o += 2;
|
|
117
|
+
buffer.writeUInt16LE(16, o); o += 2;
|
|
118
|
+
|
|
119
|
+
buffer.write("data", o); o += 4;
|
|
120
|
+
buffer.writeUInt32LE(subchunk2Size, o); o += 4;
|
|
121
|
+
|
|
122
|
+
for (let i = 0; i < samples.length; i++, o += 2) {
|
|
123
|
+
buffer.writeInt16LE(samples[i].value, o);
|
|
124
|
+
}
|
|
125
|
+
return buffer;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* parseWavPCM16 decodes a WAV PCM 16-bit Buffer into its sample data and format information.
|
|
130
|
+
*
|
|
131
|
+
* @private
|
|
132
|
+
* @static
|
|
133
|
+
* @param {Buffer} buffer
|
|
134
|
+
* @returns {{ samples: any; sampleRate: any; channels: any; bitsPerSample: any; }}
|
|
135
|
+
*/
|
|
136
|
+
private static parseWavPCM16(buffer: Buffer) {
|
|
137
|
+
if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") { return null; }
|
|
138
|
+
let fmt = null;
|
|
139
|
+
let data = null;
|
|
140
|
+
let i = 12;
|
|
141
|
+
while (i + 8 <= buffer.length) {
|
|
142
|
+
const id = buffer.toString("ascii", i, i + 4);
|
|
143
|
+
const size = buffer.readUInt32LE(i + 4);
|
|
144
|
+
const start = i + 8;
|
|
145
|
+
const end = start + size;
|
|
146
|
+
if (id === "fmt ") fmt = buffer.slice(start, end);
|
|
147
|
+
if (id === "data") data = buffer.slice(start, end);
|
|
148
|
+
i = end + (size % 2);
|
|
149
|
+
}
|
|
150
|
+
if (!fmt || !data) return null;
|
|
151
|
+
const audioFormat = fmt.readUInt16LE(0);
|
|
152
|
+
const channels = fmt.readUInt16LE(2);
|
|
153
|
+
const sampleRate = fmt.readUInt32LE(4);
|
|
154
|
+
const bitsPerSample = fmt.readUInt16LE(14);
|
|
155
|
+
if (audioFormat !== 1 || bitsPerSample !== 16 || channels !== 1) { return null; }
|
|
156
|
+
const samples = new Int16Array(data.buffer, data.byteOffset, data.length / 2);
|
|
157
|
+
return { samples: new Int16Array(samples), sampleRate, channels, bitsPerSample };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* concatPCM16 concatenates multiple Int16Array buffers into a single Int16Array buffer.
|
|
162
|
+
*
|
|
163
|
+
* @private
|
|
164
|
+
* @static
|
|
165
|
+
* @param {Int16Array[]} arrays
|
|
166
|
+
* @returns {*}
|
|
167
|
+
*/
|
|
168
|
+
private static concatPCM16(arrays: Int16Array[]) {
|
|
169
|
+
let total = 0;
|
|
170
|
+
for (const a of arrays) total += a.length;
|
|
171
|
+
const out = new Int16Array(total);
|
|
172
|
+
let o = 0;
|
|
173
|
+
for (const a of arrays) {
|
|
174
|
+
out.set(a, o);
|
|
175
|
+
o += a.length;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* pcm16toFloat converts an Int16Array of PCM 16-bit samples to a Float32Array of normalized float samples.
|
|
182
|
+
*
|
|
183
|
+
* @private
|
|
184
|
+
* @static
|
|
185
|
+
* @param {Int16Array} int16
|
|
186
|
+
* @returns {*}
|
|
187
|
+
*/
|
|
188
|
+
private static pcm16toFloat(int16: Int16Array) {
|
|
189
|
+
const out = new Float32Array(int16.length);
|
|
190
|
+
for (let i = 0; i < int16.length; i++) out[i] = int16[i] / 32768;
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* floatToPcm16 converts a Float32Array of normalized float samples to an Int16Array of PCM 16-bit samples.
|
|
196
|
+
*
|
|
197
|
+
* @private
|
|
198
|
+
* @static
|
|
199
|
+
* @param {Float32Array} float32
|
|
200
|
+
* @returns {*}
|
|
201
|
+
*/
|
|
202
|
+
private static floatToPcm16(float32: Float32Array) {
|
|
203
|
+
const out = new Int16Array(float32.length);
|
|
204
|
+
for (let i = 0; i < float32.length; i++) {
|
|
205
|
+
let v = Math.max(-1, Math.min(1, float32[i]));
|
|
206
|
+
out[i] = Math.round(v * 32767);
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* resamplePCM16 resamples an Int16Array of PCM 16-bit samples from the original sample rate to the target sample rate using linear interpolation.
|
|
213
|
+
*
|
|
214
|
+
* @private
|
|
215
|
+
* @static
|
|
216
|
+
* @param {Int16Array} int16
|
|
217
|
+
* @param {number} originalRate
|
|
218
|
+
* @param {number} targetRate
|
|
219
|
+
* @returns {*}
|
|
220
|
+
*/
|
|
221
|
+
private static resamplePCM16(int16: Int16Array, originalRate: number, targetRate: number) {
|
|
222
|
+
if (originalRate === targetRate) return int16;
|
|
223
|
+
const ratio = targetRate / originalRate;
|
|
224
|
+
const outLen = Math.max(1, Math.round(int16.length * ratio));
|
|
225
|
+
const out = new Int16Array(outLen);
|
|
226
|
+
for (let i = 0; i < outLen; i++) {
|
|
227
|
+
const pos = i / ratio;
|
|
228
|
+
const i0 = Math.floor(pos);
|
|
229
|
+
const i1 = Math.min(i0 + 1, int16.length - 1);
|
|
230
|
+
const frac = pos - i0;
|
|
231
|
+
const v = int16[i0] * (1 - frac) + int16[i1] * frac;
|
|
232
|
+
out[i] = Math.round(v);
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* generateSilence creates an Int16Array of PCM 16-bit samples representing silence for the specified duration in milliseconds.
|
|
239
|
+
*
|
|
240
|
+
* @private
|
|
241
|
+
* @static
|
|
242
|
+
* @param {number} ms
|
|
243
|
+
* @param {number} [sampleRate=8000]
|
|
244
|
+
* @returns {*}
|
|
245
|
+
*/
|
|
246
|
+
private static generateSilence(ms: number, sampleRate:number = 8000) {
|
|
247
|
+
return new Int16Array(Math.floor(ms * sampleRate));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* generateAttentionTone creates an Int16Array of PCM 16-bit samples representing the EAS attention tone for the specified duration in milliseconds.
|
|
252
|
+
*
|
|
253
|
+
* @private
|
|
254
|
+
* @static
|
|
255
|
+
* @param {*} ms
|
|
256
|
+
* @param {number} [sampleRate=8000]
|
|
257
|
+
* @returns {*}
|
|
258
|
+
*/
|
|
259
|
+
private static generateAttentionTone(ms, sampleRate: number = 8000) {
|
|
260
|
+
const len = Math.floor(ms * sampleRate);
|
|
261
|
+
const out = new Int16Array(len);
|
|
262
|
+
const f1 = 853;
|
|
263
|
+
const f2 = 960;
|
|
264
|
+
const twoPi = Math.PI * 2;
|
|
265
|
+
const amp = 0.1;
|
|
266
|
+
const fadeLen = Math.floor(sampleRate * 0.00);
|
|
267
|
+
for (let i = 0; i < len; i++) {
|
|
268
|
+
const t = i / sampleRate;
|
|
269
|
+
const s = Math.sin(twoPi * f1 * t) + Math.sin(twoPi * f2 * t);
|
|
270
|
+
let gain = 1;
|
|
271
|
+
if (i < fadeLen) gain = i / fadeLen;
|
|
272
|
+
else if (i > len - fadeLen) gain = (len - i) / fadeLen;
|
|
273
|
+
const v = Math.max(-1, Math.min(1, (s / 2) * amp * gain));
|
|
274
|
+
out[i] = Math.round(v * 32767);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* applyNWREffect applies a series of audio processing effects to simulate the sound characteristics of NOAA Weather Radio broadcasts.
|
|
281
|
+
*
|
|
282
|
+
* @private
|
|
283
|
+
* @static
|
|
284
|
+
* @param {Int16Array} int16
|
|
285
|
+
* @param {number} [sampleRate=8000]
|
|
286
|
+
* @returns {*}
|
|
287
|
+
*/
|
|
288
|
+
private static applyNWREffect(int16: Int16Array, sampleRate: number = 8000) {
|
|
289
|
+
const hpCut = 3555;
|
|
290
|
+
const lpCut = 1600;
|
|
291
|
+
const noiseLevel = 0.0;
|
|
292
|
+
const crushBits = 8;
|
|
293
|
+
const x = this.pcm16toFloat(int16);
|
|
294
|
+
const dt = 1 / sampleRate;
|
|
295
|
+
const rcHP = 1 / (2 * Math.PI * hpCut);
|
|
296
|
+
const aHP = rcHP / (rcHP + dt);
|
|
297
|
+
let yHP = 0, xPrev = 0;
|
|
298
|
+
for (let i = 0; i < x.length; i++) {
|
|
299
|
+
const xi = x[i];
|
|
300
|
+
yHP = aHP * (yHP + xi - xPrev);
|
|
301
|
+
xPrev = xi;
|
|
302
|
+
x[i] = yHP;
|
|
303
|
+
}
|
|
304
|
+
const rcLP = 1 / (2 * Math.PI * lpCut);
|
|
305
|
+
const aLP = dt / (rcLP + dt);
|
|
306
|
+
let yLP = 0;
|
|
307
|
+
for (let i = 0; i < x.length; i++) {
|
|
308
|
+
yLP = yLP + aLP * (x[i] - yLP);
|
|
309
|
+
x[i] = yLP;
|
|
310
|
+
}
|
|
311
|
+
const compGain = 2.0;
|
|
312
|
+
const norm = Math.tanh(compGain);
|
|
313
|
+
for (let i = 0; i < x.length; i++) x[i] = Math.tanh(x[i] * compGain) / norm;
|
|
314
|
+
const levels = Math.pow(2, crushBits) - 1;
|
|
315
|
+
return this.floatToPcm16(x);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* addNoise adds low-level white noise to an Int16Array of PCM 16-bit samples to simulate analog broadcast imperfections.
|
|
320
|
+
*
|
|
321
|
+
* @private
|
|
322
|
+
* @static
|
|
323
|
+
* @param {Int16Array} int16
|
|
324
|
+
* @param {number} [noiseLevel=0.02]
|
|
325
|
+
* @returns {*}
|
|
326
|
+
*/
|
|
327
|
+
private static addNoise(int16: Int16Array, noiseLevel: number = 0.02) {
|
|
328
|
+
const x = this.pcm16toFloat(int16);
|
|
329
|
+
for (let i = 0; i < x.length; i++) x[i] += (Math.random() * 2 - 1) * noiseLevel;
|
|
330
|
+
let peak = 0;
|
|
331
|
+
for (let i = 0; i < x.length; i++) peak = Math.max(peak, Math.abs(x[i]));
|
|
332
|
+
if (peak > 1) for (let i = 0; i < x.length; i++) x[i] *= 0.98 / peak;
|
|
333
|
+
return this.floatToPcm16(x);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* asciiTo8N1Bits converts an ASCII string to a sequence of bits using 8-N-1 encoding (8 data bits, no parity, 1 stop bit).
|
|
338
|
+
*
|
|
339
|
+
* @private
|
|
340
|
+
* @static
|
|
341
|
+
* @param {string} str
|
|
342
|
+
* @returns {{}}
|
|
343
|
+
*/
|
|
344
|
+
private static asciiTo8N1Bits(str: string) {
|
|
345
|
+
const bits = [];
|
|
346
|
+
for (let i = 0; i < str.length; i++) {
|
|
347
|
+
const c = str.charCodeAt(i) & 0xFF;
|
|
348
|
+
bits.push(0);
|
|
349
|
+
for (let b = 0; b < 8; b++) bits.push((c >> b) & 1);
|
|
350
|
+
bits.push(1, 1);
|
|
351
|
+
}
|
|
352
|
+
return bits;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* generateAFSK generates an Int16Array of PCM 16-bit samples representing AFSK modulation of the provided bit sequence.
|
|
357
|
+
*
|
|
358
|
+
* @private
|
|
359
|
+
* @static
|
|
360
|
+
* @param {number[]} bits
|
|
361
|
+
* @param {number} [sampleRate=8000]
|
|
362
|
+
* @returns {*}
|
|
363
|
+
*/
|
|
364
|
+
private static generateAFSK(bits: number[], sampleRate: number = 8000) {
|
|
365
|
+
const baud = 520.83;
|
|
366
|
+
const markFreq = 2083.3;
|
|
367
|
+
const spaceFreq = 1562.5;
|
|
368
|
+
const amplitude = 0.6;
|
|
369
|
+
const twoPi = Math.PI * 2;
|
|
370
|
+
const result = [];
|
|
371
|
+
let phase = 0;
|
|
372
|
+
let frac = 0;
|
|
373
|
+
for (let b = 0; b < bits.length; b++) {
|
|
374
|
+
const bit = bits[b];
|
|
375
|
+
const freq = bit ? markFreq : spaceFreq;
|
|
376
|
+
const samplesPerBit = sampleRate / baud + frac;
|
|
377
|
+
const n = Math.round(samplesPerBit);
|
|
378
|
+
frac = samplesPerBit - n;
|
|
379
|
+
const inc = twoPi * freq / sampleRate;
|
|
380
|
+
for (let i = 0; i < n; i++) {
|
|
381
|
+
result.push(Math.round(Math.sin(phase) * amplitude * 32767));
|
|
382
|
+
phase += inc;
|
|
383
|
+
if (phase > twoPi) phase -= twoPi;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const fadeSamples = Math.floor(sampleRate * 0.002);
|
|
387
|
+
for (let i = 0; i < fadeSamples; i++) {
|
|
388
|
+
const gain = i / fadeSamples;
|
|
389
|
+
result[i] = Math.round(result[i] * gain);
|
|
390
|
+
result[result.length - 1 - i] = Math.round(result[result.length - 1 - i] * gain);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return Int16Array.from(result);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* generateSAMEHeader generates an Int16Array of PCM 16-bit samples representing the SAME header repeated the specified number of times.
|
|
398
|
+
*
|
|
399
|
+
* @private
|
|
400
|
+
* @static
|
|
401
|
+
* @param {string} vtec
|
|
402
|
+
* @param {number} repeats
|
|
403
|
+
* @param {number} [sampleRate=8000]
|
|
404
|
+
* @param {{preMarkSec?: number, gapSec?: number}} [options={}]
|
|
405
|
+
* @returns {*}
|
|
406
|
+
*/
|
|
407
|
+
private static generateSAMEHeader(vtec: string, repeats: number, sampleRate: number = 8000, options: {preMarkSec?: number, gapSec?: number} = {}) {
|
|
408
|
+
const preMarkSec = options.preMarkSec ?? 0.3;
|
|
409
|
+
const gapSec = options.gapSec ?? 0.1;
|
|
410
|
+
const bursts = [];
|
|
411
|
+
const gap = this.generateSilence(gapSec, sampleRate);
|
|
412
|
+
for (let i = 0; i < repeats; i++) {
|
|
413
|
+
const bodyBits = this.asciiTo8N1Bits(vtec);
|
|
414
|
+
const body = this.generateAFSK(bodyBits, sampleRate);
|
|
415
|
+
const extendedBodyDuration = Math.round(preMarkSec * sampleRate);
|
|
416
|
+
const extendedBody = new Int16Array(extendedBodyDuration + gap.length);
|
|
417
|
+
for (let j = 0; j < extendedBodyDuration; j++) {
|
|
418
|
+
extendedBody[j] = Math.round(body[j % body.length] * 0.2);
|
|
419
|
+
}
|
|
420
|
+
extendedBody.set(gap, extendedBodyDuration);
|
|
421
|
+
bursts.push(extendedBody);
|
|
422
|
+
if (i !== repeats - 1) bursts.push(gap);
|
|
423
|
+
}
|
|
424
|
+
return this.concatPCM16(bursts);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export default EAS;
|
package/src/helper.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: KiyoWx (k3yomi)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import * as loader from './bootstrap';
|
|
16
|
+
import * as types from './types';
|
|
17
|
+
import Utils from './utils';
|
|
18
|
+
import Xmpp from './xmpp';
|
|
19
|
+
import StanzaParser from './parsers/stanza';
|
|
20
|
+
import Database from './database';
|
|
21
|
+
import EAS from './eas';
|
|
22
|
+
import EventParser from './parsers/events';
|
|
23
|
+
import TextParser from './parsers/text';
|
|
24
|
+
import VtecParser from './parsers/vtec';
|
|
25
|
+
import UGCParser from './parsers/ugc';
|
|
26
|
+
|
|
27
|
+
export class AlertManager {
|
|
28
|
+
isNoaaWeatherWireService: boolean
|
|
29
|
+
constructor(metadata: Record<string, string> = {}) { this.start(metadata) }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* setDisplayName allows you to set or update the nickname of the client for identification purposes.
|
|
33
|
+
* This does require you to restart the XMPP client if you are using NoaaWeatherWireService for primary data.
|
|
34
|
+
* Changing this setting does not affect the username used for authentication.
|
|
35
|
+
*
|
|
36
|
+
* @public
|
|
37
|
+
* @param {?string} [name]
|
|
38
|
+
*/
|
|
39
|
+
public setDisplayName(name?: string): void {
|
|
40
|
+
const settings = loader.settings as types.ClientSettings;
|
|
41
|
+
const trimmed = name?.trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
loader.cache.events.emit(`onError`, { code: `error-invalid-nickname`, message: loader.definitions.messages.invalid_nickname });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
settings.NoaaWeatherWireService.clientCredentials.nickname = trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* getAllAlertTypes provides a comprehensive list of all possible alert event and action combinations
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
* @returns {{}}
|
|
54
|
+
*/
|
|
55
|
+
public getAllAlertTypes() {
|
|
56
|
+
const events = new Set<string>();
|
|
57
|
+
const actions = new Set<string>();
|
|
58
|
+
const combinations: string[] = [];
|
|
59
|
+
Object.values(loader.definitions.events).forEach(event => events.add(event));
|
|
60
|
+
Object.values(loader.definitions.actions).forEach(action => actions.add(action));
|
|
61
|
+
Array.from(events).forEach(event => {
|
|
62
|
+
Array.from(actions).forEach(action => {
|
|
63
|
+
combinations.push(`${event} ${action}`);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return combinations;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* searchAlertDatbase allows you to search the internal alert database for previously received alerts up to 50,000 records.
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
* @async
|
|
74
|
+
* @param {string} query
|
|
75
|
+
* @returns {unknown}
|
|
76
|
+
*/
|
|
77
|
+
public async searchStanzaDatabase(query: string) {
|
|
78
|
+
return await loader.cache.db.prepare(`SELECT * FROM stanzas WHERE stanza LIKE ? ORDER BY id DESC LIMIT 250`).all(`%${query}%`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* setSettings allow you to dynamically update the settings of the AlertManager instance. This doesn't
|
|
83
|
+
* require a refresh of the instance. However, if you are switching to NWWS->NWS or vice versa,
|
|
84
|
+
* you will need to call stop() and then start() for changes to take effect.
|
|
85
|
+
*
|
|
86
|
+
* @public
|
|
87
|
+
* @async
|
|
88
|
+
* @param {types.ClientSettings} settings
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
public async setSettings(settings: types.ClientSettings): Promise<void> {
|
|
92
|
+
Utils.mergeClientSettings(loader.settings, settings);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* onEvent allows the client to listen for specific events emitted by the parser.
|
|
97
|
+
* Events include:
|
|
98
|
+
* - onAlerts: Emitted when a batch of new alerts have been fully parsed
|
|
99
|
+
* - onMessage: Emitted when a raw CAP/XML has been parsed by the StanzaParser
|
|
100
|
+
* - onError: Emitted when an error occurs within the parser
|
|
101
|
+
* - onConnection: Emitted when the XMPP client connects successfully
|
|
102
|
+
* - onReconnect: Emitted when the XMPP client is attempting to reconnect
|
|
103
|
+
* - onOccupant: Emitted when an occupant joins or leaves the XMPP MUC room (NWWS only)
|
|
104
|
+
* - onAnyEventType (Ex. onTornadoWarning) Emitted when a specific alert event type is received
|
|
105
|
+
*
|
|
106
|
+
* @public
|
|
107
|
+
* @param {string} event
|
|
108
|
+
* @param {(...args: any[]) => void} callback
|
|
109
|
+
* @returns {() => void}
|
|
110
|
+
*/
|
|
111
|
+
public onEvent(event: string, callback: (...args: any[]) => void): () => void {
|
|
112
|
+
loader.cache.events.on(event, callback);
|
|
113
|
+
return () => loader.cache.events.off(event, callback);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* start initializes the AlertManager instance, setting up necessary configurations and connections.
|
|
118
|
+
* This method must be called before the instance can begin processing alerts.
|
|
119
|
+
*
|
|
120
|
+
* @public
|
|
121
|
+
* @async
|
|
122
|
+
* @param {Record<string, string>} [metadata={}]
|
|
123
|
+
* @returns {Promise<void>}
|
|
124
|
+
*/
|
|
125
|
+
public async start(metadata: Record<string, string> = {}): Promise<void> {
|
|
126
|
+
if (!loader.cache.isReady) {
|
|
127
|
+
console.log(loader.definitions.messages.not_ready);
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
}
|
|
130
|
+
Object.assign(loader.settings, metadata)
|
|
131
|
+
Utils.detectUncaughtExceptions();
|
|
132
|
+
const settings = loader.settings as types.ClientSettings;
|
|
133
|
+
this.isNoaaWeatherWireService = loader.settings.isNWWS
|
|
134
|
+
loader.cache.isReady = false;
|
|
135
|
+
if (this.isNoaaWeatherWireService) {
|
|
136
|
+
await Database.loadDatabase();
|
|
137
|
+
await Xmpp.deploySession();
|
|
138
|
+
await Utils.loadCollectionCache();
|
|
139
|
+
}
|
|
140
|
+
Utils.handleCronJob(this.isNoaaWeatherWireService)
|
|
141
|
+
loader.packages.cron.schedule(`*/${!this.isNoaaWeatherWireService ? settings.NationalWeatherService.checkInterval : 5} * * * * *`, () => {
|
|
142
|
+
Utils.handleCronJob(this.isNoaaWeatherWireService);
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* stop terminates the AlertManager instance, closing any active connections and cleaning up resources.
|
|
148
|
+
*
|
|
149
|
+
* @public
|
|
150
|
+
* @async
|
|
151
|
+
* @returns {Promise<void>}
|
|
152
|
+
*/
|
|
153
|
+
public async stop(): Promise<void> {
|
|
154
|
+
loader.cache.isReady = true;
|
|
155
|
+
loader.packages.cron.getTasks().forEach((task: any) => task.stop());
|
|
156
|
+
if (loader.cache.session && this.isNoaaWeatherWireService) {
|
|
157
|
+
await loader.cache.session.stop();
|
|
158
|
+
loader.cache.sigHalt = true;
|
|
159
|
+
loader.cache.isConnected = false;
|
|
160
|
+
loader.cache.session = null;
|
|
161
|
+
this.isNoaaWeatherWireService = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default AlertManager;
|
|
167
|
+
export { StanzaParser, EventParser, TextParser, VtecParser, UGCParser, EAS, Database };
|