Convert-TextToSpeech.ps1

function Convert-TextToSpeech
{
  <#
      .SYNOPSIS
      Converts text to speech. Requires TTS engine (Windows)
 
      .DESCRIPTION
      Converts text to speech using any of the installed TTS voices.
      Speech is outputted to the default audio device unless a path to a wav file is specified. In this case, the voice is recorded to file.
 
      .EXAMPLE
      Convert-TextToSpeech -Culture en-us -Text 'this is english'
      Outputs English text
 
      .EXAMPLE
      Convert-TextToSpeech -Culture de-de -Text 'dies ist deutsch'
      Outputs German text (make sure the German TTS voice is installed. You may have to install the German voice pack in Windows.
 
      .EXAMPLE
      'This is a test' | Convert-TextToSpeech -Culture en-us -OutputPath $env:temp\recording.wav -PassThru | Invoke-Item
      Record a file "recording.wav" in the temp folder, then play the file back in your default media player
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture en-us -Volume 100
      Counts from 1 to 10 with an English voice
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture de-de -Volume 100
      Counts from 1 to 10 with a German voice
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Volume 100 -Voice 'Microsoft Zira Desktop'
      Counts from 1 to 10 using the e-us female adult Zira voice (make sure the voice is installed, or choose a different voice)
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture en-us -Volume 100 -PassThru -OutputPath c:\counterFiles
      Create 10 files, named 1.wav to 10.wav, and store in c:\counterfiles. Output the generated files to the console.
 
      .EXAMPLE
      1..4 | Convert-TextToSpeech -Culture en-us -Volume 100 -PassThru -OutputPath c:\counterFiles | Get-AudioFileInfo
      Create four voice recordings as wav files, and output the audio codec and bitrate details
 
      .EXAMPLE
      1..3 | Convert-TextToSpeech -Culture en-us -PassThru -OutputPath { "c:\testFiles\{0:d3}.wav" -f $_ } -Text { "File $_ WAV Format" } | Foreach-Object { $_ | Invoke-Item; Start-Sleep -Seconds 2 }
      Creates three files named "001.wav" to "003.wav", which contain the english narration of "File xx WAV Format", where xx is a number between 1 and 10, and play the first 2 seconds of each file in your media player.
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture en-us -PassThru -OutputPath { "c:\testFiles\{0:d3}.wav" -f $_ } -Text { "File $_ WAV" } | Convert-AudioWavFile -PassThru | Get-AudioFileInfo
      Creates ten files named "001.wav" to "010.wav", which contain the english narration of "File xx WAV", where xx is a number between 1 and 10.
      The created audio files are then converted to "ADPCM IMA WAV" format which uses 1:4 compression and can be played back by simple MP3 players such as DFPlayer Mini.
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture en-us -PassThru -OutputPath { "c:\testFiles\{0:d3}.wav" -f $_ } -Text { "File $_ MP3" } | Convert-AudioWavFile -CreateMp3 -Force -PassThru | Get-AudioFileInfo
      Creates ten files named "001.wav" to "010.wav", which contain the english narration of "File xx MP3", where xx is a number between 1 and 10.
      The resulting files "001.wav" - "010.wav" are then converted to MP3 and renamed to "001.mp3" - "010.mp3"
 
      .EXAMPLE
      1..10 | Convert-TextToSpeech -Culture en-us -PassThru -OutputPath { "c:\testFiles\{0:d3}.wav" -f $_ } -Text { "File $_ WAV" } | Convert-AudioWavFile -PassThru | Get-AudioFileInfo
      Creates ten files named "001.wav" to "010.wav" using the male en-us voice "David", which contain the english narration of "File xx WAV", where xx is a number between 1 and 10.
      The created audio files are then converted to "ADPCM IMA WAV" format which uses 1:4 compression and can be played back by simple MP3 players such as DFPlayer Mini.
  #>


  [CmdletBinding(DefaultParameterSetName='Audio')]
  param
  (
    # Output Text
    [Parameter(Mandatory,ValueFromPipeline,ValueFromPipelineByPropertyName,Position=0)]
    [string]
    $Text,
    
    # Output Volume (0 to 100, default: 100)
    [ValidateRange(0,100)]
    [int]
    $Volume = 100,
    
    # Output Speed (-10 to 10, default: 0)
    [ValidateRange(-10,10)]
    [int]
    $Speed = 0,
    
    # Output Culture (default: en-us). Available cultures depend on installed voices.
    [ArgumentCompleter({
          param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
          $exists = Test-Path -Path 'variable:_availableCultures'
          if (!$exists)
          {
            Add-Type -AssemblyName System.Speech
            $speak = [System.Speech.Synthesis.SpeechSynthesizer]::new()
            # get installed voice cultures
            $script:_availableCultures = 
            ($speak.GetInstalledVoices() | Where-Object Enabled | Select-Object -ExpandProperty VoiceInfo).Culture | Sort-Object -Property Name -Unique |
            Foreach-Object { 
              # create completionresult items:
              $displayname = $_.DisplayName
              $id = $_.lcid
              $name = $_.name
              [System.Management.Automation.CompletionResult]::new($name, $name, "ParameterValue", "$displayName`r`nLCID: $id")
            }
            $speak.Dispose()
          }
          # return available cultures
          $script:_availableCultures |
          Where-Object { 
            $_.ListItemText -like ($wordToComplete.Replace('"','').Replace("'",'') + '*')   
          }
    })]
    [System.Globalization.CultureInfo]
    $Culture = 'en-us',
    
    # Voice to use for text synthesis. Make sure the voice you specify is installed.
    [ArgumentCompleter({
          param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
          $exists = Test-Path -Path 'variable:_availableVoices'
          if (!$exists)
          {
            Add-Type -AssemblyName System.Speech
            $speak = [System.Speech.Synthesis.SpeechSynthesizer]::new()
            # get installed voices
            $script:_availableVoices = $speak.GetInstalledVoices() | Where-Object Enabled | Select-Object -ExpandProperty VoiceInfo
            $speak.Dispose()
          } 
          # filter by Culture (if specified)
          $voices = if ($fakeBoundParameters.ContainsKey('Culture'))
          {
            $Script:_availableVoices | Where-Object Culture -eq $fakeBoundParameters['Culture']
          }
          else
          {
            $Script:_availableVoices
          }
          $voices |
          Where-Object { 
            $_.Name -like ($wordToComplete.Replace('"','').Replace("'",'') + '*')   
          } |
          Foreach-Object { 
            # create completionresult items:
            $gender = $_.Gender
            $age = $_.age
            $name = $_.name
            $hasSpecialChar = $name -match '[\s()\[\]"]'
            $nameQuoted = if ($hasSpecialChar)
            {
              "'{0}'" -f $Name
            }
            else
            {
              $name.Replace("'", "''")
            }
            $culture = $_.culture.DisplayName
            $description = $_.description
            $id = $_.id
            $tooltip = "$description`r`n$culture`r`n$age - $gender`r`n$id"
            [System.Management.Automation.CompletionResult]::new($nameQuoted, $name, "ParameterValue", $tooltip)
          }
    })]
    [string]
    $Voice,
    
    # optional: Path to a wav file to record the spoken text
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='File',Mandatory)]
    [string]
    $OutputPath,

    # emit the created audio files
    [Parameter(ValueFromPipelineByPropertyName,ParameterSetName='File')]
    [switch]
    $PassThru,
    
    # overwrite existing files
    [switch]
    $Force
  )
  
  begin
  {
    Add-Type -AssemblyName System.Speech
    
    $recordingFormat = [System.Speech.AudioFormat.SpeechAudioFormatInfo]::new(16000, 
      [System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen, 
    [System.Speech.AudioFormat.AudioChannel]::Mono)
    
  }
  
  process
  {
    $speak = [System.Speech.Synthesis.SpeechSynthesizer]::new()
    
    $speak.Rate = $Speed
    $speak.Volume = $Volume

    # set the selected voice
    if ($PSBoundParameters.ContainsKey('Voice'))
    {
      # a specific voice was selected. Make sure it exists:
      $voiceObj = $speak.GetInstalledVoices() | Where-Object { $_.Enabled -and $_.VoiceInfo.Name -eq $Voice } | Select-Object -First 1
      if (!$voiceObj) 
      {
        throw "voice '$Voice' not found. It may not be installed on your system. Select a different voice, or use the default voice."
      } 
      # does voice match selected culture?
      if ($PSBoundParameters.ContainsKey('Culture'))
      {
        if ($Culture -ne $voiceObj.VoiceInfo.Culture)
        {
          Write-Warning ("Selected voice '{0}' is culture {1} but you specified culture {2}. Select a different voice if you want to output in culture {2}." -f $Voice, $Voice.culture.name, $Culture.Name)
        }
      }
      $speak.SelectVoice($Voice) 
    }
    elseif ($PSBoundParameters.ContainsKey('Culture'))
    {
      # just a culture was specified, pick first voice that matches:
      $voiceObj = $speak.GetInstalledVoices($Culture) | Select-Object -First 1
      if (!$voiceObj) 
      {
        throw "No voice installed that matches culture '$Culture'. Select a different culture, or install the voice pack for the requested culture."
      } 
      $speak.SelectVoice($voiceObj.VoiceInfo.Name) 
    }
    
    
    $recordingPath = ''
    
    if ($PSBoundParameters.ContainsKey('OutputPath'))
    {
      # is the file a wav file?
      $extension = [System.IO.Path]::GetExtension($OutputPath).ToLower().Trim()
      $recordingPath = 
      if ([string]::IsNullOrWhiteSpace($extension))
      {
        # a folder path was specified
        # make sure the folder exists
        $outputFolder = $OutputPath.Trim()
        if ($outputFolder -eq '') { throw "An empty output path was specified." }
        $exists = Test-Path -Path $outputFolder
        if (!$exists) { $null = New-Item -Path $outputFolder -ItemType Directory }
        
        # the output file is the first 20 characters of the spoken text
        $maxLen = [Math]::Min($Text.Length, 20)
        $filename = $Text.Substring(0, $maxLen) + '.wav'
        Join-Path -Path $OutputPath -ChildPath $filename
        
      }
      elseif ($extension -eq '.wav')
      {
        # ensure parent path exists:
        $parentFolder = $OutputPath | Split-Path
        $exists = Test-Path -Path $parentFolder
        if (!$exists) { $null = New-Item -Path $parentFolder -ItemType Directory }
        $OutputPath
      }
      else
      {
        throw "Output path must be a *.wav file or an output folder. No other file extensions are supported."
      }
    }
    else
    {
      # output to standard audio
      $recordingPath = ''
    }
    
    # output to file
    if ($recordingPath -eq '')
    {
      $speak.SetOutputToDefaultAudioDevice()
      $ok = $true
    }
    else
    {
      $exists = Test-Path -Path $recordingPath
      $ok = (!$exists) -or ($exists -and $force)
      if ($ok) 
      { 
        # this call deletes the content of an existing file and must be omitted
        # when overwriting is not allowed:
        $speak.SetOutputToWaveFile($recordingPath, $recordingFormat)
      }
    }
    
    # perform the output
    if ($ok)
    { 
      $speak.Speak($Text) 
    }
    else
    {   
      Write-Warning "Target file '$recordingPath' exists. This file was not changed. Use -Force to overwrite." 
    }
    
    # release all resources, or else recorded files are locked and cannot be manipulated by following pipeline cmdlets
    $speak.Dispose()
    if ($PassThru)
    {
      Get-Item -Path $recordingPath
    }
  }
  
  end
  {
    $exists = Test-Path -Path 'variable:_availablevoices'
    if ($exists) { Remove-Variable -Name '_availablevoices' -scope script -ErrorAction Ignore }
    $exists = Test-Path -Path 'variable:_availableCultures'
    if ($exists) { Remove-Variable -Name '_availableCultures' -scope script -ErrorAction Ignore }

  }

}