@olares/lares-core 0.15.7 → 0.15.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +96 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bluetooth headset helper for voice capture.
|
|
3
|
+
|
|
4
|
+
ensure() makes a paired headset able to capture before a recording starts.
|
|
5
|
+
scan lists devices advertising right now. connect pairs and connects one of them.
|
|
6
|
+
After boot the radio has dropped the link, so serve() pages the trusted
|
|
7
|
+
headset again until it answers or the reconnect window closes.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
A connected card is ready once it is on the microphone profile and that input
|
|
11
|
+
exists. Switching an already linked card does not wait for a silence probe.
|
|
12
|
+
The probe stays on a fresh connect, where the codec often stays quiet for
|
|
13
|
+
several seconds.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import ctypes
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import pwd
|
|
21
|
+
import re
|
|
22
|
+
import signal
|
|
23
|
+
import socket
|
|
24
|
+
import struct
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import time
|
|
28
|
+
import wave
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
HEADSET_PROFILE = "headset-head-unit-msbc"
|
|
32
|
+
PROBE_SECONDS = "1.2"
|
|
33
|
+
LIVE_PEAK = 1
|
|
34
|
+
POLL_SECONDS = 12
|
|
35
|
+
PROFILE_WAIT_SECONDS = 3
|
|
36
|
+
SCAN_SECONDS = 8
|
|
37
|
+
MAX_DEVICES = 16
|
|
38
|
+
# A headset answers pages for about a minute after the host radio returns.
|
|
39
|
+
RESTORE_TRIES = 8
|
|
40
|
+
RESTORE_GAP_SECONDS = 3
|
|
41
|
+
ARM_TRIES = 15
|
|
42
|
+
ADDRESS = re.compile(r"^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$")
|
|
43
|
+
DEVICE_LINE = re.compile(
|
|
44
|
+
r"\[(?:NEW|CHG)\] Device ([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})(?:\s+(.*))?$"
|
|
45
|
+
)
|
|
46
|
+
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
47
|
+
WIDEBAND = "/usr/local/sbin/bluetooth-wideband-speech"
|
|
48
|
+
AF_BLUETOOTH = 31
|
|
49
|
+
BTPROTO_HCI = 1
|
|
50
|
+
HCI_CHANNEL_CONTROL = 3
|
|
51
|
+
HCI_DEV_NONE = 0xFFFF
|
|
52
|
+
OP_READ_INFO = 0x0004
|
|
53
|
+
OP_POWER = 0x0005
|
|
54
|
+
OP_CONNECTABLE = 0x0007
|
|
55
|
+
OP_WBS = 0x0047
|
|
56
|
+
EV_COMPLETE = 0x0001
|
|
57
|
+
EV_STATUS = 0x0002
|
|
58
|
+
POWERED = 1 << 0
|
|
59
|
+
CONNECTABLE = 1 << 1
|
|
60
|
+
WIDE = 1 << 17
|
|
61
|
+
WIREPLUMBER_SNIPPET = """\
|
|
62
|
+
bluez_monitor.properties["with-logind"] = false
|
|
63
|
+
bluez_midi_monitor.properties["with-logind"] = false
|
|
64
|
+
bluez_monitor.properties["bluez5.hfphsp-backend"] = "native"
|
|
65
|
+
bluez_monitor.properties["bluez5.hfphsp-backend-native-modem"] = "any"
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def audio_user():
|
|
70
|
+
try:
|
|
71
|
+
return pwd.getpwnam("olares")
|
|
72
|
+
except KeyError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run(cmd, user=None, timeout=20):
|
|
77
|
+
if user is not None:
|
|
78
|
+
cmd = ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}", *cmd]
|
|
79
|
+
return subprocess.run(cmd, text=True, capture_output=True, timeout=timeout)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pcm_peak(path):
|
|
83
|
+
try:
|
|
84
|
+
with wave.open(path) as wav:
|
|
85
|
+
width = wav.getsampwidth()
|
|
86
|
+
raw = wav.readframes(wav.getnframes())
|
|
87
|
+
except (wave.Error, FileNotFoundError, EOFError):
|
|
88
|
+
return -1
|
|
89
|
+
if width != 2 or len(raw) < 2:
|
|
90
|
+
return -1
|
|
91
|
+
peak = 0
|
|
92
|
+
for offset in range(0, len(raw) - 1, 2):
|
|
93
|
+
sample = int.from_bytes(raw[offset:offset + 2], "little", signed=True)
|
|
94
|
+
magnitude = sample if sample >= 0 else -sample
|
|
95
|
+
if magnitude > peak:
|
|
96
|
+
peak = magnitude
|
|
97
|
+
return peak
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def source_labels(text):
|
|
101
|
+
found = {}
|
|
102
|
+
name = ""
|
|
103
|
+
for raw in str(text or "").splitlines():
|
|
104
|
+
line = raw.strip()
|
|
105
|
+
if line.startswith("Name:"):
|
|
106
|
+
name = line.split(":", 1)[1].strip()
|
|
107
|
+
elif name and line.startswith("Description:"):
|
|
108
|
+
found[name] = line.split(":", 1)[1].strip()
|
|
109
|
+
name = ""
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def capture_ready(user, source):
|
|
114
|
+
listed = run(["pactl", "list", "sources"], user=user, timeout=8)
|
|
115
|
+
return {"ready": True, "source": source, "label": source_labels(listed.stdout).get(source, "")}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def bluez_sources(user):
|
|
119
|
+
listed = run(["pactl", "list", "sources", "short"], user=user, timeout=8)
|
|
120
|
+
sources = []
|
|
121
|
+
for line in listed.stdout.splitlines():
|
|
122
|
+
parts = line.split()
|
|
123
|
+
if len(parts) >= 2 and parts[1].startswith("bluez_input."):
|
|
124
|
+
sources.append(parts[1])
|
|
125
|
+
return sources
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def probe(user, source):
|
|
129
|
+
path = "/tmp/lares-bt-probe.wav"
|
|
130
|
+
try:
|
|
131
|
+
os.remove(path)
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
pass
|
|
134
|
+
cmd = [
|
|
135
|
+
"timeout", PROBE_SECONDS, "pw-record",
|
|
136
|
+
"--target", source,
|
|
137
|
+
"--rate=16000", "--channels=1", "--format=s16",
|
|
138
|
+
path,
|
|
139
|
+
]
|
|
140
|
+
try:
|
|
141
|
+
run(cmd, user=user, timeout=8)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
return -1
|
|
144
|
+
return pcm_peak(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def live_source(user):
|
|
148
|
+
for source in bluez_sources(user):
|
|
149
|
+
if probe(user, source) >= LIVE_PEAK:
|
|
150
|
+
return source
|
|
151
|
+
return ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def paired_headsets():
|
|
155
|
+
listed = run(["bluetoothctl", "devices"], timeout=8)
|
|
156
|
+
headsets = []
|
|
157
|
+
for mac in known_addresses(listed.stdout):
|
|
158
|
+
info = run(["bluetoothctl", "info", mac], timeout=8)
|
|
159
|
+
text = info.stdout
|
|
160
|
+
if is_voice_device(text):
|
|
161
|
+
headsets.append((mac, "Connected: yes" in text))
|
|
162
|
+
return headsets
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def card_for_node(node, prefix):
|
|
166
|
+
# bluez_input.AA_BB_CC_DD_EE_FF.0 → bluez_card.AA_BB_CC_DD_EE_FF
|
|
167
|
+
body = node.removeprefix(prefix)
|
|
168
|
+
mac = body.rsplit(".", 1)[0]
|
|
169
|
+
return f"bluez_card.{mac}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def card_for_source(source):
|
|
173
|
+
return card_for_node(source, "bluez_input.")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def bluetooth_cards(user):
|
|
177
|
+
cards = []
|
|
178
|
+
for source in bluez_sources(user):
|
|
179
|
+
card = card_for_source(source)
|
|
180
|
+
if card not in cards:
|
|
181
|
+
cards.append(card)
|
|
182
|
+
listed = run(["pactl", "list", "sinks", "short"], user=user, timeout=8)
|
|
183
|
+
for line in listed.stdout.splitlines():
|
|
184
|
+
parts = line.split()
|
|
185
|
+
if len(parts) < 2 or not parts[1].startswith("bluez_output."):
|
|
186
|
+
continue
|
|
187
|
+
card = card_for_node(parts[1], "bluez_output.")
|
|
188
|
+
if card not in cards:
|
|
189
|
+
cards.append(card)
|
|
190
|
+
return cards
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def call_sink_name(text):
|
|
194
|
+
"""The call speaker. Wideband mSBC wins over narrowband CVSD."""
|
|
195
|
+
name = ""
|
|
196
|
+
chosen = {"msbc": "", "cvsd": ""}
|
|
197
|
+
for raw in str(text or "").splitlines():
|
|
198
|
+
line = raw.strip()
|
|
199
|
+
if line.startswith("Name: bluez_output."):
|
|
200
|
+
name = line.split("Name:", 1)[1].strip()
|
|
201
|
+
elif name and "api.bluez5.codec" in line:
|
|
202
|
+
codec = line.split("=", 1)[-1].strip().strip('"')
|
|
203
|
+
if codec in chosen and not chosen[codec]:
|
|
204
|
+
chosen[codec] = name
|
|
205
|
+
name = ""
|
|
206
|
+
return chosen["msbc"] or chosen["cvsd"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def call_sink(user):
|
|
210
|
+
listed = run(["pactl", "list", "sinks"], user=user, timeout=8)
|
|
211
|
+
return call_sink_name(listed.stdout)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
playback_proc = None
|
|
215
|
+
capture_proc = None
|
|
216
|
+
# Set when a sounding readout is cut. The next capture drops the speaker tail, once.
|
|
217
|
+
# A readout that played to its end leaves no tail, so the take keeps the first words.
|
|
218
|
+
playback_unsettled = False
|
|
219
|
+
CAPTURE_FILE = "capture.s16"
|
|
220
|
+
# WebRTC gain inside PipeWire. A voice that is not against the headset stays
|
|
221
|
+
# loud enough to transcribe. Pulse names are translated to webrtc.* by the module.
|
|
222
|
+
ECHO_SOURCE = "lares.echo-cancel-source"
|
|
223
|
+
ECHO_SINK = "lares.echo-cancel-sink"
|
|
224
|
+
ECHO_ARGS = "digital_gain_control=1 noise_suppression=1 high_pass_filter=1"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def consume_settle():
|
|
228
|
+
global playback_unsettled
|
|
229
|
+
due = playback_unsettled
|
|
230
|
+
playback_unsettled = False
|
|
231
|
+
return due
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# A few minutes of 16 kHz mono. Past this the next take opens a new link.
|
|
235
|
+
CAPTURE_KEEP_BYTES = 8 * 1024 * 1024
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def stop_playback():
|
|
239
|
+
"""Drop a readout that is still running so the headset can return to the microphone."""
|
|
240
|
+
global playback_proc
|
|
241
|
+
proc = playback_proc
|
|
242
|
+
playback_proc = None
|
|
243
|
+
if proc is None or proc.poll() is not None:
|
|
244
|
+
return False
|
|
245
|
+
proc.terminate()
|
|
246
|
+
try:
|
|
247
|
+
proc.wait(timeout=2)
|
|
248
|
+
except subprocess.TimeoutExpired:
|
|
249
|
+
proc.kill()
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def interrupt_playback():
|
|
254
|
+
"""The microphone takes the link. A readout it cuts still rings in the earcup."""
|
|
255
|
+
global playback_unsettled
|
|
256
|
+
if stop_playback():
|
|
257
|
+
playback_unsettled = True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def stop_playback_request():
|
|
261
|
+
interrupt_playback()
|
|
262
|
+
return {"op": "playback-stop", "ready": True, "stopped": True}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def stop_proc(proc):
|
|
266
|
+
if proc is None or proc.poll() is not None:
|
|
267
|
+
return
|
|
268
|
+
try:
|
|
269
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
270
|
+
except ProcessLookupError:
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
proc.wait(timeout=0.3)
|
|
274
|
+
except subprocess.TimeoutExpired:
|
|
275
|
+
try:
|
|
276
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
277
|
+
except ProcessLookupError:
|
|
278
|
+
return
|
|
279
|
+
proc.wait(timeout=2)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def stop_capture():
|
|
283
|
+
"""Drop the call link. Doing this at the end of a sentence stalls the machine."""
|
|
284
|
+
global capture_proc
|
|
285
|
+
proc = capture_proc
|
|
286
|
+
capture_proc = None
|
|
287
|
+
stop_proc(proc)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def keep_capture(alive, size):
|
|
291
|
+
return bool(alive) and 0 <= size < CAPTURE_KEEP_BYTES
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def echo_cancel_text(user):
|
|
295
|
+
return run(["pactl", "list", "modules"], user=user, timeout=8).stdout
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def echo_cancel_ready(user, source, sink):
|
|
299
|
+
text = echo_cancel_text(user)
|
|
300
|
+
return (
|
|
301
|
+
f"source_master={source}" in text
|
|
302
|
+
and f"sink_master={sink}" in text
|
|
303
|
+
and f"source_name={ECHO_SOURCE}" in text
|
|
304
|
+
and ECHO_ARGS in text
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def unload_echo_cancel(user):
|
|
309
|
+
listed = run(["pactl", "list", "modules", "short"], user=user, timeout=8)
|
|
310
|
+
for line in listed.stdout.splitlines():
|
|
311
|
+
parts = line.split("\t")
|
|
312
|
+
if len(parts) >= 2 and parts[1] == "module-echo-cancel":
|
|
313
|
+
run(["pactl", "unload-module", parts[0]], user=user, timeout=8)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def ensure_echo_cancel(user, source):
|
|
317
|
+
"""Capture through echo-cancel so a normal speaking distance is still intelligible.
|
|
318
|
+
|
|
319
|
+
The raw headset is quiet once you step back, and that is what the recognizer
|
|
320
|
+
mishears. The module's digital gain is the same control the browser mic used.
|
|
321
|
+
A missing module leaves the raw headset in place.
|
|
322
|
+
"""
|
|
323
|
+
sink = call_sink(user)
|
|
324
|
+
if not source or not sink:
|
|
325
|
+
return source
|
|
326
|
+
if echo_cancel_ready(user, source, sink):
|
|
327
|
+
return ECHO_SOURCE
|
|
328
|
+
unload_echo_cancel(user)
|
|
329
|
+
loaded = run([
|
|
330
|
+
"pactl", "load-module", "module-echo-cancel",
|
|
331
|
+
f"source_master={source}",
|
|
332
|
+
f"sink_master={sink}",
|
|
333
|
+
f"source_name={ECHO_SOURCE}",
|
|
334
|
+
f"sink_name={ECHO_SINK}",
|
|
335
|
+
"rate=16000",
|
|
336
|
+
"channels=1",
|
|
337
|
+
"aec_method=webrtc",
|
|
338
|
+
f"aec_args={ECHO_ARGS}",
|
|
339
|
+
], user=user, timeout=8)
|
|
340
|
+
if loaded.returncode != 0:
|
|
341
|
+
return source
|
|
342
|
+
names = run(["pactl", "list", "sources", "short"], user=user, timeout=8).stdout
|
|
343
|
+
if ECHO_SOURCE in names:
|
|
344
|
+
return ECHO_SOURCE
|
|
345
|
+
return source
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def speaker_sink(user, sink):
|
|
349
|
+
names = run(["pactl", "list", "sinks", "short"], user=user, timeout=8).stdout
|
|
350
|
+
if ECHO_SINK in names:
|
|
351
|
+
return ECHO_SINK
|
|
352
|
+
return sink
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def begin_capture(directory):
|
|
356
|
+
"""Record the headset on this machine. The browser only watches the phase.
|
|
357
|
+
|
|
358
|
+
pw-cat writes the device latency as digital silence when the target is
|
|
359
|
+
stdout. A file target does not, and it still grows while the take is open.
|
|
360
|
+
The process stays up across takes: closing it drops the Bluetooth call
|
|
361
|
+
link, and that stalls the machine.
|
|
362
|
+
"""
|
|
363
|
+
global capture_proc
|
|
364
|
+
path = Path(directory) / CAPTURE_FILE
|
|
365
|
+
if capture_proc is not None and capture_proc.poll() is None and path.is_file():
|
|
366
|
+
size = path.stat().st_size
|
|
367
|
+
if keep_capture(True, size):
|
|
368
|
+
interrupt_playback()
|
|
369
|
+
return {
|
|
370
|
+
"op": "capture-start",
|
|
371
|
+
"ready": True,
|
|
372
|
+
"source": "",
|
|
373
|
+
"file": CAPTURE_FILE,
|
|
374
|
+
"offset": size,
|
|
375
|
+
"settle": consume_settle(),
|
|
376
|
+
}
|
|
377
|
+
stop_capture()
|
|
378
|
+
ready = ensure()
|
|
379
|
+
ready["op"] = "capture-start"
|
|
380
|
+
if not ready.get("ready") or ready.get("skipped") or not ready.get("source"):
|
|
381
|
+
return ready
|
|
382
|
+
user = audio_user()
|
|
383
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
384
|
+
return {"op": "capture-start", "ready": True, "skipped": True, "reason": "no desktop audio session"}
|
|
385
|
+
path = Path(directory) / CAPTURE_FILE
|
|
386
|
+
path.unlink(missing_ok=True)
|
|
387
|
+
source = ensure_echo_cancel(user, ready["source"])
|
|
388
|
+
proc = subprocess.Popen(
|
|
389
|
+
[
|
|
390
|
+
"runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}",
|
|
391
|
+
"pw-cat", "--record", "--target", source,
|
|
392
|
+
"--rate", "16000", "--channels", "1", "--format", "s16",
|
|
393
|
+
"--latency", "40ms", str(path),
|
|
394
|
+
],
|
|
395
|
+
stdout=subprocess.DEVNULL,
|
|
396
|
+
stderr=subprocess.DEVNULL,
|
|
397
|
+
start_new_session=True,
|
|
398
|
+
)
|
|
399
|
+
capture_proc = proc
|
|
400
|
+
time.sleep(0.2)
|
|
401
|
+
if proc.poll() is not None:
|
|
402
|
+
stop_capture()
|
|
403
|
+
return {"op": "capture-start", "ready": False, "error": "microphone did not start"}
|
|
404
|
+
if path.exists():
|
|
405
|
+
os.chmod(path, 0o644)
|
|
406
|
+
return {
|
|
407
|
+
"op": "capture-start",
|
|
408
|
+
"ready": True,
|
|
409
|
+
"source": ready["source"],
|
|
410
|
+
"label": ready.get("label", ""),
|
|
411
|
+
"file": CAPTURE_FILE,
|
|
412
|
+
"offset": 0,
|
|
413
|
+
"settle": consume_settle(),
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def end_capture():
|
|
418
|
+
return {"op": "capture-stop", "ready": True}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def reset_capture():
|
|
422
|
+
"""A take heard only digital zeros. The kernel can lose the SCO handle
|
|
423
|
+
("SCO packet for unknown connection handle") while the card stays on the
|
|
424
|
+
call profile, and reopening the recorder keeps that dead link. Leaving the
|
|
425
|
+
profile rebuilds it.
|
|
426
|
+
"""
|
|
427
|
+
stop_capture()
|
|
428
|
+
user = audio_user()
|
|
429
|
+
if user is not None and os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
430
|
+
for card in bluetooth_cards(user):
|
|
431
|
+
run(["pactl", "set-card-profile", card, "off"], user=user, timeout=8)
|
|
432
|
+
run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
|
|
433
|
+
return {"op": "capture-reset", "ready": True}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def spawn(cmd, user):
|
|
437
|
+
full = ["runuser", "-u", user.pw_name, "--", "env", f"XDG_RUNTIME_DIR=/run/user/{user.pw_uid}", *cmd]
|
|
438
|
+
return subprocess.Popen(full, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def play_file(directory, name):
|
|
442
|
+
"""Play speech on the machine the headset is plugged into.
|
|
443
|
+
|
|
444
|
+
The browser's player reports success without opening a playback stream, so
|
|
445
|
+
a readout has to be played here. Ready means the speaker monitor heard it.
|
|
446
|
+
"""
|
|
447
|
+
global playback_proc, playback_unsettled
|
|
448
|
+
safe = Path(str(name or "")).name
|
|
449
|
+
path = Path(directory) / safe
|
|
450
|
+
if safe != str(name or "") or not safe.endswith(".wav") or not path.is_file():
|
|
451
|
+
return {"op": "play", "ready": False, "error": "playback file is missing"}
|
|
452
|
+
user = audio_user()
|
|
453
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
454
|
+
return {"op": "play", "ready": True, "skipped": True}
|
|
455
|
+
os.chmod(path, 0o644)
|
|
456
|
+
prepared = prepare_playback()
|
|
457
|
+
if prepared.get("skipped"):
|
|
458
|
+
return {"op": "play", "ready": True, "skipped": True}
|
|
459
|
+
if not prepared.get("ready"):
|
|
460
|
+
return {"op": "play", "ready": False, "error": prepared.get("error") or "bluetooth speaker did not become ready"}
|
|
461
|
+
sink = speaker_sink(user, prepared["sink"])
|
|
462
|
+
run(["pactl", "set-sink-volume", sink, "80%"], user=user, timeout=8)
|
|
463
|
+
stop_playback()
|
|
464
|
+
playback_unsettled = False
|
|
465
|
+
proc = spawn(["pw-play", "--target", sink, str(path)], user)
|
|
466
|
+
playback_proc = proc
|
|
467
|
+
for stale in Path(directory).glob("speech-*.wav"):
|
|
468
|
+
if stale.name != safe:
|
|
469
|
+
stale.unlink(missing_ok=True)
|
|
470
|
+
time.sleep(0.2)
|
|
471
|
+
if proc.poll() is not None:
|
|
472
|
+
err = proc.stderr.read().decode(errors="replace")[:200].strip() if proc.stderr else ""
|
|
473
|
+
return {"op": "play", "ready": False, "playing": False, "error": err or "playback ended before it started"}
|
|
474
|
+
# The headset monitor stays digital silence while the earcup plays.
|
|
475
|
+
heard = probe(user, f"{sink}.monitor") >= LIVE_PEAK or (
|
|
476
|
+
sink in (call_sink(user), ECHO_SINK) and proc.poll() is None
|
|
477
|
+
)
|
|
478
|
+
if heard:
|
|
479
|
+
return {"op": "play", "ready": True, "playing": True, "sink": sink}
|
|
480
|
+
stop_playback()
|
|
481
|
+
return {"op": "play", "ready": False, "playing": False, "error": "speaker did not receive audio"}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def open_stream(directory, name, rate=24000, channels=1):
|
|
485
|
+
"""Start the speaker and return once it is waiting for PCM.
|
|
486
|
+
|
|
487
|
+
The caller writes 24 kHz mono s16le into the fifo as each sentence arrives.
|
|
488
|
+
pw-cat reads raw PCM from stdin; giving it the fifo path makes sndfile
|
|
489
|
+
try to parse the stream as a container and exit before any sample plays.
|
|
490
|
+
"""
|
|
491
|
+
global playback_proc, playback_unsettled
|
|
492
|
+
safe = Path(str(name or "")).name
|
|
493
|
+
try:
|
|
494
|
+
rate = int(rate)
|
|
495
|
+
channels = int(channels)
|
|
496
|
+
except (TypeError, ValueError):
|
|
497
|
+
return {"op": "play-stream", "ready": False, "error": "invalid playback format"}
|
|
498
|
+
if safe != str(name or "") or not safe.endswith(".pcm") or rate < 8000 or rate > 96000 or channels != 1:
|
|
499
|
+
return {"op": "play-stream", "ready": False, "error": "invalid playback format"}
|
|
500
|
+
user = audio_user()
|
|
501
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
502
|
+
return {"op": "play-stream", "ready": True, "skipped": True}
|
|
503
|
+
prepared = prepare_playback()
|
|
504
|
+
if prepared.get("skipped"):
|
|
505
|
+
return {"op": "play-stream", "ready": True, "skipped": True}
|
|
506
|
+
if not prepared.get("ready"):
|
|
507
|
+
return {"op": "play-stream", "ready": False, "error": prepared.get("error") or "bluetooth speaker did not become ready"}
|
|
508
|
+
sink = speaker_sink(user, prepared["sink"])
|
|
509
|
+
run(["pactl", "set-sink-volume", sink, "80%"], user=user, timeout=8)
|
|
510
|
+
stop_playback()
|
|
511
|
+
playback_unsettled = False
|
|
512
|
+
path = Path(directory) / safe
|
|
513
|
+
for stale in Path(directory).glob("speech-*.pcm"):
|
|
514
|
+
if stale.name != safe:
|
|
515
|
+
stale.unlink(missing_ok=True)
|
|
516
|
+
if path.exists():
|
|
517
|
+
path.unlink()
|
|
518
|
+
os.mkfifo(path, 0o666)
|
|
519
|
+
os.chmod(path, 0o666)
|
|
520
|
+
proc = spawn([
|
|
521
|
+
"sh", "-c",
|
|
522
|
+
'exec pw-cat --playback --target "$1" --rate "$2" --channels 1 --format s16 --latency 40ms - < "$3"',
|
|
523
|
+
"pw-cat",
|
|
524
|
+
sink,
|
|
525
|
+
str(rate),
|
|
526
|
+
str(path),
|
|
527
|
+
], user)
|
|
528
|
+
playback_proc = proc
|
|
529
|
+
time.sleep(0.2)
|
|
530
|
+
if proc.poll() is not None:
|
|
531
|
+
err = proc.stderr.read().decode(errors="replace")[:200].strip() if proc.stderr else ""
|
|
532
|
+
return {"op": "play-stream", "ready": False, "playing": False, "error": err or "playback ended before it started"}
|
|
533
|
+
return {"op": "play-stream", "ready": True, "playing": True, "sink": sink, "file": safe}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def prepare_playback():
|
|
537
|
+
"""Play on the call profile.
|
|
538
|
+
|
|
539
|
+
The card stays on the microphone profile, so the next recording does not
|
|
540
|
+
reopen the headset link. The call speaker is mSBC, 16 kHz mono.
|
|
541
|
+
"""
|
|
542
|
+
user = audio_user()
|
|
543
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
544
|
+
return {"op": "playback", "ready": True, "skipped": True}
|
|
545
|
+
cards = bluetooth_cards(user)
|
|
546
|
+
if not cards:
|
|
547
|
+
connect_headset()
|
|
548
|
+
cards = bluetooth_cards(user)
|
|
549
|
+
listed = run(["pactl", "list", "cards"], user=user, timeout=8)
|
|
550
|
+
for card in cards_to_switch(cards, active_profiles(listed.stdout)):
|
|
551
|
+
run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
|
|
552
|
+
deadline = time.monotonic() + 8
|
|
553
|
+
while time.monotonic() < deadline:
|
|
554
|
+
sink = call_sink(user)
|
|
555
|
+
if sink:
|
|
556
|
+
sources = bluez_sources(user)
|
|
557
|
+
if sources:
|
|
558
|
+
ensure_echo_cancel(user, sources[0])
|
|
559
|
+
run(["pactl", "set-default-sink", sink], user=user, timeout=8)
|
|
560
|
+
return {"op": "playback", "ready": True, "sink": speaker_sink(user, sink)}
|
|
561
|
+
time.sleep(0.4)
|
|
562
|
+
return {"op": "playback", "ready": False, "error": "bluetooth speaker did not become ready"}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def ensure_wireplumber(user):
|
|
566
|
+
path = Path(user.pw_dir) / ".config/wireplumber/bluetooth.lua.d/80-bluetooth-without-seat.lua"
|
|
567
|
+
current = path.read_text() if path.exists() else ""
|
|
568
|
+
if "hfphsp-backend-native-modem" in current and "enable-msbc" not in current and "with-logind" in current:
|
|
569
|
+
return False
|
|
570
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
571
|
+
path.write_text(WIREPLUMBER_SNIPPET)
|
|
572
|
+
os.chown(path, user.pw_uid, user.pw_gid)
|
|
573
|
+
run(["systemctl", "--user", "restart", "wireplumber"], user=user, timeout=30)
|
|
574
|
+
time.sleep(2)
|
|
575
|
+
return True
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def wideband_ready(settings):
|
|
579
|
+
return bool(settings is not None and settings & POWERED and settings & WIDE)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class SockAddrHCI(ctypes.Structure):
|
|
583
|
+
_fields_ = [
|
|
584
|
+
("hci_family", ctypes.c_ushort),
|
|
585
|
+
("hci_dev", ctypes.c_ushort),
|
|
586
|
+
("hci_channel", ctypes.c_ushort),
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def bind_management(sock):
|
|
591
|
+
"""Open the kernel management channel.
|
|
592
|
+
|
|
593
|
+
Python's socket.bind on an HCI socket only accepts a device index and
|
|
594
|
+
forces the raw channel, so the management socket never comes up and the
|
|
595
|
+
adapter looks missing.
|
|
596
|
+
"""
|
|
597
|
+
addr = SockAddrHCI(AF_BLUETOOTH, HCI_DEV_NONE, HCI_CHANNEL_CONTROL)
|
|
598
|
+
err = ctypes.CDLL(None, use_errno=True).bind(
|
|
599
|
+
sock.fileno(), ctypes.byref(addr), ctypes.sizeof(addr),
|
|
600
|
+
)
|
|
601
|
+
if err != 0:
|
|
602
|
+
code = ctypes.get_errno()
|
|
603
|
+
raise OSError(code, os.strerror(code))
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def bluetooth_audio_plugin():
|
|
607
|
+
return bool(glob.glob("/usr/lib/*/spa-0.2/bluez5/libspa-bluez5.so"))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _mgmt_recv(sock):
|
|
611
|
+
# One recv is one datagram. A short read discards the rest of the message.
|
|
612
|
+
data = sock.recv(4096)
|
|
613
|
+
if len(data) < 6:
|
|
614
|
+
raise OSError("bluetooth management socket closed")
|
|
615
|
+
event, _index, length = struct.unpack_from("<HHH", data)
|
|
616
|
+
body = data[6:6 + length]
|
|
617
|
+
if len(body) < length:
|
|
618
|
+
raise OSError("truncated bluetooth management packet")
|
|
619
|
+
return event, body
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _mgmt_transact(sock, opcode, payload=b""):
|
|
623
|
+
sock.sendall(struct.pack("<HHH", opcode, 0, len(payload)) + payload)
|
|
624
|
+
while True:
|
|
625
|
+
event, body = _mgmt_recv(sock)
|
|
626
|
+
if len(body) < 3:
|
|
627
|
+
continue
|
|
628
|
+
op, status = struct.unpack_from("<HB", body)
|
|
629
|
+
if op != opcode or event not in (EV_COMPLETE, EV_STATUS):
|
|
630
|
+
continue
|
|
631
|
+
if status != 0:
|
|
632
|
+
raise OSError(f"management op {opcode:#06x} failed with status {status:#04x}")
|
|
633
|
+
return body
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def controller_settings():
|
|
637
|
+
try:
|
|
638
|
+
sock = socket.socket(AF_BLUETOOTH, socket.SOCK_RAW, BTPROTO_HCI)
|
|
639
|
+
except OSError:
|
|
640
|
+
return None
|
|
641
|
+
sock.settimeout(8)
|
|
642
|
+
try:
|
|
643
|
+
bind_management(sock)
|
|
644
|
+
body = _mgmt_transact(sock, OP_READ_INFO)
|
|
645
|
+
except (OSError, TimeoutError):
|
|
646
|
+
return None
|
|
647
|
+
finally:
|
|
648
|
+
sock.close()
|
|
649
|
+
if len(body) < 20:
|
|
650
|
+
return None
|
|
651
|
+
return struct.unpack_from("<I", body, 16)[0]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def enable_wideband():
|
|
655
|
+
sock = socket.socket(AF_BLUETOOTH, socket.SOCK_RAW, BTPROTO_HCI)
|
|
656
|
+
sock.settimeout(8)
|
|
657
|
+
try:
|
|
658
|
+
bind_management(sock)
|
|
659
|
+
_mgmt_transact(sock, OP_POWER, bytes([0]))
|
|
660
|
+
_mgmt_transact(sock, OP_WBS, bytes([1]))
|
|
661
|
+
_mgmt_transact(sock, OP_POWER, bytes([1]))
|
|
662
|
+
body = _mgmt_transact(sock, OP_CONNECTABLE, bytes([1]))
|
|
663
|
+
finally:
|
|
664
|
+
sock.close()
|
|
665
|
+
if len(body) < 7:
|
|
666
|
+
return None
|
|
667
|
+
return struct.unpack_from("<I", body, 3)[0]
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def ensure_wideband():
|
|
671
|
+
"""Turn wideband speech on. BlueZ 5.72 forgets it, and the kernel only accepts it while the radio is off."""
|
|
672
|
+
settings = controller_settings()
|
|
673
|
+
if settings is None:
|
|
674
|
+
if os.path.isfile(WIDEBAND):
|
|
675
|
+
run([WIDEBAND], timeout=20)
|
|
676
|
+
return "" if controller_settings() is not None else "bluetooth adapter is not available"
|
|
677
|
+
return "bluetooth adapter is not available"
|
|
678
|
+
if wideband_ready(settings):
|
|
679
|
+
return ""
|
|
680
|
+
try:
|
|
681
|
+
enabled = enable_wideband()
|
|
682
|
+
except (OSError, TimeoutError):
|
|
683
|
+
enabled = None
|
|
684
|
+
if wideband_ready(enabled) or wideband_ready(controller_settings()):
|
|
685
|
+
return ""
|
|
686
|
+
if os.path.isfile(WIDEBAND):
|
|
687
|
+
run([WIDEBAND], timeout=20)
|
|
688
|
+
if wideband_ready(controller_settings()):
|
|
689
|
+
return ""
|
|
690
|
+
return "bluetooth wideband speech is off"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def prepare_audio():
|
|
694
|
+
if not bluetooth_audio_plugin():
|
|
695
|
+
return "bluetooth audio is not available"
|
|
696
|
+
err = ensure_wideband()
|
|
697
|
+
if err:
|
|
698
|
+
return err
|
|
699
|
+
user = audio_user()
|
|
700
|
+
if user is not None and os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
701
|
+
ensure_wireplumber(user)
|
|
702
|
+
return ""
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def connect_headset(timeout=20):
|
|
706
|
+
headsets = paired_headsets()
|
|
707
|
+
if not headsets:
|
|
708
|
+
return ""
|
|
709
|
+
connected = [mac for mac, is_up in headsets if is_up]
|
|
710
|
+
mac = connected[0] if connected else headsets[0][0]
|
|
711
|
+
if mac not in connected:
|
|
712
|
+
run(["bluetoothctl", "connect", mac], timeout=timeout)
|
|
713
|
+
time.sleep(1)
|
|
714
|
+
return mac
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def new_restore():
|
|
718
|
+
return {"phase": "connect", "attempts": 0, "arm_attempts": 0, "next_at": 0.0}
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def restore_step(state, headsets, session_ready, armed, now):
|
|
722
|
+
"""What boot should do next for the trusted headset. No radio I/O here.
|
|
723
|
+
|
|
724
|
+
A connected call sink is done. A link without a desktop session waits.
|
|
725
|
+
Otherwise page the headset a bounded number of times.
|
|
726
|
+
"""
|
|
727
|
+
if state.get("phase") == "done":
|
|
728
|
+
return state, None
|
|
729
|
+
attempts = state.get("attempts", 0)
|
|
730
|
+
arm_attempts = state.get("arm_attempts", 0)
|
|
731
|
+
up = any(connected for _, connected in headsets or [])
|
|
732
|
+
if now < state.get("next_at", 0) and not (up and armed):
|
|
733
|
+
return state, None
|
|
734
|
+
if up and armed:
|
|
735
|
+
return {**state, "phase": "done"}, None
|
|
736
|
+
if up and not session_ready:
|
|
737
|
+
return {**state, "phase": "wait", "next_at": now + 5}, None
|
|
738
|
+
if up:
|
|
739
|
+
if arm_attempts >= ARM_TRIES:
|
|
740
|
+
return {**state, "phase": "done"}, None
|
|
741
|
+
return {
|
|
742
|
+
**state,
|
|
743
|
+
"phase": "arm",
|
|
744
|
+
"arm_attempts": arm_attempts + 1,
|
|
745
|
+
"next_at": now + 2,
|
|
746
|
+
}, "arm"
|
|
747
|
+
if attempts >= RESTORE_TRIES:
|
|
748
|
+
return {**state, "phase": "done"}, None
|
|
749
|
+
# An empty list at startup is bluetoothd still loading, not "no headset".
|
|
750
|
+
if not headsets:
|
|
751
|
+
return {
|
|
752
|
+
**state,
|
|
753
|
+
"phase": "connect",
|
|
754
|
+
"attempts": attempts + 1,
|
|
755
|
+
"next_at": now + RESTORE_GAP_SECONDS,
|
|
756
|
+
}, None
|
|
757
|
+
return {
|
|
758
|
+
**state,
|
|
759
|
+
"phase": "connect",
|
|
760
|
+
"attempts": attempts + 1,
|
|
761
|
+
"next_at": now + RESTORE_GAP_SECONDS,
|
|
762
|
+
}, "connect"
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def tick_restore(state, now=None):
|
|
766
|
+
"""Page a trusted headset that the reboot disconnected. Requests still win."""
|
|
767
|
+
now = time.monotonic() if now is None else now
|
|
768
|
+
if state.get("phase") == "done" or now < state.get("next_at", 0):
|
|
769
|
+
return state
|
|
770
|
+
try:
|
|
771
|
+
headsets = paired_headsets()
|
|
772
|
+
except (OSError, subprocess.SubprocessError):
|
|
773
|
+
return {**state, "next_at": now + RESTORE_GAP_SECONDS}
|
|
774
|
+
user = audio_user()
|
|
775
|
+
session = user is not None and os.path.isdir(f"/run/user/{user.pw_uid}")
|
|
776
|
+
armed = False
|
|
777
|
+
if session:
|
|
778
|
+
try:
|
|
779
|
+
armed = bool(call_sink(user))
|
|
780
|
+
except (OSError, subprocess.SubprocessError):
|
|
781
|
+
armed = False
|
|
782
|
+
state, action = restore_step(state, headsets, session, armed, now)
|
|
783
|
+
try:
|
|
784
|
+
if action == "connect":
|
|
785
|
+
connect_headset(timeout=12)
|
|
786
|
+
elif action == "arm" and user is not None:
|
|
787
|
+
select_headset(user)
|
|
788
|
+
except (OSError, subprocess.SubprocessError):
|
|
789
|
+
pass
|
|
790
|
+
return state
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def active_profiles(text):
|
|
794
|
+
found = {}
|
|
795
|
+
name = ""
|
|
796
|
+
for raw in str(text or "").splitlines():
|
|
797
|
+
line = raw.strip()
|
|
798
|
+
if line.startswith("Name:"):
|
|
799
|
+
name = line.split(":", 1)[1].strip()
|
|
800
|
+
elif name and line.startswith("Active Profile:"):
|
|
801
|
+
found[name] = line.split(":", 1)[1].strip()
|
|
802
|
+
name = ""
|
|
803
|
+
return found
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def cards_to_switch(cards, profiles):
|
|
807
|
+
return [card for card in cards if profiles.get(card) != HEADSET_PROFILE]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def arm_headset(user):
|
|
811
|
+
"""Return the headset input, switching off the speaker profile only when needed.
|
|
812
|
+
|
|
813
|
+
The profile change is the whole wait. A silence probe is for a cold connect.
|
|
814
|
+
"""
|
|
815
|
+
cards = bluetooth_cards(user)
|
|
816
|
+
if not cards:
|
|
817
|
+
return ""
|
|
818
|
+
listed = run(["pactl", "list", "cards"], user=user, timeout=8)
|
|
819
|
+
for card in cards_to_switch(cards, active_profiles(listed.stdout)):
|
|
820
|
+
run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
|
|
821
|
+
deadline = time.monotonic() + PROFILE_WAIT_SECONDS
|
|
822
|
+
while True:
|
|
823
|
+
sources = bluez_sources(user)
|
|
824
|
+
if sources:
|
|
825
|
+
source = sources[0]
|
|
826
|
+
run(["pactl", "set-default-source", source], user=user, timeout=8)
|
|
827
|
+
return source
|
|
828
|
+
if time.monotonic() >= deadline:
|
|
829
|
+
return ""
|
|
830
|
+
time.sleep(0.2)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def select_profile(user, source):
|
|
834
|
+
if not source:
|
|
835
|
+
return
|
|
836
|
+
run(["pactl", "set-card-profile", card_for_source(source), HEADSET_PROFILE], user=user, timeout=8)
|
|
837
|
+
run(["pactl", "set-default-source", source], user=user, timeout=8)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def select_headset(user):
|
|
841
|
+
for card in bluetooth_cards(user):
|
|
842
|
+
run(["pactl", "set-card-profile", card, HEADSET_PROFILE], user=user, timeout=8)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def wait_until_live(user, seconds):
|
|
846
|
+
deadline = time.monotonic() + seconds
|
|
847
|
+
while time.monotonic() < deadline:
|
|
848
|
+
source = live_source(user)
|
|
849
|
+
if source:
|
|
850
|
+
select_profile(user, source)
|
|
851
|
+
return source
|
|
852
|
+
time.sleep(1)
|
|
853
|
+
return ""
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def ensure():
|
|
857
|
+
interrupt_playback()
|
|
858
|
+
user = audio_user()
|
|
859
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
860
|
+
return {"ready": True, "skipped": True, "reason": "no desktop audio session"}
|
|
861
|
+
source = arm_headset(user)
|
|
862
|
+
if source:
|
|
863
|
+
return capture_ready(user, source)
|
|
864
|
+
ensure_wideband()
|
|
865
|
+
ensure_wireplumber(user)
|
|
866
|
+
connect_headset()
|
|
867
|
+
select_headset(user)
|
|
868
|
+
source = wait_until_live(user, POLL_SECONDS)
|
|
869
|
+
if source:
|
|
870
|
+
return capture_ready(user, source)
|
|
871
|
+
# The first codec answer is often dropped. Reconnecting gives the retry a clean link.
|
|
872
|
+
headsets = paired_headsets()
|
|
873
|
+
if headsets:
|
|
874
|
+
mac = headsets[0][0]
|
|
875
|
+
run(["bluetoothctl", "disconnect", mac], timeout=15)
|
|
876
|
+
time.sleep(1)
|
|
877
|
+
run(["bluetoothctl", "connect", mac], timeout=20)
|
|
878
|
+
time.sleep(1)
|
|
879
|
+
select_headset(user)
|
|
880
|
+
source = wait_until_live(user, POLL_SECONDS)
|
|
881
|
+
if source:
|
|
882
|
+
return capture_ready(user, source)
|
|
883
|
+
if not headsets and not bluez_sources(user):
|
|
884
|
+
return {"ready": True, "skipped": True, "reason": "no bluetooth headset"}
|
|
885
|
+
return {"ready": False, "error": "bluetooth headset is not capturing"}
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def valid_address(address):
|
|
889
|
+
return bool(ADDRESS.fullmatch(str(address or "").strip().upper()))
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def parse_scan(text):
|
|
893
|
+
found = {}
|
|
894
|
+
order = []
|
|
895
|
+
for raw in str(text or "").splitlines():
|
|
896
|
+
match = DEVICE_LINE.search(ANSI.sub("", raw).strip())
|
|
897
|
+
if not match:
|
|
898
|
+
continue
|
|
899
|
+
mac = match.group(1).upper()
|
|
900
|
+
rest = (match.group(2) or "").strip()
|
|
901
|
+
if mac not in found:
|
|
902
|
+
order.append(mac)
|
|
903
|
+
found[mac] = {"name": "", "alias": ""}
|
|
904
|
+
entry = found[mac]
|
|
905
|
+
if rest.startswith("Alias:"):
|
|
906
|
+
entry["alias"] = rest.split(":", 1)[1].strip()
|
|
907
|
+
elif rest.startswith("Name:"):
|
|
908
|
+
entry["name"] = rest.split(":", 1)[1].strip()
|
|
909
|
+
elif rest and ":" not in rest.split(" ", 1)[0] and not entry["name"]:
|
|
910
|
+
entry["name"] = rest
|
|
911
|
+
return [{"address": mac, "name": found[mac]["alias"] or found[mac]["name"]} for mac in order]
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def device_name(text):
|
|
915
|
+
alias = ""
|
|
916
|
+
name = ""
|
|
917
|
+
for line in str(text or "").splitlines():
|
|
918
|
+
stripped = line.strip()
|
|
919
|
+
if stripped.startswith("Alias:"):
|
|
920
|
+
alias = stripped.split(":", 1)[1].strip()
|
|
921
|
+
elif stripped.startswith("Name:"):
|
|
922
|
+
name = stripped.split(":", 1)[1].strip()
|
|
923
|
+
return alias or name
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def known_addresses(text):
|
|
927
|
+
found = []
|
|
928
|
+
for line in ANSI.sub("", str(text or "")).splitlines():
|
|
929
|
+
parts = line.split()
|
|
930
|
+
if len(parts) >= 2 and parts[0] == "Device" and valid_address(parts[1]):
|
|
931
|
+
mac = parts[1].upper()
|
|
932
|
+
if mac not in found:
|
|
933
|
+
found.append(mac)
|
|
934
|
+
return found
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def address_named(address, name):
|
|
938
|
+
shown = re.sub(r"[^0-9A-F]", "", str(name or "").upper())
|
|
939
|
+
return bool(shown) and shown == re.sub(r"[^0-9A-F]", "", str(address or "").upper())
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def is_voice_device(text):
|
|
943
|
+
"""A headset. Phones and computers also advertise the headset UUIDs."""
|
|
944
|
+
plain = ANSI.sub("", str(text or ""))
|
|
945
|
+
icon = ""
|
|
946
|
+
for line in plain.splitlines():
|
|
947
|
+
stripped = line.strip()
|
|
948
|
+
if stripped.startswith("Icon:"):
|
|
949
|
+
icon = stripped.split(":", 1)[1].strip()
|
|
950
|
+
break
|
|
951
|
+
if icon in ("computer", "phone", "video-display"):
|
|
952
|
+
return False
|
|
953
|
+
if icon in ("audio-headset", "audio-headphones"):
|
|
954
|
+
return True
|
|
955
|
+
match = re.search(r"Class:\s+0x([0-9A-Fa-f]+)", plain)
|
|
956
|
+
if not match:
|
|
957
|
+
return False
|
|
958
|
+
value = int(match.group(1), 16)
|
|
959
|
+
major = (value >> 8) & 0x1F
|
|
960
|
+
minor = (value >> 2) & 0x3F
|
|
961
|
+
return major == 0x04 and minor in {0x01, 0x02, 0x04, 0x06, 0x07}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def scan_candidates(scanned, known):
|
|
965
|
+
"""Paired devices first, then named advertisers. An address-shaped name is not a device."""
|
|
966
|
+
names = {}
|
|
967
|
+
order = []
|
|
968
|
+
for mac in known or []:
|
|
969
|
+
mac = str(mac or "").upper()
|
|
970
|
+
if not valid_address(mac) or mac in names:
|
|
971
|
+
continue
|
|
972
|
+
names[mac] = ""
|
|
973
|
+
order.append(mac)
|
|
974
|
+
for item in scanned or []:
|
|
975
|
+
mac = str(item.get("address") or "").upper()
|
|
976
|
+
if not valid_address(mac) or mac in names:
|
|
977
|
+
continue
|
|
978
|
+
name = item.get("name") or ""
|
|
979
|
+
if address_named(mac, name):
|
|
980
|
+
continue
|
|
981
|
+
names[mac] = name
|
|
982
|
+
order.append(mac)
|
|
983
|
+
return [{"address": mac, "name": names[mac]} for mac in order]
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def voice_devices(candidates):
|
|
987
|
+
"""Voice devices only. A connected headset stays even if it did not advertise,
|
|
988
|
+
and a second address with the same name does not show up as disconnected.
|
|
989
|
+
"""
|
|
990
|
+
found = []
|
|
991
|
+
for item in candidates or []:
|
|
992
|
+
info = ANSI.sub("", str(item.get("info") or ""))
|
|
993
|
+
if not is_voice_device(info):
|
|
994
|
+
continue
|
|
995
|
+
mac = str(item.get("address") or "").upper()
|
|
996
|
+
if not valid_address(mac):
|
|
997
|
+
continue
|
|
998
|
+
name = device_name(info) or item.get("name") or mac
|
|
999
|
+
if address_named(mac, name):
|
|
1000
|
+
continue
|
|
1001
|
+
found.append({
|
|
1002
|
+
"address": mac,
|
|
1003
|
+
"name": name,
|
|
1004
|
+
"paired": "Paired: yes" in info,
|
|
1005
|
+
"connected": "Connected: yes" in info,
|
|
1006
|
+
})
|
|
1007
|
+
connected_names = {item["name"] for item in found if item["connected"]}
|
|
1008
|
+
kept = [
|
|
1009
|
+
item for item in found
|
|
1010
|
+
if item["connected"] or item["name"] not in connected_names
|
|
1011
|
+
]
|
|
1012
|
+
kept.sort(key=lambda item: not item["connected"])
|
|
1013
|
+
return kept[:MAX_DEVICES]
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def source_for_address(user, mac):
|
|
1017
|
+
token = mac.replace(":", "_")
|
|
1018
|
+
for source in bluez_sources(user):
|
|
1019
|
+
if token in source:
|
|
1020
|
+
return source
|
|
1021
|
+
return ""
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def wait_for_source(user, mac, seconds):
|
|
1025
|
+
deadline = time.monotonic() + seconds
|
|
1026
|
+
while time.monotonic() < deadline:
|
|
1027
|
+
source = source_for_address(user, mac)
|
|
1028
|
+
if source:
|
|
1029
|
+
select_profile(user, source)
|
|
1030
|
+
return source
|
|
1031
|
+
time.sleep(1)
|
|
1032
|
+
return ""
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def activate_capture(mac):
|
|
1036
|
+
user = audio_user()
|
|
1037
|
+
if user is None or not os.path.isdir(f"/run/user/{user.pw_uid}"):
|
|
1038
|
+
return "no desktop audio session"
|
|
1039
|
+
if wait_for_source(user, mac, POLL_SECONDS):
|
|
1040
|
+
return ""
|
|
1041
|
+
run(["bluetoothctl", "disconnect", mac], timeout=15)
|
|
1042
|
+
time.sleep(1)
|
|
1043
|
+
run(["bluetoothctl", "connect", mac], timeout=20)
|
|
1044
|
+
time.sleep(1)
|
|
1045
|
+
if wait_for_source(user, mac, POLL_SECONDS):
|
|
1046
|
+
return ""
|
|
1047
|
+
return "bluetooth headset is not capturing"
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def scan_nearby():
|
|
1051
|
+
err = prepare_audio()
|
|
1052
|
+
if err:
|
|
1053
|
+
return {"op": "scan", "devices": [], "error": err, "code": "voice_bluetooth_unavailable"}
|
|
1054
|
+
try:
|
|
1055
|
+
run(["bluetoothctl", "power", "on"], timeout=8)
|
|
1056
|
+
except subprocess.TimeoutExpired:
|
|
1057
|
+
pass
|
|
1058
|
+
except FileNotFoundError:
|
|
1059
|
+
return {"op": "scan", "devices": [], "error": "bluetoothctl is not installed"}
|
|
1060
|
+
try:
|
|
1061
|
+
listed = run(
|
|
1062
|
+
["bluetoothctl", "--timeout", str(SCAN_SECONDS), "scan", "on"],
|
|
1063
|
+
timeout=SCAN_SECONDS + 8,
|
|
1064
|
+
)
|
|
1065
|
+
text = f"{listed.stdout or ''}\n{listed.stderr or ''}"
|
|
1066
|
+
except subprocess.TimeoutExpired as err:
|
|
1067
|
+
text = f"{err.stdout or ''}\n{err.stderr or ''}"
|
|
1068
|
+
except FileNotFoundError:
|
|
1069
|
+
return {"op": "scan", "devices": [], "error": "bluetoothctl is not installed"}
|
|
1070
|
+
known = known_addresses(run(["bluetoothctl", "devices"], timeout=8).stdout)
|
|
1071
|
+
candidates = scan_candidates(parse_scan(text), known)
|
|
1072
|
+
detailed = []
|
|
1073
|
+
for item in candidates:
|
|
1074
|
+
info = run(["bluetoothctl", "info", item["address"]], timeout=8)
|
|
1075
|
+
detailed.append({**item, "info": info.stdout})
|
|
1076
|
+
return {"op": "scan", "devices": voice_devices(detailed)}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def connect_address(address):
|
|
1080
|
+
mac = str(address or "").strip().upper()
|
|
1081
|
+
if not valid_address(mac):
|
|
1082
|
+
return {"op": "connect", "connected": False, "error": "invalid bluetooth address"}
|
|
1083
|
+
err = prepare_audio()
|
|
1084
|
+
if err:
|
|
1085
|
+
return {"op": "connect", "address": mac, "connected": False, "error": err, "code": "voice_bluetooth_unavailable"}
|
|
1086
|
+
try:
|
|
1087
|
+
run(["bluetoothctl", "power", "on"], timeout=8)
|
|
1088
|
+
except subprocess.TimeoutExpired:
|
|
1089
|
+
pass
|
|
1090
|
+
except FileNotFoundError:
|
|
1091
|
+
return {"op": "connect", "address": mac, "connected": False, "error": "bluetoothctl is not installed"}
|
|
1092
|
+
info = run(["bluetoothctl", "info", mac], timeout=8)
|
|
1093
|
+
if "Paired: yes" not in info.stdout:
|
|
1094
|
+
run(
|
|
1095
|
+
["bluetoothctl", "--agent", "NoInputNoOutput", "--timeout", "30", "pair", mac],
|
|
1096
|
+
timeout=35,
|
|
1097
|
+
)
|
|
1098
|
+
run(["bluetoothctl", "trust", mac], timeout=10)
|
|
1099
|
+
run(["bluetoothctl", "--timeout", "20", "connect", mac], timeout=25)
|
|
1100
|
+
info = run(["bluetoothctl", "info", mac], timeout=8)
|
|
1101
|
+
connected = "Connected: yes" in info.stdout
|
|
1102
|
+
result = {
|
|
1103
|
+
"op": "connect",
|
|
1104
|
+
"address": mac,
|
|
1105
|
+
"name": device_name(info.stdout) or mac,
|
|
1106
|
+
"connected": connected,
|
|
1107
|
+
}
|
|
1108
|
+
if not connected:
|
|
1109
|
+
result["error"] = "bluetooth connect failed"
|
|
1110
|
+
return result
|
|
1111
|
+
err = activate_capture(mac)
|
|
1112
|
+
if err:
|
|
1113
|
+
result["connected"] = False
|
|
1114
|
+
result["error"] = err
|
|
1115
|
+
return result
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def dispatch(payload, directory):
|
|
1119
|
+
op = payload.get("op") or "ensure"
|
|
1120
|
+
if op == "scan":
|
|
1121
|
+
return scan_nearby()
|
|
1122
|
+
if op == "connect":
|
|
1123
|
+
return connect_address(payload.get("address"))
|
|
1124
|
+
if op == "ensure":
|
|
1125
|
+
outcome = ensure()
|
|
1126
|
+
outcome["op"] = "ensure"
|
|
1127
|
+
return outcome
|
|
1128
|
+
if op == "playback":
|
|
1129
|
+
return prepare_playback()
|
|
1130
|
+
if op == "play":
|
|
1131
|
+
return play_file(directory, payload.get("file"))
|
|
1132
|
+
if op == "play-stream":
|
|
1133
|
+
return open_stream(directory, payload.get("file"), payload.get("rate"), payload.get("channels"))
|
|
1134
|
+
if op == "playback-stop":
|
|
1135
|
+
return stop_playback_request()
|
|
1136
|
+
if op == "capture-start":
|
|
1137
|
+
return begin_capture(directory)
|
|
1138
|
+
if op == "capture-stop":
|
|
1139
|
+
return end_capture()
|
|
1140
|
+
if op == "capture-reset":
|
|
1141
|
+
return reset_capture()
|
|
1142
|
+
return {"op": op, "error": "unknown bluetooth operation"}
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def serve(directory):
|
|
1146
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1147
|
+
os.chmod(directory, 0o777)
|
|
1148
|
+
agent = directory / "agent.json"
|
|
1149
|
+
request = directory / "request.json"
|
|
1150
|
+
result = directory / "result.json"
|
|
1151
|
+
restore = new_restore()
|
|
1152
|
+
while True:
|
|
1153
|
+
agent.write_text(json.dumps({"pid": os.getpid(), "at": time.time()}))
|
|
1154
|
+
if request.exists():
|
|
1155
|
+
try:
|
|
1156
|
+
payload = json.loads(request.read_text() or "{}")
|
|
1157
|
+
except json.JSONDecodeError:
|
|
1158
|
+
payload = {}
|
|
1159
|
+
request.unlink(missing_ok=True)
|
|
1160
|
+
try:
|
|
1161
|
+
outcome = dispatch(payload, directory)
|
|
1162
|
+
except Exception as err:
|
|
1163
|
+
outcome = {
|
|
1164
|
+
"op": payload.get("op") or "ensure",
|
|
1165
|
+
"ready": False,
|
|
1166
|
+
"connected": False,
|
|
1167
|
+
"error": str(err),
|
|
1168
|
+
}
|
|
1169
|
+
outcome["id"] = payload.get("id", "")
|
|
1170
|
+
result.write_text(json.dumps(outcome))
|
|
1171
|
+
os.chmod(result, 0o644)
|
|
1172
|
+
else:
|
|
1173
|
+
restore = tick_restore(restore)
|
|
1174
|
+
time.sleep(0.2)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def main():
|
|
1178
|
+
command = sys.argv[1] if len(sys.argv) > 1 else "ensure"
|
|
1179
|
+
if command == "serve":
|
|
1180
|
+
raw = os.environ.get("LARES_VOICE_BLUETOOTH_DIR", "")
|
|
1181
|
+
if not raw:
|
|
1182
|
+
raise SystemExit("LARES_VOICE_BLUETOOTH_DIR is required")
|
|
1183
|
+
serve(Path(raw))
|
|
1184
|
+
return
|
|
1185
|
+
if command == "wideband":
|
|
1186
|
+
err = ensure_wideband()
|
|
1187
|
+
if err:
|
|
1188
|
+
raise SystemExit(err)
|
|
1189
|
+
return
|
|
1190
|
+
if command != "ensure":
|
|
1191
|
+
raise SystemExit(f"unknown command {command}")
|
|
1192
|
+
print(json.dumps(ensure()))
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
if __name__ == "__main__":
|
|
1196
|
+
main()
|