From e9b3ba0075857e5e74f37f61f81c03963dcda1b8 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 29 May 2026 22:08:40 +0800 Subject: [PATCH] feat(queue): shuffle-bag random modes so every song plays before repeating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle. 随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add). Closes #70 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio/queue.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++ src/audio/queue.ts | 51 +++++++++++++++----------- 2 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/audio/queue.test.ts b/src/audio/queue.test.ts index e0dd233..be5f3fb 100644 --- a/src/audio/queue.test.ts +++ b/src/audio/queue.test.ts @@ -484,4 +484,84 @@ describe("PlayQueue", () => { expect(promoted?.id).toBe("x"); }); }); + + // Issue #70: 随机循环 (rloop) used true random-with-replacement, so some + // songs repeated often while others were starved. It should behave like a + // shuffle bag (NetEase/QQ style): play every song once per cycle in random + // order, then reshuffle and continue, avoiding an immediate cross-cycle repeat. + describe("random-loop shuffle bag (issue #70)", () => { + it("plays every song exactly once per cycle before repeating", () => { + queue.setMode(PlayMode.RandomLoop); + const N = 12; + for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`)); + queue.play(); + + const cycle1 = [queue.current()!.id]; + for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id); + const cycle2: string[] = []; + for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id); + + // Each cycle is a full permutation of all N songs — zero repeats within + // a cycle, and both cycles cover the same complete set. + expect(new Set(cycle1).size).toBe(N); + expect(new Set(cycle2).size).toBe(N); + expect(new Set(cycle1)).toEqual(new Set(cycle2)); + }); + + it("distributes plays evenly across songs over many cycles (no starvation)", () => { + queue.setMode(PlayMode.RandomLoop); + const N = 6; + const CYCLES = 20; + for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`)); + queue.play(); + + const counts = new Map(); + counts.set(queue.current()!.id, 1); + for (let i = 0; i < CYCLES * N - 1; i++) { + const id = queue.next()!.id; + counts.set(id, (counts.get(id) ?? 0) + 1); + } + + // Shuffle bag => each song plays exactly CYCLES times. True random + // would skew heavily. + for (let i = 0; i < N; i++) { + expect(counts.get(`s${i}`)).toBe(CYCLES); + } + }); + + it("does not replay the same song across a cycle boundary", () => { + queue.setMode(PlayMode.RandomLoop); + const N = 5; + for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`)); + queue.play(); + + // Walk to the last song of cycle 1, then cross into cycle 2. + for (let i = 0; i < N - 1; i++) queue.next(); + const lastOfCycle1 = queue.current()!.id; + const firstOfCycle2 = queue.next()!.id; + expect(firstOfCycle2).not.toBe(lastOfCycle1); + }); + + it("includes a song added mid-cycle within the current cycle", () => { + queue.setMode(PlayMode.RandomLoop); + queue.add(makeSong("A")); + queue.add(makeSong("B")); + queue.play(); // A + queue.next(); // B — both originals now played this cycle + queue.add(makeSong("C")); // added mid-cycle, still unplayed + // C is the only unplayed song, so it must come next (not a reshuffle). + expect(queue.next()?.id).toBe("C"); + }); + + it("keeps looping forever with multiple songs (never returns null)", () => { + queue.setMode(PlayMode.RandomLoop); + queue.add(makeSong("A")); + queue.add(makeSong("B")); + queue.add(makeSong("C")); + queue.play(); + for (let i = 0; i < 30; i++) { + expect(queue.next()).not.toBeNull(); + } + }); + }); }); diff --git a/src/audio/queue.ts b/src/audio/queue.ts index a09beaf..3c47faf 100644 --- a/src/audio/queue.ts +++ b/src/audio/queue.ts @@ -155,33 +155,42 @@ export class PlayQueue { return this.songs[target]; } } - // 前进栈为空,走纯随机逻辑 - if (this.mode === PlayMode.Random) { - const unplayed: number[] = []; - for (let i = 0; i < this.songs.length; i++) { - if (!this.playedIndices.has(i)) unplayed.push(i); - } - if (unplayed.length === 0) return null; - const nextIndex = - unplayed[Math.floor(Math.random() * unplayed.length)]; - this.pushHistory(this.currentIndex); - this.currentIndex = nextIndex; - this.playedIndices.add(nextIndex); - return this.songs[nextIndex]; - } else { + + // Shuffle bag: pick uniformly from the songs not yet played this + // cycle, so every song plays once before any repeats (NetEase/QQ + // style). Songs added mid-cycle aren't in playedIndices, so they're + // naturally eligible within the current cycle. + const unplayed: number[] = []; + for (let i = 0; i < this.songs.length; i++) { + if (!this.playedIndices.has(i)) unplayed.push(i); + } + + if (unplayed.length === 0) { + // Cycle complete. + if (this.mode === PlayMode.Random) return null; // 随机:播完即停 + // 随机循环:reshuffle and keep going forever. if (this.songs.length === 1) { this.pushHistory(this.currentIndex); this.currentIndex = 0; + this.playedIndices = new Set([0]); return this.songs[0]; } - let idx: number; - do { - idx = Math.floor(Math.random() * this.songs.length); - } while (idx === this.currentIndex); - this.pushHistory(this.currentIndex); - this.currentIndex = idx; - return this.songs[idx]; + // Start a fresh cycle: every song is eligible again, but exclude + // the song that just played from THIS pick only, so it doesn't + // repeat back-to-back across the boundary. It stays eligible for + // the rest of the new cycle, so every song still plays exactly once. + this.playedIndices = new Set(); + for (let i = 0; i < this.songs.length; i++) { + if (i !== this.currentIndex) unplayed.push(i); + } } + + const nextIndex = + unplayed[Math.floor(Math.random() * unplayed.length)]; + this.pushHistory(this.currentIndex); + this.currentIndex = nextIndex; + this.playedIndices.add(nextIndex); + return this.songs[nextIndex]; } } }