mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge a6b481d8c9 into 52135913d4
This commit is contained in:
commit
e0c4aecafe
5 changed files with 482 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
94
ui/src/actions/player.test.js
Normal file
94
ui/src/actions/player.test.js
Normal 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'])
|
||||
})
|
||||
})
|
||||
173
ui/src/utils/balancedShuffle.js
Normal file
173
ui/src/utils/balancedShuffle.js
Normal 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
|
||||
}
|
||||
208
ui/src/utils/balancedShuffle.test.js
Normal file
208
ui/src/utils/balancedShuffle.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './balancedShuffle'
|
||||
export * from './formatters'
|
||||
export * from './intersperse'
|
||||
export * from './notifications'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue