From 12705e347aeffe4aa1b4961f6c5a296182f47889 Mon Sep 17 00:00:00 2001 From: jrmmhm <104490445+jrmmhm@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:46:09 +0200 Subject: [PATCH 1/2] feat(ui): spread same-artist tracks when shuffling A uniform shuffle often plays tracks by the same artist back to back (with 10 tracks by one artist in a 100-track playlist, 63% of all orders do), which listeners do not perceive as random. Album, playlist and context-menu shuffles now use balancedShuffle, which follows Spotify's 2014 approach: each artist's tracks are spread evenly along the queue with a random offset and jitter, albums are spread within an artist, and a final pass removes remaining adjacent repeats. When one artist holds more than half of the queue (rounded up), its tracks are split into runs whose lengths differ by at most one instead. Signed-off-by: jrmmhm <104490445+jrmmhm@users.noreply.github.com> --- ui/src/actions/player.js | 11 +- ui/src/utils/balancedShuffle.js | 173 ++++++++++++++++++++++++++++++++ ui/src/utils/index.js | 1 + 3 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 ui/src/utils/balancedShuffle.js 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/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/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' From a6b481d8c913534f00e266331311168b7b30c28b Mon Sep 17 00:00:00 2001 From: jrmmhm <104490445+jrmmhm@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:46:12 +0200 Subject: [PATCH 2/2] test(ui): cover balanced shuffle and shuffle actions Signed-off-by: jrmmhm <104490445+jrmmhm@users.noreply.github.com> --- ui/src/actions/player.test.js | 94 ++++++++++++ ui/src/utils/balancedShuffle.test.js | 208 +++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 ui/src/actions/player.test.js create mode 100644 ui/src/utils/balancedShuffle.test.js 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.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) + }) +})