Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/app/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
},
stt: {
enabled: 'Speech To Text',
echoTranscripts: 'Echo Transcripts',
provider: 'Speech-To-Text Provider',
local: {
model: 'Local Transcription Model',
Expand Down Expand Up @@ -486,6 +487,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = 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.'
}
Expand Down Expand Up @@ -568,6 +570,7 @@ export const SECTIONS: DesktopConfigSection[] = [
keys: [
'tts.provider',
'stt.enabled',
'stt.echo_transcripts',
'stt.provider',
'voice.auto_tts',
'tts.edge.voice',
Expand Down
12 changes: 12 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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"]
Expand Down
31 changes: 18 additions & 13 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -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:
Expand Down Expand Up @@ -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 []
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
70 changes: 70 additions & 0 deletions tests/gateway/test_stt_transcript_echo_config.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
Loading