From c40481abc630c30ef5acc94cac4deeb423f9e58d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 19 Aug 2026 13:30:43 -0700 Subject: [PATCH 1/3] RG-T132 Bug fixes --- .../Areas/User/Views/Inventory/Adjust.cshtml | 2 +- .../User/Views/Shared/_UserLayout.cshtml | 8 ++- Web/Resgrid.Web/Models/AccountModels.cs | 5 -- Web/Resgrid.Web/wwwroot/_references.js | 1 - .../inventory/resgrid.inventory.adjust.js | 12 +++-- .../js/app/register/resgrid.register.js | 53 ------------------- 6 files changed, 17 insertions(+), 64 deletions(-) delete mode 100644 Web/Resgrid.Web/wwwroot/js/app/register/resgrid.register.js diff --git a/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml b/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml index d89fbea84..62d62e8a7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Inventory/Adjust.cshtml @@ -121,5 +121,5 @@ noUnit: '@Html.Raw(localizer["NoUnit"].Value)' }; - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml index e70ed99c8..e88ed2ed9 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_UserLayout.cshtml @@ -43,7 +43,13 @@ tracesSampleRate: @Resgrid.Config.ExternalErrorConfig.SentryPerfSampleRate, // Safari masks injected extension scripts as webkit-masked-url://; those // frames are browser-extension code, not ours. - denyUrls: [/webkit-masked-url:\/\//i] + denyUrls: [/webkit-masked-url:\/\//i], + // jQuery 1.12 .trigger() of any event whose type is 'error' (DataTables + // 'error.dt', bootstrapValidator 'error.field.bv'/'error.form.bv') bubbles to + // window and invokes window.onerror with the jQuery.Event object itself, which + // Sentry reports as "Object captured as exception with keys: ...". isTrigger + // and rnamespace are jQuery.Event internals, so this only drops those events. + ignoreErrors: [/^Object captured as exception with keys: .*\bisTrigger\b.*\brnamespace\b/] }); } diff --git a/Web/Resgrid.Web/Models/AccountModels.cs b/Web/Resgrid.Web/Models/AccountModels.cs index e32d2d8af..eded4884d 100644 --- a/Web/Resgrid.Web/Models/AccountModels.cs +++ b/Web/Resgrid.Web/Models/AccountModels.cs @@ -64,9 +64,6 @@ public class RegisterModel [Display(Name = "Department Name")] public string DepartmentName { get; set; } - [Display(Name = "Existing Department")] - public string ExistingDepartment { get; set; } - [Required] [DataType(DataType.EmailAddress)] [Display(Name = "Email address")] @@ -87,8 +84,6 @@ public class RegisterModel public string DepartmentType { get; set; } public SelectList DepartmentTypes = new SelectList(new List() { "Emergency Management", "Volunteer Fire", "Career Fire", "Search and Rescue", "HAZMAT", "EMS", "CERT", "Public Safety", "Disaster Response", "Relief Org", "Private", "Security", "Other" }); - - public List ExistingDepartments { get; set; } } public class CompleteInviteModel diff --git a/Web/Resgrid.Web/wwwroot/_references.js b/Web/Resgrid.Web/wwwroot/_references.js index 34b6773fc..f862e9565 100644 --- a/Web/Resgrid.Web/wwwroot/_references.js +++ b/Web/Resgrid.Web/wwwroot/_references.js @@ -718,7 +718,6 @@ /// /// /// -/// /// /// /// diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js index 58863bcff..445e51f1b 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js @@ -8,18 +8,24 @@ var resgrid; $(document).ready(function () { resgrid.common.analytics.track('Inventory - Adjust'); $('select').select2(); - $('#Inventory_GroupId').on("change", function (e) { getUnits(e.val); }); + $('#Inventory_GroupId').on("change", function (e) { getUnits($(this).val()); }); getUnits($('#Inventory_GroupId').val()); $("#Inventory_Amount").attr({ type: 'number', min: -999999999, max: 999999999, step: 1 }); }); function getUnits(stationId) { var noUnitLabel = (typeof inventoryAdjustStrings !== 'undefined' && inventoryAdjustStrings.noUnit) ? inventoryAdjustStrings.noUnit : 'No Unit'; + var groupId = parseInt(stationId, 10); + if (isNaN(groupId) || groupId <= 0) { + $('#UnitId').empty(); + $('#UnitId').append(''); + return; + } $.ajax({ - url: resgrid.absoluteBaseUrl + '/User/Units/GetUnitsForGroup?groupId=' + stationId, + url: resgrid.absoluteBaseUrl + '/User/Units/GetUnitsForGroup?groupId=' + groupId, contentType: 'application/json; charset=utf-8', type: 'GET' }).done(function (data) { - if (data) { + if (data && $.isArray(data)) { $('#UnitId').empty(); $('#UnitId').append(''); $.each(data, function (index, value) { diff --git a/Web/Resgrid.Web/wwwroot/js/app/register/resgrid.register.js b/Web/Resgrid.Web/wwwroot/js/app/register/resgrid.register.js deleted file mode 100644 index c0f262e60..000000000 --- a/Web/Resgrid.Web/wwwroot/js/app/register/resgrid.register.js +++ /dev/null @@ -1,53 +0,0 @@ -$(document).ready(function () { - $('#ExistingDepartment').select2({ - placeholder: "---New Department---", - allowClear: true - }); - - $('#ExistingDepartment').on("change", function (e) { switchInputs(e.val); }); - //$("#Password").valid(); - - //$("#register-wizard").formwizard({ - //validationEnabled: true, - //focusFirstInput : true, - //disableUIStyles: true, - //validationOptions : { - // rules: { - // FullName: "required", - // UserName: "required", - // Password: "required", - // ConfirmPassword: { - // equalTo: "#Password" - // }, - // Email: { required: true, email: true } - // }, - // messages: { - // FullName: "Enter your First and Last name", - // UserName: "Enter your desired Username", - // Password: "You must enter a password", - // ConfirmPassword: { equalTo: "Passwords don't match" }, - // Email: { required: "Supply your email address", Email: "Correct email format is name@domain.com" } - // }, - // errorLabelContainer: "#errors", - // wrapper: "li", - // highlight:function(element, errorClass, validClass) { - // $(element).parents('.input-group').addClass('has-error'); - // }, - // unhighlight: function(element, errorClass, validClass) { - // $(element).parents('.input-group').removeClass('has-error'); - // } - //} - //}); - - function switchInputs(value) { - if (value) { - if (value === "---New Department---") { - $('#newDepartment').show(); - $('#existingDepartment').hide(); - } else { - $('#newDepartment').hide(); - $('#existingDepartment').show(); - } - } - } -}); From a6287fb1a4e20c7d7d2bbf8c0618b3c52221b0ce Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 19 Aug 2026 16:20:48 -0700 Subject: [PATCH 2/3] RG-T132 TTS Fixes, DateTime Issue with apps and API temp fix for older apps. --- Core/Resgrid.Config/TtsConfig.cs | 16 +- .../Resgrid.Model/TwilioVoicePromptCatalog.cs | 5 +- .../DispatchVoicePromptBuilder.cs | 116 +++++- ...LegacyZonelessUtcDateTimeConverterTests.cs | 88 ++++ .../DispatchVoicePromptBuilderTests.cs | 92 ++++ .../TwilioControllerVoiceVerificationTests.cs | 75 +++- .../Web/Tts/PiperProcessPoolTests.cs | 161 +++++++ .../TempDirectorySweepHostedServiceTests.cs | 13 + .../Web/Tts/TextPreprocessorTests.cs | 122 ++++++ .../Resgrid.Tests/Web/Tts/TtsServiceTests.cs | 22 +- .../Controllers/TwilioController.cs | 68 ++- .../Controllers/v4/WeatherAlertsController.cs | 4 + .../LegacyZonelessUtcDateTimeConverter.cs | 22 + .../Models/v4/Calls/CallResult.cs | 6 +- .../WeatherAlerts/WeatherAlertResultData.cs | 24 ++ .../Resgrid.Web.Services.xml | 39 +- .../ServiceCollectionExtensions.cs | 2 + .../Configuration/TtsOptions.cs | 18 +- Web/Resgrid.Web.Tts/Dockerfile | 2 +- Web/Resgrid.Web.Tts/Program.cs | 2 + .../Services/AudioProcessingService.cs | 34 +- .../Services/IPiperProcessPool.cs | 34 ++ .../Services/PiperProcessPool.cs | 126 ++++++ Web/Resgrid.Web.Tts/Services/PiperWorker.cs | 227 ++++++++++ .../TempDirectorySweepHostedService.cs | 8 + .../Services/TextPreprocessor.cs | 325 +++++++-------- .../Services/TtsShorthandCatalog.cs | 392 ++++++++++++++++++ Web/Resgrid.Web.Tts/k8s/deployment.yaml | 4 +- 28 files changed, 1822 insertions(+), 225 deletions(-) create mode 100644 Tests/Resgrid.Tests/Web/LegacyZonelessUtcDateTimeConverterTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Tts/PiperProcessPoolTests.cs create mode 100644 Web/Resgrid.Web.Services/Helpers/LegacyZonelessUtcDateTimeConverter.cs create mode 100644 Web/Resgrid.Web.Tts/Services/IPiperProcessPool.cs create mode 100644 Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs create mode 100644 Web/Resgrid.Web.Tts/Services/PiperWorker.cs create mode 100644 Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs index 6c9b8feb5..0e114ddd9 100644 --- a/Core/Resgrid.Config/TtsConfig.cs +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -45,6 +45,19 @@ public static class TtsConfig public static int GenerationTimeoutSeconds = 300; public static string PiperExecutable = "piper"; public static string PiperModelDirectory = "/usr/local/share/piper-voices"; + + /// + /// When true, synthesis goes through a pool of long-lived Piper processes (one + /// model load per process lifetime) instead of spawning a fresh process per + /// request. A failed or wedged worker is killed and respawned automatically. + /// + public static bool PiperPersistentProcessEnabled = true; + + /// + /// Maximum concurrent persistent Piper workers kept per (model, speed) profile. + /// Each worker holds its model in memory. + /// + public static int PiperMaxWorkersPerVoice = 2; public static string FfmpegExecutable = "ffmpeg"; public static string TempDirectory = ""; @@ -94,7 +107,8 @@ public static class TtsConfig "Invalid staffing selection. Returning to the main menu.", "No staffing selection made. Returning to the main menu.", "Thank you. Your response has been recorded.", - "Please wait while we prepare your dispatch information." + "Please wait while we prepare your dispatch information.", + "Please wait while we gather that information." }); public static int RateLimitPermitLimit = 600; diff --git a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs index 5c3837d97..aaef57b68 100644 --- a/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs +++ b/Core/Resgrid.Model/TwilioVoicePromptCatalog.cs @@ -38,6 +38,8 @@ public static class TwilioVoicePromptCatalog public const string PleaseWaitForDispatch = "Please wait while we prepare your dispatch information."; + public const string PleaseWaitForInformation = "Please wait while we gather that information."; + public static string CallClosedByNumber(string callNumber) => $"This call, ID {callNumber}, has been closed. Goodbye."; public static string RespondingToStation(string stationName) => $"You have been marked responding to {stationName}. Goodbye."; @@ -93,7 +95,8 @@ public static IReadOnlyCollection GetStaticPrompts() CommunicationTestGreeting, CommunicationTestPressOne, CommunicationTestNoResponse, - PleaseWaitForDispatch + PleaseWaitForDispatch, + PleaseWaitForInformation }; } } diff --git a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs index 7faec5c0e..f87a9b415 100644 --- a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs +++ b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs @@ -22,21 +22,127 @@ namespace Resgrid.Services /// public static class DispatchVoicePromptBuilder { + // Ordinal wording for re-dispatches ("Second alarm"). Index = dispatch count. + private static readonly string[] AlarmOrdinals = + { + null, null, "Second", "Third", "Fourth", "Fifth", "Sixth", "Seventh", "Eighth", "Ninth" + }; + + // Trailing address segments that carry no value over the phone. Country names + // and state/zip segments are stripped from the END only, so freeform locations + // ("Building 5, Floor 3, Room 10") are never touched. + private static readonly HashSet CountryNames = new(StringComparer.OrdinalIgnoreCase) + { + "USA", "U.S.A.", "US", "U.S.", "United States", "United States of America", "Canada" + }; + + private static readonly HashSet StateNames = new(StringComparer.OrdinalIgnoreCase) + { + "Alabama", "Alaska", "Arizona", "Arkansas", "California", "Colorado", "Connecticut", "Delaware", + "Florida", "Georgia", "Hawaii", "Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", + "Louisiana", "Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota", "Mississippi", + "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire", "New Jersey", "New Mexico", + "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", + "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", + "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming", "District of Columbia", + "Puerto Rico", "Guam" + }; + + // Two-letter state codes match case-sensitively: CAD feeds emit them upper-case, + // and a case-insensitive match would eat street segments like "La" or "In". + private static readonly HashSet StateAbbreviations = new(StringComparer.Ordinal) + { + "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", + "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", + "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", + "VA", "WA", "WV", "WI", "WY", "DC", "PR", "GU" + }; + + private static readonly Regex PostalCodeRegex = new(@"^(\d{5}(-\d{4})?|[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + public static string BuildDispatchPrompt(Call call, string address) { // Periods between the segments give the TTS engine sentence boundaries - // (Piper inserts 0.35s of silence per sentence), which keeps the priority, - // address and nature audibly separated instead of running together. + // (Piper inserts 0.35s of silence per sentence), which keeps the alarm + // intro, nature, address and priority audibly separated. var nature = StringHelpers.StripHtmlTagsCharArray(call.NatureOfCall); - var prompt = !String.IsNullOrWhiteSpace(address) - ? string.Format("{0}, Priority {1}. Address {2}. Nature {3}", call.Name, call.GetPriorityText(), address, nature) - : string.Format("{0}, Priority {1}. Nature {2}", call.Name, call.GetPriorityText(), nature); + var intro = BuildAlarmIntro(call.DispatchCount); + var spokenAddress = TrimAddressForSpeech(address); + + string prompt; + if (!String.IsNullOrWhiteSpace(spokenAddress)) + { + // "Address" for street-style values (leading house number), "Location" for + // freeform values ("Building 5, Floor 3", "Bottom of Bucks Canyon"). + var placeLabel = char.IsDigit(spokenAddress.TrimStart()[0]) ? "Address" : "Location"; + prompt = $"{intro}, {call.Name}. Nature, {nature}. {placeLabel}, {spokenAddress}. Priority, {call.GetPriorityText()}"; + } + else + { + prompt = $"{intro}, {call.Name}. Nature, {nature}. Priority, {call.GetPriorityText()}"; + } return prompt.EndsWith(".", StringComparison.Ordinal) || prompt.EndsWith("!", StringComparison.Ordinal) || prompt.EndsWith("?", StringComparison.Ordinal) ? prompt : $"{prompt}."; } + /// + /// "New call" for a first dispatch, "Second alarm" / "Third alarm" / ... for + /// re-dispatches. DispatchCount is 0 for departments whose dispatch path never + /// increments it, and 1 after the first send — both mean a first dispatch. + /// + private static string BuildAlarmIntro(int dispatchCount) + { + if (dispatchCount <= 1) + return "New call"; + + return dispatchCount < AlarmOrdinals.Length + ? $"{AlarmOrdinals[dispatchCount]} alarm" + : $"Alarm {dispatchCount}"; + } + + /// + /// Drops trailing state, postal code and country segments from a comma-separated + /// postal address so the spoken prompt stays short ("123 Main St, Springfield, WA + /// 98111, USA" becomes "123 Main St, Springfield"). Trimming walks from the end + /// and stops at the first segment that isn't a state/zip/country, so freeform + /// locations pass through untouched. At least one segment is always kept. + /// + public static string TrimAddressForSpeech(string address) + { + if (String.IsNullOrWhiteSpace(address)) + return address; + + var segments = address.Split(',') + .Select(segment => segment.Trim()) + .Where(segment => segment.Length > 0) + .ToList(); + + while (segments.Count > 1 && IsDroppableTrailingSegment(segments[^1])) + { + segments.RemoveAt(segments.Count - 1); + } + + return string.Join(", ", segments); + } + + private static bool IsDroppableTrailingSegment(string segment) + { + if (CountryNames.Contains(segment) || PostalCodeRegex.IsMatch(segment)) + return true; + + // "WA", "Washington", "WA 98111" or "Washington 98111". + var lastSpaceIndex = segment.LastIndexOf(' '); + var head = lastSpaceIndex > 0 ? segment[..lastSpaceIndex].Trim() : segment; + var tail = lastSpaceIndex > 0 ? segment[(lastSpaceIndex + 1)..] : null; + + if (tail != null && !PostalCodeRegex.IsMatch(tail)) + return StateAbbreviations.Contains(segment) || StateNames.Contains(segment); + + return StateAbbreviations.Contains(head) || StateNames.Contains(head); + } + public static async Task ResolveDispatchAddressAsync(Call call, IGeoLocationProvider geoLocationProvider, CancellationToken cancellationToken = default) { var address = call.Address; diff --git a/Tests/Resgrid.Tests/Web/LegacyZonelessUtcDateTimeConverterTests.cs b/Tests/Resgrid.Tests/Web/LegacyZonelessUtcDateTimeConverterTests.cs new file mode 100644 index 000000000..1e2bb8fd5 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/LegacyZonelessUtcDateTimeConverterTests.cs @@ -0,0 +1,88 @@ +using System; +using FluentAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Web.Services.Helpers; + +namespace Resgrid.Tests.Web +{ + /// + /// TEMPORARY compatibility shim (RG-T132): deployed app builds parse CallResult.LoggedOnUtc as + /// device-local time and offset "now" to compensate, so the wire value must stay zone-less until + /// the fixed apps are rolled out. These tests pin that contract: writes carry no "Z", reads accept + /// both the zone-less and the "Z" form as the same UTC instant. Delete alongside the converter. + /// + [TestFixture] + public class LegacyZonelessUtcDateTimeConverterTests + { + private static readonly DateTime ExpectedUtc = new DateTime(2026, 8, 12, 13, 5, 22, 123, DateTimeKind.Utc); + + [TestCase(DateTimeKind.Unspecified, TestName = "An Unspecified instant serialises zone-less")] + [TestCase(DateTimeKind.Utc, TestName = "A UTC instant serialises zone-less")] + public void Serialize_WritesTheInstantWithoutAZoneMarker(DateTimeKind kind) + { + // Arrange + var original = new LegacyTimestampPayload + { + Timestamp = DateTime.SpecifyKind(new DateTime(2026, 8, 12, 13, 5, 22, 123), kind) + }; + + // Act + var serialized = JsonConvert.SerializeObject(original); + + // Assert + serialized.Should().Contain("2026-08-12T13:05:22.123"); + serialized.Should().NotContain("2026-08-12T13:05:22.123Z"); + } + + [Test] + public void Serialize_WithALocalInstant_WritesTheUtcWallTimeZoneless() + { + // Arrange -- a Local value means the process timezone leaked in somewhere upstream. + var original = new LegacyTimestampPayload { Timestamp = ExpectedUtc.ToLocalTime() }; + + // Act + var serialized = JsonConvert.SerializeObject(original); + + // Assert + serialized.Should().Contain("2026-08-12T13:05:22.123"); + serialized.Should().NotContain("2026-08-12T13:05:22.123Z"); + } + + [TestCase("2026-08-12T13:05:22.123", TestName = "Zone-less string reads back as the same UTC instant")] + [TestCase("2026-08-12T13:05:22.123Z", TestName = "String with Z reads back as the same UTC instant")] + [TestCase("2026-08-12T06:05:22.123-07:00", TestName = "String with an offset is converted to UTC")] + public void ReadJson_AcceptsBothFormats(string serialized) + { + // Arrange -- DateParseHandling.None forces the reader to hand over a JsonToken.String. + var settings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None }; + + // Act + var payload = JsonConvert.DeserializeObject($"{{\"Timestamp\":\"{serialized}\"}}", settings); + + // Assert + payload.Timestamp.Kind.Should().Be(DateTimeKind.Utc); + payload.Timestamp.Should().Be(ExpectedUtc); + } + + [Test] + public void SerializeThenDeserialize_PreservesTheInstant() + { + // Arrange + var original = new LegacyTimestampPayload { Timestamp = ExpectedUtc }; + + // Act + var round = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(original)); + + // Assert + round.Timestamp.Kind.Should().Be(DateTimeKind.Utc); + round.Timestamp.Should().Be(ExpectedUtc); + } + + private class LegacyTimestampPayload + { + [JsonConverter(typeof(LegacyZonelessUtcDateTimeConverter))] + public DateTime Timestamp { get; set; } + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs b/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs new file mode 100644 index 000000000..045ee0293 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs @@ -0,0 +1,92 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Web.Services +{ + [TestFixture] + public class DispatchVoicePromptBuilderTests + { + private static Call CreateCall(int dispatchCount = 0) + { + return new Call + { + Name = "Call 42", + Priority = (int)CallPriority.High, + NatureOfCall = "Structure fire", + DispatchCount = dispatchCount + }; + } + + [Test] + public void should_announce_new_call_with_nature_then_address_then_priority() + { + DispatchVoicePromptBuilder.BuildDispatchPrompt(CreateCall(), "123 Main St") + .Should().Be("New call, Call 42. Nature, Structure fire. Address, 123 Main St. Priority, High."); + } + + [Test] + public void should_omit_the_place_sentence_when_no_address_is_available() + { + DispatchVoicePromptBuilder.BuildDispatchPrompt(CreateCall(), null) + .Should().Be("New call, Call 42. Nature, Structure fire. Priority, High."); + } + + [TestCase(0, "New call")] + [TestCase(1, "New call")] + [TestCase(2, "Second alarm")] + [TestCase(3, "Third alarm")] + [TestCase(4, "Fourth alarm")] + [TestCase(9, "Ninth alarm")] + [TestCase(12, "Alarm 12")] + public void should_announce_the_alarm_level_from_the_dispatch_count(int dispatchCount, string expectedIntro) + { + DispatchVoicePromptBuilder.BuildDispatchPrompt(CreateCall(dispatchCount), "123 Main St") + .Should().StartWith($"{expectedIntro}, Call 42."); + } + + [Test] + public void should_speak_location_instead_of_address_for_freeform_places() + { + DispatchVoicePromptBuilder.BuildDispatchPrompt(CreateCall(), "At the bottom of Bucks Canyon") + .Should().Contain("Location, At the bottom of Bucks Canyon."); + } + + [Test] + public void should_strip_html_from_the_nature_of_call() + { + var call = CreateCall(); + call.NatureOfCall = "
Structure fire
"; + + DispatchVoicePromptBuilder.BuildDispatchPrompt(call, null) + .Should().Contain("Nature, Structure fire."); + } + + [TestCase("123 Main St, Springfield, WA 98111, USA", "123 Main St, Springfield")] + [TestCase("123 Main St, Springfield, WA, USA", "123 Main St, Springfield")] + [TestCase("123 Main St, Springfield, Washington 98111", "123 Main St, Springfield")] + [TestCase("123 Main St, Springfield, WA", "123 Main St, Springfield")] + [TestCase("450 Elk Run Rd, Victor, Idaho, United States", "450 Elk Run Rd, Victor")] + [TestCase("1 Front St, Toronto, M5J 2X5, Canada", "1 Front St, Toronto")] + public void trim_address_should_drop_trailing_state_zip_and_country(string input, string expected) + { + DispatchVoicePromptBuilder.TrimAddressForSpeech(input).Should().Be(expected); + } + + [TestCase("Building 5, Floor 3, Room 10")] + [TestCase("At the bottom of Bucks Canyon")] + [TestCase("123 Main St, Springfield")] + [TestCase("Main St and 5th Ave")] + public void trim_address_should_leave_freeform_locations_untouched(string input) + { + DispatchVoicePromptBuilder.TrimAddressForSpeech(input).Should().Be(input); + } + + [Test] + public void trim_address_should_always_keep_at_least_one_segment() + { + DispatchVoicePromptBuilder.TrimAddressForSpeech("WA 98111").Should().Be("WA 98111"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 481395363..b82ab782d 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -288,7 +288,7 @@ public async System.Threading.Tasks.Task should_play_dispatch_before_outbound_re var result = await BuildController().VoiceCall("user1", 42); var content = ((ContentResult)result).Content; - var dispatchPrompt = Uri.EscapeDataString("Call 42, Priority High. Address 123 Main St. Nature Structure fire."); + var dispatchPrompt = Uri.EscapeDataString("New call, Call 42. Nature, Structure fire. Address, 123 Main St. Priority, High."); var menuPrompt = Uri.EscapeDataString(TwilioVoicePromptCatalog.OutboundDispatchMenu); content.Should().Contain(dispatchPrompt); @@ -308,9 +308,9 @@ public void dispatch_prompt_helpers_should_end_with_sentence_punctuation() }; InvokeBuildDispatchPrompt(typeof(TwilioController), call, "123 Main St") - .Should().Be("Call 42, Priority High. Address 123 Main St. Nature Structure fire."); + .Should().Be("New call, Call 42. Nature, Structure fire. Address, 123 Main St. Priority, High."); InvokeBuildDispatchPrompt(typeof(TwilioController), call, null) - .Should().Be("Call 42, Priority High. Nature Structure fire."); + .Should().Be("New call, Call 42. Nature, Structure fire. Priority, High."); } [TestCase("1", "https://resgridapi.local/api/Twilio/VoiceCall?userId=user1&callId=42")] @@ -388,6 +388,75 @@ public async System.Threading.Tasks.Task should_present_multi_digit_status_optio content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.GoBackToMainMenuWithPound)); } + [Test] + public async System.Threading.Tasks.Task should_play_please_wait_and_redirect_when_listing_audio_is_not_ready() + { + var department = new Department { DepartmentId = 7, Name = "Dept 1" }; + var profile = new UserProfile { UserId = "user1", FirstName = "Pat" }; + var listingPrompt = "There are no units for department Dept 1."; + + _departmentsServiceMock.Setup(x => x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _unitsServiceMock.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(7)).ReturnsAsync(new List()); + _unitsServiceMock.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List()); + // Cold TTS generation: the readiness probe's append only completes when its + // timeout token fires, mirroring audio that isn't cached yet. + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), listingPrompt, It.IsAny(), It.IsAny())) + .Returns((_, _, token, _) => System.Threading.Tasks.Task.Delay(Timeout.Infinite, token)); + + var result = await BuildController().InboundVoiceAction("user1", new VoiceRequest { Digits = "3" }); + + var content = ((ContentResult)result).Content; + content.Should().Contain(Uri.EscapeDataString(TwilioVoicePromptCatalog.PleaseWaitForInformation)); + content.Should().Contain("https://resgridapi.local/api/Twilio/InboundVoiceAction?userId=user1&Digits=3&retry=1"); + content.Should().NotContain(" x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _unitsServiceMock.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(7)).ReturnsAsync(new List()); + _unitsServiceMock.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List()); + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), listingPrompt, It.IsAny(), It.IsAny())) + .Returns((_, _, token, _) => System.Threading.Tasks.Task.Delay(Timeout.Infinite, token)); + + var result = await BuildController().InboundVoiceAction("user1", new VoiceRequest { Digits = "3" }, retry: "3"); + + var content = ((ContentResult)result).Content; + content.Should().NotContain("Redirect"); + content.Should().Contain(" x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _unitsServiceMock.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(7)).ReturnsAsync(new List()); + _unitsServiceMock.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List()); + + var result = await BuildController().InboundVoiceAction("user1", new VoiceRequest { Digits = "3" }); + + var content = ((ContentResult)result).Content; + content.Should().NotContain("Redirect"); + content.Should().Contain(" x.AppendPromptAsync(It.IsAny(), "There are no units for department Dept 1.", It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + [Test] public async System.Threading.Tasks.Task should_mark_multi_digit_status_selection_by_menu_position() { diff --git a/Tests/Resgrid.Tests/Web/Tts/PiperProcessPoolTests.cs b/Tests/Resgrid.Tests/Web/Tts/PiperProcessPoolTests.cs new file mode 100644 index 000000000..00eff9d11 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Tts/PiperProcessPoolTests.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Resgrid.Web.Tts.Configuration; +using Resgrid.Web.Tts.Services; + +namespace Resgrid.Tests.Web.Tts +{ + [TestFixture] + public class PiperProcessPoolTests + { + private static readonly PiperSynthesisProfile Profile = new("/voices/en_US-ryan-medium.onnx", "1.17"); + + private sealed class FakeWorker : IPiperWorker + { + private readonly Queue> _behaviors; + + public FakeWorker(params Func[] behaviors) + { + _behaviors = new Queue>(behaviors); + } + + public int SynthesisCount { get; private set; } + public bool Disposed { get; private set; } + + public Task SynthesizeAsync(string text, string outputFilePath, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + SynthesisCount++; + return _behaviors.Count > 0 ? _behaviors.Dequeue()() : Task.CompletedTask; + } + + public void Dispose() + { + Disposed = true; + } + } + + private sealed class FakeWorkerFactory : IPiperWorkerFactory + { + private readonly Queue _workers; + + public FakeWorkerFactory(params FakeWorker[] workers) + { + _workers = new Queue(workers); + } + + public List Created { get; } = new(); + + public IPiperWorker Create(PiperSynthesisProfile profile) + { + var worker = _workers.Count > 0 ? _workers.Dequeue() : new FakeWorker(); + Created.Add(worker); + return worker; + } + } + + private static PiperProcessPool CreatePool(FakeWorkerFactory factory, int maxWorkersPerVoice = 2) + { + return new PiperProcessPool( + Options.Create(new TtsOptions { PiperMaxWorkersPerVoice = maxWorkersPerVoice }), + NullLogger.Instance, + factory); + } + + [Test] + public async Task should_reuse_a_healthy_worker_across_requests() + { + var factory = new FakeWorkerFactory(); + await using var pool = CreatePool(factory); + + await pool.SynthesizeAsync(Profile, "one", "/tmp/one.wav", CancellationToken.None); + await pool.SynthesizeAsync(Profile, "two", "/tmp/two.wav", CancellationToken.None); + + factory.Created.Should().HaveCount(1); + factory.Created[0].SynthesisCount.Should().Be(2); + } + + [Test] + public async Task should_dispose_a_failed_worker_and_retry_on_a_fresh_one() + { + var failing = new FakeWorker(() => throw new InvalidOperationException("piper died")); + var factory = new FakeWorkerFactory(failing); + await using var pool = CreatePool(factory); + + await pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", CancellationToken.None); + + failing.Disposed.Should().BeTrue(); + factory.Created.Should().HaveCount(2); + factory.Created[1].SynthesisCount.Should().Be(1); + factory.Created[1].Disposed.Should().BeFalse(); + } + + [Test] + public async Task should_surface_the_first_failure_when_the_respawned_worker_also_fails() + { + var factory = new FakeWorkerFactory( + new FakeWorker(() => throw new InvalidOperationException("first failure")), + new FakeWorker(() => throw new InvalidOperationException("second failure"))); + await using var pool = CreatePool(factory); + + var act = () => pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .WithInnerException() + .WithMessage("first failure"); + factory.Created.Should().OnlyContain(worker => worker.Disposed); + } + + [Test] + public async Task should_dispose_the_worker_and_propagate_on_cancellation() + { + using var cts = new CancellationTokenSource(); + var worker = new FakeWorker(() => + { + cts.Cancel(); + return Task.FromCanceled(cts.Token); + }); + var factory = new FakeWorkerFactory(worker); + await using var pool = CreatePool(factory); + + var act = () => pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", cts.Token); + + await act.Should().ThrowAsync(); + worker.Disposed.Should().BeTrue(); + factory.Created.Should().HaveCount(1); + } + + [Test] + public async Task should_dispose_idle_workers_when_the_pool_is_disposed() + { + var factory = new FakeWorkerFactory(); + var pool = CreatePool(factory); + + await pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", CancellationToken.None); + await pool.DisposeAsync(); + + factory.Created.Should().OnlyContain(worker => worker.Disposed); + + var act = () => pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", CancellationToken.None); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task should_keep_separate_workers_per_synthesis_profile() + { + var factory = new FakeWorkerFactory(); + await using var pool = CreatePool(factory); + + await pool.SynthesizeAsync(Profile, "text", "/tmp/one.wav", CancellationToken.None); + await pool.SynthesizeAsync(Profile with { LengthScale = "0.80" }, "text", "/tmp/two.wav", CancellationToken.None); + + factory.Created.Should().HaveCount(2); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs index 483011b19..63e2d676e 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TempDirectorySweepHostedServiceTests.cs @@ -69,6 +69,19 @@ public void sweep_once_should_delete_stale_loose_files() File.Exists(stalePath).Should().BeFalse(); } + [Test] + public void sweep_once_should_never_touch_the_persistent_piper_worker_root() + { + // Persistent Piper workers keep their output directories for the whole pod + // lifetime, so the root ages past the sweep cutoff while still in use. + var workerRoot = CreateWorkingDirectory(PiperWorkerFactory.WorkerRootDirectoryName, DateTime.UtcNow.AddHours(-48)); + + var removed = CreateService().SweepOnce(); + + removed.Should().Be(0); + Directory.Exists(workerRoot).Should().BeTrue(); + } + [Test] public void sweep_once_should_return_zero_when_the_temp_root_does_not_exist() { diff --git a/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs b/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs index a62b7856c..b79705f4f 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs @@ -98,6 +98,128 @@ public void Preprocess_SplitsUnitIdentifiers() .Should().Be("E one and L fourteen responding."); } + // ----------------------------------------------------------- + // CAD patient age/sex shorthand. + // ----------------------------------------------------------- + + [TestCase("35/F fall victim", "35 Year Old Female fall victim.")] + [TestCase("35/f fall victim", "35 Year Old Female fall victim.")] + [TestCase("9/M seizure", "nine Year Old Male seizure.")] + [TestCase("35F chest pain", "35 Year Old Female chest pain.")] + [TestCase("35f chest pain", "35 Year Old Female chest pain.")] + [TestCase("104M fall", "104 Year Old Male fall.")] + [TestCase("35YOM chest pain", "35 Year Old Male chest pain.")] + [TestCase("35 yof unconscious", "35 Year Old Female unconscious.")] + [TestCase("35yo diabetic", "35 Year Old diabetic.")] + public void Preprocess_ExpandsAgeSexShorthand(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("Apt 5F", "Apt 5F.")] + [TestCase("I-35F at exit 12", "I-35F at exit 12.")] + public void Preprocess_LeavesNonPatientDigitLetterTokensAlone(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("PT C/O SOB", "Patient Complaining Of Shortness of Breath.")] + [TestCase("N/V since morning", "Nausea and Vomiting since morning.")] + [TestCase("AMS UNRESP on arrival", "Altered Mental Status Unresponsive on arrival.")] + [TestCase("FX to left leg, LAC to head", "Fracture to left leg, Laceration to head.")] + public void Preprocess_ExpandsMedicalShorthand(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + // ----------------------------------------------------------- + // Domain coverage: fire, police/security, SAR, industrial, + // emergency management. + // ----------------------------------------------------------- + + [TestCase("STRU FIRE SMK SHOWING", "Structure FIRE Smoke SHOWING.")] + [TestCase("AFA CHIM FIRE", "Automatic Fire Alarm Chimney FIRE.")] + [TestCase("VEG FIRE NEAR XFMR", "Vegetation FIRE NEAR Transformer.")] + public void Preprocess_ExpandsFireDispatchCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("BOLO W/M NB HWY 101", "Be On the Lookout White Male Northbound Highway 101.")] + [TestCase("B&E IN PROGRESS WPN SEEN", "Breaking and Entering IN PROGRESS Weapon SEEN.")] + [TestCase("DUI STOP", "D, U, I STOP.")] + [TestCase("SUSP SUBJ GOA", "Suspicious Subject Gone on Arrival.")] + public void Preprocess_ExpandsPoliceAndSecurityCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("MISPER LKP TRAILHEAD", "Missing Person Last Known Position TRAILHEAD.")] + [TestCase("LSW RED JACKET", "Last Seen Wearing RED JACKET.")] + [TestCase("USAR TEAM TO ICP", "Urban Search and Rescue TEAM TO Incident Command Post.")] + public void Preprocess_ExpandsSearchAndRescueCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("H2S ALARM LEL 15 PPM", "Hydrogen Sulfide ALARM Lower Explosive Limit fifteen Parts Per Million.")] + [TestCase("CO2 DISCHARGE RM 4", "Carbon Dioxide DISCHARGE Room 4.")] + [TestCase("LOTO NOT VERIFIED", "Lockout Tagout NOT VERIFIED.")] + public void Preprocess_ExpandsIndustrialCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + [TestCase("EOC ACTIVATED SITREP TO FOLLOW", "Emergency Operations Center ACTIVATED Situation Report TO FOLLOW.")] + [TestCase("SIP ORDERED FOR EVAC ZONE", "Shelter in Place ORDERED FOR Evacuation ZONE.")] + public void Preprocess_ExpandsEmergencyManagementCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + // ----------------------------------------------------------- + // Directional bounds: NB/SB/EB/WB are the standard tokens, and + // compass corners expand. NH/SH/EH/WH must never expand as bounds — + // NH is New Hampshire (it spells out instead). + // ----------------------------------------------------------- + + // "5 AT" → "five AT" comes from the long-standing small-number rule. + [TestCase("NB I-5 AT EXIT 120", "Northbound I-five AT EXIT 120.")] + [TestCase("VEH S/B ON MAIN", "Vehicle Southbound ON MAIN.")] + [TestCase("NW corner of BLDG", "Northwest corner of Building.")] + public void Preprocess_HandlesDirectionalBounds(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + // ----------------------------------------------------------- + // Spell-out codes: no safe expansion, so read as spaced letters. + // Word-colliding codes (OK, OH, IN, ...) must stay untouched. + // ----------------------------------------------------------- + + [TestCase("Nashua, NH", "Nashua, N H.")] + [TestCase("Detroit, MI", "Detroit, M I.")] + [TestCase("Vancouver BC", "Vancouver B C.")] + [TestCase("CHECK ID", "CHECK I D.")] + [TestCase("IS EVERYONE OK", "IS EVERYONE OK.")] + [TestCase("HEAD TO OH", "HEAD TO OH.")] + public void Preprocess_SpellsOutUnexpandableCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + + // ----------------------------------------------------------- + // Ten-codes keep their numbers but lose the dash for pacing. + // ----------------------------------------------------------- + + [TestCase("10-4", "10 4.")] + [TestCase("10-50 AT MAIN", "10 50 AT MAIN.")] + [TestCase("11-99 OFC DOWN", "11 99 Officer DOWN.")] + public void Preprocess_PacesTenCodes(string input, string expected) + { + _preprocessor.Preprocess(input, EnglishVoice).Should().Be(expected); + } + // ----------------------------------------------------------- // Slash notation and sentence termination. // ----------------------------------------------------------- diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index 104904dfd..1943144df 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -51,9 +51,9 @@ public async Task generate_async_should_return_cached_response_without_generatin _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-ryan-high.onnx", 165)); + .Returns(("en_US-ryan-medium.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-medium.onnx", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -84,9 +84,9 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-ryan-high.onnx", 165)); + .Returns(("en_US-ryan-medium.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-medium.onnx", 165)) .Returns(CacheKey); _cacheService .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -150,9 +150,9 @@ public async Task generate_async_should_replace_legacy_default_voices_with_confi _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-ryan-high.onnx", 165)); + .Returns(("en_US-ryan-medium.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-medium.onnx", 165)) .Returns(cacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) @@ -189,9 +189,9 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-ryan-high.onnx", 165)); + .Returns(("en_US-ryan-medium.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-medium.onnx", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -249,7 +249,7 @@ public void create_piper_start_info_should_use_english_model_for_english_voices( startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-medium.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", @@ -274,7 +274,7 @@ public void create_piper_start_info_should_fallback_to_default_model_for_unmappe startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-medium.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", @@ -298,7 +298,7 @@ public void create_piper_start_info_should_adjust_length_scale_for_speed() startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-medium.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 211371657..bc0cf9479 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -98,6 +98,15 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN private const int MAX_DISPATCH_RETRY = 3; + // Retry budget for the inbound-menu dynamic listings (active calls, statuses, + // calendar). Same please-wait-and-redirect pattern as dispatch playback. + private const int MAX_LISTING_RETRY = 3; + + // How long a webhook waits for listing audio to come out of the TTS cache + // before redirecting. Generation keeps running server-side after the timeout, + // so a redirect re-entry finds the audio cached. + private static readonly TimeSpan ListingReadinessTimeout = TimeSpan.FromSeconds(3); + // Shared time budget for ALL TTS prompt playback within a single webhook request. // A TTS service that is down fails fast and degrades to inside // TwilioVoiceResponseService, but a HUNG service eats the full RestClient @@ -1136,7 +1145,7 @@ private async Task GetVoiceVerificationErrorResult() [HttpGet("InboundVoiceAction")] [Produces("application/xml")] - public async Task InboundVoiceAction(string userId, [FromQuery] VoiceRequest twilioRequest) + public async Task InboundVoiceAction(string userId, [FromQuery] VoiceRequest twilioRequest, [FromQuery] string retry = null) { var response = new VoiceResponse(); @@ -1144,6 +1153,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo var profile = await _userProfileService.GetProfileByUserIdAsync(userId); var prompts = new List(); + var isDynamicListing = false; Uri gatherAction = new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}"); string gatherFinishOnKey = null; int? gatherNumDigits = 1; @@ -1155,6 +1165,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo } else if (twilioRequest.Digits == "1") { + isDynamicListing = true; var calls = await _callsService.GetActiveCallsByDepartmentAsync(department.DepartmentId); if (calls != null && calls.Any()) @@ -1182,6 +1193,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo } else if (twilioRequest.Digits == "2") { + isDynamicListing = true; var allUsers = await _usersService.GetUserGroupAndRolesByDepartmentIdInLimitAsync(department.DepartmentId, false, false, false); var lastUserActionlogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(department.DepartmentId); var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(department.DepartmentId); @@ -1210,6 +1222,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo } else if (twilioRequest.Digits == "3") { + isDynamicListing = true; var units = await _unitsService.GetUnitsForDepartmentUnlimitedAsync(department.DepartmentId); var states = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(department.DepartmentId); var unitStatuses = await _customStateService.GetAllActiveUnitStatesForDepartmentAsync(department.DepartmentId); @@ -1239,6 +1252,7 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo } else if (twilioRequest.Digits == "4") { + isDynamicListing = true; var upcomingItems = await _calendarService.GetUpcomingCalendarItemsAsync(department.DepartmentId, DateTime.UtcNow); if (upcomingItems != null && upcomingItems.Any()) @@ -1306,6 +1320,30 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo goBackPrompt = TwilioVoicePromptCatalog.GoBackToMainMenuWithPound; } + // Dynamic listings are unique text and therefore almost always a TTS cache + // miss; cold generation routinely outlives the shared prompt budget, which + // used to skip the prompt entirely (silence, then hangup). Same pattern as + // dispatch playback in VoiceCall: wait briefly for the audio, and if it + // isn't ready, play a pre-warmed "please wait" and redirect back — the + // generation keeps running server-side, so the re-entry finds it cached. + if (isDynamicListing && prompts.Count > 0 && !await IsPromptAudioReadyAsync(prompts[0], department.DepartmentId)) + { + if (!int.TryParse(retry, out var retryCount)) + retryCount = 0; + + if (retryCount < MAX_LISTING_RETRY) + { + await AppendVoicePromptAsync(response, TwilioVoicePromptCatalog.PleaseWaitForInformation, department.DepartmentId); + response.Redirect( + new Uri($"{Config.SystemBehaviorConfig.ResgridApiBaseUrl}/api/Twilio/InboundVoiceAction?userId={userId}&Digits={twilioRequest.Digits}&retry={retryCount + 1}"), + "GET"); + return CreateVoiceContentResult(response); + } + + // Retry budget exhausted — fall through to the normal budgeted append, + // which degrades to (when enabled) or skips the prompt. + } + for (int repeat = 0; repeat < 2; repeat++) { var gather = new Gather(action: gatherAction, method: "GET", finishOnKey: gatherFinishOnKey, numDigits: gatherNumDigits) @@ -1322,6 +1360,34 @@ public async Task InboundVoiceAction(string userId, [FromQuery] Vo return CreateVoiceContentResult(response); } + /// + /// Waits up to for the TTS audio of the + /// given text to resolve (cache hit or fast generation). On timeout the + /// generation continues in TwilioVoiceResponseService's URL cache, so a + /// redirect re-entry gets an instant hit. TTS hard failures return true — the + /// normal append path already degrades those to <Say> or a skip. + /// + private async Task IsPromptAudioReadyAsync(string text, int? departmentId) + { + var ttsLanguage = await GetDepartmentTtsLanguageAsync(departmentId); + + using var timeoutCts = new CancellationTokenSource(ListingReadinessTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, + HttpContext?.RequestAborted ?? CancellationToken.None); + + try + { + var scratch = new VoiceResponse(); + await _twilioVoiceResponseService.AppendPromptAsync(scratch, text, linkedCts.Token, ttsLanguage); + return true; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested) + { + return false; + } + } + [HttpGet("InboundVoiceActionStatus")] [Produces("application/xml")] public async Task InboundVoiceActionStatus(string userId, [FromQuery] VoiceRequest twilioRequest) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index 82923c131..cb31c06cb 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -490,6 +490,10 @@ private static WeatherAlertResultData MapAlertToResultData(WeatherAlert alert, D ExpiresUtc = alert.ExpiresUtc?.TimeConverterToString(department), EffectiveUtc = alert.EffectiveUtc.TimeConverterToString(department), SentUtc = alert.SentUtc?.TimeConverterToString(department), + OnsetOnUtc = alert.OnsetUtc, + ExpiresOnUtc = alert.ExpiresUtc, + EffectiveOnUtc = alert.EffectiveUtc, + SentOnUtc = alert.SentUtc, FirstSeenUtc = alert.FirstSeenUtc.TimeConverterToString(department), LastUpdatedUtc = alert.LastUpdatedUtc.TimeConverterToString(department), ReferencesExternalId = alert.ReferencesExternalId, diff --git a/Web/Resgrid.Web.Services/Helpers/LegacyZonelessUtcDateTimeConverter.cs b/Web/Resgrid.Web.Services/Helpers/LegacyZonelessUtcDateTimeConverter.cs new file mode 100644 index 000000000..706d2d996 --- /dev/null +++ b/Web/Resgrid.Web.Services/Helpers/LegacyZonelessUtcDateTimeConverter.cs @@ -0,0 +1,22 @@ +namespace Resgrid.Web.Services.Helpers +{ + /// + /// TEMPORARY (RG-T132): serialises a UTC instant WITHOUT the trailing "Z". + /// + /// Deployed app builds compute "time ago" for CallResult.LoggedOnUtc by parsing the value + /// as device-local time and shifting "now" by the device's UTC offset -- math that is only + /// correct when the value is zone-less. The "Z" added by made + /// those builds show call times off by the device's UTC offset. Updated app builds handle both + /// formats, so once the fixed apps are rolled out, delete this class and restore + /// on the properties using it. + /// + /// Reads are inherited from and accept both formats. + /// + public class LegacyZonelessUtcDateTimeConverter : UtcDateTimeConverter + { + public LegacyZonelessUtcDateTimeConverter() + { + DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fff"; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index e9f5e53c2..44dd9fb0f 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -187,9 +187,11 @@ public class CallResultData public int? ActiveRunCardId { get; set; } /// - /// When was the call Logged On in UTC time + /// When was the call Logged On in UTC time. Temporarily serialised WITHOUT the "Z" so app + /// builds in the field keep showing correct call times; see LegacyZonelessUtcDateTimeConverter + /// for when to switch this back to UtcDateTimeConverter. /// - [JsonConverter(typeof(UtcDateTimeConverter))] + [JsonConverter(typeof(LegacyZonelessUtcDateTimeConverter))] public DateTime LoggedOnUtc { get; set; } /// diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs index 1b594ec40..bc4cedfa0 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertResultData.cs @@ -1,3 +1,8 @@ +using System; + +using Newtonsoft.Json; +using Resgrid.Web.Services.Helpers; + namespace Resgrid.Web.Services.Models.v4.WeatherAlerts { public class WeatherAlertResultData @@ -20,10 +25,29 @@ public class WeatherAlertResultData public string Polygon { get; set; } public string Geocodes { get; set; } public string CenterGeoLocation { get; set; } + // Despite the names, these string fields carry the department-local DISPLAY format + // ("MM/dd/yyyy h:mm:ss tt"). Deployed app builds render them verbatim, so the format cannot + // change. Clients doing date math must use the *OnUtc instants below instead. public string OnsetUtc { get; set; } public string ExpiresUtc { get; set; } public string EffectiveUtc { get; set; } public string SentUtc { get; set; } + + /// Actual UTC instant the alert became effective, serialised with an explicit "Z". + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime EffectiveOnUtc { get; set; } + + /// Actual UTC instant the alert expires, serialised with an explicit "Z". + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? ExpiresOnUtc { get; set; } + + /// Actual UTC onset instant, serialised with an explicit "Z". + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? OnsetOnUtc { get; set; } + + /// Actual UTC instant the alert was sent, serialised with an explicit "Z". + [JsonConverter(typeof(UtcDateTimeConverter))] + public DateTime? SentOnUtc { get; set; } public string FirstSeenUtc { get; set; } public string LastUpdatedUtc { get; set; } public string ReferencesExternalId { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 60fbefcce..1a2033993 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -45,6 +45,15 @@ the Twilio middleware's BaseUrlOverride behind the reverse proxy). + + + Waits up to for the TTS audio of the + given text to resolve (cache hit or fast generation). On timeout the + generation continues in TwilioVoiceResponseService's URL cache, so a + redirect re-entry gets an instant hit. TTS hard failures return true — the + normal append path already degrades those to <Say> or a skip. + + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -5394,6 +5403,20 @@ part of the shallow /health liveness endpoint. + + + TEMPORARY (RG-T132): serialises a UTC instant WITHOUT the trailing "Z". + + Deployed app builds compute "time ago" for CallResult.LoggedOnUtc by parsing the value + as device-local time and shifting "now" by the device's UTC offset -- math that is only + correct when the value is zone-less. The "Z" added by made + those builds show call times off by the device's UTC offset. Updated app builds handle both + formats, so once the fixed apps are rolled out, delete this class and restore + on the properties using it. + + Reads are inherited from and accept both formats. + + Serialises a that is known to hold a UTC instant with an explicit "Z". @@ -6553,7 +6576,9 @@ - When was the call Logged On in UTC time + When was the call Logged On in UTC time. Temporarily serialised WITHOUT the "Z" so app + builds in the field keep showing correct call times; see LegacyZonelessUtcDateTimeConverter + for when to switch this back to UtcDateTimeConverter. @@ -13436,6 +13461,18 @@ Id used to connect to the session + + Actual UTC instant the alert became effective, serialised with an explicit "Z". + + + Actual UTC instant the alert expires, serialised with an explicit "Z". + + + Actual UTC onset instant, serialised with an explicit "Z". + + + Actual UTC instant the alert was sent, serialised with an explicit "Z". + Plaintext credential JSON — will be AES-encrypted server-side before storage. diff --git a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs index 9a9be64da..eb40a06db 100644 --- a/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/ServiceCollectionExtensions.cs @@ -56,6 +56,8 @@ private static void ApplyTtsOptions(TtsOptions options) options.GenerationTimeoutSeconds = TtsConfig.GenerationTimeoutSeconds; options.PiperExecutable = string.IsNullOrWhiteSpace(TtsConfig.PiperExecutable) ? options.PiperExecutable : TtsConfig.PiperExecutable; options.PiperModelDirectory = string.IsNullOrWhiteSpace(TtsConfig.PiperModelDirectory) ? options.PiperModelDirectory : TtsConfig.PiperModelDirectory; + options.PiperPersistentProcessEnabled = TtsConfig.PiperPersistentProcessEnabled; + options.PiperMaxWorkersPerVoice = TtsConfig.PiperMaxWorkersPerVoice; options.FfmpegExecutable = string.IsNullOrWhiteSpace(TtsConfig.FfmpegExecutable) ? options.FfmpegExecutable : TtsConfig.FfmpegExecutable; options.TempDirectory = string.IsNullOrWhiteSpace(TtsConfig.TempDirectory) ? options.TempDirectory : TtsConfig.TempDirectory; options.TempDirectorySweepHours = TtsConfig.TempDirectorySweepHours; diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index 00ea49a99..768cfd5bd 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -32,6 +32,21 @@ public sealed class TtsOptions [Required] public string PiperModelDirectory { get; set; } = "/usr/local/share/piper-voices"; + /// + /// When true, synthesis goes through a pool of long-lived Piper processes (one + /// model load per process lifetime) instead of spawning a fresh process per + /// request. A failed or wedged worker is killed and respawned automatically; + /// a request retries once on a fresh worker before failing. + /// + public bool PiperPersistentProcessEnabled { get; set; } = true; + + /// + /// Maximum concurrent persistent Piper workers per (model, speed) profile. + /// Each worker keeps its ONNX model resident in memory. + /// + [Range(1, 8)] + public int PiperMaxWorkersPerVoice { get; set; } = 2; + [Required] public string FfmpegExecutable { get; set; } = "ffmpeg"; @@ -105,7 +120,8 @@ public sealed class TtsOptions "Invalid staffing selection. Returning to the main menu.", "No staffing selection made. Returning to the main menu.", "Thank you. Your response has been recorded.", - "Please wait while we prepare your dispatch information." + "Please wait while we prepare your dispatch information.", + "Please wait while we gather that information." }; } } \ No newline at end of file diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile index 2b8d5ce76..afd127f6c 100644 --- a/Web/Resgrid.Web.Tts/Dockerfile +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -69,7 +69,7 @@ RUN set -eu; \ RUN set -eu; \ mkdir -p /usr/local/share/piper-voices; \ for f in \ - "en/en_US/ryan/high/en_US-ryan-high" \ + "en/en_US/ryan/medium/en_US-ryan-medium" \ "es/es_MX/claude/high/es_MX-claude-high" \ "sv/sv_SE/nst/medium/sv_SE-nst-medium" \ "de/de_DE/thorsten/medium/de_DE-thorsten-medium" \ diff --git a/Web/Resgrid.Web.Tts/Program.cs b/Web/Resgrid.Web.Tts/Program.cs index 4db77e554..214072abf 100644 --- a/Web/Resgrid.Web.Tts/Program.cs +++ b/Web/Resgrid.Web.Tts/Program.cs @@ -110,6 +110,8 @@ await context.HttpContext.Response.WriteAsJsonAsync( }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index 14313f172..14f8e79fe 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -12,7 +12,10 @@ public sealed class AudioProcessingService : IAudioProcessingService private const float SpeedReferenceWpm = 175f; private const float MinLengthScale = 0.25f; private const float MaxLengthScale = 3.0f; - private const string DefaultEnglishModel = "en_US-ryan-high.onnx"; + // ryan-medium over ryan-high: near-identical intelligibility on the 8kHz mulaw + // telephony output, at a fraction of the model-load and synthesis cost. The + // model name feeds the TTS cache key, so this swap self-invalidates the cache. + private const string DefaultEnglishModel = "en_US-ryan-medium.onnx"; // Lowpass sits at 3400 Hz (the telephony band edge) rather than 3000 so // sibilants survive the mulaw encode; loudnorm keeps clips at a consistent // perceived level over the phone. @@ -60,15 +63,18 @@ public sealed class AudioProcessingService : IAudioProcessingService private readonly TtsOptions _options; private readonly ILogger _logger; private readonly ITextPreprocessor _textPreprocessor; + private readonly IPiperProcessPool _piperProcessPool; public AudioProcessingService( IOptions options, ILogger logger, - ITextPreprocessor textPreprocessor) + ITextPreprocessor textPreprocessor, + IPiperProcessPool piperProcessPool = null) { _options = options.Value; _logger = logger; _textPreprocessor = textPreprocessor; + _piperProcessPool = piperProcessPool; } public async Task GenerateNormalizedWavAsync(string text, string voice, int speed, CancellationToken cancellationToken) @@ -189,6 +195,17 @@ private static float ComputeLengthScale(int speed) private async Task RunPiperAsync(string text, string voice, int speed, string outputFilePath, CancellationToken cancellationToken) { + if (_options.PiperPersistentProcessEnabled && _piperProcessPool is not null) + { + var invocation = GetPiperInvocation(voice, speed); + var profile = new PiperSynthesisProfile( + Path.Combine(_options.PiperModelDirectory, invocation.ModelName), + invocation.LengthScale.ToString("0.00", CultureInfo.InvariantCulture)); + + await _piperProcessPool.SynthesizeAsync(profile, text, outputFilePath, cancellationToken); + return; + } + var startInfo = CreatePiperStartInfo(voice, speed, outputFilePath); await RunProcessAsync(startInfo, text, "Piper TTS", cancellationToken); } @@ -203,18 +220,7 @@ private ProcessStartInfo CreatePiperStartInfo(string voice, int speed, string ou startInfo.ArgumentList.Add(modelPath); startInfo.ArgumentList.Add("--output_file"); startInfo.ArgumentList.Add(outputFilePath); - startInfo.ArgumentList.Add("--length-scale"); - startInfo.ArgumentList.Add(invocation.LengthScale.ToString("0.00", CultureInfo.InvariantCulture)); - // 0.35s of silence between sentences — dispatch messages are strings of - // short sentences and need audible boundaries to stay intelligible. - startInfo.ArgumentList.Add("--sentence-silence"); - startInfo.ArgumentList.Add("0.35"); - // Lower generation noise than the Piper defaults (0.667/0.8): reduces - // prosody jitter that makes digits and short words sound mumbled. - startInfo.ArgumentList.Add("--noise-scale"); - startInfo.ArgumentList.Add("0.333"); - startInfo.ArgumentList.Add("--noise-w"); - startInfo.ArgumentList.Add("0.4"); + PiperTuning.AppendCommonArguments(startInfo, invocation.LengthScale.ToString("0.00", CultureInfo.InvariantCulture)); return startInfo; } diff --git a/Web/Resgrid.Web.Tts/Services/IPiperProcessPool.cs b/Web/Resgrid.Web.Tts/Services/IPiperProcessPool.cs new file mode 100644 index 000000000..0c6daff5a --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/IPiperProcessPool.cs @@ -0,0 +1,34 @@ +namespace Resgrid.Web.Tts.Services +{ + /// + /// A synthesis profile a persistent Piper worker is started with. Length scale is + /// carried as its formatted command-line value so profiles compare exactly. + /// + public sealed record PiperSynthesisProfile(string ModelPath, string LengthScale); + + /// + /// Pool of long-lived Piper processes, keyed by synthesis profile. A worker keeps + /// its ONNX model resident, so synthesis skips the per-request model load that + /// dominates cold generation time. Failed or wedged workers are disposed and the + /// request retries once on a freshly spawned worker. + /// + public interface IPiperProcessPool : IAsyncDisposable + { + Task SynthesizeAsync(PiperSynthesisProfile profile, string text, string outputFilePath, CancellationToken cancellationToken); + } + + /// + /// One persistent Piper process. Callers must serialize access: a worker handles a + /// single synthesis at a time (the pool guarantees this). A worker that throws is + /// in an unknown protocol state and must be disposed, never reused. + /// + public interface IPiperWorker : IDisposable + { + Task SynthesizeAsync(string text, string outputFilePath, CancellationToken cancellationToken); + } + + public interface IPiperWorkerFactory + { + IPiperWorker Create(PiperSynthesisProfile profile); + } +} diff --git a/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs b/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs new file mode 100644 index 000000000..8eb95f86a --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using System.Collections.Concurrent; + +namespace Resgrid.Web.Tts.Services +{ + /// + /// See . Per profile the pool holds up to + /// PiperMaxWorkersPerVoice workers behind a semaphore; excess requests queue on the + /// semaphore rather than spawning more model-resident processes. + /// + /// Self-healing: any worker failure (process exit, closed pipe, missing output + /// file, protocol garbage) disposes that worker and retries the request once on a + /// brand-new process. The second failure surfaces as InvalidOperationException, + /// which the TTS request path already treats as a generation failure. A caller + /// cancellation also disposes the in-use worker — its stdin/stdout protocol state + /// is unknown mid-request — and the next request simply spawns a replacement. + /// + public sealed class PiperProcessPool : IPiperProcessPool + { + private sealed class ProfileState + { + public required SemaphoreSlim Slots { get; init; } + public ConcurrentBag Idle { get; } = new(); + } + + private readonly IPiperWorkerFactory _workerFactory; + private readonly ILogger _logger; + private readonly int _maxWorkersPerProfile; + private readonly ConcurrentDictionary _profiles = new(StringComparer.Ordinal); + private volatile bool _disposed; + + public PiperProcessPool( + IOptions options, + ILogger logger, + IPiperWorkerFactory workerFactory) + { + _workerFactory = workerFactory; + _logger = logger; + _maxWorkersPerProfile = Math.Max(1, options.Value.PiperMaxWorkersPerVoice); + } + + public async Task SynthesizeAsync(PiperSynthesisProfile profile, string text, string outputFilePath, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(profile); + ArgumentException.ThrowIfNullOrWhiteSpace(text); + ArgumentException.ThrowIfNullOrWhiteSpace(outputFilePath); + + var state = _profiles.GetOrAdd( + $"{profile.ModelPath}{profile.LengthScale}", + _ => new ProfileState { Slots = new SemaphoreSlim(_maxWorkersPerProfile, _maxWorkersPerProfile) }); + + await state.Slots.WaitAsync(cancellationToken); + + try + { + Exception firstFailure = null; + + for (var attempt = 1; attempt <= 2; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + IPiperWorker worker = null; + + try + { + worker = state.Idle.TryTake(out var idleWorker) ? idleWorker : _workerFactory.Create(profile); + await worker.SynthesizeAsync(text, outputFilePath, cancellationToken); + state.Idle.Add(worker); + return; + } + catch (OperationCanceledException) + { + // The worker may have a response still in flight for the abandoned + // request; reusing it would desynchronize the protocol. + worker?.Dispose(); + throw; + } + catch (Exception ex) + { + worker?.Dispose(); + firstFailure ??= ex; + _logger.LogWarning( + ex, + "Piper worker for model {ModelPath} failed on attempt {Attempt}; respawning.", + profile.ModelPath, + attempt); + } + } + + throw new InvalidOperationException( + $"Piper synthesis failed twice (fresh worker included) for model {profile.ModelPath}.", + firstFailure); + } + finally + { + state.Slots.Release(); + } + } + + public ValueTask DisposeAsync() + { + _disposed = true; + + // Idle workers are killed here; a worker still serving a request is disposed + // by that request's cancellation path when the host stops. + foreach (var state in _profiles.Values) + { + while (state.Idle.TryTake(out var worker)) + { + try + { + worker.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispose a pooled Piper worker during shutdown."); + } + } + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/PiperWorker.cs b/Web/Resgrid.Web.Tts/Services/PiperWorker.cs new file mode 100644 index 000000000..91b4144c9 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/PiperWorker.cs @@ -0,0 +1,227 @@ +using Microsoft.Extensions.Options; +using Resgrid.Web.Tts.Configuration; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; + +namespace Resgrid.Web.Tts.Services +{ + /// + /// Piper synthesis tuning shared by the persistent workers and the one-shot + /// process fallback in . Keep both in sync — + /// the audio must be identical regardless of which path produced it. + /// + internal static class PiperTuning + { + // 0.35s of silence between sentences — dispatch messages are strings of + // short sentences and need audible boundaries to stay intelligible. + public const string SentenceSilence = "0.35"; + + // Lower generation noise than the Piper defaults (0.667/0.8): reduces + // prosody jitter that makes digits and short words sound mumbled. + public const string NoiseScale = "0.333"; + public const string NoiseW = "0.4"; + + public static void AppendCommonArguments(ProcessStartInfo startInfo, string lengthScale) + { + startInfo.ArgumentList.Add("--length-scale"); + startInfo.ArgumentList.Add(lengthScale); + startInfo.ArgumentList.Add("--sentence-silence"); + startInfo.ArgumentList.Add(SentenceSilence); + startInfo.ArgumentList.Add("--noise-scale"); + startInfo.ArgumentList.Add(NoiseScale); + startInfo.ArgumentList.Add("--noise-w"); + startInfo.ArgumentList.Add(NoiseW); + } + } + + public sealed class PiperWorkerFactory : IPiperWorkerFactory + { + // Reserved directory under the TTS temp root for worker output. Excluded from + // TempDirectorySweepHostedService (live worker directories must not be swept); + // stale content from a previous process is removed wholesale in the constructor. + public const string WorkerRootDirectoryName = "piper-workers"; + + private readonly TtsOptions _options; + private readonly ILoggerFactory _loggerFactory; + private readonly string _workerRoot; + + public PiperWorkerFactory(IOptions options, ILoggerFactory loggerFactory) + { + _options = options.Value; + _loggerFactory = loggerFactory; + + var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.TempDirectory) + ? Path.GetTempPath() + : _options.TempDirectory); + _workerRoot = Path.Combine(tempRoot, WorkerRootDirectoryName); + + try + { + if (Directory.Exists(_workerRoot)) + { + Directory.Delete(_workerRoot, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Stale files just take space until the next start; not fatal. + } + + Directory.CreateDirectory(_workerRoot); + } + + public IPiperWorker Create(PiperSynthesisProfile profile) + { + var workerDirectory = Path.Combine(_workerRoot, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workerDirectory); + + var startInfo = new ProcessStartInfo + { + FileName = _options.PiperExecutable, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + startInfo.ArgumentList.Add("--model"); + startInfo.ArgumentList.Add(profile.ModelPath); + // --json-input + --output_dir: each stdin line is {"text": ...}; Piper + // writes one WAV per line into the directory and prints its path to stdout, + // which is the per-request completion signal the worker waits on. + startInfo.ArgumentList.Add("--json-input"); + startInfo.ArgumentList.Add("--output_dir"); + startInfo.ArgumentList.Add(workerDirectory); + PiperTuning.AppendCommonArguments(startInfo, profile.LengthScale); + + return new PiperWorker(startInfo, workerDirectory, _loggerFactory.CreateLogger()); + } + } + + /// + /// See . Protocol per request: write one JSON line to + /// stdin, await one stdout line naming the WAV Piper wrote, move that file to the + /// caller's path. Any deviation throws, and the pool replaces the worker. + /// + public sealed class PiperWorker : IPiperWorker + { + private const int StderrTailLength = 5; + + private readonly Process _process; + private readonly string _workerDirectory; + private readonly ILogger _logger; + private readonly ConcurrentQueue _stderrTail = new(); + private bool _disposed; + + public PiperWorker(ProcessStartInfo startInfo, string workerDirectory, ILogger logger) + { + _workerDirectory = workerDirectory; + _logger = logger; + _process = new Process { StartInfo = startInfo }; + + if (!_process.Start()) + { + throw new InvalidOperationException("The Piper worker process failed to start."); + } + + // Drain stderr continuously — an undrained pipe buffer eventually blocks + // Piper mid-synthesis. The tail is kept for failure diagnostics. + _ = Task.Run(DrainStandardErrorAsync); + } + + public async Task SynthesizeAsync(string text, string outputFilePath, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_process.HasExited) + { + throw new InvalidOperationException($"The Piper worker exited with code {_process.ExitCode}.{StderrSuffix()}"); + } + + var inputLine = JsonSerializer.Serialize(new { text }); + await _process.StandardInput.WriteLineAsync(inputLine.AsMemory(), cancellationToken); + await _process.StandardInput.FlushAsync(cancellationToken); + + var reportedPath = await _process.StandardOutput.ReadLineAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(reportedPath)) + { + throw new InvalidOperationException($"The Piper worker closed its output stream mid-request.{StderrSuffix()}"); + } + + var producedFilePath = reportedPath.Trim(); + + if (!File.Exists(producedFilePath)) + { + throw new InvalidOperationException($"The Piper worker reported \"{producedFilePath}\" but the file does not exist.{StderrSuffix()}"); + } + + File.Move(producedFilePath, outputFilePath, overwrite: true); + } + + private async Task DrainStandardErrorAsync() + { + try + { + while (await _process.StandardError.ReadLineAsync() is { } line) + { + _stderrTail.Enqueue(line); + + while (_stderrTail.Count > StderrTailLength) + { + _stderrTail.TryDequeue(out _); + } + + _logger.LogDebug("piper: {StderrLine}", line); + } + } + catch (Exception) + { + // The stream closes when the process dies or is disposed; nothing to do. + } + } + + private string StderrSuffix() + { + var tail = string.Join(" | ", _stderrTail); + return string.IsNullOrWhiteSpace(tail) ? string.Empty : $" Last stderr: {tail}"; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + } + + _process.Dispose(); + + try + { + if (Directory.Exists(_workerDirectory)) + { + Directory.Delete(_workerDirectory, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Failed to delete Piper worker directory {WorkerDirectory}.", _workerDirectory); + } + } + } +} diff --git a/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs b/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs index 6f3694155..5a1292459 100644 --- a/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs +++ b/Web/Resgrid.Web.Tts/Services/TempDirectorySweepHostedService.cs @@ -81,6 +81,14 @@ public int SweepOnce() foreach (var entry in entries) { + // The persistent Piper workers' output root lives under the temp root for + // the pod's whole lifetime; sweeping it would break live workers. The + // worker factory clears it wholesale at startup instead. + if (string.Equals(entry.Name, PiperWorkerFactory.WorkerRootDirectoryName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (entry.LastWriteTimeUtc >= cutoff) { continue; diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs index a1b560e12..d609546b0 100644 --- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -19,176 +19,72 @@ namespace Resgrid.Web.Tts.Services /// public sealed partial class TextPreprocessor : ITextPreprocessor { - // --------------------------------------------------------------- - // Fire / EMS / Police dispatch abbreviations - // Ordered longest-first so "HAZMAT" matches before "MAT". - // --------------------------------------------------------------- - private static readonly Dictionary AbbreviationMap = new(StringComparer.Ordinal) - { - // Patient / incident descriptors - { "SFD", "Single Family Dwelling" }, - { "MFD", "Multi-Family Dwelling" }, - { "MCI", "Mass Casualty Incident" }, - { "MVC", "Motor Vehicle Collision" }, - { "MVA", "Motor Vehicle Accident" }, - { "PI", "Personal Injury" }, - { "GSW", "Gunshot Wound" }, - { "DOA", "Dead on Arrival" }, - { "CPR", "Cardio Pulmonary Resuscitation" }, - { "AED", "Automated External Defibrillator" }, - { "CO", "Carbon Monoxide" }, - { "UTL", "Unable to Locate" }, - { "ETA", "Estimated Time of Arrival" }, - - // Service types - { "ALS", "Advanced Life Support" }, - { "BLS", "Basic Life Support" }, - { "EMS", "Emergency Medical Services" }, - { "ALSEMS","Advanced Life Support Emergency Medical Services" }, - - // Agencies - { "HAZMAT","Hazardous Materials" }, - { "HazMat","Hazardous Materials" }, - { "WMD", "Weapons of Mass Destruction" }, - { "PD", "Police Department" }, - { "FD", "Fire Department" }, - { "SO", "Sheriff's Office" }, - { "SAR", "Search and Rescue" }, - - // Incident command - { "IC", "Incident Command" }, - { "PIO", "Public Information Officer" }, - { "POV", "Personally Owned Vehicle" }, - - // Firefighting equipment / tactics - { "SCBA", "Self-Contained Breathing Apparatus" }, - { "PASS", "Personal Alert Safety System" }, - { "RIT", "Rapid Intervention Team" }, - { "PPE", "Personal Protective Equipment" }, - { "PAR", "Personnel Accountability Report" }, - - // Medical - { "DNR", "Do Not Resuscitate" }, - { "CPAP", "Continuous Positive Airway Pressure" }, - { "BVM", "Bag Valve Mask" }, - - // Command / operations - { "SOP", "Standard Operating Procedure" }, - { "SME", "Subject Matter Expert" }, - - // Miscellaneous - { "FAQ", "Frequently Asked Questions" }, - }; + // The shorthand data lives in TtsShorthandCatalog; this class owns the + // matching mechanics. Rules are compiled once, ordered longest-key-first so + // "ALSEMS" matches before "ALS" and "W/M" before "W/". + private static readonly IReadOnlyList<(Regex Pattern, string Replacement)> AbbreviationRules = + CompileWordRules(TtsShorthandCatalog.Abbreviations); - // --------------------------------------------------------------- - // CAD / dispatch shorthand that the engine reads letter-by-letter - // or mispronounces as garbled words. These are the raw tokens - // that appear in CAD-to-email or CAD-to-API dispatch feeds. - // Ordered longest-first. - // --------------------------------------------------------------- - private static readonly Dictionary DispatchShorthandMap = new(StringComparer.Ordinal) + private static readonly IReadOnlyList<(Regex Pattern, string Replacement)> DispatchShorthandRules = + CompileWordRules(TtsShorthandCatalog.DispatchShorthand); + + private static readonly IReadOnlyList<(Regex Pattern, string Replacement)> SlashNotationRules = + CompileSymbolRules(TtsShorthandCatalog.SlashNotation); + + private static readonly IReadOnlyList<(Regex Pattern, string Replacement)> AddressSuffixRules = + CompileAddressSuffixRules(TtsShorthandCatalog.AddressSuffixes); + + private static readonly IReadOnlyList<(Regex Pattern, string Replacement)> SpellOutRules = + CompileWordRules(TtsShorthandCatalog.SpellOut); + + private static IReadOnlyList<(Regex, string)> CompileWordRules(IReadOnlyDictionary map) { - // Transport & entrapment - { "XPORT", "Transport" }, - { "ENTRP", "Entrapment" }, - - // Structures - { "BLDG", "Building" }, - { "APT", "Apartment" }, - { "RM", "Room" }, - - // Address references - { "ADDR", "Address" }, - { "BLK", "Block" }, - { "CS", "Cross Street" }, - { "LOC", "Location" }, - - // Patient / person descriptors - { "YOM", "Year Old Male" }, - { "YOF", "Year Old Female" }, - { "PTS", "Patients" }, - { "PT", "Patient" }, - { "UNC", "Unconscious" }, - { "UNK", "Unknown" }, - { "INJ", "Injuries" }, - { "RP", "Reporting Party" }, - - // Vehicles - { "VEH", "Vehicle" }, - { "VEC", "Vehicle" }, - - // Status / actions - { "ENR", "En Route" }, - { "ADV", "Advised" }, - { "NEG", "Negative" }, - { "RPT", "Report" }, - - // Communications - { "PX", "Phone Extension" }, - // All casings of "etc" are safe to expand (no English-word collision), - // so list each explicitly for the case-sensitive matcher — same pattern - // as HAZMAT/HazMat in AbbreviationMap. - { "etc", "et cetera" }, - { "ETC", "et cetera" }, - { "Etc", "et cetera" }, - - // Geographical - { "NH", "Northbound" }, - { "SH", "Southbound" }, - { "EH", "Eastbound" }, - { "WH", "Westbound" }, - }; + return map.OrderByDescending(entry => entry.Key.Length) + .Select(entry => ( + new Regex($@"\b{Regex.Escape(entry.Key)}\b", RegexOptions.Compiled | RegexOptions.CultureInvariant), + entry.Value)) + .ToList(); + } - // --------------------------------------------------------------- - // Address abbreviations (standalone words, only after a digit). - // --------------------------------------------------------------- - private static readonly Dictionary AddressAbbreviationMap = new(StringComparer.OrdinalIgnoreCase) + // Keys like "W/" and "Y/O" contain non-word characters that defeat the + // standard \b anchor. Use lookaround boundaries instead: (? CompileSymbolRules(IReadOnlyDictionary map) { - { "St", "Street" }, - { "Ave", "Avenue" }, - { "Blvd", "Boulevard" }, - { "Apt", "Apartment" }, - { "Ste", "Suite" }, - { "Rd", "Road" }, - { "Dr", "Drive" }, - { "Ct", "Court" }, - { "Ln", "Lane" }, - { "Cir", "Circle" }, - { "Pl", "Place" }, - { "Pkwy", "Parkway" }, - { "Hwy", "Highway" }, - { "Fwy", "Freeway" }, - { "Tpke", "Turnpike" }, - { "Xing", "Crossing" }, - }; + return map.OrderByDescending(entry => entry.Key.Length) + .Select(entry => ( + new Regex($@"(? SlashNotationMap = new(StringComparer.OrdinalIgnoreCase) + // Address suffixes are only expanded after a house/building number + // (e.g. "123 Main St" → "123 Main Street"). The pattern anchors to a leading + // digit (\b\d+\b), then lazily skips over the street name before matching the + // suffix; the house number and street name are captured and re-emitted. + private static IReadOnlyList<(Regex, string)> CompileAddressSuffixRules(IReadOnlyDictionary map) { - { "Y/O", "Year Old" }, - { "W/", "With" }, - { "W/O", "Without" }, - }; + return map.OrderByDescending(entry => entry.Key.Length) + .Select(entry => ( + new Regex($@"(\b\d+\b[\s\w,]*?)\b{Regex.Escape(entry.Key)}\b", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant), + "${1}" + entry.Value)) + .ToList(); + } // --------------------------------------------------------------- - // 10-codes — the engine reads "10-4" as "ten dash four", which is - // actually fine for most listeners. We keep them as-is for now. - // Uncomment the map and the handler below if you prefer expansion. + // 10-codes are never translated (meanings vary by agency); the + // ExpandTenCodes pass below only drops the dash so "10-4" is spoken + // as a paced "ten four". See TtsShorthandCatalog's ground rules. // --------------------------------------------------------------- - // private static readonly Dictionary TenCodeMap = new(StringComparer.Ordinal) - // { - // { "10-4", "acknowledged" }, - // { "10-50", "traffic accident" }, - // ... - // }; private static readonly Regex LongNumberRegex = LongNumberExpandoRegex(); private static readonly Regex WhitespaceRegex = WhitespaceExpandoRegex(); private static readonly Regex UnitIdentifierRegex = UnitIdentifierExpandoRegex(); private static readonly Regex NumberToWordRegexField = NumberToWordRegex(); + private static readonly Regex AgeYoSexRegexField = AgeYoSexRegex(); + private static readonly Regex AgeSlashSexRegexField = AgeSlashSexRegex(); + private static readonly Regex AgeJoinedSexRegexField = AgeJoinedSexRegex(); + private static readonly Regex TenCodeRegexField = TenCodeRegex(); private readonly ILogger _logger; public TextPreprocessor(ILogger logger) @@ -215,8 +111,15 @@ public string Preprocess(string text, string voice) // passes operate on natural-language words rather than codes. result = ExpandAbbreviations(result); result = ExpandDispatchShorthand(result); + // Age/sex before slash notation so "35/F" is consumed as a patient + // descriptor rather than reaching the generic slash handling. + result = ExpandAgeSexShorthand(result); result = ExpandSlashNotation(result); result = ExpandAddressAbbreviations(result); + // After every expansion map, so a mapped meaning always beats + // letter-spelling; before the number passes, which never touch letters. + result = ExpandSpellOutCodes(result); + result = ExpandTenCodes(result); result = ExpandUnitIdentifiers(result); result = ExpandLongNumbers(result); result = NormalizeSmallNumbers(result); @@ -250,15 +153,13 @@ public string Preprocess(string text, string voice) private static string ExpandAbbreviations(string text) { - // Sort keys longest-first so "ALSEMS" is matched before "ALS". // Matching is case-sensitive: short tokens like "SO", "PASS", "CO" and "PI" // collide with ordinary English words when lowercased, and CAD feeds emit - // these codes in upper case (the map carries explicit casing variants such - // as "HAZMAT"/"HazMat" where more than one form is expected). - foreach (var kvp in AbbreviationMap.OrderByDescending(k => k.Key.Length)) + // these codes in upper case (the catalog carries explicit casing variants + // such as "HAZMAT"/"HazMat" where more than one form is expected). + foreach (var (pattern, replacement) in AbbreviationRules) { - var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; - text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.CultureInvariant); + text = pattern.Replace(text, replacement); } return text; @@ -275,15 +176,48 @@ private static string ExpandDispatchShorthand(string text) // Case-sensitive for the same reason as ExpandAbbreviations: "APT", "PT", // "RM" and "ADV" lowercased are (parts of) ordinary words, and CAD systems // emit shorthand in upper case. - foreach (var kvp in DispatchShorthandMap.OrderByDescending(k => k.Key.Length)) + foreach (var (pattern, replacement) in DispatchShorthandRules) { - var pattern = $@"\b{Regex.Escape(kvp.Key)}\b"; - text = Regex.Replace(text, pattern, kvp.Value, RegexOptions.CultureInvariant); + text = pattern.Replace(text, replacement); } return text; } + /// + /// Expands CAD patient age/sex shorthand into spoken English: + ///
+ /// "35/F", "35/f" → "35 Year Old Female" + /// "35F", "35f" → "35 Year Old Female" (two/three digit ages only) + /// "35YOM", "35 yof" → "35 Year Old Male" / "35 Year Old Female" + /// "35yo", "35 YO" → "35 Year Old" + ///
+ /// The joined digits+letter form requires a 2-3 digit age: single-digit + /// tokens like "Apt 5F" or grid references collide too easily. The negative + /// lookbehind keeps highway ("I-35F"), decimal and fraction contexts out. + /// A trailing Fahrenheit reading ("101F") is misread as an age — patient + /// descriptors vastly outnumber temperatures in dispatch text. + ///
+ private static string ExpandAgeSexShorthand(string text) + { + text = AgeYoSexRegexField.Replace(text, match => + $"{match.Groups["age"].Value} Year Old{SexWord(match.Groups["sex"].Value)}"); + text = AgeSlashSexRegexField.Replace(text, match => + $"{match.Groups["age"].Value} Year Old{SexWord(match.Groups["sex"].Value)}"); + text = AgeJoinedSexRegexField.Replace(text, match => + $"{match.Groups["age"].Value} Year Old{SexWord(match.Groups["sex"].Value)}"); + + return text; + } + + private static string SexWord(string sexToken) + { + if (string.IsNullOrEmpty(sexToken)) + return string.Empty; + + return char.ToUpperInvariant(sexToken[0]) == 'M' ? " Male" : " Female"; + } + /// /// Converts slash-delimited abbreviations into spoken English so /// the engine doesn't say the word "slash" aloud. @@ -292,15 +226,9 @@ private static string ExpandDispatchShorthand(string text) /// private static string ExpandSlashNotation(string text) { - // Sort longest-first so "W/O" is matched before "W/". - foreach (var kvp in SlashNotationMap.OrderByDescending(k => k.Key.Length)) + foreach (var (pattern, replacement) in SlashNotationRules) { - // Keys like "W/" and "Y/O" contain non-word characters that - // defeat the standard \b anchor. Use lookaround boundaries - // instead: (? k.Key.Length)) + foreach (var (pattern, replacement) in AddressSuffixRules) { - var pattern = $@"(\b\d+\b[\s\w,]*?)\b{Regex.Escape(kvp.Key)}\b"; - text = Regex.Replace(text, pattern, "${1}" + kvp.Value, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + text = pattern.Replace(text, replacement); } return text; } + /// + /// Reads codes with no safe expansion as spaced letters ("MI" → "M I") so + /// each letter is spoken distinctly instead of running together as a word. + /// + private static string ExpandSpellOutCodes(string text) + { + foreach (var (pattern, replacement) in SpellOutRules) + { + text = pattern.Replace(text, replacement); + } + + return text; + } + + /// + /// Paces radio ten-codes: "10-4" → "10 4", spoken "ten four" instead of a + /// hurried "ten dash four". The numbers are kept (meanings vary by agency). + /// + private static string ExpandTenCodes(string text) + { + return TenCodeRegexField.Replace(text, "${prefix} ${code}"); + } + private static string ExpandUnitIdentifiers(string text) { // Transform common unit-identifier patterns so the engine speaks them @@ -442,5 +385,21 @@ private static string ExpandLongNumbers(string text) /// Collapses multiple whitespace characters into a single space. [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceExpandoRegex(); + + /// Matches "35YO", "35 yo", "35YOM"/"35 yof" — age + YO + optional sex. + [GeneratedRegex(@"(?\d{1,3})\s*YO(?[MF])?\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AgeYoSexRegex(); + + /// Matches "35/F", "9/m" — age, slash, sex letter. + [GeneratedRegex(@"(?\d{1,3})\s*/\s*(?[MF])\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AgeSlashSexRegex(); + + /// Matches "35F", "104m" — joined age + sex letter, 2-3 digit ages only. + [GeneratedRegex(@"(?\d{2,3})(?[MF])\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex AgeJoinedSexRegex(); + + /// Matches radio ten-codes and eleven-codes: "10-4", "11-99". + [GeneratedRegex(@"\b(?1[01])-(?\d{1,3})\b", RegexOptions.CultureInvariant)] + private static partial Regex TenCodeRegex(); } } \ No newline at end of file diff --git a/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs b/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs new file mode 100644 index 000000000..66283b164 --- /dev/null +++ b/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs @@ -0,0 +1,392 @@ +namespace Resgrid.Web.Tts.Services +{ + /// + /// The shorthand-to-spoken-English conversion library used by + /// . Data only — matching mechanics (word + /// boundaries, ordering, compiled patterns) live in the preprocessor. + /// + /// Ground rules for adding entries: + /// - and match + /// case-sensitively: CAD feeds emit codes in upper case, and lower-cased + /// tokens collide with ordinary English ("so", "co", "apt", "wit"). + /// Add explicit casing variants (like HAZMAT/HazMat) when needed. + /// - Many feeds are written ENTIRELY in caps, so an upper-case-only entry can + /// still collide with a real word in an all-caps sentence ("TO WIT", "SIP OF + /// WATER"). Only add a token when its caps form overwhelmingly means the code. + /// - Never add tokens that collide with US state or Canadian province codes — + /// they appear in addresses: MI, CA, TX, OK, OR, IN, LA, PA, ME, HI, DE, BC. + /// (That is why myocardial infarction, cardiac arrest and battalion chief are + /// absent.) Deliberately skipped for ambiguity: RESP (respiratory vs + /// responding), DIST (disturbance vs district), EXP (explosion vs exposure), + /// PLS (point last seen vs "please" in relayed texts), LOC stays "Location" + /// (not loss of consciousness), CP reads as "Chest Pain" (not command post — + /// nature text outnumbers ICS radio traffic in dispatches). + /// - Ten-codes keep their numbers ("10-4" is spoken "ten four" — the + /// preprocessor drops the dash for pacing) because meanings vary by agency; + /// other numeric codes ("5150", "459") are left entirely as-is. + /// - Unit designators ("E1", "L14", "K9") are handled by the unit-identifier + /// regex in the preprocessor, not by these maps. + /// - Comma-separated single letters ("D, U, I") force paced letter-by-letter + /// reading for initialisms the engine would otherwise pronounce as a word — + /// the commas buy a prosodic pause between letters. + /// - holds codes that have no safe spoken expansion + /// (state/province codes and context-dependent initialisms): they read as + /// spaced letters ("MI" → "M I") so they stay distinct instead of being + /// mumbled as a word. Only letter-spacing here, never a meaning — spelling + /// is neutral when the expansion would be ambiguous. + /// + internal static class TtsShorthandCatalog + { + /// + /// Standard acronyms across dispatch, EMS, fire, police/security, SAR, + /// emergency management and industrial response. Case-sensitive. + /// + public static readonly IReadOnlyDictionary Abbreviations = new Dictionary(StringComparer.Ordinal) + { + // Incident descriptors + { "SFD", "Single Family Dwelling" }, + { "MFD", "Multi-Family Dwelling" }, + { "MCI", "Mass Casualty Incident" }, + { "MVC", "Motor Vehicle Collision" }, + { "MVA", "Motor Vehicle Accident" }, + { "TC", "Traffic Collision" }, + { "PI", "Personal Injury" }, + { "GSW", "Gunshot Wound" }, + { "DOA", "Dead on Arrival" }, + { "UTL", "Unable to Locate" }, + { "GOA", "Gone on Arrival" }, + { "ETA", "Estimated Time of Arrival" }, + { "FA", "Fire Alarm" }, + { "AFA", "Automatic Fire Alarm" }, + + // Medical + { "CPR", "Cardio Pulmonary Resuscitation" }, + { "AED", "Automated External Defibrillator" }, + { "CO", "Carbon Monoxide" }, + { "DNR", "Do Not Resuscitate" }, + { "CPAP", "Continuous Positive Airway Pressure" }, + { "BVM", "Bag Valve Mask" }, + { "SOB", "Shortness of Breath" }, + { "DIB", "Difficulty Breathing" }, + { "AMS", "Altered Mental Status" }, + { "ALOC", "Altered Level of Consciousness" }, + { "CP", "Chest Pain" }, + { "BP", "Blood Pressure" }, + { "OD", "Overdose" }, + { "ETOH", "Alcohol Intoxication" }, + { "CVA", "Stroke" }, + { "VFIB", "Ventricular Fibrillation" }, + { "NKA", "No Known Allergies" }, + { "NKDA", "No Known Drug Allergies" }, + { "AMA", "Against Medical Advice" }, + { "EDP", "Emotionally Disturbed Person" }, + { "OB", "Obstetric" }, + { "PEDS", "Pediatric" }, + + // Service types + { "ALS", "Advanced Life Support" }, + { "BLS", "Basic Life Support" }, + { "EMS", "Emergency Medical Services" }, + { "ALSEMS", "Advanced Life Support Emergency Medical Services" }, + + // Agencies + { "HAZMAT", "Hazardous Materials" }, + { "HazMat", "Hazardous Materials" }, + { "WMD", "Weapons of Mass Destruction" }, + { "CBRN", "Chemical Biological Radiological Nuclear" }, + { "PD", "Police Department" }, + { "FD", "Fire Department" }, + { "SO", "Sheriff's Office" }, + { "SAR", "Search and Rescue" }, + { "USAR", "Urban Search and Rescue" }, + { "ERT", "Emergency Response Team" }, + + // Incident command / emergency management + { "IC", "Incident Command" }, + { "ICP", "Incident Command Post" }, + { "ICS", "Incident Command System" }, + { "IAP", "Incident Action Plan" }, + { "EOC", "Emergency Operations Center" }, + { "PIO", "Public Information Officer" }, + { "POV", "Personally Owned Vehicle" }, + { "POC", "Point of Contact" }, + { "SITREP", "Situation Report" }, + { "SIP", "Shelter in Place" }, + { "EAS", "Emergency Alert System" }, + { "NWS", "National Weather Service" }, + + // Firefighting equipment / tactics + { "SCBA", "Self-Contained Breathing Apparatus" }, + { "PASS", "Personal Alert Safety System" }, + { "RIT", "Rapid Intervention Team" }, + { "RIC", "Rapid Intervention Crew" }, + { "PPE", "Personal Protective Equipment" }, + { "PAR", "Personnel Accountability Report" }, + { "LZ", "Landing Zone" }, + { "FF", "Firefighter" }, + + // Police / security + { "BOLO", "Be On the Lookout" }, + { "APB", "All Points Bulletin" }, + { "DV", "Domestic Violence" }, + { "DUI", "D, U, I" }, + { "DWI", "D, W, I" }, + { "TRO", "Temporary Restraining Order" }, + { "POI", "Person of Interest" }, + { "CCTV", "C, C, T, V" }, + + // Search and rescue + { "LKP", "Last Known Position" }, + { "LSW", "Last Seen Wearing" }, + { "PLB", "Personal Locator Beacon" }, + { "ELT", "Emergency Locator Transmitter" }, + { "ATV", "All Terrain Vehicle" }, + { "UTV", "Utility Terrain Vehicle" }, + { "GPS", "G, P, S" }, + + // Hazmat / industrial + { "SDS", "Safety Data Sheet" }, + { "MSDS", "Material Safety Data Sheet" }, + { "LEL", "Lower Explosive Limit" }, + { "UEL", "Upper Explosive Limit" }, + { "PPM", "Parts Per Million" }, + { "IDLH", "Immediately Dangerous to Life and Health" }, + { "LOTO", "Lockout Tagout" }, + { "LPG", "Liquefied Petroleum Gas" }, + { "LNG", "Liquefied Natural Gas" }, + { "NFPA", "N, F, P, A" }, + + // Command / operations + { "SOP", "Standard Operating Procedure" }, + { "SME", "Subject Matter Expert" }, + { "ASAP", "As Soon As Possible" }, + + // Miscellaneous + { "FAQ", "Frequently Asked Questions" }, + }; + + /// + /// Raw CAD/dispatch feed contractions — the cryptic truncations CAD systems + /// embed in email/API dispatch output. Case-sensitive. + /// + public static readonly IReadOnlyDictionary DispatchShorthand = new Dictionary(StringComparer.Ordinal) + { + // Transport & entrapment + { "XPORT", "Transport" }, + { "ENTRP", "Entrapment" }, + + // Structures + { "BLDG", "Building" }, + { "APT", "Apartment" }, + { "RM", "Room" }, + { "STRU", "Structure" }, + { "STRUCT", "Structure" }, + + // Address references + { "ADDR", "Address" }, + { "BLK", "Block" }, + { "CS", "Cross Street" }, + { "LOC", "Location" }, + { "HWY", "Highway" }, + { "FWY", "Freeway" }, + { "XING", "Crossing" }, + + // Patient / person descriptors + { "YOM", "Year Old Male" }, + { "YOF", "Year Old Female" }, + { "PTS", "Patients" }, + { "PT", "Patient" }, + { "UNC", "Unconscious" }, + { "UNCON", "Unconscious" }, + { "UNRESP", "Unresponsive" }, + { "UNK", "Unknown" }, + { "INJ", "Injuries" }, + { "RP", "Reporting Party" }, + { "ABD", "Abdominal" }, + { "SZ", "Seizure" }, + { "FX", "Fracture" }, + // LAC collides with Los Angeles County in SoCal feeds; laceration is the + // overwhelmingly common meaning in nature text. + { "LAC", "Laceration" }, + { "HX", "History" }, + { "PEDI", "Pediatric" }, + { "PED", "Pedestrian" }, + { "JUV", "Juvenile" }, + { "INTOX", "Intoxicated" }, + { "SUBJ", "Subject" }, + { "SUSP", "Suspicious" }, + { "WIT", "Witness" }, + { "MISPER", "Missing Person" }, + + // Vehicles / apparatus + { "VEH", "Vehicle" }, + { "VEC", "Vehicle" }, + { "AMB", "Ambulance" }, + { "ENG", "Engine" }, + { "TRK", "Truck" }, + { "SQD", "Squad" }, + { "HELO", "Helicopter" }, + + // Status / actions + { "ENR", "En Route" }, + { "ENRT", "En Route" }, + { "ADV", "Advised" }, + { "NEG", "Negative" }, + { "RPT", "Report" }, + { "DISP", "Dispatch" }, + { "CANC", "Cancelled" }, + { "CTC", "Contact" }, + { "REQ", "Request" }, + { "POSS", "Possible" }, + { "AVAIL", "Available" }, + { "EVAC", "Evacuation" }, + { "DECON", "Decontamination" }, + + // Fire nature codes + { "SMK", "Smoke" }, + { "INV", "Investigation" }, + { "EXPL", "Explosion" }, + { "ELEC", "Electrical" }, + { "CHIM", "Chimney" }, + { "VEG", "Vegetation" }, + { "XFMR", "Transformer" }, + + // Police nature codes + { "ASLT", "Assault" }, + { "WPN", "Weapon" }, + { "BURG", "Burglary" }, + { "VAND", "Vandalism" }, + { "TRESP", "Trespass" }, + + // Ranks + { "SGT", "Sergeant" }, + { "OFC", "Officer" }, + { "CMD", "Command" }, + { "OPS", "Operations" }, + + // Organizational + { "DEPT", "Department" }, + { "STA", "Station" }, + { "EMER", "Emergency" }, + { "TFC", "Traffic" }, + + // Communications + { "PX", "Phone Extension" }, + { "ATTN", "Attention" }, + { "APPROX", "Approximately" }, + { "BTWN", "Between" }, + // All casings of "etc" are safe to expand (no English-word collision), + // so list each explicitly for the case-sensitive matcher — same pattern + // as HAZMAT/HazMat in Abbreviations. + { "etc", "et cetera" }, + { "ETC", "et cetera" }, + { "Etc", "et cetera" }, + + // Directional (roadway bounds and compass corners). NE reads as + // "Northeast", not Nebraska — compass usage dominates dispatch text, + // and the state codes live in SpellOut instead. + { "NB", "Northbound" }, + { "SB", "Southbound" }, + { "EB", "Eastbound" }, + { "WB", "Westbound" }, + { "NE", "Northeast" }, + { "NW", "Northwest" }, + { "SE", "Southeast" }, + { "SW", "Southwest" }, + + // Weather + { "TSTM", "Thunderstorm" }, + + // Chemical formulas heard in industrial/hazmat alarms + { "O2", "Oxygen" }, + { "CO2", "Carbon Dioxide" }, + { "H2S", "Hydrogen Sulfide" }, + { "NH3", "Ammonia" }, + { "CL2", "Chlorine" }, + }; + + /// + /// Slash- and symbol-delimited notation. Matched case-insensitively with + /// lookaround boundaries (the tokens contain non-word characters that defeat + /// \b anchors). Longest key wins, so "W/M" is consumed before "W/". + /// + public static readonly IReadOnlyDictionary SlashNotation = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Y/O", "Year Old" }, + { "W/O", "Without" }, + { "W/", "With" }, + { "C/O", "Complaining Of" }, + { "N/V", "Nausea and Vomiting" }, + { "A/O", "Alert and Oriented" }, + { "D/T", "Due To" }, + { "M/A", "Mutual Aid" }, + { "B&E", "Breaking and Entering" }, + + // Person descriptors used by police/security CAD + { "W/M", "White Male" }, + { "W/F", "White Female" }, + { "B/M", "Black Male" }, + { "B/F", "Black Female" }, + { "H/M", "Hispanic Male" }, + { "H/F", "Hispanic Female" }, + + // Directional slash variants + { "N/B", "Northbound" }, + { "S/B", "Southbound" }, + { "E/B", "Eastbound" }, + { "W/B", "Westbound" }, + }; + + /// + /// Codes with no safe spoken expansion, read as spaced letters so the engine + /// speaks each letter distinctly instead of mumbling them as a word + /// ("Detroit, MI" → "Detroit, M I"). Case-sensitive, applied after every + /// expansion map so mapped meanings always win. US state and Canadian + /// province codes that double as English words in an all-caps feed are + /// excluded (OH, OK, OR, IN, ME, HI, DE, LA, AL, MA, PA, ON), as are codes + /// already claimed by an expansion (CO, NB, NE and the other bounds). + /// + public static readonly IReadOnlyDictionary SpellOut = new Dictionary(StringComparer.Ordinal) + { + // US states / district + { "AK", "A K" }, { "AZ", "A Z" }, { "CA", "C A" }, { "CT", "C T" }, + { "DC", "D C" }, { "FL", "F L" }, { "GA", "G A" }, { "IA", "I A" }, + { "ID", "I D" }, { "IL", "I L" }, { "KS", "K S" }, { "KY", "K Y" }, + { "MD", "M D" }, { "MI", "M I" }, { "MN", "M N" }, { "MO", "M O" }, + { "MS", "M S" }, { "MT", "M T" }, { "NC", "N C" }, { "ND", "N D" }, + { "NH", "N H" }, { "NJ", "N J" }, { "NM", "N M" }, { "NV", "N V" }, + { "NY", "N Y" }, { "RI", "R I" }, { "SC", "S C" }, { "SD", "S D" }, + { "TN", "T N" }, { "TX", "T X" }, { "UT", "U T" }, { "VA", "V A" }, + { "VT", "V T" }, { "WA", "W A" }, { "WI", "W I" }, { "WV", "W V" }, + { "WY", "W Y" }, + + // Canadian provinces + { "BC", "B C" }, { "AB", "A B" }, { "QC", "Q C" }, { "SK", "S K" }, + { "MB", "M B" }, + }; + + /// + /// Street-suffix abbreviations, expanded only when they follow a house or + /// building number ("123 Main St" → "123 Main Street"). Case-insensitive. + /// + public static readonly IReadOnlyDictionary AddressSuffixes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "St", "Street" }, + { "Ave", "Avenue" }, + { "Blvd", "Boulevard" }, + { "Apt", "Apartment" }, + { "Ste", "Suite" }, + { "Rd", "Road" }, + { "Dr", "Drive" }, + { "Ct", "Court" }, + { "Ln", "Lane" }, + { "Cir", "Circle" }, + { "Pl", "Place" }, + { "Pkwy", "Parkway" }, + { "Hwy", "Highway" }, + { "Fwy", "Freeway" }, + { "Tpke", "Turnpike" }, + { "Xing", "Crossing" }, + }; + } +} diff --git a/Web/Resgrid.Web.Tts/k8s/deployment.yaml b/Web/Resgrid.Web.Tts/k8s/deployment.yaml index 4ad2515b8..a5f1ed0d7 100644 --- a/Web/Resgrid.Web.Tts/k8s/deployment.yaml +++ b/Web/Resgrid.Web.Tts/k8s/deployment.yaml @@ -23,6 +23,8 @@ data: RESGRID__TtsConfig__MaxTextLength: "1000" RESGRID__TtsConfig__PiperExecutable: /usr/local/bin/piper RESGRID__TtsConfig__PiperModelDirectory: /usr/local/share/piper-voices + RESGRID__TtsConfig__PiperPersistentProcessEnabled: "true" + RESGRID__TtsConfig__PiperMaxWorkersPerVoice: "2" RESGRID__TtsConfig__FfmpegExecutable: /usr/bin/ffmpeg RESGRID__TtsConfig__TempDirectory: /tmp/resgrid-tts RESGRID__TtsConfig__TempDirectorySweepHours: "6" @@ -32,7 +34,7 @@ data: RESGRID__TtsConfig__WarmupEnabled: "true" RESGRID__TtsConfig__StaticPromptRefreshIntervalMinutes: "1440" RESGRID__TtsConfig__PreGeneratedPrompts: >- - Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene. Goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;To hear the dispatch again, press 1. To hear response options, press 2.;To choose a response option, enter the option number, then press pound.;To hear the dispatch again, enter 0 and press pound.;Press 0 to go back to the main menu.;To go back to the main menu, enter 0 and press pound.;To set your current status, enter the number of your selection, then press pound.;To set your current staffing, enter the number of your selection, then press pound.;Invalid status selection. Returning to the main menu.;No status selection made. Returning to the main menu.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded. + Press 1 for yes;Press 2 for no;Invalid option;Please try again;Please stay on the line;This call has been closed. Goodbye.;You have been marked responding to the scene. Goodbye.;Sorry, that was not a valid selection.;Hello, this is Resgrid calling with your verification code.;That was your Resgrid verification code. Goodbye.;Thank you for calling the Resgrid automated personnel system. The number you called is not tied to an active department, or the department doesn't have this feature enabled. Goodbye.;We couldn't complete your verification call. Please request a new code and try again. Goodbye.;Please select from the following options.;To list current active calls, press 1.;To list current user statuses, press 2.;To list current unit statuses, press 3.;To list upcoming calendar events, press 4.;To list upcoming shifts, press 5.;To set your current status, press 6.;To set your current staffing level, press 7.;Press 0 to repeat. Press 1 to respond to the scene.;To hear the dispatch again, press 1. To hear response options, press 2.;To choose a response option, enter the option number, then press pound.;To hear the dispatch again, enter 0 and press pound.;Press 0 to go back to the main menu.;To go back to the main menu, enter 0 and press pound.;To set your current status, enter the number of your selection, then press pound.;To set your current staffing, enter the number of your selection, then press pound.;Invalid status selection. Returning to the main menu.;No status selection made. Returning to the main menu.;Invalid staffing selection. Returning to the main menu.;No staffing selection made. Returning to the main menu.;Thank you. Your response has been recorded.;Please wait while we prepare your dispatch information.;Please wait while we gather that information. RESGRID__TtsConfig__RateLimitPermitLimit: "60" RESGRID__TtsConfig__RateLimitQueueLimit: "10" RESGRID__TtsConfig__RateLimitWindowSeconds: "60" From 599ee3a255985a1e7c04592e9d174f8f60d83ff1 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 19 Aug 2026 17:05:14 -0700 Subject: [PATCH 3/3] RG-T132 PR#474 fixes --- .../DispatchVoicePromptBuilder.cs | 10 ++++- .../DispatchVoicePromptBuilderTests.cs | 15 +++++++ .../TwilioControllerVoiceVerificationTests.cs | 24 +++++++++++ .../Web/Tts/PiperProcessPoolTests.cs | 18 +++++++++ .../Web/Tts/TextPreprocessorTests.cs | 17 ++++++++ .../Controllers/TwilioController.cs | 10 +++++ .../Services/PiperProcessPool.cs | 40 ++++++++++++++----- Web/Resgrid.Web.Tts/Services/PiperWorker.cs | 37 +++++++++++++++-- .../Services/TextPreprocessor.cs | 15 +++++-- .../Services/TtsShorthandCatalog.cs | 12 ++++++ .../inventory/resgrid.inventory.adjust.js | 28 +++++++++++-- 11 files changed, 202 insertions(+), 24 deletions(-) diff --git a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs index f87a9b415..1e741bb02 100644 --- a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs +++ b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs @@ -45,7 +45,11 @@ public static class DispatchVoicePromptBuilder "New York", "North Carolina", "North Dakota", "Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island", "South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont", "Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming", "District of Columbia", - "Puerto Rico", "Guam" + "Puerto Rico", "Guam", + // Canadian provinces/territories + "Alberta", "British Columbia", "Manitoba", "New Brunswick", "Newfoundland and Labrador", + "Nova Scotia", "Ontario", "Prince Edward Island", "Quebec", "Saskatchewan", + "Northwest Territories", "Nunavut", "Yukon" }; // Two-letter state codes match case-sensitively: CAD feeds emit them upper-case, @@ -55,7 +59,9 @@ public static class DispatchVoicePromptBuilder "AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", - "VA", "WA", "WV", "WI", "WY", "DC", "PR", "GU" + "VA", "WA", "WV", "WI", "WY", "DC", "PR", "GU", + // Canadian provinces/territories + "AB", "BC", "MB", "NB", "NL", "NS", "ON", "PE", "QC", "SK", "NT", "NU", "YT" }; private static readonly Regex PostalCodeRegex = new(@"^(\d{5}(-\d{4})?|[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); diff --git a/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs b/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs index 045ee0293..742ef2cbc 100644 --- a/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs @@ -69,6 +69,10 @@ public void should_strip_html_from_the_nature_of_call() [TestCase("123 Main St, Springfield, WA", "123 Main St, Springfield")] [TestCase("450 Elk Run Rd, Victor, Idaho, United States", "450 Elk Run Rd, Victor")] [TestCase("1 Front St, Toronto, M5J 2X5, Canada", "1 Front St, Toronto")] + [TestCase("1 Front St, Toronto, ON, Canada", "1 Front St, Toronto")] + [TestCase("100 Main St, Whitehorse, Yukon", "100 Main St, Whitehorse")] + [TestCase("20 Water St, Charlottetown, Prince Edward Island", "20 Water St, Charlottetown")] + [TestCase("300 Portage Ave, Winnipeg, MB, Canada", "300 Portage Ave, Winnipeg")] public void trim_address_should_drop_trailing_state_zip_and_country(string input, string expected) { DispatchVoicePromptBuilder.TrimAddressForSpeech(input).Should().Be(expected); @@ -88,5 +92,16 @@ public void trim_address_should_always_keep_at_least_one_segment() { DispatchVoicePromptBuilder.TrimAddressForSpeech("WA 98111").Should().Be("WA 98111"); } + + // A rural/unincorporated address carries no city segment, so trimming has to + // run down to the street alone — guarding the loop at two segments instead of + // one would leave the zip and state being read aloud. + [TestCase("123 Main St, WA 98111, USA", "123 Main St")] + [TestCase("450 Elk Run Rd, Idaho", "450 Elk Run Rd")] + [TestCase("1 Front St, Canada", "1 Front St")] + public void trim_address_should_trim_down_to_the_street_when_no_city_segment_exists(string input, string expected) + { + DispatchVoicePromptBuilder.TrimAddressForSpeech(input).Should().Be(expected); + } } } diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index b82ab782d..0b0cc99db 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -436,6 +436,30 @@ public async System.Threading.Tasks.Task should_stop_redirecting_listings_after_ content.Should().Contain(" x.GetDepartmentByUserIdAsync("user1", false)).ReturnsAsync(department); + _userProfileServiceMock.Setup(x => x.GetProfileByUserIdAsync("user1", false)).ReturnsAsync(profile); + _unitsServiceMock.Setup(x => x.GetUnitsForDepartmentUnlimitedAsync(7)).ReturnsAsync(new List()); + _unitsServiceMock.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(7)).ReturnsAsync(new List()); + // A hard TTS fault must not fail the webhook or start a redirect loop — + // the readiness probe reports ready and the normal append path degrades. + _twilioVoiceResponseServiceMock + .Setup(x => x.AppendPromptAsync(It.IsAny(), listingPrompt, It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("tts service unavailable")); + + var result = await BuildController().InboundVoiceAction("user1", new VoiceRequest { Digits = "3" }); + + var content = ((ContentResult)result).Content; + content.Should().NotContain("Redirect"); + content.Should().Contain("(); } + [Test] + public async Task should_dispose_a_worker_whose_synthesis_finishes_during_shutdown() + { + // The pool drained its idle bag before this synthesis completed; returning + // the worker to the bag unconditionally would leak its Piper process. + PiperProcessPool pool = null; + var worker = new FakeWorker(async () => + { + await pool.DisposeAsync(); + }); + var factory = new FakeWorkerFactory(worker); + pool = CreatePool(factory); + + await pool.SynthesizeAsync(Profile, "text", "/tmp/out.wav", CancellationToken.None); + + worker.Disposed.Should().BeTrue(); + } + [Test] public async Task should_keep_separate_workers_per_synthesis_profile() { diff --git a/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs b/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs index b79705f4f..576774939 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TextPreprocessorTests.cs @@ -38,6 +38,23 @@ public void Preprocess_ExpandsMultipleAddressSuffixesInOneAddress() .Should().Be("100 Main Street Apartment 4."); } + [Test] + public void Preprocess_DoesNotReachAcrossACommaForStreetSuffixes() + { + // A street suffix belongs to the street phrase, so the match must not + // bridge a comma into the next clause and rewrite an unrelated word. + _preprocessor.Preprocess("Fall at 100 Center St, Dr Jones on scene", EnglishVoice) + .Should().Be("Fall at 100 Center Street, Dr Jones on scene."); + } + + [Test] + public void Preprocess_ExpandsUnitDesignatorWrittenAfterAComma() + { + // Sub-unit designators are routinely comma-separated in CAD address fields. + _preprocessor.Preprocess("123 Main St, Apt 4", EnglishVoice) + .Should().Be("123 Main Street, Apartment 4."); + } + [Test] public void Preprocess_DoesNotExpandAddressSuffixWithoutLeadingNumber() { diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index bc0cf9479..68a29f414 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -1386,6 +1386,16 @@ private async Task IsPromptAudioReadyAsync(string text, int? departmentId) { return false; } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // TwilioVoiceResponseService already degrades TTS faults to /skip, so + // nothing should reach here. Enforce the documented contract locally anyway: + // a readiness probe must never fail the webhook, and redirecting on a hard + // failure would just loop. Caller-abort cancellation is deliberately not + // caught — it is control flow, and the next append rethrows it regardless. + Logging.LogException(ex); + return true; + } } [HttpGet("InboundVoiceActionStatus")] diff --git a/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs b/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs index 8eb95f86a..c480c3745 100644 --- a/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs +++ b/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs @@ -67,7 +67,18 @@ public async Task SynthesizeAsync(PiperSynthesisProfile profile, string text, st { worker = state.Idle.TryTake(out var idleWorker) ? idleWorker : _workerFactory.Create(profile); await worker.SynthesizeAsync(text, outputFilePath, cancellationToken); + + // Publish first, then re-check disposal: a shutdown that drained the + // bag while this synthesis was in flight would otherwise never see + // this worker, leaking its Piper process for the pod's lifetime. + // Draining again here is idempotent and catches our own add. state.Idle.Add(worker); + + if (_disposed) + { + DisposeIdleWorkers(state); + } + return; } catch (OperationCanceledException) @@ -101,26 +112,33 @@ public async Task SynthesizeAsync(PiperSynthesisProfile profile, string text, st public ValueTask DisposeAsync() { + // Set before draining so a synthesis finishing concurrently sees the flag + // and drains its own worker (see SynthesizeAsync). _disposed = true; // Idle workers are killed here; a worker still serving a request is disposed // by that request's cancellation path when the host stops. foreach (var state in _profiles.Values) { - while (state.Idle.TryTake(out var worker)) - { - try - { - worker.Dispose(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to dispose a pooled Piper worker during shutdown."); - } - } + DisposeIdleWorkers(state); } return ValueTask.CompletedTask; } + + private void DisposeIdleWorkers(ProfileState state) + { + while (state.Idle.TryTake(out var worker)) + { + try + { + worker.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to dispose a pooled Piper worker during shutdown."); + } + } + } } } diff --git a/Web/Resgrid.Web.Tts/Services/PiperWorker.cs b/Web/Resgrid.Web.Tts/Services/PiperWorker.cs index 91b4144c9..3d3cecaf0 100644 --- a/Web/Resgrid.Web.Tts/Services/PiperWorker.cs +++ b/Web/Resgrid.Web.Tts/Services/PiperWorker.cs @@ -44,12 +44,14 @@ public sealed class PiperWorkerFactory : IPiperWorkerFactory private readonly TtsOptions _options; private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; private readonly string _workerRoot; public PiperWorkerFactory(IOptions options, ILoggerFactory loggerFactory) { _options = options.Value; _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_options.TempDirectory) ? Path.GetTempPath() @@ -65,7 +67,15 @@ public PiperWorkerFactory(IOptions options, ILoggerFactory loggerFac } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Stale files just take space until the next start; not fatal. + // Not fatal — synthesis still works and the stale entries only take + // space until the next start. Logged because the temp volume is a + // fixed-size emptyDir: repeated failures here are the leading + // indicator of the disk filling up, and the sweep service + // deliberately skips this directory. + _logger.LogWarning( + ex, + "Failed to delete the stale Piper worker root {WorkerRoot} during startup; orphaned worker directories will remain.", + _workerRoot); } Directory.CreateDirectory(_workerRoot); @@ -128,7 +138,20 @@ public PiperWorker(ProcessStartInfo startInfo, string workerDirectory, ILogger

