Same language more than once; timed warm microphone
- 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.
This commit is contained in:
+60
-11
@@ -113,6 +113,7 @@ DEFAULT_CONFIG = {
|
|||||||
"device": "default",
|
"device": "default",
|
||||||
"animation": "bars", # what the bar shows while recording: bars | wave | pulse | dots
|
"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
|
"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
|
"prerollMs": 600, # audio from just before the key press that a warm microphone keeps
|
||||||
"cancelKey": "ESCAPE",
|
"cancelKey": "ESCAPE",
|
||||||
"notify": True,
|
"notify": True,
|
||||||
@@ -183,7 +184,10 @@ def load_config():
|
|||||||
|
|
||||||
|
|
||||||
def normalize_languages(langs):
|
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 []:
|
for l in langs or []:
|
||||||
if not isinstance(l, dict):
|
if not isinstance(l, dict):
|
||||||
continue
|
continue
|
||||||
@@ -191,11 +195,19 @@ def normalize_languages(langs):
|
|||||||
if code not in LANGUAGES: # "pt-BR", "ptbr", "en_US" -> whisper's two-letter code
|
if code not in LANGUAGES: # "pt-BR", "ptbr", "en_US" -> whisper's two-letter code
|
||||||
base = code.split("-")[0]
|
base = code.split("-")[0]
|
||||||
code = base if base in LANGUAGES else (base[:2] if base[:2] in LANGUAGES else code)
|
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
|
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({
|
out.append({
|
||||||
|
"id": id,
|
||||||
"code": code,
|
"code": code,
|
||||||
"label": LANGUAGES.get(code, str(l.get("label", "") or code)),
|
"label": LANGUAGES.get(code, code),
|
||||||
"key": str(l.get("key", "") or "").strip(),
|
"key": str(l.get("key", "") or "").strip(),
|
||||||
"autoSend": bool(l.get("autoSend", False)),
|
"autoSend": bool(l.get("autoSend", False)),
|
||||||
"agentKey": str(l.get("agentKey", "") or "").strip(),
|
"agentKey": str(l.get("agentKey", "") or "").strip(),
|
||||||
@@ -317,12 +329,16 @@ class Binds:
|
|||||||
def specs(cfg):
|
def specs(cfg):
|
||||||
out = []
|
out = []
|
||||||
for lang in cfg["languages"]:
|
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:
|
if not code:
|
||||||
continue
|
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 (
|
for field, desc, cmd in (
|
||||||
("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code}"),
|
("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 {code} --agent"),
|
("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid} --agent"),
|
||||||
):
|
):
|
||||||
pk = parse_key(lang.get(field, ""))
|
pk = parse_key(lang.get(field, ""))
|
||||||
if not pk:
|
if not pk:
|
||||||
@@ -991,6 +1007,7 @@ class Daemon:
|
|||||||
self.missing = missing_tools(self.cfg)
|
self.missing = missing_tools(self.cfg)
|
||||||
self.error_clear = None # timer handle: errors fade by themselves
|
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 = 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.loop = None
|
||||||
self.stopping = False
|
self.stopping = False
|
||||||
self.stop_event = None
|
self.stop_event = None
|
||||||
@@ -1052,11 +1069,15 @@ class Daemon:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# ---- recording ----
|
# ---- recording ----
|
||||||
def find_lang(self, code):
|
def find_lang(self, ref):
|
||||||
"""The language with this code, else the default (the first in the list)."""
|
"""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"]]
|
usable = [l for l in self.cfg["languages"] if l["code"]]
|
||||||
|
if ref:
|
||||||
for l in usable:
|
for l in usable:
|
||||||
if code and l["code"] == code:
|
if l.get("id") == ref:
|
||||||
|
return l
|
||||||
|
for l in usable:
|
||||||
|
if l["code"] == ref:
|
||||||
return l
|
return l
|
||||||
return usable[0] if usable else self.cfg["languages"][0]
|
return usable[0] if usable else self.cfg["languages"][0]
|
||||||
|
|
||||||
@@ -1172,6 +1193,10 @@ class Daemon:
|
|||||||
if self.missing or not which("pw-record"):
|
if self.missing or not which("pw-record"):
|
||||||
self.warm = None
|
self.warm = None
|
||||||
return
|
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"))
|
rec = Recorder(self.cfg.get("device", "default"))
|
||||||
try:
|
try:
|
||||||
rec.start()
|
rec.start()
|
||||||
@@ -1182,11 +1207,33 @@ class Daemon:
|
|||||||
rec.park()
|
rec.park()
|
||||||
self.warm = rec
|
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):
|
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)."""
|
"""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:
|
if self.cfg.get("warmMic") and rec.alive and not self.stopping:
|
||||||
rec.park()
|
rec.park()
|
||||||
self.warm = rec
|
self.warm = rec
|
||||||
|
self._arm_warm_close()
|
||||||
else:
|
else:
|
||||||
if self.warm is rec:
|
if self.warm is rec:
|
||||||
self.warm = None
|
self.warm = None
|
||||||
@@ -1523,7 +1570,7 @@ class Daemon:
|
|||||||
if not (5 <= self.cfg.get("maxDurationSecs", 300) <= 7200):
|
if not (5 <= self.cfg.get("maxDurationSecs", 300) <= 7200):
|
||||||
self.cfg["maxDurationSecs"] = DEFAULT_CONFIG["maxDurationSecs"]
|
self.cfg["maxDurationSecs"] = DEFAULT_CONFIG["maxDurationSecs"]
|
||||||
self.cfg["languages"] = normalize_languages(self.cfg.get("languages"))
|
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]
|
self.lang = self.cfg["languages"][0]
|
||||||
try:
|
try:
|
||||||
save_config(self.cfg)
|
save_config(self.cfg)
|
||||||
@@ -1537,6 +1584,8 @@ class Daemon:
|
|||||||
w, self.warm = self.warm, None
|
w, self.warm = self.warm, None
|
||||||
self.loop.run_in_executor(None, w.stop)
|
self.loop.run_in_executor(None, w.stop)
|
||||||
self.ensure_warm()
|
self.ensure_warm()
|
||||||
|
if self.warm:
|
||||||
|
self._arm_warm_close()
|
||||||
self.ensure_models()
|
self.ensure_models()
|
||||||
|
|
||||||
# ---- socket ----
|
# ---- socket ----
|
||||||
@@ -1689,7 +1738,7 @@ class Daemon:
|
|||||||
"""A parked stream can die (device unplugged, headset off): reopen it when it does."""
|
"""A parked stream can die (device unplugged, headset off): reopen it when it does."""
|
||||||
while not self.stopping:
|
while not self.stopping:
|
||||||
await asyncio.sleep(3)
|
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()
|
self.ensure_warm()
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
|
|||||||
+30
-22
@@ -96,15 +96,7 @@ Panel {
|
|||||||
for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code
|
for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
function keyFor(code, kind) { // the applied key for one language ("" if none); kind "Dictate" (default) or "Ask agent"
|
function keyFor(code) { return langs.length && langs[0].code === code ? String(langs[0].key || "") : "" } // the default entry's dictate key
|
||||||
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 ""
|
|
||||||
}
|
|
||||||
readonly property string defaultLang: langs.length ? langs[0].code : ""
|
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 }
|
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"
|
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 string langsJson: JSON.stringify(svc ? svc.languages : [])
|
||||||
readonly property var langs: JSON.parse(langsJson)
|
readonly property var langs: JSON.parse(langsJson)
|
||||||
readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({}))
|
readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({}))
|
||||||
readonly property var addableLangs: {
|
readonly property var addableLangs: { // every language, even ones already there: a second entry can send, ask the agent…
|
||||||
var names = JSON.parse(namesJson), have = {}, out = []
|
var names = JSON.parse(namesJson), out = []
|
||||||
for (var i = 0; i < langs.length; i++) have[langs[i].code] = true
|
for (var code in names) out.push({ value: code, label: names[code] + " (" + code + ")" })
|
||||||
for (var code in names) if (!have[code]) 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) })
|
out.sort(function(a, b) { return a.value === "auto" ? -1 : b.value === "auto" ? 1 : a.label.localeCompare(b.label) })
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -301,10 +292,13 @@ Panel {
|
|||||||
function addLang(code) {
|
function addLang(code) {
|
||||||
if (!code) return
|
if (!code) return
|
||||||
var list = JSON.parse(langsJson)
|
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: "" })
|
list.push({ code: code, key: "", autoSend: false, agentKey: "", engineArgs: "" })
|
||||||
saveLangs(list)
|
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
|
function moveLang(index, delta) { // the first language is the default
|
||||||
var list = JSON.parse(langsJson), j = index + delta
|
var list = JSON.parse(langsJson), j = index + delta
|
||||||
if (index < 0 || index >= list.length || j < 0 || j >= list.length) return
|
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
|
width: parent.width - langRow.cols.keyW * 2 - langRow.cols.sendW - langRow.cols.actW - parent.spacing * 4
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: root.langName(langRow.modelData.code)
|
text: root.langTitle(langRow.modelData)
|
||||||
color: root.fg
|
color: root.fg
|
||||||
font.family: root.fontFamily
|
font.family: root.fontFamily
|
||||||
font.pixelSize: Style.font.body
|
font.pixelSize: Style.font.body
|
||||||
@@ -1089,7 +1083,7 @@ Panel {
|
|||||||
color: Color.urgent
|
color: Color.urgent
|
||||||
opacity: 1
|
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 ----------
|
// ---------- 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." }
|
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 {
|
Row {
|
||||||
label: "Keep the microphone open"
|
width: parent.width
|
||||||
summary: checked ? "instant start, half a second of pre-roll" : "opens on each key press"
|
spacing: Style.space(8)
|
||||||
checked: !!root.cfg.warmMic
|
RowLabel { text: "Keep mic open" }
|
||||||
onToggled: if (root.svc) root.svc.setSetting("warmMic", !root.cfg.warmMic)
|
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 {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: Style.space(8)
|
spacing: Style.space(8)
|
||||||
|
|||||||
Reference in New Issue
Block a user