infinicode 2.8.75 → 2.8.77

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.
@@ -1083,7 +1083,16 @@ button.act:disabled{opacity:0.5;cursor:default;}
1083
1083
 
1084
1084
  <!-- ═══ Setup ═══ -->
1085
1085
  <div class="rp-sub" id="rp-sub-setup">
1086
- <!-- Add LiveKit server -->
1086
+ <div class="rp-form" id="rp-provider-secrets">
1087
+ <h3>Voice provider secret</h3>
1088
+ <div class="rp-quickrow">
1089
+ <div class="rp-field" style="flex:1"><label>ElevenLabs API key</label><input type="password" id="rp-elevenlabs-key" autocomplete="new-password" placeholder="stored only on the scheduler"></div>
1090
+ <button class="rp-btn primary" id="rp-elevenlabs-save" style="height:fit-content">Save secret</button>
1091
+ </div>
1092
+ <div class="rp-autonote" id="rp-elevenlabs-status">not configured · cached voice speaker tests require this key</div>
1093
+ </div>
1094
+
1095
+ <!-- Add LiveKit server -->
1087
1096
  <div class="rp-form" id="rp-addserver">
1088
1097
  <h3>Add LiveKit server</h3>
1089
1098
  <div class="rp-quickrow">
@@ -1893,11 +1902,25 @@ button.act:disabled{opacity:0.5;cursor:default;}
1893
1902
  .then(function(){ pmBusy=false; applyReadOnly(); });
1894
1903
  });
1895
1904
  })();
1896
- function pollSettings(){
1897
- fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
1898
- if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
1899
- }).catch(function(){});
1900
- }
1905
+ function pollSettings(){
1906
+ fetch('/robopark/api/settings'+QS,{cache:'no-store'}).then(function(r){return r.json();}).then(function(s){
1907
+ if(s && typeof s.production_mode!=='undefined') setPm(!!s.production_mode);
1908
+ var providerStatus=$('rp-elevenlabs-status');
1909
+ if(providerStatus) providerStatus.textContent=s&&s.elevenlabs_configured
1910
+ ? 'configured · source: '+(s.elevenlabs_source==='environment'?'scheduler environment':'persistent scheduler store')
1911
+ : 'not configured · cached voice speaker tests require this key';
1912
+ }).catch(function(){});
1913
+ }
1914
+
1915
+ var elevenlabsSave=$('rp-elevenlabs-save');
1916
+ if(elevenlabsSave) elevenlabsSave.addEventListener('click',function(){
1917
+ var input=$('rp-elevenlabs-key'), key=input?input.value.trim():'';
1918
+ if(key.length<8){ showOut('Enter a valid ElevenLabs API key'); return; }
1919
+ elevenlabsSave.disabled=true;
1920
+ rpAction('POST','/robopark/api/settings/provider-secret',{provider:'elevenlabs',api_key:key}).then(function(){
1921
+ input.value=''; showOut('ElevenLabs secret saved on scheduler'); pollSettings();
1922
+ }).catch(function(e){ showOut('ElevenLabs secret: '+e.message); }).then(function(){elevenlabsSave.disabled=false;});
1923
+ });
1901
1924
 
1902
1925
  function robotName(x){ return x.robot_id||x.id||x.name||'robot'; }
1903
1926
  function endSession(id){
@@ -6190,6 +6213,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
6190
6213
  if(!directRef){ visionOn=false; directImg.src=''; directImg.style.display='none'; return; }
6191
6214
  var directCamNote=$('pk-camnote'), directLiveNote=$('pk-golivenote'), directRes=$('pk-camres');
6192
6215
  visionOn=true;
6216
+ directImg.style.display='block';
6217
+ if(directCamNote)directCamNote.textContent='opening RoboVision camera relay...';
6218
+ if(directRes)directRes.textContent='CONNECTING';
6193
6219
  directImg.onerror=function(){ visionOn=false; directImg.style.display='none'; directImg.src=''; if(directCamNote) directCamNote.textContent='RoboVision feed unavailable on robot'; if(directLiveNote) directLiveNote.textContent='camera unavailable'; if(directRes) directRes.textContent='OFFLINE'; };
6194
6220
  directImg.onload=function(){ directImg.style.display='block'; if(directCamNote) directCamNote.textContent='live camera via RoboVision mesh relay'; if(directLiveNote) directLiveNote.textContent='live'; if(directRes) directRes.textContent='LIVE'; };
6195
6221
  directImg.src='/fed/media/robots/'+encodeURIComponent(directRef)+'/video_feed'+QS+(QS?'&':'?')+'_='+Date.now();
@@ -6879,7 +6905,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
6879
6905
  .catch(function(){ idleCam('unreachable'); });
6880
6906
  }
