Voice
Publish external audio and video into voice channels, and receive what users are sending.
The voice API is the low-level integration point for plugins that touch media: music bots, IPTV bridges, soundboards, recorders, transcribers. It hands you the raw mediasoup router for a channel, lets you register your own producers as a stream users can see and hear, and lets you consume what people in the channel are sending.
This assumes you know mediasoup. If you do not, read its documentation first; nothing here replaces it.
The API
| Method | Returns |
|---|---|
ctx.voice.getRouter(channelId) | The mediasoup Router for that voice channel |
ctx.voice.getListenInfo() | { ip, announcedAddress } |
ctx.voice.getState(channelId) | Who is connected, what they muted, and plugin streams |
ctx.voice.getProducers(channelId) | Every live producer in the channel |
ctx.voice.createStream(options) | A handle to the stream you created |
ctx.voice.consume(options) | A handle to a user's media, as RTP packets |
Everything but getListenInfo throws if the channel has no voice runtime. A runtime exists only while someone is in the channel, so wait for the voice:runtime_initialized event rather than assuming one is there.
getListenInfo is synchronous and returns the address Sharkord is listening on and the address it advertises to clients (webRtc.announcedAddress, when set). Use it when building your own transports.
Creating a Stream
The flow is: get the router, create your transports and producers, then register them.
import type { PluginContext } from "@sharkord/plugin-sdk";
const VIDEO_SSRC = 11111111;
const AUDIO_SSRC = 22222222;
const onLoad = async (ctx: PluginContext) => {
ctx.events.on("voice:runtime_initialized", async ({ channelId }) => {
const router = ctx.voice.getRouter(channelId);
const { ip, announcedAddress } = ctx.voice.getListenInfo();
const transport = await router.createPlainTransport({
listenInfo: { ip, protocol: "udp", announcedAddress },
rtcpMux: true,
comedia: true,
});
const audioProducer = await transport.produce({
kind: "audio",
rtpParameters: {
codecs: [
{
mimeType: "audio/opus",
payloadType: 111,
clockRate: 48000,
channels: 2,
parameters: {},
rtcpFeedback: [],
},
],
encodings: [{ ssrc: AUDIO_SSRC }],
},
});
const stream = ctx.voice.createStream({
channelId,
title: "Studio Feed",
key: "studio-feed",
producers: { audio: audioProducer },
});
stream.update({ title: "Studio Feed Live" });
// stream.remove() when you are done
});
};Send RTP to the transport's address and port, and it appears in the channel. Video works the same way, with a video producer on its own transport; the SDK re-exports the mediasoup types you need (Router, PlainTransport, Producer, RtpParameters, and friends).
createStream Options
| Option | Required | Description |
|---|---|---|
channelId | yes | The voice channel to publish into |
title | yes | Shown to users |
key | yes | Your identifier for the stream |
producers | yes | { audio?, video? } mediasoup producers |
avatarUrl | no | Avatar shown next to the stream |
bannerUrl | no | Banner image for the stream |
videoLayers | no | Simulcast layer labels, only used when simulcast is on in the server settings |
It returns:
{
streamId: number;
update(options): void; // title, avatarUrl, bannerUrl, producers, videoLayers
remove(): void;
}Reading the Channel
getState is the snapshot of who is in the channel and what they have turned on:
const { users, externalStreams } = ctx.voice.getState(channelId);
for (const { userId, state } of users) {
// state: { micMuted, soundMuted, webcamEnabled, sharingScreen }
}externalStreams is keyed by stream id and holds every plugin stream playing in the channel, yours and other plugins'.
getProducers is the list of live media in the channel:
const producers = ctx.voice.getProducers(channelId);
// [{ userId, kind, producerId, paused }]kind is a StreamKind: AUDIO, VIDEO, SCREEN or SCREEN_AUDIO. paused is true while the user has that track muted or stopped, and the producer stays in the list either way.
The SDK's own entry point does not export the StreamKind enum, so importing
it from @sharkord/plugin-sdk fails today. Until it does, take it from
@sharkord/shared.
Consuming a User's Media
ctx.voice.consume hands you a user's media as RTP packets, inside the Sharkord process. No ports are opened and no external program is involved.
import { StreamKind, type PluginContext } from "@sharkord/plugin-sdk";
const onLoad = async (ctx: PluginContext) => {
ctx.events.on("voice:producer_added", async ({ channelId, userId, kind }) => {
if (kind !== StreamKind.AUDIO) return;
const consumer = await ctx.voice.consume({
channelId,
userId,
kind,
onRtp: (packet) => buffer.push(packet),
});
// consumer.close() when you are done
});
};| Option | Description |
|---|---|
channelId | The voice channel |
userId | Whose media to consume |
kind | StreamKind.AUDIO, VIDEO, SCREEN or SCREEN_AUDIO |
onRtp | Called with one RTP packet, header included |
The handle carries producerId, the rtpParameters describing what the packets are, requestKeyFrame() for video, and close().
consume throws when that user has no producer of that kind in the channel, so a plugin that starts mid-call has to read getProducers as well as listening for voice:producer_added, or it only hears whoever unmutes after it started.
Audio is Opus at 48 kHz, and decoding it is yours to do. When what you want is
a file, do not decode packets yourself: create a plain transport from
getRouter() and consume the same producerId into ffmpeg, which gets you
decoding, resampling and muxing for free.
onRtp is called often, roughly every 20 ms per speaker for audio. Keep it to buffering and do the work elsewhere.
A plugin that consumes is listening to people in a voice channel, and nothing in the interface tells them so. Sharkord writes a line to the plugin's log when a consumer starts and stops, which is what an admin can find later. If you ship this, say so.
Cleaning Up
Keep every handle you create and call remove() on streams in onUnload. ctx.voice is available on the unload context for exactly this. Streams left behind stay visible to users until the channel's runtime closes.
Consumers are the exception: they close themselves when the producer ends and when the plugin unloads. Closing one yourself is how you stop early.