mirror of
https://github.com/9ParsonsB/Pulsar.git
synced 2025-04-05 17:39:39 -04:00
Herald v2 (#74)
* Add speech rate setting * Add volume slider * New speech manager skeleton * User API key from resx * Implement voice list retrieve via new api * Rewrite to use ObAPI, remove all dependancies * Use volume setting * Clean up using statements * Volume and timing adjustments * Lookup rate value * Use numeric rates for tighter spread * Manage plugin data folder via core interface * Add check that nullable settings are not null. * Get file size before it's deleted. * Improve old settings migration. * Ignore cache sizes below 1MB * Re-index orphaned files in cache, purge legacy wav files. * Call top level error logging for native voice exception. * Async title and detail requests to remove pause * Remove NetCoreAudio use of temp files. * Remove orphan using.
This commit is contained in:
parent
3cc8cc3abe
commit
1950d477fd
@ -32,7 +32,14 @@ namespace Observatory.NativeNotification
|
||||
|
||||
private async void ProcessQueueAsync()
|
||||
{
|
||||
await Task.Factory.StartNew(ProcessQueue);
|
||||
try
|
||||
{
|
||||
await Task.Factory.StartNew(ProcessQueue);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
ObservatoryCore.LogError(ex, " - Native Voice Notifier");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessQueue()
|
||||
|
@ -22,15 +22,19 @@ namespace Observatory
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var docPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
|
||||
var errorMessage = new System.Text.StringBuilder();
|
||||
errorMessage
|
||||
.AppendLine($"Error encountered in Elite Observatory {version}.")
|
||||
.AppendLine(FormatExceptionMessage(ex))
|
||||
.AppendLine();
|
||||
System.IO.File.AppendAllText(docPath + System.IO.Path.DirectorySeparatorChar + "ObservatoryErrorLog.txt", errorMessage.ToString());
|
||||
LogError(ex, version);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void LogError(Exception ex, string context)
|
||||
{
|
||||
var docPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
|
||||
var errorMessage = new System.Text.StringBuilder();
|
||||
errorMessage
|
||||
.AppendLine($"Error encountered in Elite Observatory {context}.")
|
||||
.AppendLine(FormatExceptionMessage(ex))
|
||||
.AppendLine();
|
||||
System.IO.File.AppendAllText(docPath + System.IO.Path.DirectorySeparatorChar + "ObservatoryErrorLog.txt", errorMessage.ToString());
|
||||
}
|
||||
|
||||
static string FormatExceptionMessage(Exception ex, bool inner = false)
|
||||
|
@ -3,6 +3,7 @@ using Observatory.Framework.Files;
|
||||
using Observatory.Framework.Interfaces;
|
||||
using Observatory.NativeNotification;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Observatory.PluginManagement
|
||||
{
|
||||
@ -145,5 +146,21 @@ namespace Observatory.PluginManagement
|
||||
}
|
||||
|
||||
public event EventHandler<NotificationArgs> Notification;
|
||||
|
||||
public string PluginStorageFolder
|
||||
{
|
||||
get
|
||||
{
|
||||
var context = new System.Diagnostics.StackFrame(1).GetMethod();
|
||||
|
||||
string folderLocation = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData)
|
||||
+ $"{Path.DirectorySeparatorChar}ObservatoryCore{Path.DirectorySeparatorChar}{context.DeclaringType.Assembly.GetName().Name}{Path.DirectorySeparatorChar}";
|
||||
|
||||
if (!Directory.Exists(folderLocation))
|
||||
Directory.CreateDirectory(folderLocation);
|
||||
|
||||
return folderLocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -194,5 +194,10 @@ namespace Observatory.Framework.Interfaces
|
||||
/// Returns true if the current LogMonitor state represents a batch-read mode.
|
||||
/// </summary>
|
||||
public bool IsLogMonitorBatchReading { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves and ensures creation of a location which can be used by the plugin to store persistent data.
|
||||
/// </summary>
|
||||
public string PluginStorageFolder { get; }
|
||||
}
|
||||
}
|
||||
|
@ -138,6 +138,28 @@
|
||||
Specifies the desired renderings of the notification.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:Observatory.Framework.LogMonitorStateChangedEventArgs">
|
||||
<summary>
|
||||
Provides information about a LogMonitor state transition.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="F:Observatory.Framework.LogMonitorStateChangedEventArgs.PreviousState">
|
||||
<summary>
|
||||
The previous LogMonitor state.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="F:Observatory.Framework.LogMonitorStateChangedEventArgs.NewState">
|
||||
<summary>
|
||||
The new, current LogMonitor state.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:Observatory.Framework.LogMonitorStateChangedEventArgs.IsBatchRead(Observatory.Framework.LogMonitorState)">
|
||||
<summary>
|
||||
Determins if the given state is a batch read of any form.
|
||||
</summary>
|
||||
<param name="state">The state to evaluate</param>
|
||||
<returns>A boolean; True iff the state provided represents a batch-mode read.</returns>
|
||||
</member>
|
||||
<member name="T:Observatory.Framework.PluginException">
|
||||
<summary>
|
||||
Container for exceptions within plugins which cannot be gracefully handled in context,
|
||||
@ -760,6 +782,12 @@
|
||||
</summary>
|
||||
<param name="status">Player status.json content, deserialized into a .NET object.</param>
|
||||
</member>
|
||||
<member name="M:Observatory.Framework.Interfaces.IObservatoryWorker.LogMonitorStateChanged(Observatory.Framework.LogMonitorStateChangedEventArgs)">
|
||||
<summary>
|
||||
Called when the LogMonitor changes state. Useful for suppressing output in certain situations
|
||||
such as batch reads (ie. "Read all") or responding to other state transitions.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="M:Observatory.Framework.Interfaces.IObservatoryWorker.ReadAllStarted">
|
||||
<summary>
|
||||
Method called when the user begins "Read All" journal processing, before any journal events are sent.<br/>
|
||||
@ -856,6 +884,21 @@
|
||||
Shared application HttpClient object. Provided so that plugins can adhere to .NET recommended behaviour of a single HttpClient object per application.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:Observatory.Framework.Interfaces.IObservatoryCore.CurrentLogMonitorState">
|
||||
<summary>
|
||||
Returns the current LogMonitor state.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:Observatory.Framework.Interfaces.IObservatoryCore.IsLogMonitorBatchReading">
|
||||
<summary>
|
||||
Returns true if the current LogMonitor state represents a batch-read mode.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:Observatory.Framework.Interfaces.IObservatoryCore.PluginStorageFolder">
|
||||
<summary>
|
||||
Retrieves and ensures creation of a location which can be used by the plugin to store persistent data.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:Observatory.Framework.PluginUI">
|
||||
<summary>
|
||||
Class permitting plugins to provide their UI, if any, to Observatory Core.
|
||||
|
@ -1,230 +0,0 @@
|
||||
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);
|
||||
|
||||
voiceOptions.Add(
|
||||
$"{demonym} - {voice.LocalName}",
|
||||
voice);
|
||||
|
||||
foreach (var style in voice.StyleList)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(style))
|
||||
voiceOptions.Add(
|
||||
$"{demonym} - {voice.LocalName} - {style}",
|
||||
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);
|
||||
ssmlNs.AddNamespace("mstts", "http://www.w3.org/2001/mstts");
|
||||
|
||||
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
|
||||
|
||||
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(styleName))
|
||||
{
|
||||
var expressAsNode = ssmlDoc.CreateElement("express-as", "http://www.w3.org/2001/mstts");
|
||||
expressAsNode.SetAttribute("style", styleName);
|
||||
expressAsNode.InnerXml = voiceNode.InnerXml;
|
||||
voiceNode.InnerXml = expressAsNode.OuterXml;
|
||||
}
|
||||
return ssmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
using Microsoft.CognitiveServices.Speech;
|
||||
using Observatory.Framework;
|
||||
using Observatory.Framework;
|
||||
using Observatory.Framework.Interfaces;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
@ -9,11 +8,19 @@ namespace Observatory.Herald
|
||||
{
|
||||
public HeraldNotifier()
|
||||
{
|
||||
heraldSettings = new()
|
||||
heraldSettings = DefaultSettings;
|
||||
}
|
||||
|
||||
private static HeraldSettings DefaultSettings
|
||||
{
|
||||
get => new HeraldSettings()
|
||||
{
|
||||
SelectedVoice = "American - Christopher",
|
||||
AzureAPIKeyOverride = string.Empty,
|
||||
Enabled = false
|
||||
SelectedRate = "Default",
|
||||
Volume = 75,
|
||||
Enabled = false,
|
||||
ApiEndpoint = "https://api.observatory.xjph.net/AzureVoice",
|
||||
CacheSize = 100
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,12 +32,29 @@ namespace Observatory.Herald
|
||||
|
||||
public PluginUI PluginUI => new (PluginUI.UIType.None, null);
|
||||
|
||||
public object Settings { get => heraldSettings; set => heraldSettings = (HeraldSettings)value; }
|
||||
|
||||
public object Settings
|
||||
{
|
||||
get => heraldSettings;
|
||||
set
|
||||
{
|
||||
// Need to perform migration here, older
|
||||
// version settings object not fully compatible.
|
||||
var savedSettings = (HeraldSettings)value;
|
||||
if (string.IsNullOrWhiteSpace(savedSettings.SelectedRate))
|
||||
{
|
||||
heraldSettings.SelectedVoice = savedSettings.SelectedVoice;
|
||||
heraldSettings.Enabled = savedSettings.Enabled;
|
||||
}
|
||||
else
|
||||
{
|
||||
heraldSettings = savedSettings;
|
||||
}
|
||||
}
|
||||
}
|
||||
public void Load(IObservatoryCore observatoryCore)
|
||||
{
|
||||
var azureManager = new VoiceSpeechManager(heraldSettings, observatoryCore.HttpClient);
|
||||
heraldSpeech = new HeraldQueue(azureManager);
|
||||
var speechManager = new SpeechRequestManager(heraldSettings, observatoryCore.HttpClient, observatoryCore.PluginStorageFolder);
|
||||
heraldSpeech = new HeraldQueue(speechManager);
|
||||
heraldSettings.Test = TestVoice;
|
||||
}
|
||||
|
||||
@ -43,7 +67,9 @@ namespace Observatory.Herald
|
||||
Detail = $"This is {heraldSettings.SelectedVoice.Split(" - ")[1]}."
|
||||
},
|
||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice),
|
||||
heraldSettings.Rate[heraldSettings.SelectedRate].ToString(),
|
||||
heraldSettings.Volume);
|
||||
}
|
||||
|
||||
public void OnNotificationEvent(NotificationArgs notificationEventArgs)
|
||||
@ -52,13 +78,15 @@ namespace Observatory.Herald
|
||||
heraldSpeech.Enqueue(
|
||||
notificationEventArgs,
|
||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice),
|
||||
heraldSettings.Rate[heraldSettings.SelectedRate].ToString(),
|
||||
heraldSettings.Volume);
|
||||
}
|
||||
|
||||
private string GetAzureNameFromSetting(string settingName)
|
||||
{
|
||||
var voiceInfo = (VoiceInfo)heraldSettings.Voices[settingName];
|
||||
return voiceInfo.Name;
|
||||
var voiceInfo = (JsonElement)heraldSettings.Voices[settingName];
|
||||
return voiceInfo.GetProperty("ShortName").GetString();
|
||||
}
|
||||
|
||||
private string GetAzureStyleNameFromSetting(string settingName)
|
||||
|
@ -13,22 +13,32 @@ namespace Observatory.Herald
|
||||
private bool processing;
|
||||
private string voice;
|
||||
private string style;
|
||||
private VoiceSpeechManager azureCacheManager;
|
||||
private string rate;
|
||||
private byte volume;
|
||||
private SpeechRequestManager speechManager;
|
||||
private Player audioPlayer;
|
||||
|
||||
public HeraldQueue(VoiceSpeechManager azureCacheManager)
|
||||
public HeraldQueue(SpeechRequestManager speechManager)
|
||||
{
|
||||
this.azureCacheManager = azureCacheManager;
|
||||
this.speechManager = speechManager;
|
||||
processing = false;
|
||||
notifications = new();
|
||||
audioPlayer = new();
|
||||
}
|
||||
|
||||
|
||||
internal void Enqueue(NotificationArgs notification, string selectedVoice, string selectedStyle = "")
|
||||
internal void Enqueue(NotificationArgs notification, string selectedVoice, string selectedStyle = "", string selectedRate = "", int volume = 75)
|
||||
{
|
||||
voice = selectedVoice;
|
||||
style = selectedStyle;
|
||||
rate = selectedRate;
|
||||
// Ignore invalid values; assume default.
|
||||
volume = volume >= 0 && volume <= 100 ? volume : 75;
|
||||
|
||||
// Volume is perceived logarithmically, convert to exponential curve
|
||||
// to make perceived volume more in line with value set.
|
||||
this.volume = ((byte)System.Math.Floor(System.Math.Pow(volume / 100.0, 2.0) * 100));
|
||||
|
||||
notifications.Enqueue(notification);
|
||||
|
||||
if (!processing)
|
||||
@ -45,50 +55,60 @@ namespace Observatory.Herald
|
||||
|
||||
private void ProcessQueue()
|
||||
{
|
||||
|
||||
while (notifications.Any())
|
||||
{
|
||||
audioPlayer.SetVolume(volume).Wait();
|
||||
var notification = notifications.Dequeue();
|
||||
|
||||
Task<string>[] audioRequestTasks = new Task<string> [2];
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
|
||||
{
|
||||
Speak(notification.Title);
|
||||
audioRequestTasks[0] = RetrieveAudioToFile(notification.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
SpeakSsml(notification.TitleSsml);
|
||||
audioRequestTasks[0] = RetrieveAudioSsmlToFile(notification.TitleSsml);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
|
||||
{
|
||||
Speak(notification.Detail);
|
||||
audioRequestTasks[1] = RetrieveAudioToFile(notification.Detail);
|
||||
}
|
||||
else
|
||||
{
|
||||
SpeakSsml(notification.DetailSsml);
|
||||
audioRequestTasks[1] = RetrieveAudioSsmlToFile(notification.DetailSsml);
|
||||
}
|
||||
|
||||
PlayAudioRequestsSequentially(audioRequestTasks);
|
||||
}
|
||||
|
||||
processing = false;
|
||||
}
|
||||
|
||||
private void Speak(string text)
|
||||
private async Task<string> RetrieveAudioToFile(string text)
|
||||
{
|
||||
SpeakSsml($"<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xml:lang=\"en-US\"><voice name=\"\">{text}</voice></speak>");
|
||||
return await RetrieveAudioSsmlToFile($"<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)
|
||||
private async Task<string> RetrieveAudioSsmlToFile(string ssml)
|
||||
{
|
||||
string file = azureCacheManager.GetAudioFileFromSsml(ssml, voice, style);
|
||||
return await speechManager.GetAudioFileFromSsml(ssml, voice, style, rate);
|
||||
}
|
||||
|
||||
// For some reason .Wait() concludes before audio playback is complete.
|
||||
audioPlayer.Play(file);
|
||||
while (audioPlayer.Playing)
|
||||
private void PlayAudioRequestsSequentially(Task<string>[] requestTasks)
|
||||
{
|
||||
foreach (var request in requestTasks)
|
||||
{
|
||||
Thread.Sleep(20);
|
||||
string file = request.Result;
|
||||
audioPlayer.Play(file).Wait();
|
||||
|
||||
while (audioPlayer.Playing)
|
||||
Thread.Sleep(50);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,6 @@
|
||||
using Observatory.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
@ -15,15 +12,40 @@ namespace Observatory.Herald
|
||||
[SettingDisplayName("Voice")]
|
||||
[SettingBackingValue("SelectedVoice")]
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Dictionary<string, object> Voices { get; internal set; }
|
||||
public Dictionary<string, object> Voices {get; internal set;}
|
||||
|
||||
[SettingIgnore]
|
||||
public string SelectedVoice { get; set; }
|
||||
|
||||
[SettingBackingValue("SelectedRate")]
|
||||
public Dictionary<string, object> Rate
|
||||
{ get => new Dictionary<string, object>
|
||||
{
|
||||
{"Slowest", "0.5"},
|
||||
{"Slower", "0.75"},
|
||||
{"Default", "1.0"},
|
||||
{"Faster", "1.25"},
|
||||
{"Fastest", "1.5"}
|
||||
};
|
||||
}
|
||||
|
||||
[SettingIgnore]
|
||||
public string SelectedRate { get; set; }
|
||||
|
||||
[SettingDisplayName("Volume")]
|
||||
[SettingNumericUseSlider, SettingNumericBounds(0,100,1)]
|
||||
public int Volume { get; set;}
|
||||
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public Action Test { get; internal set; }
|
||||
|
||||
[SettingDisplayName("Enabled")]
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
[SettingIgnore]
|
||||
public string ApiEndpoint { get; set; }
|
||||
|
||||
[SettingDisplayName("Cache Size (MB): ")]
|
||||
public int CacheSize { get; set; }
|
||||
}
|
||||
}
|
||||
|
19
ObservatoryHerald/NetCoreAudio/Interfaces/IPlayer.cs
Normal file
19
ObservatoryHerald/NetCoreAudio/Interfaces/IPlayer.cs
Normal file
@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NetCoreAudio.Interfaces
|
||||
{
|
||||
public interface IPlayer : IDisposable
|
||||
{
|
||||
event EventHandler PlaybackFinished;
|
||||
|
||||
bool Playing { get; }
|
||||
bool Paused { get; }
|
||||
|
||||
Task Play(string fileName);
|
||||
Task Pause();
|
||||
Task Resume();
|
||||
Task Stop();
|
||||
Task SetVolume(byte percent);
|
||||
}
|
||||
}
|
98
ObservatoryHerald/NetCoreAudio/Player.cs
Normal file
98
ObservatoryHerald/NetCoreAudio/Player.cs
Normal file
@ -0,0 +1,98 @@
|
||||
using NetCoreAudio.Interfaces;
|
||||
using NetCoreAudio.Players;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NetCoreAudio
|
||||
{
|
||||
public class Player : IPlayer
|
||||
{
|
||||
private readonly IPlayer _internalPlayer;
|
||||
|
||||
/// <summary>
|
||||
/// Internally, sets Playing flag to false. Additional handlers can be attached to it to handle any custom logic.
|
||||
/// </summary>
|
||||
public event EventHandler PlaybackFinished;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the audio is currently playing.
|
||||
/// </summary>
|
||||
public bool Playing => _internalPlayer.Playing;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the audio playback is currently paused.
|
||||
/// </summary>
|
||||
public bool Paused => _internalPlayer.Paused;
|
||||
|
||||
public Player()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
_internalPlayer = new WindowsPlayer();
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
_internalPlayer = new LinuxPlayer();
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
_internalPlayer = new MacPlayer();
|
||||
else
|
||||
throw new Exception("No implementation exist for the current OS");
|
||||
|
||||
_internalPlayer.PlaybackFinished += OnPlaybackFinished;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Will stop any current playback and will start playing the specified audio file. The fileName parameter can be an absolute path or a path relative to the directory where the library is located. Sets Playing flag to true. Sets Paused flag to false.
|
||||
/// </summary>
|
||||
/// <param name="fileName"></param>
|
||||
/// <returns></returns>
|
||||
public async Task Play(string fileName)
|
||||
{
|
||||
await _internalPlayer.Play(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pauses any ongong playback. Sets Paused flag to true. Doesn't modify Playing flag.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task Pause()
|
||||
{
|
||||
await _internalPlayer.Pause();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resumes any paused playback. Sets Paused flag to false. Doesn't modify Playing flag.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task Resume()
|
||||
{
|
||||
await _internalPlayer.Resume();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops any current playback and clears the buffer. Sets Playing and Paused flags to false.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task Stop()
|
||||
{
|
||||
await _internalPlayer.Stop();
|
||||
}
|
||||
|
||||
private void OnPlaybackFinished(object sender, EventArgs e)
|
||||
{
|
||||
PlaybackFinished?.Invoke(this, e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the playing volume as percent
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task SetVolume(byte percent)
|
||||
{
|
||||
await _internalPlayer.SetVolume(percent);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_internalPlayer.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
33
ObservatoryHerald/NetCoreAudio/Players/LinuxPlayer.cs
Normal file
33
ObservatoryHerald/NetCoreAudio/Players/LinuxPlayer.cs
Normal file
@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using NetCoreAudio.Interfaces;
|
||||
|
||||
namespace NetCoreAudio.Players
|
||||
{
|
||||
internal class LinuxPlayer : UnixPlayerBase, IPlayer
|
||||
{
|
||||
protected override string GetBashCommand(string fileName)
|
||||
{
|
||||
if (Path.GetExtension(fileName).ToLower().Equals(".mp3"))
|
||||
{
|
||||
return "mpg123 -q";
|
||||
}
|
||||
else
|
||||
{
|
||||
return "aplay -q";
|
||||
}
|
||||
}
|
||||
|
||||
public override Task SetVolume(byte percent)
|
||||
{
|
||||
if (percent > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(percent), "Percent can't exceed 100");
|
||||
|
||||
var tempProcess = StartBashProcess($"amixer -M set 'Master' {percent}%");
|
||||
tempProcess.WaitForExit();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
25
ObservatoryHerald/NetCoreAudio/Players/MacPlayer.cs
Normal file
25
ObservatoryHerald/NetCoreAudio/Players/MacPlayer.cs
Normal file
@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using NetCoreAudio.Interfaces;
|
||||
|
||||
namespace NetCoreAudio.Players
|
||||
{
|
||||
internal class MacPlayer : UnixPlayerBase, IPlayer
|
||||
{
|
||||
protected override string GetBashCommand(string fileName)
|
||||
{
|
||||
return "afplay";
|
||||
}
|
||||
|
||||
public override Task SetVolume(byte percent)
|
||||
{
|
||||
if (percent > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(percent), "Percent can't exceed 100");
|
||||
|
||||
var tempProcess = StartBashProcess($"osascript -e \"set volume output volume {percent}\"");
|
||||
tempProcess.WaitForExit();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
110
ObservatoryHerald/NetCoreAudio/Players/UnixPlayerBase.cs
Normal file
110
ObservatoryHerald/NetCoreAudio/Players/UnixPlayerBase.cs
Normal file
@ -0,0 +1,110 @@
|
||||
using NetCoreAudio.Interfaces;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace NetCoreAudio.Players
|
||||
{
|
||||
internal abstract class UnixPlayerBase : IPlayer
|
||||
{
|
||||
private Process _process = null;
|
||||
|
||||
internal const string PauseProcessCommand = "kill -STOP {0}";
|
||||
internal const string ResumeProcessCommand = "kill -CONT {0}";
|
||||
|
||||
public event EventHandler PlaybackFinished;
|
||||
|
||||
public bool Playing { get; private set; }
|
||||
|
||||
public bool Paused { get; private set; }
|
||||
|
||||
protected abstract string GetBashCommand(string fileName);
|
||||
|
||||
public async Task Play(string fileName)
|
||||
{
|
||||
await Stop();
|
||||
var BashToolName = GetBashCommand(fileName);
|
||||
_process = StartBashProcess($"{BashToolName} '{fileName}'");
|
||||
_process.EnableRaisingEvents = true;
|
||||
_process.Exited += HandlePlaybackFinished;
|
||||
_process.ErrorDataReceived += HandlePlaybackFinished;
|
||||
_process.Disposed += HandlePlaybackFinished;
|
||||
Playing = true;
|
||||
}
|
||||
|
||||
public Task Pause()
|
||||
{
|
||||
if (Playing && !Paused && _process != null)
|
||||
{
|
||||
var tempProcess = StartBashProcess(string.Format(PauseProcessCommand, _process.Id));
|
||||
tempProcess.WaitForExit();
|
||||
Paused = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Resume()
|
||||
{
|
||||
if (Playing && Paused && _process != null)
|
||||
{
|
||||
var tempProcess = StartBashProcess(string.Format(ResumeProcessCommand, _process.Id));
|
||||
tempProcess.WaitForExit();
|
||||
Paused = false;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Stop()
|
||||
{
|
||||
if (_process != null)
|
||||
{
|
||||
_process.Kill();
|
||||
_process.Dispose();
|
||||
_process = null;
|
||||
}
|
||||
|
||||
Playing = false;
|
||||
Paused = false;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected Process StartBashProcess(string command)
|
||||
{
|
||||
var escapedArgs = command.Replace("\"", "\\\"");
|
||||
|
||||
var process = new Process()
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "/bin/bash",
|
||||
Arguments = $"-c \"{escapedArgs}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardInput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
return process;
|
||||
}
|
||||
|
||||
internal void HandlePlaybackFinished(object sender, EventArgs e)
|
||||
{
|
||||
if (Playing)
|
||||
{
|
||||
Playing = false;
|
||||
PlaybackFinished?.Invoke(this, e);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task SetVolume(byte percent);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop().Wait();
|
||||
}
|
||||
}
|
||||
}
|
141
ObservatoryHerald/NetCoreAudio/Players/WindowsPlayer.cs
Normal file
141
ObservatoryHerald/NetCoreAudio/Players/WindowsPlayer.cs
Normal file
@ -0,0 +1,141 @@
|
||||
using NetCoreAudio.Interfaces;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
|
||||
namespace NetCoreAudio.Players
|
||||
{
|
||||
internal class WindowsPlayer : IPlayer
|
||||
{
|
||||
[DllImport("winmm.dll")]
|
||||
private static extern int mciSendString(string command, StringBuilder stringReturn, int returnLength, IntPtr hwndCallback);
|
||||
|
||||
[DllImport("winmm.dll")]
|
||||
private static extern int mciGetErrorString(int errorCode, StringBuilder errorText, int errorTextSize);
|
||||
|
||||
[DllImport("winmm.dll")]
|
||||
public static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume);
|
||||
|
||||
private Timer _playbackTimer;
|
||||
private Stopwatch _playStopwatch;
|
||||
|
||||
private string _fileName;
|
||||
|
||||
public event EventHandler PlaybackFinished;
|
||||
|
||||
public bool Playing { get; private set; }
|
||||
public bool Paused { get; private set; }
|
||||
|
||||
public Task Play(string fileName)
|
||||
{
|
||||
_fileName = $"\"{fileName}\"";
|
||||
_playbackTimer = new Timer
|
||||
{
|
||||
AutoReset = false
|
||||
};
|
||||
_playStopwatch = new Stopwatch();
|
||||
|
||||
ExecuteMciCommand($"Status {_fileName} Length");
|
||||
ExecuteMciCommand($"Play {_fileName}");
|
||||
Paused = false;
|
||||
Playing = true;
|
||||
_playbackTimer.Elapsed += HandlePlaybackFinished;
|
||||
_playbackTimer.Start();
|
||||
_playStopwatch.Start();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Pause()
|
||||
{
|
||||
if (Playing && !Paused)
|
||||
{
|
||||
ExecuteMciCommand($"Pause {_fileName}");
|
||||
Paused = true;
|
||||
_playbackTimer.Stop();
|
||||
_playStopwatch.Stop();
|
||||
_playbackTimer.Interval -= _playStopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Resume()
|
||||
{
|
||||
if (Playing && Paused)
|
||||
{
|
||||
ExecuteMciCommand($"Resume {_fileName}");
|
||||
Paused = false;
|
||||
_playbackTimer.Start();
|
||||
_playStopwatch.Reset();
|
||||
_playStopwatch.Start();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task Stop()
|
||||
{
|
||||
if (Playing)
|
||||
{
|
||||
ExecuteMciCommand($"Stop {_fileName}");
|
||||
Playing = false;
|
||||
Paused = false;
|
||||
_playbackTimer.Stop();
|
||||
_playStopwatch.Stop();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void HandlePlaybackFinished(object sender, ElapsedEventArgs e)
|
||||
{
|
||||
Playing = false;
|
||||
PlaybackFinished?.Invoke(this, e);
|
||||
_playbackTimer?.Dispose();
|
||||
_playbackTimer = null;
|
||||
}
|
||||
|
||||
private Task ExecuteMciCommand(string commandString)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
var result = mciSendString(commandString, sb, 1024 * 1024, IntPtr.Zero);
|
||||
|
||||
if (result != 0)
|
||||
{
|
||||
var errorSb = new StringBuilder($"Error executing MCI command '{commandString}'. Error code: {result}.");
|
||||
var sb2 = new StringBuilder(128);
|
||||
|
||||
mciGetErrorString(result, sb2, 128);
|
||||
errorSb.Append($" Message: {sb2}");
|
||||
|
||||
throw new Exception(errorSb.ToString());
|
||||
}
|
||||
|
||||
if (commandString.ToLower().StartsWith("status") && int.TryParse(sb.ToString(), out var length))
|
||||
_playbackTimer.Interval = length + 75;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetVolume(byte percent)
|
||||
{
|
||||
// Calculate the volume that's being set
|
||||
int NewVolume = ushort.MaxValue / 100 * percent;
|
||||
// Set the same volume for both the left and the right channels
|
||||
uint NewVolumeAllChannels = ((uint)NewVolume & 0x0000ffff) | ((uint)NewVolume << 16);
|
||||
// Set the volume
|
||||
waveOutSetVolume(IntPtr.Zero, NewVolumeAllChannels);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop().Wait();
|
||||
ExecuteMciCommand("Close All");
|
||||
}
|
||||
}
|
||||
}
|
72
ObservatoryHerald/ObservatoryAPI.Designer.cs
generated
Normal file
72
ObservatoryHerald/ObservatoryAPI.Designer.cs
generated
Normal file
@ -0,0 +1,72 @@
|
||||
//------------------------------------------------------------------------------
|
||||
// <auto-generated>
|
||||
// This code was generated by a tool.
|
||||
// Runtime Version:4.0.30319.42000
|
||||
//
|
||||
// Changes to this file may cause incorrect behavior and will be lost if
|
||||
// the code is regenerated.
|
||||
// </auto-generated>
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
namespace Observatory.Herald {
|
||||
using System;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A strongly-typed resource class, for looking up localized strings, etc.
|
||||
/// </summary>
|
||||
// This class was auto-generated by the StronglyTypedResourceBuilder
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class ObservatoryAPI {
|
||||
|
||||
private static global::System.Resources.ResourceManager resourceMan;
|
||||
|
||||
private static global::System.Globalization.CultureInfo resourceCulture;
|
||||
|
||||
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
|
||||
internal ObservatoryAPI() {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached ResourceManager instance used by this class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Resources.ResourceManager ResourceManager {
|
||||
get {
|
||||
if (object.ReferenceEquals(resourceMan, null)) {
|
||||
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Observatory.Herald.ObservatoryAPI", typeof(ObservatoryAPI).Assembly);
|
||||
resourceMan = temp;
|
||||
}
|
||||
return resourceMan;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides the current thread's CurrentUICulture property for all
|
||||
/// resource lookups using this strongly typed resource class.
|
||||
/// </summary>
|
||||
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
|
||||
internal static global::System.Globalization.CultureInfo Culture {
|
||||
get {
|
||||
return resourceCulture;
|
||||
}
|
||||
set {
|
||||
resourceCulture = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to e698cf7e-0fbc-4e9a-b6a4-2480381afcc1.
|
||||
/// </summary>
|
||||
internal static string ApiKey {
|
||||
get {
|
||||
return ResourceManager.GetString("ApiKey", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
123
ObservatoryHerald/ObservatoryAPI.resx
Normal file
123
ObservatoryHerald/ObservatoryAPI.resx
Normal file
@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="ApiKey" xml:space="preserve">
|
||||
<value />
|
||||
</data>
|
||||
</root>
|
@ -11,30 +11,37 @@
|
||||
<AssemblyVersion Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</AssemblyVersion>
|
||||
<Version Condition=" '$(VersionSuffix)' == '' ">0.0.1.0</Version>
|
||||
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</Version>
|
||||
<RootNamespace>Observatory.Herald</RootNamespace>
|
||||
</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>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="ObservatoryAPI.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>ObservatoryAPI.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="ObservatoryAPI.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>ObservatoryAPI.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="NetCoreAudio\Players\" />
|
||||
</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>
|
||||
|
295
ObservatoryHerald/SpeechRequestManager.cs
Normal file
295
ObservatoryHerald/SpeechRequestManager.cs
Normal file
@ -0,0 +1,295 @@
|
||||
using Observatory.Framework;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Observatory.Herald
|
||||
{
|
||||
class SpeechRequestManager
|
||||
{
|
||||
private HttpClient httpClient;
|
||||
private string ApiKey;
|
||||
private string ApiEndpoint;
|
||||
private DirectoryInfo cacheLocation;
|
||||
private int cacheSize;
|
||||
|
||||
internal SpeechRequestManager(HeraldSettings settings, HttpClient httpClient, string cacheFolder)
|
||||
{
|
||||
ApiKey = ObservatoryAPI.ApiKey;
|
||||
ApiEndpoint = settings.ApiEndpoint;
|
||||
this.httpClient = httpClient;
|
||||
cacheSize = Math.Max(settings.CacheSize, 1);
|
||||
cacheLocation = new DirectoryInfo(cacheFolder);
|
||||
|
||||
if (!Directory.Exists(cacheLocation.FullName))
|
||||
{
|
||||
Directory.CreateDirectory(cacheLocation.FullName);
|
||||
}
|
||||
|
||||
settings.Voices = PopulateVoiceSettingOptions();
|
||||
}
|
||||
|
||||
internal async Task<string> GetAudioFileFromSsml(string ssml, string voice, string style, string rate)
|
||||
{
|
||||
|
||||
ssml = AddVoiceToSsml(ssml, voice, style, rate);
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
var ssmlHash = BitConverter.ToString(
|
||||
sha.ComputeHash(Encoding.UTF8.GetBytes(ssml))
|
||||
).Replace("-", string.Empty);
|
||||
|
||||
var audioFilename = cacheLocation + ssmlHash + ".mp3";
|
||||
|
||||
if (!File.Exists(audioFilename))
|
||||
{
|
||||
using StringContent request = new(ssml)
|
||||
{
|
||||
Headers = {
|
||||
{ "obs-plugin", "herald" },
|
||||
{ "api-key", ApiKey }
|
||||
}
|
||||
};
|
||||
|
||||
using var response = await httpClient.PostAsync(ApiEndpoint + "/Speak", request);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
using FileStream fileStream = new FileStream(audioFilename, FileMode.CreateNew);
|
||||
response.Content.ReadAsStream().CopyTo(fileStream);
|
||||
fileStream.Close();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new PluginException("Herald", "Unable to retrieve audio data.", new Exception(response.StatusCode.ToString() + ": " + response.ReasonPhrase));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
UpdateAndPruneCache(new FileInfo(audioFilename));
|
||||
|
||||
return audioFilename;
|
||||
}
|
||||
|
||||
private static string AddVoiceToSsml(string ssml, string voiceName, string styleName, string rate)
|
||||
{
|
||||
XmlDocument ssmlDoc = new();
|
||||
ssmlDoc.LoadXml(ssml);
|
||||
|
||||
var ssmlNamespace = ssmlDoc.DocumentElement.NamespaceURI;
|
||||
XmlNamespaceManager ssmlNs = new(ssmlDoc.NameTable);
|
||||
ssmlNs.AddNamespace("ssml", ssmlNamespace);
|
||||
ssmlNs.AddNamespace("mstts", "http://www.w3.org/2001/mstts");
|
||||
|
||||
var voiceNode = ssmlDoc.SelectSingleNode("/ssml:speak/ssml:voice", ssmlNs);
|
||||
voiceNode.Attributes.GetNamedItem("name").Value = voiceName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(styleName))
|
||||
{
|
||||
var expressAsNode = ssmlDoc.CreateElement("express-as", "http://www.w3.org/2001/mstts");
|
||||
expressAsNode.SetAttribute("style", styleName);
|
||||
expressAsNode.InnerXml = voiceNode.InnerXml;
|
||||
voiceNode.InnerXml = expressAsNode.OuterXml;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rate))
|
||||
{
|
||||
var prosodyNode = ssmlDoc.CreateElement("prosody", ssmlNamespace);
|
||||
prosodyNode.SetAttribute("rate", rate);
|
||||
prosodyNode.InnerXml = voiceNode.InnerXml;
|
||||
voiceNode.InnerXml = prosodyNode.OuterXml;
|
||||
}
|
||||
|
||||
return ssmlDoc.OuterXml;
|
||||
}
|
||||
|
||||
private Dictionary<string, object> PopulateVoiceSettingOptions()
|
||||
{
|
||||
Dictionary<string, object> voices = new();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, ApiEndpoint + "/List")
|
||||
{
|
||||
Headers = {
|
||||
{ "obs-plugin", "herald" },
|
||||
{ "api-key", ApiKey }
|
||||
}
|
||||
};
|
||||
|
||||
var response = httpClient.SendAsync(request).Result;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var voiceJson = JsonDocument.Parse(response.Content.ReadAsStringAsync().Result);
|
||||
|
||||
var englishSpeakingVoices = from v in voiceJson.RootElement.EnumerateArray()
|
||||
where v.GetProperty("Locale").GetString().StartsWith("en-")
|
||||
select v;
|
||||
|
||||
foreach(var voice in englishSpeakingVoices)
|
||||
{
|
||||
string demonym = GetDemonymFromLocale(voice.GetProperty("Locale").GetString());
|
||||
|
||||
voices.Add(
|
||||
demonym + " - " + voice.GetProperty("LocalName").GetString(),
|
||||
voice);
|
||||
|
||||
if (voice.TryGetProperty("StyleList", out var styles))
|
||||
foreach (var style in styles.EnumerateArray())
|
||||
{
|
||||
voices.Add(
|
||||
demonym + " - " + voice.GetProperty("LocalName").GetString() + " - " + style.GetString(),
|
||||
voice);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new PluginException("Herald", "Unable to retrieve available voices.", new Exception(response.StatusCode.ToString() + ": " + response.ReasonPhrase));
|
||||
}
|
||||
|
||||
return voices;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private void UpdateAndPruneCache(FileInfo currentFile)
|
||||
{
|
||||
Dictionary<string, CacheData> cacheIndex;
|
||||
|
||||
string cacheIndexFile = cacheLocation + "CacheIndex.json";
|
||||
|
||||
if (File.Exists(cacheIndexFile))
|
||||
{
|
||||
var indexFileContent = File.ReadAllText(cacheIndexFile);
|
||||
try
|
||||
{
|
||||
cacheIndex = JsonSerializer.Deserialize<Dictionary<string, CacheData>>(indexFileContent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex.Message);
|
||||
cacheIndex = new();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cacheIndex = new();
|
||||
}
|
||||
|
||||
// Re-index orphaned files in event of corrupted or lost index.
|
||||
var cacheFiles = cacheLocation.GetFiles("*.mp3");
|
||||
foreach (var file in cacheFiles.Where(file => !cacheIndex.ContainsKey(file.Name)))
|
||||
{
|
||||
cacheIndex.Add(file.Name, new(file.CreationTime, 0));
|
||||
};
|
||||
|
||||
if (cacheIndex.ContainsKey(currentFile.Name))
|
||||
{
|
||||
cacheIndex[currentFile.Name] = new(
|
||||
cacheIndex[currentFile.Name].Created,
|
||||
cacheIndex[currentFile.Name].HitCount + 1
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
cacheIndex.Add(currentFile.Name, new(DateTime.UtcNow, 1));
|
||||
}
|
||||
|
||||
var currentCacheSize = cacheFiles.Sum(f => f.Length);
|
||||
while (currentCacheSize > cacheSize * 1024 * 1024)
|
||||
{
|
||||
|
||||
var staleFile = (from file in cacheIndex
|
||||
orderby file.Value.HitCount, file.Value.Created
|
||||
select file.Key).First();
|
||||
|
||||
if (staleFile == currentFile.Name)
|
||||
break;
|
||||
|
||||
currentCacheSize -= new FileInfo(cacheLocation + staleFile).Length;
|
||||
File.Delete(cacheLocation + staleFile);
|
||||
cacheIndex.Remove(staleFile);
|
||||
}
|
||||
|
||||
File.WriteAllText(cacheIndexFile, JsonSerializer.Serialize(cacheIndex));
|
||||
|
||||
// Purge cache from earlier versions, if still present.
|
||||
var legacyCache = cacheLocation.GetFiles("*.wav");
|
||||
Array.ForEach(legacyCache, file => File.Delete(file.FullName));
|
||||
|
||||
}
|
||||
|
||||
public class CacheData
|
||||
{
|
||||
public CacheData(DateTime Created, int HitCount)
|
||||
{
|
||||
this.Created = Created;
|
||||
this.HitCount = HitCount;
|
||||
}
|
||||
public DateTime Created { get; set; }
|
||||
public int HitCount { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user