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:
@@ -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.
|
||||||
|
|||||||
+167
-26
@@ -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,14 +1215,23 @@ class Daemon:
|
|||||||
self.committed = []
|
self.committed = []
|
||||||
self.committed_off = 0
|
self.committed_off = 0
|
||||||
self.tail_text = ""
|
self.tail_text = ""
|
||||||
self.rec = Recorder(self.cfg.get("device", "default"))
|
if self.warm and self.warm.alive and self.warm.device == self.cfg.get("device", "default"):
|
||||||
try:
|
# The stream is already open: no start-up gap, and the moment before the key press comes along.
|
||||||
self.rec.start()
|
self.rec = self.warm
|
||||||
except (OSError, ValueError, TypeError) as e:
|
self.warm = None
|
||||||
self.rec = None
|
self.rec.begin(max(0, int(self.cfg.get("prerollMs", 600))) / 1000)
|
||||||
self.fail(f"cannot record: {e}")
|
else:
|
||||||
self.broadcast()
|
if self.warm:
|
||||||
return
|
self.warm.stop()
|
||||||
|
self.warm = None
|
||||||
|
self.rec = Recorder(self.cfg.get("device", "default"))
|
||||||
|
try:
|
||||||
|
self.rec.start()
|
||||||
|
except (OSError, ValueError, TypeError) as e:
|
||||||
|
self.rec = None
|
||||||
|
self.fail(f"cannot record: {e}")
|
||||||
|
self.broadcast()
|
||||||
|
return
|
||||||
self.state = "recording"
|
self.state = "recording"
|
||||||
self.binds.set_cancel(self.cfg, True)
|
self.binds.set_cancel(self.cfg, True)
|
||||||
self.broadcast()
|
self.broadcast()
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user