Status: Draft; Phase 0 passed, Phase 0.5 under way (platform findings in Platform facts)
Date: 2026-10-12
Owner: Wacek
Platform: Android (Kotlin, Jetpack Compose, Material 3)
License: GPL-3.0
Repository: wkusnierczyk/sleevesight
Prototype: Sleevesight simulator
Sleevesight is an open-source Android app that reads what a music player is playing, through Android’s media session metadata, and shows grounded insights about the song, its artists, its album and its genres. It never touches the audio stream. Every insight is written by an AI service of the user’s choice, from evidence retrieved from sources the user switched on, and every claim points back to its source.
The phone already announces what is playing. Any app with notification access can read the title, artists, album, duration and album art of the current track from every player that publishes a media session, and can send play, pause, next and previous back to it. Sleevesight builds on that:
The prototype in the simulator covers the whole flow against a real 80-track playlist, with frozen lookups and, while it was hosted as a claude.ai artifact, live synthesis. The public copy linked above has no AI service, and its insight cards show the first facts found instead. This document records what it established and what still has to be verified on a real phone.
These were established while designing, and checked in Phase 0 with the platform probe (spike/, removed after the gate; history keeps it) on a Samsung Galaxy S25 Ultra (SM-S938B, Android 16, API 36), with Spotify, Castbox (podcasts), YouTube Music (free tier) and a Sonos session, on 2026-10-12. Items marked to measure are still open.
MediaSessionManager.getActiveSessions(componentName) returns a MediaController for every app currently publishing a media session: Spotify, YouTube Music, Tidal, Deezer, podcast apps, browsers playing media.NotificationListenerService. Its ComponentName is passed to getActiveSessions(); without it the call throws SecurityException. The user grants access in Settings, Notification access.addOnActiveSessionsChangedListener tracks sessions appearing and disappearing; MediaController.Callback.onMetadataChanged and onPlaybackStateChanged deliver track and state changes.TITLE, ARTIST, ALBUM, ALBUM_ARTIST, DURATION, the album art (a bitmap 300 pixels wide, usually 300×300, or 472×472, and an ALBUM_ART_URI), and METADATA_KEY_MEDIA_ID as a spotify:track:<id> URI. ARTIST is one comma-joined string of the credited artists (“Artist A, Artist B, Artist C”), as the normalizer assumes. It also sends its own keys: the playing context (com.spotify.music.extra.CONTEXT_URI, CONTEXT_TITLE, CONTEXT_DESCRIPTION, CONTEXT_SHARE_URL), an ART_HTTPS_URI on Spotify’s image CDN, an extracted background colour, and explicit, download and advertisement flags. Its CROSSFADE_ENABLED and CROSSFADE_DURATION extras are unreliable: they read false and 5000 throughout, including a run with a 12 s crossfade, so nothing relies on them.TITLE, DISPLAY_TITLE, ARTIST (the publisher), ALBUM (the episode title), DURATION, MEDIA_ID (its own episode id) and, for the playing episode, MEDIA_URI and http(s) art URIs. YouTube Music (measured, free tier) fills TITLE, ARTIST, ALBUM, ALBUM_ARTIST, DURATION, an art bitmap, a rating (always unrated here) and video dimensions (always 0 here), but no MEDIA_ID and no art URI: a YouTube Music track is looked up and opened by its title and artists, and within a session told apart by its active queue item (see Track changes). On the free tier playback stops when YouTube Music leaves the foreground, so its session is often PAUSED or STOPPED; a paused session is normal, not a change. A Sonos session sent only art and an empty media id.ALBUM_ART_URI is a content://com.spotify.mobile.android.mediaapi/... URI, and another app can read it. The bitmap and the https URI are there as well.:sources fetches like any other network resource.MediaController.getTransportControls() gives play(), pause(), skipToNext(), skipToPrevious() and seekTo().PlaybackState.getActions(). Measured: Spotify advertises play or pause, previous, next, seek, play from search and play from URI; play, pause, next and previous were sent and worked (the others were not tried). Castbox advertises play, pause, next, seek and its own set, but not previous, and usually only play/pause while in NONE or BUFFERING. YouTube Music advertises play, pause, previous, next, seek and play from search, URI or media id. Next and previous come and go in its advertised actions (they are missing, with seek, in its early STOPPED and PAUSED states); its session extras carry only two car display hints.MediaController.getSessionActivity() returns a PendingIntent to the player’s own screen for Castbox, which opened it (observed by the tester), and for YouTube Music (not tried); Spotify publishes none. So for Spotify the app opens the player through a spotify: URI or its launch intent instead.Intent.ACTION_VIEW with spotify:track:<id>, spotify:album:<id> or spotify:artist:<id>. Measured: spotify:track: with the id from MEDIA_ID opens the track.spotify:search:<query>, which always opens (measured). Log the fallback.The media session carries more context than the design assumed. In order of reliability:
CONTEXT_URI and CONTEXT_TITLE name the playlist or album being played (spotify:playlist:... and the playlist’s name), without an account.getQueue() (the upcoming tracks, with spotify:track: ids; 4 to 50 items, one fewer per track, reset when a track is opened through a deep link) and getQueueTitle() (“Play Queue”). Castbox publishes its queue too (episode ids), and YouTube Music its “Up next” (13 to 25 items, titles without media ids, starting with the playing track rather than after it).GET /playlists/{id}/items returns the whole playlist, not only what is queued.The track lists can therefore say “In this playlist ·
Measured for decision D5 on 21 Spotify track changes (of 24 logged; the other three were two Castbox changes and one at the start of a probe session): at each change, the previous track’s remaining time (its length less its position, extrapolated from the last playback state), and the playback states before the new metadata (the probe kept the last 3 s; the table counts the last 1.5 s).
| Change | Count | Remaining in the previous track | PLAYING states in the 1.5 s before |
|---|---|---|---|
| Ended on its own, no crossfade | 4 | −87 to −39 ms | 6 |
| Ended on its own, crossfade 12 s | 3 | 11.945 to 11.987 s | 6 |
| Skipped in Spotify, with earphones or from the notification | 12 | 20 s to 429 s | 0 |
| Skipped with the probe’s buttons, just after pressing play | 2 | 175 s and 369 s | 3 or 4 |
STATE_SKIPPING_TO_NEXT or STATE_SKIPPING_TO_PREVIOUS, for any kind of skip. Skips in the Spotify app, from earphones and from the notification look the same to the session.PLAYING states: one about 3 s before the change, and one 0.5 to 1 s before. Before a skip from the app, earphones or the notification it published none. Mid-track it publishes nothing but clusters around seeks and an occasional cluster of three. The late cluster therefore separates a natural end from a skip even where timing cannot. It is undocumented Spotify behaviour, so it is used with the timing, not alone. A seek shortly before a skip near the end is not measured.Decision (D5, refined): a user setting, Crossfade: Autodetect (the default) or a fixed value, 0 to 15 s. Changes that Sleevesight caused itself (its own transport buttons, selecting a track) are known to be user-driven and are not put through detection. The rules below are the floor; D15 lets detection learn per player on the device, within them (architecture.md, Learning skip detection).
PLAYING states within 1.5 s before the change) or the remaining time is within 0.5 s of 0 or of the learned crossfade. Otherwise it is a skip.adb, the probe got notification access without restriction (packageSource OTHER, initiating package com.android.shell).:app build in Phase 1 (#29).adb installs are not affected by either block.Since the February 2026 changes, Development Mode apps need the app owner to have Spotify Premium, are capped at five allowlisted users per client ID, and lost several endpoints and fields (artist followers and popularity, track ISRCs, artist top tracks, new releases). Extended quota is available to organizations only.
For an open-source app this is workable with a bring-your-own model: each user registers their own Spotify developer app and pastes its client ID, the same way they supply an AI key. The cost is friction, so the Spotify account stays optional and nothing in the core flow depends on it.
flowchart LR
P[Player app] -- media session --> L[Session layer]
L -- transport controls --> P
L --> N[Normalizer]
N --> R[Entity resolution]
R --> A[Source adapters]
A --> E[Evidence set]
E --> S[Synthesis via AI provider]
S --> G[Grounding check]
G --> UI[Compose UI]
E --> UI
W[Spotify Web API, optional] --> C[Playlist context]
L --> C
C --> UI
NotificationListenerService plus MediaSessionManager. Follows only the players switched on under Settings, Players to follow; events from other packages are dropped and logged as ignored.Players report a song the way their catalogue files it, which is often not how sources such as MusicBrainz and Wikipedia name it: “Fisherman’s Blues - 2006 Remaster” is the song “Fisherman’s Blues”, and “Baby, Please Don’t Go (feat. Van Morrison)” has a second artist hidden in its title. Normalizing turns the title and artist strings into the song’s plain title, its list of credited artists, and a lookup key, without losing what it removed: each removed part is kept, as a note, a source work, a variant or an added artist. Rules proven on the test playlist:
| Input pattern | Action | Example |
|---|---|---|
A trailing - 2006 Remaster, - demo, - Radio Edit, - … Version |
Strip, record as a note | “Fisherman’s Blues - 2006 Remaster” |
(feat. X) in the title |
Move X to the artists | “Baby, Please Don’t Go (feat. Van Morrison)” |
(From X), a trailing - From "X" |
Strip, record the source work | “The Departure (From The Leftovers)” |
(felt piano), (acoustic) |
Strip, record as a variant | “And Dream Of Sheep (felt piano)” |
A, B, C in the artist field |
Split into credited artists | “Peleboy, Tony K” |
The key is the lowercased, accent-folded primary artist plus the normalized title, keeping letters and digits in any script, and + (“C++” is not “C”); other punctuation, symbols and emoji become a space, unless they are all there is (a title ★ keys as ★). Accents are folded on Latin and Greek letters only: Cyrillic й is not и. Duplicates in a playlist hit the cache.
The hard part, and the place most failures came from in the prototype. Resolution produces a typed outcome per source, never a silent miss:
| Outcome | Meaning |
|---|---|
song_article |
The source describes this recording |
artist_article |
The source describes the performer only |
original_song |
The source describes the song as first recorded by someone else; this track is a cover or version |
rejected(reason) |
A candidate was found and failed the entity check, for example a different artist with the same name |
none |
Nothing found |
source_off |
The user switched the source off |
unreachable |
Network or source error |
none and source_off are deliberately different: one is a gap in the world’s documentation, the other is the user’s choice.
Rules:
One interface, one adapter per source:
interface SourceAdapter {
val id: SourceId // MUSICBRAINZ, WIKIPEDIA (also emits WIKIDATA lines), LASTFM, DISCOGS, WEB
suspend fun lookup(track: NormalizedTrack, resolution: Deferred<Resolution>): SourceResult // typed outcome + evidence lines; see architecture.md, Source adapters
}
val SourceAdapter.tag: String get() = id.tag // MB, W (and WD), L, D, G
| Source | Role | Notes |
|---|---|---|
| MusicBrainz | Recordings, releases, credits, relationships, first release dates | Rate limit about one request per second; descriptive User-Agent with contact info is required. No key. Core data is CC0; tags and genre associations are CC BY-NC-SA 3.0 (architecture.md, Licensing). |
| Wikipedia and Wikidata | Song and artist articles | Reach the article through the MusicBrainz Wikidata link where possible, to avoid same-name collisions. No key. |
| Last.fm | Artist bio, listener count, tags, similar artists | User’s own API key. Tags are crowd-applied and noisy; similar artists come from listening overlap, not audio. |
| Discogs | Releases and personnel | User’s own token. |
| Web search | Generic RAG over the open web | User’s own key; Exa first (D11, below). Results may describe a different song with the same title, so they are filtered. |
The model’s own knowledge is modelled as a pseudo-source with tag M: it retrieves nothing, and when enabled it lets the model add at most one claim, marked unverified.
Exa first (#40, 2026-10-14). The user brings the key, so the deciding points are what a key costs them to get and keep, and whether the results are worth an evidence line. Exa’s free tier needs no card and renews monthly; on the fixture’s thin tracks it found real listings where the simulator’s search found nothing; and its terms, though strict, are no worse than the others’. Only Exa was tested live: it is the only key the project holds. The others were compared on their official pages (2026-10-14):
| Provider | Free tier | Card for the free tier | Price per 1000 searches | Rate limit | Terms on results |
|---|---|---|---|---|---|
| Exa | $10 of credit a month, “up to 2,500 Instant searches” | No | $4 instant, $7 auto, for up to 10 results; whether highlights cost more is ambiguous (below) |
10 per second for /search |
§4.2(a): no copying, distributing or displaying information obtained through the services beyond the terms or Exa’s written leave; no attribution clause |
| Brave Search API | $5 of credit a month | Yes (“to verify identity”, not charged) | $5 | 50 per second | Storing results needs a plan with storage rights; grants no rights in the third-party pages |
| Tavily | 1000 credits a month (a basic search is 1 credit) | Pricing page says no; the owner was asked for one at sign-up | $8 (pay as you go) | 100 a minute on a development key | For integration in the customer’s own applications; no attribution clause |
| Linkup | $20 of credit, topped up monthly, for a professional email | Not stated | $5 (standard search) | Not stated | Not reviewed |
| You.com | $100 of credit at sign-up | Not stated | $5 | Not stated | Not reviewed |
| Serper, SerpApi | 2500 queries once; 250 a month | No; not stated | $0.30 to $1; $25 for 1000 a month | 50 per second; 50 an hour | They resell Google’s results page, scraped: not a sound basis |
| Google Custom Search JSON API | Closed to new customers, ends 2027-01-01 | ||||
| Bing Web Search API | Retired in 2025 |
What a search with highlights costs is not settled by Exa’s documents (checked 2026-10-14). The pricing page and pricing docs say a /search price covers up to 10 results and that “Additional results and Exa-generated page summaries are billed on top”; they price contents at “$1 / 1k pages, per content type” in the /contents row, and say nothing on text or highlights requested within /search. The search reference describes costDollars.contents as the cost of “standalone content retrieval”, with text, highlights and summaries “billed outside the bundled search price”, and calls costDollars an estimate, “not an invoice record”. In the spike every instant search with 10 highlighted results reported $0.004 and no contents charge. So a month’s free credit is 2500 searches if highlights are included, as the spike’s estimates suggest, and about 700 if each highlighted result costs $0.001 on top ($14 per 1000); the dashboard’s usage settles it. The adapter keeps numResults at 10: the filter kept 0 to 4 of 10 on the thin and same-title tracks, so fewer candidates would cost lines where they are scarcest. If highlights turn out to be billed per result, 5 would halve that charge.
The live test (20 calls, about $0.10 by Exa’s estimates): tracks 1, 12, 23, 30, 45, 61 and 77, with auto and instant. instant was as relevant as auto at a tenth of the latency (0.3 s against 3 s) and a little over half the price, so the adapter uses it. On the thin tracks Exa found what the simulator’s search did not: for 23, a library catalogue entry naming the composers and the album, a soundtrack listing with the scene, and two song-database pages; for 30, a soundtrack listing and a CritiqueBrainz page; for 77, only Alphaville’s and Rod Stewart’s songs, which the filter rejects. For 12 it mixed Reem’s song with Juice WRLD’s of the same name and returned one undecodable page; for 61 one Spotify page for Jessiah’s recording among other artists’ “willow”s; for 45, Yanjoco’s own listings and a Spotify page for another Yanjoco song whose list of the artist’s tracks named the title, the case behind the filter’s rule on result titles below. This is a summary; no result text is kept in the repository (Open on the terms). The app’s and the CLI’s retrieval cache does keep web search results on the device, for web search’s lifetime (D9: 1 day), like any other source’s: during development, by the owner’s decision of 2026-10-14 (#81), which reversed the #44 review’s refusal, since without it every repeat lookup is a paid search.
Keeping results about the right recording. The query is "<title>" from <work> by <artists>, song, the from part only when the player names the work (track 34, Theme from Coronation Street), which suits Exa’s neural search; highlights of at most 300 characters, steered by the same words, are the snippet. A track with no credited artist is not searched, since no result could be checked. Ten results are asked for, and at most five kept, in Exa’s order. Phrases are matched whole in the result’s title and snippet, after folding case, accents and punctuation, with & read as and and apostrophes dropped (“Don’t” and “Dont” agree) except before a possessive “s”, which stays a word of its own (“Lightfoot’s” still names Lightfoot). A track whose title or artists fold to nothing (an artist named “!!!”) is not searched either. A result is kept only if:
song_article; results that all fail give rejected; no results give none. Known limits: an artist named only with common words, when no other credited artist is distinctive, is matched by those words; a result can quote lyrics; a page about another recording that names the song and the artist in passing still passes; and a listing titled with several artists, this one among them, but not the song (a soundtrack page titled “Ray Johnson And The Sou’ Westers, Die Hexen and others”) is dropped, even when it lists the song.Open on the terms. Exa’s §4.2(a) forbids, without its written permission, to “download, modify, copy, distribute, transmit, display, perform, reproduce, duplicate, publish, license, create derivative works from, or offer for sale any information” obtained through its services. Read literally, it covers what the app does with a result on the user’s own key: it displays the highlight, transmits it to the user’s AI provider in the prompt, and creates a derivative work from it, the written insight. Showing results is what the API is for (§1.1 grants use “for the limited purposes set forth in the documentation”), but the terms do not say so of these uses. Before release, confirm with Exa that the app’s use is within the terms, including the on-device retrieval cache, which keeps web search results for a day during development (owner, 2026-10-14); the question is tracked in #80 and must be settled before the app is published. Because the same clause covers stored copies, the test fixtures in fixtures/http/web/ are synthetic: the requests are the adapter’s own, the responses have Exa’s shape with titles, URLs and highlights written by the project; live checks go through ExaLiveTest, which keeps nothing. A second provider can be added behind the same SourceAdapter, with the filter shared.
data class EvidenceLine(
val id: String, // "W1", "L3", "G2", "P1"
val source: SourceId,
val scope: Scope, // SONG, ARTIST, ORIGINAL_SONG, RELEASE, WEB
val text: String, // one claim: templated from structured data, or a short attributed quote, never a whole passage (D1)
val url: String?,
val retrievedAt: Instant,
)
P lines come from player metadata (the release). Scope is passed to the model so it never credits an original song’s facts to a cover act.
AiProvider with implementations for Anthropic, OpenAI, Google and a local model through Ollama. The user picks provider and model and supplies the key.Little else is documented. (prompt version 2, #46). If there is no usable evidence, the model replies NO_EVIDENCE and the UI says that little is documented.ok, hedge (states a gap), model (cites M), uncited, or unknown (cites an id that does not exist, or cites M when model knowledge is off). Uncited and unknown sentences are underlined; the counts are logged.Prompt contracts are in the appendix.
architecture.md, MusicBrainz tags and genres). The vocabulary may take names from MusicBrainz’s genre list, which is CC0.architecture.md, Cache and history).unreachable, never with a blank card.Material 3, with dynamic color taken from the album art (material-color-utilities from the art bitmap). The prototype is the reference for layout.
| Opened from | Content |
|---|---|
| Song title | Insight on the recording, Open in player, sources |
| Each artist | Insight on that artist, tracks by them in the playlist or session, Open in player, sources |
| Album | Insight on the release, tracks from it, Open in player, sources |
| Genre label | Why the track fits (grounded), what the genre is (general knowledge), more like this (general knowledge, opens a player search) |
| Sounds-like artist | Why they’re linked (grounded), how they compare (general knowledge), songs to start with (general knowledge, opens a player search) |
The detail section is called Insight. “Analysis” is reserved for a future deeper mode (see Open questions).
The detail content is one component mounted either way.
Labelled by where they came from: “Played this session” by default, and “In this playlist ·
| Group | Setting | Default |
|---|---|---|
| Layout | Separate pages or expandable boxes | Separate pages |
| AI service | Provider, model, API key | None until set; the model then defaults per provider (Anthropic: Claude Haiku 5.5, its cheapest current model) |
| AI service | Reasoning effort: how much the model may reason before it writes (low, medium, high, or the provider’s default) | Low: an insight narrates a few lines and is waited for |
| Spotify account | Connect (OAuth PKCE, user’s own client ID) | Off |
| Players to follow | One switch per player seen | Spotify on |
| Where to look | MusicBrainz, Wikipedia, Last.fm, Discogs, Web search, Model’s own knowledge | All on except model knowledge |
| Insights | Write insights (off: no AI calls at all; evidence is still shown) | On |
| Insights | Write insights automatically: on every track change, when I skip, or never (D5) | On every track change |
| Insights | Crossfade, for telling skips from natural ends under When I skip (D5) | Autodetect |
| Insights | Prepare the next track: off, evidence, or evidence and insight; the insight only where Write insights automatically would write it (D13) | Evidence and insight |
| Insights | Style: a preset or free text, with overrides per card under Advanced (D14) | Each card’s built-in style |
| Lookups | Look up in the background (D6) | Off |
| Lookups | Cache lifetime per source (D9) | MusicBrainz, Wikidata and Discogs 30 days, Wikipedia and Last.fm 7 days, web search 1 day |
| Lookups | Cache lifetime of artist identities | 90 days |
| Lookups | Cache size limit | 50 MB |
The cache size limit covers the evidence and the written insights. A track’s evidence is about 5 to 15 KB and each written card 1 to 3 KB (estimates, measured at the Phase 0.5 gate), so 50 MB holds about 2,500 tracks with their insights. Evidence expires within 30 days, so on its own it reaches the limit only for someone playing over 80 new tracks a day; written insights never expire, so they accumulate until the limit, which every listener reaches in time (some 25,000 cards). From then on the least recently used entries go first: the insights of the tracks not played for longest. It is not lower because an evicted insight costs a paid call to write again.
Source switches decide what the model may use. The filter chips in the Sources section only change what is displayed.
Nothing else leaves the phone. The duration, the album art, the session history and the record of track changes used to learn skip detection (D15) stay on it.
Enforced in CI rather than stated as policy:
A throwaway app on a real phone. Outputs, written to a log file:
METADATA_KEY_MEDIA_ID.getQueue() and getQueueTitle().PlaybackState actions each player advertises; whether transport controls work.getSessionActivity() and spotify: deep links open the expected screens.Gate: every unverified item in Platform facts is resolved and this document updated. Passed 2026-10-13, with one item moved to Phase 1 (owner, 2026-10-13): restricted settings on notification access for an app installed from a file, on a device without Advanced Protection (#29). In light of the installation findings, D7 moved distribution to Google Play (owner, 2026-10-12), so this matters for development installs and for any later channel outside Play, not for the release. The probe is removed (#30).
A JVM Kotlin command-line tool sharing the pipeline module: normalizer, resolution, adapters, evidence, synthesis, grounding check. Input is a playlist fixture; output is the coverage report and the generated insights.
Gate: on the test playlist, zero unknown citations, uncited sentences under 5 percent, every same-name collision either rejected or correctly resolved, and the coverage numbers reviewed. Passed 2026-10-15, with the coverage numbers reviewed and D12 decided (#88).
The three criteria the CLI checks pass with insight prompt version 2 (d0e6986), which the owner chose after the first runs (below); the review of the coverage numbers, and so the gate, was the owner’s, on #88. Run on 2026-10-14 with :cli live --tracks all (80 positions, 78 unique tracks) and the owner’s keys: MusicBrainz, Wikipedia with Wikidata, web search (Exa, instant), and Claude Haiku 5.5 at the app’s default effort (low). Last.fm was off: no key exists. The reports stay in the gitignored scratchpad, since most insights were written from web lines (on 72 of 80 positions in the first run) (Web search provider (D11), Open on the terms); what follows is counts. The run predates the three resolution fixes it led to (#76, #77, #78; PR #83): after them the regression tracks, the listed collisions among them, replay unchanged, and tracks 55 and 62 change as noted below.
Cost. Four runs: the first live throughout; the second the same run from its cache, the insights re-checked unchanged after the grounding fix; the third with a trial wording of the prompt; the fourth with prompt version 2 as adopted, its MusicBrainz and Wikipedia answers from the first run’s retrieval cache, web search and insights live (one insight, track 70, failed when the model’s stream ended early, and was written again alone). Anthropic, first, third and fourth runs (the second’s insights came from the cache): 362,363 tokens in, 113,086 out, about $0.09 at $0.10 and $0.50 per million. Exa: 80 searches a run, 321 in all with the retry (web results were not cached at the time; #82 has since added a cache), $1.28 at Exa’s $0.004 per instant search; if highlights turn out to be billed per result (the open question under D11), up to $4.49. About $1.38 in all on Exa’s own per-search estimates.
The criteria.
| Criterion | First run (prompt version 1) | Its replies after the split fix (2b25013) |
Trial wording | Prompt version 2 (d0e6986) |
|---|---|---|---|---|
Zero unknown citations |
Pass: 0 of 265 sentences | Pass: 0 of 262 | Pass: 0 of 258 | Pass: 0 of 282 |
| Uncited under 5 percent | Fail: 35 of 265 (13.2 %) | Fail: 18 of 262 (6.9 %) | Pass: 4 of 258 (1.6 %) | Pass: 0 of 282 (0.0 %) |
| Same-name collisions rejected or correctly resolved | Pass: 8 of 8 checks (tracks 8 and 22, 12 and 20, 45, 48, 76, 61; Wikipedia’s; Last.fm’s for 61 not checked, the source being off) | Pass | Pass | Pass: 8 of 8 |
| Coverage reviewed | The owner’s (below) |
The second column counted “little else” and “little more” as hedges, a widening d0e6986 removed again; the fourth counts only the fixed sentence and the simulator’s patterns.
Uncited sentences, by cause (first run, 35):
The fix in code (2b25013, the grounding check): an abbreviation (U.S., R.E.M., a middle initial, Dr., St., feat., vs.) directly before the next word does not end a sentence, unless initials come before a word that usually opens one (It, The, He; added after the PR #86 reviews). Its cost: a sentence that does end in one before another word (... toured the U.S. Billboard named ...) is joined to the next, and if it was uncited the next one’s citation hides it. None of the gate’s replies ended a sentence that way. It deviates from the simulator on purpose; the simulator’s test case that splits after Dr. is listed as a deviation and every other stored reply still agrees with it. The same commit let a stated gap say “little else” or “little more”; with it, re-checking the same 78 replies gave 18 uncited of 262 (6.9 %): 12 gaps in yet other words, 2 commentary, the restatement, and the 3 of track 77’s reasoning. Widening the check’s phrase list to every way of saying a gap makes the check guess, and a guessing check lets uncited claims through as hedges.
The prompt fix, adopted (d0e6986, prompt version 2; the owner’s choice). The contract’s rule on a gap now reads: “If the evidence covers only part of what you would say, write the sentences it supports. To state the gap, end with exactly this sentence, without a citation: Little else is documented. Write no other sentence without a citation.” The grounding check accepts exactly that sentence as a hedge (GroundingCheck.GAP_SENTENCE), and the “little else”/”little more” widening is removed, so the hedge patterns are the simulator’s again. A trial wording first (“…in these words: “Little else is documented.”, or “Little is documented.” when nothing is.”) left 4 uncited of 258: a fact without its id, a paraphrased quote, a comment, a remark on a title’s variant. With version 2, over all 78 insights: 282 sentences, 212 cited, 70 hedges (69 the fixed sentence, 1 matched by the simulator’s patterns), no uncited and no unknown sentence; 2 positions (4 and 24) had no usable evidence, as before, and replied NO_EVIDENCE. No reply in this run had uncited commentary or reasoning in the open, as the first run’s did; a later model or style may bring them back, and the check will flag them.
Collisions, in detail. Wikipedia gave no song article for the Peleboy track (8, 22), whose namesake is DMX’s song; no artist article for the wrong Reem (12, 20); no song lines for the covers 45 and 48 (rejected: the articles are the original songs, and MusicBrainz does not mark these recordings as covers) or 76 (none found); no song article for Jessiah’s “willow” (61). MusicBrainz’s one rejection was track 50: the recording that matches its title and length is credited to “Mr. A Balladeer”, and the player says “a balladeer”. Probably the same act under another form of its name, so a false rejection, in the safe direction: a rejection shows, a wrong match would mislead. The album title on 61 (“Taylor Swift Covers”) was not weighed: nothing reads it, and the gate passes without it.
A wrong original-song label, outside the listed collisions. Wikipedia gave original_song on three tracks. Two are right: 40 (Siouxsie and the Banshees’ “The Passenger”, Iggy Pop’s song) and 79 (a cover of “Dancing in the Dark”). The third is wrong: 55, Chuck Berry’s own “Roll Over Beethoven”. MusicBrainz links the recording’s work to the Wikidata item of the song, whose performers (P175) are the Beatles and three other performers; Chuck Berry is on the item only as composer and lyricist. The identity check reads an item whose performers include no credited artist as the original song (architecture.md, step 5), so the article’s lines went to the model scoped as another recording’s, against a song that is this one. A rule to consider: a credited artist who is the item’s composer or lyricist, on a recording MusicBrainz does not mark as a cover, makes it the track’s own song. Not a gate criterion; followed up in #76, fixed in PR #83 (a credited writer, or the track’s artists’ earlier recording of the work, makes the item the track’s own song; architecture.md, step 5): track 55 is now song_article.
Thresholds (ResolutionThresholds; the search size is MusicBrainzClient.SEARCH_LIMIT). Evidence from each unique track’s MusicBrainz search (78 searches, every candidate’s title similarity, length gap and credited artists), replayed through the resolver’s rules at other settings:
fixtures/http/ must be re-recorded with it. Raised to 100 in #78 (PR #83), with the fixtures re-recorded; track 55 resolves to the May 1956 single at rank 87.D4 profile (first run; the CLI runs tracks one after another, so MusicBrainz’s one request a second is never shared between tracks; each latency counts from the start of the track’s lookups):
| Median | 90th percentile | Max | |
|---|---|---|---|
| MusicBrainz resolution (and its own lines) | 1.09 s | 2.28 s | 3.11 s |
| Wikipedia with Wikidata, verified | 1.60 s | 4.24 s | 5.22 s |
| Web search (Exa) | 0.56 s | 0.72 s | 0.90 s |
| First source result | 0.51 s | 0.63 s | 0.90 s |
| Last source result | 1.60 s | 4.24 s | 5.22 s |
| The insight call (Haiku 5.5), after the last source | 2.16 s | 4.39 s | 8.23 s |
Waiting for MusicBrainz first would make the first result MusicBrainz’s own, at 1.09 s median and 2.28 s at the 90th percentile, against 0.51 s and 0.63 s in parallel; web search would land at resolution plus its own time, 1.61 s median and 2.88 s at the 90th percentile. Wikipedia lands after resolution in either design, since it awaits the ids to verify: it finished a median 0.87 s after resolution, more than 0.5 s after it on 49 of 78 tracks. The events give each source’s total, not its requests, so how much of Wikipedia’s by-name work the parallel design overlaps with resolution is not measured; the last result is no later in parallel, and the first is twice as early or better. D4 stands. The insight waits for every source, so the time to the insight is the last source’s plus the call’s: about 3.8 s median.
Coverage (first run, all 80 positions):
| Source | song | artist | original song | rejected | none | Cited lines |
|---|---|---|---|---|---|---|
| MusicBrainz | 42 | 1 | 37 | 132 | ||
| Wikipedia (W) and Wikidata (WD) | 18 | 15 | 3 | 34 | 10 | 79 W, 151 WD |
| Web search | 72 | 8 | 249 | |||
| Last.fm | off (no key) |
Per track, 678 cited lines in all (with 67 P lines), 0 to 26 a track: none on 2 positions (the duplicate pair 4 and 24, a NO_EVIDENCE reply each), 1 to 3 on 30, 4 to 9 on 20, 10 or more on 28. By kind: curated lines (MusicBrainz, Wikipedia or Wikidata) and web lines on 40 positions, web lines alone on 32, curated alone on 1, only the player’s release or nothing on 7. So web search carries the thin tracks: without it, 39 positions would have nothing beyond the player’s own release. No genre labels: they come from Last.fm alone.
D12: MusicBrainz tags against Last.fm alone. The comparison the issue asks for needs Last.fm live, and there is no key, so it was made against the simulator’s frozen Last.fm tags (facts.json), for the one artist it looked up per track. MusicBrainz tags were fetched for evaluation only (inc=tags on each artist the resolver gave an id), kept in the gitignored scratchpad, and mapped through GenreVocabulary; what follows is counts (MusicBrainz tags and genres (#9), Conditions). Of the 78 unique tracks:
| Tracks with labels | Without | |
|---|---|---|
| Last.fm (simulator’s tags) | 48 | 30 |
| MusicBrainz tags alone | 29 | 49 |
| Either | 51 | 27 |
Recommendation, decided as written on 2026-10-15 (D12 in Decisions): do not adopt them to fill Last.fm’s gaps, which they barely do (3 of 30), and the licence conditions (Conditions) and an extra request per artist at one a second would be paid for that. Consider them as the keyless source of labels when Last.fm is off, for labels only and not in prompts, which keeps clear of the provider-terms question still open under #9; decide that with the genre labels in Phase 2, after MetaBrainz answers the inquiry. The Last.fm side of the comparison is from frozen data and awaits a Last.fm key: Last.fm’s live tags, after its verification (architecture.md, Last.fm verification), may label fewer tracks than the simulator’s 48.
Session layer, home screen, sources section, insight with grounding check, settings for sources and AI service, caching; preparing the next track (D13); recording track changes for learned skip detection (D15).
The release through Google Play (D7), after Phase 1 (owner, 2026-10-13): a signed app bundle from a release workflow, the store listing, privacy policy and data safety form, and Play’s review of an app that holds notification access. The work is in plan.md.
Song, artist and album detail content; genre labels and cards; sounds-like cards; layout setting; transport controls and player handoff; the prompt style with its overrides per card (D14); learned skip detection (D15), here or later.
Optional Spotify account; further players; local model through Ollama; Discogs.
D1 to D15 are set out, with their options and reasoning, under Decisions in architecture.md.
| Date | Decision | Reason |
|---|---|---|
| 2026-10-11 | Android only | iOS cannot read other apps’ now-playing information |
| 2026-10-11 | Metadata only, never audio | Enough for the purpose, and no audio permissions |
| 2026-10-12 | Sources are a user setting, with curated, open-web and model-knowledge as separate switches | The user decides what the insight may rest on |
| 2026-10-12 | Grounded synthesis with mechanical citation check | Insights must be traceable; failures must be visible |
| 2026-10-12 | Bring-your-own AI provider, model and key; Sleevesight is the author | Claude is one possible service, not the product |
| 2026-10-12 | Spotify account optional, with the user’s own client ID | Development Mode caps users per client ID |
| 2026-10-12 | Session history as the default playlist context | The only context that works without an account |
| 2026-10-12 | Sources section at the bottom, quiet | Provenance matters but is the least interesting content |
| 2026-10-12 | Layout setting: separate pages or expandable boxes | Both are useful; one component serves both |
| 2026-10-12 | Genre labels from a fixed vocabulary over Last.fm tags | Raw tags are crowd-sourced and noisy |
| 2026-10-12 | D1: evidence lines from prose sources are short verbatim quotes, attributed, a fixed number at most per article; templated wherever structured data exists | Deterministic and checkable, no extra paid call; the model still never decides what is true |
| 2026-10-12 | D2: the pipeline in plain Kotlin JVM modules, kept Multiplatform-ready | The CLI and Android both run JVM bytecode; iOS may follow |
| 2026-10-12 | D3: small HTTP clients for the AI providers, not vendor SDKs | One network boundary, a small dependency allowlist, Multiplatform readiness |
| 2026-10-12 | D4: sources queried by name in parallel, matches verified against MusicBrainz and Wikidata afterwards | A slow MusicBrainz delays only its own lines |
| 2026-10-12 | D5: Write insights automatically, a user setting (on every track change, when I skip, never), with a Crossfade setting for skip detection. The default is on every track change. Replaces “auto-write off by default” | The cost is the user’s, so the choice is too; the default goes with D13 |
| 2026-10-12 | D6: retrieval and synthesis only while the app is visible; history always. A user setting, Look up in the background, off | No paid or network activity nobody is looking at |
| 2026-10-12 | D7: distribution through Google Play. Replaces “GitHub releases first, F-Droid next” | Phase 0: a file install meets a Play Protect warning, and Advanced Protection blocks it outright |
| 2026-10-12 | D8: a local model through Ollama over HTTPS behind a reverse proxy, with a pinned certificate for that one host; plain HTTP only to a private address, with a warning | Android blocks cleartext and distrusts private CAs; no app-wide trust is widened |
| 2026-10-12 | D9: retrieval cache lifetime a user setting per source | Freshness against requests is the user’s trade-off |
| 2026-10-12 | D10: minSdk 26 |
Reach; nothing used needs more, and the Keystore floor is 23 |
| 2026-10-12 | D11: the first web search provider chosen in Phase 0.5 | The adapter interface does not depend on it |
| 2026-10-14 | D11: Exa is the first web search provider (#40); results kept only when they name the title and a credited artist | A free tier without a card, renewed monthly; real results for the thin tracks. Only Exa was tested live |
| 2026-10-12 | D12: MusicBrainz tags and genres judged in the Phase 0.5 pilot; their licence settled before they ship (#9); licence findings in architecture.md, MusicBrainz tags and genres |
Last.fm alone left 30 of 78 tracks without genres |
| 2026-10-15 | D12: MusicBrainz tags are not adopted to fill Last.fm’s gaps; whether they are the keyless labels when Last.fm is off is decided with the genre labels in Phase 2 | They fill 3 of Last.fm’s 30 gaps (the gate, #46), at the cost of the licence conditions and a request per artist; with Last.fm off they would label 29 of 78 tracks instead of none. Last.fm’s side is the simulator’s frozen tags |
| 2026-10-12 | D13: prepare the next track in the queue ahead, a user setting (off, evidence, evidence and insight; the default the last) | The card is ready when the track plays, and the slow MusicBrainz lookups are done before they are needed |
| 2026-10-12 | D14: a prompt style for insights, with presets, free text and overrides per card, over a fixed contract | The user shapes the writing; the grounding check keeps its guarantees |
| 2026-10-12 | D15: skip detection learned per player on the device, within the fixed rules, without an LLM | Better detection without cost, data leaving the phone, or decisions moving out of code |
| 2026-10-12 | Working name Sleevesight | Spoken-aloud test still to do |
| 2026-10-13 | Phase 1.5, Play release, after Phase 1 | D7 makes Play’s review, the listing and the signed bundle work of their own; the MVP’s exit needs only an adb install |
The simulator’s data makes a ready fixture: the playlist “Below Soundtrack Netflix” (80 tracks, 78 unique), with frozen lookups from Wikipedia, Last.fm and web search, and album and Spotify-link data. Tracks worth keeping as regression cases:
| Track | Case |
|---|---|
| 1 Sundown, Gordon Lightfoot | Rich data from every source |
| 7 Fisherman’s Blues - 2006 Remaster | Version suffix |
| 8 and 22, 4 and 24 | Duplicates in the playlist, should hit the cache |
| 12 and 20, Reem | Wikipedia article about a different Reem, must be rejected |
| 14 Up Down, Peleboy, Tony K | Multiple credited artists |
| 37 The Ode To Newfoundland | Three credited artists |
| 45, 48, 76 | Covers; Wikipedia only describes the original song |
| 61 willow, Jessiah | Last.fm name resolves to an unrelated band. Also a cover of Taylor Swift’s “willow” (its album is “Taylor Swift Covers (single)”), which nothing the identity check reads confirms; the album title is a signal for the Phase 0.5 gate (#46) to weigh |
| 63 Baby, Please Don’t Go (feat. Van Morrison) | Featured artist in the title |
| 13 New York Groove, 36 Snowbird | Release year is a compilation’s, not the song’s |
| 23, 30, 77 | Almost nothing documented anywhere; the card must say so |
Each prompt has two parts (D14; architecture.md, Prompt style):
NO_EVIDENCE, plain text without markdown, and for the genre and sounds-like cards their JSON structure. It is restated after the style, so a style cannot override it.The prompt states the contract, then the style, then the contract again. The data it narrates (the track as the player reports it, and the evidence) is fenced in <track> and <evidence> blocks that the prompt calls data, not instructions; each field and each evidence line is flattened to one line and its < replaced with ‹, so no text from a player or a web page can write a block’s tag or pass for an evidence line of its own (:core, InsightPrompt).
Input: the now-playing title, artists and album as the player reports them (not the duration), the normalized fields, and evidence lines as [ID] (source, scope) text. Rules given to the model:
[W1] or [W2][G1].Little else is documented., uncited, and no other uncited sentence (prompt version 2, #46; the simulator let the model word the gap freely).[M]. Otherwise none.NO_EVIDENCE.Detail insights use the same rules, with a built-in style of one or two short paragraphs, at most 130 words, focused on the song, artist or release.
{
"fits": "1-3 grounded sentences with ids",
"about": "2-3 sentences of general knowledge, no ids",
"more": [{"name": "Artist, or Song - Artist", "kind": "artist|song", "why": "under 15 words"}]
}
{
"link": "1-2 grounded sentences with ids; similarity comes from listening overlap",
"compare": "2-3 sentences of general knowledge; say so plainly if the artist is not known reliably",
"start": [{"name": "song by the similar artist", "why": "under 15 words"}]
}
start must be empty rather than guessed.