Repository: discordjs/voice (ts)

Issue:
Infer stream type for applicable streams
<!-- Use Discord for questions: https://discord.gg/bRCvFy9 -->

The library could easily infer some stream types, e.g. a stream would be input type `OggOpus` if `stream instanceof prism.opus.OggDemuxer` held. This would save from having to specify streamType so explicitly.

Pull request description:
feat: createAudioResource now infers stream type
**Please describe the changes this PR makes and why it should be merged:**

Resolves #83. Simplifies playing streams with specific types, keeping any optimisations in the transcoder pipeline:

```ts
// old
createAudioResource(raw.pipe(new prism.opus.Encoder(options)), { inputType: StreamType.Opus });
createAudioResource(opus.pipe(new prism.opus.Decoder(options)), { inputType: StreamType.Raw });
createAudioResource(ogg.pipe(new prism.opus.OggDemuxer()), { inputType: StreamType.Opus });

// new
createAudioResource(raw.pipe(new prism.opus.Encoder(options)));
createAudioResource(opus.pipe(new prism.opus.Decoder(options)));
createAudioResource(ogg.pipe(new prism.opus.OggDemuxer()));
```

Additionally, if you specify `inlineVolume` as true and pass in a prism-media VolumeTransformer, the pipeline will just re-use the input VolumeTransformer rather than creating a new one to improve performance.


**Status and versioning classification:**

- Code changes have been tested against the Discord API, or there are no code changes
- This PR changes the library's interface (methods or parameters added)


Patch:
diff --git a/README.md b/README.md
index 017a554..a8de8bc 100644
--- a/README.md
+++ b/README.md
@@ -41,4 +41,4 @@ try installing another.
 
 ## Contribution
 
