Zum Inhalt

Empfangen und aufnehmen

Bostie hält ein Mikrofon, während Schallwellen ankommen

vc.receive() öffnet einen Receiver: einen begrenzten asynchronen Iterator über das Audio, das der Bot hört. Jeder Sprecher kommt einzeln an, entschlüsselt, wieder in Reihenfolge gebracht und in Rust dekodiert. An einer Verbindung können mehrere Receiver offen sein, jeder mit eigenem Format und eigenen Filtern.

import logging

import bostie
from bostie import Context, voice

log = logging.getLogger("my-bot")
bot = bostie.Bot()


@bot.command
async def meter(ctx: Context, seconds: int = 10) -> None:
    """Log how loud each speaker is for a few seconds."""
    vc = ctx.voice.current or await ctx.voice.connect()
    await ctx.respond("Listening.")
    loudest: dict[int, float] = {}
    async with vc.receive(voice.PCM(rate=16_000, channels=1)) as frames:
        async for frame in frames:
            if frame.user_id is not None and frame.level is not None:
                level = max(loudest.get(frame.user_id, frame.level), frame.level)
                loudest[frame.user_id] = level
            if frame.offset >= seconds * 48_000:
                break
    for user_id, level in loudest.items():
        log.info("user %d peaked at %.1f dBFS", user_id, level)


@bot.command
async def transcribe(ctx: Context) -> None:
    """Collect what each person says, one utterance at a time."""
    vc = ctx.voice.current or await ctx.voice.connect()
    await ctx.respond("Transcribing until the bot leaves.")
    rx = vc.receive(voice.PCM(rate=16_000, channels=1), markers=True)
    async for said in voice.utterances(rx, min_silence=0.6, max_length=20.0):
        log.info(
            "user %d spoke %.1f s (%d bytes of 16 kHz mono)",
            said.user_id,
            said.duration,
            len(said.data),
        )

Formate

vc.receive(format) gibt einen voice.Receiver für ein voice.ReceiveFormat zurück:

vc.receive(format) Elemente Für
voice.PCM(rate=48000, channels=2, sample="s16le") Frames mit dekodierten Samples in diesem Format; die Rate ist eine der voice.DECODE_RATES, 8, 12, 16, 24 oder 48 kHz Zuhören, Spracherkennung, Pegel
voice.Opus() Frames, deren data ein Opus-Paket ist Aufnehmen oder Weiterleiten ohne Dekodieren
voice.Packets() Packets, entschlüsselt, in Ankunftsreihenfolge, vor dem Jitter-Puffer deinen eigenen Jitter-Puffer, RTP-Header-Erweiterungen

Ein Frame hat user_id, ssrc, den RTP-timestamp, offset und samples auf der 48-kHz-Uhr des Receivers, received_at, data, lost (wahr für Audio, das Opus erfunden hat, um ein verlorenes Paket zu kaschieren) und bei PCM level, den RMS des Frames in dBFS. Die Uhr startet, wenn der Receiver öffnet, sodass sich Frames verschiedener Benutzer über offset ausrichten lassen. Ein Packet hat user_id, ssrc, sequence, timestamp, received_at, die opus-Nutzdaten und die RTP-Header-extensions.

Auswählen, wen du hörst

  • users=[...] behält nur diese Benutzer, und exclude=[...] lässt einige weg.
  • include_self=True fügt das eigene Audio des Bots hinzu.
  • mix=True (PCM und Opus) liefert einen Stream, in dem alle gemischt sind, mit user_id auf None.
  • markers=True setzt SpeechStart-, SpeechStop- und UserLeave-Events zwischen die Frames, jeweils mit ihrem offset auf derselben Uhr, sodass ein Verbraucher weiß, wo Sprache beginnt und endet, ohne selbst zu messen.

Einstellungen

vc.receive nimmt diese Schlüsselwortargumente; Packets() hat keinen Jitter-Puffer und nimmt daher kein jitter.

Einstellung Standard Bedeutung
buffer Defaults.receive_buffer, 2.0 Sekunden an Elementen, die der Receiver für einen langsamen Verbraucher hält, 0,02 bis 60
overflow Defaults.overflow, "drop_oldest" wenn der Puffer voll ist: die ältesten Elemente verwerfen, oder "drop_newest"
jitter Defaults.jitter, 0.1 Sekunden, die auf verspätete Pakete gewartet wird, bevor es weitergeht, 0 bis 1; nur PCM und Opus
stall Defaults.receive_stall, "wait" wenn sich die Verbindung neu verbindet: "wait" hält den Receiver offen, "end" beendet ihn

Lesen

async for item in rx wartet auf Elemente und hört auf, wenn der Receiver endet. async with rx: schließt ihn, wenn der Block endet.

  • rx.get_nowait() gibt das nächste Element zurück, oder löst voice.Empty aus, wenn nichts eingereiht ist, und voice.ReceiverClosed, wenn der Receiver beendet ist.
  • rx.queued ist, wie lange das älteste eingereihte Element schon wartet: der Rückstand des Verbrauchers.
  • rx.clear() verwirft alles Eingereihte und macht live weiter.
  • await rx.mark() gibt True zurück, sobald das vor dem Aufruf empfangene Audio eingereiht oder verworfen ist, oder False nach 1,2 Sekunden plus dem doppelten Jitter; das Abbrechen eines Wartens lässt die Marke für die anderen bestehen.
  • rx.stats() gibt ein ReceiverStats zurück: delivered gelieferte Elemente, durch Überlauf dropped verworfene und unterwegs lost verlorene Pakete.
  • rx.close() beendet den Empfang. rx.closed_reason, ein voice.ClosedReason, sagt, warum ein Receiver endete: CLOSED, DISCONNECTED, MOVED (der Bot hat den Kanal gewechselt) oder RECONNECTING (mit stall="end").

