[结束提示音]:完成对话结束音效提示,包含本地短音生成、恢复待机播放和配置测试

This commit is contained in:
mkbk
2026-06-18 13:59:12 +08:00
parent ac72738fc8
commit 80549fb312
12 changed files with 130 additions and 23 deletions
+2 -1
View File
@@ -27,7 +27,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 .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, sanitize_tts_text
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_prompt_chime, sanitize_tts_text
from .assets import validate_pet_assets
from .ui import ConsolePetWindow, PetStateController, PetVisualState
@@ -70,6 +70,7 @@ __all__ = [
"MacSayTtsProvider",
"SentenceBuffer",
"SineTtsProvider",
"make_prompt_chime",
"sanitize_tts_text",
"validate_pet_assets",
"ConsolePetWindow",
+23 -14
View File
@@ -47,7 +47,7 @@ from .protocols import (
WakeWordProvider,
)
from .stt import is_valid_transcript_text
from .tts import SentenceBuffer, sanitize_tts_text
from .tts import SentenceBuffer, make_prompt_chime, sanitize_tts_text
from .vad import VadRecorder
@@ -344,19 +344,10 @@ class TurnController:
else:
decision = self._decide_continuation(current_user_text, last_assistant_text, current_turn_id)
if not decision.should_continue:
self._event(
CONTINUOUS_SESSION_ENDED,
PipelineState.WAKE_LISTENING,
"",
turn_id=current_turn_id,
self._end_conversation(
current_turn_id,
payload={"decision": decision.action, "reason": decision.reason},
)
self._event(
STANDBY_RESUMED,
PipelineState.WAKE_LISTENING,
"恢复待机:可继续唤醒",
turn_id=current_turn_id,
)
return TurnResult(
True,
current_user_text,
@@ -488,11 +479,29 @@ class TurnController:
"追问超时:未检测到用户回答",
turn_id=turn_id,
)
self._event(CONTINUOUS_SESSION_ENDED, PipelineState.WAKE_LISTENING, "", turn_id=turn_id)
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
self._end_conversation(turn_id)
return None
return user_text
def _end_conversation(self, turn_id: int, *, payload: dict[str, object] | None = None) -> None:
self._event(CONTINUOUS_SESSION_ENDED, PipelineState.WAKE_LISTENING, "", turn_id=turn_id, payload=payload)
self._play_end_chime()
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
def _play_end_chime(self) -> None:
if not self.config.end_chime_enabled:
return
segment = make_prompt_chime(
frequency_hz=self.config.end_chime_frequency_hz,
duration_ms=self.config.end_chime_duration_ms,
sample_rate=self.config.sample_rate,
channels=self.config.channels,
)
playback = self.transport.play_pcm(segment)
if playback.error:
return
self._drain_input_after_playback()
def _speak(self, sentence: str, turn_id: int) -> SpeakResult:
spoken_sentence = sanitize_tts_text(sentence)
if not spoken_sentence:
+3
View File
@@ -104,6 +104,9 @@ def main(argv: list[str] | None = None) -> int:
"barge_in_enabled": config.barge_in_enabled,
"barge_in_min_speech_ms": config.barge_in_min_speech_ms,
"barge_in_echo_guard_ms": config.barge_in_echo_guard_ms,
"end_chime_enabled": config.end_chime_enabled,
"end_chime_frequency_hz": config.end_chime_frequency_hz,
"end_chime_duration_ms": config.end_chime_duration_ms,
},
ensure_ascii=False,
sort_keys=True,
+20
View File
@@ -59,6 +59,9 @@ class AppConfig:
barge_in_enabled: bool = True
barge_in_min_speech_ms: int = 250
barge_in_echo_guard_ms: int = 500
end_chime_enabled: bool = True
end_chime_frequency_hz: int = 880
end_chime_duration_ms: int = 140
@classmethod
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig":
@@ -130,6 +133,9 @@ class AppConfig:
barge_in_enabled=(get("BARGE_IN_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
barge_in_min_speech_ms=int(get("BARGE_IN_MIN_SPEECH_MS", "250") or "250"),
barge_in_echo_guard_ms=int(get("BARGE_IN_ECHO_GUARD_MS", "500") or "500"),
end_chime_enabled=(get("END_CHIME_ENABLED", "1") or "1").lower() not in {"0", "false", "no"},
end_chime_frequency_hz=int(get("END_CHIME_FREQUENCY_HZ", "880") or "880"),
end_chime_duration_ms=int(get("END_CHIME_DURATION_MS", "140") or "140"),
)
@classmethod
@@ -394,6 +400,20 @@ class AppConfig:
"startup",
)
)
for name, value in {
"OWNER_END_CHIME_FREQUENCY_HZ": self.end_chime_frequency_hz,
"OWNER_END_CHIME_DURATION_MS": self.end_chime_duration_ms,
}.items():
if value <= 0:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
f"{name} must be positive",
False,
"config",
"startup",
)
)
return errors
def api_url(self, path: str) -> str:
+16 -1
View File
@@ -33,7 +33,7 @@ from .models import AudioSegment, ErrorCode, PipelineState, ProviderError
from .protocols import AudioTransport, LlmProvider, RealtimeSttProvider, SttProvider, TtsProvider, WakeWordProvider
from .stt import CloudAsrSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text
from .transport import SoundDeviceAudioTransport
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, sanitize_tts_text
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, make_prompt_chime, sanitize_tts_text
from .vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, SherpaOnnxVadProvider, VadRecorder
from .wakeword import SherpaOnnxKeywordWakeWordProvider
@@ -323,6 +323,7 @@ class LiveVoiceRuntime:
turn_id,
)
self.context.append_assistant(spoken_text)
self._play_end_chime()
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return TurnResult(True, user_text, spoken_text, states=list(self._states))
@@ -339,6 +340,20 @@ class LiveVoiceRuntime:
self._drain_input_after_playback()
return spoken_sentence
def _play_end_chime(self) -> None:
if not self.config.end_chime_enabled:
return
segment = make_prompt_chime(
frequency_hz=self.config.end_chime_frequency_hz,
duration_ms=self.config.end_chime_duration_ms,
sample_rate=self.config.sample_rate,
channels=self.config.channels,
)
playback = self.transport.play_pcm(segment)
if playback.error:
return
self._drain_input_after_playback()
def _recover(self, error: ProviderError, turn_id: int) -> TurnResult:
self._event(STAGE_ERROR, PipelineState.ERROR_RECOVERING, error.message, turn_id=turn_id, payload={"error": error})
self._event(RECOVERING, PipelineState.ERROR_RECOVERING, "恢复待机:本轮已结束", turn_id=turn_id)
+30
View File
@@ -144,6 +144,36 @@ def _normalize_spoken_text(text: str) -> str:
return clean.strip(" \t\r\n,;:")
def make_prompt_chime(
*,
frequency_hz: int = 880,
duration_ms: int = 140,
sample_rate: int = 16000,
channels: int = 1,
) -> AudioSegment:
duration_ms = max(20, duration_ms)
sample_rate = max(8000, sample_rate)
channels = max(1, channels)
samples = int(sample_rate * duration_ms / 1000)
fade_samples = max(1, min(samples // 2, int(sample_rate * 0.015)))
pcm = bytearray()
for idx in range(samples):
fade_in = idx / fade_samples if idx < fade_samples else 1.0
fade_out = (samples - idx - 1) / fade_samples if idx >= samples - fade_samples else 1.0
envelope = max(0.0, min(1.0, fade_in, fade_out))
value = int(math.sin(2 * math.pi * frequency_hz * idx / sample_rate) * 9000 * envelope)
packed = struct.pack("<h", value)
pcm.extend(packed * channels)
return AudioSegment(
bytes(pcm),
sample_rate,
channels,
0,
duration_ms,
{"chime": "end", "text": "end_chime"},
)
class SentenceBuffer:
def __init__(self, max_chars: int = 80) -> None:
self.max_chars = max_chars