+ { + try + { + await DrainStandardErrorAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed draining Piper worker stderr."); + } + }); } public async Task SynthesizeAsync(string text, string outputFilePath, CancellationToken cancellationToken) @@ -177,9 +200,11 @@ private async Task DrainStandardErrorAsync() _logger.LogDebug("piper: {StderrLine}", line); } } - catch (Exception) + catch (Exception ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException) { - // The stream closes when the process dies or is disposed; nothing to do. + // Expected: the stream closes when the process dies or is disposed. + // Anything outside this set is a real fault and reaches the call-site + // handler in the constructor rather than being swallowed here. } } @@ -207,6 +232,10 @@ public void Dispose() } catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) { + // The process exited between the HasExited check and the Kill call, so + // there is nothing left to kill. Expected on normal worker recycling — + // logged at debug so it stays diagnosable without adding routine noise. + _logger.LogDebug(ex, "Piper worker process had already exited when disposal tried to kill it."); } _process.Dispose(); diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs index d609546b0..da8bd37ba 100644 --- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -62,12 +62,21 @@ public sealed partial class TextPreprocessor : ITextPreprocessor // (e.g. "123 Main St" → "123 Main Street"). The pattern anchors to a leading // digit (\b\d+\b), then lazily skips over the street name before matching the // suffix; the house number and street name are captured and re-emitted. + // A street suffix belongs to the street phrase, so its bridge stops at a comma + // — otherwise the house number reaches into the following clause and rewrites + // an unrelated word ("100 Center St, Dr Jones" → "Drive Jones"). Sub-unit + // designators are exempt: CAD writes them after a comma ("123 Main St, Apt 4"). private static IReadOnlyList<(Regex, string)> CompileAddressSuffixRules(IReadOnlyDictionary map) { return map.OrderByDescending(entry => entry.Key.Length) - .Select(entry => ( - new Regex($@"(\b\d+\b[\s\w,]*?)\b{Regex.Escape(entry.Key)}\b", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant), - "${1}" + entry.Value)) + .Select(entry => + { + var bridge = TtsShorthandCatalog.UnitDesignators.Contains(entry.Key) ? @"[\s\w,]" : @"[\s\w]"; + + return ( + new Regex($@"(\b\d+\b{bridge}*?)\b{Regex.Escape(entry.Key)}\b", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant), + "${1}" + entry.Value); + }) .ToList(); } diff --git a/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs b/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs index 66283b164..39cc768c7 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs @@ -365,6 +365,18 @@ internal static class TtsShorthandCatalog { "MB", "M B" }, }; + ///

+ /// The subset of naming a sub-unit rather than a + /// street. CAD address fields routinely comma-separate these ("123 Main St, + /// Apt 4"), so their match may bridge a comma; a street suffix may not, or the + /// house number reaches across the comma into the next clause and rewrites an + /// unrelated word ("100 Center St, Dr Jones" → "Drive Jones"). + /// + public static readonly IReadOnlySet UnitDesignators = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Apt", "Ste", + }; + /// /// Street-suffix abbreviations, expanded only when they follow a house or /// building number ("123 Main St" → "123 Main Street"). Case-insensitive. diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js index 445e51f1b..6e33c9e2e 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/inventory/resgrid.inventory.adjust.js @@ -12,26 +12,46 @@ var resgrid; getUnits($('#Inventory_GroupId').val()); $("#Inventory_Amount").attr({ type: 'number', min: -999999999, max: 999999999, step: 1 }); }); + var unitsRequestSequence = 0; function getUnits(stationId) { var noUnitLabel = (typeof inventoryAdjustStrings !== 'undefined' && inventoryAdjustStrings.noUnit) ? inventoryAdjustStrings.noUnit : 'No Unit'; - var groupId = parseInt(stationId, 10); - if (isNaN(groupId) || groupId <= 0) { + var requestId = ++unitsRequestSequence; + function resetUnits() { $('#UnitId').empty(); $('#UnitId').append(''); + } + var groupId = parseInt(stationId, 10); + if (isNaN(groupId) || groupId <= 0) { + resetUnits(); return; } + // Clear before the request so the previous group's units are never + // selectable while it is in flight, nor left behind if it fails or + // answers with something other than a unit array. Submitting an + // adjustment against a unit from the wrong station must not be possible. + resetUnits(); $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Units/GetUnitsForGroup?groupId=' + groupId, contentType: 'application/json; charset=utf-8', type: 'GET' }).done(function (data) { + // A newer group change already reset the selector; this response is + // for a group the user has moved off of, so applying it would restore + // exactly the stale options the up-front clear removed. + if (requestId !== unitsRequestSequence) { + return; + } if (data && $.isArray(data)) { - $('#UnitId').empty(); - $('#UnitId').append(''); + resetUnits(); $.each(data, function (index, value) { $('#UnitId').append(''); }); } + }).fail(function () { + if (requestId !== unitsRequestSequence) { + return; + } + resetUnits(); }); } adjust.getUnits = getUnits;