Sharkord
Plugins

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

MethodReturns
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

OptionRequiredDescription
channelIdyesThe voice channel to publish into
titleyesShown to users
keyyesYour identifier for the stream
producersyes{ audio?, video? } mediasoup producers
avatarUrlnoAvatar shown next to the stream
bannerUrlnoBanner image for the stream
videoLayersnoSimulcast 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.

StreamKind is not re-exported yet

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
  });
};
OptionDescription
channelIdThe voice channel
userIdWhose media to consume
kindStreamKind.AUDIO, VIDEO, SCREEN or SCREEN_AUDIO
onRtpCalled 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.

What arrives is what the browser sent

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.

Consuming is recording

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.

On this page