infinicode 2.8.76 → 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
|
-
|
|
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
|
-
|
|
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
|
|
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){
|
|
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
|
@@ -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
|
-
|
|
3466
|
-
|
|
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":
|
|
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
|
-
|
|
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 /
|
|
531
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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,
|