From 14a29c3eb4e35580575c1479f6b80a726d8a1c26 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Tue, 21 Jul 2026 01:19:29 -0400
Subject: [PATCH 1/8] Add text-to-speech for severe weather alerts
- New WeatherAlertTtsService using the browser's SpeechSynthesis API
- Speaks newly-arrived severe/extreme alerts in the header ticker
- User toggleable setting (weatherAlertTtsEnabled) alongside the
existing weather alert sound setting
- "Test Voice" preview button in Settings
- Gracefully disables the toggle if the browser doesn't support
speech synthesis
---
.../weather-alert-ticker-bridge.service.ts | 4 +-
.../weather/weather-alert-ticker.component.ts | 17 +++-
.../weather/weather-alert-tts.service.ts | 84 +++++++++++++++++++
3 files changed, 102 insertions(+), 3 deletions(-)
create mode 100644 client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts
diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts
index 76c72268..375a904a 100644
--- a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts
+++ b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker-bridge.service.ts
@@ -20,11 +20,11 @@ export class WeatherAlertTickerBridgeService {
}
}
- triggerTest(playSound: boolean, soundName?: string): boolean {
+ triggerTest(playSound: boolean, soundName?: string, speakTts?: boolean): boolean {
if (!this.ticker) {
return false;
}
- this.ticker.showTestAlert(playSound, soundName);
+ this.ticker.showTestAlert(playSound, soundName, speakTts);
return true;
}
}
diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts
index 89f0dd3b..3d16f091 100644
--- a/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts
+++ b/client/src/app/components/rdio-scanner/weather/weather-alert-ticker.component.ts
@@ -14,6 +14,7 @@ import { AlertSoundService } from '../alert-sound.service';
import { SettingsService } from '../settings/settings.service';
import { NwsSevereAlert, NwsService } from './nws.service';
import { WeatherAlertTickerBridgeService } from './weather-alert-ticker-bridge.service';
+import { WeatherAlertTtsService } from './weather-alert-tts.service';
const TEST_ALERT_DURATION_MS = 25_000;
@@ -41,6 +42,7 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy
private settingsService: SettingsService,
private alertSoundService: AlertSoundService,
private tickerBridge: WeatherAlertTickerBridgeService,
+ private weatherAlertTtsService: WeatherAlertTtsService,
private cdr: ChangeDetectorRef,
) {}
@@ -82,7 +84,7 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy
}
}
- showTestAlert(playSound: boolean, soundOverride?: string): void {
+ showTestAlert(playSound: boolean, soundOverride?: string, speakTts?: boolean): void {
if (this.testClearTimer) {
clearTimeout(this.testClearTimer);
}
@@ -103,6 +105,9 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy
if (playSound) {
this.playConfiguredSound(soundOverride);
}
+ if (speakTts) {
+ this.weatherAlertTtsService.speakAlerts(testAlerts);
+ }
this.testClearTimer = setTimeout(() => this.clearTestAlert(), TEST_ALERT_DURATION_MS);
}
@@ -145,8 +150,10 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy
}
const newIds = alerts.map((a) => a.id).filter(Boolean);
const hasNew = newIds.some((id) => !this.knownAlertIds.has(id));
+ const newAlerts = alerts.filter((a) => a.id && !this.knownAlertIds.has(a.id));
if (hasNew && this.knownAlertIds.size > 0) {
this.maybePlaySound();
+ this.maybeSpeakAlerts(newAlerts);
}
if (hasNew && alerts.length > 0) {
this.flashing = true;
@@ -179,6 +186,14 @@ export class RdioScannerWeatherAlertTickerComponent implements OnInit, OnDestroy
this.playConfiguredSound();
}
+ private maybeSpeakAlerts(alerts: NwsSevereAlert[]): void {
+ const enabled = !!this.settingsSnapshot['weatherAlertTtsEnabled'];
+ if (!enabled || !alerts.length) {
+ return;
+ }
+ this.weatherAlertTtsService.speakAlerts(alerts);
+ }
+
private playConfiguredSound(soundOverride?: string): void {
const sound = soundOverride
|| (typeof this.settingsSnapshot['weatherAlertSound'] === 'string'
diff --git a/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts b/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts
new file mode 100644
index 00000000..1b3d018e
--- /dev/null
+++ b/client/src/app/components/rdio-scanner/weather/weather-alert-tts.service.ts
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2025 Thinline Dynamic Solutions
+ */
+
+import { Injectable } from '@angular/core';
+import { NwsSevereAlert } from './nws.service';
+
+/**
+ * Reads severe weather alerts aloud using the browser's built-in
+ * text-to-speech engine (Web Speech API). No server/API key required.
+ */
+@Injectable({
+ providedIn: 'root',
+})
+export class WeatherAlertTtsService {
+ private queue: string[] = [];
+ private speaking = false;
+
+ /** Whether the current browser supports speech synthesis at all. */
+ isSupported(): boolean {
+ return typeof window !== 'undefined' && 'speechSynthesis' in window;
+ }
+
+ /** Speak one or more severe alerts, queuing them so they don't overlap. */
+ speakAlerts(alerts: NwsSevereAlert[]): void {
+ if (!alerts.length) {
+ return;
+ }
+ for (const alert of alerts) {
+ this.speak(this.buildSpeechText(alert));
+ }
+ }
+
+ /** Speak a single line of text (used for the "Test TTS" preview button). */
+ speak(text: string): void {
+ if (!this.isSupported() || !text) {
+ return;
+ }
+ this.queue.push(text);
+ this.pumpQueue();
+ }
+
+ /** Stop anything currently being read and clear the queue. */
+ stop(): void {
+ this.queue = [];
+ this.speaking = false;
+ if (this.isSupported()) {
+ window.speechSynthesis.cancel();
+ }
+ }
+
+ private buildSpeechText(alert: NwsSevereAlert): string {
+ const parts = [alert.event || 'Weather Alert'];
+ if (alert.area) {
+ parts.push(`for ${alert.area}`);
+ }
+ if (alert.headline) {
+ parts.push(alert.headline);
+ }
+ return parts.join('. ');
+ }
+
+ private pumpQueue(): void {
+ if (this.speaking || !this.queue.length || !this.isSupported()) {
+ return;
+ }
+ const text = this.queue.shift();
+ if (!text) {
+ return;
+ }
+ this.speaking = true;
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.rate = 1;
+ utterance.pitch = 1;
+ utterance.volume = 1;
+ const finish = () => {
+ this.speaking = false;
+ this.pumpQueue();
+ };
+ utterance.onend = finish;
+ utterance.onerror = finish;
+ window.speechSynthesis.speak(utterance);
+ }
+}
From f587f3f9fb71993871663decb4ed2ab0d4ce6419 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Tue, 21 Jul 2026 01:20:47 -0400
Subject: [PATCH 2/8] Add text-to-speech for severe weather alerts
- New WeatherAlertTtsService using the browser's SpeechSynthesis API
- Speaks newly-arrived severe/extreme alerts in the header ticker
- User-toggleable setting (weatherAlertTtsEnabled) alongside the
existing weather alert sound setting
- "Test Voice" preview button in Settings
- Gracefully disables the toggle if the browser doesn't support
speech synthesis
---
.../settings/settings.component.html | 21 +++++++++++++++++++
.../settings/settings.component.ts | 19 +++++++++++++++++
2 files changed, 40 insertions(+)
diff --git a/client/src/app/components/rdio-scanner/settings/settings.component.html b/client/src/app/components/rdio-scanner/settings/settings.component.html
index 915264dc..f2707ff4 100644
--- a/client/src/app/components/rdio-scanner/settings/settings.component.html
+++ b/client/src/app/components/rdio-scanner/settings/settings.component.html
@@ -91,6 +91,27 @@
Severe Weather Alert Sound
+
+
+ Text-to-speech isn't supported in this browser.
+
+
+
+
+
Weather Alert Sound
diff --git a/client/src/app/components/rdio-scanner/settings/settings.component.ts b/client/src/app/components/rdio-scanner/settings/settings.component.ts
index 5907efcb..d2e57606 100644
--- a/client/src/app/components/rdio-scanner/settings/settings.component.ts
+++ b/client/src/app/components/rdio-scanner/settings/settings.component.ts
@@ -27,6 +27,7 @@ import { SettingsService } from './settings.service';
import { TagColorService, TagColorConfig } from '../tag-color.service';
import { AlertSoundService, AlertSound } from '../alert-sound.service';
import { WeatherAlertTickerBridgeService } from '../weather/weather-alert-ticker-bridge.service';
+import { WeatherAlertTtsService } from '../weather/weather-alert-tts.service';
import { AlertsService } from '../alerts/alerts.service';
import { RdioScannerAlertPreference } from '../rdio-scanner';
import { APP_FONTS } from '../app-font.util';
@@ -50,6 +51,8 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
alertSound: string = 'alert';
weatherAlertSoundEnabled = false;
weatherAlertSound: string = 'alert';
+ weatherAlertTtsEnabled = false;
+ ttsSupported = true;
availableAlertSounds: AlertSound[] = [];
// Font selection
@@ -107,7 +110,9 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
private fb: FormBuilder,
private snackBar: MatSnackBar,
private weatherAlertTickerBridge: WeatherAlertTickerBridgeService,
+ private weatherAlertTtsService: WeatherAlertTtsService,
) {
+ this.ttsSupported = this.weatherAlertTtsService.isSupported();
this.emailForm = this.fb.group({
newEmail: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required]],
@@ -421,6 +426,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
this.alertSound = this.settings.alertSound || 'alert';
this.weatherAlertSoundEnabled = !!this.settings.weatherAlertSoundEnabled;
this.weatherAlertSound = this.settings.weatherAlertSound || 'alert';
+ this.weatherAlertTtsEnabled = !!this.settings.weatherAlertTtsEnabled;
// Load font setting
this.appFont = this.settings.appFont || 'Roboto';
this.appFontService.apply(this.appFont);
@@ -433,6 +439,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
this.alertSound = 'alert';
this.weatherAlertSoundEnabled = false;
this.weatherAlertSound = 'alert';
+ this.weatherAlertTtsEnabled = false;
this.appFont = 'Roboto';
},
});
@@ -502,6 +509,7 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
this.settings.alertSound = this.alertSound;
this.settings.weatherAlertSoundEnabled = this.weatherAlertSoundEnabled;
this.settings.weatherAlertSound = this.weatherAlertSound;
+ this.settings.weatherAlertTtsEnabled = this.weatherAlertTtsEnabled;
this.settings.appFont = this.appFont;
this.settingsService.saveSettings(this.settings).subscribe({
next: () => {
@@ -538,14 +546,25 @@ export class RdioScannerSettingsComponent implements OnDestroy, OnInit {
this.saveSettings();
}
+ onWeatherAlertTtsChange(): void {
+ this.saveSettings();
+ }
+
previewAlertSound(soundName: string): void {
this.alertSoundService.previewSound(soundName);
}
+ previewWeatherAlertTts(): void {
+ this.weatherAlertTtsService.speak(
+ 'Severe Thunderstorm Warning for your area. This is a sample of the weather alert reader.',
+ );
+ }
+
testWeatherAlertTicker(): void {
const played = this.weatherAlertTickerBridge.triggerTest(
this.weatherAlertSoundEnabled,
this.weatherAlertSound,
+ this.weatherAlertTtsEnabled,
);
if (!played) {
this.snackBar.open('Could not reach the header ticker. Try refreshing the page.', 'Close', {
From 87100c971f13923244874836a602f4a9b8ba7f34 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Tue, 21 Jul 2026 01:21:30 -0400
Subject: [PATCH 3/8] Add text-to-speech for severe weather alerts
- New WeatherAlertTtsService using the browser's SpeechSynthesis API
- Speaks newly-arrived severe/extreme alerts in the header ticker
- User-toggleable setting (weatherAlertTtsEnabled) alongside the
existing weather alert sound setting
- "Test Voice" preview button in Settings
- Gracefully disables the toggle if the browser doesn't support
speech synthesis
---
client/src/app/components/rdio-scanner/rdio-scanner.module.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/client/src/app/components/rdio-scanner/rdio-scanner.module.ts b/client/src/app/components/rdio-scanner/rdio-scanner.module.ts
index 69ec0b39..967a94e5 100644
--- a/client/src/app/components/rdio-scanner/rdio-scanner.module.ts
+++ b/client/src/app/components/rdio-scanner/rdio-scanner.module.ts
@@ -61,6 +61,7 @@ import { RdioScannerWeatherWidgetComponent } from './weather/weather-widget.comp
import { RdioScannerWeatherAlertTickerComponent } from './weather/weather-alert-ticker.component';
import { WeatherAlertTickerBridgeService } from './weather/weather-alert-ticker-bridge.service';
import { NwsService } from './weather/nws.service';
+import { WeatherAlertTtsService } from './weather/weather-alert-tts.service';
@NgModule({
declarations: [
@@ -116,6 +117,7 @@ import { NwsService } from './weather/nws.service';
RdioScannerAdminService,
TranscriptReviewService,
NwsService,
+ WeatherAlertTtsService,
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer },
],
})
From 4074940446739cf5e6cfe0ee4a8e4122bec6bd67 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:13:08 -0400
Subject: [PATCH 4/8] Add DeleteSystemDialogComponent for system deletion
---
.../systems/delete-system-dialog.component.ts | 85 +++++++++++++++++++
1 file changed, 85 insertions(+)
create mode 100644 client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts
diff --git a/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts b/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts
new file mode 100644
index 00000000..c9eeef7d
--- /dev/null
+++ b/client/src/app/components/rdio-scanner/admin/config/systems/delete-system-dialog.component.ts
@@ -0,0 +1,85 @@
+/*
+ * *****************************************************************************
+ * Copyright (C) 2025 Thinline Dynamic Solutions
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see
+ * ****************************************************************************
+ */
+
+import { Component, Inject } from '@angular/core';
+import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { FormBuilder, FormGroup } from '@angular/forms';
+
+export interface DeleteSystemDialogData {
+ /** The system's display name/label the operator must type back exactly. */
+ systemLabel: string;
+}
+
+@Component({
+ selector: 'rdio-scanner-delete-system-dialog',
+ template: `
+ Delete System
+
+
+ This permanently deletes {{ data.systemLabel }} — its talkgroups, sites,
+ units, tone sets, and all associated calls. This cannot be undone.
+
+
+ Type {{ data.systemLabel }} below to confirm:
+
+
+
+
+
+
+
+ `,
+})
+export class DeleteSystemDialogComponent {
+ confirmForm: FormGroup;
+
+ constructor(
+ private dialogRef: MatDialogRef,
+ private formBuilder: FormBuilder,
+ @Inject(MAT_DIALOG_DATA) public data: DeleteSystemDialogData,
+ ) {
+ this.confirmForm = this.formBuilder.group({ confirmText: [''] });
+ }
+
+ /** Exact, case-sensitive match — same behavior as GitHub's "type to confirm". */
+ matches(): boolean {
+ const typed = this.confirmForm.get('confirmText')?.value ?? '';
+ return typed === this.data.systemLabel;
+ }
+
+ onConfirm(): void {
+ if (this.matches()) {
+ this.dialogRef.close(true);
+ }
+ }
+
+ onCancel(): void {
+ this.dialogRef.close(false);
+ }
+}
From 9686114a6a859b3a01778f2802b0c345dcaa1337 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:14:16 -0400
Subject: [PATCH 5/8] Add new dialog components to admin component
Added imports for TransferUserDialogComponent and DeleteSystemDialogComponent.
---
client/src/app/components/rdio-scanner/admin/admin.component.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/client/src/app/components/rdio-scanner/admin/admin.component.ts b/client/src/app/components/rdio-scanner/admin/admin.component.ts
index 21940f8f..6d432a33 100644
--- a/client/src/app/components/rdio-scanner/admin/admin.component.ts
+++ b/client/src/app/components/rdio-scanner/admin/admin.component.ts
@@ -25,6 +25,8 @@ import { AdminEvent, Config, RdioScannerAdminService } from './admin.service';
import { RdioScannerAdminLogsComponent } from './logs/logs.component';
import { RdioScannerAdminConfigComponent } from './config/config.component';
import { RdioScannerAdminToolsComponent } from './tools/tools.component';
+import { TransferUserDialogComponent } from './config/users/transfer-user-dialog.component';
+import { DeleteSystemDialogComponent } from './config/systems/delete-system-dialog.component';
export interface SearchResult {
label: string;
From b4f49a9ea4a1e0e65390ee852f18667bf213eccb Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:22:07 -0400
Subject: [PATCH 6/8] Add files via upload
From f52120f14825b09636ea8b81404000d0f85dad6f Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:22:41 -0400
Subject: [PATCH 7/8] Add files via upload
---
client/src/app/components/rdio-scanner/admin/admin.module.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/client/src/app/components/rdio-scanner/admin/admin.module.ts b/client/src/app/components/rdio-scanner/admin/admin.module.ts
index b241b844..9d7d5467 100644
--- a/client/src/app/components/rdio-scanner/admin/admin.module.ts
+++ b/client/src/app/components/rdio-scanner/admin/admin.module.ts
@@ -47,6 +47,7 @@ import { RdioScannerAdminUserRegistrationComponent } from './config/user-registr
import { RdioScannerAdminUsersComponent } from './config/users/users.component';
import { InviteUserDialogComponent, InvitationResultsDialogComponent, CreateUserDialogComponent, ResetPasswordDialogComponent } from './config/users/invite-user-dialog.component';
import { TransferUserDialogComponent } from './config/users/transfer-user-dialog.component';
+import { DeleteSystemDialogComponent } from './config/systems/delete-system-dialog.component';
import { RequestAPIKeyDialogComponent } from './config/options/request-api-key-dialog.component';
import { RecoverAPIKeyDialogComponent } from './config/options/recover-api-key-dialog.component';
import { RelayAccountDialogComponent } from './config/options/relay-account-dialog.component';
@@ -105,6 +106,7 @@ import { TalkgroupLocationDialogComponent } from './config/systems/system/talkgr
CreateUserDialogComponent,
ResetPasswordDialogComponent,
TransferUserDialogComponent,
+ DeleteSystemDialogComponent,
RequestAPIKeyDialogComponent,
RecoverAPIKeyDialogComponent,
RelayAccountDialogComponent,
From c72188616fe85fbc53993750aea7a01cd256f704 Mon Sep 17 00:00:00 2001
From: WhatTheReL <79932956+Imwhattherel@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:23:52 -0400
Subject: [PATCH 8/8] Add files via upload
---
.../admin/config/systems/system/system.component.html | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html b/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html
index 85698dc7..09ababb1 100644
--- a/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html
+++ b/client/src/app/components/rdio-scanner/admin/config/systems/system/system.component.html
@@ -42,7 +42,7 @@ {{ $any(form.value).label?.trim() || 'New System' }}
{{ saveSuccess ? 'check_circle' : 'save' }}
{{ saving ? 'Saving…' : (saveSuccess ? 'Saved!' : 'Save System') }}
-