Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

145 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Live microphone/ASR/TTS channel and a deterministic test channel."""
from __future__ import annotations
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from pathlib import Path
from typing import Dict, List, Protocol
class PhoneChannel(Protocol):
async def say(self, text: str) -> None: ...
async def listen(self, *, timeout: float = 30.0) -> str: ...
class LiveMicrophoneChannel:
"""A real cascaded phone-audio loop: OpenAI TTS -> speaker -> mic -> OpenAI ASR.
Local microphone/speaker are the call transport. The provider boundary is kept
behind this class so a PSTN/WebRTC transport can implement the same two methods.
"""
def __init__(self, *, language: str = "zh", voice: str = "coral"):
from openai import OpenAI
if not os.getenv("OPENAI_API_KEY"):
raise RuntimeError("实时语音需要 OPENAI_API_KEYOpenRouter 不提供 ASR/TTS")
self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=60, max_retries=1)
self.language = language
self.voice = voice
self.sample_rate = int(os.getenv("VOICE_SAMPLE_RATE", "16000"))
self.silence_seconds = float(os.getenv("VOICE_SILENCE_SECONDS", "0.9"))
self.threshold = float(os.getenv("VOICE_RMS_THRESHOLD", "0.012"))
self.latencies: List[Dict[str, float]] = []
async def say(self, text: str) -> None:
started = time.monotonic()
path = Path(tempfile.mkstemp(suffix=".mp3")[1])
def synthesize():
result = self.client.audio.speech.create(
model=os.getenv("OPENAI_TTS_MODEL", "tts-1"),
voice=self.voice,
input=text,
)
result.stream_to_file(path)
try:
await asyncio.to_thread(synthesize)
synth_done = time.monotonic()
player = os.getenv("AUDIO_PLAYER", "afplay")
proc = await asyncio.create_subprocess_exec(
player, str(path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
await proc.wait()
self.latencies.append({
"tts_seconds": round(synth_done - started, 3),
"playback_seconds": round(time.monotonic() - synth_done, 3),
})
finally:
path.unlink(missing_ok=True)
async def listen(self, *, timeout: float = 30.0) -> str:
path = Path(tempfile.mkstemp(suffix=".wav")[1])
started = time.monotonic()
try:
await asyncio.to_thread(self._record_vad, path, timeout)
record_done = time.monotonic()
def transcribe() -> str:
with path.open("rb") as audio:
response = self.client.audio.transcriptions.create(
model=os.getenv("OPENAI_ASR_MODEL", "whisper-1"),
file=audio,
language=self.language,
)
return response.text.strip()
text = await asyncio.to_thread(transcribe)
self.latencies.append({
"capture_seconds": round(record_done - started, 3),
"asr_seconds": round(time.monotonic() - record_done, 3),
})
print(f" [ASR] 用户:{text}")
return text
finally:
path.unlink(missing_ok=True)
def _record_vad(self, path: Path, timeout: float) -> None:
import numpy as np
import sounddevice as sd
block = 1024
frames = []
heard_speech = False
silent_blocks = 0
required_silence = max(1, int(self.silence_seconds * self.sample_rate / block))
deadline = time.monotonic() + timeout
print(" [麦克风] 请开始回答;检测到句末静音后自动提交……")
with sd.InputStream(samplerate=self.sample_rate, channels=1, dtype="float32", blocksize=block) as stream:
while time.monotonic() < deadline:
data, overflowed = stream.read(block)
if overflowed:
print(" [麦克风] 输入发生 overflow,继续采集")
mono = data[:, 0].copy()
frames.append(mono)
rms = float(np.sqrt(np.mean(np.square(mono))))
if rms >= self.threshold:
heard_speech = True
silent_blocks = 0
elif heard_speech:
silent_blocks += 1
if silent_blocks >= required_silence:
break
if not heard_speech:
raise TimeoutError("未在规定时间内检测到语音")
pcm = (np.concatenate(frames).clip(-1, 1) * 32767).astype("<i2")
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(self.sample_rate)
wav.writeframes(pcm.tobytes())
class ScriptedPhoneChannel:
"""Non-audio supplement for tests and orchestration debugging only."""
def __init__(self, answers: List[str]):
self.answers = asyncio.Queue()
for answer in answers:
self.answers.put_nowait(answer)
self.prompts: List[str] = []
async def say(self, text: str) -> None:
self.prompts.append(text)
print(f" [scripted-phone] {text}")
await asyncio.sleep(0)
async def listen(self, *, timeout: float = 30.0) -> str:
return await asyncio.wait_for(self.answers.get(), timeout)