|
param( |
|
[string]$ApiKey = $env:XAI_API_KEY, |
|
[string]$Voice = 'eve' |
|
) |
|
|
|
# $env:XAI_API_KEY = 'xai-your-key' |
|
|
|
$model = 'grok-voice-think-fast-1.0' |
|
$rate = 24000 |
|
$wav = Join-Path ([IO.Path]::GetTempPath()) 'xai-voice-reply.wav' |
|
|
|
if (!$ApiKey) { throw 'Set $env:XAI_API_KEY first.' } |
|
|
|
function Send($event) { |
|
$json = $event | ConvertTo-Json -Depth 20 -Compress |
|
$bytes = [Text.Encoding]::UTF8.GetBytes($json) |
|
$null = $ws.SendAsync( |
|
[ArraySegment[byte]]::new($bytes), |
|
[Net.WebSockets.WebSocketMessageType]::Text, |
|
$true, |
|
[Threading.CancellationToken]::None |
|
).GetAwaiter().GetResult() |
|
} |
|
|
|
function Receive { |
|
$stream = [IO.MemoryStream]::new() |
|
do { |
|
$buffer = [byte[]]::new(65536) |
|
$result = $ws.ReceiveAsync( |
|
[ArraySegment[byte]]::new($buffer), |
|
[Threading.CancellationToken]::None |
|
).GetAwaiter().GetResult() |
|
if ($result.MessageType -eq 'Close') { throw 'WebSocket closed.' } |
|
$stream.Write($buffer, 0, $result.Count) |
|
} until ($result.EndOfMessage) |
|
|
|
try { [Text.Encoding]::UTF8.GetString($stream.ToArray()) | ConvertFrom-Json } |
|
finally { $stream.Dispose() } |
|
} |
|
|
|
function Save-Wav($chunks) { |
|
$bytes = 0 |
|
foreach ($chunk in $chunks) { $bytes += $chunk.Length } |
|
|
|
$w = [IO.BinaryWriter]::new([IO.File]::Create($wav)) |
|
try { |
|
$w.Write([Text.Encoding]::ASCII.GetBytes('RIFF')) |
|
$w.Write([uint32](36 + $bytes)) |
|
$w.Write([Text.Encoding]::ASCII.GetBytes('WAVEfmt ')) |
|
$w.Write([uint32]16) |
|
$w.Write([uint16]1) |
|
$w.Write([uint16]1) |
|
$w.Write([uint32]$rate) |
|
$w.Write([uint32]($rate * 2)) |
|
$w.Write([uint16]2) |
|
$w.Write([uint16]16) |
|
$w.Write([Text.Encoding]::ASCII.GetBytes('data')) |
|
$w.Write([uint32]$bytes) |
|
foreach ($chunk in $chunks) { $w.Write([byte[]]$chunk) } |
|
} |
|
finally { $w.Dispose() } |
|
|
|
$wav |
|
} |
|
|
|
function Play-Wav($path) { |
|
Add-Type -AssemblyName System.Windows.Extensions -ErrorAction SilentlyContinue |
|
$player = [System.Media.SoundPlayer]::new($path) |
|
try { $player.PlaySync() } finally { $player.Dispose() } |
|
} |
|
|
|
function Ask($text) { |
|
Send @{ |
|
type = 'conversation.item.create' |
|
item = @{ |
|
type = 'message' |
|
role = 'user' |
|
content = @(@{ type = 'input_text'; text = $text }) |
|
} |
|
} |
|
Send @{ type = 'response.create' } |
|
|
|
$audio = [Collections.Generic.List[byte[]]]::new() |
|
$printed = $false |
|
|
|
while ($true) { |
|
$event = Receive |
|
switch -Regex ($event.type) { |
|
'output_audio\.delta$|response\.audio\.delta$' { |
|
$audio.Add([Convert]::FromBase64String($event.delta)) | Out-Null |
|
} |
|
'transcript\.delta$|text\.delta$' { |
|
$printed = $true |
|
Write-Host -NoNewline $event.delta |
|
} |
|
'transcript\.done$' { |
|
if (!$printed) { Write-Host -NoNewline $event.transcript } |
|
} |
|
'^error$' { |
|
throw ($event | ConvertTo-Json -Depth 20) |
|
} |
|
'^response\.done$' { |
|
if ($audio.Count) { Play-Wav (Save-Wav $audio) } |
|
return |
|
} |
|
} |
|
} |
|
} |
|
|
|
$ws = [Net.WebSockets.ClientWebSocket]::new() |
|
$ws.Options.SetRequestHeader('Authorization', "Bearer $ApiKey") |
|
$null = $ws.ConnectAsync( |
|
[uri]"wss://api.x.ai/v1/realtime?model=$model", |
|
[Threading.CancellationToken]::None |
|
).GetAwaiter().GetResult() |
|
|
|
try { |
|
Send @{ |
|
type = 'session.update' |
|
session = @{ |
|
instructions = 'You are a concise, helpful voice assistant.' |
|
voice = $Voice |
|
audio = @{ |
|
input = @{ format = @{ type = 'audio/pcm'; rate = $rate } } |
|
output = @{ format = @{ type = 'audio/pcm'; rate = $rate } } |
|
} |
|
} |
|
} |
|
|
|
while (($text = Read-Host 'you') -ne '/exit') { |
|
Write-Host -NoNewline 'assistant: ' |
|
Ask $text |
|
Write-Host '' |
|
} |
|
} |
|
finally { |
|
if ($ws.State -eq 'Open') { |
|
$null = $ws.CloseAsync('NormalClosure', 'bye', [Threading.CancellationToken]::None).GetAwaiter().GetResult() |
|
} |
|
$ws.Dispose() |
|
} |