Commit Graph
14 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 5 a63db8c957 Clear the search list after a pick, and stop double-announcing tracks
Two bits of clutter visible in one screenshot: the results list stayed in
the channel after a track was chosen, still wearing its reactions, and
every track produced two lines — the command's own reply and the player's
"now playing" notice.

Picking now spends the session and deletes the list, so a second reaction
does nothing and the message goes away. The player announces a track only
when it started one by itself; when a command started it, that command
has already said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 06:04:17 +03:00
Leonid PershinandClaude Opus 5 7cca374e66 Take the picture down when the queue runs out
The clip stayed frozen on screen after the music ended. Advancing the
queue cleaned up the decoder but never unpublished the video track, and a
published track keeps showing its last frame, so an empty queue left the
bot displaying a still. Every other teardown path (stop, skip, leaving
the channel) already dropped it; the natural end of the queue did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 05:27:02 +03:00
Leonid PershinandClaude Opus 5 7a369cf9f2 Sync the picture to the sound people hear, not the sound queued
The diagnostics came back clean — aheadBy steady at 3.3 s, nothing
dropped, nothing re-pegged — so the remaining lip-sync error was a
constant, and the cause is the clock: media.seconds counts audio handed
to LiveKit, whose audio source holds up to a second (its default queue)
before anyone hears it. Frames released against that ran a queue-length
early, every time.

The clock now subtracts what is still queued, which the source reports
directly, so the compensation follows the real buffer instead of a
guessed constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 04:12:48 +03:00
Leonid PershinandClaude Opus 5 bc567e953e Release video frames against the audio clock
With the codec fixed the picture stopped stuttering but ran ahead of the
sound, and for a structural reason: the audio takes a longer road to the
call — our PCM goes through revoice's own ffmpeg and its buffer — while
frames went out the moment they were decoded.

Each frame now carries its position in the stream and waits until the
audio that belongs with it has actually played, using the player's own
playback position as the shared clock. Frames that fall more than 250 ms
behind are dropped rather than shown late, so the picture recovers by
itself instead of accumulating drift.

The queue is bounded by bytes and never by pausing the stream: one ffmpeg
feeds both outputs, so blocking the video pipe would stop the audio whose
clock we are waiting on — a deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:56:04 +03:00
Leonid PershinandClaude Opus 5 ec50e4c4df Read voice presence from the bot's own LiveKit room
The panel still claimed it could not see anyone in voice while the bot
and the user were sitting in the same channel: Stoat's gateway does not
send voice states to bots, so the SDK's participant list stays empty no
matter what.

While the bot is connected its LiveKit room knows exactly who is there,
so that is now consulted alongside the SDK — presence is reported as
known, the panel names the channel the user is in, and the listener rule
has real data to work with instead of falling back to trust.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:51:24 +03:00
Leonid PershinandClaude Opus 5 a9b680c418 Make video a per-server toggle instead of an env-wide setting
VIDEO_ENABLED is now permission rather than behaviour: it decides whether
the feature exists at all, while turning it on for a server is a toggle in
the panel or !video in chat, off by default. Video costs real CPU for
every playing channel, so that should be a deliberate choice rather than
something a config flag switches on everywhere.

With the env flag off the panel renders no toggle at all and !video says
so, and the switch applies from the next track — swapping tracks mid-play
would cut the current one.

