Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4555,6 +4555,17 @@ components:
type: string
seedArtist:
type: string
reason:
type: string
description: |
Why the DJ chose it, in a few words: "Because Sam's Radiohead →
Portishead (similar 0.82)", "A top song by Radiohead, an artist
the room loves", "Bridging Sam's Radiohead and Jo's Massive
Attack". Absent for a song a service recommended, or one picked
at random; say "Like {seedTitle}" then.
source:
type: string
description: The music knowledge the reason came from, for a credit, e.g. "Last.fm, Deezer".

Palette:
type: object
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0012-smart-dj.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ If the graph has no sources, knows nothing near the room's taste, or none of its
- The similar-artists dataset name is ListenBrainz's own and may change. If it does, ListenBrainz stops contributing similar artists until the constant is updated, and the other sources carry on.
- An artist's name can belong to more than one artist. Deezer takes the one with the most fans, and MusicBrainz the best search result.
- The first fill after a cold start knows only the artists it fetched in time. The warmer catches up from the songs being queued.
- `AutopilotInfo.reason` is stored with each song; it isn't in the API until MAD-760.
- `AutopilotInfo.reason` is stored with each song. The API carries a short summary of it and its sources (`AutopilotPick.reason` and `source`, MAD-760), shown on now playing, up next and the big screen. The summary names members as they were called when the song was picked.
- A room's long-term taste is the room's: it's deleted with the room. It names members by ID, and keeps an artist only while its likes or skips are above 0.005, about 15 nights of silence for one play.
- Suggestions (ADR 0010) come from the DJ too (MAD-759): the asker's taste for "your vibe", the room's for "group vibe", found only on the asker's links. Without a graph they fall back on the services' recommendations, turning artists away by the DJ's rule (`suggest.TurnedAway` reads `dj.Profile.Avoid`), so a skip fades there too (MAD-762).
- Tempo is often unknown: Deezer has a BPM for some songs and 0 for many, and few libraries tag it. Set flow treats 0 as unknown, so a room with little known about its songs plays as it did before; it flows better as the cache warms.
Expand Down
4 changes: 3 additions & 1 deletion server/internal/autopilot/autopilot.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,11 @@ func (f *fill) fromGraph(ctx context.Context, users []string) []pick {
if len(users) > 0 {
fallback = users[0]
}
name := f.s.names(ctx)
var out []pick
for _, p := range picks {
info := queue.AutopilotInfo{Reason: &queue.AutopilotReason{
summary, source := explain(p, name)
info := queue.AutopilotInfo{Summary: summary, Source: source, Reason: &queue.AutopilotReason{
Kind: p.Why.Kind, Via: p.Why.Via, Similarity: p.Why.Similarity, Popularity: p.Why.Popularity,
Novelty: p.Why.Novelty, Prior: p.Why.Prior, Score: p.Why.Score, DeepCut: p.Why.DeepCut, Sources: p.Why.Sources,
}}
Expand Down
5 changes: 5 additions & 0 deletions server/internal/autopilot/dj_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package autopilot_test
import (
"context"
"fmt"
"strings"
"testing"

"github.com/madeofpendletonwool/syncphony/server/internal/musicgraph"
Expand Down Expand Up @@ -78,6 +79,10 @@ func TestDJPicksFromTheGraph(t *testing.T) {
if r == nil || r.Kind != "similar-artist" || r.Via != "The Test Patterns" || r.Popularity != 1 || r.Score <= 0 {
t.Fatalf("reason = %+v", r)
}
// It's explained in words (MAD-760).
if !strings.HasPrefix(info.Summary, "Because alice's The Test Patterns → Null Island (similar") || info.Source != "test" {
t.Errorf("summary = %q, source %q", info.Summary, info.Source)
}
// It planned what might follow, and the room's energy curve is on.
if r.Flow == nil || len(r.Flow.Ahead) == 0 || r.Flow.Target == nil {
t.Errorf("flow = %+v: want a plan ahead, and the curve's target", r.Flow)
Expand Down
100 changes: 100 additions & 0 deletions server/internal/autopilot/explain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: AGPL-3.0-only

package autopilot

import (
"context"
"fmt"
"strings"

"github.com/madeofpendletonwool/syncphony/server/internal/dj"
)

// Explaining the DJ's picks (MAD-760): people forgive picks they
// understand. Each pick gets a short reason, in words the room can read on
// now playing, up next and the big screen, and the knowledge sources it
// came from.

// sourceNames are how music knowledge sources are shown.
var sourceNames = map[string]string{
"lastfm": "Last.fm",
"listenbrainz": "ListenBrainz",
"deezer": "Deezer",
"musicbrainz": "MusicBrainz",
}

// explain returns why the DJ chose p, as a short reason, and where the
// knowledge came from ("" if it didn't say). name returns a member's
// display name.
func explain(p dj.Pick, name func(userID string) string) (reason, source string) {
w := p.Why
artist := ""
if len(p.Track.Artists) > 0 {
artist = p.Track.Artists[0].Name
}
// whose is a member's artist: "Sam's Radiohead", or the room's.
whose := func(userID, a string) string {
if n := name(userID); n != "" {
return n + "'s " + a
}
return "the room's " + a
}
// The member's song it leads from, if any, names the artist it's
// like: the via artist, or for a member's turn, theirs.
seedUser, via := "", w.Via
if p.Seed.ID != "" {
seedUser = p.Seed.AddedBy
if t := dj.TrackOf(p.Seed); len(t.Artists) > 0 {
via = t.Artists[0].Name
}
}
switch {
case w.Bridge != nil:
b := w.Bridge
reason = fmt.Sprintf("Bridging %s and %s", whose(b.Users[0], b.Artists[0]), whose(b.Users[1], b.Artists[1]))
case w.Kind == dj.KindThrowback:
reason = fmt.Sprintf("A throwback to %s, a favorite from past nights", w.Via)
case w.Kind == dj.KindArtist && w.DeepCut:
reason = fmt.Sprintf("A deep cut by %s, an artist the room loves", artist)
case w.Kind == dj.KindArtist:
reason = fmt.Sprintf("A top song by %s, an artist the room loves", artist)
case w.Kind == dj.KindSimilarSong && p.Seed.ID != "":
reason = fmt.Sprintf("Like %s", whose(seedUser, dj.TrackOf(p.Seed).Title))
case w.Kind == dj.KindSimilarSong:
reason = fmt.Sprintf("Like a song by %s", w.Via)
case w.Kind == dj.KindTwoSteps:
reason = fmt.Sprintf("Further out from %s → %s", whose(seedUser, via), artist)
default:
reason = fmt.Sprintf("Because %s → %s (similar %.2f)", whose(seedUser, via), artist, w.Similarity)
}
var names []string
for _, s := range w.Sources {
if n, ok := sourceNames[s]; ok {
names = append(names, n)
} else {
names = append(names, s)
}
}
return reason, strings.Join(names, ", ")
}

// names returns a member's display name, looked up once, or "" if they're
// gone.
func (s *Service) names(ctx context.Context) func(userID string) string {
seen := map[string]string{}
return func(id string) string {
if id == "" {
return ""
}
if n, ok := seen[id]; ok {
return n
}
u, err := s.db.GetUser(ctx, id)
if err == nil {
seen[id] = u.DisplayName
} else {
seen[id] = ""
}
return seen[id]
}
}
75 changes: 75 additions & 0 deletions server/internal/autopilot/explain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-only

package autopilot

import (
"encoding/json"
"testing"

"github.com/madeofpendletonwool/syncphony/server/internal/dj"
"github.com/madeofpendletonwool/syncphony/server/internal/provider"
"github.com/madeofpendletonwool/syncphony/server/internal/store"
)

func TestExplain(t *testing.T) {
names := map[string]string{"sam": "Sam", "jo": "Jo"}
name := func(id string) string { return names[id] }
song := func(artist, title string) provider.Track {
return provider.Track{Title: title, Artists: []provider.ArtistCredit{{Name: artist}}}
}
seed := func(user, artist, title string) store.QueueItem {
meta, _ := json.Marshal(song(artist, title))
return store.QueueItem{ID: "i1", AddedBy: user, Metadata: string(meta)}
}
cases := []struct {
name string
pick dj.Pick
want, source string
}{
{
"similar artist",
dj.Pick{Track: song("Portishead", "Roads"), Seed: seed("sam", "Radiohead", "Creep"), Why: dj.Why{
Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.8234, Sources: []string{"lastfm", "deezer"},
}},
"Because Sam's Radiohead → Portishead (similar 0.82)", "Last.fm, Deezer",
},
{
"autopilot's own taste",
dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Via: "Radiohead", Similarity: 0.5}},
"Because the room's Radiohead → Portishead (similar 0.50)", "",
},
{
"top song",
dj.Pick{Track: song("Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindArtist, Via: "Radiohead"}},
"A top song by Radiohead, an artist the room loves", "",
},
{
"deep cut",
dj.Pick{Track: song("Radiohead", "Nude"), Why: dj.Why{Kind: dj.KindArtist, DeepCut: true}},
"A deep cut by Radiohead, an artist the room loves", "",
},
{
"bridge",
dj.Pick{Track: song("Portishead", "Roads"), Why: dj.Why{Kind: dj.KindSimilarArtist, Bridge: &dj.Bridge{
Users: [2]string{"sam", "jo"}, Artists: [2]string{"Radiohead", "Massive Attack"},
}}},
"Bridging Sam's Radiohead and Jo's Massive Attack", "",
},
{
"similar song",
dj.Pick{Track: song("Muse", "Unintended"), Seed: seed("jo", "Radiohead", "Creep"), Why: dj.Why{Kind: dj.KindSimilarSong, Sources: []string{"lastfm"}}},
"Like Jo's Creep", "Last.fm",
},
{
"throwback",
dj.Pick{Track: song("Blur", "Song 2"), Why: dj.Why{Kind: dj.KindThrowback, Via: "Blur"}},
"A throwback to Blur, a favorite from past nights", "",
},
}
for _, c := range cases {
got, source := explain(c.pick, name)
if got != c.want || source != c.source {
t.Errorf("%s: explained %q (%q), want %q (%q)", c.name, got, source, c.want, c.source)
}
}
}
9 changes: 9 additions & 0 deletions server/internal/httpapi/api.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion server/internal/httpapi/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,10 @@ func toQueueItem(it store.QueueItem) QueueItem {
out.Via = &PlaysVia{Provider: it.ViaProvider.String, LinkId: it.ViaLinkID.String, TrackId: it.ViaTrackID.String}
}
if info, ok := queue.ParseAutopilot(it); ok {
out.Autopilot = &AutopilotPick{SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist)}
out.Autopilot = &AutopilotPick{
SeedItemId: nonEmpty(info.SeedItemID), SeedTitle: nonEmpty(info.SeedTitle), SeedArtist: nonEmpty(info.SeedArtist),
Reason: nonEmpty(info.Summary), Source: nonEmpty(info.Source),
}
}
if it.Palette.Valid {
var p palette.Palette
Expand Down
5 changes: 5 additions & 0 deletions server/internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ type AutopilotInfo struct {
// Reason is the DJ's reasoning (ADR 0012). It's nil for a song a
// service recommended, or one picked at random.
Reason *AutopilotReason `json:"reason,omitempty"`
// Summary is the DJ's reasoning in a few words, for people (MAD-760):
// "Because Sam's Radiohead → Portishead (similar 0.82)". Source is the
// music knowledge it came from: "Last.fm, Deezer".
Summary string `json:"summary,omitempty"`
Source string `json:"source,omitempty"`
}

// AutopilotReason is why the DJ chose a song: how it relates to the room
Expand Down
10 changes: 10 additions & 0 deletions web/src/api/schema.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion web/src/components/room/autopilot-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { api } from '@/api/client'
import { errorMessage, unwrap } from '@/api/errors'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { autopilotReason, type AutopilotPick } from '@/lib/autopilot'
import { autopilotReason, autopilotSource, type AutopilotPick } from '@/lib/autopilot'
import { tap } from '@/lib/haptics'
import type { QueueItem } from '@/lib/playback'
import { queueQuery } from '@/lib/room'
Expand All @@ -21,6 +21,17 @@ export function AutopilotBadge({ pick, className }: { pick: AutopilotPick; class
)
}

/** Why autopilot chose a song, and where the DJ's knowledge came from. */
export function AutopilotWhy({ pick, className }: { pick: AutopilotPick; className?: string }) {
const source = autopilotSource(pick)
return (
<p className={cn('text-pretty text-muted-foreground', className)}>
<span className="text-primary">{autopilotReason(pick)}</span>
{source && <span className="text-muted-foreground/70"> · {source}</span>}
</p>
)
}

/** Autopilot's stand-in for a user avatar, in queue rows. */
export function AutopilotMark({ className }: { className?: string }) {
return (
Expand Down
3 changes: 2 additions & 1 deletion web/src/components/shell/now-playing-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Dialog } from 'radix-ui'
import { useRef, useState } from 'react'
import { Artwork } from '@/components/artwork'
import { LyricsView } from '@/components/lyrics/lyrics-view'
import { AutopilotBadge } from '@/components/room/autopilot-badge'
import { AutopilotBadge, AutopilotWhy } from '@/components/room/autopilot-badge'
import { SourceTag } from '@/components/service-tag'
import { AlbumLink, ArtistLinks } from '@/components/track-credits'
import { Badge } from '@/components/ui/badge'
Expand Down Expand Up @@ -136,6 +136,7 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC
{np.autopilot && <AutopilotBadge pick={np.autopilot} />}
<SourceTag provider={np.track.provider} via={np.via} className="py-1" />
</div>
{np.autopilot && <AutopilotWhy pick={np.autopilot} className="mt-2 text-sm" />}
</motion.div>

<div className="mt-5">
Expand Down
10 changes: 9 additions & 1 deletion web/src/components/tv/tv-stage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { AlbumBackdrop } from '@/components/shell/album-backdrop'
import { UserAvatar } from '@/components/user-avatar'
import { useAlbumPalette } from '@/hooks/use-album-palette'
import { usePosition } from '@/hooks/use-position'
import { autopilotReason } from '@/lib/autopilot'
import { autopilotReason, autopilotSource } from '@/lib/autopilot'
import { guestPassQuery } from '@/lib/guests'
import { laneStyle } from '@/lib/lane'
import { linerCards, linerNotesQuery } from '@/lib/liner-notes'
Expand Down Expand Up @@ -148,6 +148,9 @@ function NowPlayingColumn({ np }: { np: NowPlaying }) {
<p className="text-[clamp(0.95rem,1.5vw,1.5rem)]">
<span className="font-semibold text-primary">Autopilot</span>
<span className="text-muted-foreground"> · {autopilotReason(np.autopilot)}</span>
{autopilotSource(np.autopilot) && (
<span className="text-muted-foreground/70"> · {autopilotSource(np.autopilot)}</span>
)}
</p>
</div>
)}
Expand Down Expand Up @@ -308,6 +311,11 @@ function UpNext({
<span className={cn('size-2 shrink-0 rounded-full bg-(--lane)', item.autopilot && 'bg-primary')} />
{item.autopilot ? 'Autopilot' : (u?.displayName ?? 'Someone')}
</p>
{item.autopilot && (
<p className="truncate text-[clamp(0.7rem,0.85vw,0.9rem)] text-primary" title={autopilotReason(item.autopilot)}>
{autopilotReason(item.autopilot)}
</p>
)}
</div>
</motion.li>
)
Expand Down
10 changes: 9 additions & 1 deletion web/src/lib/autopilot.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { autopilotReason, isMine } from './autopilot'
import { autopilotReason, autopilotSource, isMine } from './autopilot'

describe('isMine', () => {
it("counts your songs, not autopilot's", () => {
Expand All @@ -14,4 +14,12 @@ describe('autopilotReason', () => {
expect(autopilotReason({ seedItemId: 'i1', seedTitle: 'Heroes', seedArtist: 'David Bowie' })).toBe('Like Heroes')
expect(autopilotReason({})).toBe('To keep the music going')
})

it("prefers the DJ's reason, and credits its source", () => {
const pick = { seedTitle: 'Creep', reason: "Because Sam's Radiohead → Portishead (similar 0.82)", source: 'Last.fm, Deezer' }
expect(autopilotReason(pick)).toBe("Because Sam's Radiohead → Portishead (similar 0.82)")
expect(autopilotSource(pick)).toBe('via Last.fm, Deezer')
expect(autopilotSource({ seedTitle: 'Creep', source: 'Last.fm' })).toBeUndefined()
expect(autopilotSource({ reason: 'A top song by Radiohead, an artist the room loves' })).toBeUndefined()
})
})
Loading
Loading