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
@ -31,9 +31,16 @@ namespace Observatory.NativeNotification
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async void ProcessQueueAsync()
|
private async void ProcessQueueAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await Task.Factory.StartNew(ProcessQueue);
|
await Task.Factory.StartNew(ProcessQueue);
|
||||||
}
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
ObservatoryCore.LogError(ex, " - Native Voice Notifier");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ProcessQueue()
|
private void ProcessQueue()
|
||||||
{
|
{
|
||||||
|
@ -21,18 +21,22 @@ namespace Observatory
|
|||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogError(ex, version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static void LogError(Exception ex, string context)
|
||||||
{
|
{
|
||||||
var docPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
|
var docPath = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments);
|
||||||
var errorMessage = new System.Text.StringBuilder();
|
var errorMessage = new System.Text.StringBuilder();
|
||||||
errorMessage
|
errorMessage
|
||||||
.AppendLine($"Error encountered in Elite Observatory {version}.")
|
.AppendLine($"Error encountered in Elite Observatory {context}.")
|
||||||
.AppendLine(FormatExceptionMessage(ex))
|
.AppendLine(FormatExceptionMessage(ex))
|
||||||
.AppendLine();
|
.AppendLine();
|
||||||
System.IO.File.AppendAllText(docPath + System.IO.Path.DirectorySeparatorChar + "ObservatoryErrorLog.txt", errorMessage.ToString());
|
System.IO.File.AppendAllText(docPath + System.IO.Path.DirectorySeparatorChar + "ObservatoryErrorLog.txt", errorMessage.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
static string FormatExceptionMessage(Exception ex, bool inner = false)
|
static string FormatExceptionMessage(Exception ex, bool inner = false)
|
||||||
{
|
{
|
||||||
var errorMessage = new System.Text.StringBuilder();
|
var errorMessage = new System.Text.StringBuilder();
|
||||||
|
@ -3,6 +3,7 @@ using Observatory.Framework.Files;
|
|||||||
using Observatory.Framework.Interfaces;
|
using Observatory.Framework.Interfaces;
|
||||||
using Observatory.NativeNotification;
|
using Observatory.NativeNotification;
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
namespace Observatory.PluginManagement
|
namespace Observatory.PluginManagement
|
||||||
{
|
{
|
||||||
@ -145,5 +146,21 @@ namespace Observatory.PluginManagement
|
|||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler<NotificationArgs> Notification;
|
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.
|
/// Returns true if the current LogMonitor state represents a batch-read mode.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsLogMonitorBatchReading { get; }
|
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.
|
Specifies the desired renderings of the notification.
|
||||||
</summary>
|
</summary>
|
||||||
</member>
|
</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">
|
<member name="T:Observatory.Framework.PluginException">
|
||||||
<summary>
|
<summary>
|
||||||
Container for exceptions within plugins which cannot be gracefully handled in context,
|
Container for exceptions within plugins which cannot be gracefully handled in context,
|
||||||
@ -760,6 +782,12 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<param name="status">Player status.json content, deserialized into a .NET object.</param>
|
<param name="status">Player status.json content, deserialized into a .NET object.</param>
|
||||||
</member>
|
</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">
|
<member name="M:Observatory.Framework.Interfaces.IObservatoryWorker.ReadAllStarted">
|
||||||
<summary>
|
<summary>
|
||||||
Method called when the user begins "Read All" journal processing, before any journal events are sent.<br/>
|
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.
|
Shared application HttpClient object. Provided so that plugins can adhere to .NET recommended behaviour of a single HttpClient object per application.
|
||||||
</summary>
|
</summary>
|
||||||
</member>
|
</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">
|
<member name="T:Observatory.Framework.PluginUI">
|
||||||
<summary>
|
<summary>
|
||||||
Class permitting plugins to provide their UI, if any, to Observatory Core.
|
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 Observatory.Framework.Interfaces;
|
||||||
using System;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Observatory.Herald
|
namespace Observatory.Herald
|
||||||
{
|
{
|
||||||
@ -9,11 +8,19 @@ namespace Observatory.Herald
|
|||||||
{
|
{
|
||||||
public HeraldNotifier()
|
public HeraldNotifier()
|
||||||
{
|
{
|
||||||
heraldSettings = new()
|
heraldSettings = DefaultSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeraldSettings DefaultSettings
|
||||||
|
{
|
||||||
|
get => new HeraldSettings()
|
||||||
{
|
{
|
||||||
SelectedVoice = "American - Christopher",
|
SelectedVoice = "American - Christopher",
|
||||||
AzureAPIKeyOverride = string.Empty,
|
SelectedRate = "Default",
|
||||||
Enabled = false
|
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 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)
|
public void Load(IObservatoryCore observatoryCore)
|
||||||
{
|
{
|
||||||
var azureManager = new VoiceSpeechManager(heraldSettings, observatoryCore.HttpClient);
|
var speechManager = new SpeechRequestManager(heraldSettings, observatoryCore.HttpClient, observatoryCore.PluginStorageFolder);
|
||||||
heraldSpeech = new HeraldQueue(azureManager);
|
heraldSpeech = new HeraldQueue(speechManager);
|
||||||
heraldSettings.Test = TestVoice;
|
heraldSettings.Test = TestVoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +67,9 @@ namespace Observatory.Herald
|
|||||||
Detail = $"This is {heraldSettings.SelectedVoice.Split(" - ")[1]}."
|
Detail = $"This is {heraldSettings.SelectedVoice.Split(" - ")[1]}."
|
||||||
},
|
},
|
||||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice),
|
||||||
|
heraldSettings.Rate[heraldSettings.SelectedRate].ToString(),
|
||||||
|
heraldSettings.Volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnNotificationEvent(NotificationArgs notificationEventArgs)
|
public void OnNotificationEvent(NotificationArgs notificationEventArgs)
|
||||||
@ -52,13 +78,15 @@ namespace Observatory.Herald
|
|||||||
heraldSpeech.Enqueue(
|
heraldSpeech.Enqueue(
|
||||||
notificationEventArgs,
|
notificationEventArgs,
|
||||||
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
GetAzureNameFromSetting(heraldSettings.SelectedVoice),
|
||||||
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice));
|
GetAzureStyleNameFromSetting(heraldSettings.SelectedVoice),
|
||||||
|
heraldSettings.Rate[heraldSettings.SelectedRate].ToString(),
|
||||||
|
heraldSettings.Volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetAzureNameFromSetting(string settingName)
|
private string GetAzureNameFromSetting(string settingName)
|
||||||
{
|
{
|
||||||
var voiceInfo = (VoiceInfo)heraldSettings.Voices[settingName];
|
var voiceInfo = (JsonElement)heraldSettings.Voices[settingName];
|
||||||
return voiceInfo.Name;
|
return voiceInfo.GetProperty("ShortName").GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetAzureStyleNameFromSetting(string settingName)
|
private string GetAzureStyleNameFromSetting(string settingName)
|
||||||
|
@ -13,22 +13,32 @@ namespace Observatory.Herald
|
|||||||
private bool processing;
|
private bool processing;
|
||||||
private string voice;
|
private string voice;
|
||||||
private string style;
|
private string style;
|
||||||
private VoiceSpeechManager azureCacheManager;
|
private string rate;
|
||||||
|
private byte volume;
|
||||||
|
private SpeechRequestManager speechManager;
|
||||||
private Player audioPlayer;
|
private Player audioPlayer;
|
||||||
|
|
||||||
public HeraldQueue(VoiceSpeechManager azureCacheManager)
|
public HeraldQueue(SpeechRequestManager speechManager)
|
||||||
{
|
{
|
||||||
this.azureCacheManager = azureCacheManager;
|
this.speechManager = speechManager;
|
||||||
processing = false;
|
processing = false;
|
||||||
notifications = new();
|
notifications = new();
|
||||||
audioPlayer = 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;
|
voice = selectedVoice;
|
||||||
style = selectedStyle;
|
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);
|
notifications.Enqueue(notification);
|
||||||
|
|
||||||
if (!processing)
|
if (!processing)
|
||||||
@ -45,50 +55,60 @@ namespace Observatory.Herald
|
|||||||
|
|
||||||
private void ProcessQueue()
|
private void ProcessQueue()
|
||||||
{
|
{
|
||||||
|
|
||||||
while (notifications.Any())
|
while (notifications.Any())
|
||||||
{
|
{
|
||||||
|
audioPlayer.SetVolume(volume).Wait();
|
||||||
var notification = notifications.Dequeue();
|
var notification = notifications.Dequeue();
|
||||||
|
|
||||||
|
Task<string>[] audioRequestTasks = new Task<string> [2];
|
||||||
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
|
if (string.IsNullOrWhiteSpace(notification.TitleSsml))
|
||||||
{
|
{
|
||||||
Speak(notification.Title);
|
audioRequestTasks[0] = RetrieveAudioToFile(notification.Title);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SpeakSsml(notification.TitleSsml);
|
audioRequestTasks[0] = RetrieveAudioSsmlToFile(notification.TitleSsml);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
|
if (string.IsNullOrWhiteSpace(notification.DetailSsml))
|
||||||
{
|
{
|
||||||
Speak(notification.Detail);
|
audioRequestTasks[1] = RetrieveAudioToFile(notification.Detail);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SpeakSsml(notification.DetailSsml);
|
audioRequestTasks[1] = RetrieveAudioSsmlToFile(notification.DetailSsml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlayAudioRequestsSequentially(audioRequestTasks);
|
||||||
}
|
}
|
||||||
|
|
||||||
processing = false;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PlayAudioRequestsSequentially(Task<string>[] requestTasks)
|
||||||
|
{
|
||||||
|
foreach (var request in requestTasks)
|
||||||
|
{
|
||||||
|
string file = request.Result;
|
||||||
|
audioPlayer.Play(file).Wait();
|
||||||
|
|
||||||
// For some reason .Wait() concludes before audio playback is complete.
|
|
||||||
audioPlayer.Play(file);
|
|
||||||
while (audioPlayer.Playing)
|
while (audioPlayer.Playing)
|
||||||
{
|
Thread.Sleep(50);
|
||||||
Thread.Sleep(20);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
using Observatory.Framework;
|
using Observatory.Framework;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace Observatory.Herald
|
namespace Observatory.Herald
|
||||||
{
|
{
|
||||||
@ -15,15 +12,40 @@ namespace Observatory.Herald
|
|||||||
[SettingDisplayName("Voice")]
|
[SettingDisplayName("Voice")]
|
||||||
[SettingBackingValue("SelectedVoice")]
|
[SettingBackingValue("SelectedVoice")]
|
||||||
[System.Text.Json.Serialization.JsonIgnore]
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
public Dictionary<string, object> Voices { get; internal set; }
|
public Dictionary<string, object> Voices {get; internal set;}
|
||||||
|
|
||||||
[SettingIgnore]
|
[SettingIgnore]
|
||||||
public string SelectedVoice { get; set; }
|
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]
|
[System.Text.Json.Serialization.JsonIgnore]
|
||||||
public Action Test { get; internal set; }
|
public Action Test { get; internal set; }
|
||||||
|
|
||||||
[SettingDisplayName("Enabled")]
|
[SettingDisplayName("Enabled")]
|
||||||
public bool Enabled { get; set; }
|
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>
|
<AssemblyVersion Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</AssemblyVersion>
|
||||||
<Version Condition=" '$(VersionSuffix)' == '' ">0.0.1.0</Version>
|
<Version Condition=" '$(VersionSuffix)' == '' ">0.0.1.0</Version>
|
||||||
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</Version>
|
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionSuffix)</Version>
|
||||||
|
<RootNamespace>Observatory.Herald</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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">
|
<Reference Include="ObservatoryFramework">
|
||||||
<HintPath>..\ObservatoryFramework\bin\Release\net5.0\ObservatoryFramework.dll</HintPath>
|
<HintPath>..\ObservatoryFramework\bin\Release\net5.0\ObservatoryFramework.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
</ItemGroup>
|
</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">
|
<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 "$(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="[ ! -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>
|
</Target>
|
||||||
|
|
||||||
</Project>
|
</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