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

142 lines
5.2 KiB
Python

"""VAD + Whisper comparison baseline for Experiment 6-4."""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass
from pathlib import Path
try:
import librosa
import soundfile as sf
except ImportError:
librosa = None
sf = None
import numpy as np
@dataclass
class BaselineResult:
configured_silence_ms: int
first_speech_start_seconds: float
first_endpoint_audio_seconds: float
first_decision_audio_seconds: float
post_speech_vad_delay_seconds: float
first_segment_asr_seconds: float
all_segments_asr_seconds: float
post_endpoint_response_seconds: float
first_response_from_audio_start_seconds: float
transcript: str
segment_transcripts: list[str]
segment_asr_seconds: list[float]
segment_count: int
endpoints: list[float]
decisions: list[float]
@dataclass
class VadEvent:
speech_start: int
speech_endpoint: int
decision: int
def energy_vad_events(audio: np.ndarray, sr: int, silence_ms: int = 600) -> list[VadEvent]:
"""Return start, acoustic endpoint, and later VAD decision sample.
The decision occurs only after the full low-energy run. Keeping it separate
from the acoustic endpoint prevents a 4-second utterance position from being
mislabeled as a 600 ms VAD latency.
"""
if len(audio) == 0:
return []
frame = max(1, int(sr * 0.02))
energies = np.array([np.sqrt(np.mean(audio[i:i + frame] ** 2) + 1e-12) for i in range(0, len(audio), frame)])
# A conservative fixed-relative threshold keeps a long silent gap distinct
# even when speech occupies most of the clip (where p30 itself is speech).
threshold = max(0.004, min(0.03, float(np.percentile(energies, 90) * 0.15)))
silent_needed = max(1, silence_ms // 20)
# A streaming endpoint detector observes silence after the physical file
# ends too, so append an analysis-only silent tail. It is never transcribed.
analysis_energies = np.concatenate([energies, np.zeros(silent_needed)])
events, silent, speech_start = [], 0, None
active = False
for index, energy in enumerate(analysis_energies):
if energy >= threshold:
if not active:
speech_start = index * frame
active, silent = True, 0
elif active:
silent += 1
if silent >= silent_needed:
endpoint = min(len(audio), (index + 1 - silent_needed) * frame)
decision = min(len(audio) + silent_needed * frame, (index + 1) * frame)
events.append(VadEvent(int(speech_start or 0), endpoint, decision))
active, silent, speech_start = False, 0, None
return events
def energy_vad_endpoints(audio: np.ndarray, sr: int, silence_ms: int = 600) -> list[int]:
"""Compatibility helper returning only acoustic endpoint locations."""
return [event.speech_endpoint for event in energy_vad_events(audio, sr, silence_ms)]
class LocalWhisper:
"""Actual open-source Whisper inference, loaded once for all VAD segments."""
def __init__(self, model: str = "small") -> None:
import whisper
self.model_name = model
self.model = whisper.load_model(model)
def transcribe(self, path: Path) -> str:
return str(self.model.transcribe(str(path), language="zh", fp16=False)["text"]).strip()
def run_whisper_baseline(audio_path: str | Path, transcriber) -> BaselineResult:
audio, sr = librosa.load(str(audio_path), sr=None, mono=True)
silence_ms = 600
events = energy_vad_events(audio, sr, silence_ms)
if not events:
raise RuntimeError("VAD found no speech event")
texts, asr_times, previous = [], [], 0
temp_dir = Path(audio_path).parent / ".baseline_chunks"
temp_dir.mkdir(exist_ok=True)
try:
for index, event in enumerate(events):
chunk = temp_dir / f"chunk_{index}.wav"
sf.write(chunk, audio[previous:event.speech_endpoint], sr)
t0 = time.perf_counter()
text = transcriber.transcribe(chunk)
asr_times.append(time.perf_counter() - t0)
texts.append(text)
previous = event.speech_endpoint
finally:
for item in temp_dir.glob("*.wav"):
item.unlink()
temp_dir.rmdir()
first = events[0]
vad_delay = (first.decision - first.speech_endpoint) / sr
return BaselineResult(
configured_silence_ms=silence_ms,
first_speech_start_seconds=first.speech_start / sr,
first_endpoint_audio_seconds=first.speech_endpoint / sr,
first_decision_audio_seconds=first.decision / sr,
post_speech_vad_delay_seconds=vad_delay,
first_segment_asr_seconds=asr_times[0],
all_segments_asr_seconds=sum(asr_times),
post_endpoint_response_seconds=vad_delay + asr_times[0],
first_response_from_audio_start_seconds=first.decision / sr + asr_times[0],
transcript=" ".join(t for t in texts if t),
segment_transcripts=texts,
segment_asr_seconds=asr_times,
segment_count=len(events),
endpoints=[round(event.speech_endpoint / sr, 3) for event in events],
decisions=[round(event.decision / sr, 3) for event in events],
)
def serialize(result: BaselineResult):
return asdict(result)