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(); - } - } - } -});