Covers API helpers (_get_similar_artists, _get_top_tags,
_get_similar_tracks, _search_track), config resolution, metadata
extraction from track titles, match score formatting, and both
commands (!similar, !tags) including play mode delegation, fallback
from track to artist similarity, and current-track integration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Move TTS greeting from mumble._play_greet to voice.on_connected
(fires once on first connect, gated on _is_audio_ready)
- Add initial_prompt multipart field to Whisper STT for trigger word
bias (auto-generated from trigger config, overridable)
- Enhanced !queue: elapsed/total on now-playing, per-track durations,
footer with track count and total time
- New !playlist command: save/load/list/del named playlists via
bot.state persistence (playlist:<name> keys)
- Fix duck floor test (1% -> 2% to match default change)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instant ducking is purely packet-based now -- _instant_duck() fires
on _on_sound_received, not on user state changes. Removes the
USERUPDATED callback that preemptively ducked on 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)
- _cleanup_track: never delete files from kept directory (data/music/)
even when track.keep=False -- fixes kept files vanishing on replay
- !kept rm: skip to next track if removing the currently playing one
- !skip: silent (no reply), restarts play loop for autoplay on empty queue
- TTS plays through merlin's own connection instead of derp's, preventing
choppy audio when music and TTS compete for the same output buffer
- !play recognizes bare YouTube video IDs (11-char alphanumeric)
- !kept rm <id> subcommand for removing individual kept tracks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When the initial download failed during playback and the track streamed
directly from URL, !keep would refuse with "No local file". Now it
downloads the track on the spot before keeping it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Music:
- #random URL fragment shuffles playlist tracks before enqueuing
- Lazy playlist resolution: first 10 tracks resolve immediately,
remaining are fetched in a background task
- !kept repair re-downloads kept tracks with missing local files
- !kept shows [MISSING] marker for tracks without local files
- TTS ducking: music ducks when merlin speaks via voice peer,
smooth restore after TTS finishes
Performance (from profiling):
- Connection pool: preload_content=True for SOCKS connection reuse
- Pool tuning: 30 pools / 8 connections (up from 20/4)
- _PooledResponse wrapper for stdlib-compatible read interface
- Iterative _extract_videos (replace 51K-deep recursion with stack)
- proxy=False for local SearXNG
Voice + multi-bot:
- Per-bot voice config lookup ([<username>.voice] in TOML)
- Mute detection: skip duck silence when all users muted
- Autoplay shuffle deck (no repeats until full cycle)
- Seek clamp to track duration (prevent seek-past-end stall)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- _on_sound_received skips audio from our own bots entirely,
preventing self-ducking and STT transcribing bot TTS/music
- New self_mute config: mute on connect, unmute before stream_audio,
re-mute 3s after audio finishes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add rubberband package to container for pitch-shifting FX
- Split FX chain: rubberband CLI for pitch, ffmpeg for filters
- Configurable voice profile (voice, fx, piper params) in [voice]
- Extra bots inherit voice config (minus trigger) for own TTS
- Greeting is voice-only, spoken directly by the greeting bot
- Per-bot only_plugins/except_plugins filtering on Mumble
- Alias plugin, core plugin tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Shell scripts for build, start, stop, restart, nuke, logs, status.
Shared helpers in _common.sh (colours, compose detection, project root).
Updated CHEATSHEET.md with new tool references.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove invalid --depth=0 from gitleaks checkout (needs full history).
Add opus symlink and pymumble/opuslib patch step to test jobs so
ctypes.find_library works on Alpine/musl.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add fade_step parameter to stream_audio for fast volume ramps
- _fade_and_cancel helper: smooth ~0.8s fade before track switch
- !skip, !stop, !seek now fade out instead of cutting instantly
- !prev command: go back to previous track (10-track history stack)
- !keep fetches title/artist/duration via yt-dlp, stores in bot.state
- !kept displays metadata (title, artist, duration, file size)
- !kept clear also removes stored metadata
- 29 new tests for fade, prev, history, keep metadata
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
actions/checkout@v4 requires node, which isn't available in
alpine or gitleaks images. Use plain git clone instead for
containerized jobs; keep actions/checkout for the host build job.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use container: directive for gitleaks, lint, and test jobs
- Build job stays on host (needs podman for image build/push)
- Add requirements-dev.txt for unified dev/test dependency install
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- gitleaks flagged fake api_key in test fixtures as a secret leak;
allowlist tests/ directory since it contains only mock data
- Install libopus0 in test runner for pymumble/opuslib import chain
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Dynamically resolve latest gitleaks version from GitHub releases
instead of hardcoded tarball URL that 404'd
- Add pymumble to test job install (needed by derp.mumble import
chain, not in pyproject.toml base deps)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Build and push to harbor.mymx.me/library/derp after gitleaks
and test jobs pass. Only runs on push to master. Tags with
short SHA and latest.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add gitleaks secret scanning (full history)
- Separate lint (ruff, Python 3.13 only) from test matrix
- Test job gates on lint; gitleaks runs in parallel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The else branch (no active task) only cleared current, task, and
duck_vol. Now resets all play state fields to match _play_loop's
finally block: done_event, duck_task, progress, and cur_seek.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
task.cancel() triggers _play_loop's finally block asynchronously.
When cmd_skip or cmd_seek called _ensure_loop before the finally
block ran, the old task's cleanup would overwrite the new task's
state -- causing !np to report "Nothing playing" while audio
was still streaming.
Now await the cancelled task before restarting the loop, ensuring
the finally block completes first.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Single-shot (!ask) and conversational (!chat) LLM commands backed by
OpenRouter's API. Per-user history (20 msg cap), 5s cooldown, reasoning
model fallback, and model switching via subcommands.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Piper is on 192.168.129.9:5100, not :5000. It expects POST with
JSON body {"text": "..."}, not GET with query params. Also update
Whisper default to 192.168.129.9.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Support [[mumble.extra]] config for additional Mumble identities that
inherit connection settings from the main [mumble] section. Extra bots
get their own state DB and do not run the voice trigger by default.
Add TTS greeting on first connect via mumble.greet config option.
Merlin joins as a second identity with his own client certificate.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two issues causing ~2min reconnect cycles:
1. pymumble captures threading.current_thread() as parent_thread at
init. Since we construct it in a run_in_executor thread, the parent
dies after _connect_sync returns, triggering pymumble's loop exit.
Fix: point parent_thread at threading.main_thread().
2. pymumble's init_connection() drops the control_socket reference
without closing it. The lingering TCP connection makes Murmur kick
the new session with "connected from another device". Fix: patch
init_connection to close the old socket before reconnecting.
pymumble passes a User object, not a dict. The isinstance(user, dict)
check returned False, setting name to None and silently discarding
every voice packet. Use try/except for dict-like access instead.
Seek to absolute or relative positions mid-track via !seek. Supports
M:SS and plain seconds with +/- prefixes. Volume is now saved to
bot.state and restored on connect.
When [voice] trigger is set in config, the bot continuously listens and
transcribes voice. Speech starting with the trigger word is stripped and
echoed back via TTS. Non-triggered speech is silently discarded unless
!listen is also active.
Whisper STT: buffers incoming voice PCM per user, transcribes on
silence gap via local whisper.cpp endpoint, posts results as actions.
Piper TTS: !say fetches WAV from local Piper endpoint and plays via
stream_audio(). 37 tests cover buffering, flush logic, transcription,
WAV encoding, commands, and lifecycle.
Audio is now downloaded to data/music/ before playback begins,
eliminating CDN hiccups mid-stream. Falls back to streaming on
download failure. Files are deleted after playback unless marked
with !keep. stream_audio detects local files and uses a direct
ffmpeg pipeline (no yt-dlp).
Guard stream_audio with _is_audio_ready() so that PCM frames are
dropped (not crashed on) when pymumble recreates SoundOutput with
encoder=None during reconnect. The ffmpeg pipeline stays alive,
position tracking remains accurate, and audio feeding resumes once
the codec is negotiated. Listeners hear brief silence instead of
a 30+ second restart with URL re-resolution.
Also adds chat messages to _auto_resume so users see what the bot
intends ("Resuming 'X' at M:SS in a moment" / "...aborted").
Auto-resume: save playback position on stream errors and cancellation,
restore automatically after reconnect or container restart once the
channel is silent. Plugin lifecycle hook (on_connected) ensures the
reconnect watcher starts without waiting for user commands.
Sorcerer tier: new permission level between oper and admin. Configured
via [mumble] sorcerers list in derp.toml.
Mumble cert auth: pass certfile/keyfile to pymumble for client
certificate authentication.
Fixes: stream_audio now re-raises CancelledError and Exception so
_play_loop detects failures correctly. Subprocess cleanup uses 3s
timeout. Graceful shutdown cancels background tasks before stopping
pymumble. Safe getattr for _opers in core plugin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
!volume +10 increases by 10, !volume -5 decreases by 5.
Out-of-range results (below 0 or above 100) are rejected.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tracks playback position via frame counting in stream_audio().
On stop/skip, saves URL + elapsed time to bot.state (SQLite).
!resume reloads the track and seeks to the saved position via
ffmpeg -ss. State persists across bot restarts.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-URL input (e.g. !play classical music) searches YouTube for 10
results and picks one randomly. Also fixes --flat-playlist returning
"NA" as the URL for single videos by falling back to the original
input URL.
Volume changes now ramp linearly per-sample via _scale_pcm_ramp instead
of jumping abruptly. Each frame steps _cur_vol toward target by at most
0.1, giving ~200ms for a full 0-to-1 sweep. Fast path unchanged when
volume is stable.
_resolve_title replaced with _resolve_tracks using --flat-playlist to
enumerate playlist entries. cmd_play enqueues each track individually,
with truncation when the queue is nearly full. Single-video behavior
unchanged.