Repository: discordjs/voice (ts)

Issue:
Stream time information
Add an information in the player about time streamed.
such as `<Player>.streamTime`

Pull request:
feat: stream time statistics
**Please describe the changes this PR makes and why it should be merged:**

Resolves #71

Adds `playbackDuration` to both `AudioPlayer` and `AudioResource`:

- `AudioResource.playbackDuration` is incremented by 20ms every time an Opus packet is read from its underlying Opus stream. This value can be thought of as the progress through an audio track. It starts off at 0.

- `AudioPlayer.state.playbackDuration` is available in the `Playing`, `Paused`, and `AutoPaused` states. It is incremented by 20ms every time an Opus packet (whether from the audio resource, or a filler silence packet) is prepared for dispatch by the subscribers. This value can be thought of as the "real" playback time of the bot, including any transmission gaps. It resets back to 0 when playing a new resource.

You can therefore derive the length of time that filler silence has been played as the difference between these two quantities.

**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/src/audio/AudioPlayer.ts b/src/audio/AudioPlayer.ts
index 4e5ddd2..03837ee 100644
--- a/src/audio/AudioPlayer.ts
+++ b/src/audio/AudioPlayer.ts
@@ -72,12 +72,14 @@ type AudioPlayerState =
 	| {
 			status: AudioPlayerStatus.Playing;
 			missedFrames: number;
+			playbackDuration: number;
 			resource: AudioResource;
 			onStreamError: (error: Error) => void;
 	  }
 	| {
 			status: AudioPlayerStatus.Paused | AudioPlayerStatus.AutoPaused;
 			silencePacketsRemaining: number;
+			playbackDuration: number;
 			resource: AudioResource;
 			onStreamError: (error: Error) => void;
 	  };
@@ -314,6 +316,7 @@ export class AudioPlayer extends EventEmitter {
 			this.state = {
 				status: AudioPlayerStatus.Playing,
 				missedFrames: 0,
+				playbackDuration: 0,
 				resource,
 				onStreamError,
 			};
@@ -323,6 +326,7 @@ export class AudioPlayer extends EventEmitter {
 					this.state = {
 						status: AudioPlayerStatus.Playing,
 						missedFrames: 0,
+						playbackDuration: 0,
 						resource,
 						onStreamError,
 					};
@@ -459,6 +463,7 @@ export class AudioPlayer extends EventEmitter {
 		if (state.status === AudioPlayerStatus.Paused || state.status === AudioPlayerStatus.AutoPaused) {
 			if (state.silencePacketsRemaining > 0) {
 				state.silencePacketsRemaining--;
+				state.playbackDuration += 20;
 				this._preparePacket(SILENCE_FRAME, playable);
 				if (state.silencePacketsRemaining === 0) {
 					this._signalStopSpeaking();
@@ -484,10 +489,11 @@ export class AudioPlayer extends EventEmitter {
 		/* Attempt to read an Opus packet from the resource. If there isn't an available packet,
 			 play a silence packet. If there are 5 consecutive cycles with failed reads, then the
 			 playback will end. */
-		const packet: Buffer | null = state.resource.playStream.read();
+		const packet: Buffer | null = state.resource.read();
 
 		// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 		if (state.status === AudioPlayerStatus.Playing) {
+			state.playbackDuration += 20;
 			if (packet) {
 				this._preparePacket(packet, playable);
 				state.missedFrames = 0;
diff --git a/src/audio/AudioResource.ts b/src/audio/AudioResource.ts
index 763877b..ef872f7 100644
--- a/src/audio/AudioResource.ts
+++ b/src/audio/AudioResource.ts
@@ -59,12 +59,34 @@ export class AudioResource<T = unknown> {
 	 */
 	public audioPlayer?: AudioPlayer;
 
+	/**
+	 * The playback duration of this audio resource, given in milliseconds.
+	 */
+	public playbackDuration = 0;
+
 	public constructor(pipeline: Edge[], playStream: Readable, metadata?: T, volume?: VolumeTransformer) {
 		this.pipeline = pipeline;
 		this.playStream = playStream;
 		this.metadata = metadata;
 		this.volume = volume;
 	}
+
+	/**
+	 * Attempts to read an Opus packet from the audio resource. If a packet is available, the playbackDuration
+	 * is incremented.
+	 * @internal
+	 * @remarks
+	 * It is advisable to check that the playStream is readable before calling this method. While no runtime
+	 * errors will be thrown, you should check that the resource is still available before attempting to
+	 * read from it.
+	 */
+	public read(): Buffer | null {
+		const packet: Buffer | null = this.playStream.read();
+		if (packet) {
+			this.playbackDuration += 20;
+		}
+		return packet;
+	}
 }
 
 /**


Test patch:
diff --git a/src/audio/__tests__/AudioPlayer.test.ts b/src/audio/__tests__/AudioPlayer.test.ts
index de50416..2c89453 100644
--- a/src/audio/__tests__/AudioPlayer.test.ts
+++ b/src/audio/__tests__/AudioPlayer.test.ts
@@ -201,6 +201,10 @@ describe('State transitions', () => {
 			player['_stepPrepare']();
 			expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(i);
 			expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(buffer);
+			expect(player.state.status).toBe(AudioPlayerStatus.Playing);
+			if (player.state.status === AudioPlayerStatus.Playing) {
+				expect(player.state.playbackDuration).toStrictEqual(i * 20);
+			}
 		}
 
 		// Expect silence to be played
@@ -249,6 +253,7 @@ describe('State transitions', () => {
 		for (let i = 1; i <= 5; i++) {
 			expect(player.state.status).toBe(AudioPlayerStatus.Playing);
 			if (player.state.status !== AudioPlayerStatus.Playing) throw new Error('Error');
+			expect(player.state.playbackDuration).toStrictEqual((i - 1) * 20);
 			expect(player.state.missedFrames).toBe(i - 1);
 			player['_stepDispatch']();
 			expect(connection.dispatchAudio).toHaveBeenCalledTimes(i);
@@ -257,7 +262,6 @@ describe('State transitions', () => {
 			expect(prepareAudioPacket.mock.calls[i - 1][0]).toEqual(silence().next().value);
 		}
 
-		expect(player.state.status).toBe(AudioPlayerStatus.Idle);
 		expect(player.state.status).toBe(AudioPlayerStatus.Idle);
 		expect(connection.setSpeaking).toBeCalledTimes(1);
 		expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);