diff --git a/src/audio/player.ts b/src/audio/player.ts
index 48e7b42..e7a714a 100644
--- a/src/audio/player.ts
+++ b/src/audio/player.ts
@@ -17,13 +17,14 @@ export class AudioPlayer extends EventEmitter {
private ffmpeg: ChildProcess | null = null;
private encoder: Encoder;
private state: PlayerState = "idle";
- private volume = 75; // 0-100
+ private volume = 75;
private pcmBuffer: Buffer = Buffer.alloc(0);
private logger: Logger;
private frameLoopRunning = false;
private nextFrameTime = 0;
private currentUrl = "";
- private seekOffset = 0; // seconds offset from seek
+ private seekOffset = 0;
+ private framesPlayed = 0; // ground truth: number of 20ms frames sent
constructor(logger: Logger) {
super();
@@ -35,6 +36,7 @@ export class AudioPlayer extends EventEmitter {
this.stop();
this.currentUrl = url;
this.seekOffset = seekSeconds;
+ this.framesPlayed = 0;
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
@@ -57,19 +59,14 @@ export class AudioPlayer extends EventEmitter {
this.ffmpeg = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
- this.ffmpeg.stderr!.on("data", () => {
- // Suppress FFmpeg stderr output
- });
+ this.ffmpeg.stderr!.on("data", () => {});
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
});
- this.ffmpeg.on("close", (code) => {
- this.logger.debug({ code }, "FFmpeg process closed");
- if (this.state === "playing" || this.state === "paused") {
- // Let the frame loop drain remaining frames, then it will emit trackEnd
- }
+ this.ffmpeg.on("close", () => {
+ // Let frame loop drain remaining buffer
});
this.ffmpeg.on("error", (err) => {
@@ -81,10 +78,6 @@ export class AudioPlayer extends EventEmitter {
this.startFrameLoop();
}
- /**
- * High-precision frame sending loop using drift-correcting setTimeout.
- * Much more accurate than setInterval which drifts ~4-16ms on Windows.
- */
private startFrameLoop(): void {
if (this.frameLoopRunning) return;
this.frameLoopRunning = true;
@@ -105,11 +98,9 @@ export class AudioPlayer extends EventEmitter {
if (this.state === "playing") {
this.sendNextFrame();
} else if (this.state === "paused") {
- // Keep the loop alive but adjust timing to avoid drift accumulation
this.nextFrameTime = performance.now();
}
- // Check if we should stop (FFmpeg done + buffer empty)
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
@@ -129,10 +120,10 @@ export class AudioPlayer extends EventEmitter {
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
- // Apply volume by scaling PCM samples directly
const adjusted = this.applyVolume(pcmFrame);
const opusFrame = this.encoder.encode(adjusted);
this.emit("frame", opusFrame);
+ this.framesPlayed++;
}
private applyVolume(pcm: Buffer): Buffer {
@@ -142,7 +133,6 @@ export class AudioPlayer extends EventEmitter {
for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i);
sample = Math.round(sample * factor);
- // Clamp to 16-bit range
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
@@ -150,6 +140,21 @@ export class AudioPlayer extends EventEmitter {
return out;
}
+ /** Actual elapsed time in seconds (ground truth from frame count) */
+ getElapsed(): number {
+ return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
+ }
+
+ seek(seconds: number): void {
+ if (!this.currentUrl) return;
+ this.logger.info({ seek: seconds }, "Seeking");
+ this.play(this.currentUrl, seconds);
+ }
+
+ getSeekOffset(): number {
+ return this.seekOffset;
+ }
+
pause(): void {
if (this.state === "playing") {
this.state = "paused";
@@ -160,23 +165,11 @@ export class AudioPlayer extends EventEmitter {
resume(): void {
if (this.state === "paused") {
this.state = "playing";
- // Reset timing to avoid burst of frames after unpause
this.nextFrameTime = performance.now();
this.logger.debug("Playback resumed");
}
}
- /** Seek to a position in seconds. Restarts FFmpeg with -ss offset. */
- seek(seconds: number): void {
- if (!this.currentUrl) return;
- this.logger.info({ seek: seconds }, "Seeking");
- this.play(this.currentUrl, seconds);
- }
-
- getSeekOffset(): number {
- return this.seekOffset;
- }
-
stop(): void {
this.frameLoopRunning = false;
if (this.ffmpeg) {
@@ -187,6 +180,7 @@ export class AudioPlayer extends EventEmitter {
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
+ this.framesPlayed = 0;
}
setVolume(vol: number): void {
diff --git a/src/bot/instance.ts b/src/bot/instance.ts
index 95f69bd..5529d30 100644
--- a/src/bot/instance.ts
+++ b/src/bot/instance.ts
@@ -37,8 +37,7 @@ export interface BotStatus {
queueSize: number;
volume: number;
playMode: PlayMode;
- seekOffset: number; // seconds offset from last seek/play start
- playStartTime: number; // server timestamp when playback started
+ elapsed: number; // ground truth elapsed seconds from frame count
}
export class BotInstance extends EventEmitter {
@@ -507,8 +506,7 @@ export class BotInstance extends EventEmitter {
queueSize: this.queue.size(),
volume: this.player.getVolume(),
playMode: this.queue.getMode(),
- seekOffset: this.player.getSeekOffset(),
- playStartTime: Date.now(),
+ elapsed: this.player.getElapsed(),
};
}
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index 2c6731c..9a7868d 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -104,6 +104,12 @@ export function createPlayerRouter(
}
});
+ // Get current elapsed time (ground truth from server)
+ router.get("/:botId/elapsed", (req, res) => {
+ const bot = (req as any).bot;
+ res.json({ elapsed: bot.getPlayer().getElapsed() });
+ });
+
// Seek to position
router.post("/:botId/seek", async (req, res) => {
try {
@@ -153,6 +159,7 @@ export function createPlayerRouter(
});
// Play a playlist by ID — stores metadata only, resolves URL for first song
+ // Respects current play mode (random = pick random first song)
router.post("/:botId/play-playlist", async (req, res) => {
try {
const bot = (req as any).bot;
@@ -163,21 +170,31 @@ export function createPlayerRouter(
return;
}
+ // Stop current playback
+ bot.getPlayer().stop();
+
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) {
res.json({ message: "Playlist is empty" });
return;
}
- // Add all songs as metadata only (no URL fetching!)
const queue = bot.getQueueManager();
queue.clear();
for (const song of songs) {
queue.add({ ...song, platform: provider.platform });
}
- // Only resolve URL for the first song
- const first = queue.play();
+ // Use queue.play() for sequential, or pick random index for random modes
+ const mode = queue.getMode();
+ let first;
+ if (mode === "random" || mode === "rloop") {
+ const idx = Math.floor(Math.random() * queue.size());
+ first = queue.playAt(idx);
+ } else {
+ first = queue.play();
+ }
+
if (first) {
await bot.resolveAndPlay(first);
}
diff --git a/web/src/App.vue b/web/src/App.vue
index 63572b2..16f8d86 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -9,7 +9,7 @@
diff --git a/web/src/components/Player.vue b/web/src/components/Player.vue
index ee2dc3d..8471689 100644
--- a/web/src/components/Player.vue
+++ b/web/src/components/Player.vue
@@ -74,7 +74,6 @@