diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs index 139966e2..91a72096 100644 --- a/Core/Resgrid.Config/TtsConfig.cs +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -25,7 +25,7 @@ public static class TtsConfig public static string S3PublicBaseUrl = ""; public static string DefaultVoice = "en-us+klatt4"; - public static int DefaultSpeed = 165; + public static int DefaultSpeed = 150; public static int MaxConcurrentGenerations = 4; public static int MaxTextLength = 1000; public static string PiperExecutable = "piper"; diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index 569bd813..4e6c2470 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -253,7 +253,9 @@ public void create_piper_start_info_should_use_english_model_for_english_voices( "--output_file", "/tmp/raw.wav", "--length-scale", - "1.06"); + "1.06", + "--sentence-silence", + "0.0"); } [Test] @@ -272,7 +274,9 @@ public void create_piper_start_info_should_fallback_to_default_model_for_unmappe "--output_file", "/tmp/raw.wav", "--length-scale", - "1.06"); + "1.06", + "--sentence-silence", + "0.0"); } [Test] @@ -290,7 +294,9 @@ public void create_piper_start_info_should_adjust_length_scale_for_speed() "--output_file", "/tmp/raw.wav", "--length-scale", - "0.50"); + "0.50", + "--sentence-silence", + "0.0"); } [Test] 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/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index 48dbafc3..896765ea 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -8,7 +8,7 @@ public sealed class TtsOptions public string DefaultVoice { get; set; } = "en-us+klatt4"; [Range(80, 450)] - public int DefaultSpeed { get; set; } = 165; + public int DefaultSpeed { get; set; } = 150; [Range(1, 64)] public int MaxConcurrentGenerations { get; set; } = 4; diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index 9e972b58..f02a2f63 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -163,6 +163,8 @@ private ProcessStartInfo CreatePiperStartInfo(string voice, int speed, string ou startInfo.ArgumentList.Add(outputFilePath); startInfo.ArgumentList.Add("--length-scale"); startInfo.ArgumentList.Add(invocation.LengthScale.ToString("0.00", CultureInfo.InvariantCulture)); + startInfo.ArgumentList.Add("--sentence-silence"); + startInfo.ArgumentList.Add("0.0"); return startInfo; } diff --git a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs index a1baf20b..344c3f8b 100644 --- a/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs +++ b/Web/Resgrid.Web.Tts/Services/TextPreprocessor.cs @@ -179,10 +179,10 @@ public sealed partial class TextPreprocessor : ITextPreprocessor // ... // }; + 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 readonly ILogger _logger; public TextPreprocessor(ILogger logger) @@ -199,33 +199,40 @@ public string Preprocess(string text, string voice) var result = text.Trim(); - // Only preprocess English voices — other languages need their - // own abbreviation dictionaries. - if (!IsEnglishVoice(voice)) + // Only preprocess English voices — they need their own abbreviation + // dictionaries. Other languages pass through to Piper directly. + if (IsEnglishVoice(voice)) { - return result; + var original = result; + + // Order matters: expand abbreviations first so downstream + // passes operate on natural-language words rather than codes. + result = ExpandAbbreviations(result); + result = ExpandDispatchShorthand(result); + result = ExpandSlashNotation(result); + result = ExpandAddressAbbreviations(result); + result = ExpandUnitIdentifiers(result); + result = ExpandLongNumbers(result); + result = NormalizeSmallNumbers(result); + // Collapse any whitespace artefacts introduced by expansion. + result = WhitespaceRegex.Replace(result, " ").Trim(); + + if (!string.Equals(original, result, StringComparison.Ordinal)) + { + _logger.LogDebug( + "TextPreprocessor normalised \"{OriginalText}\" to \"{NormalisedText}\"", + original, + result); + } } - var original = result; - - // Order matters: expand abbreviations first so downstream - // passes operate on natural-language words rather than codes. - result = ExpandAbbreviations(result); - result = ExpandDispatchShorthand(result); - result = ExpandSlashNotation(result); - result = ExpandAddressAbbreviations(result); - result = ExpandUnitIdentifiers(result); - result = NormalizeSmallNumbers(result); - - // Collapse any whitespace artefacts introduced by expansion. - result = WhitespaceRegex.Replace(result, " ").Trim(); - - if (!string.Equals(original, result, StringComparison.Ordinal)) + // Ensure the text ends with sentence-ending punctuation so that + // Piper does not hallucinate extra speech past the intended end. + // Without a clear sentence boundary, neural TTS models may + // continue generating audio that sounds like additional words. + if (result.Length > 0 && result[^1] is not '.' and not '!' and not '?') { - _logger.LogDebug( - "TextPreprocessor normalised \"{OriginalText}\" to \"{NormalisedText}\"", - original, - result); + result += "."; } return result; @@ -375,6 +382,27 @@ private static bool IsEnglishVoice(string voice) || string.Equals(baseVoice, "mb-us1", StringComparison.OrdinalIgnoreCase); } + // --------------------------------------------------------------- + // Long number expansion for clarity + // --------------------------------------------------------------- + + /// + /// Converts digit sequences of 4 or more consecutive digits into + /// individual space-separated digits so that Piper reads them as + /// digit-by-digit (e.g. "12345" → "1 2 3 4 5") rather than as + /// a large composite number ("twelve thousand three hundred forty-five"). + /// This is critical for address numbers, call IDs, and other dispatch + /// identifiers where digit-by-digit reading is significantly clearer. + /// + private static string ExpandLongNumbers(string text) + { + return LongNumberRegex.Replace(text, match => + { + var digits = match.Groups[1].Value; + return match.Value.Replace(digits, string.Join(" ", digits.ToCharArray())); + }); + } + // --------------------------------------------------------------- // Source-generated regex helpers // --------------------------------------------------------------- @@ -387,6 +415,10 @@ private static bool IsEnglishVoice(string voice) [GeneratedRegex(@"\b(?(?:[1-9]|1[0-9]|20))\s(?[A-Za-z])", RegexOptions.CultureInvariant)] private static partial Regex NumberToWordRegex(); + /// Matches a run of 4+ consecutive digits not surrounded by other digits. + [GeneratedRegex(@"(?\d{4,})(?!\d)", RegexOptions.CultureInvariant)] + private static partial Regex LongNumberExpandoRegex(); + /// Collapses multiple whitespace characters into a single space. [GeneratedRegex(@"\s+")] private static partial Regex WhitespaceExpandoRegex();