Merge pull request #2 from narrrl/rust-backend

Rust backend
This commit is contained in:
nvrl authored and GitHub committed 2026-11-01 14:33:53 +01:00
commit 2125000644
21 files changed
+1574 -85

No files matched your search

+26 -5
View File
@@ -121,6 +121,7 @@ visitor address reaches pl8list or a CDN.
| drag waveform | drag waveform · ← → | seek | | drag waveform | drag waveform · ← → | seek |
| follow button | f | follow the artist | | follow button | f | follow the artist |
| radio button | r | artist radio (more like them) | | radio button | r | artist radio (more like them) |
| | v | liquid / simple visuals |
| ✕ button | x | not for me (skip, show less) | | ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link | | share button | s | share / copy link |
| | m | mute | | | m | mute |
@@ -128,9 +129,14 @@ visitor address reaches pl8list or a CDN.
Lock-screen, notification and headphone buttons control playback too (Media Session API). Lock-screen, notification and headphone buttons control playback too (Media Session API).
"likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the "likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the
feed (for you, riddim, fresh, deep cuts); "radio" on a card starts an artist radio, and the chip feed (for you, riddim, fresh, deep cuts) and the visuals. "radio" on a card opens the radio
in the top bar goes back to your feed. Feed and filters (free only, BPM range) are remembered sheet: the artist, their tags and the artists next to them (tap one to walk over to theirs).
per browser. The site can be installed to the home screen as an app. "start radio" jumps to the radio's first track; each radio track says why it is there, and the
chip in the top bar goes back to your feed. Feed and filters (free only, BPM range) are
remembered per browser. The site can be installed to the home screen as an app.
Single-letter keys clash with vim-key browser extensions (Vimium and the like), which take
`r`, `x` and `f` before the page sees them; turn those off for the site.
## Front end ## Front end
@@ -145,8 +151,18 @@ per browser. The site can be installed to the home screen as an app.
starts just before the current one ends. Both decks run through a Web Audio graph with an starts just before the current one ends. Both decks run through a Web Audio graph with an
analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen
locked, so there tracks switch with a cut and lock-screen playback keeps working. locked, so there tracks switch with a cut and lock-screen playback keeps working.
- `visual.ts`: per frame, the waveform progress and a bass level (`--energy`) that pulses the - `features.ts`: what the music does, per frame: band levels against their recent range,
artwork and its glow. Without the analyser the level comes from the track's waveform. kick and snare onsets (spectral flux over an adaptive threshold), wobble in the mids, and
drops. A plain bass meter sits at full scale through a whole dubstep drop; these keep
moving. Without the analyser only loudness from the track's waveform is left.
`?debug=audio` shows them live, with onset counts.
- `blob/`: the liquid artwork. One WebGL2 canvas moves into the playing card: an icosphere
displaced by noise driven by those features (lobes, ripples, snare spikes, a ring after each
kick), showing the artwork through its surface. Tempo, tags and key set its character
(`blob/style.ts`). It steps its quality down on slow devices and falls back to the plain
artwork without WebGL2, with reduced motion, or with visuals set to simple.
- `visual.ts`: runs both per frame, plus `--energy` (kick-driven) for the CSS glow.
- `station.ts`: the radio sheet.
- `color.ts`: each card's accent colour (`--card-accent`) from the artist image. - `color.ts`: each card's accent colour (`--card-accent`) from the artist image.
- `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else. - `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else.
@@ -197,6 +213,11 @@ row. Weights live in a `Ranker`, which can also narrow the candidates and add a
| `deep` | tracks with 15 saves or fewer; popularity counts against a track | | `deep` | tracks with 15 saves or fewer; popularity counts against a track |
| `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) | | `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) |
In an artist radio each track carries `why`: `seed`, `related` (a neighbour), or `via` with the
neighbour that leads to a second-hop artist. `/local/artist?handle=` returns an artist, their
top tags and closest neighbours for the radio sheet, and starts crawling the neighbourhood
(at most every 10 minutes per artist); `exploring` is true while there are fewer than three.
**Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the **Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the
title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`, title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`,
…), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…) …), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…)
+13 -1
View File
@@ -14,7 +14,7 @@
use std::{ use std::{
cmp::Reverse, cmp::Reverse,
collections::{BinaryHeap, HashSet}, collections::{BinaryHeap, HashMap, HashSet},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::Duration, time::Duration,
}; };
@@ -50,6 +50,8 @@ const IDLE_DELAY: Duration = Duration::from_secs(20); // extra wait between requ
const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks
const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often
const RESEED_EVERY: Duration = Duration::from_secs(600); const RESEED_EVERY: Duration = Duration::from_secs(600);
/// An artist radio crawls its artist's neighbourhood at most this often.
const EXPLORE_EVERY: Duration = Duration::from_secs(600);
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)] #[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
enum Job { enum Job {
@@ -73,6 +75,7 @@ pub struct Crawler {
db: Db, db: Db,
queue: Mutex<Queue>, queue: Mutex<Queue>,
wake: Notify, wake: Notify,
explored: Mutex<HashMap<String, Instant>>,
} }
fn s(v: &Value, k: &str) -> Option<String> { fn s(v: &Value, k: &str) -> Option<String> {
@@ -86,6 +89,7 @@ impl Crawler {
db, db,
queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }), queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }),
wake: Notify::new(), wake: Notify::new(),
explored: Mutex::new(HashMap::new()),
}) })
} }
@@ -150,6 +154,14 @@ impl Crawler {
/// An artist radio was opened: crawl the artist, and the related pages of their /// An artist radio was opened: crawl the artist, and the related pages of their
/// best tracks that were not read yet, to fill in the artist graph around them. /// best tracks that were not read yet, to fill in the artist graph around them.
pub async fn explore_artist(&self, handle: String) { pub async fn explore_artist(&self, handle: String) {
{
let mut explored = self.explored.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
explored.retain(|_, at| now.duration_since(*at) < EXPLORE_EVERY);
if explored.insert(handle.clone(), now).is_some() {
return;
}
}
self.push(P_FEEDBACK, Job::Artist(handle.clone())); self.push(P_FEEDBACK, Job::Artist(handle.clone()));
let h = handle.clone(); let h = handle.clone();
let tracks = self let tracks = self
+30
View File
@@ -278,6 +278,36 @@ pub fn artist_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Ve
.collect() .collect()
} }
/// An artist as seen on their tracks.
#[derive(Debug, Serialize)]
pub struct Artist {
pub handle: String,
pub name: String,
pub img: Option<String>,
pub verified: bool,
pub tracks: i64,
}
pub fn artist(conn: &Connection, handle: &str) -> rusqlite::Result<Option<Artist>> {
conn.prepare_cached(
"SELECT handle, artist_name, artist_img, artist_verified, COUNT(*) FROM tracks WHERE handle=? GROUP BY handle",
)?
.query_row([handle], |r| {
Ok(Artist { handle: r.get(0)?, name: r.get(1)?, img: r.get(2)?, verified: r.get(3)?, tracks: r.get(4)? })
})
.optional()
}
/// An artist's most common style tags; tempo tags left out.
pub fn artist_tags(conn: &Connection, handle: &str, n: usize) -> rusqlite::Result<Vec<String>> {
conn.prepare_cached(
"SELECT g.tag FROM track_tags g JOIN tracks t ON t.id = g.track_id \
WHERE t.handle=? AND g.tag NOT GLOB '[0-9]*' GROUP BY g.tag ORDER BY COUNT(*) DESC, g.tag LIMIT ?",
)?
.query_map(params![handle, n as i64], |r| r.get(0))?
.collect()
}
/// How many tracks each artist has with `tag`. /// How many tracks each artist has with `tag`.
pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> { pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached( conn.prepare_cached(
+41
View File
@@ -144,6 +144,47 @@ pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, J
Ok((no_store(), Json(json!({ "ok": true }))).into_response()) Ok((no_store(), Json(json!({ "ok": true }))).into_response())
} }
/// How many neighbours the radio sheet shows, and how few count as still exploring.
const SHEET_NEIGHBOURS: usize = 12;
const FEW_NEIGHBOURS: usize = 3;
/// An artist and the ones next to them in the artist graph, for the radio sheet.
/// Starts crawling the neighbourhood when it is new, so the sheet can poll while
/// `exploring` is true.
pub async fn artist(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let Some(handle) = q.get("handle").filter(|h| valid_handle(h)).cloned() else {
return Ok(StatusCode::BAD_REQUEST.into_response());
};
app.crawler.explore_artist(handle.clone()).await;
let body = app
.db
.call(move |c| {
let Some(artist) = db::artist(c, &handle)? else { return Ok(None) };
let mut neighbours = vec![];
for (h, weight) in ranker::scaled_neighbours(c, &handle)?.into_iter().take(SHEET_NEIGHBOURS) {
if let Some(a) = db::artist(c, &h)? {
neighbours.push(json!({ "handle": a.handle, "name": a.name, "img": a.img, "weight": weight }));
}
}
Ok(Some(json!({
"followed": db::follows(c, uid)?.contains(&handle),
"tags": db::artist_tags(c, &handle, 4)?,
"exploring": neighbours.len() < FEW_NEIGHBOURS,
"artist": artist,
"neighbours": neighbours,
})))
})
.await?;
Ok(match body {
Some(b) => (no_store(), Json(b)).into_response(),
None => json_error(StatusCode::NOT_FOUND, "unknown artist"),
})
}
/// Valid pl8list handles, as seen in every handle crawled so far. /// Valid pl8list handles, as seen in every handle crawled so far.
fn valid_handle(h: &str) -> bool { fn valid_handle(h: &str) -> bool {
(1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".." (1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".."
+1
View File
@@ -316,6 +316,7 @@ pub fn router(app: Arc<App>) -> Router {
.route("/local/me", get(app::me)) .route("/local/me", get(app::me))
.route("/local/feed", get(app::feed)) .route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes)) .route("/local/likes", get(app::likes))
.route("/local/artist", get(app::artist))
.route("/local/event", post(app::event)) .route("/local/event", post(app::event))
.route("/local/follow", post(app::follow)) .route("/local/follow", post(app::follow))
.route("/account/export", get(account::export)) .route("/account/export", get(account::export))
+47 -1
View File
@@ -1,6 +1,6 @@
//! End-to-end tests of the HTTP API against a fake pl8list. //! End-to-end tests of the HTTP API against a fake pl8list.
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use axum::{ use axum::{
Router, Router,
@@ -417,3 +417,49 @@ async fn feed_modes_filter_and_tag_tracks() {
let res = get("/local/feed?mode=artist&artist=h").await.unwrap(); let res = get("/local/feed?mode=artist&artist=h").await.unwrap();
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1); assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1);
} }
#[tokio::test]
async fn artist_sheet_and_radio_reasons() {
let (r, app, _) = setup("radio", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
let t = |id, handle: &str, title: &str| crate::db::Track {
id,
handle: handle.into(),
artist_name: handle.to_uppercase(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(140.0),
full_access: true,
..Default::default()
};
crate::db::upsert_tracks(c, &[t(1, "a", "Riddim one"), t(2, "b", "Two"), t(3, "c", "Three")], None)?;
crate::db::add_related(c, 1, &["b".into()])?;
crate::db::add_related(c, 2, &["c".into()])?;
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1"));
let res = get("/local/artist?handle=a").await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = json_body(res).await;
assert_eq!(body["artist"]["name"], "A");
assert_eq!(body["tags"], serde_json::json!(["riddim"]));
assert_eq!(body["neighbours"][0]["handle"], "b");
assert_eq!(body["exploring"], true);
assert_eq!(get("/local/artist?handle=..").await.unwrap().status(), StatusCode::BAD_REQUEST);
assert_eq!(get("/local/artist?handle=nobody").await.unwrap().status(), StatusCode::NOT_FOUND);
let res = get("/local/feed?mode=artist&artist=a").await.unwrap();
let body = json_body(res).await;
let why: HashMap<i64, serde_json::Value> =
body["tracks"].as_array().unwrap().iter().map(|t| (t["id"].as_i64().unwrap(), t["why"].clone())).collect();
assert_eq!(why[&1], serde_json::json!({ "kind": "seed" }));
assert_eq!(why[&2], serde_json::json!({ "kind": "related" }));
assert_eq!(why[&3], serde_json::json!({ "kind": "via", "handle": "b", "name": "B" }));
}
+66 -22
View File
@@ -48,6 +48,10 @@ pub trait Ranker: Send + Sync {
fn bonus(&self, _track: &Track) -> f64 { fn bonus(&self, _track: &Track) -> f64 {
0.0 0.0
} }
/// Why a picked track is in this feed, sent along as `why`.
fn reason(&self, _track: &Track) -> Option<Value> {
None
}
} }
/// The original mix. /// The original mix.
@@ -164,8 +168,7 @@ impl Ranker for Fresh {
struct ArtistRadio { struct ArtistRadio {
seed: String, seed: String,
/// Neighbours, 0..1 by how closely they are linked. near: HashMap<String, Near>,
near: HashMap<String, f64>,
} }
const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC }; const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC };
@@ -179,32 +182,57 @@ impl Ranker for ArtistRadio {
} }
// The artist themselves now and then; mostly the ones around them. // The artist themselves now and then; mostly the ones around them.
fn bonus(&self, t: &Track) -> f64 { fn bonus(&self, t: &Track) -> f64 {
if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).copied().unwrap_or(0.0) } if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).map_or(0.0, |n| n.score) }
}
fn reason(&self, t: &Track) -> Option<Value> {
if t.handle == self.seed {
return Some(json!({ "kind": "seed" }));
}
Some(match &self.near.get(&t.handle)?.via {
None => json!({ "kind": "related" }),
Some(via) => json!({ "kind": "via", "handle": via }),
})
} }
} }
/// Neighbours of an artist up to two hops away, scored 0..1. A second-hop artist /// An artist near a radio's seed.
/// scores at most 0.4 of the link that leads to it. #[derive(Debug, PartialEq)]
fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, f64>> { pub struct Near {
let scaled = |links: Vec<(String, i64)>| { /// 0..1 by how closely linked.
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64; pub score: f64,
links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect::<Vec<_>>() /// For a second hop: the seed's neighbour that leads here.
}; pub via: Option<String>,
let first = scaled(crate::db::artist_neighbours(conn, seed)?); }
let mut near: HashMap<String, f64> = first.iter().cloned().collect();
let mut by_score = first; /// Neighbours of an artist up to two hops away. A second-hop artist scores at most
by_score.sort_by(|a, b| b.1.total_cmp(&a.1)); /// 0.4 of the link that leads to it.
for (h, w) in by_score.into_iter().take(10) { fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, Near>> {
for (h2, w2) in scaled(crate::db::artist_neighbours(conn, &h)?) { let first = scaled_neighbours(conn, seed)?;
if h2 != seed { let mut near: HashMap<String, Near> = first.iter().map(|(h, w)| (h.clone(), Near { score: *w, via: None })).collect();
let s = near.entry(h2).or_default(); for (h, w) in first.into_iter().take(10) {
*s = s.max(0.4 * w * w2); for (h2, w2) in scaled_neighbours(conn, &h)? {
if h2 == seed {
continue;
}
let score = 0.4 * w * w2;
let n = near.entry(h2).or_insert(Near { score: 0.0, via: Some(h.clone()) });
if n.via.is_some() && score > n.score {
*n = Near { score, via: Some(h.clone()) };
} }
} }
} }
Ok(near) Ok(near)
} }
/// Direct neighbours of an artist, closest first, scored 0..1 against the closest.
pub fn scaled_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Vec<(String, f64)>> {
let links = crate::db::artist_neighbours(conn, handle)?;
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64;
let mut out: Vec<(String, f64)> = links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect();
out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(out)
}
/// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three /// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three
/// or more count fully), or half of what the artists next to them in the graph have. /// or more count fully), or half of what the artists next to them in the graph have.
pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> { pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> {
@@ -391,12 +419,27 @@ pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn
let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0); let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0);
let (s, r) = scored.remove(best); let (s, r) = scored.remove(best);
seen.push(r.handle.clone()); seen.push(r.handle.clone());
out.push(serialize(&r, Some(s))); let mut v = serialize(&r, Some(s));
if let Some(why) = ranker.reason(&r) {
v["why"] = why;
}
out.push(v);
} }
add_tags(conn, &mut out)?; add_tags(conn, &mut out)?;
name_reasons(conn, &mut out)?;
Ok(out) Ok(out)
} }
/// Artist names for reasons that point at another artist.
fn name_reasons(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks {
if let Some(h) = t["why"]["handle"].as_str().map(String::from) {
t["why"]["name"] = json!(crate::db::artist(conn, &h)?.map_or(h, |a| a.name));
}
}
Ok(())
}
fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> { fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks { for t in tracks {
if let Some(id) = t["id"].as_i64() { if let Some(id) = t["id"].as_i64() {
@@ -509,8 +552,9 @@ mod tests {
db::add_related(&mut c, 1, &["b".into()]).unwrap(); db::add_related(&mut c, 1, &["b".into()]).unwrap();
db::add_related(&mut c, 2, &["c".into()]).unwrap(); db::add_related(&mut c, 2, &["c".into()]).unwrap();
let near = neighbourhood(&c, "a").unwrap(); let near = neighbourhood(&c, "a").unwrap();
assert_eq!(near["b"], 1.0); assert_eq!(near["b"], Near { score: 1.0, via: None });
assert!((near["c"] - 0.4).abs() < 1e-9); assert!((near["c"].score - 0.4).abs() < 1e-9);
assert_eq!(near["c"].via.as_deref(), Some("b"));
assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]); assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]);
} }
+40 -1
View File
@@ -31,7 +31,9 @@
<header id="bar" hidden> <header id="bar" hidden>
<span class="logo">pl8list <b>∞</b></span> <span class="logo">pl8list <b>∞</b></span>
<button id="mode-chip" type="button" title="back to your feed" hidden></button> <button id="mode-chip" type="button" title="back to your feed" hidden>
<span class="chip-avatar" aria-hidden="true"></span><span class="chip-label"></span><span aria-hidden="true">×</span>
</button>
<span id="stats"></span> <span id="stats"></span>
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button> <button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button> <button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
@@ -88,6 +90,11 @@
<label><input type="radio" name="mode" value="fresh"> fresh</label> <label><input type="radio" name="mode" value="fresh"> fresh</label>
<label><input type="radio" name="mode" value="deep"> deep cuts</label> <label><input type="radio" name="mode" value="deep"> deep cuts</label>
</fieldset> </fieldset>
<fieldset class="modes">
<legend>visuals</legend>
<label><input type="radio" name="visuals" value="liquid"> liquid</label>
<label><input type="radio" name="visuals" value="simple"> simple</label>
</fieldset>
<label><input type="checkbox" name="free"> free only</label> <label><input type="checkbox" name="free"> free only</label>
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label> <label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label> <label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
@@ -130,6 +137,33 @@
<button type="button">tap anywhere to start</button> <button type="button">tap anywhere to start</button>
</div> </div>
<!-- Artist radio: who the radio is built around, and who is next to them. -->
<div id="station" hidden>
<section class="station-box" role="dialog" aria-modal="true" aria-labelledby="station-name">
<button type="button" id="station-back" class="link" hidden></button>
<button type="button" id="station-close" class="icon-btn" aria-label="close"><svg><use href="#i-x"/></svg></button>
<div class="station-head">
<div class="station-avatar"><span class="initial"></span></div>
<div>
<p class="station-kicker">artist radio</p>
<h2 id="station-name"></h2>
<p id="station-sub"></p>
<div id="station-tags" class="chips"></div>
</div>
</div>
<h3>around them</h3>
<ol id="station-neighbours"></ol>
<p id="station-exploring" hidden>finding related artists…</p>
<button type="button" id="station-start" class="primary"><svg><use href="#i-play"/></svg> start radio</button>
</section>
</div>
<div id="radio-end" hidden>
<p></p>
<button type="button" id="radio-end-feed">back to your feed</button>
<button type="button" id="radio-end-next" class="primary"></button>
</div>
<div id="help" hidden> <div id="help" hidden>
<div class="help-box"> <div class="help-box">
<h2>controls</h2> <h2>controls</h2>
@@ -148,10 +182,14 @@
<dt>r</dt><dd>artist radio</dd> <dt>r</dt><dd>artist radio</dd>
<dt>x</dt><dd>not for me</dd> <dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd> <dt>s</dt><dd>share</dd>
<dt>v</dt><dd>liquid / simple visuals</dd>
<dt>m</dt><dd>mute</dd> <dt>m</dt><dd>mute</dd>
<dt>?</dt><dd>this help</dd> <dt>?</dt><dd>this help</dd>
</dl> </dl>
<p>Lock-screen and headphone buttons work too.</p> <p>Lock-screen and headphone buttons work too.</p>
<p class="pointer-only fineprint">Keys like r or x doing something else, such as reloading or
closing the tab? A vim-keys browser extension (Vimium and the like) takes them first:
turn it off for this site.</p>
<button type="button" id="help-close">got it</button> <button type="button" id="help-close">got it</button>
</div> </div>
</div> </div>
@@ -168,6 +206,7 @@
<span class="paused-badge"><svg><use href="#i-play"/></svg></span> <span class="paused-badge"><svg><use href="#i-play"/></svg></span>
<span class="spinner"></span> <span class="spinner"></span>
</div> </div>
<p class="why"></p>
<h1 class="title"></h1> <h1 class="title"></h1>
<div class="byline"> <div class="byline">
<a class="artist" target="_blank" rel="noopener"></a> <a class="artist" target="_blank" rel="noopener"></a>
+29 -2
View File
@@ -24,6 +24,30 @@ export interface Track {
followed?: boolean; followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs. /// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[]; tags?: string[];
/// In an artist radio: why this track is in it.
why?: Why;
}
/// `seed`: by the radio's artist; `related`: by an artist next to them;
/// `via`: one step further, through the artist `handle`.
export type Why = { kind: "seed" } | { kind: "related" } | { kind: "via"; handle: string; name: string };
export interface Artist {
handle: string;
name: string;
img: string | null;
verified: boolean;
tracks: number;
}
/// An artist and their neighbours in the artist graph, for the radio sheet.
export interface ArtistInfo {
artist: Artist;
neighbours: { handle: string; name: string; img: string | null; weight: number }[];
tags: string[];
followed: boolean;
/// Still crawling for neighbours; ask again in a moment.
exploring: boolean;
} }
export interface Stats { export interface Stats {
@@ -55,7 +79,7 @@ export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
/// that artist and the ones pl8list relates to them. /// that artist and the ones pl8list relates to them.
export type Mode = export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" } | { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string }; | { mode: "artist"; artist: string; name: string; img?: string | null };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike"; export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
@@ -99,6 +123,8 @@ export function feed(filters: Filters, mode: Mode) {
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p); return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
} }
export const artist = (handle: string) => getJSON<ArtistInfo>("/local/artist?" + new URLSearchParams({ handle }));
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes"); export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
export const me = () => getJSON<Me>("/local/me"); export const me = () => getJSON<Me>("/local/me");
@@ -107,7 +133,8 @@ export const PL8 = "https://pl8list.com";
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`; export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`; export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
// Images come through our server, so pl8list never sees the visitor's address. // Images come through our server, so pl8list never sees the visitor's address.
export const artistImg = (t: Track) => (t.artist_img ? `/img/${encodeURIComponent(t.artist_img)}` : null); export const imgUrl = (id: string | null) => (id ? `/img/${encodeURIComponent(id)}` : null);
export const artistImg = (t: Track) => imgUrl(t.artist_img);
export function streamUrl(t: Track): string { export function streamUrl(t: Track): string {
const p = new URLSearchParams(); const p = new URLSearchParams();
+259
View File
@@ -0,0 +1,259 @@
// The liquid artwork drop: one WebGL2 canvas, moved into the playing card's art slot.
// Browsers allow only a handful of WebGL contexts, so there is one for the whole
// page; cards around it show their artwork as a plain image.
import type { Features } from "../features";
import { icosphere } from "./mesh";
import { FRAGMENT, VERTEX } from "./shaders";
import type { Look } from "./style";
const FOV = (30 * Math.PI) / 180;
/// The canvas is this much larger than the art slot, so the liquid can spill out.
export const SPILL = 1.5;
const DIST = SPILL / Math.tan(FOV / 2);
const MORPH_IN = 0.7; // s from flat artwork to liquid
const TEXTURE_SIZE = 512;
const SLOW_FRAME = 0.02; // s; frames slower than this for SLOW_FOR …
const SLOW_FOR = 2; // … seconds step the quality down
/// Quality steps: icosphere subdivisions and resolution scale.
const QUALITY: [number, number][] = [[5, 1], [4, 1], [4, 0.6], [3, 0.5]];
const UNIFORMS = [
"uProj", "uDist", "uTime", "uMorph", "uLevel", "uWobble", "uKick", "uKickAge", "uSnare", "uDrop",
"uGain", "uSpike", "uStep", "uTwist", "uRipple", "uShock", "uPointer",
"uArt", "uAccent", "uHue", "uIri", "uMetal", "uHeat",
] as const;
export class LiquidBlob {
readonly canvas = document.createElement("canvas");
/// Called once if the GPU drops the context; the page then falls back to plain art.
onLost: (() => void) | null = null;
private gl: WebGL2RenderingContext;
private u = {} as Record<(typeof UNIFORMS)[number], WebGLUniformLocation | null>;
private vao: WebGLVertexArrayObject;
private buffers: WebGLBuffer[] = [];
private count = 0;
private tex: WebGLTexture;
private quality: number;
private look: Look | null = null;
private accent: [number, number, number] = [1, 0.3, 0.43];
private ready = false;
private gen = 0;
private morph = 0;
private time = 0;
private pointer = [0, 0, 1, 0];
private pointerGoal = [0, 0, 1, 0];
private slow = 0;
/// Null where WebGL2 is not available.
static create(touch: boolean): LiquidBlob | null {
try {
return new LiquidBlob(touch);
} catch (err) {
console.warn("liquid visuals off:", (err as Error).message);
return null;
}
}
private constructor(private touch: boolean) {
const gl = this.canvas.getContext("webgl2", { antialias: true, premultipliedAlpha: true });
if (!gl) throw new Error("no WebGL2");
this.gl = gl;
this.canvas.className = "liquid-canvas";
this.canvas.addEventListener("webglcontextlost", () => {
this.ready = false;
this.onLost?.();
});
const prog = gl.createProgram();
gl.attachShader(prog, this.shader(gl.VERTEX_SHADER, VERTEX));
gl.attachShader(prog, this.shader(gl.FRAGMENT_SHADER, FRAGMENT));
gl.bindAttribLocation(prog, 0, "position");
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog) || "link failed");
gl.useProgram(prog);
for (const name of UNIFORMS) this.u[name] = gl.getUniformLocation(prog, name);
this.vao = gl.createVertexArray();
this.quality = touch ? 1 : 0;
this.buildMesh();
this.tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.uniform1i(this.u.uArt, 0);
gl.uniformMatrix4fv(this.u.uProj, false, perspective(FOV, 1, 0.1, 20));
gl.uniform1f(this.u.uDist, DIST);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0, 0, 0, 0);
}
private shader(type: number, src: string): WebGLShader {
const gl = this.gl;
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || "compile failed");
return s;
}
private buildMesh(): void {
const gl = this.gl;
const mesh = icosphere(QUALITY[this.quality][0]);
for (const b of this.buffers) gl.deleteBuffer(b);
gl.bindVertexArray(this.vao);
const pos = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pos);
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
const idx = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW);
this.buffers = [pos, idx];
this.count = mesh.indices.length;
}
/// Show a new track in `slot`. Resolves once its artwork is on the GPU, which is
/// when the slot's own image can be hidden.
async mount(slot: HTMLElement, look: Look, art: string | null, initial: string): Promise<boolean> {
if (this.canvas.parentElement !== slot) slot.append(this.canvas);
const gen = ++this.gen;
this.look = look;
this.ready = false;
this.morph = 0;
this.slow = 0;
const source = await artwork(art, initial).catch(() => artwork(null, initial));
if (gen !== this.gen || this.gl.isContextLost()) return false;
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.generateMipmap(gl.TEXTURE_2D);
this.ready = true;
return true;
}
/// Rim colour, as CSS `hsl()` from color.ts or any `#rrggbb`.
setAccent(css: string | null): void {
this.accent = parseColor(css) ?? [1, 0.3, 0.43];
}
/// Pointer over the slot: x, y from the centre in units of the art's radius
/// (y up); `on` false when it left or was lifted.
setPointer(x: number, y: number, on: boolean): void {
const r = Math.hypot(x, y);
const [dx, dy, dz] = r < 1 ? [x, y, Math.sqrt(1 - r * r)] : [x / r, y / r, 0];
this.pointerGoal = [dx, dy, dz, on && r < 1.4 ? 1 : 0];
}
/// Draw one frame. Returns true while it still moves without music (morphing in,
/// settling, following the pointer).
frame(f: Features, dt: number, playing: boolean): boolean {
const look = this.look;
if (!look || !this.ready) return false;
const gl = this.gl;
this.watch(dt);
const scale = Math.min(devicePixelRatio || 1, this.touch ? 1 : 1.5) * QUALITY[this.quality][1];
const w = Math.round(this.canvas.clientWidth * scale), h = Math.round(this.canvas.clientHeight * scale);
if (!w || !h) return false;
if (this.canvas.width !== w || this.canvas.height !== h) {
this.canvas.width = w;
this.canvas.height = h;
}
gl.viewport(0, 0, w, h);
this.morph = Math.min(1, this.morph + dt / MORPH_IN);
const morph = 1 - (1 - this.morph) ** 3;
// The liquid flows with the tempo, faster when the music moves more.
this.time += dt * look.flow * (playing ? 0.6 + 0.8 * f.wobble + 0.6 * f.drop : 0.25);
const k = 1 - Math.exp(-dt / 0.15);
for (let i = 0; i < 4; i++) this.pointer[i] += (this.pointerGoal[i] - this.pointer[i]) * k;
const u = this.u;
gl.uniform1f(u.uTime, this.time);
gl.uniform1f(u.uMorph, morph);
gl.uniform1f(u.uLevel, (f.sub + f.low) / 2);
gl.uniform1f(u.uWobble, f.wobble);
gl.uniform1f(u.uKick, f.kick);
gl.uniform1f(u.uKickAge, f.kickAge);
gl.uniform1f(u.uSnare, f.snare);
gl.uniform1f(u.uDrop, f.drop);
gl.uniform1f(u.uGain, look.gain);
gl.uniform1f(u.uSpike, look.spike);
gl.uniform1f(u.uStep, look.step);
gl.uniform1f(u.uTwist, look.twist);
gl.uniform1f(u.uRipple, look.ripple);
gl.uniform1f(u.uShock, look.shock);
gl.uniform1f(u.uIri, look.iri);
gl.uniform1f(u.uMetal, look.metal);
gl.uniform1f(u.uHeat, look.heat);
gl.uniform1f(u.uHue, look.hue);
gl.uniform3fv(u.uAccent, this.accent);
gl.uniform4fv(u.uPointer, this.pointer);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.bindVertexArray(this.vao);
gl.drawElements(gl.TRIANGLES, this.count, gl.UNSIGNED_SHORT, 0);
return this.morph < 1 || this.pointer[3] > 0.01 || this.pointerGoal[3] > 0;
}
/// Step the quality down when frames keep coming in slow.
private watch(dt: number): void {
this.slow = dt > SLOW_FRAME ? this.slow + dt : Math.max(0, this.slow - dt);
if (this.slow < SLOW_FOR || this.quality >= QUALITY.length - 1) return;
this.slow = 0;
const before = QUALITY[this.quality][0];
this.quality++;
if (QUALITY[this.quality][0] !== before) this.buildMesh();
}
}
/// The artwork cropped square, or a gradient with the artist's initial.
async function artwork(src: string | null, initial: string): Promise<HTMLCanvasElement> {
const c = document.createElement("canvas");
c.width = c.height = TEXTURE_SIZE;
const ctx = c.getContext("2d")!;
if (src) {
const img = new Image();
img.src = src;
await img.decode();
const side = Math.min(img.naturalWidth, img.naturalHeight);
ctx.drawImage(img, (img.naturalWidth - side) / 2, (img.naturalHeight - side) / 2, side, side, 0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
} else {
const g = ctx.createLinearGradient(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
g.addColorStop(0, "#2a2a2e");
g.addColorStop(1, "#111");
ctx.fillStyle = g;
ctx.fillRect(0, 0, TEXTURE_SIZE, TEXTURE_SIZE);
ctx.fillStyle = "#ffffff66";
ctx.font = `700 ${TEXTURE_SIZE * 0.4}px system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(initial, TEXTURE_SIZE / 2, TEXTURE_SIZE / 2);
}
return c;
}
function perspective(fov: number, aspect: number, near: number, far: number): Float32Array {
const f = 1 / Math.tan(fov / 2);
const nf = 1 / (near - far);
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) * nf, -1, 0, 0, 2 * far * near * nf, 0]);
}
/// `hsl(h s% l%)` (as color.ts makes them) or `#rrggbb`, as 0..1 RGB.
function parseColor(css: string | null): [number, number, number] | null {
if (!css) return null;
const hex = css.match(/^#([0-9a-f]{6})$/i);
if (hex) {
const n = parseInt(hex[1], 16);
return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
const m = css.match(/hsl\(\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%/);
if (!m) return null;
const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100;
const a = s * Math.min(l, 1 - l);
const ch = (n: number) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [ch(0), ch(8), ch(4)];
}
+46
View File
@@ -0,0 +1,46 @@
// Unit icosphere: an icosahedron with each triangle split into four, `levels` times.
// Level 5 has 10 242 vertices, which still fits 16-bit indices.
export interface Mesh {
positions: Float32Array<ArrayBuffer>;
indices: Uint16Array<ArrayBuffer>;
}
export function icosphere(levels: number): Mesh {
const t = (1 + Math.sqrt(5)) / 2;
const verts: number[][] = [
[-1, t, 0], [1, t, 0], [-1, -t, 0], [1, -t, 0],
[0, -1, t], [0, 1, t], [0, -1, -t], [0, 1, -t],
[t, 0, -1], [t, 0, 1], [-t, 0, -1], [-t, 0, 1],
].map(unit);
let faces = [
[0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11],
[1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8],
[3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9],
[4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1],
];
for (let l = 0; l < levels; l++) {
// Each edge is split once, shared by the two faces on it.
const mids = new Map<string, number>();
const mid = (a: number, b: number) => {
const key = a < b ? `${a},${b}` : `${b},${a}`;
let i = mids.get(key);
if (i === undefined) {
const [p, q] = [verts[a], verts[b]];
i = verts.push(unit([p[0] + q[0], p[1] + q[1], p[2] + q[2]])) - 1;
mids.set(key, i);
}
return i;
};
faces = faces.flatMap(([a, b, c]) => {
const ab = mid(a, b), bc = mid(b, c), ca = mid(c, a);
return [[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]];
});
}
return { positions: new Float32Array(verts.flat()), indices: new Uint16Array(faces.flat()) };
}
function unit([x, y, z]: number[]): number[] {
const l = Math.hypot(x, y, z);
return [x / l, y / l, z / l];
}
+164
View File
@@ -0,0 +1,164 @@
// GLSL for the liquid artwork drop. The vertex shader moves a unit sphere with
// noise driven by the music; the fragment shader shows the artwork through the
// surface, with a rim in the card's accent colour.
// 3D simplex noise by Ian McEwan and Stefan Gustavson (Ashima Arts), MIT licence:
// https://github.com/ashima/webgl-noise
const NOISE = /* glsl */ `
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec4 permute(vec4 x) { return mod289(((x * 34.0) + 10.0) * x); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
`;
export const VERTEX = /* glsl */ `#version 300 es
precision highp float;
in vec3 position;
uniform mat4 uProj;
uniform float uDist; // camera distance
uniform float uTime; // flow time, runs with the tempo
uniform float uMorph; // 0 flat disc (the static artwork), 1 liquid
// Music, see features.ts.
uniform float uLevel, uWobble, uKick, uKickAge, uSnare, uDrop;
// Character of the track, see style.ts.
uniform float uGain, uSpike, uStep, uTwist, uRipple, uShock;
uniform vec4 uPointer; // direction on the sphere, strength
out vec3 vPos; // view space
out vec3 vNormal; // view space
out vec2 vFlat; // front projection of the resting sphere, for the artwork
${NOISE}
float disp(vec3 p) {
vec3 q = p;
float a = uTwist * (p.y + 0.3 * sin(uTime)) * (0.5 + uWobble);
q.xz = mat2(cos(a), -sin(a), sin(a), cos(a)) * q.xz;
float t = uTime;
float lobes = snoise(q * 1.1 + vec3(0.0, t * 0.35, t * 0.2)) * (0.05 + 0.12 * uLevel + 0.12 * uDrop);
// Terraces with soft edges: stiff, but no torn shards.
float steps = lobes * 10.0;
lobes = mix(lobes, (floor(steps) + smoothstep(0.3, 0.7, fract(steps))) / 10.0, uStep);
float ripples = snoise(q * 3.2 + vec3(t * 1.3, -t, 0.0)) * (0.012 + 0.07 * uWobble) * uRipple;
float n = snoise(q * 2.4 - vec3(0.0, t * 0.6, t * 0.9));
float spikes = pow(max(n, 0.0), 3.0) * (0.06 + 0.35 * uSnare + 0.15 * uDrop) * uSpike;
// After each kick a ring runs from the front of the drop round to the back.
float angle = acos(clamp(p.z, -1.0, 1.0));
float ring = exp(-pow((angle - uKickAge * 6.0) * 2.5, 2.0)) * exp(-uKickAge * 3.0) * 0.14 * uShock;
float swell = uKick * 0.05;
float dent = -0.18 * uPointer.w * exp(-pow(distance(p, uPointer.xyz) * 2.2, 2.0));
return clamp((lobes + ripples + spikes + ring + swell) * uGain + dent, -0.3, 0.42);
}
vec3 shape(vec3 p) {
vec3 s = p * (1.0 + disp(p) * uMorph);
s.z *= mix(0.04, 1.0, uMorph);
return s;
}
void main() {
vec3 p = position;
// Normal from two displaced neighbours along the surface.
vec3 t1 = normalize(cross(p, abs(p.y) < 0.99 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0)));
vec3 t2 = cross(p, t1);
const float e = 0.012;
vec3 s = shape(p);
vec3 n = normalize(cross(shape(normalize(p + t1 * e)) - s, shape(normalize(p + t2 * e)) - s));
if (dot(n, p) < 0.0) n = -n;
vPos = s - vec3(0.0, 0.0, uDist);
vNormal = n;
vFlat = p.xy;
gl_Position = uProj * vec4(vPos, 1.0);
}
`;
export const FRAGMENT = /* glsl */ `#version 300 es
precision highp float;
in vec3 vPos;
in vec3 vNormal;
in vec2 vFlat;
uniform sampler2D uArt;
uniform vec3 uAccent;
uniform float uTime, uMorph, uKick, uDrop;
uniform float uHue; // rim hue offset, radians
uniform float uIri; // how much the rim hue drifts
uniform float uMetal; // sharper, brighter highlights
uniform float uHeat; // pulls the rim towards orange
out vec4 outColor;
vec3 hueRotate(vec3 c, float a) {
const vec3 k = vec3(0.57735);
float ca = cos(a);
return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(-vPos);
float fres = pow(1.0 - max(dot(n, v), 0.0), 3.0);
// Looking through the surface bends the artwork like water does.
vec2 bend = n.xy * (0.22 + 0.1 * uDrop) * uMorph;
vec2 uv = 0.5 + vec2(vFlat.x, -vFlat.y) * 0.48 - vec2(bend.x, -bend.y) * 0.5;
vec2 split = n.xy * (0.004 + 0.02 * uKick) * uMorph;
vec3 art = vec3(texture(uArt, uv + split).r, texture(uArt, uv).g, texture(uArt, uv - split).b);
art = mix(art, smoothstep(0.0, 1.0, art), 0.6 * uDrop);
vec3 light = normalize(vec3(-0.5, 0.7, 0.6));
float spec = pow(max(dot(n, normalize(light + v)), 0.0), mix(40.0, 140.0, uMetal)) * mix(0.35, 0.8, uMetal);
vec3 rim = hueRotate(uAccent, uHue + uIri * (fres * 3.0 + uTime * 0.3));
rim = mix(rim, vec3(1.0, 0.35, 0.1), uHeat * 0.6);
vec3 col = art * mix(1.0, 0.45, fres * uMorph);
col += rim * fres * (0.6 + 0.7 * uKick + 0.4 * uDrop) * uMorph;
col += spec * uMorph;
outColor = vec4(col, 1.0);
}
`;
+61
View File
@@ -0,0 +1,61 @@
// The character of the drop for one track, from its tempo, tags and key. Each
// field is a shader uniform; tune here.
import type { Track } from "../api";
export interface Look {
/// Flow speed of the noise.
flow: number;
/// Overall size of the movement.
gain: number;
/// Spikes on the snare.
spike: number;
/// 0 smooth, 1 stepped (stiff, quantised) lobes.
step: number;
twist: number;
/// Fast ripples from the wobble.
ripple: number;
/// Ring that runs over the surface on each kick.
shock: number;
/// Rim hue drift.
iri: number;
metal: number;
heat: number;
/// Rim hue offset from the key, radians.
hue: number;
}
const BASE: Look = { flow: 1, gain: 1, spike: 0.5, step: 0, twist: 0.15, ripple: 1, shock: 1, iri: 0.1, metal: 0.3, heat: 0, hue: 0 };
const TAGS: Record<string, (l: Look) => void> = {
riddim: (l) => Object.assign(l, { step: 0.8, metal: 0.9, shock: 1.4, spike: 0.6, ripple: 0.7 }),
tearout: (l) => Object.assign(l, { spike: 1.4, gain: l.gain * 1.25, heat: 0.7 }),
deathstep: (l) => Object.assign(l, { spike: 1.5, gain: l.gain * 1.25, heat: 0.9, metal: 0.7 }),
melodic: (l) => Object.assign(l, { iri: 0.8, spike: 0.15, gain: l.gain * 0.9, flow: l.flow * 0.8, metal: 0.1 }),
colourbass: (l) => Object.assign(l, { iri: 1, spike: 0.25, metal: 0.15 }),
dnb: (l) => Object.assign(l, { ripple: 1.6 }),
"170": (l) => Object.assign(l, { ripple: 1.5 }),
halftime: (l) => Object.assign(l, { flow: l.flow * 0.7, gain: l.gain * 1.15 }),
wonk: (l) => Object.assign(l, { twist: 0.9 }),
};
export function lookFor(t: Track): Look {
// About one noise cycle per bar.
const l: Look = { ...BASE, flow: ((t.bpm || 140) / 240) * 0.9 };
for (const tag of t.tags ?? []) TAGS[tag]?.(l);
l.hue = keyHue(t.key);
return l;
}
const PITCH: Record<string, number> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
/// Keys a fifth apart get neighbouring hues: up to ±26° around the accent colour.
function keyHue(key: string | null): number {
const m = key?.trim().match(/^([a-g])\s*([#♯b♭]?)\s*(.*)$/i);
if (!m) return 0;
let pc = PITCH[m[1].toLowerCase()] + (/[#♯]/.test(m[2]) ? 1 : /[b♭]/.test(m[2]) ? -1 : 0);
// A minor key sits with its relative major.
if (/^(m(?!aj)|min)/i.test(m[3])) pc += 3;
const fifths = (((pc * 7) % 12) + 12) % 12;
return (fifths / 12 - 0.5) * 0.9;
}
+17 -1
View File
@@ -31,14 +31,18 @@ export class Card {
private dragging = false; private dragging = false;
private accent: string | null = null; private accent: string | null = null;
/// `station`: the artist radio this card was picked for, if any.
constructor( constructor(
readonly track: Track, readonly track: Track,
private actions: CardActions, private actions: CardActions,
{ liked = false } = {}, { liked = false, station = undefined as string | undefined } = {},
) { ) {
this.liked = liked; this.liked = liked;
this.station = station;
} }
private station: string | undefined;
element(): HTMLElement { element(): HTMLElement {
return this.el ?? this.build(); return this.el ?? this.build();
} }
@@ -70,6 +74,7 @@ export class Card {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase(); $(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
} }
$(".why").textContent = this.whyText();
$(".title").textContent = cleanTitle(t.title); $(".title").textContent = cleanTitle(t.title);
const artist = $<HTMLAnchorElement>(".artist"); const artist = $<HTMLAnchorElement>(".artist");
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : ""); artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
@@ -112,6 +117,17 @@ export class Card {
return el; return el;
} }
/// Where a radio track comes from, e.g. "Lower radio · via Motus".
private whyText(): string {
const { station } = this;
const why = this.track.why;
if (!station) return "";
if (!why) return `${station} radio`;
if (why.kind === "seed") return `${station} radio`;
if (why.kind === "related") return `${station} radio · related artist`;
return `${station} radio · via ${why.name}`;
}
showLike(pop = false): void { showLike(pop = false): void {
const btn = this.el?.querySelector(".like"); const btn = this.el?.querySelector(".like");
if (!btn) return; if (!btn) return;
+38
View File
@@ -0,0 +1,38 @@
// `?debug=audio`: live bars of what features.ts hears, for tuning its constants by
// ear. Loaded only when asked for.
import type { Features } from "./features";
const KEYS: (keyof Features)[] = ["sub", "low", "mid", "high", "kick", "snare", "wobble", "drop"];
/// Onsets counted since the overlay opened; also on `window.audioDebug` for scripts.
const counts = { kicks: 0, snares: 0, frames: 0 };
export function audioDebug(): (f: Features) => void {
(window as unknown as { audioDebug: typeof counts }).audioDebug = counts;
let lastAge = 99, lastSnare = 0;
const canvas = document.createElement("canvas");
canvas.className = "audio-debug";
const w = (canvas.width = 240), h = (canvas.height = 124);
document.body.append(canvas);
const ctx = canvas.getContext("2d")!;
ctx.font = "10px system-ui, sans-serif";
const bar = w / KEYS.length;
return (f) => {
counts.frames++;
if (f.kickAge < lastAge) counts.kicks++;
if (f.snare > lastSnare + 0.5) counts.snares++;
lastAge = f.kickAge;
lastSnare = f.snare;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = "#fff9";
ctx.fillText(`kicks ${counts.kicks} · snares ${counts.snares}`, 4, 11);
KEYS.forEach((k, i) => {
const v = Math.min(1, Math.max(0, f[k]));
ctx.fillStyle = k === "kick" || k === "snare" ? "#ff4d6d" : k === "drop" ? "#ffb02e" : "#7fd4ff";
ctx.fillRect(i * bar + 3, h - 14 - v * (h - 30), bar - 6, v * (h - 30));
ctx.fillStyle = "#fff9";
ctx.fillText(k, i * bar + 3, h - 3);
});
};
}
+189
View File
@@ -0,0 +1,189 @@
// What the music is doing right now, in a form visuals can move to.
//
// Loudness alone is useless for bass music: in a dubstep or riddim drop the sub is
// loud and steady, so a loudness meter sits at its maximum and nothing moves. The
// movement is in the changes, so besides slow band levels this tracks onsets (kick,
// snare), the wobble of the mids, and drops (the low end jumping well above where
// it has been).
//
// All constants that shape the feel are here, in one place, for tuning by ear.
export interface Features {
/// Band levels, 0..1, against the recent range of each band. For slow breathing.
sub: number;
low: number;
mid: number;
high: number;
/// Onset envelopes: 1 on a hit, then decaying.
kick: number;
snare: number;
/// Seconds since the last kick; large when there was none.
kickAge: number;
/// How much the mids move (wobble bass, growls), 0..1.
wobble: number;
/// 0..1, high while the low end sits well above its recent average.
drop: number;
}
const BANDS = { sub: [30, 90], low: [90, 250], mid: [250, 2000], high: [2000, 10000] } as const;
type Band = keyof typeof BANDS;
const SILENCE_DB = -110;
const CEIL_FALL = 4; // dB/s; a band's ceiling follows its peaks down this slowly
const FLOOR_RISE = 3; // dB/s; and its floor follows the quiet parts up
const MIN_RANGE = 14; // dB; below this a band counts as flat, not as full scale
const ONSET_WINDOW = 0.75; // s of flux history the threshold adapts to
const ONSET_SIGMA = 1.5; // a hit is this many deviations above the mean flux
const ONSET_MIN_FLUX = 1.2; // dB of mean rise per bin; quieter changes are noise
const ONSET_REFRACTORY = 0.11; // s
const KICK_DECAY = 0.16; // s
const SNARE_DECAY = 0.12; // s
const WOBBLE_SMOOTH = 0.12; // s
const DROP_SHORT = 1; // s of low-end loudness …
const DROP_LONG = 12; // … against this much
const DROP_HEARD_DB = -70; // the low end counts as sounding above this
const DROP_RISE_DB = 2.5; // dB above the long average where a drop starts
const DROP_FULL_DB = 5; // dB above the start for a full drop
const DROP_ATTACK = 0.25; // s
const DROP_RELEASE = 2; // s
const ease = (dt: number, tau: number) => 1 - Math.exp(-dt / tau);
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/// Maps a band's dB level into its recent range.
class Range {
private floor = -60;
private ceil = -30;
update(db: number, dt: number): number {
this.ceil = db > this.ceil ? db : this.ceil - CEIL_FALL * dt;
this.floor = db < this.floor ? db : this.floor + FLOOR_RISE * dt;
const span = Math.max(MIN_RANGE, this.ceil - this.floor);
return clamp01((db - (this.ceil - span)) / span);
}
}
/// Spectral flux against its own running mean and spread.
class Onset {
private mean = 0;
private variance = 1;
private last = -1;
env = 0;
age = 99;
constructor(private decay: number) {}
update(flux: number, now: number, dt: number): void {
const threshold = this.mean + ONSET_SIGMA * Math.sqrt(this.variance);
const hit = flux > threshold && flux > ONSET_MIN_FLUX && now - this.last > ONSET_REFRACTORY;
const a = ease(dt, ONSET_WINDOW);
const d = flux - this.mean;
this.mean += a * d;
this.variance += a * (d * d - this.variance);
this.age += dt;
if (hit) {
this.last = now;
this.env = 1;
this.age = 0;
} else {
this.env *= Math.exp(-dt / this.decay);
}
}
}
export class FeatureTracker {
readonly f: Features = { sub: 0, low: 0, mid: 0, high: 0, kick: 0, snare: 0, kickAge: 99, wobble: 0, drop: 0 };
private bins: Float32Array<ArrayBuffer> | null = null;
private prev: Float32Array<ArrayBuffer> | null = null;
private ranges = { sub: new Range(), low: new Range(), mid: new Range(), high: new Range() };
private kick = new Onset(KICK_DECAY);
private snare = new Onset(SNARE_DECAY);
private lastMid = 0;
private wobblePeak = 1;
private short = SILENCE_DB;
private long = SILENCE_DB;
/// Seconds of sound heard so far: the long average covers no more than that, so
/// music that starts in the middle of a drop is the baseline, not a drop.
private heard = 0;
/// One frame from the analyser.
fromAnalyser(an: AnalyserNode, now: number, dt: number): Features {
const n = an.frequencyBinCount;
if (this.bins?.length !== n) {
this.bins = new Float32Array(n);
this.prev = new Float32Array(n).fill(SILENCE_DB);
}
const bins = this.bins, prev = this.prev!;
an.getFloatFrequencyData(bins);
const hz = an.context.sampleRate / an.fftSize;
const bin = (f: number) => Math.min(n - 1, Math.max(1, Math.round(f / hz)));
const db: Record<Band, number> = { sub: 0, low: 0, mid: 0, high: 0 };
const flux: Record<Band, number> = { sub: 0, low: 0, mid: 0, high: 0 };
for (const band of Object.keys(BANDS) as Band[]) {
const [lo, hi] = BANDS[band];
const a = bin(lo), b = Math.max(a, bin(hi));
let power = 0, rise = 0;
for (let i = a; i <= b; i++) {
const v = Math.max(SILENCE_DB, bins[i]);
power += 10 ** (v / 10);
rise += Math.max(0, v - prev[i]);
prev[i] = v;
}
const count = b - a + 1;
db[band] = Math.max(SILENCE_DB, 10 * Math.log10(power / count));
flux[band] = rise / count;
}
const f = this.f;
for (const band of Object.keys(BANDS) as Band[]) f[band] = this.ranges[band].update(db[band], dt);
this.kick.update((flux.sub + flux.low) / 2, now, dt);
this.snare.update((flux.mid + flux.high) / 2, now, dt);
f.kick = this.kick.env;
f.kickAge = this.kick.age;
f.snare = this.snare.env;
this.track(f.mid, Math.max(db.sub, db.low), dt);
return f;
}
/// One frame where there is no analyser: only the loudness from the waveform.
fromLoudness(level: number, dt: number): Features {
const f = this.f;
f.sub = f.low = f.mid = f.high = level;
f.kick = f.snare = 0;
f.kickAge = 99;
this.track(level, level > 0 ? 20 * Math.log10(level) : SILENCE_DB, dt);
return f;
}
/// Nothing playing: let everything settle.
idle(dt: number): Features {
const f = this.f;
const k = Math.exp(-dt / 0.35);
f.sub *= k; f.low *= k; f.mid *= k; f.high *= k;
f.kick *= k; f.snare *= k; f.wobble *= k; f.drop *= k;
f.kickAge += dt;
return f;
}
/// Wobble from how fast the mids move, drop from the low end against its past.
private track(mid: number, lowDb: number, dt: number): void {
const f = this.f;
const speed = Math.abs(mid - this.lastMid) / Math.max(dt, 1 / 240);
this.lastMid = mid;
this.wobblePeak = Math.max(1, speed, this.wobblePeak * Math.exp(-dt / 4));
f.wobble += (clamp01(speed / this.wobblePeak) - f.wobble) * ease(dt, WOBBLE_SMOOTH);
if (this.heard === 0) {
if (lowDb < DROP_HEARD_DB) return;
this.short = this.long = lowDb;
}
this.heard += dt;
this.short += (lowDb - this.short) * ease(dt, DROP_SHORT);
this.long += (lowDb - this.long) * ease(dt, Math.min(DROP_LONG, DROP_SHORT + this.heard));
const target = clamp01((this.short - this.long - DROP_RISE_DB) / DROP_FULL_DB);
f.drop += (target - f.drop) * ease(dt, target > f.drop ? DROP_ATTACK : DROP_RELEASE);
}
}
+70 -9
View File
@@ -5,7 +5,8 @@ import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
import { Card, type CardActions, cleanTitle } from "./cards"; import { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager"; import { Pager } from "./pager";
import { Player } from "./player"; import { Player } from "./player";
import { Visualizer } from "./visual"; import { Station } from "./station";
import { type VisualsSetting, Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end const PREFETCH_AHEAD = 4; // fetch more when this close to the end
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
@@ -34,9 +35,10 @@ const toastEl = $("toast");
const loginEl = $("login"); const loginEl = $("login");
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches; const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
const radioEnd = $("radio-end");
const player = new Player(); const player = new Player();
const visual = new Visualizer(player); const visual = new Visualizer(player, isTouch);
const cards: Card[] = []; const cards: Card[] = [];
let active = -1; let active = -1;
let loading = false; let loading = false;
@@ -50,6 +52,8 @@ let preloadTimer = 0;
let filters: Filters = loadFilters(); let filters: Filters = loadFilters();
let mode: Mode = loadMode(); let mode: Mode = loadMode();
let stats: Stats | null = null; let stats: Stats | null = null;
let jumpOnLoad = false; // a radio just started: go to its first track as soon as it is here
let radioDry = false; // the artist radio found nothing more; say so on its last card
const current = () => cards[active] as Card | undefined; const current = () => cards[active] as Card | undefined;
@@ -77,18 +81,25 @@ async function loadMore(): Promise<void> {
if (gen !== feedGen) return; if (gen !== feedGen) return;
showStats(data.stats); showStats(data.stats);
if (!data.tracks.length) { if (!data.tracks.length) {
setStatus(mode.mode === "artist" jumpOnLoad = false;
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.` if (mode.mode === "artist") {
: mode.mode !== "classic" || Object.keys(filters).length radioDry = true;
updateRadioEnd();
} else setStatus(mode.mode !== "classic" || Object.keys(filters).length
? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit." ? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit."
: "nothing new right now, the crawler is still digging. try again in a bit."); : "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000); setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
return; return;
} }
setStatus(""); setStatus("");
cards.push(...data.tracks.map((t) => new Card(t, actions))); radioDry = false;
updateRadioEnd();
const station = mode.mode === "artist" ? mode.name : undefined;
cards.push(...data.tracks.map((t) => new Card(t, actions, { station })));
pager.refresh(); pager.refresh();
if (active < 0) activate(0); if (active < 0) activate(0);
else if (jumpOnLoad) go(active + 1);
jumpOnLoad = false;
} catch (err) { } catch (err) {
if (gen !== feedGen) return; if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) { if (err instanceof api.HttpError && err.status === 401) {
@@ -147,6 +158,7 @@ function activate(idx: number): void {
if (next && started) player.preload(next.track); if (next && started) player.preload(next.track);
}, PRELOAD_AFTER_MS); }, PRELOAD_AFTER_MS);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore(); if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
updateRadioEnd();
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); }); cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
} }
@@ -172,7 +184,7 @@ const actions: CardActions = {
share: (c) => share(c), share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1), next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed), follow: (c) => setFollow(c, !c.track.followed),
radio: (c) => setMode({ mode: "artist", artist: c.track.handle, name: c.track.artist_name }), radio: (c) => station.open(c.track.handle, c.track.artist_name, api.artistImg(c.track)),
canSeek: (c) => c === current() && !!player.audio.duration, canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0, duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s), seek: (_, s) => player.seek(s),
@@ -504,13 +516,21 @@ function loadMode(): Mode {
const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]); const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
const MODE_ICONS = { riddim: "◆", fresh: "✦", deep: "◎" };
function showMode(): void { function showMode(): void {
modeChip.hidden = mode.mode === "classic"; modeChip.hidden = mode.mode === "classic";
modeChip.textContent = `${modeName(mode)} ×`; modeChip.querySelector(".chip-label")!.textContent = modeName(mode);
const avatar = modeChip.querySelector<HTMLElement>(".chip-avatar")!;
const img = mode.mode === "artist" ? mode.img : null;
avatar.style.backgroundImage = img ? `url("${img}")` : "";
avatar.textContent = img ? "" : mode.mode === "artist" ? mode.name[0]?.toUpperCase() ?? "" : mode.mode === "classic" ? "" : MODE_ICONS[mode.mode];
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode; for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
} }
function setMode(m: Mode): void { function setMode(m: Mode): void {
radioDry = false;
updateRadioEnd();
if (JSON.stringify(m) === JSON.stringify(mode)) return; if (JSON.stringify(m) === JSON.stringify(mode)) return;
mode = m; mode = m;
localStorage.setItem(MODE_KEY, JSON.stringify(mode)); localStorage.setItem(MODE_KEY, JSON.stringify(mode));
@@ -521,15 +541,53 @@ function setMode(m: Mode): void {
} }
modeChip.addEventListener("click", () => setMode({ mode: "classic" })); modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
const station = new Station((m) => {
if (!started) start();
jumpOnLoad = true;
setMode(m);
});
/// On the last card of a radio that has played everything known around its artist.
function updateRadioEnd(): void {
const m = mode;
radioEnd.hidden = !(radioDry && m.mode === "artist" && active >= cards.length - 1);
if (radioEnd.hidden || m.mode !== "artist") return;
const text = radioEnd.querySelector("p")!;
const next = $("radio-end-next");
text.textContent = `that's all around ${m.name} for now. the crawler keeps looking.`;
// Hand over to the artist playing now, if the radio wandered away from its seed.
const card = current();
const other = card && card.track.handle !== m.artist ? card.track : null;
next.hidden = !other;
if (other) {
next.textContent = `${other.artist_name} radio`;
next.onclick = () => station.open(other.handle, other.artist_name, api.artistImg(other));
}
}
$("radio-end-feed").addEventListener("click", () => setMode({ mode: "classic" }));
filtersForm.addEventListener("change", (e) => { filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" }); if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
if (input.name === "visuals") setVisuals(input.value as VisualsSetting);
}); });
function setVisuals(v: VisualsSetting): void {
visual.visuals = v;
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=visuals]")) input.checked = input.value === v;
}
setVisuals(visual.visuals);
showMode(); showMode();
filtersBtn.addEventListener("click", () => togglePanel(filtersForm)); filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); }); filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); }); $("filters-clear").addEventListener("click", () => {
filtersForm.reset();
// reset() also clears the mode and visuals radios, which "clear" leaves alone.
showMode();
setVisuals(visual.visuals);
applyFilters();
});
showFilters(); showFilters();
// Keyboard -------------------------------------------------------------- // Keyboard --------------------------------------------------------------
@@ -540,8 +598,10 @@ document.addEventListener("keydown", (e) => {
if (k === "Escape") { if (k === "Escape") {
closePanels(); closePanels();
hideHelp(); hideHelp();
station.close();
return; return;
} }
if (station.isOpen) return;
if (!loginEl.hidden) return; if (!loginEl.hidden) return;
if (!started) { if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); } if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
@@ -559,6 +619,7 @@ document.addEventListener("keydown", (e) => {
else if (k === "r" && card) actions.radio(card); else if (k === "r" && card) actions.radio(card);
else if (k === "x" && card) dislike(card); else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card); else if (k === "s" && card) share(card);
else if (k === "v") { setVisuals(visual.visuals === "liquid" ? "simple" : "liquid"); toast(`${visual.visuals} visuals`); }
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); } else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); } else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
}); });
+2 -2
View File
@@ -148,8 +148,8 @@ export class Player {
return; return;
} }
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = 1024; analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.7; analyser.smoothingTimeConstant = 0.2; // features.ts smooths per feature; this only takes the edge off the FFT
analyser.connect(ctx.destination); analyser.connect(ctx.destination);
for (const d of this.decks) { for (const d of this.decks) {
const gain = ctx.createGain(); const gain = ctx.createGain();
+155
View File
@@ -0,0 +1,155 @@
// The radio sheet: who an artist radio is built around and who is next to them.
// Opening it changes nothing yet; "start radio" does. Tapping a neighbour shows
// their sheet, so the artist graph can be walked before picking a station.
//
// A new artist may have no neighbours yet. The server then starts crawling them
// and says `exploring`; the sheet asks again every few seconds and fades new ones in.
import * as api from "./api";
import type { ArtistInfo, Mode } from "./api";
const POLL_MS = 2000;
const POLL_FOR_MS = 20000;
interface Shown {
handle: string;
name: string;
img: string | null;
}
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
export class Station {
private root = $("station");
private nameEl = $("station-name");
private subEl = $("station-sub");
private tagsEl = $("station-tags");
private avatar = this.root.querySelector<HTMLElement>(".station-avatar")!;
private list = $("station-neighbours");
private exploringEl = $("station-exploring");
private startBtn = $<HTMLButtonElement>("station-start");
private backBtn = $<HTMLButtonElement>("station-back");
private shown: Shown | null = null;
/// Artists walked through to get here, for the back button.
private trail: Shown[] = [];
private gen = 0;
private timer = 0;
private known = new Set<string>();
constructor(private start: (m: Mode) => void) {
$("station-close").addEventListener("click", () => this.close());
this.root.addEventListener("click", (e) => { if (e.target === this.root) this.close(); });
this.backBtn.addEventListener("click", () => {
const prev = this.trail.pop();
if (prev) this.show(prev);
});
this.startBtn.addEventListener("click", () => {
const s = this.shown;
if (!s) return;
this.close();
this.start({ mode: "artist", artist: s.handle, name: s.name, img: s.img });
});
}
get isOpen(): boolean {
return !this.root.hidden;
}
open(handle: string, name: string, img: string | null): void {
this.trail = [];
this.root.hidden = false;
this.show({ handle, name, img });
this.startBtn.focus();
}
close(): void {
this.root.hidden = true;
this.gen++;
clearTimeout(this.timer);
}
private show(s: Shown): void {
this.shown = s;
this.known.clear();
this.list.replaceChildren();
this.tagsEl.replaceChildren();
this.nameEl.textContent = s.name;
this.subEl.textContent = "";
this.setAvatar(this.avatar, s.img, s.name);
this.startBtn.lastChild!.textContent = ` start ${s.name} radio`;
const prev = this.trail[this.trail.length - 1];
this.backBtn.hidden = !prev;
if (prev) this.backBtn.textContent = `← ${prev.name}`;
this.exploringEl.hidden = true;
const gen = ++this.gen;
clearTimeout(this.timer);
this.load(s.handle, gen, performance.now() + POLL_FOR_MS);
}
private async load(handle: string, gen: number, until: number): Promise<void> {
let info: ArtistInfo;
try {
info = await api.artist(handle);
} catch (err) {
if (gen === this.gen) this.subEl.textContent = (err as Error).message;
return;
}
if (gen !== this.gen) return;
this.render(info);
const again = info.exploring && performance.now() < until;
this.exploringEl.hidden = !again;
if (again) this.timer = window.setTimeout(() => this.load(handle, gen, until), POLL_MS);
else if (!info.neighbours.length) this.list.textContent = "nobody linked to them yet. the radio plays their own tracks and fills up as more are found.";
}
private render(info: ArtistInfo): void {
const { artist, neighbours, tags } = info;
const s = this.shown!;
if (!s.img && artist.img) {
s.img = api.imgUrl(artist.img);
this.setAvatar(this.avatar, s.img, s.name);
}
const count = neighbours.length;
this.subEl.textContent =
`${artist.tracks} track${artist.tracks === 1 ? "" : "s"} here` +
(count ? ` · plus ${count}${count >= 12 ? "+" : ""} related artist${count === 1 ? "" : "s"}` : "");
this.tagsEl.replaceChildren(...tags.map((t) => {
const span = document.createElement("span");
span.textContent = t;
return span;
}));
if (count && this.list.firstChild?.nodeType === Node.TEXT_NODE) this.list.replaceChildren();
for (const n of neighbours) {
if (this.known.has(n.handle)) continue;
this.known.add(n.handle);
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.title = `${n.name}'s radio`;
const av = document.createElement("span");
av.className = "station-avatar small";
const img = api.imgUrl(n.img);
this.setAvatar(av, img, n.name);
const label = document.createElement("span");
label.textContent = n.name;
btn.append(av, label);
btn.addEventListener("click", () => {
this.trail.push(this.shown!);
this.show({ handle: n.handle, name: n.name, img });
});
li.append(btn);
this.list.append(li);
}
}
private setAvatar(el: HTMLElement, img: string | null, name: string): void {
el.style.backgroundImage = img ? `url("${img}")` : "";
let initial = el.querySelector(".initial");
if (!initial) {
initial = document.createElement("span");
initial.className = "initial";
el.append(initial);
}
initial.textContent = img ? "" : (name || "?")[0].toUpperCase();
}
}
+166 -4
View File
@@ -83,12 +83,25 @@ button {
} }
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); } #bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip { #mode-chip {
max-width: 12rem; max-width: 14rem;
overflow: hidden; display: inline-flex;
text-overflow: ellipsis; align-items: center;
gap: 0.45em;
padding-left: 0.3em;
white-space: nowrap; white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent); background: color-mix(in oklab, var(--accent) 40%, transparent);
} }
#mode-chip .chip-label { overflow: hidden; text-overflow: ellipsis; }
.chip-avatar {
flex: none;
width: 1.5em;
height: 1.5em;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.8em;
background: #0006 center / cover no-repeat;
}
.panel { .panel {
position: fixed; position: fixed;
@@ -226,7 +239,8 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
position: relative; position: relative;
width: min(16rem, 42vh, 70vw); width: min(16rem, 42vh, 70vw);
aspect-ratio: 1; aspect-ratio: 1;
border-radius: 14px; /* Round, so the static artwork matches the liquid drop (blob/) it turns into. */
border-radius: 50%;
background: #222 center / cover no-repeat; background: #222 center / cover no-repeat;
box-shadow: box-shadow:
0 20px 60px #000a, 0 20px 60px #000a,
@@ -240,6 +254,28 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
} }
.initial { font-size: 4rem; font-weight: 700; color: #fff6; } .initial { font-size: 4rem; font-weight: 700; color: #fff6; }
/* The liquid drop spills past the art slot; taps go through it to the card. */
.liquid-canvas {
position: absolute;
inset: -25%;
width: 150%;
height: 150%;
pointer-events: none;
}
.card.liquid .art { background-image: none !important; background-color: transparent; box-shadow: none; scale: 1; }
.card.liquid .initial { display: none; }
.paused-badge, .spinner { z-index: 1; }
.audio-debug {
position: fixed;
left: 0.7rem;
bottom: 0.7rem;
z-index: 30;
border-radius: var(--radius-s);
background: #000b;
pointer-events: none;
}
.card.paused .art { transform: scale(0.96); filter: brightness(0.7); } .card.paused .art { transform: scale(0.96); filter: brightness(0.7); }
.paused-badge { .paused-badge {
position: absolute; position: absolute;
@@ -282,6 +318,15 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
overflow-wrap: anywhere; overflow-wrap: anywhere;
text-wrap: balance; text-wrap: balance;
} }
.why {
margin: 0;
min-height: 0;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in oklab, var(--card-accent) 70%, #fff);
}
.why:empty { display: none; }
.byline { display: flex; align-items: center; gap: 0.6rem; } .byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; } .artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; } .artist:hover { text-decoration: underline; }
@@ -450,6 +495,118 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
} }
#start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; } #start button { font-size: 1.3rem; padding: 0.7em 1.6em; background: var(--accent); border: none; color: #fff; }
/* Radio sheet: a bottom sheet on phones, a centred box elsewhere. */
#station {
position: fixed;
inset: 0;
z-index: 26;
display: grid;
place-items: center;
padding: 1rem;
background: #000b;
animation: fade 0.2s ease;
}
.station-box {
position: relative;
width: min(26rem, 100%);
max-height: calc(100dvh - 2rem);
overflow-y: auto;
background: var(--panel);
border: 1px solid #333;
border-radius: var(--radius-l);
padding: 1.4rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
animation: rise 0.35s var(--ease-out);
}
@keyframes rise { from { transform: translateY(2rem); opacity: 0; } }
#station-back { align-self: flex-start; }
#station-close { position: absolute; top: 0.8rem; right: 0.8rem; }
.icon-btn { width: 2.2rem; height: 2.2rem; padding: 0; display: grid; place-items: center; }
.icon-btn svg { width: 1rem; height: 1rem; }
.station-head { display: flex; gap: 1rem; align-items: center; }
.station-head h2 { margin: 0; font: 800 1.4rem/1.1 var(--font-display); overflow-wrap: anywhere; }
.station-kicker { margin: 0 0 0.2rem; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent); }
#station-sub { margin: 0.3rem 0 0.4rem; color: var(--muted); font-size: 0.85rem; }
#station-tags { justify-content: flex-start; }
#station-tags span { background: rgba(255, 255, 255, 0.12); }
.station-avatar {
flex: none;
width: 5.5rem;
height: 5.5rem;
border-radius: 50%;
display: grid;
place-items: center;
background: #222 center / cover no-repeat;
box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 60%, transparent), 0 10px 30px #000a;
}
.station-avatar .initial { font-size: 2rem; }
.station-avatar.small { width: 3.4rem; height: 3.4rem; box-shadow: none; }
.station-avatar.small .initial { font-size: 1.2rem; }
.station-box h3 { margin: 0; font-size: 0.8rem; font-weight: 600; color: var(--muted); }
#station-neighbours {
list-style: none;
margin: 0 -1.4rem;
padding: 0 1.4rem 0.3rem;
display: flex;
gap: 0.7rem;
overflow-x: auto;
scrollbar-width: none;
color: var(--muted);
font-size: 0.85rem;
}
#station-neighbours li { animation: fade 0.5s ease; }
#station-neighbours button {
width: 4.2rem;
padding: 0;
border: none;
background: none;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--fg);
}
#station-neighbours button > span:last-child { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#station-neighbours button:hover .station-avatar { box-shadow: 0 0 0 2px var(--accent); }
#station-exploring { margin: 0; color: var(--muted); font-size: 0.85rem; animation: breathe 1.4s ease-in-out infinite; }
@keyframes breathe { 50% { opacity: 0.35; } }
button.primary {
background: var(--accent);
border: none;
color: #fff;
font-weight: 600;
padding: 0.7em 1.2em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4em;
}
button.primary svg { width: 1em; height: 1em; }
/* The end of an artist radio, over its last card. */
#radio-end {
position: fixed;
z-index: 6;
left: 50%;
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
transform: translateX(-50%);
width: min(24rem, calc(100vw - 2rem));
padding: 1rem;
border-radius: var(--radius-m);
background: var(--panel);
border: 1px solid #333;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
text-align: center;
animation: rise 0.35s var(--ease-out);
}
#radio-end p { flex-basis: 100%; margin: 0 0 0.3rem; color: var(--muted); font-size: 0.9rem; }
#help { #help {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -530,6 +687,11 @@ button.danger:not(.link) { background: var(--accent); border: none; color: #fff;
#help-btn { display: none; } #help-btn { display: none; }
#account-btn { max-width: 6.5rem; } #account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; } .panel { left: 0.7rem; right: 0.7rem; }
#station { place-items: end stretch; padding: 0; }
.station-box { width: 100%; border-radius: var(--radius-l) var(--radius-l) 0 0; padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); }
#mode-chip { max-width: 9rem; }
/* A station chip says more than the logo; there is no room for both. */
#bar:has(#mode-chip:not([hidden])) .logo { display: none; }
#likes { width: auto; } #likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; } .content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
.actions { .actions {
+114 -37
View File
@@ -1,32 +1,103 @@
// Per-frame visuals of the playing card: waveform progress, and a bass level that // Per-frame visuals of the playing card: waveform progress, the liquid artwork drop
// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1). // (blob/), and `--energy` (0..1) for the stylesheet's glow and pulse, which is all
// The level comes from the analyser where audio runs through Web Audio, otherwise // there is where the drop is off or WebGL2 is missing.
// from the track's waveform at the current position. //
// What drives them comes from features.ts: from the analyser where audio runs through
// Web Audio, otherwise from the track's waveform at the current position.
import { artistImg } from "./api";
import { LiquidBlob, SPILL } from "./blob/blob";
import { lookFor } from "./blob/style";
import type { Card } from "./cards"; import type { Card } from "./cards";
import { accentFor } from "./color";
import { type Features, FeatureTracker } from "./features";
import type { Player } from "./player"; import type { Player } from "./player";
export type VisualsSetting = "liquid" | "simple";
const VISUALS_KEY = "pl8inf.visuals";
export class Visualizer { export class Visualizer {
private raf = 0; private raf = 0;
private bins: Uint8Array<ArrayBuffer> | null = null; private last = 0;
private level = 0;
private peak = 0.3;
private card: Card | null = null; private card: Card | null = null;
private features = new FeatureTracker();
/// Undefined until first needed; null where WebGL2 failed.
private blob: LiquidBlob | null | undefined;
private reduced = matchMedia("(prefers-reduced-motion: reduce)"); private reduced = matchMedia("(prefers-reduced-motion: reduce)");
private setting: VisualsSetting = localStorage.getItem(VISUALS_KEY) === "simple" ? "simple" : "liquid";
private debug: ((f: Features) => void) | null = null;
constructor(private player: Player) { constructor(
private player: Player,
private touch: boolean,
) {
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start())); document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
this.reduced.addEventListener("change", () => this.attach(this.card, true));
for (const ev of ["pointermove", "pointerdown"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, true), { passive: true });
for (const ev of ["pointerup", "pointercancel", "pointerleave"]) document.addEventListener(ev, (e) => this.pointer(e as PointerEvent, false), { passive: true });
if (new URLSearchParams(location.search).get("debug") === "audio") {
import("./debug").then((m) => { this.debug = m.audioDebug(); });
}
}
get visuals(): VisualsSetting {
return this.setting;
}
set visuals(v: VisualsSetting) {
this.setting = v;
localStorage.setItem(VISUALS_KEY, v);
this.attach(this.card, true);
}
/// Whether the liquid drop runs: wanted, motion allowed, and WebGL2 works.
private liquid(): LiquidBlob | null {
if (this.setting !== "liquid" || this.reduced.matches || this.blob === null) return null;
if (this.blob === undefined) {
this.blob = LiquidBlob.create(this.touch);
if (this.blob) {
this.blob.onLost = () => {
this.blob?.canvas.remove();
this.blob = null;
this.card?.el?.classList.remove("liquid");
};
}
}
return this.blob;
} }
/// Follow `card` from now on. /// Follow `card` from now on.
attach(card: Card | null): void { attach(card: Card | null, again = false): void {
if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0"); if (this.card === card && !again) return;
if (this.card && this.card !== card) {
this.card.el?.style.setProperty("--energy", "0");
this.card.el?.classList.remove("liquid");
}
this.card = card; this.card = card;
const blob = this.liquid();
const slot = card?.el?.querySelector<HTMLElement>(".art");
if (!card || !slot || !blob) {
this.blob?.canvas.remove();
card?.el?.classList.remove("liquid");
} else {
const img = artistImg(card.track);
blob.setAccent(null);
if (img) accentFor(img).then((c) => { if (this.card === card) blob.setAccent(c); });
blob.mount(slot, lookFor(card.track), img, (card.track.artist_name || "?")[0].toUpperCase()).then((ok) => {
if (ok && this.card === card) {
card.el?.classList.add("liquid");
this.start();
}
});
}
this.start(); this.start();
} }
start(): void { start(): void {
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame); if (!this.raf && !document.hidden) {
this.last = 0;
this.raf = requestAnimationFrame(this.frame);
}
} }
stop(): void { stop(): void {
@@ -34,36 +105,42 @@ export class Visualizer {
this.raf = 0; this.raf = 0;
} }
private frame = () => { /// Pointer over the drop pushes a dent into it.
private pointer(e: PointerEvent, on: boolean): void {
const blob = this.blob;
if (!blob?.canvas.isConnected) return;
const r = blob.canvas.getBoundingClientRect();
const radius = r.width / SPILL / 2;
blob.setPointer((e.clientX - r.left - r.width / 2) / radius, -(e.clientY - r.top - r.height / 2) / radius, on);
if (on) this.start();
}
private frame = (now: number) => {
this.raf = 0; this.raf = 0;
const dt = this.last ? Math.min(0.1, (now - this.last) / 1000) : 1 / 60;
this.last = now;
const card = this.card; const card = this.card;
const a = this.player.audio;
if (!card) return; if (!card) return;
const a = this.player.audio;
const progress = a.duration ? a.currentTime / a.duration : 0; const progress = a.duration ? a.currentTime / a.duration : 0;
card.drawWave(progress); card.drawWave(progress);
if (!this.reduced.matches) { const playing = !a.paused;
const raw = a.paused ? 0 : this.bass(card, progress);
// Fast attack, slow release, against a slowly falling peak: kicks stand out
// the same whether a track is mastered loud or quiet.
this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12);
this.peak = Math.max(this.peak * 0.997, this.level, 0.2);
const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65);
card.el?.style.setProperty("--energy", energy.toFixed(3));
}
// Keep running while playing; one last frame after a pause lets the glow fade.
if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame);
};
private bass(card: Card, progress: number): number {
const an = this.player.analyser; const an = this.player.analyser;
if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8; const mixed = !!an && this.player.mixing;
if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount); const f = !playing
an.getByteFrequencyData(this.bins); ? this.features.idle(dt)
// Bins up to ~150 Hz: kick and sub. : mixed
const hz = an.context.sampleRate / an.fftSize; ? this.features.fromAnalyser(an, now / 1000, dt)
const top = Math.max(2, Math.round(150 / hz)); : this.features.fromLoudness(card.loudnessAt(progress), dt);
let sum = 0; this.debug?.(f);
for (let i = 1; i <= top; i++) sum += this.bins[i]; let busy = playing;
return sum / top / 255; if (!this.reduced.matches) {
} // The kick drives the pulse; without an analyser there are no kicks, only loudness.
const energy = Math.min(1, mixed ? 0.65 * f.kick + 0.35 * f.low : 0.7 * f.low);
card.el?.style.setProperty("--energy", energy.toFixed(3));
busy ||= energy > 0.01;
if (this.blob && card.el?.classList.contains("liquid")) busy = this.blob.frame(f, dt, playing) || busy;
}
if (busy) this.raf = requestAnimationFrame(this.frame);
};
} }