feat: ack tone, duck-before-TTS, instant ducking on voice/unmute

- Add ascending two-tone chime (880Hz/1320Hz) before TTS playback
  as audible acknowledgment that the voice trigger was recognized
- Signal music ducking 1.5s before TTS starts so music is already
  lowered when audio begins playing
- Snap duck volume to floor instantly on voice packet or user unmute
  via pymumble callback, eliminating the 1s poll delay
- Register USERUPDATED callback to preemptively duck when a user
  unmutes (they're about to speak)
- Strip leading punctuation from trigger remainder (Whisper artifacts)
This commit is contained in:
user
2026-02-22 18:46:33 +01:00
parent 068734d931
commit c522d30c36
2 changed files with 88 additions and 2 deletions

View File

@@ -11,6 +11,8 @@ import asyncio
import io
import json
import logging
import math
import struct
import threading
import time
import urllib.request
@@ -83,6 +85,50 @@ def _pcm_to_wav(pcm: bytes) -> bytes:
return buf.getvalue()
# -- Acknowledge tone --------------------------------------------------------
_ACK_FREQ = (880, 1320) # A5 -> E6 ascending
_ACK_NOTE_DUR = 0.15 # seconds per note
_ACK_AMP = 12000 # gentle amplitude
_ACK_FRAME = 960 # 20ms at 48kHz, matches Mumble native
async def _ack_tone(bot) -> None:
"""Play a short two-tone ascending chime via pymumble sound_output."""
mu = getattr(bot, "_mumble", None)
if mu is None:
return
so = mu.sound_output
if so is None:
return
# Unmute if self-muted (stream_audio handles re-mute later)
if getattr(bot, "_self_mute", False):
if bot._mute_task and not bot._mute_task.done():
bot._mute_task.cancel()
bot._mute_task = None
try:
mu.users.myself.unmute()
except Exception:
pass
frames_per_note = int(_ACK_NOTE_DUR / 0.02) # 0.02s per frame
for freq in _ACK_FREQ:
for i in range(frames_per_note):
samples = []
for j in range(_ACK_FRAME):
t = (i * _ACK_FRAME + j) / _SAMPLE_RATE
samples.append(int(_ACK_AMP * math.sin(2 * math.pi * freq * t)))
pcm = struct.pack(f"<{_ACK_FRAME}h", *samples)
so.add_sound(pcm)
while so.get_buffer_size() > 0.5:
await asyncio.sleep(0.02)
# Wait for tone to finish
while so.get_buffer_size() > 0:
await asyncio.sleep(0.05)
# -- STT: Sound listener (pymumble thread) ----------------------------------
@@ -170,7 +216,7 @@ async def _flush_monitor(bot):
trigger = ps["trigger"]
if trigger and text.lower().startswith(trigger.lower()):
remainder = text[len(trigger):].strip()
remainder = text[len(trigger):].strip().lstrip(",.;:!?")
if remainder:
log.info("voice: trigger from %s: %s", name, remainder)
bot._spawn(
@@ -243,8 +289,10 @@ async def _tts_play(bot, text: str):
if wav_path is None:
return
try:
# Signal music plugin to duck while TTS is playing
# Signal music plugin to duck, wait for it to take effect
bot.registry._tts_active = True
await asyncio.sleep(1.5)
await _ack_tone(bot)
done = asyncio.Event()
await bot.stream_audio(str(wav_path), volume=1.0, on_done=done)
await done.wait()

View File

@@ -20,6 +20,7 @@ from pymumble_py3.constants import (
PYMUMBLE_CLBK_DISCONNECTED,
PYMUMBLE_CLBK_SOUNDRECEIVED,
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED,
PYMUMBLE_CLBK_USERUPDATED,
)
from derp.bot import _TokenBucket
@@ -217,6 +218,10 @@ class MumbleBot:
PYMUMBLE_CLBK_SOUNDRECEIVED,
self._on_sound_received,
)
self._mumble.callbacks.set_callback(
PYMUMBLE_CLBK_USERUPDATED,
self._on_user_updated,
)
self._mumble.set_receive_sound(self._receive_sound)
self._mumble.start()
self._mumble.is_ready()
@@ -287,6 +292,18 @@ class MumbleBot:
log.warning("mumble: disconnected")
self._last_voice_ts = 0.0
def _instant_duck(self) -> None:
"""Snap music volume to duck floor immediately.
Called from pymumble thread on voice/unmute events so ducking
takes effect on the next audio frame (~20ms) instead of waiting
for the 1s duck monitor poll.
"""
for peer in getattr(self.registry, "_bots", {}).values():
ps = getattr(peer, "_pstate", {}).get("music")
if ps and ps.get("duck_enabled") and ps.get("task"):
ps["duck_vol"] = float(ps["duck_floor"])
def _on_sound_received(self, user, sound_chunk) -> None:
"""Callback from pymumble thread: voice audio received.
@@ -302,12 +319,33 @@ class MumbleBot:
self.registry._voice_ts = self._last_voice_ts
if prev == 0.0:
log.info("mumble: first voice packet from %s", name or "?")
self._instant_duck()
for fn in self._sound_listeners:
try:
fn(user, sound_chunk)
except Exception:
log.exception("mumble: sound listener error")
def _on_user_updated(self, user, actions) -> None:
"""Callback from pymumble thread: user state changed.
When a non-bot user unmutes, update ``_voice_ts`` and snap duck
volume to floor immediately.
"""
if "self_mute" not in actions:
return
name = user["name"] if isinstance(user, dict) else None
bots = getattr(self.registry, "_bots", {})
if name and name in bots:
return
# Only care about unmute (self_mute going False)
if user.get("self_mute", True):
return
log.info("mumble: %s unmuted, preemptive duck", name or "?")
self._last_voice_ts = time.monotonic()
self.registry._voice_ts = self._last_voice_ts
self._instant_duck()
def _on_text_message(self, message) -> None:
"""Callback from pymumble thread: text message received.