Ein Moderator kann den Bot taubschalten, und Discord sendet ihm dann kein Audio; bostie loggt eine Warnung, wenn ein Receiver öffnet, während der Bot taubgeschaltet ist, und wenn der Bot taubgeschaltet wird, während Receiver offen sind.

Weiterleiten

Ein Receiver und ein Sink lassen sich direkt verbinden, um Audio von einem Anruf in einen anderen zu tragen:

from bostie import voice


async def relay_call(
    source: voice.VoiceConnection, target: voice.VoiceConnection
) -> None:
    async with source.receive(voice.PCM(), mix=True) as rx, target.sink() as out:
        async for frame in rx:
            await out.write(frame.data)


async def relay_speaker(
    source: voice.VoiceConnection, target: voice.VoiceConnection, user_id: int
) -> None:
    async with (
        source.receive(voice.Opus(), users=[user_id]) as rx,
        target.sink(voice.Opus()) as out,
    ):
        async for frame in rx:
            if not frame.lost:
                await out.write(frame.data)

Ein gemischter Frame hat weder user_id noch ssrc oder timestamp, und ein Mix kann keine Marker tragen. Ein verlorener Opus-Frame hat leere data, weil es kein Paket zum Weiterreichen gibt; überspring ihn, bevor du in einen Opus-Sink schreibst, wie es relay_speaker tut. Beide Enden verwenden standardmäßig 48-kHz-Stereo-16-Bit-PCM, sodass ein PCM-Receiver direkt in einen Standard-Sink schreibt.

Sprache

Die Spracherkennung läuft auf jeder Verbindung in Rust, solange speech_activity eingeschaltet ist: Ein Benutzer beginnt mit seinem ersten Audio zu sprechen und hört nach speech_hangover Sekunden Stille auf, standardmäßig 0,2. vc.speaking enthält, wer gerade spricht, die Events SpeechStart und SpeechStop melden es an Listener, und Receiver, die mit markers=True geöffnet wurden, tragen es in ihrem Stream. Ist speech_activity aus und kein Receiver offen, entschlüsselt bostie das eingehende Audio nicht einmal.

voice.utterances(rx, min_silence=0.8, max_length=30.0) gruppiert die Frames eines PCM-Receivers zu Utterances: die Sprache eines Benutzers von Anfang bis Ende, mit data im Format des Receivers, ihrem offset, duration und den lost verlorenen Sekunden. Eine Äußerung endet nach min_silence Sekunden ohne Sprache oder bei max_length Sekunden. Es braucht einen PCM-Receiver ohne mix, der mit markers=True geöffnet wurde, oder einen level in dBFS, der dann statt der Marker entscheidet, was als Sprache zählt.

Aufnehmen

import bostie
from bostie import Context, voice

bot = bostie.Bot()


@bot.command
async def record_call(ctx: Context, minutes: float = 5.0) -> None:
    """Record the whole call as one WAV file, for at most a few minutes."""
    vc = ctx.voice.current or await ctx.voice.connect()
    await ctx.respond("Recording. Everyone in the call can see the bot.")
    recorder = voice.Recorder(
        vc,
        "recordings/{guild_id}/call.wav",
        mix=True,
        max_duration=minutes * 60,
        max_size=200_000_000,
    )
    files = await recorder.wait()
    await ctx.followup(f"Saved {files[None]}; {recorder.stats().lost} packets lost.")

voice.Recorder(vc, path) nimmt jeden Benutzer in einer eigenen Datei auf, oder mit mix=True den ganzen Anruf in einer. Der Pfad ist eine Vorlage mit {guild_id} und {user_id}, erforderlich für eine Datei pro Benutzer und bei mix=True abgelehnt, und seine Dateiendung bestimmt den Container:

  • .ogg oder .opus schreibt Ogg Opus. Pro Benutzer werden die empfangenen Opus-Pakete unverändert geschrieben, ohne Dekodierung.
  • .wav schreibt 16-Bit-PCM im format des Recorders, standardmäßig 48 kHz Stereo; seine Rate ist eine der voice.DECODE_RATES.

Mit align=True, dem Standard, füllt Stille die Lücken, sodass jede Datei beginnt, als die Aufnahme begann, und die Dateien zueinander passen. users, exclude und include_self wählen wie bei einem Receiver, wer aufgenommen wird, und buffer (30 Sekunden) und jitter (0,3 Sekunden) sind größer als die Standardwerte eines Receivers, weil eine Festplatte ins Stocken geraten kann. Die Aufnahme endet von selbst nach max_duration Sekunden, sobald ihre Dateien max_size Bytes enthalten, oder wenn die Verbindung endet.

await recorder.stop() beendet sie, sobald das bereits gehörte Audio geschrieben ist, und gibt die Dateien als Dict von Benutzer-ID auf Path zurück, mit None als Schlüssel einer gemischten Datei. await recorder.wait() wartet, bis sie von selbst endet. recorder.files listet die bisher geschriebenen Dateien und recorder.stats() die Zähler des Receivers, wobei dropped bedeutet, dass die Festplatte länger als buffer zurückgefallen ist.

Sag den Leuten im Anruf, dass der Bot sie aufnimmt, wie es das Snippet tut.