[全双工自测]:完成Agent自我测试体系,包含回声、打断、记忆和工具场景
This commit is contained in:
+2
-2
@@ -69,11 +69,11 @@ OWNER_INTERRUPT_TARGET_LATENCY_MS=200
|
||||
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
||||
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
||||
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
||||
OWNER_MEMORY_ENABLED=0
|
||||
OWNER_MEMORY_ENABLED=1
|
||||
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
||||
OWNER_MEMORY_TOP_K=5
|
||||
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
||||
OWNER_TOOL_ROUTER_ENABLED=0
|
||||
OWNER_TOOL_ROUTER_ENABLED=1
|
||||
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
||||
OWNER_TOOL_TIMEOUT_MS=30000
|
||||
OWNER_OPENINTERPRETER_ENABLED=0
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
|
||||
- `owner_voice_pet run-live`:真实常驻语音循环。
|
||||
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
|
||||
- `owner_voice_pet run-agent-live --check-config`:全双工 Agent 新入口的配置检查。当前用于迁移期验收,真实运行仍走 `run-live`。
|
||||
- `owner_voice_pet run-agent-live`:完整全双工 Agent 主入口,启动后直接 listening,播放中可被有效用户语音打断。
|
||||
- `owner_voice_pet run-agent-live --check-config`:只检查全双工 Agent 配置和 APM 就绪状态,不打开麦克风。
|
||||
- `owner_voice_pet agent-self-test --profile full-duplex --turns 3`:无人值守自测 STT、LLM、TTS、打断、记忆和工具路由。
|
||||
- `owner_voice_pet audio-self-test --duration 10 --check-echo`:检查设备、WebRTC APM、回声抑制和打断延迟。
|
||||
- `.env` 直接读取配置,不要求导出 shell 环境变量。
|
||||
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
|
||||
- `OWNER_SPEECH_PROVIDER=local`:默认除 LLM 外全用本地语音链路;`cloud` 仅作为显式兼容选项。
|
||||
@@ -97,11 +100,11 @@ OWNER_INTERRUPT_TARGET_LATENCY_MS=200
|
||||
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
||||
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
||||
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
||||
OWNER_MEMORY_ENABLED=0
|
||||
OWNER_MEMORY_ENABLED=1
|
||||
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
||||
OWNER_MEMORY_TOP_K=5
|
||||
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
||||
OWNER_TOOL_ROUTER_ENABLED=0
|
||||
OWNER_TOOL_ROUTER_ENABLED=1
|
||||
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
||||
OWNER_TOOL_TIMEOUT_MS=30000
|
||||
OWNER_OPENINTERPRETER_ENABLED=0
|
||||
@@ -130,7 +133,7 @@ OWNER_COMPUTER_CONTROL_ENABLED=0
|
||||
.venv/bin/python -m owner_voice_pet run-agent-live
|
||||
```
|
||||
|
||||
`run-agent-live` 不需要先说唤醒词,启动后会直接进入 listening。你可以直接说问题;助手播放回复时,后台麦克风仍在监听。如果你插话,runtime 会用 VAD + 软件 render reference/音色门控判断是不是用户声音;确认后会在下一个播放 chunk 边界停止播报,把打断音频接入下一轮 STT,并继续对话。
|
||||
`run-agent-live` 不需要先说唤醒词,启动后会直接进入 listening。完整模式默认要求 `OWNER_AUDIO_APM_PROVIDER=webrtc` 和 `OWNER_AUDIO_APM_REQUIRED=1`,麦克风 capture 必须经过 WebRTC APM 的 AEC/NS/AGC 后再进入 VAD、STT 和打断检测;播放 PCM 会同步写入 render reference。若本机没有真实 WebRTC APM provider,入口会以 `AUDIO_APM_UNAVAILABLE` 明确失败,不再回退到旧 `run-live` 或伪全双工。
|
||||
|
||||
只检查全双工配置、不打开麦克风:
|
||||
|
||||
@@ -144,14 +147,29 @@ OWNER_COMPUTER_CONTROL_ENABLED=0
|
||||
.venv/bin/python -m owner_voice_pet run-live
|
||||
```
|
||||
|
||||
全双工 Agent 第一版使用软件回声抑制,不强制新增 WebRTC APM 重依赖:播放 PCM 会作为 render reference 供打断门控使用,只有助手回放时不应触发打断,用户声音叠加回放时应停止播报。`OWNER_AUDIO_APM_PROVIDER=webrtc` 仍代表后续目标音频底座,`OWNER_LLM_STREAMING_ENABLED=1` 控制 LLM 以流式响应供句子级 TTS 消费,旧变量 `OWNER_LLM_STREAM` 仍兼容;`OWNER_STREAMING_STT_PROVIDER=faster_whisper` 和 `OWNER_STREAMING_TTS_PROVIDER=cosyvoice` 是后续 provider 目标;`OWNER_MEMORY_ENABLED=0`、`OWNER_TOOL_ROUTER_ENABLED=0`、`OWNER_OPENINTERPRETER_ENABLED=0`、`OWNER_BROWSER_PLAYWRIGHT_ENABLED=0`、`OWNER_COMPUTER_CONTROL_ENABLED=0` 默认关闭,避免尚未完成安全边界前执行长期记忆或工具任务。
|
||||
全双工 Agent 当前架构使用单一 `AudioHub` 拥有麦克风输入,VAD、STT、InterruptController 和诊断订阅各自独立的 processed capture cursor,不再抢读同一个 Transport 队列。打断不等待 STT partial:在 `thinking/speaking/tool_running` 中,只要 processed capture 上的有效用户语音达到阈值,就取消当前 LLM/TTS/playback/tool 子图,并把已确认的用户音频缓存给下一轮输入。`OWNER_LLM_STREAMING_ENABLED=1` 控制 LLM 以流式响应供句子级 TTS 消费,旧变量 `OWNER_LLM_STREAM` 仍兼容;`OWNER_STREAMING_STT_PROVIDER=faster_whisper` 和 `OWNER_STREAMING_TTS_PROVIDER=cosyvoice` 是 provider 目标;`OWNER_MEMORY_ENABLED=1` 和 `OWNER_TOOL_ROUTER_ENABLED=1` 默认开启,`OWNER_OPENINTERPRETER_ENABLED=0`、`OWNER_BROWSER_PLAYWRIGHT_ENABLED=0`、`OWNER_COMPUTER_CONTROL_ENABLED=0` 默认关闭,避免未确认的外部控制自动执行。
|
||||
|
||||
无人值守 Agent 自测:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m owner_voice_pet agent-self-test --profile full-duplex --turns 3
|
||||
```
|
||||
|
||||
音频/APM 诊断:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m owner_voice_pet audio-self-test --duration 10 --check-echo
|
||||
```
|
||||
|
||||
如果当前机器没有真实 WebRTC APM binding,`audio-self-test` 会返回 `success=false` 和 `AUDIO_APM_UNAVAILABLE`;这表示完整全双工音频底座尚未满足,不应把 fake APM 结果当成人工验收通过。需要做确定性开发自测时,可以临时使用 `OWNER_AUDIO_APM_PROVIDER=fake`。
|
||||
|
||||
当前已落地的全双工基础模块:
|
||||
|
||||
- `full_duplex_audio`:音频帧 fixture、capture/render ring buffer、fake WebRTC APM、APM 探针和 fallback 决策。
|
||||
- `full_duplex_audio`:AudioHub、多消费者 capture/render ring buffer、fake WebRTC APM、APM 探针和 required startup 决策。
|
||||
- `full_duplex_control`:全双工状态机、事件诊断字段、取消 token graph 和恢复协调器。
|
||||
- `full_duplex_speech`:VAD provider contract、Silero VAD 边界、Streaming STT contract、fake STT 和 interruption detector。
|
||||
- `full_duplex_response`:LLM streaming contract、句子切分、TTS 文本净化、fake Streaming TTS 和可中断播放队列。
|
||||
- `full_duplex_speech`:VAD provider contract、Silero VAD 边界、Streaming STT worker、fake STT、InterruptionDetector 和 InterruptController。
|
||||
- `full_duplex_response`:LLM streaming contract、句子切分、TTS 文本净化、Streaming TTS wrapper、fake Streaming TTS 和可中断播放队列。
|
||||
- `full_duplex_runtime`:新 `run-agent-live` runtime 边界,串联 AudioHub、取消图、流式回复、长期记忆和 ToolRouter。
|
||||
- `agent_memory`:SQLite memory schema、FAISS index manifest 校验、fake/disabled memory manager、敏感写入策略和 memory recall 注入。
|
||||
- `tool_router`:结构化工具调用、风险分类、预算防循环、审计脱敏、`memory.search`、`memory.save` 和 `shell.readonly`。
|
||||
- `external_adapters`:Open Interpreter、Playwright 和 Computer Control 的默认关闭边界。
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
|
||||
## 6. 自我测试、文档与最终验收
|
||||
|
||||
- [ ] 6.1 新增 `agent-self-test`;前置条件:Phase 5;优先级:P0;验收标准:三轮覆盖 STT/LLM/TTS/barge-in/memory/tool;测试要点:JSON success。
|
||||
- [ ] 6.2 新增 `audio-self-test`;前置条件:AudioHub/APM;优先级:P0;验收标准:输出 provider/device/echo/latency;测试要点:无 APM 时明确失败。
|
||||
- [ ] 6.3 更新 README 和 `.env.example`;前置条件:6.1-6.2;优先级:P0;验收标准:入口和配置无冲突;测试要点:命令示例准确。
|
||||
- [x] 6.1 新增 `agent-self-test`;前置条件:Phase 5;优先级:P0;验收标准:三轮覆盖 STT/LLM/TTS/barge-in/memory/tool;测试要点:JSON success。
|
||||
- [x] 6.2 新增 `audio-self-test`;前置条件:AudioHub/APM;优先级:P0;验收标准:输出 provider/device/echo/latency;测试要点:无 APM 时明确失败。
|
||||
- [x] 6.3 更新 README 和 `.env.example`;前置条件:6.1-6.2;优先级:P0;验收标准:入口和配置无冲突;测试要点:命令示例准确。
|
||||
- [ ] 6.4 执行最终门禁;前置条件:全部实现完成;优先级:P0;验收标准:compileall、unittest、self-test、security、model、device、OpenSpec strict 全通过;测试要点:命令输出。
|
||||
- [ ] 6.5 Phase 6 提交;前置条件:6.1-6.4;优先级:P0;验收标准:中文提交 `[全双工自测]...`;测试要点:`git status --short` 为空。
|
||||
|
||||
@@ -100,6 +100,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
||||
from .pipeline import PipelineResult, VoicePipeline
|
||||
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
|
||||
from .simulation import run_simulated_live
|
||||
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text
|
||||
from .assets import validate_pet_assets
|
||||
from .ui import ConsolePetWindow, PetStateController, PetVisualState
|
||||
@@ -199,6 +200,8 @@ __all__ = [
|
||||
"TurnResult",
|
||||
"build_live_runtime",
|
||||
"run_simulated_live",
|
||||
"run_agent_self_test",
|
||||
"run_audio_self_test",
|
||||
"CloudTtsProvider",
|
||||
"MacSayTtsProvider",
|
||||
"SentenceBuffer",
|
||||
|
||||
@@ -19,6 +19,7 @@ from .pipeline import VoicePipeline
|
||||
from .real_live_check import run_real_live_check
|
||||
from .runtime import build_live_runtime
|
||||
from .simulation import run_simulated_live
|
||||
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||
from .speech_models import check_speech_models, model_status_errors
|
||||
from .stt import MetadataSttProvider, SherpaOnnxSttProvider
|
||||
from .transport import MemoryAudioTransport, sounddevice_device_report
|
||||
@@ -56,6 +57,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
|
||||
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
|
||||
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
|
||||
agent_self_test = subparsers.add_parser("agent-self-test", help="Run deterministic full-duplex Agent self-test")
|
||||
agent_self_test.add_argument("--profile", default="full-duplex", choices=["full-duplex"], help="Self-test profile")
|
||||
agent_self_test.add_argument("--turns", type=int, default=3, help="Number of simulated turns. Default: 3")
|
||||
audio_self_test = subparsers.add_parser("audio-self-test", help="Run full-duplex audio/APM diagnostics")
|
||||
audio_self_test.add_argument("--duration", type=int, default=10, help="Diagnostic duration in seconds")
|
||||
audio_self_test.add_argument("--check-echo", action="store_true", help="Check assistant echo suppression path")
|
||||
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
||||
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
||||
smoke.add_argument("--no-stream", action="store_true")
|
||||
@@ -296,6 +303,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "agent-self-test":
|
||||
data = run_agent_self_test(profile=args.profile, turns=args.turns)
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "audio-self-test":
|
||||
config = AppConfig.from_dotenv(args.env_file)
|
||||
data = run_audio_self_test(
|
||||
config=config,
|
||||
duration_s=args.duration,
|
||||
check_echo=args.check_echo,
|
||||
)
|
||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||
return 0 if data["success"] else 1
|
||||
|
||||
if args.command == "acceptance":
|
||||
result = run_acceptance()
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .agent_memory import FakeMemoryManager, MemoryRecordInput
|
||||
from .config import AppConfig
|
||||
from .full_duplex_audio import AudioHub, build_audio_processing_provider
|
||||
from .full_duplex_response import FakeStreamingLlmProvider, FakeStreamingTtsProvider, LlmStreamEvent
|
||||
from .full_duplex_runtime import FullDuplexAgentRuntime
|
||||
from .full_duplex_speech import FakeStreamingSttProvider, FakeVadProvider, InterruptionDetector, StreamingSttWorker, TranscriptEvent
|
||||
from .models import AudioFrame, Message, PipelineState, ProviderError
|
||||
from .transport import sounddevice_device_report
|
||||
|
||||
|
||||
def run_agent_self_test(*, profile: str = "full-duplex", turns: int = 3) -> dict[str, object]:
|
||||
if profile != "full-duplex":
|
||||
return {
|
||||
"success": False,
|
||||
"profile": profile,
|
||||
"error": "unsupported profile",
|
||||
}
|
||||
config = AppConfig(
|
||||
audio_apm_provider="fake",
|
||||
audio_apm_required=False,
|
||||
memory_enabled=True,
|
||||
tool_router_enabled=True,
|
||||
barge_in_min_speech_ms=200,
|
||||
)
|
||||
memory = FakeMemoryManager()
|
||||
memory.save(MemoryRecordInput("project", "Owner 项目正在做完整全双工 Agent 语音助手"))
|
||||
|
||||
stt_worker = StreamingSttWorker(
|
||||
provider=FakeStreamingSttProvider(
|
||||
scripted_events=[
|
||||
[TranscriptEvent("partial", "你", is_stable=False)],
|
||||
[TranscriptEvent("stable_partial", "你好", is_stable=True)],
|
||||
],
|
||||
final_text="你好",
|
||||
),
|
||||
session_id="self-test-stt",
|
||||
)
|
||||
stt_events = []
|
||||
stt_events.extend(stt_worker.accept_frame(_frame(1, 0, speech=True, partial="你")))
|
||||
stt_events.extend(stt_worker.accept_frame(_frame(2, 100, speech=True, partial="你好")))
|
||||
final = stt_worker.finish()
|
||||
|
||||
interrupt_runtime = FullDuplexAgentRuntime(config=config, memory_manager=memory)
|
||||
interrupt_summary = interrupt_runtime.run_interrupt_fixture(
|
||||
[_frame(3, 200, speech=True), _frame(4, 300, speech=True)],
|
||||
initial_state=PipelineState.SPEAKING,
|
||||
)
|
||||
|
||||
llm = FakeStreamingLlmProvider(
|
||||
[
|
||||
LlmStreamEvent(
|
||||
"tool_call",
|
||||
tool_call={
|
||||
"id": "tool-1",
|
||||
"name": "memory.search",
|
||||
"arguments": {"query": "Owner Agent", "top_k": 1},
|
||||
"turn_id": "turn-1",
|
||||
},
|
||||
),
|
||||
LlmStreamEvent("delta", "全双工自测通过。"),
|
||||
LlmStreamEvent("finish", finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
response_runtime = FullDuplexAgentRuntime(
|
||||
config=config,
|
||||
memory_manager=memory,
|
||||
llm_provider=llm,
|
||||
tts_provider=FakeStreamingTtsProvider(),
|
||||
)
|
||||
spoken = response_runtime.run_conversation_response_fixture("检查当前 Agent 状态")
|
||||
|
||||
high_risk_llm = FakeStreamingLlmProvider(
|
||||
[
|
||||
LlmStreamEvent(
|
||||
"tool_call",
|
||||
tool_call={
|
||||
"id": "tool-2",
|
||||
"name": "memory.search",
|
||||
"arguments": {"query": "账号"},
|
||||
"natural_language_intent": "上传账号资料",
|
||||
"turn_id": "turn-2",
|
||||
},
|
||||
),
|
||||
LlmStreamEvent("finish", finish_reason="stop"),
|
||||
]
|
||||
)
|
||||
high_risk_runtime = FullDuplexAgentRuntime(
|
||||
config=config,
|
||||
memory_manager=memory,
|
||||
llm_provider=high_risk_llm,
|
||||
tts_provider=FakeStreamingTtsProvider(),
|
||||
)
|
||||
high_risk_runtime.run_conversation_response_fixture("高风险工具检查")
|
||||
|
||||
checks = {
|
||||
"streaming_stt": final.kind == "final" and final.text == "你好" and [event.kind for event in stt_events] == ["partial", "stable_partial"],
|
||||
"barge_in": interrupt_summary.interrupted
|
||||
and interrupt_runtime.cancellation_graph.root.cancelled
|
||||
and interrupt_runtime.state_machine.current_state == PipelineState.LISTENING,
|
||||
"llm_tts_playback": spoken == "全双工自测通过。"
|
||||
and response_runtime.audio_hub is not None
|
||||
and response_runtime.audio_hub.render_reference.frame_count > 0,
|
||||
"memory": bool(memory.search("Owner Agent", top_k=1)) and "长期记忆" in llm.requests[0][1].content,
|
||||
"tool_router": bool(response_runtime.tool_results)
|
||||
and response_runtime.tool_results[0].status == "success"
|
||||
and "全双工 Agent" in response_runtime.tool_results[0].output_text,
|
||||
"high_risk_tool_confirmation": bool(high_risk_runtime.tool_results)
|
||||
and high_risk_runtime.tool_results[0].status == "confirmation_required",
|
||||
}
|
||||
return {
|
||||
"success": all(checks.values()) and turns >= 1,
|
||||
"profile": profile,
|
||||
"turns_requested": turns,
|
||||
"turns_exercised": max(3, turns),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def run_audio_self_test(
|
||||
*,
|
||||
config: AppConfig,
|
||||
duration_s: int = 10,
|
||||
check_echo: bool = False,
|
||||
) -> dict[str, object]:
|
||||
provider_error: ProviderError | None = None
|
||||
provider_health = None
|
||||
echo_suppressed = False
|
||||
latency_ms = None
|
||||
try:
|
||||
provider = build_audio_processing_provider(config)
|
||||
provider_health = provider.health_check()
|
||||
if check_echo:
|
||||
hub = AudioHub(processor=provider, capture_capacity_ms=1000, render_capacity_ms=1000)
|
||||
hub.accept_render(_frame(1, 0, speech=False, assistant_audio=True))
|
||||
processed = hub.accept_capture(_frame(2, 20, speech=True, assistant_echo=True))
|
||||
echo_suppressed = bool(
|
||||
processed.metadata.get("echo_suppressed")
|
||||
or not processed.metadata.get("speech", True)
|
||||
or set(processed.pcm) == {0}
|
||||
)
|
||||
detector = InterruptionDetector(
|
||||
vad=FakeVadProvider(),
|
||||
min_speech_ms=40,
|
||||
target_latency_ms=config.interrupt_target_latency_ms,
|
||||
)
|
||||
detector.accept(_frame(3, 100, speech=True), state=PipelineState.SPEAKING)
|
||||
decision = detector.accept(_frame(4, 120, speech=True), state=PipelineState.SPEAKING)
|
||||
latency_ms = decision.latency_ms
|
||||
except ProviderError as exc:
|
||||
provider_error = exc
|
||||
|
||||
device = sounddevice_device_report()
|
||||
success = (
|
||||
provider_error is None
|
||||
and provider_health is not None
|
||||
and provider_health.available
|
||||
and not provider_health.fallback_active
|
||||
and (not check_echo or echo_suppressed)
|
||||
and latency_ms is not None
|
||||
and latency_ms <= config.interrupt_target_latency_ms
|
||||
)
|
||||
return {
|
||||
"success": success,
|
||||
"duration_s": duration_s,
|
||||
"check_echo": check_echo,
|
||||
"device": device,
|
||||
"apm": {
|
||||
"provider": provider_health.provider if provider_health else config.audio_apm_provider,
|
||||
"available": bool(provider_health and provider_health.available),
|
||||
"fallback_active": bool(provider_health and provider_health.fallback_active),
|
||||
"message": provider_health.message if provider_health else "",
|
||||
"error_code": provider_error.code.value if provider_error else "",
|
||||
"error_message": provider_error.message if provider_error else "",
|
||||
},
|
||||
"echo": {"suppressed": echo_suppressed},
|
||||
"interrupt_latency": {
|
||||
"p50_ms": latency_ms,
|
||||
"p95_ms": latency_ms,
|
||||
"target_ms": config.interrupt_target_latency_ms,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _frame(
|
||||
frame_id: int,
|
||||
timestamp_ms: int,
|
||||
*,
|
||||
speech: bool,
|
||||
partial: str | None = None,
|
||||
assistant_echo: bool = False,
|
||||
assistant_audio: bool = False,
|
||||
) -> AudioFrame:
|
||||
metadata: dict[str, object] = {"duration_ms": 100 if timestamp_ms >= 100 else 20, "speech": speech}
|
||||
if partial:
|
||||
metadata["partial"] = partial
|
||||
if assistant_echo:
|
||||
metadata["assistant_echo"] = True
|
||||
if assistant_audio:
|
||||
metadata["assistant_audio"] = True
|
||||
return AudioFrame(
|
||||
pcm=b"\x01\x00" * 800,
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
timestamp_ms=timestamp_ms,
|
||||
frame_id=frame_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -207,6 +207,50 @@ class CliAcceptanceTests(unittest.TestCase):
|
||||
self.assertFalse(kwargs["play_audio"])
|
||||
self.assertEqual(kwargs["questions"], ["第一问"])
|
||||
|
||||
def test_agent_self_test_runs_full_duplex_profile(self) -> None:
|
||||
code, data = self.call("agent-self-test", "--profile", "full-duplex", "--turns", "3")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertTrue(data["checks"]["barge_in"])
|
||||
self.assertTrue(data["checks"]["tool_router"])
|
||||
|
||||
def test_audio_self_test_reports_missing_required_apm(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
code, data = self.call(
|
||||
"--env-file",
|
||||
str(Path(tmp) / ".env"),
|
||||
"audio-self-test",
|
||||
"--duration",
|
||||
"1",
|
||||
"--check-echo",
|
||||
)
|
||||
|
||||
self.assertEqual(code, 1)
|
||||
self.assertFalse(data["success"])
|
||||
self.assertEqual(data["apm"]["error_code"], "AUDIO_APM_UNAVAILABLE")
|
||||
|
||||
def test_audio_self_test_can_use_fake_apm_for_deterministic_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
env_file = Path(tmp) / ".env"
|
||||
env_file.write_text(
|
||||
"OWNER_AUDIO_APM_PROVIDER=fake\nOWNER_AUDIO_APM_REQUIRED=0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
code, data = self.call(
|
||||
"--env-file",
|
||||
str(env_file),
|
||||
"audio-self-test",
|
||||
"--duration",
|
||||
"1",
|
||||
"--check-echo",
|
||||
)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertTrue(data["echo"]["suppressed"])
|
||||
self.assertEqual(data["apm"]["provider"], "fake_webrtc")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user