Compare commits

...

10 Commits

Author SHA1 Message Date
alan b93327dc26 Ask for the headset profile at capture start; relaunch the daemon on update
The daemon now requests the Bluetooth headset profile itself (pactl
set-card-profile) the moment a take starts, instead of waiting for
WirePlumber to notice the stream and run its switch timer. The microphone
link comes up 0.15-0.18 s after the key instead of 0.21-0.27 s; WirePlumber
still restores A2DP when the stream goes away. First real audio is now at
0.82-0.86 s, which is the headset's own floor.

When the plugin is updated while the daemon runs, the daemon sees its source
change, exits with code 4 once idle, and the shell service relaunches it
without counting that as a crash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xoc9DJCViR7dg9eKzzAcfQ
2026-09-09 12:17:06 +01:00
alan 6f7f7ddd6c Bluetooth mic startup: honest Listening indicator, startup-time investigation
The bar turned green after 2 s on a timer even when the microphone delivered
nothing. It now turns green only on real audio: RMS above 0.0005, or three
consecutive chunks that are not digital silence (the virtual mic emits one
stray nonzero chunk right after start, and a Bluetooth headset's floor ramps
in from a few LSB). Measured 1.2-1.4 s after the key, within 0.14 s of the
first real samples.

diagnostics/bluetooth/ records how startup went from ~1.6 s (or never) to
~1.1-1.4 s: a btusb driver bug, two PipeWire bluez5 bugs, a 500 ms WirePlumber
switch timeout and an over-broad auto-connect rule. Those fixes are machine
level and live outside this repo; the patches, tools and measurements are here.
STARTUP-TIME.md is the summary and explains the ~0.9 s hardware floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xoc9DJCViR7dg9eKzzAcfQ
2026-09-09 11:58:16 +01:00
alan e5020d24fe Pin model downloads: fixed commit, SHA-256 and size checks, transfer deadline 2026-09-08 15:26:44 +01:00
alan bb215de565 Marketplace preview image 2026-09-07 12:21:18 +01:00
alan f592fe8d6a Settings: room for duplicate language names
Wider popup, slightly narrower key buttons, 'English 2' instead of 'English · 2'.
2026-09-07 01:07:30 +01:00
alan 2cf875de28 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.
2026-09-07 01:05:10 +01:00
alan 29c2e83f0c Update README.md 2026-09-07 00:47:34 +01:00
alan 1d6c2228a8 Settings: live preview tiles for the bar animation
Four tiles (bars, wave, pulse, dots) animate with a synthetic voice; click
one to pick it.
2026-09-07 00:35:03 +01:00
alan 15cd5116a2 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.
2026-09-07 00:31:14 +01:00
alan a07082f17b History retention, simpler history actions, microphone picker, install offer
- Settings: "Keep history" (a day … forever, default a month); older
  recordings and their audio are pruned at startup, after each recording
  and when the setting changes.
- History rows keep play, copy and delete only.
- Advanced: the microphone is picked from PipeWire's sources instead of a
  free-text field, with a note about Bluetooth headsets switching profile.
- A stock machine without voxtype gets a banner with an Install button that
  runs omarchy-voxtype-install in a floating terminal; the daemon refuses to
  record until the tools are there and says why.
