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()