diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 9ef127c65122..5ae4eb393f5e 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -335,6 +335,7 @@ export const FIELD_LABELS: Record = defineFieldCopy({ }, stt: { enabled: 'Speech To Text', + echoTranscripts: 'Echo Transcripts', provider: 'Speech-To-Text Provider', local: { model: 'Local Transcription Model', @@ -486,6 +487,7 @@ export const FIELD_DESCRIPTIONS: Record = defineFieldCopy({ }, stt: { enabled: 'Enable local or provider-backed speech transcription.', + echoTranscripts: 'Post the raw 🎙️ transcript of voice messages back to the chat.', elevenlabs: { languageCode: 'Optional ISO-639-3 language code. Blank lets ElevenLabs auto-detect.' } @@ -568,6 +570,7 @@ export const SECTIONS: DesktopConfigSection[] = [ keys: [ 'tts.provider', 'stt.enabled', + 'stt.echo_transcripts', 'stt.provider', 'voice.auto_tts', 'tts.edge.voice', diff --git a/gateway/config.py b/gateway/config.py index 7693cef1c27e..1c7735a6ed70 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -603,6 +603,7 @@ class GatewayConfig: # STT settings stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages + stt_echo_transcripts: bool = True # Whether to echo raw STT transcripts back to the user # Session isolation in shared chats group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available @@ -726,6 +727,7 @@ def to_dict(self) -> Dict[str, Any]: "always_log_local": self.always_log_local, "filter_silence_narration": self.filter_silence_narration, "stt_enabled": self.stt_enabled, + "stt_echo_transcripts": self.stt_echo_transcripts, "group_sessions_per_user": self.group_sessions_per_user, "thread_sessions_per_user": self.thread_sessions_per_user, "max_concurrent_sessions": self.max_concurrent_sessions, @@ -772,6 +774,13 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": stt_enabled = data.get("stt_enabled") if stt_enabled is None: stt_enabled = data.get("stt", {}).get("enabled") if isinstance(data.get("stt"), dict) else None + stt_echo_transcripts = data.get("stt_echo_transcripts") + if stt_echo_transcripts is None: + stt_echo_transcripts = ( + data.get("stt", {}).get("echo_transcripts") + if isinstance(data.get("stt"), dict) + else None + ) group_sessions_per_user = data.get("group_sessions_per_user") thread_sessions_per_user = data.get("thread_sessions_per_user") @@ -815,6 +824,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": data.get("filter_silence_narration"), True ), stt_enabled=_coerce_bool(stt_enabled, True), + stt_echo_transcripts=_coerce_bool(stt_echo_transcripts, True), group_sessions_per_user=_coerce_bool(group_sessions_per_user, True), thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False), multiplex_profiles=_coerce_bool(multiplex_profiles, False), @@ -917,6 +927,8 @@ def load_gateway_config() -> GatewayConfig: stt_cfg = yaml_cfg.get("stt") if isinstance(stt_cfg, dict): gw_data["stt"] = stt_cfg + if "stt_echo_transcripts" in yaml_cfg: + gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] diff --git a/gateway/run.py b/gateway/run.py index 7e50f73638c4..9381f0eab0f6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10147,10 +10147,11 @@ async def _prepare_inbound_message_text( message_text, audio_paths, ) - # Echo each successful transcript back to the user immediately, - # before the agent loop runs. Lets the user verify STT quality - # in real-time and see the raw whisper output verbatim. - if _successful_transcripts: + # Echo each successful transcript back to the user immediately + # when configured. Lets users verify STT quality in real-time, + # while allowing quiet STT for users who only want the agent to + # receive the transcription. + if _successful_transcripts and self._should_echo_stt_transcripts(): _echo_adapter = self.adapters.get(source.platform) _echo_meta = self._thread_metadata_for_source(source, self._reply_anchor_for_event(event)) if _echo_adapter: @@ -12638,6 +12639,10 @@ def _should_send_voice_reply( return True + def _should_echo_stt_transcripts(self) -> bool: + """Return whether inbound voice/STT transcripts should be echoed to chat.""" + return bool(getattr(self.config, "stt_echo_transcripts", True)) + async def _send_voice_reply(self, event: MessageEvent, text: str) -> None: """Generate TTS audio and send as a voice message before the text reply.""" import uuid as _uuid @@ -14738,9 +14743,9 @@ async def _dequeue_pending_with_transcription( enriched_text, successful_transcripts = await self._enrich_message_with_transcription( text, audio_paths, ) - # Echo raw transcripts back to the user so voice interrupts - # feel identical to fresh voice messages. - if successful_transcripts: + # Echo raw transcripts back to the user when configured so voice + # interrupts feel identical to fresh voice messages. + if successful_transcripts and self._should_echo_stt_transcripts(): echo_adapter = self.adapters.get(source.platform) echo_meta = {"thread_id": source.thread_id} if source.thread_id else None if echo_adapter: @@ -18608,7 +18613,7 @@ async def monitor_for_interrupt(): # real transcript instead of an empty string # (or file-path placeholder). Matches the UX # of fresh voice messages including the - # 🎙️ echo back to the user. + # optional 🎙️ echo back to the user. _media_urls = getattr(_peek_event, "media_urls", None) or [] _media_types = getattr(_peek_event, "media_types", None) or [] _audio_paths = [] @@ -18626,7 +18631,7 @@ async def monitor_for_interrupt(): pending_text, _audio_paths, ) pending_text = _enriched - if _transcripts: + if _transcripts and self._should_echo_stt_transcripts(): _echo_meta = {"thread_id": source.thread_id} if source.thread_id else None for _tx in _transcripts: try: @@ -19025,9 +19030,9 @@ def _stream_confirmed_final_delivery( # Transcribe audio media on the dequeued event BEFORE it is # handed back as the next user turn, so queued/interrupting # voice messages drain with the real transcript instead of - # a file-path placeholder. Echo each transcript back to the - # user (same 🎙️ format as fresh voice messages) so voice - # interrupts feel identical to text interrupts. + # a file-path placeholder. When configured, echo each + # transcript back to the user in the same 🎙️ format as + # fresh voice messages. _pending_text = pending_event.text or "" _media_urls = getattr(pending_event, "media_urls", None) or [] _media_types = getattr(pending_event, "media_types", None) or [] @@ -19046,7 +19051,7 @@ def _stream_confirmed_final_delivery( _pending_text, _audio_paths, ) pending = _enriched or None - if _transcripts: + if _transcripts and self._should_echo_stt_transcripts(): _echo_meta = {"thread_id": source.thread_id} if source.thread_id else None for _tx in _transcripts: try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3cd901c803ee..f7fb4c51b12b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2017,6 +2017,10 @@ def _ensure_hermes_home_managed(home: Path): "stt": { "enabled": True, + # When true, gateway voice messages are transcribed for the agent and + # the raw transcript is also echoed back to the user as a 🎙️ message. + # Set false to keep STT for the agent while suppressing that user-facing echo. + "echo_transcripts": True, "provider": "local", # "local" (free, faster-whisper) | "groq" | "openai" (Whisper API) | "mistral" (Voxtral Transcribe) | "elevenlabs" (Scribe) "local": { "model": "base", # tiny, base, small, medium, large-v3 diff --git a/scripts/release.py b/scripts/release.py index b71a3a455860..eb9cf9d89553 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -448,6 +448,7 @@ "simpolism@gmail.com": "simpolism", "jake@nousresearch.com": "simpolism", "mgongzai@gmail.com": "vKongv", + "perkintahmaz50@gmail.com": "devatnull", "0x.badfriend@gmail.com": "discodirector", "altriatree@gmail.com": "TruaShamu", "contact-me@stark-x.cn": "Stark-X", diff --git a/tests/gateway/test_stt_transcript_echo_config.py b/tests/gateway/test_stt_transcript_echo_config.py new file mode 100644 index 000000000000..4fd3649c9ec5 --- /dev/null +++ b/tests/gateway/test_stt_transcript_echo_config.py @@ -0,0 +1,70 @@ +from pathlib import Path +from types import SimpleNamespace + +from gateway.config import GatewayConfig, load_gateway_config +from gateway.run import GatewayRunner + + +def test_stt_echo_transcripts_defaults_on_for_backwards_compatibility(): + cfg = GatewayConfig.from_dict({}) + + assert cfg.stt_enabled is True + assert cfg.stt_echo_transcripts is True + assert cfg.to_dict()["stt_echo_transcripts"] is True + + +def test_stt_echo_transcripts_can_be_disabled_in_stt_section(): + cfg = GatewayConfig.from_dict({"stt": {"enabled": True, "echo_transcripts": False}}) + + assert cfg.stt_enabled is True + assert cfg.stt_echo_transcripts is False + + +def test_top_level_stt_echo_transcripts_takes_precedence(): + cfg = GatewayConfig.from_dict({ + "stt_echo_transcripts": False, + "stt": {"echo_transcripts": True}, + }) + + assert cfg.stt_echo_transcripts is False + + +def test_load_gateway_config_honors_top_level_stt_echo_transcripts(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text( + "stt:\n echo_transcripts: true\nstt_echo_transcripts: false\n", + encoding="utf-8", + ) + + cfg = load_gateway_config() + + assert cfg.stt_echo_transcripts is False + + +def test_gateway_runner_uses_stt_echo_transcripts_flag(): + runner = GatewayRunner.__new__(GatewayRunner) + + runner.config = SimpleNamespace(stt_echo_transcripts=False) + assert runner._should_echo_stt_transcripts() is False + + runner.config = SimpleNamespace(stt_echo_transcripts=True) + assert runner._should_echo_stt_transcripts() is True + + runner.config = SimpleNamespace() + assert runner._should_echo_stt_transcripts() is True + + +def test_all_gateway_transcript_echo_sends_are_gated(): + source = Path(__file__).resolve().parents[2] / "gateway" / "run.py" + lines = source.read_text().splitlines() + + echo_send_lines = [ + index + for index, line in enumerate(lines) + if "f'🎙️" in line or 'f"🎙️' in line + ] + + assert echo_send_lines + for index in echo_send_lines: + context = "\n".join(lines[max(0, index - 12): index + 1]) + assert "_should_echo_stt_transcripts()" in context diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 0bcda2138a45..93df14b4b8dd 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1543,6 +1543,8 @@ Hashes are deterministic — the same user always maps to the same hash, so the ```yaml stt: + enabled: true # Auto-transcribe inbound voice messages (default: true) + echo_transcripts: true # Post raw transcripts back to the chat as 🎙️ "..." (default: true) provider: "local" # "local" | "groq" | "openai" | "mistral" local: model: "base" # tiny, base, small, medium, large-v3 @@ -1551,6 +1553,8 @@ stt: # model: "whisper-1" # Legacy fallback key still respected ``` +Set `stt.echo_transcripts: false` when the gateway should transcribe voice notes for the agent but must not post the raw transcript back to the chat (for example, customer-facing WhatsApp bots). + Provider behavior: - `local` uses `faster-whisper` running on your machine. Install it separately with `pip install faster-whisper`.