infinicode 2.8.76 → 2.8.78

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.
@@ -458,7 +458,7 @@ SPEAKER_TEST_INPUT_DEVICE = None
458
458
  SPEAKER_TEST_PEAK_DB_THRESHOLD = -30.0
459
459
 
460
460
 
461
- def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
461
+ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
462
462
  """Resolve a free-form device name/index to a PyAudio device index.
463
463
 
464
464
  kind is "input" or "output". None / "default" picks the WASAPI
@@ -468,14 +468,14 @@ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
468
468
  result is recorded so the operator can see it."""
469
469
  if selected is None:
470
470
  selected = "default"
471
- s = str(selected).strip()
472
- if s == "" or s.lower() == "default":
473
- try:
474
- wasapi = pa.get_host_api_info_by_type(pyaudio.paWASAPI)
475
- key = "defaultInputDevice" if kind == "input" else "defaultOutputDevice"
476
- idx = wasapi.get(key)
477
- if idx is not None and idx >= 0:
478
- return idx
471
+ s = str(selected).strip()
472
+ if s == "" or s.lower() == "default":
473
+ try:
474
+ info = (pa.get_default_input_device_info() if kind == "input"
475
+ else pa.get_default_output_device_info())
476
+ idx = info.get("index")
477
+ if idx is not None and idx >= 0:
478
+ return int(idx)
479
479
  except Exception:
480
480
  pass
481
481
  return None
@@ -491,9 +491,93 @@ def _resolve_audio_device(pa, selected: str, kind: str) -> Optional[int]:
491
491
  continue
492
492
  if needle in str(info.get("name", "")).lower():
493
493
  return i
494
- return None
495
-
496
-
494
+ return None
495
+
496
+
497
+ def _audio_rate_candidates(info: dict, requested_rate: int) -> list[int]:
498
+ """Return practical PCM rates with the device native rate first."""
499
+ rates = []
500
+ for value in (
501
+ info.get("defaultSampleRate"),
502
+ requested_rate,
503
+ 48000,
504
+ 44100,
505
+ 32000,
506
+ 24000,
507
+ 16000,
508
+ ):
509
+ try:
510
+ rate = int(round(float(value)))
511
+ except (TypeError, ValueError):
512
+ continue
513
+ if rate > 0 and rate not in rates:
514
+ rates.append(rate)
515
+ return rates
516
+
517
+
518
+ def _resample_pcm16_mono(raw: bytes, source_rate: int, target_rate: int) -> bytes:
519
+ """Linearly resample little-endian mono PCM16 without optional DSP deps."""
520
+ if source_rate == target_rate or not raw:
521
+ return raw
522
+ source_count = len(raw) // 2
523
+ target_count = max(1, int(round(source_count * target_rate / source_rate)))
524
+ source = struct.unpack(f"<{source_count}h", raw[:source_count * 2])
525
+ if source_count == 1:
526
+ return struct.pack("<h", source[0]) * target_count
527
+ scale = (source_count - 1) / max(1, target_count - 1)
528
+ result = bytearray(target_count * 2)
529
+ for index in range(target_count):
530
+ position = index * scale
531
+ left = int(position)
532
+ right = min(left + 1, source_count - 1)
533
+ fraction = position - left
534
+ value = int(round(source[left] + (source[right] - source[left]) * fraction))
535
+ struct.pack_into("<h", result, index * 2, value)
536
+ return bytes(result)
537
+
538
+
539
+ def _open_pcm_stream(pa, pyaudio_module, kind: str, device_index: Optional[int],
540
+ requested_rate: int):
541
+ """Open a PCM16 stream using the first format the device accepts."""
542
+ try:
543
+ if device_index is None:
544
+ info = (pa.get_default_output_device_info() if kind == "output"
545
+ else pa.get_default_input_device_info())
546
+ device_index = int(info.get("index"))
547
+ else:
548
+ info = pa.get_device_info_by_index(device_index)
549
+ except Exception as exc:
550
+ raise OSError(f"selected {kind} device is unavailable: {exc}") from exc
551
+
552
+ channel_key = "maxOutputChannels" if kind == "output" else "maxInputChannels"
553
+ max_channels = int(info.get(channel_key, 0) or 0)
554
+ if max_channels < 1:
555
+ raise OSError(f"{info.get('name', device_index)} has no {kind} channels")
556
+ channels = [2, 1] if kind == "output" and max_channels >= 2 else [1]
557
+ errors = []
558
+ for rate in _audio_rate_candidates(info, requested_rate):
559
+ for channel_count in channels:
560
+ kwargs = {
561
+ "format": pyaudio_module.paInt16,
562
+ "channels": channel_count,
563
+ "rate": rate,
564
+ kind: True,
565
+ f"{kind}_device_index": device_index,
566
+ }
567
+ if kind == "input":
568
+ kwargs["frames_per_buffer"] = 1024
569
+ try:
570
+ stream = pa.open(**kwargs)
571
+ return stream, rate, channel_count, info
572
+ except Exception as exc:
573
+ errors.append(f"{rate}Hz/{channel_count}ch: {exc}")
574
+ attempts = "; ".join(errors[-4:])
575
+ raise OSError(
576
+ f"{info.get('name', device_index)} supports no usable PCM16 {kind} format"
577
+ + (f" ({attempts})" if attempts else "")
578
+ )
579
+
580
+
497
581
  def _speaker_roundtrip_test(params: dict) -> dict:
