dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

Generated
+60
View File
@@ -165,6 +165,8 @@ version = "0.1.0-alpha.4"
dependencies = [ dependencies = [
"audio-formats", "audio-formats",
"base64 0.22.1", "base64 0.22.1",
"crc32fast",
"flate2",
"image", "image",
"keyring", "keyring",
"library", "library",
@@ -172,6 +174,7 @@ dependencies = [
"metadata", "metadata",
"player", "player",
"quick-xml 0.37.5", "quick-xml 0.37.5",
"realfft",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -2579,6 +2582,15 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -3181,6 +3193,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "1.3.1" version = "1.3.1"
@@ -3376,6 +3397,15 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "realfft"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677"
dependencies = [
"rustfft",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3653,6 +3683,20 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@@ -4150,6 +4194,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.9.0" version = "0.9.0"
@@ -5209,6 +5259,16 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.24.1" version = "0.24.1"
+116 -20
View File
@@ -93,11 +93,12 @@ pub struct PlaylistItem {
pub artist: String, pub artist: String,
pub album: Option<String>, pub album: Option<String>,
pub duration_secs: Option<u32>, pub duration_secs: Option<u32>,
/// The playable local file, when resolution found one. An unresolved /// The playable local file, when resolution found one. A stream or
/// import row keeps its metadata and order with no path at all. /// unresolved row keeps its metadata and order with no path at all.
pub path: Option<String>, pub path: Option<String>,
pub art_path: Option<String>, pub art_path: Option<String>,
/// "local" (has a playable path) | "unresolved" (import placeholder). /// "local" (a file on disk) | "stream" (full identity, plays through the
/// resolver) | "unresolved" (import placeholder awaiting repair).
pub kind: String, pub kind: String,
/// Sanitized provenance: the raw locator or pasted line this row came /// Sanitized provenance: the raw locator or pasted line this row came
/// from. Repair tools read it; nothing ever executes it. /// from. Repair tools read it; nothing ever executes it.
@@ -106,6 +107,11 @@ pub struct PlaylistItem {
/// begins, milliseconds. /// begins, milliseconds.
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub note: Option<String>, pub note: Option<String>,
/// A stream row's identity anchor: the catalog track id (exact id, never
/// a name match — the same idiom as plays.catalog_album_id).
pub track_id: Option<String>,
/// A stream row's cover, which has no local file to be read from.
pub art_url: Option<String>,
} }
/// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`). /// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`).
@@ -117,11 +123,34 @@ pub struct NewPlaylistItem {
pub duration_secs: Option<u32>, pub duration_secs: Option<u32>,
pub path: Option<String>, pub path: Option<String>,
pub art_path: Option<String>, pub art_path: Option<String>,
/// "local" | "unresolved"; empty defaults to "local" when a path exists. /// "local" | "stream" | "unresolved"; empty lets the insert classify:
/// a path is local, a full identity streams, anything weaker is a
/// placeholder (see `pathless_kind`).
pub kind: String, pub kind: String,
pub source_line: Option<String>, pub source_line: Option<String>,
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub note: Option<String>, pub note: Option<String>,
pub track_id: Option<String>,
pub art_url: Option<String>,
}
/// Classify a row that names no local file. A full identity — an artist and
/// a real title, never the DJ unknown-token "ID" — plays through the normal
/// resolver exactly like a catalog track, so it is a first-class 'stream'
/// row. Anything weaker stays an 'unresolved' placeholder for the repair
/// tools. (The same gate the playlist page always used to decide whether a
/// click streams or opens repair.)
fn pathless_kind(artist: &str, title: &str) -> &'static str {
let t = title.trim();
if !artist.trim().is_empty()
&& !t.is_empty()
&& !t.eq_ignore_ascii_case("id")
&& !t.eq_ignore_ascii_case("i.d.")
{
"stream"
} else {
"unresolved"
}
} }
/// One row of the whole-library matching index (playlist import resolution: /// One row of the whole-library matching index (playlist import resolution:
@@ -292,7 +321,9 @@ impl Library {
source_line TEXT, source_line TEXT,
start_ms INTEGER, start_ms INTEGER,
note TEXT, note TEXT,
added_at INTEGER NOT NULL DEFAULT 0 added_at INTEGER NOT NULL DEFAULT 0,
track_id TEXT,
art_url TEXT
); );
CREATE INDEX IF NOT EXISTS idx_playlist_items CREATE INDEX IF NOT EXISTS idx_playlist_items
ON playlist_items(playlist_id, position); ON playlist_items(playlist_id, position);
@@ -392,6 +423,22 @@ impl Library {
"ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0", "ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
[], [],
); );
// Playlist rows split into local vs stream (2026-08-18): a row with a
// full identity and no file is a STREAMING track, not an error state.
// Its anchor is the catalog track id (exact id, never a name match —
// the plays.catalog_album_id idiom) with the cover URL riding along,
// since a stream row has no local file to read artwork from.
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN track_id TEXT", []);
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN art_url TEXT", []);
// Reclassify rows stored before the split: anything 'unresolved' that
// carries an artist and a real title already streamed on click — the
// kind now says so. Idempotent, so it simply runs on every open.
let _ = conn.execute(
"UPDATE playlist_items SET kind = 'stream'
WHERE kind = 'unresolved' AND TRIM(artist) <> '' AND TRIM(title) <> ''
AND LOWER(TRIM(title)) NOT IN ('id', 'i.d.')",
[],
);
migrate_playlist_rows(&mut conn)?; migrate_playlist_rows(&mut conn)?;
Ok(Library { Ok(Library {
conn: Mutex::new(conn), conn: Mutex::new(conn),
@@ -1126,7 +1173,7 @@ impl Library {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, position, title, artist, album, duration_secs, path, art_path, "SELECT id, position, title, artist, album, duration_secs, path, art_path,
kind, source_line, start_ms, note kind, source_line, start_ms, note, track_id, art_url
FROM playlist_items WHERE playlist_id = ?1 ORDER BY position", FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
)?; )?;
let rows = stmt.query_map([playlist_id], |r| { let rows = stmt.query_map([playlist_id], |r| {
@@ -1143,6 +1190,8 @@ impl Library {
source_line: r.get(9)?, source_line: r.get(9)?,
start_ms: r.get(10)?, start_ms: r.get(10)?,
note: r.get(11)?, note: r.get(11)?,
track_id: r.get(12)?,
art_url: r.get(13)?,
}) })
})?; })?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into) rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
@@ -1198,12 +1247,17 @@ impl Library {
let mut ins = tx.prepare( let mut ins = tx.prepare(
"INSERT INTO playlist_items "INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs, (playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at) path, art_path, kind, source_line, start_ms, note, added_at,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", track_id, art_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?; )?;
for (offset, item) in items.iter().enumerate() { for (offset, item) in items.iter().enumerate() {
let kind = if item.kind.is_empty() { let kind = if item.kind.is_empty() {
if item.path.is_some() { "local" } else { "unresolved" } if item.path.is_some() {
"local"
} else {
pathless_kind(&item.artist, &item.title)
}
} else { } else {
&item.kind &item.kind
}; };
@@ -1221,6 +1275,8 @@ impl Library {
item.start_ms, item.start_ms,
item.note, item.note,
now, now,
item.track_id,
item.art_url,
])?; ])?;
} }
} }
@@ -1286,7 +1342,8 @@ impl Library {
} }
/// Repair one row: attach a playable local file (or correct its display /// Repair one row: attach a playable local file (or correct its display
/// metadata). Passing `path` Some flips the row to "local". /// metadata). Passing `path` Some flips the row to "local". `track_id`
/// and `art_url` enrich a stream row's catalog anchor and cover.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn relink_playlist_item( pub fn relink_playlist_item(
&self, &self,
@@ -1298,6 +1355,8 @@ impl Library {
duration_secs: Option<u32>, duration_secs: Option<u32>,
path: Option<&str>, path: Option<&str>,
art_path: Option<&str>, art_path: Option<&str>,
track_id: Option<&str>,
art_url: Option<&str>,
now: i64, now: i64,
) -> Result<()> { ) -> Result<()> {
let mut conn = self.conn.lock().unwrap(); let mut conn = self.conn.lock().unwrap();
@@ -1310,9 +1369,11 @@ impl Library {
duration_secs = COALESCE(?6, duration_secs), duration_secs = COALESCE(?6, duration_secs),
path = COALESCE(?7, path), path = COALESCE(?7, path),
art_path = COALESCE(?8, art_path), art_path = COALESCE(?8, art_path),
track_id = COALESCE(?9, track_id),
art_url = COALESCE(?10, art_url),
kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END
WHERE playlist_id = ?1 AND id = ?2", WHERE playlist_id = ?1 AND id = ?2",
rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path], rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path, track_id, art_url],
)?; )?;
Self::touch_playlist(&tx, playlist_id, now)?; Self::touch_playlist(&tx, playlist_id, now)?;
tx.commit()?; tx.commit()?;
@@ -1337,9 +1398,11 @@ impl Library {
tx.execute( tx.execute(
"INSERT INTO playlist_items "INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs, (playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at) path, art_path, kind, source_line, start_ms, note, added_at,
track_id, art_url)
SELECT ?2, position, title, artist, album, duration_secs, SELECT ?2, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, ?3 path, art_path, kind, source_line, start_ms, note, ?3,
track_id, art_url
FROM playlist_items WHERE playlist_id = ?1", FROM playlist_items WHERE playlist_id = ?1",
rusqlite::params![id, new_id, now], rusqlite::params![id, new_id, now],
)?; )?;
@@ -1369,12 +1432,17 @@ impl Library {
let mut ins = tx.prepare( let mut ins = tx.prepare(
"INSERT INTO playlist_items "INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs, (playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at) path, art_path, kind, source_line, start_ms, note, added_at,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", track_id, art_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?; )?;
for (position, item) in items.iter().enumerate() { for (position, item) in items.iter().enumerate() {
let kind = if item.kind.is_empty() { let kind = if item.kind.is_empty() {
if item.path.is_some() { "local" } else { "unresolved" } if item.path.is_some() {
"local"
} else {
pathless_kind(&item.artist, &item.title)
}
} else { } else {
&item.kind &item.kind
}; };
@@ -1392,6 +1460,8 @@ impl Library {
item.start_ms, item.start_ms,
item.note, item.note,
now, now,
item.track_id,
item.art_url,
])?; ])?;
} }
} }
@@ -2261,7 +2331,9 @@ mod tests {
art_path: art.map(String::from), art_path: art.map(String::from),
..Default::default() ..Default::default()
}; };
// Two copies of the same file, on purpose, plus an unresolved import row. // Two copies of the same file, on purpose, plus a pathless row with a
// full identity (a streaming track, first-class) and a genuinely
// unknown one (the DJ token "ID" stays a placeholder for repair).
lib.add_playlist_items( lib.add_playlist_items(
id, id,
&[ &[
@@ -2274,10 +2346,31 @@ mod tests {
110, 110,
) )
.unwrap(); .unwrap();
lib.add_playlist_items(
id,
&[NewPlaylistItem {
title: "ID".into(),
artist: "Artist".into(),
..Default::default()
}],
None,
111,
)
.unwrap();
let items = lib.playlist_items(id).unwrap();
assert_eq!(items.len(), 5);
assert_eq!(
items[3].kind, "stream",
"a pathless row with full identity streams — never dropped, never an error"
);
assert_eq!(
items[4].kind, "unresolved",
"the DJ unknown-token stays a placeholder for repair"
);
lib.remove_playlist_items(id, &[items[4].id], 112).unwrap();
let items = lib.playlist_items(id).unwrap(); let items = lib.playlist_items(id).unwrap();
assert_eq!(items.len(), 4); assert_eq!(items.len(), 4);
assert_eq!(items[3].kind, "unresolved", "no path means placeholder, never dropped");
// Reorder: move the SECOND "One" first; the two copies stay distinct. // Reorder: move the SECOND "One" first; the two copies stay distinct.
let ids: Vec<i64> = items.iter().map(|i| i.id).collect(); let ids: Vec<i64> = items.iter().map(|i| i.id).collect();
@@ -2308,9 +2401,10 @@ mod tests {
assert_eq!(items.len(), 4); assert_eq!(items.len(), 4);
// Aggregates: unresolved count, duration sum, distinct art seeds. // Aggregates: unresolved count, duration sum, distinct art seeds.
// "Ghost" is a stream row (full identity), so nothing is unresolved.
let meta = lib.playlist_meta(id).unwrap().unwrap(); let meta = lib.playlist_meta(id).unwrap().unwrap();
assert_eq!(meta.track_count, 4); assert_eq!(meta.track_count, 4);
assert_eq!(meta.unresolved_count, 1); assert_eq!(meta.unresolved_count, 0);
assert_eq!(meta.duration_secs, 400, "all four rows carry 100s"); assert_eq!(meta.duration_secs, 400, "all four rows carry 100s");
assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]); assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]);
assert!(meta.revision > 0); assert!(meta.revision > 0);
@@ -2349,7 +2443,7 @@ mod tests {
.unwrap(); .unwrap();
let rows = lib.playlist_items(imported).unwrap(); let rows = lib.playlist_items(imported).unwrap();
assert_eq!(rows.len(), 2); assert_eq!(rows.len(), 2);
assert_eq!(rows[0].kind, "unresolved"); assert_eq!(rows[0].kind, "stream", "full identity, no file: streams");
assert_eq!(rows[1].kind, "local"); assert_eq!(rows[1].kind, "local");
assert_eq!(rows[1].start_ms, Some(3_600_000)); assert_eq!(rows[1].start_ms, Some(3_600_000));
let meta = lib.playlist_meta(imported).unwrap().unwrap(); let meta = lib.playlist_meta(imported).unwrap().unwrap();
@@ -2365,6 +2459,8 @@ mod tests {
Some(240), Some(240),
Some("C:\\m\\opener.mp3"), Some("C:\\m\\opener.mp3"),
None, None,
None,
None,
170, 170,
) )
.unwrap(); .unwrap();
+21
View File
@@ -4500,6 +4500,27 @@ impl MetadataClient {
} }
} }
/// One catalog track by ISRC — the anchor-exact lookup for rows whose
/// identity came from AUDIO RECOGNITION (the recognizer reports the
/// matched release's ISRC). An id-grade identity, never a name guess;
/// `None` is an honest "that release isn't in the catalog".
pub async fn catalog_track_by_isrc(&self, isrc: &str) -> Option<CatalogTrack> {
let clean: String = isrc
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
// ISRCs are exactly 12 characters (CC-XXX-YY-NNNNN without the
// dashes); anything else would only ever be a malformed URL.
if clean.len() != 12 {
return None;
}
let body = self
.deezer(&format!("https://api.deezer.com/track/isrc:{clean}"))
.await?;
parse_track(&body)
}
/// The two things the search hero can say about an artist that Deezer /// The two things the search hero can say about an artist that Deezer
/// cannot: what they sound like, and how big they are. /// cannot: what they sound like, and how big they are.
/// ///
+1 -1
View File
@@ -13,7 +13,7 @@ mod waveform;
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter}; pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
pub use source::{GrowingFile, StreamProgress, Terminal}; pub use source::{GrowingFile, StreamProgress, Terminal};
pub use waveform::compute_waveform; pub use waveform::{compute_waveform, compute_waveform_range};
use std::collections::VecDeque; use std::collections::VecDeque;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+406
View File
@@ -115,6 +115,138 @@ fn decode(path: &Path, limit_secs: Option<f64>) -> Result<Decoded, String> {
}) })
} }
// ---- streaming long-form decode ---------------------------------------------
/// Streaming 16 kHz mono decode for LONG sources (DJ sets, hour-plus mixes):
/// packets are downmixed and resampled as they arrive, so memory holds only
/// the 16 kHz mono result (~230 MB per hour as f32) and never the source-rate
/// PCM. The whole-file `decode` above deliberately caps at 20 minutes; this
/// is the path past that cap, bounded by `max_secs` of OUTPUT.
pub fn mono_pcm_16k_streaming(path: &Path, max_secs: f64) -> Result<Vec<f32>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions::default(),
&MetadataOptions::default(),
)
.map_err(|e| e.to_string())?;
let mut format = probed.format;
let track = format
.default_track()
.ok_or("no default audio track")?
.clone();
let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| e.to_string())?;
let mut out: Vec<f32> = Vec::new();
let cap = (max_secs * 16_000.0) as usize;
let mut resampler: Option<StreamResampler> = None;
let mut buffer: Option<SampleBuffer<f32>> = None;
loop {
if out.len() >= cap {
break;
}
let packet = match format.next_packet() {
Ok(p) => p,
Err(_) => break,
};
if packet.track_id() != track.id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
Err(_) => break,
};
let spec = *decoded.spec();
let channels = spec.channels.count().max(1);
let buf = buffer.get_or_insert_with(|| {
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec)
});
if buf.capacity() < decoded.capacity() * channels {
*buf = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
}
buf.copy_interleaved_ref(decoded);
let rs = resampler.get_or_insert_with(|| StreamResampler::new(spec.rate, 16_000));
rs.push_interleaved(buf.samples(), channels, &mut out);
}
if out.is_empty() {
return Err("nothing decodable".into());
}
out.truncate(cap);
Ok(out)
}
/// Linear resampler with carry across pushes, so packet boundaries do not
/// click or drift: keeps the previous source sample and a fractional read
/// position between calls.
struct StreamResampler {
ratio: f64,
/// Fractional position into the CURRENT push, may start negative into
/// the carried sample.
pos: f64,
carry: f32,
has_carry: bool,
passthrough: bool,
}
impl StreamResampler {
fn new(src_rate: u32, dst_rate: u32) -> Self {
StreamResampler {
ratio: src_rate as f64 / dst_rate as f64,
pos: 0.0,
carry: 0.0,
has_carry: false,
passthrough: src_rate == dst_rate,
}
}
/// Downmix `interleaved` to mono and append the resampled result to
/// `out`.
fn push_interleaved(&mut self, interleaved: &[f32], channels: usize, out: &mut Vec<f32>) {
let frames = interleaved.len() / channels.max(1);
if frames == 0 {
return;
}
let mono = |i: usize| -> f32 {
let row = &interleaved[i * channels..(i + 1) * channels];
row.iter().sum::<f32>() / channels as f32
};
if self.passthrough {
out.extend((0..frames).map(mono));
return;
}
// Source timeline for this push: sample -1 is the carried one.
loop {
let i0 = self.pos.floor();
let frac = (self.pos - i0) as f32;
let i0 = i0 as isize;
if i0 + 1 >= frames as isize {
break;
}
let a = if i0 < 0 {
if self.has_carry { self.carry } else { mono(0) }
} else {
mono(i0 as usize)
};
let b = mono((i0 + 1) as usize);
out.push(a + (b - a) * frac);
self.pos += self.ratio;
}
self.carry = mono(frames - 1);
self.has_carry = true;
self.pos -= frames as f64;
}
}
// ---- leading silence ------------------------------------------------------- // ---- leading silence -------------------------------------------------------
/// Milliseconds of leading silence in `path`, or None when the file cannot be /// Milliseconds of leading silence in `path`, or None when the file cannot be
@@ -216,6 +348,216 @@ pub fn vocal_activity(path: &Path, frame_ms: u32) -> Result<VocalActivity, Strin
Ok(VocalActivity { frame_ms, frames }) Ok(VocalActivity { frame_ms, frames })
} }
// ---- mono PCM for the CTC aligner ------------------------------------------
/// Whole-file VOCAL-FORWARD mono PCM at 16 kHz for the CTC aligner: the mid
/// (center) channel through the same 180 Hz – 7.5 kHz band the reducer and
/// the activity envelope use, linearly resampled. Cutting bass/kick
/// fundamentals and cymbal air before the model's per-window normalization
/// leaves the voice owning more of the input's dynamic range — cheap
/// pre-emphasis, NOT separation (center-mixed instruments remain). The
/// low-pass doubles as a crude anti-alias guard for the 16 kHz resample.
pub fn vocal_pcm_16k(path: &Path) -> Result<Vec<f32>, String> {
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
let ch = decoded.channels.max(1);
let mut low = Biquad::highpass(decoded.sample_rate as f32, 180.0);
let mut high = Biquad::lowpass(decoded.sample_rate as f32, 7_500.0);
let mono: Vec<f32> = decoded
.samples
.chunks(ch)
.map(|f| high.process(low.process(f.iter().sum::<f32>() / ch as f32)))
.collect();
let src = decoded.sample_rate as f64;
if (src - 16_000.0).abs() < 1.0 {
return Ok(mono);
}
let ratio = 16_000.0 / src;
let out_len = (mono.len() as f64 * ratio) as usize;
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let pos = i as f64 / ratio;
let i0 = pos as usize;
let frac = (pos - i0 as f64) as f32;
let a = mono.get(i0).copied().unwrap_or(0.0);
let b = mono.get(i0 + 1).copied().unwrap_or(a);
out.push(a + (b - a) * frac);
}
Ok(out)
}
/// Stereo 44.1 kHz planar PCM for the learned vocal separator: any layout
/// folds to two channels (mono duplicates its one, extra channels beyond the
/// front pair are ignored), any rate linearly resampled. Linear interpolation
/// is deliberate — the stem feeds ALIGNMENT, not ears, the same tradeoff
/// `vocal_pcm_16k` already makes for its own input.
pub fn separation_input_44k1(path: &Path) -> Result<[Vec<f32>; 2], String> {
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
let ch = decoded.channels.max(1);
let frames = decoded.samples.len() / ch;
let mut left = Vec::with_capacity(frames);
let mut right = Vec::with_capacity(frames);
for f in decoded.samples.chunks(ch) {
let l = f[0];
left.push(l);
right.push(if f.len() > 1 { f[1] } else { l });
}
Ok([
resample_linear(left, decoded.sample_rate as f64, 44_100.0),
resample_linear(right, decoded.sample_rate as f64, 44_100.0),
])
}
/// Aligner input from a separated vocal stem (44.1 kHz planar stereo): mid
/// fold, a 60 Hz rumble high-pass, the same 7.5 kHz low-pass `vocal_pcm_16k`
/// uses as its 16 kHz anti-alias guard, then the resample. No 180 Hz floor
/// here — that floor exists to fight INSTRUMENTS sharing the band, and a
/// separated stem has (mostly) none left to fight.
pub fn stem_pcm_16k(left: &[f32], right: &[f32]) -> Vec<f32> {
let mut rumble = Biquad::highpass(44_100.0, 60.0);
let mut guard = Biquad::lowpass(44_100.0, 7_500.0);
let n = left.len().min(right.len());
let mono: Vec<f32> = (0..n)
.map(|i| guard.process(rumble.process((left[i] + right[i]) * 0.5)))
.collect();
resample_linear(mono, 44_100.0, 16_000.0)
}
/// Linear resampler shared by the separator inputs (see the tradeoff note on
/// `separation_input_44k1`). Within 1 Hz of the target it hands the buffer
/// back untouched — whole-track copies are not free at 44.1 kHz.
fn resample_linear(src_pcm: Vec<f32>, src: f64, dst: f64) -> Vec<f32> {
if (src - dst).abs() < 1.0 {
return src_pcm;
}
let ratio = dst / src;
let out_len = (src_pcm.len() as f64 * ratio) as usize;
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let pos = i as f64 / ratio;
let i0 = pos as usize;
let frac = (pos - i0 as f64) as f32;
let a = src_pcm.get(i0).copied().unwrap_or(0.0);
let b = src_pcm.get(i0 + 1).copied().unwrap_or(a);
out.push(a + (b - a) * frac);
}
out
}
// ---- vocal features (word-timing worker) -----------------------------------
/// Per-frame vocal evidence for the lyric word-timing worker: the band
/// energy `vocal_activity` measures PLUS a voicing verdict — vocals are
/// PITCHED, so an autocorrelation clarity check on a decimated mid channel
/// separates sung frames from drums and noise that light the band up.
pub struct VocalFrames {
pub frame_ms: u32,
/// 0..1 per frame, normalized like `vocal_activity`.
pub energy: Vec<f32>,
/// Frame carries periodic (pitched) content in the vocal F0 range.
pub voiced: Vec<bool>,
}
pub fn vocal_frames(path: &Path, frame_ms: u32) -> Result<VocalFrames, String> {
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
let frame_ms = frame_ms.clamp(40, 500);
let frame_len = (decoded.sample_rate as usize * frame_ms as usize) / 1000;
let channels = decoded.channels;
let mut low = Biquad::highpass(decoded.sample_rate as f32, 180.0);
let mut high = Biquad::lowpass(decoded.sample_rate as f32, 7_500.0);
// Decimate the mid channel to ~8 kHz for the pitch check — full-rate
// autocorrelation across a song is debug-build poison, and F0 up to
// 500 Hz needs nothing like 44.1 kHz.
let decim = (decoded.sample_rate as usize / 8_000).max(1);
let target_rate = decoded.sample_rate as usize / decim;
let mut energy = Vec::new();
let mut voiced = Vec::new();
let mut acc = 0.0f64;
let mut n = 0usize;
let mut pitch_buf: Vec<f32> = Vec::with_capacity(frame_len / decim + 2);
let mut decim_acc = 0.0f32;
let mut decim_n = 0usize;
for frame in decoded.samples.chunks(channels) {
let mid = if channels >= 2 {
0.5 * (frame[0] + frame[1])
} else {
frame[0]
};
let banded = high.process(low.process(mid));
acc += (banded * banded) as f64;
n += 1;
decim_acc += banded;
decim_n += 1;
if decim_n >= decim {
pitch_buf.push(decim_acc / decim as f32);
decim_acc = 0.0;
decim_n = 0;
}
if n >= frame_len {
energy.push((acc / n as f64).sqrt() as f32);
voiced.push(frame_is_voiced(&pitch_buf, target_rate));
pitch_buf.clear();
acc = 0.0;
n = 0;
}
}
if n > frame_len / 4 {
energy.push((acc / n.max(1) as f64).sqrt() as f32);
voiced.push(frame_is_voiced(&pitch_buf, target_rate));
}
if energy.is_empty() {
return Err("nothing decodable".into());
}
let mut sorted: Vec<f32> = energy.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p95 = sorted[(sorted.len() * 95 / 100).min(sorted.len() - 1)].max(1e-6);
for value in &mut energy {
*value = (*value / p95).min(1.0);
}
Ok(VocalFrames {
frame_ms,
energy,
voiced,
})
}
/// Normalized-autocorrelation clarity over the sung F0 range (80–500 Hz).
/// A frame is voiced when some lag in that range correlates strongly —
/// periodic content — rather than merely being loud.
fn frame_is_voiced(buf: &[f32], rate: usize) -> bool {
if buf.len() < 64 || rate == 0 {
return false;
}
let energy: f32 = buf.iter().map(|s| s * s).sum();
if energy < 1e-7 {
return false;
}
let lag_min = (rate / 500).max(2);
let lag_max = (rate / 80).min(buf.len() / 2);
if lag_min >= lag_max {
return false;
}
let mut best = 0.0f32;
let mut lag = lag_min;
// Stride lags by 2: clarity peaks are broad; halving the work loses
// nothing a boolean cares about.
while lag < lag_max {
let n = buf.len() - lag;
let mut num = 0.0f32;
let mut den_a = 0.0f32;
let mut den_b = 0.0f32;
for i in 0..n {
num += buf[i] * buf[i + lag];
den_a += buf[i] * buf[i];
den_b += buf[i + lag] * buf[i + lag];
}
let den = (den_a * den_b).sqrt().max(1e-9);
best = best.max(num / den);
lag += 2;
}
best >= 0.55
}
// ---- vocal reduction ------------------------------------------------------- // ---- vocal reduction -------------------------------------------------------
/// What the renderer produced (for the caller's labels and sanity checks). /// What the renderer produced (for the caller's labels and sanity checks).
@@ -407,6 +749,70 @@ mod tests {
out out
} }
/// The streaming resampler must produce the same stream regardless of
/// how the source is chopped into pushes — no clicks, no drift at
/// packet boundaries.
#[test]
fn stream_resampler_is_chunking_invariant() {
let src_rate = 44_100u32;
let n = 44_100usize;
let stereo: Vec<f32> = (0..n)
.flat_map(|i| {
let s = (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin();
[s, s]
})
.collect();
let mut one_shot = Vec::new();
let mut a = StreamResampler::new(src_rate, 16_000);
a.push_interleaved(&stereo, 2, &mut one_shot);
let mut chunked = Vec::new();
let mut b = StreamResampler::new(src_rate, 16_000);
let mut at = 0usize;
for (k, step) in [431usize, 977, 1153, 64, 2048].iter().cycle().enumerate() {
if at >= stereo.len() {
break;
}
let end = (at + step * 2).min(stereo.len());
b.push_interleaved(&stereo[at..end], 2, &mut chunked);
at = end;
assert!(k < 200_000, "runaway");
}
assert!(
(one_shot.len() as i64 - chunked.len() as i64).abs() <= 2,
"length drift: {} vs {}",
one_shot.len(),
chunked.len()
);
let worst = one_shot
.iter()
.zip(&chunked)
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1e-4, "boundary artifacts, worst {worst}");
}
/// The stem→aligner path: a vocal-band tone passes the 60 Hz / 7.5 kHz
/// hygiene filters and lands at 16 kHz with its energy intact.
#[test]
fn stem_pcm_16k_resamples_and_keeps_vocal_band() {
let frames = 44_100usize;
let mono: Vec<f32> = (0..frames)
.map(|i| (2.0 * std::f32::consts::PI * 1_000.0 * i as f32 / 44_100.0).sin() * 0.5)
.collect();
let out = stem_pcm_16k(&mono, &mono);
assert!(
(15_998..=16_000).contains(&out.len()),
"one second must land at ~16000 samples, got {}",
out.len()
);
let level = rms(&out);
assert!(
level > 0.3 && level < 0.4,
"1kHz mid tone must pass the stem filters near unity, rms {level}"
);
assert!(out.iter().all(|s| s.is_finite()));
}
/// The whole point: a center-panned vocal-band tone dies, a center bass /// The whole point: a center-panned vocal-band tone dies, a center bass
/// tone survives, side content survives. /// tone survives, side content survives.
#[test] #[test]
+73 -1
View File
@@ -10,10 +10,11 @@ use std::path::Path;
use symphonia::core::audio::SampleBuffer; use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::DecoderOptions; use symphonia::core::codecs::DecoderOptions;
use symphonia::core::formats::FormatOptions; use symphonia::core::formats::{FormatOptions, SeekMode, SeekTo};
use symphonia::core::io::MediaSourceStream; use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions; use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint; use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
/// Peaks folded over this many frames before resampling to the final bucket /// Peaks folded over this many frames before resampling to the final bucket
/// count. Keeps memory bounded regardless of track length. /// count. Keeps memory bounded regardless of track length.
@@ -31,6 +32,25 @@ pub fn compute_waveform(path: &Path, buckets: usize) -> Vec<f32> {
} }
} }
/// Like [`compute_waveform`], but for one SLICE of the file — the setlist
/// segment rows' lane. `end_secs: None` runs to the file's end. The decode
/// seeks coarsely and then trims by packet timestamps, so a 4-minute slice
/// of a 90-minute set costs a 4-minute decode, not a 90-minute one.
pub fn compute_waveform_range(
path: &Path,
buckets: usize,
start_secs: f64,
end_secs: Option<f64>,
) -> Vec<f32> {
match coarse_rms_range(path, start_secs, end_secs).map(|c| resample(&c, buckets)) {
Ok(v) => v,
Err(e) => {
log::debug!("range waveform for {} failed: {e}", path.display());
Vec::new()
}
}
}
fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> { fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
Ok(resample(&coarse_rms(path)?, buckets)) Ok(resample(&coarse_rms(path)?, buckets))
} }
@@ -41,6 +61,14 @@ fn try_compute(path: &Path, buckets: usize) -> Result<Vec<f32>, String> {
/// against a real decode without re-reading the file per variant — see /// against a real decode without re-reading the file per variant — see
/// `bucketing_strategies_on_a_real_track`. /// `bucketing_strategies_on_a_real_track`.
fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> { fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
coarse_rms_range(path, 0.0, None)
}
fn coarse_rms_range(
path: &Path,
start_secs: f64,
end_secs: Option<f64>,
) -> Result<Vec<f32>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -73,14 +101,34 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
.default_track() .default_track()
.ok_or_else(|| "no default track".to_string())?; .ok_or_else(|| "no default track".to_string())?;
let track_id = track.id; let track_id = track.id;
let time_base = track.codec_params.time_base;
let sample_rate = track.codec_params.sample_rate;
let mut decoder = symphonia::default::get_codecs() let mut decoder = symphonia::default::get_codecs()
.make(&track.codec_params, &DecoderOptions::default()) .make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
if start_secs > 0.0 {
// A coarse seek lands at a sync point near (usually before) the
// target; the timestamp gate in the loop trims the difference. A
// format that cannot seek just decodes from the top — slower, same
// result.
let _ = format.seek(
SeekMode::Coarse,
SeekTo::Time {
time: Time::from(start_secs),
track_id: Some(track_id),
},
);
decoder.reset();
}
let mut sample_buf: Option<SampleBuffer<f32>> = None; let mut sample_buf: Option<SampleBuffer<f32>> = None;
let mut coarse: Vec<f32> = Vec::new(); let mut coarse: Vec<f32> = Vec::new();
let mut win_energy = 0f64; let mut win_energy = 0f64;
let mut win_count = 0usize; let mut win_count = 0usize;
// When the container carries no timestamps, the slice is bounded by
// counted frames from wherever the seek landed instead.
let mut frames_seen: u64 = 0;
loop { loop {
let packet = match format.next_packet() { let packet = match format.next_packet() {
@@ -90,6 +138,22 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
if packet.track_id() != track_id { if packet.track_id() != track_id {
continue; continue;
} }
// Where this packet sits in the file, when the format can say.
let packet_secs = time_base.map(|tb| {
let t = tb.calc_time(packet.ts());
t.seconds as f64 + t.frac
});
if let Some(t) = packet_secs {
if t < start_secs {
// Before the slice: decode and discard, keeping codec state
// warm across the boundary.
let _ = decoder.decode(&packet);
continue;
}
if end_secs.is_some_and(|end| t >= end) {
break;
}
}
let decoded = match decoder.decode(&packet) { let decoded = match decoder.decode(&packet) {
Ok(d) => d, Ok(d) => d,
Err(symphonia::core::errors::Error::DecodeError(_)) => continue, Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
@@ -115,6 +179,14 @@ fn coarse_rms(path: &Path) -> Result<Vec<f32>, String> {
win_count = 0; win_count = 0;
} }
} }
if packet_secs.is_none() {
frames_seen += (sb.samples().len() / channels) as u64;
if let (Some(end), Some(rate)) = (end_secs, sample_rate) {
if frames_seen as f64 / rate as f64 >= end - start_secs {
break;
}
}
}
} }
if win_count > 0 { if win_count > 0 {
coarse.push((win_energy / win_count as f64).sqrt() as f32); coarse.push((win_energy / win_count as f64).sqrt() as f32);
+8
View File
@@ -29,6 +29,14 @@ image.workspace = true
tokio.workspace = true tokio.workspace = true
base64 = "0.22" base64 = "0.22"
# Shazam signature packets (shazam.rs) carry a CRC-32 of their body.
crc32fast = "1"
# Inflate for the deno release zip (ytdlp.rs) — already in the lock via the
# image stack, so this adds no download.
flate2 = "1"
# STFT/iSTFT for the vocal separator (vocal_sep.rs) — pure Rust, so the
# no-child-processes charter holds; wraps rustfft with real transforms.
realfft = "3"
# XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs # XSPF playlist import (playlist_formats.rs). quick-xml never resolves DTDs
# or external entities, which is exactly the safety posture the importer wants. # or external entities, which is exactly the safety posture the importer wants.
quick-xml = "0.37" quick-xml = "0.37"
+43 -3
View File
@@ -76,6 +76,8 @@ pub struct Settings {
pub stream_source_prefs: config::SourcePrefs, pub stream_source_prefs: config::SourcePrefs,
pub download_source_prefs: config::SourcePrefs, pub download_source_prefs: config::SourcePrefs,
pub volume_normalization: bool, pub volume_normalization: bool,
/// Word-by-word lyric fill — experimental, default off.
pub word_timing: bool,
} }
#[tauri::command] #[tauri::command]
@@ -114,6 +116,7 @@ pub async fn get_settings(
stream_source_prefs: cfg.stream_source_prefs, stream_source_prefs: cfg.stream_source_prefs,
download_source_prefs: cfg.download_source_prefs, download_source_prefs: cfg.download_source_prefs,
volume_normalization: config::volume_normalization(&cfg), volume_normalization: config::volume_normalization(&cfg),
word_timing: config::word_timing(&cfg),
username: cfg.username, username: cfg.username,
}) })
} }
@@ -141,6 +144,7 @@ pub async fn set_settings(
stream_source_prefs: Option<config::SourcePrefs>, stream_source_prefs: Option<config::SourcePrefs>,
download_source_prefs: Option<config::SourcePrefs>, download_source_prefs: Option<config::SourcePrefs>,
volume_normalization: Option<bool>, volume_normalization: Option<bool>,
word_timing: Option<bool>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut cfg = config::load(&app); let mut cfg = config::load(&app);
let library_folder_moved = download_dir let library_folder_moved = download_dir
@@ -209,6 +213,9 @@ pub async fn set_settings(
// takes effect from the next track. // takes effect from the next track.
crate::loudness::set_enabled(&app, enabled); crate::loudness::set_enabled(&app, enabled);
} }
if word_timing.is_some() {
cfg.word_timing = word_timing;
}
config::save(&app, &cfg); config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than // A new library folder is a new library. Re-base immediately rather than
@@ -1550,6 +1557,15 @@ pub async fn catalog_search(
Ok(state.metadata.catalog_search(&query).await) Ok(state.metadata.catalog_search(&query).await)
} }
#[tauri::command]
pub async fn catalog_track_by_isrc(
state: State<'_, LibraryState>,
isrc: String,
) -> Result<Option<metadata::CatalogTrack>, String> {
state.touch_catalog_activity();
Ok(state.metadata.catalog_track_by_isrc(&isrc).await)
}
#[tauri::command] #[tauri::command]
pub async fn catalog_artist( pub async fn catalog_artist(
app: tauri::AppHandle, app: tauri::AppHandle,
@@ -4051,6 +4067,7 @@ pub fn play_local(
app: tauri::AppHandle, app: tauri::AppHandle,
path: String, path: String,
duration: Option<f64>, duration: Option<f64>,
start_secs: Option<f64>,
) -> Result<(), String> { ) -> Result<(), String> {
if std::fs::metadata(&path).is_err() { if std::fs::metadata(&path).is_err() {
return Err("file not found".into()); return Err("file not found".into());
@@ -4064,7 +4081,11 @@ pub fn play_local(
0, 0,
None, None,
Box::new(move || { Box::new(move || {
app2.state::<PlayerState>().player.play_local(&path2, duration); // `start_secs` = play a SLICE of the file from there (the
// setlist segment rows); plain tracks pass None.
app2.state::<PlayerState>()
.player
.play_local_from(&path2, duration, start_secs);
}), }),
); );
Ok(()) Ok(())
@@ -4407,6 +4428,8 @@ pub async fn waveform(
players: State<'_, PlayerState>, players: State<'_, PlayerState>,
path: String, path: String,
buckets: usize, buckets: usize,
start_secs: Option<f64>,
end_secs: Option<f64>,
) -> Result<Vec<f32>, String> { ) -> Result<Vec<f32>, String> {
const MAX_WAVEFORM_BUCKETS: usize = 4096; const MAX_WAVEFORM_BUCKETS: usize = 4096;
if buckets > MAX_WAVEFORM_BUCKETS { if buckets > MAX_WAVEFORM_BUCKETS {
@@ -4417,7 +4440,20 @@ pub async fn waveform(
if buckets == 0 { if buckets == 0 {
return Ok(Vec::new()); return Ok(Vec::new());
} }
let key = format!("{path}#{buckets}"); // A range = a setlist SLICE's lane; each slice of one file caches
// separately under its own key.
let ranged = start_secs.is_some() || end_secs.is_some();
let key = if ranged {
format!(
"{path}#{buckets}@{:.3}-{}",
start_secs.unwrap_or(0.0),
end_secs
.map(|e| format!("{e:.3}"))
.unwrap_or_else(|| "end".into()),
)
} else {
format!("{path}#{buckets}")
};
let job = players.waveform_job(&key); let job = players.waveform_job(&key);
let result = async { let result = async {
let job_guard = job.clone().lock_owned().await; let job_guard = job.clone().lock_owned().await;
@@ -4426,7 +4462,11 @@ pub async fn waveform(
} }
let p = std::path::PathBuf::from(&path); let p = std::path::PathBuf::from(&path);
let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || { let (job_guard, peaks) = tauri::async_runtime::spawn_blocking(move || {
let peaks = player::compute_waveform(&p, buckets); let peaks = if ranged {
player::compute_waveform_range(&p, buckets, start_secs.unwrap_or(0.0), end_secs)
} else {
player::compute_waveform(&p, buckets)
};
(job_guard, peaks) (job_guard, peaks)
}) })
.await .await
+9
View File
@@ -139,6 +139,7 @@ pub struct AppConfig {
/// every track plays at roughly the same perceived level. None → true. /// every track plays at roughly the same perceived level. None → true.
#[serde(default)] #[serde(default)]
pub volume_normalization: Option<bool>, pub volume_normalization: Option<bool>,
pub word_timing: Option<bool>,
} }
/// Default longest edge for embedded covers when the user hasn't chosen. /// Default longest edge for embedded covers when the user hasn't chosen.
@@ -180,6 +181,14 @@ pub fn volume_normalization(cfg: &AppConfig) -> bool {
cfg.volume_normalization.unwrap_or(true) cfg.volume_normalization.unwrap_or(true)
} }
/// Word-by-word lyric timing (the karaoke fill) — EXPERIMENTAL, off by
/// default: generated word spans still drift on dense or echo-heavy mixes,
/// and the solid line-level sync must be what users live with until the
/// feature earns its keep (Kyle, 2026-08-19).
pub fn word_timing(cfg: &AppConfig) -> bool {
cfg.word_timing.unwrap_or(false)
}
/// One-time migration after the identifier rename: the app-data dir moved /// One-time migration after the identifier rename: the app-data dir moved
/// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename /// from `com.trifecta.soulseek-plus` to the new identifier's folder. Rename
/// the old dir into place so the library DB, config and stream cache /// the old dir into place so the library DB, config and stream cache
+21
View File
@@ -6,15 +6,22 @@ mod hero_crop;
mod import; mod import;
mod hero_detector; mod hero_detector;
mod loudness; mod loudness;
mod lyric_align;
mod lyric_prep;
mod lyrics; mod lyrics;
mod pipeline; mod pipeline;
mod playback; mod playback;
mod playlist_formats; mod playlist_formats;
mod playlists; mod playlists;
mod preview; mod preview;
mod setlist_scan;
mod shazam;
mod smtc; mod smtc;
mod state; mod state;
mod thumbbar; mod thumbbar;
mod vocal_sep;
mod youtube_setlist;
mod ytdlp;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
@@ -114,7 +121,15 @@ pub fn run() {
playlists::playlist_import_probe, playlists::playlist_import_probe,
playlists::playlist_import_folder, playlists::playlist_import_folder,
playlists::playlist_import_text, playlists::playlist_import_text,
playlists::playlist_import_youtube,
playlists::playlist_import_commit, playlists::playlist_import_commit,
setlist_scan::playlist_scan_youtube,
setlist_scan::playlist_pick_set_file,
setlist_scan::playlist_scan_file,
setlist_scan::playlist_scan_cancel,
ytdlp::ytdlp_status,
ytdlp::ytdlp_download,
ytdlp::deno_download,
playlists::playlist_export, playlists::playlist_export,
playlists::library_track_search, playlists::library_track_search,
lyrics::lyrics_get, lyrics::lyrics_get,
@@ -128,6 +143,7 @@ pub fn run() {
lyrics::lyrics_save_text, lyrics::lyrics_save_text,
lyrics::lyrics_clear, lyrics::lyrics_clear,
commands::catalog_search, commands::catalog_search,
commands::catalog_track_by_isrc,
import::import_scan, import::import_scan,
import::import_portable_root, import::import_portable_root,
import::import_resolve_root, import::import_resolve_root,
@@ -187,6 +203,11 @@ pub fn run() {
preview::player_fade_resume, preview::player_fade_resume,
diagnostics::debug_stats, diagnostics::debug_stats,
smtc::smtc_metadata, smtc::smtc_metadata,
lyric_prep::lyric_prep_request,
lyric_align::lyric_model_status,
lyric_align::lyric_model_download,
vocal_sep::vocal_sep_status,
vocal_sep::vocal_sep_download,
]) ])
.run({ .run({
#[allow(unused_mut)] #[allow(unused_mut)]
+525
View File
@@ -0,0 +1,525 @@
//! Tier B word alignment: CTC forced alignment with a small acoustic model
//! through WinML — the same built-in runtime the hero detector uses, so no
//! new dependency and no child processes (the preparation-worker charter).
//!
//! The model (MMS-300m forced-aligner, ONNX f32 export — Meta's multilingual
//! wav2vec2 fine-tuned specifically FOR forced alignment, the checkpoint
//! torchaudio's alignment API standardized on) emits per-20ms CHARACTER
//! probabilities; a Viterbi pass aligns the KNOWN lyric text against them —
//! no transcription, no guessing, just "when does each known word occur".
//! Its vocab is lowercase romanized characters with NO word separator and
//! blank = `<blank>`; the loader reads all of that from the repo's own
//! vocab.json rather than hardcoding (the vocab-never-hardcoded rule).
//! It is downloaded ONLY from the user's explicit click in Settings
//! (`lyric_model_download`) — the charter's consent rule — verified by
//! length, and loaded lazily. When it is absent or fails, the worker's
//! envelope estimate stands; nothing ever depends on this module working.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Emitter, Manager};
use windows::{
core::{Interface, HSTRING},
AI::MachineLearning::{
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
LearningModelSession, TensorFloat,
},
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
};
const MODEL_URL: &str =
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/onnx/model.onnx";
const VOCAB_URL: &str =
"https://huggingface.co/onnx-community/mms-300m-1130-forced-aligner-ONNX/resolve/main/vocab.json";
/// Exact f32 model size, shown in Settings and sanity-checked after download.
const MODEL_BYTES: u64 = 1_262_529_881;
/// wav2vec2-family conv stride: 320 samples at 16 kHz (MMS keeps it).
const FRAME_MS: i64 = 20;
struct Engine {
session: LearningModelSession,
/// char → token id, from the repo's own vocab.json (never hardcoded).
chars: HashMap<char, usize>,
vocab_len: usize,
blank: usize,
/// Word-separator token — wav2vec2-960h had `|`, MMS has none.
sep: Option<usize>,
}
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("lyric-models");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(models_dir(app)?.join("mms-300m-fa.onnx"))
}
fn vocab_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(models_dir(app)?.join("mms-300m-fa.vocab.json"))
}
pub fn model_present(app: &tauri::AppHandle) -> bool {
matches!((model_file(app), vocab_file(app)), (Ok(m), Ok(v)) if m.is_file() && v.is_file())
}
#[tauri::command]
pub fn lyric_model_status(app: tauri::AppHandle) -> serde_json::Value {
let size = model_file(&app)
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.unwrap_or(0);
serde_json::json!({
"present": model_present(&app),
"bytes": size,
// The tier the machine would actually prepare under right now —
// ctc-v5 once the separator model is ALSO on disk.
"aligner": crate::lyric_prep::active_aligner(&app),
"downloadBytes": MODEL_BYTES,
})
}
/// Download the model + vocab. Runs ONLY from the user's explicit Settings
/// click — the consent-before-download charter rule. Streams to a temp file,
/// renames atomically, emits `lyrics:model-progress` along the way.
#[tauri::command]
pub async fn lyric_model_download(app: tauri::AppHandle) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1 (lyric alignment)")
.build()
.map_err(|e| e.to_string())?;
// Vocab first — tiny, and the engine cannot load without it.
let vocab_dest = vocab_file(&app)?;
let vocab = client
.get(VOCAB_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())?;
serde_json::from_slice::<HashMap<String, usize>>(&vocab)
.map_err(|_| "vocab.json did not parse".to_string())?;
std::fs::write(&vocab_dest, &vocab).map_err(|e| e.to_string())?;
let dest = model_file(&app)?;
let tmp = dest.with_extension("onnx.downloading");
let mut response = client
.get(MODEL_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(0);
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut received: u64 = 0;
let mut last_emit: u64 = 0;
use std::io::Write;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if received - last_emit > 4 * 1024 * 1024 {
last_emit = received;
let _ = app.emit(
"lyrics:model-progress",
serde_json::json!({ "received": received, "total": total }),
);
}
}
drop(file);
if total > 0 && received != total {
let _ = std::fs::remove_file(&tmp);
return Err(format!("download truncated: {received} of {total} bytes"));
}
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
// The superseded wav2vec2 tier's files are dead weight once this lands.
if let Ok(dir) = models_dir(&app) {
for legacy in ["wav2vec2-base-960h.onnx", "wav2vec2-base-960h.vocab.json"] {
let _ = std::fs::remove_file(dir.join(legacy));
}
}
let _ = app.emit(
"lyrics:model-progress",
serde_json::json!({ "received": received, "total": total, "done": true }),
);
Ok(())
}
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
let mut guard = ENGINE.lock().unwrap();
if guard.is_some() {
return Ok(());
}
let model_path = model_file(app)?;
if !model_path.is_file() {
return Err("model not downloaded".into());
}
let raw = std::fs::read(vocab_file(app)?).map_err(|e| e.to_string())?;
let vocab: HashMap<String, usize> =
serde_json::from_slice(&raw).map_err(|_| "vocab.json did not parse".to_string())?;
let vocab_len = vocab.values().copied().max().unwrap_or(0) + 1;
// CTC blank: MMS names it `<blank>` (its pad_token_id points there);
// the wav2vec2-960h family used `<pad>` itself. Prefer the explicit one.
let blank = *vocab
.get("<blank>")
.or_else(|| vocab.get("<pad>"))
.ok_or("vocab has no blank token")?;
let mut chars = HashMap::new();
for (token, id) in &vocab {
let mut it = token.chars();
if let (Some(c), None) = (it.next(), it.next()) {
chars.insert(c, *id);
}
}
let sep = chars.get(&'|').copied();
// Match the hero detector's WinRT posture: initialize, tolerate an
// existing apartment, never uninitialize someone else's.
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
Ok(()) => {}
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
Err(e) => return Err(format!("WinRT init failed: {e}")),
}
let model =
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
.map_err(|e| format!("could not load alignment model: {e}"))?;
let device = LearningModelDevice::Create(LearningModelDeviceKind::Cpu)
.map_err(|e| format!("could not create WinML device: {e}"))?;
let session = LearningModelSession::CreateFromModelOnDevice(&model, &device)
.map_err(|e| format!("could not create alignment session: {e}"))?;
*guard = Some(Engine {
session,
chars,
vocab_len,
blank,
sep,
});
Ok(())
}
/// Bare-ASCII fold for the Latin letters MMS's romanized vocab expects:
/// Western lyrics keep their diacritics, the model's chars do not. Anything
/// outside this stays unmapped and the line falls back to the estimate.
fn fold_latin(c: char) -> char {
match c {
'à'..='å' | 'À'..='Å' => 'a',
'è'..='ë' | 'È'..='Ë' => 'e',
'ì'..='ï' | 'Ì'..='Ï' => 'i',
'ò'..='ö' | 'Ò'..='Ö' | 'ø' | 'Ø' => 'o',
'ù'..='ü' | 'Ù'..='Ü' => 'u',
'ý' | 'ÿ' | 'Ý' => 'y',
'ñ' | 'Ñ' => 'n',
'ç' | 'Ç' => 'c',
// Typographic apostrophes down to the vocab's plain one.
'’' | 'ʼ' | '`' => '\'',
_ => c,
}
}
pub struct AlignedWord {
pub start_ms: i64,
pub end_ms: i64,
}
pub struct AlignedLine {
pub words: Vec<AlignedWord>,
pub confidence: f32,
}
/// Force-align a PASSAGE — one or more consecutive lines' words — against a
/// 16 kHz mono window. Returns per-line word times RELATIVE to the window
/// plus a per-line confidence, or Err when the model cannot carry the
/// passage (the caller retries per line, then falls back to the estimate).
///
/// Aligning lines TOGETHER is what lets word boundaries pull free of a
/// slightly-late source cue: inside a passage the line cues are only hints,
/// not walls — the research bundle's "whole-song alignment using line
/// starts as hints", bounded to passage windows. `floors_ms[l]` is line
/// l's earliest permitted emission (window-relative): without that floor,
/// a repeating chorus lets the path lock whole lines onto the PREVIOUS
/// acoustic instance of the same words (the charter's "competing repeated
/// sections") — hint means "at most this much early", never "anywhere".
/// `ceils_ms[l]` is the floor's mirror, the latest permitted emission:
/// without it a line may smear arbitrarily LATE across its successors'
/// audio when the space between lines still sounds vocal-ish (a separated
/// stem keeps reverb/delay tails), sustaining one token for seconds at
/// HIGH margin confidence — free decoding hears the same tail, so the
/// score cannot object. Hint means "at most this much late", too.
pub fn align_passage(
app: &tauri::AppHandle,
pcm: &[f32],
lines: &[Vec<String>],
floors_ms: &[i64],
ceils_ms: &[i64],
) -> Result<Vec<AlignedLine>, String> {
let word_total: usize = lines.iter().map(|l| l.len()).sum();
if pcm.len() < 1600
|| word_total == 0
|| floors_ms.len() != lines.len()
|| ceils_ms.len() != lines.len()
{
return Err("window too short".into());
}
ensure_engine(app)?;
let guard = ENGINE.lock().unwrap();
let engine = guard.as_ref().ok_or("engine unavailable")?;
// Zero-mean / unit-variance, the model's expected normalization.
let mean = pcm.iter().sum::<f32>() / pcm.len() as f32;
let var = pcm.iter().map(|s| (s - mean) * (s - mean)).sum::<f32>() / pcm.len() as f32;
let scale = 1.0 / (var.sqrt() + 1e-7);
let normed: Vec<f32> = pcm.iter().map(|s| (s - mean) * scale).collect();
let input = TensorFloat::CreateFromShapeArrayAndDataArray(&[1, normed.len() as i64], &normed)
.map_err(|e| format!("could not create input tensor: {e}"))?;
let binding = LearningModelBinding::CreateFromSession(&engine.session)
.map_err(|e| format!("could not create binding: {e}"))?;
binding
.Bind(&HSTRING::from("input_values"), &input)
.map_err(|e| format!("could not bind input: {e}"))?;
let result = engine
.session
.Evaluate(&binding, &HSTRING::from("lyric-align"))
.map_err(|e| format!("inference failed: {e}"))?;
if !result.Succeeded().unwrap_or(false) {
return Err("inference did not succeed".into());
}
let logits: TensorFloat = result
.Outputs()
.and_then(|o| o.Lookup(&HSTRING::from("logits")))
.map_err(|e| format!("logits missing: {e}"))?
.cast()
.map_err(|e| format!("logits wrong type: {e}"))?;
let values: Vec<f32> = logits
.GetAsVectorView()
.map_err(|e| format!("could not read logits: {e}"))?
.into_iter()
.collect();
let v = engine.vocab_len;
if v == 0 || values.len() % v != 0 {
return Err(format!("unexpected logits length {}", values.len()));
}
let t_frames = values.len() / v;
// log-softmax per frame.
let mut logp = vec![0.0f32; values.len()];
for t in 0..t_frames {
let row = &values[t * v..(t + 1) * v];
let max = row.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let sum: f32 = row.iter().map(|x| (x - max).exp()).sum();
let log_z = max + sum.ln();
for (i, x) in row.iter().enumerate() {
logp[t * v + i] = x - log_z;
}
}
// Token sequence: each token knows its WORD (passage-global index), each
// word knows its line; a separator joins words only when the vocab has
// one (wav2vec2-960h did) — MMS aligns bare chars. Mapping is
// vocab-driven and case-agnostic, with a Latin fold so accented lyrics
// reach the romanized vocab. A word that maps to nothing (digits,
// symbols, other scripts) sinks the whole passage to the per-line
// ladder rather than being silently skipped.
let map_char = |c: char| -> Option<usize> {
engine
.chars
.get(&c)
.or_else(|| c.to_lowercase().next().and_then(|l| engine.chars.get(&l)))
.or_else(|| c.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
.or_else(|| {
let f = fold_latin(c);
engine
.chars
.get(&f)
.or_else(|| f.to_uppercase().next().and_then(|u| engine.chars.get(&u)))
})
.copied()
};
let mut tokens: Vec<(usize, usize)> = Vec::new();
// Per-token emission bounds, frames — the line's cue-derived floor and
// its successor-derived ceiling.
let mut floor_frame: Vec<usize> = Vec::new();
let mut ceil_frame: Vec<usize> = Vec::new();
let mut next_wi = 0usize;
for (li, line) in lines.iter().enumerate() {
let floor = (floors_ms[li].max(0) / FRAME_MS) as usize;
let ceil = (ceils_ms[li].max(0) / FRAME_MS) as usize;
for word in line {
let wi = next_wi;
next_wi += 1;
if wi > 0 {
if let Some(sep) = engine.sep {
tokens.push((sep, wi));
floor_frame.push(floor);
ceil_frame.push(ceil);
}
}
let before = tokens.len();
for c in word.chars() {
if let Some(id) = map_char(c) {
tokens.push((id, wi));
floor_frame.push(floor);
ceil_frame.push(ceil);
}
}
if tokens.len() == before {
return Err("word has no mappable characters".into());
}
}
}
let n = tokens.len();
if t_frames < n {
return Err("window shorter than token sequence".into());
}
// Real CTC trellis: at each frame stay on blank, SUSTAIN the current
// token by re-emitting it (the self-loop), or advance by emitting the
// next. The self-loop turns single emission frames into token SPANS —
// without it every word "ended" one frame after it started and sustained
// notes were invisible. Known approximation, kept deliberately: identical
// CONSECUTIVE tokens (double letters, "LL") properly require a mandatory
// blank between them; collapsing that costs ~one frame (20ms).
const NEG: f32 = f32::NEG_INFINITY;
let mut trellis = vec![NEG; (t_frames + 1) * (n + 1)];
// Backpointers (0 = blank, 1 = self-loop, 2 = advance) make the
// backtrack exact instead of re-deriving float comparisons.
let mut back = vec![0u8; (t_frames + 1) * (n + 1)];
let idx = |t: usize, j: usize| t * (n + 1) + j;
trellis[idx(0, 0)] = 0.0;
for t in 0..t_frames {
let blank_p = logp[t * v + engine.blank];
for j in 0..=n.min(t + 1) {
let mut best = trellis[idx(t, j)] + blank_p;
let mut way = 0u8;
// A token may not be emitted before its line's floor (repeated
// choruses would lock onto an earlier acoustic instance) nor at
// or past its ceiling (stem reverb tails would smear a sustain
// across the successors' audio). A line that cannot fit between
// its bounds leaves no path — the caller's ladder handles it.
if j > 0 && t >= floor_frame[j - 1] && t < ceil_frame[j - 1] {
let tok_p = logp[t * v + tokens[j - 1].0];
let sustain = trellis[idx(t, j)] + tok_p;
if sustain > best {
best = sustain;
way = 1;
}
let advance = trellis[idx(t, j - 1)] + tok_p;
if advance > best {
best = advance;
way = 2;
}
}
trellis[idx(t + 1, j)] = best;
back[idx(t + 1, j)] = way;
}
}
if trellis[idx(t_frames, n)] == NEG {
return Err("no alignment path".into());
}
// Backtrack the pointers into per-token spans: an advance opens the
// token's span, self-loops extend it. Walking backwards, the first
// emission met is the token's LAST frame. Every backtrack step consumes
// exactly one frame emitting blank or a token — recording that symbol's
// logp gives the path's per-frame score, which per-LINE confidence
// needs below.
let mut first_emit = vec![usize::MAX; n];
let mut last_emit = vec![usize::MAX; n];
let mut path_logp = vec![0.0f32; t_frames];
let mut j = n;
let mut t = t_frames;
while t > 0 {
match back[idx(t, j)] {
2 => {
first_emit[j - 1] = t - 1;
if last_emit[j - 1] == usize::MAX {
last_emit[j - 1] = t - 1;
}
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
j -= 1;
}
1 => {
if last_emit[j - 1] == usize::MAX {
last_emit[j - 1] = t - 1;
}
path_logp[t - 1] = logp[(t - 1) * v + tokens[j - 1].0];
}
_ => {
path_logp[t - 1] = logp[(t - 1) * v + engine.blank];
}
}
t -= 1;
}
if j != 0 {
return Err("backtrack failed".into());
}
// Per-word spans, then grouped into lines.
let mut word_span: Vec<Option<(usize, usize)>> = vec![None; word_total];
for (k, (id, w)) in tokens.iter().enumerate() {
if Some(*id) == engine.sep || first_emit[k] == usize::MAX {
continue;
}
word_span[*w] = Some(match word_span[*w] {
None => (first_emit[k], last_emit[k]),
Some((f, _)) => (f, last_emit[k]),
});
}
// Confidence = margin to FREE decoding, per LINE: exp of the mean
// per-frame gap between the forced path and the best unconstrained path
// over the line's own sung span — "how close is the known lyric to what
// the model actually hears", the calibration CTC-segmentation systems
// use. Near 1.0 when text matches audio, sinking as it diverges. (v1
// sampled exp(logprob) at each token's single emission frame, where CTC
// piles mass on blank — every line pinned to the clamp floor.)
let free: Vec<f32> = (0..t_frames)
.map(|t| logp[t * v..(t + 1) * v].iter().cloned().fold(NEG, f32::max))
.collect();
let mut out = Vec::with_capacity(lines.len());
let mut wi = 0usize;
for line in lines {
let mut words_out = Vec::with_capacity(line.len());
let mut span_first = usize::MAX;
let mut span_last = 0usize;
for _ in line {
match word_span[wi] {
Some((f, l)) => {
span_first = span_first.min(f);
span_last = span_last.max(l);
words_out.push(AlignedWord {
start_ms: f as i64 * FRAME_MS,
end_ms: (l as i64 + 1) * FRAME_MS,
});
}
None => return Err("word lost in backtrack".into()),
}
wi += 1;
}
let confidence = if span_first == usize::MAX {
0.05
} else {
let gap: f32 = (span_first..=span_last.min(t_frames - 1))
.map(|t| path_logp[t] - free[t])
.sum();
let frames = (span_last.min(t_frames - 1) - span_first + 1) as f32;
(gap / frames).exp().clamp(0.05, 0.99)
};
out.push(AlignedLine {
words: words_out,
confidence,
});
}
Ok(out)
}
File diff suppressed because it is too large. Load diff
+33
View File
@@ -1410,6 +1410,7 @@ pub async fn lyrics_get(
let payload = payload_from(user, offset, duration_secs); let payload = payload_from(user, offset, duration_secs);
if payload.status != "none" { if payload.status != "none" {
return Ok(finish_synced( return Ok(finish_synced(
&app,
payload, payload,
entry.auto_sync_off, entry.auto_sync_off,
path.as_deref(), path.as_deref(),
@@ -1514,6 +1515,7 @@ pub async fn lyrics_get(
let payload = payload_from(doc, offset, duration_secs); let payload = payload_from(doc, offset, duration_secs);
if payload.status == "synced" { if payload.status == "synced" {
return Ok(finish_synced( return Ok(finish_synced(
&app,
payload, payload,
entry.auto_sync_off, entry.auto_sync_off,
path.as_deref(), path.as_deref(),
@@ -1603,6 +1605,7 @@ pub async fn lyrics_get(
// local copy may be the corrected one). // local copy may be the corrected one).
if payload.status == "synced" || payload.status == "instrumental" { if payload.status == "synced" || payload.status == "instrumental" {
return Ok(finish_synced( return Ok(finish_synced(
&app,
payload, payload,
entry.auto_sync_off, entry.auto_sync_off,
path.as_deref(), path.as_deref(),
@@ -1638,6 +1641,7 @@ pub async fn lyrics_get(
/// Stamp the auto-sync fields onto a winning synced payload. /// Stamp the auto-sync fields onto a winning synced payload.
async fn finish_synced( async fn finish_synced(
app: &tauri::AppHandle,
mut payload: LyricsPayload, mut payload: LyricsPayload,
auto_sync_off: bool, auto_sync_off: bool,
path: Option<&str>, path: Option<&str>,
@@ -1649,6 +1653,35 @@ async fn finish_synced(
let first = payload.synced.first().map(|l| l.time_ms); let first = payload.synced.first().map(|l| l.time_ms);
payload.auto_offset_ms = payload.auto_offset_ms =
compute_auto_offset(path, duration_secs, source_duration_secs, first).await; compute_auto_offset(path, duration_secs, source_duration_secs, first).await;
// Second evidence source, consulted only when the corroborated
// duration/silence probe found nothing: the word-timing artifact.
// When forced alignment has already measured this file against this
// doc and the sung words consistently start N ms from their cues,
// that median is the doc's shift. Cache read only — never computed
// here. (Aligned WORD times themselves are absolute and exempt from
// this offset; only line cues move. See ArtifactLine::ctc.)
if payload.auto_offset_ms == 0 {
if let Some(p) = path {
// The exact shape the word-timing request sends: original
// indexes, empties dropped — the artifact key depends on it.
let lines: Vec<crate::lyric_prep::PrepLine> = payload
.synced
.iter()
.enumerate()
.map(|(i, l)| crate::lyric_prep::PrepLine {
index: i as u32,
time_ms: l.time_ms as i64,
text: l.text.clone(),
})
.filter(|l| !l.text.trim().is_empty())
.collect();
if let Some(shift) =
crate::lyric_prep::cue_shift_evidence(app, Path::new(p), &lines)
{
payload.auto_offset_ms = shift;
}
}
}
} }
payload payload
} }
+1 -1
View File
@@ -775,7 +775,7 @@ pub fn parse_text(bytes: &[u8]) -> Result<ParsedPlaylist, String> {
} }
/// "1:23" → 83_000 ms; "1:23:45" → 5_025_000; rejects anything else. /// "1:23" → 83_000 ms; "1:23:45" → 5_025_000; rejects anything else.
fn parse_timestamp(s: &str) -> Option<i64> { pub(crate) fn parse_timestamp(s: &str) -> Option<i64> {
let parts: Vec<&str> = s.split(':').collect(); let parts: Vec<&str> = s.split(':').collect();
if !(2..=3).contains(&parts.len()) { if !(2..=3).contains(&parts.len()) {
return None; return None;
+181 -10
View File
@@ -1,4 +1,4 @@
//! Playlist commands: CRUD over the occurrence model, covers (custom + //! Playlist commands: CRUD over the occurrence model, covers (custom +
//! composed mosaic), file import with staged preview, and export profiles. //! composed mosaic), file import with staged preview, and export profiles.
//! //!
//! Boundaries, per the architecture audit: parsing happens in //! Boundaries, per the architecture audit: parsing happens in
@@ -246,6 +246,13 @@ pub struct AddItemInput {
pub source_line: Option<String>, pub source_line: Option<String>,
#[serde(default)] #[serde(default)]
pub note: Option<String>, pub note: Option<String>,
/// Catalog track id for a stream row — the identity anchor (exact id,
/// never a name match).
#[serde(default)]
pub track_id: Option<String>,
/// Cover URL for a stream row, which has no local file to read art from.
#[serde(default)]
pub art_url: Option<String>,
} }
#[tauri::command] #[tauri::command]
@@ -269,6 +276,8 @@ pub async fn playlist_add_items(
start_ms: t.start_ms, start_ms: t.start_ms,
source_line: t.source_line, source_line: t.source_line,
note: t.note, note: t.note,
track_id: t.track_id,
art_url: t.art_url,
..Default::default() ..Default::default()
}) })
.collect(); .collect();
@@ -318,7 +327,8 @@ pub async fn playlist_set_order(
Ok(()) Ok(())
} }
/// Repair one row — attach a found local file or corrected metadata. /// Repair one row — attach a found local file, corrected metadata, or a
/// stream row's catalog anchor (track id + cover URL).
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn playlist_relink_item( pub async fn playlist_relink_item(
@@ -332,6 +342,8 @@ pub async fn playlist_relink_item(
duration_secs: Option<u32>, duration_secs: Option<u32>,
path: Option<String>, path: Option<String>,
art_path: Option<String>, art_path: Option<String>,
track_id: Option<String>,
art_url: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let l = lib.library.clone(); let l = lib.library.clone();
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
@@ -344,6 +356,8 @@ pub async fn playlist_relink_item(
duration_secs, duration_secs,
path.as_deref(), path.as_deref(),
art_path.as_deref(), art_path.as_deref(),
track_id.as_deref(),
art_url.as_deref(),
now_secs(), now_secs(),
) )
}) })
@@ -583,7 +597,9 @@ pub struct DraftEntry {
pub duration_secs: Option<u32>, pub duration_secs: Option<u32>,
pub start_ms: Option<i64>, pub start_ms: Option<i64>,
pub raw: String, pub raw: String,
/// "local" (matched a library file) | "unresolved". /// "local" (matched a library file) | "stream" (full identity, no local
/// file — becomes a Soulseek stream on commit) | "unresolved" (identity
/// too thin to play: ID rows, title-only lines).
pub resolution: String, pub resolution: String,
/// How the local match was made: "path" | "relinked" | "tags" — evidence /// How the local match was made: "path" | "relinked" | "tags" — evidence
/// the preview shows so an exact path match reads differently from a /// the preview shows so an exact path match reads differently from a
@@ -591,6 +607,18 @@ pub struct DraftEntry {
pub matched_by: Option<String>, pub matched_by: Option<String>,
pub path: Option<String>, pub path: Option<String>,
pub art_path: Option<String>, pub art_path: Option<String>,
/// Catalog identity, attached by the review's verification pass (the
/// modal matches stream rows against the Deezer-backed catalog — Kyle:
/// verify against "the streaming library part, not Soulseek"). Flows
/// into the committed row so it plays as a catalog track with artwork.
#[serde(default)]
pub track_id: Option<i64>,
#[serde(default)]
pub art_url: Option<String>,
/// The heard release's ISRC, when this row came from the listener door
/// — lets the review resolve the catalog by exact recording id.
#[serde(default)]
pub isrc: Option<String>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -604,7 +632,13 @@ pub struct ImportDraft {
pub entries: Vec<DraftEntry>, pub entries: Vec<DraftEntry>,
pub warnings: Vec<String>, pub warnings: Vec<String>,
pub resolved_count: u32, pub resolved_count: u32,
/// Rows that will play as Soulseek streams (identified, not owned).
pub stream_count: u32,
pub unresolved_count: u32, pub unresolved_count: u32,
/// A suggested cover image URL (the YouTube doors set the video's own
/// thumbnail). Commit installs it as the playlist's custom cover —
/// failure-soft, a cover is never worth failing an import over.
pub cover_url: Option<String>,
} }
/// Open a playlist file and stage it for review. Parsing and resolution only — /// Open a playlist file and stage it for review. Parsing and resolution only —
@@ -769,6 +803,9 @@ pub async fn playlist_import_folder(
matched_by: Some(if indexed.is_some() { "path" } else { "file" }.into()), matched_by: Some(if indexed.is_some() { "path" } else { "file" }.into()),
path: Some(path_string), path: Some(path_string),
art_path: indexed.and_then(|r| r.art_path.clone()), art_path: indexed.and_then(|r| r.art_path.clone()),
track_id: None,
art_url: None,
isrc: None,
} }
}) })
.collect(); .collect();
@@ -782,7 +819,9 @@ pub async fn playlist_import_folder(
entries, entries,
warnings: Vec::new(), warnings: Vec::new(),
resolved_count: resolved, resolved_count: resolved,
stream_count: 0,
unresolved_count: 0, unresolved_count: 0,
cover_url: None,
})) }))
} }
@@ -875,6 +914,38 @@ pub async fn playlist_import_text(
resolve_draft(&lib, parsed, "Pasted tracklist".into(), None).await resolve_draft(&lib, parsed, "Pasted tracklist".into(), None).await
} }
/// Stage a YouTube DJ set for review: fetch the video's own tracklist
/// evidence (chapters → description → top comments, the research bundle's
/// declared-data rungs), then run it through the exact pasted-tracklist
/// pipeline — same parser, same resolution, same review modal, same commit.
/// No audio is fetched and nothing is guessed: a set with no posted
/// tracklist says so instead of inventing one.
#[tauri::command]
pub async fn playlist_import_youtube(
lib: State<'_, LibraryState>,
url: String,
) -> Result<ImportDraft, String> {
let fetched = crate::youtube_setlist::fetch(&url).await?;
let parsed = playlist_formats::parse_text(fetched.tracklist.as_bytes())?;
let source = if fetched.author.is_empty() {
fetched.title.clone()
} else {
format!("{} — {}", fetched.author, fetched.title)
};
let mut draft = resolve_draft(&lib, parsed, source, None).await?;
draft.format = "youtube".into();
draft.name = Some(fetched.title);
// The video's own thumbnail is the playlist's default face.
if let Some(id) = crate::youtube_setlist::video_id_from_url(&url) {
draft.cover_url = Some(format!("https://i.ytimg.com/vi/{id}/maxresdefault.jpg"));
}
draft
.warnings
.insert(0, format!("Tracklist from {}.", fetched.provenance));
draft.warnings.extend(fetched.warnings);
Ok(draft)
}
/// Commit a reviewed draft as a new playlist, atomically. /// Commit a reviewed draft as a new playlist, atomically.
#[tauri::command] #[tauri::command]
pub async fn playlist_import_commit( pub async fn playlist_import_commit(
@@ -884,6 +955,7 @@ pub async fn playlist_import_commit(
description: Option<String>, description: Option<String>,
kind: String, kind: String,
entries: Vec<DraftEntry>, entries: Vec<DraftEntry>,
cover_url: Option<String>,
) -> Result<i64, String> { ) -> Result<i64, String> {
let trimmed = name.trim().to_string(); let trimmed = name.trim().to_string();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -897,16 +969,25 @@ pub async fn playlist_import_commit(
artist: e.artist, artist: e.artist,
album: e.album, album: e.album,
duration_secs: e.duration_secs, duration_secs: e.duration_secs,
kind: if e.resolution == "local" && e.path.is_some() { // Empty lets the insert classify: a matched path is local, an
"local".into() // unmatched row with full identity is a stream, the rest stay
// unresolved placeholders. The one kind the command DOES assert
// is "segment" — a listener row that plays a slice of the set's
// own recording; the classifier would read its path as "local".
kind: if e.resolution == "segment" {
"segment".into()
} else { } else {
"unresolved".into() String::new()
}, },
path: e.path.filter(|p| !p.is_empty()), path: e.path.filter(|p| !p.is_empty()),
art_path: e.art_path, art_path: e.art_path,
source_line: (!e.raw.is_empty()).then_some(e.raw), source_line: (!e.raw.is_empty()).then_some(e.raw),
start_ms: e.start_ms, start_ms: e.start_ms,
note: None, note: None,
// Catalog identity from the review's verification pass rides
// into the row: the playlist plays it as a catalog track.
track_id: e.track_id.map(|id| id.to_string()),
art_url: e.art_url,
}) })
.collect(); .collect();
let l = lib.library.clone(); let l = lib.library.clone();
@@ -919,9 +1000,74 @@ pub async fn playlist_import_commit(
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
changed(&app); changed(&app);
if let Some(url) = cover_url {
// Failure-soft: the playlist is already real; a cover is never
// worth failing it over.
match set_cover_from_url(&app, &lib, id, &url).await {
Ok(()) => changed(&app),
Err(e) => log::debug!("import cover fetch failed: {e}"),
}
}
Ok(id) Ok(id)
} }
/// Download an image URL and install it as the playlist's custom cover —
/// the same square master `playlist_cover_pick` writes. YouTube's
/// maxresdefault 404s on some videos, so that URL falls back to hqdefault.
async fn set_cover_from_url(
app: &tauri::AppHandle,
lib: &State<'_, LibraryState>,
id: i64,
url: &str,
) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1")
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| e.to_string())?;
let mut candidates = vec![url.to_string()];
if let Some(rest) = url.strip_suffix("maxresdefault.jpg") {
candidates.push(format!("{rest}hqdefault.jpg"));
}
let mut bytes: Option<Vec<u8>> = None;
for candidate in candidates {
if let Ok(resp) = client.get(&candidate).send().await {
if resp.status().is_success() {
if let Ok(body) = resp.bytes().await {
// YouTube's CDN can answer a missing size with a tiny
// gray placeholder instead of a 404 — refuse those.
if body.len() > 4096 {
bytes = Some(body.to_vec());
break;
}
}
}
}
}
let bytes = bytes.ok_or("no usable image answered")?;
let dir = covers_dir(app)?;
let target = dir.join(format!("{id}.jpg"));
let jpeg = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<u8>, String> {
let img =
image::load_from_memory(&bytes).map_err(|e| format!("not a usable image: {e}"))?;
Ok(square_jpeg(&img, COVER_MASTER_EDGE))
})
.await
.map_err(|e| e.to_string())??;
let temp = dir.join(format!("{id}.importing"));
std::fs::write(&temp, &jpeg).map_err(|e| e.to_string())?;
std::fs::rename(&temp, &target).map_err(|e| e.to_string())?;
let l = lib.library.clone();
let stored = target.to_string_lossy().into_owned();
tauri::async_runtime::spawn_blocking(move || {
l.set_playlist_cover(id, Some(&stored), now_secs())
})
.await
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
Ok(())
}
/// Resolve parsed entries against the local library, in memory. /// Resolve parsed entries against the local library, in memory.
/// ///
/// The ladder, strongest evidence first (identity rules: an exact locator /// The ladder, strongest evidence first (identity rules: an exact locator
@@ -931,7 +1077,7 @@ pub async fn playlist_import_commit(
/// 3. a unique basename match in the library index; /// 3. a unique basename match in the library index;
/// 4. a unique normalized (artist, title) tag match; /// 4. a unique normalized (artist, title) tag match;
/// 5. a unique normalized title match, when the entry names no artist. /// 5. a unique normalized title match, when the entry names no artist.
async fn resolve_draft( pub(crate) async fn resolve_draft(
lib: &State<'_, LibraryState>, lib: &State<'_, LibraryState>,
parsed: playlist_formats::ParsedPlaylist, parsed: playlist_formats::ParsedPlaylist,
source_name: String, source_name: String,
@@ -980,7 +1126,7 @@ async fn resolve_draft(
}; };
let mut entries = Vec::new(); let mut entries = Vec::new();
let (mut resolved, mut unresolved) = (0u32, 0u32); let (mut resolved, mut streams, mut unresolved) = (0u32, 0u32, 0u32);
let mut any_start = false; let mut any_start = false;
for entry in &parsed.entries { for entry in &parsed.entries {
let ParsedEntry { let ParsedEntry {
@@ -1022,6 +1168,9 @@ async fn resolve_draft(
matched_by: Some("path".into()), matched_by: Some("path".into()),
path: Some(abs), path: Some(abs),
art_path: None, art_path: None,
track_id: None,
art_url: None,
isrc: None,
}); });
resolved += 1; resolved += 1;
continue; continue;
@@ -1090,10 +1239,26 @@ async fn resolve_draft(
matched_by: Some(how.into()), matched_by: Some(how.into()),
path: Some(row.path.clone()), path: Some(row.path.clone()),
art_path: row.art_path.clone(), art_path: row.art_path.clone(),
track_id: None,
art_url: None,
isrc: None,
}); });
} }
None => { None => {
unresolved += 1; // No library file — but a row with FULL identity becomes a
// STREAM on commit (the insert classifier's rule), so the
// preview says so instead of "unresolved": Kyle read that
// label as "not identified" on a fully-recognized set.
// "unresolved" now means what it says: identity too thin
// to play (ID rows, title-only lines).
let full_identity = title.is_some()
&& artist.as_deref().map(|a| !a.trim().is_empty()).unwrap_or(false)
&& title.as_deref() != Some("ID");
if full_identity {
streams += 1;
} else {
unresolved += 1;
}
entries.push(DraftEntry { entries.push(DraftEntry {
ordinal: *ordinal, ordinal: *ordinal,
title: title.clone().unwrap_or_else(|| "Unknown".into()), title: title.clone().unwrap_or_else(|| "Unknown".into()),
@@ -1102,10 +1267,13 @@ async fn resolve_draft(
duration_secs: *duration_secs, duration_secs: *duration_secs,
start_ms: *start_ms, start_ms: *start_ms,
raw: raw.clone(), raw: raw.clone(),
resolution: "unresolved".into(), resolution: if full_identity { "stream" } else { "unresolved" }.into(),
matched_by: None, matched_by: None,
path: None, path: None,
art_path: None, art_path: None,
track_id: None,
art_url: None,
isrc: None,
}); });
} }
} }
@@ -1119,7 +1287,9 @@ async fn resolve_draft(
entries, entries,
warnings: parsed.warnings, warnings: parsed.warnings,
resolved_count: resolved, resolved_count: resolved,
stream_count: streams,
unresolved_count: unresolved, unresolved_count: unresolved,
cover_url: None,
}) })
} }
@@ -1277,3 +1447,4 @@ pub async fn playlist_export(
.map_err(|e| e.to_string())??; .map_err(|e| e.to_string())??;
Ok(Some(dest)) Ok(Some(dest))
} }
File diff suppressed because it is too large. Load diff
+478
View File
@@ -0,0 +1,478 @@
//! Shazam-style audio recognition for the DJ-set setlist assistant — the
//! research bundle's "broader recognition adapter" (feasibility §3 rung 4,
//! plan phase 7), Kyle's pick 2026-08-19: identify a mix's tracks by
//! LISTENING, not just by reading its posted tracklist.
//!
//! The signature algorithm and packet format are reimplemented from the
//! publicly documented Shazam fingerprint (Wang 2003; layout and constants
//! cross-checked against the open-source SongRec project's description of
//! the format). Nothing here links foreign code: plain Rust over the same
//! realfft the vocal separator uses.
//!
//! Posture: in-process, no child processes; ONE request per analyzed
//! window against the free discovery endpoint, paced by the caller
//! (research §11 — rate limits, timeout, cache belong to the job layer,
//! built next). Failure is always soft: a window that cannot be
//! recognized is an honest Unknown, never a guess.
use realfft::num_complex::Complex;
use realfft::RealFftPlanner;
/// One recognized window: what Shazam heard and where it claims the window
/// sits inside the identified track.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Recognition {
pub artist: String,
pub title: String,
/// Shazam track key — stable id for consensus grouping.
pub key: String,
/// Offset of the analyzed window within the identified track, seconds,
/// when the response carries one (it usually does).
pub offset_secs: Option<f64>,
pub album: Option<String>,
pub cover_url: Option<String>,
/// The matched RELEASE's ISRC when the response carries one — an
/// id-grade identity the review can resolve against the catalog
/// exactly, instead of by name.
pub isrc: Option<String>,
}
// ---- signature generation (pure DSP) ----------------------------------------
const FFT_SIZE: usize = 2048;
const BINS: usize = FFT_SIZE / 2 + 1; // 1025
const HOP: usize = 128;
/// The generator's working window: 12 s at 16 kHz mono, the length the
/// service expects a tag to be.
pub const WINDOW_SECS: usize = 12;
pub const SAMPLE_RATE: usize = 16_000;
struct Rings {
samples: [i16; FFT_SIZE],
samples_at: usize,
ffts: Vec<[f32; BINS]>,
ffts_at: u8,
spread: Vec<[f32; BINS]>,
spread_at: u8,
passes: u32,
peaks: [Vec<Peak>; 4],
}
struct Peak {
pass: u32,
magnitude: u16,
corrected_bin: u16,
}
/// Symmetric Hann of length 2048 (numpy-style, N−1 denominator) — the
/// window the reference tables were generated from.
fn hann() -> Vec<f32> {
(0..FFT_SIZE)
.map(|i| {
0.5 - 0.5
* (2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32).cos()
})
.collect()
}
/// Build the binary signature for ≤ WINDOW_SECS of 16 kHz mono PCM.
pub fn signature(pcm: &[f32]) -> Vec<u8> {
let mut samples: Vec<i16> = pcm
.iter()
.map(|&x| (x * 32768.0).clamp(-32768.0, 32767.0) as i16)
.collect();
let want = WINDOW_SECS * SAMPLE_RATE;
if samples.len() < want {
samples.resize(want, 0);
} else {
samples.truncate(want);
}
let window = hann();
let mut planner = RealFftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
let mut rings = Rings {
samples: [0i16; FFT_SIZE],
samples_at: 0,
ffts: vec![[0.0; BINS]; 256],
ffts_at: 0,
spread: vec![[0.0; BINS]; 256],
spread_at: 0,
passes: 0,
peaks: Default::default(),
};
let mut re_in = vec![0.0f32; FFT_SIZE];
let mut cx_out = vec![Complex::default(); BINS];
for chunk in samples.chunks_exact(HOP) {
// Ring in the new hop, then window the reordered buffer.
for (i, &s) in chunk.iter().enumerate() {
rings.samples[(rings.samples_at + i) & (FFT_SIZE - 1)] = s;
}
rings.samples_at = (rings.samples_at + HOP) & (FFT_SIZE - 1);
for i in 0..FFT_SIZE {
re_in[i] =
rings.samples[(i + rings.samples_at) & (FFT_SIZE - 1)] as f32 * window[i];
}
let _ = fft.process(&mut re_in, &mut cx_out);
{
let out = &mut rings.ffts[rings.ffts_at as usize];
for (o, c) in out.iter_mut().zip(cx_out.iter()) {
*o = ((c.re * c.re + c.im * c.im) / (1 << 17) as f32).max(1e-10);
}
}
rings.ffts_at = rings.ffts_at.wrapping_add(1);
// Frequency- then time-domain peak spreading.
{
let src = rings.ffts[rings.ffts_at.wrapping_sub(1) as usize];
let dst = &mut rings.spread[rings.spread_at as usize];
dst.copy_from_slice(&src);
for i in 0..=BINS - 3 {
dst[i] = dst[i].max(dst[i + 1]).max(dst[i + 2]);
}
let snapshot = *dst;
for former in [1u8, 3, 6] {
let f = &mut rings.spread[rings.spread_at.wrapping_sub(former) as usize];
for i in 0..BINS {
f[i] = f[i].max(snapshot[i]);
}
}
}
rings.spread_at = rings.spread_at.wrapping_add(1);
rings.passes += 1;
if rings.passes >= 46 {
recognize_peaks(&mut rings);
}
}
encode(&rings.peaks, want as u32)
}
fn recognize_peaks(r: &mut Rings) {
let f46 = r.ffts[r.ffts_at.wrapping_sub(46) as usize];
let s49 = r.spread[r.spread_at.wrapping_sub(49) as usize];
for bin in 10..=BINS - 11 {
if f46[bin] < 1.0 / 64.0 || f46[bin] < s49[bin - 1] {
continue;
}
let mut neighbor_max = 0.0f32;
for off in [-10i32, -7, -4, -3, 1, 2, 5, 8] {
neighbor_max = neighbor_max.max(s49[(bin as i32 + off) as usize]);
}
if f46[bin] <= neighbor_max {
continue;
}
let mut other_max = neighbor_max;
for off in [
-53i32, -45, 165, 172, 179, 186, 193, 200, 214, 221, 228, 235, 242, 249,
] {
let other = &r.spread[((r.spread_at as i32 + off) & 255) as usize];
other_max = other_max.max(other[bin - 1]);
}
if f46[bin] <= other_max {
continue;
}
let pass = r.passes - 46;
let mag = f46[bin].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
let before = f46[bin - 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
let after = f46[bin + 1].ln().max(1.0 / 64.0) * 1477.3 + 6144.0;
let variation_1 = mag * 2.0 - before - after;
if variation_1 < 0.0 {
continue;
}
let variation_2 = (after - before) * 32.0 / variation_1;
let corrected = (bin as i32 * 64 + variation_2 as i32) as u16;
let hz = corrected as f32 * (SAMPLE_RATE as f32 / 2.0 / 1024.0 / 64.0);
let band = match hz as i32 {
250..=519 => 0,
520..=1449 => 1,
1450..=3499 => 2,
3500..=5500 => 3,
_ => continue,
};
r.peaks[band].push(Peak {
pass,
magnitude: mag as u16,
corrected_bin: corrected,
});
}
}
/// The wire format: 48-byte header (magic, CRC-32 of everything after the
/// first 8 bytes, payload size, 16 kHz marker, sample count + 0.24 s), a
/// type-length echo, then per-band peak runs with delta-coded pass numbers.
fn encode(peaks: &[Vec<Peak>; 4], number_samples: u32) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(4096);
let w32 = |out: &mut Vec<u8>, v: u32| out.extend_from_slice(&v.to_le_bytes());
let w16 = |out: &mut Vec<u8>, v: u16| out.extend_from_slice(&v.to_le_bytes());
w32(&mut out, 0xcafe2580); // magic1
w32(&mut out, 0); // crc32 backfilled
w32(&mut out, 0); // size backfilled
w32(&mut out, 0x94119c00); // magic2
w32(&mut out, 0);
w32(&mut out, 0);
w32(&mut out, 0);
w32(&mut out, 3u32 << 27); // 16 kHz
w32(&mut out, 0);
w32(&mut out, 0);
w32(&mut out, number_samples + (SAMPLE_RATE as f32 * 0.24) as u32);
w32(&mut out, (15 << 19) + 0x40000);
w32(&mut out, 0x40000000);
w32(&mut out, 0); // size echo backfilled
for (band, list) in peaks.iter().enumerate() {
if list.is_empty() {
continue;
}
let mut body: Vec<u8> = Vec::with_capacity(list.len() * 5);
let mut pass = 0u32;
for p in list {
if p.pass - pass >= 255 {
body.push(0xff);
body.extend_from_slice(&p.pass.to_le_bytes());
pass = p.pass;
}
body.push((p.pass - pass) as u8);
body.extend_from_slice(&p.magnitude.to_le_bytes());
body.extend_from_slice(&p.corrected_bin.to_le_bytes());
pass = p.pass;
}
w32(&mut out, 0x60030040 + band as u32);
w32(&mut out, body.len() as u32);
out.extend_from_slice(&body);
while out.len() % 4 != 0 {
out.push(0);
}
}
let _ = w16; // (kept for symmetry; u16 writes happen inside the body)
let size = (out.len() - 48) as u32;
out[8..12].copy_from_slice(&size.to_le_bytes());
out[52..56].copy_from_slice(&size.to_le_bytes());
let mut hasher = crc32fast::Hasher::new();
hasher.update(&out[8..]);
let crc = hasher.finalize();
out[4..8].copy_from_slice(&crc.to_le_bytes());
out
}
// ---- the discovery request ----------------------------------------------------
/// Pseudo-UUID v4 from OS entropy via the stdlib hasher — unique enough for
/// the tag ids the endpoint expects, no uuid/rand dependency.
fn pseudo_uuid() -> String {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let mut parts = [0u64; 2];
for p in parts.iter_mut() {
let mut h = RandomState::new().build_hasher();
h.write_u64(std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0));
*p = h.finish();
}
let b: Vec<u8> = parts.iter().flat_map(|p| p.to_le_bytes()).collect();
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-4{:01x}{:02x}-a{:01x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[0], b[1], b[2], b[3], b[4], b[5], b[6] & 0xf, b[7], b[8] & 0xf, b[9], b[10], b[11], b[12], b[13], b[14], b[15]
)
}
/// Recognize one ≤12 s window of 16 kHz mono PCM. `Ok(None)` = the service
/// answered and heard nothing it knows — an honest Unknown. `Err` = the
/// request itself failed (network, rate limit) and the caller may retry
/// later.
pub async fn recognize_window(pcm16k: &[f32]) -> Result<Option<Recognition>, String> {
let sig = signature(pcm16k);
use base64::Engine as _;
let uri = format!(
"data:audio/vnd.shazam.sig;base64,{}",
base64::engine::general_purpose::STANDARD.encode(&sig)
);
let sample_ms = (pcm16k.len().min(WINDOW_SECS * SAMPLE_RATE) * 1000 / SAMPLE_RATE) as u64;
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let body = serde_json::json!({
"geolocation": { "altitude": 300, "latitude": 45, "longitude": 2 },
"signature": { "samplems": sample_ms, "timestamp": ts, "uri": uri },
"timestamp": ts,
"timezone": "Europe/Paris",
});
let url = format!(
"https://amp.shazam.com/discovery/v5/en/US/android/-/tag/{}/{}?sync=true&webv3=true&sampling=true&connected=&shazamapiversion=v3&sharehub=true&video=v3",
pseudo_uuid().to_uppercase(),
pseudo_uuid()
);
let client = reqwest::Client::builder()
.user_agent("Dalvik/2.1.0 (Linux; U; Android 13; Pixel 7 Build/TQ3A.230901.001)")
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post(&url)
.header("Content-Language", "en_US")
.json(&body)
.send()
.await
.map_err(|e| format!("Shazam did not answer: {e}"))?;
if resp.status().as_u16() == 429 {
return Err("rate-limited by the recognition service".into());
}
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("unreadable recognition response: {e}"))?;
let Some(track) = v.get("track") else {
return Ok(None);
};
let title = track
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
let artist = track
.get("subtitle")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
if title.is_empty() {
return Ok(None);
}
let key = track
.get("key")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string();
let offset_secs = v
.get("matches")
.and_then(|m| m.get(0))
.and_then(|m| m.get("offset"))
.and_then(serde_json::Value::as_f64);
// Album rides in the metadata sections when present.
let mut album = None;
if let Some(sections) = track.get("sections").and_then(serde_json::Value::as_array) {
for s in sections {
if let Some(meta) = s.get("metadata").and_then(serde_json::Value::as_array) {
for m in meta {
if m.get("title").and_then(serde_json::Value::as_str) == Some("Album") {
album = m
.get("text")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
}
}
}
}
}
let cover_url = track
.get("images")
.and_then(|i| i.get("coverart"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let isrc = track
.get("isrc")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(Some(Recognition {
artist,
title,
key,
offset_secs,
album,
cover_url,
isrc,
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// The packet must round out structurally: magics in place, CRC over
/// bytes 8.., size echoed at 8 and 52, bands tagged 0x60030040+n.
#[test]
fn signature_packet_is_well_formed() {
// A tone sweep gives the recognizer real peaks to encode.
let pcm: Vec<f32> = (0..WINDOW_SECS * SAMPLE_RATE)
.map(|i| {
let t = i as f32 / SAMPLE_RATE as f32;
(2.0 * std::f32::consts::PI * (440.0 + 80.0 * t) * t).sin() * 0.4
})
.collect();
let sig = signature(&pcm);
assert!(sig.len() > 64, "packet too small: {}", sig.len());
assert_eq!(&sig[0..4], &0xcafe2580u32.to_le_bytes());
assert_eq!(&sig[12..16], &0x94119c00u32.to_le_bytes());
let size = u32::from_le_bytes(sig[8..12].try_into().unwrap());
assert_eq!(size as usize, sig.len() - 48);
assert_eq!(&sig[52..56], &size.to_le_bytes());
let mut h = crc32fast::Hasher::new();
h.update(&sig[8..]);
assert_eq!(u32::from_le_bytes(sig[4..8].try_into().unwrap()), h.finalize());
let band_tag = u32::from_le_bytes(sig[56..60].try_into().unwrap());
assert!(
(0x60030040..=0x60030043).contains(&band_tag),
"first band tag {band_tag:#x}"
);
}
/// A pure tone at 1 kHz must place its peaks in band 1 (520–1450 Hz).
#[test]
fn peaks_land_in_the_right_band() {
let pcm: Vec<f32> = (0..WINDOW_SECS * SAMPLE_RATE)
.map(|i| (2.0 * std::f32::consts::PI * 1000.0 * i as f32 / 16000.0).sin() * 0.4)
.collect();
let mut samples: Vec<i16> = pcm.iter().map(|&x| (x * 32768.0) as i16).collect();
samples.truncate(WINDOW_SECS * SAMPLE_RATE);
let sig = signature(&pcm);
// Band 1's tag must appear; bands 0/2/3 may be empty or tiny.
let mut found_band1 = false;
let mut at = 56usize;
while at + 8 <= sig.len() {
let tag = u32::from_le_bytes(sig[at..at + 4].try_into().unwrap());
let len = u32::from_le_bytes(sig[at + 4..at + 8].try_into().unwrap()) as usize;
if tag == 0x60030041 && len > 50 {
found_band1 = true;
}
at += 8 + len + ((4 - len % 4) % 4);
}
assert!(found_band1, "1 kHz peaks must dominate band 1");
}
/// LIVE: recognize a 12 s window of a known library track against the
/// real service. Ignored by default — network, unofficial endpoint —
/// run explicitly: cargo test -p app shazam_live -- --ignored --nocapture
#[test]
#[ignore]
fn shazam_live_known_track() {
let path = std::path::Path::new(
"D:\\Music\\SoulSeek Music\\Panda Bear\\2015 - Panda Bear Meets The Grim Reaper\\1_SEQUENTIAL CIRCUITS.flac",
);
assert!(path.is_file(), "corpus track missing");
let [l, r] = player::vocals::separation_input_44k1(path).unwrap();
let mono = player::vocals::stem_pcm_16k(&l, &r);
// A mid-track window, well into the singing.
let start = 60 * SAMPLE_RATE;
let window = &mono[start..start + WINDOW_SECS * SAMPLE_RATE];
let rt = tokio::runtime::Runtime::new().unwrap();
let got = rt.block_on(recognize_window(window)).expect("request failed");
let rec = got.expect("service heard nothing — signature likely malformed");
println!(
"LIVE RESULT: {} — {} (key {}, offset {:?}, album {:?}, isrc {:?})",
rec.artist, rec.title, rec.key, rec.offset_secs, rec.album, rec.isrc
);
assert!(
rec.title.to_lowercase().contains("sequential"),
"wrong track: {} — {}",
rec.artist,
rec.title
);
}
}
+499
View File
@@ -0,0 +1,499 @@
//! Learned vocal separation for the lyric aligner — the "honest fix" the
//! vocals.rs reducer comment promises: a real separator lifting the voice
//! out of the band before CTC alignment, instead of band-pass pre-emphasis.
//!
//! Model: UVR-MDX-NET Voc_FT (KUIELab MDX-Net / TFC-TDF v2, the Ultimate
//! Vocal Remover project's vocal fine-tune; MIT with credit). Chosen
//! 2026-08-19 from the verified table in
//! research/lyrics-vocal-separation/candidates.md: 63.7 MiB, ONNX opset 13 —
//! under the inbox-WinML ceiling this machine PROVED by running the MMS
//! aligner (opset 14) — static `input`/`output` [1,4,3072,256] f32, vocals
//! SDR 9.64 on the MVSEP multisong benchmark (+1.5 dB over htdemucs at a
//! fifth of the size; the RoFormer/Demucs ONNX exports are opset 17–23 and
//! cannot load here as published).
//!
//! Posture is lyric_align.rs's exactly: WinML (no onnxruntime, no child
//! processes), download only from the user's explicit Settings click, the
//! session cached in a static — and NOTHING depends on this module. When
//! the model is absent or anything fails, the aligner keeps its band-passed
//! mixture input (the ctc-v4 behavior).
//!
//! The inference recipe is UVR's own non-overlap framing, kept bit-faithful
//! so the model sees what it was trained on: stereo 44.1 kHz → per-chunk
//! centered STFT (n_fft 7680, hop 1024, periodic Hann, reflect pad) → crop
//! to 3072 bins → model (channels [L_re, L_im, R_re, R_im]) → vocal
//! spectrogram → zero-pad the cropped bins back → WOLA iSTFT → trim
//! n_fft/2 from each end → butt-join. Each chunk contributes 253,440 fresh
//! samples (~5.75 s), so a four-minute track is ~42 model evaluations.
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use realfft::num_complex::Complex;
use realfft::{ComplexToReal, RealFftPlanner, RealToComplex};
use tauri::Emitter;
use windows::{
core::{Interface, HSTRING},
AI::MachineLearning::{
LearningModel, LearningModelBinding, LearningModelDevice, LearningModelDeviceKind,
LearningModelSession, TensorFloat,
},
Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED},
};
const MODEL_URL: &str =
"https://github.com/TRvlvr/model_repo/releases/download/all_public_uvr_models/UVR-MDX-NET-Voc_FT.onnx";
/// Exact pinned size, shown in Settings and enforced after download — if the
/// asset is ever republished with different bytes, refuse rather than trust.
/// (Verified against the served bytes AND the file's own protobuf tail:
/// opset 13, `input`/`output` [batch,4,3072,256]. Kim_Vocal_2 is 66,759,214
/// — a first pin mistakenly used that sibling's size, and this guard caught
/// it exactly as designed.)
const MODEL_BYTES: u64 = 66_762_490;
// UVR MDX family constants (their model_data.json for this checkpoint:
// dim_f 3072, dim_t 2^8, n_fft 7680; hop is the architecture's fixed 1024).
const N_FFT: usize = 7_680;
const HOP: usize = 1_024;
const BINS: usize = N_FFT / 2 + 1; // 3841
const DIM_F: usize = 3_072;
const DIM_T: usize = 256;
/// Samples one model window covers: 256 centered STFT frames at hop 1024.
const CHUNK: usize = HOP * (DIM_T - 1); // 261,120
/// STFT edge effects live inside half a window; UVR trims this from each
/// chunk end and butt-joins the rest — no cross-fade, nothing to tune, and
/// the kept region sits in full window-overlap coverage by construction.
const TRIM: usize = N_FFT / 2; // 3840
/// Fresh samples each chunk contributes (~5.75 s at 44.1 kHz).
const GEN: usize = CHUNK - 2 * TRIM; // 253,440
/// Flat f32 count of the model's input and output: [1, 4, 3072, 256].
const TENSOR_LEN: usize = 4 * DIM_F * DIM_T;
struct Engine {
session: LearningModelSession,
/// Which device kind actually carried the smoke eval — for the log line.
device: &'static str,
}
static ENGINE: Mutex<Option<Engine>> = Mutex::new(None);
fn model_file(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::lyric_align::models_dir(app)?.join("uvr-mdx-voc-ft.onnx"))
}
pub fn model_present(app: &tauri::AppHandle) -> bool {
matches!(model_file(app), Ok(m) if m.is_file())
}
#[tauri::command]
pub fn vocal_sep_status(app: tauri::AppHandle) -> serde_json::Value {
let size = model_file(&app)
.ok()
.and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len())
.unwrap_or(0);
serde_json::json!({
"present": model_present(&app),
"bytes": size,
"downloadBytes": MODEL_BYTES,
})
}
/// Download the separation model. Runs ONLY from the user's explicit
/// Settings click — the consent-before-download charter rule. Streams to a
/// temp file, verifies the exact pinned byte count, renames atomically,
/// emits `lyrics:sep-model-progress` along the way.
#[tauri::command]
pub async fn vocal_sep_download(app: tauri::AppHandle) -> Result<(), String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1 (vocal separation)")
.build()
.map_err(|e| e.to_string())?;
let dest = model_file(&app)?;
let tmp = dest.with_extension("onnx.downloading");
let mut response = client
.get(MODEL_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(MODEL_BYTES);
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut received: u64 = 0;
let mut last_emit: u64 = 0;
use std::io::Write;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if received - last_emit > 2 * 1024 * 1024 {
last_emit = received;
let _ = app.emit(
"lyrics:sep-model-progress",
serde_json::json!({ "received": received, "total": total }),
);
}
}
drop(file);
if received != MODEL_BYTES {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"download size mismatch: {received} bytes, expected {MODEL_BYTES}"
));
}
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
let _ = app.emit(
"lyrics:sep-model-progress",
serde_json::json!({ "received": received, "total": total, "done": true }),
);
Ok(())
}
fn ensure_engine(app: &tauri::AppHandle) -> Result<(), String> {
let mut guard = ENGINE.lock().unwrap();
if guard.is_some() {
return Ok(());
}
let model_path = model_file(app)?;
if !model_path.is_file() {
return Err("separation model not downloaded".into());
}
// Same WinRT posture as the aligner: initialize, tolerate an existing
// apartment, never uninitialize someone else's.
match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
Ok(()) => {}
Err(e) if e.code().0 as u32 == 0x8001_0106 => {}
Err(e) => return Err(format!("WinRT init failed: {e}")),
}
let model =
LearningModel::LoadFromFilePath(&HSTRING::from(model_path.to_string_lossy().as_ref()))
.map_err(|e| format!("could not load separation model: {e}"))?;
// DirectX first (DirectML — the discrete GPU when there is one): a track
// is ~42 conv-net evaluations and the GPU turns minutes into seconds.
// The zeros smoke eval proves the device can actually RUN the graph —
// "loads but cannot execute" is a real DML failure mode — then Cpu is
// the quiet fallback, so this works everywhere the aligner does.
for (kind, name) in [
(LearningModelDeviceKind::DirectX, "DirectX"),
(LearningModelDeviceKind::Cpu, "Cpu"),
] {
let Ok(device) = LearningModelDevice::Create(kind) else {
continue;
};
let Ok(session) = LearningModelSession::CreateFromModelOnDevice(&model, &device) else {
continue;
};
let zeros = vec![0.0f32; TENSOR_LEN];
if eval_chunk(&session, &zeros).is_ok() {
*guard = Some(Engine {
session,
device: name,
});
return Ok(());
}
}
Err("no WinML device could run the separation model".into())
}
fn eval_chunk(session: &LearningModelSession, flat: &[f32]) -> Result<Vec<f32>, String> {
let input = TensorFloat::CreateFromShapeArrayAndDataArray(
&[1, 4, DIM_F as i64, DIM_T as i64],
flat,
)
.map_err(|e| format!("could not create input tensor: {e}"))?;
let binding = LearningModelBinding::CreateFromSession(session)
.map_err(|e| format!("could not create binding: {e}"))?;
binding
.Bind(&HSTRING::from("input"), &input)
.map_err(|e| format!("could not bind input: {e}"))?;
let result = session
.Evaluate(&binding, &HSTRING::from("vocal-sep"))
.map_err(|e| format!("inference failed: {e}"))?;
if !result.Succeeded().unwrap_or(false) {
return Err("inference did not succeed".into());
}
let out: TensorFloat = result
.Outputs()
.and_then(|o| o.Lookup(&HSTRING::from("output")))
.map_err(|e| format!("output missing: {e}"))?
.cast()
.map_err(|e| format!("output wrong type: {e}"))?;
let values: Vec<f32> = out
.GetAsVectorView()
.map_err(|e| format!("could not read output: {e}"))?
.into_iter()
.collect();
if values.len() != TENSOR_LEN {
return Err(format!("unexpected output length {}", values.len()));
}
Ok(values)
}
// ---- STFT plumbing (pure DSP — no WinML below this line) -------------------
struct Stft {
r2c: Arc<dyn RealToComplex<f32>>,
c2r: Arc<dyn ComplexToReal<f32>>,
/// Periodic Hann — torch.hann_window's default, what the model trained on.
window: Vec<f32>,
/// WOLA denominator Σ w² over the padded chunk layout. The 256 frames
/// tile the padded buffer exactly, so it is identical for every chunk.
envelope: Vec<f32>,
}
impl Stft {
fn new() -> Self {
let mut planner = RealFftPlanner::<f32>::new();
let r2c = planner.plan_fft_forward(N_FFT);
let c2r = planner.plan_fft_inverse(N_FFT);
let window: Vec<f32> = (0..N_FFT)
.map(|n| 0.5 - 0.5 * (2.0 * std::f32::consts::PI * n as f32 / N_FFT as f32).cos())
.collect();
let mut envelope = vec![1e-8f32; CHUNK + 2 * TRIM];
for t in 0..DIM_T {
for (n, w) in window.iter().enumerate() {
envelope[t * HOP + n] += w * w;
}
}
Stft {
r2c,
c2r,
window,
envelope,
}
}
/// Centered STFT of one CHUNK-sized segment: reflect-pad n_fft/2 each
/// side (torch.stft center=True), frames at the hop — frame-major
/// [t * BINS + f].
fn forward(&self, chunk: &[f32]) -> Result<Vec<Complex<f32>>, String> {
debug_assert_eq!(chunk.len(), CHUNK);
let mut padded = vec![0.0f32; CHUNK + 2 * TRIM];
padded[TRIM..TRIM + CHUNK].copy_from_slice(chunk);
for i in 1..=TRIM {
padded[TRIM - i] = chunk[i];
padded[TRIM + CHUNK - 1 + i] = chunk[CHUNK - 1 - i];
}
let mut spec = vec![Complex::default(); DIM_T * BINS];
let mut frame = vec![0.0f32; N_FFT];
for t in 0..DIM_T {
for (n, w) in self.window.iter().enumerate() {
frame[n] = padded[t * HOP + n] * w;
}
self.r2c
.process(&mut frame, &mut spec[t * BINS..(t + 1) * BINS])
.map_err(|e| format!("stft failed: {e}"))?;
}
Ok(spec)
}
/// WOLA inverse of a frame-major spectrogram back to CHUNK samples with
/// the centered pad trimmed off — torch.istft's window-envelope
/// normalization. Consumes the spectrogram (the FFT scratches it).
fn inverse(&self, spec: &mut [Complex<f32>]) -> Result<Vec<f32>, String> {
let mut acc = vec![0.0f32; CHUNK + 2 * TRIM];
let mut frame = vec![0.0f32; N_FFT];
let scale = 1.0 / N_FFT as f32; // realfft's c2r is unnormalized
for t in 0..DIM_T {
let row = &mut spec[t * BINS..(t + 1) * BINS];
// The model emits free re/im everywhere; a real inverse needs
// pure-real DC and Nyquist. Nyquist sits in the cropped-zero
// region already, DC gets clamped here.
row[0].im = 0.0;
row[BINS - 1].im = 0.0;
self.c2r
.process(row, &mut frame)
.map_err(|e| format!("istft failed: {e}"))?;
for (n, w) in self.window.iter().enumerate() {
acc[t * HOP + n] += frame[n] * scale * w;
}
}
Ok((TRIM..TRIM + CHUNK)
.map(|i| acc[i] / self.envelope[i])
.collect())
}
}
/// Pack two frame-major spectrograms into the model's flat input — channel
/// order [L_re, L_im, R_re, R_im] (UVR's reshape/permute order), frequency
/// cropped to DIM_F.
fn pack_input(spec_l: &[Complex<f32>], spec_r: &[Complex<f32>]) -> Vec<f32> {
let mut flat = vec![0.0f32; TENSOR_LEN];
for (c, spec) in [spec_l, spec_r].into_iter().enumerate() {
for f in 0..DIM_F {
for t in 0..DIM_T {
let z = spec[t * BINS + f];
flat[((2 * c) * DIM_F + f) * DIM_T + t] = z.re;
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t] = z.im;
}
}
}
flat
}
/// Unpack the model's flat output back into two frame-major spectrograms.
/// Bins at and above DIM_F stay zero — the crop restored as silence, which
/// for a vocal stem is honest (nothing sung lives above ~17.6 kHz).
fn unpack_output(flat: &[f32]) -> (Vec<Complex<f32>>, Vec<Complex<f32>>) {
let mut left = vec![Complex::default(); DIM_T * BINS];
let mut right = vec![Complex::default(); DIM_T * BINS];
for (c, spec) in [&mut left, &mut right].into_iter().enumerate() {
for f in 0..DIM_F {
for t in 0..DIM_T {
spec[t * BINS + f] = Complex::new(
flat[((2 * c) * DIM_F + f) * DIM_T + t],
flat[((2 * c + 1) * DIM_F + f) * DIM_T + t],
);
}
}
}
(left, right)
}
/// Chunk a stereo 44.1 kHz track through `run` (the [1,4,3072,256] →
/// same-shape spectrogram transform) and reassemble the returned stem.
/// UVR's non-overlap framing: TRIM zeros lead the track so chunk c's kept
/// middle lands exactly at c·GEN, edge effects fall in the trimmed halves,
/// and segments butt-join.
fn process_chunks(
stft: &Stft,
left: &[f32],
right: &[f32],
mut run: impl FnMut(&[f32]) -> Result<Vec<f32>, String>,
) -> Result<(Vec<f32>, Vec<f32>), String> {
let len = left.len().min(right.len());
if len < HOP {
return Err("audio too short to separate".into());
}
let chunks = len.div_ceil(GEN);
let padded_len = TRIM + chunks * GEN + TRIM;
let mut mix_l = vec![0.0f32; padded_len];
let mut mix_r = vec![0.0f32; padded_len];
mix_l[TRIM..TRIM + len].copy_from_slice(&left[..len]);
mix_r[TRIM..TRIM + len].copy_from_slice(&right[..len]);
let mut stem_l = vec![0.0f32; chunks * GEN];
let mut stem_r = vec![0.0f32; chunks * GEN];
for c in 0..chunks {
let s = c * GEN;
let spec_l = stft.forward(&mix_l[s..s + CHUNK])?;
let spec_r = stft.forward(&mix_r[s..s + CHUNK])?;
let out = run(&pack_input(&spec_l, &spec_r))?;
if out.len() != TENSOR_LEN {
return Err(format!("unexpected chunk output length {}", out.len()));
}
let (mut vl, mut vr) = unpack_output(&out);
let wl = stft.inverse(&mut vl)?;
let wr = stft.inverse(&mut vr)?;
stem_l[s..s + GEN].copy_from_slice(&wl[TRIM..TRIM + GEN]);
stem_r[s..s + GEN].copy_from_slice(&wr[TRIM..TRIM + GEN]);
// Politeness between chunks — this thread must never contend with
// the audio path for sustained stretches (the passage loop's rule).
std::thread::sleep(std::time::Duration::from_millis(3));
}
stem_l.truncate(len);
stem_r.truncate(len);
Ok((stem_l, stem_r))
}
/// Whole-file vocal stem as 16 kHz mono for the CTC aligner: decode →
/// chunked WinML separation → mid fold + anti-alias resample (player crate).
/// Fails soft by design — the caller falls back to the band-passed mixture.
pub fn separate_vocals_16k(
app: &tauri::AppHandle,
path: &std::path::Path,
) -> Result<Vec<f32>, String> {
if !model_present(app) {
return Err("separation model not downloaded".into());
}
let [left, right] = player::vocals::separation_input_44k1(path)?;
ensure_engine(app)?;
let guard = ENGINE.lock().unwrap();
let engine = guard.as_ref().ok_or("engine unavailable")?;
let stft = Stft::new();
let started = Instant::now();
let (stem_l, stem_r) = process_chunks(&stft, &left, &right, |flat| {
eval_chunk(&engine.session, flat)
})?;
log::info!(
"vocal_sep: {:.0}s of audio separated in {:.1}s on {}",
stem_l.len() as f32 / 44_100.0,
started.elapsed().as_secs_f32(),
engine.device
);
Ok(player::vocals::stem_pcm_16k(&stem_l, &stem_r))
}
#[cfg(test)]
mod tests {
use super::*;
/// The WOLA plumbing alone — forward STFT straight back through the
/// inverse, no crop — must be per-sample exact: the numerator and the
/// envelope share the same window algebra, so any real error here is a
/// tiling or normalization bug, not numerics.
#[test]
fn stft_wola_roundtrip_is_exact() {
let chunk: Vec<f32> = (0..CHUNK)
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
.collect();
let stft = Stft::new();
let mut spec = stft.forward(&chunk).unwrap();
let back = stft.inverse(&mut spec).unwrap();
let worst = chunk
.iter()
.zip(&back)
.map(|(a, b)| (a - b).abs())
.fold(0.0f32, f32::max);
assert!(worst < 1e-4, "un-cropped WOLA must be exact, worst {worst}");
}
/// An identity model (output spectrogram = input spectrogram) must hand
/// the track back through the whole STFT → crop → pack → unpack →
/// zero-pad → iSTFT → trim → butt-join pipeline: proves the frame
/// tiling, the chunk joins, and the layout round-trip all at once.
/// Tolerance: the reflect pad puts a derivative kink at each chunk
/// edge whose broadband energy reaches above bin 3072, and the model
/// crop discards it — torch.stft + UVR's own framing lose exactly the
/// same (~1% here); the model trained on that world.
#[test]
fn identity_model_reconstructs_across_chunk_joins() {
let len = GEN + GEN / 2; // two chunks, one join, one truncation
let track: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * 440.0 * i as f32 / 44_100.0).sin() * 0.5)
.collect();
let stft = Stft::new();
let (out_l, out_r) =
process_chunks(&stft, &track, &track, |flat| Ok(flat.to_vec())).unwrap();
assert_eq!(out_l.len(), len);
assert_eq!(out_r.len(), len);
let mut worst = 0.0f32;
for (a, b) in track.iter().zip(&out_l) {
worst = worst.max((a - b).abs());
}
assert!(
worst < 2e-2,
"identity round-trip must reconstruct the tone, worst err {worst}"
);
// The join at GEN must not be a seam beyond that same crop error.
let seam = (GEN - 50..GEN + 50)
.map(|i| (track[i] - out_l[i]).abs())
.fold(0.0f32, f32::max);
assert!(seam < 2e-2, "butt-join must be seamless, err {seam}");
}
/// The kept region of every chunk sits in full window-overlap coverage:
/// the WOLA envelope there is flat and far from zero.
#[test]
fn envelope_is_stable_where_samples_are_kept() {
let stft = Stft::new();
let kept = &stft.envelope[2 * TRIM..2 * TRIM + GEN];
let min = kept.iter().cloned().fold(f32::INFINITY, f32::min);
let max = kept.iter().cloned().fold(0.0f32, f32::max);
assert!(min > 2.0, "envelope must be well-conditioned, min {min}");
assert!(
max / min < 1.5,
"envelope must be near-flat in the kept region, {min}..{max}"
);
}
}
+652
View File
@@ -0,0 +1,652 @@
//! YouTube DJ-set tracklist extraction — the evidence rungs the feasibility
//! research ranks FIRST (creator- and viewer-declared data): the video's
//! chapters, its description, and timestamped tracklists in top comments.
//! (TODO-Feature-Docs/playlist-management-and-setlist-import, research
//! §3/§15: "the high-value, reliable subset is metadata import … broader
//! automatic recognition must remain optional".) No audio is fetched and
//! nothing is recognized — this is metadata import, honest about it, and a
//! set with no posted tracklist simply reports that instead of guessing.
//!
//! Everything runs in-process over reqwest (the no-child-processes posture):
//! one GET of the watch page, plus at most one innertube POST for comments
//! when the page itself carried no tracklist. The extracted lines feed the
//! EXISTING pasted-tracklist pipeline (playlist_formats::parse_text →
//! resolve_draft → review modal → commit), so every downstream behavior —
//! setlist stamps, library matching, stream fallbacks, unresolved rows —
//! is the one already shipped and tested.
use serde_json::Value;
/// What one video yielded: identity for the draft header plus the chosen
/// tracklist text and where it came from.
pub struct SetlistFetch {
pub title: String,
pub author: String,
pub tracklist: String,
/// "chapters" | "description" | "a comment by …" — shown in the modal's
/// warnings so the evidence rung is visible (research §3: "show which
/// rung produced a result").
pub provenance: String,
pub warnings: Vec<String>,
}
/// Minimum timestamped lines before a block counts as a tracklist — one or
/// two stamps are a lyric quote or a "best drop" comment, not a setlist.
const MIN_TRACKLIST_LINES: usize = 3;
/// Top-level comments scanned for a tracklist before giving up.
const MAX_COMMENTS_SCANNED: usize = 40;
// ---- URL handling -----------------------------------------------------------
/// Accepts watch/short/live/embed/youtu.be URLs (and a bare 11-char id) and
/// returns the canonical video id.
pub fn video_id_from_url(url: &str) -> Option<String> {
let s = url.trim();
let is_id = |c: &str| {
c.len() == 11
&& c.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
};
if is_id(s) {
return Some(s.to_string());
}
let after_scheme = s.split("://").nth(1).unwrap_or(s);
let (host, rest) = after_scheme.split_once('/')?;
let host = host.split('@').next_back().unwrap_or(host).to_lowercase();
let host = host.split(':').next().unwrap_or(&host);
let path_q = rest.split('#').next().unwrap_or(rest);
let (path, query) = match path_q.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (path_q, None),
};
let candidate = if host == "youtu.be" {
path.split('/').next().map(str::to_string)
} else if host.ends_with("youtube.com") || host.ends_with("youtube-nocookie.com") {
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match segs.first().copied() {
Some("watch") => query.and_then(|q| {
q.split('&')
.find_map(|kv| kv.strip_prefix("v=").map(str::to_string))
}),
Some("shorts") | Some("live") | Some("embed") | Some("v") => {
segs.get(1).map(|s| s.to_string())
}
_ => None,
}
} else {
None
};
candidate.filter(|c| is_id(c))
}
// ---- watch-page JSON slicing --------------------------------------------------
/// Slice the JSON object that begins at the first `{` after `needle`,
/// brace-counting with string/escape awareness — the watch page embeds its
/// data as `var ytInitialData = {...};` inside script tags, megabytes long.
pub(crate) fn slice_json_after<'a>(html: &'a str, needle: &str) -> Option<&'a str> {
let at = html.find(needle)?;
let rest = &html[at + needle.len()..];
let open = rest.find('{')?;
let bytes = rest.as_bytes();
let mut depth = 0usize;
let mut in_str = false;
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate().skip(open) {
if in_str {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == b'"' {
in_str = false;
}
continue;
}
match b {
b'"' => in_str = true,
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(&rest[open..=i]);
}
}
_ => {}
}
}
None
}
/// Depth-first search for every object under `root` that contains `key`,
/// applying `f` to the value. Youtube's trees are deep, volatile, and
/// renderer-name-happy; searching for leaf keys survives their reshuffles
/// better than hardcoded paths.
fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) {
match root {
Value::Object(map) => {
if let Some(v) = map.get(key) {
f(v);
}
for v in map.values() {
walk(v, key, f);
}
}
Value::Array(items) => {
for v in items {
walk(v, key, f);
}
}
_ => {}
}
}
/// First string under any of `keys` in reading order — for label-ish leaves
/// ("simpleText", nested runs) where youtube varies the wrapper.
fn first_string(v: &Value, keys: &[&str]) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
_ => {
for k in keys {
let mut found = None;
walk(v, k, &mut |x| {
if found.is_none() {
if let Some(s) = x.as_str() {
found = Some(s.to_string());
}
}
});
if found.is_some() {
return found;
}
}
None
}
}
}
// ---- the three evidence rungs -------------------------------------------------
/// A line "looks timestamped" when it carries a `m:ss`-or-longer stamp plus
/// some letters — the same acceptance the pasted-tracklist parser applies,
/// reused for candidate SCORING before anything is parsed for real.
pub(crate) fn is_timestamped_line(line: &str) -> bool {
let has_letters = line.chars().filter(|c| c.is_alphabetic()).count() >= 2;
if !has_letters {
return false;
}
line.split(|c: char| c.is_whitespace() || c == '[' || c == ']' || c == '(' || c == ')')
.any(|tok| {
let t = tok.trim_matches(|c: char| !c.is_ascii_digit() && c != ':');
crate::playlist_formats::parse_timestamp(t).is_some()
})
}
/// Chapters out of ytInitialData: every `chapterRenderer` carries a title
/// and `timeRangeStartMillis`. Ordered, creator-authored — the top rung.
pub(crate) fn chapters_tracklist(initial_data: &Value) -> Option<String> {
let mut rows: Vec<(i64, String)> = Vec::new();
walk(initial_data, "chapterRenderer", &mut |ch| {
let ms = ch
.get("timeRangeStartMillis")
.and_then(Value::as_i64)
.unwrap_or(-1);
let title = ch
.get("title")
.and_then(|t| first_string(t, &["simpleText", "text"]));
if ms >= 0 {
if let Some(t) = title {
rows.push((ms, t));
}
}
});
if rows.len() < MIN_TRACKLIST_LINES {
return None;
}
rows.sort_by_key(|(ms, _)| *ms);
rows.dedup();
Some(
rows.into_iter()
.map(|(ms, t)| format!("{} {}", fmt_ts(ms), t))
.collect::<Vec<_>>()
.join("\n"),
)
}
/// The description's best block: the longest run of timestamped lines with
/// at most one untimed line between them (headers like "Tracklist:" break
/// nothing; prose paragraphs do).
pub(crate) fn description_tracklist(description: &str) -> Option<String> {
fn flush<'a>(
current: &mut Vec<&'a str>,
stamped: &mut usize,
best: &mut Option<(usize, Vec<&'a str>)>,
) {
if *stamped >= MIN_TRACKLIST_LINES
&& best.as_ref().map(|(n, _)| *stamped > *n).unwrap_or(true)
{
*best = Some((*stamped, current.clone()));
}
current.clear();
*stamped = 0;
}
let mut best: Option<(usize, Vec<&str>)> = None;
let mut current: Vec<&str> = Vec::new();
let mut gap = 0usize;
let mut stamped = 0usize;
for line in description.lines() {
let trimmed = line.trim();
if is_timestamped_line(trimmed) {
current.push(trimmed);
stamped += 1;
gap = 0;
} else if !current.is_empty() && !trimmed.is_empty() && gap == 0 {
// One stray untimed line rides along (a wrapped title, an "ID"),
// a second breaks the block.
gap = 1;
} else if !current.is_empty() {
flush(&mut current, &mut stamped, &mut best);
gap = 0;
}
}
flush(&mut current, &mut stamped, &mut best);
best.map(|(_, lines)| lines.join("\n"))
}
/// Comment texts out of an innertube `next` response. Youtube has shipped
/// two shapes in the wild — legacy `contentText` runs and the newer
/// `commentEntityPayload` — so both are walked; each yields (author, text).
pub(crate) fn comment_texts(next_response: &Value) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
walk(next_response, "commentEntityPayload", &mut |p| {
let text = p
.get("properties")
.and_then(|pr| pr.get("content"))
.and_then(|c| c.get("content"))
.and_then(Value::as_str);
let author = p
.get("author")
.and_then(|a| a.get("displayName"))
.and_then(Value::as_str)
.unwrap_or("a commenter");
if let Some(t) = text {
out.push((author.to_string(), t.to_string()));
}
});
walk(next_response, "commentRenderer", &mut |c| {
let text = c
.get("contentText")
.map(|ct| collect_runs(ct))
.filter(|t| !t.is_empty());
let author = c
.get("authorText")
.and_then(|a| first_string(a, &["simpleText"]))
.unwrap_or_else(|| "a commenter".into());
if let Some(t) = text {
out.push((author, t));
}
});
out.truncate(MAX_COMMENTS_SCANNED);
out
}
/// Flatten a `runs`/`simpleText` rich-text node into plain text, preserving
/// the run boundaries' own newlines (timestamps arrive as separate runs).
fn collect_runs(v: &Value) -> String {
if let Some(s) = v.get("simpleText").and_then(Value::as_str) {
return s.to_string();
}
let mut out = String::new();
walk(v, "text", &mut |t| {
if let Some(s) = t.as_str() {
out.push_str(s);
}
});
out
}
/// The comments continuation token for the initial `next` call, discovered
/// generically: the token whose enclosing area names the comment section.
pub(crate) fn comments_token(initial_data: &Value) -> Option<String> {
// The comment section's continuation is the one inside an
// itemSectionRenderer whose identifier mentions comments.
let mut token: Option<String> = None;
walk(initial_data, "itemSectionRenderer", &mut |sec| {
if token.is_some() {
return;
}
let ident = sec
.get("sectionIdentifier")
.and_then(Value::as_str)
.unwrap_or("");
if !ident.contains("comment") {
return;
}
walk(sec, "continuationCommand", &mut |cc| {
if token.is_none() {
if let Some(t) = cc.get("token").and_then(Value::as_str) {
token = Some(t.to_string());
}
}
});
});
token
}
fn fmt_ts(ms: i64) -> String {
let total = ms / 1000;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
/// Innertube client version, read from the page so the comments call speaks
/// the same dialect the page was served with; a dated fallback otherwise.
pub(crate) fn client_version(html: &str) -> String {
slice_after_quote(html, "\"INNERTUBE_CONTEXT_CLIENT_VERSION\":\"")
.unwrap_or("2.20260101.00.00")
.to_string()
}
fn slice_after_quote<'a>(html: &'a str, needle: &str) -> Option<&'a str> {
let at = html.find(needle)?;
let rest = &html[at + needle.len()..];
rest.split('"').next()
}
// ---- the fetch ----------------------------------------------------------------
/// Just the video's identity (title, author) — for the LISTENER's draft
/// header, whose sets typically have NO posted tracklist (that's why they
/// get listened to): the full `fetch` returns Err for those and took the
/// title down with it (Kyle's first own-set run was named by raw video id).
pub async fn fetch_title(url: &str) -> Option<(String, String)> {
let id = video_id_from_url(url)?;
let client = reqwest::Client::builder()
.user_agent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/126.0.0.0 Safari/537.36",
)
.timeout(std::time::Duration::from_secs(15))
.build()
.ok()?;
let html = client
.get(format!("https://www.youtube.com/watch?v={id}&hl=en"))
.header("Cookie", "CONSENT=YES+1; SOCS=CAI")
.send()
.await
.ok()?
.text()
.await
.ok()?;
let player: Value = slice_json_after(&html, "ytInitialPlayerResponse")
.and_then(|s| serde_json::from_str(s).ok())?;
let details = player.get("videoDetails")?;
let title = details.get("title").and_then(Value::as_str)?.to_string();
let author = details
.get("author")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
Some((title, author))
}
/// Fetch one video's metadata and pick its best tracklist. At most two
/// requests; every rung failure downgrades to the next with a warning
/// instead of failing the whole import (research §12).
pub async fn fetch(url: &str) -> Result<SetlistFetch, String> {
let id = video_id_from_url(url).ok_or("That doesn't look like a YouTube video link")?;
let client = reqwest::Client::builder()
.user_agent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/126.0.0.0 Safari/537.36",
)
.timeout(std::time::Duration::from_secs(25))
.build()
.map_err(|e| e.to_string())?;
let html = client
.get(format!("https://www.youtube.com/watch?v={id}&hl=en"))
.header("Cookie", "CONSENT=YES+1; SOCS=CAI")
.header("Accept-Language", "en")
.send()
.await
.map_err(|e| format!("YouTube did not answer: {e}"))?
.error_for_status()
.map_err(|e| format!("YouTube refused the page: {e}"))?
.text()
.await
.map_err(|e| e.to_string())?;
let player: Value = slice_json_after(&html, "ytInitialPlayerResponse")
.and_then(|s| serde_json::from_str(s).ok())
.ok_or("Could not read the video's data — is the link public?")?;
// Unavailable/private/region-locked videos still serve a page — with a
// playability error instead of details. Say that plainly (research §8).
let playability = player
.get("playabilityStatus")
.and_then(|p| p.get("status"))
.and_then(Value::as_str)
.unwrap_or("OK");
if playability != "OK" && player.get("videoDetails").is_none() {
let reason = player
.get("playabilityStatus")
.and_then(|p| p.get("reason"))
.and_then(Value::as_str)
.unwrap_or("it may be private, removed, or region-locked");
return Err(format!("YouTube says this video is unavailable — {reason}"));
}
let details = player.get("videoDetails").cloned().unwrap_or(Value::Null);
let title = details
.get("title")
.and_then(Value::as_str)
.unwrap_or("YouTube set")
.to_string();
let author = details
.get("author")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let description = details
.get("shortDescription")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let initial_data: Value = slice_json_after(&html, "ytInitialData")
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(Value::Null);
let mut warnings = Vec::new();
// Rung 1: chapters. Rung 2: the description's timestamped block.
if let Some(list) = chapters_tracklist(&initial_data) {
return Ok(SetlistFetch {
title,
author,
tracklist: list,
provenance: "the video's chapters".into(),
warnings,
});
}
if let Some(list) = description_tracklist(&description) {
return Ok(SetlistFetch {
title,
author,
tracklist: list,
provenance: "the video description".into(),
warnings,
});
}
// Rung 3: top comments — one innertube call, failure-soft.
match comments_token(&initial_data) {
None => warnings.push("The video page exposed no comments to scan.".into()),
Some(token) => {
let version = client_version(&html);
let body = serde_json::json!({
"context": { "client": {
"clientName": "WEB", "clientVersion": version,
"hl": "en", "gl": "US",
}},
"continuation": token,
});
let resp = client
.post("https://www.youtube.com/youtubei/v1/next?prettyPrint=false")
.json(&body)
.send()
.await;
match resp {
Err(e) => warnings.push(format!("Comments could not be scanned: {e}")),
Ok(r) => match r.json::<Value>().await {
Err(e) => warnings.push(format!("Comments could not be read: {e}")),
Ok(next) => {
let mut best: Option<(usize, String, String)> = None;
for (who, text) in comment_texts(&next) {
let stamped =
text.lines().filter(|l| is_timestamped_line(l.trim())).count();
if stamped >= MIN_TRACKLIST_LINES
&& best.as_ref().map(|(n, _, _)| stamped > *n).unwrap_or(true)
{
let block: Vec<&str> = text
.lines()
.map(str::trim)
.filter(|l| is_timestamped_line(l))
.collect();
best = Some((stamped, who.clone(), block.join("\n")));
}
}
if let Some((n, who, list)) = best {
warnings.push(format!(
"Tracklist found in a comment by {who} ({n} timestamped lines) — viewer-supplied, worth a skim."
));
return Ok(SetlistFetch {
title,
author,
tracklist: list,
provenance: format!("a comment by {who}"),
warnings,
});
}
warnings.push("No comment carried a timestamped tracklist.".into());
}
},
}
}
}
Err(format!(
"No tracklist found on \"{title}\" — no chapters, no timestamped description, and {} \
Paste one into the tracklist door if you have it.",
if warnings.iter().any(|w| w.contains("comment")) {
"none of the top comments carry one."
} else {
"the comments could not be scanned."
}
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn video_ids_from_every_url_shape() {
for (url, want) in [
("https://www.youtube.com/watch?v=dQw4w9WgXcQ", Some("dQw4w9WgXcQ")),
("https://youtu.be/dQw4w9WgXcQ?t=42", Some("dQw4w9WgXcQ")),
("https://www.youtube.com/live/abc-DEF_123?feature=share", Some("abc-DEF_123")),
("https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=RD", Some("dQw4w9WgXcQ")),
("www.youtube.com/shorts/abc-DEF_123", Some("abc-DEF_123")),
("dQw4w9WgXcQ", Some("dQw4w9WgXcQ")),
("https://example.com/watch?v=dQw4w9WgXcQ", None),
("https://www.youtube.com/watch?v=tooShort", None),
] {
assert_eq!(
video_id_from_url(url).as_deref(),
want,
"{url} must yield {want:?}"
);
}
}
#[test]
fn json_slicer_handles_braces_in_strings() {
let html = r#"<script>var ytInitialData = {"a":"br{ace \" quote","b":{"c":1}};</script>"#;
let json = slice_json_after(html, "ytInitialData").unwrap();
let v: Value = serde_json::from_str(json).unwrap();
assert_eq!(v["b"]["c"], 1);
}
#[test]
fn chapters_walk_and_format() {
let data: Value = serde_json::from_str(
r#"{"markersMap":[{"value":{"chapters":[
{"chapterRenderer":{"title":{"simpleText":"Bicep - Glue"},"timeRangeStartMillis":0}},
{"chapterRenderer":{"title":{"simpleText":"Overmono - So U Kno"},"timeRangeStartMillis":330000}},
{"chapterRenderer":{"title":{"simpleText":"Joy O - Sin Palta"},"timeRangeStartMillis":3900000}}
]}}]}"#,
)
.unwrap();
let list = chapters_tracklist(&data).unwrap();
assert_eq!(
list,
"0:00 Bicep - Glue\n5:30 Overmono - So U Kno\n1:05:00 Joy O - Sin Palta"
);
}
#[test]
fn description_block_beats_prose_and_survives_headers() {
let desc = "Recorded live at the warehouse.\nBig thanks to everyone!\n\nTracklist:\n00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\nID - ID\n12:44 Artist Three - Closer\n\nFollow me on socials at 10:00 tonight";
let block = description_tracklist(desc).unwrap();
assert_eq!(
block,
"00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\n12:44 Artist Three - Closer"
);
}
#[test]
fn too_few_stamps_is_no_tracklist() {
assert!(description_tracklist("00:01 hi\nsome words\n03:30 drop!").is_none());
assert!(!is_timestamped_line("10000 subscribers!"));
assert!(is_timestamped_line("1:23:45 Artist - Title"));
assert!(is_timestamped_line("Artist - Title [04:20]"));
}
#[test]
fn comment_payloads_both_shapes() {
let next: Value = serde_json::from_str(
r#"{"frameworkUpdates":{"entityBatchUpdate":{"mutations":[
{"payload":{"commentEntityPayload":{
"properties":{"content":{"content":"00:00 A - B\n03:00 C - D\n06:00 E - F"}},
"author":{"displayName":"@digger"}}}}
]}},
"old":[{"commentRenderer":{
"authorText":{"simpleText":"@legacy"},
"contentText":{"runs":[{"text":"great set"}]}}}]}"#,
)
.unwrap();
let texts = comment_texts(&next);
assert_eq!(texts.len(), 2);
assert_eq!(texts[0].0, "@digger");
assert!(texts[0].1.contains("03:00 C - D"));
assert_eq!(texts[1], ("@legacy".into(), "great set".into()));
}
#[test]
fn comments_token_found_by_section_identifier() {
let data: Value = serde_json::from_str(
r#"{"contents":[
{"itemSectionRenderer":{"sectionIdentifier":"related-items",
"contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"WRONG"}}}}]}},
{"itemSectionRenderer":{"sectionIdentifier":"comment-item-section",
"contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"RIGHT"}}}}]}}
]}"#,
)
.unwrap();
assert_eq!(comments_token(&data).as_deref(), Some("RIGHT"));
}
}
+493
View File
@@ -0,0 +1,493 @@
//! yt-dlp as an OPTIONAL external tool — the acquisition adapter for the
//! DJ-set listener, Kyle's explicit call 2026-08-19 ("okay implement
//! yt-dlp"), relaxing the no-child-processes posture for THIS feature
//! only: anonymous in-process YouTube audio is dead upstream (PO-token
//! wall, verified same day), and the download arms race belongs to the
//! people who fight it full-time.
//!
//! Posture kept from the model downloads: the binary arrives ONLY from an
//! explicit click, from yt-dlp's official GitHub release (a moving target
//! by design — self-updating is why it keeps working, so there is no byte
//! pin; the SOURCE is pinned instead). A PATH-installed yt-dlp is
//! respected before the managed copy. Audio lands in a per-video cache so
//! re-analysis is free (research §11), fetched as the m4a/AAC stream the
//! app's own decoder reads — no ffmpeg, no conversion step.
use std::path::PathBuf;
use std::process::Stdio;
use tauri::{Emitter, Manager};
/// The NIGHTLY channel, deliberately: as of 2026-08 the stable release
/// 403s on YouTube (extractor drift — burned live TWICE the same evening,
/// the second time when the update button itself overwrote a working
/// nightly with stale stable). Nightly is the yt-dlp project's own
/// recommendation for extractor freshness; against YouTube it is the only
/// channel that works.
const RELEASE_URL: &str =
"https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp.exe";
/// Deno's official Windows build — the RECOMMENDED EJS runtime (nightly
/// yt-dlp + deno is the one combo proven against YouTube, 2026-08-19).
/// Same posture as yt-dlp: no byte pin, the SOURCE is pinned; consent is
/// the click; the tool's own --version is the sanity check.
const DENO_RELEASE_URL: &str =
"https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip";
/// Windows: no console window for spawned children.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
fn tools_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("tools");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn managed_exe(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(tools_dir(app)?.join("yt-dlp.exe"))
}
/// The yt-dlp this machine would use: the managed copy first, then PATH.
pub fn locate(app: &tauri::AppHandle) -> Option<PathBuf> {
if let Ok(p) = managed_exe(app) {
if p.is_file() {
return Some(p);
}
}
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|d| d.join("yt-dlp.exe"))
.find(|c| c.is_file())
}
fn tool_version(exe: &PathBuf) -> Option<String> {
use std::os::windows::process::CommandExt;
let out = std::process::Command::new(exe)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.output()
.ok()?;
let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!v.is_empty()).then_some(v)
}
#[tauri::command]
pub fn ytdlp_status(app: tauri::AppHandle) -> serde_json::Value {
// The runtime rides along so the modal can offer the deno download
// exactly when it matters: nothing found, or a PATH node/bun that EJS
// may reject (the home laptop's node v19 lesson).
let runtime = js_runtime(&app);
match locate(&app) {
Some(exe) => serde_json::json!({
"present": true,
"path": exe.to_string_lossy(),
"managed": managed_exe(&app).map(|m| m == exe).unwrap_or(false),
"version": tool_version(&exe),
"runtime": runtime,
}),
None => serde_json::json!({ "present": false, "runtime": runtime }),
}
}
/// Download (or update) the managed yt-dlp from the official release.
/// Consent = the click that invoked this. Streams to a temp name, renames
/// atomically, then reports the tool's own --version as the sanity check —
/// there is no byte pin to check against, the release moves by design.
#[tauri::command]
pub async fn ytdlp_download(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1 (setlist tools)")
.build()
.map_err(|e| e.to_string())?;
let dest = managed_exe(&app)?;
let tmp = dest.with_extension("exe.downloading");
let mut response = client
.get(RELEASE_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(0);
let mut file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
let mut received: u64 = 0;
let mut last_emit: u64 = 0;
use std::io::Write;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if received - last_emit > 512 * 1024 {
last_emit = received;
let _ = app.emit(
"playlist:scan-progress",
serde_json::json!({ "stage": "tool", "received": received, "total": total }),
);
}
}
drop(file);
if received < 2 * 1024 * 1024 {
let _ = std::fs::remove_file(&tmp);
return Err(format!("suspiciously small download ({received} bytes) — refused"));
}
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
let version = match tool_version(&dest) {
Some(v) => v,
None => {
let _ = std::fs::remove_file(&dest);
return Err("downloaded tool did not answer --version — removed".into());
}
};
Ok(serde_json::json!({ "version": version }))
}
fn deno_version(exe: &PathBuf) -> Option<String> {
use std::os::windows::process::CommandExt;
let out = std::process::Command::new(exe)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.output()
.ok()?;
// First line reads "deno 2.9.5 (stable, release, x86_64-pc-windows-msvc)".
let text = String::from_utf8_lossy(&out.stdout);
let v = text.lines().next()?.split_whitespace().nth(1)?.to_string();
(!v.is_empty()).then_some(v)
}
/// Minimal single-entry ZIP extraction, central-directory-driven (the
/// authoritative record even when local headers defer their sizes to data
/// descriptors). Only what deno's release zip needs — stored or deflate,
/// CRC-verified — hand-rolled over the flate2/crc32fast already in the
/// tree rather than growing a zip dependency for one file.
fn extract_zip_entry(bytes: &[u8], want: &str) -> Result<Vec<u8>, String> {
const EOCD: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
const CENTRAL: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
const LOCAL: [u8; 4] = [0x50, 0x4b, 0x03, 0x04];
let u16at = |at: usize| u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize;
let u32at = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
if bytes.len() < 22 {
return Err("archive too small to be a zip".into());
}
let eocd = (bytes.len().saturating_sub(65_557)..=bytes.len() - 22)
.rev()
.find(|&i| bytes[i..i + 4] == EOCD)
.ok_or("no zip directory found")?;
let mut at = u32at(eocd + 16);
for _ in 0..u16at(eocd + 10) {
if at + 46 > bytes.len() || bytes[at..at + 4] != CENTRAL {
return Err("malformed zip directory".into());
}
let method = u16at(at + 10);
let crc = u32at(at + 16) as u32;
let comp_size = u32at(at + 20);
let name_len = u16at(at + 28);
let extra_len = u16at(at + 30);
let comment_len = u16at(at + 32);
let local_at = u32at(at + 42);
let name = bytes
.get(at + 46..at + 46 + name_len)
.ok_or("malformed zip directory")?;
if name == want.as_bytes() {
if local_at + 30 > bytes.len() || bytes[local_at..local_at + 4] != LOCAL {
return Err("malformed zip entry".into());
}
let data_at = local_at + 30 + u16at(local_at + 26) + u16at(local_at + 28);
let data = bytes
.get(data_at..data_at + comp_size)
.ok_or("zip entry data out of range")?;
let out = match method {
0 => data.to_vec(),
8 => {
use std::io::Read;
let mut out = Vec::new();
flate2::read::DeflateDecoder::new(data)
.read_to_end(&mut out)
.map_err(|e| format!("zip inflate failed: {e}"))?;
out
}
m => return Err(format!("unsupported zip method {m}")),
};
let mut hasher = crc32fast::Hasher::new();
hasher.update(&out);
if hasher.finalize() != crc {
return Err("zip CRC mismatch — corrupted download, refused".into());
}
return Ok(out);
}
at += 46 + name_len + extra_len + comment_len;
}
Err(format!("{want} not found in the archive"))
}
/// Download the managed Deno runtime from denoland's official release and
/// unpack the one file that matters into the tools dir. Progress rides the
/// same event stream as the yt-dlp download, stage "runtime".
#[tauri::command]
pub async fn deno_download(app: tauri::AppHandle) -> Result<serde_json::Value, String> {
let client = reqwest::Client::builder()
.user_agent("SoulStream/0.1 (setlist tools)")
.build()
.map_err(|e| e.to_string())?;
let mut response = client
.get(DENO_RELEASE_URL)
.send()
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let total = response.content_length().unwrap_or(0);
let mut zip: Vec<u8> = Vec::with_capacity(total as usize);
let mut last_emit: usize = 0;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
zip.extend_from_slice(&chunk);
if zip.len() - last_emit > 1024 * 1024 {
last_emit = zip.len();
let _ = app.emit(
"playlist:scan-progress",
serde_json::json!({ "stage": "runtime", "received": zip.len(), "total": total }),
);
}
}
if zip.len() < 10 * 1024 * 1024 {
return Err(format!(
"suspiciously small download ({} bytes) — refused",
zip.len()
));
}
let dest = tools_dir(&app)?.join("deno.exe");
// Inflating ~100 MB is real CPU work — off the async runtime's threads.
let version = tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
let exe = extract_zip_entry(&zip, "deno.exe")?;
let tmp = dest.with_extension("exe.downloading");
std::fs::write(&tmp, &exe).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
match deno_version(&dest) {
Some(v) => Ok(v),
None => {
let _ = std::fs::remove_file(&dest);
Err("downloaded deno did not answer --version — removed".into())
}
}
})
.await
.map_err(|e| e.to_string())??;
Ok(serde_json::json!({ "version": version }))
}
/// Where a video's fetched audio lives; keyed by video id so a re-scan of
/// the same set never re-downloads.
fn audio_cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("setlist-audio");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
pub fn cached_audio(app: &tauri::AppHandle, video_id: &str) -> Option<PathBuf> {
let dir = audio_cache_dir(app).ok()?;
for ext in ["m4a", "mp4", "aac"] {
let p = dir.join(format!("{video_id}.{ext}"));
if p.is_file() {
return Some(p);
}
}
None
}
/// 2026 yt-dlp needs an external JS runtime for YouTube (the EJS change:
/// "extraction without a JS runtime has been deprecated" — verified live,
/// a runtime-less run 403s). Find one: managed deno first, then PATH
/// deno/node/bun. Returns the `--js-runtimes` value, or None.
fn js_runtime(app: &tauri::AppHandle) -> Option<String> {
if let Ok(dir) = tools_dir(app) {
let managed = dir.join("deno.exe");
if managed.is_file() {
return Some(format!("deno:{}", managed.to_string_lossy()));
}
}
let path_var = std::env::var_os("PATH")?;
for (exe, name) in [("deno.exe", "deno"), ("node.exe", "node"), ("bun.exe", "bun")] {
if std::env::split_paths(&path_var).any(|d| d.join(exe).is_file()) {
return Some(name.to_string());
}
}
None
}
/// Fetch a video's audio-only stream into the cache, emitting download
/// progress parsed from yt-dlp's own lines. Blocking (the caller runs it
/// on a worker thread). Strictly the m4a/AAC ladder — the app decodes that
/// in-process; a set that only offers opus errors honestly rather than
/// handing back a file nothing can read.
pub fn fetch_audio_blocking(
app: &tauri::AppHandle,
video_id: &str,
cancel: &std::sync::atomic::AtomicBool,
) -> Result<PathBuf, String> {
use std::io::BufRead;
use std::os::windows::process::CommandExt;
if let Some(hit) = cached_audio(app, video_id) {
return Ok(hit);
}
let exe = locate(app).ok_or("yt-dlp is not installed yet")?;
let runtime = js_runtime(app).ok_or(
"yt-dlp needs a JavaScript runtime for YouTube now (Deno, Node, or Bun) and none was \
found on this machine — install one and try again",
)?;
let dir = audio_cache_dir(app)?;
let template = dir.join("%(id)s.%(ext)s");
let mut child = std::process::Command::new(&exe)
.args([
"--no-playlist",
"--newline",
"--no-color",
"--js-runtimes",
&runtime,
"-f",
"bestaudio[ext=m4a]/bestaudio[acodec^=mp4a]",
"-o",
&template.to_string_lossy(),
&format!("https://www.youtube.com/watch?v={video_id}"),
])
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("could not start yt-dlp: {e}"))?;
let stdout = child.stdout.take().ok_or("no stdout")?;
let reader = std::io::BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
let _ = child.kill();
return Err("canceled".into());
}
// "[download] 42.3% of 61.53MiB at ..."
if let Some(rest) = line.trim().strip_prefix("[download]") {
if let Some(pct) = rest.trim().split('%').next() {
if let Ok(p) = pct.trim().parse::<f32>() {
let _ = app.emit(
"playlist:scan-progress",
serde_json::json!({ "stage": "audio", "pct": p }),
);
}
}
}
}
let status = child.wait().map_err(|e| e.to_string())?;
if !status.success() {
let mut err = String::new();
if let Some(mut se) = child.stderr.take() {
use std::io::Read;
let _ = se.read_to_string(&mut err);
}
let tail: String = err.lines().rev().take(3).collect::<Vec<_>>().join(" | ");
return Err(format!("yt-dlp failed: {tail}"));
}
cached_audio(app, video_id).ok_or_else(|| {
"yt-dlp finished but no m4a landed — the set may only offer opus audio".into()
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Assemble a real zip in memory — local headers, central directory,
/// EOCD — the structure `extract_zip_entry` actually parses.
fn build_zip(entries: &[(&str, &[u8], u16)]) -> Vec<u8> {
use std::io::Write as _;
let mut out: Vec<u8> = Vec::new();
let mut central: Vec<u8> = Vec::new();
for (name, data, method) in entries {
let mut hasher = crc32fast::Hasher::new();
hasher.update(data);
let crc = hasher.finalize();
let comp: Vec<u8> = match method {
0 => data.to_vec(),
8 => {
let mut enc = flate2::write::DeflateEncoder::new(
Vec::new(),
flate2::Compression::default(),
);
enc.write_all(data).unwrap();
enc.finish().unwrap()
}
_ => unreachable!("test zips are stored or deflated"),
};
let local_at = out.len() as u32;
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&[20, 0, 0, 0]); // version, flags
out.extend_from_slice(&method.to_le_bytes());
out.extend_from_slice(&[0; 4]); // time, date
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&(comp.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&[0, 0]); // extra_len
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(&comp);
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&[20, 0, 20, 0, 0, 0]); // versions, flags
central.extend_from_slice(&method.to_le_bytes());
central.extend_from_slice(&[0; 4]); // time, date
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&(comp.len() as u32).to_le_bytes());
central.extend_from_slice(&(data.len() as u32).to_le_bytes());
central.extend_from_slice(&(name.len() as u16).to_le_bytes());
central.extend_from_slice(&[0; 12]); // extra/comment/disk/attrs
central.extend_from_slice(&local_at.to_le_bytes());
central.extend_from_slice(name.as_bytes());
}
let cd_at = out.len() as u32;
out.extend_from_slice(&central);
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&[0; 4]); // disk numbers
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(central.len() as u32).to_le_bytes());
out.extend_from_slice(&cd_at.to_le_bytes());
out.extend_from_slice(&[0, 0]); // comment_len
out
}
#[test]
fn extracts_the_named_deflated_entry_among_others() {
let payload: Vec<u8> = (0..40_000u32).flat_map(|i| i.to_le_bytes()).collect();
let zip = build_zip(&[
("LICENSE.md", b"decoy text", 8),
("deno.exe", &payload, 8),
]);
let got = extract_zip_entry(&zip, "deno.exe").unwrap();
assert_eq!(got, payload);
}
#[test]
fn extracts_a_stored_entry() {
let zip = build_zip(&[("deno.exe", b"stored bytes", 0)]);
assert_eq!(extract_zip_entry(&zip, "deno.exe").unwrap(), b"stored bytes");
}
#[test]
fn corrupted_data_fails_the_crc_check() {
let mut zip = build_zip(&[("deno.exe", b"soon to be flipped payload", 0)]);
// Flip one payload byte: local header is 30 + name, data follows.
let at = 30 + "deno.exe".len() + 3;
zip[at] ^= 0xff;
let err = extract_zip_entry(&zip, "deno.exe").unwrap_err();
assert!(err.contains("CRC"), "{err}");
}
#[test]
fn a_missing_name_is_an_honest_error() {
let zip = build_zip(&[("other.txt", b"x", 0)]);
assert!(extract_zip_entry(&zip, "deno.exe").is_err());
}
}
+14 -2
View File
@@ -4,7 +4,6 @@
import PlayerBar from "./components/PlayerBar.svelte"; import PlayerBar from "./components/PlayerBar.svelte";
import NowPlaying from "./components/NowPlaying.svelte"; import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte"; import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LyricsOverlay from "./components/LyricsOverlay.svelte";
import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte"; import LyricsSyncEditor from "./components/LyricsSyncEditor.svelte";
import ImportWindow from "./components/ImportWindow.svelte"; import ImportWindow from "./components/ImportWindow.svelte";
import LoginModal from "./components/LoginModal.svelte"; import LoginModal from "./components/LoginModal.svelte";
@@ -23,8 +22,10 @@
import CatalogArtist from "./views/CatalogArtist.svelte"; import CatalogArtist from "./views/CatalogArtist.svelte";
import CatalogAlbum from "./views/CatalogAlbum.svelte"; import CatalogAlbum from "./views/CatalogAlbum.svelte";
import CatalogDiscography from "./views/CatalogDiscography.svelte"; import CatalogDiscography from "./views/CatalogDiscography.svelte";
import Lyrics from "./views/Lyrics.svelte";
import FollowToast from "./components/FollowToast.svelte"; import FollowToast from "./components/FollowToast.svelte";
import ImportToast from "./components/ImportToast.svelte"; import ImportToast from "./components/ImportToast.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte";
import { import {
ui, ui,
initEvents, initEvents,
@@ -48,6 +49,7 @@
catalogArtist: CatalogArtist, catalogArtist: CatalogArtist,
catalogAlbum: CatalogAlbum, catalogAlbum: CatalogAlbum,
catalogDiscography: CatalogDiscography, catalogDiscography: CatalogDiscography,
lyrics: Lyrics,
}; };
const CurrentView = $derived(views[ui.view] ?? Home); const CurrentView = $derived(views[ui.view] ?? Home);
@@ -82,6 +84,16 @@
// --bar-h + air rather than --pane-pad. Its ambience wash is the one // --bar-h + air rather than --pane-pad. Its ambience wash is the one
// exception and deliberately runs to the pane top behind the glass. // exception and deliberately runs to the pane top behind the glass.
"home", "home",
// The playlist editor follows the album-page rule: its hero banner
// runs under the frost and the page takes padding-top: --pane-pad.
"playlist",
// The playlist shelf follows the Library exception: its blurred
// ambience pins behind the glass and must begin at the very top of
// the pane, so the page carries its own top inset instead.
"playlists",
// The lyrics room follows the Library exception too: its full-bleed
// duotone backdrop pins behind the glass from the very pane top.
"lyrics",
]); ]);
initEvents(); initEvents();
@@ -127,8 +139,8 @@
<PlayerBar /> <PlayerBar />
<FollowToast /> <FollowToast />
<ImportToast /> <ImportToast />
<SetlistScanChip />
<PreviewOverlay /> <PreviewOverlay />
<LyricsOverlay />
<LyricsSyncEditor /> <LyricsSyncEditor />
<ArtworkViewer /> <ArtworkViewer />
<ImportWindow /> <ImportWindow />
-104
View File
@@ -1,104 +0,0 @@
<script>
// Fullscreen lyrics: the whole window washed in the playing record's
// accent, the same LyricsPane rendered at reading-room scale. Opened from
// the pane's expand button; Escape or ✕ returns to the app.
import { ui, player, theme } from "../lib/state.svelte.js";
import LyricsPane from "./LyricsPane.svelte";
import Icon from "./Icon.svelte";
const artist = $derived.by(() => {
const track = player.track;
if (!track) return "";
return (track.artist ?? "").trim() || String(track.sub ?? "").split(" — ")[0].trim();
});
function onkeydown(e) {
if (e.key === "Escape") ui.lyricsFull = false;
}
// Close with the track when playback stops entirely — a lyrics room with
// nothing playing is a dead end.
$effect(() => {
if (ui.lyricsFull && !player.track) ui.lyricsFull = false;
});
</script>
<svelte:window onkeydown={ui.lyricsFull ? onkeydown : undefined} />
{#if ui.lyricsFull}
<div class="room" style="--wash: {theme.accent}">
<header>
<div class="who">
<p class="t">{player.track?.title ?? ""}</p>
{#if artist}<p class="a">{artist}</p>{/if}
</div>
<button class="close" title="Close (Esc)" onclick={() => (ui.lyricsFull = false)}>
<Icon name="shrink" size={18} />
</button>
</header>
<div class="body">
<LyricsPane big />
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 90;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 90% at 20% 0%, color-mix(in srgb, var(--wash) 32%, transparent), transparent 60%),
radial-gradient(120% 90% at 90% 100%, color-mix(in srgb, var(--wash) 14%, transparent), transparent 55%),
var(--bg-base);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 26px 38px 10px;
flex: none;
}
.who {
min-width: 0;
}
.t {
font-size: 17px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
flex: none;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.body {
flex: 1;
min-height: 0;
display: flex;
justify-content: center;
padding: 6px 38px 30px;
}
.body :global(.lyr) {
width: min(820px, 100%);
height: 100%;
}
</style>
+47 -23
View File
@@ -4,14 +4,15 @@
// picker and a paste door for tracks nothing automatic can cover. Also // picker and a paste door for tracks nothing automatic can cover. Also
// rendered by the fullscreen overlay in `big` mode — one component, two // rendered by the fullscreen overlay in `big` mode — one component, two
// type scales. // type scales.
import { player, ui, playLocalFile } from "../lib/state.svelte.js"; import { player, ui, playLocalFile, goToView } from "../lib/state.svelte.js";
import { import {
lyricsStore, lyricsStore,
lyricIdentity, lyricIdentity,
ensureLyrics, ensureLyrics,
noteTrackChange, noteTrackChange,
activeLineIndex, activeLineIndex,
activeWordIndex, wordClockMs,
wordfill,
totalOffsetMs, totalOffsetMs,
nudgeOffset, nudgeOffset,
setAutoSync, setAutoSync,
@@ -253,19 +254,26 @@
onclick={() => seekTo(line)} onclick={() => seekTo(line)}
title="Play from here" title="Play from here"
> >
{#if index === active && line.words?.length} {#if index === active && lyricsStore.wordFill && line.words?.length}
<!-- Real word timing from the source: crisp per-word. <!-- Karaoke word fill over REAL word times (the
This is the ONLY karaoke mode. A line-level source wordfill mock's dialed variant A): each word
gets the whole-line highlight and nothing more — wipes across its OWN sung span, silence holds —
a sweep interpolated across the line's window carried, never invented (the Noctis rule). A
tracked the layout, not the voice (it crawled line-level source still gets the whole-line
through instrumental gaps), so it read as broken. highlight and nothing more: the interpolated
Word times are carried, never invented (the sweep that tracked the layout instead of the
Noctis rule). --> voice stays rejected. -->
{@const sungTo = activeWordIndex(line, player.position ?? 0)} <span
<span class="wline"> class="wline"
use:wordfill={{
line,
ms: wordClockMs(line, player.position ?? 0),
endBound: lyricsStore.lines[index + 1]?.time_ms
?? line.words[line.words.length - 1].time_ms + 2500,
}}
>
{#each line.words as word, wi (wi)} {#each line.words as word, wi (wi)}
<span class="w" class:sung={wi <= sungTo}>{word.text}</span> <span class="fw">{word.text}</span>
{/each} {/each}
</span> </span>
{:else} {:else}
@@ -331,7 +339,14 @@
{#snippet tools()} {#snippet tools()}
<span class="tools"> <span class="tools">
{#if !big} {#if !big}
<button class="tool" title="Fullscreen lyrics" onclick={() => (ui.lyricsFull = true)}> <button
class="tool"
title="Open the lyrics page"
onclick={() =>
goToView("lyrics", {
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
})}
>
<Icon name="expand" size={13} /> <Icon name="expand" size={13} />
</button> </button>
{/if} {/if}
@@ -615,20 +630,29 @@
} }
/* ── karaoke rendering inside the active line ── /* ── karaoke rendering inside the active line ──
Word mode: crisp steps on the source's own word times. */ Word fill (the wordfill mock's dialed variant A): each word is its own
gradient-clip raster wiping across the word's real sung span — accent
left of the feathered boundary (±8%, mock-dialed), the pane's quieter
unsung tone right of it. --lyr-p per element (inherits: false;
registered @property in Lyrics.svelte). */
.wline { .wline {
display: block; display: block;
} }
.w { .fw {
color: var(--text-secondary); --lyr-p: -8%;
transition: color 0.12s ease; background-image: linear-gradient(
90deg,
var(--accent-hover) calc(var(--lyr-p) - 8%),
var(--text-secondary) calc(var(--lyr-p) + 8%)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
} }
.w + .w { .fw + .fw {
margin-left: 0.32em; margin-left: 0.32em;
} }
.w.sung {
color: #fff;
}
.autochip { .autochip {
flex: none; flex: none;
+8 -4
View File
@@ -15,6 +15,7 @@
openCurrentArtist, openCurrentArtist,
openCurrentAlbum, openCurrentAlbum,
openCurrentSong, openCurrentSong,
seekPlayer,
libraryStore, libraryStore,
artBroken, artBroken,
markArtBroken, markArtBroken,
@@ -943,7 +944,7 @@
const now = performance.now(); const now = performance.now();
if (now - lastWaveSeekAt >= 75) { if (now - lastWaveSeekAt >= 75) {
lastWaveSeekAt = now; lastWaveSeekAt = now;
api.playerControl("seek", target); seekPlayer(target);
} }
} }
function waveUp(e) { function waveUp(e) {
@@ -951,7 +952,7 @@
waveDragging = false; waveDragging = false;
player.scrubbing = false; player.scrubbing = false;
const target = waveTarget(e); const target = waveTarget(e);
if (target != null) api.playerControl("seek", target); if (target != null) seekPlayer(target);
try { try {
waveEl.releasePointerCapture(e.pointerId); waveEl.releasePointerCapture(e.pointerId);
} catch { } catch {
@@ -1102,10 +1103,13 @@
album: currentItem.album ?? metaParts[1] ?? null, album: currentItem.album ?? metaParts[1] ?? null,
duration_secs: currentItem.duration ?? null, duration_secs: currentItem.duration ?? null,
// A stream item's path names a file on the PEER's // A stream item's path names a file on the PEER's
// machine — the row goes in as a placeholder that // machine — the row goes in as a stream row that
// streams on click, not as a fake local file. // plays through the resolver, not a fake local
// file. Its catalog id and cover ride along.
path: currentItem.stream ? null : currentItem.path, path: currentItem.stream ? null : currentItem.path,
art_path: currentItem.stream ? null : (currentItem.art ?? null), art_path: currentItem.stream ? null : (currentItem.art ?? null),
trackId: currentItem.trackId ?? null,
artUrl: currentItem.stream ? (currentItem.artUrl ?? null) : null,
}, },
]} ]}
/> />
+12 -4
View File
@@ -17,6 +17,8 @@
togglePlayerExpanded, togglePlayerExpanded,
showNowPlaying, showNowPlaying,
saveStream, saveStream,
goToView,
engineTime,
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { import {
@@ -265,7 +267,11 @@
} }
function openLyrics() { function openLyrics() {
menuOpen = false; menuOpen = false;
ui.lyricsFull = true; // the fullscreen overlay (LyricsOverlay.svelte) // The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
// is gone.
goToView("lyrics", {
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
});
} }
// ---- Save to library, from the ⋯ menu --------------------------------- // ---- Save to library, from the ⋯ menu ---------------------------------
@@ -678,7 +684,9 @@
const now = performance.now(); const now = performance.now();
if (force || now - lastScrubSeekAt >= 75) { if (force || now - lastScrubSeekAt >= 75) {
lastScrubSeekAt = now; lastScrubSeekAt = now;
api.playerControl("seek", target); // TRACK time in, ENGINE time out — a set-recording slice's
// engine clock is offset by the slice start.
api.playerControl("seek", engineTime(target));
} }
} }
@@ -706,7 +714,7 @@
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6)); const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
if (activatingScratch || now - lastScrubSeekAt >= 50) { if (activatingScratch || now - lastScrubSeekAt >= 50) {
lastScrubSeekAt = now; lastScrubSeekAt = now;
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate); api.playerControl("scratch", engineTime(target), e.clientX < scrubLastX ? -rate : rate);
} }
} else if (!canScratch) { } else if (!canScratch) {
regularScrub(target); regularScrub(target);
@@ -719,7 +727,7 @@
if (!draggingSeek) return; if (!draggingSeek) return;
const target = previewScrub(e, el); const target = previewScrub(e, el);
if (target != null) { if (target != null) {
if (scratchActive) api.playerControl("end-scratch", target); if (scratchActive) api.playerControl("end-scratch", engineTime(target));
else regularScrub(target, true); // a simple click remains exact seek else regularScrub(target, true); // a simple click remains exact seek
} }
draggingSeek = false; draggingSeek = false;
+576 -28
View File
@@ -3,23 +3,55 @@
// commit. The preview names what matched and what did not; unresolved // commit. The preview names what matched and what did not; unresolved
// rows stay visible placeholders unless the user explicitly leaves them // rows stay visible placeholders unless the user explicitly leaves them
// out. Nothing touches the database until "Create playlist". // out. Nothing touches the database until "Create playlist".
//
// Identified-but-unowned rows are VERIFIED against the CATALOG while
// the review is open (Kyle: match them to "the streaming library part,
// not Soulseek") — a strict name+duration match against Deezer-backed
// catalog search. A hit attaches the catalog track id + artwork to the
// row, so the committed playlist plays it as a catalog track.
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import {
setlistScan,
startYoutubeListen,
startFileListen,
} from "../lib/setlistScan.svelte.js";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
let { let {
open = $bindable(false), open = $bindable(false),
mode = "file", // "file": a probed draft arrives via `draft`; "paste": textarea first // "file": a probed draft arrives via `draft`; "paste": textarea
// first; "youtube": a link field first — the set's own tracklist
// evidence (chapters/description/comments) becomes the draft.
mode = "file",
draft = null, draft = null,
onimported = () => {}, onimported = () => {},
} = $props(); } = $props();
let pasteText = $state(""); let pasteText = $state("");
let youtubeUrl = $state("");
let localDraft = $state(null); let localDraft = $state(null);
// The local listen door: the picked set recording + its optional
// pasted tracklist (B-flow: pick first, paste after).
let pickedFile = $state(null); // {path, name, duration_secs}
let fileTracklist = $state("");
let picking = $state(false);
let name = $state(""); let name = $state("");
let includeUnresolved = $state(true); let includeUnresolved = $state(true);
let committing = $state(false); let committing = $state(false);
let parsing = $state(false); let parsing = $state(false);
let error = $state(""); let error = $state("");
// The listener: yt-dlp tool state + live scan progress.
let tool = $state(null);
let toolBusy = $state(false);
let denoBusy = $state(false);
// "deno" (managed or PATH) | "node"/"bun" (EJS may reject old ones —
// deno stays on offer) | null (no runtime at all).
const runtimeKind = $derived.by(() => {
const r = tool?.runtime;
if (!r) return null;
return r.startsWith("deno") ? "deno" : r;
});
let scanning = $state(null); // {stage, pct?, window?, total?, hit?}
// The working draft: probed (file mode) or built from the paste box. // The working draft: probed (file mode) or built from the paste box.
const working = $derived(localDraft ?? draft); const working = $derived(localDraft ?? draft);
@@ -31,8 +63,108 @@
committing = false; committing = false;
localDraft = null; localDraft = null;
pasteText = ""; pasteText = "";
youtubeUrl = "";
pickedFile = null;
fileTracklist = "";
includeUnresolved = true; includeUnresolved = true;
scanning = null;
name = draft?.name ?? ""; name = draft?.name ?? "";
if (mode === "youtube") {
void api.ytdlpStatus().then((s) => (tool = s)).catch(() => {});
}
if (draft?.entries?.length) startChecks(draft);
});
$effect(() => {
return api.onEvent("playlist:scan-progress", (p) => {
if (scanning || p?.stage === "tool" || p?.stage === "runtime")
scanning = { ...scanning, ...p };
});
});
async function downloadTool() {
if (toolBusy) return;
toolBusy = true;
error = "";
scanning = { stage: "tool" };
try {
await api.ytdlpDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
toolBusy = false;
scanning = null;
}
}
async function downloadDeno() {
if (denoBusy) return;
denoBusy = true;
error = "";
scanning = { stage: "runtime" };
try {
await api.denoDownload();
tool = await api.ytdlpStatus();
} catch (e) {
error = String(e);
} finally {
denoBusy = false;
scanning = null;
}
}
// Listens run in the BACKGROUND: starting one closes this modal and
// hands the whole job to the setlistScan store — its chip narrates the
// progress, and the finished draft re-opens the review from there.
function listenYoutube() {
if (!youtubeUrl.trim()) return;
if (!startYoutubeListen(youtubeUrl.trim())) return;
close();
}
// The local door picks FIRST: the panel that follows shows the chosen
// file and offers an optional pasted tracklist — the same declared
// evidence the YouTube door fetches for itself — before the listen
// starts. An empty box listens purely by ear.
async function pickLocalFile() {
if (picking || setlistScan.active) return;
picking = true;
error = "";
try {
const f = await api.playlistPickSetFile();
if (f) {
pickedFile = f;
fileTracklist = "";
}
} catch (e) {
error = String(e);
} finally {
picking = false;
}
}
function listenPicked() {
if (!pickedFile) return;
if (!startFileListen(pickedFile.path, fileTracklist.trim() || null, pickedFile.name))
return;
close();
}
function cancelScan() {
void api.playlistScanCancel();
}
const scanLabel = $derived.by(() => {
if (!scanning) return "";
if (scanning.stage === "tool") return "Downloading yt-dlp…";
if (scanning.stage === "runtime")
return `Downloading deno…${scanning.total ? ` ${Math.round(((scanning.received ?? 0) / scanning.total) * 100)}%` : ""}`;
if (scanning.stage === "audio")
return `Fetching the set's audio…${scanning.pct != null ? ` ${Math.round(scanning.pct)}%` : ""}`;
if (scanning.stage === "decode") return "Decoding the set…";
if (scanning.stage === "listen")
return `Listening… window ${scanning.window ?? 0} of ${scanning.total ?? "?"}`;
return "Working…";
}); });
async function previewPaste() { async function previewPaste() {
if (!pasteText.trim() || parsing) return; if (!pasteText.trim() || parsing) return;
@@ -45,6 +177,26 @@
} else { } else {
localDraft = parsed; localDraft = parsed;
if (!name) name = parsed.name ?? ""; if (!name) name = parsed.name ?? "";
startChecks(parsed);
}
} catch (e) {
error = String(e);
} finally {
parsing = false;
}
}
async function previewYoutube() {
if (!youtubeUrl.trim() || parsing) return;
parsing = true;
error = "";
try {
const parsed = await api.playlistImportYoutube(youtubeUrl.trim());
if (!parsed.entries.length) {
error = "The set's tracklist parsed to nothing usable.";
} else {
localDraft = parsed;
name = parsed.name ?? name;
startChecks(parsed);
} }
} catch (e) { } catch (e) {
error = String(e); error = String(e);
@@ -55,11 +207,109 @@
const kept = $derived.by(() => { const kept = $derived.by(() => {
if (!working) return []; if (!working) return [];
// Streams are first-class (identified, playable) — the checkbox
// only gates the TRUE unknowns.
return includeUnresolved return includeUnresolved
? working.entries ? working.entries
: working.entries.filter((e) => e.resolution === "local"); : working.entries.filter((e) => e.resolution !== "unresolved");
}); });
// ---- catalog verification of stream rows, live in the review -------
let checks = $state({}); // ordinal → "checking" | {album, cover} | "none"
let checkGen = 0;
const norm = (s) =>
(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
/// Strict match: at least one exact side (title or artist), both at
/// least partial; duration within ±7 s strengthens; Deezer rank breaks
/// ties so a famous track beats a namesake.
function pickCatalogMatch(tracks, e) {
const wantT = norm(e.title);
const wantA = norm(e.artist);
let best = null;
let bestScore = 0;
for (const t of tracks ?? []) {
const tT = norm(t.title);
const tA = norm(t.artist);
let score = 0;
if (tT === wantT) score += 4;
else if (tT.includes(wantT) || wantT.includes(tT)) score += 2;
if (tA === wantA) score += 4;
else if (tA.includes(wantA) || wantA.includes(tA)) score += 2;
if (score < 6) continue;
if (
e.duration_secs &&
t.duration_secs &&
Math.abs(t.duration_secs - e.duration_secs) <= 7
)
score += 2;
score += Math.min((t.rank ?? 0) / 1_000_000, 1);
if (score > bestScore) {
bestScore = score;
best = t;
}
}
return best;
}
/// Kick the walk OUTSIDE any effect's tracking zone: verifyStreams
/// reads and writes `checks`, and doing that inside an $effect made
/// every row verdict re-trigger the effect, which bumped the
/// generation and killed its own walk — row one froze at "matching…"
/// forever. Explicit kicks from the draft-arrival sites, via a
/// microtask, keep reactivity out of the loop.
function startChecks(w) {
checks = {};
const gen = ++checkGen;
queueMicrotask(() => void verifyStreams(w, gen));
}
async function verifyStreams(w, gen) {
for (const e of w.entries) {
if (e.resolution !== "stream") continue;
if (gen !== checkGen || !open) return;
checks[e.ordinal] = "checking";
try {
// Anchor-exact first: a row the listener heard carries the
// matched release's ISRC, and one /track/isrc: lookup beats
// any name matcher. A miss falls through to the name walk.
let hit = null;
let anchored = false;
if (e.isrc) {
hit = await api.catalogTrackByIsrc(e.isrc);
if (gen !== checkGen) return;
anchored = !!hit;
}
if (!hit) {
const res = await api.catalogSearch(`${e.artist} ${e.title}`);
if (gen !== checkGen) return;
hit = pickCatalogMatch(res?.tracks, e);
}
if (hit) {
// Attach catalog identity to the row commit will send.
e.track_id = Number(hit.id);
e.art_url = hit.cover ?? null;
if (!e.album) e.album = hit.album ?? null;
if (!e.duration_secs) e.duration_secs = hit.duration_secs ?? null;
checks[e.ordinal] = {
album: hit.album ?? "",
cover: hit.cover ?? null,
anchored,
};
} else {
checks[e.ordinal] = "none";
}
} catch {
if (gen === checkGen) checks[e.ordinal] = "none";
}
await new Promise((r) => setTimeout(r, 250));
}
}
const checkedFound = $derived(
Object.values(checks).filter((v) => v && v !== "none" && v !== "checking").length,
);
async function commit() { async function commit() {
if (!working || committing) return; if (!working || committing) return;
const finalName = (name || working.name || "Imported playlist").trim(); const finalName = (name || working.name || "Imported playlist").trim();
@@ -71,6 +321,7 @@
working.description ?? null, working.description ?? null,
working.kind, working.kind,
kept, kept,
working.cover_url ?? null,
); );
onimported(id, finalName); onimported(id, finalName);
} catch (e) { } catch (e) {
@@ -80,6 +331,7 @@
} }
function close() { function close() {
checkGen += 1; // stop any verification walk
open = false; open = false;
} }
@@ -111,6 +363,8 @@
soulstream: "SoulStream backup", soulstream: "SoulStream backup",
text: "Pasted text", text: "Pasted text",
folder: "Folder", folder: "Folder",
youtube: "YouTube",
listened: "Recognized by ear",
}; };
</script> </script>
@@ -132,6 +386,10 @@
<h2> <h2>
{#if working} {#if working}
Review import Review import
{:else if pickedFile}
Listen to a set recording
{:else if mode === "youtube"}
Import a DJ set from YouTube
{:else} {:else}
Paste a tracklist Paste a tracklist
{/if} {/if}
@@ -142,35 +400,177 @@
</header> </header>
{#if !working} {#if !working}
<p class="lead"> {#if pickedFile}
One track per line. Timestamps make it a setlist — <div class="srcfile">
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix. <Icon name="music" size={14} />
</p> <span class="fname" title={pickedFile.path}>{pickedFile.name}</span>
<textarea {#if pickedFile.duration_secs != null}
bind:value={pasteText} <span class="flen">{fmtTime(pickedFile.duration_secs * 1000)}</span>
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"} {/if}
spellcheck="false" </div>
></textarea> <p class="lead">
{#if error}<p class="error">{error}</p>{/if} Listening identifies the tracks by sound. If the set came with a
<footer> tracklist, paste it too — it names the exact <b>version</b> played
<button class="btn ghost" onclick={close}>Cancel</button> where the ear can only name the release it matched, and fills
<button stretches nothing recognized. Leave it empty to listen purely by ear.
class="btn primary" </p>
onclick={previewPaste} <textarea
disabled={!pasteText.trim() || parsing} class="tl"
> bind:value={fileTracklist}
{parsing ? "Reading…" : "Preview"} placeholder={"00:00 Worakls - Red Dressed (Ben Böhmer Remix)\n05:30 Overmono - So U Kno\n…"}
</button> spellcheck="false"
</footer> ></textarea>
<div class="hint">
Optional. Only timestamped lines count — the stamp anchors each
name to its place in the mix.
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={() => (pickedFile = null)}>
Back
</button>
<button
class="btn primary"
onclick={listenPicked}
disabled={setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
</footer>
{:else if mode === "youtube"}
{#if scanning}
<div class="scanbox">
<span class="ring"></span>
<div class="scanline">{scanLabel}</div>
{#if scanning.hit}
<div class="scanhit">{scanning.hit}</div>
{/if}
</div>
<footer>
<button class="btn ghost" onclick={cancelScan}>Cancel</button>
</footer>
{:else}
<p class="lead">
Paste a set's link. <b>Fetch tracklist</b> reads the video's own
chapters, description, or a commented tracklist — free and instant.
<b>Listen</b> downloads the audio and identifies the tracks by
sound: a few minutes, and unreleased material stays an honest ID.
</p>
<input
class="url"
type="text"
bind:value={youtubeUrl}
placeholder="https://www.youtube.com/watch?v=…"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && previewYoutube()}
/>
<div class="toolline">
{#if tool?.present}
<span class="toolok">
yt-dlp {tool.version ?? ""}{tool.managed ? "" : " (from PATH)"}
</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
update
</button>
{:else}
<span>Listening needs the downloader tool:</span>
<button class="linkbtn" onclick={downloadTool} disabled={toolBusy}>
{toolBusy ? "downloading…" : "download yt-dlp (~17 MB, official nightly)"}
</button>
{/if}
<span class="toolsep">·</span>
{#if runtimeKind === "deno"}
<span class="toolok">deno</span>
{:else if runtimeKind}
<span>runtime: {runtimeKind}</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "get deno (recommended, ~40 MB)"}
</button>
{:else}
<span>and a JS runtime:</span>
<button class="linkbtn" onclick={downloadDeno} disabled={denoBusy}>
{denoBusy ? "downloading…" : "download deno (~40 MB, official release)"}
</button>
{/if}
<span class="toolsep">·</span>
<button
class="linkbtn"
onclick={pickLocalFile}
disabled={parsing || picking || setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: ""}
>
or listen to a local file…
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn ghost"
onclick={listenYoutube}
disabled={!youtubeUrl.trim() ||
parsing ||
!tool?.present ||
!tool?.runtime ||
setlistScan.active}
title={setlistScan.active
? "A listen is already running — see the chip"
: !tool?.present
? "Download yt-dlp first"
: !tool?.runtime
? "Download a JS runtime first"
: "Identify the tracks by sound — runs in the background"}
>
Listen
</button>
<button
class="btn primary"
onclick={previewYoutube}
disabled={!youtubeUrl.trim() || parsing}
>
{parsing ? "Fetching…" : "Fetch tracklist"}
</button>
</footer>
{/if}
{:else}
<p class="lead">
One track per line. Timestamps make it a setlist —
<span class="mono">00:00 Artist - Title</span> keeps its place in the mix.
</p>
<textarea
bind:value={pasteText}
placeholder={"00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby"}
spellcheck="false"
></textarea>
{#if error}<p class="error">{error}</p>{/if}
<footer>
<button class="btn ghost" onclick={close}>Cancel</button>
<button
class="btn primary"
onclick={previewPaste}
disabled={!pasteText.trim() || parsing}
>
{parsing ? "Reading…" : "Preview"}
</button>
</footer>
{/if}
{:else} {:else}
<div class="srcline"> <div class="srcline">
<span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span> <span class="chip">{FORMAT_LABEL[working.format] ?? working.format}</span>
{#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if} {#if working.kind === "setlist"}<span class="chip set">Setlist</span>{/if}
<span class="srcname" title={working.source_name}>{working.source_name}</span> <span class="srcname" title={working.source_name}>{working.source_name}</span>
<span class="counts"> <span class="counts">
<b class="ok">{working.resolved_count}</b> matched <b class="ok">{working.resolved_count}</b> in library
{#if working.stream_count > 0}
&thinsp;·&thinsp;<b class="st">{checkedFound}</b>/{working.stream_count} in catalog
{/if}
{#if working.unresolved_count > 0} {#if working.unresolved_count > 0}
&thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unresolved &thinsp;·&thinsp;<b class="un">{working.unresolved_count}</b> unknown
{/if} {/if}
</span> </span>
</div> </div>
@@ -187,8 +587,8 @@
{#each working.entries as e (e.ordinal)} {#each working.entries as e (e.ordinal)}
<div <div
class="row" class="row"
class:unresolved={e.resolution !== "local"} class:unresolved={e.resolution === "unresolved"}
class:excluded={!includeUnresolved && e.resolution !== "local"} class:excluded={!includeUnresolved && e.resolution === "unresolved"}
> >
<span class="n">{e.ordinal + 1}</span> <span class="n">{e.ordinal + 1}</span>
{#if working.kind === "setlist"} {#if working.kind === "setlist"}
@@ -215,8 +615,41 @@
? "relinked" ? "relinked"
: "tags"} : "tags"}
</span> </span>
{:else if e.resolution === "stream"}
{#if checks[e.ordinal] === "checking"}
<span class="tag st dim">matching…</span>
{:else if checks[e.ordinal] && checks[e.ordinal] !== "none"}
<span
class="tag st"
title={`${checks[e.ordinal].anchored ? "Exact recording match (ISRC)" : "Matched in the catalog"}${checks[e.ordinal].album ? ` — ${checks[e.ordinal].album}` : ""} · streams like any catalog track`}
>
<Icon name="check" size={11} />
catalog
</span>
{:else if checks[e.ordinal] === "none"}
<span
class="tag un"
title="No confident catalog match — still plays as a by-name stream"
>
not in catalog
</span>
{:else}
<span
class="tag st dim"
title="Identified but not in your library — plays as a stream"
>
stream
</span>
{/if}
{:else if e.resolution === "segment"}
<span
class="tag st dim"
title="Unidentified — will play this stretch of the set's own recording"
>
set audio
</span>
{:else} {:else}
<span class="tag un" title="No local match — kept as a placeholder"> <span class="tag un" title="Identity too thin to play — kept as a placeholder">
unresolved unresolved
</span> </span>
{/if} {/if}
@@ -242,7 +675,7 @@
</div> </div>
{#if error}<p class="error">{error}</p>{/if} {#if error}<p class="error">{error}</p>{/if}
<footer> <footer>
{#if mode === "paste"} {#if mode === "paste" || mode === "youtube"}
<button class="btn ghost" onclick={() => (localDraft = null)}>Back</button> <button class="btn ghost" onclick={() => (localDraft = null)}>Back</button>
{:else} {:else}
<button class="btn ghost" onclick={close}>Cancel</button> <button class="btn ghost" onclick={close}>Cancel</button>
@@ -336,6 +769,111 @@
textarea:focus { textarea:focus {
border-color: var(--accent); border-color: var(--accent);
} }
input.url {
width: 100%;
background: var(--bg-base);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
font-family: Consolas, monospace;
font-size: 12.5px;
outline: none;
}
input.url:focus {
border-color: var(--accent);
}
/* The local door's picked-file line (B-flow panel). */
.srcfile {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
font-size: 12.5px;
color: var(--text-muted);
}
.srcfile .fname {
font-family: Consolas, monospace;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.srcfile .flen {
color: var(--text-muted);
margin-left: auto;
flex: none;
}
textarea.tl {
min-height: 170px;
}
.hint {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 7px;
}
.toolline {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 12px;
color: var(--text-secondary);
}
.toolok {
color: #3ecf7e;
}
.toolsep {
opacity: 0.5;
}
.linkbtn {
background: none;
border: none;
padding: 0;
font-size: 12px;
color: var(--accent);
cursor: pointer;
}
.linkbtn:hover {
text-decoration: underline;
}
.linkbtn:disabled {
opacity: 0.5;
cursor: default;
}
.scanbox {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 42px 0 30px;
}
.scanline {
font-size: 13.5px;
color: var(--text-primary);
}
.scanhit {
font-size: 12.5px;
color: var(--text-secondary);
}
.ring {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-top-color: var(--accent);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.srcline { .srcline {
display: flex; display: flex;
@@ -376,6 +914,9 @@
.counts .ok { .counts .ok {
color: #3ecf7e; color: #3ecf7e;
} }
.counts .st {
color: #58b6e8;
}
.counts .un { .counts .un {
color: #e8a33d; color: #e8a33d;
} }
@@ -465,6 +1006,13 @@
background: rgba(62, 207, 126, 0.13); background: rgba(62, 207, 126, 0.13);
color: #3ecf7e; color: #3ecf7e;
} }
.tag.st {
background: rgba(88, 182, 232, 0.13);
color: #58b6e8;
}
.tag.dim {
opacity: 0.65;
}
.tag.un { .tag.un {
background: rgba(232, 163, 61, 0.15); background: rgba(232, 163, 61, 0.15);
color: #e8a33d; color: #e8a33d;
+109 -40
View File
@@ -3,6 +3,13 @@
// rail, track rows, Now Playing). Callers hand a THUNK that produces the // rail, track rows, Now Playing). Callers hand a THUNK that produces the
// rows when a target is picked, so building the payload costs nothing // rows when a target is picked, so building the payload costs nothing
// until the user actually commits. // until the user actually commits.
//
// The open menu is PORTALED to <body> and positioned fixed (the SaveMenu
// idiom): triggers live inside transformed hover overlays (.rowbtns,
// .rowact) and glass headers, and a transform or backdrop-filter ancestor
// both traps z-index and re-anchors position:fixed — the third
// menu-trapped-in-a-context episode made this the standing rule. Scoped
// styles still apply to the portaled node (the hash class travels with it).
import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js"; import { playlistStore, refreshPlaylists } from "../lib/state.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import Icon from "./Icon.svelte"; import Icon from "./Icon.svelte";
@@ -10,15 +17,22 @@
let { let {
// () => [{ title, artist, album, durationSecs, path, artPath }] // () => [{ title, artist, album, durationSecs, path, artPath }]
tracks, tracks,
align = "left", // "left" | "right" align = "left", // preferred horizontal edge: "left" | "right" of trigger
iconSize = 13, iconSize = 13,
title = "Add to playlist", title = "Add to playlist",
compact = false, // icon-only trigger compact = false, // icon-only trigger
exclude = null, // playlist id to omit (a playlist's own rows)
} = $props(); } = $props();
let open = $state(false); let open = $state(false);
let busy = $state(false); let busy = $state(false);
let doneFor = $state(null); // playlist id that just accepted rows let doneFor = $state(null); // playlist id that just accepted rows
let triggerEl = $state(null);
let menuEl = null;
const targets = $derived(
playlistStore.playlists.filter((pl) => pl.id !== exclude),
);
$effect(() => { $effect(() => {
if (open && !playlistStore.loaded) refreshPlaylists(); if (open && !playlistStore.loaded) refreshPlaylists();
@@ -30,6 +44,56 @@
doneFor = null; doneFor = null;
} }
/** Portal the menu to <body>, then place it against the trigger: below
* and edge-aligned, flipped above when the viewport runs out. Measured
* after mount so the content-driven width is what gets clamped. */
function overlay(node) {
document.body.appendChild(node);
const r = triggerEl.getBoundingClientRect();
const mw = node.offsetWidth;
const mh = node.offsetHeight;
let left = align === "right" ? r.right - mw : r.left;
left = Math.max(8, Math.min(left, window.innerWidth - mw - 8));
let top = r.bottom + 7;
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - 7 - mh);
node.style.top = `${top}px`;
node.style.left = `${left}px`;
node.style.visibility = "visible";
return {
destroy() {
node.remove();
},
};
}
// Close on outside click, Escape, resize, or any scroll OUTSIDE the menu
// (the playlist list scrolls inside it) — a fixed menu must not drift away
// from its trigger.
$effect(() => {
if (!open) return;
const onDoc = (e) => {
if (menuEl?.contains(e.target) || triggerEl?.contains(e.target)) return;
open = false;
};
const onScroll = (e) => {
if (menuEl && (e.target === menuEl || menuEl.contains(e.target))) return;
open = false;
};
const onKey = (e) => {
if (e.key === "Escape") open = false;
};
document.addEventListener("click", onDoc, true);
document.addEventListener("scroll", onScroll, true);
document.addEventListener("keydown", onKey, true);
window.addEventListener("resize", onScroll);
return () => {
document.removeEventListener("click", onDoc, true);
document.removeEventListener("scroll", onScroll, true);
document.removeEventListener("keydown", onKey, true);
window.removeEventListener("resize", onScroll);
};
});
function payload() { function payload() {
return (tracks() ?? []).map((t) => ({ return (tracks() ?? []).map((t) => ({
title: t.title ?? "", title: t.title ?? "",
@@ -38,6 +102,11 @@
duration_secs: t.durationSecs ?? t.duration_secs ?? null, duration_secs: t.durationSecs ?? t.duration_secs ?? null,
path: t.path ?? null, path: t.path ?? null,
art_path: t.artPath ?? t.art_path ?? null, art_path: t.artPath ?? t.art_path ?? null,
// A pathless row is a STREAM row; these anchor it — the catalog
// track id for identity, the cover URL for artwork it has no
// local file to read from.
track_id: t.trackId != null ? String(t.trackId) : (t.track_id ?? null),
art_url: t.artUrl ?? t.art_url ?? null,
})); }));
} }
@@ -84,16 +153,11 @@
busy = false; busy = false;
} }
} }
function windowClick() {
open = false;
}
</script> </script>
<svelte:window onclick={open ? windowClick : undefined} /> <div class="plm" onkeydown={(e) => e.stopPropagation()}>
<div class="plm">
<button <button
bind:this={triggerEl}
class="plm-trigger" class="plm-trigger"
class:compact class:compact
{title} {title}
@@ -104,31 +168,39 @@
<Icon name="list-plus" size={iconSize} /> <Icon name="list-plus" size={iconSize} />
{#if !compact}<span>Add to playlist</span>{/if} {#if !compact}<span>Add to playlist</span>{/if}
</button> </button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="plm-menu" class:right={align === "right"} role="menu" tabindex="-1" onclick={(e) => e.stopPropagation()}>
<button class="plm-item new" onclick={addToNew} disabled={busy}>
<Icon name="plus" size={14} /> New playlist
</button>
{#if playlistStore.playlists.length}
<div class="plm-rule"></div>
<div class="plm-list">
{#each playlistStore.playlists as pl (pl.id)}
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
{#if doneFor === pl.id}
<span class="ok"><Icon name="check" size={14} /></span>
{:else}
<Icon name="playlist" size={14} />
{/if}
<span class="nm">{pl.name}</span>
<span class="ct">{pl.track_count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div> </div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div
class="plm-menu"
bind:this={menuEl}
use:overlay
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<button class="plm-item new" onclick={addToNew} disabled={busy}>
<Icon name="plus" size={14} /> New playlist
</button>
{#if targets.length}
<div class="plm-rule"></div>
<div class="plm-list">
{#each targets as pl (pl.id)}
<button class="plm-item" onclick={() => addTo(pl)} disabled={busy}>
{#if doneFor === pl.id}
<span class="ok"><Icon name="check" size={14} /></span>
{:else}
<Icon name="playlist" size={14} />
{/if}
<span class="nm">{pl.name}</span>
<span class="ct">{pl.track_count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<style> <style>
.plm { .plm {
@@ -145,23 +217,20 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
/* Fixed positioning escapes overflow clipping and every stacking context;
placed (and made visible) by the overlay action once measured. */
.plm-menu { .plm-menu {
position: absolute; position: fixed;
top: calc(100% + 7px); visibility: hidden;
left: 0;
min-width: 210px; min-width: 210px;
max-width: 270px; max-width: 270px;
background: var(--bg-elevated); background: var(--bg-elevated);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
padding: 5px; padding: 5px;
z-index: 80; z-index: 1000;
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55); box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
} }
.plm-menu.right {
left: auto;
right: 0;
}
.plm-list { .plm-list {
max-height: 260px; max-height: 260px;
overflow-y: auto; overflow-y: auto;
+185
View File
@@ -0,0 +1,185 @@
<script>
// The listen's presence while the import modal is closed — the import
// toast's dark glass pill, one berth higher so both can show at once.
// Running: live stage + window count + a cancel. Done: click to open
// the review. Error: what happened + dismiss.
import { goToView } from "../lib/state.svelte.js";
import { setlistScan, dismissScan } from "../lib/setlistScan.svelte.js";
const label = $derived.by(() => {
if (setlistScan.error) return setlistScan.error;
if (setlistScan.draft) {
const n = setlistScan.draft.entries?.length ?? 0;
return `${setlistScan.label} — ${n} row${n === 1 ? "" : "s"}`;
}
switch (setlistScan.stage) {
case "tool":
return "Fetching yt-dlp…";
case "runtime":
return "Fetching deno…";
case "audio":
return `Fetching the set's audio…${
setlistScan.pct != null ? ` ${Math.round(setlistScan.pct)}%` : ""
}`;
case "decode":
return "Decoding the set…";
case "listen":
return `Listening… ${setlistScan.window} of ${setlistScan.total || "?"}`;
default:
return "Listening…";
}
});
function review() {
setlistScan.wantsReview = true;
goToView("playlists");
}
</script>
{#if setlistScan.active}
<div class="layer">
<div class="toast" class:err={!!setlistScan.error}>
{#if setlistScan.draft}
<button class="body" onclick={review} title="Open the review">
<span class="mark ok">✓</span>
<span>{label} — <b>review</b></span>
</button>
<button
class="x"
onclick={dismissScan}
title="Dismiss — the hearing is remembered, a re-listen is instant"
>×</button>
{:else if setlistScan.error}
<span class="body">
<span class="mark warn">!</span>
<span>{label}</span>
</span>
<button class="x" onclick={dismissScan} title="Dismiss">×</button>
{:else}
<span class="body">
<span class="mark pulse">▸</span>
<span>{label}</span>
{#if setlistScan.stage === "listen" && setlistScan.hit}
<span class="hit">{setlistScan.hit}</span>
{/if}
</span>
<button class="x" onclick={dismissScan} title="Cancel the listen">×</button>
{/if}
</div>
</div>
{/if}
<style>
/* The import toast's berth, one pill higher, so both can show at once. */
.layer {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--player-height, 62px) + 8px + 26px + 44px);
display: flex;
justify-content: center;
z-index: 80;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 8px 5px 16px;
border-radius: 99px;
background: rgba(22, 23, 28, 0.94);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
font-size: 12.5px;
font-weight: 700;
color: #fff;
animation: chip-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.toast.err {
border-color: rgba(240, 205, 119, 0.4);
}
.body {
display: inline-flex;
align-items: center;
gap: 10px;
background: none;
border: 0;
padding: 4px 8px 4px 0;
color: inherit;
font: inherit;
cursor: default;
max-width: 520px;
}
button.body {
cursor: pointer;
border-radius: 99px;
}
button.body:hover {
color: var(--accent);
}
.hit {
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
}
.x {
background: none;
border: 0;
color: rgba(255, 255, 255, 0.55);
font: inherit;
font-size: 15px;
line-height: 1;
padding: 4px 8px;
border-radius: 99px;
cursor: pointer;
}
.x:hover {
color: #fff;
}
.mark {
flex: none;
font-style: normal;
}
.mark.pulse {
color: var(--accent);
animation: chip-pulse 1s ease-in-out infinite;
}
@keyframes chip-pulse {
50% {
opacity: 0.35;
}
}
.mark.ok {
color: var(--accent);
}
.mark.warn {
color: #f0cd77;
}
b {
font-weight: 800;
}
@keyframes chip-in {
from {
opacity: 0;
transform: translateY(16px) scale(0.95);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation-duration: 1ms;
}
.mark.pulse {
animation: none;
}
}
</style>
+42 -4
View File
@@ -73,7 +73,8 @@ export const showInFolder = (path) => invoke("show_in_folder", { path });
export const refreshArtistArt = () => invoke("refresh_artist_art"); export const refreshArtistArt = () => invoke("refresh_artist_art");
export const playStream = (username, path, size, duration) => export const playStream = (username, path, size, duration) =>
invoke("play_stream", { username, path, size, duration }); invoke("play_stream", { username, path, size, duration });
export const playLocal = (path, duration) => invoke("play_local", { path, duration }); export const playLocal = (path, duration, startSecs = null) =>
invoke("play_local", { path, duration, startSecs });
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration }); export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
export const playerControl = (action, value, rate) => export const playerControl = (action, value, rate) =>
invoke("player_control", { action, value: value ?? null, rate: rate ?? null }); invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
@@ -81,7 +82,8 @@ export const playerControl = (action, value, rate) =>
// playback from events, so after a reload this is the only way to find out that // playback from events, so after a reload this is the only way to find out that
// audio is still running — see `restoreSession`. // audio is still running — see `restoreSession`.
export const playerState = () => invoke("player_state"); export const playerState = () => invoke("player_state");
export const waveform = (path, buckets) => invoke("waveform", { path, buckets }); export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
invoke("waveform", { path, buckets, startSecs, endSecs });
export const libraryAlbums = () => invoke("library_albums"); export const libraryAlbums = () => invoke("library_albums");
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs }); export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
export const albumTracks = (albumId) => invoke("album_tracks", { albumId }); export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
@@ -170,6 +172,8 @@ export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
durationSecs: fields.durationSecs ?? null, durationSecs: fields.durationSecs ?? null,
path: fields.path ?? null, path: fields.path ?? null,
artPath: fields.artPath ?? null, artPath: fields.artPath ?? null,
trackId: fields.trackId ?? null,
artUrl: fields.artUrl ?? null,
}); });
// Cover as a data URL: custom master, else composed mosaic, else null. // Cover as a data URL: custom master, else composed mosaic, else null.
export const playlistCover = (id, maxEdge = null) => export const playlistCover = (id, maxEdge = null) =>
@@ -181,11 +185,44 @@ export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id })
export const playlistImportProbe = () => invoke("playlist_import_probe"); export const playlistImportProbe = () => invoke("playlist_import_probe");
export const playlistImportFolder = () => invoke("playlist_import_folder"); export const playlistImportFolder = () => invoke("playlist_import_folder");
export const playlistImportText = (text) => invoke("playlist_import_text", { text }); export const playlistImportText = (text) => invoke("playlist_import_text", { text });
export const playlistImportCommit = (name, description, kind, entries) => export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries }); // The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
// The local door is split pick/scan so the modal can collect an optional
// pasted tracklist between the two.
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
export const playlistScanFile = (path, tracklist = null) =>
invoke("playlist_scan_file", { path, tracklist });
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
export const ytdlpStatus = () => invoke("ytdlp_status");
export const ytdlpDownload = () => invoke("ytdlp_download");
export const denoDownload = () => invoke("deno_download");
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
invoke("playlist_import_commit", {
name,
description: description ?? null,
kind,
entries,
coverUrl,
});
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to // Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
// the written path, or null when the dialog was cancelled. // the written path, or null when the dialog was cancelled.
export const playlistExport = (id, format) => invoke("playlist_export", { id, format }); export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
export const lyricModelStatus = () => invoke("lyric_model_status");
export const lyricModelDownload = () => invoke("lyric_model_download");
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
export const vocalSepStatus = () => invoke("vocal_sep_status");
export const vocalSepDownload = () => invoke("vocal_sep_download");
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
invoke("lyric_prep_request", {
path: path ?? null,
stream,
durationSecs: durationSecs ?? null,
lines,
});
// Local-library candidates for the unresolved-row repair panel. // Local-library candidates for the unresolved-row repair panel.
export const libraryTrackSearch = (query) => invoke("library_track_search", { query }); export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
@@ -220,6 +257,7 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
export const lyricsSaveLines = (title, artist, durationSecs, lines) => export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines }); invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
export const catalogSearch = (query) => invoke("catalog_search", { query }); export const catalogSearch = (query) => invoke("catalog_search", { query });
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
export const rememberFusedArtistPortrait = (artistId, portrait) => export const rememberFusedArtistPortrait = (artistId, portrait) =>
invoke("remember_fused_artist_portrait", { artistId, portrait }); invoke("remember_fused_artist_portrait", { artistId, portrait });
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId); export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
Binary file not shown.
+13
View File
@@ -59,6 +59,7 @@ const ROOTS = new Set([
"library", "library",
"downloads", "downloads",
"settings", "settings",
"playlists",
"catalog", "catalog",
]); ]);
@@ -248,6 +249,18 @@ export function trail() {
}, },
); );
} }
// A playlist sits under the Playlists shelf the same way. The shelf is a
// root, so when the page was reached through it the origin crumb already
// says Playlists; this branch covers the direct arrivals (a pinned
// playlist in the sidebar) by borrowing the shelf from history or
// synthesising it.
if (here.view === "playlist" && nav.origin?.view !== "playlists") {
const known = [...nav.stack].reverse().find((e) => e.view === "playlists");
crumbs.push(
known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" },
);
}
crumbs.push(here); crumbs.push(here);
return crumbs; return crumbs;
} }
+101
View File
@@ -0,0 +1,101 @@
// Background DJ-set listening. A scan OUTLIVES the import modal: starting a
// listen closes the modal, progress rides a global chip above the player
// bar, the app stays fully usable, and the finished draft waits on the chip
// until it is reviewed or dismissed. One scan at a time — the backend's
// cancel flag is global by design.
import * as api from "./api.js";
export const setlistScan = $state({
active: false,
mode: null, // "youtube" | "file"
label: "",
stage: null, // "tool" | "runtime" | "audio" | "decode" | "listen"
window: 0,
total: 0,
pct: null,
hit: null,
draft: null, // finished ImportDraft awaiting review
error: null,
wantsReview: false, // chip clicked; the Playlists view consumes this
});
let subscribed = false;
function subscribe() {
if (subscribed) return;
subscribed = true;
api.onEvent("playlist:scan-progress", (p) => {
// A ready chip must not be mutated by stray late events.
if (!setlistScan.active || setlistScan.draft || !p?.stage) return;
setlistScan.stage = p.stage;
if (p.window != null) setlistScan.window = p.window;
if (p.total != null) setlistScan.total = p.total;
setlistScan.pct = p.pct ?? null;
if (p.hit != null) setlistScan.hit = p.hit;
});
}
async function run(mode, label, call) {
subscribe();
Object.assign(setlistScan, {
active: true,
mode,
label,
stage: mode === "youtube" ? "audio" : "decode",
window: 0,
total: 0,
pct: null,
hit: null,
draft: null,
error: null,
wantsReview: false,
});
try {
const draft = await call();
if (draft) {
setlistScan.draft = draft;
setlistScan.label = draft.name ?? label;
} else {
// Defensive: a null draft must not wedge the chip.
setlistScan.active = false;
}
} catch (e) {
const message = String(e);
if (message.includes("canceled")) setlistScan.active = false;
else setlistScan.error = message;
}
}
/** Kick a YouTube listen into the background. False = one already runs. */
export function startYoutubeListen(url) {
if (setlistScan.active) return false;
void run("youtube", "YouTube set", () => api.playlistScanYoutube(url));
return true;
}
/** Listen to an already-picked set recording in the background, with an
* optionally pasted tracklist riding along. False = one already runs. */
export function startFileListen(path, tracklist = null, label = "Set recording") {
if (setlistScan.active) return false;
void run("file", label, () => api.playlistScanFile(path, tracklist));
return true;
}
/** Hand the finished draft to the review and clear the chip. */
export function takeScanDraft() {
const draft = setlistScan.draft;
setlistScan.draft = null;
setlistScan.active = false;
setlistScan.wantsReview = false;
return draft;
}
/** Cancel a running scan, or drop a finished/failed one unseen. */
export function dismissScan() {
if (setlistScan.active && !setlistScan.draft && !setlistScan.error) {
void api.playlistScanCancel();
}
setlistScan.active = false;
setlistScan.draft = null;
setlistScan.error = null;
setlistScan.wantsReview = false;
}
+275 -27
View File
@@ -1624,7 +1624,9 @@ const VIEW_LABELS = {
library: "Your Library", library: "Your Library",
downloads: "Downloads", downloads: "Downloads",
settings: "Settings", settings: "Settings",
playlists: "Playlists",
playlist: "Playlist", playlist: "Playlist",
lyrics: "Lyrics",
}; };
const VIEW_GLYPHS = { const VIEW_GLYPHS = {
home: "home", home: "home",
@@ -1632,7 +1634,9 @@ const VIEW_GLYPHS = {
library: "library", library: "library",
downloads: "download", downloads: "download",
settings: "settings", settings: "settings",
playlists: "playlist",
playlist: "music", playlist: "music",
lyrics: "music",
}; };
/** Re-open a recorded place. The one function the history arrows and the trail /** Re-open a recorded place. The one function the history arrows and the trail
@@ -3384,10 +3388,37 @@ export async function saveStream(scope) {
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */ /** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
function currentArtistAlbum() { function currentArtistAlbum() {
const item = player.queue[player.queueIndex] ?? null;
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
// so its own fields are the whole identity — splitting would offer the
// playlist name as an artist (and negative-cache the click into silence).
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
const sub = player.track?.sub ?? ""; const sub = player.track?.sub ?? "";
return sub.includes(" — ") const split = sub.includes(" — ")
? sub.split(" — ").map((s) => s.trim()) ? sub.split(" — ").map((s) => s.trim())
: [sub.trim(), ""]; : [sub.trim(), ""];
// Per field, the item's own artist/album win — the player bar's rule.
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
}
/** Anchor-exact album jump for a row that knows its catalog track id but not
* its record (playlist stream rows from before commits stored albums): one
* name search, then the ID picks the track — never a name guess. */
async function openAlbumForCurrentTrackId(opts = {}) {
const item = player.queue[player.queueIndex] ?? null;
const trackId = item?.trackId;
if (trackId == null || !item?.artist || !item?.title) return false;
try {
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
if (hit?.album_id != null) {
openCatalogAlbum(hit.album_id, { trackId, ...opts });
return true;
}
} catch {
// fall through to the name path
}
return false;
} }
/** Now-playing meta links: jump to the artist / album / song's catalog page. */ /** Now-playing meta links: jump to the artist / album / song's catalog page. */
@@ -3395,15 +3426,16 @@ export function openCurrentArtist() {
const [artist] = currentArtistAlbum(); const [artist] = currentArtistAlbum();
if (artist) openArtistByName(artist); if (artist) openArtistByName(artist);
} }
export function openCurrentAlbum() { export async function openCurrentAlbum() {
if (player.catalogRef.albumId != null) { if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId); openCatalogAlbum(player.catalogRef.albumId);
return; return;
} }
if (await openAlbumForCurrentTrackId()) return;
const [artist, album] = currentArtistAlbum(); const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album); if (album) openAlbumByName(artist, album);
} }
export function openCurrentSong() { export async function openCurrentSong() {
const trackTitle = player.track?.title; const trackTitle = player.track?.title;
if (player.catalogRef.albumId != null) { if (player.catalogRef.albumId != null) {
openCatalogAlbum(player.catalogRef.albumId, { openCatalogAlbum(player.catalogRef.albumId, {
@@ -3412,6 +3444,7 @@ export function openCurrentSong() {
}); });
return; return;
} }
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
const [artist, album] = currentArtistAlbum(); const [artist, album] = currentArtistAlbum();
if (album) openAlbumByName(artist, album, { trackTitle }); if (album) openAlbumByName(artist, album, { trackTitle });
} }
@@ -3827,6 +3860,18 @@ async function cachedStreamSourceFor(trackId, artist, title) {
for (const key of streamSourceKeys(trackId, artist, title)) { for (const key of streamSourceKeys(trackId, artist, title)) {
const src = map.get(key); const src = map.get(key);
if (!src) continue; if (!src) continue;
// A remembered file whose NAME does not even contain the track's
// base title is a mis-paired memory (the 2026-08-20 incident:
// three unrelated rows each "remembered" the same Weezer FLAC and
// played it from the warm cache forever). Delete it and resolve
// normally — a wrongly-dropped legit source only costs one search.
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
if (base.length >= 4 && fileName && !fileName.includes(base)) {
map.delete(key);
saveStreamSources();
continue;
}
try { try {
if (await api.streamCacheReady(src.username, src.path, src.size)) return src; if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
} catch { } catch {
@@ -5542,7 +5587,7 @@ export function initEvents() {
// the reader back where they were. Taken exactly once, so an // the reader back where they were. Taken exactly once, so an
// ordinary resume later in the same track is unaffected. // ordinary resume later in the same track is unaffected.
const seekTo = takeResumeSeek(); const seekTo = takeResumeSeek();
if (seekTo != null) api.playerControl("seek", seekTo); if (seekTo != null) seekPlayer(seekTo);
break; break;
} }
case "paused": case "paused":
@@ -5559,7 +5604,19 @@ export function initEvents() {
if (ev.gapless) advanceGaplessly(); if (ev.gapless) advanceGaplessly();
else nextTrack(true); else nextTrack(true);
break; break;
case "position": case "position": {
// A set-recording slice shows slice-relative time, keeps its
// own duration (the engine reports the whole set's), and
// cuts to the next row at its end boundary.
const seg = player.queue[player.queueIndex]?.segment;
if (seg) {
const rel = Math.max(0, ev.secs - seg.start);
latestPlayerPosition = rel;
if (!player.scrubbing && ui.windowActive) player.position = rel;
listenTick(rel);
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
break;
}
latestPlayerPosition = ev.secs; latestPlayerPosition = ev.secs;
if (ev.duration != null) latestPlayerDuration = ev.duration; if (ev.duration != null) latestPlayerDuration = ev.duration;
if (!player.scrubbing && ui.windowActive) player.position = ev.secs; if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
@@ -5568,6 +5625,7 @@ export function initEvents() {
} }
listenTick(ev.secs); listenTick(ev.secs);
break; break;
}
case "error": case "error":
player.status = "idle"; player.status = "idle";
player.error = ev.message; player.error = ev.message;
@@ -5593,8 +5651,7 @@ export function initEvents() {
// Fully cached → final waveform, and the peer's slot is // Fully cached → final waveform, and the peer's slot is
// definitely free, so make sure the next track is pre-buffering. // definitely free, so make sure the next track is pre-buffering.
if (cur?.stream) loadStreamWaveform(cur, true); if (cur?.stream) loadStreamWaveform(cur, true);
preloadNextStream(); prepareFollowing();
tryAppendNext();
} else if ( } else if (
ui.windowActive ui.windowActive
&& cur?.stream && cur?.stream
@@ -5823,22 +5880,33 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
} }
/** Play an album/playlist (list of library tracks) starting at `start`. /** Play an album/playlist (list of library tracks) starting at `start`.
* `artPath` is the album cover; playlist tracks carry their own art_path. */ * `artPath` is the album cover; playlist tracks carry their own art_path.
*
* A row with `needsResolve` is a playlist STREAM row: full identity, no
* source yet. It queues as a pending item that resolves through the normal
* ladder when reached (or by the look-ahead a track early), so Play on a
* mixed playlist keeps every row in order instead of skipping the streams. */
export function playAlbum(tracks, subLabel, start = 0, artPath = null) { export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
autoAlbum = null; autoAlbum = null;
player.queue = tracks.map((t) => ({ player.queue = tracks.map((t) => ({
path: t.path, path: t.path ?? null,
title: t.title, title: t.title,
sub: subLabel, sub: subLabel,
artist: t.artist ?? null, artist: t.artist ?? null,
album: t.album ?? null, album: t.album ?? null,
duration: t.duration_secs ?? null, duration: t.duration_secs ?? null,
art: t.art_path ?? artPath, art: t.art_path ?? (t.needsResolve ? null : artPath),
quality: localQuality(t), artUrl: t.artUrl ?? null,
trackId: t.trackId ?? null,
quality: t.path ? localQuality(t) : null,
// Which playlist OCCURRENCE this queue item came from, so the playlist // Which playlist OCCURRENCE this queue item came from, so the playlist
// page highlights exactly the row that is playing — a recording that // page highlights exactly the row that is playing — a recording that
// appears twice must light up once. Null for album playback. // appears twice must light up once. Null for album playback.
plItemId: t.plItemId ?? null, plItemId: t.plItemId ?? null,
// A set-recording SLICE: {start, end} seconds into the file at
// `path` (end null = to the file's end). The playback layer seeks
// in at start and cuts at end; display time is slice-relative.
segment: t.segment ?? null,
})); }));
player.catalogRef = { albumId: null, trackId: null }; player.catalogRef = { albumId: null, trackId: null };
playQueueIndex(start); playQueueIndex(start);
@@ -5863,6 +5931,15 @@ function playQueueIndex(i) {
}; };
player.status = "buffering"; player.status = "buffering";
player.duration = item.duration; player.duration = item.duration;
// A playlist stream row queued without a source yet: resolve it now, then
// re-enter with the source attached. The item mutates in place, so a
// repeat visit (prev/next, repeat-all) is instant.
if (!item.path && !item.stream) {
player.track.source = "stream";
player.streamTotal = 0;
void resolvePendingQueueItem(i, item);
return;
}
if (item.stream) { if (item.stream) {
// Remote track streamed from a peer while it downloads to cache. // Remote track streamed from a peer while it downloads to cache.
rememberStreamSource(item); rememberStreamSource(item);
@@ -5881,8 +5958,7 @@ function playQueueIndex(i) {
.then(() => { .then(() => {
// Ordered behind play_stream so the gapless append can't // Ordered behind play_stream so the gapless append can't
// race the generation bump on the audio thread. // race the generation bump on the audio thread.
preloadNextStream(); prepareFollowing();
tryAppendNext();
}) })
.catch((e) => { .catch((e) => {
player.error = String(e); player.error = String(e);
@@ -5891,12 +5967,27 @@ function playQueueIndex(i) {
return; return;
} }
player.streamTotal = 0; player.streamTotal = 0;
if (item.segment) {
// A set-recording slice: the lane shows the SLICE's own waveform (a
// ranged decode — a 90-minute skyline says nothing about a 4-minute
// stretch), and the decoder comes up at the slice start. The early
// cut at segment.end is enforced by the position handler.
loadWaveform(item.path, item.segment);
setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
player.error = String(e);
});
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
// a pending row after one still resolves a track early.
prepareFollowing();
return;
}
loadWaveform(item.path); loadWaveform(item.path);
setAccentFromArt(item.art ?? null); setAccentFromArt(item.art ?? null);
api.playLocal(item.path, item.duration).catch((e) => { api.playLocal(item.path, item.duration).catch((e) => {
player.error = String(e); player.error = String(e);
}); });
preloadFollowingTrack(); prepareFollowing();
} }
/** Play a specific queue position (Now Playing panel track clicks). */ /** Play a specific queue position (Now Playing panel track clicks). */
@@ -5910,14 +6001,23 @@ function gaplessNextIndex() {
if (player.shuffle) return -1; if (player.shuffle) return -1;
if (player.repeat === "one") return player.queueIndex; if (player.repeat === "one") return player.queueIndex;
const next = player.queueIndex + 1; const next = player.queueIndex + 1;
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1; const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
// Set-recording slices never join the gapless machinery: a preload or
// append would come up at the FILE's start, not the slice's, and the
// slice's early cut is driven by the position handler, not the
// engine's track-ended.
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
return -1;
}
return i;
} }
function preloadFollowingTrack() { function preloadFollowingTrack() {
const next = gaplessNextIndex(); const next = gaplessNextIndex();
if (next < 0) return; if (next < 0) return;
const item = player.queue[next]; const item = player.queue[next];
if (item.stream) return; // gapless preload is local-only // Gapless preload is local-only; a pending stream row has no path at all.
if (item.stream || !item.path) return;
api.preloadLocal(item.path, item.duration).catch(() => {}); api.preloadLocal(item.path, item.duration).catch(() => {});
} }
@@ -5967,6 +6067,21 @@ async function tryAppendNext() {
} }
} }
/** Arm every next-track preparation that applies: a LOCAL next preloads
* into the sink, a resolved STREAM next pre-buffers and offers the
* gapless append, and a PENDING playlist row resolves a track early so
* it can join the same chain. Each is a cheap no-op when it does not
* apply — call this at every track start and every gapless boundary,
* whatever the current source is. (The playlist lesson: only the
* album-shaped paths were armed, so a mixed playlist went seamless for
* exactly one boundary and stalled on every one after it.) */
function prepareFollowing() {
preloadFollowingTrack();
preloadNextStream();
tryAppendNext();
scheduleQueueLookahead();
}
function advanceGaplessly() { function advanceGaplessly() {
const next = gaplessNextIndex(); const next = gaplessNextIndex();
// The backend only labels a boundary gapless when it has this source // The backend only labels a boundary gapless when it has this source
@@ -6000,15 +6115,14 @@ function advanceGaplessly() {
if ("trackId" in item) player.catalogRef.trackId = item.trackId; if ("trackId" in item) player.catalogRef.trackId = item.trackId;
setAccentFromArtUrl(item.artUrl ?? null); setAccentFromArtUrl(item.artUrl ?? null);
loadStreamWaveform(item); loadStreamWaveform(item);
preloadNextStream(); prepareFollowing();
tryAppendNext();
scheduleAlbumFill(); scheduleAlbumFill();
return; return;
} }
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path }; player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
loadWaveform(item.path); loadWaveform(item.path);
setAccentFromArt(item.art ?? null); setAccentFromArt(item.art ?? null);
preloadFollowingTrack(); prepareFollowing();
} }
// ---- album continuation ---- // ---- album continuation ----
@@ -6164,6 +6278,118 @@ async function maybeExtendAlbumQueue() {
} }
} }
// ---- pending playlist stream rows -------------------------------------------
// A mixed playlist queues its stream rows as PENDING items — identity, no
// source. They resolve through the same ladder the album fill uses: the
// remembered-source memory first (free), then the sanitized search. One
// resolution is in flight per item, shared between play-on-reach and the
// look-ahead, so hammering next can't stack searches for one row.
const pendingResolves = new WeakMap();
/** Resolve one pending descriptor to a playable source, or null. */
async function resolveStreamSourceFor(t, budgetMs = 7000) {
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
if (known) {
return {
path: known.path,
duration: known.duration ?? t.duration ?? null,
stream: { username: known.username, size: known.size },
quality: known.quality ?? null,
};
}
if (app.connection !== "online") return null;
const goodCount = (rs) =>
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
let artistIncluded = true;
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
if (!responses.length && fallback && fallback !== primary) {
responses = await collectSources(fallback, budgetMs, goodCount);
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
} else if (primary) {
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
}
const best = songCandidates(
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
)[0];
if (!best) return null;
return {
path: best.file.path,
duration: best.file.duration ?? t.duration ?? null,
stream: { username: best.username, size: best.file.size },
quality: qualityOf(best.file),
};
}
/** Single-flight source resolution for a queue item; mutates it in place. */
function resolveQueueItem(item) {
const inFlight = pendingResolves.get(item);
if (inFlight) return inFlight;
const p = resolveStreamSourceFor(item)
.then((src) => {
if (src) Object.assign(item, src);
return src;
})
.catch(() => null)
.finally(() => pendingResolves.delete(item));
pendingResolves.set(item, p);
return p;
}
// Two sourceless rows in a row is a signal (a whole stretch that cannot
// resolve), and auto-advancing through a long dead playlist would fire one
// Soulseek search per row — the exact flood the album fill's delay exists to
// prevent. The streak resets on any success.
let pendingFailStreak = 0;
async function resolvePendingQueueItem(i, item) {
const src = await resolveQueueItem(item);
// The world may have moved on mid-search: queue replaced, or the user is
// on another row. An enriched item keeps its source either way.
if (player.queue[i] !== item || player.queueIndex !== i) return;
if (src) {
pendingFailStreak = 0;
playQueueIndex(i);
return;
}
item.unplayable = true;
player.error = `No streamable sources found for “${item.title}”`;
pendingFailStreak += 1;
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
pendingFailStreak = 0;
stopPlayback();
return;
}
nextTrack(true);
}
// Resolve the NEXT pending row a track early, on the album fill's own
// anti-flood delay, so the boundary into a stream row starts instantly
// instead of stalling on a live search.
let queueLookaheadTimer = null;
function scheduleQueueLookahead() {
clearTimeout(queueLookaheadTimer);
queueLookaheadTimer = setTimeout(() => {
queueLookaheadTimer = null;
void resolveNextPendingItem();
}, ALBUM_FILL_DELAY_MS);
}
async function resolveNextPendingItem() {
if (player.shuffle) return; // the next index is unknowable
const next = player.queueIndex + 1;
const item = player.queue[next];
if (!item || item.path || item.stream || item.unplayable) return;
const src = await resolveQueueItem(item);
if (!src || !player.queue.includes(item)) return;
// Now a normal upcoming stream: pre-buffer it and offer the gapless
// append, exactly as if it had been queued with its source.
preloadNextStream();
tryAppendNext();
}
// ---- waveform ---- // ---- waveform ----
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so // 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
@@ -6177,24 +6403,32 @@ let waveReqPath = null;
// Throttle for progressive stream-waveform recomputes (see soulseek:stream). // Throttle for progressive stream-waveform recomputes (see soulseek:stream).
let waveRefreshAt = 0; let waveRefreshAt = 0;
async function loadWaveform(path) { async function loadWaveform(path, range = null) {
player.waveform = []; player.waveform = [];
player.wavePending = !!path; player.wavePending = !!path;
waveReqPath = path; // A range = a set-recording slice's lane; each slice keys its own
// request so two slices of one file never answer for each other.
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
waveReqPath = req;
if (!path) return; if (!path) return;
try { try {
const peaks = await api.waveform(path, WAVE_BUCKETS); const peaks = await api.waveform(
path,
WAVE_BUCKETS,
range?.start ?? null,
range?.end ?? null,
);
// Ignore if the track changed while we were computing. // Ignore if the track changed while we were computing.
if (waveReqPath === path) { if (waveReqPath === req) {
player.waveform = peaks; player.waveform = peaks;
player.waveformCoverage = 1; player.waveformCoverage = 1;
} }
} catch { } catch {
if (waveReqPath === path) player.waveform = []; if (waveReqPath === req) player.waveform = [];
} finally { } finally {
// Either way the request is over; a failed local decode rests as dots // Either way the request is over; a failed local decode rests as dots
// rather than swelling forever at a waveform that will never come. // rather than swelling forever at a waveform that will never come.
if (waveReqPath === path) player.wavePending = false; if (waveReqPath === req) player.wavePending = false;
} }
} }
@@ -6314,15 +6548,29 @@ export function nextTrack(auto = false) {
playQueueIndex(next); playQueueIndex(next);
} }
/** Engine time for a TRACK-time value. A set-recording slice's engine
* clock is the whole file's, so the slice offset is added exactly here —
* every transport call that hands the engine a time (seek, scratch,
* scratch release) must convert through this door. */
export function engineTime(secs) {
const seg = player.queue[player.queueIndex]?.segment;
return seg ? seg.start + Math.max(0, secs) : secs;
}
/** Seek in TRACK time; see `engineTime`. */
export function seekPlayer(secs) {
api.playerControl("seek", engineTime(secs));
}
export function prevTrack() { export function prevTrack() {
// Restart the current track if we're more than 3s in. // Restart the current track if we're more than 3s in.
if (player.position > 3) { if (player.position > 3) {
api.playerControl("seek", 0); seekPlayer(0);
return; return;
} }
const prev = player.queueIndex - 1; const prev = player.queueIndex - 1;
if (prev >= 0) playQueueIndex(prev); if (prev >= 0) playQueueIndex(prev);
else api.playerControl("seek", 0); else seekPlayer(0);
} }
export function toggleShuffle() { export function toggleShuffle() {
+106
View File
@@ -30,6 +30,7 @@
import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { listenerHeat, listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … } const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
const album = $derived(page?.album ?? null); const album = $derived(page?.album ?? null);
@@ -132,18 +133,23 @@
* library keeps albums, not songs, so the owned tracklist has to be read * library keeps albums, not songs, so the owned tracklist has to be read
* from the matching local album rather than filtered out of a song list. */ * from the matching local album rather than filtered out of a song list. */
let libraryTicks = $state(new Set()); let libraryTicks = $state(new Set());
/** The same rows with their paths kept, so a playlist add can point at the
* owned file instead of a stream placeholder. */
let libraryRows = $state([]);
let ticksFor = null; let ticksFor = null;
$effect(() => { $effect(() => {
const id = localAlbum?.id ?? null; const id = localAlbum?.id ?? null;
if (id === ticksFor) return; if (id === ticksFor) return;
ticksFor = id; ticksFor = id;
libraryTicks = new Set(); libraryTicks = new Set();
libraryRows = [];
if (id == null) return; if (id == null) return;
let stale = false; let stale = false;
api.albumTracks(id) api.albumTracks(id)
.then((rows) => { .then((rows) => {
if (!stale && ticksFor === id) { if (!stale && ticksFor === id) {
libraryTicks = new Set(rows.map((r) => norm(r.title || ""))); libraryTicks = new Set(rows.map((r) => norm(r.title || "")));
libraryRows = rows;
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -152,6 +158,40 @@
}; };
}); });
// ---- add to playlist ----------------------------------------------------
/** Local files for this record by normalized title — a local page carries
* them in `page.libraryTracks`; an owned catalog page gets them from the
* ticks fetch above. */
const localByTitle = $derived.by(() => {
const m = new Map();
for (const lt of page?.libraryTracks ?? []) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
for (const lt of libraryRows) {
const k = norm(lt.title || "");
if (k && !m.has(k)) m.set(k, lt);
}
return m;
});
/** A playlist row for a track on this record. Owned tracks go in as the
* file on disk; the rest are placeholders that stream on click. */
function playlistRow(t) {
const local = localByTitle.get(norm(t.title || ""));
return {
title: t.title,
artist: album?.artist ?? null,
album: album?.title ?? null,
duration_secs: t.duration_secs ?? local?.duration_secs ?? null,
path: local?.path ?? null,
art_path: local ? (page?.artPath ?? localAlbum?.art_path ?? null) : null,
// Stream anchors, mirroring what playTrack hands the resolver. A
// local record's cover is a file path, not a URL — omit it there.
trackId: t.id ?? null,
artUrl: isLocal ? null : (t.cover ?? album?.cover ?? null),
};
}
// An upcoming album that still has unreleased songs can't be streamed or // An upcoming album that still has unreleased songs can't be streamed or
// saved as a whole — only its already-released singles play (per track). // saved as a whole — only its already-released singles play (per track).
const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased)); const hasUnreleased = $derived((page?.tracks ?? []).some((t) => t.unreleased));
@@ -804,6 +844,12 @@
<span class="badge" aria-hidden="true">{previewable}</span> <span class="badge" aria-hidden="true">{previewable}</span>
{/if} {/if}
</button> </button>
<PlaylistMenu
compact
iconSize={17}
title="Add this record to a playlist"
tracks={() => tracks.filter((t) => !t.unreleased).map(playlistRow)}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -900,6 +946,14 @@
<span class="tdur">{fmtDuration(t.duration_secs)}</span> <span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if} {/if}
<span class="rowbtns"> <span class="rowbtns">
{#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [playlistRow(t)]}
/>
{/if}
<button <button
class="mini-btn play" class="mini-btn play"
disabled={locked} disabled={locked}
@@ -1364,6 +1418,20 @@
color: rgba(255, 255, 255, 0.28); color: rgba(255, 255, 255, 0.28);
cursor: default; cursor: default;
} }
/* The add-to-playlist trigger wears the iconbtn look — the component
itself stays unstyled chrome (the Library rail set the precedent). */
.actions :global(.plm-trigger) {
display: grid;
place-items: center;
width: 2em;
height: 2em;
border-radius: 50%;
color: rgba(255, 255, 255, 0.85);
}
.actions :global(.plm-trigger:hover) {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* Count for a record only some of whose tracks have a clip — the same /* Count for a record only some of whose tracks have a clip — the same
partial-availability report the artist page makes. */ partial-availability report the artist page makes. */
.badge { .badge {
@@ -1745,10 +1813,14 @@
} }
.trow:hover .tdur, .trow:hover .tdur,
.trow:focus-within .tdur { .trow:focus-within .tdur {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0; opacity: 0;
pointer-events: none;
} }
.trow.locked:hover .tdur { .trow.locked:hover .tdur {
opacity: 1; opacity: 1;
pointer-events: auto;
} }
.tag-unreleased { .tag-unreleased {
@@ -1799,6 +1871,40 @@
.rowbtns .play { .rowbtns .play {
color: var(--accent-hover); color: var(--accent-hover);
} }
/* Three buttons now reach past the duration column into the listeners
bar on counted records, so the cluster carries its own fade-in shim —
on the hover highlight it reads as the buttons simply taking over. */
.rowbtns {
padding-left: 14px;
background: linear-gradient(to right, transparent, var(--bg-highlight) 14px);
}
.trow:focus-within {
background: var(--bg-highlight);
}
.rowbtns :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.rowbtns :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .rowbtns {
display: flex;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
@media (max-width: 820px) { @media (max-width: 820px) {
.hero { .hero {
+121
View File
@@ -51,6 +51,7 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte"; import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -5071,6 +5072,19 @@
<span class="track-album">{track.album}</span> <span class="track-album">{track.album}</span>
<span class="lis" class:unknown={track.listeners == null} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>{fmtListeners(track.listeners)}</span> <span class="lis" class:unknown={track.listeners == null} title={track.listeners == null ? "No Last.fm listener data for this track" : `${track.listeners.toLocaleString()} Last.fm listeners`}>{fmtListeners(track.listeners)}</span>
<span class="tdur">{fmtDuration(track.duration_secs)}</span> <span class="tdur">{fmtDuration(track.duration_secs)}</span>
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: track.title,
artist: page.artist.name,
album: track.album ?? null,
duration_secs: track.duration_secs ?? null,
trackId: track.id ?? null,
artUrl: track.cover ?? null,
}]}
/>
<button <button
class="mini-get" class="mini-get"
title="Save to library" title="Save to library"
@@ -5425,6 +5439,19 @@
<span class="tdur">{fmtDuration(t.duration_secs)}</span> <span class="tdur">{fmtDuration(t.duration_secs)}</span>
{/if} {/if}
{#if !locked} {#if !locked}
<PlaylistMenu
compact
iconSize={13}
align="right"
tracks={() => [{
title: t.title,
artist: page.artist.name,
album: openAlbum?.title ?? null,
duration_secs: t.duration_secs ?? null,
trackId: t.id ?? null,
artUrl: t.cover ?? openAlbum?.cover ?? null,
}]}
/>
<button <button
class="mini-btn play" class="mini-btn play"
title="Play" title="Play"
@@ -7633,6 +7660,11 @@
} }
.popular-row { .popular-row {
/* The anchor the absolute row actions were always written against —
without it the save button resolved against the page and its
reveal rules never existed, so it was dead UI. The 24px last
column is the space the overlaid actions land on. */
position: relative;
display: grid; display: grid;
grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px; grid-template-columns: 26px 40px minmax(130px, 1fr) minmax(100px, 0.75fr) 62px 40px 24px;
align-items: center; align-items: center;
@@ -7647,6 +7679,56 @@
background: var(--bg-highlight); background: var(--bg-highlight);
} }
/* Hover slides the actions over the right end and the duration gets out
of the way — the album page's treatment. The listener count stays: the
list is ordered by it. */
.popular-row :global(.plm) {
position: absolute;
top: 50%;
right: 36px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.popular-row :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.popular-row :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.popular-row:hover .mini-get,
.popular-row:focus-within .mini-get,
.popular-row:hover :global(.plm),
.popular-row:focus-within :global(.plm) {
opacity: 1;
}
.popular-row:hover .tdur,
.popular-row:focus-within .tdur {
/* pointer-events too: the faded duration is a stacking context and
must never eat clicks meant for the buttons over it. */
opacity: 0;
pointer-events: none;
}
/* While its menu is open the pointer is off in the portaled dropdown, so
hover alone would drop the cluster mid-interaction. */
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.popular-row:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
}
.popular-row.now .rank, .popular-row.now .rank,
.popular-row.now .track-name { .popular-row.now .track-name {
color: var(--accent-hover); color: var(--accent-hover);
@@ -8984,6 +9066,45 @@
.trow:hover .tdur, .trow:hover .tdur,
.trow:focus-within .tdur { .trow:focus-within .tdur {
opacity: 0; opacity: 0;
pointer-events: none;
}
/* Add-to-playlist joins the expander row's overlay pair, one slot left of
Play; its menu is portaled, so hover alone would drop the cluster while
the menu is open — the aria-expanded rules hold it. */
.trow :global(.plm) {
position: absolute;
top: 50%;
right: 64px;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.12s ease;
}
.trow :global(.plm-trigger) {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
color: var(--text-secondary);
}
.trow :global(.plm-trigger:hover) {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.08);
}
.trow:hover :global(.plm),
.trow:focus-within :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-btn,
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .mini-get {
opacity: 1;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) .tdur {
opacity: 0;
pointer-events: none;
}
.trow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: var(--bg-highlight);
} }
.trow.locked { .trow.locked {
+38 -2
View File
@@ -44,6 +44,7 @@
import { accentFromImage } from "../lib/color.js"; import { accentFromImage } from "../lib/color.js";
import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const TABS = ["All", "Songs", "Albums", "Artists", "In your library"]; const TABS = ["All", "Songs", "Albums", "Artists", "In your library"];
@@ -1284,6 +1285,19 @@
<span>{fmtCount(listenersOf(t))}</span> <span>{fmtCount(listenersOf(t))}</span>
</span> </span>
{/if} {/if}
<PlaylistMenu
compact
iconSize={14}
align="right"
tracks={() => [{
title: t.title,
artist: t.artist ?? null,
album: t.album ?? null,
duration_secs: t.duration_secs ?? null,
trackId: t.id ?? null,
artUrl: t.cover ?? null,
}]}
/>
<button <button
class="sv" class="sv"
title="Save to library" title="Save to library"
@@ -1928,7 +1942,7 @@
/* ---- song rows ------------------------------------------------------ */ /* ---- song rows ------------------------------------------------------ */
.srow { .srow {
display: grid; display: grid;
grid-template-columns: 20px 36px 1fr auto auto; grid-template-columns: 20px 36px 1fr auto auto auto;
align-items: center; align-items: center;
gap: 11px; gap: 11px;
padding: 7px 9px; padding: 7px 9px;
@@ -1936,7 +1950,7 @@
cursor: pointer; cursor: pointer;
} }
.srow.has-count { .srow.has-count {
grid-template-columns: 20px 36px 1fr auto auto auto; grid-template-columns: 20px 36px 1fr auto auto auto auto;
} }
.srow:hover { .srow:hover {
background: rgba(255, 255, 255, 0.055); background: rgba(255, 255, 255, 0.055);
@@ -2023,6 +2037,28 @@
.sv:hover { .sv:hover {
color: var(--accent-hover); color: var(--accent-hover);
} }
/* Add-to-playlist rides one cell left of Save, same reveal; the
aria-expanded rules hold the pair while its portaled menu is open. */
.srow :global(.plm) {
opacity: 0;
}
.srow :global(.plm-trigger) {
display: grid;
place-items: center;
color: var(--text-secondary);
}
.srow :global(.plm-trigger:hover) {
color: var(--accent-hover);
}
.srow:hover :global(.plm),
.srow:focus-within :global(.plm),
.srow:has(:global(.plm-trigger[aria-expanded="true"])) :global(.plm),
.srow:has(:global(.plm-trigger[aria-expanded="true"])) .sv {
opacity: 1;
}
.srow:has(:global(.plm-trigger[aria-expanded="true"])) {
background: rgba(255, 255, 255, 0.055);
}
/* Listener weight: the bar's LENGTH is the count, curved by /* Listener weight: the bar's LENGTH is the count, curved by
src/lib/listenerBars.js so one runaway hit cannot squash the rest of the src/lib/listenerBars.js so one runaway hit cannot squash the rest of the
+20
View File
@@ -2,6 +2,7 @@
import { downloads, network, playLocalFile } from "../lib/state.svelte.js"; import { downloads, network, playLocalFile } from "../lib/state.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte";
const uploads = $derived( const uploads = $derived(
Object.values(network.uploads) Object.values(network.uploads)
@@ -149,6 +150,15 @@
title="Show in folder" title="Show in folder"
onclick={() => api.showInFolder(d.local_path)} onclick={() => api.showInFolder(d.local_path)}
><Icon name="folder-open" size={16} /></button> ><Icon name="folder-open" size={16} /></button>
<PlaylistMenu
compact
iconSize={15}
align="right"
tracks={() => [{
title: d.name.replace(/\.[^.]+$/, ""),
path: d.local_path,
}]}
/>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -305,4 +315,14 @@
.reveal:hover { .reveal:hover {
opacity: 1; opacity: 1;
} }
.row :global(.plm-trigger) {
display: grid;
place-items: center;
opacity: 0.8;
color: inherit;
}
.row :global(.plm-trigger:hover) {
opacity: 1;
}
</style> </style>
+791
View File
@@ -0,0 +1,791 @@
<script>
// The lyrics PAGE — variant C "Immersion" of research/lyrics-redesign,
// implemented to the user's dialed recipe verbatim (2026-08-18):
// 38px/1028px centred column, gap 0.76em, idle 42%, active ×1.20,
// hero dim 57% + blur 3px, accent wash 22%, duotone 56%, focus falloff
// 0.3px·d² (capped), sung lines dimmed harder, accent karaoke fill,
// scroll-to-seek, word timing when the source carries it. Hero drift and
// auto-hide chrome were tried in the mock and left off.
//
// A real page in the shell — glass bar above, player bar below, Esc goes
// back. It replaced the fullscreen overlay room.
import { untrack } from "svelte";
import {
player,
ui,
theme,
catalog,
getArt,
getArtMaster,
navBack,
resolveArtistSeedByName,
getCatalogArtistPage,
getCatalogArtistHeroSeed,
} from "../lib/state.svelte.js";
import {
lyricsStore,
lyricIdentity,
ensureLyrics,
noteTrackChange,
activeLineIndex,
wordClockMs,
wordfill,
totalOffsetMs,
nudgeOffset,
setAutoSync,
} from "../lib/lyrics.svelte.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
// ---- the playing identity -------------------------------------------
const currentItem = $derived(player.queue?.[player.queueIndex] ?? null);
const title = $derived(player.track?.title ?? "");
const artistName = $derived(
(player.track?.artist ?? "").trim()
|| String(player.track?.sub ?? "").split(" — ")[0].trim(),
);
const albumName = $derived(currentItem?.album ?? null);
// Chip artwork: a local file's embedded art, else the stream's cover URL.
let chipArt = $state(null);
$effect(() => {
const item = currentItem;
chipArt = null;
if (!item) return;
if (item.art) {
let stale = false;
getArt(item.art, 256).then((u) => { if (!stale) chipArt = u; }).catch(() => {});
return () => { stale = true; };
}
chipArt = item.artUrl ?? null;
});
/** A Deezer cover URL re-asked at hero size — a display upgrade of the
* SAME asset, not a different lookup. */
function coverAtHeroSize(url) {
return typeof url === "string"
? url.replace(/\/(\d+)x\1(-[0-9a-f-]+\.jpg)$/i, "/1000x1000$2")
: url;
}
// ---- the backdrop ladder --------------------------------------------
// Artist hero first — the staged artist page's fully-resolved set when
// the user has it open in this session, else the cached payload's
// banners/seed (the raw payload keeps the hero SEED; the resolved set
// only stages on the artist page itself). The record's cover stands in
// until a hero lands, and stays for artists without one — at MASTER
// resolution for local files, never the 256px chip thumb (a thumbnail
// stretched over the whole stage is what "low quality" looks like).
/** Every http hero candidate a page payload carries, resolved set first. */
function bannersOf(p) {
const list = p?.banners?.length
? p.banners
: p?.banner
? [p.banner]
: p?.hero_seed_banner
? [p.hero_seed_banner]
: [];
return list.filter((b) => typeof b === "string" && b.startsWith("http"));
}
let heroUrl = $state(null);
let heroGen = 0;
$effect(() => {
const name = artistName;
const gen = ++heroGen;
heroUrl = null;
if (!name) return;
// The artist page already staged this artist? Its resolved hero set
// is the best source and costs nothing.
const staged = catalog.artistPage;
if (
staged?.artist?.name
&& staged.artist.name.trim().toLowerCase() === name.trim().toLowerCase()
) {
const b = bannersOf(staged);
if (b.length) {
heroUrl = b[0];
return;
}
}
untrack(() => {
void (async () => {
try {
const seed = await resolveArtistSeedByName(name);
if (heroGen !== gen || seed?.id == null) return;
// The full payload's banners cover custom heroes; the
// HERO SEED is the standard case — the same image the
// artist page paints first.
const page = await getCatalogArtistPage(seed.id).catch(() => null);
if (heroGen !== gen) return;
const b = bannersOf(page);
if (b.length) {
heroUrl = b[0];
return;
}
const hs = await getCatalogArtistHeroSeed(seed.id);
if (heroGen !== gen) return;
const url =
typeof hs === "string"
? hs
: hs?.banner ?? hs?.url ?? hs?.src ?? null;
if (typeof url === "string" && url.startsWith("http")) heroUrl = url;
} catch {
// no banner — the cover backdrop stands
}
})();
});
});
// The stage's cover: the real pixels. Local files ask for the MASTER
// (the deliberate act getArt's doc demands of full-bleed surfaces);
// stream covers get re-asked at hero size.
let coverBig = $state(null);
$effect(() => {
const item = currentItem;
coverBig = null;
if (!item) return;
if (item.art) {
let stale = false;
getArtMaster(item.art).then((u) => { if (!stale) coverBig = u; }).catch(() => {});
return () => { stale = true; };
}
coverBig = coverAtHeroSize(item.artUrl ?? null);
});
const backdrop = $derived(heroUrl ?? coverBig ?? chipArt);
// ---- lyrics ----------------------------------------------------------
let shownKey = null;
$effect(() => {
const identity = lyricIdentity();
const key = identity
? `${identity.artist} ${identity.title} ${identity.durationSecs ?? "x"}`
: "";
if (key !== shownKey) shownKey = key;
// untrack: ensureLyrics reads and writes store fields synchronously —
// tracked, a failed fetch retriggers forever (the pane documents it).
untrack(() => {
noteTrackChange();
void ensureLyrics();
});
});
const lines = $derived(lyricsStore.status === "synced" ? lyricsStore.lines : []);
const active = $derived(
lyricsStore.status === "synced" ? activeLineIndex(player.position ?? 0) : -1,
);
// ---- follow + scrub --------------------------------------------------
let feed = $state(null);
let holdUntil = 0;
let scrubIdx = $state(null);
let scrubbing = $state(false);
let chipPos = $state({ top: 0, left: 0 });
let settleTimer = 0;
let programmaticUntil = 0;
// The lens centres on the scrubbed line while the wheel is live,
// otherwise on the playing one.
const focusIdx = $derived(scrubIdx ?? active);
function centreOn(index, smooth = true) {
if (!feed || index < 0) return;
const el = feed.querySelector(`[data-line="${index}"]`);
if (!el) return;
const fr = feed.getBoundingClientRect();
const lr = el.getBoundingClientRect();
const top = feed.scrollTop + (lr.top + lr.height / 2) - (fr.top + fr.height / 2);
programmaticUntil = performance.now() + 800;
feed.scrollTo({
top: Math.max(0, top),
behavior: smooth && ui.windowActive ? "smooth" : "auto",
});
}
$effect(() => {
const index = active;
if (lyricsStore.status !== "synced") return;
if (performance.now() < holdUntil) return;
centreOn(index);
});
function fmtStamp(ms) {
const secs = Math.max(0, Math.round((ms + totalOffsetMs()) / 1000));
return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, "0")}`;
}
function onUserScroll() {
if (performance.now() < programmaticUntil) return;
if (!feed || lyricsStore.status !== "synced") return;
holdUntil = performance.now() + 3500;
const fr = feed.getBoundingClientRect();
const y = fr.top + fr.height / 2;
let best = -1;
let bd = Infinity;
feed.querySelectorAll("[data-line]").forEach((el) => {
const r = el.getBoundingClientRect();
const d = Math.abs(r.top + r.height / 2 - y);
if (d < bd) { bd = d; best = Number(el.dataset.line); }
});
if (best < 0) return;
scrubIdx = best;
scrubbing = true;
chipPos = { top: Math.round(y - 16), left: Math.max(10, Math.round(fr.left + fr.width / 2 - 640)) };
clearTimeout(settleTimer);
settleTimer = setTimeout(settleScrub, 340);
}
function settleScrub() {
const idx = scrubIdx;
scrubbing = false;
scrubIdx = null;
if (idx == null) return;
const line = lines[idx];
if (!line) return;
// The jump: scrolling through the words IS seeking the song.
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
function seekTo(line) {
clearTimeout(settleTimer);
scrubbing = false;
scrubIdx = null;
const secs = Math.max(0, (line.time_ms + totalOffsetMs()) / 1000);
api.playerControl("seek", secs).catch(() => {});
holdUntil = 0;
}
/** Focus falloff, the recipe's quadratic ramp: min(d²/4, 8) × 0.3px. */
function dofBlur(i) {
const d = Math.abs(i - focusIdx);
return Math.min((d * d) / 4, 8);
}
const SOURCE_LABEL = {
sidecar: "from a lyrics file",
embedded: "from the audio file",
lrclib: "via LRCLIB",
amll: "via AMLL DB · word-synced",
picked: "picked on LRCLIB",
user: "pasted by you",
};
function onkeydown(e) {
if (e.key === "Escape") void navBack();
}
// The room follows the music out: nothing playing, nothing to show.
$effect(() => {
if (ui.view === "lyrics" && !player.track) void navBack();
});
</script>
<svelte:window onkeydown={ui.view === "lyrics" ? onkeydown : undefined} />
<div class="lyrpage" style="--wash-accent: {theme.accent}">
<div class="full" aria-hidden="true">
{#if backdrop}
{#key backdrop}
<img src={backdrop} alt="" draggable="false" />
{/key}
<span class="tint"></span>
{/if}
<span class="grade"></span>
</div>
{#if title}
<div class="chip">
{#if chipArt}
<img src={chipArt} alt="" draggable="false" />
{:else}
<span class="chip-fallback"><Icon name="music" size={18} /></span>
{/if}
<div>
<div class="t">{title}</div>
<div class="a">{artistName}{albumName ? ` — ${albumName}` : ""}</div>
</div>
</div>
{/if}
{#if lyricsStore.status === "synced"}
<div class="tools">
<span class="src">{SOURCE_LABEL[lyricsStore.source] ?? ""}</span>
{#if lyricsStore.autoOffsetMs !== 0}
<button
class="autochip"
title="Timing auto-corrected from this file's extra lead-in — click to turn off"
onclick={() => setAutoSync(false)}
>
auto {lyricsStore.autoOffsetMs > 0 ? "+" : ""}{(lyricsStore.autoOffsetMs / 1000).toFixed(1)}s
</button>
{/if}
<span class="nudge" title="Shift lyric timing">
<button class="tool" title="Lyrics earlier" onclick={() => nudgeOffset(-500)}>−</button>
<span class="off" class:on={lyricsStore.offsetMs !== 0}>
{lyricsStore.offsetMs === 0
? "sync"
: `${lyricsStore.offsetMs > 0 ? "+" : ""}${(lyricsStore.offsetMs / 1000).toFixed(1)}s`}
</span>
<button class="tool" title="Lyrics later" onclick={() => nudgeOffset(500)}>+</button>
</span>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="feed"
bind:this={feed}
onwheel={onUserScroll}
ontouchmove={onUserScroll}
onscroll={onUserScroll}
>
<div class="pad"></div>
{#each lines as line, index (index)}
{#if !line.text}
<button
class="line gapdots"
class:on={index === active}
class:past={index < active}
data-line={index}
style="--dblur: {dofBlur(index)}"
onclick={() => seekTo(line)}
title="Play from here"
><i></i><i></i><i></i></button>
{:else}
<button
class="line"
class:on={index === active}
class:past={index < active}
class:scrub={index === scrubIdx}
data-line={index}
style="--dblur: {dofBlur(index)}"
onclick={() => seekTo(line)}
title="Play from here"
>
{#if index === active && lyricsStore.wordFill && line.words?.length}
<!-- Karaoke word fill over REAL word times (the
wordfill mock's dialed variant A): each word
wipes across its OWN sung span, silence
holds — never invented (the Noctis rule). -->
<span
class="wline"
use:wordfill={{
line,
ms: wordClockMs(line, player.position ?? 0),
endBound: lines[index + 1]?.time_ms
?? line.words[line.words.length - 1].time_ms + 2500,
}}
>
{#each line.words as word, wi (wi)}
<span class="fw">{word.text}</span>
{/each}
</span>
{:else}
<!-- Line mode: the active line is a SOLID accent
fill (.line.on) — the interpolated sweep is
gone (Kyle 2026-08-19: mid-word splits read
as glitches; only REAL word times may move
through letters, and those live behind the
experimental toggle above). -->
{line.text}
{/if}
</button>
{/if}
{/each}
<div class="pad"></div>
</div>
{#if scrubbing && scrubIdx != null && lines[scrubIdx]}
<div class="scrubchip" style="top: {chipPos.top}px; left: {chipPos.left}px">
{fmtStamp(lines[scrubIdx].time_ms)}
</div>
{/if}
{:else if lyricsStore.status === "plain"}
<div class="feed plain">
<div class="pad"></div>
{#each (lyricsStore.plain ?? "").split("\n") as pline, i (i)}
<p class="line stat">{pline.trim() ? pline : "· · ·"}</p>
{/each}
<div class="pad"></div>
</div>
<div class="statechip">not synced — the rail's lyrics pane can time them</div>
{:else if lyricsStore.status === "loading" || lyricsStore.status === "idle"}
<div class="statebox">
<span class="ring"></span>
</div>
{:else if lyricsStore.status === "instrumental"}
<div class="statebox">
<Icon name="music" size={30} />
<p>Instrumental — sit back.</p>
</div>
{:else}
<div class="statebox">
<Icon name="music" size={30} />
<p>No lyrics found for this one.</p>
</div>
{/if}
</div>
<style>
/* The stage fills the pane, edge to edge, and never scrolls itself —
the feed inside it does. Follows the Library/discography exception:
the backdrop must begin at the very pane top, behind the glass.
Height arithmetic: 100% resolves against the pane's CONTENT box —
both --pane-pad bands already subtracted — while the glass bar
cancels the top one, so a bare 100% ends 2×pad short and the hero
stops in a black band above the player bar (Kyle, 2026-08-19).
Reclaim both pads and bleed the bottom one away. */
.lyrpage {
position: relative;
margin: 0 -28px calc(-1 * var(--pane-pad, 24px));
height: calc(100% + 2 * var(--pane-pad, 24px));
overflow: hidden;
isolation: isolate;
/* The type scale below is container-relative: the page is the
container (its height is an explicit calc, so `size` is legal). */
container-type: size;
}
/* ---- backdrop: hero (or cover) in the record's own duotone ---- */
.full {
position: absolute;
inset: -20px;
z-index: 0;
}
.full img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: 50% 24%;
/* recipe: hero-dim 57%, hero-blur 3px, duotone base */
filter: grayscale(1) brightness(0.43) blur(3px) contrast(1.05);
transform: scale(1.04);
}
.full .tint {
position: absolute;
inset: 0;
/* recipe: duotone 56%, tone pulled toward neutral so it reads like a
sleeve, not paint */
background: color-mix(in srgb, var(--wash-accent) 80%, #77706e);
mix-blend-mode: color;
opacity: 0.56;
}
.full .grade {
position: absolute;
inset: 0;
background:
radial-gradient(120% 90% at 50% 108%, rgba(10, 10, 10, 0.9), rgba(10, 10, 10, 0.42) 55%, rgba(10, 10, 10, 0.28)),
radial-gradient(80% 60% at 50% 0%, color-mix(in srgb, var(--wash-accent) 22%, transparent), transparent 62%);
}
/* ---- identity chip, top centre under the glass ---- */
.chip {
position: absolute;
top: 72px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
z-index: 3;
background: rgba(12, 12, 12, 0.5);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 9px 16px 9px 9px;
max-width: min(60%, 560px);
}
.chip img,
.chip-fallback {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
flex: none;
display: grid;
place-items: center;
background: #232323;
color: var(--text-muted);
}
.chip .t {
font-size: 14.5px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip .a {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- source + nudge, top right under the glass ---- */
.tools {
position: absolute;
top: calc(var(--bar-h, 46px) + 12px);
right: 24px;
z-index: 3;
display: flex;
align-items: center;
gap: 10px;
}
.src {
font-size: 10px;
letter-spacing: 0.12em;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
}
.autochip {
font-size: 11px;
font-weight: 700;
color: var(--accent-hover);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-radius: 999px;
padding: 3px 9px;
}
.nudge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
color: var(--text-secondary);
}
.nudge .tool {
color: var(--text-secondary);
padding: 0 3px;
}
.nudge .tool:hover {
color: var(--text-primary);
}
.nudge .off.on {
color: var(--accent-hover);
}
/* ---- the feed ---- */
.feed {
position: absolute;
inset: 0;
z-index: 1;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
align-items: center;
-webkit-mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
mask-image: linear-gradient(180deg, transparent 0, #000 12%, #000 88%, transparent 100%);
}
.feed::-webkit-scrollbar {
display: none;
}
.feed .pad {
flex: none;
height: 52vh;
}
.line {
position: relative;
flex: none;
/* recipe: 38px / 1028px / 0.76em gap, centred — the 38 now the CAP
of a container-relative scale, so a small window shrinks the type
with it (Kyle, 2026-08-19) and a full-size pane is pixel-identical
to the dialed recipe. Coefficients: 3.5cqi ≈ 38px at the pane
width where the 1028px feed first compresses (~1086px incl the
reclaimed side pads); 5.3cqb ≈ 38px at the ~717px pane height of
the dialed layout; the SMALLER axis governs, floored for tiny
windows. Everything inside the line is em-based and follows. */
font-size: clamp(20px, min(3.5cqi, 5.3cqb), 38px);
line-height: 1.28;
font-weight: 700;
max-width: min(1028px, 86%);
margin: 0.38em 0;
text-align: center;
/* recipe: idle 42% */
color: color-mix(in srgb, var(--text-primary) 42%, transparent);
transform-origin: center;
transition:
color 260ms ease,
transform 320ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 260ms ease,
filter 260ms ease;
/* recipe: focus falloff 0.3px × d²/4, capped */
filter: blur(calc(var(--dblur, 1) * 0.3px));
cursor: pointer;
}
/* recipe: sung lines dim harder than unsung */
.line.past {
opacity: 0.3;
}
.line.on {
/* recipe: active ×1.20; SOLID accent letters — the whole line is
the highlight (word mode's children clip their own fills and
ignore this color). */
transform: scale(1.2);
color: var(--accent-hover);
filter: none;
opacity: 1;
}
.line.scrub {
color: var(--text-primary);
filter: none;
}
.line:not(.on):hover {
color: color-mix(in srgb, var(--text-primary) 80%, transparent);
filter: none;
}
.line:not(.on):hover::before {
content: "▶";
position: absolute;
left: -30px;
top: 50%;
translate: 0 -50%;
font-size: 0.38em;
color: var(--accent-hover);
opacity: 0.85;
}
/* accent karaoke fill: ONE glyph raster — the active line's text is
painted by a gradient clipped to the glyphs, sung accent left of the
feathered boundary, unsung white right of it. (Two stacked text layers
fringe against each other; this cannot.)
NAMESPACED on pain of death: @property registrations are DOCUMENT
GLOBAL — registering bare `--p` as a percentage broke the artist
page's scroll variable of the same name (its unitless writes stopped
parsing and the whole hero pipeline collapsed). */
@property --lyr-p {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
/* karaoke word fill (the wordfill mock's dialed variant A): each word is
its OWN gradient-clip raster — accent left of its feathered boundary,
unsung white right of it — wiping across the word's real sung span.
Feather ±8%, mock-dialed. --lyr-p is per-element (inherits: false). */
.wline .fw {
--lyr-p: -8%;
background-image: linear-gradient(
90deg,
var(--accent-hover) calc(var(--lyr-p) - 8%),
var(--text-primary) calc(var(--lyr-p) + 8%)
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.wline .fw + .fw {
margin-left: 0.28em;
}
/* instrumental gap: three breathing dots */
.line.gapdots {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
height: 1.28em;
}
.line.gapdots i {
width: 0.32em;
height: 0.32em;
border-radius: 50%;
background: color-mix(in srgb, var(--text-primary) 40%, transparent);
}
.line.gapdots.on i {
animation: dotpulse 1.4s ease-in-out infinite;
}
.line.gapdots.on i:nth-child(2) {
animation-delay: 0.22s;
}
.line.gapdots.on i:nth-child(3) {
animation-delay: 0.44s;
}
@keyframes dotpulse {
0%, 100% {
transform: scale(1);
background: color-mix(in srgb, var(--text-primary) 45%, transparent);
}
50% {
transform: scale(1.55);
background: var(--text-primary);
}
}
@media (prefers-reduced-motion: reduce) {
.line.gapdots.on i {
animation: none;
background: var(--text-primary);
}
}
/* scrub time chip, pinned to the focus band */
.scrubchip {
position: fixed;
z-index: 60;
display: inline-flex;
align-items: center;
gap: 7px;
background: rgba(12, 12, 12, 0.62);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 5px 12px;
font-size: 12.5px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
.scrubchip::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
}
/* ---- non-synced states ---- */
.feed.plain .line.stat {
cursor: default;
filter: none;
}
.statechip {
position: absolute;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
font-size: 11.5px;
color: var(--text-secondary);
background: rgba(12, 12, 12, 0.55);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 999px;
padding: 5px 12px;
}
.statebox {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 14px;
}
.ring {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2.5px solid rgba(255, 255, 255, 0.16);
border-top-color: var(--accent-hover);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
+596 -272
View File
File diff suppressed because it is too large. Load diff
+285 -101
View File
@@ -1,7 +1,7 @@
<script> <script>
// The playlist shelf: every playlist as a cover card — custom art or the // The playlist shelf: every playlist as a cover card — custom art or the
// composed four-cover mosaic — with pinning, duplication, export, and the // composed four-cover mosaic — with pinning, duplication, export, and the
// two import doors (a playlist file, a pasted tracklist). // the import doors (playlist file, folder, pasted tracklist, YouTube set).
import { import {
playlistStore, playlistStore,
refreshPlaylists, refreshPlaylists,
@@ -10,6 +10,8 @@
playAlbum, playAlbum,
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { setlistScan, takeScanDraft } from "../lib/setlistScan.svelte.js";
import { fade } from "svelte/transition";
import Icon from "./../components/Icon.svelte"; import Icon from "./../components/Icon.svelte";
import PlaylistImportModal from "./../components/PlaylistImportModal.svelte"; import PlaylistImportModal from "./../components/PlaylistImportModal.svelte";
@@ -34,6 +36,27 @@
const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned)); const pinned = $derived(playlistStore.playlists.filter((p) => p.pinned));
const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned)); const rest = $derived(playlistStore.playlists.filter((p) => !p.pinned));
// The stat row's numbers — derived from what the store already carries.
const totals = $derived.by(() => {
let tracks = 0;
let secs = 0;
for (const p of playlistStore.playlists) {
tracks += p.track_count ?? 0;
secs += p.duration_secs ?? 0;
}
return { tracks, secs };
});
// The ambience wash borrows the first pinned playlist's cover, else the
// first cover that has resolved. Covers arrive lazily, so this settles a
// beat after the cards do — the keyed fade below makes that a dissolve.
const ambUrl = $derived.by(() => {
const pick =
playlistStore.playlists.find((p) => p.pinned && playlistStore.covers[p.id]) ??
playlistStore.playlists.find((p) => playlistStore.covers[p.id]);
return pick ? playlistStore.covers[pick.id] : null;
});
async function newPlaylist() { async function newPlaylist() {
if (creating) return; if (creating) return;
creating = true; creating = true;
@@ -90,6 +113,23 @@
importOpen = true; importOpen = true;
} }
// A finished background listen: the chip's "review" click lands here —
// consume the waiting draft into the ordinary review modal.
$effect(() => {
if (setlistScan.wantsReview && setlistScan.draft && !importOpen) {
importDraft = takeScanDraft();
importMode = "file";
importOpen = true;
}
});
function importFromYoutube() {
newMenuOpen = false;
importDraft = null;
importMode = "youtube";
importOpen = true;
}
function open(pl) { function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name }); goToView("playlist", { playlistId: pl.id, label: pl.name });
} }
@@ -102,21 +142,49 @@
const gen = ++playCardGen; const gen = ++playCardGen;
const rows = await api.playlistItems(pl.id); const rows = await api.playlistItems(pl.id);
if (gen !== playCardGen) return; if (gen !== playCardGen) return;
const playable = rows.filter((r) => r.path && !r.missing); // Present local files plus stream rows — streams queue as pending
// items that resolve when reached, same as the playlist page's Play.
const playable = rows.filter((r) => (r.path && !r.missing) || r.kind === "stream");
if (!playable.length) { if (!playable.length) {
open(pl); // nothing playable: the page explains why open(pl); // nothing playable: the page explains why
return; return;
} }
// Set-recording slices: each is bounded by the NEXT stamped row in
// the full order (the playlist page derives the same map).
const segmentEnds = new Map();
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
if (r.kind !== "segment" || r.start_ms == null) continue;
const next = rows.slice(i + 1).find((n) => n.start_ms != null);
segmentEnds.set(r.id, next ? next.start_ms : null);
}
playAlbum( playAlbum(
playable.map((r) => ({ playable.map((r) => {
path: r.path, const local = r.path && !r.missing;
title: r.title, const tid = Number(r.track_id);
artist: r.artist, const endMs = segmentEnds.get(r.id);
album: r.album, const segment =
duration_secs: r.duration_secs, r.kind === "segment" && local && r.start_ms != null
art_path: r.art_path, ? { start: r.start_ms / 1000, end: endMs != null ? endMs / 1000 : null }
plItemId: r.id, : null;
})), return {
path: local ? r.path : null,
needsResolve: !local,
title: r.title,
artist: r.artist,
album: r.album,
duration_secs:
r.duration_secs ??
(segment && segment.end != null
? Math.round(segment.end - segment.start)
: null),
art_path: local ? r.art_path : null,
artUrl: local ? null : (r.art_url ?? null),
trackId: Number.isFinite(tid) ? tid : null,
plItemId: r.id,
segment,
};
}),
pl.name, pl.name,
0, 0,
); );
@@ -186,53 +254,85 @@
<svelte:window onclick={pageClick} /> <svelte:window onclick={pageClick} />
<div class="page"> <div class="page">
<header> {#if ambUrl}
<div class="head-text"> <!-- The Library's ambience recipe: a blurred cover pinned behind the
glass, faded out before the cards. Keyed so a cover change
CROSSFADES — background-image is not an animatable property, so
the outgoing wash survives as its own element while the incoming
one fades up over it. -->
{#key ambUrl}
<div class="ambience" style="background-image:url({ambUrl})" transition:fade={{ duration: 600 }}></div>
{/key}
<div class="ambience-glow"></div>
{/if}
<!-- The Library's own header shape: h1 + tools on one line, the stat row
under them. An empty shelf keeps the same header — the Import door
matters most at the moment there is nothing here. -->
<header class="hero">
<div class="hero-top">
<h1>Playlists</h1> <h1>Playlists</h1>
{#if playlistStore.playlists.length} <div class="tools">
<p class="sub"> <div class="menu-wrap">
{playlistStore.playlists.length} <button
{playlistStore.playlists.length === 1 ? "playlist" : "playlists"} class="tool"
</p> onclick={(e) => {
{/if} e.stopPropagation();
</div> menuFor = null;
<div class="head-actions"> newMenuOpen = !newMenuOpen;
<div class="menu-wrap"> }}
<button >
class="btn ghost" <Icon name="download" size={15} /> Import
onclick={(e) => { <Icon name="chevron-down" size={13} />
e.stopPropagation(); </button>
menuFor = null; {#if newMenuOpen}
newMenuOpen = !newMenuOpen; <div class="menu" role="menu">
}} <button class="mi" onclick={importFromFile} disabled={probing}>
> <Icon name="file-text" size={15} />
<Icon name="download" size={15} /> Import Playlist file…
<Icon name="chevron-down" size={13} /> <span class="hint">M3U · XSPF · PLS · CSV · JSON</span>
</button>
<button class="mi" onclick={importFromFolder} disabled={probing}>
<Icon name="folder-open" size={15} />
Folder of audio files…
<span class="hint">tag or natural order</span>
</button>
<button class="mi" onclick={importFromText}>
<Icon name="pencil" size={15} />
Paste a tracklist…
<span class="hint">plain lines or timestamped setlist</span>
</button>
<button class="mi" onclick={importFromYoutube}>
<Icon name="link" size={15} />
YouTube DJ set…
<span class="hint">tracks from chapters, description or comments</span>
</button>
</div>
{/if}
</div>
<button class="tool primary" onclick={newPlaylist} disabled={creating}>
<Icon name="plus" size={15} /> New playlist
</button> </button>
{#if newMenuOpen} </div>
<div class="menu" role="menu"> </div>
<button class="mi" onclick={importFromFile} disabled={probing}> {#if playlistStore.playlists.length}
<Icon name="file-text" size={15} /> <div class="stats">
Playlist file… <span class="stat">
<span class="hint">M3U · XSPF · PLS · CSV · JSON</span> <b>{playlistStore.playlists.length}</b>
</button> <i>{playlistStore.playlists.length === 1 ? "playlist" : "playlists"}</i>
<button class="mi" onclick={importFromFolder} disabled={probing}> </span>
<Icon name="folder-open" size={15} /> <span class="stat-div"></span>
Folder of audio files… <span class="stat"><b>{totals.tracks.toLocaleString()}</b><i>{totals.tracks === 1 ? "track" : "tracks"}</i></span>
<span class="hint">tag or natural order</span> {#if totals.secs}
</button> <span class="stat-div"></span>
<button class="mi" onclick={importFromText}> <span class="stat"><b>{fmtDuration(totals.secs)}</b><i>of music</i></span>
<Icon name="pencil" size={15} /> {/if}
Paste a tracklist… {#if pinned.length}
<span class="hint">plain lines or timestamped setlist</span> <span class="stat-div"></span>
</button> <span class="stat"><b>{pinned.length}</b><i>pinned</i></span>
</div>
{/if} {/if}
</div> </div>
<button class="btn primary" onclick={newPlaylist} disabled={creating}> {/if}
<Icon name="plus" size={15} /> New playlist
</button>
</div>
</header> </header>
{#if playlistStore.playlists.length === 0} {#if playlistStore.playlists.length === 0}
@@ -353,63 +453,147 @@
/> />
<style> <style>
/* The Library page's own frame: the blurred ambience pins behind the
glass bar, so the page starts at the very top of the pane and carries
its own inset instead of taking padding-top from the BAR_VIEWS rule
(the Library exception, documented at the set in App.svelte). The
isolation keeps the z-indexed washes inside this page's own stacking
context — without it they escape past the transparent pane and paint
behind the app's opaque black. */
.page { .page {
padding: 8px 4px 24px; position: relative;
} isolation: isolate;
header { --pad-top: calc(var(--bar-h, 46px) + 14px);
display: flex; padding: var(--pad-top) 0 40px;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -0.02em;
}
.sub {
margin-top: 3px;
font-size: 12.5px;
color: var(--text-muted);
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
} }
.btn { /* ------------------------------------------------------------ ambience
The Library's recipe at this page's colour: blurred cover, faded out
before the cards; z-index 0 lies OVER the in-flow header (that is the
approved look — the h1 carries a text-shadow for it), pointer-events
none so nothing under it goes dead. */
.ambience {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 430px;
z-index: 0;
pointer-events: none;
background-size: cover;
background-position: center 30%;
filter: blur(90px) saturate(1.5);
opacity: 0.2;
transform: translateZ(0);
-webkit-mask-image: linear-gradient(180deg, #000 35%, transparent);
mask-image: linear-gradient(180deg, #000 35%, transparent);
}
.ambience-glow {
position: absolute;
left: -28px;
right: -28px;
top: calc(-1 * var(--pad-top));
height: 300px;
z-index: 0;
pointer-events: none;
background: radial-gradient(
640px 260px at 14% 0%,
color-mix(in srgb, var(--accent) 13%, transparent),
transparent 70%
);
}
/* ---------------------------------------------------------------- hero */
.hero {
padding-top: 4px;
}
.hero-top {
display: flex;
align-items: center;
gap: 16px;
}
h1 {
font-size: 31px;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
}
.tools {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
/* The Library's tool button: translucent elevated glass, one height. */
.tool {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 9px 16px; height: 32px;
border-radius: 20px; padding: 0 12px;
font-size: 13px; border-radius: 8px;
font-weight: 700; background: color-mix(in srgb, var(--bg-elevated) 74%, transparent);
transition: border: 1px solid var(--border);
background 0.14s,
color 0.14s,
transform 0.14s;
}
.btn.primary {
background: var(--accent);
color: #000;
}
.btn.primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.btn.ghost {
background: var(--bg-highlight);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
cursor: pointer;
backdrop-filter: blur(6px);
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
} }
.btn.ghost:hover { .tool:hover:not(:disabled) {
background: var(--bg-press);
color: var(--text-primary); color: var(--text-primary);
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
} }
.btn:disabled { .tool:disabled {
opacity: 0.55; opacity: 0.5;
cursor: default;
}
.tool.primary {
background: var(--accent);
border-color: transparent;
color: #08080a;
}
.tool.primary:hover:not(:disabled) {
background: var(--accent-hover);
color: #08080a;
}
/* The stat row — the Library's value/label idiom for the collection. */
.stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
margin: 15px 0 20px;
}
.stat {
display: inline-flex;
align-items: baseline;
gap: 7px;
white-space: nowrap;
}
.stat b {
font-size: 15.5px;
font-weight: 800;
letter-spacing: -0.01em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.stat i {
font-style: normal;
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-muted);
}
.stat-div {
width: 1px;
height: 17px;
flex: none;
background: linear-gradient(rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.03));
} }
.menu-wrap { .menu-wrap {
+183
View File
@@ -1,10 +1,71 @@
<script> <script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js"; import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
let settings = $state(null); let settings = $state(null);
let saving = $state(false); let saving = $state(false);
// Lyric alignment model (Tier B word timing) — downloaded only from the
// explicit click below, per the preparation-worker consent charter.
let lyricModel = $state(null);
let modelBusy = $state(false);
let modelPct = $state(0);
let modelError = $state("");
// Vocal isolation model (ctc-v5 stem tier) — same consent rule, its own
// row below, shown only once the aligner model above exists.
let sepModel = $state(null);
let sepBusy = $state(false);
let sepPct = $state(0);
let sepError = $state("");
void api.lyricModelStatus().then((s) => (lyricModel = s)).catch(() => {});
void api.vocalSepStatus().then((s) => (sepModel = s)).catch(() => {});
$effect(() => {
let unlisten = null;
let unlistenSep = null;
void import("@tauri-apps/api/event").then(({ listen }) => {
listen("lyrics:model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) modelPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlisten = u));
listen("lyrics:sep-model-progress", (e) => {
const p = e.payload ?? {};
if (p.total > 0) sepPct = Math.round((p.received / p.total) * 100);
}).then((u) => (unlistenSep = u));
});
return () => {
unlisten?.();
unlistenSep?.();
};
});
async function downloadModel() {
if (modelBusy) return;
modelBusy = true;
modelError = "";
modelPct = 0;
try {
await api.lyricModelDownload();
lyricModel = await api.lyricModelStatus();
} catch (e) {
modelError = String(e).slice(0, 160);
} finally {
modelBusy = false;
}
}
async function downloadSepModel() {
if (sepBusy) return;
sepBusy = true;
sepError = "";
sepPct = 0;
try {
await api.vocalSepDownload();
sepModel = await api.vocalSepStatus();
} catch (e) {
sepError = String(e).slice(0, 160);
} finally {
sepBusy = false;
}
}
let savedFlash = $state(false); let savedFlash = $state(false);
let saveError = $state(""); let saveError = $state("");
let refreshingArtists = $state(false); let refreshingArtists = $state(false);
@@ -44,6 +105,15 @@
} }
} }
async function setWordTiming(next) {
if (next !== settings.word_timing) {
await commit({ wordTiming: next });
// Applies live: surfaces stop/start rendering word markup
// reactively, and turning it on asks for the current track.
setWordFill(next);
}
}
async function chooseDownloadDir() { async function chooseDownloadDir() {
const dir = await api.pickFolder(); const dir = await api.pickFolder();
if (dir) await commit({ downloadDir: dir }); if (dir) await commit({ downloadDir: dir });
@@ -250,6 +320,79 @@
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p> <p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
</section> </section>
<section>
<h2>Lyrics</h2>
<div class="row">
<div class="label">
<div class="name">Word-by-word fill <span class="exp-tag">Experimental</span></div>
<div class="hint">
Fills each word as it is sung, karaoke-style. Still experimental: timing
can drift on dense or echo-heavy songs. Off keeps the reliable
line-by-line sync everywhere and pauses the background word analysis.
</div>
</div>
<select
class="device-select"
value={settings.word_timing ? "on" : "off"}
disabled={saving}
onchange={(e) => setWordTiming(e.currentTarget.value === "on")}
>
<option value="off">Off (recommended for now)</option>
<option value="on">On</option>
</select>
</div>
{#if settings.word_timing}
<div class="row">
<div class="label">
<div class="name">Precise word timing</div>
<div class="hint">
Downloads an alignment model (MMS, ~1.26 GB, from Hugging Face) that
listens to each lyric line and times every word against the actual singing,
in most languages. Runs entirely on this computer, in the background, never
against the playing track. Without it, word timing stays a lighter estimate.
Already-timed songs re-prepare automatically the next time you open their
lyrics.
</div>
</div>
{#if lyricModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={modelBusy} onclick={downloadModel}>
{modelBusy ? `Downloading… ${modelPct}%` : "Download (1.26 GB)"}
</button>
{/if}
</div>
{#if modelError}
<p class="muted small">Download failed: {modelError}</p>
{/if}
{#if lyricModel?.present}
<div class="row">
<div class="label">
<div class="name">Vocal isolation</div>
<div class="hint">
Downloads a small separation model (UVR MDX-Net Voc FT, ~64 MB, from
the Ultimate Vocal Remover project) that lifts the singing away from
the band before words are timed — noticeably steadier timing on dense
mixes. Same rules as above: runs on this computer, in the background,
never against the playing track. Timed songs re-prepare the next time
you open their lyrics.
</div>
</div>
{#if sepModel?.present}
<span class="model-ok"><Icon name="check" size={14} /> Downloaded</span>
{:else}
<button class="model-btn" disabled={sepBusy} onclick={downloadSepModel}>
{sepBusy ? `Downloading… ${sepPct}%` : "Download (64 MB)"}
</button>
{/if}
</div>
{#if sepError}
<p class="muted small">Download failed: {sepError}</p>
{/if}
{/if}
{/if}
</section>
<section> <section>
<h2>Streaming</h2> <h2>Streaming</h2>
<div class="row"> <div class="row">
@@ -736,4 +879,44 @@
.save-error { .save-error {
color: #f15e6c; color: #f15e6c;
} }
.model-btn {
flex: none;
padding: 8px 16px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 13px;
font-weight: 700;
}
.model-btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.model-btn:disabled {
opacity: 0.7;
cursor: default;
}
.exp-tag {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
color: var(--accent);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
vertical-align: 2px;
}
.model-ok {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: #3ecf7e;
font-size: 13px;
font-weight: 700;
}
</style> </style>