From d8b22844f195ba99de215c755578afbb2dbb45aa Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 2 May 2026 19:41:40 -0700 Subject: [PATCH 1/5] RE1-T115 TTS fixes --- .../Web/Tts/S3StorageServiceTests.cs | 34 + .../Resgrid.Tests/Web/Tts/TtsServiceTests.cs | 86 ++ .../Resgrid.Web.Services.xml | 1114 ++++++++--------- .../Services/AudioProcessingService.cs | 69 +- .../Services/S3StorageService.cs | 57 +- 5 files changed, 792 insertions(+), 568 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs index c39eff5c..eb166b64 100644 --- a/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/S3StorageServiceTests.cs @@ -123,6 +123,40 @@ public async Task exists_async_should_return_false_when_presigned_head_reports_m s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); } + [Test] + public async Task exists_async_should_verify_with_presigned_head_when_metadata_throws_raw_format_exception() + { + var s3Client = new Mock(MockBehavior.Strict); + s3Client + .Setup(x => x.GetObjectMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new FormatException("bad metadata expiration header")); + s3Client + .Setup(x => x.GetPreSignedURL(It.IsAny())) + .Returns(request => + { + request.BucketName.Should().Be("tts-bucket"); + request.Key.Should().Be("tts/audio.wav"); + request.Verb.Should().Be(HttpVerb.HEAD); + request.Protocol.Should().Be(Protocol.HTTP); + return "http://download.example.com/tts/audio.wav?signature=head-raw-format"; + }); + + var handler = new RecordingHttpMessageHandler((request, _) => + { + request.Method.Should().Be(HttpMethod.Head); + request.RequestUri.Should().Be(new Uri("http://download.example.com/tts/audio.wav?signature=head-raw-format")); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + }); + var service = CreateService(s3Client.Object, handler, useSsl: false); + + var exists = await service.ExistsAsync("tts/audio.wav", CancellationToken.None); + + exists.Should().BeTrue(); + handler.Requests.Should().HaveCount(1); + s3Client.Verify(x => x.GetObjectMetadataAsync(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav"), It.IsAny()), Times.Once); + s3Client.Verify(x => x.GetPreSignedURL(It.Is(request => request.BucketName == "tts-bucket" && request.Key == "tts/audio.wav" && request.Verb == HttpVerb.HEAD && request.Protocol == Protocol.HTTP)), Times.Once); + } + [Test] public async Task upload_async_should_treat_malformed_put_response_as_success_when_the_object_is_verified() { diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index 8dc76fdd..a7214c82 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -217,4 +219,88 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th Times.Exactly(4)); } } + + [TestFixture] + public class AudioProcessingServiceTests + { + [Test] + public void create_espeak_start_info_should_use_mbrola_profile_for_english_voices() + { + var service = CreateService(); + + var startInfo = InvokePrivateMethod(service, "CreateEspeakStartInfo", "en-gb-x-rp+klatt4", 165, "/tmp/raw.wav"); + + startInfo.FileName.Should().Be("espeak-ng"); + startInfo.ArgumentList.Should().Equal( + "--stdin", + "-w", + "/tmp/raw.wav", + "-v", + "mb-us1", + "-s", + "130", + "-p", + "50", + "-g", + "3"); + } + + [Test] + public void create_espeak_start_info_should_keep_requested_voice_and_speed_for_non_english_voices() + { + var service = CreateService(); + + var startInfo = InvokePrivateMethod(service, "CreateEspeakStartInfo", "fr+klatt4", 165, "/tmp/raw.wav"); + + startInfo.FileName.Should().Be("espeak-ng"); + startInfo.ArgumentList.Should().Equal( + "--stdin", + "-w", + "/tmp/raw.wav", + "-v", + "fr+klatt4", + "-s", + "165"); + } + + [Test] + public void create_ffmpeg_start_info_should_apply_the_requested_telephone_filter() + { + var service = CreateService(); + + var startInfo = InvokePrivateMethod(service, "CreateFfmpegStartInfo", "/tmp/raw.wav", "/tmp/normalized.wav"); + + startInfo.FileName.Should().Be("ffmpeg"); + startInfo.ArgumentList.Should().Equal( + "-nostdin", + "-loglevel", + "error", + "-y", + "-i", + "/tmp/raw.wav", + "-ar", + "8000", + "-ac", + "1", + "-acodec", + "pcm_s16le", + "-af", + "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1", + "/tmp/normalized.wav"); + } + + private static AudioProcessingService CreateService() + { + return new AudioProcessingService( + Options.Create(new TtsOptions()), + Mock.Of>()); + } + + private static T InvokePrivateMethod(object instance, string methodName, params object[] arguments) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + method.Should().NotBeNull($"{methodName} should exist on {instance.GetType().FullName}"); + return (T)method!.Invoke(instance, arguments)!; + } + } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 15601e50..526ac72e 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3573,6 +3573,52 @@ Is the user a group admin + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + Input data to add a staffing schedule in the Resgrid system @@ -3678,52 +3724,6 @@ Note for this staffing schedule - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - A resrouce in the system this could be a user or unit @@ -7508,209 +7508,379 @@ Identifier of the new npte - + - A GPS location for a point in time of a specificed person + The result of getting all personnel filters for the system - + - PersonId of the person that the location is for + The Id value of the filter - + - The timestamp of the location in UTC + The type of the filter - + - GPS Latitude of the Person + The filters name - + - GPS Longitude of the Person + Result containing all the data required to populate the New Call form - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - GPS Altitude Accuracy of the Person + Response Data - + - GPS Speed of the Person + Result containing all the data required to populate the New Call form - + - GPS Heading of the Person + Response Data - + - A unit location in the Resgrid system + Information about a User - + - Response Data + The UserId GUID/UUID for the user - + - The information about a specific unit's location + DepartmentId of the deparment the user belongs to - + - Id of the Person + Department specificed ID number for this user - + - The Timestamp for the location in UTC + The Users First Name - + - GPS Latitude of the Person + The Users Last Name - + - GPS Longitude of the Person + The Users Email Address - + - GPS Latitude\Longitude Accuracy of the Person + The Users Mobile Telephone Number - + - GPS Altitude of the Person + GroupId the user is assigned to (0 for no group) - + - GPS Altitude Accuracy of the Person + Name of the group the user is assigned to - + - GPS Speed of the Person + Enumeration/List of roles the user currently holds - + - GPS Heading of the Person + The current action/status type for the user - + - The result of getting the current staffing for a user + The current action/status string for the user - + - Response Data + The current action/status color hex string for the user - + - Information about a User staffing + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - + - The UserId GUID/UUID for the user status being return + The current action/status destination id for the user - + - DepartmentId of the deparment the user belongs to + The current action/status destination name for the user - + - The current staffing type for the user + The current staffing level (state) type for the user - + - The timestamp of the last staffing. This is converted UTC version of the timestamp. + The current staffing level (state) string for the user - + - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + The current staffing level (state) color hex string for the user - + - Note for this staffing + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - + - Saves (sets) and Personnel Staffing in the system, for a single user + Users last known location - + - UnitId of the apparatus that the state is being set for + Sorting weight for the user - + - The UnitStateType of the Unit + User Defined Field values for this personnel record - + - The timestamp of the status event in UTC + A GPS location for a point in time of a specificed person - + - The timestamp of the status event in the local time of the device + PersonId of the person that the location is for - + - User provided note for this event + The timestamp of the location in UTC - + - The event id used for queuing on mobile applications + GPS Latitude of the Person - + - Depicts a result after saving a person status + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Saves (sets) and Personnel Status in the system, for a single user + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + A unit location in the Resgrid system + + + + + Response Data + + + + + The information about a specific unit's location + + + + + Id of the Person + + + + + The Timestamp for the location in UTC + + + + + GPS Latitude of the Person + + + + + GPS Longitude of the Person + + + + + GPS Latitude\Longitude Accuracy of the Person + + + + + GPS Altitude of the Person + + + + + GPS Altitude Accuracy of the Person + + + + + GPS Speed of the Person + + + + + GPS Heading of the Person + + + + + The result of getting the current staffing for a user + + + + + Response Data + + + + + Information about a User staffing + + + + + The UserId GUID/UUID for the user status being return + + + + + DepartmentId of the deparment the user belongs to + + + + + The current staffing type for the user + + + + + The timestamp of the last staffing. This is converted UTC version of the timestamp. + + + + + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. + + + + + Note for this staffing + + + + + Saves (sets) and Personnel Staffing in the system, for a single user + + + + + UnitId of the apparatus that the state is being set for + + + + + The UnitStateType of the Unit + + + + + The timestamp of the status event in UTC + + + + + The timestamp of the status event in the local time of the device + + + + + User provided note for this event + + + + + The event id used for queuing on mobile applications + + + + + Depicts a result after saving a person status + + + + + Response Data + + + + + Saves (sets) and Personnel Status in the system, for a single user @@ -8011,282 +8181,112 @@ Response Data - + - The result of getting all personnel filters for the system + Result containing all the data required to populate the New Call form - + - The Id value of the filter + Response Data - + - The type of the filter + Details of a protocol - + - The filters name + Protocol id - + - Result containing all the data required to populate the New Call form + Department id - + - Response Data + Name of the Protocol - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + Protocol code - + - Response Data + This this protocol disabled - + - Result containing all the data required to populate the New Call form + Protocol description - + - Response Data + Text of the protocol - + - Information about a User + UTC date and time when the Protocol was created - + - The UserId GUID/UUID for the user + UserId of the user who created the protocol - + - DepartmentId of the deparment the user belongs to + UTC timestamp of when the Protocol was updated - + - Department specificed ID number for this user + Minimum triggering Weight of the Protocol - + - The Users First Name + UserId that last updated the Protocol - + - The Users Last Name + Triggers used to activate this Protocol - + - The Users Email Address + Attachments for this Protocol - + - The Users Mobile Telephone Number + Questions used to determine if this Protocol needs to be used or not - + - GroupId the user is assigned to (0 for no group) + State type - + - Name of the group the user is assigned to + Result containing all the data required to populate the New Call form - + - Enumeration/List of roles the user currently holds + Response Data - - - The current action/status type for the user - - - - - The current action/status string for the user - - - - - The current action/status color hex string for the user - - - - - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. - - - - - The current action/status destination id for the user - - - - - The current action/status destination name for the user - - - - - The current staffing level (state) type for the user - - - - - The current staffing level (state) string for the user - - - - - The current staffing level (state) color hex string for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Users last known location - - - - - Sorting weight for the user - - - - - User Defined Field values for this personnel record - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - - - Details of a protocol - - - - - Protocol id - - - - - Department id - - - - - Name of the Protocol - - - - - Protocol code - - - - - This this protocol disabled - - - - - Protocol description - - - - - Text of the protocol - - - - - UTC date and time when the Protocol was created - - - - - UserId of the user who created the protocol - - - - - UTC timestamp of when the Protocol was updated - - - - - Minimum triggering Weight of the Protocol - - - - - UserId that last updated the Protocol - - - - - Triggers used to activate this Protocol - - - - - Attachments for this Protocol - - - - - Questions used to determine if this Protocol needs to be used or not - - - - - State type - - - - - Result containing all the data required to populate the New Call form - - - - - Response Data - - - + A role in the Resgrid system @@ -9480,545 +9480,545 @@ Default constructor - + - Depicts a result after saving a unit status + Result that contains all the options available to filter units against compatible Resgrid APIs - + Response Data - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + A unit in the Resgrid system - + - UnitId of the apparatus that the state is being set for + Response Data - + - The UnitStateType of the Unit + The information about a specific unit - + - The Call/Station the unit is responding to + Id of the Unit - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + The Id of the department the unit is under - + - The timestamp of the status event in UTC + Name of the Unit - + - The timestamp of the status event in the local time of the device + Department assigned type for the unit - + - User provided note for this event + Department assigned type id for the unit - + - GPS Latitude of the Unit + Custom Statuses Set Id - + - GPS Longitude of the Unit + Station Id of the station housing the unit (0 means no station) - + - GPS Latitude\Longitude Accuracy of the Unit + Name of the station the unit is under - + - GPS Altitude of the Unit + Vehicle Identification Number for the unit - + - GPS Altitude Accuracy of the Unit + Plate Number for the Unit - + - GPS Speed of the Unit + Is the unit 4-Wheel drive - + - GPS Heading of the Unit + Does the unit require a special permit to drive - + - The event id used for queuing on mobile applications + Id number of the units current destionation (0 means no destination) - + - The accountability roles filed for this event + The current status/state of the Unit - + - Role filled by a User on a Unit for an event + The Timestamp of the status - + - Id of the locally stored event + The units current Latitude - + - Local Event Id + The units current Longitude - + - UserId of the user filling the role + Current user provide status note - + - RoleId of the role being filled + User Defined Field values for this unit - + - The name of the Role + Unit role information for roles on a unit - + - Depicts a unit status in the Resgrid system. + Unit Role Id - + - Response Data + User Id of the user in the role (could be null) - + - Depicts a unit's status + Name of the Role - + - Unit Id + Name of the user in the role (could be null) - + - Units Name + Multiple Unit infos Result - + - The Type of the Unit + Response Data - + - Units current Status (State) + Default constructor - + - CSS for status (for display) + The information about a specific unit - + - CSS Style for status (for display) + Id of the Unit - + - Timestamp of this Unit State + The Id of the department the unit is under - + - Timestamp in Utc of this Unit State + Name of the Unit - + - Destination Id (Station or Call) + Department assigned type for the unit - + - Destination type (Station, Call, or POI). + Department assigned type id for the unit - + - Name of the Desination (Call or Station) + Custom Statuses Set Id - + - Destination address. + Station Id of the station housing the unit (0 means no station) - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + Name of the station the unit is under - + - Note for the State + Vehicle Identification Number for the unit - + - Latitude + Plate Number for the Unit - + - Longitude + Is the unit 4-Wheel drive - + - Name of the Group the Unit is in + Does the unit require a special permit to drive - + - Id of the Group the Unit is in + Id number of the units current destination (0 means no destination) - + - Unit statuses (states) + Name of the units current destination (0 means no destination) - + - Response Data + The current status/state of the Unit - + - Default constructor + The current status/state of the Unit as a name - + - Result that contains all the options available to filter units against compatible Resgrid APIs + The current status/state of the Unit color - + - Response Data + The Timestamp of the status - + - A unit in the Resgrid system + The Timestamp of the status in UTC/GMT - + - Response Data + The units current Latitude - + - The information about a specific unit + The units current Longitude - + - Id of the Unit + Current user provide status note - + - The Id of the department the unit is under + Units Roles - + - Name of the Unit + Multiple Units Result - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Default constructor - + - Custom Statuses Set Id + Depicts a result after saving a unit status - + - Station Id of the station housing the unit (0 means no station) + Response Data - + - Name of the station the unit is under + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Vehicle Identification Number for the unit + UnitId of the apparatus that the state is being set for - + - Plate Number for the Unit + The UnitStateType of the Unit - + - Is the unit 4-Wheel drive + The Call/Station the unit is responding to - + - Does the unit require a special permit to drive + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Id number of the units current destionation (0 means no destination) + The timestamp of the status event in UTC - + - The current status/state of the Unit + The timestamp of the status event in the local time of the device - + - The Timestamp of the status + User provided note for this event - + - The units current Latitude + GPS Latitude of the Unit - + - The units current Longitude + GPS Longitude of the Unit - + - Current user provide status note + GPS Latitude\Longitude Accuracy of the Unit - + - User Defined Field values for this unit + GPS Altitude of the Unit - + - Unit role information for roles on a unit + GPS Altitude Accuracy of the Unit - + - Unit Role Id + GPS Speed of the Unit - + - User Id of the user in the role (could be null) + GPS Heading of the Unit - + - Name of the Role + The event id used for queuing on mobile applications - + - Name of the user in the role (could be null) + The accountability roles filed for this event - + - Multiple Unit infos Result + Role filled by a User on a Unit for an event - + - Response Data + Id of the locally stored event - + - Default constructor + Local Event Id - + - The information about a specific unit + UserId of the user filling the role - + - Id of the Unit + RoleId of the role being filled - + - The Id of the department the unit is under + The name of the Role - + - Name of the Unit + Depicts a unit status in the Resgrid system. - + - Department assigned type for the unit + Response Data - + - Department assigned type id for the unit + Depicts a unit's status - + - Custom Statuses Set Id + Unit Id - + - Station Id of the station housing the unit (0 means no station) + Units Name - + - Name of the station the unit is under + The Type of the Unit - + - Vehicle Identification Number for the unit + Units current Status (State) - + - Plate Number for the Unit + CSS for status (for display) - + - Is the unit 4-Wheel drive + CSS Style for status (for display) - + - Does the unit require a special permit to drive + Timestamp of this Unit State - + - Id number of the units current destination (0 means no destination) + Timestamp in Utc of this Unit State - + - Name of the units current destination (0 means no destination) + Destination Id (Station or Call) - + - The current status/state of the Unit + Destination type (Station, Call, or POI). - + - The current status/state of the Unit as a name + Name of the Desination (Call or Station) - + - The current status/state of the Unit color + Destination address. - + - The Timestamp of the status + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - The Timestamp of the status in UTC/GMT + Note for the State - + - The units current Latitude + Latitude - + - The units current Longitude + Longitude - + - Current user provide status note + Name of the Group the Unit is in - + - Units Roles + Id of the Group the Unit is in - + - Multiple Units Result + Unit statuses (states) - + Response Data - + Default constructor diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index d8c8cddd..b81a73ff 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -8,6 +8,12 @@ namespace Resgrid.Web.Tts.Services { public sealed class AudioProcessingService : IAudioProcessingService { + private const string MbrolaEnglishVoice = "mb-us1"; + private const int MbrolaEnglishSpeed = 130; + private const int MbrolaEnglishPitch = 50; + private const int MbrolaEnglishWordGap = 3; + private const string TelephoneAudioFilter = "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1"; + private readonly TtsOptions _options; private readonly ILogger _logger; @@ -43,19 +49,69 @@ public async Task GenerateNormalizedWavAsync(string text, string voice, private async Task RunEspeakAsync(string text, string voice, int speed, string outputFilePath, CancellationToken cancellationToken) { + var startInfo = CreateEspeakStartInfo(voice, speed, outputFilePath); + await RunProcessAsync(startInfo, text, "eSpeak NG", cancellationToken); + } + + private ProcessStartInfo CreateEspeakStartInfo(string voice, int speed, string outputFilePath) + { + var invocation = GetEspeakInvocation(voice, speed); var startInfo = CreateStartInfo(_options.EspeakExecutable, redirectStandardInput: true); startInfo.ArgumentList.Add("--stdin"); startInfo.ArgumentList.Add("-w"); startInfo.ArgumentList.Add(outputFilePath); startInfo.ArgumentList.Add("-v"); - startInfo.ArgumentList.Add(voice); + startInfo.ArgumentList.Add(invocation.Voice); startInfo.ArgumentList.Add("-s"); - startInfo.ArgumentList.Add(speed.ToString(CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add(invocation.Speed.ToString(CultureInfo.InvariantCulture)); - await RunProcessAsync(startInfo, text, "eSpeak NG", cancellationToken); + if (invocation.Pitch.HasValue) + { + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add(invocation.Pitch.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (invocation.WordGap.HasValue) + { + startInfo.ArgumentList.Add("-g"); + startInfo.ArgumentList.Add(invocation.WordGap.Value.ToString(CultureInfo.InvariantCulture)); + } + + return startInfo; + } + + private static EspeakInvocation GetEspeakInvocation(string voice, int speed) + { + // English playback uses a fixed MBROLA telephony profile. + // Other languages continue to use their current eSpeak-NG voice and requested speed. + return IsEnglishVoice(voice) + ? new EspeakInvocation(MbrolaEnglishVoice, MbrolaEnglishSpeed, MbrolaEnglishPitch, MbrolaEnglishWordGap) + : new EspeakInvocation(voice, speed, null, null); + } + + private static bool IsEnglishVoice(string voice) + { + if (string.IsNullOrWhiteSpace(voice)) + { + return false; + } + + var trimmedVoice = voice.Trim(); + var variantSeparatorIndex = trimmedVoice.IndexOf('+'); + var baseVoice = variantSeparatorIndex <= 0 ? trimmedVoice : trimmedVoice[..variantSeparatorIndex]; + + return string.Equals(baseVoice, MbrolaEnglishVoice, StringComparison.OrdinalIgnoreCase) + || string.Equals(baseVoice, "en", StringComparison.OrdinalIgnoreCase) + || baseVoice.StartsWith("en-", StringComparison.OrdinalIgnoreCase); } private async Task RunFfmpegAsync(string inputFilePath, string outputFilePath, CancellationToken cancellationToken) + { + var startInfo = CreateFfmpegStartInfo(inputFilePath, outputFilePath); + await RunProcessAsync(startInfo, null, "ffmpeg", cancellationToken); + } + + private ProcessStartInfo CreateFfmpegStartInfo(string inputFilePath, string outputFilePath) { var startInfo = CreateStartInfo(_options.FfmpegExecutable); startInfo.ArgumentList.Add("-nostdin"); @@ -70,9 +126,10 @@ private async Task RunFfmpegAsync(string inputFilePath, string outputFilePath, C startInfo.ArgumentList.Add(_options.NormalizedChannels.ToString(CultureInfo.InvariantCulture)); startInfo.ArgumentList.Add("-acodec"); startInfo.ArgumentList.Add("pcm_s16le"); + startInfo.ArgumentList.Add("-af"); + startInfo.ArgumentList.Add(TelephoneAudioFilter); startInfo.ArgumentList.Add(outputFilePath); - - await RunProcessAsync(startInfo, null, "ffmpeg", cancellationToken); + return startInfo; } private static ProcessStartInfo CreateStartInfo(string fileName, bool redirectStandardInput = false) @@ -161,5 +218,7 @@ private static void TryKillProcess(Process process) { } } + + private sealed record EspeakInvocation(string Voice, int Speed, int? Pitch, int? WordGap); } } diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index cf916df5..d6626904 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -56,25 +56,70 @@ await ExecuteWithRetryAsync( { return await HandleMalformedMetadataResponseAsync(objectKey, ex, formatException, cancellationToken); } + catch (FormatException ex) + { + return await HandleMalformedMetadataResponseAsync(objectKey, ex, cancellationToken); + } } - private async Task HandleMalformedMetadataResponseAsync( + private Task HandleMalformedMetadataResponseAsync( string objectKey, AmazonUnmarshallingException exception, FormatException formatException, CancellationToken cancellationToken) { - _logger.LogWarning( + return HandleMalformedMetadataResponseAsync( + objectKey, exception, - "The S3 client could not parse the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request. Inner format error: {InnerFormatErrorMessage}", + formatException, + exception.LastKnownLocation ?? "unknown", + cancellationToken, + wrappedByAmazonUnmarshallingException: true); + } + + private Task HandleMalformedMetadataResponseAsync( + string objectKey, + FormatException exception, + CancellationToken cancellationToken) + { + return HandleMalformedMetadataResponseAsync( objectKey, - formatException.Message); + exception, + exception, + "unknown", + cancellationToken, + wrappedByAmazonUnmarshallingException: false); + } + + private async Task HandleMalformedMetadataResponseAsync( + string objectKey, + Exception exception, + FormatException formatException, + string lastKnownLocation, + CancellationToken cancellationToken, + bool wrappedByAmazonUnmarshallingException) + { + if (wrappedByAmazonUnmarshallingException) + { + _logger.LogWarning( + exception, + "The S3 client could not parse the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request. Inner format error: {InnerFormatErrorMessage}", + objectKey, + formatException.Message); + } + else + { + _logger.LogWarning( + exception, + "The S3 client surfaced a raw FormatException while parsing the metadata response for {ObjectKey}. Verifying existence with a presigned HEAD request because the AWS SDK did not wrap the parsing failure in an AmazonUnmarshallingException.", + objectKey); + } _logger.LogDebug( formatException, - "Inner FormatException while parsing the metadata response for {ObjectKey}. Last known location: {LastKnownLocation}.", + "FormatException while parsing the metadata response for {ObjectKey}. Last known location: {LastKnownLocation}.", objectKey, - exception.LastKnownLocation ?? "unknown"); + lastKnownLocation); try { From ebb12c6051d64f4842dcc4e8dd46993b03078fb7 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 11:02:07 -0700 Subject: [PATCH 2/5] RE1-T115 Weather Alert fixes --- Core/Resgrid.Services/CommunicationService.cs | 21 +- Core/Resgrid.Services/WeatherAlertService.cs | 17 +- .../Controllers/v4/MessagesController.cs | 8 +- .../Areas/User/Apps/package-lock.json | 571 ------------------ .../User/Controllers/DispatchController.cs | 12 + .../User/Controllers/MessagesController.cs | 4 + 6 files changed, 52 insertions(+), 581 deletions(-) diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 19fd32e4..c1234eec 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -23,10 +23,11 @@ public class CommunicationService : ICommunicationService private readonly IDepartmentSettingsService _departmentSettingsService; private readonly ISubscriptionsService _subscriptionsService; private readonly IUserStateService _userStateService; + private readonly IDepartmentsService _departmentsService; public CommunicationService(ISmsService smsService, IEmailService emailService, IPushService pushService, IGeoLocationProvider geoLocationProvider, IOutboundVoiceProvider outboundVoiceProvider, IUserProfileService userProfileService, IDepartmentSettingsService departmentSettingsService, - ISubscriptionsService subscriptionsService, IUserStateService userStateService) + ISubscriptionsService subscriptionsService, IUserStateService userStateService, IDepartmentsService departmentsService) { _smsService = smsService; _emailService = emailService; @@ -37,6 +38,7 @@ public CommunicationService(ISmsService smsService, IEmailService emailService, _departmentSettingsService = departmentSettingsService; _subscriptionsService = subscriptionsService; _userStateService = userStateService; + _departmentsService = departmentsService; } public async Task SendMessageAsync(Message message, string sendersName, string departmentNumber, int departmentId, UserProfile profile = null, Department department = null) @@ -50,7 +52,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st if (profile == null && !String.IsNullOrWhiteSpace(message.ReceivingUserId)) profile = await _userProfileService.GetProfileByUserIdAsync(message.ReceivingUserId); - if (profile == null || profile.SendMessageSms) + if (profile == null || (message.SystemGenerated ? profile.SendNotificationSms : profile.SendMessageSms)) { if (profile == null || profile.MobileNumberVerified.IsContactMethodAllowedForSending()) { @@ -66,7 +68,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st } } - if (profile == null || profile.SendMessageEmail) + if (profile == null || (message.SystemGenerated ? profile.SendNotificationEmail : profile.SendMessageEmail)) { if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) { @@ -81,7 +83,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st } } - if (profile == null || profile.SendMessagePush) + if (profile == null || (message.SystemGenerated ? profile.SendNotificationPush : profile.SendMessagePush)) { var spm = new StandardPushMessage(); spm.MessageId = message.MessageId; @@ -684,6 +686,9 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven foreach (var recipient in recipients) { + if (!await CanSendToUser(recipient.UserId, departmentId)) + continue; + // Send a Push Notification if (recipient.SendPush) { @@ -766,6 +771,14 @@ public async Task SendTextMessageAsync(string userId, string title, string private async Task CanSendToUser(string userId, int departmentId) { + // Filter out disabled or deleted users + if (!string.IsNullOrWhiteSpace(userId)) + { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, false); + if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + return false; + } + var supressStaffingInfo = await _departmentSettingsService.GetDepartmentStaffingSuppressInfoAsync(departmentId); var lastUserStaffing = await _userStateService.GetLastUserStateByUserIdAsync(userId); diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index 8ac41945..fc0f1bc2 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -400,7 +400,7 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default) foreach (var member in members) { - if (member.UserId != senderId) + if (member.UserId != senderId && !member.IsDisabled.GetValueOrDefault() && !member.IsDeleted) message.AddRecipient(member.UserId); } @@ -560,6 +560,7 @@ private static string FormatAlertMessageBody(WeatherAlert alert, Department depa { var sb = new System.Text.StringBuilder(); + // Header sb.AppendLine($"WEATHER ALERT: {alert.Event?.ToUpper()}"); sb.AppendLine($"Severity: {SeverityNames[Math.Min(alert.Severity, 4)]}"); @@ -573,18 +574,26 @@ private static string FormatAlertMessageBody(WeatherAlert alert, Department depa sb.AppendLine(); + // Headline as summary if (!string.IsNullOrEmpty(alert.Headline)) + { sb.AppendLine(alert.Headline); + sb.AppendLine(); + } + + // Description — the core alert details + if (!string.IsNullOrEmpty(alert.Description)) + { + sb.AppendLine(alert.Description); + } + // Safety instructions, if provided if (!string.IsNullOrEmpty(alert.Instruction)) { sb.AppendLine(); sb.AppendLine(alert.Instruction); } - sb.AppendLine(); - sb.AppendLine("View active weather alerts for full details."); - var body = sb.ToString(); if (body.Length > 3950) body = body.Substring(0, 3947) + "..."; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs index 312c8b2d..e9c2e7f5 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs @@ -304,6 +304,9 @@ public async Task> SendMessage([FromBody] NewMes { foreach (var departmentMember in departmentUsers) { + if (departmentMember.IsDisabled.GetValueOrDefault() || departmentMember.IsDeleted) + continue; + message.AddRecipient(departmentMember.UserId); } } @@ -319,8 +322,9 @@ public async Task> SendMessage([FromBody] NewMes if (usersToSendTo.All(x => x != userIdToSendTo) && userIdToSendTo != UserId) { - // Ensure the user is in the same department - if (departmentUsers.Any(x => x.UserId == userIdToSendTo)) + // Ensure the user is in the same department and not disabled/deleted + var dm = departmentUsers.FirstOrDefault(x => x.UserId == userIdToSendTo); + if (dm != null && !dm.IsDisabled.GetValueOrDefault() && !dm.IsDeleted) { usersToSendTo.Add(userIdToSendTo); message.AddRecipient(userIdToSendTo); diff --git a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json index 38a5974a..6d420cd7 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/package-lock.json +++ b/Web/Resgrid.Web/Areas/User/Apps/package-lock.json @@ -421,18 +421,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", @@ -1042,77 +1030,6 @@ "node": ">=6.5" } }, - "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.24.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", @@ -1145,14 +1062,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/caniuse-lite": { "version": "1.0.30001703", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz", @@ -1179,32 +1088,6 @@ "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", "license": "ISC" }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -1214,28 +1097,6 @@ "node": ">=6" } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "optional": true, - "peer": true - }, - "node_modules/copy-anything": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz", - "integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "is-what": "^3.14.1" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, "node_modules/csscolorparser": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", @@ -1323,43 +1184,6 @@ "integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA==", "dev": true }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/errno": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1429,20 +1253,6 @@ "node": ">= 4.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1478,61 +1288,17 @@ "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", "license": "MIT" }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/globalize": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/globalize/-/globalize-0.1.1.tgz", "integrity": "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA==" }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/grid-index": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", "license": "ISC" }, - "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -1542,64 +1308,6 @@ "loose-envify": "^1.0.0" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-what": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz", - "integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1641,96 +1349,6 @@ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" }, - "node_modules/less": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", - "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "copy-anything": "^2.0.1", - "parse-node-version": "^1.0.1", - "tslib": "^2.3.0" - }, - "bin": { - "lessc": "bin/lessc" - }, - "engines": { - "node": ">=6" - }, - "optionalDependencies": { - "errno": "^0.1.1", - "graceful-fs": "^4.1.2", - "image-size": "~0.5.0", - "make-dir": "^2.1.0", - "mime": "^1.4.1", - "needle": "^3.1.0", - "source-map": "~0.6.0" - } - }, - "node_modules/less/node_modules/make-dir": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", - "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "pify": "^4.0.1", - "semver": "^5.6.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/less/node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/less/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/less/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1878,38 +1496,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/needle": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", - "integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.3", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -1935,17 +1521,6 @@ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1954,17 +1529,6 @@ "node": ">=0.10.0" } }, - "node_modules/parse-node-version": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", - "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/pbf": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", @@ -2006,14 +1570,6 @@ "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, - "node_modules/prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -2140,34 +1696,6 @@ "node": ">=0.10.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -2231,40 +1759,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "optional": true, - "peer": true - }, - "node_modules/sass": { - "version": "1.71.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.71.1.tgz", - "integrity": "sha512-wovtnV2PxzteLlfNzbgm1tFXPLoZILYAMJtvoXXkD7/+1uP41eKkIt1ypWq5/q2uT94qHjXehEYfmjKOvjL9sg==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2289,29 +1783,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/splaytree": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", @@ -2327,59 +1798,17 @@ "kdbush": "^4.0.2" } }, - "node_modules/terser": { - "version": "5.29.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.29.1.tgz", - "integrity": "sha512-lZQ/fyaIGxsbGxApKmoPTODIzELy3++mXhS5hOqaAWZjQtpq/hFHAc+rm29NND1rYRxRWKcjuARNwULNXa5RtQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tinyqueue": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "optional": true, - "peer": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index bda67e2e..c846ea67 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -318,6 +318,10 @@ public async Task NewCall(NewCallView model, IFormCollection coll { foreach (var userId in dispatchingUserIds) { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId, false); + if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + continue; + CallDispatch cd = new CallDispatch(); cd.UserId = userId; @@ -672,6 +676,10 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio foreach (var userId in dispatchingUserIds) { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId, false); + if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + continue; + if (!call.Dispatches.Any(x => x.UserId == userId)) { CallDispatch cd = new CallDispatch(); @@ -1181,6 +1189,10 @@ public async Task AddArchivedCall(NewCallView model, IFormCollect foreach (var userId in dispatchingUserIds) { + var member = await _departmentsService.GetDepartmentMemberAsync(userId, DepartmentId, false); + if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + continue; + CallDispatch cd = new CallDispatch(); cd.UserId = userId; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs index 3cf295a9..9f66e5bf 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs @@ -180,6 +180,10 @@ public async Task Compose(ComposeMessageModel model, IFormCollect { foreach (var user in users) { + var member = await _departmentsService.GetDepartmentMemberAsync(user, DepartmentId, false); + if (member == null || member.IsDisabled.GetValueOrDefault() || member.IsDeleted) + continue; + model.Message.AddRecipient(user); } From 76c9c33917e65c605ea593a9f98f0e7615d6f152 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 11:55:16 -0700 Subject: [PATCH 3/5] RE1-T115 PR#359 fixes --- Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs | 6 ++++++ Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs | 9 +++++++++ Web/Resgrid.Web.Tts/Services/S3StorageService.cs | 7 ------- Web/Resgrid.Web.Tts/Services/TtsService.cs | 3 ++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index b81a73ff..f3a35a87 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -47,6 +47,12 @@ public async Task GenerateNormalizedWavAsync(string text, string voice, } } + public (string Voice, int Speed) GetEffectiveSynthesisProfile(string voice, int speed) + { + var invocation = GetEspeakInvocation(voice, speed); + return (invocation.Voice, invocation.Speed); + } + private async Task RunEspeakAsync(string text, string voice, int speed, string outputFilePath, CancellationToken cancellationToken) { var startInfo = CreateEspeakStartInfo(voice, speed, outputFilePath); diff --git a/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs index 2ae39524..37c03a16 100644 --- a/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/IAudioProcessingService.cs @@ -3,5 +3,14 @@ namespace Resgrid.Web.Tts.Services public interface IAudioProcessingService { Task GenerateNormalizedWavAsync(string text, string voice, int speed, CancellationToken cancellationToken); + + /// + /// Returns the voice and speed that will actually be used for synthesis, + /// after normalization (e.g. English voices are remapped to a fixed + /// MBROLA telephony profile regardless of the requested voice/speed). + /// This allows cache keys to be derived from the effective synthesis + /// profile rather than the original request parameters. + /// + (string Voice, int Speed) GetEffectiveSynthesisProfile(string voice, int speed); } } diff --git a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs index d6626904..1845e54e 100644 --- a/Web/Resgrid.Web.Tts/Services/S3StorageService.cs +++ b/Web/Resgrid.Web.Tts/Services/S3StorageService.cs @@ -226,13 +226,6 @@ private async Task WasUploadPersistedAsync(string objectKey, CancellationT "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure. Falling back to a presigned PUT upload.", objectKey); } - catch (FormatException ex) - { - _logger.LogWarning( - ex, - "Unable to verify whether {ObjectKey} exists after the PUT response parsing failure because the metadata response could not be parsed. Falling back to a presigned PUT upload.", - objectKey); - } catch (HttpRequestException ex) { _logger.LogWarning( diff --git a/Web/Resgrid.Web.Tts/Services/TtsService.cs b/Web/Resgrid.Web.Tts/Services/TtsService.cs index 2db62ce0..f2eef0c5 100644 --- a/Web/Resgrid.Web.Tts/Services/TtsService.cs +++ b/Web/Resgrid.Web.Tts/Services/TtsService.cs @@ -89,7 +89,8 @@ public async Task WarmPromptsAsync(CancellationToken cancellationToken) private async Task GenerateInternalAsync(NormalizedTtsRequest request, CancellationToken cancellationToken) { - var cacheKey = _cacheService.CreateCacheKey(request.Text, request.Voice, request.Speed); + var effectiveProfile = _audioProcessingService.GetEffectiveSynthesisProfile(request.Voice, request.Speed); + var cacheKey = _cacheService.CreateCacheKey(request.Text, effectiveProfile.Voice, effectiveProfile.Speed); var cachedUrl = await _cacheService.TryGetCachedUrlAsync(cacheKey, cancellationToken); if (cachedUrl is not null) From 8dc3e52f84b4f83afb292738e278faf1997dfd28 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 12:38:23 -0700 Subject: [PATCH 4/5] RE1-T115 PR#359 fixes --- .../Resgrid.Tests/Services/CommunicationServiceTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index ef78d3e2..4f16cc88 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -26,6 +26,7 @@ public class with_the_communication_service : TestBase protected Mock _departmentSettingsServiceMock; protected Mock _subscriptionsServiceMock; protected Mock _userStateServiceMock; + protected Mock _departmentsServiceMock; protected ICommunicationService _communicationService; @@ -40,10 +41,16 @@ protected with_the_communication_service() _departmentSettingsServiceMock = new Mock(); _subscriptionsServiceMock = new Mock(); _userStateServiceMock = new Mock(); + _departmentsServiceMock = new Mock(); + + // CanSendToUser requires a valid DepartmentMember for the user to proceed. + _departmentsServiceMock + .Setup(x => x.GetDepartmentMemberAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new DepartmentMember()); _communicationService = new CommunicationService(_smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object, _geoLocationProviderMock.Object, _outboundVoiceProviderMock.Object, _userProfileServiceMock.Object, _departmentSettingsServiceMock.Object, - _subscriptionsServiceMock.Object, _userStateServiceMock.Object); + _subscriptionsServiceMock.Object, _userStateServiceMock.Object, _departmentsServiceMock.Object); } } From c55df3aa537249593c4d429b3a907eaef5d9106e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 3 May 2026 13:34:52 -0700 Subject: [PATCH 5/5] RE1-T115 PR#359 fixes --- .../Resgrid.Tests/Web/Tts/TtsServiceTests.cs | 23 +- .../Resgrid.Web.Services.xml | 1114 ++++++++--------- 2 files changed, 576 insertions(+), 561 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index a7214c82..e1959d2b 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -48,8 +48,11 @@ public async Task generate_async_should_return_cached_response_without_generatin { var cachedUri = new Uri("https://cdn.example.com/tts/abc123.wav"); + _audioProcessingService + .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) + .Returns(("mb-us1", 130)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -78,8 +81,11 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss var audioBytes = new byte[] { 1, 2, 3, 4 }; var objectUri = new Uri("https://cdn.example.com/tts/abc123.wav"); + _audioProcessingService + .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) + .Returns(("mb-us1", 130)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130)) .Returns(CacheKey); _cacheService .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -113,6 +119,9 @@ public async Task generate_async_should_apply_configured_klatt_variant_to_reques var cachedUri = new Uri("https://cdn.example.com/tts/xyz789.wav"); var cacheKey = new TtsCacheKey("xyz789", "tts/xyz789.wav"); + _audioProcessingService + .Setup(x => x.GetEffectiveSynthesisProfile("fr+klatt4", 165)) + .Returns(("fr+klatt4", 165)); _cacheService .Setup(x => x.CreateCacheKey("Bonjour", "fr+klatt4", 165)) .Returns(cacheKey); @@ -138,8 +147,11 @@ public async Task generate_async_should_replace_legacy_default_voices_with_confi var cachedUri = new Uri("https://cdn.example.com/tts/legacy.wav"); var cacheKey = new TtsCacheKey("legacy", "tts/legacy.wav"); + _audioProcessingService + .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) + .Returns(("mb-us1", 130)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130)) .Returns(cacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) @@ -174,8 +186,11 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th var allowGenerationCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var cacheLookupCount = 0; + _audioProcessingService + .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) + .Returns(("mb-us1", 130)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en-us+klatt4", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "mb-us1", 130)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 526ac72e..15601e50 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -3573,52 +3573,6 @@ Is the user a group admin - - - UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a - function that is setting status for the current user. - - - - - The state/staffing level of the user to set for the user. - - - - - Note for the staffing level - - - - - The result object for a state/staffing level request. - - - - - The UserId GUID/UUID for the user state/staffing level being return - - - - - The full name of the user for the state/staffing level being returned - - - - - The current staffing level (state) type for the user - - - - - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. - - - - - Staffing note for the User's staffing - - Input data to add a staffing schedule in the Resgrid system @@ -3724,6 +3678,52 @@ Note for this staffing schedule + + + UserId (GUID/UUID) of the User to set. This field will be ignored if the input is used on a + function that is setting status for the current user. + + + + + The state/staffing level of the user to set for the user. + + + + + Note for the staffing level + + + + + The result object for a state/staffing level request. + + + + + The UserId GUID/UUID for the user state/staffing level being return + + + + + The full name of the user for the state/staffing level being returned + + + + + The current staffing level (state) type for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Staffing note for the User's staffing + + A resrouce in the system this could be a user or unit @@ -7508,379 +7508,209 @@ Identifier of the new npte - + - The result of getting all personnel filters for the system + A GPS location for a point in time of a specificed person - + - The Id value of the filter + PersonId of the person that the location is for - + - The type of the filter + The timestamp of the location in UTC - + - The filters name + GPS Latitude of the Person - + - Result containing all the data required to populate the New Call form + GPS Longitude of the Person - + - Response Data + GPS Latitude\Longitude Accuracy of the Person - + - Result that contains all the options available to filter personnel against compatible Resgrid APIs + GPS Altitude of the Person - + - Response Data + GPS Altitude Accuracy of the Person - + - Result containing all the data required to populate the New Call form + GPS Speed of the Person - + - Response Data + GPS Heading of the Person - + - Information about a User + A unit location in the Resgrid system - + - The UserId GUID/UUID for the user + Response Data - + - DepartmentId of the deparment the user belongs to + The information about a specific unit's location - + - Department specificed ID number for this user + Id of the Person - + - The Users First Name + The Timestamp for the location in UTC - + - The Users Last Name + GPS Latitude of the Person - + - The Users Email Address + GPS Longitude of the Person - + - The Users Mobile Telephone Number + GPS Latitude\Longitude Accuracy of the Person - + - GroupId the user is assigned to (0 for no group) + GPS Altitude of the Person - + - Name of the group the user is assigned to + GPS Altitude Accuracy of the Person - + - Enumeration/List of roles the user currently holds + GPS Speed of the Person - + - The current action/status type for the user + GPS Heading of the Person - + - The current action/status string for the user + The result of getting the current staffing for a user - + - The current action/status color hex string for the user + Response Data - + - The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + Information about a User staffing - + - The current action/status destination id for the user + The UserId GUID/UUID for the user status being return - + - The current action/status destination name for the user + DepartmentId of the deparment the user belongs to - + - The current staffing level (state) type for the user + The current staffing type for the user - + - The current staffing level (state) string for the user + The timestamp of the last staffing. This is converted UTC version of the timestamp. - + - The current staffing level (state) color hex string for the user + The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - + - The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + Note for this staffing - + - Users last known location + Saves (sets) and Personnel Staffing in the system, for a single user - + - Sorting weight for the user + UnitId of the apparatus that the state is being set for - + - User Defined Field values for this personnel record + The UnitStateType of the Unit - + - A GPS location for a point in time of a specificed person + The timestamp of the status event in UTC - + - PersonId of the person that the location is for + The timestamp of the status event in the local time of the device - + - The timestamp of the location in UTC + User provided note for this event - + - GPS Latitude of the Person + The event id used for queuing on mobile applications - + - GPS Longitude of the Person + Depicts a result after saving a person status - + - GPS Latitude\Longitude Accuracy of the Person + Response Data - + - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - A unit location in the Resgrid system - - - - - Response Data - - - - - The information about a specific unit's location - - - - - Id of the Person - - - - - The Timestamp for the location in UTC - - - - - GPS Latitude of the Person - - - - - GPS Longitude of the Person - - - - - GPS Latitude\Longitude Accuracy of the Person - - - - - GPS Altitude of the Person - - - - - GPS Altitude Accuracy of the Person - - - - - GPS Speed of the Person - - - - - GPS Heading of the Person - - - - - The result of getting the current staffing for a user - - - - - Response Data - - - - - Information about a User staffing - - - - - The UserId GUID/UUID for the user status being return - - - - - DepartmentId of the deparment the user belongs to - - - - - The current staffing type for the user - - - - - The timestamp of the last staffing. This is converted UTC version of the timestamp. - - - - - The timestamp of the last staffing. This is converted UTC to the departments, or users, TimeZone. - - - - - Note for this staffing - - - - - Saves (sets) and Personnel Staffing in the system, for a single user - - - - - UnitId of the apparatus that the state is being set for - - - - - The UnitStateType of the Unit - - - - - The timestamp of the status event in UTC - - - - - The timestamp of the status event in the local time of the device - - - - - User provided note for this event - - - - - The event id used for queuing on mobile applications - - - - - Depicts a result after saving a person status - - - - - Response Data - - - - - Saves (sets) and Personnel Status in the system, for a single user + Saves (sets) and Personnel Status in the system, for a single user @@ -8181,112 +8011,282 @@ Response Data - + - Result containing all the data required to populate the New Call form + The result of getting all personnel filters for the system - + - Response Data + The Id value of the filter - + - Details of a protocol + The type of the filter - + - Protocol id + The filters name - + - Department id + Result containing all the data required to populate the New Call form - + - Name of the Protocol + Response Data - + - Protocol code + Result that contains all the options available to filter personnel against compatible Resgrid APIs - + - This this protocol disabled + Response Data - + - Protocol description + Result containing all the data required to populate the New Call form - + - Text of the protocol + Response Data - + - UTC date and time when the Protocol was created + Information about a User - + - UserId of the user who created the protocol + The UserId GUID/UUID for the user - + - UTC timestamp of when the Protocol was updated + DepartmentId of the deparment the user belongs to - + - Minimum triggering Weight of the Protocol + Department specificed ID number for this user - + - UserId that last updated the Protocol + The Users First Name - + - Triggers used to activate this Protocol + The Users Last Name - + - Attachments for this Protocol + The Users Email Address - + - Questions used to determine if this Protocol needs to be used or not + The Users Mobile Telephone Number - + - State type + GroupId the user is assigned to (0 for no group) - + - Result containing all the data required to populate the New Call form + Name of the group the user is assigned to - + - Response Data + Enumeration/List of roles the user currently holds - + + + The current action/status type for the user + + + + + The current action/status string for the user + + + + + The current action/status color hex string for the user + + + + + The timestamp of the last action. This is converted UTC to the departments, or users, TimeZone. + + + + + The current action/status destination id for the user + + + + + The current action/status destination name for the user + + + + + The current staffing level (state) type for the user + + + + + The current staffing level (state) string for the user + + + + + The current staffing level (state) color hex string for the user + + + + + The timestamp of the last state/staffing level. This is converted UTC to the departments, or users, TimeZone. + + + + + Users last known location + + + + + Sorting weight for the user + + + + + User Defined Field values for this personnel record + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + + + Details of a protocol + + + + + Protocol id + + + + + Department id + + + + + Name of the Protocol + + + + + Protocol code + + + + + This this protocol disabled + + + + + Protocol description + + + + + Text of the protocol + + + + + UTC date and time when the Protocol was created + + + + + UserId of the user who created the protocol + + + + + UTC timestamp of when the Protocol was updated + + + + + Minimum triggering Weight of the Protocol + + + + + UserId that last updated the Protocol + + + + + Triggers used to activate this Protocol + + + + + Attachments for this Protocol + + + + + Questions used to determine if this Protocol needs to be used or not + + + + + State type + + + + + Result containing all the data required to populate the New Call form + + + + + Response Data + + + A role in the Resgrid system @@ -9480,545 +9480,545 @@ Default constructor - + - Result that contains all the options available to filter units against compatible Resgrid APIs + Depicts a result after saving a unit status - + Response Data - + - A unit in the Resgrid system + Object inputs for setting a users Status/Action. If this object is used in an operation that sets + a status for the current user the UserId value in this object will be ignored. - + - Response Data + UnitId of the apparatus that the state is being set for - + - The information about a specific unit + The UnitStateType of the Unit - + - Id of the Unit + The Call/Station the unit is responding to - + - The Id of the department the unit is under + Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). - + - Name of the Unit + The timestamp of the status event in UTC - + - Department assigned type for the unit + The timestamp of the status event in the local time of the device - + - Department assigned type id for the unit + User provided note for this event - + - Custom Statuses Set Id + GPS Latitude of the Unit - + - Station Id of the station housing the unit (0 means no station) + GPS Longitude of the Unit - + - Name of the station the unit is under + GPS Latitude\Longitude Accuracy of the Unit - + - Vehicle Identification Number for the unit + GPS Altitude of the Unit - + - Plate Number for the Unit + GPS Altitude Accuracy of the Unit - + - Is the unit 4-Wheel drive + GPS Speed of the Unit - + - Does the unit require a special permit to drive + GPS Heading of the Unit - + - Id number of the units current destionation (0 means no destination) + The event id used for queuing on mobile applications - + - The current status/state of the Unit + The accountability roles filed for this event - + - The Timestamp of the status + Role filled by a User on a Unit for an event - + - The units current Latitude + Id of the locally stored event - + - The units current Longitude + Local Event Id - + - Current user provide status note + UserId of the user filling the role - + - User Defined Field values for this unit + RoleId of the role being filled - + - Unit role information for roles on a unit + The name of the Role - + - Unit Role Id + Depicts a unit status in the Resgrid system. - + - User Id of the user in the role (could be null) + Response Data - + - Name of the Role + Depicts a unit's status - + - Name of the user in the role (could be null) + Unit Id - + - Multiple Unit infos Result + Units Name - + - Response Data + The Type of the Unit - + - Default constructor + Units current Status (State) - + - The information about a specific unit + CSS for status (for display) - + - Id of the Unit + CSS Style for status (for display) - + - The Id of the department the unit is under + Timestamp of this Unit State - + - Name of the Unit + Timestamp in Utc of this Unit State - + - Department assigned type for the unit + Destination Id (Station or Call) - + - Department assigned type id for the unit + Destination type (Station, Call, or POI). - + - Custom Statuses Set Id + Name of the Desination (Call or Station) - + - Station Id of the station housing the unit (0 means no station) + Destination address. - + - Name of the station the unit is under + Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not + suitable for programmatic branching; use as the + machine-readable discriminator instead. - + - Vehicle Identification Number for the unit + Note for the State - + - Plate Number for the Unit + Latitude - + - Is the unit 4-Wheel drive + Longitude - + - Does the unit require a special permit to drive + Name of the Group the Unit is in - + - Id number of the units current destination (0 means no destination) + Id of the Group the Unit is in - + - Name of the units current destination (0 means no destination) + Unit statuses (states) - + - The current status/state of the Unit + Response Data - + - The current status/state of the Unit as a name + Default constructor - + - The current status/state of the Unit color + Result that contains all the options available to filter units against compatible Resgrid APIs - + - The Timestamp of the status + Response Data - + - The Timestamp of the status in UTC/GMT + A unit in the Resgrid system - + - The units current Latitude + Response Data - + - The units current Longitude + The information about a specific unit - + - Current user provide status note + Id of the Unit - + - Units Roles + The Id of the department the unit is under - + - Multiple Units Result + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Default constructor + Department assigned type id for the unit - + - Depicts a result after saving a unit status + Custom Statuses Set Id - + - Response Data + Station Id of the station housing the unit (0 means no station) - + - Object inputs for setting a users Status/Action. If this object is used in an operation that sets - a status for the current user the UserId value in this object will be ignored. + Name of the station the unit is under - + - UnitId of the apparatus that the state is being set for + Vehicle Identification Number for the unit - + - The UnitStateType of the Unit + Plate Number for the Unit - + - The Call/Station the unit is responding to + Is the unit 4-Wheel drive - + - Destination type for RespondingTo (Station = 1, Call = 2, POI = 3). + Does the unit require a special permit to drive - + - The timestamp of the status event in UTC + Id number of the units current destionation (0 means no destination) - + - The timestamp of the status event in the local time of the device + The current status/state of the Unit - + - User provided note for this event + The Timestamp of the status - + - GPS Latitude of the Unit + The units current Latitude - + - GPS Longitude of the Unit + The units current Longitude - + - GPS Latitude\Longitude Accuracy of the Unit + Current user provide status note - + - GPS Altitude of the Unit + User Defined Field values for this unit - + - GPS Altitude Accuracy of the Unit + Unit role information for roles on a unit - + - GPS Speed of the Unit + Unit Role Id - + - GPS Heading of the Unit + User Id of the user in the role (could be null) - + - The event id used for queuing on mobile applications + Name of the Role - + - The accountability roles filed for this event + Name of the user in the role (could be null) - + - Role filled by a User on a Unit for an event + Multiple Unit infos Result - + - Id of the locally stored event + Response Data - + - Local Event Id + Default constructor - + - UserId of the user filling the role + The information about a specific unit - + - RoleId of the role being filled + Id of the Unit - + - The name of the Role + The Id of the department the unit is under - + - Depicts a unit status in the Resgrid system. + Name of the Unit - + - Response Data + Department assigned type for the unit - + - Depicts a unit's status + Department assigned type id for the unit - + - Unit Id + Custom Statuses Set Id - + - Units Name + Station Id of the station housing the unit (0 means no station) - + - The Type of the Unit + Name of the station the unit is under - + - Units current Status (State) + Vehicle Identification Number for the unit - + - CSS for status (for display) + Plate Number for the Unit - + - CSS Style for status (for display) + Is the unit 4-Wheel drive - + - Timestamp of this Unit State + Does the unit require a special permit to drive - + - Timestamp in Utc of this Unit State + Id number of the units current destination (0 means no destination) - + - Destination Id (Station or Call) + Name of the units current destination (0 means no destination) - + - Destination type (Station, Call, or POI). + The current status/state of the Unit - + - Name of the Desination (Call or Station) + The current status/state of the Unit as a name - + - Destination address. + The current status/state of the Unit color - + - Localized display label for the destination type (e.g. "Station", "Call", "POI"). Not - suitable for programmatic branching; use as the - machine-readable discriminator instead. + The Timestamp of the status - + - Note for the State + The Timestamp of the status in UTC/GMT - + - Latitude + The units current Latitude - + - Longitude + The units current Longitude - + - Name of the Group the Unit is in + Current user provide status note - + - Id of the Group the Unit is in + Units Roles - + - Unit statuses (states) + Multiple Units Result - + Response Data - + Default constructor