//! 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(); }