Localized bar strings, signal-gated listening, warm microphone, animation styles

- The bar speaks the language being dictated ("Ouvindo…", "Escuchando…", …).
- Green now means real audio is arriving, not just that pw-record connected:
  a Bluetooth microphone sends silence while it switches profile, which is
  where the first words were being lost.
- Advanced → "Keep the microphone open": the stream is parked between
  recordings and a recording starts instantly with ~0.6 s of pre-roll.
- Settings → "Bar animation": bars, wave, pulse or dots.
- pw-record is shut down off the event loop so a cancel can never stall.
This commit is contained in:
2026-09-07 00:31:14 +01:00
parent a07082f17b
commit 15cd5116a2
5 changed files with 314 additions and 50 deletions
+2 -1
View File
@@ -9,7 +9,8 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past
- **One key per language** (default: `SUPER ALT D` for English; set your own in Settings). Press to start, press again to stop and paste. Esc discards. - **One key per language** (default: `SUPER ALT D` for English; set your own in Settings). Press to start, press again to stop and paste. Esc discards.
- **+ Return** per language: also press Return after pasting (for chat boxes and prompts). - **+ Return** per language: also press Return after pasting (for chat boxes and prompts).
- **Ask your agent**: a second key per language hands the text to Omarchy's default coding agent (`omarchy default agent`) instead of pasting it. - **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. - **Live waveform and live text** in the bar, in the language you are dictating: yellow while the microphone opens, green once your voice is actually coming through; the bar goes back to the icon the moment the text is pasted. Pick the look in Settings: bars, wave, pulse or dots.
- **Never miss the first words**: Settings → Advanced → "Keep the microphone open" keeps the stream running between recordings, so a recording starts instantly and even includes the half second before the key press. (Not for a Bluetooth headset: it would stay in headset mode all the time.)
- **Stop is instant**: the recording is transcribed at every pause while you talk, so only the last phrase is left when you stop. - **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 and delete, searchable, in the bar popup; kept for a month by default (Settings). - **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. - **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.
+159 -18
View File
@@ -58,6 +58,38 @@ LANGUAGES = {
"ha": "Hausa", "so": "Somali", "uz": "Uzbek", "tg": "Tajik", "be": "Belarusian", "bs": "Bosnian", "mt": "Maltese", "ha": "Hausa", "so": "Somali", "uz": "Uzbek", "tg": "Tajik", "be": "Belarusian", "bs": "Bosnian", "mt": "Maltese",
"ga": "Irish", "la": "Latin", "yi": "Yiddish", "mi": "Maori", "haw": "Hawaiian", "jw": "Javanese", "su": "Sundanese", "ga": "Irish", "la": "Latin", "yi": "Yiddish", "mi": "Maori", "haw": "Hawaiian", "jw": "Javanese", "su": "Sundanese",
} }
# What the bar says while recording, in the language being dictated.
UI_STRINGS = {
"en": {"listening": "Listening…", "opening": "Opening microphone…", "transcribing": "Transcribing…"},
"pt": {"listening": "Ouvindo…", "opening": "Abrindo o microfone…", "transcribing": "Transcrevendo…"},
"es": {"listening": "Escuchando…", "opening": "Abriendo el micrófono…", "transcribing": "Transcribiendo…"},
"fr": {"listening": "À l’écoute…", "opening": "Ouverture du micro…", "transcribing": "Transcription…"},
"de": {"listening": "Ich höre zu…", "opening": "Mikrofon wird geöffnet…", "transcribing": "Transkribiere…"},
"it": {"listening": "In ascolto…", "opening": "Apertura del microfono…", "transcribing": "Trascrizione…"},
"nl": {"listening": "Luistert…", "opening": "Microfoon openen…", "transcribing": "Transcriberen…"},
"pl": {"listening": "Słucham…", "opening": "Otwieranie mikrofonu…", "transcribing": "Transkrypcja…"},
"sv": {"listening": "Lyssnar…", "opening": "Öppnar mikrofonen…", "transcribing": "Transkriberar…"},
"da": {"listening": "Lytter…", "opening": "Åbner mikrofonen…", "transcribing": "Transskriberer…"},
"no": {"listening": "Lytter…", "opening": "Åpner mikrofonen…", "transcribing": "Transkriberer…"},
"fi": {"listening": "Kuuntelee…", "opening": "Avataan mikrofonia…", "transcribing": "Litteroidaan…"},
"tr": {"listening": "Dinliyor…", "opening": "Mikrofon açılıyor…", "transcribing": "Yazıya dökülüyor…"},
"ru": {"listening": "Слушаю…", "opening": "Открываю микрофон…", "transcribing": "Расшифровка…"},
"uk": {"listening": "Слухаю…", "opening": "Відкриваю мікрофон…", "transcribing": "Розшифровка…"},
"cs": {"listening": "Poslouchám…", "opening": "Otevírám mikrofon…", "transcribing": "Přepisuji…"},
"el": {"listening": "Ακούω…", "opening": "Άνοιγμα μικροφώνου…", "transcribing": "Απομαγνητοφώνηση…"},
"he": {"listening": "מקשיב…", "opening": "פותח מיקרופון…", "transcribing": "מתמלל…"},
"ar": {"listening": "أستمع…", "opening": "جارٍ فتح الميكروفون…", "transcribing": "جارٍ التفريغ…"},
"hi": {"listening": "सुन रहा है…", "opening": "माइक्रोफ़ोन खुल रहा है…", "transcribing": "लिख रहा है…"},
"ja": {"listening": "聞いています…", "opening": "マイクを開いています…", "transcribing": "文字起こし中…"},
"zh": {"listening": "正在聆听…", "opening": "正在打开麦克风…", "transcribing": "正在转写…"},
"ko": {"listening": "듣고 있습니다…", "opening": "마이크 여는 중…", "transcribing": "받아쓰는 중…"},
"id": {"listening": "Mendengarkan…", "opening": "Membuka mikrofon…", "transcribing": "Menyalin…"},
"vi": {"listening": "Đang nghe…", "opening": "Đang mở micrô…", "transcribing": "Đang ghi lại…"},
"th": {"listening": "กำลังฟัง…", "opening": "กำลังเปิดไมโครโฟน…", "transcribing": "กำลังถอดความ…"},
"hu": {"listening": "Hallgatom…", "opening": "Mikrofon megnyitása…", "transcribing": "Átírás…"},
"ro": {"listening": "Ascult…", "opening": "Deschid microfonul…", "transcribing": "Transcriu…"},
"ca": {"listening": "Escoltant…", "opening": "Obrint el micròfon…", "transcribing": "Transcrivint…"},
}
VOXTYPE_MODELS = os.environ.get("STT_MODELS_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")), "voxtype", "models") VOXTYPE_MODELS = os.environ.get("STT_MODELS_DIR") or os.path.join(os.environ.get("XDG_DATA_HOME", os.path.join(HOME, ".local", "share")), "voxtype", "models")
MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{model}.bin" MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{model}.bin"
@@ -79,6 +111,9 @@ DEFAULT_CONFIG = {
"restoreClipboard": True, "restoreClipboard": True,
"maxDurationSecs": 300, "maxDurationSecs": 300,
"device": "default", "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
"prerollMs": 600, # audio from just before the key press that a warm microphone keeps
"cancelKey": "ESCAPE", "cancelKey": "ESCAPE",
"notify": True, "notify": True,
} }
@@ -434,7 +469,9 @@ class Recorder:
self.lock = threading.Lock() self.lock = threading.Lock()
self.started = 0.0 self.started = 0.0
self.error = "" self.error = ""
self.listening = False # first audio chunk has arrived (pw-record takes a moment to connect) self.listening = False # real audio is arriving (a Bluetooth mic sends silence while it switches profile)
self.chunks = 0
self.standby = False # warm mode: the stream runs but only the last second is kept
def start(self): def start(self):
fake = os.environ.get("STT_FAKE_INPUT") # tests: stream a 16 kHz mono wav at real-time pace instead of the mic fake = os.environ.get("STT_FAKE_INPUT") # tests: stream a 16 kHz mono wav at real-time pace instead of the mic
@@ -459,29 +496,62 @@ class Recorder:
data = out.read(CHUNK) data = out.read(CHUNK)
if not data: if not data:
break break
self.listening = True
n = len(data) // 2 n = len(data) // 2
samples = struct.unpack(f"<{n}h", data[: n * 2]) samples = struct.unpack(f"<{n}h", data[: n * 2])
rms = math.sqrt(sum(s * s for s in samples) / max(1, n)) / 32768.0 rms = math.sqrt(sum(s * s for s in samples) / max(1, n)) / 32768.0
level = min(1.0, math.sqrt(rms * 12.0)) # perceptual-ish: speech at normal level fills most of the bar level = min(1.0, math.sqrt(rms * 12.0)) # perceptual-ish: speech at normal level fills most of the bar
self.chunks += 1
# Green means "your voice is getting through": any real signal (room noise counts),
# or 2 s of chunks for a microphone that is digitally silent.
if rms > 0.0005 or self.chunks >= 40:
self.listening = True
with self.lock: with self.lock:
self.buf += data self.buf += data
self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, so never trimmed self.levels.append(round(level, 3)) # one per 50 ms; indexed by absolute offset, never trimmed while recording
if self.standby and len(self.buf) > 3 * RATE * 2: # warm: keep only the last 1.5 s
drop = len(self.buf) - int(1.5 * RATE) * 2
drop -= drop % CHUNK
del self.buf[:drop]
del self.levels[: drop // CHUNK]
err = self.proc.stderr.read().decode(errors="replace").strip() err = self.proc.stderr.read().decode(errors="replace").strip()
rc = self.proc.wait() rc = self.proc.wait()
if rc not in (0, -15, -9) and err: if rc not in (0, -15, -9) and err:
self.error = err.splitlines()[-1] self.error = err.splitlines()[-1]
def stop(self): def stop(self):
"""Blocking (up to ~1 s): call it from an executor, never on the event loop."""
if self.proc and self.proc.poll() is None: if self.proc and self.proc.poll() is None:
self.proc.terminate() self.proc.terminate()
try: try:
self.proc.wait(timeout=2) self.proc.wait(timeout=0.5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
self.proc.kill() self.proc.kill()
self.proc.wait() self.proc.wait()
if self.thread: if self.thread:
self.thread.join(timeout=2) self.thread.join(timeout=1)
@property
def alive(self):
return self.proc is not None and self.proc.poll() is None
def park(self):
"""Warm mode: keep the stream open between recordings, remembering only the last moment."""
with self.lock:
self.standby = True
def begin(self, preroll_secs):
"""Turn a parked stream into a recording, keeping `preroll_secs` of what was just heard."""
keep = int(preroll_secs * RATE) * 2
keep -= keep % CHUNK
with self.lock:
self.standby = False
if keep and len(self.buf) > keep:
drop = len(self.buf) - keep
del self.buf[:drop]
del self.levels[: drop // CHUNK]
kept = len(self.buf)
self.started = time.time() - kept / 2 / RATE
self.error = ""
@property @property
def duration(self): def duration(self):
@@ -920,6 +990,7 @@ class Daemon:
self.sources = audio_sources() self.sources = audio_sources()
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.loop = None self.loop = None
self.stopping = False self.stopping = False
self.stop_event = None self.stop_event = None
@@ -951,6 +1022,8 @@ class Daemon:
"agentName": self.agent, "agentName": self.agent,
"agentMode": self.agent_mode if self.state != "idle" else False, "agentMode": self.agent_mode if self.state != "idle" else False,
"missing": self.missing, "missing": self.missing,
"strings": UI_STRINGS.get(self.lang["code"], UI_STRINGS["en"]),
"warm": bool(self.warm and self.warm.alive),
} }
if full: if full:
msg["languageNames"] = LANGUAGES msg["languageNames"] = LANGUAGES
@@ -1082,6 +1155,43 @@ class Daemon:
self.download_task = None self.download_task = None
self.ensure_models() # next one, if any self.ensure_models() # next one, if any
# ---- warm microphone ----
def ensure_warm(self):
"""With warmMic on, keep a parked stream ready whenever nothing is recording."""
if self.stopping or self.state != "idle":
return
if not self.cfg.get("warmMic"):
if self.warm:
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
return
if self.warm and self.warm.alive:
return
if self.warm:
self.loop.run_in_executor(None, self.warm.stop)
if self.missing or not which("pw-record"):
self.warm = None
return
rec = Recorder(self.cfg.get("device", "default"))
try:
rec.start()
except (OSError, ValueError, TypeError) as e:
log("warm microphone failed:", e)
self.warm = None
return
rec.park()
self.warm = 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)."""
if self.cfg.get("warmMic") and rec.alive and not self.stopping:
rec.park()
self.warm = rec
else:
if self.warm is rec:
self.warm = None
await self.loop.run_in_executor(None, rec.stop)
async def start(self, code=None, agent=False): async def start(self, code=None, agent=False):
if self.state != "idle": if self.state != "idle":
self.broadcast() self.broadcast()
@@ -1105,6 +1215,15 @@ class Daemon:
self.committed = [] self.committed = []
self.committed_off = 0 self.committed_off = 0
self.tail_text = "" self.tail_text = ""
if self.warm and self.warm.alive and self.warm.device == self.cfg.get("device", "default"):
# The stream is already open: no start-up gap, and the moment before the key press comes along.
self.rec = self.warm
self.warm = None
self.rec.begin(max(0, int(self.cfg.get("prerollMs", 600))) / 1000)
else:
if self.warm:
self.warm.stop()
self.warm = None
self.rec = Recorder(self.cfg.get("device", "default")) self.rec = Recorder(self.cfg.get("device", "default"))
try: try:
self.rec.start() self.rec.start()
@@ -1172,9 +1291,9 @@ class Daemon:
return (n - 100 + j) * CHUNK return (n - 100 + j) * CHUNK
return None return None
async def _transcribe_range(self, rec, a, b, prefix): async def _transcribe_range(self, audio, a, b, prefix):
"""Run the engine on buf[a:b] (under the engine lock). Returns text ('' for noise) or None on error.""" """Run the engine on audio[a:b] (under the engine lock). Returns text ('' for noise) or None on error."""
pcm = rec.snapshot_range(a, b) pcm = bytes(audio[a:b])
if len(pcm) < int(0.3 * RATE) * 2: if len(pcm) < int(0.3 * RATE) * 2:
return "" return ""
fd, tmp = tempfile.mkstemp(prefix=prefix, suffix=".wav", dir=RUNTIME) fd, tmp = tempfile.mkstemp(prefix=prefix, suffix=".wav", dir=RUNTIME)
@@ -1202,10 +1321,11 @@ class Daemon:
await asyncio.sleep(interval) await asyncio.sleep(interval)
while self.state == "recording" and self.rec is rec: while self.state == "recording" and self.rec is rec:
levels = rec.levels_copy() levels = rec.levels_copy()
audio = rec.snapshot()
cut = self._find_cut(levels) cut = self._find_cut(levels)
if cut is not None and cut > self.committed_off: if cut is not None and cut > self.committed_off:
a = self.committed_off a = self.committed_off
text = await self._transcribe_range(rec, a, cut, "live-") if self._voiced(levels, a, cut) else "" text = await self._transcribe_range(audio, a, cut, "live-") if self._voiced(levels, a, cut) else ""
if self.rec is not rec: if self.rec is not rec:
return return
if text is not None: # on an engine error the segment stays uncommitted and is retried if text is not None: # on an engine error the segment stays uncommitted and is retried
@@ -1214,9 +1334,9 @@ class Daemon:
self.committed_off = cut self.committed_off = cut
self.tail_text = "" self.tail_text = ""
else: else:
a, b = self.committed_off, rec.size a, b = self.committed_off, len(audio)
if b - a > int(0.8 * RATE) * 2 and self._voiced(levels, a, b): if b - a > int(0.8 * RATE) * 2 and self._voiced(levels, a, b):
text = await self._transcribe_range(rec, a, b, "live-") text = await self._transcribe_range(audio, a, b, "live-")
if self.rec is not rec: if self.rec is not rec:
return return
if text: if text:
@@ -1230,8 +1350,11 @@ class Daemon:
self.broadcast() self.broadcast()
return return
rec = self.rec rec = self.rec
rec.stop() pcm = rec.snapshot() # frozen now: a parked stream keeps only its last moment
levels = rec.levels_copy()
started = rec.started
self.state = "transcribing" self.state = "transcribing"
await self.release_rec(rec)
self.enter_pending = bool(enter) self.enter_pending = bool(enter)
agent = bool(agent) or self.agent_mode agent = bool(agent) or self.agent_mode
lang = self.lang lang = self.lang
@@ -1239,9 +1362,7 @@ class Daemon:
self.broadcast() self.broadcast()
path = "" path = ""
try: try:
pcm = rec.snapshot()
duration = len(pcm) / 2 / RATE duration = len(pcm) / 2 / RATE
started = rec.started
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(started)) stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(started))
path = os.path.join(TAKES, f"{stamp}-{lang['code']}.wav") path = os.path.join(TAKES, f"{stamp}-{lang['code']}.wav")
write_wav(path, pcm) write_wav(path, pcm)
@@ -1257,7 +1378,7 @@ class Daemon:
raise asyncio.CancelledError raise asyncio.CancelledError
committed, off = list(self.committed), self.committed_off committed, off = list(self.committed), self.committed_off
a, b = off, len(pcm) a, b = off, len(pcm)
tail = await self._transcribe_range(rec, a, b, "final-") if self._voiced(rec.levels_copy(), a, b) else "" tail = await self._transcribe_range(pcm, a, b, "final-") if self._voiced(levels, a, b) else ""
if tail is None: if tail is None:
text, err = "", self.error text, err = "", self.error
else: else:
@@ -1300,6 +1421,7 @@ class Daemon:
self.state = "idle" self.state = "idle"
self.rec = None self.rec = None
self.partial = "" self.partial = ""
self.ensure_warm()
self.broadcast() self.broadcast()
@staticmethod @staticmethod
@@ -1313,14 +1435,17 @@ class Daemon:
if self.state == "idle": if self.state == "idle":
self.broadcast() self.broadcast()
return return
if self.rec: rec = self.rec
self.rec.stop()
EngineRun.kill() # a transcription in flight is for a recording nobody wants
self.state = "idle" self.state = "idle"
self.rec = None self.rec = None
self.partial = "" self.partial = ""
EngineRun.kill() # a transcription in flight is for a recording nobody wants
self.binds.set_cancel(self.cfg, False) self.binds.set_cancel(self.cfg, False)
self.broadcast() self.broadcast()
if rec:
await self.release_rec(rec)
self.ensure_warm()
self.broadcast()
async def toggle(self, code=None, enter=False, agent=False): async def toggle(self, code=None, enter=False, agent=False):
if self.state == "idle": if self.state == "idle":
@@ -1408,6 +1533,10 @@ class Daemon:
self.refresh_environment() self.refresh_environment()
if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)): if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)):
self.broadcast({"type": "history-changed"}) self.broadcast({"type": "history-changed"})
if self.warm and ("device" in (patch or {}) or not self.cfg.get("warmMic")):
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
self.ensure_warm()
self.ensure_models() self.ensure_models()
# ---- socket ---- # ---- socket ----
@@ -1556,12 +1685,22 @@ class Daemon:
pass pass
await asyncio.sleep(5) await asyncio.sleep(5)
async def warm_watch(self):
"""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):
self.ensure_warm()
async def shutdown(self): async def shutdown(self):
if self.stopping: if self.stopping:
return return
self.stopping = True self.stopping = True
if self.rec: if self.rec:
self.rec.stop() self.rec.stop()
if self.warm:
self.warm.stop()
self.warm = None
EngineRun.kill() EngineRun.kill()
if self.download_task and not self.download_task.done(): if self.download_task and not self.download_task.done():
self.download_task.cancel() self.download_task.cancel()
@@ -1592,8 +1731,10 @@ class Daemon:
self.binds.apply(self.cfg) self.binds.apply(self.cfg)
if self.history.prune(self.cfg.get("historyDays", 30)): if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned") log("history pruned")
self.ensure_warm()
self.ensure_models() self.ensure_models()
self.loop.create_task(self.hypr_events()) self.loop.create_task(self.hypr_events())
self.loop.create_task(self.warm_watch())
for s in (signal.SIGTERM, signal.SIGINT): for s in (signal.SIGTERM, signal.SIGINT):
self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown())) self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown()))
log(f"sttd {VERSION} listening on {SOCK}") log(f"sttd {VERSION} listening on {SOCK}")
+30 -6
View File
@@ -106,6 +106,7 @@ Panel {
return "" 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 }
readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent" readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent"
function clock(secs) { function clock(secs) {
var s = Math.floor(secs || 0) var s = Math.floor(secs || 0)
@@ -122,17 +123,17 @@ Panel {
function subtitle() { function subtitle() {
if (!svc) return "Service not loaded" if (!svc) return "Service not loaded"
if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…" if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…"
if (connecting) return "Opening the microphone…" if (connecting) return t("opening", "Opening the microphone…")
if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "") if (recording) return t("listening", "Listening…").replace(/…$/, "") + " · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
if (transcribing) return "Transcribing…" if (transcribing) return t("transcribing", "Transcribing…")
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
return "Idle · " + svc.langLabel return "Idle · " + svc.langLabel
} }
readonly property string liveText: { readonly property string liveText: {
if (!svc) return "" if (!svc) return ""
if (connecting) return "Opening microphone…" if (connecting) return t("opening", "Opening microphone…")
if (recording) return svc.partial !== "" ? svc.partial : "Listening…" if (recording) return svc.partial !== "" ? svc.partial : t("listening", "Listening…")
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…") if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : t("transcribing", "Transcribing…"))
if (showError) return svc.error if (showError) return svc.error
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%" if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
return "" return ""
@@ -173,6 +174,7 @@ Panel {
Waveform { Waveform {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
height: Style.bar.iconCanvas height: Style.bar.iconCanvas
style: ["bars", "wave", "pulse", "dots"].indexOf(String(root.cfg.animation)) >= 0 ? String(root.cfg.animation) : "bars"
bars: 18 bars: 18
levels: root.svc ? root.svc.levels : [] levels: root.svc ? root.svc.levels : []
sine: root.connecting || (root.download !== null && !root.busy) sine: root.connecting || (root.download !== null && !root.busy)
@@ -1104,6 +1106,21 @@ Panel {
checked: root.cfg.keepAudio !== false checked: root.cfg.keepAudio !== false
onToggled: if (root.svc) root.svc.setSetting("keepAudio", 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: "Bar animation" }
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
showLabel: false
enabled: root.connected
value: String(root.cfg.animation || "bars")
options: [ { value: "bars", label: "Bars" }, { value: "wave", label: "Wave" }, { value: "pulse", label: "Pulse" }, { value: "dots", label: "Dots" } ]
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("animation", v); value = Qt.binding(function() { return String(root.cfg.animation || "bars") }) }
}
}
Row { Row {
width: parent.width width: parent.width
spacing: Style.space(8) spacing: Style.space(8)
@@ -1265,6 +1282,13 @@ 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 {
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)
}
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." }
Row { Row {
width: parent.width width: parent.width
spacing: Style.space(8) spacing: Style.space(8)
+2
View File
@@ -39,6 +39,8 @@ Item {
readonly property string agentName: state.agentName || "" // omarchy's default coding agent 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 bool agentMode: !!state.agentMode // this recording goes to the agent
readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…) readonly property var missing: state.missing || [] // tools a stock machine still lacks (voxtype, wtype…)
readonly property var strings: state.strings || ({}) // bar wording in the language being dictated
readonly property bool warm: !!state.warm // the microphone stream is kept open between recordings
property var sources: [] // microphones (sent with the greeting and on `get`) property var sources: [] // microphones (sent with the greeting and on `get`)
// ---- history (fetched on demand; the daemon says when it changed) ---- // ---- history (fetched on demand; the daemon says when it changed) ----
+113 -17
View File
@@ -1,30 +1,39 @@
import QtQuick import QtQuick
// A row of thin bars mirrored around the middle. `levels` holds 0..1 values, // The bar's picture of the microphone, in one of four styles:
// newest last; the bars show the most recent `bars` of them. With `idle` on // bars thin bars mirrored around the middle (the default)
// (nothing to show yet, or transcribing) the bars breathe gently instead. // wave a smooth sine-like line whose swell follows the voice
// pulse a dot inside a ring that breathes with the voice
// dots a row of dots that grow with the voice
// `levels` holds 0..1 values, newest last. With `sine` on (the microphone is
// still opening) every style shows a travelling wave; with `idle` on
// (transcribing, or an error) it breathes gently instead.
Item { Item {
id: root id: root
property var levels: [] property var levels: []
property string style: "bars"
property int bars: 20 property int bars: 20
property real barWidth: 2 property real barWidth: 2
property real gap: 2 property real gap: 2
property color color: "white" property color color: "white"
property bool idle: false // gentle breathing (nothing to show yet, or transcribing) property bool idle: false
property bool sine: false // a travelling sine wave (the microphone is still connecting) property bool sine: false
property real minHeight: 2 property real minHeight: 2
implicitWidth: bars * (barWidth + gap) - gap readonly property bool animated: idle || sine || style === "wave" || style === "pulse"
implicitWidth: style === "pulse" ? height + 6 : bars * (barWidth + gap) - gap
implicitHeight: 16 implicitHeight: 16
property real phase: 0 property real phase: 0
Timer { Timer {
interval: 40 interval: 40
repeat: true repeat: true
running: (root.idle || root.sine) && root.visible running: root.animated && root.visible
onTriggered: root.phase += root.sine ? 0.35 : 0.2 onTriggered: { root.phase += root.sine ? 0.35 : 0.2; if (root.style === "wave") waveCanvas.requestPaint() }
} }
onLevelsChanged: if (style === "wave") waveCanvas.requestPaint()
onStyleChanged: if (style === "wave") waveCanvas.requestPaint()
function levelAt(i) { function levelAt(i) {
var lv = levels || [] var lv = levels || []
@@ -32,23 +41,28 @@ Item {
var v = offset + i >= 0 ? Number(lv[offset + i]) : 0 var v = offset + i >= 0 ? Number(lv[offset + i]) : 0
return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0 return isFinite(v) ? Math.max(0, Math.min(1, v)) : 0
} }
function breath(i) { return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 }
function breath(i) { function travel(i) { return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2 }
return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 function amp(i) { return sine ? travel(i) : idle ? breath(i) : levelAt(i) }
} // The loudest of the last few levels, smoothed: what a single shape should show.
function recent() {
function wave(i) { if (sine) return 0.4 + 0.35 * (1 + Math.sin(phase * 1.5)) / 2
return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2 if (idle) return 0.15 + 0.1 * (1 + Math.sin(phase)) / 2
var lv = levels || [], m = 0
for (var i = Math.max(0, lv.length - 4); i < lv.length; i++) m = Math.max(m, Number(lv[i]) || 0)
return Math.min(1, m)
} }
// ---- bars ----
Row { Row {
visible: root.style === "bars"
anchors.centerIn: parent anchors.centerIn: parent
spacing: root.gap spacing: root.gap
Repeater { Repeater {
model: root.bars model: root.style === "bars" ? root.bars : 0
Rectangle { Rectangle {
required property int index required property int index
readonly property real lv: root.sine ? root.wave(index) : root.idle ? root.breath(index) : root.levelAt(index) readonly property real lv: root.amp(index)
width: root.barWidth width: root.barWidth
height: Math.max(root.minHeight, Math.round(lv * root.height)) height: Math.max(root.minHeight, Math.round(lv * root.height))
radius: root.barWidth / 2 radius: root.barWidth / 2
@@ -58,4 +72,86 @@ Item {
} }
} }
} }
// ---- dots ----
Row {
visible: root.style === "dots"
anchors.centerIn: parent
spacing: 0
readonly property int count: Math.max(4, Math.round(root.bars / 2))
readonly property real slot: root.implicitWidth / count
Repeater {
model: root.style === "dots" ? parent.count : 0
Item {
required property int index
readonly property real lv: root.amp(Math.min(root.bars - 1, Math.round(index * root.bars / parent.count + root.bars / parent.count / 2)))
width: parent.slot
height: root.height
Rectangle {
anchors.centerIn: parent
width: Math.max(2, Math.round(2 + lv * (Math.min(root.height, parent.width) - 3)))
height: width
radius: width / 2
color: root.color
opacity: 0.5 + 0.5 * lv
Behavior on width { NumberAnimation { duration: 70 } }
}
}
}
}
// ---- wave ----
Canvas {
id: waveCanvas
visible: root.style === "wave"
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.clearRect(0, 0, width, height)
if (!visible) return
var mid = height / 2, n = root.bars, span = width / Math.max(1, n - 1)
ctx.strokeStyle = root.color
ctx.lineWidth = 1.6
ctx.lineCap = "round"
ctx.beginPath()
var steps = Math.max(24, Math.round(width))
for (var s = 0; s <= steps; s++) {
var x = width * s / steps
var pos = x / span, i = Math.floor(pos), f = pos - i
var a0 = root.amp(Math.min(n - 1, i)), a1 = root.amp(Math.min(n - 1, i + 1))
var envelope = a0 + (a1 - a0) * f
var y = mid + Math.sin(x * 0.55 - root.phase * 2) * envelope * (mid - 1)
if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y)
}
ctx.stroke()
}
}
// ---- pulse ----
Item {
visible: root.style === "pulse"
anchors.centerIn: parent
width: root.height + 6
height: root.height
readonly property real lv: root.recent()
Rectangle { // ring
anchors.centerIn: parent
width: Math.round(root.height * (0.45 + 0.55 * parent.lv))
height: width
radius: width / 2
color: "transparent"
border.width: 1.5
border.color: root.color
opacity: 0.35 + 0.65 * parent.lv
Behavior on width { NumberAnimation { duration: 90 } }
}
Rectangle { // dot
anchors.centerIn: parent
width: Math.round(root.height * (0.2 + 0.3 * parent.lv))
height: width
radius: width / 2
color: root.color
Behavior on width { NumberAnimation { duration: 60 } }
}
}
} }