VAD、打断与陈旧事件:练习答案
参考实现
复用Player:
python
class Player:
def __init__(self):
self.generation = 0
self.queue = []
self.played_ms = 0
def start(self):
self.generation += 1
self.queue.clear()
self.played_ms = 0
return self.generation
def receive(self, generation, duration):
if generation == self.generation:
self.queue.append(duration)
def play_one(self):
if self.queue:
self.played_ms += self.queue.pop(0)
p = Player()
old = p.start(); p.receive(old,100); p.play_one()
heard = p.played_ms
new = p.start()
p.receive(old,200)
p.receive(new,50)
assert heard == 100
assert p.queue == [50]
p.play_one()
assert p.played_ms == 50
print('stale audio rejected')
p = Player()
a = p.start(); b = p.start(); c = p.start()
p.receive(a,100); p.receive(b,100)
assert p.queue == []
p.receive(c,80); p.play_one()
assert p.played_ms == 80单纯调用取消API不能保证旧事件不再到达;身份守卫是播放器自己的责任。
返回题目。
真实会话变体
下面完整文件使用本章相同SDK环境,保存 answer_session.py 运行;实际回环WebSocket验证40ms与两段迟到音频。
python
import asyncio,base64,json,struct
from openai import AsyncOpenAI
from openai.types.realtime import RealtimeServerEvent
from pydantic import TypeAdapter
from websockets.asyncio.server import serve
schema=TypeAdapter(RealtimeServerEvent)
audio=struct.pack('<h',1000)*2400 # 100ms mono PCM16 @24kHz
async def emit(ws,event):
schema.validate_python(event)
await ws.send(json.dumps(event))
async def server(ws):
requests=[json.loads(await ws.recv()) for _ in range(4)]
assert [r['type'] for r in requests]==[
'session.update','input_audio_buffer.append','input_audio_buffer.commit','response.create']
assert base64.b64decode(requests[1]['audio'])==audio
await emit(ws,{'type':'response.created','event_id':'e1',
'response':{'id':'r1','status':'in_progress'}})
delta={'type':'response.output_audio.delta','event_id':'e2','response_id':'r1',
'item_id':'assistant1','output_index':0,'content_index':0,
'delta':base64.b64encode(audio).decode()}
await emit(ws,delta)
await emit(ws,{'type':'input_audio_buffer.speech_started','event_id':'e3',
'audio_start_ms':120,'item_id':'user2'})
cancel=json.loads(await ws.recv());truncate=json.loads(await ws.recv())
assert cancel['type']=='response.cancel' and cancel['response_id']=='r1'
assert truncate['type']=='conversation.item.truncate'
assert truncate['item_id']=='assistant1' and truncate['audio_end_ms']==40
await emit(ws,dict(delta,event_id='late'))
await emit(ws,dict(delta,event_id='late2'))
await emit(ws,{'type':'response.done','event_id':'e4',
'response':{'id':'r1','status':'cancelled'}})
async def main():
async with serve(server,'127.0.0.1',0) as fixture:
port=fixture.sockets[0].getsockname()[1]
async with AsyncOpenAI(api_key='local-fixture-only',
websocket_base_url=f'ws://127.0.0.1:{port}') as client:
async with client.realtime.connect(model='local-fixture',
websocket_connection_options={'proxy':None}) as connection:
await connection.session.update(session={'type':'realtime',
'output_modalities':['audio'],'audio':{'input':{'format':{'type':'audio/pcm','rate':24000},
'turn_detection':None},'output':{'format':{'type':'audio/pcm','rate':24000}}}})
await connection.input_audio_buffer.append(audio=base64.b64encode(audio).decode())
await connection.input_audio_buffer.commit()
await connection.response.create()
accepted=0;active=None;item=None;played_samples=0;queue=[];done=False
async with asyncio.timeout(5):
async for event in connection:
if event.type=='response.created':active=event.response.id
elif event.type=='response.output_audio.delta' and event.response_id==active:
chunk=base64.b64decode(event.delta);queue.append(chunk)
item=event.item_id;accepted+=1
# 设备回执夹具:只播出960样本(40ms),其余仍在缓冲。
played_samples=960
elif event.type=='input_audio_buffer.speech_started' and active:
old=active;active=None;queue.clear()
await connection.response.cancel(response_id=old)
if item:
await connection.conversation.item.truncate(item_id=item,content_index=0,
audio_end_ms=played_samples*1000//24000)
elif event.type=='error':raise RuntimeError(event.error.message)
elif event.type=='response.done':
assert event.response.status=='cancelled'
done=True;break
assert done and accepted==1 and queue==[]
print('real SDK socket, cancellation, truncation and late audio passed')
asyncio.run(main())失败终态与取消不是同一个产品结果。下面独立片段演示三个终态都显式处理,不把失败转成完成:
python
def terminal(status):
if status=='completed':return '完成'
if status=='cancelled':return '已中断'
raise RuntimeError('response failed: '+status)
assert terminal('completed')=='完成'
assert terminal('cancelled')=='已中断'
try:terminal('failed')
except RuntimeError:pass
else:raise AssertionError('failure accepted')设备与端点
静音块索引3、4分别结束于80、100ms,因此hangover=2在100ms发stop。纯音RMS约幅度/√2,0.01幅度低于0.05阈值;增益改变幅度,阈值不适合脱离设备固定。
保存 answer_gate.py,使用正文numpy环境独立运行:
python
import numpy as np
rate=16000;size=320 # 每块20ms
silence=np.zeros(size)
tone=.2*np.sin(2*np.pi*440*np.arange(size)/rate)
def boundaries(blocks,threshold=.05,hangover=3):
speaking=False;quiet=0;events=[]
for i,block in enumerate(blocks):
if len(block)!=size:raise ValueError('partial frame')
active=np.sqrt(np.mean(np.asarray(block,dtype=float)**2))>=threshold
if active:
quiet=0
if not speaking:events.append(('start',i*20));speaking=True
elif speaking:
quiet+=1
if quiet>=hangover:
events.append(('stop',(i+1)*20));speaking=False;quiet=0
return events
assert boundaries([silence,tone,tone,silence,silence,silence])==[('start',20),('stop',120)]
assert boundaries([silence]*6)==[]
assert boundaries([tone]*6)==[('start',0)]
print('energy gate and hangover passed')
assert boundaries([silence,tone,tone,silence,silence],hangover=2)[-1]==('stop',100)
assert boundaries([tone*.05]*6)==[]
print('endpoint variants passed')设备验收应观察track.readyState和浏览器采集指示,检查拒绝、迟到授权、停止及离开页面。纯函数测试不能代替这一手动检查;不要自动授予麦克风权限来让测试变绿。
转录归并变体
先将正文完整保存为transcript_lab.py,再把答案保存为asr_answers.py,同目录运行;导入正文不会自动打开socket。
python
from transcript_lab import Transcripts,schema,event,final,PREFIX
from pathlib import Path
import tempfile
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'state.sqlite';s=Transcripts(path,'s1')
def send(e):return s.accept('s1',schema.validate_python(e))
send(event('input_audio_buffer.committed','c1','u1',previous_item_id=None))
send(event('input_audio_buffer.committed','c2','u2',previous_item_id='u1'))
s.cancel('u1')
assert send(final('late','u1','不要执行'))=='cancelled' and not s.ready
send(event(PREFIX+'failed','error','u2',content_index=0,
error={'type':'server_error','code':'asr_failed','message':'no speech','param':None}))
assert send(final('late2','u2','也不要执行'))=='failed'
send(event('input_audio_buffer.committed','c3','u3',previous_item_id='u2'))
send(final('f3','u3','订单42'))
assert len(s.ready)==1
try:send(final('conflict','u3','订单43'))
except ValueError as e:assert str(e)=='conflicting final'
else:raise AssertionError('conflict accepted')
s.close();s=Transcripts(path,'s1')
send(event('input_audio_buffer.committed','replay-commit','u3',previous_item_id=None))
assert send(final('replay-final','u3','订单42'))=='duplicate-final' and s.ready==[]
s.close();s=Transcripts(path,'s2')
assert s.accept('s1',schema.validate_python(final('old','u3','旧连接')))=='old-session'
s.close()
print('cancel, ASR failure, conflicting final and durable duplicate checks passed')数据库final去重只防同一连接item再次通知。新连接重复同一订票意图仍有新item,必须使用业务intent ID和参数摘要识别重复;取消字幕也不回滚已提交订单。
设备回调归属
将设备正文HTML存为device_lab.html,下面保存device_test.cjs,在同目录用Node.js20+执行node device_test.cjs。测试主动控制回调先后,验证程序资源归属;没有调用真实麦克风。
javascript
const fs=require('node:fs'),vm=require('node:vm'),assert=require('node:assert/strict');
const code=fs.readFileSync('device_lab.html','utf8').match(/<script>([\s\S]*?)<\/script>/)[1];
const nodes=Object.fromEntries(['start','stop','status','audio'].map(x=>[x,{}]));
const recordings=[],streams=[],handlers={};let pending=null,urlCount=0,revoked=0;
function makeStream(){const track={stopped:false,stop(){this.stopped=true}};const s={getTracks:()=>[track]};streams.push(s);return s}
class Recorder{constructor(s){this.stream=s;this.state='inactive';this.mimeType='audio/webm';recordings.push(this)}start(){this.state='recording'}stop(){this.state='inactive'}}
vm.runInNewContext(code,{document:{querySelector:x=>nodes[x.slice(1)]},navigator:{mediaDevices:{getUserMedia:()=>pending?pending:Promise.resolve(makeStream())}},MediaRecorder:Recorder,Blob,URL:{createObjectURL:()=>`blob:${++urlCount}`,revokeObjectURL:()=>revoked++},addEventListener:(n,f)=>handlers[n]=f});
(async()=>{
await nodes.start.onclick();nodes.stop.onclick();await nodes.start.onclick();
const newer=recordings[1];recordings[0].onstop();recordings[0].onerror();
assert.equal(newer.state,'recording');assert.equal(nodes.status.textContent,'本地录音中');
assert.equal(newer.stream.getTracks()[0].stopped,false);
nodes.stop.onclick();newer.onstop();assert.equal(urlCount,1);
let grant;pending=new Promise(resolve=>grant=resolve);const startPending=nodes.start.onclick();
nodes.stop.onclick();const late=makeStream();grant(late);await startPending;
assert(late.getTracks()[0].stopped);assert.equal(recordings.length,2);pending=null;
await nodes.start.onclick();const last=recordings[2];handlers.pagehide();last.onstop();
assert(last.stream.getTracks()[0].stopped);assert.equal(urlCount,1);assert.equal(revoked,1);
console.log('old stop/error, late permission and pagehide do not own new resources');
})();第一组让旧stop/error迟到,断言新录音仍在进行;第二组让许可Promise晚于停止,断言迟到track被释放;第三组pagehide后回调不得创建新URL。真实设备仍需手动检查权限拒绝和指示灯释放。