feat: play real music from local library in Memories slideshow

Scans MUSIC_DIR (mounted at /music in Docker) with music-metadata to
read embedded BPM/genre tags and classify tracks as upbeat/cinematic/
dreamy. Falls back to untagged general pool when mood bucket is thin.
Slideshow plays real audio via <audio> element with range-request
streaming; Web Audio synth is kept as fallback when MUSIC_DIR is unset.
Shows now-playing title/artist in the slideshow bottom bar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 15:26:18 -04:00
1 parent d35836131a
commit 6f4e90a1c8
6 files changed
+493 -24

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import fs from 'fs';
import path from 'path';
import { parseFile } from 'music-metadata';
const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.opus', '.wma']);
// Genre keywords → mood (case-insensitive substring match, first hit wins)
const UPBEAT_KEYS = ['rock', 'pop', 'dance', 'electronic', 'hip hop', 'hip-hop', 'r&b', 'rnb',
'reggae', 'funk', 'disco', 'punk', 'metal', 'latin', 'techno', 'house',
'edm', 'trap', 'country'];
const DREAMY_KEYS = ['ambient', 'classical', 'chill', 'lounge', 'jazz', 'new age', 'folk',
'acoustic', 'meditation', 'sleep', 'indie folk', 'singer-songwriter', 'blues',
'orchestral', 'soundtrack', 'cinematic'];
function classifyMood(bpm, genre) {
if (bpm != null) {
if (bpm >= 120) return 'upbeat';
if (bpm >= 80) return 'cinematic';
return 'dreamy';
}
if (genre) {
const g = genre.toLowerCase();
if (UPBEAT_KEYS.some(k => g.includes(k))) return 'upbeat';
if (DREAMY_KEYS.some(k => g.includes(k))) return 'dreamy';
return 'cinematic';
}
return 'general'; // no tags — all moods can use
}
function* walkDir(dir) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) yield* walkDir(full);
else if (AUDIO_EXTS.has(path.extname(e.name).toLowerCase())) yield full;
}
}
export let musicScanActive = false;
export function initMusicTable(db) {
db.raw.exec(`
CREATE TABLE IF NOT EXISTS music (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filepath TEXT NOT NULL UNIQUE,
title TEXT,
artist TEXT,
album TEXT,
bpm REAL,
genre TEXT,
duration REAL,
mood TEXT NOT NULL DEFAULT 'general',
scanned_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_music_mood ON music(mood);
`);
}
export async function scanMusicLibrary(musicDir, db) {
if (musicScanActive) return 0;
musicScanActive = true;
initMusicTable(db);
const hasTrack = db.raw.prepare('SELECT 1 FROM music WHERE filepath = ? LIMIT 1');
const insert = db.raw.prepare(`
INSERT OR IGNORE INTO music (filepath, title, artist, album, bpm, genre, duration, mood)
VALUES (@filepath, @title, @artist, @album, @bpm, @genre, @duration, @mood)
`);
let added = 0;
try {
for (const fp of walkDir(path.resolve(musicDir))) {
if (hasTrack.get(fp)) continue;
try {
const { common, format } = await parseFile(fp, { duration: true, skipCovers: true });
const bpm = common.bpm ?? null;
const genre = common.genre?.[0] ?? null;
insert.run({
filepath: fp,
title: common.title ?? path.basename(fp, path.extname(fp)),
artist: common.artist ?? null,
album: common.album ?? null,
bpm, genre, duration: format.duration ?? null,
mood: classifyMood(bpm, genre),
});
added++;
} catch {}
}
} finally {
musicScanActive = false;
}
return added;
}