2026-09-07 00:08:05 +01:00
19 changed files with 6476 additions and 125 deletions
+5
View File
@@ -1,2 +1,7 @@
__pycache__/ __pycache__/
*.pyc *.pyc
diagnostics/**/*.ko
diagnostics/**/*.so
diagnostics/**/*.log
diagnostics/**/*.btsnoop
__pycache__/
+19 -7
View File
@@ -1,4 +1,15 @@
# Speech to Text # Speech to Text (Voxtype wrapper that's good looking)
https://github.com/user-attachments/assets/55d86289-ce72-4d24-94a0-d4888110cc01
Dictation for Omarchy: press a key, talk, press it again, and the words are pasted where your cursor is. A live waveform and the words as they are recognised show in the bar while you talk; every recording is kept with its text so you can play it back, copy or paste it again. Dictation for Omarchy: press a key, talk, press it again, and the words are pasted where your cursor is. A live waveform and the words as they are recognised show in the bar while you talk; every recording is kept with its text so you can play it back, copy or paste it again.
@@ -9,10 +20,11 @@ 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, paste, paste-and-send and delete, searchable, in the bar popup. - **History** of every recording (text + audio) with play, copy and delete, searchable, in the bar popup; kept for a month by default (Settings).
- **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one. - **Languages** picked from Whisper's list; the model a language needs is downloaded by itself the first time, from a pinned commit of ggerganov/whisper.cpp and only kept when its size and SHA-256 match the checksums shipped in the daemon (about 150 MB for the default model; the bar shows the progress). English-only models are swapped for the multilingual one.
- **Engine**: Omarchy's own dictation engine, [voxtype](https://github.com/peteonrails/voxtype) (local Whisper), by default, so there is nothing new to install. whisper.cpp (`whisper-cli`) and any custom command are also supported (Settings → Advanced). - **Engine**: Omarchy's own dictation engine, [voxtype](https://github.com/peteonrails/voxtype) (local Whisper), by default, so there is nothing new to install. whisper.cpp (`whisper-cli`) and any custom command are also supported (Settings → Advanced).
- **Safe key bindings**: a key that anything else already uses is refused, never taken over. - **Safe key bindings**: a key that anything else already uses is refused, never taken over.
- Local only. Nothing leaves your machine. - Local only. Nothing leaves your machine.
@@ -23,12 +35,12 @@ Dictation for Omarchy: press a key, talk, press it again, and the words are past
omarchy plugin add https://github.com/alanfortlink/speech-to-text.git --enable omarchy plugin add https://github.com/alanfortlink/speech-to-text.git --enable
``` ```
That is all: the microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched). That is all. The microphone icon appears in the bar, the daemon starts with the shell and applies the key bindings itself (nothing in `~/.config/hypr` is touched).
If Omarchy's dictation engine (voxtype) is not installed yet, the popup says so and offers an **Install** button, which runs `omarchy-voxtype-install` in a floating terminal (about 150 MB, asks for your password). Everything else the plugin needs is part of a stock Omarchy: PipeWire, `wl-clipboard`, `curl`, Python 3; `wtype` comes with voxtype.
Optional: `~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh` puts the `stt` command on your PATH. From a checkout anywhere else, `./install.sh` also links the checkout into the plugins directory (handy for development). Optional: `~/.config/omarchy/plugins/alanfortlink.speech-to-text/install.sh` puts the `stt` command on your PATH. From a checkout anywhere else, `./install.sh` also links the checkout into the plugins directory (handy for development).
Requires `voxtype` (Omarchy ships it: `omarchy-voxtype-install`), `pipewire`, `wtype`, `wl-clipboard`, `curl`. All present on a stock Omarchy.
## Use ## Use
- Press the language's key, talk, press it again. `Esc` discards while recording. The first press for a new language downloads its model; the bar shows "Getting ready…" until it is there. - Press the language's key, talk, press it again. `Esc` discards while recording. The first press for a new language downloads its model; the bar shows "Getting ready…" until it is there.
+411 -49
View File
@@ -9,6 +9,7 @@ and the `stt` CLI are its clients. Standard library only.
import asyncio import asyncio
import fcntl import fcntl
import hashlib
import json import json
import math import math
import os import os
@@ -58,8 +59,80 @@ 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" # Models come from one pinned commit of ggerganov/whisper.cpp on Hugging Face, and a
# download is only used when its size and SHA-256 match this table (taken from that
# commit's LFS pointers). An unlisted model name is refused rather than fetched.
MODEL_COMMIT = "5359861c739e955e79d9a303bcbc70fb988958b1"
MODEL_URL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/" + MODEL_COMMIT + "/ggml-{model}.bin"
MODEL_DIGESTS = { # name: (sha256, bytes)
"base": ("60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe", 147951465),
"base-q5_1": ("422f1ae452ade6f30a004d7e5c6a43195e4433bc370bf23fac9cc591f01a8898", 59707625),
"base-q8_0": ("c577b9a86e7e048a0b7eada054f4dd79a56bbfa911fbdacf900ac5b567cbb7d9", 81768585),
"base.en": ("a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002", 147964211),
"base.en-q5_1": ("4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f", 59721011),
"base.en-q8_0": ("a4d4a0768075e13cfd7e19df3ae2dbc4a68d37d36a7dad45e8410c9a34f8c87e", 81781811),
"large-v1": ("7d99f41a10525d0206bddadd86760181fa920438b6b33237e3118ff6c83bb53d", 3094623691),
"large-v2": ("9a423fe4d40c82774b6af34115b8b935f34152246eb19e80e376071d3f999487", 3094623691),
"large-v2-q5_0": ("3a214837221e4530dbc1fe8d734f302af393eb30bd0ed046042ebf4baf70f6f2", 1080732091),
"large-v2-q8_0": ("fef54e6d898246a65c8285bfa83bd1807e27fadf54d5d4e81754c47634737e8c", 1656129691),
"large-v3": ("64d182b440b98d5203c4f9bd541544d84c605196c4f7b845dfa11fb23594d1e2", 3095033483),
"large-v3-q5_0": ("d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1", 1081140203),
"large-v3-turbo": ("1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69", 1624555275),
"large-v3-turbo-q5_0": ("394221709cd5ad1f40c46e6031ca61bce88931e6e088c188294c6d5a55ffa7e2", 574041195),
"large-v3-turbo-q8_0": ("317eb69c11673c9de1e1f0d459b253999804ec71ac4c23c17ecf5fbe24e259a1", 874188075),
"medium": ("6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208", 1533763059),
"medium-q5_0": ("19fea4b380c3a618ec4723c3eef2eb785ffba0d0538cf43f8f235e7b3b34220f", 539212467),
"medium-q8_0": ("42a1ffcbe4167d224232443396968db4d02d4e8e87e213d3ee2e03095dea6502", 823369779),
"medium.en": ("cc37e93478338ec7700281a7ac30a10128929eb8f427dda2e865faa8f6da4356", 1533774781),
"medium.en-q5_0": ("76733e26ad8fe1c7a5bf7531a9d41917b2adc0f20f2e4f5531688a8c6cd88eb0", 539225533),
"medium.en-q8_0": ("43fa2cd084de5a04399a896a9a7a786064e221365c01700cea4666005218f11c", 823382461),
"small": ("1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b", 487601967),
"small-q5_1": ("ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb", 190085487),
"small-q8_0": ("49c8fb02b65e6049d5fa6c04f81f53b867b5ec9540406812c643f177317f779f", 264464607),
"small.en": ("c6138d6d58ecc8322097e0f987c32f1be8bb0a18532a3f88f734d1bbf9c41e5d", 487614201),
"small.en-q5_1": ("bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30", 190098681),
"small.en-q8_0": ("67a179f608ea6114bd3fdb9060e762b588a3fb3bd00c4387971be4d177958067", 264477561),
"tiny": ("be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21", 77691713),
"tiny-q5_1": ("818710568da3ca15689e31a743197b520007872ff9576237bda97bd1b469c3d7", 32152673),
"tiny-q8_0": ("c2085835d3f50733e2ff6e4b41ae8a2b8d8110461e18821b09a15c40c42d1cca", 43537433),
"tiny.en": ("921e4cf8686fdd993dcd081a5da5b6c365bfde1162e72b08d75ac75289920b1f", 77704715),
"tiny.en-q5_1": ("c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b", 32166155),
"tiny.en-q8_0": ("5bc2b3860aa151a4c6e7bb095e1fcce7cf12c7b020ca08dcec0c6d018bb7dd94", 43550795),
}
MODEL_DOWNLOAD_DEADLINE = 3600 # seconds for the whole transfer, the largest model is ~3 GB
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"engine": "voxtype", # voxtype | whisper-cpp | command "engine": "voxtype", # voxtype | whisper-cpp | command
@@ -72,12 +145,17 @@ DEFAULT_CONFIG = {
"liveText": True, # transcribe while recording and show it in the bar "liveText": True, # transcribe while recording and show it in the bar
"liveIntervalMs": 1500, "liveIntervalMs": 1500,
"liveWindowSecs": 30, "liveWindowSecs": 30,
"keepAudio": True, # keep the wav of every take next to its text "keepAudio": True, # keep the wav of every recording next to its text
"historyDays": 30, # delete recordings older than this (0 = keep forever)
"outputMode": "paste", # paste | type | clipboard "outputMode": "paste", # paste | type | clipboard
"pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert "pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert
"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
"warmHoldSecs": 0, # with warmMic: close the stream this long after the last recording (0 = keep it open)
"prerollMs": 600, # audio from just before the key press that a warm microphone keeps
"cancelKey": "ESCAPE", "cancelKey": "ESCAPE",
"notify": True, "notify": True,
} }
@@ -97,6 +175,9 @@ TERMINAL_CLASSES = {"alacritty", "kitty", "foot", "com.mitchellh.ghostty", "org.
LOG_FILE = os.path.join(RUNTIME, "daemon.log") LOG_FILE = os.path.join(RUNTIME, "daemon.log")
# The CLI next to this daemon: key bindings call it by absolute path, so they work whatever Hyprland's PATH is. # The CLI next to this daemon: key bindings call it by absolute path, so they work whatever Hyprland's PATH is.
SOURCE = os.path.abspath(__file__)
SOURCE_MTIME = os.stat(SOURCE).st_mtime
EXIT_RELAUNCH = 4 # the source changed underneath us (plugin update): the shell service starts the new one
STT_CLI = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "stt") STT_CLI = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "stt")
@@ -147,7 +228,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
@@ -155,11 +239,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(),
@@ -281,12 +373,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:
@@ -423,6 +519,28 @@ class Binds:
# recorder # recorder
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def bt_card_for(device):
"""bluez_input.88:C9:E8:A7:EC:7E (or bluez_input.88_C9_....0) -> bluez_card.88_C9_E8_A7_EC_7E, else None."""
if not device.startswith("bluez_input."):
return None
return "bluez_card." + device[len("bluez_input."):].split(".")[0].replace(":", "_")
def request_headset_profile(device):
"""Ask for the headset (HFP) profile the moment capture starts. WirePlumber would do the same
once it notices the stream, plus its switch timer: asking first saves ~0.15 s on the microphone
link. Fire and forget: no-op if the profile is already active, and WirePlumber still restores
A2DP when the stream goes away, whoever switched."""
card = bt_card_for(device)
if not card:
return
try:
subprocess.Popen(["pactl", "set-card-profile", card, "headset-head-unit"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError:
pass
class Recorder: class Recorder:
def __init__(self, device): def __init__(self, device):
self.device = device self.device = device
@@ -433,7 +551,10 @@ 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.nonzero_run = 0 # consecutive chunks that were not digital silence
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
@@ -458,29 +579,67 @@ 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". Clear input (speech, room noise on most
# mics) turns it on at once. Otherwise wait for three consecutive chunks that are not
# digital silence: a Bluetooth headset delivers exact zeros until its microphone link
# is up, the virtual mic emits one stray nonzero chunk right after start (processing
# residue), and the headset's own floor then ramps in from a few LSB with the odd
# all-zero chunk. No time-based fallback: a mic that only sends zeros is not listening.
self.nonzero_run = self.nonzero_run + 1 if rms > 0 else 0
if rms > 0.0005 or self.nonzero_run >= 3:
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):
@@ -592,6 +751,14 @@ def model_path(model):
return os.path.join(VOXTYPE_MODELS, f"ggml-{model}.bin") return os.path.join(VOXTYPE_MODELS, f"ggml-{model}.bin")
def sha256_file(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
class EngineRun: class EngineRun:
"""The transcription process now running (if any), so cancel/shutdown can kill it.""" """The transcription process now running (if any), so cancel/shutdown can kill it."""
proc = None proc = None
@@ -760,6 +927,39 @@ def deliver(cfg, text, enter):
wl_copy(saved) wl_copy(saved)
def audio_sources():
"""Microphones PipeWire offers: [{name, label}] (a virtual one like Microphone Effects included)."""
try:
r = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=5)
nodes = json.loads(r.stdout) if r.returncode == 0 else []
except (OSError, subprocess.TimeoutExpired, ValueError):
return []
out = []
for n in nodes:
props = (n.get("info") or {}).get("props") or {}
if props.get("media.class") != "Audio/Source":
continue
name = str(props.get("node.name") or "")
if not name or name.startswith("alsa_output") or "monitor" in name:
continue
out.append({"name": name, "label": str(props.get("node.description") or props.get("node.nick") or name)})
return out
def missing_tools(cfg):
"""What a stock machine may still lack; the panel offers to install it."""
out = []
if not which("pw-record"):
out.append("pipewire")
if not which("wtype"):
out.append("wtype")
if cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"):
out.append("voxtype")
if not which("wl-copy"):
out.append("wl-clipboard")
return out
def deliver_agent(cfg, text): def deliver_agent(cfg, text):
"""Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it.""" """Blocking. Hands the text to the default coding agent (a new terminal) instead of pasting it."""
tmpl = cfg.get("agentCommand") or "omarchy-agent-prompt {text}" tmpl = cfg.get("agentCommand") or "omarchy-agent-prompt {text}"
@@ -835,6 +1035,22 @@ class History:
self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id)) self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id))
self.db.commit() self.db.commit()
def prune(self, days):
"""Delete recordings older than `days` (0: keep everything). Returns how many went."""
if not days or days <= 0:
return 0
cutoff = time.time() - days * 86400
rows = self.db.execute("SELECT id, audio FROM takes WHERE created_at < ?", (cutoff,)).fetchall()
for id, audio in rows:
if audio:
try:
os.remove(audio)
except OSError:
pass
self.db.execute("DELETE FROM takes WHERE created_at < ?", (cutoff,))
self.db.commit()
return len(rows)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# daemon # daemon
@@ -867,10 +1083,15 @@ class Daemon:
self.wanted_model = "" # model a refused recording was waiting for self.wanted_model = "" # model a refused recording was waiting for
self.engines = available_engines() # cached: state is pushed 20×/s while recording self.engines = available_engines() # cached: state is pushed 20×/s while recording
self.agent = self.agent_name() self.agent = self.agent_name()
self.sources = audio_sources()
self.missing = missing_tools(self.cfg)
self.error_clear = None # timer handle: errors fade by themselves self.error_clear = None # timer handle: errors fade by themselves
self.warm = None # a parked Recorder (warmMic): the stream is already open when the key is pressed
self.warm_close = None # timer handle: with warmHoldSecs, the parked stream closes after a quiet spell
self.loop = None self.loop = None
self.stopping = False self.stopping = False
self.stop_event = None self.stop_event = None
self.exit_code = 0
# ---- state ---- # ---- state ----
def state_msg(self, full=False): def state_msg(self, full=False):
@@ -898,11 +1119,22 @@ class Daemon:
"download": self.download, "download": self.download,
"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,
"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
msg["sources"] = self.sources
return msg return msg
def refresh_environment(self):
"""Things that change rarely and cost a subprocess: only on demand."""
self.engines = available_engines()
self.agent = self.agent_name()
self.sources = audio_sources()
self.missing = missing_tools(self.cfg)
def broadcast(self, msg=None): def broadcast(self, msg=None):
line = (json.dumps(msg or self.state_msg()) + "\n").encode() line = (json.dumps(msg or self.state_msg()) + "\n").encode()
for w in list(self.clients): for w in list(self.clients):
@@ -918,11 +1150,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]
@@ -958,6 +1194,10 @@ class Daemon:
return model return model
async def fetch_model(self, model): async def fetch_model(self, model):
if model not in MODEL_DIGESTS:
self.fail(f"No pinned checksum for the {model} model; put ggml-{model}.bin in {VOXTYPE_MODELS} yourself")
return
want_sha, want_size = MODEL_DIGESTS[model]
os.makedirs(VOXTYPE_MODELS, exist_ok=True) os.makedirs(VOXTYPE_MODELS, exist_ok=True)
dest = model_path(model) dest = model_path(model)
fd, part = tempfile.mkstemp(prefix=f"ggml-{model}.", suffix=".part", dir=VOXTYPE_MODELS) fd, part = tempfile.mkstemp(prefix=f"ggml-{model}.", suffix=".part", dir=VOXTYPE_MODELS)
@@ -968,14 +1208,11 @@ class Daemon:
log("downloading model", model) log("downloading model", model)
proc = None proc = None
try: try:
head = await self.loop.run_in_executor(None, lambda: subprocess.run(
["curl", "-sIL", url], capture_output=True, text=True, timeout=60))
total = 0
for line in head.stdout.splitlines():
if line.lower().startswith("content-length:"):
total = int(line.split(":", 1)[1].strip() or 0)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"curl", "-sSL", "--fail", "-o", part, url, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE) "curl", "-sSL", "--fail", "--proto", "=https",
"--max-filesize", str(want_size), "--max-time", str(MODEL_DOWNLOAD_DEADLINE),
"--speed-limit", "1000", "--speed-time", "60", # give up when stalled for a minute
"-o", part, url, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE)
while proc.returncode is None: while proc.returncode is None:
try: try:
await asyncio.wait_for(proc.wait(), timeout=0.5) await asyncio.wait_for(proc.wait(), timeout=0.5)
@@ -985,14 +1222,17 @@ class Daemon:
done = os.path.getsize(part) done = os.path.getsize(part)
except OSError: except OSError:
done = 0 done = 0
self.download["pct"] = int(done * 100 / total) if total else 0 self.download["pct"] = int(done * 100 / want_size)
self.broadcast() self.broadcast()
err = (await proc.stderr.read()).decode(errors="replace").strip() err = (await proc.stderr.read()).decode(errors="replace").strip()
if proc.returncode != 0: if proc.returncode != 0:
raise RuntimeError(err.splitlines()[-1] if err else f"curl exited {proc.returncode}") raise RuntimeError(err.splitlines()[-1] if err else f"curl exited {proc.returncode}")
size = os.path.getsize(part) size = os.path.getsize(part)
if size < 1_000_000 or (total and size != total): if size != want_size:
raise RuntimeError(f"incomplete file ({size} of {total} bytes)") raise RuntimeError(f"unexpected size ({size} bytes, expected {want_size})")
got_sha = await self.loop.run_in_executor(None, sha256_file, part)
if got_sha != want_sha:
raise RuntimeError("checksum mismatch; the file was discarded")
os.chmod(part, 0o644) # mkstemp makes it private; models are plain shared files os.chmod(part, 0o644) # mkstemp makes it private; models are plain shared files
os.replace(part, dest) os.replace(part, dest)
log("model ready", dest) log("model ready", dest)
@@ -1021,20 +1261,80 @@ 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
if self.cfg.get("warmHoldSecs", 0) and self.cfg.get("warmHoldSecs", 0) > 0 and not self.rec:
# With a hold time the stream is only kept open *after* a recording, not opened ahead of one.
self.warm = None
return
rec = Recorder(self.cfg.get("device", "default"))
try:
rec.start()
except (OSError, ValueError, TypeError) as e:
log("warm microphone failed:", e)
self.warm = None
return
rec.park()
self.warm = rec
def _arm_warm_close(self):
"""With a hold time, a parked stream is closed once nothing has been recorded for that long
(a Bluetooth headset then drops back to its music profile)."""
if self.warm_close:
self.warm_close.cancel()
self.warm_close = None
hold = self.cfg.get("warmHoldSecs", 0)
if self.cfg.get("warmMic") and hold and hold > 0:
self.warm_close = self.loop.call_later(hold, self._close_warm)
def _close_warm(self):
self.warm_close = None
if not self.warm:
return
if self.state != "idle": # still transcribing: look again in a moment
self.warm_close = self.loop.call_later(2, self._close_warm)
return
w, self.warm = self.warm, None
self.loop.run_in_executor(None, w.stop)
self.broadcast()
async def release_rec(self, rec):
"""A recording is over: park the stream (warm) or close it (off the loop: pw-record can take a moment to die)."""
if self.cfg.get("warmMic") and rec.alive and not self.stopping:
rec.park()
self.warm = rec
self._arm_warm_close()
else:
if self.warm is rec:
self.warm = None
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()
return return
if not which("pw-record"): self.missing = missing_tools(self.cfg)
self.fail("pw-record (PipeWire) is not installed") if self.missing:
self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it")
self.broadcast() self.broadcast()
return return
self.agent_mode = bool(agent) self.agent_mode = bool(agent)
self.lang = self.find_lang(code) self.lang = self.find_lang(code)
if self.cfg.get("engine", "voxtype") == "voxtype" and not which("voxtype"):
self.fail("voxtype is not installed — run omarchy-voxtype-install")
self.broadcast()
return
model = model_for(self.cfg, self.lang) model = model_for(self.cfg, self.lang)
if model and not os.path.exists(model_path(model)): if model and not os.path.exists(model_path(model)):
# Not an error: the bar shows "Getting ready for <language> · N%" while it downloads. # Not an error: the bar shows "Getting ready for <language> · N%" while it downloads.
@@ -1047,7 +1347,17 @@ 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"))
request_headset_profile(self.rec.device or "")
try: try:
self.rec.start() self.rec.start()
except (OSError, ValueError, TypeError) as e: except (OSError, ValueError, TypeError) as e:
@@ -1114,9 +1424,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)
@@ -1144,10 +1454,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
@@ -1156,9 +1467,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:
@@ -1172,8 +1483,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
@@ -1181,9 +1495,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)
@@ -1199,7 +1511,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:
@@ -1227,6 +1539,8 @@ class Daemon:
await self.loop.run_in_executor(None, deliver_agent, self.cfg, text) await self.loop.run_in_executor(None, deliver_agent, self.cfg, text)
else: else:
await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending) await self.loop.run_in_executor(None, deliver, self.cfg, text, self.enter_pending)
if self.history.prune(self.cfg.get("historyDays", 30)):
log("history pruned")
self.broadcast({"type": "history-changed"}) self.broadcast({"type": "history-changed"})
except asyncio.CancelledError: except asyncio.CancelledError:
self._discard(path) self._discard(path)
@@ -1240,6 +1554,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
@@ -1253,14 +1568,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":
@@ -1338,15 +1656,22 @@ 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)
except OSError as e: except OSError as e:
self.fail(f"cannot save config: {e}") self.fail(f"cannot save config: {e}")
self.binds.apply(self.cfg) self.binds.apply(self.cfg)
self.engines = available_engines() self.refresh_environment()
self.agent = self.agent_name() if "historyDays" in (patch or {}) and self.history.prune(self.cfg.get("historyDays", 30)):
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()
if self.warm:
self._arm_warm_close()
self.ensure_models() self.ensure_models()
# ---- socket ---- # ---- socket ----
@@ -1403,7 +1728,13 @@ class Daemon:
async def dispatch(self, msg, writer): async def dispatch(self, msg, writer):
cmd = msg.get("cmd") cmd = msg.get("cmd")
if cmd == "get": if cmd == "get":
self.refresh_environment()
writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode()) writer.write((json.dumps(self.state_msg(full=True)) + "\n").encode())
elif cmd == "install":
# Omarchy's own installer (asks for confirmation and the password in a floating terminal).
self.spawn(self.loop.run_in_executor(None, lambda: subprocess.Popen(
["omarchy-launch-floating-terminal-with-presentation", "omarchy-voxtype-install"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)))
elif cmd == "toggle": elif cmd == "toggle":
self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent")))) self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent"))))
elif cmd == "start": elif cmd == "start":
@@ -1489,12 +1820,36 @@ 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) and not self.cfg.get("warmHoldSecs", 0):
self.ensure_warm()
async def source_watch(self):
"""Plugin updated while running: exit when idle so the shell service relaunches the new code."""
while not self.stopping:
await asyncio.sleep(5)
try:
changed = os.stat(SOURCE).st_mtime != SOURCE_MTIME
except OSError:
changed = False
if changed and self.state == "idle" and not (self.download_task and not self.download_task.done()):
log("daemon source changed; relaunching")
self.exit_code = EXIT_RELAUNCH
await self.shutdown()
return
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()
@@ -1523,8 +1878,13 @@ class Daemon:
server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20) server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20)
self.binds.sweep() self.binds.sweep()
self.binds.apply(self.cfg) self.binds.apply(self.cfg)
if self.history.prune(self.cfg.get("historyDays", 30)):
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())
self.loop.create_task(self.source_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}")
@@ -1548,7 +1908,9 @@ def main():
sys.exit(3) sys.exit(3)
# The socket is not removed on exit: the shell may already have started a # The socket is not removed on exit: the shell may already have started a
# replacement daemon that listens on the same path. # replacement daemon that listens on the same path.
asyncio.run(Daemon().run()) daemon = Daemon()
asyncio.run(daemon.run())
sys.exit(daemon.exit_code)
if __name__ == "__main__": if __name__ == "__main__":
@@ -0,0 +1,13 @@
--- a/spa/plugins/bluez5/backend-native.c
+++ b/spa/plugins/bluez5/backend-native.c
@@ -2835,6 +2835,11 @@
if (td->err != -EINPROGRESS)
sco_ready(t);
+ else
+ /* A second node can acquire the same transport while connect() is
+ * pending. Do not let it re-emit an error from the previous attempt.
+ */
+ spa_bt_transport_set_state(t, SPA_BT_TRANSPORT_STATE_PENDING);
return 0;
@@ -0,0 +1,72 @@
From: Alan Silva <alanfortlink@gmail.com>
Subject: [PATCH] Bluetooth: btusb: switch the isoc interface on every SCO enable notification
hci_conn_num(hdev, SCO_LINK) also counts (e)SCO links that are still being
set up, since hci_connect_sco() adds them to the connection hash before the
Synchronous Connection Complete event arrives. btusb_notify() only compares
that count with data->sco_num and stores whatever event it was called with
as the air mode. Two things go wrong when an unrelated connection event
arrives while an (e)SCO link is pending:
1. The unrelated event (e.g. HCI_NOTIFY_CONN_DEL from a failed ACL page to
another device) changes the count from 0 to 1, so btusb_work() runs
with air_mode = HCI_NOTIFY_CONN_DEL, computes new_alts = 0 and submits
isochronous URBs on the alternate setting 0 endpoints, whose
wMaxPacketSize is 0. usb_submit_urb() fails with -EMSGSIZE:
Bluetooth: hci0: urb 00000000d42f828b submission failed (90)
and BTUSB_ISOC_RUNNING is cleared again.
2. When HCI_NOTIFY_ENABLE_SCO_TRANSP finally arrives for that link, the
count already equals data->sco_num, so nothing is scheduled. The
interface stays on alternate setting 0, no isochronous URBs are
submitted, and the SCO link carries no audio in either direction for
its whole lifetime. The user sees a working headset with a dead
microphone.
Reproduced on an Intel AX210 (8087:0032) with a Sony WH-1000XM5 and
PipeWire 1.6.8: PipeWire re-tries ConnectProfile() on absent paired devices
when it starts, bluetoothd pages them, the page times out (status 0x04)
while dictation opens the headset microphone, and the mSBC link comes up
silent. Traced with kprobes on btusb_notify/btusb_work/btusb_submit_isoc_urb.
Program the alternate setting on every ENABLE_SCO_* notification, and on
other events only react when links went away.
---
--- a/drivers/bluetooth/btusb.c 2026-09-09 02:54:49.664029766 +0100
+++ b/drivers/bluetooth/btusb.c 2026-09-09 02:55:52.408391149 +0100
@@ -2289,13 +2289,33 @@
static void btusb_notify(struct hci_dev *hdev, unsigned int evt)
{
struct btusb_data *data = hci_get_drvdata(hdev);
+ int sco_num = hci_conn_num(hdev, SCO_LINK);
BT_DBG("%s evt %d", hdev->name, evt);
- if (hci_conn_num(hdev, SCO_LINK) != data->sco_num) {
- data->sco_num = hci_conn_num(hdev, SCO_LINK);
+ switch (evt) {
+ case HCI_NOTIFY_ENABLE_SCO_CVSD:
+ case HCI_NOTIFY_ENABLE_SCO_TRANSP:
+ /* A (e)SCO link just came up: program the isochronous
+ * alternate setting for its air mode, even if the link was
+ * already counted while it was still being set up.
+ */
+ data->sco_num = sco_num;
data->air_mode = evt;
schedule_work(&data->work);
+ break;
+ default:
+ /* hci_conn_num() also counts (e)SCO links that are still
+ * being set up, and only the ENABLE_SCO_* notifications
+ * carry an air mode. Any other event may only tear the
+ * isochronous interface down or adjust it for fewer links;
+ * a pending link is handled once it is enabled.
+ */
+ if (sco_num < data->sco_num) {
+ data->sco_num = sco_num;
+ schedule_work(&data->work);
+ }
+ break;
}
}
@@ -0,0 +1,51 @@
--- a/spa/plugins/bluez5/media-sink.c
+++ b/spa/plugins/bluez5/media-sink.c
@@ -152,6 +152,7 @@
unsigned int start_ready:1;
unsigned int transport_started:1;
unsigned int following:1;
+ bool transport_wrote; /* data left for the remote since transport start (HFP: only after RX was seen) */
unsigned int is_output:1;
unsigned int flush_pending:1;
unsigned int iso_pending:1;
@@ -748,6 +749,8 @@
written = spa_bt_send(this->flush_source.fd, this->buffer, this->buffer_used,
&this->tx_latency, SPA_TIMESPEC_TO_NSEC(&ts_pre));
}
+ if (written > 0)
+ this->transport_wrote = true;
if (SPA_UNLIKELY(spa_log_level_topic_enabled(this->log, SPA_LOG_TOPIC_DEFAULT, SPA_LOG_LEVEL_TRACE))) {
struct timespec ts;
@@ -1517,6 +1520,7 @@
struct impl *this = user_data;
this->transport_started = true;
+ this->transport_wrote = false;
if (this->transport->iso_io)
spa_bt_iso_io_set_cb(this->transport->iso_io, media_iso_pull, this);
return 0;
@@ -2466,7 +2470,8 @@
else
transport_stop(this);
- if (state < SPA_BT_TRANSPORT_STATE_ACTIVE && was_started && !this->is_duplex && this->is_output) {
+ if (state < SPA_BT_TRANSPORT_STATE_ACTIVE && was_started && !this->is_duplex && this->is_output &&
+ !(this->codec->kind == MEDIA_CODEC_HFP && this->transport_wrote)) {
/*
* If establishing connection fails due to remote end not activating
* the transport, we won't get a write error, but instead see a transport
@@ -2474,6 +2479,13 @@
*
* Treat this as a transport error, so that upper levels don't try to
* retry too often.
+ *
+ * An HFP link that already carried audio both ways (sco-io only writes
+ * after the first packet came in) and then hangs up is not a failed
+ * activation: headsets drop the SCO link when the profile switches back
+ * to A2DP after every call. Counting that as an error made
+ * spa_bt_transport_acquire() refuse the next acquire once three such
+ * hangups landed within its error window.
*/
spa_log_debug(this->log, "%p: transport %p becomes inactive: stop and indicate error",
+405
View File
@@ -0,0 +1,405 @@
# XM5 dictation startup: investigation handoff
Date: 2026-09-09. Status: **fixed and installed** (see the resolution section
below). The older sections are kept as the investigation record.
## Resolution, 2026-09-09 afternoon session
Three independent faults were reproduced, fixed and re-measured with
`tools/trial2.py` (kernel kprobes with the `mono` trace clock, the daemon's own
level stream, the card profile before/during/after each take). Everything below
is installed on this machine; nothing was submitted upstream.
### 1. Silent first start: btusb alternate-setting bug (patch `0002`)
Reproduced on the first stock attempt (`tools/stock-repro-1.log`): eSCO
`connect()` at 0.76 s stuck behind two pages, `btusb_switch_alt_setting(0)` and
the isochronous submit failure at 0.95 s, link up at 6.19 s with **zero** SCO
URBs for its whole life. With the patched module the exact same race
(`tools/patched-repro-3.log`: eSCO request stalled 3 s behind a page timeout,
`HCI_NOTIFY_CONN_DEL` ignored, alt setting 6 programmed on
`ENABLE_SCO_TRANSP`) delivered audio.
Installed: `/usr/lib/modules/7.2.3-arch1-3/updates/btusb.ko` (+ `depmod`);
`modinfo -n btusb` resolves there. btusb is not in the UKI, so no initramfs
rebuild was needed. A kernel upgrade replaces the whole modules directory and
silently returns to the stock driver; rebuild `tools/btusb.c` against the new
kernel and reinstall. `/etc/modprobe.d` needs nothing.
The trigger was `~/.config/wireplumber/wireplumber.conf.d/bluetooth-a2dp-autoconnect.conf`
matching every card. It is now narrowed to `bluez_card.88_C9_E8_A7_EC_7E`.
Disabling it entirely was tried first and is wrong: after a WirePlumber restart
the XM5's A2DP profile does not come back on its own (the card offered only
`headset-head-unit`), so the XM5 needs the rule. Absent devices are no longer
paged (`journalctl -u bluetooth` shows no "Host is down" after a restart).
### 2. Autoswitch delay: 50 ms script now in effect
`~/.local/share/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua`
(the packaged script with `PROFILE_SWITCH_TIMEOUT_MSEC = 50`). eSCO connect
moved from 0.650.85 s to 0.260.49 s after the start command. The
ineffective copy under `~/.config/wireplumber/scripts/device/` is still there
(this session could not delete it); it is harmless.
### 3. PipeWire: stale error replay and a hangup counted as failure (patches `0001`, `0003`)
With 1 and 2 in place, 1 take in 3 still came up silent with the stock plugin:
`bluez_input ... running -> error` right at acquire, the `0001` replay
(`tools/normal-5.log`, journal 11:26:46). `0001` is a real, independent bug,
not a symptom of the paging.
With `0001` alone, the 4th of 4 quick takes failed with
`spa.audioadapter: can't send command 2: Input/output error` and no eSCO
request at all (`tools/pw-4.log`). Cause: every take ends with
`Failure in Bluetooth audio transport .../fd60` because `media-sink.c`
converts the SCO hangup (the headset drops SCO when the profile goes back to
A2DP) into `SPA_BT_TRANSPORT_STATE_ERROR`, and `spa_bt_transport_acquire()`
returns `-EIO` once `error_count >= 3` within a 6 s window
(`TRANSPORT_ERROR_TIMEOUT = 2 * BLUEZ_ACTION_RATE_MSEC`). Four takes each
started within ~8 s of the previous one ending is enough. Patch `0003` skips
that escalation for an HFP sink that already wrote data (sco-io only writes
after the first packet came in, so this implies the link was really up).
Installed: `~/.local/lib/spa-0.2/bluez5/libspa-bluez5.so` (PipeWire 1.6.8 +
`0001` + `0003`, built with `--prefix=/usr` so `bluez-hardware.conf` resolves
to `/usr/share`), selected by
`~/.config/systemd/user/{pipewire,wireplumber}.service.d/spa-plugin-dir.conf`
(`SPA_PLUGIN_DIR=/home/tank/.local/lib/spa-0.2:/usr/lib/spa-0.2`). Codec
plugins still come from `/usr/lib/spa-0.2/bluez5`. WirePlumber is the process
that maps the plugin. After a PipeWire upgrade rebuild against the new source
(the build needs `gdbus-codegen`, extracted from `glib2-devel` without
installing it, and a `gio-2.0.pc` copy pointing at it) or delete the two
drop-ins to fall back to the packaged plugin. A copy of the built plugin is in
`tools/libspa-bluez5.so`.
Result: 8 of 8 rapid takes (`tools/pw2-*.log`) and 6 more after that with no
transport failure, no start error, A2DP restored 2 s after every take.
### 4. Honest "Listening" indicator (`daemon/sttd.py`)
The 40-chunk fallback lit the indicator at 2.1 s on a dead microphone. The
level stream showed why a plain "first nonzero chunk" test is wrong too: the
virtual mic emits one stray nonzero chunk at ~0.15 s (level 0.02, processing
residue), then exact zeros until the headset floor ramps in from a few LSB
with the odd all-zero chunk (`tools/win-*.log`). The daemon now turns
`listening` on at RMS > 0.0005 or after three consecutive nonzero chunks, and
never on a timer. Measured: 1.191.43 s after the start command, 00.14 s
after the first nonzero chunk (`tools/ind2-*.log`).
### Timeline now (normal path, quiet room)
| after F13 | event |
|---|---|
| 0.260.49 s | eSCO link up |
| 1.061.43 s | first nonzero PCM at the daemon |
| 1.191.43 s | UI "Listening" |
The 0.70.9 s between link-up and first PCM is the headset's own silence plus
the Camera Effects path; see the earlier analysis. The remaining Linux-side
gain would be requesting the headset profile from the daemon at capture start
(~0.25 s), not done: the autoswitch script only restores a profile it switched
itself, so a daemon-side switch would leave the headset in mono after the take.
### Not fixable
Stereo playback while the headset microphone is in use: A2DP is one-way and
the WH-1000XM5 has no LE Audio. Every OS drops to mono HFP for its mic.
### Files added this session
`0003-media-sink-hfp-hangup-after-audio-is-not-a-failure.patch`,
`tools/trial2.py` (trial driver, replaces `trial.sh`, which needed a `/tmp`
script that is gone), `tools/libspa-bluez5.so`, `tools/*.log`.
`tools/kernel-probes.sh` now disables the events before rewriting them, so it
can be re-run after a module reload.
---
## Earlier record (superseded where the resolution above says so)
## Update, 2026-09-09 later session: silent first start root-caused
Status: **the "first microphone start gets no audio at all" failure is fully
explained and reproduced under kernel tracing; a kernel driver patch is written and
built but not loaded. The remaining startup delay is measured and its floor is set
by the headset, not by Linux.**
### The silent start is a btusb driver bug, triggered by paging absent devices
Reproduced twice out of two attempts by restarting WirePlumber and starting
dictation about 10 s later. Kernel event order (kprobes, see `tools/`):
1. PipeWire `connect()`s the mSBC SCO socket: `hci_conn_add_unset(ESCO)` puts the
pending link in the connection hash (counted by `hci_conn_num(SCO_LINK)`), and
the Enhanced Setup Synchronous Connection command is queued.
2. The command does **not** go out for 2.3 s: bluetoothd is paging another paired
device at that moment, and the kernel's synchronous Create Connection request
holds the HCI command queue until its Connect Complete arrives (page timeout,
~5.1 s). Any dictation start that overlaps such a page stalls the same way.
3. Connect Complete arrives with status 0x04 (Page Timeout) for that other device;
its `hci_conn_del()` notifies btusb with `HCI_NOTIFY_CONN_DEL`.
4. `btusb_notify()` only compares `hci_conn_num(SCO_LINK)` (now 1, the pending
link) with its `data->sco_num` (0), stores CONN_DEL as the "air mode" and runs
`btusb_work()`, which computes `new_alts = 0` and submits isochronous URBs on the
alternate-setting-0 endpoints (wMaxPacketSize 0). `usb_submit_urb()` returns
-EMSGSIZE: that is the `urb ... submission failed (90)` line. The `len 0 mtu 0`
debug print from `__fill_isoc_descriptor` confirms it.
5. 0.14 s later the eSCO link comes up (Synchronous Connect Complete, air mode
transparent) and `HCI_NOTIFY_ENABLE_SCO_TRANSP` is sent, but the count already
matches `data->sco_num`, so nothing is scheduled. The interface stays on
alternate setting 0 for the life of the link: no SCO URBs, no audio either way.
PipeWire waits for the first incoming packet (USB adapters) forever, the daemon
sees silence, and "Listening" appears only from the 40-chunk fallback.
All four historical `submission failed (90)` entries (00:49, 00:53, 01:39, 02:17)
sit 25 s after a WirePlumber restart, and the "physical HFP nodes enter error"
failures in the stock trials are the same event seen from PipeWire (the pending
SCO connect fails with the paging device's status). The `0001` PipeWire patch
therefore addresses a symptom of this, not an independent bug.
Why a page was in flight: the user's own
`~/.config/wireplumber/wireplumber.conf.d/bluetooth-a2dp-autoconnect.conf` sets
`bluez5.auto-connect = [ a2dp_sink a2dp_source ]`. On every WirePlumber start
PipeWire calls `ConnectProfile()` on every paired A2DP device that is not connected
("Nothing Ear (3)" and "RB Meta 042Q" here), bluetoothd pages each for 5.1 s, and
the whole HCI command queue is blocked while it does. In normal use that is the
first ~11 s after login or after any WirePlumber restart; every absent paired
device extends it by ~5 s. Dropping that rule, or narrowing its match to the
devices it was written for, removes the trigger. The btusb bug itself fires on
any non-SCO connection add/remove that lands while an eSCO link is pending (an
LE device connecting, another ACL dropping), so it stays worth fixing.
Fix: `0002-btusb-program-isoc-alt-setting-on-every-sco-enable.patch` changes
`btusb_notify()` to program the interface on every `ENABLE_SCO_*` notification and
to ignore count increases from other events. `tools/btusb.ko` is that patch built
against the exact stable 7.2.3 source (identical to the local driver source) with
the installed headers, vermagic `7.2.3-arch1-3 SMP preempt mod_unload`. It is
**not loaded**; test with `sudo rmmod btusb && sudo insmod tools/btusb.ko`
(headset reconnects), revert with `sudo rmmod btusb && sudo modprobe btusb`.
Not submitted upstream.
### Normal-path timing, stock, ACL in sniff mode (trace of 02:49:34.93)
| after F13 | event |
|---|---|
| 0.01 s | daemon recording, pw-record running |
| 0.28 s | Camera Effects helper stream running, autoswitch script triggered |
| 0.79 s | autoswitch 500 ms timer fires, profile set to headset-head-unit |
| 0.82 s | SCO `connect()`; Exit Sniff Mode, Mode Change 65 ms later |
| 1.01 s | eSCO up (Enhanced Setup Sync took 117 ms); first SCO packet 19 ms later |
| ~1.2 s | first decoded PCM, all zeros (earlier instrumented runs) |
| 1.61 s | first nonzero input at Camera Effects; 1.72 s first nonzero daemon level |
| 2.07 s | UI "Listening" (40-chunk fallback; room noise is below the RMS threshold) |
The 0.50.6 s between eSCO link-up and the first nonzero sample is the headset:
it sends encoded digital silence for roughly 300 ms after the link opens, plus
mSBC sync. Linux cannot shorten it. What Linux can shorten:
- The 500 ms autoswitch timer (`PROFILE_SWITCH_TIMEOUT_MSEC` in
`/usr/share/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua`):
~0.45 s. A copy with 50 ms placed at
`~/.config/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua` was
**not** picked up (measured: still 500 ms). It is still there; remove it, or move
it to `~/.local/share/wireplumber/scripts/device/` (where the user's other
script lives) and re-measure the gap between `Triggering profile switch` and
`Switching profile` in the WirePlumber journal at info level.
- The ~0.27 s before the profile switch is even requested (Camera Effects 250 ms
maintenance poll opening the helper stream, then WirePlumber noticing it).
Requesting the headset profile directly when capture starts would remove most
of it.
With both, eSCO link-up lands near 0.30.35 s and first real audio near 0.9 s.
That is the floor with this headset. Words spoken before ~0.9 s are lost unless
the microphone is already in HFP, which the user has rejected; the honest
alternative is an indicator that turns on only when nonzero physical input
arrives, which the current 0.0005 RMS threshold and 40-chunk fallback do not give
(quiet-room noise measured RMS ~1e-4, Camera Effects residue ~4e-5).
### Stereo playback while the microphone is used
Not possible with this headset on any OS. A2DP is one-way; the microphone
needs an HFP SCO link, and the WH-1000XM5 has no LE Audio. Windows and macOS drop
to the same mono hands-free profile when its microphone is used.
### State after this session
Stock btusb loaded (srcversion matches the packaged module), all kprobes and
dynamic-debug prints removed, no btmon running, WirePlumber restarted with default
logging and `WIREPLUMBER_DEBUG` unset, dictation idle, warm mic off. The only
persistent change is the ineffective script copy under
`~/.config/wireplumber/scripts/device/`. `tools/` holds the probe installer
(`kernel-probes.sh`, `off` to remove), the trial driver (`trial.sh`, needs
`/tmp/stt-startup-trace.py`), the restart-and-retry reproducer (`repro-loop.sh`),
and the patched driver source and module.
## Required outcome
F13 dictation must use the Sony WH-1000XM5's own microphone, start immediately
without losing opening words, and preserve headphone playback quality. The user
rejects substituting the webcam microphone and keeping HFP/mic capture permanently
open. Do not compare this with Deadlock. Mac/Windows microphone selection was not
verified; do not assume that comparison establishes an identical Bluetooth path.
## Verified state at handoff
- Stock WirePlumber is active/running, with no service drop-ins and no test SPA or
data-directory environment overrides.
- Camera Effects explicitly selects `bluez_input.88:C9:E8:A7:EC:7E` (WH-1000XM5).
- Speech-to-text is idle, warm capture is false, Camera Effects is not capturing.
- No btmon, pkexec, startup trace, or patched-test process remains running.
- The earlier unwanted webcam-microphone substitution was reverted.
- No kernel/module replacement, system package installation, permanent candidate
PipeWire installation, or upstream submission was done.
- Repository status is only untracked `diagnostics/`; STT runtime source was not
changed by this investigation.
## Actual application path
`F13 -> speech-to-text/bin/stt -> daemon/sttd.py -> pw-record -> camera-effects-mic`
VoxType is the transcription engine here, not the ordinary VoxType microphone
daemon. Testing its separate CLI does not reproduce the F13 startup path.
Camera Effects creates `camera-effects-mic-input`, which links through the
WirePlumber Bluetooth loopback to the physical HFP source. A helper capture named
`camera-effects-mic-headset` triggers automatic profile switching. The user's
Camera Effects permission rule hides raw microphones from unrelated clients;
do not bypass that rule by impersonating an allowed client.
STT requests raw S16, 16 kHz, mono audio in 50 ms chunks. Its `listening` flag
becomes true when RMS exceeds 0.0005, or after 40 chunks (two seconds of PCM).
This flag controls UI readiness, not recording commencement: preceding chunks
are already collected. Therefore quiet input can show a two-second delay, and
silence from a disconnected microphone can falsely become "Listening."
Camera Effects maintenance polls every 250 ms. Its virtual stream supplies
silence before the physical mic is ready and may initially contain residual
buffered audio. First virtual buffers or early virtual nonzero samples are not
reliable proof that current headset speech is being captured. Read-only input
metering and instrumentation at the Bluetooth decoder are more meaningful.
## Measurements
Tests invoked the same daemon start command used by F13, without keyboard
dispatch. Six-second takes were cancelled, not pasted or saved as completed
history. Live transcription may nevertheless have processed data during them.
Stock observations:
- Daemon recording state begins in roughly 1220 ms; virtual PCM around 160 ms.
- WirePlumber intentionally waits 500 ms before a headset-profile switch and
restarts that timer on further relevant graph events. Restoration waits 2 s.
- Profile change generally starts around 0.60.9 s.
- HCI synchronous connection completion was around 0.871.02 s. That is not
equivalent to microphone audio being ready.
- Some trials fail entirely: physical HFP nodes enter error, or usable audio
never arrives. A stock failing trial stopped accumulating PCM at 0.8 s.
- Kernel logs sometimes contain `Bluetooth: hci0: urb ... submission failed (90)`.
Error 90 is EMSGSIZE. Which USB submission path causes it is not yet proven.
With the candidate PipeWire fix and temporary decoder instrumentation, two
successful trials measured: incoming encoded packets at 0.970.99 s; first decoded
PCM at 1.161.19 s; first nonzero decoded PCM at 1.481.50 s; nonzero PCM sent to
the graph at 1.531.55 s; Camera Effects input signal around 1.62 s; UI readiness
around 2.14 s. This narrows the delay but does not establish speech intelligibility.
Initial zero packets versus codec synchronization versus headset startup remains
unresolved. The H2 reader itself parses a 60-byte frame without an explicit timer.
The first trial after the instrumented WirePlumber restart received no encoded
audio within six seconds, with a USB submission error around 4.46 s.
## Candidate actual PipeWire bug
An HFP transport can retain ERROR from an earlier use. `sco_acquire_cb()` starts
an asynchronous connection without changing that stale state. A second node
acquiring the same transport takes the reference-count branch in
`spa_bt_transport_acquire()` and re-emits the old ERROR, even though the new
connection subsequently succeeds.
Observed debug sequence: 01:32:25.430 acquire, .447 second acquire/error replay,
.538 successful connection (BST). The candidate sets PENDING while the asynchronous
acquire is in progress. It does not signal readiness early or suppress real errors.
Files in this directory:
- `0001-bluez5-mark-sco-acquire-pending.patch`
- `test_sco_acquire.py`: extracts the actual acquire functions and compiles them
with mocked I/O. Original source fails; patched source passes cold/retry
concurrent acquire, delayed readiness, synchronous success, and real failures.
The Bluetooth component builds. No stale-error replay was observed in the three
initial patched trials, but silent startup remained. This is a partial candidate,
not proof of an end-to-end fix. The relevant upstream master acquire function
inspected during the investigation matched 1.6.8; nothing was submitted upstream.
## Important integration-test confounds
The local Meson build used its default `/usr/local` prefix. A later log showed
that the test plugin could not load the installed Bluetooth hardware-quirks file:
it searched `/usr/local/share/spa-0.2/bluez5/bluez-hardware.conf`, while the file is
at `/usr/share/spa-0.2/bluez5/bluez-hardware.conf`. Correct build prefix/data paths
before attributing any patched-versus-stock behavior to the candidate patch.
A separate 50 ms WirePlumber-delay experiment previously removed roughly 450 ms
but did not eliminate failures. The latest attempted combined test did not run:
`WIREPLUMBER_DATA_DIR` pointed to a copied system data directory without the user's
required `camera-effects-hide-mics.lua`. WirePlumber hit its restart limit. The
override was removed and stock service restored/reset successfully. Do not count
this attempt as latency data. Preserve user scripts when building another overlay.
## Machine and relevant paths
- PipeWire 1.6.8, WirePlumber 0.5.17, BlueZ 5.87, kernel 7.2.3-arch1-3.
- Intel AX210 Bluetooth USB 8087:0032; device `/sys/bus/usb/devices/1-9`,
isochronous interface `1-9:1.1`, full-speed USB.
- Sony WH-1000XM5 address 88:C9:E8:A7:EC:7E.
- Observed profiles: A2DP AAC/SBC/SBC-XQ and HFP CVSD/mSBC. No LE Audio profile
observed. Idle playback is AAC stereo; mic use selects mSBC mono HFP.
- Those observations do not establish that a Linux-only update can provide the
headset mic plus unchanged stereo playback. That part of the requirement is
unresolved; do not promise it as a consequence of reducing switching delay.
- Camera Effects running binary: `/usr/local/lib/camera-effects/camera-effects-server`.
- Camera Effects sources: `/home/tank/.config/omarchy/plugins/alanfortlink.camera-effects/daemon/src/`.
- STT config: `/home/tank/.config/speech-to-text/config.json`.
- Camera Effects config: `/home/tank/.config/camera-effects/config.json`.
- WirePlumber user scripts include both `~/.config/wireplumber/scripts/` and
`~/.local/share/wireplumber/scripts/camera-effects-hide-mics.lua`.
## Temporary working materials (may disappear after reboot)
Root: `/tmp/stt-bluetooth-switch.uZDCcW/`
- `pipewire-1.6.8/`: official source plus candidate backend patch and temporary
`media-source.c` timing instrumentation. `build-test/` holds its Meson build.
- `backend-native.c`, `bluez5-dbus.c`, `media-source.c`: original source copies.
- `patched-spa/bluez5/libspa-bluez5.so`: experimental component; **not suitable
for permanent installation** given the caveat above.
- `build-tools.ini`, `build-tools/`: extracted matching glib2 development tool
used for gdbus-codegen, without installing system packages.
- `run-patched-test.sh`: temporary service override, three cancelled captures,
automatic restoration. Correct its environment setup before reuse.
- `autoswitch-bluetooth-profile.lua`: packaged script with 500 changed to 50 ms.
- `filter-hci.py`: btmon metadata-only filter, drops audio/keys/addresses. Fixed
SCO parser recognizes actual `> BR-ESCO:` RX and `< BR-ESCO:` TX headers.
Earlier zero RX/TX counts from the incorrect parser were invalid evidence.
- `btusb.c`: upstream v7.2 driver reference, not yet checked against all 7.2.3
changes; `bluez-packet.c`: BlueZ 5.87 monitor formatting reference.
- `/tmp/stt-startup-trace.py`: daemon start/cancel, graph and input-meter timing.
## Most useful remaining work
1. Correct the test build paths and repeat controlled stock/patched measurements.
2. Trace the first-start USB failure. `sudo -n true` succeeded at the end of this
investigation, so passwordless scoped tracing may now be available. Earlier
pending pkexec/btmon authentication was cancelled; it is not running.
3. Tracefs exists at `/sys/kernel/tracing`, accessible with sudo. No probes were
installed. No perf/bpftrace/bpftool is installed; kernel headers and BTF exist.
4. Determine which `usb_submit_urb` returns EMSGSIZE and the associated endpoint,
alternate setting, and packet lengths. USB alt6 permits 63 bytes; SCO frames
observed were 60 bytes. Kernel SCO_OPTIONS reports 96, which PipeWire already
knows is unreliable for USB and avoids by waiting for incoming packet size.
5. Investigate before changing that wait: native SCO waits for readable data on
USB before declaring transport ready; sco-io also waits for RX before TX.
No-RX startup could involve this interaction, the driver, or the headset.
6. Treat quality preservation as a separate capability question, not a side
effect of a latency patch. Do not restore webcam or warm-mic workarounds.
+113
View File
@@ -0,0 +1,113 @@
# F13 Bluetooth startup investigation
Status: **fixed and installed on 2026-09-09** — three faults (btusb
alternate-setting bug, PipeWire stale-error replay, PipeWire counting the
end-of-take SCO hangup as a failure), the 500 ms autoswitch delay, and the
daemon's false "Listening" indicator. Stereo playback with the headset
microphone is not possible with this headset. Full account in the resolution
section at the top of [HANDOFF.md](HANDOFF.md).
## Reproduction
Environment: PipeWire 1.6.8, WirePlumber 0.5.17, BlueZ 5.87,
Linux 7.2.3-arch1-3, Intel AX210 USB Bluetooth, Sony WH-1000XM5.
Measurements taken on 2026-09-09, with the dictation microphone initially idle.
The F13 extension uses its Python daemon and `pw-record`, targeting
`camera-effects-mic`. VoxType performs transcription, not microphone startup.
Tests invoked the same daemon start command, omitting keyboard dispatch, and
cancelled the take without pasting or adding completed recordings to history.
Observed on the stock system:
- Recording state starts in roughly 1220 ms.
- The virtual microphone supplies buffers before the Bluetooth mic connects;
even an early nonzero level can be buffered data, not current microphone input.
- Successful HCI synchronous-connection completion was measured around
0.871.02 seconds after the start command. This does not establish when usable
speech first arrives.
- A failing trial accumulated 0.8 seconds of PCM, then stayed at that duration
through cancellation at six seconds. Both physical HFP nodes entered error.
- The UI also has a separate signal threshold / two-seconds-of-PCM fallback.
Its “Listening” indication is not proof of physical microphone readiness.
## Candidate PipeWire fix
An HFP transport can retain `SPA_BT_TRANSPORT_STATE_ERROR` from an earlier use.
`sco_acquire_cb()` starts an asynchronous connection but does not change that
state while waiting. A second node acquiring the same transport executes
`spa_bt_transport_acquire()`'s reference-count path, which re-emits the retained
error to both nodes. This can happen even though the new connection succeeds
shortly afterward.
The debug trace reproduced that sequence: acquire at 01:32:25.430,
second acquire and error re-emission at .447, successful completion at .538 BST.
`0001-bluez5-mark-sco-acquire-pending.patch` sets the state to `PENDING` during
the asynchronous acquire. It does not report readiness early, disable genuine
errors, change the Bluetooth codec, keep the microphone open, or shorten
WirePlumber's profile-switch delay. It applies to PipeWire 1.6.8; the corresponding
acquire function in the upstream master source inspected that day was unchanged.
## Validation and limits
`test_sco_acquire.py` compiles the actual two acquire functions with mocked I/O.
It fails against the unmodified source and passes with the patch. It covers
concurrent cold/retry acquisition, delayed readiness, synchronous success, and
real connection failures. It is a focused reproduction harness, not the full
PipeWire integration suite.
Run it against a PipeWire source checkout:
```sh
python3 diagnostics/bluetooth/test_sco_acquire.py \
/path/to/pipewire/spa/plugins/bluez5/backend-native.c \
/path/to/pipewire/spa/plugins/bluez5/bluez5-dbus.c
```
The patched Bluetooth component compiled and was loaded in WirePlumber using
a temporary, process-specific SPA plugin path. Across three cancelled startup
trials, the HFP error-state replay was not observed, but the first trial still
produced only silence. The kernel logged `urb ... submission failed (90)` during
that trial. The other trials produced signal but indicated readiness at about
2.2 seconds. Similar USB errors had also occurred in earlier unpatched tests;
their cause and relation to ordinary cold-start delay remain unproven.
The temporary service override was removed and the stock service restored.
No system packages, permanent microphone settings, or speech-to-text behavior
were changed. Nothing has been submitted upstream.
Further work must distinguish the USB/SCO first-start failure from the
error-state replay and measure current microphone input, not virtual-buffer
arrival or the UI indicator. This patch alone does not meet the requested
instant-start / full-quality experience.
## Later measurements and test-environment caveat
Temporary instrumentation in `media-source.c` measured two successful starts
with the candidate patch and the stock WirePlumber 500 ms switch delay:
- First encoded SCO data: 0.970.99 s after the start request.
- First decoded PCM: 1.161.19 s.
- First nonzero decoded PCM: 1.481.50 s.
- First nonzero PCM delivered to the graph: 1.531.55 s.
- Camera Effects input meter first nonzero: about 1.62 s.
- UI listening indication: about 2.14 s.
Nonzero samples establish signal, not intelligibility or preservation of opening
words. The first trial after restarting WirePlumber had no encoded data during
the six-second capture and logged a USB submission error at about 4.46 s.
A later combined test with a 50 ms WirePlumber delay did **not run**: the temporary
data-directory override omitted the user's required `camera-effects-hide-mics.lua`
script, so WirePlumber failed to start. The override was removed, the service's
restart limit reset, and the stock service successfully started again.
That startup log also exposed a build-environment confound: the locally built
PipeWire plugin searched `/usr/local/share/spa-0.2/bluez5/bluez-hardware.conf`,
whereas the installed quirks file is under `/usr/share/spa-0.2/bluez5/`.
Correct the build prefix/data paths before relying on patched-versus-stock
integration comparisons. The focused acquire-function regression test is
independent of this configuration problem.
See [HANDOFF.md](HANDOFF.md) for the complete investigation handoff.
+54
View File
@@ -0,0 +1,54 @@
# How we cut Bluetooth microphone startup time
Measured on a Sony WH-1000XM5 over an Intel AX210, PipeWire 1.6.8, WirePlumber
0.5.17, kernel 7.2.3. Times are after the dictation key press.
| | before | after |
|---|---|---|
| mic link (eSCO) up | 0.71.0 s, sometimes never | 0.150.18 s |
| first real audio at the daemon | 1.51.7 s, or never | 0.820.86 s |
| "Listening" in the bar | 2.1 s on a timer, even with no audio | 0.860.94 s, only on real audio |
"After" is with the daemon recording straight from `bluez_input.<addr>`; via
`camera-effects-mic` add ~0.25 s (its 250 ms poll and processing).
## What changed
| Change | Where it lives | Gain |
|---|---|---|
| Daemon indicator: green on real audio only (RMS > 0.0005 or three consecutive non-silent chunks), no timer | `daemon/sttd.py` | the bar cannot claim to listen to a dead mic |
| Daemon asks for the headset profile itself at capture start (`pactl set-card-profile`), before WirePlumber notices the stream | `daemon/sttd.py` | link up ~0.1 s sooner (0.210.27 s → 0.150.18 s); WirePlumber still restores A2DP |
| Record from `bluez_input.<addr>` instead of `camera-effects-mic` | `~/.config/speech-to-text/config.json` | ~0.25 s sooner, no noise suppression on dictation |
| WirePlumber profile-switch timeout 500 ms → 50 ms | `~/.local/share/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua` | link up ~0.45 s sooner |
| A2DP auto-connect rule narrowed to the XM5 | `~/.config/wireplumber/wireplumber.conf.d/bluetooth-a2dp-autoconnect.conf` | no 5 s pages of absent devices at login |
| btusb driver patch `0002` | `/usr/lib/modules/<kernel>/updates/btusb.ko` | mic link no longer comes up silent when a page overlaps it |
| PipeWire bluez5 patches `0001` + `0003` | `~/.local/lib/spa-0.2/bluez5/` via `SPA_PLUGIN_DIR` drop-ins | no stale-error dead takes; back-to-back takes work |
Only the first two rows are part of this repo. The rest are machine-level and are
described, with patches and measurements, in [HANDOFF.md](HANDOFF.md).
## Why ~0.85 s is the floor
```
key press
0.00 s ─┬─ daemon asks for the headset profile, starts pw-record
0.07 s ─┼─ eSCO connect() reaches the kernel
│ headset leaves sniff mode, radio sets up the link
0.16 s ─┼─ eSCO link up
│ headset sends encoded digital silence while its own
│ mic path starts and mSBC syncs: ~0.6 s
│ (measured at the decoder; Linux cannot shorten it)
0.80 s ─┼─ first non-zero samples leave the headset
│ decode + 50 ms chunking
0.85 s ─┴─ first real audio reaches the daemon
```
Nothing measurable is left on the Linux side.
The headset's silence after link-up and the eSCO setup are hardware. Words
spoken in the first ~0.9 s are lost unless the microphone is already in
hands-free mode, which drops playback to mono, so it is off by default
(`warmMic`).
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Compile the actual acquire functions with mocked I/O; no Bluetooth access.
This is a focused reproduction harness, not PipeWire's full integration suite.
Usage: test-sco-acquire.py <backend-native.c> <bluez5-dbus.c>
"""
import pathlib
import subprocess
import sys
import tempfile
def function(path, signature):
source = pathlib.Path(path).read_text()
start = source.index(signature + "\n{")
end = source.index("\n}", start) + 2
return source[start:end]
stub = r'''
#include <assert.h>
#include <stdbool.h>
#include <errno.h>
#include <stdio.h>
#include <stdint.h>
enum { SPA_BT_TRANSPORT_STATE_ERROR = -1, SPA_BT_TRANSPORT_STATE_IDLE,
SPA_BT_TRANSPORT_STATE_PENDING, SPA_BT_TRANSPORT_STATE_ACTIVE };
struct impl { void *log; } backend;
struct spa_bt_monitor { void *log; } monitor;
struct transport_data { int err; bool requesting; } td;
struct spa_bt_transport {
void *backend, *user_data;
struct spa_bt_monitor *monitor;
int fd, state, acquire_refcount, error_count;
bool acquired;
uint64_t last_error_time;
};
#define SPA_CONTAINER_OF(p, type, member) ((type *)(p))
#define spa_log_debug(...) ((void)0)
#define spa_assert assert
#define TRANSPORT_ERROR_TIMEOUT 6000000000ULL
#define TRANSPORT_ERROR_MAX_RETRY 3
#define spa_bt_transport_impl(t, op, version, optional) sco_acquire_cb(t, optional)
static int error_events, connect_result, connect_error;
static uint64_t get_time_now(struct spa_bt_monitor *m) { return 10000000000ULL; }
static void spa_bt_transport_emit_state_changed(struct spa_bt_transport *t, int old, int state) {
if (state == SPA_BT_TRANSPORT_STATE_ERROR) ++error_events;
}
static void spa_bt_transport_set_state(struct spa_bt_transport *t, int state) {
int old = t->state;
if (old != state) {
t->state = state;
spa_bt_transport_emit_state_changed(t, old, state);
}
}
static int sco_do_connect(struct spa_bt_transport *t) {
td.err = connect_error;
return connect_result;
}
static void sco_start_source(struct spa_bt_transport *t) {}
static void sco_ready(struct spa_bt_transport *t) {
td.requesting = false;
spa_bt_transport_set_state(t, td.err ? SPA_BT_TRANSPORT_STATE_ERROR : SPA_BT_TRANSPORT_STATE_ACTIVE);
}
'''
tests = r'''
static struct spa_bt_transport fresh(int state) {
td = (struct transport_data){0};
error_events = 0;
connect_result = 42;
connect_error = -EINPROGRESS;
return (struct spa_bt_transport){ .backend=&backend, .user_data=&td,
.monitor=&monitor, .fd=-1, .state=state };
}
int main(void) {
for (int initial = SPA_BT_TRANSPORT_STATE_ERROR; initial <= SPA_BT_TRANSPORT_STATE_IDLE; ++initial) {
struct spa_bt_transport t = fresh(initial);
assert(spa_bt_transport_acquire(&t, false) == 0);
if (t.state != SPA_BT_TRANSPORT_STATE_PENDING) {
fprintf(stderr, "FAIL: asynchronous acquire retained state %d (initial %d)\n", t.state, initial);
return 1;
}
assert(td.requesting && t.acquire_refcount == 1 && t.acquired);
assert(spa_bt_transport_acquire(&t, false) == 0);
assert(t.acquire_refcount == 2 && !error_events);
assert(t.state != SPA_BT_TRANSPORT_STATE_ACTIVE);
td.err = 0;
sco_ready(&t);
assert(t.state == SPA_BT_TRANSPORT_STATE_ACTIVE);
}
struct spa_bt_transport t = fresh(SPA_BT_TRANSPORT_STATE_IDLE);
connect_result = -1;
assert(spa_bt_transport_acquire(&t, false) < 0);
assert(t.state == SPA_BT_TRANSPORT_STATE_ERROR && !t.acquired && error_events == 1);
t = fresh(SPA_BT_TRANSPORT_STATE_IDLE);
t.fd = 42;
assert(spa_bt_transport_acquire(&t, true) == 0);
assert(t.state == SPA_BT_TRANSPORT_STATE_ACTIVE && !error_events);
t = fresh(SPA_BT_TRANSPORT_STATE_ERROR);
assert(spa_bt_transport_acquire(&t, false) == 0);
td.err = -ECONNREFUSED;
sco_ready(&t);
assert(t.state == SPA_BT_TRANSPORT_STATE_ERROR && error_events == 1);
puts("PASS: cold/retry concurrent acquire, delayed readiness, synchronous success, real failures");
}
'''
source = stub + function(sys.argv[1], 'static int sco_acquire_cb(void *data, bool optional)')
source += function(sys.argv[2], 'int spa_bt_transport_acquire(struct spa_bt_transport *transport, bool optional)')
source += tests
with tempfile.TemporaryDirectory(prefix='stt-sco-unit-') as tmp:
binary = str(pathlib.Path(tmp) / 'test')
subprocess.run(['cc', '-std=c11', '-Wall', '-Wno-unused-variable', '-Wno-unused-parameter',
'-x', 'c', '-', '-o', binary], input=source, text=True, check=True)
sys.exit(subprocess.run([binary]).returncode)
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Install (or with "off": remove) the tracefs kprobes and dynamic-debug prints used to trace
# Bluetooth SCO setup through the kernel and btusb. Needs passwordless sudo.
S=/sys/kernel/tracing
if [ "${1:-}" = off ]; then
sudo -n sh -c "echo 0 > $S/events/bt/enable; echo > $S/kprobe_events; echo > $S/trace
echo 'module btusb -p' > /sys/kernel/debug/dynamic_debug/control
echo 'func hci_sync_conn_complete_evt -p' > /sys/kernel/debug/dynamic_debug/control"
exit
fi
sudo -n sh -c "
echo 0 > $S/events/bt/enable 2>/dev/null
echo > $S/kprobe_events
echo 'p:bt/notify btusb_notify evt=\$arg2:u32' >> $S/kprobe_events
echo 'p:bt/work btusb_work' >> $S/kprobe_events
echo 'p:bt/switch_alt btusb_switch_alt_setting new_alts=\$arg2:s32' >> $S/kprobe_events
echo 'r:bt/switch_alt_ret btusb_switch_alt_setting ret=\$retval:s32' >> $S/kprobe_events
echo 'p:bt/set_intf usb_set_interface ifnum=\$arg2:s32 alt=\$arg3:s32' >> $S/kprobe_events
echo 'r:bt/set_intf_ret usb_set_interface ret=\$retval:s32' >> $S/kprobe_events
echo 'r:bt/submit_isoc_ret btusb_submit_isoc_urb ret=\$retval:s32' >> $S/kprobe_events
echo 'r:bt/autopm_ret usb_autopm_get_interface ret=\$retval:s32' >> $S/kprobe_events
echo 'p:bt/conn_add hci_conn_add_unset type=\$arg2:s32' >> $S/kprobe_events
echo 'p:bt/conn_del hci_conn_del' >> $S/kprobe_events
echo 'p:bt/conn_failed hci_conn_failed status=\$arg2:u8' >> $S/kprobe_events
echo 'p:bt/conn_complete hci_conn_complete_evt status=+0(\$arg2):u8 link_type=+9(\$arg2):u8' >> $S/kprobe_events
echo 'p:bt/disconn_complete hci_disconn_complete_evt status=+0(\$arg2):u8 handle=+1(\$arg2):u16 reason=+3(\$arg2):u8' >> $S/kprobe_events
echo 'p:bt/mode_change hci_mode_change_evt status=+0(\$arg2):u8' >> $S/kprobe_events
echo 'p:bt/sco_setup hci_sco_setup status=\$arg2:u8' >> $S/kprobe_events
echo 'p:bt/cs_enh_setup_sync hci_cs_enhanced_setup_sync_conn status=\$arg2:u8' >> $S/kprobe_events
echo 'p:bt/sync_complete hci_sync_conn_complete_evt' >> $S/kprobe_events
echo 'p:bt/connect_cfm sco_connect_cfm status=\$arg2:u8' >> $S/kprobe_events
echo 'p:bt/sco_tx hci_send_sco' >> $S/kprobe_events
echo 'p:bt/sco_rx_urb btusb_isoc_complete' >> $S/kprobe_events
echo 1 > $S/events/bt/enable
echo 'module btusb +pf' > /sys/kernel/debug/dynamic_debug/control
echo 'func hci_sync_conn_complete_evt +pf' > /sys/kernel/debug/dynamic_debug/control
echo > $S/trace
echo 1 > $S/tracing_on"
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Restart WirePlumber, run a traced trial, stop when the kernel logs submission failed (90).
SC=$(dirname "$0")
for i in 1 2 3 4 5 6; do
systemctl --user restart wireplumber; sleep 9
/home/tank/repos/speech-to-text/bin/stt status --json | grep -q '"state": *"idle"' || { echo "stt busy"; break; }
T0=$(date +%s)
"$SC/trial.sh" "repro-$i" > "$SC/repro-$i.log" 2>&1
if sudo -n journalctl -k --since "@$T0" --no-pager | grep -q 'submission failed (90)'; then echo "REPRODUCED on attempt $i"; exit 0; fi
echo "attempt $i: no failure"; sleep 3
done
echo "not reproduced"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# One dictation startup trial: kernel probes + btmon + userspace timing. $1 = label
set -u
SC=$(dirname "$0"); L=${1:-trial}; S=/sys/kernel/tracing
sudo -n sh -c "echo > $S/trace"
sudo -n btmon -w "$SC/$L.btsnoop" >/dev/null 2>&1 &
BTMON=$!
sleep 0.5
T0=$(date +%s.%N)
echo "T0 unix $T0 monotonic $(python3 -c 'import time;print(round(time.monotonic(),3))')"
python3 /tmp/stt-startup-trace.py 2>&1 | grep -vE '"graph"|audio-progress'
sleep 0.5
sudo -n kill $BTMON; wait $BTMON 2>/dev/null
echo "--- ftrace"
sudo -n cat $S/trace | grep -v '^#' | grep -vE 'sco_tx|sco_rx_urb' | sed -E 's/^ +//; s/ \[[0-9]+\] [^ ]+ / /; s/\([^)]*\)//' | cut -c1-120
echo "--- sco tx/rx-urb per second"
sudo -n cat $S/trace | grep -E 'sco_tx|sco_rx_urb' | sed -E 's/.* ([0-9]+)\.[0-9]+: (sco_tx|sco_rx_urb).*/\1 \2/' | sort | uniq -c | tr '\n' ';'; echo
echo "--- dmesg"
sudo -n journalctl -k -o short-precise --since "@${T0%.*}" --no-pager | grep -E 'Bluetooth:|hci_sync_conn|hci_conn_request|btusb_notify|__set_isoc|btusb_submit_isoc|__fill_isoc' | cut -c1-160
echo "--- btmon (commands/events only)"
sudo -n chown $USER "$SC/$L.btsnoop"
btmon -r "$SC/$L.btsnoop" -t 2>/dev/null | grep -E '^[<>] HCI (Command|Event)|^\s+(Status|Handle|Link type|Air mode|Reason|Opcode|Voice setting|Transmit coding|Receive coding|RX packet length|TX packet length|Mode|Interval|Packet type|Retransmission|Max latency|Transmit bandwidth|Receive bandwidth):' | grep -vE 'HCI Event: Number of Completed|HCI Event: Command Complete.*(Read RSSI|Read Clock)|Vendor' | sed -E 's/\{[^}]*\}//' | cut -c1-140
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""One F13-path dictation startup trial, timed against the kernel.
usage: trial2.py LABEL [--wp-restart SECS] (--wp-restart: restart WirePlumber, wait SECS, then start)
Needs tools/kernel-probes.sh installed and `echo mono > /sys/kernel/tracing/trace_clock` (passwordless sudo).
Starts dictation the way F13 does (`stt start`), polls the daemon for 6 s, cancels the take (nothing is
pasted or kept), then prints a timeline relative to the start command.
"""
import json, os, re, socket, subprocess, sys, time
STT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "bin", "stt")
SOCK = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "speech-to-text", "ctl.sock")
TR = "/sys/kernel/tracing"
def sudo(cmd):
return subprocess.run(["sudo", "-n", "sh", "-c", cmd], capture_output=True, text=True).stdout
def profile():
out = subprocess.run(["pactl", "list", "cards"], capture_output=True, text=True).stdout
card = out.split("Name: bluez_card.88_C9_E8_A7_EC_7E", 1)
if len(card) < 2: return "no-card"
m = re.search(r"Active Profile: (\S+)", card[1].split("Card #")[0])
return m.group(1) if m else "?"
def status():
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.connect(SOCK); s.settimeout(2)
buf = b""
while b"\n" not in buf:
buf += s.recv(65536)
s.close()
return json.loads(buf.split(b"\n", 1)[0])
label = sys.argv[1]
if "--wp-restart" in sys.argv:
wait = float(sys.argv[sys.argv.index("--wp-restart") + 1])
subprocess.run(["systemctl", "--user", "restart", "wireplumber"], check=True)
time.sleep(wait)
assert status()["state"] == "idle", "stt busy"
p0 = profile()
assert p0.startswith("a2dp"), f"headset not in A2DP before start: {p0}"
sudo(f"echo > {TR}/trace")
t0m, t0w = time.monotonic(), time.time()
subprocess.Popen([STT, "start"], stdout=subprocess.DEVNULL)
first_nz = first_sig = listening = None
levels = []; window = None
while time.monotonic() - t0m < 6.0:
st = status(); t = time.monotonic() - t0m
if st["state"] == "recording" and st["levels"]:
lv = st["levels"][-1]
levels.append((round(t, 2), lv))
if lv > 0 and first_nz is None: first_nz = t
if lv >= 0.05 and first_sig is None: first_sig = t # rms >= 2e-4
if st["listening"] and listening is None: listening = t
if window is None and t >= 2.3: window = (round(t, 2), st["levels"])
elif st["state"] != "recording" and st["state"] != "idle" and st["state"] != "opening":
pass
time.sleep(0.02)
subprocess.run([STT, "cancel"], stdout=subprocess.DEVNULL)
time.sleep(0.3)
p_rec = profile()
# kernel timeline (trace clock must be "mono")
ev = {}
trace = sudo(f"cat {TR}/trace")
rx = tx = 0
for line in trace.splitlines():
m = re.match(r"\s*\S+\s+\[\d+\]\s+\S+\s+([\d.]+):\s+(\S+):\s*(.*)", line)
if not m: continue
t = float(m.group(1)) - t0m; name = m.group(2); rest = m.group(3)
if name == "sco_rx_urb": rx += 1; ev.setdefault("first_sco_rx_urb", t); continue
if name == "sco_tx": tx += 1; ev.setdefault("first_sco_tx", t); continue
if name == "conn_add" and "type=2" in rest: ev.setdefault("esco_connect", t)
elif name == "cs_enh_setup_sync": ev.setdefault("enh_setup_cmd_status", t)
elif name == "sync_complete": ev.setdefault("esco_up", t)
elif name == "submit_isoc_ret" and "ret=-" in rest: ev.setdefault("isoc_submit_fail", t)
elif name == "notify": ev.setdefault("notify", []).append((round(t, 3), rest))
elif name == "switch_alt": ev.setdefault("switch_alt", []).append((round(t, 3), rest))
elif name == "conn_complete": ev.setdefault("conn_complete", []).append((round(t, 3), rest))
dmesg = sudo(f"journalctl -k --since @{int(t0w)-1} --no-pager -o short-precise | grep -E 'submission failed|Bluetooth' | cut -c1-140")
print(f"== {label}")
for k in ("esco_connect", "enh_setup_cmd_status", "esco_up", "first_sco_rx_urb", "first_sco_tx", "isoc_submit_fail"):
print(f"{k:22s} {ev[k]:.3f}" if k in ev else f"{k:22s} -")
print(f"sco rx urbs {rx} sco tx {tx}")
print(f"{'first nonzero level':22s} {first_nz:.3f}" if first_nz else f"{'first nonzero level':22s} -")
print(f"{'first level>=0.05':22s} {first_sig:.3f}" if first_sig else f"{'first level>=0.05':22s} -")
print(f"{'UI listening':22s} {listening:.3f}" if listening else f"{'UI listening':22s} -")
print("notify:", ev.get("notify")); print("switch_alt:", ev.get("switch_alt")); print("conn_complete:", ev.get("conn_complete"))
print("levels:", [l for l in levels if l[1] > 0][:12])
if window: print(f"level window at {window[0]} s (oldest first, 50 ms each):", window[1])
print("dmesg:", dmesg.strip() or "-")
print("ERROR90" if "submission failed (90)" in dmesg else "no error 90")
time.sleep(3.5)
print(f"profile: before {p0}, during {p_rec}, 3.5 s after cancel {profile()}")
+180 -42
View File
@@ -96,16 +96,9 @@ 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 }
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 +115,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 ""
@@ -151,7 +144,7 @@ Panel {
active: root.busy || root.showError active: root.busy || root.showError
useActiveColor: true useActiveColor: true
activeColor: root.busy ? root.takeColor : Color.urgent activeColor: root.busy ? root.takeColor : Color.urgent
tooltipText: (root.connected ? "Speech to text" : "Speech to text · starting") tooltipText: (root.connected ? (root.svc.missing.length ? "Speech to text · click to install " + root.svc.missing.join(", ") : "Speech to text") : "Speech to text · starting")
+ (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "") + (root.svc && root.svc.error !== "" ? " · " + root.svc.error : "")
+ (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "") + (root.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "")
+ " · right-click: dictate" + " · right-click: dictate"
@@ -173,6 +166,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)
@@ -260,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
} }
@@ -272,6 +265,14 @@ Panel {
return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e } return { value: e, label: ({ voxtype: "Omarchy built-in (voxtype)", "whisper-cpp": "whisper.cpp", command: "Custom command" })[e] || e }
}) })
readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } }) readonly property var langOpts: langs.map(function(l) { return { value: l.code, label: l.label || l.code } })
readonly property string sourcesJson: JSON.stringify(svc ? svc.sources : [])
readonly property var micOpts: {
var list = JSON.parse(sourcesJson), cur = String(cfg.device || "default")
var opts = [ { value: "default", label: "System default" } ]
for (var i = 0; i < list.length; i++) opts.push({ value: String(list[i].name), label: String(list[i].label) })
if (cur !== "default" && !list.some(function(m) { return String(m.name) === cur })) opts.push({ value: cur, label: cur + " (not connected)" })
return opts
}
readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : []) readonly property string conflictsJson: JSON.stringify(svc ? svc.conflicts : [])
readonly property var conflicts: JSON.parse(conflictsJson) readonly property var conflicts: JSON.parse(conflictsJson)
function conflictText() { function conflictText() {
@@ -291,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
@@ -388,7 +392,7 @@ Panel {
bar: root.bar bar: root.bar
open: root.opened open: root.opened
focusTarget: keyCatcher focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(520)) contentWidth: panel.fittedContentWidth(Style.space(560))
contentHeight: panel.fittedContentHeight(topBlock.implicitHeight + Style.space(10) + body.implicitHeight + Style.space(4)) contentHeight: panel.fittedContentHeight(topBlock.implicitHeight + Style.space(10) + body.implicitHeight + Style.space(4))
PanelKeyCatcher { PanelKeyCatcher {
@@ -474,6 +478,39 @@ Panel {
} }
} }
// A stock machine may not have the dictation engine yet: say so, and install it from here.
Rectangle {
width: parent.width
visible: root.connected && root.svc.missing.length > 0
height: missingCol.implicitHeight + Style.space(16)
radius: Style.cornerRadius
color: Style.normalFillFor(root.fg, Color.accent)
Column {
id: missingCol
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Style.space(8)
spacing: Style.space(6)
Text {
width: parent.width
text: "Dictation needs " + (root.svc ? root.svc.missing.join(", ") : "") + " to be installed. Omarchy's installer takes care of it (about 150 MB, asks for your password)."
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.Wrap
}
Button {
text: "Install"
iconText: "󰇚"
foreground: root.fg
fontFamily: root.fontFamily
bordered: true
onClicked: { if (root.svc) root.svc.install(); root.close() }
}
}
}
Text { Text {
width: parent.width width: parent.width
visible: root.busy visible: root.busy
@@ -810,20 +847,6 @@ Panel {
fontFamily: root.fontFamily fontFamily: root.fontFamily
onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() } onClicked: { if (root.svc) root.svc.copyTake(row.modelData.id); row.copied = true; copiedTimer.restart() }
} }
PanelActionButton {
iconText: "󰆒"
tooltipText: "Paste into the focused window"
foreground: root.fg
fontFamily: root.fontFamily
onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, false) }) }
}
PanelActionButton {
iconText: "󰌑"
tooltipText: "Paste and press Return"
foreground: root.fg
fontFamily: root.fontFamily
onClicked: { var id = row.modelData.id; root.closeThen(function() { if (root.svc) root.svc.pasteTake(id, true) }) }
}
PanelActionButton { PanelActionButton {
iconText: "󰆴" iconText: "󰆴"
tooltipText: "Delete" tooltipText: "Delete"
@@ -906,7 +929,7 @@ Panel {
spacing: Style.space(10) spacing: Style.space(10)
PanelSectionHeader { text: "LANGUAGES"; foreground: root.fg; fontFamily: root.fontFamily } PanelSectionHeader { text: "LANGUAGES"; foreground: root.fg; fontFamily: root.fontFamily }
readonly property int keyW: Style.space(112) readonly property int keyW: Style.space(108)
readonly property int sendW: Style.space(70) readonly property int sendW: Style.space(70)
readonly property int actW: Style.space(72) readonly property int actW: Style.space(72)
@@ -947,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
@@ -1060,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 ----------
@@ -1077,6 +1100,94 @@ 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)
} }
// Bar animation: four live tiles, each showing its style with a pretend voice.
Column {
id: animCol
width: parent.width
spacing: Style.space(6)
Text { text: "Bar animation"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.body }
// A synthetic voice envelope shared by the previews: phrases with pauses.
property var demoLevels: []
property real demoT: 0
Timer {
interval: 50
repeat: true
running: settingsLoader.active && root.opened
onTriggered: {
var col = animCol
col.demoT += 0.05
var phrase = (Math.sin(col.demoT * 0.9) + 1) / 2 > 0.35 // talking vs. a pause
var v = phrase ? 0.25 + 0.55 * Math.abs(Math.sin(col.demoT * 7.3) * Math.sin(col.demoT * 2.1)) + Math.random() * 0.15 : 0.03 + Math.random() * 0.04
var lv = col.demoLevels.slice(-47)
lv.push(Math.min(1, v))
col.demoLevels = lv
}
}
Row {
width: parent.width - root.trailInset
spacing: Style.space(8)
Repeater {
model: [ { key: "bars", label: "Bars" }, { key: "wave", label: "Wave" }, { key: "pulse", label: "Pulse" }, { key: "dots", label: "Dots" } ]
Rectangle {
id: tile
required property var modelData
readonly property bool current: String(root.cfg.animation || "bars") === modelData.key
width: (parent.width - parent.spacing * 3) / 4
height: Style.space(52)
radius: Style.cornerRadius
color: current ? Style.selectedFillFor(root.fg, Color.accent) : tileHover.hovered ? Style.hoverFillFor(root.fg, Color.accent) : Style.normalFillFor(root.fg, Color.accent)
border.width: current ? 1 : 0
border.color: Color.accent
HoverHandler { id: tileHover }
Column {
anchors.centerIn: parent
spacing: Style.space(6)
Waveform {
anchors.horizontalCenter: parent.horizontalCenter
height: Style.bar.iconCanvas
style: tile.modelData.key
bars: 18
levels: animCol.demoLevels
color: root.green
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: tile.modelData.label
color: tile.current ? Color.accent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: tile.current
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
enabled: root.connected
onClicked: if (root.svc) root.svc.setSetting("animation", tile.modelData.key)
}
}
}
}
}
Row {
width: parent.width
spacing: Style.space(8)
RowLabel { text: "Keep history" }
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
showLabel: false
enabled: root.connected
value: String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays)
options: [ { value: "1", label: "For a day" }, { value: "7", label: "For a week" }, { value: "30", label: "For a month" },
{ value: "90", label: "For 3 months" }, { value: "365", label: "For a year" }, { value: "0", label: "Forever" } ]
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("historyDays", parseInt(v)); value = Qt.binding(function() { return String(root.cfg.historyDays === undefined ? 30 : root.cfg.historyDays) }) }
}
}
Note { text: "Older recordings and their audio are deleted automatically." }
} }
// ---------- Advanced (collapsed) ---------- // ---------- Advanced (collapsed) ----------
@@ -1209,12 +1320,39 @@ Panel {
width: parent.width width: parent.width
spacing: Style.space(8) spacing: Style.space(8)
RowLabel { text: "Microphone" } RowLabel { text: "Microphone" }
ConfigField { Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset width: parent.width - root.labelW - parent.spacing - root.trailInset
placeholderText: "default" showLabel: false
key: "device" enabled: root.connected
value: String(root.cfg.device || "default")
options: root.micOpts
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) { if (root.svc) root.svc.setSetting("device", v); value = Qt.binding(function() { return String(root.cfg.device || "default") }) }
} }
} }
Note { text: "A Bluetooth headset switches to its low-quality headset profile while its microphone is open, which pauses or degrades whatever it is playing. Pick another microphone here to avoid that." }
Row {
width: parent.width
spacing: Style.space(8)
RowLabel { text: "Keep mic open" }
Dropdown {
width: parent.width - root.labelW - parent.spacing - root.trailInset
showLabel: false
enabled: root.connected
readonly property string mode: !root.cfg.warmMic ? "off" : String(root.cfg.warmHoldSecs || 0)
value: mode
options: [ { value: "off", label: "No — open it on each key press" }, { value: "120", label: "For 2 minutes after a recording" },
{ value: "600", label: "For 10 minutes after a recording" }, { value: "0", label: "Always" } ]
foreground: root.fg
fontFamily: root.fontFamily
onChanged: function(v) {
if (root.svc) root.svc.setConfig(v === "off" ? { warmMic: false } : { warmMic: true, warmHoldSecs: parseInt(v) })
value = Qt.binding(function() { return mode })
}
}
}
Note { text: "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)
+11
View File
@@ -38,6 +38,10 @@ Item {
readonly property var download: state.download || null // {model, pct} while a model is fetched readonly property var download: state.download || null // {model, pct} while a model is fetched
readonly property string agentName: state.agentName || "" // omarchy's default coding agent readonly property 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 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`)
// ---- history (fetched on demand; the daemon says when it changed) ---- // ---- history (fetched on demand; the daemon says when it changed) ----
property var historyItems: [] property var historyItems: []
@@ -72,6 +76,7 @@ Item {
function rebind() { return send({ cmd: "rebind" }) } function rebind() { return send({ cmd: "rebind" }) }
function setLang(code) { return send({ cmd: "setLang", lang: code }) } function setLang(code) { return send({ cmd: "setLang", lang: code }) }
// While the panel captures a key, our own binds must not fire on it. // While the panel captures a key, our own binds must not fire on it.
function install() { return send({ cmd: "install" }) } // omarchy-voxtype-install in a floating terminal
function suspendBinds() { return send({ cmd: "suspendBinds" }) } function suspendBinds() { return send({ cmd: "suspendBinds" }) }
function resumeBinds() { return send({ cmd: "resumeBinds" }) } function resumeBinds() { return send({ cmd: "resumeBinds" }) }
function loadHistory(query, limit) { function loadHistory(query, limit) {
@@ -100,6 +105,11 @@ Item {
} }
onExited: function(code, status) { onExited: function(code, status) {
root.state = ({}) root.state = ({})
if (code === 4) { // the daemon saw its own source change (plugin update) and asked to be relaunched
restartTimer.interval = 500
restartTimer.restart()
return
}
root.restarts += 1 root.restarts += 1
if (code !== 3 && root.restarts >= 3) root.daemonError = "daemon keeps exiting (code " + code + ") — check the log" if (code !== 3 && root.restarts >= 3) root.daemonError = "daemon keeps exiting (code " + code + ") — check the log"
restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock
@@ -140,6 +150,7 @@ Item {
if (!msg) return if (!msg) return
if (msg.type === "state") { if (msg.type === "state") {
if (msg.languageNames) root.languageNames = msg.languageNames if (msg.languageNames) root.languageNames = msg.languageNames
if (msg.sources) root.sources = msg.sources
root.state = msg root.state = msg
if (root.daemonError !== "") root.daemonError = "" if (root.daemonError !== "") root.daemonError = ""
} else if (msg.type === "history") { } else if (msg.type === "history") {
+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 } }
}
}
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB