From 7921ac7c921907936a4a1bf89cf075c044f18654 Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 13 Jul 2026 12:18:22 -0400 Subject: [PATCH] feat(ostt): add monitor and recorder services for OSTT integration --- monitor.luau | 185 +++++++++++++++++++++++++++++++++++++++++++++++++ recorder.luau | 187 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 monitor.luau create mode 100644 recorder.luau diff --git a/monitor.luau b/monitor.luau new file mode 100644 index 0000000..65b1314 --- /dev/null +++ b/monitor.luau @@ -0,0 +1,185 @@ +--!nonstrict +-- Headless [[service]]: the single poller behind the OSTT recorder widget. +-- +-- ostt writes $XDG_RUNTIME_DIR/ostt/recording.pid while the microphone is +-- capturing (and keeps the process alive through transcription). This service +-- polls that pidfile (liveness-checked — ostt leaves a stale file behind when +-- it is killed) plus a wrapper-written exit-code file, derives a phase, and +-- publishes it to the plugin state channel: +-- +-- status = { phase = "idle"|"starting"|"recording"|"transcribing", +-- pid = number?, elapsed = seconds } +-- +-- The recorder widget writes an `intent` record for every action it takes so +-- the service can distinguish widget-started sessions (notify on completion, +-- report failures) from external ones (`ostt launch` hotkeys — stay silent, +-- ostt's own popup already reports those): +-- +-- intent = { action = "start"|"stop"|"cancel", pid = number?, +-- mode = "paste"|"clipboard", notify = boolean } | false +-- +-- Time comes from the poll shell (`date +%s`), not the Luau VM, so elapsed +-- math never depends on os.* availability in the sandbox. + +local function shq(s) + return "'" .. (s:gsub("'", "'\\''")) .. "'" +end + +local runtime = noctalia.getenv("XDG_RUNTIME_DIR") +local user = noctalia.getenv("USER") or "unknown" +local osttRuntimeDir = runtime and (runtime .. "/ostt") or ("/tmp/ostt-" .. user) +local pidFile = osttRuntimeDir .. "/recording.pid" +local exitFile = (runtime or "/tmp") .. "/noctalia-ostt-exit" + +-- Emits one line: " " +local pollCmd = table.concat({ + "t=$(date +%s)", + "p=$(cat " .. shq(pidFile) .. " 2>/dev/null)", + 'if [ -n "$p" ] && kill -0 "$p" 2>/dev/null; then :; else p=-; fi', + "e=$(cat " .. shq(exitFile) .. " 2>/dev/null)", + '[ -n "$e" ] || e=-', + 'echo "$t $p $e"', +}, "; ") + +local STARTING_GRACE = 6 -- seconds to wait for the pidfile after a start intent + +local phase = "idle" +local since = nil -- epoch when the live recorder was first observed +local startingSince = nil -- epoch when the start intent was first observed +local polling = false + +local function getIntent() + local v = noctalia.state.get("intent") + return type(v) == "table" and v or nil +end + +local function clearIntent() + noctalia.state.set("intent", false) +end + +local function publish(newPhase, pid, elapsed) + phase = newPhase + noctalia.state.set("status", { phase = newPhase, pid = pid, elapsed = elapsed or 0 }) + -- Poll faster while a session is in flight so stop/cancel feel immediate. + noctalia.setUpdateInterval(newPhase == "idle" and 1000 or 500) +end + +-- Session ended (pid gone). Notify only for widget-started sessions where the +-- wrapper reported an exit code; external sessions have neither intent nor +-- exit file and stay silent. +local function finishSession(exitCode) + local intent = getIntent() + clearIntent() + noctalia.runAsync("rm -f " .. shq(exitFile)) + if not intent or intent.action == "cancel" then + return + end + if exitCode == "0" then + if intent.notify then + local key = intent.mode == "clipboard" and "notify.done_clipboard" or "notify.done_paste" + noctalia.notify(noctalia.tr("title"), noctalia.tr(key)) + end + elseif exitCode ~= nil then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.failed", { code = exitCode })) + end +end + +local function handlePoll(t, pid, exitCode) + local intent = getIntent() + + if pid then + startingSince = nil + -- Cancel raced the startup: the recorder appeared after the widget gave + -- up on it. Kill it instead of adopting it. + if intent and intent.action == "cancel" then + noctalia.runAsync("kill -TERM " .. pid .. " 2>/dev/null; rm -f " .. shq(pidFile)) + clearIntent() + publish("idle") + return + end + since = since or t + local stopping = intent and intent.action == "stop" and intent.pid == pid + publish(stopping and "transcribing" or "recording", pid, t - since) + return + end + + -- No live recorder. + local wasActive = phase == "recording" or phase == "transcribing" + since = nil + if wasActive then + startingSince = nil + finishSession(exitCode) + publish("idle") + return + end + + if intent and intent.action == "start" then + if exitCode then + -- The wrapped command exited before the pidfile ever appeared + -- (e.g. no audio device, ostt misconfigured). + startingSince = nil + finishSession(exitCode) + publish("idle") + else + startingSince = startingSince or t + if t - startingSince > STARTING_GRACE then + startingSince = nil + clearIntent() + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notify.start_timeout")) + publish("idle") + else + publish("starting") + end + end + return + end + + -- A cancel that raced startup: keep it briefly so a late-appearing recorder + -- still gets killed, then expire it so it can never hit a future session. + if intent and intent.action == "cancel" then + startingSince = startingSince or t + if t - startingSince > STARTING_GRACE then + startingSince = nil + clearIntent() + end + publish("idle") + return + end + + startingSince = nil + if phase ~= "idle" then + publish("idle") + end +end + +local function doPoll() + if polling then + return + end + polling = true + local started = noctalia.runAsync(pollCmd, function(res) + polling = false + if res.exitCode ~= 0 then + return + end + local t, pid, exitCode = string.match(res.stdout, "(%d+)%s+(%S+)%s+(%S+)") + if not t then + return + end + handlePoll(tonumber(t), pid ~= "-" and tonumber(pid) or nil, exitCode ~= "-" and exitCode or nil) + end) + if not started then + polling = false + end +end + +-- React to widget actions immediately instead of waiting for the next tick. +noctalia.state.watch("intent", function() + doPoll() +end) + +noctalia.setUpdateInterval(1000) + +function update() + doPoll() +end diff --git a/recorder.luau b/recorder.luau new file mode 100644 index 0000000..2e18441 --- /dev/null +++ b/recorder.luau @@ -0,0 +1,187 @@ +--!nonstrict +-- OSTT recorder [[widget]]: a bar capsule that toggles a headless ostt +-- recording and shows a record indicator while the microphone is live. +-- +-- left click idle → start recording; recording → stop & transcribe +-- right click cancel the in-flight session (no transcription) +-- IPC toggle | start | stop | cancel, for compositor hotkeys: +-- noctalia msg plugin prdlk/ostt:recorder focused toggle +-- +-- ostt's recorder is a TUI and refuses to start without a terminal, so the +-- widget runs it inside a throwaway pty via `script(1)` — no popup window, +-- the bar capsule is the only UI. Stopping is ostt's own external-trigger +-- contract: SIGUSR1 finishes the recording and runs transcription (the same +-- mechanism `ostt launch` uses for hotkey toggling). +-- +-- Output defaults to `ostt --paste`: the transcript is pasted straight into +-- the focused app. State/phase comes from the monitor service via +-- noctalia.state (see monitor.luau for the status/intent contract). + +local function shq(s) + return "'" .. (s:gsub("'", "'\\''")) .. "'" +end + +local runtime = noctalia.getenv("XDG_RUNTIME_DIR") +local user = noctalia.getenv("USER") or "unknown" +local osttRuntimeDir = runtime and (runtime .. "/ostt") or ("/tmp/ostt-" .. user) +local pidFile = osttRuntimeDir .. "/recording.pid" +local exitFile = (runtime or "/tmp") .. "/noctalia-ostt-exit" + +local outputMode = barWidget.getConfig("output_mode") or "paste" +local model = barWidget.getConfig("model") or "" +local processAction = barWidget.getConfig("process_action") or "" +local showElapsed = barWidget.getConfig("show_elapsed") +local notifyOnDone = barWidget.getConfig("notify_on_done") +local extraArgs = barWidget.getConfig("extra_args") or "" + +local osttMissing = not noctalia.commandExists("ostt") +local scriptMissing = not noctalia.commandExists("script") + +local status = nil -- authoritative phase, published by the monitor service +do + local v = noctalia.state.get("status") + status = type(v) == "table" and v or { phase = "idle" } +end + +local function fmtElapsed(sec) + sec = math.max(0, math.floor(tonumber(sec) or 0)) + return string.format("%d:%02d", sec // 60, sec % 60) +end + +local function render() + if osttMissing or scriptMissing then + barWidget.setGlyph("microphone-off") + barWidget.setGlyphColor("error") + barWidget.setText("") + barWidget.setTooltip(noctalia.tr(osttMissing and "tooltip.missing_ostt" or "tooltip.missing_script")) + return + end + + local phase = status.phase + if phase == "recording" then + barWidget.setGlyph("microphone-filled") + barWidget.setGlyphColor("error") + barWidget.setColor("error") + barWidget.setText(showElapsed and fmtElapsed(status.elapsed) or "") + barWidget.setTooltip(noctalia.tr("tooltip.recording")) + elseif phase == "transcribing" then + barWidget.setGlyph("loader-2") + barWidget.setGlyphColor("primary") + barWidget.setText("") + barWidget.setTooltip(noctalia.tr("tooltip.transcribing")) + elseif phase == "starting" then + barWidget.setGlyph("microphone") + barWidget.setGlyphColor("primary") + barWidget.setText("") + barWidget.setTooltip(noctalia.tr("tooltip.starting")) + else + barWidget.setGlyph("microphone") + barWidget.setGlyphColor("on_surface") + barWidget.setColor("on_surface") + barWidget.setText("") + barWidget.setTooltip(noctalia.tr("tooltip.idle")) + end +end + +noctalia.state.watch("status", function(value) + status = type(value) == "table" and value or { phase = "idle" } + render() +end) + +local function buildRecordCmd() + local parts = { "ostt", "record" } + if outputMode == "clipboard" then + parts[#parts + 1] = "-c" + else + parts[#parts + 1] = "--paste" + end + if model ~= "" then + parts[#parts + 1] = "-m " .. shq(model) + end + if processAction ~= "" then + parts[#parts + 1] = "-p " .. shq(processAction) + end + if extraArgs ~= "" then + parts[#parts + 1] = extraArgs + end + local inner = table.concat(parts, " ") + -- pty wrapper: `script -e` propagates ostt's exit code, which the wrapper + -- persists for the monitor service to report. + return "script -qec " .. shq(inner) .. " /dev/null >/dev/null 2>&1; echo $? > " .. shq(exitFile) +end + +local function startRecording() + if osttMissing or scriptMissing then + render() + return + end + -- Clear any stale exit file BEFORE publishing the intent, so the monitor + -- never mistakes a previous session's exit code for this one. + noctalia.runAsync("rm -f " .. shq(exitFile), function() + noctalia.state.set("intent", { action = "start", mode = outputMode, notify = notifyOnDone == true }) + noctalia.runAsync(buildRecordCmd()) + end) +end + +local function stopRecording() + local pid = status.pid + if not pid then + return + end + -- Intent first: the monitor flips to "transcribing" on its next poll. + noctalia.state.set("intent", { action = "stop", pid = pid, mode = outputMode, notify = notifyOnDone == true }) + noctalia.runAsync("kill -USR1 " .. pid) +end + +local function cancelRecording() + noctalia.state.set("intent", { action = "cancel", pid = status.pid }) + if status.pid then + -- ostt has no cancel signal; SIGTERM kills it and leaves the pidfile + -- behind, so sweep that too. + noctalia.runAsync("kill -TERM " .. status.pid .. " 2>/dev/null; rm -f " .. shq(pidFile)) + noctalia.notify(noctalia.tr("title"), noctalia.tr("notify.cancelled")) + end +end + +local function toggle() + local phase = status.phase + if phase == "idle" then + startRecording() + elseif phase == "recording" then + stopRecording() + end + -- starting/transcribing: a toggle is ambiguous — ignore it. +end + +function update() + noctalia.setUpdateInterval(2000) + render() +end + +function onClick() + toggle() +end + +function onRightClick() + if status.phase ~= "idle" then + cancelRecording() + end +end + +function onIpc(event, _payload) + if event == "toggle" then + toggle() + elseif event == "start" then + if status.phase == "idle" then + startRecording() + end + elseif event == "stop" then + if status.phase == "recording" then + stopRecording() + end + elseif event == "cancel" then + if status.phase ~= "idle" then + cancelRecording() + end + end +end