-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathbot.js
More file actions
6500 lines (5697 loc) · 257 KB
/
Copy pathbot.js
File metadata and controls
6500 lines (5697 loc) · 257 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// bot.js - Main Discord bot application with integrated webserver and initialization
require('dotenv').config();
// Get environment variables first, before any usage
const {
BOT_PORT: PORT,
PUBLIC_DOMAIN,
DISCORD_TOKEN,
MAPPED_TALK_GROUPS: mappedTalkGroupsString,
ENABLE_MAPPED_TALK_GROUPS = 'true',
TIMEZONE,
API_KEY_FILE,
SUMMARY_LOOKBACK_HOURS,
TRANSCRIPTION_MODE,
FASTER_WHISPER_SERVER_URL,
WHISPER_MODEL,
STORAGE_MODE,
S3_ENDPOINT,
S3_BUCKET_NAME,
S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY,
ASK_AI_LOOKBACK_HOURS,
MAX_CONCURRENT_TRANSCRIPTIONS,
// --- NEW: AI Provider Env Vars ---
AI_PROVIDER,
OPENAI_API_KEY,
OPENAI_MODEL,
OLLAMA_URL,
OLLAMA_MODEL,
TRANSCRIPTION_DEVICE,
// --- NEW: Python Command Override ---
PYTHON_COMMAND,
// --- NEW: Auto-update Control ---
AUTO_UPDATE_PYTHON_PACKAGES = 'true',
// --- NEW: ICAD Transcription Env Vars ---
ICAD_URL,
ICAD_PROFILE,
ICAD_API_KEY,
// --- NEW: OpenAI Transcription Prompting ---
OPENAI_TRANSCRIPTION_PROMPT,
OPENAI_TRANSCRIPTION_MODEL,
OPENAI_TRANSCRIPTION_TEMPERATURE,
// --- Webserver Env Vars ---
WEBSERVER_PORT = '3001',
WEBSERVER_PASSWORD,
ENABLE_AUTH = 'false',
SESSION_DURATION_DAYS = '7',
MAX_SESSIONS_PER_USER = '5',
GOOGLE_MAPS_API_KEY = null,
LOCATIONIQ_API_KEY = null,
// --- NEW: Two-Tone Detection Env Vars ---
ENABLE_TWO_TONE_MODE,
TWO_TONE_TALK_GROUPS: twoToneTalkGroupsString,
TWO_TONE_QUEUE_SIZE,
TONE_DETECTION_TYPE,
TWO_TONE_MIN_TONE_LENGTH,
TWO_TONE_MAX_TONE_LENGTH,
PULSED_MIN_CYCLES,
PULSED_MIN_ON_MS,
PULSED_MAX_ON_MS,
PULSED_MIN_OFF_MS,
PULSED_MAX_OFF_MS,
PULSED_BANDWIDTH_HZ,
LONG_TONE_MIN_LENGTH,
LONG_TONE_BANDWIDTH_HZ,
TONE_DETECTION_THRESHOLD,
TONE_FREQUENCY_BAND,
TONE_TIME_RESOLUTION_MS
} = process.env;
// --- VALIDATE AI-RELATED ENV VARS ---
if (!AI_PROVIDER) {
console.error("FATAL: AI_PROVIDER is not set in the .env file. Please specify 'ollama' or 'openai'.");
process.exit(1);
}
if (AI_PROVIDER.toLowerCase() === 'openai') {
if (!OPENAI_API_KEY || !OPENAI_MODEL) {
console.error("FATAL: AI_PROVIDER is 'openai', but OPENAI_API_KEY or OPENAI_MODEL is missing in the .env file.");
process.exit(1);
}
} else if (AI_PROVIDER.toLowerCase() === 'ollama') {
if (!OLLAMA_URL || !OLLAMA_MODEL) {
console.error("FATAL: AI_PROVIDER is 'ollama', but OLLAMA_URL or OLLAMA_MODEL is missing in the .env file.");
process.exit(1);
}
} else {
console.error(`FATAL: Invalid AI_PROVIDER specified in .env file: '${AI_PROVIDER}'. Must be 'openai' or 'ollama'.`);
process.exit(1);
}
// --- END VALIDATION ---
// --- VALIDATE TRANSCRIPTION-RELATED ENV VARS ---
const effectiveTranscriptionMode = TRANSCRIPTION_MODE || 'local'; // Keep this to ensure a default
if (!['local', 'remote', 'openai', 'icad'].includes(effectiveTranscriptionMode)) {
console.error(`FATAL: Invalid TRANSCRIPTION_MODE specified in .env file: '${TRANSCRIPTION_MODE}'. Must be 'local', 'remote', 'openai', or 'icad'.`);
process.exit(1);
}
if (effectiveTranscriptionMode === 'local' && !TRANSCRIPTION_DEVICE) {
console.error("FATAL: TRANSCRIPTION_MODE is 'local', but TRANSCRIPTION_DEVICE is missing in the .env file. Please set it to 'cuda' for a GPU or 'cpu' for CPU.");
process.exit(1);
}
if (effectiveTranscriptionMode === 'remote' && !FASTER_WHISPER_SERVER_URL) {
console.error("FATAL: TRANSCRIPTION_MODE is 'remote', but FASTER_WHISPER_SERVER_URL is missing in the .env file.");
process.exit(1);
}
if (effectiveTranscriptionMode === 'openai' && !OPENAI_API_KEY) {
console.error("FATAL: TRANSCRIPTION_MODE is 'openai', but OPENAI_API_KEY is missing in the .env file. This is required for OpenAI transcriptions.");
process.exit(1);
}
if (effectiveTranscriptionMode === 'icad' && !ICAD_URL) {
console.error("FATAL: TRANSCRIPTION_MODE is 'icad', but ICAD_URL is missing in the .env file. Please set it to your ICAD API endpoint URL.");
process.exit(1);
}
// --- END VALIDATION ---
// Now initialize derived variables
const express = require('express');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const busboy = require('busboy');
const sqlite3 = require('sqlite3').verbose();
const { spawn } = require('child_process');
const { Readable } = require('stream');
const fetch = require('node-fetch');
const winston = require('winston');
const moment = require('moment-timezone');
const TALK_GROUPS = {};
const readline = require('readline');
const FormData = require('form-data');
const csv = require('csv-parser');
const http = require('http');
const socketIo = require('socket.io');
const crypto = require('crypto');
let summaryChannel;
const SUMMARY_INTERVAL = 10 * 60 * 1000; // Changed from 5 to 10 minutes in milliseconds
let lastSummaryUpdate = 0;
let summaryMessage = null;
// Parse lookback hours from environment with fallback to 1 hour
const parsedLookbackHours = parseFloat(SUMMARY_LOOKBACK_HOURS);
const LOOKBACK_HOURS = isNaN(parsedLookbackHours) ? 1 : parsedLookbackHours;
const LOOKBACK_PERIOD = LOOKBACK_HOURS * 60 * 60 * 1000; // Convert hours to milliseconds
// Parse the string into an array
const MAPPED_TALK_GROUPS = mappedTalkGroupsString
? mappedTalkGroupsString.split(',').map(id => id.trim())
: [];
const IS_MAPPED_TALK_GROUPS_ENABLED = ENABLE_MAPPED_TALK_GROUPS.toLowerCase() === 'true';
// Parse Two-Tone configuration
const IS_TWO_TONE_MODE_ENABLED = ENABLE_TWO_TONE_MODE.toLowerCase() === 'true';
const TWO_TONE_TALK_GROUPS = twoToneTalkGroupsString
? twoToneTalkGroupsString.split(',').map(id => id.trim())
: [];
const TWO_TONE_QUEUE_SIZE_VALUE = parseInt(TWO_TONE_QUEUE_SIZE, 10) || 1;
// Validate required two-tone environment variables if two-tone mode is enabled
if (ENABLE_TWO_TONE_MODE && ENABLE_TWO_TONE_MODE.toLowerCase() === 'true') {
const requiredTwoToneVars = [
'ENABLE_TWO_TONE_MODE', 'TWO_TONE_TALK_GROUPS', 'TWO_TONE_QUEUE_SIZE',
'TONE_DETECTION_TYPE', 'TWO_TONE_MIN_TONE_LENGTH', 'TWO_TONE_MAX_TONE_LENGTH',
'PULSED_MIN_CYCLES', 'PULSED_MIN_ON_MS', 'PULSED_MAX_ON_MS',
'PULSED_MIN_OFF_MS', 'PULSED_MAX_OFF_MS', 'PULSED_BANDWIDTH_HZ',
'LONG_TONE_MIN_LENGTH', 'LONG_TONE_BANDWIDTH_HZ', 'TONE_DETECTION_THRESHOLD',
'TONE_FREQUENCY_BAND', 'TONE_TIME_RESOLUTION_MS'
];
const missingVars = requiredTwoToneVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
console.error(`FATAL: Two-tone mode is enabled but missing required environment variables: ${missingVars.join(', ')}`);
console.error('Please add these variables to your .env file. See TWO_TONE_ENV_ADDITIONS.txt for the complete list.');
process.exit(1);
}
}
const TWO_TONE_CONFIG = {
detectionType: TONE_DETECTION_TYPE,
minToneLength: parseFloat(TWO_TONE_MIN_TONE_LENGTH),
maxToneLength: parseFloat(TWO_TONE_MAX_TONE_LENGTH),
pulsedMinCycles: parseInt(PULSED_MIN_CYCLES, 10),
pulsedMinOnMs: parseInt(PULSED_MIN_ON_MS, 10),
pulsedMaxOnMs: parseInt(PULSED_MAX_ON_MS, 10),
pulsedMinOffMs: parseInt(PULSED_MIN_OFF_MS, 10),
pulsedMaxOffMs: parseInt(PULSED_MAX_OFF_MS, 10),
pulsedBandwidthHz: parseInt(PULSED_BANDWIDTH_HZ, 10),
longToneMinLength: parseFloat(LONG_TONE_MIN_LENGTH),
longToneBandwidthHz: parseInt(LONG_TONE_BANDWIDTH_HZ, 10),
detectionThreshold: parseFloat(TONE_DETECTION_THRESHOLD),
frequencyBand: TONE_FREQUENCY_BAND,
timeResolutionMs: parseInt(TONE_TIME_RESOLUTION_MS, 10)
};
// Parse MAX_CONCURRENT_TRANSCRIPTIONS from env or use default
const parsedMaxConcurrent = parseInt(MAX_CONCURRENT_TRANSCRIPTIONS, 10);
const MAX_CONCURRENT_TRANSCRIPTIONS_VALUE = !isNaN(parsedMaxConcurrent) && parsedMaxConcurrent > 0 ? parsedMaxConcurrent : 3;
// High-volume system optimizations
const MAX_QUEUE_SIZE = 50; // Limit queue size to prevent memory issues
const QUEUE_DRAIN_THRESHOLD = 40; // Start warning when queue gets large
const PRIORITY_QUEUE_THRESHOLD = 30; // Start prioritizing newer items
// NOTE: For very busy systems with lots of concurrent calls, consider:
// 1. Increasing MAX_CONCURRENT_TRANSCRIPTIONS in .env (default is 3, try 5-8 for busy systems)
// 2. Using a faster GPU for local transcription or switching to 'remote' mode
// 3. Monitoring memory usage and adjusting MAX_QUEUE_SIZE if needed
// 4. Consider using 'openai' transcription mode for highest reliability under load
// Two-Tone Detection System
let twoToneQueue = []; // Queue to track calls after two-tone detection
let pendingToneDetections = new Map(); // Track ongoing tone detections
let lastTwoToneTime = 0; // Timestamp of last detected two-tone
let lastDetectedToneGroup = null; // Talk group where last tone was detected
// *** NEW: Tone Detection Queue to prevent race conditions ***
let toneDetectionQueue = []; // Queue for tone detection requests
let isProcessingToneDetection = false; // Flag to prevent concurrent processing
function addToTwoToneQueue(callInfo) {
logger.info(`Adding call to two-tone queue: ${callInfo.id} (TG ${callInfo.talkGroupID})`);
twoToneQueue.push({
...callInfo,
addedAt: Date.now()
});
// Keep queue size manageable - remove old calls for the same talk group
const sameTgCalls = twoToneQueue.filter(call => call.talkGroupID === callInfo.talkGroupID);
if (sameTgCalls.length > TWO_TONE_QUEUE_SIZE_VALUE) {
// Find and remove the oldest call for this talk group
const oldestIndex = twoToneQueue.findIndex(call => call.talkGroupID === callInfo.talkGroupID);
if (oldestIndex !== -1) {
const removed = twoToneQueue.splice(oldestIndex, 1)[0];
logger.info(`Removed old call from two-tone queue: ${removed.id} (TG ${removed.talkGroupID})`);
}
}
}
function shouldCheckForAddress(talkGroupID, transcriptionId) {
let shouldCheck = false;
// Check 1: Traditional mapped talk groups (if enabled)
if (IS_MAPPED_TALK_GROUPS_ENABLED && MAPPED_TALK_GROUPS.includes(talkGroupID)) {
shouldCheck = true;
logger.info(`Address check: Talk group ${talkGroupID} is in mapped talk groups`);
}
// Check 2: Two-tone queue (if two-tone mode is enabled)
if (IS_TWO_TONE_MODE_ENABLED) {
const queueIndex = twoToneQueue.findIndex(call => call.id === transcriptionId);
if (queueIndex !== -1) {
// Remove from queue since we're processing it
twoToneQueue.splice(queueIndex, 1);
shouldCheck = true;
logger.info(`Address check: Call ${transcriptionId} found in two-tone queue`);
}
}
if (!shouldCheck) {
logger.info(`Address check: Skipping - not in mapped groups (${IS_MAPPED_TALK_GROUPS_ENABLED}) and not in two-tone queue`);
}
return shouldCheck;
}
function handleToneDetectionResult(hasTwoTone, detectedTones, transcriptionId, talkGroupID, detectedType = 'unknown') {
if (hasTwoTone) {
lastTwoToneTime = Date.now();
lastDetectedToneGroup = talkGroupID; // Store which talk group had the tone
// Clear any existing queue entries for this talk group to start fresh
const initialQueueLength = twoToneQueue.length;
twoToneQueue = twoToneQueue.filter(call => call.talkGroupID !== talkGroupID);
const removedCount = initialQueueLength - twoToneQueue.length;
if (removedCount > 0) {
logger.info(`Cleared ${removedCount} existing queue entries for TG ${talkGroupID} after new tone detection`);
}
// *** NEW: Check if this call needs address extraction ***
// If this call was skipped for address extraction earlier, process it now
if (IS_TWO_TONE_MODE_ENABLED && TWO_TONE_TALK_GROUPS.includes(talkGroupID)) {
// Get the transcription text from the database to check if it has content
db.get(`SELECT transcription FROM transcriptions WHERE id = ?`, [transcriptionId], async (err, row) => {
if (!err && row && row.transcription && row.transcription.length >= 15) {
logger.info(`Tone detected on call ID ${transcriptionId} - checking if address extraction was skipped`);
// Check if this call already has coordinates (meaning address was already processed)
db.get(`SELECT lat, lon FROM transcriptions WHERE id = ?`, [transcriptionId], async (err2, coordRow) => {
if (!err2 && coordRow && (coordRow.lat === null || coordRow.lon === null)) {
logger.info(`Address extraction was skipped for tone call ID ${transcriptionId} - processing now`);
try {
await extractAndProcessAddress(transcriptionId, row.transcription, talkGroupID);
logger.info(`Successfully processed address extraction for tone call ID ${transcriptionId}`);
} catch (addressError) {
logger.error(`Error processing address extraction for tone call ID ${transcriptionId}: ${addressError.message}`);
}
} else if (!err2 && coordRow && coordRow.lat !== null && coordRow.lon !== null) {
logger.info(`Address extraction already completed for tone call ID ${transcriptionId}`);
}
});
}
});
}
// Build tone details for the main message
let toneDetails = '';
if (detectedTones && detectedTones.length > 0) {
// Build comprehensive tone details including all detected tones
const toneDetailsList = [];
detectedTones.forEach((tone, index) => {
if (tone.tone_a && tone.tone_b) {
// Two-tone sequence with separate fields
toneDetailsList.push(`${tone.tone_a?.toFixed(1)}Hz + ${tone.tone_b?.toFixed(1)}Hz`);
} else if (tone.detected) {
// Handle both single tones and two-tone arrays
if (Array.isArray(tone.detected)) {
// Two-tone sequence with array format [freq1, freq2]
const freq1 = Math.round(tone.detected[0]);
const freq2 = Math.round(tone.detected[1]);
const length1 = tone.tone_a_length ? `${tone.tone_a_length.toFixed(1)}s` : '';
const length2 = tone.tone_b_length ? `${tone.tone_b_length.toFixed(1)}s` : '';
toneDetailsList.push(`${freq1}Hz (${length1}) + ${freq2}Hz (${length2})`);
} else {
// Single frequency (long tone)
const length = tone.length ? `${tone.length.toFixed(1)}s` : '';
toneDetailsList.push(`${Math.round(tone.detected)}Hz (${length})`);
}
} else if (tone.frequency) {
// Alternative frequency field
const length = tone.length ? `${tone.length.toFixed(1)}s` : '';
toneDetailsList.push(`${tone.frequency?.toFixed(1)}Hz (${length})`);
}
});
if (toneDetailsList.length > 0) {
toneDetails = ` TONE_DETAILS[${detectedType}: ${toneDetailsList.join(' | ')}]TONE_DETAILS`;
}
}
logger.info(`Dispatch tone detected on TG ${talkGroupID}!${toneDetails} Next ${TWO_TONE_QUEUE_SIZE_VALUE} calls from this talk group will be checked for addresses`);
// Log additional detected tone frequencies if any
if (detectedTones && detectedTones.length > 0) {
detectedTones.forEach((tone, index) => {
if (tone.tone_a && tone.tone_b) {
logger.info(` Tone ${index + 1}: ${tone.tone_a?.toFixed(1)}Hz → ${tone.tone_b?.toFixed(1)}Hz`);
} else if (tone.detected) {
// Handle both single tones and two-tone arrays
if (Array.isArray(tone.detected)) {
// Two-tone sequence with array format [freq1, freq2]
const freq1 = tone.detected[0]?.toFixed(1);
const freq2 = tone.detected[1]?.toFixed(1);
const length1 = tone.tone_a_length ? `${tone.tone_a_length.toFixed(1)}s` : '';
const length2 = tone.tone_b_length ? `${tone.tone_b_length.toFixed(1)}s` : '';
logger.info(` Tone ${index + 1}: ${freq1}Hz (${length1}) → ${freq2}Hz (${length2})`);
} else {
// Single frequency (long tone)
logger.info(` Tone ${index + 1}: ${tone.detected?.toFixed(1)}Hz (${tone.length?.toFixed(1)}s)`);
}
} else if (tone.frequency) {
logger.info(` Pulse ${index + 1}: ${tone.frequency?.toFixed(1)}Hz`);
}
});
}
} else {
logger.info(`No dispatch tone detected for TG ${talkGroupID} (ID: ${transcriptionId})`);
// Clean up stale queue entries when no tone is detected
cleanStaleQueueEntries();
}
}
// Clean up stale queue entries (older than 10 minutes)
function cleanStaleQueueEntries() {
const now = Date.now();
const staleThreshold = 10 * 60 * 1000; // 10 minutes
const initialLength = twoToneQueue.length;
twoToneQueue = twoToneQueue.filter(call => {
const age = now - call.addedAt;
return age < staleThreshold;
});
const removedCount = initialLength - twoToneQueue.length;
if (removedCount > 0) {
logger.info(`Cleaned ${removedCount} stale queue entries (older than 10 minutes)`);
}
}
function detectTwoTone(audioFilePath, transcriptionId, talkGroupID, callback) {
// For non-local modes, use a separate Python process for tone detection
if (effectiveTranscriptionMode !== 'local') {
logger.info(`Using standalone tone detection for ${effectiveTranscriptionMode} mode`);
return detectTwoToneStandalone(audioFilePath, transcriptionId, talkGroupID, callback);
}
if (!transcriptionProcess) {
logger.error('Transcription process not available for tone detection');
if (callback) callback(false, null);
return;
}
const requestId = uuidv4();
pendingToneDetections.set(requestId, {
transcriptionId,
talkGroupID,
audioFilePath,
callback,
startTime: Date.now()
});
logger.info(`Starting tone detection for ID ${transcriptionId} (request: ${requestId})`);
const toneDetectionPayload = {
command: 'detect_tones',
id: requestId,
path: audioFilePath
};
try {
transcriptionProcess.stdin.write(JSON.stringify(toneDetectionPayload) + '\n');
} catch (error) {
logger.error(`Error sending tone detection command: ${error.message}`);
pendingToneDetections.delete(requestId);
if (callback) callback(false, error);
}
}
// *** NEW: Queue-based tone detection to prevent race conditions ***
function detectTwoToneQueued(audioFilePath, transcriptionId, talkGroupID, callback) {
// Add to queue instead of running immediately
toneDetectionQueue.push({
audioFilePath,
transcriptionId,
talkGroupID,
callback,
addedAt: Date.now()
});
logger.info(`Queued tone detection for ID ${transcriptionId} (queue length: ${toneDetectionQueue.length})`);
// Start processing if not already running
if (!isProcessingToneDetection) {
processNextToneDetection();
}
}
function processNextToneDetection() {
if (toneDetectionQueue.length === 0 || isProcessingToneDetection) {
return;
}
isProcessingToneDetection = true;
const request = toneDetectionQueue.shift();
logger.info(`Processing tone detection for ID ${request.transcriptionId} (queue length: ${toneDetectionQueue.length})`);
// Use the standalone detection function
detectTwoToneStandalone(request.audioFilePath, request.transcriptionId, request.talkGroupID, (hasTwoTone, detectedTones, detectedType) => {
// Call the original callback
if (request.callback) {
request.callback(hasTwoTone, detectedTones, detectedType);
}
// Mark as done and process next
isProcessingToneDetection = false;
// Process next request if any
if (toneDetectionQueue.length > 0) {
setImmediate(() => processNextToneDetection());
}
});
}
function detectTwoToneStandalone(audioFilePath, transcriptionId, talkGroupID, callback) {
const { spawn } = require('child_process');
logger.info(`Starting standalone tone detection for ID ${transcriptionId}`);
// Create the Python command to run tone detection
const pythonCommand = PYTHON_COMMAND || 'python';
logger.info(`Using Python command: ${pythonCommand}`);
logger.info(`Running: ${pythonCommand} tone_detect.py "${audioFilePath}"`);
const toneArgs = [
'tone_detect.py',
audioFilePath
];
// Set environment variables for the Python process
const env = {
...process.env,
TONE_DETECTION_TYPE: TWO_TONE_CONFIG.detectionType,
TWO_TONE_MIN_TONE_LENGTH: TWO_TONE_CONFIG.minToneLength.toString(),
TWO_TONE_MAX_TONE_LENGTH: TWO_TONE_CONFIG.maxToneLength.toString(),
PULSED_MIN_CYCLES: TWO_TONE_CONFIG.pulsedMinCycles.toString(),
PULSED_MIN_ON_MS: TWO_TONE_CONFIG.pulsedMinOnMs.toString(),
PULSED_MAX_ON_MS: TWO_TONE_CONFIG.pulsedMaxOnMs.toString(),
PULSED_MIN_OFF_MS: TWO_TONE_CONFIG.pulsedMinOffMs.toString(),
PULSED_MAX_OFF_MS: TWO_TONE_CONFIG.pulsedMaxOffMs.toString(),
PULSED_BANDWIDTH_HZ: TWO_TONE_CONFIG.pulsedBandwidthHz.toString(),
LONG_TONE_MIN_LENGTH: TWO_TONE_CONFIG.longToneMinLength.toString(),
LONG_TONE_BANDWIDTH_HZ: TWO_TONE_CONFIG.longToneBandwidthHz.toString(),
TONE_DETECTION_THRESHOLD: TWO_TONE_CONFIG.detectionThreshold.toString(),
TONE_FREQUENCY_BAND: TWO_TONE_CONFIG.frequencyBand,
TONE_TIME_RESOLUTION_MS: TWO_TONE_CONFIG.timeResolutionMs.toString()
};
try {
const toneProcess = spawn(pythonCommand, toneArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env: env,
timeout: 30000 // 30 second timeout
});
let output = '';
let errorOutput = '';
toneProcess.stdout.on('data', (data) => {
output += data.toString();
});
toneProcess.stderr.on('data', (data) => {
errorOutput += data.toString();
});
toneProcess.on('close', (code) => {
if (code === 0) {
try {
const result = JSON.parse(output);
const hasTwoTone = result.has_two_tone || false;
// Extract tones from the CLI output JSON
let detectedTones = [];
let detectedType = result.detected_type || 'unknown';
if (result.detection_result && result.detection_result.cli_output) {
try {
const cliResult = JSON.parse(result.detection_result.cli_output);
// Combine all detected tone types into one array
detectedTones = [
...(cliResult.long_tone || []),
...(cliResult.two_tone || []),
...(cliResult.pulsed || [])
];
} catch (cliParseError) {
logger.warn(`Error parsing CLI output: ${cliParseError.message}`);
}
}
logger.info(`Standalone tone detection result for ID ${transcriptionId}: ${hasTwoTone} (${detectedTones.length} tones)`);
// Log stderr output for debugging
if (errorOutput.trim()) {
logger.info(`Tone detection stderr output: ${errorOutput.trim()}`);
}
// Log stdout for debugging
if (output.trim()) {
logger.info(`Tone detection stdout output: ${output.trim()}`);
}
// Note: Don't process the result here - let the callback handle it via handleToneDetectionResult()
if (callback) {
callback(hasTwoTone, detectedTones, detectedType);
}
} catch (parseError) {
logger.error(`Error parsing tone detection output: ${parseError.message}`);
logger.error(`Raw output: ${output}`);
if (callback) callback(false, null, 'unknown');
}
} else {
logger.error(`Standalone tone detection failed with code ${code}`);
logger.error(`Error output: ${errorOutput}`);
if (callback) callback(false, null, 'unknown');
}
});
toneProcess.on('error', (error) => {
logger.error(`Standalone tone detection process error: ${error.message}`);
if (callback) callback(false, null, 'unknown');
});
} catch (error) {
logger.error(`Error starting standalone tone detection: ${error.message}`);
if (callback) callback(false, null, 'unknown');
}
}
function handleLocalToneDetectionResult(result) {
const requestId = result.id;
const pendingDetection = pendingToneDetections.get(requestId);
if (!pendingDetection) {
logger.warn(`Received tone detection result for unknown request: ${requestId}`);
return;
}
pendingToneDetections.delete(requestId);
const { transcriptionId, talkGroupID, callback } = pendingDetection;
const hasTwoTone = result.has_two_tone || false;
const detectedTones = result.detected_tones || [];
logger.info(`Tone detection result for ID ${transcriptionId}: ${hasTwoTone} (${detectedTones.length} tones)`);
if (hasTwoTone) {
lastTwoToneTime = Date.now();
lastDetectedToneGroup = talkGroupID; // Store which talk group had the tone
logger.info(`Tone detected on TG ${talkGroupID}! Next ${TWO_TONE_QUEUE_SIZE_VALUE} calls from this talk group will be checked for addresses`);
// Log detected tone frequencies
detectedTones.forEach((tone, index) => {
if (tone.tone_a && tone.tone_b) {
logger.info(` Tone ${index + 1}: ${tone.tone_a?.toFixed(1)}Hz → ${tone.tone_b?.toFixed(1)}Hz`);
} else if (tone.detected) {
// Handle both single tones and two-tone arrays
if (Array.isArray(tone.detected)) {
// Two-tone sequence with array format [freq1, freq2]
const freq1 = tone.detected[0]?.toFixed(1);
const freq2 = tone.detected[1]?.toFixed(1);
const length1 = tone.tone_a_length ? `${tone.tone_a_length.toFixed(1)}s` : '';
const length2 = tone.tone_b_length ? `${tone.tone_b_length.toFixed(1)}s` : '';
logger.info(` Tone ${index + 1}: ${freq1}Hz (${length1}) → ${freq2}Hz (${length2})`);
} else {
// Single frequency (long tone)
logger.info(` Tone ${index + 1}: ${tone.detected?.toFixed(1)}Hz (${tone.length?.toFixed(1)}s)`);
}
} else if (tone.frequency) {
logger.info(` Pulse ${index + 1}: ${tone.frequency?.toFixed(1)}Hz`);
}
});
}
if (callback) {
callback(hasTwoTone, detectedTones);
}
}
// Whitelist patterns for console INFO messages
const allowedPatterns = [
// Core dispatch information
/^--- Incoming Request ---$/,
/^Talk Group: .+ - .+$/,
/^Geocoded Address: ".+" with coordinates \(.+, .+\) in .+$/,
/^Extracted Address:/,
// Startup & shutdown messages
/^Shutting down gracefully...$/,
/^Express server closed.$/,
/^Discord bot disconnected.$/,
/^Database connection closed.$/,
/^Loaded \d+ talk groups from environment variables$/,
/^Using upload directory: .+$/,
/^Loaded \d+ API keys.$/,
/^Starting persistent transcription process \(local mode\)...$/,
/^Local transcription process spawned, waiting for ready signal...$/,
/^Local transcription service ready$/,
/^Bot server is running on port \d+$/,
/^Connected to SQLite database.$/,
/^Using talk groups from environment variables. Found \d+ talk groups$/,
/^Loaded \d+ talk groups for geocoding$/,
/^Logged in as .+!$/,
/^Started refreshing application \(\/\) commands.$/,
/^Successfully reloaded application \(\/\) commands.$/,
/^Summary channel is ready.$/,
/^Initializing local transcription process...$/,
/^Transcription mode set to 'remote'/,
/^FATAL: TRANSCRIPTION_MODE is remote, but FASTER_WHISPER_SERVER_URL is not set!/,
// Transcription Text - KEEP THIS
/^Transcription Text:/,
// Essential Processing Messages (Uncomment the ones you want to see)
// /^Received SDRTrunk audio:/,
// /^Received TrunkRecorder audio:/,
// /^Saved audio blob for transcription ID/,
// /^Initiating transcription for/, // <--- COMMENTED OUT THIS LINE
// /^Updated DB transcription for ID/,
// /^Successfully processed:/,
// /^Sent alert message/,
// /^Playing audio for talk group/,
// /^Updated summary embed message/,
// /^Created new summary embed message/,
// /^Requesting remote model:/,
// /^Sending remote transcription request for/,
// /^Received remote transcription for/,
// Add specific essential INFO messages you *do* want to see below:
// Two-tone detection messages (only the main alert)
/^Dispatch tone detected on TG \d+!/,
];
// Custom Winston format to filter INFO messages based on allowedPatterns
const infoFilter = winston.format((info, opts) => {
// Only filter 'info' level messages
if (info.level === 'info') {
// Check if the message matches any allowed pattern
const allow = opts.allowedPatterns.some(pattern => pattern.test(info.message));
// If it doesn't match any pattern, filter it out by returning false
if (!allow) {
return false;
}
}
// If it's not 'info' level OR it matched a pattern, pass it through
return info;
});
// Logger setup
const logger = winston.createLogger({
level: 'info', // Log info and above to files
format: winston.format.combine(
// Default format for files (timestamp + standard json/logfmt)
winston.format.timestamp({
format: () => moment().tz(TIMEZONE).format('MM/DD/YYYY HH:mm:ss.SSS')
}),
winston.format.errors({ stack: true }), // Log stack traces for errors
winston.format.splat(),
winston.format.json() // Log to files as JSON
),
transports: [
// File transports log everything (info and above) as JSON
new winston.transports.File({
filename: 'error.log',
level: 'error', // Only errors
format: winston.format.json()
}),
new winston.transports.File({
filename: 'combined.log', // Info, warn, error
format: winston.format.json()
}),
// Console transport has special filtering and coloring
new winston.transports.Console({
level: 'info', // Process info and above for the console
format: winston.format.combine(
// 1. Add timestamp
winston.format.timestamp({
format: () => moment().tz(TIMEZONE).format('MM/DD/YYYY HH:mm:ss.SSS')
}),
// 2. Apply the custom info whitelist filter *for console only*
infoFilter({ allowedPatterns }), // Pass the patterns here
// 3. Apply coloring/printf format
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let color = '\x1b[37m'; // Default white
let formattedMessage = message;
// Apply colors based on level first
if (level === 'error') {
color = '\x1b[31m'; // Red
} else if (level === 'warn') {
color = '\x1b[33m'; // Yellow
} else if (level === 'debug') { // Debug messages will still be colored if level is set lower
color = '\x1b[36m'; // Cyan
}
// Apply specific content colors (if message is a string)
if (typeof message === 'string') {
if (message.includes('Talk Group:') || message.includes('Incoming Request')) {
color = '\x1b[33m'; // Yellow overrides level color
} else if (message.includes('Extracted Address') || message.includes('Geocoded Address')) {
color = '\x1b[32m'; // Green overrides level color
} else if (message.includes('Dispatch tone detected on TG')) {
// Special handling for tone detection messages with blue details
if (message.includes('TONE_DETAILS')) {
// Split the message at the tone details markers
const parts = message.split('TONE_DETAILS');
if (parts.length === 3) {
// parts[0] = "Dispatch tone detected on TG 4005! "
// parts[1] = "[long: 436Hz, 1.0s]"
// parts[2] = " Next 1 calls..."
formattedMessage = `\x1b[33m${parts[0]}\x1b[34m${parts[1]}\x1b[33m${parts[2]}\x1b[0m`;
color = ''; // Don't apply additional color since we handle it inline
} else {
color = '\x1b[33m'; // Yellow fallback
}
} else {
color = '\x1b[33m'; // Yellow for tone detection alerts without details
}
}
// Handle Transcription Text coloring separately
const transcriptionPrefix = 'Transcription Text:';
if (message.startsWith(transcriptionPrefix)) {
const actualText = message.substring(transcriptionPrefix.length).trim();
// Check if the text has the new format with talker alias: (alias) text
const aliasMatch = actualText.match(/^\(([^)]+)\)\s+(.+)$/);
if (aliasMatch) {
const [, alias, transcriptionText] = aliasMatch;
// Format: Timestamp [LEVEL] Prefix (Default Color) (Red Alias) (Cyan Text)
formattedMessage = `\x1b[37m${transcriptionPrefix}\x1b[0m (\x1b[31m${alias}\x1b[0m) \x1b[36m${transcriptionText}\x1b[0m`;
} else {
// Fallback for old format without alias
formattedMessage = `\x1b[37m${transcriptionPrefix}\x1b[0m \x1b[36m${actualText}\x1b[0m`;
}
// Return directly without applying default level color to the whole line
return `${timestamp} [${level.toUpperCase()}] ${formattedMessage}`;
}
}
// Fallback for other messages that passed the filter
// If it's an error object, stringify it
if (typeof formattedMessage !== 'string') {
formattedMessage = JSON.stringify(formattedMessage);
}
return `${timestamp} ${color}[${level.toUpperCase()}]\x1b[0m ${color}${formattedMessage}\x1b[0m`;
})
) // End of combine for Console
})
]
});
// --- NEW: Add S3 Client Setup ---
const AWS = require('aws-sdk');
let s3 = null;
if (STORAGE_MODE === 's3') {
if (!S3_ENDPOINT || !S3_BUCKET_NAME || !S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) {
logger.error('FATAL: STORAGE_MODE is s3, but required S3 environment variables are missing! Check bot .env');
process.exit(1); // Exit if S3 config is incomplete
}
AWS.config.update({
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_SECRET_ACCESS_KEY,
endpoint: S3_ENDPOINT,
s3ForcePathStyle: true, // Necessary for MinIO/non-AWS S3
signatureVersion: 'v4'
});
s3 = new AWS.S3();
logger.info(`[Bot] Storage mode set to S3. Endpoint: ${S3_ENDPOINT}, Bucket: ${S3_BUCKET_NAME}`);
} else {
logger.info('[Bot] Storage mode set to local.');
}
// --- END S3 Client Setup ---
// --- INITIALIZATION FUNCTIONS ---
// Function to check if talkgroups have been imported
function checkTalkGroupsImported() {
return new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM talk_groups', (err, row) => {
if (err) {
reject(err);
} else {
resolve(row.count > 0);
}
});
});
}
// Function to import talkgroups from CSV
function importTalkGroups() {
return new Promise((resolve, reject) => {
const talkGroupsFile = path.join(__dirname, 'talkgroups.csv');
if (!fs.existsSync(talkGroupsFile)) {
logger.warn('talkgroups.csv not found. Skipping talkgroup import.');
resolve();
return;
}
logger.info('Importing talkgroups from CSV...');
let importCount = 0;
fs.createReadStream(talkGroupsFile)
.pipe(csv({
headers: ['DEC', 'HEX', 'Alpha Tag', 'Mode', 'Description', 'Tag', 'County'],
skipLines: 0,
}))
.on('data', (row) => {
const id = parseInt(row['DEC'], 10);
const hex = row['HEX'];
const alphaTag = row['Alpha Tag'];
const mode = row['Mode'];
const description = row['Description'];
const tag = row['Tag'];
const county = row['County'];
db.run(
`INSERT OR REPLACE INTO talk_groups (id, hex, alpha_tag, mode, description, tag, county) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[id, hex, alphaTag, mode, description, tag, county],
(err) => {
if (err) {
logger.error('Error inserting talk group:', err.message);
} else {
importCount++;
}
}
);
})
.on('end', () => {
logger.info(`Successfully imported ${importCount} talkgroups.`);
resolve();
})
.on('error', (err) => {
logger.error('Error importing talkgroups:', err);
reject(err);
});
});
}
// Function to ensure API key exists
function ensureApiKey() {
return new Promise((resolve, reject) => {
try {
// Ensure directory exists
const apiKeyDir = path.dirname(API_KEY_FILE);
if (!fs.existsSync(apiKeyDir)) {
fs.mkdirSync(apiKeyDir, { recursive: true });
}
if (!fs.existsSync(API_KEY_FILE)) {
// Create a default API key
const defaultKey = uuidv4();
const hashedKey = bcrypt.hashSync(defaultKey, 10);
const initialApiKeys = [{
key: hashedKey,
name: 'Default',
disabled: false,
created_at: new Date().toISOString(),
description: 'Auto-generated API key for first boot'
}];
fs.writeFileSync(API_KEY_FILE, JSON.stringify(initialApiKeys, null, 2));
logger.info(`Created default API key: ${defaultKey}`);
logger.info(`API key saved to: ${API_KEY_FILE}`);
logger.warn('IMPORTANT: Save this API key as it won\'t be shown again!');
resolve(defaultKey);
} else {
logger.info('API key file already exists.');
resolve(null);
}
} catch (err) {
logger.error('Error ensuring API key:', err);
reject(err);
}
});
}
// Function to initialize database tables
function initializeDatabase() {
return new Promise((resolve, reject) => {
logger.info('Initializing database tables...');
db.serialize(() => {
let tablesCreated = 0;
let totalTables = ENABLE_AUTH?.toLowerCase() === 'true' ? 7 : 5;
const tableCreated = (err, tableName) => {
if (err) {
logger.error(`Error creating ${tableName} table:`, err);
reject(err);
return;
}
tablesCreated++;
if (tablesCreated === totalTables) {
logger.info('Database tables initialized successfully.');
resolve();
}
};
db.run(`CREATE TABLE IF NOT EXISTS transcriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
talk_group_id TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
transcription TEXT,
audio_file_path TEXT,
address TEXT,
lat REAL,
lon REAL,
category TEXT
)`, (err) => tableCreated(err, 'transcriptions'));
db.run(`CREATE TABLE IF NOT EXISTS global_keywords (
keyword TEXT UNIQUE,
talk_group_id TEXT
)`, (err) => tableCreated(err, 'global_keywords'));
db.run(`CREATE TABLE IF NOT EXISTS talk_groups (
id TEXT PRIMARY KEY,
hex TEXT,
alpha_tag TEXT,
mode TEXT,
description TEXT,
tag TEXT,
county TEXT
)`, (err) => tableCreated(err, 'talk_groups'));
db.run(`CREATE TABLE IF NOT EXISTS frequencies (
id INTEGER PRIMARY KEY,
frequency TEXT,
description TEXT
)`, (err) => tableCreated(err, 'frequencies'));