507 lines
18 KiB
ObjectPascal
507 lines
18 KiB
ObjectPascal
unit WhisperMain;
|
|
|
|
interface
|
|
|
|
uses
|
|
Winapi.Windows, Winapi.MMSystem, System.SysUtils, System.Types, System.UITypes,
|
|
System.Classes, System.Variants, System.IOUtils, FMX.Types, FMX.Controls,
|
|
FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Memo.Types, FMX.ScrollBox,
|
|
FMX.Memo, FMX.Controls.Presentation, FMX.StdCtrls, System.JSON,
|
|
System.Net.HttpClient, System.Net.URLClient, System.Net.HttpClientComponent,
|
|
IdTCPClient, IdException, IdGlobal, IdComponent;
|
|
|
|
type
|
|
TWaveHeader = packed record
|
|
RIFF: array[0..3] of AnsiChar;
|
|
FileSize: Cardinal;
|
|
WAVE: array[0..3] of AnsiChar;
|
|
fmt: array[0..3] of AnsiChar;
|
|
FormatLength: Cardinal;
|
|
FormatTag: Word;
|
|
Channels: Word;
|
|
SampleRate: Cardinal;
|
|
BytesPerSecond: Cardinal;
|
|
BlockAlign: Word;
|
|
BitsPerSample: Word;
|
|
Data: array[0..3] of AnsiChar;
|
|
DataLength: Cardinal;
|
|
end;
|
|
|
|
TForm1 = class(TForm)
|
|
RecordButton: TButton;
|
|
StopButton: TButton;
|
|
LogMemo: TMemo;
|
|
OutputMemo: TMemo;
|
|
DebugCheckBox: TCheckBox;
|
|
procedure RecordButtonClick(Sender: TObject);
|
|
procedure StopButtonClick(Sender: TObject);
|
|
procedure FormDestroy(Sender: TObject);
|
|
private
|
|
waveHandle: HWAVEIN;
|
|
fileStream: TFileStream;
|
|
waveHeader: TWaveHeader;
|
|
isRecording: Boolean;
|
|
tempAudioPath: string;
|
|
|
|
waveCaps: array[0..1] of TWaveHdr;
|
|
waveBuffers: array[0..1] of array[0..4095] of Byte;
|
|
|
|
procedure Log(const Msg: string);
|
|
procedure LogDebug(const Msg: string);
|
|
|
|
// Wyoming / Whisper
|
|
procedure SendToWhisperAsync(const FilePath: string);
|
|
function CreateEvent(const EventType: string; Data: TJSONObject = nil; PayloadLen: Int64 = -1): string;
|
|
function GetAudioMetadata: TJSONObject;
|
|
|
|
// LLM / OpenAI API
|
|
procedure QueryLLM(const UserInput: string);
|
|
|
|
// Audio Capture
|
|
procedure StartCapture(const FileName: string);
|
|
procedure StopCapture;
|
|
procedure WriteWavHeader;
|
|
procedure UpdateWavHeader;
|
|
public
|
|
end;
|
|
|
|
var
|
|
Form1: TForm1;
|
|
|
|
implementation
|
|
|
|
{$R *.fmx}
|
|
|
|
procedure waveInProc(hwi: HWAVEIN; uMsg: UINT; dwInstance, dwParam1, dwParam2: DWORD_PTR); stdcall;
|
|
var
|
|
header: PWaveHdr;
|
|
form: TForm1;
|
|
begin
|
|
if (uMsg = WIM_DATA) then
|
|
begin
|
|
header := PWaveHdr(dwParam1);
|
|
form := TForm1(dwInstance);
|
|
|
|
if (form.isRecording) and (header.dwBytesRecorded > 0) then
|
|
begin
|
|
form.fileStream.WriteBuffer(header.lpData^, header.dwBytesRecorded);
|
|
waveInPrepareHeader(hwi, header, sizeof(TWaveHdr));
|
|
waveInAddBuffer(hwi, header, sizeof(TWaveHdr));
|
|
end;
|
|
end;
|
|
end;
|
|
|
|
procedure TForm1.Log(const Msg: string);
|
|
begin
|
|
TThread.Queue(nil,
|
|
procedure
|
|
begin
|
|
LogMemo.Lines.Add(FormatDateTime('hh:nn:ss.zzz', Now) + ': ' + Msg);
|
|
LogMemo.GoToTextEnd;
|
|
end);
|
|
end;
|
|
|
|
procedure TForm1.LogDebug(const Msg: string);
|
|
begin
|
|
if not DebugCheckBox.IsChecked then exit;
|
|
|
|
TThread.Queue(nil,
|
|
procedure
|
|
begin
|
|
LogMemo.Lines.Add(FormatDateTime('hh:nn:ss.zzz', Now) + ' [DEBUG]: ' + Msg);
|
|
LogMemo.GoToTextEnd;
|
|
end);
|
|
end;
|
|
|
|
function TForm1.GetAudioMetadata: TJSONObject;
|
|
begin
|
|
Result := TJSONObject.Create;
|
|
Result.AddPair('rate', TJSONNumber.Create(16000));
|
|
Result.AddPair('width', TJSONNumber.Create(2));
|
|
Result.AddPair('channels', TJSONNumber.Create(1));
|
|
end;
|
|
|
|
procedure TForm1.WriteWavHeader;
|
|
begin
|
|
FillChar(waveHeader, sizeof(TWaveHeader), 0);
|
|
waveHeader.RIFF := 'RIFF';
|
|
waveHeader.WAVE := 'WAVE';
|
|
waveHeader.fmt := 'fmt ';
|
|
waveHeader.FormatLength := 16;
|
|
waveHeader.FormatTag := WAVE_FORMAT_PCM;
|
|
waveHeader.Channels := 1;
|
|
waveHeader.SampleRate := 16000;
|
|
waveHeader.BitsPerSample := 16;
|
|
waveHeader.BlockAlign := (waveHeader.Channels * waveHeader.BitsPerSample) div 8;
|
|
waveHeader.BytesPerSecond := waveHeader.SampleRate * waveHeader.BlockAlign;
|
|
waveHeader.Data := 'data';
|
|
fileStream.WriteBuffer(waveHeader, sizeof(TWaveHeader));
|
|
end;
|
|
|
|
procedure TForm1.UpdateWavHeader;
|
|
var
|
|
pos: Int64;
|
|
begin
|
|
pos := fileStream.Position;
|
|
LogDebug(Format('Updating WAV Header. Total File Size: %d', [pos]));
|
|
waveHeader.FileSize := pos - 8;
|
|
waveHeader.DataLength := pos - sizeof(TWaveHeader);
|
|
fileStream.Position := 0;
|
|
fileStream.WriteBuffer(waveHeader, sizeof(TWaveHeader));
|
|
end;
|
|
|
|
procedure TForm1.StartCapture(const FileName: string);
|
|
var
|
|
wfx: TWaveFormatEx;
|
|
i: Integer;
|
|
mmRes: MMRESULT;
|
|
begin
|
|
if (isRecording) then exit;
|
|
|
|
LogDebug('Starting capture to: ' + FileName);
|
|
fileStream := TFileStream.Create(FileName, fmCreate);
|
|
WriteWavHeader;
|
|
|
|
wfx.wFormatTag := WAVE_FORMAT_PCM;
|
|
wfx.nChannels := waveHeader.Channels;
|
|
wfx.nSamplesPerSec := waveHeader.SampleRate;
|
|
wfx.nAvgBytesPerSec := waveHeader.BytesPerSecond;
|
|
wfx.nBlockAlign := waveHeader.BlockAlign;
|
|
wfx.wBitsPerSample := waveHeader.BitsPerSample;
|
|
wfx.cbSize := 0;
|
|
|
|
LogDebug('Opening WaveIn device...');
|
|
mmRes := waveInOpen(@waveHandle, WAVE_MAPPER, @wfx, DWORD_PTR(@waveInProc), DWORD_PTR(self), CALLBACK_FUNCTION);
|
|
|
|
if (mmRes = MMSYSERR_NOERROR) then
|
|
begin
|
|
isRecording := true;
|
|
LogDebug('WaveIn device opened successfully. Preparing buffers...');
|
|
for i := 0 to 1 do
|
|
begin
|
|
FillChar(waveCaps[i], sizeof(TWaveHdr), 0);
|
|
waveCaps[i].lpData := @waveBuffers[i];
|
|
waveCaps[i].dwBufferLength := sizeof(waveBuffers[i]);
|
|
waveInPrepareHeader(waveHandle, @waveCaps[i], sizeof(TWaveHdr));
|
|
waveInAddBuffer(waveHandle, @waveCaps[i], sizeof(TWaveHdr));
|
|
end;
|
|
waveInStart(waveHandle);
|
|
LogDebug('WaveIn started.');
|
|
end
|
|
else
|
|
begin
|
|
Log('Error opening WaveIn device. Error code: ' + IntToStr(mmRes));
|
|
end;
|
|
end;
|
|
|
|
procedure TForm1.StopCapture;
|
|
var
|
|
i: Integer;
|
|
begin
|
|
if (not isRecording) then exit;
|
|
|
|
LogDebug('Stopping capture...');
|
|
isRecording := false;
|
|
waveInStop(waveHandle);
|
|
waveInReset(waveHandle);
|
|
|
|
for i := 0 to 1 do
|
|
waveInUnprepareHeader(waveHandle, @waveCaps[i], sizeof(TWaveHdr));
|
|
|
|
waveInClose(waveHandle);
|
|
LogDebug('WaveIn closed.');
|
|
|
|
UpdateWavHeader;
|
|
if (Assigned(fileStream)) then
|
|
FreeAndNil(fileStream);
|
|
end;
|
|
|
|
procedure TForm1.RecordButtonClick(Sender: TObject);
|
|
begin
|
|
tempAudioPath := TPath.Combine(TPath.GetTempPath, 'whisper_capture.wav');
|
|
StartCapture(tempAudioPath);
|
|
RecordButton.Enabled := false;
|
|
StopButton.Enabled := true;
|
|
Log('Recording started...');
|
|
end;
|
|
|
|
procedure TForm1.StopButtonClick(Sender: TObject);
|
|
begin
|
|
StopCapture;
|
|
RecordButton.Enabled := true;
|
|
StopButton.Enabled := false;
|
|
if (TFile.Exists(tempAudioPath)) then
|
|
SendToWhisperAsync(tempAudioPath);
|
|
end;
|
|
|
|
procedure TForm1.FormDestroy(Sender: TObject);
|
|
begin
|
|
if (isRecording) then
|
|
StopCapture;
|
|
end;
|
|
|
|
function TForm1.CreateEvent(const EventType: string; Data: TJSONObject; PayloadLen: Int64): string;
|
|
var
|
|
evt: TJSONObject;
|
|
begin
|
|
evt := TJSONObject.Create;
|
|
try
|
|
evt.AddPair('type', EventType);
|
|
if (Assigned(Data)) then
|
|
evt.AddPair('data', Data)
|
|
else
|
|
evt.AddPair('data', TJSONObject.Create);
|
|
|
|
if (PayloadLen >= 0) then
|
|
evt.AddPair('payload_length', TJSONNumber.Create(PayloadLen));
|
|
|
|
Result := evt.ToJSON + #10;
|
|
finally
|
|
evt.Free;
|
|
end;
|
|
end;
|
|
|
|
procedure TForm1.QueryLLM(const UserInput: string);
|
|
var
|
|
client: TNetHTTPClient;
|
|
reqJson, msgSystem, msgUser: TJSONObject;
|
|
msgs: TJSONArray;
|
|
resp: IHTTPResponse;
|
|
respJson: TJSONObject;
|
|
choices: TJSONArray;
|
|
firstChoice, messageObj: TJSONObject;
|
|
content: string;
|
|
|
|
exePath, promptPath, systemPromptText: string;
|
|
begin
|
|
LogDebug('Querying LLM (RAKI)...');
|
|
|
|
// Resolve system prompt path
|
|
exePath := ExtractFilePath(ParamStr(0));
|
|
promptPath := TPath.GetFullPath(TPath.Combine(exePath, '..\..\Prompt.md'));
|
|
|
|
if TFile.Exists(promptPath) then
|
|
begin
|
|
LogDebug('Loading system prompt from: ' + promptPath);
|
|
systemPromptText := TFile.ReadAllText(promptPath, TEncoding.UTF8);
|
|
end
|
|
else
|
|
begin
|
|
LogDebug('Prompt file not found at: ' + promptPath + '. Using default.');
|
|
systemPromptText := 'You are a helpful assistant.';
|
|
end;
|
|
|
|
client := TNetHTTPClient.Create(nil);
|
|
reqJson := TJSONObject.Create;
|
|
try
|
|
try
|
|
// Prepare Request JSON
|
|
reqJson.AddPair('model', 'openai/gpt-oss-20b');
|
|
reqJson.AddPair('temperature', TJSONNumber.Create(0.7));
|
|
reqJson.AddPair('stream', TJSONBool.Create(False));
|
|
|
|
msgs := TJSONArray.Create;
|
|
|
|
msgSystem := TJSONObject.Create;
|
|
msgSystem.AddPair('role', 'system');
|
|
msgSystem.AddPair('content', systemPromptText);
|
|
msgs.Add(msgSystem);
|
|
|
|
msgUser := TJSONObject.Create;
|
|
msgUser.AddPair('role', 'user');
|
|
msgUser.AddPair('content', UserInput);
|
|
msgs.Add(msgUser);
|
|
|
|
reqJson.AddPair('messages', msgs);
|
|
|
|
client.ContentType := 'application/json';
|
|
client.Accept := 'application/json';
|
|
|
|
// Execute Request
|
|
resp := client.Post('http://raki:1234/v1/chat/completions', TStringStream.Create(reqJson.ToJSON, TEncoding.UTF8));
|
|
|
|
LogDebug('LLM Status: ' + IntToStr(resp.StatusCode));
|
|
|
|
if resp.StatusCode = 200 then
|
|
begin
|
|
respJson := TJSONObject.ParseJSONValue(resp.ContentAsString(TEncoding.UTF8)) as TJSONObject;
|
|
if Assigned(respJson) then
|
|
try
|
|
// Parse: choices[0].message.content
|
|
choices := respJson.GetValue('choices') as TJSONArray;
|
|
if (Assigned(choices)) and (choices.Count > 0) then
|
|
begin
|
|
firstChoice := choices.Items[0] as TJSONObject;
|
|
messageObj := firstChoice.GetValue('message') as TJSONObject;
|
|
if Assigned(messageObj) then
|
|
begin
|
|
content := messageObj.GetValue('content').Value;
|
|
|
|
TThread.Queue(nil,
|
|
procedure
|
|
begin
|
|
OutputMemo.Lines.Add('');
|
|
OutputMemo.Lines.Add('RAKI: ' + content);
|
|
end);
|
|
end;
|
|
end;
|
|
finally
|
|
respJson.Free;
|
|
end;
|
|
end
|
|
else
|
|
begin
|
|
Log('LLM Error (' + IntToStr(resp.StatusCode) + '): ' + resp.StatusText);
|
|
end;
|
|
|
|
except
|
|
on E: Exception do
|
|
Log('LLM Exception: ' + E.Message);
|
|
end;
|
|
finally
|
|
reqJson.Free;
|
|
client.Free;
|
|
end;
|
|
end;
|
|
|
|
procedure TForm1.SendToWhisperAsync(const FilePath: string);
|
|
begin
|
|
TThread.CreateAnonymousThread(
|
|
procedure
|
|
var
|
|
client: TIdTCPClient;
|
|
stream: TFileStream;
|
|
jsonResp, jsonData: TJSONObject;
|
|
response, msgType, textValue: string;
|
|
dataSize: Int64;
|
|
isFinished: Boolean;
|
|
payloadLen: Integer;
|
|
debugMsg: string;
|
|
begin
|
|
client := TIdTCPClient.Create(nil);
|
|
isFinished := false;
|
|
try
|
|
try
|
|
client.Host := '192.168.178.10';
|
|
client.Port := 10300;
|
|
client.ReadTimeout := 30000;
|
|
|
|
LogDebug('Connecting to ' + client.Host + ':' + IntToStr(client.Port));
|
|
|
|
if not TFile.Exists(FilePath) then
|
|
begin
|
|
LogDebug('File not found: ' + FilePath);
|
|
exit;
|
|
end;
|
|
|
|
client.Connect;
|
|
client.IOHandler.DefStringEncoding := IndyTextEncoding_UTF8;
|
|
|
|
stream := TFileStream.Create(FilePath, fmOpenRead or fmShareDenyNone);
|
|
try
|
|
dataSize := stream.Size - sizeof(TWaveHeader);
|
|
LogDebug(Format('Sending audio. PCM Data Size: %d', [dataSize]));
|
|
|
|
// 1. Wyoming protocol sequence
|
|
debugMsg := CreateEvent('audio-start', self.GetAudioMetadata);
|
|
LogDebug('Sending: ' + debugMsg.Trim);
|
|
client.IOHandler.Write(debugMsg);
|
|
|
|
debugMsg := CreateEvent('audio-chunk', self.GetAudioMetadata, dataSize);
|
|
LogDebug('Sending: ' + debugMsg.Trim);
|
|
client.IOHandler.Write(debugMsg);
|
|
|
|
stream.Position := sizeof(TWaveHeader);
|
|
client.IOHandler.Write(stream, dataSize);
|
|
|
|
LogDebug('Audio data sent.');
|
|
|
|
client.IOHandler.Write(CreateEvent('audio-stop'));
|
|
|
|
// 2. Response event loop
|
|
while (client.Connected) and (not isFinished) do
|
|
begin
|
|
response := client.IOHandler.ReadLn();
|
|
if (response = '') then
|
|
begin
|
|
client.IOHandler.CheckForDisconnect(true, true);
|
|
if not client.Connected then break;
|
|
TThread.Sleep(10);
|
|
continue;
|
|
end;
|
|
|
|
LogDebug('Received: ' + response);
|
|
|
|
jsonResp := TJSONObject.ParseJSONValue(response) as TJSONObject;
|
|
if Assigned(jsonResp) then
|
|
try
|
|
if jsonResp.TryGetValue('type', msgType) then
|
|
begin
|
|
if (msgType = 'transcript') then
|
|
begin
|
|
textValue := '';
|
|
if (jsonResp.TryGetValue('data_length', payloadLen)) and (payloadLen > 0) then
|
|
begin
|
|
textValue := client.IOHandler.ReadString(payloadLen);
|
|
// Handle case where payload is a JSON object itself (e.g. {"text": "..."})
|
|
if textValue.Trim.StartsWith('{') then
|
|
begin
|
|
var nestedJson := TJSONObject.ParseJSONValue(textValue) as TJSONObject;
|
|
if Assigned(nestedJson) then
|
|
try
|
|
nestedJson.TryGetValue('text', textValue);
|
|
finally
|
|
nestedJson.Free;
|
|
end;
|
|
end;
|
|
end
|
|
else if jsonResp.TryGetValue('data', jsonData) then
|
|
jsonData.TryGetValue('text', textValue);
|
|
|
|
if (textValue <> '') then
|
|
begin
|
|
var finalTxt := textValue.Trim;
|
|
|
|
LogDebug('Transcript payload: ' + finalTxt);
|
|
|
|
TThread.Queue(nil,
|
|
procedure
|
|
begin
|
|
OutputMemo.Lines.Add('Me: ' + finalTxt);
|
|
end);
|
|
|
|
// Trigger LLM request (still in background thread context)
|
|
QueryLLM(finalTxt);
|
|
end;
|
|
isFinished := true;
|
|
end
|
|
else if (msgType = 'error') then
|
|
begin
|
|
Log('[Thread] Server Error: ' + response);
|
|
isFinished := true;
|
|
end;
|
|
end;
|
|
finally
|
|
jsonResp.Free;
|
|
end;
|
|
end;
|
|
finally
|
|
stream.Free;
|
|
end;
|
|
except
|
|
on E: Exception do
|
|
begin
|
|
var errMsg := E.Message;
|
|
Log('[Thread] Exception: ' + errMsg);
|
|
end;
|
|
end;
|
|
finally
|
|
if client.Connected then
|
|
client.Disconnect;
|
|
client.Free;
|
|
end;
|
|
end).Start;
|
|
end;
|
|
|
|
end.
|