mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-10 16:43:31 +01:00
* 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>
282 lines
7.1 KiB
TypeScript
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));
|
|
}
|