Functions/GenXdev.Console/Start-TextToSpeech.ps1

<##############################################################################
Part of PowerShell module : GenXdev.Console
Original cmdlet filename : Start-TextToSpeech.ps1
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.
################################################################################>


###############################################################################
<#
.SYNOPSIS
Converts text to speech using Microsoft Edge's neural TTS engine.

.DESCRIPTION
Uses Microsoft Edge's neural TTS engine (via EdgeTTS.DotNet) to convert text
to speech with natural-sounding voices. This function provides flexible
text-to-speech capabilities with support for different voices, locales,
prosody adjustments (rate, volume, pitch), and synchronous/asynchronous
playback options. It can handle both single strings and arrays of text.

.PARAMETER Lines
The text to be spoken. Accepts single string or array of strings. Each line
will be processed sequentially for speech synthesis.

.PARAMETER Locale
The language locale ID to use (e.g., 'en-US', 'es-ES'). When specified, the
function will attempt to use a voice matching this locale.

.PARAMETER VoiceName
The specific voice name to use for speech synthesis. If specified, the
function will attempt to find and use a matching voice from available
Edge TTS voices.

.PARAMETER Rate
The speech rate as a relative value (e.g. "+0%", "-20%", "+50%"). Positive
values speed up speech, negative values slow it down. Default is "+0%".

.PARAMETER Volume
The volume level as a relative value (e.g. "+0%", "-25%", "+100%").
Positive values increase volume, negative values decrease it.
Default is "+0%".

.PARAMETER Pitch
The pitch shift in Hz (e.g. "+0Hz", "-10Hz", "+20Hz"). Positive values
raise the pitch, negative values lower it. Default is "+0Hz".

.PARAMETER PassThru
When specified, outputs the text being spoken to the pipeline, allowing for
text processing while speaking.

.PARAMETER Wait
When specified, waits for speech to complete before continuing execution.
Useful for synchronous operations.

.EXAMPLE
Start-TextToSpeech -Lines "Hello World" -Locale "en-US" -Wait

.EXAMPLE
"Hello World" | say

.EXAMPLE
say "Hello World" -Rate "+50%" -Pitch "-5Hz"
#>

