Functions/GenXdev.Helpers/misc.cs
|
// ################################################################################
// Part of PowerShell module : GenXdev.Helpers // Original cmdlet filename : misc.cs // Original author : René Vaessen / GenXdev // Version : 3.28.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 System.Collections.Concurrent; 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(); /// <summary> /// Emergency TTS synthesizer – used by <c>Start-TextToSpeech -Force</c> /// to speak urgent text without disturbing the main queue. /// </summary> public static EdgeTtsSynthesizer EmergencySpeech = 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 { /// <summary>Lightweight record for queued speech items.</summary> private readonly record struct SpeechItem( string Text, string Voice, string Rate, string Volume, string Pitch); private readonly object _gate = new(); // -- queue-based speech (replaces _queueSemaphore + _globalCancel) -- private readonly ConcurrentQueue<SpeechItem> _speechQueue = new(); private readonly SemaphoreSlim _queueSignal = new(0, int.MaxValue); private CancellationTokenSource _queueCts; private bool _queueActive; // guarded by _gate // -- settings / state -- private string _voice = "en-US-AriaNeural"; private string _rate = "+0%"; private string _volume = "+0%"; private string _pitch = "+0Hz"; private CancellationTokenSource _activeCts; private WaveOutEvent _currentOutput; // guarded by _gate 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; } } /// <summary> /// Copies voice/prosody settings to another synthesizer. /// </summary> public void CopySettingsTo(EdgeTtsSynthesizer other) { string v, r, vol, p; lock (_gate) { v = _voice; r = _rate; vol = _volume; p = _pitch; } other.SetVoice(v); other.SetRate(r); other.SetVolume(vol); other.SetPitch(p); } // -- 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); } // -- text splitting ----------------------------------------------- /// <summary> /// Splits text on sentence boundaries (., !, ?) and line-breaks, /// trims each chunk, and discards empty ones. This keeps Edge TTS /// responsive by avoiding huge single MP3 files. /// </summary> public static IEnumerable<string> SplitText(string text) { if (string.IsNullOrWhiteSpace(text)) yield break; var chunks = System.Text.RegularExpressions.Regex.Split( text, @"(?<=[.!?])\s+|\r?\n"); foreach (var chunk in chunks) { var trimmed = chunk.Trim(); if (trimmed.Length > 0) yield return trimmed; } } // -- speak (synchronous) -------------------------------------------- /// <summary> /// Speak with configured voice/prosody. Blocks until playback /// finishes. The input text is split on sentence boundaries /// for responsiveness. /// </summary> public void Speak(string text) { foreach (var chunk in SplitText(text)) SpeakInternal(chunk, 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. A single background processor drains /// the queue so that <see cref="SpeakAsyncCancelAll"/> can /// atomically clear it. /// The input text is split on sentence boundaries so each /// chunk is a small, quick-to-cancel MP3. /// </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; } int count = 0; foreach (var chunk in SplitText(text)) { _speechQueue.Enqueue( new SpeechItem(chunk, voice, rate, volume, pitch)); count++; } if (count > 0) { // One signal is enough — the processor drains all items // in its inner while (TryDequeue) loop once woken. _queueSignal.Release(); EnsureQueueProcessor(); } } /// <summary> /// Starts the single background queue processor if it isn't /// already running. Caller must NOT hold <c>_gate</c>. /// </summary> private void EnsureQueueProcessor() { lock (_gate) { if (_queueActive || _disposed) return; _queueActive = true; _queueCts = new CancellationTokenSource(); var myCts = _queueCts; // capture for finally check var token = _queueCts.Token; _ = Task.Run(() => ProcessQueueAsync(token, myCts)); } } /// <summary> /// Single background loop that drains <c>_speechQueue</c> /// one item at a time. Exits when <paramref name="token"/> /// is cancelled. /// </summary> private async Task ProcessQueueAsync( CancellationToken token, CancellationTokenSource myCts) { try { while (!token.IsCancellationRequested) { await _queueSignal.WaitAsync(token); while (_speechQueue.TryDequeue(out var item)) { if (token.IsCancellationRequested) break; using var cts = CancellationTokenSource .CreateLinkedTokenSource(token); lock (_gate) { _activeCts?.Dispose(); _activeCts = cts; } try { await SpeakCoreAsync( item.Text, item.Voice, item.Rate, item.Volume, item.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; } } } } } catch (OperationCanceledException) { } finally { // Only clean up if WE are still the active processor. // Prevents a stale finally from clobbering a new // processor started after a cancel+enqueue race. lock (_gate) { if (_queueCts == myCts) { _queueActive = false; _queueCts?.Dispose(); _queueCts = null; } } } } // -- pause / resume ------------------------------------------------- /// <summary>Pause the currently playing audio, if any.</summary> public void PauseSpeech() { lock (_gate) { _currentOutput?.Pause(); } } /// <summary>Resume previously paused audio, if any.</summary> public void ResumeSpeech() { lock (_gate) { _currentOutput?.Play(); } } // -- cancel / clear ------------------------------------------------- /// <summary> /// Cancel the currently-playing item (if any), immediately stop /// audio output, and atomically clear the internal queue and stop /// the background processor. /// </summary> public void StopAndClear() { CancellationTokenSource capturedQueueCts; lock (_gate) { _activeCts?.Cancel(); // Stop NAudio playback immediately — don't wait for the // token-polling loop in PlaySync. _currentOutput?.Stop(); capturedQueueCts = DrainAndStopQueue(); } capturedQueueCts?.Cancel(); capturedQueueCts?.Dispose(); } /// <summary> /// Cancel all active and queued speech. Atomically clears the /// internal queue, cancels the active item, and stops the /// background processor. /// </summary> public void SpeakAsyncCancelAll() => StopAndClear(); /// <summary> /// Pauses any currently playing speech on this instance, speaks /// <paramref name="text"/> on a <b>separate emergency instance</b> /// (blocking), then resumes the paused speech. The internal /// queue on this instance is left completely untouched. /// </summary> public void InterruptAndSpeak(string text) { // 1. Pause any audio currently playing on this instance. PauseSpeech(); // Let the pause settle before using the audio device. System.Threading.Thread.Sleep(1000); try { // 2. Copy voice/prosody settings to the emergency // synthesizer — it is a completely independent // instance so the main queue keeps running. CopySettingsTo(Misc.EmergencySpeech); // 3. Speak the forced text synchronously on the // emergency instance (blocking). Misc.EmergencySpeech.Speak(text); } finally { // 4. Brief silence so the interjection is clearly // separated from the resumed speech. System.Threading.Thread.Sleep(2000); // 5. Resume whatever was paused. ResumeSpeech(); } } // -- helpers -------------------------------------------------------- /// <summary> /// Drains <c>_speechQueue</c>, sets <c>_queueActive=false</c>, /// nulls <c>_queueCts</c>. MUST be called under <c>_gate</c>. /// Returns the old <c>_queueCts</c> so the caller can cancel /// and dispose it OUTSIDE the lock. /// </summary> private CancellationTokenSource DrainAndStopQueue() { _speechQueue.Clear(); var old = _queueCts; _queueActive = false; _queueCts = null; return old; } // -- 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 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 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 void PlaySync( WaveOutEvent output, AudioFileReader reader, string filePath, CancellationToken token) { // Track the output so PauseSpeech/ResumeSpeech can control it. lock (_gate) { _currentOutput = output; } try { using (reader) { 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)); } } finally { lock (_gate) { _currentOutput = null; } output.Dispose(); } // Safe to delete now — playback is done try { File.Delete(filePath); } catch { } } private void PlayAsync( WaveOutEvent output, AudioFileReader reader, string filePath, CancellationToken token) { // Track the output so PauseSpeech/ResumeSpeech can control it. lock (_gate) { _currentOutput = output; } // 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 { } lock (_gate) { _currentOutput = null; } }; capturedOutput.Play(); } // -- IDisposable ---------------------------------------------------- public void Dispose() { CancellationTokenSource capturedQueueCts; lock (_gate) { if (_disposed) return; _disposed = true; _activeCts?.Cancel(); capturedQueueCts = DrainAndStopQueue(); } capturedQueueCts?.Cancel(); _activeCts?.Dispose(); _activeCts = null; capturedQueueCts?.Dispose(); _queueSignal.Dispose(); } } } |