Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b93327dc26 | |||
| 6f7f7ddd6c | |||
| e5020d24fe | |||
| bb215de565 | |||
| f592fe8d6a | |||
| 2cf875de28 | |||
| 29c2e83f0c | |||
| 1d6c2228a8 | |||
| 15cd5116a2 | |||
| a07082f17b |
@@ -1,2 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
diagnostics/**/*.ko
|
||||
diagnostics/**/*.so
|
||||
diagnostics/**/*.log
|
||||
diagnostics/**/*.btsnoop
|
||||
__pycache__/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
- **+ 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.
|
||||
- **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.
|
||||
- **History** of every recording (text + audio) with play, copy, paste, paste-and-send and delete, searchable, in the bar popup.
|
||||
- **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.
|
||||
- **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, 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).
|
||||
- **Safe key bindings**: a key that anything else already uses is refused, never taken over.
|
||||
- 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
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
Requires `voxtype` (Omarchy ships it: `omarchy-voxtype-install`), `pipewire`, `wtype`, `wl-clipboard`, `curl`. All present on a stock Omarchy.
|
||||
|
||||
## 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.
|
||||
|
||||
+411
-49
@@ -9,6 +9,7 @@ and the `stt` CLI are its clients. Standard library only.
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
@@ -58,8 +59,80 @@ LANGUAGES = {
|
||||
"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",
|
||||
}
|
||||
# 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")
|
||||
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 = {
|
||||
"engine": "voxtype", # voxtype | whisper-cpp | command
|
||||
@@ -72,12 +145,17 @@ DEFAULT_CONFIG = {
|
||||
"liveText": True, # transcribe while recording and show it in the bar
|
||||
"liveIntervalMs": 1500,
|
||||
"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
|
||||
"pasteKeys": "auto", # auto | ctrl+v | ctrl+shift+v | shift+insert
|
||||
"restoreClipboard": True,
|
||||
"maxDurationSecs": 300,
|
||||
"device": "default",
|
||||
"animation": "bars", # what the bar shows while recording: bars | wave | pulse | dots
|
||||
"warmMic": False, # keep the microphone stream open between recordings: instant start + pre-roll
|
||||
"warmHoldSecs": 0, # with warmMic: close the stream this long after the last recording (0 = keep it open)
|
||||
"prerollMs": 600, # audio from just before the key press that a warm microphone keeps
|
||||
"cancelKey": "ESCAPE",
|
||||
"notify": True,
|
||||
}
|
||||
@@ -97,6 +175,9 @@ TERMINAL_CLASSES = {"alacritty", "kitty", "foot", "com.mitchellh.ghostty", "org.
|
||||
|
||||
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.
|
||||
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")
|
||||
|
||||
|
||||
@@ -147,7 +228,10 @@ def load_config():
|
||||
|
||||
|
||||
def normalize_languages(langs):
|
||||
out = []
|
||||
"""A language may appear several times (one entry that sends, one that
|
||||
does not…): every entry gets its own id (en, en-2, …) that the key
|
||||
bindings and `stt toggle --lang` refer to."""
|
||||
out, ids = [], set()
|
||||
for l in langs or []:
|
||||
if not isinstance(l, dict):
|
||||
continue
|
||||
@@ -155,11 +239,19 @@ def normalize_languages(langs):
|
||||
if code not in LANGUAGES: # "pt-BR", "ptbr", "en_US" -> whisper's two-letter code
|
||||
base = code.split("-")[0]
|
||||
code = base if base in LANGUAGES else (base[:2] if base[:2] in LANGUAGES else code)
|
||||
if code not in LANGUAGES or any(o["code"] == code for o in out):
|
||||
if code not in LANGUAGES:
|
||||
continue # unknown codes would reach `voxtype --language` and the bind's shell line
|
||||
id = str(l.get("id", "") or "").strip().lower()
|
||||
if not re.fullmatch(r"[a-z]{2,3}(-\d+)?", id) or not id.startswith(code) or id in ids:
|
||||
n, id = 1, code
|
||||
while id in ids:
|
||||
n += 1
|
||||
id = f"{code}-{n}"
|
||||
ids.add(id)
|
||||
out.append({
|
||||
"id": id,
|
||||
"code": code,
|
||||
"label": LANGUAGES.get(code, str(l.get("label", "") or code)),
|
||||
"label": LANGUAGES.get(code, code),
|
||||
"key": str(l.get("key", "") or "").strip(),
|
||||
"autoSend": bool(l.get("autoSend", False)),
|
||||
"agentKey": str(l.get("agentKey", "") or "").strip(),
|
||||
@@ -281,12 +373,16 @@ class Binds:
|
||||
def specs(cfg):
|
||||
out = []
|
||||
for lang in cfg["languages"]:
|
||||
code, label = lang["code"], lang["label"]
|
||||
code, label, lid = lang["code"], lang["label"], lang.get("id") or lang["code"]
|
||||
if not code:
|
||||
continue
|
||||
if "-" in lid: # a second entry for the same language: tell them apart in the keybindings list
|
||||
label = f"{label} {lid.split('-')[1]}"
|
||||
if lang.get("autoSend"):
|
||||
label += ", sends"
|
||||
for field, desc, cmd in (
|
||||
("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code}"),
|
||||
("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {code} --agent"),
|
||||
("key", f"Dictate ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid}"),
|
||||
("agentKey", f"Ask agent ({label}){MARK}", f"{shlex.quote(STT_CLI)} toggle --lang {lid} --agent"),
|
||||
):
|
||||
pk = parse_key(lang.get(field, ""))
|
||||
if not pk:
|
||||
@@ -423,6 +519,28 @@ class Binds:
|
||||
# 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:
|
||||
def __init__(self, device):
|
||||
self.device = device
|
||||
@@ -433,7 +551,10 @@ class Recorder:
|
||||
self.lock = threading.Lock()
|
||||
self.started = 0.0
|
||||
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):
|
||||
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)
|
||||
if not data:
|
||||
break
|
||||
self.listening = True
|
||||
n = len(data) // 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
|
||||
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:
|
||||
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()
|
||||
rc = self.proc.wait()
|
||||
if rc not in (0, -15, -9) and err:
|
||||
self.error = err.splitlines()[-1]
|
||||
|
||||
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:
|
||||
self.proc.terminate()
|
||||
try:
|
||||
self.proc.wait(timeout=2)
|
||||
self.proc.wait(timeout=0.5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.kill()
|
||||
self.proc.wait()
|
||||
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
|
||||
def duration(self):
|
||||
@@ -592,6 +751,14 @@ def model_path(model):
|
||||
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:
|
||||
"""The transcription process now running (if any), so cancel/shutdown can kill it."""
|
||||
proc = None
|
||||
@@ -760,6 +927,39 @@ def deliver(cfg, text, enter):
|
||||
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):
|
||||
"""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}"
|
||||
@@ -835,6 +1035,22 @@ class History:
|
||||
self.db.execute("UPDATE takes SET text=? WHERE id=?", (text, id))
|
||||
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
|
||||
@@ -867,10 +1083,15 @@ class Daemon:
|
||||
self.wanted_model = "" # model a refused recording was waiting for
|
||||
self.engines = available_engines() # cached: state is pushed 20×/s while recording
|
||||
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.warm = None # a parked Recorder (warmMic): the stream is already open when the key is pressed
|
||||
self.warm_close = None # timer handle: with warmHoldSecs, the parked stream closes after a quiet spell
|
||||
self.loop = None
|
||||
self.stopping = False
|
||||
self.stop_event = None
|
||||
self.exit_code = 0
|
||||
|
||||
# ---- state ----
|
||||
def state_msg(self, full=False):
|
||||
@@ -898,11 +1119,22 @@ class Daemon:
|
||||
"download": self.download,
|
||||
"agentName": self.agent,
|
||||
"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:
|
||||
msg["languageNames"] = LANGUAGES
|
||||
msg["sources"] = self.sources
|
||||
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):
|
||||
line = (json.dumps(msg or self.state_msg()) + "\n").encode()
|
||||
for w in list(self.clients):
|
||||
@@ -918,11 +1150,15 @@ class Daemon:
|
||||
pass
|
||||
|
||||
# ---- recording ----
|
||||
def find_lang(self, code):
|
||||
"""The language with this code, else the default (the first in the list)."""
|
||||
def find_lang(self, ref):
|
||||
"""The entry with this id (en-2), else the first with this code, else the default (the first in the list)."""
|
||||
usable = [l for l in self.cfg["languages"] if l["code"]]
|
||||
if ref:
|
||||
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 usable[0] if usable else self.cfg["languages"][0]
|
||||
|
||||
@@ -958,6 +1194,10 @@ class Daemon:
|
||||
return 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)
|
||||
dest = model_path(model)
|
||||
fd, part = tempfile.mkstemp(prefix=f"ggml-{model}.", suffix=".part", dir=VOXTYPE_MODELS)
|
||||
@@ -968,14 +1208,11 @@ class Daemon:
|
||||
log("downloading model", model)
|
||||
proc = None
|
||||
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(
|
||||
"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:
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=0.5)
|
||||
@@ -985,14 +1222,17 @@ class Daemon:
|
||||
done = os.path.getsize(part)
|
||||
except OSError:
|
||||
done = 0
|
||||
self.download["pct"] = int(done * 100 / total) if total else 0
|
||||
self.download["pct"] = int(done * 100 / want_size)
|
||||
self.broadcast()
|
||||
err = (await proc.stderr.read()).decode(errors="replace").strip()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(err.splitlines()[-1] if err else f"curl exited {proc.returncode}")
|
||||
size = os.path.getsize(part)
|
||||
if size < 1_000_000 or (total and size != total):
|
||||
raise RuntimeError(f"incomplete file ({size} of {total} bytes)")
|
||||
if size != want_size:
|
||||
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.replace(part, dest)
|
||||
log("model ready", dest)
|
||||
@@ -1021,20 +1261,80 @@ class Daemon:
|
||||
self.download_task = None
|
||||
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):
|
||||
if self.state != "idle":
|
||||
self.broadcast()
|
||||
return
|
||||
if not which("pw-record"):
|
||||
self.fail("pw-record (PipeWire) is not installed")
|
||||
self.missing = missing_tools(self.cfg)
|
||||
if self.missing:
|
||||
self.fail("Dictation needs " + ", ".join(self.missing) + " — open the microphone icon to install it")
|
||||
self.broadcast()
|
||||
return
|
||||
self.agent_mode = bool(agent)
|
||||
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)
|
||||
if model and not os.path.exists(model_path(model)):
|
||||
# Not an error: the bar shows "Getting ready for <language> · N%" while it downloads.
|
||||
@@ -1047,7 +1347,17 @@ class Daemon:
|
||||
self.committed = []
|
||||
self.committed_off = 0
|
||||
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"))
|
||||
request_headset_profile(self.rec.device or "")
|
||||
try:
|
||||
self.rec.start()
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
@@ -1114,9 +1424,9 @@ class Daemon:
|
||||
return (n - 100 + j) * CHUNK
|
||||
return None
|
||||
|
||||
async def _transcribe_range(self, rec, a, b, prefix):
|
||||
"""Run the engine on buf[a:b] (under the engine lock). Returns text ('' for noise) or None on error."""
|
||||
pcm = rec.snapshot_range(a, b)
|
||||
async def _transcribe_range(self, audio, a, b, prefix):
|
||||
"""Run the engine on audio[a:b] (under the engine lock). Returns text ('' for noise) or None on error."""
|
||||
pcm = bytes(audio[a:b])
|
||||
if len(pcm) < int(0.3 * RATE) * 2:
|
||||
return ""
|
||||
fd, tmp = tempfile.mkstemp(prefix=prefix, suffix=".wav", dir=RUNTIME)
|
||||
@@ -1144,10 +1454,11 @@ class Daemon:
|
||||
await asyncio.sleep(interval)
|
||||
while self.state == "recording" and self.rec is rec:
|
||||
levels = rec.levels_copy()
|
||||
audio = rec.snapshot()
|
||||
cut = self._find_cut(levels)
|
||||
if cut is not None and cut > 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:
|
||||
return
|
||||
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.tail_text = ""
|
||||
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):
|
||||
text = await self._transcribe_range(rec, a, b, "live-")
|
||||
text = await self._transcribe_range(audio, a, b, "live-")
|
||||
if self.rec is not rec:
|
||||
return
|
||||
if text:
|
||||
@@ -1172,8 +1483,11 @@ class Daemon:
|
||||
self.broadcast()
|
||||
return
|
||||
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"
|
||||
await self.release_rec(rec)
|
||||
self.enter_pending = bool(enter)
|
||||
agent = bool(agent) or self.agent_mode
|
||||
lang = self.lang
|
||||
@@ -1181,9 +1495,7 @@ class Daemon:
|
||||
self.broadcast()
|
||||
path = ""
|
||||
try:
|
||||
pcm = rec.snapshot()
|
||||
duration = len(pcm) / 2 / RATE
|
||||
started = rec.started
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime(started))
|
||||
path = os.path.join(TAKES, f"{stamp}-{lang['code']}.wav")
|
||||
write_wav(path, pcm)
|
||||
@@ -1199,7 +1511,7 @@ class Daemon:
|
||||
raise asyncio.CancelledError
|
||||
committed, off = list(self.committed), self.committed_off
|
||||
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:
|
||||
text, err = "", self.error
|
||||
else:
|
||||
@@ -1227,6 +1539,8 @@ class Daemon:
|
||||
await self.loop.run_in_executor(None, deliver_agent, self.cfg, text)
|
||||
else:
|
||||
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"})
|
||||
except asyncio.CancelledError:
|
||||
self._discard(path)
|
||||
@@ -1240,6 +1554,7 @@ class Daemon:
|
||||
self.state = "idle"
|
||||
self.rec = None
|
||||
self.partial = ""
|
||||
self.ensure_warm()
|
||||
self.broadcast()
|
||||
|
||||
@staticmethod
|
||||
@@ -1253,14 +1568,17 @@ class Daemon:
|
||||
if self.state == "idle":
|
||||
self.broadcast()
|
||||
return
|
||||
if self.rec:
|
||||
self.rec.stop()
|
||||
EngineRun.kill() # a transcription in flight is for a recording nobody wants
|
||||
rec = self.rec
|
||||
self.state = "idle"
|
||||
self.rec = None
|
||||
self.partial = ""
|
||||
EngineRun.kill() # a transcription in flight is for a recording nobody wants
|
||||
self.binds.set_cancel(self.cfg, False)
|
||||
self.broadcast()
|
||||
if rec:
|
||||
await self.release_rec(rec)
|
||||
self.ensure_warm()
|
||||
self.broadcast()
|
||||
|
||||
async def toggle(self, code=None, enter=False, agent=False):
|
||||
if self.state == "idle":
|
||||
@@ -1338,15 +1656,22 @@ class Daemon:
|
||||
if not (5 <= self.cfg.get("maxDurationSecs", 300) <= 7200):
|
||||
self.cfg["maxDurationSecs"] = DEFAULT_CONFIG["maxDurationSecs"]
|
||||
self.cfg["languages"] = normalize_languages(self.cfg.get("languages"))
|
||||
if self.lang["code"] not in [l["code"] for l in self.cfg["languages"]]:
|
||||
if self.lang.get("id") not in [l.get("id") for l in self.cfg["languages"]]:
|
||||
self.lang = self.cfg["languages"][0]
|
||||
try:
|
||||
save_config(self.cfg)
|
||||
except OSError as e:
|
||||
self.fail(f"cannot save config: {e}")
|
||||
self.binds.apply(self.cfg)
|
||||
self.engines = available_engines()
|
||||
self.agent = self.agent_name()
|
||||
self.refresh_environment()
|
||||
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()
|
||||
|
||||
# ---- socket ----
|
||||
@@ -1403,7 +1728,13 @@ class Daemon:
|
||||
async def dispatch(self, msg, writer):
|
||||
cmd = msg.get("cmd")
|
||||
if cmd == "get":
|
||||
self.refresh_environment()
|
||||
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":
|
||||
self.spawn(self.toggle(self._lang(msg), bool(msg.get("enter")), bool(msg.get("agent"))))
|
||||
elif cmd == "start":
|
||||
@@ -1489,12 +1820,36 @@ class Daemon:
|
||||
pass
|
||||
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):
|
||||
if self.stopping:
|
||||
return
|
||||
self.stopping = True
|
||||
if self.rec:
|
||||
self.rec.stop()
|
||||
if self.warm:
|
||||
self.warm.stop()
|
||||
self.warm = None
|
||||
EngineRun.kill()
|
||||
if self.download_task and not self.download_task.done():
|
||||
self.download_task.cancel()
|
||||
@@ -1523,8 +1878,13 @@ class Daemon:
|
||||
server = await asyncio.start_unix_server(self.handle, path=SOCK, limit=1 << 20)
|
||||
self.binds.sweep()
|
||||
self.binds.apply(self.cfg)
|
||||
if self.history.prune(self.cfg.get("historyDays", 30)):
|
||||
log("history pruned")
|
||||
self.ensure_warm()
|
||||
self.ensure_models()
|
||||
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):
|
||||
self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown()))
|
||||
log(f"sttd {VERSION} listening on {SOCK}")
|
||||
@@ -1548,7 +1908,9 @@ def main():
|
||||
sys.exit(3)
|
||||
# The socket is not removed on exit: the shell may already have started a
|
||||
# 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__":
|
||||
|
||||
@@ -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",
|
||||
@@ -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.65–0.85 s to 0.26–0.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.19–1.43 s after the start command, 0–0.14 s
|
||||
after the first nonzero chunk (`tools/ind2-*.log`).
|
||||
|
||||
### Timeline now (normal path, quiet room)
|
||||
|
||||
| after F13 | event |
|
||||
|---|---|
|
||||
| 0.26–0.49 s | eSCO link up |
|
||||
| 1.06–1.43 s | first nonzero PCM at the daemon |
|
||||
| 1.19–1.43 s | UI "Listening" |
|
||||
|
||||
The 0.7–0.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 2–5 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.5–0.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.3–0.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 12–20 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.6–0.9 s.
|
||||
- HCI synchronous connection completion was around 0.87–1.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.97–0.99 s; first decoded
|
||||
PCM at 1.16–1.19 s; first nonzero decoded PCM at 1.48–1.50 s; nonzero PCM sent to
|
||||
the graph at 1.53–1.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.
|
||||
@@ -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 12–20 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.87–1.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.97–0.99 s after the start request.
|
||||
- First decoded PCM: 1.16–1.19 s.
|
||||
- First nonzero decoded PCM: 1.48–1.50 s.
|
||||
- First nonzero PCM delivered to the graph: 1.53–1.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.
|
||||
@@ -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.7–1.0 s, sometimes never | 0.15–0.18 s |
|
||||
| first real audio at the daemon | 1.5–1.7 s, or never | 0.82–0.86 s |
|
||||
| "Listening" in the bar | 2.1 s on a timer, even with no audio | 0.86–0.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.21–0.27 s → 0.15–0.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`).
|
||||
@@ -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
Executable
+38
@@ -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"
|
||||
Executable
+12
@@ -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"
|
||||
Executable
+22
@@ -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
|
||||
Executable
+94
@@ -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
@@ -96,16 +96,9 @@ Panel {
|
||||
for (var i = 0; i < langs.length; i++) if (langs[i].code === code) return langs[i].label || code
|
||||
return code
|
||||
}
|
||||
function keyFor(code, kind) { // the applied key for one language ("" if none); kind "Dictate" (default) or "Ask agent"
|
||||
if (!svc) return ""
|
||||
var want = (kind || "Dictate") + " (" + langName(code) + ")", b = svc.binds || []
|
||||
for (var i = 0; i < b.length; i++) {
|
||||
if (String(b[i].desc || "") !== want) continue
|
||||
return (b[i].mods ? b[i].mods + " " : "") + b[i].key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
function keyFor(code) { return langs.length && langs[0].code === code ? String(langs[0].key || "") : "" } // the default entry's dictate key
|
||||
readonly property string defaultLang: langs.length ? langs[0].code : ""
|
||||
function t(key, fallback) { var st = svc ? svc.strings : null; return st && st[key] ? st[key] : fallback }
|
||||
readonly property string agentName: svc && svc.agentName !== "" ? svc.agentName : "agent"
|
||||
function clock(secs) {
|
||||
var s = Math.floor(secs || 0)
|
||||
@@ -122,17 +115,17 @@ Panel {
|
||||
function subtitle() {
|
||||
if (!svc) return "Service not loaded"
|
||||
if (!connected) return svc.daemonError !== "" ? svc.daemonError : "Starting…"
|
||||
if (connecting) return "Opening the microphone…"
|
||||
if (recording) return "Listening · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
|
||||
if (transcribing) return "Transcribing…"
|
||||
if (connecting) return t("opening", "Opening the microphone…")
|
||||
if (recording) return t("listening", "Listening…").replace(/…$/, "") + " · " + svc.langLabel + " · " + clock(svc.elapsed) + (svc.agentMode ? " · to " + agentName : "")
|
||||
if (transcribing) return t("transcribing", "Transcribing…")
|
||||
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
|
||||
return "Idle · " + svc.langLabel
|
||||
}
|
||||
readonly property string liveText: {
|
||||
if (!svc) return ""
|
||||
if (connecting) return "Opening microphone…"
|
||||
if (recording) return svc.partial !== "" ? svc.partial : "Listening…"
|
||||
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : "Transcribing…")
|
||||
if (connecting) return t("opening", "Opening microphone…")
|
||||
if (recording) return svc.partial !== "" ? svc.partial : t("listening", "Listening…")
|
||||
if (transcribing) return svc.partial !== "" ? svc.partial : (svc.agentMode ? "Sending to " + agentName + "…" : t("transcribing", "Transcribing…"))
|
||||
if (showError) return svc.error
|
||||
if (download) return "Getting ready for " + (download.lang || download.model) + " · " + download.pct + "%"
|
||||
return ""
|
||||
@@ -151,7 +144,7 @@ Panel {
|
||||
active: root.busy || root.showError
|
||||
useActiveColor: true
|
||||
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.keyFor(root.defaultLang) !== "" ? " · " + root.keyFor(root.defaultLang) + ": dictate " + root.langName(root.defaultLang) : "")
|
||||
+ " · right-click: dictate"
|
||||
@@ -173,6 +166,7 @@ Panel {
|
||||
Waveform {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: Style.bar.iconCanvas
|
||||
style: ["bars", "wave", "pulse", "dots"].indexOf(String(root.cfg.animation)) >= 0 ? String(root.cfg.animation) : "bars"
|
||||
bars: 18
|
||||
levels: root.svc ? root.svc.levels : []
|
||||
sine: root.connecting || (root.download !== null && !root.busy)
|
||||
@@ -260,10 +254,9 @@ Panel {
|
||||
readonly property string langsJson: JSON.stringify(svc ? svc.languages : [])
|
||||
readonly property var langs: JSON.parse(langsJson)
|
||||
readonly property string namesJson: JSON.stringify(svc ? svc.languageNames : ({}))
|
||||
readonly property var addableLangs: {
|
||||
var names = JSON.parse(namesJson), have = {}, out = []
|
||||
for (var i = 0; i < langs.length; i++) have[langs[i].code] = true
|
||||
for (var code in names) if (!have[code]) out.push({ value: code, label: names[code] + " (" + code + ")" })
|
||||
readonly property var addableLangs: { // every language, even ones already there: a second entry can send, ask the agent…
|
||||
var names = JSON.parse(namesJson), out = []
|
||||
for (var code in names) out.push({ value: code, label: names[code] + " (" + code + ")" })
|
||||
out.sort(function(a, b) { return a.value === "auto" ? -1 : b.value === "auto" ? 1 : a.label.localeCompare(b.label) })
|
||||
return out
|
||||
}
|
||||
@@ -272,6 +265,14 @@ Panel {
|
||||
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 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 var conflicts: JSON.parse(conflictsJson)
|
||||
function conflictText() {
|
||||
@@ -291,10 +292,13 @@ Panel {
|
||||
function addLang(code) {
|
||||
if (!code) return
|
||||
var list = JSON.parse(langsJson)
|
||||
for (var i = 0; i < list.length; i++) if (list[i].code === code) return
|
||||
list.push({ code: code, key: "", autoSend: false, agentKey: "", engineArgs: "" })
|
||||
saveLangs(list)
|
||||
}
|
||||
function langTitle(l) { // "English", or "English · 2" for a second entry of the same language
|
||||
var id = String(l.id || l.code), n = id.indexOf("-") > 0 ? id.slice(id.indexOf("-") + 1) : ""
|
||||
return langName(l.code) + (n ? " " + n : "")
|
||||
}
|
||||
function moveLang(index, delta) { // the first language is the default
|
||||
var list = JSON.parse(langsJson), j = index + delta
|
||||
if (index < 0 || index >= list.length || j < 0 || j >= list.length) return
|
||||
@@ -388,7 +392,7 @@ Panel {
|
||||
bar: root.bar
|
||||
open: root.opened
|
||||
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))
|
||||
|
||||
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 {
|
||||
width: parent.width
|
||||
visible: root.busy
|
||||
@@ -810,20 +847,6 @@ Panel {
|
||||
fontFamily: root.fontFamily
|
||||
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 {
|
||||
iconText: ""
|
||||
tooltipText: "Delete"
|
||||
@@ -906,7 +929,7 @@ Panel {
|
||||
spacing: Style.space(10)
|
||||
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 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
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.langName(langRow.modelData.code)
|
||||
text: root.langTitle(langRow.modelData)
|
||||
color: root.fg
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
@@ -1060,7 +1083,7 @@ Panel {
|
||||
color: Color.urgent
|
||||
opacity: 1
|
||||
}
|
||||
Note { text: "The first language is the default. + Return also presses Return after pasting. Esc discards while recording." }
|
||||
Note { text: "The first entry is the default. + Return also presses Return after pasting. Add the same language twice for one key that sends and one that does not. Esc discards while recording." }
|
||||
}
|
||||
|
||||
// ---------- The two switches that matter ----------
|
||||
@@ -1077,6 +1100,94 @@ Panel {
|
||||
checked: 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) ----------
|
||||
@@ -1209,12 +1320,39 @@ Panel {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
RowLabel { text: "Microphone" }
|
||||
ConfigField {
|
||||
Dropdown {
|
||||
width: parent.width - root.labelW - parent.spacing - root.trailInset
|
||||
placeholderText: "default"
|
||||
key: "device"
|
||||
showLabel: false
|
||||
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 {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
@@ -38,6 +38,10 @@ Item {
|
||||
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 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) ----
|
||||
property var historyItems: []
|
||||
@@ -72,6 +76,7 @@ Item {
|
||||
function rebind() { return send({ cmd: "rebind" }) }
|
||||
function setLang(code) { return send({ cmd: "setLang", lang: code }) }
|
||||
// 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 resumeBinds() { return send({ cmd: "resumeBinds" }) }
|
||||
function loadHistory(query, limit) {
|
||||
@@ -100,6 +105,11 @@ Item {
|
||||
}
|
||||
onExited: function(code, status) {
|
||||
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
|
||||
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
|
||||
@@ -140,6 +150,7 @@ Item {
|
||||
if (!msg) return
|
||||
if (msg.type === "state") {
|
||||
if (msg.languageNames) root.languageNames = msg.languageNames
|
||||
if (msg.sources) root.sources = msg.sources
|
||||
root.state = msg
|
||||
if (root.daemonError !== "") root.daemonError = ""
|
||||
} else if (msg.type === "history") {
|
||||
|
||||
+113
-17
@@ -1,30 +1,39 @@
|
||||
import QtQuick
|
||||
|
||||
// A row of thin bars mirrored around the middle. `levels` holds 0..1 values,
|
||||
// newest last; the bars show the most recent `bars` of them. With `idle` on
|
||||
// (nothing to show yet, or transcribing) the bars breathe gently instead.
|
||||
// The bar's picture of the microphone, in one of four styles:
|
||||
// bars thin bars mirrored around the middle (the default)
|
||||
// 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 {
|
||||
id: root
|
||||
|
||||
property var levels: []
|
||||
property string style: "bars"
|
||||
property int bars: 20
|
||||
property real barWidth: 2
|
||||
property real gap: 2
|
||||
property color color: "white"
|
||||
property bool idle: false // gentle breathing (nothing to show yet, or transcribing)
|
||||
property bool sine: false // a travelling sine wave (the microphone is still connecting)
|
||||
property bool idle: false
|
||||
property bool sine: false
|
||||
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
|
||||
|
||||
property real phase: 0
|
||||
Timer {
|
||||
interval: 40
|
||||
repeat: true
|
||||
running: (root.idle || root.sine) && root.visible
|
||||
onTriggered: root.phase += root.sine ? 0.35 : 0.2
|
||||
running: root.animated && root.visible
|
||||
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) {
|
||||
var lv = levels || []
|
||||
@@ -32,23 +41,28 @@ Item {
|
||||
var v = offset + i >= 0 ? Number(lv[offset + i]) : 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 wave(i) {
|
||||
return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 2
|
||||
function breath(i) { return 0.12 + 0.1 * (1 + Math.sin(phase + i * 0.45)) / 2 }
|
||||
function travel(i) { return 0.15 + 0.75 * (1 + Math.sin(i * 0.7 - phase)) / 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() {
|
||||
if (sine) return 0.4 + 0.35 * (1 + Math.sin(phase * 1.5)) / 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 {
|
||||
visible: root.style === "bars"
|
||||
anchors.centerIn: parent
|
||||
spacing: root.gap
|
||||
Repeater {
|
||||
model: root.bars
|
||||
model: root.style === "bars" ? root.bars : 0
|
||||
Rectangle {
|
||||
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
|
||||
height: Math.max(root.minHeight, Math.round(lv * root.height))
|
||||
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
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Reference in New Issue
Block a user