function Start-TextToSpeech {

    [CmdletBinding(SupportsShouldProcess = $true)]
    [Alias('say')]
    param(
        ########################################################################
        [Parameter(
            Mandatory = $true,
            Position = 0,
            ValueFromPipeline = $true,
            ValueFromRemainingArguments = $false,
            ParameterSetName = 'strings',
            HelpMessage = 'Text to be spoken'
        )]
        [string[]] $Lines,

        ########################################################################
        [ValidateSet(
            'af-ZA-AdriNeural',
            'af-ZA-WillemNeural',
            'am-ET-AmehaNeural',
            'am-ET-MekdesNeural',
            'ar-AE-FatimaNeural',
            'ar-AE-HamdanNeural',
            'ar-BH-AliNeural',
            'ar-BH-LailaNeural',
            'ar-DZ-AminaNeural',
            'ar-DZ-IsmaelNeural',
            'ar-EG-SalmaNeural',
            'ar-EG-ShakirNeural',
            'ar-IQ-BasselNeural',
            'ar-IQ-RanaNeural',
            'ar-JO-SanaNeural',
            'ar-JO-TaimNeural',
            'ar-KW-FahedNeural',
            'ar-KW-NouraNeural',
            'ar-LB-LaylaNeural',
            'ar-LB-RamiNeural',
            'ar-LY-ImanNeural',
            'ar-LY-OmarNeural',
            'ar-MA-JamalNeural',
            'ar-MA-MounaNeural',
            'ar-OM-AbdullahNeural',
            'ar-OM-AyshaNeural',
            'ar-QA-AmalNeural',
            'ar-QA-MoazNeural',
            'ar-SA-HamedNeural',
            'ar-SA-ZariyahNeural',
            'ar-SY-AmanyNeural',
            'ar-SY-LaithNeural',
            'ar-TN-HediNeural',
            'ar-TN-ReemNeural',
            'ar-YE-MaryamNeural',
            'ar-YE-SalehNeural',
            'az-AZ-BabekNeural',
            'az-AZ-BanuNeural',
            'bg-BG-BorislavNeural',
            'bg-BG-KalinaNeural',
            'bn-BD-NabanitaNeural',
            'bn-BD-PradeepNeural',
            'bn-IN-BashkarNeural',
            'bn-IN-TanishaaNeural',
            'bs-BA-GoranNeural',
            'bs-BA-VesnaNeural',
            'ca-ES-EnricNeural',
            'ca-ES-JoanaNeural',
            'cs-CZ-AntoninNeural',
            'cs-CZ-VlastaNeural',
            'cy-GB-AledNeural',
            'cy-GB-NiaNeural',
            'da-DK-ChristelNeural',
            'da-DK-JeppeNeural',
            'de-AT-IngridNeural',
            'de-AT-JonasNeural',
            'de-CH-JanNeural',
            'de-CH-LeniNeural',
            'de-DE-AmalaNeural',
            'de-DE-ConradNeural',
            'de-DE-FlorianMultilingualNeural',
            'de-DE-KatjaNeural',
            'de-DE-KillianNeural',
            'de-DE-SeraphinaMultilingualNeural',
            'el-GR-AthinaNeural',
            'el-GR-NestorasNeural',
            'en-AU-NatashaNeural',
            'en-AU-WilliamMultilingualNeural',
            'en-CA-ClaraNeural',
            'en-CA-LiamNeural',
            'en-GB-LibbyNeural',
            'en-GB-MaisieNeural',
            'en-GB-RyanNeural',
            'en-GB-SoniaNeural',
            'en-GB-ThomasNeural',
            'en-HK-SamNeural',
            'en-HK-YanNeural',
            'en-IE-ConnorNeural',
            'en-IE-EmilyNeural',
            'en-IN-NeerjaExpressiveNeural',
            'en-IN-NeerjaNeural',
            'en-IN-PrabhatNeural',
            'en-KE-AsiliaNeural',
            'en-KE-ChilembaNeural',
            'en-NG-AbeoNeural',
            'en-NG-EzinneNeural',
            'en-NZ-MitchellNeural',
            'en-NZ-MollyNeural',
            'en-PH-JamesNeural',
            'en-PH-RosaNeural',
            'en-SG-LunaNeural',
            'en-SG-WayneNeural',
            'en-TZ-ElimuNeural',
            'en-TZ-ImaniNeural',
            'en-US-AnaNeural',
            'en-US-AndrewMultilingualNeural',
            'en-US-AndrewNeural',
            'en-US-AriaNeural',
            'en-US-AvaMultilingualNeural',
            'en-US-AvaNeural',
            'en-US-BrianMultilingualNeural',
            'en-US-BrianNeural',
            'en-US-ChristopherNeural',
            'en-US-EmmaMultilingualNeural',
            'en-US-EmmaNeural',
            'en-US-EricNeural',
            'en-US-GuyNeural',
            'en-US-JennyNeural',
            'en-US-MichelleNeural',
            'en-US-RogerNeural',
            'en-US-SteffanNeural',
            'en-ZA-LeahNeural',
            'en-ZA-LukeNeural',
            'es-AR-ElenaNeural',
            'es-AR-TomasNeural',
            'es-BO-MarceloNeural',
            'es-BO-SofiaNeural',
            'es-CL-CatalinaNeural',
            'es-CL-LorenzoNeural',
            'es-CO-GonzaloNeural',
            'es-CO-SalomeNeural',
            'es-CR-JuanNeural',
            'es-CR-MariaNeural',
            'es-CU-BelkysNeural',
            'es-CU-ManuelNeural',
            'es-DO-EmilioNeural',
            'es-DO-RamonaNeural',
            'es-EC-AndreaNeural',
            'es-EC-LuisNeural',
            'es-ES-AlvaroNeural',
            'es-ES-ElviraNeural',
            'es-ES-XimenaNeural',
            'es-GQ-JavierNeural',
            'es-GQ-TeresaNeural',
            'es-GT-AndresNeural',
            'es-GT-MartaNeural',
            'es-HN-CarlosNeural',
            'es-HN-KarlaNeural',
            'es-MX-DaliaNeural',
            'es-MX-JorgeNeural',
            'es-NI-FedericoNeural',
            'es-NI-YolandaNeural',
            'es-PA-MargaritaNeural',
            'es-PA-RobertoNeural',
            'es-PE-AlexNeural',
            'es-PE-CamilaNeural',
            'es-PR-KarinaNeural',
            'es-PR-VictorNeural',
            'es-PY-MarioNeural',
            'es-PY-TaniaNeural',
            'es-SV-LorenaNeural',
            'es-SV-RodrigoNeural',
            'es-US-AlonsoNeural',
            'es-US-PalomaNeural',
            'es-UY-MateoNeural',
            'es-UY-ValentinaNeural',
            'es-VE-PaolaNeural',
            'es-VE-SebastianNeural',
            'et-EE-AnuNeural',
            'et-EE-KertNeural',
            'fa-IR-DilaraNeural',
            'fa-IR-FaridNeural',
            'fi-FI-HarriNeural',
            'fi-FI-NooraNeural',
            'fil-PH-AngeloNeural',
            'fil-PH-BlessicaNeural',
            'fr-BE-CharlineNeural',
            'fr-BE-GerardNeural',
            'fr-CA-AntoineNeural',
            'fr-CA-JeanNeural',
            'fr-CA-SylvieNeural',
            'fr-CA-ThierryNeural',
            'fr-CH-ArianeNeural',
            'fr-CH-FabriceNeural',
            'fr-FR-DeniseNeural',
            'fr-FR-EloiseNeural',
            'fr-FR-HenriNeural',
            'fr-FR-RemyMultilingualNeural',
            'fr-FR-VivienneMultilingualNeural',
            'ga-IE-ColmNeural',
            'ga-IE-OrlaNeural',
            'gl-ES-RoiNeural',
            'gl-ES-SabelaNeural',
            'gu-IN-DhwaniNeural',
            'gu-IN-NiranjanNeural',
            'he-IL-AvriNeural',
            'he-IL-HilaNeural',
            'hi-IN-MadhurNeural',
            'hi-IN-SwaraNeural',
            'hr-HR-GabrijelaNeural',
            'hr-HR-SreckoNeural',
            'hu-HU-NoemiNeural',
            'hu-HU-TamasNeural',
            'id-ID-ArdiNeural',
            'id-ID-GadisNeural',
            'is-IS-GudrunNeural',
            'is-IS-GunnarNeural',
            'it-IT-DiegoNeural',
            'it-IT-ElsaNeural',
            'it-IT-GiuseppeMultilingualNeural',
            'it-IT-IsabellaNeural',
            'iu-Cans-CA-SiqiniqNeural',
            'iu-Cans-CA-TaqqiqNeural',
            'iu-Latn-CA-SiqiniqNeural',
            'iu-Latn-CA-TaqqiqNeural',
            'ja-JP-KeitaNeural',
            'ja-JP-NanamiNeural',
            'jv-ID-DimasNeural',
            'jv-ID-SitiNeural',
            'ka-GE-EkaNeural',
            'ka-GE-GiorgiNeural',
            'kk-KZ-AigulNeural',
            'kk-KZ-DauletNeural',
            'km-KH-PisethNeural',
            'km-KH-SreymomNeural',
            'kn-IN-GaganNeural',
            'kn-IN-SapnaNeural',
            'ko-KR-HyunsuMultilingualNeural',
            'ko-KR-InJoonNeural',
            'ko-KR-SunHiNeural',
            'lo-LA-ChanthavongNeural',
            'lo-LA-KeomanyNeural',
            'lt-LT-LeonasNeural',
            'lt-LT-OnaNeural',
            'lv-LV-EveritaNeural',
            'lv-LV-NilsNeural',
            'mk-MK-AleksandarNeural',
            'mk-MK-MarijaNeural',
            'ml-IN-MidhunNeural',
            'ml-IN-SobhanaNeural',
            'mn-MN-BataaNeural',
            'mn-MN-YesuiNeural',
            'mr-IN-AarohiNeural',
            'mr-IN-ManoharNeural',
            'ms-MY-OsmanNeural',
            'ms-MY-YasminNeural',
            'mt-MT-GraceNeural',
            'mt-MT-JosephNeural',
            'my-MM-NilarNeural',
            'my-MM-ThihaNeural',
            'nb-NO-FinnNeural',
            'nb-NO-PernilleNeural',
            'ne-NP-HemkalaNeural',
            'ne-NP-SagarNeural',
            'nl-BE-ArnaudNeural',
            'nl-BE-DenaNeural',
            'nl-NL-ColetteNeural',
            'nl-NL-FennaNeural',
            'nl-NL-MaartenNeural',
            'pl-PL-MarekNeural',
            'pl-PL-ZofiaNeural',
            'ps-AF-GulNawazNeural',
            'ps-AF-LatifaNeural',
            'pt-BR-AntonioNeural',
            'pt-BR-FranciscaNeural',
            'pt-BR-ThalitaMultilingualNeural',
            'pt-PT-DuarteNeural',
            'pt-PT-RaquelNeural',
            'ro-RO-AlinaNeural',
            'ro-RO-EmilNeural',
            'ru-RU-DmitryNeural',
            'ru-RU-SvetlanaNeural',
            'si-LK-SameeraNeural',
            'si-LK-ThiliniNeural',
            'sk-SK-LukasNeural',
            'sk-SK-ViktoriaNeural',
            'sl-SI-PetraNeural',
            'sl-SI-RokNeural',
            'so-SO-MuuseNeural',
            'so-SO-UbaxNeural',
            'sq-AL-AnilaNeural',
            'sq-AL-IlirNeural',
            'sr-RS-NicholasNeural',
            'sr-RS-SophieNeural',
            'su-ID-JajangNeural',
            'su-ID-TutiNeural',
            'sv-SE-MattiasNeural',
            'sv-SE-SofieNeural',
            'sw-KE-RafikiNeural',
            'sw-KE-ZuriNeural',
            'sw-TZ-DaudiNeural',
            'sw-TZ-RehemaNeural',
            'ta-IN-PallaviNeural',
            'ta-IN-ValluvarNeural',
            'ta-LK-KumarNeural',
            'ta-LK-SaranyaNeural',
            'ta-MY-KaniNeural',
            'ta-MY-SuryaNeural',
            'ta-SG-AnbuNeural',
            'ta-SG-VenbaNeural',
            'te-IN-MohanNeural',
            'te-IN-ShrutiNeural',
            'th-TH-NiwatNeural',
            'th-TH-PremwadeeNeural',
            'tr-TR-AhmetNeural',
            'tr-TR-EmelNeural',
            'uk-UA-OstapNeural',
            'uk-UA-PolinaNeural',
            'ur-IN-GulNeural',
            'ur-IN-SalmanNeural',
            'ur-PK-AsadNeural',
            'ur-PK-UzmaNeural',
            'uz-UZ-MadinaNeural',
            'uz-UZ-SardorNeural',
            'vi-VN-HoaiMyNeural',
            'vi-VN-NamMinhNeural',
            'zh-Hans-CN-XiaoxiaoNeural',
            'zh-Hans-CN-XiaoyiNeural',
            'zh-Hans-CN-YunjianNeural',
            'zh-Hans-CN-YunxiNeural',
            'zh-Hans-CN-YunxiaNeural',
            'zh-Hans-CN-YunyangNeural',
            'zh-Hans-CN-liaoning-XiaobeiNeural',
            'zh-Hans-CN-shaanxi-XiaoniNeural',
            'zh-HK-HiuGaaiNeural',
            'zh-HK-HiuMaanNeural',
            'zh-HK-WanLungNeural',
            'zh-TW-HsiaoChenNeural',
            'zh-TW-HsiaoYuNeural',
            'zh-TW-YunJheNeural',
            'zu-ZA-ThandoNeural',
            'zu-ZA-ThembaNeural'
        )]
        [Parameter(
            Mandatory = $false,
            Position = 1,
            HelpMessage = 'Name of the voice to use for speech'
        )]
        [string] $VoiceName = $null,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = "The language locale id to use, e.g. 'en-US'"
        )]
        [string] $Locale = $null,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Speech rate, e.g. "+0%", "-20%", "+50%"'
        )]
        [string] $Rate = $null,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Volume level, e.g. "+0%", "-25%", "+100%"'
        )]
        [string] $Volume = $null,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Pitch shift, e.g. "+0Hz", "-10Hz", "+20Hz"'
        )]
        [string] $Pitch = $null,
        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Output the text being spoken to the pipeline'
        )]
        [Alias('pt')]
        [switch]$PassThru,

        ########################################################################
        [Parameter(
            Mandatory = $false,
            HelpMessage = 'Wait for speech to complete before continuing'
        )]
        [switch] $Wait
        ########################################################################
    )

    begin {

        Microsoft.PowerShell.Utility\Write-Verbose (
            "Initializing text-to-speech with Locale: $Locale, " +
            "Voice: $VoiceName, Rate: $Rate, Volume: $Volume, " +
            "Pitch: $Pitch")

        # Configure prosody on the customized synthesizer
        if ($Rate) {
            [GenXdev.Helpers.Misc]::SpeechCustomized.SetRate($Rate)
        }
        if ($Volume) {
            [GenXdev.Helpers.Misc]::SpeechCustomized.SetVolume($Volume)
        }
        if ($Pitch) {
            [GenXdev.Helpers.Misc]::SpeechCustomized.SetPitch($Pitch)
        }
    }


    process {

        @($Lines) | Microsoft.PowerShell.Core\ForEach-Object {

            $text = $PSItem

            if ($text -isnot [string]) { $text = "$text" }

            try {
                if ($PassThru) { $text }

                $text = $text.Replace("`r", ' ').Replace("`n", ' ').
                Replace("`t", ' ')
                Microsoft.PowerShell.Utility\Write-Verbose (
                    "Processing text: $text")

                # Resolve voice: locale + voice name matching
                if (-not [string]::IsNullOrWhiteSpace($VoiceName) -or
                    -not [string]::IsNullOrWhiteSpace($Locale)) {

                    $voices = [GenXdev.Helpers.Misc]::SpeechCustomized.
                    GetInstalledVoices()

                    if (-not [string]::IsNullOrWhiteSpace($Locale)) {
                        $voices = $voices |
                            Microsoft.PowerShell.Core\Where-Object {
                                $PSItem.Locale -like "$Locale*"
                            }
                        }

                        if (-not [string]::IsNullOrWhiteSpace($VoiceName)) {
                            $voices = $voices |
                                Microsoft.PowerShell.Core\Where-Object {
                                    $PSItem.Name -like "*$VoiceName*"
                                }
                            }

                            $voice = $voices |
                                Microsoft.PowerShell.Utility\Select-Object -First 1

                                if ($voice) {
                                    [GenXdev.Helpers.Misc]::SpeechCustomized.
                                    SelectVoice($voice)
                                    Microsoft.PowerShell.Utility\Write-Verbose (
                                        "Selected voice: $($voice.Name)")
                                }
                                else {
                                    Microsoft.PowerShell.Utility\Write-Warning (
                                        "No matching voice found for Locale=$Locale" +
                                        " VoiceName=$VoiceName")
                                }
                            }

                            # Speak via the customized synthesizer
                            if ($PSCmdlet.ShouldProcess($text, 'Speak')) {
                                if ($Wait) {
                                    $null = [GenXdev.Helpers.Misc]::SpeechCustomized.
                                    Speak($text)
                                }
                                else {
                                    $null = [GenXdev.Helpers.Misc]::SpeechCustomized.
                                    SpeakAsyncQueued($text)
                                }
                            }
                        }
                        catch {
                            Microsoft.PowerShell.Utility\Write-Error (
                                "Speech synthesis failed: $($PSItem.Exception.Message)")
                        }
                    }
                }

                end {
                }
            }