6881
6907
  function idleCam(reason){
6882
- var cc=$('pk-camcv'), msg={connecting:'connecting to live session…', idle:'robot idle · no live session', production_mode_off:'production mode off', 'no-sdk':'live session up · loading video…', unreachable:'scheduler unreachable'}[reason]||'no live feed';
6908
+ var cc=$('pk-camcv'), msg={connecting:'connecting to RoboVision camera relay…', idle:'camera relay standing by', production_mode_off:'production mode off', 'no-sdk':'voice session active · loading microphone…', unreachable:'camera relay unreachable'}[reason]||'no live feed';
6883
6909
  if(cc&&cc.getContext){ var cx=cc.getContext('2d'); cx.fillStyle='#0b0d12'; cx.fillRect(0,0,cc.width,cc.height); cx.strokeStyle='rgba(255,255,255,0.05)'; for(var y=0;y<cc.height;y+=4){cx.beginPath();cx.moveTo(0,y);cx.lineTo(cc.width,y);cx.stroke();} cx.fillStyle='#5a6a80'; cx.font='11px monospace'; cx.textAlign='center'; cx.fillText(msg, cc.width/2, cc.height/2); }
6884
6910
  var res=$('pk-camres'); if(res) res.textContent = reason==='idle'?'idle':'—';
6885
6911
  var note=$('pk-camnote'); if(note) note.textContent = reason==='idle'?'the feed goes live automatically when the robot opens a session':msg;
@@ -6985,7 +7011,17 @@ button.act:disabled{opacity:0.5;cursor:default;}
6985
7011
  (function loop(){ if(!$('pk-micbars')||drawerId==null) return;
6986
7012
  // MIC — real RMS from the subscribed LiveKit mic track (else no signal).
6987
7013
  var lvl=0;
6988
- if(micAnalyser){ micAnalyser.getByteTimeDomainData(buf); var sum=0; for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; } lvl=Math.min(1, Math.sqrt(sum/buf.length)*3.4); }
7014
+ if(micAnalyser){
7015
+ micAnalyser.getByteTimeDomainData(buf);
7016
+ var sum=0;
7017
+ for(var q=0;q<buf.length;q++){ var v=(buf[q]-128)/128; sum+=v*v; }
7018
+ var rms=Math.sqrt(sum/buf.length);
7019
+ // USB microphones commonly sit around -55 dBFS at room level and
7020
+ // -20 dBFS when spoken into. Map that useful range to 0..100 rather
7021
+ // than displaying normalized RMS directly (which looked stuck at 1%).
7022
+ var db=rms>0 ? 20*Math.log10(rms) : -90;
7023
+ lvl=Math.max(0,Math.min(1,(db+60)/45));
7024
+ }
6989
7025
  for(var i=0;i<mi.length;i++){ var on=i<lvl*mi.length; mi[i].style.height=(3+(on?lvl*13:0))+'px'; mi[i].style.background=on?(i<mi.length*0.7?'var(--ok)':i<mi.length*0.9?'var(--warn)':'var(--bad)'):'var(--glass-border)'; }
6990
7026
  set('pk-micval', micAnalyser?Math.round(lvl*100)+'%':'—');
6991
7027
  set('pk-micstate', micAnalyser?('selected '+selectedRobotMic()+' · '+(lvl>0.12?'live, hearing audio':'live, quiet')):('selected '+selectedRobotMic()+' · '+micMonitorState));
@@ -7099,8 +7135,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
7099
7135
  + '<div><span class="l">duration</span><span class="v">'+esc(result.duration+' s')+'</span></div>'
7100
7136
  + '<div><span class="l">latency</span><span class="v">'+esc(result.duration_ms+' ms')+'</span></div>'
7101
7137
  + '<div><span class="l">output</span><span class="v">'+esc(result.output_device)+'</span></div>'
7102
- + '<div><span class="l">input</span><span class="v">'+esc(result.input_device)+'</span></div>'
7103
- + '</div>';
7138
+ + '<div><span class="l">input</span><span class="v">'+esc(result.input_device)+'</span></div>'
7139
+ + '<div><span class="l">format</span><span class="v">'+esc((result.output_sample_rate||result.sample_rate)+' Hz / '+(result.output_channels||1)+'ch out · '+(result.input_sample_rate||result.sample_rate)+' Hz / '+(result.input_channels||1)+'ch in')+'</span></div>'
7140
+ + '</div>';
7104
7141
  var diag = result.diagnostic ? '<div class="pk-spk-diag">'+esc(result.diagnostic)+'</div>' : '';
7105
7142
  host.innerHTML = '<div class="pk-spk-result '+(result.pass?'ok':'bad')+'">'
7106
7143
  + banner + meta + diag + '</div>';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.75",
3
+ "version": "2.8.77",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -69,7 +69,14 @@ ELEVENLABS_API_KEY = (
69
69
  or os.getenv("ELEVENLABS_KEY", "").strip()
70
70
  or os.getenv("ELEVEN_API_KEY", "").strip()
71
71
  )
72
- ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
72
+ ELEVENLABS_BASE_URL = os.getenv("ELEVENLABS_BASE_URL", "https://api.elevenlabs.io").rstrip("/")
73
+
74
+
75
+ async def _get_elevenlabs_api_key() -> str:
76
+ """Resolve the provider key without ever returning it through public APIs."""
77
+ if ELEVENLABS_API_KEY:
78
+ return ELEVENLABS_API_KEY
79
+ return (await get_setting("elevenlabs_api_key") or "").strip()
73
80
 
74
81
  # Optional base URL of a local Kokoro TTS server. When set, /api/voices/kokoro
75
82
  # will proxy its /v1/audio/voices endpoint to return the dynamic list of voices
@@ -1991,12 +1998,29 @@ async def get_settings():
1991
1998
  """Public settings with secret values reduced to status only."""
1992
1999
  pm = await get_production_mode()
1993
2000
  has_default_token = bool(await get_setting("default_enrollment_token"))
2001
+ elevenlabs_key = await _get_elevenlabs_api_key()
1994
2002
  return {
1995
2003
  "production_mode": pm,
1996
2004
  "default_enrollment_token_set": has_default_token,
1997
2005
  "agent_token_configured": bool(ROBOPARK_AGENT_TOKEN),
1998
2006
  "agent_token_source": "environment" if os.getenv("ROBOPARK_AGENT_TOKEN", "").strip() else "scheduler_secret_file",
2007
+ "elevenlabs_configured": bool(elevenlabs_key),
2008
+ "elevenlabs_source": "environment" if ELEVENLABS_API_KEY else ("scheduler_store" if elevenlabs_key else None),
1999
2009
  }
2010
+
2011
+
2012
+ @app.post("/api/settings/provider-secret")
2013
+ async def set_provider_secret(payload: dict):
2014
+ """Persist a provider credential while only exposing configured status."""
2015
+ provider = str(payload.get("provider") or "").strip().lower()
2016
+ api_key = str(payload.get("api_key") or "").strip()
2017
+ if provider != "elevenlabs":
2018
+ raise HTTPException(400, "unsupported provider")
2019
+ if len(api_key) < 8:
2020
+ raise HTTPException(400, "ElevenLabs API key must be at least 8 characters")
2021
+ await set_setting("elevenlabs_api_key", api_key)
2022
+ await _log_history("settings", "provider_secret", provider, "updated", "operator")
2023
+ return {"ok": True, "provider": provider, "configured": True, "source": "scheduler_store"}
2000
2024
 
2001
2025
  @app.put("/api/settings")
2002
2026
  async def update_settings(payload: SettingsPayload):
@@ -3462,8 +3486,9 @@ async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
3462
3486
  voice_id = stack.tts_voice if stack else "21m00Tcm4TlvDq8ikWAM"
3463
3487
  if provider != "elevenlabs":
3464
3488
  raise HTTPException(400, f"Cached voice test currently requires ElevenLabs; selected stack uses {provider}")
3465
- if not ELEVENLABS_API_KEY:
3466
- raise HTTPException(503, "ELEVENLABS_API_KEY is not configured on the scheduler")
3489
+ elevenlabs_key = await _get_elevenlabs_api_key()
3490
+ if not elevenlabs_key:
3491
+ raise HTTPException(503, "ElevenLabs is not configured; open Control Center > Setup > Voice provider secret")
3467
3492
  if not voice_id:
3468
3493
  raise HTTPException(400, "Selected voice stack has no ElevenLabs voice id")
3469
3494
  cache_dir = os.path.join(os.path.dirname(DB_PATH), "tts-test-cache")
@@ -3479,7 +3504,7 @@ async def _cached_speaker_tts(robot_id: str, text: str) -> dict:
3479
3504
  response = await client.post(
3480
3505
  f"{ELEVENLABS_BASE_URL}/v1/text-to-speech/{voice_id}",
3481
3506
  params={"output_format": "pcm_24000"},
3482
- headers={"xi-api-key": ELEVENLABS_API_KEY, "Accept": "audio/pcm"},
3507
+ headers={"xi-api-key": elevenlabs_key, "Accept": "audio/pcm"},
3483
3508
  json={"text": phrase, "model_id": "eleven_flash_v2_5"},
3484
3509
  )
3485
3510
  if response.status_code != 200:
@@ -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
@@ -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
 
@@ -510,7 +594,7 @@ def _speaker_roundtrip_test(params: dict) -> dict:
510
594
  threshold_db = float(params.get("threshold_db", SPEAKER_TEST_PEAK_DB_THRESHOLD))
511
595
  out_name = params.get("output", os.environ.get("ROBOPARK_AUDIO_OUTPUT") or SPEAKER_TEST_OUTPUT_DEVICE)
512
596
  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))
