<# Batch Text-to-Speech WAV File Generator Author: Claude AI from prompts by Mike Shellim. With input from Paul Grey. Reads input from a CSV file with filename and text columns Uses Windows Speech Platform API to convert text to speech and save as WAV Requirements ============ Windows 10, 11 FFMpeg To install FFmpeg ================= https://ffmpeg.org/download.html#build-windows 'essentials' pack is sufficient To install this script ====================== 1. Change the file extention of this script file to .ps1 (note numeral '1'). 2. Create a folder to hold script file and output files 3. Right click script file and choose 'Run with Powershell' Input file format ================= Create a text file. Each line must be in the format FILENAME, TEXT. Dont' forget to include the comma. Omit the file extention ('.wav' extention is added by the script) Note: Filenames for EdgeTX should be no more than 6 characters. Troubleshooting =============== If the script flashes briefly then closes, it means the script has failed. Cause is almost certainly an overly restrictive PowerShell execution policy. This is easy to fix, see the following link: https://powershellfaqs.com/powershell-running-scripts-is-disabled-on-this-system/ Method 2 is recommended as a good balance between utility and security. #> # Load required assemblies Add-Type -AssemblyName System.Speech Add-Type -AssemblyName System.Windows.Forms function Get-InstalledVoices { $synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer $voices = $synthesizer.GetInstalledVoices() $voiceList = @() foreach ($voice in $voices) { $voiceInfo = [PSCustomObject]@{ Name = $voice.VoiceInfo.Name Gender = $voice.VoiceInfo.Gender Age = $voice.VoiceInfo.Age Culture = $voice.VoiceInfo.Culture.Name Enabled = $voice.Enabled } $voiceList += $voiceInfo } $synthesizer.Dispose() return $voiceList } function Show-VoiceMenu { param($voices) Write-Host "`nAvailable Voices:" -ForegroundColor Green Write-Host "=================" -ForegroundColor Green for ($i = 0; $i -lt $voices.Count; $i++) { $voice = $voices[$i] $status = if ($voice.Enabled) { "[Available]" } else { "[Disabled]" } Write-Host "$($i + 1). $($voice.Name) - $($voice.Gender), $($voice.Culture) $status" } Write-Host "" } function Generate-SpeechWav { param( [string]$text, [string]$voiceName, [string]$outputPath ) try { # Create speech synthesizer $synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer # Set the voice $synthesizer.SelectVoice($voiceName) # Set volume to maximum (100) for loudest output without distortion $synthesizer.Volume = 100 # Set speaking rate to normal $synthesizer.Rate = 0 # Configure WAV output with 8kHz sample rate $format = New-Object System.Speech.AudioFormat.SpeechAudioFormatInfo(16000, [System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen, [System.Speech.AudioFormat.AudioChannel]::Mono) $synthesizer.SetOutputToWaveFile($outputPath, $format) # Speak the text (this generates the WAV file) $synthesizer.Speak($text) # Clean up $synthesizer.Dispose() return $true } catch { Write-Error "Error generating speech: $($_.Exception.Message)" if ($synthesizer) { $synthesizer.Dispose() } return $false } } function Test-FFmpegAvailable { try { $null = & ffmpeg -version 2>$null return $true } catch { return $false } } function Strip-AudioSilence { param( [string]$inputPath, [string]$outputPath ) try { # Set silence and tempo parameters $silence = "0.01" # 0.1 second silence threshold $tempo = "1.0" # Normal tempo (1.0 = no change) # FFmpeg command using the working recipe $ffmpegArgs = @( "-i", $inputPath, "-af", "silenceremove=start_periods=1:start_silence=$silence :start_threshold=-50dB,areverse,silenceremove=start_periods=1:start_silence=$silence :start_threshold=-50dB,areverse,atempo=$tempo,volume=4dB", "-ar", "16000", "-ac", "1", "-y", $outputPath ) Write-Host "Processing with FFmpeg (stripping silence and amplifying volume)..." -ForegroundColor Yellow & ffmpeg @ffmpegArgs 2>$null if (Test-Path $outputPath) { return $true } else { return $false } } catch { Write-Error "Error processing audio: $($_.Exception.Message)" return $false } } function Read-InputFile { param([string]$filePath) try { $lines = Get-Content $filePath -ErrorAction Stop $entries = @() $lineNumber = 0 $validLines = 0 $invalidLines = @() foreach ($line in $lines) { $lineNumber++ # Skip empty lines if ([string]::IsNullOrWhiteSpace($line)) { continue } # Parse CSV line properly - handle quoted fields and embedded commas $fields = Parse-CsvLine -line $line if ($fields -eq $null) { $invalidLines += "Line $lineNumber : Malformed CSV format - '$line'" continue } if ($fields.Count -lt 2) { $invalidLines += "Line $lineNumber : Missing comma separator or insufficient fields - '$line'" continue } if ($fields.Count -gt 2) { $invalidLines += "Line $lineNumber : Too many fields (expected 2: filename,text) - '$line'" continue } $filename = $fields[0].Trim() $text = $fields[1].Trim() # Validate that both parts are not empty if ([string]::IsNullOrWhiteSpace($filename)) { $invalidLines += "Line $lineNumber : Empty filename - '$line'" continue } if ([string]::IsNullOrWhiteSpace($text)) { $invalidLines += "Line $lineNumber : Empty text - '$line'" continue } # Check for invalid characters in filename $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() $hasInvalidChars = $false foreach ($char in $invalidChars) { if ($filename.Contains($char)) { $hasInvalidChars = $true break } } if ($hasInvalidChars) { $invalidLines += "Line $lineNumber : Filename contains invalid characters - '$filename'" continue } $entry = [PSCustomObject]@{ Filename = $filename Text = $text LineNumber = $lineNumber } $entries += $entry $validLines++ } # Report validation results Write-Host "File validation results:" -ForegroundColor Yellow Write-Host " Total lines processed: $lineNumber" -ForegroundColor Cyan Write-Host " Valid entries found: $validLines" -ForegroundColor Green Write-Host " Invalid entries: $($invalidLines.Count)" -ForegroundColor $(if ($invalidLines.Count -gt 0) { "Red" } else { "Green" }) if ($invalidLines.Count -gt 0) { Write-Host "`nInvalid entries details:" -ForegroundColor Red foreach ($invalidLine in $invalidLines) { Write-Host " $invalidLine" -ForegroundColor Red } } return @{ Entries = $entries ValidCount = $validLines InvalidCount = $invalidLines.Count InvalidLines = $invalidLines } } catch { Write-Error "Error reading input file: $($_.Exception.Message)" return $null } } function Parse-CsvLine { param([string]$line) try { $fields = @() $currentField = "" $inQuotes = $false $i = 0 while ($i -lt $line.Length) { $char = $line[$i] if ($char -eq '"') { if ($inQuotes) { # Check if this is an escaped quote (double quote) if ($i + 1 -lt $line.Length -and $line[$i + 1] -eq '"') { $currentField += '"' $i += 2 continue } else { # End of quoted field $inQuotes = $false } } else { # Start of quoted field $inQuotes = $true } } elseif ($char -eq ',' -and -not $inQuotes) { # Field separator found outside quotes $fields += $currentField $currentField = "" } else { # Regular character $currentField += $char } $i++ } # Add the last field $fields += $currentField # Check for unclosed quotes if ($inQuotes) { return $null # Malformed - unclosed quotes } return $fields } catch { return $null } } function Validate-Filename { param([string]$filename) # Remove invalid filename characters $safeFilename = $filename -replace '[<>:"/\\|?*]', '_' # Ensure it ends with .wav if (-not $safeFilename.EndsWith('.wav', [StringComparison]::OrdinalIgnoreCase)) { $safeFilename += '.wav' } return $safeFilename } # Main script execution Write-Host "======================================" -ForegroundColor Cyan Write-Host "Batch Text-to-Speech WAV File Generator" -ForegroundColor Cyan Write-Host "======================================" -ForegroundColor Cyan Write-Host "" # Check if FFmpeg is available if (-not (Test-FFmpegAvailable)) { Write-Error "FFmpeg is required for this script but not found on this system." Write-Host "Please install FFmpeg and ensure it's accessible from the command line." -ForegroundColor Red Write-Host "You can download FFmpeg from: https://ffmpeg.org/download.html" -ForegroundColor Yellow exit 1 } # Prompt for input file do { $inputFile = Read-Host "Enter the path to the input text file (CSV format: filename,text)" if (-not (Test-Path $inputFile)) { Write-Host "File not found. Please enter a valid file path." -ForegroundColor Red continue } break } while ($true) # Read input file Write-Host "Reading input file..." -ForegroundColor Yellow $inputResult = Read-InputFile -filePath $inputFile if (-not $inputResult) { Write-Host "Failed to read input file. Exiting." -ForegroundColor Red exit 1 } $entries = $inputResult.Entries # Check if we have any valid entries if (-not $entries -or $entries.Count -eq 0) { Write-Host "`nNo valid entries found in the input file." -ForegroundColor Red Write-Host "`nExpected format:" -ForegroundColor Yellow Write-Host " filename.wav,text to convert" -ForegroundColor Yellow Write-Host " hello_world.wav,Hello world this is a test" -ForegroundColor Yellow Write-Host " \"file with spaces.wav\",\"Text with, commas in it\"" -ForegroundColor Yellow Write-Host "`nRequirements:" -ForegroundColor Yellow Write-Host " - Each line must contain exactly two comma-separated fields" -ForegroundColor Yellow Write-Host " - Use quotes around fields containing commas or special characters" -ForegroundColor Yellow Write-Host " - Filename cannot be empty or contain invalid characters" -ForegroundColor Yellow Write-Host " - Text cannot be empty" -ForegroundColor Yellow Write-Host " - Empty lines are ignored" -ForegroundColor Yellow if ($inputResult.InvalidCount -gt 0) { Write-Host "`nPlease fix the invalid entries listed above and try again." -ForegroundColor Red } Write-Host "`nScript terminated." -ForegroundColor Red exit 1 } # Show summary and ask for confirmation if there were invalid lines if ($inputResult.InvalidCount -gt 0) { Write-Host "`nFound $($entries.Count) valid entries to process, but $($inputResult.InvalidCount) invalid entries were skipped." -ForegroundColor Yellow $continue = Read-Host "Do you want to continue processing only the valid entries? (y/N)" if ($continue -notmatch '^[Yy]') { Write-Host "Processing cancelled. Please fix the input file and try again." -ForegroundColor Yellow exit 0 } } else { Write-Host "Found $($entries.Count) valid entries to process." -ForegroundColor Green } # Get available voices Write-Host "Loading available voices..." -ForegroundColor Yellow $voices = Get-InstalledVoices | Where-Object { $_.Enabled -eq $true } if ($voices.Count -eq 0) { Write-Error "No enabled voices found on this system." exit 1 } # Show voice menu and get selection Show-VoiceMenu -voices $voices do { $voiceSelection = Read-Host "Select a voice to use for all entries (1-$($voices.Count))" if ($voiceSelection -match '^\d+$' -and [int]$voiceSelection -ge 1 -and [int]$voiceSelection -le $voices.Count) { $selectedVoice = $voices[[int]$voiceSelection - 1] break } else { Write-Host "Invalid selection. Please enter a number between 1 and $($voices.Count)." -ForegroundColor Red } } while ($true) Write-Host "`nUsing voice: $($selectedVoice.Name)" -ForegroundColor Green Write-Host "Processing entries..." -ForegroundColor Yellow Write-Host "" # Process each entry $successCount = 0 $failCount = 0 foreach ($entry in $entries) { $safeFilename = Validate-Filename -filename $entry.Filename $tempFilename = "temp_" + [System.IO.Path]::GetRandomFileName() + ".wav" $tempPath = Join-Path -Path (Get-Location) -ChildPath $tempFilename $finalPath = Join-Path -Path (Get-Location) -ChildPath $safeFilename Write-Host "Processing: $safeFilename" -ForegroundColor Cyan Write-Host "Text: $($entry.Text.Substring(0, [Math]::Min(50, $entry.Text.Length)))$(if ($entry.Text.Length -gt 50) { '...' })" try { # Generate initial WAV file if (Generate-SpeechWav -text $entry.Text -voiceName $selectedVoice.Name -outputPath $tempPath) { # Process with FFmpeg (strip silence and amplify) if (Strip-AudioSilence -inputPath $tempPath -outputPath $finalPath) { # Clean up temporary file if (Test-Path $tempPath) { Remove-Item $tempPath -Force } # Show file info $fileInfo = Get-Item $finalPath Write-Host "Success! File: $safeFilename ($([math]::Round($fileInfo.Length / 1KB, 2)) KB)" -ForegroundColor Green $successCount++ } else { Write-Host "Failed to process audio with FFmpeg: $safeFilename" -ForegroundColor Red $failCount++ # Clean up temporary file if (Test-Path $tempPath) { Remove-Item $tempPath -Force } } } else { Write-Host "Failed to generate speech: $safeFilename" -ForegroundColor Red $failCount++ } } catch { Write-Host "Error processing $safeFilename : $($_.Exception.Message)" -ForegroundColor Red $failCount++ # Clean up temporary file if it exists if (Test-Path $tempPath) { Remove-Item $tempPath -Force } } Write-Host "" } # Summary Write-Host "======================================" -ForegroundColor Cyan Write-Host "Processing Complete!" -ForegroundColor Green Write-Host "======================================" -ForegroundColor Cyan Write-Host "Successfully processed: $successCount files" -ForegroundColor Green Write-Host "Failed: $failCount files" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" }) Write-Host "Voice used: $($selectedVoice.Name)" -ForegroundColor Cyan Write-Host "Audio format: 8kHz, 16-bit Mono WAV with silence stripped and volume amplified" -ForegroundColor Cyan Write-Host "" Write-Host "All files have been saved in the current directory." -ForegroundColor Yellow