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 @@