diff --git a/Core/Resgrid.Config/TtsConfig.cs b/Core/Resgrid.Config/TtsConfig.cs index 91a72096a..61b436326 100644 --- a/Core/Resgrid.Config/TtsConfig.cs +++ b/Core/Resgrid.Config/TtsConfig.cs @@ -32,7 +32,7 @@ public static class TtsConfig public static string PiperModelDirectory = "/usr/local/share/piper-voices"; public static string FfmpegExecutable = "ffmpeg"; public static string TempDirectory = ""; - public static string CachePrefix = "tts"; + public static string CachePrefix = "tts2"; public static int NormalizedSampleRate = 8000; public static int NormalizedChannels = 1; public static bool WarmupEnabled = true; @@ -73,8 +73,8 @@ public static class TtsConfig "Thank you. Your response has been recorded." }); - public static int RateLimitPermitLimit = 60; - public static int RateLimitQueueLimit = 10; + public static int RateLimitPermitLimit = 600; + public static int RateLimitQueueLimit = 60; public static int RateLimitWindowSeconds = 60; } } \ No newline at end of file diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs index 5103f1638..df62825f7 100644 --- a/Core/Resgrid.Services/DepartmentSettingsService.cs +++ b/Core/Resgrid.Services/DepartmentSettingsService.cs @@ -304,6 +304,9 @@ public async Task IsTestingEnabledForDepartmentAsync(int departmentId) public async Task GetMapCenterCoordinatesAsync(Department department) { + if (department == null) + return new Coordinates() { Latitude = 39.14086268299356, Longitude = -119.7583809782715 }; + var address = await GetBigBoardCenterAddressDepartmentAsync(department.DepartmentId); var gpsCoordinates = await GetBigBoardCenterGpsCoordinatesDepartmentAsync(department.DepartmentId); diff --git a/Core/Resgrid.Services/TtsAudioService.cs b/Core/Resgrid.Services/TtsAudioService.cs index 62f6c6192..47a9b4284 100644 --- a/Core/Resgrid.Services/TtsAudioService.cs +++ b/Core/Resgrid.Services/TtsAudioService.cs @@ -13,6 +13,10 @@ namespace Resgrid.Services public class TtsAudioService : ITtsAudioService { private const string AdminKeyHeaderName = "X-Resgrid-Admin-Key"; + // Retries are bounded (worst case ~1.2s of delay) so Twilio voice webhooks that + // generate audio inline still respond well within Twilio's timeout. + private const int MaxTransientRetries = 2; + private const int BaseRetryDelayMilliseconds = 300; private readonly Func _restClientFactory; public TtsAudioService(Func restClientFactory) @@ -39,7 +43,9 @@ public async Task GenerateSpeechUrlAsync(string text, string voice = null, Speed = speed ?? TtsConfig.DefaultSpeed }); - var response = await restClient.ExecuteAsync(request, cancellationToken); + var response = await ExecuteWithTransientRetryAsync( + () => restClient.ExecuteAsync(request, cancellationToken), + cancellationToken); if (!response.IsSuccessful || response.Data == null || string.IsNullOrWhiteSpace(response.Data.Url)) throw CreateRequestFailure("generate speech audio", response); @@ -74,12 +80,54 @@ public async Task RegenerateStaticPromptsAsync(IEnumerable prompts, Canc Prompts = promptRequests }); - var response = await restClient.ExecuteAsync(request, cancellationToken); + var response = await ExecuteWithTransientRetryAsync( + () => restClient.ExecuteAsync(request, cancellationToken), + cancellationToken); if (!response.IsSuccessful) throw CreateRequestFailure("regenerate static prompts", response); } + private static async Task ExecuteWithTransientRetryAsync( + Func> sendAsync, + CancellationToken cancellationToken) where TResponse : RestResponse + { + TResponse response = null; + + for (var attempt = 0; attempt <= MaxTransientRetries; attempt++) + { + if (attempt > 0) + { + // 300ms then 900ms, with ±25% jitter so retries from a dispatch + // fan-out don't land on the rate-limit window edge in lockstep. + var delayMilliseconds = BaseRetryDelayMilliseconds * Math.Pow(3, attempt - 1) + * (0.75 + (Random.Shared.NextDouble() * 0.5)); + await Task.Delay(TimeSpan.FromMilliseconds(delayMilliseconds), cancellationToken); + } + + response = await sendAsync(); + + if (!IsTransientFailure(response)) + return response; + } + + return response; + } + + private static bool IsTransientFailure(RestResponse response) + { + if (response.IsSuccessful) + return false; + + if (response.ErrorException is OperationCanceledException or TaskCanceledException) + return false; + + // 429 (rate limited), any 5xx, or no response at all (network failure). + return response.StatusCode == HttpStatusCode.TooManyRequests + || (int)response.StatusCode >= 500 + || response.StatusCode == 0; + } + private static Exception CreateRequestFailure(string operation, RestResponse response) { if (response.ErrorException is OperationCanceledException or TaskCanceledException) diff --git a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs index b64d7dc04..104904dfd 100644 --- a/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs +++ b/Tests/Resgrid.Tests/Web/Tts/TtsServiceTests.cs @@ -51,9 +51,9 @@ public async Task generate_async_should_return_cached_response_without_generatin _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-norman-medium.onnx", 165)); + .Returns(("en_US-ryan-high.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-norman-medium.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -84,9 +84,9 @@ public async Task generate_async_should_generate_and_store_audio_when_cache_miss _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-norman-medium.onnx", 165)); + .Returns(("en_US-ryan-high.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-norman-medium.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) .Returns(CacheKey); _cacheService .SetupSequence(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -150,9 +150,9 @@ public async Task generate_async_should_replace_legacy_default_voices_with_confi _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-norman-medium.onnx", 165)); + .Returns(("en_US-ryan-high.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-norman-medium.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) .Returns(cacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(cacheKey, It.IsAny())) @@ -189,9 +189,9 @@ public async Task generate_async_should_deduplicate_concurrent_generation_for_th _audioProcessingService .Setup(x => x.GetEffectiveSynthesisProfile("en-us+klatt4", 165)) - .Returns(("en_US-norman-medium.onnx", 165)); + .Returns(("en_US-ryan-high.onnx", 165)); _cacheService - .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-norman-medium.onnx", 165)) + .Setup(x => x.CreateCacheKey("Press 1 for yes", "en_US-ryan-high.onnx", 165)) .Returns(CacheKey); _cacheService .Setup(x => x.TryGetCachedUrlAsync(CacheKey, It.IsAny())) @@ -249,13 +249,17 @@ public void create_piper_start_info_should_use_english_model_for_english_voices( startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-norman-medium.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", "1.06", "--sentence-silence", - "0.0"); + "0.35", + "--noise-scale", + "0.333", + "--noise-w", + "0.4"); } [Test] @@ -270,13 +274,17 @@ public void create_piper_start_info_should_fallback_to_default_model_for_unmappe startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-norman-medium.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", "1.06", "--sentence-silence", - "0.0"); + "0.35", + "--noise-scale", + "0.333", + "--noise-w", + "0.4"); } [Test] @@ -290,13 +298,17 @@ public void create_piper_start_info_should_adjust_length_scale_for_speed() startInfo.FileName.Should().Be("piper"); startInfo.ArgumentList.Should().Equal( "--model", - Path.Combine("/usr/local/share/piper-voices", "en_US-norman-medium.onnx"), + Path.Combine("/usr/local/share/piper-voices", "en_US-ryan-high.onnx"), "--output_file", "/tmp/raw.wav", "--length-scale", "0.50", "--sentence-silence", - "0.0"); + "0.35", + "--noise-scale", + "0.333", + "--noise-w", + "0.4"); } [Test] @@ -321,7 +333,7 @@ public void create_ffmpeg_start_info_should_apply_the_requested_telephone_filter "-acodec", "pcm_mulaw", "-af", - "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1", + "highpass=f=200, lowpass=f=3400, anequalizer=c0 f=2500 w=1000 g=3 t=1, loudnorm=I=-16:TP=-1.5:LRA=11", "/tmp/normalized.wav"); } diff --git a/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs b/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs index 919c81b87..0edaa9a66 100644 --- a/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/RateLimitOptions.cs @@ -5,10 +5,10 @@ namespace Resgrid.Web.Tts.Configuration public sealed class RateLimitOptions { [Range(1, 10000)] - public int PermitLimit { get; set; } = 60; + public int PermitLimit { get; set; } = 600; [Range(0, 1000)] - public int QueueLimit { get; set; } = 10; + public int QueueLimit { get; set; } = 60; [Range(1, 3600)] public int WindowSeconds { get; set; } = 60; diff --git a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs index d2414175e..c830286ab 100644 --- a/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs +++ b/Web/Resgrid.Web.Tts/Configuration/TtsOptions.cs @@ -29,7 +29,7 @@ public sealed class TtsOptions public string TempDirectory { get; set; } = Path.Combine(Path.GetTempPath(), "resgrid-tts"); [Required] - public string CachePrefix { get; set; } = "tts"; + public string CachePrefix { get; set; } = "tts2"; [Range(8000, 8000)] public int NormalizedSampleRate { get; set; } = 8000; diff --git a/Web/Resgrid.Web.Tts/Dockerfile b/Web/Resgrid.Web.Tts/Dockerfile index 16d1e1c88..d5622cec6 100644 --- a/Web/Resgrid.Web.Tts/Dockerfile +++ b/Web/Resgrid.Web.Tts/Dockerfile @@ -67,7 +67,7 @@ RUN set -eu; \ RUN set -eu; \ mkdir -p /usr/local/share/piper-voices; \ for f in \ - "en/en_US/norman/medium/en_US-norman-medium" \ + "en/en_US/ryan/high/en_US-ryan-high" \ "es/es_MX/claude/high/es_MX-claude-high" \ "sv/sv_SE/nst/medium/sv_SE-nst-medium" \ "de/de_DE/thorsten/medium/de_DE-thorsten-medium" \ diff --git a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs index d043c9917..14313f172 100644 --- a/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs +++ b/Web/Resgrid.Web.Tts/Services/AudioProcessingService.cs @@ -12,8 +12,11 @@ public sealed class AudioProcessingService : IAudioProcessingService private const float SpeedReferenceWpm = 175f; private const float MinLengthScale = 0.25f; private const float MaxLengthScale = 3.0f; - private const string DefaultEnglishModel = "en_US-norman-medium.onnx"; - private const string TelephoneAudioFilter = "highpass=f=200, lowpass=f=3000, anequalizer=c0 f=2500 w=1000 g=3 t=1"; + private const string DefaultEnglishModel = "en_US-ryan-high.onnx"; + // Lowpass sits at 3400 Hz (the telephony band edge) rather than 3000 so + // sibilants survive the mulaw encode; loudnorm keeps clips at a consistent + // perceived level over the phone. + private const string TelephoneAudioFilter = "highpass=f=200, lowpass=f=3400, anequalizer=c0 f=2500 w=1000 g=3 t=1, loudnorm=I=-16:TP=-1.5:LRA=11"; /// /// Maps eSpeak voice identifiers to Piper model filenames provisioned in the @@ -24,10 +27,10 @@ public sealed class AudioProcessingService : IAudioProcessingService private static readonly Dictionary VoiceModelMap = new(StringComparer.OrdinalIgnoreCase) { // English variants - { "en", "en_US-norman-medium.onnx" }, - { "en-us", "en_US-norman-medium.onnx" }, - { "en-029", "en_US-norman-medium.onnx" }, - { "mb-us1", "en_US-norman-medium.onnx" }, + { "en", DefaultEnglishModel }, + { "en-us", DefaultEnglishModel }, + { "en-029", DefaultEnglishModel }, + { "mb-us1", DefaultEnglishModel }, // Spanish { "es", "es_MX-claude-high.onnx" }, @@ -202,8 +205,16 @@ 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)); + // 0.35s of silence between sentences — dispatch messages are strings of + // short sentences and need audible boundaries to stay intelligible. startInfo.ArgumentList.Add("--sentence-silence"); - startInfo.ArgumentList.Add("0.0"); + startInfo.ArgumentList.Add("0.35"); + // Lower generation noise than the Piper defaults (0.667/0.8): reduces + // prosody jitter that makes digits and short words sound mumbled. + startInfo.ArgumentList.Add("--noise-scale"); + startInfo.ArgumentList.Add("0.333"); + startInfo.ArgumentList.Add("--noise-w"); + startInfo.ArgumentList.Add("0.4"); return startInfo; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx index 6c2de6d9d..4aeba4bce 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/map/LeafletMapView.tsx @@ -127,6 +127,10 @@ export default function LeafletMapView({ zoomControl: true, }); + // Leaflet throws "Set map center and zoom first." if the user interacts before + // the first setView/fitBounds; give the map a fallback view until mapData loads. + map.setView([39.14086268299356, -119.7583809782715], 9); + L.tileLayer(resolvedMapConfig.tileUrl, { maxZoom: 19, attribution: resolvedMapConfig.attribution, diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index 1135a1b89..1df888a61 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -160,6 +160,10 @@ public async Task NewLayer() { var model = new NewLayerView(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + + if (model.Department == null) + return Unauthorized(); + model.CenterCoordinates = await _departmentSettingsService.GetMapCenterCoordinatesAsync(model.Department); return View(model); @@ -170,6 +174,10 @@ public async Task NewLayer() public async Task NewLayer(NewLayerView model) { model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + + if (model.Department == null) + return Unauthorized(); + model.CenterCoordinates = await _departmentSettingsService.GetMapCenterCoordinatesAsync(model.Department); if (ModelState.IsValid) diff --git a/Web/Resgrid.Web/Areas/User/Views/Mapping/AddPOI.cshtml b/Web/Resgrid.Web/Areas/User/Views/Mapping/AddPOI.cshtml index de49e4537..8ddb44719 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Mapping/AddPOI.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Mapping/AddPOI.cshtml @@ -127,30 +127,3 @@ - - -@section Scripts -{ - - -}