[播放平滑修复]:完成可打断播报平滑播放,包含单输出流分块播放和TTS采样率保真

This commit is contained in:
mkbk
2026-06-18 12:21:01 +08:00
parent 7519725321
commit d7dbec5c29
5 changed files with 142 additions and 40 deletions
+98
View File
@@ -6,6 +6,7 @@ import shutil
import subprocess
import tempfile
import time
from collections.abc import Callable
from collections import deque
from pathlib import Path
from typing import Any
@@ -106,6 +107,23 @@ class MemoryAudioTransport:
self.played_segments.append(segment)
return PlaybackResult(True, segment.duration_ms)
def play_pcm_chunks(
self,
segment: AudioSegment,
*,
chunk_ms: int,
after_chunk: Callable[[AudioSegment, int], bool] | None = None,
) -> PlaybackResult:
elapsed_ms = 0
for chunk in _raw_pcm_chunks(segment, chunk_ms=chunk_ms):
playback = self.play_pcm(chunk)
if playback.error:
return playback
elapsed_ms += chunk.duration_ms
if after_chunk is not None and after_chunk(chunk, elapsed_ms):
return PlaybackResult(True, elapsed_ms)
return PlaybackResult(True, elapsed_ms)
def flush_input(self) -> int:
self.flush_count += 1
if not self._flush_clears_input:
@@ -288,6 +306,59 @@ class SoundDeviceAudioTransport:
)
return PlaybackResult(True, segment.duration_ms)
def play_pcm_chunks(
self,
segment: AudioSegment,
*,
chunk_ms: int,
after_chunk: Callable[[AudioSegment, int], bool] | None = None,
) -> PlaybackResult:
if self._sd is None or segment.metadata.get("format") in {"aiff", "wav", "mp3", "m4a", "aac"}:
playback = self.play_pcm(segment)
if playback.error:
return playback
if after_chunk is not None:
after_chunk(segment, segment.duration_ms)
return playback
if not segment.pcm:
return PlaybackResult(
False,
0,
ProviderError(
ErrorCode.AUDIO_STREAM_UNDERRUN,
"cannot play empty audio segment",
True,
"sounddevice-transport",
"transport",
),
)
elapsed_ms = 0
try:
with self._sd.RawOutputStream(
samplerate=segment.sample_rate,
channels=segment.channels,
dtype="int16",
device=_coerce_device_id(self._output_device),
) as stream:
for chunk in _raw_pcm_chunks(segment, chunk_ms=chunk_ms):
stream.write(chunk.pcm)
elapsed_ms += chunk.duration_ms
if after_chunk is not None and after_chunk(chunk, elapsed_ms):
return PlaybackResult(True, elapsed_ms)
except Exception as exc:
return PlaybackResult(
False,
elapsed_ms,
ProviderError(
ErrorCode.AUDIO_OUTPUT_DEVICE_MISSING,
f"cannot play through sounddevice output stream: {exc}",
False,
"sounddevice-transport",
"transport",
),
)
return PlaybackResult(True, elapsed_ms)
def stop(self) -> None:
if self._stream is None:
return None
@@ -418,3 +489,30 @@ def _play_file_bytes_with_afplay(segment: AudioSegment) -> PlaybackResult:
),
)
return PlaybackResult(True, segment.duration_ms)
def _raw_pcm_chunks(segment: AudioSegment, *, chunk_ms: int) -> list[AudioSegment]:
if chunk_ms <= 0 or segment.duration_ms <= chunk_ms:
return [segment]
bytes_per_ms = max(1, int(segment.sample_rate * segment.channels * 2 / 1000))
chunk_bytes = max(2 * segment.channels, bytes_per_ms * chunk_ms)
chunk_bytes -= chunk_bytes % (2 * segment.channels)
chunks: list[AudioSegment] = []
offset = 0
start_ms = segment.start_time_ms
while offset < len(segment.pcm):
data = segment.pcm[offset : offset + chunk_bytes]
duration_ms = max(1, int(len(data) / bytes_per_ms))
chunks.append(
AudioSegment(
data,
segment.sample_rate,
segment.channels,
start_ms,
start_ms + duration_ms,
dict(segment.metadata),
)
)
offset += len(data)
start_ms += duration_ms
return chunks