LocalSTT.psm1
#!/usr/bin/env pwsh using namespace System.IO using namespace System.Net using namespace System.Net.Sockets using namespace System.Management.Automation #Requires -Modules cliHelper.core, pipEnv #Requires -Psedition Core #region Classes class AudioRecorder { [Socket]$server [TcpClient]$client [FileInfo]$outFile hidden [Stream]$stream hidden [TcpListener]$listener AudioRecorder([TcpListener]$listener, [FileInfo]$outFile) { $this.listener = $listener $this.outFile = $outFile } [Stream] Start() { $this.listener.Start() $this.server = ([ref]$this.listener.Server).Value return $this.Start($this.listener.AcceptTcpClient()) } [Stream] Start([TcpClient]$client) { $this.client = $client Write-Console "• " -f SlateBlue -NoNewLine; Write-Console "OutFile: " -f LightGoldenrodYellow -NoNewLine; Write-Console "'$([LocalSTT]::recorder.outFile)'" -f LimeGreen; Write-Console "┆" -f LimeGreen $this.stream = $client.GetStream() Write-Console "• " -f SlateBlue -NoNewLine; Write-Console "Started recording. Press Ctrl+C to stop." -f LightGoldenrodYellow [LocalSTT]::status.IsRecording = $true return $this.stream } [void] Stop() { $this.Stop(0) } [void] Stop([int]$ProcessId) { $this.Stop($this.listener, $ProcessId) } [void] Stop([TcpListener]$listener, [int]$ProcessId) { $this.stream.Close() $this.client.Close(); $listener.Stop() if ($ProcessId -gt 0) { Stop-Process -Id $ProcessId -Force } [LocalSTT]::status.IsRecording = $false } } # .SYNOPSIS # Local Speech to text powershell module # .DESCRIPTION # This script uses PyAudio, Retrieves a list of audio input devices available on the system. # then uses it to record audio from a selected device # .NOTES # Requires the PyAudio library to be installed. # Ensure that audio devices are properly configured on the system for accurate results. # .LINK # https://pyaudio.readthedocs.io/en/stable/ class LocalSTT { static [AudioRecorder]$recorder static [hashtable]$status = [hashtable]::Synchronized(@{ HasConfig = [LocalSTT]::HasConfig() IsRecording = $false } ) LocalSTT() {} static [IO.Fileinfo] RecordAudio() { return [LocalSTT]::RecordAudio([LocalSTT].config.outFile) } static [IO.Fileinfo] RecordAudio([string]$outFile) { if ([LocalSTT]::status.IsRecording) { throw [InvalidOperationException]::new("LocalSTT is already recording.") } [void][LocalSTT]::ResolveRequirements() if ($null -ne [LocalSTT]::recorder) { [LocalSTT]::recorder.Stop() }; $_c = [LocalSTT].config [LocalSTT]::recorder = [AudioRecorder]::New([TcpListener]::new([IPEndpoint]::new([IPAddress]$_c.host, $_c.port)), [IO.FileInfo]::New($outFile)) $pythonProcess = Start-Process -FilePath "python" -ArgumentList "$($_c.backgroundScript) --host `"$($_c.host)`" --port $($_c.port) --amplify-rate $($_c.amplifyRate) --outfile `"$outFile`" --working-directory `"$($_c.workingDirectory)`"" -PassThru -NoNewWindow Write-Console "(LocalSTT) " -f SlateBlue -NoNewLine; Write-Console "Server starting @ http://$([LocalSTT]::recorder.listener.LocalEndpoint) PID: $($pythonProcess.Id)" -f LemonChiffon; $stream = [LocalSTT]::recorder.Start(); $buffer = [byte[]]::new(1024); $OgctrInput = [Console]::TreatControlCAsInput; try { while ($true) { $bytesRead = $stream.Read($buffer, 0, $buffer.Length) if ($bytesRead -le 0) { Write-Host "No data was received from stt.py!" -f Red; break } $_str = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $bytesRead).Split('}{')[-1]; $c = [char]123 $json = $_str.StartsWith($c) ? $_str : ([char]123 + $_str) $data = $json | ConvertFrom-Json $prog = $data.progress; # $perc = ($prog -ge 100) ? 100 : $prog # failsafe to never exceed 100 and cause an error # Write-Progress -Activity "(LocalSTT)" -Status "$($data.status) $prog%" -PercentComplete $perc [progressUtil]::WriteProgressBar($prog, "(LocalSTT) $($data.status)") [threading.Thread]::Sleep(200) } } catch { Write-Host "Error receiving data: $($_.Exception.Message)" } finally { [Console]::TreatControlCAsInput = $OgctrInput [LocalSTT]::recorder.Stop($pythonProcess.Id) } return [LocalSTT]::recorder.outFile } static [bool] ResolveRequirements() { $req = [LocalSTT].config.requirementsfile $res = [IO.File]::Exists($req); $_c = [LocalSTT].config if (!$res) { throw "LocalSTT failed to resolve pip requirements. From file: '$req'." } if (![LocalSTT]::status.HasConfig) { throw [InvalidOperationException]::new("LocalSTT config found.") }; if ($_c.env.State -eq "Inactive") { $_c.env.Activate() } Write-Console "(LocalSTT) " -f SlateBlue -NoNewLine; Write-Console "Resolve pip requirements... File@$(Invoke-PathShortener $req)" -f LemonChiffon; pip install -r $req; return $res } static [bool] HasConfig() { if ($null -eq [LocalSTT].config) { [LocalSTT].PsObject.Properties.Add([PSScriptproperty]::New("config", { return [LocalSTT]::LoadConfig() }, { throw [SetValueException]::new("config can only be imported or edited") })) } return $null -ne [LocalSTT].config } static [PsObject] LoadConfig() { return [LocalSTT]::LoadConfig((Resolve-Path .).Path) } static [PsObject] LoadConfig([string]$current_path) { # .DESCRIPTION # Load the configuration from json or toml file $module_path = (Get-Module LocalSTT -ListAvailable -Verbose:$false).ModuleBase # default config values $c = @{ port = 65432 host = "127.0.0.1" amplifyRate = "1.0" workingDirectory = $current_path requirementsfile = [IO.Path]::Combine($module_path, "Private", "requirements.txt") backgroundScript = [IO.Path]::Combine($module_path, "Private", "stt.py") outFile = [IO.Path]::Combine($current_path, "$(Get-Date -Format 'yyyyMMddHHmmss')_output.wav") } -as "PsRecord" $c.PsObject.Properties.Add([PSScriptproperty]::New("env", { return [LocalSTT].config.workingDirectory | New-PipEnv }, { throw [SetValueException]::new("env is read-only") })) $c.PsObject.Properties.Add([PSScriptproperty]::New("modulePath", [scriptblock]::Create("return `"$module_path`""), { throw [SetValueException]::new("modulePath is read-only") })) return $c } } #endregion Classes # Types that will be available to users when they import the module. $typestoExport = @( [LocalSTT] ) $TypeAcceleratorsClass = [PsObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') foreach ($Type in $typestoExport) { if ($Type.FullName -in $TypeAcceleratorsClass::Get.Keys) { $Message = @( "Unable to register type accelerator '$($Type.FullName)'" 'Accelerator already exists.' ) -join ' - ' [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($Message), 'TypeAcceleratorAlreadyExists', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Type.FullName ) | Write-Warning } } # Add type accelerators for every exportable type. foreach ($Type in $typestoExport) { $TypeAcceleratorsClass::Add($Type.FullName, $Type) } # Remove type accelerators when the module is removed. $MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = { foreach ($Type in $typestoExport) { $TypeAcceleratorsClass::Remove($Type.FullName) } }.GetNewClosure(); $scripts = @(); $Public = Get-ChildItem "$PSScriptRoot/Public" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue $scripts += Get-ChildItem "$PSScriptRoot/Private" -Filter "*.ps1" -Recurse -ErrorAction SilentlyContinue $scripts += $Public foreach ($file in $scripts) { Try { if ([string]::IsNullOrWhiteSpace($file.fullname)) { continue } . "$($file.fullname)" } Catch { Write-Warning "Failed to import function $($file.BaseName): $_" $host.UI.WriteErrorLine($_) } } $Param = @{ Function = $Public.BaseName Cmdlet = '*' Alias = '*' Verbose = $false } Export-ModuleMember @Param |