498
582
  """Play a test tone + record the mic simultaneously; return metrics.
499
583
 
@@ -508,9 +592,11 @@ def _speaker_roundtrip_test(params: dict) -> dict:
508
592
  dur = float(params.get("duration", SPEAKER_TEST_DURATION_S))
509
593
  amp = float(params.get("amplitude", SPEAKER_TEST_AMPLITUDE))
510
594
  threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
511
- out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
512
- in_name = params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
513
- sample_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
595
+ # RoboVision inventory indices belong to sounddevice and may not match
596
+ # PyAudio's index space. Prefer the reported hardware name for lookup.
597
+ out_name = params.get("output_name") or params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
598
+ in_name = params.get("input_name") or params.get("input", os.environ.get("ROBOPARK_AUDIO_INPUT") or SPEAKER_TEST_INPUT_DEVICE)
599
+ source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
514
600
  if mode not in ("tone", "tts"):
515
601
  return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
516
602
  if mode == "tone" and not (50.0 <= freq <= 8000.0):
@@ -527,8 +613,10 @@ def _speaker_roundtrip_test(params: dict) -> dict:
527
613
  return {"ok": False, "error": f"invalid cached TTS audio: {e}"}
528
614
  if len(mono_pcm) < 480 or len(mono_pcm) % 2:
529
615
  return {"ok": False, "error": "cached TTS audio is empty or malformed"}
530
- dur = len(mono_pcm) / 2 / sample_rate
531
- n_samples = int(sample_rate * dur)
616
+ dur = len(mono_pcm) / 2 / source_rate
617
+ if dur > 10.0:
618
+ return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
619
+ n_samples = int(source_rate * dur)
532
620
  if n_samples < 480:
533
621
  return {"ok": False, "error": "duration too short"}
534
622
 
@@ -542,44 +630,49 @@ def _speaker_roundtrip_test(params: dict) -> dict:
542
630
  out_label = _dev_name(out_idx)
543
631
  in_label = _dev_name(in_idx)
544
632
 
545
- if mono_pcm is not None:
546
- frames = bytearray()
547
- for pos in range(0, len(mono_pcm), 2):
548
- sample = mono_pcm[pos:pos + 2]
549
- frames.extend(sample + sample)
550
- raw = bytes(frames)
551
- else:
552
- frames = bytearray()
553
- peak_value = int(32767 * amp)
554
- for n in range(n_samples):
555
- env = min(1.0, n / (sample_rate * 0.004), (n_samples - n) / (sample_rate * 0.008))
556
- value = int(peak_value * env * math.sin(2 * math.pi * freq * n / sample_rate))
557
- frames.extend(struct.pack("<hh", value, value))
558
- raw = bytes(frames)
559
-
560
- recorded_peak = 0
561
- recorded_rms = 0.0
562
- err = None
563
- t0 = time.monotonic()
564
- try:
565
- out_stream = pa.open(
566
- format=pyaudio.paInt16,
567
- channels=2,
568
- rate=sample_rate,
569
- output=True,
570
- output_device_index=out_idx,
571
- )
572
- chunk = 1024
573
- in_stream = pa.open(
574
- format=pyaudio.paInt16,
575
- channels=1,
576
- rate=sample_rate,
577
- input=True,
578
- input_device_index=in_idx,
579
- frames_per_buffer=chunk,
580
- )
581
- in_stream.start_stream()
582
- chunk_bytes = chunk * 2 * 2
633
+ recorded_peak = 0
634
+ recorded_rms = 0.0
635
+ err = None
636
+ output_rate = None
637
+ input_rate = None
638
+ output_channels = None
639
+ input_channels = None
640
+ t0 = time.monotonic()
641
+ out_stream = None
642
+ in_stream = None
643
+ try:
644
+ out_stream, output_rate, output_channels, out_info = _open_pcm_stream(
645
+ pa, pyaudio, "output", out_idx, source_rate
646
+ )
647
+ in_stream, input_rate, input_channels, in_info = _open_pcm_stream(
648
+ pa, pyaudio, "input", in_idx, source_rate
649
+ )
650
+ out_label = str(out_info.get("name", out_label))
651
+ in_label = str(in_info.get("name", in_label))
652
+
653
+ if mono_pcm is not None:
654
+ output_mono = _resample_pcm16_mono(mono_pcm, source_rate, output_rate)
655
+ else:
656
+ output_samples = int(output_rate * dur)
657
+ frames = bytearray(output_samples * 2)
658
+ peak_value = int(32767 * amp)
659
+ for n in range(output_samples):
660
+ env = min(1.0, n / (output_rate * 0.004),
661
+ (output_samples - n) / (output_rate * 0.008))
662
+ value = int(peak_value * env * math.sin(2 * math.pi * freq * n / output_rate))
663
+ struct.pack_into("<h", frames, n * 2, value)
664
+ output_mono = bytes(frames)
665
+ if output_channels == 2:
666
+ frames = bytearray(len(output_mono) * 2)
667
+ for pos in range(0, len(output_mono), 2):
668
+ frames[pos * 2:pos * 2 + 4] = output_mono[pos:pos + 2] * 2
669
+ raw = bytes(frames)
670
+ else:
671
+ raw = output_mono
672
+
673
+ chunk = 1024
674
+ in_stream.start_stream()
675
+ chunk_bytes = chunk * 2 * output_channels
583
676
  pos = 0
584
677
  peak_acc = 0
585
678
  rms_acc = 0.0
@@ -601,9 +694,10 @@ def _speaker_roundtrip_test(params: dict) -> dict:
601
694
  n_samp += 1
602
695
  except Exception:
603
696
  pass
604
- out_stream.stop_stream(); out_stream.close()
605
- try:
606
- tail = in_stream.read(n_samples, exception_on_overflow=False)
697
+ out_stream.stop_stream(); out_stream.close(); out_stream = None
698
+ try:
699
+ tail_frames = min(int(input_rate * 0.35), max(0, in_stream.get_read_available()))
700
+ tail = in_stream.read(tail_frames, exception_on_overflow=False) if tail_frames else b""
607
701
  for s in range(0, len(tail) - 1, 2):
608
702
  v = int.from_bytes(tail[s:s+2], "little", signed=True)
609
703
  a = abs(v)
@@ -612,13 +706,19 @@ def _speaker_roundtrip_test(params: dict) -> dict:
612
706
  n_samp += 1
613
707
  except Exception:
614
708
  pass
615
- in_stream.stop_stream(); in_stream.close()
709
+ in_stream.stop_stream(); in_stream.close(); in_stream = None
616
710
  recorded_peak = peak_acc
617
711
  recorded_rms = (rms_acc / max(1, n_samp)) ** 0.5
618
- except Exception as e:
619
- err = f"{type(e).__name__}: {e}"
620
- finally:
621
- try: pa.terminate()
712
+ except Exception as e:
713
+ err = f"{type(e).__name__}: {e}"
714
+ finally:
715
+ for stream in (out_stream, in_stream):
716
+ if stream is not None:
717
+ try: stream.stop_stream()
718
+ except Exception: pass
719
+ try: stream.close()
720
+ except Exception: pass
721
+ try: pa.terminate()
622
722
  except Exception: pass
623
723
  duration_ms = int((time.monotonic() - t0) * 1000)
624
724
  if err:
@@ -645,7 +745,11 @@ def _speaker_roundtrip_test(params: dict) -> dict:
645
745
  "frequency": freq,
646
746
  "duration": dur,
647
747
  "duration_ms": duration_ms,
648
- "sample_rate": sample_rate,
748
+ "sample_rate": source_rate,
749
+ "output_sample_rate": output_rate,
750
+ "input_sample_rate": input_rate,
751
+ "output_channels": output_channels,
752
+ "input_channels": input_channels,
649
753
  "amplitude": amp,
650
754
  "output_device": out_label,
651
755
  "input_device": in_label,