[结束提示音]:完成对话结束音效提示,包含本地短音生成、恢复待机播放和配置测试
This commit is contained in:
@@ -45,6 +45,9 @@ OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS=3000
|
||||
OWNER_BARGE_IN_ENABLED=1
|
||||
OWNER_BARGE_IN_MIN_SPEECH_MS=250
|
||||
OWNER_BARGE_IN_ECHO_GUARD_MS=500
|
||||
OWNER_END_CHIME_ENABLED=1
|
||||
OWNER_END_CHIME_FREQUENCY_HZ=880
|
||||
OWNER_END_CHIME_DURATION_MS=140
|
||||
OWNER_WAKE_WORD=小杰小杰
|
||||
OWNER_SAMPLE_RATE=16000
|
||||
OWNER_CHANNELS=1
|
||||
|
||||
@@ -72,6 +72,9 @@ OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS=3000
|
||||
OWNER_BARGE_IN_ENABLED=1
|
||||
OWNER_BARGE_IN_MIN_SPEECH_MS=250
|
||||
OWNER_BARGE_IN_ECHO_GUARD_MS=500
|
||||
OWNER_END_CHIME_ENABLED=1
|
||||
OWNER_END_CHIME_FREQUENCY_HZ=880
|
||||
OWNER_END_CHIME_DURATION_MS=140
|
||||
```
|
||||
|
||||
`OWNER_WAKE_PROVIDER=local_kws` 表示唤醒词“小杰小杰”由本地模型检测。唤醒命中后会先本地播报 `OWNER_WAKE_ACK_TEXT=我在`,再开始听取问题。`OWNER_SPEECH_PROVIDER=local` 表示正式问题 STT、实时字幕和 TTS 都走本地模型或 macOS 本地能力;云端只接收 final 文本和本次会话历史用于 LLM 回复。
|
||||
@@ -84,6 +87,8 @@ OWNER_BARGE_IN_ECHO_GUARD_MS=500
|
||||
|
||||
`OWNER_BARGE_IN_ENABLED=1` 表示播报中允许打断。播放开始后的 `OWNER_BARGE_IN_ECHO_GUARD_MS=500` 毫秒内忽略麦克风输入,之后如果检测到至少 `OWNER_BARGE_IN_MIN_SPEECH_MS=250` 毫秒有效用户语音,并且 realtime STT 给出有效 partial,就停止剩余播报。上下文只记录已经完整播出的 assistant 句子,未播出的内容不会写入临时历史。
|
||||
|
||||
`OWNER_END_CHIME_ENABLED=1` 表示对话自然结束或追问超时恢复待机前会播放一声本地短提示音。提示音不走 TTS,也不会写入上下文;`OWNER_END_CHIME_FREQUENCY_HZ` 和 `OWNER_END_CHIME_DURATION_MS` 可以调整音高和时长,设置 `OWNER_END_CHIME_ENABLED=0` 可以关闭。
|
||||
|
||||
## 本地模型
|
||||
|
||||
首次运行前必须准备本地语音模型;同一脚本会下载 wake、VAD、2025 中文 CTC STT 和 GTCRN denoiser 模型:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,8 @@ class CliAcceptanceTests(unittest.TestCase):
|
||||
self.assertEqual(data["endpoint_mode"], "primary_speaker")
|
||||
self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500)
|
||||
self.assertEqual(data["context_mode"], "session_memory")
|
||||
self.assertTrue(data["end_chime_enabled"])
|
||||
self.assertEqual(data["end_chime_duration_ms"], 140)
|
||||
self.assertNotIn("secret-value", str(data))
|
||||
|
||||
def test_security_check_command_has_no_leaks(self) -> None:
|
||||
|
||||
@@ -269,8 +269,10 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(runtime.config.post_playback_drain_ms, 0)
|
||||
self.assertEqual(len(stt.calls), 2)
|
||||
self.assertEqual(len(llm.calls), 2)
|
||||
self.assertEqual(len(transport.played_segments), 4)
|
||||
self.assertEqual(transport.flush_count, 4)
|
||||
self.assertEqual(len(transport.played_segments), 6)
|
||||
self.assertEqual(transport.flush_count, 6)
|
||||
self.assertEqual(transport.played_segments[2].metadata["chime"], "end")
|
||||
self.assertEqual(transport.played_segments[5].metadata["chime"], "end")
|
||||
self.assertEqual(reporter.transcripts, ["第一问", "第二问"])
|
||||
self.assertIn("应答中:我在", reporter.statuses)
|
||||
self.assertLess(reporter.statuses.index("唤醒命中"), reporter.statuses.index("应答中:我在"))
|
||||
@@ -324,7 +326,7 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(summary.completed_turns, 2)
|
||||
self.assertEqual(ack_tts.synthesized_texts, ["我在"])
|
||||
self.assertEqual(transport.played_segments[0].metadata["text"], "我在")
|
||||
self.assertEqual(transport.played_segments[2].metadata["text"], "我在")
|
||||
self.assertEqual(transport.played_segments[3].metadata["text"], "我在")
|
||||
|
||||
def test_zero_post_playback_drain_flushes_without_dropping_prefilled_question(self) -> None:
|
||||
runtime, stt, _, transport, reporter = make_runtime(["第一问"])
|
||||
@@ -335,7 +337,8 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(summary.completed_turns, 1)
|
||||
self.assertEqual(reporter.transcripts, ["第一问"])
|
||||
self.assertEqual(len(stt.calls), 1)
|
||||
self.assertEqual(transport.flush_count, 2)
|
||||
self.assertEqual(transport.flush_count, 3)
|
||||
self.assertEqual(transport.played_segments[-1].metadata["chime"], "end")
|
||||
|
||||
def test_no_ack_text_does_not_drain_before_capture(self) -> None:
|
||||
runtime, stt, _, transport, reporter = make_runtime(["第一问"], wake_ack_text="")
|
||||
@@ -345,8 +348,9 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertEqual(summary.completed_turns, 1)
|
||||
self.assertEqual(reporter.transcripts, ["第一问"])
|
||||
self.assertEqual(len(stt.calls), 1)
|
||||
self.assertEqual(len(transport.played_segments), 1)
|
||||
self.assertEqual(transport.flush_count, 1)
|
||||
self.assertEqual(len(transport.played_segments), 2)
|
||||
self.assertEqual(transport.flush_count, 2)
|
||||
self.assertEqual(transport.played_segments[-1].metadata["chime"], "end")
|
||||
|
||||
def test_temporary_context_is_sent_to_second_llm_call(self) -> None:
|
||||
runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"])
|
||||
@@ -593,6 +597,7 @@ class LiveRuntimeTests(unittest.TestCase):
|
||||
self.assertIn(FOLLOWUP_TIMEOUT, event_types)
|
||||
self.assertEqual(event_types[-1], STANDBY_RESUMED)
|
||||
self.assertEqual(len(llm.calls), 1)
|
||||
self.assertEqual(transport.played_segments[-1].metadata["chime"], "end")
|
||||
|
||||
def test_completed_reply_returns_to_standby_without_cloud_classifier_delay(self) -> None:
|
||||
frames = [wake_frame(0, 0)]
|
||||
|
||||
@@ -113,6 +113,9 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertTrue(config.barge_in_enabled)
|
||||
self.assertEqual(config.barge_in_min_speech_ms, 250)
|
||||
self.assertEqual(config.barge_in_echo_guard_ms, 500)
|
||||
self.assertTrue(config.end_chime_enabled)
|
||||
self.assertEqual(config.end_chime_frequency_hz, 880)
|
||||
self.assertEqual(config.end_chime_duration_ms, 140)
|
||||
self.assertTrue(config.llm_stream)
|
||||
self.assertEqual(config.validate_basic(), [])
|
||||
|
||||
@@ -153,6 +156,8 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
followup_listen_timeout_ms=-1,
|
||||
barge_in_min_speech_ms=-1,
|
||||
barge_in_echo_guard_ms=-1,
|
||||
end_chime_frequency_hz=0,
|
||||
end_chime_duration_ms=0,
|
||||
)
|
||||
errors = config.validate_basic()
|
||||
|
||||
@@ -161,6 +166,8 @@ class ModelsConfigTests(unittest.TestCase):
|
||||
self.assertTrue(any("OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_MIN_SPEECH_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_BARGE_IN_ECHO_GUARD_MS" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_END_CHIME_FREQUENCY_HZ" in error.message for error in errors))
|
||||
self.assertTrue(any("OWNER_END_CHIME_DURATION_MS" in error.message for error in errors))
|
||||
|
||||
def test_speaker_similarity_threshold_range_is_validated(self) -> None:
|
||||
config = AppConfig(speaker_similarity_threshold=1.5)
|
||||
|
||||
@@ -12,7 +12,7 @@ from owner_voice_pet.models import AudioFrame, ErrorCode, Message, PipelineState
|
||||
from owner_voice_pet.pipeline import VoicePipeline
|
||||
from owner_voice_pet.stt import MetadataSttProvider
|
||||
from owner_voice_pet.transport import MemoryAudioTransport
|
||||
from owner_voice_pet.tts import CloudTtsProvider, SentenceBuffer, SineTtsProvider, sanitize_tts_text
|
||||
from owner_voice_pet.tts import CloudTtsProvider, SentenceBuffer, SineTtsProvider, make_prompt_chime, sanitize_tts_text
|
||||
from owner_voice_pet.vad import EnergyVadProvider, VadRecorder
|
||||
from owner_voice_pet.wakeword import KeywordWakeWordProvider
|
||||
|
||||
@@ -73,6 +73,13 @@ class PipelineLlmTtsTests(unittest.TestCase):
|
||||
self.assertGreater(len(segment.pcm), 0)
|
||||
self.assertGreater(segment.duration_ms, 0)
|
||||
|
||||
def test_prompt_chime_generates_short_pcm_audio(self) -> None:
|
||||
segment = make_prompt_chime(frequency_hz=880, duration_ms=140, sample_rate=16000, channels=1)
|
||||
|
||||
self.assertEqual(segment.duration_ms, 140)
|
||||
self.assertEqual(segment.metadata["chime"], "end")
|
||||
self.assertGreater(len(segment.pcm), 0)
|
||||
|
||||
def test_cloud_tts_posts_speech_request(self) -> None:
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
|
||||
Reference in New Issue
Block a user