Istanza unica (lock), avvio automatico tramite LuZa.app, elenco dispositivi audio immediato su macOS, riuso delle porte del server remoto

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5.1 committed 2026-09-30 10:09:55 +02:00
1 parent 094807ffe8
commit bea863e011
4 files changed
+458 -426

No files matched your search

+3 -3
View File
@@ -175,11 +175,11 @@ class RemoteServer:
runner = web.AppRunner(app) runner = web.AppRunner(app)
self._loop.run_until_complete(runner.setup()) self._loop.run_until_complete(runner.setup())
try: try:
self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTPS_PORT, ssl_context=ctx).start()) self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTPS_PORT, ssl_context=ctx, reuse_address=True).start())
self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTP_PORT).start()) # solo CA e reindirizzo self._loop.run_until_complete(web.TCPSite(runner, "0.0.0.0", HTTP_PORT, reuse_address=True).start()) # solo CA e reindirizzo
except Exception as err: except Exception as err:
self.error = str(err) self.error = str(err)
self.log(f"ERR: Accesso remoto — porta occupata? {err}") self.log(f"ERR: Accesso remoto non avviato: porta {HTTPS_PORT} occupata (c'è un'altra LuZa aperta?). {err}")
return return
self.log(f"SYS: Accesso remoto pronto su https://{self.ip}:{HTTPS_PORT} (CA: http://{self.ip}:{HTTP_PORT}/ca.crt)") self.log(f"SYS: Accesso remoto pronto su https://{self.ip}:{HTTPS_PORT} (CA: http://{self.ip}:{HTTP_PORT}/ca.crt)")
try: try:
+424 -421
View File
@@ -1,421 +1,424 @@
""" """
core/audio_devices.py — pick which microphone and which speakers JARVIS uses. core/audio_devices.py — pick which microphone and which speakers JARVIS uses.
WHY WHY
Both audio streams in main.py were opened without a `device=` argument, so Both audio streams in main.py were opened without a `device=` argument, so
they always took whatever the operating system called "default". On a laptop they always took whatever the operating system called "default". On a laptop
with a built-in mic, a webcam mic and a headset that is a coin toss — and on with a built-in mic, a webcam mic and a headset that is a coin toss — and on
Windows the default *moves on its own* the moment you plug a headset in. Windows the default *moves on its own* the moment you plug a headset in.
"JARVIS can't hear me" almost always means "JARVIS is listening to the "JARVIS can't hear me" almost always means "JARVIS is listening to the
monitor's microphone". monitor's microphone".
WHY NAMES, NOT INDICES WHY NAMES, NOT INDICES
sounddevice identifies devices by integer index, and those indices shift sounddevice identifies devices by integer index, and those indices shift
whenever a device appears or disappears. Storing index 3 means that after whenever a device appears or disappears. Storing index 3 means that after
unplugging a USB interface the saved setting silently points at something unplugging a USB interface the saved setting silently points at something
else. We store the device *name* and resolve it to an index at open time. else. We store the device *name* and resolve it to an index at open time.
WHY THIS IS CACHED WHY THIS IS CACHED
`sd.query_devices()` talks to the host audio API and can take a few hundred `sd.query_devices()` talks to the host audio API and can take a few hundred
milliseconds on a Windows machine with many endpoints. Mark LV learned this milliseconds on a Windows machine with many endpoints. Mark LV learned this
lesson the expensive way — a 2.1-second `openwakeword` import on the Qt lesson the expensive way — a 2.1-second `openwakeword` import on the Qt
thread made the settings drawer look like it was broken. So the list is thread made the settings drawer look like it was broken. So the list is
fetched once on a background thread at startup and served from cache. fetched once on a background thread at startup and served from cache.
""" """
from __future__ import annotations from __future__ import annotations
import threading import threading
import time import time
# The label shown for "let the OS decide", and the value stored in config for # The label shown for "let the OS decide", and the value stored in config for
# it. Empty string, so an untouched install and a deliberately-default install # it. Empty string, so an untouched install and a deliberately-default install
# are the same thing — nothing changes for anyone who never opens the picker. # are the same thing — nothing changes for anyone who never opens the picker.
DEFAULT_LABEL = "System default" DEFAULT_LABEL = "System default"
DEFAULT_VALUE = "" DEFAULT_VALUE = ""
_cache: dict[str, list[str]] | None = None _cache: dict[str, list[str]] | None = None
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
# Which host API each direction settled on, so resolve() opens the same endpoint # Which host API each direction settled on, so resolve() opens the same endpoint
# the picker listed. Filled in by _query(). # the picker listed. Filled in by _query().
_chosen_api: dict = {"input": None, "output": None} _chosen_api: dict = {"input": None, "output": None}
# ── Why the raw list is unusable, and what is filtered out ─────────────────── # ── Why the raw list is unusable, and what is filtered out ───────────────────
# #
# `sd.query_devices()` returns one entry per (device × host API), not one per # `sd.query_devices()` returns one entry per (device × host API), not one per
# device. Measured on a normal Windows machine: 41 entries for what the Windows # device. Measured on a normal Windows machine: 41 entries for what the Windows
# sound settings show as 4 microphones and 4 speakers. The same Realtek # sound settings show as 4 microphones and 4 speakers. The same Realtek
# microphone appears four times — once each under MME, DirectSound, WASAPI and # microphone appears four times — once each under MME, DirectSound, WASAPI and
# WDM-KS — and none of the four is labelled to say which is which. # WDM-KS — and none of the four is labelled to say which is which.
# #
# Handing that to a person is not a choice, it is a quiz. So the list is reduced # Handing that to a person is not a choice, it is a quiz. So the list is reduced
# the way the operating system's own settings panel does it: # the way the operating system's own settings panel does it:
# #
# 1. ONE host API per direction — chosen by measurement, not by reasoning. # 1. ONE host API per direction — chosen by measurement, not by reasoning.
# See the preference note below. # See the preference note below.
# 2. No pseudo-devices. "Microsoft Sound Mapper", "Primary Sound Driver", # 2. No pseudo-devices. "Microsoft Sound Mapper", "Primary Sound Driver",
# ALSA's "default"/"sysdefault"/"dmix" are aliases for "whatever the OS # ALSA's "default"/"sysdefault"/"dmix" are aliases for "whatever the OS
# picks" — which is precisely the "System default" entry already at the top # picks" — which is precisely the "System default" entry already at the top
# of the list. Offering them again as if they were hardware is noise. # of the list. Offering them again as if they were hardware is noise.
# 3. No zero-channel or unnamed entries (WASAPI reports one of each). # 3. No zero-channel or unnamed entries (WASAPI reports one of each).
# 4. Deduplicated by name. # 4. Deduplicated by name.
# #
# Nothing is hidden that a person could actually want: the same hardware is # Nothing is hidden that a person could actually want: the same hardware is
# still there, listed once, under the name their operating system uses for it. # still there, listed once, under the name their operating system uses for it.
# ── Preference order, which is only a starting point ───────────────────────── # ── Preference order, which is only a starting point ─────────────────────────
# #
# Two earlier versions of this file were wrong in the same way: they decided # Two earlier versions of this file were wrong in the same way: they decided
# which host API to use by reasoning about it instead of measuring it. # which host API to use by reasoning about it instead of measuring it.
# #
# v1 ranked APIs by how clean their device names were and picked WASAPI. # v1 ranked APIs by how clean their device names were and picked WASAPI.
# WASAPI in shared mode does not resample — the hardware runs at 48 kHz, # WASAPI in shared mode does not resample — the hardware runs at 48 kHz,
# this app streams 16 kHz in and 24 kHz out, and every open failed with # this app streams 16 kHz in and 24 kHz out, and every open failed with
# "Invalid sample rate". The picker looked right and did nothing. # "Invalid sample rate". The picker looked right and did nothing.
# #
# v2 added a rate check and picked DirectSound, which passes that check on # v2 added a rate check and picked DirectSound, which passes that check on
# both sides. PortAudio's DirectSound *output* is a silent sink: the # both sides. PortAudio's DirectSound *output* is a silent sink: the
# stream opens, every write returns success in ~0 ms, and nothing is ever # stream opens, every write returns success in ~0 ms, and nothing is ever
# heard. Same failure, one layer deeper. # heard. Same failure, one layer deeper.
# #
# So this list is a preference, not a promise. Which API actually gets used is # So this list is a preference, not a promise. Which API actually gets used is
# decided below by _usable() (open it for real) and _transport_works() (does # decided below by _usable() (open it for real) and _transport_works() (does
# audio actually move), per direction. On this platform that lands on # audio actually move), per direction. On this platform that lands on
# DirectSound for the microphone and MME for the speakers — a split that no # DirectSound for the microphone and MME for the speakers — a split that no
# amount of reasoning would have produced. # amount of reasoning would have produced.
_PREFERRED_APIS = { _PREFERRED_APIS = {
"Windows": ("directsound", "mme", "wasapi"), "Windows": ("directsound", "mme", "wasapi"),
# macOS has only Core Audio, so there is nothing to disambiguate. # macOS has only Core Audio, so there is nothing to disambiguate.
"Darwin": ("core audio",), "Darwin": ("core audio",),
# PulseAudio/PipeWire present one clean endpoint per device; raw ALSA # PulseAudio/PipeWire present one clean endpoint per device; raw ALSA
# presents dozens of routing permutations of the same card. # presents dozens of routing permutations of the same card.
"Linux": ("pulse", "pipewire", "jack", "alsa"), "Linux": ("pulse", "pipewire", "jack", "alsa"),
} }
# ── "It opens" is not "it works" ───────────────────────────────────────────── # ── "It opens" is not "it works" ─────────────────────────────────────────────
# #
# Opening a stream successfully proves nothing. Measured, writing 2.0 s of audio: # Opening a stream successfully proves nothing. Measured, writing 2.0 s of audio:
# #
# device=None (MME) 2.02 s consumed in real time # device=None (MME) 2.02 s consumed in real time
# DirectSound, any output device 0.00 s swallowed instantly # DirectSound, any output device 0.00 s swallowed instantly
# #
# No flag or capability field reports this. The only thing that separates a real # No flag or capability field reports this. The only thing that separates a real
# sink from a fake one is whether it consumes audio at the rate audio is # sink from a fake one is whether it consumes audio at the rate audio is
# consumed at — so that is what gets measured, once per host API per direction, # consumed at — so that is what gets measured, once per host API per direction,
# on the background thread at startup, using silence. # on the background thread at startup, using silence.
# #
# Each direction is probed **the way main.py actually uses it**. That is not a # Each direction is probed **the way main.py actually uses it**. That is not a
# detail: DirectSound input passes a callback stream and fails a blocking read, # detail: DirectSound input passes a callback stream and fails a blocking read,
# so an earlier version of this probe rejected a microphone that works perfectly # so an earlier version of this probe rejected a microphone that works perfectly
# in the app. Probe the mode you ship, not the mode that is easier to write. # in the app. Probe the mode you ship, not the mode that is easier to write.
_PROBE_SECONDS = {"output": 0.6, "input": 0.35} _PROBE_SECONDS = {"output": 0.6, "input": 0.35}
# Cache: {(api_name_or_None, kind): bool} # Cache: {(api_name_or_None, kind): bool}
_probe_results: dict = {} _probe_results: dict = {}
def _transport_works(idx: int, kind: str, api_key) -> bool: def _transport_works(idx: int, kind: str, api_key) -> bool:
"""Does this host API actually move audio, or only pretend to? """Does this host API actually move audio, or only pretend to?
Probed once per API per direction and cached. Output writes silence, so the Probed once per API per direction and cached. Output writes silence, so the
probe is inaudible; input reads and discards.""" probe is inaudible; input reads and discards."""
if api_key in _probe_results: if api_key in _probe_results:
return _probe_results[api_key] return _probe_results[api_key]
ok = False ok = False
try: try:
import sounddevice as sd import sounddevice as sd
rate = _RATES.get(kind, 16000) rate = _RATES.get(kind, 16000)
secs = _PROBE_SECONDS.get(kind, 0.5) secs = _PROBE_SECONDS.get(kind, 0.5)
# Each direction is probed the way main.py actually uses it. That is not # Each direction is probed the way main.py actually uses it. That is not
# a detail: DirectSound input passes a callback stream and fails a # a detail: DirectSound input passes a callback stream and fails a
# blocking read, so probing the wrong mode rejected a microphone that # blocking read, so probing the wrong mode rejected a microphone that
# works perfectly in the app. # works perfectly in the app.
if kind == "output": if kind == "output":
# main.py writes with stream.write() — a real sink is rate-limited # main.py writes with stream.write() — a real sink is rate-limited
# by the hardware clock, a fake one swallows the buffer instantly. # by the hardware clock, a fake one swallows the buffer instantly.
st = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16", st = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16",
blocksize=1024, device=idx) blocksize=1024, device=idx)
st.start() st.start()
t0 = time.monotonic() t0 = time.monotonic()
st.write(bytes(int(rate * secs) * 2)) # silence — inaudible st.write(bytes(int(rate * secs) * 2)) # silence — inaudible
elapsed = time.monotonic() - t0 elapsed = time.monotonic() - t0
st.stop(); st.close() st.stop(); st.close()
ok = elapsed > secs * 0.5 ok = elapsed > secs * 0.5
if not ok: if not ok:
print(f"[Audio] output: host API reports success but moves no " print(f"[Audio] output: host API reports success but moves no "
f"audio ({elapsed*1000:.0f} ms for {secs*1000:.0f} ms) " f"audio ({elapsed*1000:.0f} ms for {secs*1000:.0f} ms) "
f"— skipping it") f"— skipping it")
else: else:
# main.py reads through a callback — count what arrives. # main.py reads through a callback — count what arrives.
frames = [0] frames = [0]
def _cb(indata, n, *_a): def _cb(indata, n, *_a):
frames[0] += n frames[0] += n
st = sd.InputStream(samplerate=rate, channels=1, dtype="int16", st = sd.InputStream(samplerate=rate, channels=1, dtype="int16",
blocksize=1024, device=idx, callback=_cb) blocksize=1024, device=idx, callback=_cb)
st.start() st.start()
time.sleep(secs) time.sleep(secs)
st.stop(); st.close() st.stop(); st.close()
ok = frames[0] > rate * secs * 0.3 ok = frames[0] > rate * secs * 0.3
if not ok: if not ok:
print(f"[Audio] input: host API delivered {frames[0]} frames in " print(f"[Audio] input: host API delivered {frames[0]} frames in "
f"{secs*1000:.0f} ms — skipping it") f"{secs*1000:.0f} ms — skipping it")
except Exception as e: except Exception as e:
print(f"[Audio] {kind} transport probe failed: {e}") print(f"[Audio] {kind} transport probe failed: {e}")
ok = False ok = False
_probe_results[api_key] = ok _probe_results[api_key] = ok
return ok return ok
def _display_name(name: str, devices) -> str: def _display_name(name: str, devices) -> str:
"""MME truncates device names to 31 characters, so the API that actually """MME truncates device names to 31 characters, so the API that actually
carries the audio may not be the one that can spell. If another host API carries the audio may not be the one that can spell. If another host API
knows a longer name that starts with this one, show that instead — the user knows a longer name that starts with this one, show that instead — the user
reads 'Realtek HD Audio 2nd output (Realtek(R) Audio)' while the stream runs reads 'Realtek HD Audio 2nd output (Realtek(R) Audio)' while the stream runs
on the endpoint called 'Realtek HD Audio 2nd output (Re'.""" on the endpoint called 'Realtek HD Audio 2nd output (Re'."""
if len(name) < 30: if len(name) < 30:
return name return name
best = name best = name
for dev in devices: for dev in devices:
other = (dev.get("name") or "").strip() other = (dev.get("name") or "").strip()
if len(other) > len(best) and other.startswith(name): if len(other) > len(best) and other.startswith(name):
best = other best = other
return best return best
# The rates the app opens its streams at. Defaults match main.py; main.py calls # The rates the app opens its streams at. Defaults match main.py; main.py calls
# configure() at startup with its own constants so the two can never drift apart # configure() at startup with its own constants so the two can never drift apart
# and silently reintroduce the bug above. # and silently reintroduce the bug above.
_RATES = {"input": 16000, "output": 24000} _RATES = {"input": 16000, "output": 24000}
def configure(input_rate: int, output_rate: int) -> None: def configure(input_rate: int, output_rate: int) -> None:
"""Tell this module the sample rates the audio streams will use, so the """Tell this module the sample rates the audio streams will use, so the
picker can rule out devices that cannot be opened at them. picker can rule out devices that cannot be opened at them.
Drops any cached list: which devices are usable depends on the rate, so a Drops any cached list: which devices are usable depends on the rate, so a
list built under the old rates would be stale.""" list built under the old rates would be stale."""
global _cache global _cache
_RATES["input"] = int(input_rate) _RATES["input"] = int(input_rate)
_RATES["output"] = int(output_rate) _RATES["output"] = int(output_rate)
with _cache_lock: with _cache_lock:
_cache = None _cache = None
def _usable(idx: int, kind: str) -> bool: def _usable(idx: int, kind: str) -> bool:
"""Can this device actually be opened at the rate we need? """Can this device actually be opened at the rate we need?
Deliberately opens a real stream rather than asking Deliberately opens a real stream rather than asking
`check_output_settings`, because that function lies: it passed for an MME `check_output_settings`, because that function lies: it passed for an MME
endpoint that then failed to open with "The specified format is not endpoint that then failed to open with "The specified format is not
supported or cannot be translated" [MME error 32]. Opening and immediately supported or cannot be translated" [MME error 32]. Opening and immediately
closing costs milliseconds and is the only answer that holds.""" closing costs milliseconds and is the only answer that holds."""
st = None st = None
try: try:
import sounddevice as sd import sounddevice as sd
rate = _RATES.get(kind, 16000) rate = _RATES.get(kind, 16000)
if kind == "input": if kind == "input":
st = sd.InputStream(samplerate=rate, channels=1, dtype="int16", st = sd.InputStream(samplerate=rate, channels=1, dtype="int16",
blocksize=1024, device=idx, blocksize=1024, device=idx,
callback=lambda *_a: None) callback=lambda *_a: None)
else: else:
st = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16", st = sd.RawOutputStream(samplerate=rate, channels=1, dtype="int16",
blocksize=1024, device=idx) blocksize=1024, device=idx)
st.start() st.start()
return True return True
except Exception: except Exception:
return False return False
finally: finally:
if st is not None: if st is not None:
try: try:
st.stop(); st.close() st.stop(); st.close()
except Exception: except Exception:
pass pass
# Aliases for "the default device" and internal routing endpoints. Matched # Aliases for "the default device" and internal routing endpoints. Matched
# case-insensitively as substrings against the device name. # case-insensitively as substrings against the device name.
_PSEUDO_DEVICES = ( _PSEUDO_DEVICES = (
"sound mapper", # Windows MME "sound mapper", # Windows MME
"primary sound", # Windows DirectSound ("Primary Sound Capture Driver") "primary sound", # Windows DirectSound ("Primary Sound Capture Driver")
"sysdefault", # ALSA "sysdefault", # ALSA
"default", # ALSA / PulseAudio alias "default", # ALSA / PulseAudio alias
"dmix", "dsnoop", # ALSA software mixing plugins "dmix", "dsnoop", # ALSA software mixing plugins
"surround", # ALSA channel-layout permutations of one card "surround", # ALSA channel-layout permutations of one card
"samplerate", "speexrate", "upmix", "vdownmix", "null", "samplerate", "speexrate", "upmix", "vdownmix", "null",
) )
def _is_pseudo(name: str) -> bool: def _is_pseudo(name: str) -> bool:
low = name.lower() low = name.lower()
return any(tok in low for tok in _PSEUDO_DEVICES) return any(tok in low for tok in _PSEUDO_DEVICES)
def _query() -> dict[str, list[str]]: def _query() -> dict[str, list[str]]:
"""Return {'input': [names...], 'output': [names...]}. Never raises. """Return {'input': [names...], 'output': [names...]}. Never raises.
Only real, selectable devices — see the note above.""" Only real, selectable devices — see the note above."""
out: dict[str, list[str]] = {"input": [], "output": []} out: dict[str, list[str]] = {"input": [], "output": []}
try: try:
import platform import platform
import sounddevice as sd import sounddevice as sd
devices = list(sd.query_devices()) devices = list(sd.query_devices())
try: try:
apis = [a.get("name", "") for a in sd.query_hostapis()] apis = [a.get("name", "") for a in sd.query_hostapis()]
except Exception: except Exception:
apis = [] apis = []
preferred = _PREFERRED_APIS.get(platform.system(), ()) preferred = _PREFERRED_APIS.get(platform.system(), ())
# Su macOS Core Audio non mente: aprire un flusso di prova su ogni dispositivo (Bluetooth, AirPlay, schermi)
def _collect(api_filter, kind) -> list[tuple[int, str]]: # costa secondi ciascuno e blocca l'interfaccia. Le prove servono solo per le API fasulle di Windows/Linux.
"""(index, name) for named, non-pseudo devices on one side that can fast = platform.system() == "Darwin"
be opened at the rate that side runs at."""
chan = "max_input_channels" if kind == "input" else "max_output_channels" def _collect(api_filter, kind) -> list[tuple[int, str]]:
found, seen = [], set() """(index, name) for named, non-pseudo devices on one side that can
for idx, dev in enumerate(devices): be opened at the rate that side runs at."""
name = (dev.get("name") or "").strip() chan = "max_input_channels" if kind == "input" else "max_output_channels"
if not name or _is_pseudo(name) or name in seen: found, seen = [], set()
continue for idx, dev in enumerate(devices):
if dev.get(chan, 0) <= 0: name = (dev.get("name") or "").strip()
continue if not name or _is_pseudo(name) or name in seen:
if api_filter is not None: continue
api = apis[dev["hostapi"]].lower() if dev.get("hostapi", -1) < len(apis) else "" if dev.get(chan, 0) <= 0:
if api_filter not in api: continue
continue if api_filter is not None:
if not _usable(idx, kind): api = apis[dev["hostapi"]].lower() if dev.get("hostapi", -1) < len(apis) else ""
continue if api_filter not in api:
seen.add(name) continue
found.append((idx, name)) if not fast and not _usable(idx, kind):
return found continue
seen.add(name)
# Each direction picks its own host API. They are genuinely different found.append((idx, name))
# problems — on Windows the microphone works on DirectSound while the return found
# speakers only work on MME — and a single global choice cannot be right
# for both. # Each direction picks its own host API. They are genuinely different
for kind in ("input", "output"): # problems — on Windows the microphone works on DirectSound while the
for api_filter in list(preferred) + [None]: # speakers only work on MME — and a single global choice cannot be right
found = _collect(api_filter, kind) # for both.
if not found: for kind in ("input", "output"):
continue for api_filter in list(preferred) + [None]:
# One probe per API per direction, cached, on this thread. found = _collect(api_filter, kind)
if not _transport_works(found[0][0], kind, (api_filter, kind)): if not found:
continue continue
_chosen_api[kind] = api_filter # One probe per API per direction, cached, on this thread.
out[kind] = [_display_name(n, devices) for _i, n in found] if not fast and not _transport_works(found[0][0], kind, (api_filter, kind)):
break continue
if out[kind]: _chosen_api[kind] = api_filter
print(f"[Audio] {kind}: using " out[kind] = [_display_name(n, devices) for _i, n in found]
f"{_chosen_api[kind] or 'any host API'} " break
f"({len(out[kind])} devices)") if out[kind]:
return out print(f"[Audio] {kind}: using "
f"{_chosen_api[kind] or 'any host API'} "
except Exception as e: f"({len(out[kind])} devices)")
print(f"[Audio] Device enumeration failed: {e}") return out
return out
except Exception as e:
print(f"[Audio] Device enumeration failed: {e}")
def prefetch() -> None: return out
"""Warm the cache on a background thread. Called once at startup so the
settings drawer never pays for enumeration on the Qt thread."""
def _work(): def prefetch() -> None:
global _cache """Warm the cache on a background thread. Called once at startup so the
result = _query() settings drawer never pays for enumeration on the Qt thread."""
with _cache_lock: def _work():
_cache = result global _cache
print(f"[Audio] {len(result['input'])} input / " result = _query()
f"{len(result['output'])} output devices found") with _cache_lock:
threading.Thread(target=_work, daemon=True, name="audio-devices").start() _cache = result
print(f"[Audio] {len(result['input'])} input / "
f"{len(result['output'])} output devices found")
def list_devices(kind: str, refresh: bool = False) -> list[str]: threading.Thread(target=_work, daemon=True, name="audio-devices").start()
"""Device names for 'input' or 'output'. Falls back to a synchronous query
if the prefetch has not landed yet — correctness over the cache."""
global _cache def list_devices(kind: str, refresh: bool = False) -> list[str]:
with _cache_lock: """Device names for 'input' or 'output'. Falls back to a synchronous query
cached = None if refresh else _cache if the prefetch has not landed yet — correctness over the cache."""
if cached is None: global _cache
cached = _query() with _cache_lock:
with _cache_lock: cached = None if refresh else _cache
_cache = cached if cached is None:
return list(cached.get(kind, [])) cached = _query()
with _cache_lock:
_cache = cached
def resolve(name: str, kind: str): return list(cached.get(kind, []))
"""Turn a saved device name into something sounddevice accepts.
Returns None for "system default" — which is also what we return when the def resolve(name: str, kind: str):
saved device is gone, because a missing headset must degrade to the built-in """Turn a saved device name into something sounddevice accepts.
speakers, not to a crash on startup.
Returns None for "system default" — which is also what we return when the
Candidates are walked in the same host-API order the picker used, so a name saved device is gone, because a missing headset must degrade to the built-in
the user chose from the WASAPI list resolves to the WASAPI endpoint. Without speakers, not to a crash on startup.
that ordering a full name would fall through to MME's truncated copy of the
same device — which happens to work, but means the setting quietly refers to Candidates are walked in the same host-API order the picker used, so a name
a different endpoint than the one on screen.""" the user chose from the WASAPI list resolves to the WASAPI endpoint. Without
wanted = (name or "").strip() that ordering a full name would fall through to MME's truncated copy of the
if not wanted or wanted == DEFAULT_LABEL: same device — which happens to work, but means the setting quietly refers to
return None a different endpoint than the one on screen."""
wanted = (name or "").strip()
try: if not wanted or wanted == DEFAULT_LABEL:
import platform return None
import sounddevice as sd
try:
devices = list(sd.query_devices()) import platform
try: import sounddevice as sd
apis = [a.get("name", "") for a in sd.query_hostapis()]
except Exception: devices = list(sd.query_devices())
apis = [] try:
apis = [a.get("name", "") for a in sd.query_hostapis()]
want_in = (kind == "input") except Exception:
chan_key = "max_input_channels" if want_in else "max_output_channels" apis = []
def _candidates(api_filter): want_in = (kind == "input")
for idx, dev in enumerate(devices): chan_key = "max_input_channels" if want_in else "max_output_channels"
if dev.get(chan_key, 0) <= 0:
continue def _candidates(api_filter):
if api_filter is not None: for idx, dev in enumerate(devices):
api = apis[dev["hostapi"]].lower() if dev.get("hostapi", -1) < len(apis) else "" if dev.get(chan_key, 0) <= 0:
if api_filter not in api: continue
continue if api_filter is not None:
yield idx, (dev.get("name") or "").strip() api = apis[dev["hostapi"]].lower() if dev.get("hostapi", -1) < len(apis) else ""
if api_filter not in api:
# The API the picker settled on for this direction comes first — the continue
# endpoint that was listed must be the endpoint that gets opened, or the yield idx, (dev.get("name") or "").strip()
# setting means something different from what it says. list_devices()
# populates it; calling it here is a no-op once the cache is warm. # The API the picker settled on for this direction comes first — the
list_devices(kind) # endpoint that was listed must be the endpoint that gets opened, or the
chosen = _chosen_api.get(kind) # setting means something different from what it says. list_devices()
orders = ([chosen] if chosen is not None else []) \ # populates it; calling it here is a no-op once the cache is warm.
+ [a for a in _PREFERRED_APIS.get(platform.system(), ()) if a != chosen] \ list_devices(kind)
+ [None] chosen = _chosen_api.get(kind)
orders = ([chosen] if chosen is not None else []) \
# A candidate only counts if it can be opened at the rate this side runs + [a for a in _PREFERRED_APIS.get(platform.system(), ()) if a != chosen] \
# at. The prefix match matters because the API that carries the audio is + [None]
# not always the one that can spell: MME truncates names to 31 characters
# while DirectSound and WASAPI do not, so the name shown in the picker # A candidate only counts if it can be opened at the rate this side runs
# can be longer than the name of the endpoint it actually opens. # at. The prefix match matters because the API that carries the audio is
for api_filter in orders: # not always the one that can spell: MME truncates names to 31 characters
partial = None # while DirectSound and WASAPI do not, so the name shown in the picker
for idx, dev_name in _candidates(api_filter): # can be longer than the name of the endpoint it actually opens.
if dev_name == wanted: for api_filter in orders:
if _usable(idx, kind): partial = None
return idx for idx, dev_name in _candidates(api_filter):
continue if dev_name == wanted:
if partial is None and (dev_name.startswith(wanted[:24]) if _usable(idx, kind):
or wanted.startswith(dev_name[:24])): return idx
if _usable(idx, kind): continue
partial = idx if partial is None and (dev_name.startswith(wanted[:24])
if partial is not None: or wanted.startswith(dev_name[:24])):
return partial if _usable(idx, kind):
partial = idx
print(f"[Audio] Saved {kind} device '{wanted}' cannot be opened at " if partial is not None:
f"{_RATES.get(kind)} Hz on any host API — using system default") return partial
return None
except Exception as e: print(f"[Audio] Saved {kind} device '{wanted}' cannot be opened at "
print(f"[Audio] resolve({kind}) failed: {e} — using system default") f"{_RATES.get(kind)} Hz on any host API — using system default")
return None return None
except Exception as e:
print(f"[Audio] resolve({kind}) failed: {e} — using system default")
return None
+28
View File
@@ -33,7 +33,30 @@ def ensure_identity_file() -> None:
f.write_text(json.dumps(data, indent=4, ensure_ascii=False), encoding="utf-8") f.write_text(json.dumps(data, indent=4, ensure_ascii=False), encoding="utf-8")
def _single_instance() -> bool:
"""Blocco esclusivo su data/luza.lock: una seconda LuZa (avvio automatico + avvio manuale) esce subito."""
import fcntl
from avatar.settings import DATA_DIR
DATA_DIR.mkdir(parents=True, exist_ok=True)
global _lock_file
_lock_file = open(DATA_DIR / "luza.lock", "w")
try:
fcntl.flock(_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
_lock_file.write(str(__import__("os").getpid())); _lock_file.flush()
return True
except OSError:
return False
def main() -> None: def main() -> None:
if "--smoke" not in sys.argv and not _single_instance():
print("LuZa è già in esecuzione: questa seconda istanza si chiude.")
try:
import subprocess
subprocess.run(["osascript", "-e", 'display notification "LuZa è già aperta." with title "LuZa"'], timeout=5)
except Exception:
pass
return
ensure_identity_file() ensure_identity_file()
settings = Settings() settings = Settings()
try: try:
@@ -57,6 +80,11 @@ def main() -> None:
ui.on_wake_toggle = assistant.on_wake_toggle ui.on_wake_toggle = assistant.on_wake_toggle
ui.on_wake_manual = assistant.on_wake_manual ui.on_wake_manual = assistant.on_wake_manual
ui.wake_get_state = assistant.wake_get_state ui.wake_get_state = assistant.wake_get_state
try:
from core import audio_devices
audio_devices.prefetch() # elenco dispositivi audio pronto prima che si apra la finestra
except Exception:
pass
from avatar.plugins import registry from avatar.plugins import registry
from core import confirm from core import confirm
confirm.bind(ui.show_confirm, ui.hide_confirm, ui.write_log) confirm.bind(ui.show_confirm, ui.hide_confirm, ui.write_log)
+3 -2
View File
@@ -4691,8 +4691,9 @@ class MainWindow(QMainWindow):
'<plist version="1.0"><dict>\n' '<plist version="1.0"><dict>\n'
' <key>Label</key><string>it.luza.assistente</string>\n' ' <key>Label</key><string>it.luza.assistente</string>\n'
' <key>ProgramArguments</key><array>\n' ' <key>ProgramArguments</key><array>\n'
f' <string>{sys.executable}</string>\n' + (' <string>/usr/bin/open</string>\n <string>-a</string>\n <string>/Applications/LuZa.app</string>\n'
f' <string>{script}</string>\n' if Path("/Applications/LuZa.app").exists() else
f' <string>{sys.executable}</string>\n <string>{script}</string>\n') +
' </array>\n' ' </array>\n'
' <key>RunAtLoad</key><true/>\n' ' <key>RunAtLoad</key><true/>\n'
'</dict></plist>\n' '</dict></plist>\n'