The loop button no longer reads "выкл" either: it sat next to the video
button showing the same word, so the two states were indistinguishable.
Both now name what they do and rely on highlighting for state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:13:08 +03:00
Leonid PershinandClaude Opus 5 1eec893d15 Count listeners from the room so the bot leaves empty channels
The bot stayed in a channel everyone had left. revoice.js stores voice
users by participant identity but removes them by participant name — and
Stoat sets those to different things (the user id and username#tag) — so
its list never shrinks and the channel never looked empty to us.

Participants are now counted from the LiveKit room directly, and the
check also runs on a 30s interval, so a participant event that never
arrives cannot strand the bot either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:03:28 +03:00
Leonid PershinandClaude Opus 5 49b87dc172 Publish the clip as a screen share alongside the audio
VIDEO_ENABLED makes the bot publish a second LiveKit track with the
picture. Stoat only grants screen_share in the call token when the bot
has the Video permission and the instance has video enabled, so a refusal
is reported in chat and playback continues with sound alone.

Two constraints shaped the pipeline, both found by testing rather than
assumption:

- Only progressive formats can be streamed. Separate video+audio streams
  make yt-dlp download both in full before muxing a single byte, and
  direct CDN URLs handed to ffmpeg simply hang — YouTube no longer serves
  them to other clients. That caps video at the 360p single file YouTube
  offers, and the format is checked before committing to the video path,
  since audio would otherwise come from the same broken pipeline.
- One ffmpeg with two outputs, paced by -re: an unpaced decode races
  ahead of the sound and eats memory at 1.4 MB per frame.

The same CDN-URL finding removes the audio seek shortcut, which resolved
such a URL and would have hung the same way; seeking now decodes up to
the offset like it already did behind a proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:10:11 +03:00
Leonid PershinandClaude Opus 5 3e53f34374 Tie playback to the listener and keep the panel's view live
Four things people hit while using the panel:

- The bot could be sent into a channel the requester was not in, and
  playback could be started from nowhere. Playback now follows the
  listener (REQUIRE_LISTENER, on by default), and the voice row shows
  where you and the bot are instead of offering a free channel picker.
- Voice presence only refreshed on reload, because the SDK updates
  channel participants without emitting an event. The socket now watches
  that view and pushes changes.
- The bot left the channel whenever the queue ran dry. It now leaves only
  after the last person does, EMPTY_TIMEOUT_SECONDS later (120 by
  default), and stays put while anyone is still listening.
- A search that yielded nothing said nothing: yt-dlp can exit 0 with an
  empty result, so that case now reports the reason (or "nothing found"),
  and searches are logged with their result count.

The queue moved under the player so search owns the left column, and
elapsed time no longer renders as "LIVE" — formatDuration treated 0 as a
live stream, which also affected the chat's progress bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:59:17 +03:00
Leonid PershinandClaude Opus 5 f22b08b350 Survive revoice's ffmpeg teardown and explain failed deletions
Switching tracks killed the process: revoice's #cleanUp() dereferences
this.fProc unconditionally, and its own ffmpeg error handler calls stop()
a second time after stop() has already nulled that field. Killing ffmpeg
is what triggers that error, so its handlers are detached first, the
instance's stop() is wrapped defensively (revoice calls it internally),
and an uncaughtException handler keeps the bot in the channel if the
dependency throws from another async callback.

Failed command-message deletions were logged at debug, i.e. invisible in
the default configuration; they now warn with Stoat's error type, and the
README says which permission the bot's role needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:50:31 +03:00
Leonid PershinandClaude Opus 5 315760e076 Update bundled yt-dlp and report source failures in chat
The image shipped a year-old yt-dlp, which YouTube now rejects with
"The page needs to be reloaded". Bumped to 2026.08.19 and documented
rebuilding as the standard fix, including how to pass a newer tag
without waiting for a repository update.

A downloader dying mid-stream also looked exactly like a very short
track: ffmpeg saw EOF, the player advanced, and the channel only got
"queue finished". The failure reason now reaches the chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:45:25 +03:00
Leonid PershinandClaude Opus 5 823a9f1565 Surface Stoat's voice errors and stop leaving orphan connections
Joining a channel failed silently: revoice's join() runs an async
executor inside `new Promise`, so a rejected join_call never reaches
reject() — the promise hangs forever and the real error escapes as an
unhandled rejection. The client wrapper now latches API failures and
settles the join itself, translating Stoat's error codes (AlreadyConnected,
LiveKitUnavailable, UnknownNode, ...) into messages the chat can show.

A failed join also used to leave the connection object alive, which kept
the bot registered in the channel and made the next attempt fail with
AlreadyConnected; it is now destroyed on any failure. The LiveKit node
name is configurable via VOICE_NODE for instances that renamed it, and
the README documents how to clear a stuck voice state from Redis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:40:21 +03:00
Leonid PershinandClaude Opus 5 d4880e757e Fix voice join against LiveKit 0.13+ and tidy up command messages
revoice.js reads `room.isConnected()`, but @livekit/rtc-node turned that
into a getter, so every join threw "this.room.isConnected is not a
function" before the bot ever reached the channel. The player no longer
touches that getter: readiness comes from the connection's own join /
roomfetched events and is cleared when it reports the offline state.

Also delete the invoking chat message once a command is recognised
(DELETE_COMMAND_MESSAGES, on by default) so channels stay readable;
failures are non-fatal since it needs ManageMessages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:34:39 +03:00
Leonid PershinandClaude Opus 5 a9b7ccdd16 Add music bot for self-hosted Stoat with web control panel
Plays audio into Stoat voice channels over LiveKit and exposes the same
player through both chat commands and a browser panel, so the two never
drift apart: everything routes through a single MusicManager.

- core: per-server GuildPlayer (queue, loop, shuffle, seek, volume,
  idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg
- sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet
  radio, optional local library with path-traversal guards
- bot: 18 chat commands with aliases, plus !panel one-time login links
- api: Fastify REST + WebSocket, sessions authenticated against the
  instance's own /auth/session/login (TOTP supported), permissions
  re-checked against Stoat membership and roles on every request
- web: React panel with search, queue editing, seek and volume
- deploy: Dockerfile, compose.override.yml and Caddyfile snippets for
  dropping the service into an existing /opt/stoat stack

Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs
(9 API checks). Voice playback itself needs a live instance to test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:12:43 +03:00