597
+ source_rate = int(params.get("sample_rate", SPEAKER_TEST_SAMPLE_RATE))
514
598
  if mode not in ("tone", "tts"):
515
599
  return {"ok": False, "error": f"unsupported speaker test mode: {mode}"}
516
600
  if mode == "tone" and not (50.0 <= freq <= 8000.0):
@@ -527,8 +611,10 @@ def _speaker_roundtrip_test(params: dict) -> dict:
527
611
  return {"ok": False, "error": f"invalid cached TTS audio: {e}"}
528
612
  if len(mono_pcm) < 480 or len(mono_pcm) % 2:
529
613
  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)
614
+ dur = len(mono_pcm) / 2 / source_rate
615
+ if dur > 10.0:
616
+ return {"ok": False, "error": "cached TTS audio exceeds the 10 second test limit"}
617
+ n_samples = int(source_rate * dur)
532
618
  if n_samples < 480:
533
619
  return {"ok": False, "error": "duration too short"}
534
620
 
@@ -542,44 +628,49 @@ def _speaker_roundtrip_test(params: dict) -> dict:
542
628
  out_label = _dev_name(out_idx)
543
629
  in_label = _dev_name(in_idx)
544
630
 
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
631
+ recorded_peak = 0
632
+ recorded_rms = 0.0
633
+ err = None
634
+ output_rate = None
635
+ input_rate = None
636
+ output_channels = None
637
+ input_channels = None
638
+ t0 = time.monotonic()
639
+ out_stream = None
640
+ in_stream = None
641
+ try:
642
+ out_stream, output_rate, output_channels, out_info = _open_pcm_stream(
643
+ pa, pyaudio, "output", out_idx, source_rate
644
+ )
645
+ in_stream, input_rate, input_channels, in_info = _open_pcm_stream(
646
+ pa, pyaudio, "input", in_idx, source_rate
647
+ )
648
+ out_label = str(out_info.get("name", out_label))
649
+ in_label = str(in_info.get("name", in_label))
650
+
651
+ if mono_pcm is not None:
652
+ output_mono = _resample_pcm16_mono(mono_pcm, source_rate, output_rate)
653
+ else:
654
+ output_samples = int(output_rate * dur)
655
+ frames = bytearray(output_samples * 2)
656
+ peak_value = int(32767 * amp)
657
+ for n in range(output_samples):
658
+ env = min(1.0, n / (output_rate * 0.004),
659
+ (output_samples - n) / (output_rate * 0.008))
660
+ value = int(peak_value * env * math.sin(2 * math.pi * freq * n / output_rate))
661
+ struct.pack_into("<h", frames, n * 2, value)
662
+ output_mono = bytes(frames)
663
+ if output_channels == 2:
664
+ frames = bytearray(len(output_mono) * 2)
665
+ for pos in range(0, len(output_mono), 2):
666
+ frames[pos * 2:pos * 2 + 4] = output_mono[pos:pos + 2] * 2
667
+ raw = bytes(frames)
668
+ else:
669
+ raw = output_mono
670
+
671
+ chunk = 1024
672
+ in_stream.start_stream()
673
+ chunk_bytes = chunk * 2 * output_channels
583
674
  pos = 0
