holy fuck voice to agent response works kinda sorta
This commit is contained in:
296
src/main.zig
Normal file
296
src/main.zig
Normal file
@@ -0,0 +1,296 @@
|
||||
//! inferon — voice interface daemon for Letta Code agents.
|
||||
//! Phase 2: pw-record subprocess capture wired into the state machine.
|
||||
|
||||
const std = @import("std");
|
||||
const Io = std.Io;
|
||||
const whisper = @import("whisper.zig");
|
||||
const qt6 = @import("libqt6zig");
|
||||
const QApplication = qt6.QApplication;
|
||||
const QSystemTrayIcon = qt6.QSystemTrayIcon;
|
||||
const QWidget = qt6.QWidget;
|
||||
const QMenu = qt6.QMenu;
|
||||
const QAction = qt6.QAction;
|
||||
const QPixmap = qt6.QPixmap;
|
||||
const QIcon = qt6.QIcon;
|
||||
const QColor = qt6.QColor;
|
||||
const letta = @import("letta.zig");
|
||||
|
||||
// ------------------------------------------------------------- constants ---
|
||||
|
||||
const RECORDER_BIN = "pw-record";
|
||||
const RECORDINGS_DIR = "/tmp/inferon";
|
||||
/// whisper.cpp wants 16 kHz mono S16 LE.
|
||||
const RECORD_RATE = "16000";
|
||||
const RECORD_CHANNELS = "1";
|
||||
const RECORD_FORMAT = "s16";
|
||||
const LETTA_AGENT_ID = "agent-local-f69432ca-1bcd-42eb-b7f9-cf18d187282c";
|
||||
// ---------------------------------------------------------------- states ---
|
||||
|
||||
const State = enum {
|
||||
idle, // not listening, waiting for user toggle
|
||||
recording, // mic capture in progress
|
||||
transcribing, // audio -> whisper server
|
||||
thinking, // agent processing
|
||||
speaking, // TTS playback
|
||||
error_backend, // whisper/LLM unreachable
|
||||
|
||||
fn label(s: State) []const u8 {
|
||||
return switch (s) {
|
||||
.idle => "idle",
|
||||
.recording => "rec",
|
||||
.transcribing => "stt",
|
||||
.thinking => "think",
|
||||
.speaking => "say",
|
||||
.error_backend => "err",
|
||||
};
|
||||
}
|
||||
|
||||
/// Tray icon color per state (RGB).
|
||||
fn color(s: State) [3]u8 {
|
||||
return switch (s) {
|
||||
.idle => .{ 90, 90, 110 }, // muted grey
|
||||
.recording => .{ 220, 60, 60 }, // red
|
||||
.transcribing => .{ 220, 160, 60 }, // orange
|
||||
.thinking => .{ 90, 160, 230 }, // blue
|
||||
.speaking => .{ 120, 210, 110 }, // green
|
||||
.error_backend => .{ 180, 40, 180 }, // magenta
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
var state: State = .idle;
|
||||
var tray_icon: QSystemTrayIcon = undefined;
|
||||
var allocator: std.mem.Allocator = undefined;
|
||||
|
||||
var conversation_active: bool = false;
|
||||
var last_user_text: ?[]u8 = null;
|
||||
|
||||
fn setLastUserText(text: []u8) void {
|
||||
if (last_user_text) |old| allocator.free(old);
|
||||
last_user_text = text;
|
||||
}
|
||||
// ------------------------------------------------------------------ icon ---
|
||||
|
||||
var current_icon: ?QIcon = null;
|
||||
var current_pixmap: ?QPixmap = null;
|
||||
|
||||
fn setState(next: State) void {
|
||||
state = next;
|
||||
|
||||
const c = next.color();
|
||||
const color = QColor.new5(c[0], c[1], c[2]);
|
||||
defer color.delete();
|
||||
|
||||
const pixmap = QPixmap.new2(128, 128);
|
||||
pixmap.fill1(color);
|
||||
|
||||
const icon = QIcon.new2(pixmap);
|
||||
|
||||
if (current_icon) |old| old.delete();
|
||||
if (current_pixmap) |old| old.delete();
|
||||
current_icon = icon;
|
||||
current_pixmap = pixmap;
|
||||
|
||||
tray_icon.setIcon(icon);
|
||||
|
||||
const tip = std.fmt.allocPrint(allocator, "inferon — {s}", .{next.label()}) catch return;
|
||||
defer allocator.free(tip);
|
||||
tray_icon.setToolTip(tip);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- capture ---
|
||||
|
||||
var recorder: ?std.process.Child = null;
|
||||
var recording_path: ?[]const u8 = null;
|
||||
var io_ctx: ?std.Io = null;
|
||||
|
||||
fn startRecording() void {
|
||||
const io = io_ctx orelse return;
|
||||
Io.Dir.createDirAbsolute(io, RECORDINGS_DIR, .default_dir) catch |e| switch (e) {
|
||||
error.PathAlreadyExists => {},
|
||||
else => {
|
||||
std.debug.print("inferon: cannot create {s}: {s}\n", .{ RECORDINGS_DIR, @errorName(e) });
|
||||
setState(.error_backend);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const path = std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{s}/utterance-{d}.wav",
|
||||
.{ RECORDINGS_DIR, Io.Clock.now(.real, io).nanoseconds },
|
||||
) catch {
|
||||
setState(.error_backend);
|
||||
return;
|
||||
};
|
||||
recording_path = path;
|
||||
|
||||
// pw-record finalizes the WAV header on SIGTERM — kill() sends it.
|
||||
// stderr goes to a log so failures aren't silent.
|
||||
const stderr_log = std.fmt.allocPrint(allocator, "{s}/pw-record.log", .{RECORDINGS_DIR}) catch null;
|
||||
const stderr_file: ?Io.File = if (stderr_log) |lp|
|
||||
Io.Dir.cwd().createFile(io, lp[1..], .{ .truncate = true }) catch null
|
||||
else
|
||||
null;
|
||||
|
||||
recorder = std.process.spawn(io, .{
|
||||
.argv = &.{
|
||||
RECORDER_BIN,
|
||||
"--rate",
|
||||
RECORD_RATE,
|
||||
"--channels",
|
||||
RECORD_CHANNELS,
|
||||
"--format",
|
||||
RECORD_FORMAT,
|
||||
path,
|
||||
},
|
||||
.stdin = .ignore,
|
||||
.stdout = .ignore,
|
||||
.stderr = if (stderr_file) |f| .{ .file = f } else .ignore,
|
||||
}) catch |e| {
|
||||
std.debug.print("inferon: failed to spawn {s}: {s}\n", .{ RECORDER_BIN, @errorName(e) });
|
||||
allocator.free(path);
|
||||
recording_path = null;
|
||||
setState(.error_backend);
|
||||
return;
|
||||
};
|
||||
setState(.recording);
|
||||
}
|
||||
|
||||
/// Stop pw-record. kill() delivers SIGTERM (pw-record finalizes the WAV
|
||||
/// header) and reaps the child itself — no separate wait() needed.
|
||||
fn stopRecording() void {
|
||||
const io = io_ctx orelse return;
|
||||
const child = &(recorder orelse return);
|
||||
|
||||
child.kill(io);
|
||||
recorder = null;
|
||||
}
|
||||
|
||||
fn onQuit(_: QAction) callconv(.c) void {
|
||||
if (recorder != null) stopRecording();
|
||||
whisper.stop(); // whisper-server dies with us
|
||||
QApplication.quit();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- handlers ---
|
||||
|
||||
fn onTrayActivated(_: QSystemTrayIcon, reason: i32) callconv(.c) void {
|
||||
// Trigger = 3 (click). Only toggle on left click; context menu handles the rest.
|
||||
if (reason != 3) return;
|
||||
|
||||
switch (state) {
|
||||
.idle => {
|
||||
conversation_active = true;
|
||||
startRecording();
|
||||
},
|
||||
.recording => {
|
||||
stopRecording();
|
||||
setState(.transcribing);
|
||||
// TODO: move off UI thread once agent loop lands (blocking call).
|
||||
if (recording_path) |p| {
|
||||
const io = io_ctx.?;
|
||||
if (whisper.transcribe(allocator, io, p)) |text| {
|
||||
std.debug.print("inferon: transcript: \"{s}\"\n", .{text});
|
||||
setLastUserText(text);
|
||||
setState(.thinking);
|
||||
} else |e| {
|
||||
std.debug.print("inferon: transcription failed: {s}\n", .{@errorName(e)});
|
||||
setState(.error_backend);
|
||||
}
|
||||
}
|
||||
},
|
||||
// Placeholder transitions until agent wired in:
|
||||
.transcribing => setState(.thinking),
|
||||
.thinking => {
|
||||
const prompt = last_user_text orelse {
|
||||
std.debug.print("inferon: no transcript to send\n", .{});
|
||||
setState(.idle);
|
||||
return;
|
||||
};
|
||||
|
||||
std.debug.print("Sending to agent: {s}\n", .{prompt});
|
||||
|
||||
const response = letta.infer(
|
||||
io_ctx.?,
|
||||
allocator,
|
||||
LETTA_AGENT_ID,
|
||||
prompt,
|
||||
) catch |err| {
|
||||
std.log.err("letta failed: {s}", .{@errorName(err)});
|
||||
setState(.idle); // adapt to whatever recovery makes sense in your loop
|
||||
return;
|
||||
};
|
||||
defer allocator.free(response);
|
||||
|
||||
setState(.speaking);
|
||||
|
||||
// TODO: parse `response` (JSON? plain text?) and update UI/state
|
||||
std.debug.print("[INFERON]\n{s}\n\n", .{response});
|
||||
},
|
||||
.speaking => if (conversation_active) startRecording() else setState(.idle),
|
||||
.error_backend => setState(.idle),
|
||||
}
|
||||
}
|
||||
|
||||
fn onEndConversation(_: QAction) callconv(.c) void {
|
||||
if (recorder != null) stopRecording();
|
||||
conversation_active = false;
|
||||
if (last_user_text) |t| allocator.free(t);
|
||||
last_user_text = null;
|
||||
setState(.idle);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ main ---
|
||||
|
||||
pub fn main(init: std.process.Init) !void {
|
||||
const argv = try qt6.init(init.gpa, init.minimal.args);
|
||||
defer qt6.deinit(init.gpa, argv);
|
||||
var argc: i32 = @intCast(argv.len);
|
||||
const qapp: QApplication = .new(init.arena.allocator(), &argc, argv);
|
||||
defer qapp.delete();
|
||||
|
||||
if (!QSystemTrayIcon.isSystemTrayAvailable()) {
|
||||
std.debug.print("inferon: no system tray available\n", .{});
|
||||
return error.NoSystemTray;
|
||||
}
|
||||
|
||||
QApplication.setQuitOnLastWindowClosed(false);
|
||||
allocator = init.gpa;
|
||||
io_ctx = init.io;
|
||||
|
||||
const quit_action = QAction.new5("&Quit", QWidget{ .ptr = null });
|
||||
quit_action.onTriggered(onQuit);
|
||||
|
||||
const end_action = QAction.new5("&End conversation", QWidget{ .ptr = null });
|
||||
end_action.onTriggered(onEndConversation);
|
||||
|
||||
const tray_menu = QMenu.new(QWidget{ .ptr = null });
|
||||
tray_menu.addAction(end_action);
|
||||
_ = tray_menu.addSeparator();
|
||||
tray_menu.addAction(quit_action);
|
||||
|
||||
tray_icon = .new3(QWidget{ .ptr = null });
|
||||
tray_icon.setContextMenu(tray_menu);
|
||||
tray_icon.onActivated(onTrayActivated);
|
||||
|
||||
setState(.idle);
|
||||
tray_icon.show();
|
||||
|
||||
// Own the whisper-server lifecycle: spawn AFTER the tray exists (setState
|
||||
// touches tray_icon). Create the log dir first.
|
||||
Io.Dir.createDirAbsolute(init.io, RECORDINGS_DIR, .default_dir) catch {};
|
||||
whisper.start(allocator, init.io, RECORDINGS_DIR) catch |e| {
|
||||
std.debug.print("inferon: failed to start whisper-server: {s}\n", .{@errorName(e)});
|
||||
setState(.error_backend);
|
||||
};
|
||||
if (whisper.waitReady(init.io)) {
|
||||
std.debug.print("inferon: whisper-server ready on :{d}\n", .{whisper.WHISPER_PORT});
|
||||
} else {
|
||||
std.debug.print("inferon: whisper-server NOT ready after {d}s\n", .{whisper.READY_TIMEOUT_S});
|
||||
}
|
||||
|
||||
_ = QApplication.exec();
|
||||
|
||||
whisper.stop();
|
||||
}
|
||||
Reference in New Issue
Block a user