Skip to content

Run a voice node without a gateway

You have a process that holds the bot's gateway session and another that should capture or play audio in a voice channel. The second one needs libdave, CGO and a socket near the speakers, and it must not hold the bot token. voicenode, in chat-platform-discord v0.14.0 and later, is the Client for that second process, and voicenode/link and voicenode/broker, from v0.15.0, are the control link between the two: a worker and a gateway holder wired over a messaging.Bus, with no socket or call of your own to write.

A transport can carry audio only is why the node is shaped as it is. This page is how to run the two processes.

Before you start

  • CGO, and libdave at run time, on the worker only. Everything in voicenode but its doc comment is behind //go:build cgo; without it the package is empty rather than failing. The broker needs neither: it never touches the voice stack.
  • The bot's Discord user id, on both processes. Neither learns it from the token: the broker is told with broker.Config.Bot, the worker with link.Config.Bot, link.Config.Self and voicenode.Config.Self. Bot namespaces the bus subjects, so a wrong one on the worker is a link no broker answers and an Open that waits. Self is what the link compares with the bot the broker names on every event, and a mismatch fails Open with ErrInvalidArgument rather than waiting. A wrong voicenode.Config.Self on its own is detected by nothing.
  • A worker name, matching [A-Za-z0-9_-]+. It is a lease, not an identity: two processes started with the same name are one worker to the broker, and the younger takes the name once the elder's lease lapses.
  • A messaging.Bus (gitlab.com/phpboyscout/go/messaging) reachable from both processes, over whatever backend you deploy: messaging-nats or the in-memory backend for a test. Both sides call messaging.New, which can return a usable bus and a degraded-setting error; the test is bus == nil, not the error.
  • A broker.Store. broker.NewMemoryStore() ships and is what the broker's own tests run against; it holds nothing across a restart. A deployment that wants claims to survive one writes its own and checks it against broker/storetest, the suite any Store must pass.

1. Build the gateway holder's process

store := broker.NewMemoryStore() // or your own, checked against storetest

brk, err := broker.New(store, broker.Config{Bot: botID})
if err != nil {
    return err
}

// The one store call made before Run; refused with ErrInvalidArgument after.
resume, err := brk.ResumeState(ctx)
if err != nil {
    return err
}

bus, err := messaging.New(backend, messaging.Settings{
    Source:        "voice-broker",
    Subscriptions: brk.Subscriptions(),
})
if bus == nil {
    return err
}

brk.Bind(bus)

client, err := discord.NewClient(
    chatplatform.ClientConfig{
        Token: token,
        Needs: []chatplatform.Need{chatplatform.NeedVoiceParticipants},
    },
    discord.WithControl(brk),
    discord.WithResumeState(resume.SessionID, resume.Sequence, resume.ResumeURL),
)
if err != nil {
    return err
}

go func() { errs <- brk.Run(ctx) }()

if err := bus.Start(ctx); err != nil {
    return err
}

return discord.ConnectControl(ctx, client)

The order matters. Run must be draining before bus.Start lets anything arrive, or a handler enqueues onto a queue nobody is reading yet; Run itself must start after Bind, since it is Bind that gives the broker something to answer through. discord.WithControl takes the gateway away from this client's own voice and requires NeedVoiceParticipants, because the broker reads the guild voice-state intent that need requests, and declaring NeedVoiceReceive or NeedVoiceSend beside it is refused at NewClient, because a control client joins nothing itself. ConnectControl is what opens the gateway here: Connect is a Reader method reached through a scope, and a broker holds no scope, so the provider gains this one free function instead.

discord.WithResumeState takes the checkpoint by its three fields, not the struct: a first start has an empty resume.SessionID, which sets nothing, so the client identifies afresh.

2. Build the worker process

l, err := link.New(link.Config{Bot: botID, Self: botID, Worker: workerName})
if err != nil {
    return err
}

bus, err := messaging.New(backend, messaging.Settings{
    Source:        "voice-worker-" + workerName,
    Subscriptions: l.Subscriptions(),
})
if bus == nil {
    return err
}

l.Bind(bus)

node, err := voicenode.New(voicenode.Config{
    Control: l,
    Self:    botID,
    Needs:   []chatplatform.Need{chatplatform.NeedVoiceReceive, chatplatform.NeedVoiceSend},
})
if err != nil {
    return err
}

if err := l.Attach(node); err != nil {
    return err
}

if err := bus.Start(ctx); err != nil {
    return err
}

Attach must come before Open, and only once: it is what gives the link the voicenode.Events and voicenode.Lifecycle the node implements, and it refuses a second call or a client that implements neither.

