Functions/GenXdev.Helpers/misc.cs
|
// ################################################################################
// Part of PowerShell module : GenXdev.Helpers // Original cmdlet filename : misc.cs // Original author : René Vaessen / GenXdev // Version : 3.26.2026 // ################################################################################ // Copyright (c) 2026 René Vaessen / GenXdev // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ################################################################################ using EdgeTTS.DotNet; using NAudio.Wave; namespace GenXdev.Helpers { /// <summary> /// Provides static text-to-speech instances backed by Microsoft Edge's /// neural TTS engine and NAudio for audio playback. /// </summary> public static class Misc { /// <summary> /// Default TTS synthesizer (uses Edge's default voice). /// </summary> public static EdgeTtsSynthesizer Speech = new EdgeTtsSynthesizer(); /// <summary> /// Customizable TTS synthesizer – voice, rate, volume /// and pitch can be set before speaking. /// </summary> public static EdgeTtsSynthesizer SpeechCustomized = new EdgeTtsSynthesizer(); } // ----------------------------------------------------------------------- // Voice metadata (replaces System.Speech InstalledVoice / VoiceInfo) // ----------------------------------------------------------------------- /// <summary> /// Metadata for an available Edge TTS voice. The <c>Name</c> /// property provides backward-compatibility with the old /// <c>.VoiceInfo.Name</c> pattern used in PowerShell scripts. /// </summary> public class VoiceInfo { public string ShortName { get; internal set; } public string Locale { get; internal set; } public string Gender { get; internal set; } public string Name => ShortName; // backward compat } // ----------------------------------------------------------------------- // EdgeTTS synthesizer wrapper // ----------------------------------------------------------------------- /// <summary> /// Wraps EdgeTTS.DotNet and NAudio into an API surface compatible /// with the legacy <c>SpeechSynthesizer</c> calls used by /// <c>Start-TextToSpeech</c>, <c>Stop-TextToSpeech</c> /// and <c>Get-IsSpeaking</c>. /// </summary> public class EdgeTtsSynthesizer : IDisposable { private readonly object _gate = new(); private readonly SemaphoreSlim _queueSemaphore = new(1, 1); private CancellationTokenSource _globalCancel = new(); private string _voice = "en-US-AriaNeural"; private string _rate = "+0%"; private string _volume = "+0%"; private string _pitch = "+0Hz"; private CancellationTokenSource _activeCts; private bool _disposed; /// <summary>True while audio is playing.</summary> public bool IsSpeaking { get { lock (_gate) { return _activeCts != null && !_activeCts.IsCancellationRequested; } } } /// <summary> /// String state for backward-compat ("Speaking" / "Ready"). /// </summary> public string State => IsSpeaking ? "Speaking" : "Ready"; // -- configuration setters ------------------------------------------ public void SetVoice(string voice) { lock (_gate) { _voice = voice; } } public void SetRate(string rate) { lock (_gate) { _rate = rate; } } public void SetVolume(string volume) { lock (_gate) { _volume = volume; } } public void SetPitch(string pitch) { lock (_gate) { _pitch = pitch; } } // -- voice discovery ------------------------------------------------ /// <summary>Returns all Edge TTS voices (blocks).</summary> public List<VoiceInfo> GetInstalledVoices() { var t = EdgeTTS.DotNet.Voices.ListVoicesAsync(); t.Wait(); return t.Result.Select(v => new VoiceInfo { ShortName = v.ShortName, Locale = v.Locale, Gender = v.Gender }).ToList(); } /// <summary> /// Select a voice from the list returned by /// <see cref="GetInstalledVoices"/>. /// </summary> public void SelectVoice(VoiceInfo voice) { if (voice != null) SetVoice(voice.ShortName); } // -- speak (synchronous) -------------------------------------------- /// <summary> /// Speak with configured voice/prosody. Blocks until playback /// finishes. /// </summary> public void Speak(string text) { SpeakInternal(text, wait: true); } // -- speak (asynchronous / fire-and-forget) ------------------------- /// <summary> /// Speak on a background thread. Returns immediately. /// </summary> public void SpeakAsync(string text) { string voice, rate, volume, pitch; CancellationToken token; lock (_gate) { if (_disposed) throw new ObjectDisposedException( nameof(EdgeTtsSynthesizer)); voice = _voice; rate = _rate; volume = _volume; pitch = _pitch; _activeCts?.Cancel(); _activeCts?.Dispose(); _activeCts = new CancellationTokenSource(); token = _activeCts.Token; } _ = Task.Run(async () => { try { await SpeakCoreAsync(text, voice, rate, volume, pitch, wait: false, token); } catch (OperationCanceledException) { } catch (Exception ex) { System.Console.Error.WriteLine( "EdgeTTS SpeakAsync error: " + ex.Message); } finally { lock (_gate) { if (_activeCts != null && _activeCts.Token == token) { _activeCts.Dispose(); _activeCts = null; } } } }, token); } // -- speak (queued, like old System.Speech) ------------------------- /// <summary> /// Enqueue text to speak after any currently-playing speech /// finishes. Multiple calls queue up sequentially without /// cancelling each other. /// </summary> public void SpeakAsyncQueued(string text) { string voice, rate, volume, pitch; lock (_gate) { if (_disposed) throw new ObjectDisposedException( nameof(EdgeTtsSynthesizer)); voice = _voice; rate = _rate; volume = _volume; pitch = _pitch; } // Enqueue on a serial background worker so calls play in order _ = Task.Run(async () => { await _queueSemaphore.WaitAsync(); try { using var cts = CancellationTokenSource .CreateLinkedTokenSource(_globalCancel.Token); lock (_gate) { _activeCts?.Dispose(); _activeCts = cts; } await SpeakCoreAsync(text, voice, rate, volume, pitch, wait: true, cts.Token); } catch (OperationCanceledException) { } catch (Exception ex) { System.Console.Error.WriteLine( "EdgeTTS SpeakQueued error: " + ex.Message); } finally { lock (_gate) { _activeCts?.Dispose(); _activeCts = null; } _queueSemaphore.Release(); } }); } // -- cancel --------------------------------------------------------- /// <summary>Cancel all active and queued speech.</summary> public void SpeakAsyncCancelAll() { lock (_gate) { _activeCts?.Cancel(); _globalCancel.Cancel(); _globalCancel.Dispose(); _globalCancel = new CancellationTokenSource(); } } // -- internals ------------------------------------------------------ private void SpeakInternal(string text, bool wait) { string voice, rate, volume, pitch; CancellationToken token; lock (_gate) { if (_disposed) throw new ObjectDisposedException( nameof(EdgeTtsSynthesizer)); voice = _voice; rate = _rate; volume = _volume; pitch = _pitch; _activeCts?.Cancel(); _activeCts?.Dispose(); _activeCts = new CancellationTokenSource(); token = _activeCts.Token; } try { SpeakCoreAsync(text, voice, rate, volume, pitch, wait, token).GetAwaiter().GetResult(); } catch (OperationCanceledException) { } finally { lock (_gate) { if (_activeCts != null && _activeCts.Token == token) { _activeCts.Dispose(); _activeCts = null; } } } } private static async Task SpeakCoreAsync( string text, string voice, string rate, string volume, string pitch, bool wait, CancellationToken token) { string tempFile = null; try { var communicate = new Communicate( text, voice: voice, rate: rate, volume: volume, pitch: pitch); tempFile = Path.GetTempFileName() + ".mp3"; await communicate.SaveAsync(tempFile, token); if (new FileInfo(tempFile).Length == 0) { System.Console.Error.WriteLine( "EdgeTTS: No audio data received."); return; } PlayAudioFile(tempFile, token, wait); tempFile = null; // ownership transferred to PlayAudioFile } catch (OperationCanceledException) { } catch (Exception ex) { System.Console.Error.WriteLine( "EdgeTTS SpeakCore error: " + ex.Message); } finally { if (tempFile != null) { try { File.Delete(tempFile); } catch { } } } } private static void PlayAudioFile( string filePath, CancellationToken token, bool wait) { var reader = new AudioFileReader(filePath); var output = new WaveOutEvent(); output.Init(reader); if (wait) PlaySync(output, reader, filePath, token); else PlayAsync(output, reader, filePath, token); } private static void PlaySync( WaveOutEvent output, AudioFileReader reader, string filePath, CancellationToken token) { using (reader) using (output) { var stopped = new ManualResetEventSlim(false); output.PlaybackStopped += (_, _) => stopped.Set(); output.Play(); while (output.PlaybackState == PlaybackState.Playing && !token.IsCancellationRequested) { Thread.Sleep(50); } if (token.IsCancellationRequested) output.Stop(); // Wait for PlaybackStopped with generous timeout stopped.Wait(TimeSpan.FromSeconds(60)); } // Safe to delete now — playback is done try { File.Delete(filePath); } catch { } } private static void PlayAsync( WaveOutEvent output, AudioFileReader reader, string filePath, CancellationToken token) { // NO using blocks — cleanup on PlaybackStopped or cancel var capturedOutput = output; var capturedReader = reader; var capturedPath = filePath; var ctr = token.Register(() => { try { capturedOutput.Stop(); } catch { } }); capturedOutput.PlaybackStopped += (_, _) => { ctr.Dispose(); try { capturedOutput.Dispose(); } catch { } try { capturedReader.Dispose(); } catch { } try { File.Delete(capturedPath); } catch { } }; capturedOutput.Play(); } // -- IDisposable ---------------------------------------------------- public void Dispose() { lock (_gate) { if (_disposed) return; _disposed = true; _globalCancel.Cancel(); _activeCts?.Cancel(); _activeCts?.Dispose(); _activeCts = null; _globalCancel.Dispose(); _queueSemaphore.Dispose(); } } } } |