mirror of
https://github.com/prdlk/noctalia-ostt.git
synced 2026-08-02 17:41:38 +00:00
feat(ostt): add monitor and recorder services for OSTT integration
This commit is contained in:
+185
@@ -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: "<epoch> <live-pid|-> <exit-code|->"
|
||||
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
|
||||
+187
@@ -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
|
||||
Reference in New Issue
Block a user