584
675
  peak_acc = 0
585
676
  rms_acc = 0.0
@@ -601,9 +692,10 @@ def _speaker_roundtrip_test(params: dict) -> dict:
601
692
  n_samp += 1
602
693
  except Exception:
603
694
  pass
604
- out_stream.stop_stream(); out_stream.close()
605
- try:
606
- tail = in_stream.read(n_samples, exception_on_overflow=False)
695
+ out_stream.stop_stream(); out_stream.close(); out_stream = None
696
+ try:
697
+ tail_frames = min(int(input_rate * 0.35), max(0, in_stream.get_read_available()))
698
+ tail = in_stream.read(tail_frames, exception_on_overflow=False) if tail_frames else b""
607
699
  for s in range(0, len(tail) - 1, 2):
608
700
  v = int.from_bytes(tail[s:s+2], "little", signed=True)
609
701
  a = abs(v)
@@ -612,13 +704,19 @@ def _speaker_roundtrip_test(params: dict) -> dict:
612
704
  n_samp += 1
613
705
  except Exception:
614
706
  pass
615
- in_stream.stop_stream(); in_stream.close()
707
+ in_stream.stop_stream(); in_stream.close(); in_stream = None
616
708
  recorded_peak = peak_acc
617
709
  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()
710
+ except Exception as e:
711
+ err = f"{type(e).__name__}: {e}"
712
+ finally:
713
+ for stream in (out_stream, in_stream):
714
+ if stream is not None:
715
+ try: stream.stop_stream()
716
+ except Exception: pass
717
+ try: stream.close()
718
+ except Exception: pass
719
+ try: pa.terminate()
622
720
  except Exception: pass
623
721
  duration_ms = int((time.monotonic() - t0) * 1000)
624
722
  if err:
@@ -645,7 +743,11 @@ def _speaker_roundtrip_test(params: dict) -> dict:
645
743
  "frequency": freq,
646
744
  "duration": dur,
647
745
  "duration_ms": duration_ms,
648
- "sample_rate": sample_rate,
746
+ "sample_rate": source_rate,
747
+ "output_sample_rate": output_rate,
748
+ "input_sample_rate": input_rate,
749
+ "output_channels": output_channels,
750
+ "input_channels": input_channels,
649
751
  "amplitude": amp,
650
752
  "output_device": out_label,
651
753
  "input_device": in_label,