--!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 the clipboard (`ostt -c`); the settings can switch it to -- `ostt --paste` (paste into the focused app — needs working wtype/ydotool key -- injection). 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 "clipboard" 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 == "paste" then parts[#parts + 1] = "--paste" else parts[#parts + 1] = "-c" 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. SHELL=/bin/sh is REQUIRED: -- script(1) runs the command via $SHELL, and fish probes the terminal on -- startup — those handshake bytes reach ostt's TUI as a phantom Space -- keypress, pausing the recording instantly (zero samples captured). return "SHELL=/bin/sh 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