infinicode 2.8.83 → 2.8.84
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/package.json
CHANGED
|
@@ -397,6 +397,10 @@ class PreviewAgent:
|
|
|
397
397
|
# use the resolved hardware name reported in that same inventory.
|
|
398
398
|
self.audio_capture_device = self.audio_device
|
|
399
399
|
self.audio_output_device = cfg.get("audio_output_device", os.getenv("AUDIO_OUTPUT_DEVICE", "default"))
|
|
400
|
+
# Keep RoboVision/sounddevice inventory IDs out of PyAudio. Both APIs
|
|
401
|
+
# number the same ALSA cards differently, so resolve the selected ID
|
|
402
|
+
# to its hardware name before opening any playback stream.
|
|
403
|
+
self.audio_playback_device = self.audio_output_device
|
|
400
404
|
self.robovision_url = cfg.get("robovision_url", os.getenv("ROBOVISION_URL", "http://127.0.0.1:5000"))
|
|
401
405
|
self.use_robovision_camera = bool(cfg.get("use_robovision_camera", False))
|
|
402
406
|
self.width = int(cfg.get("video_width", os.getenv("VIDEO_WIDTH", DEFAULT_VIDEO_WIDTH)))
|
|
@@ -689,6 +693,16 @@ class PreviewAgent:
|
|
|
689
693
|
if data.get("audio_output_device") is not None:
|
|
690
694
|
changed = changed or self.audio_output_device != data["audio_output_device"]
|
|
691
695
|
self.audio_output_device = data["audio_output_device"]
|
|
696
|
+
inventory = data.get("device_inventory") or {}
|
|
697
|
+
selected = str(self.audio_output_device)
|
|
698
|
+
self.audio_playback_device = next(
|
|
699
|
+
(
|
|
700
|
+
str(item.get("name"))
|
|
701
|
+
for item in inventory.get("audio_output", [])
|
|
702
|
+
if str(item.get("id")) == selected and item.get("name")
|
|
703
|
+
),
|
|
704
|
+
self.audio_output_device,
|
|
705
|
+
)
|
|
692
706
|
if changed:
|
|
693
707
|
await self._sync_robovision_media_config()
|
|
694
708
|
except Exception as e:
|
|
@@ -812,7 +826,7 @@ class PreviewAgent:
|
|
|
812
826
|
self._vision_last_activity = 0.0
|
|
813
827
|
self._remote_session_ended = False
|
|
814
828
|
await self._stop_publisher()
|
|
815
|
-
await asyncio.to_thread(_play_audio_effect, self.
|
|
829
|
+
await asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "disconnect")
|
|
816
830
|
self._current_state = PreviewState()
|
|
817
831
|
|
|
818
832
|
async def _heartbeat_loop(self) -> None:
|
|
@@ -865,7 +879,7 @@ class PreviewAgent:
|
|
|
865
879
|
restore_state = self._current_state if self._publisher else None
|
|
866
880
|
if restore_state:
|
|
867
881
|
await self._stop_publisher()
|
|
868
|
-
await asyncio.sleep(0
|
|
882
|
+
await asyncio.sleep(1.0)
|
|
869
883
|
try:
|
|
870
884
|
result = await asyncio.to_thread(_speaker_roundtrip_test, request.get("params") or {})
|
|
871
885
|
result["media_owner_paused"] = bool(restore_state)
|
|
@@ -1028,7 +1042,7 @@ class PreviewAgent:
|
|
|
1028
1042
|
await asyncio.sleep(0.5)
|
|
1029
1043
|
# I/O. Camera/mic join and the preset greeting are the critical path.
|
|
1030
1044
|
asyncio.create_task(
|
|
1031
|
-
asyncio.to_thread(_play_audio_effect, self.
|
|
1045
|
+
asyncio.to_thread(_play_audio_effect, self.audio_playback_device, "motion")
|
|
1032
1046
|
)
|
|
1033
1047
|
try:
|
|
1034
1048
|
r = await self._session.post(
|
|
@@ -1234,7 +1248,7 @@ class LiveKitPublisher:
|
|
|
1234
1248
|
# never actually reaching the real speakers. Prefer WASAPI's default output,
|
|
1235
1249
|
# which is what Windows' own volume mixer/meter is backed by.
|
|
1236
1250
|
output_device_index = None
|
|
1237
|
-
selected_output = str(self.agent.
|
|
1251
|
+
selected_output = str(self.agent.audio_playback_device or "default")
|
|
1238
1252
|
if selected_output.strip().isdigit():
|
|
1239
1253
|
output_device_index = int(selected_output.strip())
|
|
1240
1254
|
try:
|
|
@@ -578,6 +578,49 @@ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
|
|
|
578
578
|
)
|
|
579
579
|
|
|
580
580
|
|
|
581
|
+
def _linux_aplay_pcm16(raw: bytes, selected_output: str, sample_rate: int,
|
|
582
|
+
channels: int = 1) -> tuple[bool, str]:
|
|
583
|
+
"""Play PCM through the same ALSA plughw path proven during Pi setup.
|
|
584
|
+
|
|
585
|
+
PortAudio and RoboVision enumerate devices in different index spaces. The
|
|
586
|
+
inventory name contains the stable ALSA hw tuple, so use it directly and
|
|
587
|
+
let ALSA's plug layer adapt the cached TTS sample rate/channel count.
|
|
588
|
+
"""
|
|
589
|
+
import re
|
|
590
|
+
|
|
591
|
+
match = re.search(r"hw:(\d+),(\d+)", str(selected_output), re.IGNORECASE)
|
|
592
|
+
if not match:
|
|
593
|
+
return False, "selected output has no ALSA hw address"
|
|
594
|
+
alsa_device = f"plughw:{match.group(1)},{match.group(2)}"
|
|
595
|
+
command = [
|
|
596
|
+
"aplay", "-q", "-D", alsa_device, "-t", "raw", "-f", "S16_LE",
|
|
597
|
+
"-r", str(sample_rate), "-c", str(channels),
|
|
598
|
+
]
|
|
599
|
+
last_error = "aplay failed"
|
|
600
|
+
# ALSA can retain an exclusive USB handle briefly after the LiveKit
|
|
601
|
+
# playback stream closes. Retry for a bounded period instead of declaring
|
|
602
|
+
# a valid device unsupported on the first EBUSY response.
|
|
603
|
+
for attempt in range(5):
|
|
604
|
+
if attempt:
|
|
605
|
+
time.sleep(0.5)
|
|
606
|
+
try:
|
|
607
|
+
completed = subprocess.run(
|
|
608
|
+
command,
|
|
609
|
+
input=raw,
|
|
610
|
+
stdout=subprocess.DEVNULL,
|
|
611
|
+
stderr=subprocess.PIPE,
|
|
612
|
+
timeout=max(5.0, len(raw) / max(1, sample_rate * channels * 2) + 3.0),
|
|
613
|
+
check=False,
|
|
614
|
+
)
|
|
615
|
+
except Exception as exc:
|
|
616
|
+
last_error = f"{type(exc).__name__}: {exc}"
|
|
617
|
+
continue
|
|
618
|
+
if completed.returncode == 0:
|
|
619
|
+
return True, alsa_device
|
|
620
|
+
last_error = completed.stderr.decode("utf-8", errors="replace").strip() or f"aplay exited {completed.returncode}"
|
|
621
|
+
return False, f"{alsa_device}: {last_error}"
|
|
622
|
+
|
|
623
|
+
|
|
581
624
|
def _speaker_roundtrip_test(params: dict) -> dict:
|
|
582
625
|
"""Play a test tone + record the mic simultaneously; return metrics.
|
|
583
626
|
|
|
@@ -618,10 +661,46 @@ def _speaker_roundtrip_test(params: dict) -> dict:
|
|
|
618
661
|
if dur > 10.0:
|
|
619
662
|
return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
|
|
620
663
|
n_samples = int(source_rate * dur)
|
|
621
|
-
if n_samples < 480:
|
|
622
|
-
return {"ok": False, "error": "duration too short"}
|
|
623
|
-
|
|
624
|
-
|
|
664
|
+
if n_samples < 480:
|
|
665
|
+
return {"ok": False, "error": "duration too short"}
|
|
666
|
+
|
|
667
|
+
# Cached character voice is output-only. On Linux, bypass PortAudio's
|
|
668
|
+
# unrelated index space and use the exact ALSA plughw endpoint selected by
|
|
669
|
+
# RoboVision. This is the same path used by the successful onsite test.
|
|
670
|
+
if playback_only and mono_pcm is not None and sys.platform.startswith("linux"):
|
|
671
|
+
t0 = time.monotonic()
|
|
672
|
+
played, detail = _linux_aplay_pcm16(mono_pcm, str(out_name), source_rate)
|
|
673
|
+
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
674
|
+
if not played:
|
|
675
|
+
return {
|
|
676
|
+
"ok": False,
|
|
677
|
+
"error": f"ALSA playback failed: {detail}",
|
|
678
|
+
"duration_ms": duration_ms,
|
|
679
|
+
"output_device": str(out_name),
|
|
680
|
+
"input_device": "not opened (speaker-only test)",
|
|
681
|
+
"duration": dur,
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
"ok": True,
|
|
685
|
+
"pass": True,
|
|
686
|
+
"played": True,
|
|
687
|
+
"playback_only": True,
|
|
688
|
+
"mode": mode,
|
|
689
|
+
"text": params.get("text"),
|
|
690
|
+
"cache_hit": bool(params.get("cache_hit")),
|
|
691
|
+
"tts_provider": params.get("tts_provider"),
|
|
692
|
+
"tts_voice": params.get("tts_voice"),
|
|
693
|
+
"duration": dur,
|
|
694
|
+
"duration_ms": duration_ms,
|
|
695
|
+
"sample_rate": source_rate,
|
|
696
|
+
"output_sample_rate": source_rate,
|
|
697
|
+
"output_channels": 1,
|
|
698
|
+
"output_device": detail,
|
|
699
|
+
"input_device": "not opened (speaker-only test)",
|
|
700
|
+
"diagnostic": "cached voice played through the selected ALSA plughw endpoint",
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
pa = pyaudio.PyAudio()
|
|
625
704
|
out_idx = _resolve_audio_device(pa, out_name, "output")
|
|
626
705
|
in_idx = None if playback_only else _resolve_audio_device(pa, in_name, "input")
|
|
627
706
|
def _dev_name(idx):
|