diff --git a/.env.example b/.env.example index 2ec029e..e2d7393 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,7 @@ 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_FILE=assets/sounds/codex-notification.wav OWNER_END_CHIME_FREQUENCY_HZ=880 OWNER_END_CHIME_DURATION_MS=140 OWNER_WAKE_WORD=小杰小杰 diff --git a/.gitignore b/.gitignore index b3e2d90..85fa157 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ *.py[cod] *.aiff *.wav +!assets/sounds/codex-notification.wav *.flac .pytest_cache/ .mypy_cache/ diff --git a/README.md b/README.md index f477c3a..47c772c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ 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_FILE=assets/sounds/codex-notification.wav OWNER_END_CHIME_FREQUENCY_HZ=880 OWNER_END_CHIME_DURATION_MS=140 ``` @@ -87,7 +88,7 @@ OWNER_END_CHIME_DURATION_MS=140 `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` 可以关闭。 +`OWNER_END_CHIME_ENABLED=1` 表示对话自然结束或追问超时恢复待机前会播放一声项目内置提示音,默认文件是 `assets/sounds/codex-notification.wav`。提示音不走 TTS,也不会写入上下文;如果 `OWNER_END_CHIME_FILE` 指向的文件缺失,会回退到本地合成短音,`OWNER_END_CHIME_FREQUENCY_HZ` 和 `OWNER_END_CHIME_DURATION_MS` 只影响这个回退音。设置 `OWNER_END_CHIME_ENABLED=0` 可以关闭。 ## 本地模型 diff --git a/assets/sounds/codex-notification.wav b/assets/sounds/codex-notification.wav new file mode 100644 index 0000000..83004be Binary files /dev/null and b/assets/sounds/codex-notification.wav differ diff --git a/src/owner_voice_pet/__init__.py b/src/owner_voice_pet/__init__.py index d98bbec..6fb3c05 100644 --- a/src/owner_voice_pet/__init__.py +++ b/src/owner_voice_pet/__init__.py @@ -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, make_prompt_chime, sanitize_tts_text +from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, 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_end_chime", "make_prompt_chime", "sanitize_tts_text", "validate_pet_assets", diff --git a/src/owner_voice_pet/assistant_pipeline.py b/src/owner_voice_pet/assistant_pipeline.py index bb33741..5c0accd 100644 --- a/src/owner_voice_pet/assistant_pipeline.py +++ b/src/owner_voice_pet/assistant_pipeline.py @@ -47,7 +47,7 @@ from .protocols import ( WakeWordProvider, ) from .stt import is_valid_transcript_text -from .tts import SentenceBuffer, make_prompt_chime, sanitize_tts_text +from .tts import SentenceBuffer, make_end_chime, sanitize_tts_text from .vad import VadRecorder @@ -491,7 +491,8 @@ class TurnController: def _play_end_chime(self) -> None: if not self.config.end_chime_enabled: return - segment = make_prompt_chime( + segment = make_end_chime( + file_path=self.config.end_chime_file, frequency_hz=self.config.end_chime_frequency_hz, duration_ms=self.config.end_chime_duration_ms, sample_rate=self.config.sample_rate, diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index b3f0cfc..2b86bca 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -105,6 +105,7 @@ def main(argv: list[str] | None = None) -> int: "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_file": str(config.end_chime_file), "end_chime_frequency_hz": config.end_chime_frequency_hz, "end_chime_duration_ms": config.end_chime_duration_ms, }, diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index ce65442..1d48b3c 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -60,6 +60,7 @@ class AppConfig: barge_in_min_speech_ms: int = 250 barge_in_echo_guard_ms: int = 500 end_chime_enabled: bool = True + end_chime_file: Path = Path("assets/sounds/codex-notification.wav") end_chime_frequency_hz: int = 880 end_chime_duration_ms: int = 140 @@ -134,6 +135,10 @@ class AppConfig: 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_file=Path( + get("END_CHIME_FILE", "assets/sounds/codex-notification.wav") + or "assets/sounds/codex-notification.wav" + ), 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"), ) diff --git a/src/owner_voice_pet/runtime.py b/src/owner_voice_pet/runtime.py index 8be13c0..9b845e2 100644 --- a/src/owner_voice_pet/runtime.py +++ b/src/owner_voice_pet/runtime.py @@ -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, make_prompt_chime, sanitize_tts_text +from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, make_end_chime, sanitize_tts_text from .vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, SherpaOnnxVadProvider, VadRecorder from .wakeword import SherpaOnnxKeywordWakeWordProvider @@ -343,7 +343,8 @@ class LiveVoiceRuntime: def _play_end_chime(self) -> None: if not self.config.end_chime_enabled: return - segment = make_prompt_chime( + segment = make_end_chime( + file_path=self.config.end_chime_file, frequency_hz=self.config.end_chime_frequency_hz, duration_ms=self.config.end_chime_duration_ms, sample_rate=self.config.sample_rate, diff --git a/src/owner_voice_pet/tts.py b/src/owner_voice_pet/tts.py index de2dfd5..31a7f87 100644 --- a/src/owner_voice_pet/tts.py +++ b/src/owner_voice_pet/tts.py @@ -170,7 +170,55 @@ def make_prompt_chime( channels, 0, duration_ms, - {"chime": "end", "text": "end_chime"}, + {"chime": "end", "text": "end_chime", "source": "synthetic"}, + ) + + +def make_end_chime( + *, + file_path: str | Path | None = None, + frequency_hz: int = 880, + duration_ms: int = 140, + sample_rate: int = 16000, + channels: int = 1, +) -> AudioSegment: + if file_path is not None: + segment = _load_chime_file(Path(file_path)) + if segment is not None: + return segment + return make_prompt_chime( + frequency_hz=frequency_hz, + duration_ms=duration_ms, + sample_rate=sample_rate, + channels=channels, + ) + + +def _load_chime_file(path: Path) -> AudioSegment | None: + if not path.is_file(): + return None + data = path.read_bytes() + if not data: + return None + suffix = path.suffix.lower().lstrip(".") or "wav" + sample_rate = 16000 + channels = 1 + duration_ms = 120 + if suffix == "wav": + try: + with wave.open(str(path), "rb") as handle: + sample_rate = handle.getframerate() + channels = handle.getnchannels() + duration_ms = int(handle.getnframes() / max(1, sample_rate) * 1000) + except wave.Error: + pass + return AudioSegment( + data, + sample_rate, + channels, + 0, + max(20, duration_ms), + {"chime": "end", "text": "end_chime", "format": suffix, "path": str(path), "source": "file"}, ) diff --git a/tests/test_cli_acceptance.py b/tests/test_cli_acceptance.py index 4f6eca0..fed19d2 100644 --- a/tests/test_cli_acceptance.py +++ b/tests/test_cli_acceptance.py @@ -47,6 +47,7 @@ class CliAcceptanceTests(unittest.TestCase): 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_file"], "assets/sounds/codex-notification.wav") self.assertEqual(data["end_chime_duration_ms"], 140) self.assertNotIn("secret-value", str(data)) diff --git a/tests/test_live_runtime.py b/tests/test_live_runtime.py index 0ade4fb..93c3bac 100644 --- a/tests/test_live_runtime.py +++ b/tests/test_live_runtime.py @@ -272,7 +272,9 @@ class LiveRuntimeTests(unittest.TestCase): 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[2].metadata["source"], "file") self.assertEqual(transport.played_segments[5].metadata["chime"], "end") + self.assertEqual(transport.played_segments[5].metadata["source"], "file") self.assertEqual(reporter.transcripts, ["第一问", "第二问"]) self.assertIn("应答中:我在", reporter.statuses) self.assertLess(reporter.statuses.index("唤醒命中"), reporter.statuses.index("应答中:我在")) @@ -339,6 +341,7 @@ class LiveRuntimeTests(unittest.TestCase): self.assertEqual(len(stt.calls), 1) self.assertEqual(transport.flush_count, 3) self.assertEqual(transport.played_segments[-1].metadata["chime"], "end") + self.assertEqual(transport.played_segments[-1].metadata["source"], "file") def test_no_ack_text_does_not_drain_before_capture(self) -> None: runtime, stt, _, transport, reporter = make_runtime(["第一问"], wake_ack_text="") @@ -351,6 +354,18 @@ class LiveRuntimeTests(unittest.TestCase): self.assertEqual(len(transport.played_segments), 2) self.assertEqual(transport.flush_count, 2) self.assertEqual(transport.played_segments[-1].metadata["chime"], "end") + self.assertEqual(transport.played_segments[-1].metadata["source"], "file") + + def test_end_chime_can_be_disabled(self) -> None: + runtime, _, _, transport, _ = make_runtime(["第一问"], wake_ack_text="") + runtime.config = AppConfig(llm_api_key="secret", speech_provider="cloud", wake_ack_text="", end_chime_enabled=False) + runtime.controller.config = runtime.config + + summary = runtime.run(max_turns=1) + + self.assertEqual(summary.completed_turns, 1) + self.assertEqual(len(transport.played_segments), 1) + self.assertNotIn("chime", transport.played_segments[-1].metadata) def test_temporary_context_is_sent_to_second_llm_call(self) -> None: runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"]) @@ -598,6 +613,7 @@ class LiveRuntimeTests(unittest.TestCase): self.assertEqual(event_types[-1], STANDBY_RESUMED) self.assertEqual(len(llm.calls), 1) self.assertEqual(transport.played_segments[-1].metadata["chime"], "end") + self.assertEqual(transport.played_segments[-1].metadata["source"], "file") def test_completed_reply_returns_to_standby_without_cloud_classifier_delay(self) -> None: frames = [wake_frame(0, 0)] diff --git a/tests/test_models_config.py b/tests/test_models_config.py index 9eec226..e021e3c 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -114,6 +114,7 @@ class ModelsConfigTests(unittest.TestCase): 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(str(config.end_chime_file), "assets/sounds/codex-notification.wav") self.assertEqual(config.end_chime_frequency_hz, 880) self.assertEqual(config.end_chime_duration_ms, 140) self.assertTrue(config.llm_stream) diff --git a/tests/test_pipeline_llm_tts.py b/tests/test_pipeline_llm_tts.py index 146ece9..360c594 100644 --- a/tests/test_pipeline_llm_tts.py +++ b/tests/test_pipeline_llm_tts.py @@ -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, make_prompt_chime, sanitize_tts_text +from owner_voice_pet.tts import CloudTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text from owner_voice_pet.vad import EnergyVadProvider, VadRecorder from owner_voice_pet.wakeword import KeywordWakeWordProvider @@ -78,8 +78,24 @@ class PipelineLlmTtsTests(unittest.TestCase): self.assertEqual(segment.duration_ms, 140) self.assertEqual(segment.metadata["chime"], "end") + self.assertEqual(segment.metadata["source"], "synthetic") self.assertGreater(len(segment.pcm), 0) + def test_end_chime_uses_file_when_present(self) -> None: + segment = make_end_chime(file_path="assets/sounds/codex-notification.wav") + + self.assertEqual(segment.metadata["source"], "file") + self.assertEqual(segment.metadata["path"], "assets/sounds/codex-notification.wav") + self.assertEqual(segment.metadata["format"], "wav") + self.assertGreater(segment.duration_ms, 0) + + def test_end_chime_falls_back_to_synthetic_when_file_missing(self) -> None: + segment = make_end_chime(file_path="/tmp/owner-voice-pet-missing-chime.wav", duration_ms=140) + + self.assertEqual(segment.metadata["source"], "synthetic") + self.assertEqual(segment.metadata["chime"], "end") + self.assertEqual(segment.duration_ms, 140) + def test_cloud_tts_posts_speech_request(self) -> None: class FakeResponse: def __enter__(self):