修复 Pipecat 实时链路并补齐网页测试
This commit is contained in:
+56
-31
@@ -76,7 +76,8 @@ class FileAudioTrack(AudioStreamTrack):
|
||||
|
||||
kind = "audio"
|
||||
|
||||
def __init__(self, wav_path: Path, *, sample_rate: int = 16000):
|
||||
def __init__(self, wav_path: Path, *, sample_rate: int = 16000,
|
||||
tail_silence_s: float = 1.2):
|
||||
super().__init__()
|
||||
with wave.open(str(wav_path)) as w:
|
||||
assert w.getnchannels() == 1, "需要单声道"
|
||||
@@ -88,7 +89,10 @@ class FileAudioTrack(AudioStreamTrack):
|
||||
n = int(len(self.samples) * sample_rate / self.src_rate)
|
||||
self.samples = np.interp(np.linspace(0, len(self.samples) - 1, n),
|
||||
np.arange(len(self.samples)), self.samples)
|
||||
self._pos = 0
|
||||
self._source_pos = 0
|
||||
self._timestamp_samples = 0
|
||||
self._tail_remaining = int(tail_silence_s * sample_rate)
|
||||
self.source_finished_at: float | None = None
|
||||
self._t0: float | None = None
|
||||
self._frame_samples = sample_rate // 100 # 10ms
|
||||
|
||||
@@ -96,19 +100,25 @@ class FileAudioTrack(AudioStreamTrack):
|
||||
"""按 10ms 一块、以真实时间节奏产出。"""
|
||||
if self._t0 is None:
|
||||
self._t0 = time.perf_counter()
|
||||
self._pos = 0
|
||||
self._source_pos = 0
|
||||
|
||||
# 音频放完后持续吐静音,让服务端的 VAD 能判到"说完"
|
||||
if self._pos >= len(self.samples):
|
||||
frame_pts = self._timestamp_samples
|
||||
if self._source_pos >= len(self.samples):
|
||||
if self.source_finished_at is None:
|
||||
self.source_finished_at = time.perf_counter()
|
||||
audio = np.zeros(self._frame_samples, dtype=np.float32)
|
||||
self._tail_remaining = max(0, self._tail_remaining - self._frame_samples)
|
||||
else:
|
||||
audio = self.samples[self._pos:self._pos + self._frame_samples]
|
||||
audio = self.samples[self._source_pos:self._source_pos + self._frame_samples]
|
||||
if len(audio) < self._frame_samples:
|
||||
audio = np.pad(audio, (0, self._frame_samples - len(audio)))
|
||||
self._pos += self._frame_samples
|
||||
self._source_pos += self._frame_samples
|
||||
|
||||
# 即使源文件已经结束,静音帧也必须持续推进时钟和 PTS。
|
||||
self._timestamp_samples += self._frame_samples
|
||||
|
||||
# 实时节奏
|
||||
target = self._t0 + self._pos / self.sample_rate
|
||||
target = self._t0 + self._timestamp_samples / self.sample_rate
|
||||
delay = target - time.perf_counter()
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
@@ -118,33 +128,45 @@ class FileAudioTrack(AudioStreamTrack):
|
||||
f = AudioFrame(format="s16", layout="mono", samples=len(pcm))
|
||||
f.planes[0].update(pcm.tobytes())
|
||||
f.sample_rate = self.sample_rate
|
||||
f.pts = int(self._pos * 90000 / self.sample_rate)
|
||||
f.pts = int(frame_pts * 90000 / self.sample_rate)
|
||||
f.time_base = fractions.Fraction(1, 90000)
|
||||
return f
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
return self._pos >= len(self.samples)
|
||||
return self._source_pos >= len(self.samples) and self._tail_remaining <= 0
|
||||
|
||||
|
||||
class ReplyRecorder:
|
||||
"""收服务端音频,写 wav,并记录首音延迟。"""
|
||||
|
||||
def __init__(self, out_path: Path, sample_rate: int = 24000):
|
||||
def __init__(self, out_path: Path, sample_rate: int = 24000,
|
||||
speech_rms_threshold: float = 80.0):
|
||||
self.out_path = out_path
|
||||
self.sample_rate = sample_rate
|
||||
self.chunks: list[np.ndarray] = []
|
||||
self.first_at: float | None = None
|
||||
self.t0 = time.perf_counter()
|
||||
self.first_non_silent_at: float | None = None
|
||||
self.speech_rms_threshold = speech_rms_threshold
|
||||
|
||||
def feed(self, frame) -> None:
|
||||
if self.first_at is None:
|
||||
self.first_at = time.perf_counter()
|
||||
arr = frame.to_ndarray()
|
||||
# 多声道取第一路
|
||||
if arr.shape[0] > 1:
|
||||
arr = arr[0:1]
|
||||
self.chunks.append(arr.astype(np.float32).reshape(-1) / 32768.0)
|
||||
channels = len(frame.layout.channels)
|
||||
if frame.format.is_planar:
|
||||
raw = arr.astype(np.float32).mean(axis=0)
|
||||
else:
|
||||
raw = arr.astype(np.float32).reshape(-1, channels).mean(axis=1)
|
||||
# WebRTC/Opus 通常以 48kHz 交付,即使服务端原始输出是 24kHz。
|
||||
# WAV 必须记录接收帧的实际采样率,否则时长会被算错。
|
||||
if not self.chunks:
|
||||
self.sample_rate = frame.sample_rate
|
||||
elif frame.sample_rate != self.sample_rate:
|
||||
raise ValueError(
|
||||
f"回复音轨采样率发生变化: {self.sample_rate} -> {frame.sample_rate}")
|
||||
raw = raw.reshape(-1)
|
||||
rms = float(np.sqrt(np.mean(raw ** 2))) if raw.size else 0.0
|
||||
if self.first_non_silent_at is None and rms >= self.speech_rms_threshold:
|
||||
self.first_non_silent_at = time.perf_counter()
|
||||
self.chunks.append(raw / 32768.0)
|
||||
|
||||
def save(self) -> float:
|
||||
if not self.chunks:
|
||||
@@ -160,8 +182,13 @@ class ReplyRecorder:
|
||||
return len(audio) / self.sample_rate
|
||||
|
||||
@property
|
||||
def first_audio_ms(self) -> float | None:
|
||||
return None if self.first_at is None else (self.first_at - self.t0) * 1000
|
||||
def has_speech(self) -> bool:
|
||||
return self.first_non_silent_at is not None
|
||||
|
||||
def first_audio_ms_from(self, started_at: float | None) -> float | None:
|
||||
if started_at is None or self.first_non_silent_at is None:
|
||||
return None
|
||||
return (self.first_non_silent_at - started_at) * 1000
|
||||
|
||||
|
||||
def post_offer(server: str, sdp: str, type_: str) -> dict:
|
||||
@@ -197,8 +224,6 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
while True:
|
||||
frame = await t.recv()
|
||||
recorder.feed(frame)
|
||||
if not got_audio.is_set() and recorder.chunks:
|
||||
pass
|
||||
except (MediaStreamError, asyncio.CancelledError):
|
||||
pass
|
||||
finally:
|
||||
@@ -293,10 +318,10 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
while not track.finished and time.perf_counter() - t0 < timeout_s:
|
||||
await asyncio.sleep(0.2)
|
||||
log.info("音频已发完,等待回复…")
|
||||
# 等首个回复音频
|
||||
# 等首个非静音回复;SmallWebRTC 空闲时也会持续发送静音。
|
||||
t_wait = time.perf_counter()
|
||||
while time.perf_counter() - t_wait < timeout_s:
|
||||
if recorder.chunks:
|
||||
if recorder.has_speech:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
# 再收一会儿,让回复说完
|
||||
@@ -308,13 +333,13 @@ async def run(server: str, wav: Path | None, out: Path, *, connect_only: bool,
|
||||
|
||||
print()
|
||||
print("=== 结果 ===")
|
||||
print(f" 连接: {'✅ 成功' if dur > 0 else '⚠️ 无音频返回'}")
|
||||
print(f" 回复: {'✅ 收到非静音语音' if recorder.has_speech else '❌ 只有静音'}")
|
||||
if dur:
|
||||
print(f" 收到音频: {dur:.2f} 秒 → {out}")
|
||||
fa = recorder.first_audio_ms
|
||||
if fa:
|
||||
print(f" 首音延迟(发完到收到首段): {fa:.0f} ms")
|
||||
return 0 if dur > 0 else 1
|
||||
print(f" 录制音轨: {dur:.2f} 秒 → {out}")
|
||||
fa = recorder.first_audio_ms_from(track.source_finished_at if track else None)
|
||||
if fa is not None:
|
||||
print(f" 首音延迟(源音频结束→首个非静音输出): {fa:.0f} ms")
|
||||
return 0 if (connect_only or recorder.has_speech) else 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
Reference in New Issue
Block a user