@atmosx/event-product-parser 2.0.1

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