Files
Owner/src/owner_voice_pet/real_live_check.py
T

535 lines
21 KiB
Python

from __future__ import annotations
import time
from contextlib import contextmanager
from datetime import datetime
import subprocess
import tempfile
import wave
from collections import Counter, deque
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, field, replace
from pathlib import Path
from .assistant_pipeline import VoiceAssistantPipeline
from .audio_preprocess import SherpaOnnxDenoiserPreprocessor
from .config import AppConfig
from .conversation import ConversationContext
from .events import PipelineEventBus
from .llm import OpenAICompatibleLlmProvider
from .models import AudioFrame, AudioSegment, Message, PlaybackResult, ReplyDelta, TransportHealth
from .stt import SherpaOnnxSttProvider
from .transport import SoundDeviceAudioTransport
from .tts import MacSayTtsProvider
from .vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, SherpaOnnxVadProvider
from .wakeword import SherpaOnnxKeywordWakeWordProvider
FRAME_MS = 20
SAMPLE_RATE = 16000
FRAME_BYTES = int(SAMPLE_RATE * FRAME_MS / 1000) * 2
DEFAULT_WAKE_TEXT = "小杰小杰。"
DEFAULT_QUESTIONS = [
"我叫阿明,请你记住我的名字。",
"我叫什么名字?",
]
STAGE_PAIRS = [
("wake_wait", "wake_listening", "wake_detected"),
("acknowledgement", "ack_started", "question_prompt"),
("wait_for_speech", "capture_started", "speech_started"),
("capture", "speech_started", "speech_ended"),
("final_stt", "stt_started", "transcript_final"),
("llm_to_first_tts", "llm_started", "tts_started"),
("turn_total", "wake_listening", "standby_resumed"),
]
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="milliseconds")
def _epoch_ms() -> int:
return int(time.time() * 1000)
def _elapsed_ms(start: float, end: float | None = None) -> int:
return int(round(((end if end is not None else time.perf_counter()) - start) * 1000))
class TimingRecorder:
def __init__(self) -> None:
self.started_at = _now_iso()
self.started_epoch_ms = _epoch_ms()
self._started_mono = time.perf_counter()
self.finished_at: str | None = None
self.finished_epoch_ms: int | None = None
self.duration_ms: int | None = None
self.phases: list[dict[str, object]] = []
self.events: list[dict[str, object]] = []
@contextmanager
def phase(self, name: str) -> Iterable[None]:
started_mono = time.perf_counter()
record: dict[str, object] = {
"name": name,
"started_at": _now_iso(),
"start_offset_ms": _elapsed_ms(self._started_mono, started_mono),
}
try:
yield
finally:
finished_mono = time.perf_counter()
record["finished_at"] = _now_iso()
record["duration_ms"] = _elapsed_ms(started_mono, finished_mono)
self.phases.append(record)
def record_event(self, event_type: str, *, turn_id: int | None, message: str) -> None:
self.events.append(
{
"type": event_type,
"turn": turn_id,
"at": _now_iso(),
"offset_ms": _elapsed_ms(self._started_mono),
"message": message,
}
)
def finish(self) -> None:
self.finished_at = _now_iso()
self.finished_epoch_ms = _epoch_ms()
self.duration_ms = _elapsed_ms(self._started_mono)
def to_json(self) -> dict[str, object]:
if self.duration_ms is None:
self.finish()
return {
"started_at": self.started_at,
"started_epoch_ms": self.started_epoch_ms,
"finished_at": self.finished_at,
"finished_epoch_ms": self.finished_epoch_ms,
"duration_ms": self.duration_ms,
"phases": self.phases,
"events": self.events,
"stage_timings": _stage_timings_from_events(self.events),
}
@dataclass(slots=True)
class RealLiveCheckReporter:
statuses: list[dict[str, object]] = field(default_factory=list)
partials: list[dict[str, object]] = field(default_factory=list)
finals: list[dict[str, object]] = field(default_factory=list)
errors: list[dict[str, object]] = field(default_factory=list)
def status(self, state: str, message: str, *, turn_id: int | None = None) -> None:
self.statuses.append({"turn": turn_id, "state": state, "message": message})
def transcript(self, text: str, *, final: bool, turn_id: int | None = None) -> None:
target = self.finals if final else self.partials
target.append({"turn": turn_id, "text": text})
def error(self, stage: str, code: str, message: str, *, turn_id: int | None = None) -> None:
self.errors.append({"turn": turn_id, "stage": stage, "code": code, "message": message})
class RecordingLlmProvider:
def __init__(self, delegate: OpenAICompatibleLlmProvider, timing: TimingRecorder) -> None:
self.delegate = delegate
self.timing = timing
self.calls: list[list[Message]] = []
self.request_timings: list[dict[str, object]] = []
def stream_reply(self, messages: Sequence[Message]) -> Iterable[ReplyDelta]:
self.calls.append(list(messages))
sent_mono = time.perf_counter()
request_timing: dict[str, object] = {
"call_index": len(self.calls),
"sent_at": _now_iso(),
"sent_offset_ms": _elapsed_ms(self.timing._started_mono, sent_mono),
"message_count": len(messages),
"last_user_preview": _last_user_preview(messages),
}
first_delta_mono: float | None = None
try:
for delta in self.delegate.stream_reply(messages):
if first_delta_mono is None and delta.text_delta:
first_delta_mono = time.perf_counter()
request_timing["first_delta_at"] = _now_iso()
request_timing["first_delta_ms"] = _elapsed_ms(sent_mono, first_delta_mono)
yield delta
finally:
finished_mono = time.perf_counter()
request_timing["finished_at"] = _now_iso()
request_timing["duration_ms"] = _elapsed_ms(sent_mono, finished_mono)
if first_delta_mono is None:
request_timing["first_delta_ms"] = None
self.request_timings.append(request_timing)
class FixtureLiveAudioTransport:
"""Generated microphone input with optional real speaker output."""
def __init__(
self,
frames: list[AudioFrame],
*,
play_audio: bool = True,
output_transport: SoundDeviceAudioTransport | None = None,
) -> None:
self._frames: deque[AudioFrame] = deque(frames)
self.play_audio = play_audio
self.output_transport = output_transport or SoundDeviceAudioTransport()
self.played_segments: list[AudioSegment] = []
self.flush_count = 0
self.started = False
def start_input(self, device_id: str | None = None, sample_rate: int = 16000, channels: int = 1) -> None:
self.started = True
def read_frames(self, timeout_ms: int) -> list[AudioFrame]:
if not self.started or not self._frames:
return []
return [self._frames.popleft()]
def play_pcm(self, segment: AudioSegment, interrupt: bool = False) -> PlaybackResult:
if interrupt:
self.played_segments.clear()
self.played_segments.append(segment)
if not self.play_audio:
return PlaybackResult(True, segment.duration_ms)
return self.output_transport.play_pcm(segment, interrupt=interrupt)
def flush_input(self) -> int:
self.flush_count += 1
return 0
def stop(self) -> None:
self.started = False
def health(self) -> TransportHealth:
output = self.output_transport.health()
return TransportHealth(True, output.output_available if self.play_audio else True, "fixture input")
def run_real_live_check(
*,
config: AppConfig,
turns: int = 2,
voice: str = "Tingting",
wake_text: str = DEFAULT_WAKE_TEXT,
questions: list[str] | None = None,
play_audio: bool = True,
) -> dict[str, object]:
timing = TimingRecorder()
if turns <= 0:
raise ValueError("turns must be positive")
with timing.phase("prepare_config"):
selected_questions = _question_list(turns, questions)
config = replace(
config,
speech_provider="local",
noise_filter_enabled=True,
realtime_transcript_enabled=True,
continuous_dialog_enabled=False,
barge_in_enabled=False,
llm_stream=False,
post_playback_drain_ms=0,
)
config.require_llm_credentials()
with timing.phase("generate_fixture_audio"):
frames = _generated_live_frames(config, turns=turns, voice=voice, wake_text=wake_text, questions=selected_questions)
with timing.phase("build_pipeline"):
reporter = RealLiveCheckReporter()
event_bus = PipelineEventBus()
event_bus.subscribe(lambda event: timing.record_event(event.type, turn_id=event.turn_id, message=event.message))
stt = SherpaOnnxSttProvider(str(config.speech_models_dir))
tts = MacSayTtsProvider()
llm = RecordingLlmProvider(OpenAICompatibleLlmProvider(config, timeout_s=60), timing)
transport = FixtureLiveAudioTransport(
frames,
play_audio=play_audio,
output_transport=SoundDeviceAudioTransport(output_device=config.audio_output_device),
)
pipeline = VoiceAssistantPipeline(
config=config,
transport=transport,
wakeword=SherpaOnnxKeywordWakeWordProvider(
config.speech_models_dir,
keyword=config.wake_word,
keywords_file=config.wake_keywords_file,
threshold=config.wake_kws_threshold,
score=config.wake_kws_score,
),
vad_recorder=PrimarySpeakerVadRecorder(
HybridVadProvider(
SherpaOnnxVadProvider(config.speech_models_dir, threshold=config.vad_threshold),
EnergyVadProvider(),
),
min_duration_ms=config.vad_min_duration_ms,
end_silence_ms=config.vad_end_silence_ms,
no_speech_timeout_ms=config.vad_no_speech_timeout_ms,
max_recording_ms=config.vad_max_recording_ms,
speaker_profile_ms=config.speaker_profile_ms,
speaker_profile_min_ms=config.speaker_profile_min_ms,
speaker_absent_ms=config.speaker_absent_ms,
similarity_threshold=config.speaker_similarity_threshold,
min_rms=config.speaker_min_rms,
),
audio_preprocessor=SherpaOnnxDenoiserPreprocessor(config.speech_models_dir),
stt=stt,
realtime_stt=stt,
llm=llm,
tts=tts,
ack_tts=tts,
context=ConversationContext(max_messages=config.context_max_messages, max_chars=config.context_max_chars),
reporter=reporter,
event_bus=event_bus,
)
with timing.phase("pipeline_run"):
summary = pipeline.run(max_turns=turns)
timing.finish()
event_counts = Counter(event.type for event in event_bus.events)
timing_data = timing.to_json()
context_messages = [{"role": item.role, "content": item.content[:120]} for item in pipeline.context.messages()]
checks = {
"completed_turns": summary.completed_turns == turns,
"no_failed_turns": summary.failed_turns == 0,
"wake_per_turn": event_counts["wake_detected"] >= turns,
"speech_per_turn": event_counts["speech_started"] >= turns,
"final_stt_per_turn": event_counts["transcript_final"] >= turns,
"llm_per_turn": len(llm.calls) >= turns,
"tts_per_turn": event_counts["tts_started"] >= turns,
"playback_per_turn": event_counts["playback_finished"] >= turns,
"standby_per_turn": event_counts["standby_resumed"] >= turns,
"temporary_context_in_second_llm": turns < 2 or _second_call_contains_first_turn(llm.calls),
"real_output_playback": (not play_audio) or len(transport.played_segments) >= turns * 2,
"timing_present": bool(timing_data.get("duration_ms") and timing_data.get("stage_timings")),
"llm_request_timing_present": len(llm.request_timings) >= turns
and all("sent_at" in item and "duration_ms" in item for item in llm.request_timings),
}
return {
"success": all(checks.values()) and not reporter.errors,
"turns": turns,
"completed_turns": summary.completed_turns,
"failed_turns": summary.failed_turns,
"checks": checks,
"final_transcripts": [str(item["text"]) for item in reporter.finals],
"partials_preview": reporter.partials[:8],
"event_counts": dict(sorted(event_counts.items())),
"played_segments": len(transport.played_segments),
"flush_count": transport.flush_count,
"context_messages": context_messages,
"errors": reporter.errors,
"play_audio": play_audio,
"voice": voice,
"timing": timing_data,
"llm_request_timings": llm.request_timings,
}
def _question_list(turns: int, questions: list[str] | None) -> list[str]:
selected = list(questions or DEFAULT_QUESTIONS)
while len(selected) < turns:
selected.append(f"{len(selected) + 1}轮完整流程测试。")
return selected[:turns]
def _generated_live_frames(
config: AppConfig,
*,
turns: int,
voice: str,
wake_text: str,
questions: list[str],
) -> list[AudioFrame]:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp = Path(tmp_dir)
wake_pcm = _synthesize_text_pcm(wake_text, tmp, "wake", voice=voice)
question_pcm = [_synthesize_text_pcm(text, tmp, f"q{idx + 1}", voice=voice) for idx, text in enumerate(questions)]
wake_frames = _trim_wake_frames_to_detection(config, _frames_from_pcm(wake_pcm, 0, 0, prefix="wake"))
frames: list[AudioFrame] = []
frame_id = 0
timestamp_ms = 0
for turn_index, pcm in enumerate(question_pcm, start=1):
copied_wake = [
AudioFrame(
item.pcm,
item.sample_rate,
item.channels,
timestamp_ms + idx * FRAME_MS,
frame_id + idx,
dict(item.metadata),
)
for idx, item in enumerate(wake_frames)
]
frames.extend(copied_wake)
frame_id += len(copied_wake)
timestamp_ms += len(copied_wake) * FRAME_MS
gap = _silence(frame_id, timestamp_ms, 160, prefix=f"turn{turn_index}-gap")
frames.extend(gap)
frame_id += len(gap)
timestamp_ms += len(gap) * FRAME_MS
question_frames = _frames_from_pcm(pcm, frame_id, timestamp_ms, prefix=f"turn{turn_index}-question")
frames.extend(question_frames)
frame_id += len(question_frames)
timestamp_ms += len(question_frames) * FRAME_MS
tail = _silence(frame_id, timestamp_ms, 900, prefix=f"turn{turn_index}-tail")
frames.extend(tail)
frame_id += len(tail)
timestamp_ms += len(tail) * FRAME_MS
return frames
def _synthesize_text_pcm(text: str, tmp: Path, name: str, *, voice: str) -> bytes:
aiff_path = tmp / f"{name}.aiff"
wav_path = tmp / f"{name}.wav"
try:
subprocess.run(
["say", "-v", voice, "-o", str(aiff_path), text],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
subprocess.run(
["afconvert", "-f", "WAVE", "-d", "LEI16@16000", "-c", "1", str(aiff_path), str(wav_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
raise ValueError(f"failed to generate fixture speech with macOS say/afconvert: {exc}") from exc
with wave.open(str(wav_path), "rb") as handle:
if handle.getframerate() != SAMPLE_RATE or handle.getnchannels() != 1 or handle.getsampwidth() != 2:
raise ValueError("generated speech must be 16 kHz mono int16 PCM")
return handle.readframes(handle.getnframes())
def _frames_from_pcm(pcm: bytes, start_id: int, start_ms: int, *, prefix: str) -> list[AudioFrame]:
frames: list[AudioFrame] = []
frame_id = start_id
timestamp_ms = start_ms
for offset in range(0, len(pcm), FRAME_BYTES):
chunk = pcm[offset : offset + FRAME_BYTES]
if len(chunk) < FRAME_BYTES:
chunk += b"\x00" * (FRAME_BYTES - len(chunk))
frames.append(
AudioFrame(chunk, SAMPLE_RATE, 1, timestamp_ms, frame_id, {"duration_ms": FRAME_MS, "fixture": prefix})
)
frame_id += 1
timestamp_ms += FRAME_MS
return frames
def _silence(start_id: int, start_ms: int, duration_ms: int, *, prefix: str) -> list[AudioFrame]:
return [
AudioFrame(
b"\x00" * FRAME_BYTES,
SAMPLE_RATE,
1,
start_ms + idx * FRAME_MS,
start_id + idx,
{"duration_ms": FRAME_MS, "fixture": prefix},
)
for idx in range(duration_ms // FRAME_MS)
]
def _trim_wake_frames_to_detection(config: AppConfig, frames: list[AudioFrame]) -> list[AudioFrame]:
probe_frames = list(frames)
if probe_frames:
last = probe_frames[-1]
probe_frames.extend(_silence(last.frame_id + 1, last.timestamp_ms + FRAME_MS, 1400, prefix="wake-tail"))
wake = SherpaOnnxKeywordWakeWordProvider(
config.speech_models_dir,
keyword=config.wake_word,
keywords_file=config.wake_keywords_file,
threshold=config.wake_kws_threshold,
score=config.wake_kws_score,
)
wake.load()
wake.reset()
for idx, frame in enumerate(probe_frames):
if wake.detect(frame) is not None:
return probe_frames[: idx + 1]
raise ValueError("generated wake audio did not trigger local KWS")
def _second_call_contains_first_turn(calls: list[list[Message]]) -> bool:
if len(calls) < 2:
return False
second_contents = "\n".join(message.content for message in calls[1])
return "我叫阿明" in second_contents and any(message.role == "assistant" for message in calls[1])
def _last_user_preview(messages: Sequence[Message]) -> str:
for message in reversed(messages):
if message.role == "user":
return message.content[:80]
return ""
def _stage_timings_from_events(events: list[dict[str, object]]) -> list[dict[str, object]]:
timings: list[dict[str, object]] = []
turns = sorted({int(event["turn"]) for event in events if event.get("turn") is not None})
for turn_id in turns:
turn_events = [event for event in events if event.get("turn") == turn_id]
for stage_name, start_type, end_type in STAGE_PAIRS:
timing = _pair_timing(turn_events, stage_name, start_type, end_type)
if timing is not None:
timings.append(timing)
timings.extend(_repeated_pair_timings(turn_events, "tts_playback", "tts_started", "playback_finished"))
return timings
def _pair_timing(
events: list[dict[str, object]],
name: str,
start_type: str,
end_type: str,
) -> dict[str, object] | None:
start = next((event for event in events if event.get("type") == start_type), None)
if start is None:
return None
end = next(
(event for event in events if event.get("type") == end_type and int(event["offset_ms"]) >= int(start["offset_ms"])),
None,
)
if end is None:
return None
return _timing_record(name, start, end)
def _repeated_pair_timings(
events: list[dict[str, object]],
name: str,
start_type: str,
end_type: str,
) -> list[dict[str, object]]:
timings: list[dict[str, object]] = []
pending: dict[str, object] | None = None
segment_index = 1
for event in events:
if event.get("type") == start_type:
pending = event
continue
if pending is not None and event.get("type") == end_type:
timing = _timing_record(name, pending, event)
timing["segment_index"] = segment_index
timings.append(timing)
segment_index += 1
pending = None
return timings
def _timing_record(name: str, start: dict[str, object], end: dict[str, object]) -> dict[str, object]:
return {
"turn": start.get("turn"),
"name": name,
"started_at": start.get("at"),
"finished_at": end.get("at"),
"start_offset_ms": start.get("offset_ms"),
"end_offset_ms": end.get("offset_ms"),
"duration_ms": int(end["offset_ms"]) - int(start["offset_ms"]),
"start_event": start.get("type"),
"end_event": end.get("type"),
}