atmosx-nwws-parser 1.0.19 → 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/README.md +182 -64
- package/dist/cjs/index.cjs +3799 -0
- package/dist/esm/index.mjs +3757 -0
- package/package.json +49 -37
- package/src/bootstrap.ts +196 -0
- package/src/database.ts +148 -0
- package/src/dictionaries/awips.ts +342 -0
- package/src/dictionaries/events.ts +142 -0
- package/src/dictionaries/icao.ts +237 -0
- package/src/dictionaries/offshore.ts +12 -0
- package/src/dictionaries/signatures.ts +107 -0
- package/src/eas.ts +493 -0
- package/src/index.ts +229 -0
- package/src/parsers/events/api.ts +151 -0
- package/src/parsers/events/cap.ts +138 -0
- package/src/parsers/events/text.ts +106 -0
- package/src/parsers/events/ugc.ts +109 -0
- package/src/parsers/events/vtec.ts +78 -0
- package/src/parsers/events.ts +367 -0
- package/src/parsers/hvtec.ts +46 -0
- package/src/parsers/pvtec.ts +71 -0
- package/src/parsers/stanza.ts +132 -0
- package/src/parsers/text.ts +166 -0
- package/src/parsers/ugc.ts +197 -0
- package/src/types.ts +251 -0
- package/src/utils.ts +314 -0
- package/src/xmpp.ts +144 -0
- package/test.js +58 -34
- package/tsconfig.json +12 -5
- package/tsup.config.ts +14 -0
- package/bootstrap.ts +0 -122
- package/dist/bootstrap.js +0 -153
- package/dist/src/events.js +0 -585
- package/dist/src/helper.js +0 -463
- package/dist/src/stanza.js +0 -147
- package/dist/src/text-parser.js +0 -119
- package/dist/src/ugc.js +0 -214
- package/dist/src/vtec.js +0 -125
- package/src/events.ts +0 -394
- package/src/helper.ts +0 -298
- package/src/stanza.ts +0 -102
- package/src/text-parser.ts +0 -120
- package/src/ugc.ts +0 -122
- package/src/vtec.ts +0 -99
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;
|