3. Join, through the ordinary contract

Past construction, a node is an ordinary chatplatform.Client:

p, err := node.Provider(ctx, guildID, chatplatform.WithAllowedChannels(channelID))
if err != nil {
    return err
}

if err := p.Reader.Connect(ctx); err != nil { // calls link.Open
    return err
}

rx, ok := chatplatform.AsVoiceReceiver(p)
if !ok {
    return errors.New("no voice receiver on this scope")
}

session, err := rx.Join(ctx, channelID, sink)

Connect calls Control.Open, which blocks until the broker has given this worker name a generation: immediately on a fresh name, or until the predecessor's lease lapses when a replacement starts before it has stopped. Join's gate runs in the provider's own order before anything touches the link: an unparseable id or a nil sink with ErrInvalidArgument, a channel outside the allowlist with ErrChannelDenied, a scope that has not connected with ErrNotConnected, a second join with ErrVoiceBusy. Past it, Send, Stream, Interruptions, Stats and Leave behave as the provider's do, because the node shares the provider's join and session code.

A read-only scope has no Actor, so no voice; the explanation page says why that is the right answer rather than a gap.

4. What is different from the in-process provider

Provider Node, over the link
CanSend reads the member cache always false: the permission lookup needs a gateway cache the node has none of
a moderator disconnects the bot ErrRemovedFromVoice, from the gateway event ErrRemovedFromVoice, from the broker's own gateway event, forwarded through Lifecycle.HandleVoiceLeft
the broker loses the claim (expiry, supersession, a reset) n/a ErrClaimLost, from HandleVoiceLeft
VoiceParticipants present, from the voice-state cache absent, whatever was declared
a wrong Self cannot happen; learned from the token not detected on the node; the link fails Open when its own Self disagrees with the bot the broker names

The moderator row changed with the link. Before it existed, a node's only detector for a dropped session was the liveness watch on the voice connection, reporting ErrVoiceGatewayGone well after the fact. The broker now attributes the same null VOICE_STATE_UPDATE the provider's own gateway listener does (a channel change nobody asked for, on a claim that is not leaving) and the link turns that into ErrRemovedFromVoice on Lifecycle.HandleVoiceLeft, which ends the session directly. ErrClaimLost is the node's own addition: it has no equivalent on the provider, because the provider has no lease for another worker to take.

5. Test it offline

The conformance harness sends no token, so it runs against a node exactly as it runs against the provider:

chatplatformtest.RunProviderConformance(t, chatplatformtest.ConformanceConfig{
    NewClient: func(cfg chatplatform.ClientConfig) (chatplatform.Client, error) {
        return voicenode.New(voicenode.Config{Control: fakeLink, Self: self, Needs: cfg.Needs})
    },
    Refuses:      []chatplatform.Need{chatplatform.NeedMessages},
    Capabilities: chatplatformtest.Capabilities{VoiceReceiver: true, VoiceSender: true},
    // Space, SecondSpace, AllowedChannel: any syntactically valid snowflakes
})

A join past the gate is observable without a network too: it calls your Control.UpdateVoiceState with the guild and channel, then waits. A fake link that records the call and a short context deadline is a complete test of the proxy. broker/storetest is the same idea for a store: a suite whose only subject is the adapter under test proves the suite and the adapter agree, not that either is right, which is why it is run against the in-memory store too.

Operations

One broker per bot. A second is a deployment error the store's compare-and-swap turns into a crash (ErrConflict from Run) rather than into two bots fighting over a channel.

The lease numbers, all constants because a worker and a broker that disagree about them disagree about who holds a guild: a worker says hello every link.HelloInterval (5s), the broker's claim lease is broker.Lease (15s), so three hellos fit inside it, and the broker sweeps every broker.SweepInterval (5s). A worker that has heard nothing usable for link.ReconnectingAfter (10s) reports itself reconnecting before its claims are taken, since that precedes the lease.

What a dead worker costs. The main gateway never reports a dead worker's voice connection on its own, so a worker that dies leaves its bot in the channel until the broker's lease expires it: 15 to 20 seconds, measured (report 0023).

What a broker restart costs. A resumed broker session leaves every claim alone: nothing moves. A refused resume releases what it held, and the worker's own repeats recover a dropped request in about five seconds (report 0022).

Every voice-gateway close costs a leave and a rejoin. Discord answers a request naming the channel the bot is already in with no fresh credentials, so disgo's own reconnect after a voice-gateway close (4006, 4009) is a leave then a join, and the bot is audibly out of the channel for the gap (report 0021). This is the engine's behaviour, not the link's, so it costs the same with or without a broker.

See also