sipeed/picoclaw · warning

voice connection closed during playback

Error message

voice connection closed during playback

What it means

streamOggOpusToDiscord converts a panic into this error with recover: sending an audio frame on vc.OpusSend panics when discordgo closed the channel because the voice websocket dropped mid-playback (disconnect, kick, move, rehome). The recovered error means TTS/audio playback was aborted because the voice connection went away.

Source

Thrown at pkg/channels/discord/voice.go:122

					"channel": m.ChannelID,
					"error":   sendErr,
				})
			}
		}
		return true
	}
	return false
}

func VoiceReceiveActive(vc *discordgo.VoiceConnection) bool {
	return vc != nil && vc.OpusRecv != nil
}

func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, r io.Reader) (retErr error) {
	// Recover from panic if vc.OpusSend is closed mid-send (e.g. on disconnect)
	defer func() {
		if rec := recover(); rec != nil {
			retErr = fmt.Errorf("voice connection closed during playback")
			logger.RecoverPanicNoExit(rec)
		}
	}()

	// Wait for the speaking transition to register
	vc.Speaking(true)
	defer vc.Speaking(false)

	return audio.DecodeOggOpus(r, func(frame []byte) error {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case vc.OpusSend <- frame:
			return nil
		}
	})
}

View on GitHub (pinned to 49183d7e8d)

Solutions

  1. Treat as non-fatal: log it and require a fresh !vc join before the next playback
  2. Guard playback preconditions: check vc != nil && vc.OpusSend != nil before streaming (mirroring VoiceReceiveActive)
  3. Cancel the playback ctx from a voice-disconnect handler so DecodeOggOpus exits via ctx.Done() instead of panicking
  4. Rate-limit TTS length so flaps are less likely mid-playback

Example fix

// before
send := func(frame []byte) error {
    vc.OpusSend <- frame // panics 'send on closed channel' on disconnect
    return nil
}

// after (as implemented): select on ctx + recover
return audio.DecodeOggOpus(r, func(frame []byte) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case vc.OpusSend <- frame:
        return nil
    }
})
Defensive patterns

Strategy: validation

Validate before calling

// Guard before playback (mirror of VoiceReceiveActive for the send side)
func voiceSendReady(vc *discordgo.VoiceConnection) bool {
    return vc != nil && vc.OpusSend != nil
}

if !voiceSendReady(vc) {
    return fmt.Errorf("voice connection not ready; rejoin with !vc join")
}

Try / catch

if err := streamOggOpusToDiscord(ctx, vc, r); err != nil {
    if strings.Contains(err.Error(), "voice connection closed during playback") {
        // recovered panic: connection dropped mid-TTS; clear state and require rejoin
        return nil // or notify: "voice disconnected, use !vc join"
    }
    return err
}

Prevention

When it happens

Trigger: Playing TTS audio while: a user runs !vc leave, the bot is disconnected/moved from the voice channel, Discord rehomes the voice server (OpusSend closed), or the voice websocket/UDP path drops during a long playback.

Common situations: Users leaving voice while the bot is mid-sentence, voice connection flaps during long TTS output, guild voice region changes.

Understand the failure class

Related errors


AI-assisted analysis of sipeed/picoclaw@49183d7e8d (2026-08-15). Data as JSON: /api/errors/84bd5680e22c9c5d. Report an issue: GitHub.