diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js index 9056abeb6..316830a98 100644 --- a/ui/src/actions/player.js +++ b/ui/src/actions/player.js @@ -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 diff --git a/ui/src/actions/player.test.js b/ui/src/actions/player.test.js new file mode 100644 index 000000000..ba685ca2c --- /dev/null +++ b/ui/src/actions/player.test.js @@ -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']) + }) +}) diff --git a/ui/src/utils/balancedShuffle.js b/ui/src/utils/balancedShuffle.js new file mode 100644 index 000000000..2d88ceed3 --- /dev/null +++ b/ui/src/utils/balancedShuffle.js @@ -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 +} diff --git a/ui/src/utils/balancedShuffle.test.js b/ui/src/utils/balancedShuffle.test.js new file mode 100644 index 000000000..26977cba2 --- /dev/null +++ b/ui/src/utils/balancedShuffle.test.js @@ -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) + }) +}) diff --git a/ui/src/utils/index.js b/ui/src/utils/index.js index 779b6f886..e7b1ca747 100644 --- a/ui/src/utils/index.js +++ b/ui/src/utils/index.js @@ -1,3 +1,4 @@ +export * from './balancedShuffle' export * from './formatters' export * from './intersperse' export * from './notifications'