test: qualify deployed realtime voice STT Hermes and audio delivery
This commit is contained in:
1 parent
856238f47a
commit
c549b92b52
1 file changed
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Explicit live fake-microphone qualification. Never physical microphone evidence.
|
||||
|
||||
Reads ignored protected deployment credentials; emits only safe qualification facts.
|
||||
Requires local synthetic PCM fixture and deployed Hope. No deployment changes.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--url', default='https://192.168.86.2:9178')
|
||||
parser.add_argument('--credentials', default='.runtime/deployment.json')
|
||||
parser.add_argument('--wav', default='.runtime/voice-qualification/microphone.wav')
|
||||
parser.add_argument('--timeout', type=int, default=180)
|
||||
args = parser.parse_args()
|
||||
values = json.loads(Path(args.credentials).read_text())
|
||||
fixture = Path(args.wav).resolve(strict=True)
|
||||
receipt = {'mode': 'synthetic_browser_microphone_not_physical', 'connected': False,
|
||||
'user_transcript': False, 'assistant_transcript': False,
|
||||
'audio_track': False, 'audio_bytes': 0, 'errors': 0}
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True, args=[
|
||||
'--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream',
|
||||
'--use-file-for-fake-audio-capture=' + str(fixture),
|
||||
'--autoplay-policy=no-user-gesture-required'])
|
||||
context = browser.new_context(ignore_https_errors=True, permissions=['microphone'],
|
||||
viewport={'width': 1440, 'height': 1000})
|
||||
page = context.new_page()
|
||||
page.add_init_script("window.__qualificationVoiceID=null;window.addEventListener('hope:voice-state',event=>{if(event.detail?.conversation_id)window.__qualificationVoiceID=event.detail.conversation_id;});")
|
||||
page.on('pageerror', lambda error: receipt.update(errors=receipt['errors'] + 1))
|
||||
# Read-only receiver stats: exposes no tokens, credentials or private payloads.
|
||||
page.add_init_script('''window.__voiceStats={bytes:0,packets:0};
|
||||
const original=RTCPeerConnection.prototype.getStats;
|
||||
const connections=[]; const Native=window.RTCPeerConnection;
|
||||
window.RTCPeerConnection=new Proxy(Native,{construct(target,args){
|
||||
const connection=Reflect.construct(target,args); connections.push(connection); return connection;}});
|
||||
setInterval(async()=>{for(const connection of connections){try{
|
||||
const report=await original.call(connection);
|
||||
report.forEach(item=>{if(item.type==='inbound-rtp'&&(item.kind==='audio'||item.mediaType==='audio')){
|
||||
window.__voiceStats.bytes=Math.max(window.__voiceStats.bytes,item.bytesReceived||0);
|
||||
window.__voiceStats.packets=Math.max(window.__voiceStats.packets,item.packetsReceived||0);}});
|
||||
}catch{}}},500);''')
|
||||
try:
|
||||
page.goto(args.url.rstrip('/') + '/', wait_until='networkidle')
|
||||
expect(page.locator('#loginSubmit')).to_be_enabled(timeout=30000)
|
||||
page.locator('#loginUsername').fill('admin')
|
||||
page.locator('#loginPassword').fill(values['admin_password'])
|
||||
page.locator('#loginSubmit').click()
|
||||
expect(page.locator('#primaryInput')).to_be_enabled(timeout=30000)
|
||||
# Prefer restored prominent voice entry, tolerate current compatibility ID.
|
||||
for selector in ['#talkToHope', '#talkVoice', '#openVoice']:
|
||||
if page.locator(selector).count() and page.locator(selector).is_visible():
|
||||
page.locator(selector).click()
|
||||
break
|
||||
expect(page.locator('#voiceModeBtn')).to_be_visible(timeout=10000)
|
||||
page.locator('#voiceModeBtn').click()
|
||||
expect(page.locator('#startBtn')).to_be_enabled(timeout=10000)
|
||||
page.locator('#startBtn').click()
|
||||
deadline = time.monotonic() + args.timeout
|
||||
while time.monotonic() < deadline:
|
||||
status = page.locator('#status').inner_text()
|
||||
body = page.locator('#messages').inner_text() if page.locator('#messages').count() else ''
|
||||
receipt['connected'] |= 'Microphone on' in status or 'Secure room' in status
|
||||
# Synthetic question and expected answer facts, not transcript dumps.
|
||||
user_text = ' '.join(page.locator('#messages .message-row.user').all_inner_texts()).lower()
|
||||
receipt['user_transcript'] |= bool(re.search(r'(?:two|2)\s*(?:plus|\+)\s*(?:two|2)', user_text))
|
||||
assistant_text = ' '.join(page.locator('#messages .message-row.agent').all_inner_texts()).lower()
|
||||
receipt['assistant_transcript'] |= 'four' in assistant_text or '4' in assistant_text
|
||||
receipt['audio_track'] |= page.locator('audio[id^="audio-"]').count() > 0
|
||||
stats = page.evaluate('window.__voiceStats')
|
||||
receipt['audio_bytes'] = stats['bytes']
|
||||
if receipt['assistant_transcript'] and receipt['audio_bytes'] > 0:
|
||||
break
|
||||
page.wait_for_timeout(500)
|
||||
receipt['audio_playing'] = page.evaluate('''Array.from(document.querySelectorAll('audio[id^="audio-"]')).some(el=>!el.paused&&el.srcObject&&el.srcObject.getAudioTracks().some(track=>track.readyState==='live'))''')
|
||||
output = Path('.runtime/voice-qualification/live-receipt.json')
|
||||
output.write_text(json.dumps(receipt, indent=2) + '\n')
|
||||
print(json.dumps(receipt, sort_keys=True))
|
||||
assert receipt['connected'], 'synthetic_voice_connection_failed'
|
||||
assert receipt['user_transcript'], 'synthetic_voice_stt_not_observed'
|
||||
assert receipt['assistant_transcript'], 'synthetic_voice_answer_not_observed'
|
||||
assert receipt['audio_track'] and receipt['audio_bytes'] > 0, 'remote_tts_audio_not_observed'
|
||||
finally:
|
||||
if page.locator('#stopBtn').count() and page.locator('#stopBtn').is_visible() and page.locator('#stopBtn').is_enabled():
|
||||
page.locator('#stopBtn').click()
|
||||
# Delete only the ID captured for this newly created qualification
|
||||
# voice conversation. Never enumerate or remove other account chats.
|
||||
try:
|
||||
cleanup = page.evaluate('''async()=>{let removed=false;const cid=window.__qualificationVoiceID;
|
||||
if(cid){try{const result=await HopeAuth.request('/api/conversations/'+encodeURIComponent(cid),{method:'DELETE'});removed=result.ok===true;}catch{}}
|
||||
try{await HopeAuth.request('/api/auth/logout',{method:'POST'});}catch{}
|
||||
return {created_conversation_seen:!!cid,removed};}''')
|
||||
print(json.dumps({'qualification_cleanup': cleanup}, sort_keys=True))
|
||||
except Exception:
|
||||
print('qualification_cleanup_unavailable')
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user