2
0
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:
Xjph
2021-11-15 10:57:46 -03:30
committed by GitHub
parent 9ad3f77bb8
commit 554948534e
22 changed files with 1144 additions and 61 deletions

View 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($"&lt;mstts:express-as style=\"{styleName}\"&gt;", $"<mstts:express-as style=\"{styleName}\">")
.Replace("&lt;/mstts:express-as&gt;", "</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;
}
}
}

View 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;
}
}

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

View 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; }
}
}

View 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 &quot;$(TargetPath)&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)Microsoft.CognitiveServices.Speech.csharp.dll&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)NetCoreAudio.dll&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' == 'Windows_NT' " Command="xcopy &quot;$(TargetDir)\runtimes\win-x64\native\*.*&quot; &quot;$(ProjectDir)..\ObservatoryCore\$(OutDir)plugins\deps\&quot; /y" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="[ ! -d &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps&quot; ] &amp;&amp; mkdir -p &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps&quot; || echo Directory already exists" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetPath)&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/&quot; -f" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetDir)runtimes/linux-x64/lib/netstandard2.0/Microsoft.CognitiveServices.Speech.csharp.dll&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/&quot; -f" />
<Exec Condition=" '$(OS)' != 'Windows_NT' " Command="cp &quot;$(TargetDir)NetCoreAudio.dll&quot; &quot;$(ProjectDir)../ObservatoryCore/$(OutDir)plugins/deps/&quot; -f" />
</Target>
</Project>

View 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