Files
noctalia-ostt/recorder.luau
T

188 lines
6.1 KiB
Luau
Raw Normal View History

--!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