History retention, simpler history actions, microphone picker, install offer

- Settings: "Keep history" (a day … forever, default a month); older
  recordings and their audio are pruned at startup, after each recording
  and when the setting changes.
- History rows keep play, copy and delete only.
- Advanced: the microphone is picked from PipeWire's sources instead of a
  free-text field, with a note about Bluetooth headsets switching profile.
- A stock machine without voxtype gets a banner with an Install button that
  runs omarchy-voxtype-install in a floating terminal; the daemon refuses to
  record until the tools are there and says why.
This commit is contained in:
2026-09-07 00:08:05 +01:00
parent f2ed66ed17
commit a07082f17b
4 changed files with 154 additions and 31 deletions
+4 -4
View File
@@ -11,7 +11,7 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past
- **Ask your agent**: a second key per language hands the text to Omarchy's default coding agent (`omarchy default agent`) instead of pasting it.
- **Live waveform and live text** in the bar: yellow while the microphone connects, green while it listens; the bar goes back to the icon the moment the text is pasted.
- **Stop is instant**: the recording is transcribed at every pause while you talk, so only the last phrase is left when you stop.
- **History** of every recording (text + audio) with play, copy, paste, paste-and-send and delete, searchable, in the bar popup.
- **History** of every recording (text + audio) with play, copy and delete, searchable, in the bar popup; kept for a month by default (Settings).
- **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one.
- **Engine**: Omarchy's own dictation engine, [voxtype](https://github.com/peteonrails/voxtype) (local Whisper), by default, so there is nothing new to install. whisper.cpp (`whisper-cli`) and any custom command are also supported (Settings → Advanced).
- **Safe key bindings**: a key that anything else already uses is refused, never taken over.
@@ -23,12 +23,12 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past
omarchy plugin add https://github.com/alanfortlink/speech-to-text.git --enable
```
That is all: the microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched).
That is all. The microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched).
If Omarchy's dictation engine (voxtype) is not installed yet, the popup says so and offers an **Install** button, which runs `omarchy-voxtype-install` in a floating terminal (about 150 MB, asks for your password). Everything else the plugin needs is part of a stock Omarchy: PipeWire, `wl-clipboard`, `curl`, Python 3; `wtype` comes with voxtype.
Optional: `~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh` puts the `stt` command on your PATH. From a checkout anywhere else, `./install.sh` also links the checkout into the plugins directory (handy for development).
Requires `voxtype` (Omarchy ships it: `omarchy-voxtype-install`), `pipewire`, `wtype`, `wl-clipboard`, `curl`. All present on a stock Omarchy.
## Use
- Press the language's key, talk, press it again. `Esc` discards while recording. The first press for a new language downloads its model; the bar shows "Getting ready…" until it is there.
+78 -9
View File
@@ -72,7 +72,8 @@ DEFAULT_CONFIG = {
"liveText": True, # transcribe while recording and show it in the bar
"liveIntervalMs": 1500,
"liveWindowSecs": 30,
"keepAudio": True, # keep the wav of every take next to its text
"keepAudio": True, # keep the wav of every recording next to its text
"historyDays": 30, # delete recordings older than this (0 = keep forever)
"outputMode": "paste", # paste | type | clipboard
"pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert
"restoreClipboard": True,
@@ -760,6 +761,39 @@ def deliver(cfg, text, enter):
wl_copy(saved)
def audio_sources():
"""Microphones PipeWire offers: [{name, label}] (a virtual one like Microphone Effects included)."""
try:
r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5)
nodes = json.loads(r.stdout) if r.returncode == 0 else []
except (OSError, subprocess.TimeoutExpired, ValueError):
return []
out = []
for n in nodes:
props = (n.get("info") or {}).get("props") or {}
if props.get("media.class") != "Audio/Source":
continue
name = str(props.get("node.name") or "")
if not name or name.startswith("alsa_output") or "monitor" in name:
continue
out.append({"name": name, "label": str(props.get("node.description") or props.get("node.nick") or name)})
return out
def missing_tools(cfg):
"""What a stock machine may still lack; the panel offers to install it."""
out = []
if not which("pw-record"):
out.append("pipewire")
if not which("wtype"):
out.append("wtype")
if cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"):
out.append("voxtype")
if not which("wl-copy"):
out.append("wl-clipboard")
return out
def deliver_agent(cfg, text):
"""Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it."""
tmpl = cfg.get("agentCommand") or "omarchy-agent-prompt {text}"
@@ -835,6 +869,22 @@ class History:
self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id))
self.db.commit()
def prune(self, days):
"""Delete recordings older than `days` (0: keep everything). Returns how many went."""
if not days or days <= 0:
return 0
cutoff = time.time() - days * 86400
rows = self.db.execute("SELECT id, audio FROM takes WHERE created_at < ?", (cutoff,)).fetchall()
for id, audio in rows:
if audio:
try:
os.remove(audio)
except OSError:
pass
self.db.execute("DELETE FROM takes WHERE created_at < ?", (cutoff,))
self.db.commit()
return len(rows)
# ---------------------------------------------------------------------------
# daemon
@@ -867,6 +917,8 @@ class Daemon:
self.wanted_model = "" # model a refused recording was waiting for
self.engines = available_engines() # cached: state is pushed 20×/s while recording
self.agent = self.agent_name()
self.sources = audio_sources()
self.missing = missing_tools(self.cfg)
self.error_clear = None # timer handle: errors fade by themselves
self.loop = None
self.stopping = False
@@ -898,11 +950,20 @@ class Daemon:
"download": self.download,
"agentName": self.agent,
"agentMode": self.agent_mode if self.state != "idle" else False,
"missing": self.missing,
}
if full:
msg["languageNames"] = LANGUAGES
msg["sources"] = self.sources
return msg
def refresh_environment(self):
"""Things that change rarely and cost a subprocess: only on demand."""
self.engines = available_engines()
self.agent = self.agent_name()
self.sources = audio_sources()
self.missing = missing_tools(self.cfg)
def broadcast(self, msg=None):
line = (json.dumps(msg or self.state_msg()) + "\n").encode()
for w in list(self.clients):
@@ -1025,16 +1086,13 @@ class Daemon:
if self.state != "idle":
self.broadcast()
return
if not which("pw-record"):
self.fail("pw-record (PipeWire) is not installed")
self.missing = missing_tools(self.cfg)
if self.missing:
self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it")
self.broadcast()
return
self.agent_mode = bool(agent)
self.lang = self.find_lang(code)
if self.cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"):
self.fail("voxtype is not installed — run omarchy-voxtype-install")
self.broadcast()
return
model = model_for(self.cfg, self.lang)
if model and not os.path.exists(model_path(model)):
# Not an error: the bar shows "Getting ready for <language> · N%" while it downloads.
@@ -1227,6 +1285,8 @@ class Daemon:
await self.loop.run_in_executor(None, deliver_agent, self.cfg, text)
else:
await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
self.broadcast({"type": "history-changed"})
except asyncio.CancelledError:
self._discard(path)
@@ -1345,8 +1405,9 @@ class Daemon:
except OSError as e:
self.fail(f"cannot save config: {e}")
self.binds.apply(self.cfg)
self.engines = available_engines()
self.agent = self.agent_name()
self.refresh_environment()
if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)):
self.broadcast({"type": "history-changed"})
self.ensure_models()
# ---- socket ----
@@ -1403,7 +1464,13 @@ class Daemon:
async def dispatch(self, msg, writer):
cmd = msg.get("cmd")
if cmd == "get":
self.refresh_environment()
writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode())
elif cmd == "install":
# Omarchy's own installer (asks for confirmation and the password in a floating terminal).
self.spawn(self.loop.run_in_executor(None, lambda: subprocess.Popen(
["omarchy-launch-floating-terminal-with-presentation", "omarchy-voxtype-install"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)))
elif cmd == "toggle":
self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent"))))
elif cmd == "start":
@@ -1523,6 +1590,8 @@ class Daemon:
server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20)
self.binds.sweep()
self.binds.apply(self.cfg)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
self.ensure_models()
self.loop.create_task(self.hypr_events())
for s in (signal.SIGTERM, signal.SIGINT):
+68 -18
View File
@@ -151,7 +151,7 @@ Panel {
active: root.busy || root.showError
useActiveColor: true
activeColor: root.busy ? root.takeColor : Color.urgent
tooltipText: (root.connected ? "Speech to text" : "Speech to text · starting")
tooltipText: (root.connected ? (root.svc.missing.length ? "Speech to text · click to install " + root.svc.missing.join(", ") : "Speech to text") : "Speech to text · starting")
+ (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "")
+ (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "")
+ " · right-click: dictate"
@@ -272,6 +272,14 @@ Panel {
return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e }
})
readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } })
readonly property string sourcesJson: JSON.stringify(svc ? svc.sources : [])
readonly property var micOpts: {
var list = JSON.parse(sourcesJson), cur = String(cfg.device || "default")
var opts = [ { value: "default", label: "System default" } ]
for (var i = 0; i < list.length; i++) opts.push({ value: String(list[i].name), label: String(list[i].label) })
if (cur !== "default" && !list.some(function(m) { return String(m.name) === cur })) opts.push({ value: cur, label: cur + " (not connected)" })
return opts
}
readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : [])
readonly property var conflicts: JSON.parse(conflictsJson)
function conflictText() {
@@ -474,6 +482,39 @@ Panel {
}
}
// A stock machine may not have the dictation engine yet: say so, and install it from here.
Rectangle {
width: parent.width
visible: root.connected && root.svc.missing.length > 0
height: missingCol.implicitHeight + Style.space(16)
radius: Style.cornerRadius
color: Style.normalFillFor(root.fg, Color.accent)
Column {
id: missingCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(8)
spacing: Style.space(6)
Text {
width: parent.width
text: "Dictation needs " + (root.svc ? root.svc.missing.join(", ") : "") + " to be installed. Omarchy's installer takes care of it (about 150 MB, asks for your password)."
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.Wrap
}
Button {
text: "Install"
iconText: "󰇚"
foreground: root.fg
fontFamily: root.fontFamily
bordered: true
onClicked: { if (root.svc) root.svc.install(); root.close() }
}
}
}
Text {
width: parent.width
visible: root.busy
@@ -810,20 +851,6 @@ Panel {
fontFamily: root.fontFamily
onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() }
}
PanelActionButton {
iconText: "󰆒"
tooltipText: "Paste into the focused window"
foreground: root.fg
fontFamily: root.fontFamily
onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, false) }) }
}
PanelActionButton {
iconText: "󰌑"
tooltipText: "Paste and press Return"
foreground: root.fg
fontFamily: root.fontFamily
onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, true) }) }
}
PanelActionButton {
iconText: "󰆴"
tooltipText: "Delete"
@@ -1077,6 +1104,23 @@ Panel {
checked: root.cfg.keepAudio !== false
onToggled: if (root.svc) root.svc.setSetting("keepAudio", root.cfg.keepAudio === false)
}
Row {
width: parent.width
spacing: Style.space(8)
RowLabel { text: "Keep history" }
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
showLabel: false
enabled: root.connected
value: String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays)
options: [ { value: "1", label: "For a day" }, { value: "7", label: "For a week" }, { value: "30", label: "For a month" },
{ value: "90", label: "For 3 months" }, { value: "365", label: "For a year" }, { value: "0", label: "Forever" } ]
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("historyDays", parseInt(v)); value = Qt.binding(function() { return String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays) }) }
}
}
Note { text: "Older recordings and their audio are deleted automatically." }
}
// ---------- Advanced (collapsed) ----------
@@ -1209,12 +1253,18 @@ Panel {
width: parent.width
spacing: Style.space(8)
RowLabel { text: "Microphone" }
ConfigField {
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
placeholderText: "default"
key: "device"
showLabel: false
enabled: root.connected
value: String(root.cfg.device || "default")
options: root.micOpts
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("device", v); value = Qt.binding(function() { return String(root.cfg.device || "default") }) }
}
}
Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." }
Row {
width: parent.width
spacing: Style.space(8)
+4
View File
@@ -38,6 +38,8 @@ Item {
readonly property var download: state.download || null // {model, pct} while a model is fetched
readonly property string agentName: state.agentName || "" // omarchy's default coding agent
readonly property bool agentMode: !!state.agentMode // this recording goes to the agent
readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…)
property var sources: [] // microphones (sent with the greeting and on `get`)
// ---- history (fetched on demand; the daemon says when it changed) ----
property var historyItems: []
@@ -72,6 +74,7 @@ Item {
function rebind() { return send({ cmd: "rebind" }) }
function setLang(code) { return send({ cmd: "setLang", lang: code }) }
// While the panel captures a key, our own binds must not fire on it.
function install() { return send({ cmd: "install" }) } // omarchy-voxtype-install in a floating terminal
function suspendBinds() { return send({ cmd: "suspendBinds" }) }
function resumeBinds() { return send({ cmd: "resumeBinds" }) }
function loadHistory(query, limit) {
@@ -140,6 +143,7 @@ Item {
if (!msg) return
if (msg.type === "state") {
if (msg.languageNames) root.languageNames = msg.languageNames
if (msg.sources) root.sources = msg.sources
root.state = msg
if (root.daemonError !== "") root.daemonError = ""
} else if (msg.type === "history") {