mirror of
https://github.com/9ParsonsB/Pulsar.git
synced 2025-07-01 16:33:43 -04:00
observatory herald (#30)
* WIP: initial commit for observatory herald * Plugin error handling refactor * make error window non-modal * tidy up plugin error handling * first pass for basic herald functionality * corrections for linux env * Use FNV hash directly instead of managing through dictionary/index file * resolve audio queuing issue, switch to personal NetCoreAudio fork * merge cleanup * add enable setting, populate defaults * framework xml doc update * Adjust settings, add style selection, replace locale with demonym in dropdown list. * Test is position is on screen before saving/loading. * use a default that's actually in the list
This commit is contained in:
242
ObservatoryHerald/AzureSpeechManager.cs
Normal file
242
ObservatoryHerald/AzureSpeechManager.cs
Normal file
@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Xml;
|
||||
using Microsoft.CognitiveServices.Speech;
|
||||
using System.Collections.ObjectModel;
|
||||
using Observatory.Framework;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
internal class VoiceSpeechManager
|
||||
{
|
||||
private string azureKey;
|
||||
private DirectoryInfo cacheLocation;
|
||||
private SpeechConfig speechConfig;
|
||||
private SpeechSynthesizer speech;
|
||||
|
||||
internal VoiceSpeechManager(HeraldSettings settings, HttpClient httpClient)
|
||||
{
|
||||
cacheLocation = new(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
|
||||
+ $"{Path.DirectorySeparatorChar}ObservatoryCore{Path.DirectorySeparatorChar}ObservatoryHerald{Path.DirectorySeparatorChar}");
|
||||
|
||||
if (!Directory.Exists(cacheLocation.FullName))
|
||||
{
|
||||
Directory.CreateDirectory(cacheLocation.FullName);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
azureKey = GetAzureKey(settings, httpClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PluginException("Herald", "Unable to retrieve Azure API key.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
speechConfig = SpeechConfig.FromSubscription(azureKey, "eastus");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PluginException("Herald", "Error retrieving Azure account details.", ex);
|
||||
}
|
||||
|
||||
speech = new(speechConfig, null);
|
||||
|
||||
settings.Voices = PopulateVoiceSettingOptions();
|
||||
}
|
||||
|
||||
private Dictionary<string, object> PopulateVoiceSettingOptions()
|
||||
{
|
||||
ReadOnlyCollection<VoiceInfo> voices;
|
||||
|
||||
try
|
||||
{
|
||||
voices = speech.GetVoicesAsync().Result.Voices;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PluginException("Herald", "Unable to retrieve voice list from Azure.", ex);
|
||||
}
|
||||
|
||||
var voiceOptions = new Dictionary<string, object>();
|
||||
|
||||
var englishSpeakingVoices = from v in voices
|
||||
where v.Locale.StartsWith("en-")
|
||||
select v;
|
||||
|
||||
foreach (var voice in englishSpeakingVoices)
|
||||
{
|
||||
string demonym = GetDemonymFromLocale(voice.Locale);
|
||||
|
||||
if (voice.StyleList.Length > 1)
|
||||
{
|
||||
foreach (var style in voice.StyleList)
|
||||
{
|
||||
voiceOptions.Add(
|
||||
$"{demonym} - {voice.LocalName} - {style}",
|
||||
voice);
|
||||
}
|
||||
}
|
||||
else
|
||||
voiceOptions.Add(
|
||||
$"{demonym} - {voice.LocalName}",
|
||||
voice);
|
||||
}
|
||||
|
||||
return voiceOptions;
|
||||
}
|
||||
|
||||
private static string GetDemonymFromLocale(string locale)
|
||||
{
|
||||
string demonym;
|
||||
|
||||
switch (locale)
|
||||
{
|
||||
case "en-AU":
|
||||
demonym = "Australian";
|
||||
break;
|
||||
case "en-CA":
|
||||
demonym = "Canadian";
|
||||
break;
|
||||
case "en-GB":
|
||||
demonym = "British";
|
||||
break;
|
||||
case "en-HK":
|
||||
demonym = "Hong Konger";
|
||||
break;
|
||||
case "en-IE":
|
||||
demonym = "Irish";
|
||||
break;
|
||||
case "en-IN":
|
||||
demonym = "Indian";
|
||||
break;
|
||||
case "en-KE":
|
||||
demonym = "Kenyan";
|
||||
break;
|
||||
case "en-NG":
|
||||
demonym = "Nigerian";
|
||||
break;
|
||||
case "en-NZ":
|
||||
demonym = "Kiwi";
|
||||
break;
|
||||
case "en-PH":
|
||||
demonym = "Filipino";
|
||||
break;
|
||||
case "en-SG":
|
||||
demonym = "Singaporean";
|
||||
break;
|
||||
case "en-TZ":
|
||||
demonym = "Tanzanian";
|
||||
break;
|
||||
case "en-US":
|
||||
demonym = "American";
|
||||
break;
|
||||
case "en-ZA":
|
||||
demonym = "South African";
|
||||
break;
|
||||
default:
|
||||
demonym = locale;
|
||||
break;
|
||||
}
|
||||
|
||||
return demonym;
|
||||
}
|
||||
|
||||
internal string GetAudioFileFromSsml(string ssml, string voice, string style)
|
||||
{
|
||||
ssml = AddVoiceToSsml(ssml, voice, style);
|
||||
string ssmlHash = FNV64(ssml).ToString("X");
|
||||
|
||||
string audioFile = cacheLocation + ssmlHash + ".wav";
|
||||
|
||||
if (!File.Exists(audioFile))
|
||||
{
|
||||
using var stream = RequestFromAzure(ssml);
|
||||
stream.SaveToWaveFileAsync(audioFile).Wait();
|
||||
}
|
||||
|
||||
return audioFile;
|
||||
}
|
||||
|
||||
private static ulong FNV64(string data)
|
||||
{
|
||||
string lower_data = data.ToLower();
|
||||
ulong hash = 0xcbf29ce484222325uL;
|
||||
for (int i = 0; i < lower_data.Length; i++)
|
||||
{
|
||||
byte b = (byte)lower_data[i];
|
||||
hash *= 1099511628211uL;
|
||||
hash ^= b;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private AudioDataStream RequestFromAzure(string ssml)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = speech.SpeakSsmlAsync(ssml).Result;
|
||||
return AudioDataStream.FromResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new PluginException("Herald", "Unable to retrieve audio from Azure.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string AddVoiceToSsml(string ssml, string voiceName, string styleName)
|
||||
{
|
||||
XmlDocument ssmlDoc = new();
|
||||
ssmlDoc.LoadXml(ssml);
|
||||
|
||||
var ssmlNamespace = ssmlDoc.DocumentElement.NamespaceURI;
|
||||
XmlNamespaceManager ssmlNs = new(ssmlDoc.NameTable);
|
||||
ssmlNs.AddNamespace("ssml", ssmlNamespace);
|
||||
|
||||
|
||||
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
|
||||
|
||||
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
|
||||
|
||||
string ssmlResult;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(styleName))
|
||||
{
|
||||
voiceNode.InnerText = $"<mstts:express-as style=\"{styleName}\">" + voiceNode.InnerText + "</mstts:express-as>";
|
||||
|
||||
// This is a kludge but I don't feel like dealing with System.Xml and namespaces
|
||||
ssmlResult = ssmlDoc.OuterXml
|
||||
.Replace(" xmlns=", " xmlns:mstts=\"https://www.w3.org/2001/mstts\" xmlns=")
|
||||
.Replace($"<mstts:express-as style=\"{styleName}\">", $"<mstts:express-as style=\"{styleName}\">")
|
||||
.Replace("</mstts:express-as>", "</mstts:express-as>");
|
||||
}
|
||||
else
|
||||
{
|
||||
ssmlResult = ssmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
return ssmlResult;
|
||||
}
|
||||
|
||||
private static string GetAzureKey(HeraldSettings settings, HttpClient httpClient)
|
||||
{
|
||||
string azureKey;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.AzureAPIKeyOverride))
|
||||
{
|
||||
azureKey = httpClient.GetStringAsync("https://xjph.net/Observatory/ObservatoryHeraldAzureKey").Result;
|
||||
}
|
||||
else
|
||||
{
|
||||
azureKey = settings.AzureAPIKeyOverride;
|
||||
}
|
||||
|
||||
return azureKey;
|
||||
}
|
||||
}
|
||||
}
|
77
ObservatoryHerald/HeraldNotifier.cs
Normal file
77
ObservatoryHerald/HeraldNotifier.cs
Normal file
@ -0,0 +1,77 @@
|
||||
using Microsoft.CognitiveServices.Speech;
|
||||
using Observatory.Framework;
|
||||
using Observatory.Framework.Interfaces;
|
||||
using System;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
public class HeraldNotifier : IObservatoryNotifier
|
||||
{
|
||||
public HeraldNotifier()
|
||||
{
|
||||
heraldSettings = new()
|
||||
{
|
||||
SelectedVoice = "American - Christopher",
|
||||
AzureAPIKeyOverride = string.Empty,
|
||||
Enabled = true
|
||||
};
|
||||
}
|
||||
|
||||
public string Name => "Observatory Herald";
|
||||
|
||||
public string ShortName => "Herald";
|
||||
|
||||
public string Version => typeof(HeraldNotifier).Assembly.GetName().Version.ToString();
|
||||
|
||||
public PluginUI PluginUI => new (PluginUI.UIType.None, null);
|
||||
|
||||
public object Settings { get => heraldSettings; set => heraldSettings = (HeraldSettings)value; }
|
||||
|
||||
public void Load(IObservatoryCore observatoryCore)
|
||||
{
|
||||
var azureManager = new VoiceSpeechManager(heraldSettings, observatoryCore.HttpClient);
|
||||
heraldSpeech = new HeraldQueue(azureManager);
|
||||
heraldSettings.Test = TestVoice;
|
||||
}
|
||||
|
||||
private void TestVoice()
|
||||
{
|
||||
heraldSpeech.Enqueue(
|
||||
new NotificationArgs()
|
||||
{
|
||||
Title = "Herald voice testing",
|
||||
Detail = $"This is {heraldSettings.SelectedVoice.Split(" - ")[1]}."
|
||||
},
|
||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
||||
}
|
||||
|
||||
public void OnNotificationEvent(NotificationArgs notificationEventArgs)
|
||||
{
|
||||
if (heraldSettings.Enabled)
|
||||
heraldSpeech.Enqueue(
|
||||
notificationEventArgs,
|
||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
||||
}
|
||||
|
||||
private string GetAzureNameFromSetting(string settingName)
|
||||
{
|
||||
var voiceInfo = (VoiceInfo)heraldSettings.Voices[settingName];
|
||||
return voiceInfo.Name;
|
||||
}
|
||||
|
||||
private string GetAzureStyleNameFromSetting(string settingName)
|
||||
{
|
||||
string[] settingParts = settingName.Split(" - ");
|
||||
|
||||
if (settingParts.Length == 3)
|
||||
return settingParts[2];
|
||||
else
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private HeraldSettings heraldSettings;
|
||||
private HeraldQueue heraldSpeech;
|
||||
}
|
||||
}
|
94
ObservatoryHerald/HeraldQueue.cs
Normal file
94
ObservatoryHerald/HeraldQueue.cs
Normal file
@ -0,0 +1,94 @@
|
||||
using Observatory.Framework;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using NetCoreAudio;
|
||||
using System.Threading;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
class HeraldQueue
|
||||
{
|
||||
private Queue<NotificationArgs> notifications;
|
||||
private bool processing;
|
||||
private string voice;
|
||||
private string style;
|
||||
private VoiceSpeechManager azureCacheManager;
|
||||
private Player audioPlayer;
|
||||
|
||||
public HeraldQueue(VoiceSpeechManager azureCacheManager)
|
||||
{
|
||||
this.azureCacheManager = azureCacheManager;
|
||||
processing = false;
|
||||
notifications = new();
|
||||
audioPlayer = new();
|
||||
}
|
||||
|
||||
|
||||
internal void Enqueue(NotificationArgs notification, string selectedVoice, string selectedStyle = "")
|
||||
{
|
||||
voice = selectedVoice;
|
||||
style = selectedStyle;
|
||||
notifications.Enqueue(notification);
|
||||
|
||||
if (!processing)
|
||||
{
|
||||
processing = true;
|
||||
ProcessQueueAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async void ProcessQueueAsync()
|
||||
{
|
||||
await Task.Factory.StartNew(ProcessQueue);
|
||||
}
|
||||
|
||||
private void ProcessQueue()
|
||||
{
|
||||
while (notifications.Any())
|
||||
{
|
||||
var notification = notifications.Dequeue();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
|
||||
{
|
||||
Speak(notification.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
SpeakSsml(notification.TitleSsml);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
|
||||
{
|
||||
Speak(notification.Detail);
|
||||
}
|
||||
else
|
||||
{
|
||||
SpeakSsml(notification.DetailSsml);
|
||||
}
|
||||
}
|
||||
|
||||
processing = false;
|
||||
}
|
||||
|
||||
private void Speak(string text)
|
||||
{
|
||||
SpeakSsml($"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{text}</voice></speak>");
|
||||
}
|
||||
|
||||
private void SpeakSsml(string ssml)
|
||||
{
|
||||
string file = azureCacheManager.GetAudioFileFromSsml(ssml, voice, style);
|
||||
|
||||
// For some reason .Wait() concludes before audio playback is complete.
|
||||
audioPlayer.Play(file);
|
||||
while (audioPlayer.Playing)
|
||||
{
|
||||
Thread.Sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
29
ObservatoryHerald/HeraldSettings.cs
Normal file
29
ObservatoryHerald/HeraldSettings.cs
Normal file
@ -0,0 +1,29 @@
|
||||
using Observatory.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
public class HeraldSettings
|
||||
{
|
||||
[SettingDisplayName("API Key Override: ")]
|
||||
public string AzureAPIKeyOverride { get; set; }
|
||||
|
||||
[SettingDisplayName("Voice")]
|
||||
[SettingBackingValue("SelectedVoice")]
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Dictionary<string, object> Voices { get; internal set; }
|
||||
|
||||
[SettingIgnore]
|
||||
public string SelectedVoice { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Action Test { get; internal set; }
|
||||
|
||||
[SettingDisplayName("Enabled")]
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
}
|
40
ObservatoryHerald/ObservatoryHerald.csproj
Normal file
40
ObservatoryHerald/ObservatoryHerald.csproj
Normal file
@ -0,0 +1,40 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>0.0.$([System.DateTime]::UtcNow.DayOfYear.ToString()).$([System.DateTime]::UtcNow.ToString(HHmm))</VersionSuffix>
|
||||
<AssemblyVersion Condition=" '$(VersionSuffix)' == '' ">0.0.0.1</AssemblyVersion>
|
||||
<AssemblyVersion Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</AssemblyVersion>
|
||||
<Version Condition=" '$(VersionSuffix)' == '' ">0.0.1.0</Version>
|
||||
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CognitiveServices.Speech" Version="1.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="NetCoreAudio">
|
||||
<HintPath>..\..\NetCoreAudio\NetCoreAudio\bin\Release\netstandard2.0\NetCoreAudio.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="ObservatoryFramework">
|
||||
<HintPath>..\ObservatoryFramework\bin\Release\net5.0\ObservatoryFramework.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy "$(TargetPath)" "$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\" /y" />
|
||||
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy "$(TargetDir)Microsoft.CognitiveServices.Speech.csharp.dll" "$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\" /y" />
|
||||
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy "$(TargetDir)NetCoreAudio.dll" "$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\" /y" />
|
||||
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy "$(TargetDir)\runtimes\win-x64\native\*.*" "$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\" /y" />
|
||||
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="[ ! -d "$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps" ] && mkdir -p "$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps" || echo Directory already exists" />
|
||||
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp "$(TargetPath)" "$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/" -f" />
|
||||
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp "$(TargetDir)runtimes/linux-x64/lib/netstandard2.0/Microsoft.CognitiveServices.Speech.csharp.dll" "$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/" -f" />
|
||||
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp "$(TargetDir)NetCoreAudio.dll" "$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/" -f" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
25
ObservatoryHerald/ObservatoryHerald.sln
Normal file
25
ObservatoryHerald/ObservatoryHerald.sln
Normal file
@ -0,0 +1,25 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.31205.134
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ObservatoryHerald", "ObservatoryHerald.csproj", "{BC57225F-D89B-4853-A816-9AB4865E7AC5}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BC57225F-D89B-4853-A816-9AB4865E7AC5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {3466BC38-6B4F-459C-9292-DD2D77F8B8E4}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
Reference in New Issue
Block a user