- USAGE.md: music discovery (!similar, !tags), autoplay discovery
config, Mumble admin (!mu) command reference
- CHEATSHEET.md: music discovery and Mumble admin quick reference
- ROADMAP.md: mark v2.4.0 as done, add MB fallback + !mu + autoplay
- TODO.md: mark music discovery and performance items as done
- PROJECT.md: update plugin categories table
- TASKS.md: close open doc items
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove early return on missing Last.fm API key. Both commands now
fall back to MusicBrainz (mb_search_artist -> mb_artist_tags ->
mb_find_similar_recordings) when no API key is configured or when
Last.fm returns empty results. Same pattern used by discover_similar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Admin-only command with subcommand dispatch for server management:
kick, ban, mute/unmute, deafen/undeafen, move, users, channels,
mkchan, rmchan, rename, desc. Auto-loads on merlin via except_plugins.
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm
for similar tracks. When Last.fm has no key or returns nothing, fall back
to MusicBrainz tag-based recording search (no API key needed). Discovered
tracks are resolved via yt-dlp and deduplicated within the session. If
discovery fails, the kept-deck shuffle continues as before.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reporting version 1.5.0 to mumble-server caused it to expect the newer
audio frame format (terminator bit on opus length varint). pymumble
1.6.1 does not implement this, so the server silently dropped every
UDPTunnel audio packet. Revert to native 1.2.4 -- audio works, only
side effect is a cosmetic ChannelListener warning.
Also remove --cprofile from docker-compose command.
Multi-bot setups need to know which bot is streaming. Prefixes all
stream_audio log messages with [username] for clarity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
derp now handles both listening and speaking, so audition no longer
needs cross-bot lookup or dual-play through merlin.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Self-register with the Mumble server on initial connection so both
derp and merlin get persistent identities (cert-bound user_id).
Skips registration on reconnect or if already registered.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Update reported protocol version from 1.2.4 to 1.5.0 so modern
Murmur servers treat PyMumble as a compatible client
- Fix OS string to report actual platform instead of "PyMumble 1.6.1"
(was shown as [Invalid] by Murmur)
- Raise pymumble reconnect retry interval to 15s to prevent autoban
when running multiple bots from the same IP
- Enable TCP keepalive on control socket (10s idle) to prevent NAT
gateways from dropping long-lived connections
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add !playlist import <name> <url> to resolve and save tracks from a URL
without queueing them. Add !playlist show <name> to display tracks in a
saved playlist via long_reply (auto-pastes on overflow). Add optional
'shuffle' keyword to !playlist load for randomized playback order.
Send IRC QUIT before closing the socket so the server releases the nick
immediately. Wrap readline() in wait_for(timeout=2.0) so the _running
check triggers even when the server is quiet. Explicitly stop pymumble
in the signal handler to accelerate Mumble teardown. Together these
eliminate the 10s podman grace-period SIGKILL.
Replace cProfile.runctx with Profile() + daemon thread that dumps
stats to disk every 60 seconds. Final dump on clean exit. Profile
data no longer lost on container stop, OOM, or SIGKILL.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.