-See [Contributing Guide](https://github.com/discordjs/voice/blob/master/.github/CONTRIBUTING.md).
+See [Contributing Guide](https://github.com/discordjs/voice/blob/main/.github/CONTRIBUTING.md).
diff --git a/src/audio/AudioResource.ts b/src/audio/AudioResource.ts
index ef872f7..58fd773 100644
--- a/src/audio/AudioResource.ts
+++ b/src/audio/AudioResource.ts
@@ -1,7 +1,7 @@
 import { Edge, findPipeline, StreamType, TransformerType } from './TransformerGraph';
 import { pipeline, Readable } from 'stream';
 import { noop } from '../util/util';
-import { VolumeTransformer } from 'prism-media';
+import { VolumeTransformer, opus } from 'prism-media';
 import type { AudioPlayer } from './AudioPlayer';
 
 /**
@@ -94,7 +94,34 @@ export class AudioResource<T = unknown> {
  *
  * @param path - The path to validate constraints on
  */
-const VOLUME_CONSTRAINT = (path: Edge[]) => path.some((edge) => edge.type === TransformerType.InlineVolume);
+export const VOLUME_CONSTRAINT = (path: Edge[]) => path.some((edge) => edge.type === TransformerType.InlineVolume);
+
+export const NO_CONSTRAINT = () => true;
+
+/**
+ * Tries to infer the type of a stream to aid with transcoder pipelining.
+ *
+ * @param stream - The stream to infer the type of
+ */
+export function inferStreamType(
+	stream: Readable,
+): {
+	streamType: StreamType;
+	hasVolume: boolean;
+} {
+	if (stream instanceof opus.Encoder) {
+		return { streamType: StreamType.Opus, hasVolume: false };
+	} else if (stream instanceof opus.Decoder) {
+		return { streamType: StreamType.Raw, hasVolume: false };
+	} else if (stream instanceof VolumeTransformer) {
+		return { streamType: StreamType.Raw, hasVolume: true };
+	} else if (stream instanceof opus.OggDemuxer) {
+		return { streamType: StreamType.Opus, hasVolume: false };
+	} else if (stream instanceof opus.WebmDemuxer) {
+		return { streamType: StreamType.Opus, hasVolume: false };
+	}
+	return { streamType: StreamType.Arbitrary, hasVolume: false };
+}
 
 /**
  * Creates an audio resource that can be played be audio players.
@@ -113,25 +140,30 @@ export function createAudioResource<T>(
 	input: string | Readable,
 	options: CreateAudioResourceOptions<T> = {},
 ): AudioResource<T> {
-	let inputType = options.inputType ?? StreamType.Arbitrary;
+	let inputType = options.inputType;
+	let needsInlineVolume = Boolean(options.inlineVolume);
 
 	// string inputs can only be used with FFmpeg
 	if (typeof input === 'string') {
 		inputType = StreamType.Arbitrary;
+	} else if (typeof inputType === 'undefined') {
+		const analysis = inferStreamType(input);
+		inputType = analysis.streamType;
+		needsInlineVolume = needsInlineVolume && !analysis.hasVolume;
 	}
 
-	const transformerPipeline = findPipeline(inputType, options.inlineVolume ? VOLUME_CONSTRAINT : () => true);
+	const transformerPipeline = findPipeline(inputType, needsInlineVolume ? VOLUME_CONSTRAINT : NO_CONSTRAINT);
 
 	if (transformerPipeline.length === 0) {
 		if (typeof input === 'string') throw new Error(`Invalid pipeline constructed for string resource '${input}'`);
 		// No adjustments required
 		return new AudioResource([], input, options.metadata);
 	}
-	const streams = transformerPipeline.map((pipe) => pipe.transformer(input));
+	const streams = transformerPipeline.map((edge) => edge.transformer(input));
 	if (typeof input !== 'string') streams.unshift(input);
 
 	// the callback is called once the stream ends
-	const playStream = pipeline(streams, noop);
+	const playStream = streams.length > 1 ? pipeline(streams, noop) : streams[0];
 
 	// attempt to find the volume transformer in the pipeline (if one exists)
 	const volume = streams.find((stream) => stream instanceof VolumeTransformer) as VolumeTransformer | undefined;


Test patch:
diff --git a/src/audio/__tests__/AudioResource.test.ts b/src/audio/__tests__/AudioResource.test.ts
new file mode 100644
index 0000000..dd6e672
--- /dev/null
+++ b/src/audio/__tests__/AudioResource.test.ts
@@ -0,0 +1,90 @@
+import { opus, VolumeTransformer } from 'prism-media';
+import { PassThrough } from 'stream';
+import { createAudioResource, NO_CONSTRAINT, VOLUME_CONSTRAINT } from '../AudioResource';
+import { Edge, findPipeline as _findPipeline, StreamType, TransformerType } from '../TransformerGraph';
+
+jest.mock('prism-media');
+jest.mock('../TransformerGraph');
+
+const findPipeline = (_findPipeline as unknown) as jest.MockedFunction<typeof _findPipeline>;
+
+beforeAll(() => {
+	findPipeline.mockImplementation((from: StreamType, constraint: (path: Edge[]) => boolean) => {
+		const base = [
+			{
+				cost: 1,
+				transformer: () => new PassThrough(),
+				type: TransformerType.FFmpegPCM,
+			},
+		];
+		if (constraint === VOLUME_CONSTRAINT) {
+			base.push({
+				cost: 1,
+				transformer: () => new VolumeTransformer({} as any),
+				type: TransformerType.InlineVolume,
+			});
+		}
+		return base as any[];
+	});
+});
+
+beforeEach(() => {
+	findPipeline.mockClear();
+});
+
+describe('createAudioResource', () => {
+	test('Creates a resource from string path', () => {
+		const resource = createAudioResource('mypath.mp3');
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+
+	test('Creates a resource from string path (volume)', () => {
+		const resource = createAudioResource('mypath.mp3', { inlineVolume: true });
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, VOLUME_CONSTRAINT);
+		expect(resource.volume).toBeInstanceOf(VolumeTransformer);
+	});
+
+	test('Only infers type if not explicitly given', () => {
+		const resource = createAudioResource(new opus.Encoder(), { inputType: StreamType.Arbitrary });
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+
+	test('Infers from opus.Encoder', () => {
+		const resource = createAudioResource(new opus.Encoder(), { inlineVolume: true });
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, VOLUME_CONSTRAINT);
+		expect(resource.volume).toBeInstanceOf(VolumeTransformer);
+	});
+
+	test('Infers from opus.OggDemuxer', () => {
+		const resource = createAudioResource(new opus.OggDemuxer());
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+
+	test('Infers from opus.WebmDemuxer', () => {
+		const resource = createAudioResource(new opus.WebmDemuxer());
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Opus, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+
+	test('Infers from opus.Decoder', () => {
+		const resource = createAudioResource(new opus.Decoder());
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+
+	test('Infers from VolumeTransformer', () => {
+		const stream = new VolumeTransformer({} as any);
+		const resource = createAudioResource(stream, { inlineVolume: true });
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Raw, NO_CONSTRAINT);
+		expect(resource.volume).toBe(stream);
+	});
+
+	test('Falls back to Arbitrary for unknown stream type', () => {
+		const resource = createAudioResource(new PassThrough());
+		expect(findPipeline).toHaveBeenCalledWith(StreamType.Arbitrary, NO_CONSTRAINT);
+		expect(resource.volume).toBeUndefined();
+	});
+});