From 2cf875de28b15b5a557a881b29bc422591fbe487 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Mon, 7 Sep 2026 01:05:10 +0100 Subject: [PATCH] Same language more than once; timed warm microphone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A language can be added several times, each entry with its own key, + Return switch and agent key (ids en, en-2, …; the keybindings list shows 'English 2, sends'). Rows show 'English · 2'. - Advanced → Keep mic open: off / 2 minutes after a recording / 10 minutes / always. The timed options give instant starts during a burst of dictation while letting a Bluetooth headset return to its music profile afterwards. --- daemon/sttd.py | 75 +++++++++++++++++++++++++++++++++++++++--------- plugin/Panel.qml | 52 +++++++++++++++++++-------------- 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/daemon/sttd.py b/daemon/sttd.py index 902b045..6db81c9 100755 --- a/daemon/sttd.py +++ b/daemon/sttd.py @@ -113,6 +113,7 @@ DEFAULT_CONFIG = { "device": "default", "animation": "bars", # what the bar shows while recording: bars | wave | pulse | dots "warmMic": False, # keep the microphone stream open between recordings: instant start + pre-roll + "warmHoldSecs": 0, # with warmMic: close the stream this long after the last recording (0 = keep it open) "prerollMs": 600, # audio from just before the key press that a warm microphone keeps "cancelKey": "ESCAPE", "notify": True, @@ -183,7 +184,10 @@ def load_config(): def normalize_languages(langs): - out = [] + """A language may appear several times (one entry that sends, one that + does not…): every entry gets its own id (en, en-2, …) that the key + bindings and `stt toggle --lang` refer to.""" + out, ids = [], set() for l in langs or []: if not isinstance(l, dict): continue @@ -191,11 +195,19 @@ def normalize_languages(langs): if code not in LANGUAGES: # "pt-BR", "ptbr", "en_US" -> whisper's two-letter code base = code.split("-")[0] code = base if base in LANGUAGES else (base[:2] if base[:2] in LANGUAGES else code) - if code not in LANGUAGES or any(o["code"] == code for o in out): + if code not in LANGUAGES: continue # unknown codes would reach `voxtype --language` and the bind's shell line + id = str(l.get("id", "") or "").strip().lower() + if not re.fullmatch(r"[a-z]{2,3}(-\d+)?", id) or not id.startswith(code) or id in ids: + n, id = 1, code + while id in ids: + n += 1 + id = f"{code}-{n}" + ids.add(id) out.append({ + "id": id, "code": code, - "label": LANGUAGES.get(code, str(l.get("label", "") or code)), + "label": LANGUAGES.get(code, code), "key": str(l.get("key", "") or "").strip(), "autoSend": bool(l.get("autoSend", False)), "agentKey": str(l.get("agentKey", "") or "").strip(), @@ -317,12 +329,16 @@ class Binds: def specs(cfg): out = [] for lang in cfg["languages"]: - code, label = lang["code"], lang["label"] + code, label, lid = lang["code"], lang["label"], lang.get("id") or lang["code"] if not code: continue + if "-" in lid: # a second entry for the same language: tell them apart in the keybindings list + label = f"{label} {lid.split('-')[1]}" + if lang.get("autoSend"): + label += ", sends" for field, desc, cmd in ( - ("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code}"), - ("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code} --agent"), + ("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid}"), + ("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid} --agent"), ): pk = parse_key(lang.get(field, "")) if not pk: @@ -991,6 +1007,7 @@ class Daemon: self.missing = missing_tools(self.cfg) self.error_clear = None # timer handle: errors fade by themselves self.warm = None # a parked Recorder (warmMic): the stream is already open when the key is pressed + self.warm_close = None # timer handle: with warmHoldSecs, the parked stream closes after a quiet spell self.loop = None self.stopping = False self.stop_event = None @@ -1052,12 +1069,16 @@ class Daemon: pass # ---- recording ---- - def find_lang(self, code): - """The language with this code, else the default (the first in the list).""" + def find_lang(self, ref): + """The entry with this id (en-2), else the first with this code, else the default (the first in the list).""" usable = [l for l in self.cfg["languages"] if l["code"]] - for l in usable: - if code and l["code"] == code: - return l + if ref: + for l in usable: + if l.get("id") == ref: + return l + for l in usable: + if l["code"] == ref: + return l return usable[0] if usable else self.cfg["languages"][0] @staticmethod @@ -1172,6 +1193,10 @@ class Daemon: if self.missing or not which("pw-record"): self.warm = None return + if self.cfg.get("warmHoldSecs", 0) and self.cfg.get("warmHoldSecs", 0) > 0 and not self.rec: + # With a hold time the stream is only kept open *after* a recording, not opened ahead of one. + self.warm = None + return rec = Recorder(self.cfg.get("device", "default")) try: rec.start() @@ -1182,11 +1207,33 @@ class Daemon: rec.park() self.warm = rec + def _arm_warm_close(self): + """With a hold time, a parked stream is closed once nothing has been recorded for that long + (a Bluetooth headset then drops back to its music profile).""" + if self.warm_close: + self.warm_close.cancel() + self.warm_close = None + hold = self.cfg.get("warmHoldSecs", 0) + if self.cfg.get("warmMic") and hold and hold > 0: + self.warm_close = self.loop.call_later(hold, self._close_warm) + + def _close_warm(self): + self.warm_close = None + if not self.warm: + return + if self.state != "idle": # still transcribing: look again in a moment + self.warm_close = self.loop.call_later(2, self._close_warm) + return + w, self.warm = self.warm, None + self.loop.run_in_executor(None, w.stop) + self.broadcast() + async def release_rec(self, rec): """A recording is over: park the stream (warm) or close it (off the loop: pw-record can take a moment to die).""" if self.cfg.get("warmMic") and rec.alive and not self.stopping: rec.park() self.warm = rec + self._arm_warm_close() else: if self.warm is rec: self.warm = None @@ -1523,7 +1570,7 @@ class Daemon: if not (5 <= self.cfg.get("maxDurationSecs", 300) <= 7200): self.cfg["maxDurationSecs"] = DEFAULT_CONFIG["maxDurationSecs"] self.cfg["languages"] = normalize_languages(self.cfg.get("languages")) - if self.lang["code"] not in [l["code"] for l in self.cfg["languages"]]: + if self.lang.get("id") not in [l.get("id") for l in self.cfg["languages"]]: self.lang = self.cfg["languages"][0] try: save_config(self.cfg) @@ -1537,6 +1584,8 @@ class Daemon: w, self.warm = self.warm, None self.loop.run_in_executor(None, w.stop) self.ensure_warm() + if self.warm: + self._arm_warm_close() self.ensure_models() # ---- socket ---- @@ -1689,7 +1738,7 @@ class Daemon: """A parked stream can die (device unplugged, headset off): reopen it when it does.""" while not self.stopping: await asyncio.sleep(3) - if self.cfg.get("warmMic") and self.state == "idle" and (not self.warm or not self.warm.alive): + if self.cfg.get("warmMic") and self.state == "idle" and (not self.warm or not self.warm.alive) and not self.cfg.get("warmHoldSecs", 0): self.ensure_warm() async def shutdown(self): diff --git a/plugin/Panel.qml b/plugin/Panel.qml index 408a066..b3c0067 100644 --- a/plugin/Panel.qml +++ b/plugin/Panel.qml @@ -96,15 +96,7 @@ Panel { for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code return code } - function keyFor(code, kind) { // the applied key for one language ("" if none); kind "Dictate" (default) or "Ask agent" - if (!svc) return "" - var want = (kind || "Dictate") + " (" + langName(code) + ")", b = svc.binds || [] - for (var i = 0; i < b.length; i++) { - if (String(b[i].desc || "") !== want) continue - return (b[i].mods ? b[i].mods + " " : "") + b[i].key - } - return "" - } + function keyFor(code) { return langs.length && langs[0].code === code ? String(langs[0].key || "") : "" } // the default entry's dictate key readonly property string defaultLang: langs.length ? langs[0].code : "" function t(key, fallback) { var st = svc ? svc.strings : null; return st && st[key] ? st[key] : fallback } readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent" @@ -262,10 +254,9 @@ Panel { readonly property string langsJson: JSON.stringify(svc ? svc.languages : []) readonly property var langs: JSON.parse(langsJson) readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({})) - readonly property var addableLangs: { - var names = JSON.parse(namesJson), have = {}, out = [] - for (var i = 0; i < langs.length; i++) have[langs[i].code] = true - for (var code in names) if (!have[code]) out.push({ value: code, label: names[code] + " (" + code + ")" }) + readonly property var addableLangs: { // every language, even ones already there: a second entry can send, ask the agent… + var names = JSON.parse(namesJson), out = [] + for (var code in names) out.push({ value: code, label: names[code] + " (" + code + ")" }) out.sort(function(a, b) { return a.value === "auto" ? -1 : b.value === "auto" ? 1 : a.label.localeCompare(b.label) }) return out } @@ -301,10 +292,13 @@ Panel { function addLang(code) { if (!code) return var list = JSON.parse(langsJson) - for (var i = 0; i < list.length; i++) if (list[i].code === code) return list.push({ code: code, key: "", autoSend: false, agentKey: "", engineArgs: "" }) saveLangs(list) } + function langTitle(l) { // "English", or "English · 2" for a second entry of the same language + var id = String(l.id || l.code), n = id.indexOf("-") > 0 ? id.slice(id.indexOf("-") + 1) : "" + return langName(l.code) + (n ? " · " + n : "") + } function moveLang(index, delta) { // the first language is the default var list = JSON.parse(langsJson), j = index + delta if (index < 0 || index >= list.length || j < 0 || j >= list.length) return @@ -976,7 +970,7 @@ Panel { width: parent.width - langRow.cols.keyW * 2 - langRow.cols.sendW - langRow.cols.actW - parent.spacing * 4 Text { width: parent.width - text: root.langName(langRow.modelData.code) + text: root.langTitle(langRow.modelData) color: root.fg font.family: root.fontFamily font.pixelSize: Style.font.body @@ -1089,7 +1083,7 @@ Panel { color: Color.urgent opacity: 1 } - Note { text: "The first language is the default. + Return also presses Return after pasting. Esc discards while recording." } + Note { text: "The first entry is the default. + Return also presses Return after pasting. Add the same language twice for one key that sends and one that does not. Esc discards while recording." } } // ---------- The two switches that matter ---------- @@ -1338,13 +1332,27 @@ Panel { } } 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." } - SwitchRow { - label: "Keep the microphone open" - summary: checked ? "instant start, half a second of pre-roll" : "opens on each key press" - checked: !!root.cfg.warmMic - onToggled: if (root.svc) root.svc.setSetting("warmMic", !root.cfg.warmMic) + Row { + width: parent.width + spacing: Style.space(8) + RowLabel { text: "Keep mic open" } + Dropdown { + width: parent.width - root.labelW - parent.spacing - root.trailInset + showLabel: false + enabled: root.connected + readonly property string mode: !root.cfg.warmMic ? "off" : String(root.cfg.warmHoldSecs || 0) + value: mode + options: [ { value: "off", label: "No — open it on each key press" }, { value: "120", label: "For 2 minutes after a recording" }, + { value: "600", label: "For 10 minutes after a recording" }, { value: "0", label: "Always" } ] + foreground: root.fg + fontFamily: root.fontFamily + onChanged: function(v) { + if (root.svc) root.svc.setConfig(v === "off" ? { warmMic: false } : { warmMic: true, warmHoldSecs: parseInt(v) }) + value = Qt.binding(function() { return mode }) + } + } } - Note { text: "The stream stays open between recordings, so the first words are never missed: the recording even includes the moment before the key press. With a Bluetooth headset this keeps it in headset mode all the time, so pair it with a wired or USB microphone above." } + Note { text: "While the microphone is kept open a recording starts instantly and even includes the half second before the key press. A Bluetooth headset stays in headset mode (call-quality sound) for that time, so with one, prefer a timed option." } Row { width: parent.width spacing: Style.space(8)