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..1e741bb02 100644
--- a/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs
+++ b/Core/Resgrid.Services/DispatchVoicePromptBuilder.cs
@@ -22,21 +22,133 @@ 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",
+ // 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,
+ // 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",
+ // 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);
+
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..742ef2cbc
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Services/DispatchVoicePromptBuilderTests.cs
@@ -0,0 +1,107 @@
+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")]
+ [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);
+ }
+
+ [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");
+ }
+
+ // 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 481395363..0b0cc99db 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,99 @@ 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());
+ // 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(" 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..bcbdb0eb0
--- /dev/null
+++ b/Tests/Resgrid.Tests/Web/Tts/PiperProcessPoolTests.cs
@@ -0,0 +1,179 @@
+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_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()
+ {
+ 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..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()
{
@@ -98,6 +115,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..68a29f414 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,44 @@ 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;
+ }
+ 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")]
[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..c480c3745
--- /dev/null
+++ b/Web/Resgrid.Web.Tts/Services/PiperProcessPool.cs
@@ -0,0 +1,144 @@
+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);
+
+ // 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)
+ {
+ // 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()
+ {
+ // 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)
+ {
+ 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
new file mode 100644
index 000000000..3d3cecaf0
--- /dev/null
+++ b/Web/Resgrid.Web.Tts/Services/PiperWorker.cs
@@ -0,0 +1,256 @@
+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 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()
+ : _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)
+ {
+ // 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);
+ }
+
+ 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.
+ // Fire-and-forget, so the delegate must never let an exception escape
+ // unobserved: expected stream teardown is handled inside the loop, and
+ // anything else is logged here.
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await DrainStandardErrorAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed draining Piper worker stderr.");
+ }
+ });
+ }
+
+ 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 ex) when (ex is IOException or ObjectDisposedException or InvalidOperationException or OperationCanceledException)
+ {
+ // 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.
+ }
+ }
+
+ 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)
+ {
+ // 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();
+
+ 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..da8bd37ba 100644
--- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs
+++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs
@@ -19,176 +19,81 @@ 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.
+ // 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)
{
- { "Y/O", "Year Old" },
- { "W/", "With" },
- { "W/O", "Without" },
- };
+ return map.OrderByDescending(entry => entry.Key.Length)
+ .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();
+ }
// ---------------------------------------------------------------
- // 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 +120,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 +162,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 +185,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 +235,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)
+ {
+ 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)
{
- 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;
}
+ ///
+ /// 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 +394,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..39cc768c7
--- /dev/null
+++ b/Web/Resgrid.Web.Tts/Services/TtsShorthandCatalog.cs
@@ -0,0 +1,404 @@
+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" },
+ };
+
+ ///
+ /// 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.
+ ///
+ 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"
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..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
@@ -8,24 +8,50 @@ 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 });
});
+ var unitsRequestSequence = 0;
function getUnits(stationId) {
var noUnitLabel = (typeof inventoryAdjustStrings !== 'undefined' && inventoryAdjustStrings.noUnit) ? inventoryAdjustStrings.noUnit : 'No Unit';
+ 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=' + stationId,
+ url: resgrid.absoluteBaseUrl + '/User/Units/GetUnitsForGroup?groupId=' + groupId,
contentType: 'application/json; charset=utf-8',
type: 'GET'
}).done(function (data) {
- if (data) {
- $('#UnitId').empty();
- $('#UnitId').append('');
+ // 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)) {
+ resetUnits();
$.each(data, function (index, value) {
$('#UnitId').append('');
});
}
+ }).fail(function () {
+ if (requestId !== unitsRequestSequence) {
+ return;
+ }
+ resetUnits();
});
}
adjust.getUnits = getUnits;
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();
- }
- }
- }
-});