atmosx-nwws-parser 1.0.18 → 1.0.22

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/src/eas.ts ADDED
@@ -0,0 +1,493 @@
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
+ * @function generateEASAudio
25
+ * @description
26
+ * Generates an EAS (Emergency Alert System) audio file for a given message
27
+ * and SAME/VTEC code. The audio is composed of optional intro tones, SAME
28
+ * headers, attention tones, TTS narration of the message, and repeated
29
+ * SAME headers. The resulting audio is processed for NWR-style broadcast
30
+ * quality and saved as a WAV file.
31
+ *
32
+ * @static
33
+ * @async
34
+ * @param {string} message
35
+ * @param {string} header
36
+ * @returns {Promise<string | null>}
37
+ */
38
+ public static generateEASAudio(message: string, header: string) {
39
+ return new Promise(async (resolve) => {
40
+ const settings = loader.settings as types.ClientSettingsTypes;
41
+ const assetsDir = settings.global_settings.eas_settings.directory;
42
+ const rngFile = `${header.replace(/[^a-zA-Z0-9]/g, `_`)}`.substring(0, 32).replace(/^_+|_+$/g, '');
43
+ const os = loader.packages.os.platform();
44
+ for (const { regex, replacement } of loader.definitions.messageSignatures) { message = message.replace(regex, replacement); }
45
+ if (!assetsDir) { Utils.warn(loader.definitions.messages.eas_no_directory); return resolve(null); }
46
+ if (!loader.packages.fs.existsSync(assetsDir)) { loader.packages.fs.mkdirSync(assetsDir); }
47
+
48
+ const tmpTTS = loader.packages.path.join(assetsDir, `/tmp/${rngFile}.wav`);
49
+ const outTTS = loader.packages.path.join(assetsDir, `/output/${rngFile}.wav`);
50
+ const voice = process.platform === 'win32' ? 'Microsoft David Desktop' : 'en-US-GuyNeural';
51
+
52
+
53
+ if (!loader.packages.fs.existsSync(loader.packages.path.join(assetsDir, `/tmp`))) { loader.packages.fs.mkdirSync(loader.packages.path.join(assetsDir, `/tmp`), { recursive: true }); }
54
+ if (!loader.packages.fs.existsSync(loader.packages.path.join(assetsDir, `/output`))) { loader.packages.fs.mkdirSync(loader.packages.path.join(assetsDir, `/output`), { recursive: true }); }
55
+ if (os == 'win32') { loader.packages.say.export(message, voice, 1.0, tmpTTS); }
56
+ if (os == 'linux') {
57
+ message = message.replace(/[\r\n]+/g, ' ');
58
+ const festivalCommand = `echo "${message.replace(/"/g, '\\"')}" | text2wave -o "${tmpTTS}"`;
59
+ loader.packages.child.execSync(festivalCommand);
60
+ }
61
+ await Utils.sleep(3500);
62
+ let ttsBuffer: Buffer = null;
63
+ while (!loader.packages.fs.existsSync(tmpTTS) || (ttsBuffer = loader.packages.fs.readFileSync(tmpTTS)).length === 0) {
64
+ await Utils.sleep(25);
65
+ }
66
+
67
+ const ttsWav = this.parseWavPCM16(ttsBuffer);
68
+ const ttsSamples = this.resamplePCM16(ttsWav.samples, ttsWav.sampleRate, 8000);
69
+ const ttsRadio = this.applyNWREffect(ttsSamples, 8000);
70
+ let toneRadio = null;
71
+
72
+ if (loader.packages.fs.existsSync(settings.global_settings.eas_settings.intro_wav)) {
73
+ const toneBuffer = loader.packages.fs.readFileSync(settings.global_settings.eas_settings.intro_wav);
74
+ const toneWav = this.parseWavPCM16(toneBuffer);
75
+ if (toneWav == null) { console.log(`[EAS] Intro tone WAV file is not valid PCM 16-bit format.`); return resolve(null); }
76
+ const toneSamples = (toneWav.sampleRate !== 8000 ? this.resamplePCM16(toneWav.samples, toneWav.sampleRate, 8000) : toneWav.samples);
77
+ toneRadio = this.applyNWREffect(toneSamples, 8000);
78
+ }
79
+ let build = toneRadio != null ? [toneRadio, this.generateSilence(0.5, 8000)] : [];
80
+ build.push( this.generateSAMEHeader(header, 3, 8000, { preMarkSec: 1.1, gapSec: 0.5 }), this.generateSilence(0.5, 8000), this.generateAttentionTone(8, 8000), this.generateSilence(0.5, 8000), ttsRadio);
81
+
82
+ for (let i = 0; i < 3; i++) {
83
+ build.push(this.generateSAMEHeader(header, 1, 8000, { preMarkSec: 0.5, gapSec: 0.1 }));
84
+ build.push(this.generateSilence(0.5, 8000));
85
+ }
86
+ const allSamples = this.concatPCM16(build);
87
+ const finalSamples = this.addNoise(allSamples, 0.002);
88
+ const outBuffer = this.encodeWavPCM16(Array.from(finalSamples).map(v => ({ value: v })), 8000);
89
+ loader.packages.fs.writeFileSync(outTTS, outBuffer);
90
+ try {
91
+ loader.packages.fs.unlinkSync(tmpTTS);
92
+ } catch (error) {
93
+ if (error.code !== 'EBUSY') { throw error; }
94
+ }
95
+ return resolve(outTTS);
96
+ });
97
+ }
98
+
99
+ /**
100
+ * @function encodeWavPCM16
101
+ * @description
102
+ * Encodes an array of 16-bit PCM samples into a standard WAV file buffer.
103
+ * Produces mono audio with 16 bits per sample and a specified sample rate.
104
+ *
105
+ * The input `samples` array should be an array of objects containing a
106
+ * numeric `value` property representing the PCM sample.
107
+ *
108
+ * @private
109
+ * @static
110
+ * @param {Record<string, number>[]} samples
111
+ * @param {number} [sampleRate=8000]
112
+ * @returns {Buffer}
113
+ */
114
+ private static encodeWavPCM16(samples: Record<string, number>[], sampleRate: number = 8000) {
115
+ const bytesPerSample = 2;
116
+ const blockAlign = 1 * bytesPerSample;
117
+ const byteRate = sampleRate * blockAlign;
118
+ const subchunk2Size = samples.length * bytesPerSample;
119
+ const chunkSize = 36 + subchunk2Size;
120
+
121
+ const buffer = Buffer.alloc(44 + subchunk2Size);
122
+ let o = 0;
123
+ buffer.write("RIFF", o); o += 4;
124
+ buffer.writeUInt32LE(chunkSize, o); o += 4;
125
+ buffer.write("WAVE", o); o += 4;
126
+
127
+ buffer.write("fmt ", o); o += 4;
128
+ buffer.writeUInt32LE(16, o); o += 4;
129
+ buffer.writeUInt16LE(1, o); o += 2;
130
+ buffer.writeUInt16LE(1, o); o += 2;
131
+ buffer.writeUInt32LE(sampleRate, o); o += 4;
132
+ buffer.writeUInt32LE(byteRate, o); o += 4;
133
+ buffer.writeUInt16LE(blockAlign, o); o += 2;
134
+ buffer.writeUInt16LE(16, o); o += 2;
135
+
136
+ buffer.write("data", o); o += 4;
137
+ buffer.writeUInt32LE(subchunk2Size, o); o += 4;
138
+
139
+ for (let i = 0; i < samples.length; i++, o += 2) {
140
+ buffer.writeInt16LE(samples[i].value, o);
141
+ }
142
+ return buffer;
143
+ }
144
+
145
+ /**
146
+ * @function parseWavPCM16
147
+ * @description
148
+ * Parses a WAV buffer containing 16-bit PCM mono audio and extracts
149
+ * the sample data along with format information.
150
+ *
151
+ * Only supports PCM format (audioFormat = 1), 16 bits per sample,
152
+ * and single-channel (mono) audio. Returns `null` if the buffer
153
+ * is invalid or does not meet these requirements.
154
+ *
155
+ * @private
156
+ * @static
157
+ * @param {Buffer} buffer
158
+ * @returns { { samples: Int16Array; sampleRate: number; channels: number; bitsPerSample: number } | null }
159
+ */
160
+
161
+ private static parseWavPCM16(buffer: Buffer) {
162
+ if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WAVE") { return null; }
163
+ let fmt = null;
164
+ let data = null;
165
+ let i = 12;
166
+ while (i + 8 <= buffer.length) {
167
+ const id = buffer.toString("ascii", i, i + 4);
168
+ const size = buffer.readUInt32LE(i + 4);
169
+ const start = i + 8;
170
+ const end = start + size;
171
+ if (id === "fmt ") fmt = buffer.slice(start, end);
172
+ if (id === "data") data = buffer.slice(start, end);
173
+ i = end + (size % 2);
174
+ }
175
+ if (!fmt || !data) return null;
176
+ const audioFormat = fmt.readUInt16LE(0);
177
+ const channels = fmt.readUInt16LE(2);
178
+ const sampleRate = fmt.readUInt32LE(4);
179
+ const bitsPerSample = fmt.readUInt16LE(14);
180
+ if (audioFormat !== 1 || bitsPerSample !== 16 || channels !== 1) { return null; }
181
+ const samples = new Int16Array(data.buffer, data.byteOffset, data.length / 2);
182
+ return { samples: new Int16Array(samples), sampleRate, channels, bitsPerSample };
183
+ }
184
+
185
+ /**
186
+ * @function concatPCM16
187
+ * @description
188
+ * Concatenates multiple Int16Array PCM audio buffers into a single
189
+ * contiguous Int16Array.
190
+ *
191
+ * @private
192
+ * @static
193
+ * @param {Int16Array[]} arrays
194
+ * @returns {Int16Array}
195
+ */
196
+ private static concatPCM16(arrays: Int16Array[]) {
197
+ let total = 0;
198
+ for (const a of arrays) total += a.length;
199
+ const out = new Int16Array(total);
200
+ let o = 0;
201
+ for (const a of arrays) {
202
+ out.set(a, o);
203
+ o += a.length;
204
+ }
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * @function pcm16toFloat
210
+ * @description
211
+ * Converts a PCM16 Int16Array audio buffer to a Float32Array
212
+ * with normalized values in the range [-1, 1).
213
+ *
214
+ * @private
215
+ * @static
216
+ * @param {Int16Array} int16
217
+ * @returns {Float32Array}
218
+ */
219
+ private static pcm16toFloat(int16: Int16Array) {
220
+ const out = new Float32Array(int16.length);
221
+ for (let i = 0; i < int16.length; i++) out[i] = int16[i] / 32768;
222
+ return out;
223
+ }
224
+
225
+ /**
226
+ * @function floatToPcm16
227
+ * @description
228
+ * Converts a Float32Array of audio samples in the range [-1, 1]
229
+ * to a PCM16 Int16Array.
230
+ *
231
+ * @private
232
+ * @static
233
+ * @param {Float32Array} float32
234
+ * @returns {Int16Array}
235
+ */
236
+
237
+ private static floatToPcm16(float32: Float32Array) {
238
+ const out = new Int16Array(float32.length);
239
+ for (let i = 0; i < float32.length; i++) {
240
+ let v = Math.max(-1, Math.min(1, float32[i]));
241
+ out[i] = Math.round(v * 32767);
242
+ }
243
+ return out;
244
+ }
245
+
246
+ /**
247
+ * @function resamplePCM16
248
+ * @description
249
+ * Resamples a PCM16 audio buffer from an original sample rate to a
250
+ * target sample rate using linear interpolation.
251
+ *
252
+ * @private
253
+ * @static
254
+ * @param {Int16Array} int16
255
+ * @param {number} originalRate
256
+ * @param {number} targetRate
257
+ * @returns {Int16Array}
258
+ */
259
+ private static resamplePCM16(int16: Int16Array, originalRate: number, targetRate: number) {
260
+ if (originalRate === targetRate) return int16;
261
+ const ratio = targetRate / originalRate;
262
+ const outLen = Math.max(1, Math.round(int16.length * ratio));
263
+ const out = new Int16Array(outLen);
264
+ for (let i = 0; i < outLen; i++) {
265
+ const pos = i / ratio;
266
+ const i0 = Math.floor(pos);
267
+ const i1 = Math.min(i0 + 1, int16.length - 1);
268
+ const frac = pos - i0;
269
+ const v = int16[i0] * (1 - frac) + int16[i1] * frac;
270
+ out[i] = Math.round(v);
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * @function generateSilence
277
+ * @description
278
+ * Generates a PCM16 audio buffer containing silence for a specified
279
+ * duration.
280
+ *
281
+ * @private
282
+ * @static
283
+ * @param {number} ms
284
+ * @param {number} [sampleRate=8000]
285
+ * @returns {Int16Array}
286
+ */
287
+ private static generateSilence(ms: number, sampleRate:number = 8000) {
288
+ return new Int16Array(Math.floor(ms * sampleRate));
289
+ }
290
+
291
+ /**
292
+ * @function generateAttentionTone
293
+ * @description
294
+ * Generates a dual-frequency Attention Tone (853 Hz and 960 Hz) used in
295
+ * EAS/SAME alerts. Produces a PCM16 buffer of the specified duration.
296
+ *
297
+ * @private
298
+ * @static
299
+ * @param {number} ms
300
+ * @param {number} [sampleRate=8000]
301
+ * @returns {Int16Array}
302
+ */
303
+ private static generateAttentionTone(ms, sampleRate: number = 8000) {
304
+ const len = Math.floor(ms * sampleRate);
305
+ const out = new Int16Array(len);
306
+ const f1 = 853;
307
+ const f2 = 960;
308
+ const twoPi = Math.PI * 2;
309
+ const amp = 0.1;
310
+ const fadeLen = Math.floor(sampleRate * 0.00);
311
+ for (let i = 0; i < len; i++) {
312
+ const t = i / sampleRate;
313
+ const s = Math.sin(twoPi * f1 * t) + Math.sin(twoPi * f2 * t);
314
+ let gain = 1;
315
+ if (i < fadeLen) gain = i / fadeLen;
316
+ else if (i > len - fadeLen) gain = (len - i) / fadeLen;
317
+ const v = Math.max(-1, Math.min(1, (s / 2) * amp * gain));
318
+ out[i] = Math.round(v * 32767);
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /**
324
+ * @function applyNWREffect
325
+ * @description
326
+ * Applies a National Weather Radio (NWR)-style audio effect to a PCM16
327
+ * buffer, including high-pass and low-pass filtering, soft clipping
328
+ * compression, and optional bit reduction to simulate vintage broadcast
329
+ * characteristics.
330
+ *
331
+ * @private
332
+ * @static
333
+ * @param {Int16Array} int16
334
+ * @param {number} [sampleRate=8000]
335
+ * @returns {Int16Array}
336
+ */
337
+ private static applyNWREffect(int16: Int16Array, sampleRate: number = 8000) {
338
+ const hpCut = 3555;
339
+ const lpCut = 1600;
340
+ const noiseLevel = 0.0;
341
+ const crushBits = 8;
342
+ const x = this.pcm16toFloat(int16);
343
+ const dt = 1 / sampleRate;
344
+ const rcHP = 1 / (2 * Math.PI * hpCut);
345
+ const aHP = rcHP / (rcHP + dt);
346
+ let yHP = 0, xPrev = 0;
347
+ for (let i = 0; i < x.length; i++) {
348
+ const xi = x[i];
349
+ yHP = aHP * (yHP + xi - xPrev);
350
+ xPrev = xi;
351
+ x[i] = yHP;
352
+ }
353
+ const rcLP = 1 / (2 * Math.PI * lpCut);
354
+ const aLP = dt / (rcLP + dt);
355
+ let yLP = 0;
356
+ for (let i = 0; i < x.length; i++) {
357
+ yLP = yLP + aLP * (x[i] - yLP);
358
+ x[i] = yLP;
359
+ }
360
+ const compGain = 2.0;
361
+ const norm = Math.tanh(compGain);
362
+ for (let i = 0; i < x.length; i++) x[i] = Math.tanh(x[i] * compGain) / norm;
363
+ const levels = Math.pow(2, crushBits) - 1;
364
+ return this.floatToPcm16(x);
365
+ }
366
+
367
+ /**
368
+ * @function addNoise
369
+ * @description
370
+ * Adds random noise to a PCM16 audio buffer and normalizes the signal
371
+ * to prevent clipping. Useful for simulating real-world signal conditions
372
+ * or reducing digital artifacts.
373
+ *
374
+ * @private
375
+ * @static
376
+ * @param {Int16Array} int16
377
+ * @param {number} [noiseLevel=0.02]
378
+ * @returns {Int16Array}
379
+ */
380
+ private static addNoise(int16: Int16Array, noiseLevel: number = 0.02) {
381
+ const x = this.pcm16toFloat(int16);
382
+ for (let i = 0; i < x.length; i++) x[i] += (Math.random() * 2 - 1) * noiseLevel;
383
+ let peak = 0;
384
+ for (let i = 0; i < x.length; i++) peak = Math.max(peak, Math.abs(x[i]));
385
+ if (peak > 1) for (let i = 0; i < x.length; i++) x[i] *= 0.98 / peak;
386
+ return this.floatToPcm16(x);
387
+ }
388
+
389
+ /**
390
+ * @function asciiTo8N1Bits
391
+ * @description
392
+ * Converts an ASCII string into a sequence of bits using the 8N1 framing
393
+ * convention (1 start bit, 8 data bits, 2 stop bits) commonly used in
394
+ * serial and EAS transmissions.
395
+ *
396
+ * @private
397
+ * @static
398
+ * @param {string} str
399
+ * @returns {number[]}
400
+ */
401
+ private static asciiTo8N1Bits(str: string) {
402
+ const bits = [];
403
+ for (let i = 0; i < str.length; i++) {
404
+ const c = str.charCodeAt(i) & 0xFF;
405
+ bits.push(0);
406
+ for (let b = 0; b < 8; b++) bits.push((c >> b) & 1);
407
+ bits.push(1, 1);
408
+ }
409
+ return bits;
410
+ }
411
+
412
+ /**
413
+ * @function generateAFSK
414
+ * @description
415
+ * Converts a sequence of bits into AFSK-modulated PCM16 audio data for EAS
416
+ * alerts. Applies a fade-in and fade-out to reduce clicks and generates
417
+ * the audio at the specified sample rate.
418
+ *
419
+ * @private
420
+ * @static
421
+ * @param {number[]} bits
422
+ * @param {number} [sampleRate=8000]
423
+ * @returns {Int16Array}
424
+ */
425
+ private static generateAFSK(bits: number[], sampleRate: number = 8000) {
426
+ const baud = 520.83;
427
+ const markFreq = 2083.3;
428
+ const spaceFreq = 1562.5;
429
+ const amplitude = 0.6;
430
+ const twoPi = Math.PI * 2;
431
+ const result = [];
432
+ let phase = 0;
433
+ let frac = 0;
434
+ for (let b = 0; b < bits.length; b++) {
435
+ const bit = bits[b];
436
+ const freq = bit ? markFreq : spaceFreq;
437
+ const samplesPerBit = sampleRate / baud + frac;
438
+ const n = Math.round(samplesPerBit);
439
+ frac = samplesPerBit - n;
440
+ const inc = twoPi * freq / sampleRate;
441
+ for (let i = 0; i < n; i++) {
442
+ result.push(Math.round(Math.sin(phase) * amplitude * 32767));
443
+ phase += inc;
444
+ if (phase > twoPi) phase -= twoPi;
445
+ }
446
+ }
447
+ const fadeSamples = Math.floor(sampleRate * 0.002);
448
+ for (let i = 0; i < fadeSamples; i++) {
449
+ const gain = i / fadeSamples;
450
+ result[i] = Math.round(result[i] * gain);
451
+ result[result.length - 1 - i] = Math.round(result[result.length - 1 - i] * gain);
452
+ }
453
+
454
+ return Int16Array.from(result);
455
+ }
456
+
457
+ /**
458
+ * @function generateSAMEHeader
459
+ * @description
460
+ * Generates a SAME (Specific Area Message Encoding) audio header for
461
+ * EAS alerts. Converts a VTEC string into AFSK-modulated PCM16 audio,
462
+ * optionally repeating the signal with pre-mark and gap intervals.
463
+ *
464
+ * @private
465
+ * @static
466
+ * @param {string} vtec
467
+ * @param {number} repeats
468
+ * @param {number} [sampleRate=8000]
469
+ * @param {{preMarkSec?: number, gapSec?: number}} [options={}]
470
+ * @returns {Int16Array}
471
+ */
472
+ private static generateSAMEHeader(vtec: string, repeats: number, sampleRate: number = 8000, options: {preMarkSec?: number, gapSec?: number} = {}) {
473
+ const preMarkSec = options.preMarkSec ?? 0.3;
474
+ const gapSec = options.gapSec ?? 0.1;
475
+ const bursts = [];
476
+ const gap = this.generateSilence(gapSec, sampleRate);
477
+ for (let i = 0; i < repeats; i++) {
478
+ const bodyBits = this.asciiTo8N1Bits(vtec);
479
+ const body = this.generateAFSK(bodyBits, sampleRate);
480
+ const extendedBodyDuration = Math.round(preMarkSec * sampleRate);
481
+ const extendedBody = new Int16Array(extendedBodyDuration + gap.length);
482
+ for (let j = 0; j < extendedBodyDuration; j++) {
483
+ extendedBody[j] = Math.round(body[j % body.length] * 0.2);
484
+ }
485
+ extendedBody.set(gap, extendedBodyDuration);
486
+ bursts.push(extendedBody);
487
+ if (i !== repeats - 1) bursts.push(gap);
488
+ }
489
+ return this.concatPCM16(bursts);
490
+ }
491
+ }
492
+
493
+ export default EAS;