Files
discord.js/packages/voice/src/audio/TransformerGraph.ts
Jacob Morrison 637e1a4ddb refactor: Move getNode/canEnableFFmpegOptimizations into a lazy loaded call (#9918)
* Move the getNode/canEnableFFMPEGOptimizations into a lazy loaded call the first time it's actually ever referenced

* PR feedback: Make initializeNodes return a map then nullably assign NODES, this removes an extra variable and cleans up the code

* chore: lint suggestion

Co-authored-by: Aura Román <kyradiscord@gmail.com>

* Use local map instead of recursive

* Run prettier

* Fix lint

---------

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: Aura Román <kyradiscord@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2023-11-06 09:17:19 +00:00

282 lines
7.1 KiB
TypeScript

import type { Readable } from 'node:stream';
import prism from 'prism-media';
/**
* This module creates a Transformer Graph to figure out what the most efficient way
* of transforming the input stream into something playable would be.
*/
const FFMPEG_PCM_ARGUMENTS = ['-analyzeduration', '0', '-loglevel', '0', '-f', 's16le', '-ar', '48000', '-ac', '2'];
const FFMPEG_OPUS_ARGUMENTS = [
'-analyzeduration',
'0',
'-loglevel',
'0',
'-acodec',
'libopus',
'-f',
'opus',
'-ar',
'48000',
'-ac',
'2',
];
/**
* The different types of stream that can exist within the pipeline.
*/
export enum StreamType {
/**
* The type of the stream at this point is unknown.
*/
Arbitrary = 'arbitrary',
/**
* The stream at this point is Opus audio encoded in an Ogg wrapper.
*/
OggOpus = 'ogg/opus',
/**
* The stream at this point is Opus audio, and the stream is in object-mode. This is ready to play.
*/
Opus = 'opus',
/**
* The stream at this point is s16le PCM.
*/
Raw = 'raw',
/**
* The stream at this point is Opus audio encoded in a WebM wrapper.
*/
WebmOpus = 'webm/opus',
}
/**
* The different types of transformers that can exist within the pipeline.
*/
export enum TransformerType {
FFmpegOgg = 'ffmpeg ogg',
FFmpegPCM = 'ffmpeg pcm',
InlineVolume = 'volume transformer',
OggOpusDemuxer = 'ogg/opus demuxer',
OpusDecoder = 'opus decoder',
OpusEncoder = 'opus encoder',
WebmOpusDemuxer = 'webm/opus demuxer',
}
/**
* Represents a pathway from one stream type to another using a transformer.
*/
export interface Edge {
cost: number;
from: Node;
to: Node;
transformer(input: Readable | string): Readable;
type: TransformerType;
}
/**
* Represents a type of stream within the graph, e.g. an Opus stream, or a stream of raw audio.
*/
export class Node {
/**
* The outbound edges from this node.
*/
public readonly edges: Edge[] = [];
/**
* The type of stream for this node.
*/
public readonly type: StreamType;
public constructor(type: StreamType) {
this.type = type;
}
/**
* Creates an outbound edge from this node.
*
* @param edge - The edge to create
*/
public addEdge(edge: Omit<Edge, 'from'>) {
this.edges.push({ ...edge, from: this });
}
}
// Create a node for each stream type
let NODES: Map<StreamType, Node> | null = null;
/**
* Gets a node from its stream type.
*
* @param type - The stream type of the target node
*/
export function getNode(type: StreamType) {
const node = (NODES ??= initializeNodes()).get(type);
if (!node) throw new Error(`Node type '${type}' does not exist!`);
return node;
}
// Try to enable FFmpeg Ogg optimizations
function canEnableFFmpegOptimizations(): boolean {
try {
return prism.FFmpeg.getInfo().output.includes('--enable-libopus');
} catch {}
return false;
}
function initializeNodes(): Map<StreamType, Node> {
const nodes = new Map<StreamType, Node>();
for (const streamType of Object.values(StreamType)) {
nodes.set(streamType, new Node(streamType));
}
nodes.get(StreamType.Raw)!.addEdge({
type: TransformerType.OpusEncoder,
to: nodes.get(StreamType.Opus)!,
cost: 1.5,
transformer: () => new prism.opus.Encoder({ rate: 48_000, channels: 2, frameSize: 960 }),
});
nodes.get(StreamType.Opus)!.addEdge({
type: TransformerType.OpusDecoder,
to: nodes.get(StreamType.Raw)!,
cost: 1.5,
transformer: () => new prism.opus.Decoder({ rate: 48_000, channels: 2, frameSize: 960 }),
});
nodes.get(StreamType.OggOpus)!.addEdge({
type: TransformerType.OggOpusDemuxer,
to: nodes.get(StreamType.Opus)!,
cost: 1,
transformer: () => new prism.opus.OggDemuxer(),
});
nodes.get(StreamType.WebmOpus)!.addEdge({
type: TransformerType.WebmOpusDemuxer,
to: nodes.get(StreamType.Opus)!,
cost: 1,
transformer: () => new prism.opus.WebmDemuxer(),
});
const FFMPEG_PCM_EDGE: Omit<Edge, 'from'> = {
type: TransformerType.FFmpegPCM,
to: nodes.get(StreamType.Raw)!,
cost: 2,
transformer: (input) =>
new prism.FFmpeg({
args: ['-i', typeof input === 'string' ? input : '-', ...FFMPEG_PCM_ARGUMENTS],
}),
};
nodes.get(StreamType.Arbitrary)!.addEdge(FFMPEG_PCM_EDGE);
nodes.get(StreamType.OggOpus)!.addEdge(FFMPEG_PCM_EDGE);
nodes.get(StreamType.WebmOpus)!.addEdge(FFMPEG_PCM_EDGE);
nodes.get(StreamType.Raw)!.addEdge({
type: TransformerType.InlineVolume,
to: nodes.get(StreamType.Raw)!,
cost: 0.5,
transformer: () => new prism.VolumeTransformer({ type: 's16le' }),
});
if (canEnableFFmpegOptimizations()) {
const FFMPEG_OGG_EDGE: Omit<Edge, 'from'> = {
type: TransformerType.FFmpegOgg,
to: nodes.get(StreamType.OggOpus)!,
cost: 2,
transformer: (input) =>
new prism.FFmpeg({
args: ['-i', typeof input === 'string' ? input : '-', ...FFMPEG_OPUS_ARGUMENTS],
}),
};
nodes.get(StreamType.Arbitrary)!.addEdge(FFMPEG_OGG_EDGE);
// Include Ogg and WebM as well in case they have different sampling rates or are mono instead of stereo
// at the moment, this will not do anything. However, if/when detection for correct Opus headers is
// implemented, this will help inform the voice engine that it is able to transcode the audio.
nodes.get(StreamType.OggOpus)!.addEdge(FFMPEG_OGG_EDGE);
nodes.get(StreamType.WebmOpus)!.addEdge(FFMPEG_OGG_EDGE);
}
return nodes;
}
/**
* Represents a step in the path from node A to node B.
*/
interface Step {
/**
* The cost of the steps after this step.
*/
cost: number;
/**
* The edge associated with this step.
*/
edge?: Edge;
/**
* The next step.
*/
next?: Step;
}
/**
* Finds the shortest cost path from node A to node B.
*
* @param from - The start node
* @param constraints - Extra validation for a potential solution. Takes a path, returns true if the path is valid
* @param goal - The target node
* @param path - The running path
* @param depth - The number of remaining recursions
*/
function findPath(
from: Node,
constraints: (path: Edge[]) => boolean,
goal = getNode(StreamType.Opus),
path: Edge[] = [],
depth = 5,
): Step {
if (from === goal && constraints(path)) {
return { cost: 0 };
} else if (depth === 0) {
return { cost: Number.POSITIVE_INFINITY };
}
let currentBest: Step | undefined;
for (const edge of from.edges) {
if (currentBest && edge.cost > currentBest.cost) continue;
const next = findPath(edge.to, constraints, goal, [...path, edge], depth - 1);
const cost = edge.cost + next.cost;
if (!currentBest || cost < currentBest.cost) {
currentBest = { cost, edge, next };
}
}
return currentBest ?? { cost: Number.POSITIVE_INFINITY };
}
/**
* Takes the solution from findPath and assembles it into a list of edges.
*
* @param step - The first step of the path
*/
function constructPipeline(step: Step) {
const edges = [];
let current: Step | undefined = step;
while (current?.edge) {
edges.push(current.edge);
current = current.next;
}
return edges;
}
/**
* Finds the lowest-cost pipeline to convert the input stream type into an Opus stream.
*
* @param from - The stream type to start from
* @param constraint - Extra constraints that may be imposed on potential solution
*/
export function findPipeline(from: StreamType, constraint: (path: Edge[]) => boolean) {
return constructPipeline(findPath(getNode(from), constraint));
}