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

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
+3
View File
@@ -45,6 +45,9 @@ OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS=3000
OWNER_BARGE_IN_ENABLED=1 OWNER_BARGE_IN_ENABLED=1
OWNER_BARGE_IN_MIN_SPEECH_MS=250 OWNER_BARGE_IN_MIN_SPEECH_MS=250
OWNER_BARGE_IN_ECHO_GUARD_MS=500 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_WAKE_WORD=小杰小杰
OWNER_SAMPLE_RATE=16000 OWNER_SAMPLE_RATE=16000
OWNER_CHANNELS=1 OWNER_CHANNELS=1
+5
View File
@@ -72,6 +72,9 @@ OWNER_FOLLOWUP_LISTEN_TIMEOUT_MS=3000
OWNER_BARGE_IN_ENABLED=1 OWNER_BARGE_IN_ENABLED=1
OWNER_BARGE_IN_MIN_SPEECH_MS=250 OWNER_BARGE_IN_MIN_SPEECH_MS=250
OWNER_BARGE_IN_ECHO_GUARD_MS=500 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 回复。 `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_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 模型: 首次运行前必须准备本地语音模型;同一脚本会下载 wake、VAD、2025 中文 CTC STT 和 GTCRN denoiser 模型:
+2 -1
View File
@@ -27,7 +27,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
from .pipeline import PipelineResult, VoicePipeline from .pipeline import PipelineResult, VoicePipeline
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
from .simulation import run_simulated_live 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 .assets import validate_pet_assets
from .ui import ConsolePetWindow, PetStateController, PetVisualState from .ui import ConsolePetWindow, PetStateController, PetVisualState
@@ -70,6 +70,7 @@ __all__ = [
"MacSayTtsProvider", "MacSayTtsProvider",
"SentenceBuffer", "SentenceBuffer",
"SineTtsProvider", "SineTtsProvider",
"make_prompt_chime",
"sanitize_tts_text", "sanitize_tts_text",
"validate_pet_assets", "validate_pet_assets",
"ConsolePetWindow", "ConsolePetWindow",
+23 -14
View File
@@ -47,7 +47,7 @@ from .protocols import (
WakeWordProvider, WakeWordProvider,
) )
from .stt import is_valid_transcript_text 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 from .vad import VadRecorder
@@ -344,19 +344,10 @@ class TurnController:
else: else:
decision = self._decide_continuation(current_user_text, last_assistant_text, current_turn_id) decision = self._decide_continuation(current_user_text, last_assistant_text, current_turn_id)
if not decision.should_continue: if not decision.should_continue:
self._event( self._end_conversation(
CONTINUOUS_SESSION_ENDED, current_turn_id,
PipelineState.WAKE_LISTENING,
"",
turn_id=current_turn_id,
payload={"decision": decision.action, "reason": decision.reason}, payload={"decision": decision.action, "reason": decision.reason},
) )
self._event(
STANDBY_RESUMED,
PipelineState.WAKE_LISTENING,
"恢复待机:可继续唤醒",
turn_id=current_turn_id,
)
return TurnResult( return TurnResult(
True, True,
current_user_text, current_user_text,
@@ -488,11 +479,29 @@ class TurnController:
"追问超时:未检测到用户回答", "追问超时:未检测到用户回答",
turn_id=turn_id, turn_id=turn_id,
) )
self._event(CONTINUOUS_SESSION_ENDED, PipelineState.WAKE_LISTENING, "", turn_id=turn_id) self._end_conversation(turn_id)
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return None return None
return user_text 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: def _speak(self, sentence: str, turn_id: int) -> SpeakResult:
spoken_sentence = sanitize_tts_text(sentence) spoken_sentence = sanitize_tts_text(sentence)
if not spoken_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_enabled": config.barge_in_enabled,
"barge_in_min_speech_ms": config.barge_in_min_speech_ms, "barge_in_min_speech_ms": config.barge_in_min_speech_ms,
"barge_in_echo_guard_ms": config.barge_in_echo_guard_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, ensure_ascii=False,
sort_keys=True, sort_keys=True,
+20
View File
@@ -59,6 +59,9 @@ class AppConfig:
barge_in_enabled: bool = True barge_in_enabled: bool = True
barge_in_min_speech_ms: int = 250 barge_in_min_speech_ms: int = 250
barge_in_echo_guard_ms: int = 500 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 @classmethod
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig": 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_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_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"), 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 @classmethod
@@ -394,6 +400,20 @@ class AppConfig:
"startup", "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 return errors
def api_url(self, path: str) -> str: 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 .protocols import AudioTransport, LlmProvider, RealtimeSttProvider, SttProvider, TtsProvider, WakeWordProvider
from .stt import CloudAsrSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text from .stt import CloudAsrSttProvider, SherpaOnnxSttProvider, is_valid_transcript_text
from .transport import SoundDeviceAudioTransport 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 .vad import EnergyVadProvider, HybridVadProvider, PrimarySpeakerVadRecorder, SherpaOnnxVadProvider, VadRecorder
from .wakeword import SherpaOnnxKeywordWakeWordProvider from .wakeword import SherpaOnnxKeywordWakeWordProvider
@@ -323,6 +323,7 @@ class LiveVoiceRuntime:
turn_id, turn_id,
) )
self.context.append_assistant(spoken_text) self.context.append_assistant(spoken_text)
self._play_end_chime()
self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id) self._event(STANDBY_RESUMED, PipelineState.WAKE_LISTENING, "恢复待机:可继续唤醒", turn_id=turn_id)
return TurnResult(True, user_text, spoken_text, states=list(self._states)) return TurnResult(True, user_text, spoken_text, states=list(self._states))
@@ -339,6 +340,20 @@ class LiveVoiceRuntime:
self._drain_input_after_playback() self._drain_input_after_playback()
return spoken_sentence 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: 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(STAGE_ERROR, PipelineState.ERROR_RECOVERING, error.message, turn_id=turn_id, payload={"error": error})
self._event(RECOVERING, PipelineState.ERROR_RECOVERING, "恢复待机:本轮已结束", turn_id=turn_id) 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,;:") 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: class SentenceBuffer:
def __init__(self, max_chars: int = 80) -> None: def __init__(self, max_chars: int = 80) -> None:
self.max_chars = max_chars self.max_chars = max_chars
+2
View File
@@ -46,6 +46,8 @@ class CliAcceptanceTests(unittest.TestCase):
self.assertEqual(data["endpoint_mode"], "primary_speaker") self.assertEqual(data["endpoint_mode"], "primary_speaker")
self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500) self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500)
self.assertEqual(data["context_mode"], "session_memory") 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)) self.assertNotIn("secret-value", str(data))
def test_security_check_command_has_no_leaks(self) -> None: def test_security_check_command_has_no_leaks(self) -> None:
+11 -6
View File
@@ -269,8 +269,10 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertEqual(runtime.config.post_playback_drain_ms, 0) self.assertEqual(runtime.config.post_playback_drain_ms, 0)
self.assertEqual(len(stt.calls), 2) self.assertEqual(len(stt.calls), 2)
self.assertEqual(len(llm.calls), 2) self.assertEqual(len(llm.calls), 2)
self.assertEqual(len(transport.played_segments), 4) self.assertEqual(len(transport.played_segments), 6)
self.assertEqual(transport.flush_count, 4) 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.assertEqual(reporter.transcripts, ["第一问", "第二问"])
self.assertIn("应答中:我在", reporter.statuses) self.assertIn("应答中:我在", reporter.statuses)
self.assertLess(reporter.statuses.index("唤醒命中"), reporter.statuses.index("应答中:我在")) self.assertLess(reporter.statuses.index("唤醒命中"), reporter.statuses.index("应答中:我在"))
@@ -324,7 +326,7 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertEqual(summary.completed_turns, 2) self.assertEqual(summary.completed_turns, 2)
self.assertEqual(ack_tts.synthesized_texts, ["我在"]) self.assertEqual(ack_tts.synthesized_texts, ["我在"])
self.assertEqual(transport.played_segments[0].metadata["text"], "我在") 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: def test_zero_post_playback_drain_flushes_without_dropping_prefilled_question(self) -> None:
runtime, stt, _, transport, reporter = make_runtime(["第一问"]) runtime, stt, _, transport, reporter = make_runtime(["第一问"])
@@ -335,7 +337,8 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertEqual(summary.completed_turns, 1) self.assertEqual(summary.completed_turns, 1)
self.assertEqual(reporter.transcripts, ["第一问"]) self.assertEqual(reporter.transcripts, ["第一问"])
self.assertEqual(len(stt.calls), 1) 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: def test_no_ack_text_does_not_drain_before_capture(self) -> None:
runtime, stt, _, transport, reporter = make_runtime(["第一问"], wake_ack_text="") 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(summary.completed_turns, 1)
self.assertEqual(reporter.transcripts, ["第一问"]) self.assertEqual(reporter.transcripts, ["第一问"])
self.assertEqual(len(stt.calls), 1) self.assertEqual(len(stt.calls), 1)
self.assertEqual(len(transport.played_segments), 1) self.assertEqual(len(transport.played_segments), 2)
self.assertEqual(transport.flush_count, 1) 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: def test_temporary_context_is_sent_to_second_llm_call(self) -> None:
runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"]) runtime, _, llm, _, _ = make_runtime(["第一问", "第二问"])
@@ -593,6 +597,7 @@ class LiveRuntimeTests(unittest.TestCase):
self.assertIn(FOLLOWUP_TIMEOUT, event_types) self.assertIn(FOLLOWUP_TIMEOUT, event_types)
self.assertEqual(event_types[-1], STANDBY_RESUMED) self.assertEqual(event_types[-1], STANDBY_RESUMED)
self.assertEqual(len(llm.calls), 1) 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: def test_completed_reply_returns_to_standby_without_cloud_classifier_delay(self) -> None:
frames = [wake_frame(0, 0)] frames = [wake_frame(0, 0)]
+7
View File
@@ -113,6 +113,9 @@ class ModelsConfigTests(unittest.TestCase):
self.assertTrue(config.barge_in_enabled) self.assertTrue(config.barge_in_enabled)
self.assertEqual(config.barge_in_min_speech_ms, 250) self.assertEqual(config.barge_in_min_speech_ms, 250)
self.assertEqual(config.barge_in_echo_guard_ms, 500) 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.assertTrue(config.llm_stream)
self.assertEqual(config.validate_basic(), []) self.assertEqual(config.validate_basic(), [])
@@ -153,6 +156,8 @@ class ModelsConfigTests(unittest.TestCase):
followup_listen_timeout_ms=-1, followup_listen_timeout_ms=-1,
barge_in_min_speech_ms=-1, barge_in_min_speech_ms=-1,
barge_in_echo_guard_ms=-1, barge_in_echo_guard_ms=-1,
end_chime_frequency_hz=0,
end_chime_duration_ms=0,
) )
errors = config.validate_basic() 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_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_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_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: def test_speaker_similarity_threshold_range_is_validated(self) -> None:
config = AppConfig(speaker_similarity_threshold=1.5) config = AppConfig(speaker_similarity_threshold=1.5)
+8 -1
View File
@@ -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.pipeline import VoicePipeline
from owner_voice_pet.stt import MetadataSttProvider from owner_voice_pet.stt import MetadataSttProvider
from owner_voice_pet.transport import MemoryAudioTransport 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.vad import EnergyVadProvider, VadRecorder
from owner_voice_pet.wakeword import KeywordWakeWordProvider from owner_voice_pet.wakeword import KeywordWakeWordProvider
@@ -73,6 +73,13 @@ class PipelineLlmTtsTests(unittest.TestCase):
self.assertGreater(len(segment.pcm), 0) self.assertGreater(len(segment.pcm), 0)
self.assertGreater(segment.duration_ms, 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: def test_cloud_tts_posts_speech_request(self) -> None:
class FakeResponse: class FakeResponse:
def __enter__(self): def __enter__(self):