This commit is contained in:
Jerome Mohm 2026-10-06 20:08:14 +01:00 • committed by GitHub
commit e0c4aecafe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 482 additions and 5 deletions

View file

@ -1,3 +1,5 @@
import { balancedShuffle } from '../utils'
export const PLAYER_ADD_TRACKS = 'PLAYER_ADD_TRACKS'
export const PLAYER_PLAY_NEXT = 'PLAYER_PLAY_NEXT'
export const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK'
@ -46,11 +48,10 @@ export const playNext = (data, ids) => {
}
export const shuffle = (data) => {
const ids = Object.keys(data)
for (let i = ids.length - 1; i > 0; i--) {
let j = Math.floor(Math.random() * (i + 1))
;[ids[i], ids[j]] = [ids[j], ids[i]]
}
const ids = balancedShuffle(Object.keys(data), {
artistKey: (id) => data[id].artistId || data[id].artist,
albumKey: (id) => data[id].albumId || data[id].album,
})
const shuffled = {}
// The "_" is to force the object key to be a string, so it keeps the order when adding to object
// or else the keys will always be in the same (numerically) order

View file

@ -0,0 +1,94 @@
import { PLAYER_PLAY_TRACKS, shuffle, shuffleTracks } from './player'
const songs = (spec) =>
Object.fromEntries(
spec.flatMap(([artistId, count]) =>
[...Array(count).keys()].map((i) => [
`${artistId}${i}`,
{ id: `${artistId}${i}`, artistId, albumId: `${artistId}-album` },
]),
),
)
describe('shuffle', () => {
it('keeps every song and prefixes the keys to preserve the order', () => {
const data = songs([
['a', 5],
['b', 5],
])
const shuffled = shuffle(data)
expect(Object.keys(shuffled).sort()).toEqual(
Object.keys(data)
.map((id) => `_${id}`)
.sort(),
)
Object.entries(shuffled).forEach(([key, song]) => {
expect(song).toBe(data[key.substring(1)])
})
})
it('does not play the same artist twice in a row when avoidable', () => {
const data = songs([
['a', 10],
['b', 10],
['c', 10],
])
for (let n = 0; n < 50; n++) {
const artists = Object.values(shuffle(data)).map((s) => s.artistId)
artists.slice(1).forEach((artist, i) => {
expect(artist).not.toBe(artists[i])
})
}
})
it('spreads the albums of an artist', () => {
const data = Object.fromEntries(
[...Array(10).keys()].map((i) => [
`s${i}`,
{ id: `s${i}`, artistId: 'a', albumId: `album-${i % 2}` },
]),
)
let albumRepeats = 0
for (let n = 0; n < 50; n++) {
const albums = Object.values(shuffle(data)).map((s) => s.albumId)
albumRepeats += albums.slice(1).filter((a, i) => a === albums[i]).length
}
// a uniform shuffle averages 9 * 4/9 = 4 adjacent tracks from one album
expect(albumRepeats / 50).toBeLessThan(1)
})
it('falls back to the artist name when there is no artistId', () => {
const data = {
1: { id: '1', artist: 'x' },
2: { id: '2', artist: 'x' },
3: { id: '3', artist: 'y' },
}
for (let n = 0; n < 20; n++) {
const artists = Object.values(shuffle(data)).map((s) => s.artist)
expect(artists).toEqual(['x', 'y', 'x'])
}
})
})
describe('shuffleTracks', () => {
it('plays all non-missing songs starting with the first shuffled one', () => {
const data = {
...songs([
['a', 3],
['b', 3],
]),
gone: { id: 'gone', artistId: 'a', missing: true },
}
const action = shuffleTracks(data)
expect(action.type).toBe(PLAYER_PLAY_TRACKS)
expect(Object.keys(action.data)).toHaveLength(6)
expect(action.data).not.toHaveProperty('_gone')
expect(action.id).toBe(Object.keys(action.data)[0])
})
it('only shuffles the selected ids', () => {
const data = songs([['a', 4]])
const action = shuffleTracks(data, ['a0', 'a2'])
expect(Object.keys(action.data).sort()).toEqual(['_a0', '_a2'])
})
})

View file

@ -0,0 +1,173 @@
/* balancedShuffle: Return a shuffled copy of the input array in which items
* sharing an artist are spread across the whole list instead of clustering.
*
* A uniform shuffle often places tracks by the same artist next to each other
* (with 10 tracks by one artist in a 100-track playlist, 63% of all orders do),
* which listeners do not perceive as random. This follows the approach Spotify
* described in "How to shuffle songs?" (2014): every artist's tracks are
* stretched evenly along the list with a random offset and a small random
* jitter, and the list is ordered by those positions. Tracks of one artist are
* spread across their albums the same way. Positions wrap around, so an artist
* with many tracks is as likely to open the list as any other track. A final
* pass removes the adjacent repeats the spreading leaves behind. When one
* artist has more tracks than all others together (plus one), repeats are
* unavoidable; the artist's tracks are then split into runs of equal length.
*
* Options:
* - artistKey(item) / albumKey(item): grouping keys. Items without a key are
* treated as their own group.
* - random: a () => [0, 1) generator, Math.random by default.
*/
export const balancedShuffle = (items, options = {}) => {
const {
artistKey = () => undefined,
albumKey = () => undefined,
random = Math.random,
} = options
const entries = items.map((item) => ({
item,
artist: keyOrUnique(artistKey(item)),
album: keyOrUnique(albumKey(item)),
}))
return balance(entries, random).map((e) => e.item)
}
const keyOrUnique = (key) =>
key === undefined || key === null || key === '' ? Symbol() : key
const fisherYates = (list, random) => {
const shuffled = [...list]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
const groupBy = (entries, field) => {
const groups = new Map()
entries.forEach((e) => {
if (!groups.has(e[field])) {
groups.set(e[field], [])
}
groups.get(e[field]).push(e)
})
return groups
}
const dominantArtist = (entries) => {
const groups = groupBy(entries, 'artist')
let dominant = { artist: undefined, count: 0 }
groups.forEach((group, artist) => {
if (group.length > dominant.count) {
dominant = { artist, count: group.length }
}
})
return dominant
}
// Adjacent repeats can be avoided only if no artist holds more than half
// of the items (rounded up).
const isAvoidable = (entries) => {
const { count } = dominantArtist(entries)
return count <= entries.length - count + 1
}
// Stretch every group evenly along a circle of length 1: the k-th of n items
// lands at k/n plus a random offset per group plus up to 10% of the spacing
// as jitter. orderGroup decides which item of a group takes which slot.
const spread = (entries, field, random, orderGroup) => {
const positioned = []
groupBy(entries, field).forEach((group) => {
const ordered = orderGroup(group)
const spacing = 1 / ordered.length
const offset = random()
ordered.forEach((entry, k) => {
const jitter = (random() * 2 - 1) * 0.1 * spacing
const position = (((k * spacing + offset + jitter) % 1) + 1) % 1
positioned.push({ entry, position, tiebreak: random() })
})
})
positioned.sort((a, b) => a.position - b.position || a.tiebreak - b.tiebreak)
return positioned.map((p) => p.entry)
}
// Remove adjacent repeats of the same artist. A repeat at index i is fixed by
// pulling the next item of another artist forward; when none is left, the
// item moves to a random earlier gap between two other artists.
const repairAdjacent = (entries, random) => {
const list = [...entries]
let i = 1
while (i < list.length) {
if (list[i].artist !== list[i - 1].artist) {
i++
continue
}
const previous = list[i - 1].artist
let j = i + 1
while (j < list.length && list[j].artist === previous) {
j++
}
if (j < list.length) {
list.splice(i, 0, ...list.splice(j, 1))
i++
continue
}
const [entry] = list.splice(i, 1)
const gaps = list[0].artist !== entry.artist ? [0] : []
for (let p = 1; p < i; p++) {
if (
list[p - 1].artist !== entry.artist &&
list[p].artist !== entry.artist
) {
gaps.push(p)
}
}
if (gaps.length === 0) {
list.splice(i, 0, entry)
i++
continue
}
list.splice(gaps[Math.floor(random() * gaps.length)], 0, entry)
}
return list
}
const balance = (entries, random) => {
if (entries.length < 2) {
return [...entries]
}
const byAlbum = (group) =>
spread(group, 'album', random, (album) => fisherYates(album, random))
if (isAvoidable(entries)) {
return repairAdjacent(spread(entries, 'artist', random, byAlbum), random)
}
// Too many items by one artist: place the others first, then fill the
// gaps around them with runs of the dominant artist of (almost) equal size.
const { artist } = dominantArtist(entries)
const others = balance(
entries.filter((e) => e.artist !== artist),
random,
)
const dominant = byAlbum(entries.filter((e) => e.artist === artist))
const slots = others.length + 1
const baseRun = Math.floor(dominant.length / slots)
const longerRuns = new Set(
fisherYates([...Array(slots).keys()], random).slice(
0,
dominant.length % slots,
),
)
const result = []
let next = 0
for (let slot = 0; slot < slots; slot++) {
const run = baseRun + (longerRuns.has(slot) ? 1 : 0)
result.push(...dominant.slice(next, next + run))
next += run
if (slot < others.length) {
result.push(others[slot])
}
}
return result
}

View file

@ -0,0 +1,208 @@
import { balancedShuffle } from './balancedShuffle'
// Deterministic generator (mulberry32) so every assertion is reproducible
const seeded = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
// spec: [[artist, albums, tracksPerAlbum], ...]
const library = (spec) =>
spec.flatMap(([artist, albums, tracks]) =>
[...Array(albums * tracks).keys()].map((i) => ({
id: `${artist}-${i}`,
artist,
album: `${artist}-album-${i % albums}`,
})),
)
const singles = (count) =>
library([...Array(count).keys()].map((i) => [`single${i}`, 1, 1]))
const options = (random) => ({
artistKey: (t) => t.artist,
albumKey: (t) => t.album,
random,
})
const adjacentRepeats = (list, key = (t) => t.artist) =>
list.slice(1).filter((t, i) => key(t) === key(list[i])).length
const longestRun = (list) => {
let longest = list.length ? 1 : 0
let run = 1
for (let i = 1; i < list.length; i++) {
run = list[i].artist === list[i - 1].artist ? run + 1 : 1
longest = Math.max(longest, run)
}
return longest
}
const runs = (tracks, times, seed = 1) => {
const random = seeded(seed)
return [...Array(times)].map(() => balancedShuffle(tracks, options(random)))
}
const sortedIds = (list) => list.map((t) => t.id).sort()
describe('balancedShuffle', () => {
it.each([0, 1, 2, 1000])('returns a permutation of %i items', (count) => {
const tracks = [...Array(count).keys()].map((i) => ({
id: `t${i}`,
artist: ['a', 'b', 'c', 'c'][i % 4],
album: `album-${i % 3}`,
}))
const result = balancedShuffle(tracks, options(seeded(7)))
expect(result).toHaveLength(tracks.length)
expect(sortedIds(result)).toEqual(sortedIds(tracks))
})
it('does not modify the input array', () => {
const tracks = library([
['a', 1, 5],
['b', 1, 5],
])
const copy = [...tracks]
balancedShuffle(tracks, options(seeded(1)))
expect(tracks).toEqual(copy)
})
it('is deterministic for the same random source', () => {
const tracks = library([
['a', 2, 5],
['b', 1, 7],
]).concat(singles(20))
expect(balancedShuffle(tracks, options(seeded(42)))).toEqual(
balancedShuffle(tracks, options(seeded(42))),
)
})
it('works with Math.random and without keys', () => {
const tracks = library([['a', 1, 10]])
expect(sortedIds(balancedShuffle(tracks))).toEqual(sortedIds(tracks))
})
it.each([
[
'10 artists with 10 tracks each',
library([...Array(10).keys()].map((i) => [`a${i}`, 2, 5])),
],
[
'one artist with 10 of 100 tracks',
library([['a', 2, 5]]).concat(singles(90)),
],
[
'artists with 50, 30 and 20 tracks',
library([
['a', 5, 10],
['b', 3, 10],
['c', 2, 10],
]),
],
[
'one artist with 40 of 100 tracks',
library([['a', 4, 10]]).concat(singles(60)),
],
[
'one artist with exactly half of the tracks',
library([['a', 5, 10]]).concat(singles(50)),
],
])('never plays the same artist twice in a row: %s', (_, tracks) => {
runs(tracks, 200).forEach((result) => {
expect(adjacentRepeats(result)).toBe(0)
})
})
it.each([
['60 of 100', 60, 40, 19, 2],
['80 of 100', 80, 20, 59, 4],
])(
'splits unavoidable repeats into even runs: one artist with %s',
(_, dominant, others, minimalRepeats, maxRun) => {
const tracks = library([['a', 4, dominant / 4]]).concat(singles(others))
runs(tracks, 200).forEach((result) => {
expect(adjacentRepeats(result)).toBe(minimalRepeats)
expect(longestRun(result)).toBe(maxRun)
})
},
)
it('spreads the albums of a single artist', () => {
const tracks = library([['a', 3, 10]])
const albumRepeats = runs(tracks, 200).map((result) =>
adjacentRepeats(result, (t) => t.album),
)
const mean = albumRepeats.reduce((a, b) => a + b, 0) / albumRepeats.length
// a uniform shuffle averages 29 * 9/29 = 9 adjacent tracks from one album
expect(mean).toBeLessThan(1)
})
it.each([
['empty', ''],
['missing', undefined],
['null', null],
])('treats tracks with %s artist as distinct artists', (_, artist) => {
const tracks = [...Array(10).keys()]
.map((i) => ({ id: `t${i}`, artist }))
.concat(library([['b', 1, 5]]))
const results = runs(tracks, 200)
results.forEach((result) => {
expect(sortedIds(result)).toEqual(sortedIds(tracks))
})
// as one artist they would hold 10 of 15 tracks and always open the list
expect(results.some((result) => result[0].artist === 'b')).toBe(true)
})
it('spreads the tracks of an artist evenly across the list', () => {
const tracks = library([['a', 1, 10]]).concat(singles(90))
const smallestGaps = runs(tracks, 200).map((result) => {
const positions = result.flatMap((t, i) => (t.artist === 'a' ? [i] : []))
return Math.min(...positions.slice(1).map((p, i) => p - positions[i]))
})
const mean = smallestGaps.reduce((a, b) => a + b, 0) / smallestGaps.length
// a uniform order with its neighbours separated averages about 2
expect(mean).toBeGreaterThan(4)
})
it('gives every track an even chance of every position', () => {
const tracks = library([['a', 2, 5]]).concat(singles(90))
const watched = [tracks[0], tracks[50]]
const deciles = watched.map(() => Array(10).fill(0))
const results = runs(tracks, 3000, 11)
results.forEach((result) => {
watched.forEach((track, w) => {
deciles[w][Math.floor(result.indexOf(track) / 10)]++
})
})
deciles.flat().forEach((count) => {
expect(count / results.length).toBeGreaterThan(0.07)
expect(count / results.length).toBeLessThan(0.13)
})
})
it('lets an artist with many tracks open the list as often as by chance', () => {
const tracks = library([['a', 2, 5]]).concat(singles(90))
const results = runs(tracks, 3000, 5)
const opened = results.filter((r) => r[0].artist === 'a').length
expect(opened / results.length).toBeGreaterThan(0.07)
expect(opened / results.length).toBeLessThan(0.13)
})
it.each([
['one artist', library([['a', 200, 100]])],
['one artist with 95%', library([['a', 190, 100]]).concat(singles(1000))],
['one artist with half', library([['a', 100, 100]]).concat(singles(10000))],
[
'100 artists',
library([...Array(100).keys()].map((i) => [`a${i}`, 5, 20])),
],
])('shuffles 10,000+ tracks quickly: %s', (_, tracks) => {
const start = performance.now()
const result = balancedShuffle(tracks, options(seeded(1)))
// generous for slow CI runners; a quadratic repair pass takes much longer
expect(performance.now() - start).toBeLessThan(5000)
expect(result).toHaveLength(tracks.length)
})
})

View file

@ -1,3 +1,4 @@
export * from './balancedShuffle'
export * from './formatters'
export * from './intersperse'
export * from './notifications'