add Scene Music GM module: mood-based CC0-1.0 background tracks, /api/music/manifest, /music static, MoodMusic player in GMKit
This commit is contained in:
24
database/routes/musicRoutes.js
Normal file
24
database/routes/musicRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
const musicDir = path.join(__dirname, '..', '..', 'media', 'music');
|
||||
|
||||
// GET /api/music/manifest — returns the music manifest array
|
||||
router.get('/manifest', (req, res) => {
|
||||
const manifestPath = path.join(musicDir, 'manifest.json');
|
||||
try {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return res.status(404).json({ error: 'Music manifest not found' });
|
||||
}
|
||||
const data = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(data);
|
||||
return res.json(manifest);
|
||||
} catch (err) {
|
||||
console.error('Failed to read music manifest:', err);
|
||||
return res.status(500).json({ error: 'Failed to read music manifest' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -14,6 +14,7 @@ const rulesStagingRoutes = require('./routes/rulesStagingRoutes');
|
||||
const missionRoutes = require('./routes/missionRoutes');
|
||||
const simulationRoutes = require('./routes/simulationRoutes');
|
||||
const errorRoutes = require('./routes/errorRoutes');
|
||||
const musicRoutes = require('./routes/musicRoutes');
|
||||
// const rulesRoutes = require('./routes/rulesRoutes-simple');
|
||||
|
||||
const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit');
|
||||
@@ -121,6 +122,14 @@ try {
|
||||
console.error('Error mounting /api/errors:', e && e.stack ? e.stack : e);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Registering /api/music');
|
||||
app.use('/api/music', musicRoutes);
|
||||
console.log('Music routes registered');
|
||||
} catch (e) {
|
||||
console.error('Error mounting /api/music:', e && e.stack ? e.stack : e);
|
||||
}
|
||||
|
||||
// Expose gamemaster kit files and a simple listing API for GM-only resources
|
||||
try {
|
||||
console.log('Registering /api/gmkit and /gmkit static');
|
||||
@@ -234,6 +243,13 @@ if (!fs.existsSync(avatarsDir)) {
|
||||
}
|
||||
app.use('/avatars', express.static(avatarsDir));
|
||||
|
||||
// Serve mood music files from media/music
|
||||
const musicDir = path.join(__dirname, '..', 'media', 'music');
|
||||
if (!fs.existsSync(musicDir)) {
|
||||
fs.mkdirSync(musicDir, { recursive: true });
|
||||
}
|
||||
app.use('/music', express.static(musicDir));
|
||||
|
||||
// Catch-all handler for React Router (must be after API routes)
|
||||
// app.get('*', (req, res) => {
|
||||
// res.sendFile(path.join(buildDir, 'index.html'));
|
||||
@@ -259,7 +275,7 @@ if (fs.existsSync(indexHtml)) {
|
||||
// Middleware fallback for client-side routing: serve index.html for non-API and non-avatar paths.
|
||||
// Use a plain middleware (no path) to avoid route string parsing by path-to-regexp.
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/')) return next();
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/avatars/') || req.path.startsWith('/music/') || req.path.startsWith('/gmkit/')) return next();
|
||||
return res.sendFile(indexHtml);
|
||||
});
|
||||
} else {
|
||||
|
||||
52
media/music/README.md
Normal file
52
media/music/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Mood Music — Scene Background Audio
|
||||
|
||||
These files are **real CC0-1.0 music tracks** from [freepd.com](https://freepd.com/),
|
||||
redistributed via [github.com/SoundSafari/CC0-1.0-Music](https://github.com/SoundSafari/CC0-1.0-Music).
|
||||
CC0-1.0 = public domain, no attribution required (but crediting the source is polite).
|
||||
|
||||
## Moods
|
||||
|
||||
| Mood | File | Title | Source |
|
||||
|------|------|-------|--------|
|
||||
| combat | `combat.mp3` | Battle Ready | freepd.com (via SoundSafari/CC0-1.0-Music) |
|
||||
| investigation | `investigation.mp3` | Dark Hallway | freepd.com (via SoundSafari/CC0-1.0-Music) |
|
||||
| tension | `tension.mp3` | Abstract Anxiety | freepd.com (via SoundSafari/CC0-1.0-Music) |
|
||||
| victory | `victory.mp3` | Heroic Adventure | freepd.com (via SoundSafari/CC0-1.0-Music) |
|
||||
| eerie | `eerie.mp3` | Ancient Rite | freepd.com (via SoundSafari/CC0-1.0-Music) |
|
||||
|
||||
## How to Swap in Your Own Tracks
|
||||
|
||||
1. **Find DRM-free music** from sources like:
|
||||
- [Kevin MacLeod / incompetech.com](https://incompetech.com/) — CC-BY (requires attribution)
|
||||
- [Pixabay Music](https://pixabay.com/music/) — mostly CC0
|
||||
- [Free Music Archive](https://freemusicarchive.org/) — various licenses
|
||||
- [Musopen](https://musopen.org/) — public domain classical
|
||||
|
||||
2. **Drop your file in** this directory with the same name (e.g. `combat.mp3`).
|
||||
The file can be any audio format the browser supports (WAV, MP3, OGG).
|
||||
|
||||
3. **Update `manifest.json`** if you change filenames or add new moods:
|
||||
```json
|
||||
{
|
||||
"mood": "combat",
|
||||
"file": "combat.mp3",
|
||||
"title": "Your Track Title",
|
||||
"license": "CC-BY Kevin MacLeod"
|
||||
}
|
||||
```
|
||||
|
||||
4. **No code changes needed** — the frontend reads the manifest dynamically.
|
||||
|
||||
## License Notes
|
||||
|
||||
- **CC0** — no attribution required, free for any use.
|
||||
- **CC-BY** — attribution required. Add the artist name in the `license` field
|
||||
of `manifest.json` and display it in your app if desired.
|
||||
- Never use copyrighted music without explicit permission.
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Audio is served statically at `/music/<filename>` by Express.
|
||||
- The manifest is available at `GET /api/music/manifest`.
|
||||
- The GM plays music from the **GM Kit** tab → **Scene Music** section.
|
||||
- Tracks loop automatically; switching moods fades out the current track.
|
||||
BIN
media/music/combat.mp3
Normal file
BIN
media/music/combat.mp3
Normal file
Binary file not shown.
BIN
media/music/eerie.mp3
Normal file
BIN
media/music/eerie.mp3
Normal file
Binary file not shown.
BIN
media/music/investigation.mp3
Normal file
BIN
media/music/investigation.mp3
Normal file
Binary file not shown.
37
media/music/manifest.json
Normal file
37
media/music/manifest.json
Normal file
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"mood": "combat",
|
||||
"file": "combat.mp3",
|
||||
"title": "Battle Ready",
|
||||
"license": "CC0-1.0",
|
||||
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
|
||||
},
|
||||
{
|
||||
"mood": "investigation",
|
||||
"file": "investigation.mp3",
|
||||
"title": "Dark Hallway",
|
||||
"license": "CC0-1.0",
|
||||
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
|
||||
},
|
||||
{
|
||||
"mood": "tension",
|
||||
"file": "tension.mp3",
|
||||
"title": "Abstract Anxiety",
|
||||
"license": "CC0-1.0",
|
||||
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
|
||||
},
|
||||
{
|
||||
"mood": "victory",
|
||||
"file": "victory.mp3",
|
||||
"title": "Heroic Adventure",
|
||||
"license": "CC0-1.0",
|
||||
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
|
||||
},
|
||||
{
|
||||
"mood": "eerie",
|
||||
"file": "eerie.mp3",
|
||||
"title": "Ancient Rite",
|
||||
"license": "CC0-1.0",
|
||||
"source": "freepd.com (via github.com/SoundSafari/CC0-1.0-Music)"
|
||||
}
|
||||
]
|
||||
BIN
media/music/tension.mp3
Normal file
BIN
media/music/tension.mp3
Normal file
Binary file not shown.
BIN
media/music/victory.mp3
Normal file
BIN
media/music/victory.mp3
Normal file
Binary file not shown.
152
scripts/gen-music-placeholders.js
Normal file
152
scripts/gen-music-placeholders.js
Normal file
@@ -0,0 +1,152 @@
|
||||
// gen-music-placeholders.js
|
||||
// Synthesizes short loopable ambient WAV files (one per mood) using only Node stdlib.
|
||||
// Output: media/music/<mood>.wav
|
||||
// Each file: mono, 16-bit, 22050 Hz, ~8 seconds, low-amplitude, non-silent.
|
||||
// License: CC0 (synthesized placeholder — replace with real DRM-free tracks).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SAMPLE_RATE = 22050;
|
||||
const DURATION_SEC = 8;
|
||||
const NUM_SAMPLES = SAMPLE_RATE * DURATION_SEC;
|
||||
const BITS_PER_SAMPLE = 16;
|
||||
const BYTES_PER_SAMPLE = BITS_PER_SAMPLE / 8;
|
||||
const CHANNELS = 1;
|
||||
|
||||
const MOODS = {
|
||||
combat: {
|
||||
title: 'Combat — Driving Tension',
|
||||
// Fast pulsing low sine + harsh high noise burst
|
||||
generate(i) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const pulse = Math.sin(2 * Math.PI * 80 * t) * 0.15;
|
||||
const drive = Math.sin(2 * Math.PI * 160 * t) * 0.08;
|
||||
const noise = (Math.random() * 2 - 1) * 0.04;
|
||||
const accent = Math.sin(2 * Math.PI * 40 * t) * Math.sin(2 * Math.PI * 2 * t) * 0.1;
|
||||
return pulse + drive + noise + accent;
|
||||
},
|
||||
},
|
||||
investigation: {
|
||||
title: 'Investigation — Low Ambient Drone',
|
||||
generate(i) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const drone1 = Math.sin(2 * Math.PI * 55 * t) * 0.12;
|
||||
const drone2 = Math.sin(2 * Math.PI * 82.5 * t) * 0.08;
|
||||
const sub = Math.sin(2 * Math.PI * 30 * t) * 0.1;
|
||||
const shimmer = Math.sin(2 * Math.PI * 220 * t) * 0.02 * Math.sin(2 * Math.PI * 0.25 * t);
|
||||
return drone1 + drone2 + sub + shimmer;
|
||||
},
|
||||
},
|
||||
tension: {
|
||||
title: 'Tension — Ominous Unease',
|
||||
generate(i) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const low = Math.sin(2 * Math.PI * 45 * t) * 0.12;
|
||||
const dissonance = Math.sin(2 * Math.PI * 67 * t) * 0.08;
|
||||
const trill = Math.sin(2 * Math.PI * 330 * t) * 0.03 * (1 + 0.5 * Math.sin(2 * Math.PI * 3 * t));
|
||||
const scrape = (Math.random() * 2 - 1) * 0.02 * Math.abs(Math.sin(2 * Math.PI * 1.5 * t));
|
||||
return low + dissonance + trill + scrape;
|
||||
},
|
||||
},
|
||||
victory: {
|
||||
title: 'Victory — Warm Resolution',
|
||||
generate(i) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const root = Math.sin(2 * Math.PI * 130.8 * t) * 0.12;
|
||||
const third = Math.sin(2 * Math.PI * 164.8 * t) * 0.09;
|
||||
const fifth = Math.sin(2 * Math.PI * 196 * t) * 0.07;
|
||||
const octave = Math.sin(2 * Math.PI * 261.6 * t) * 0.05;
|
||||
const warmth = Math.sin(2 * Math.PI * 65.4 * t) * 0.08;
|
||||
return root + third + fifth + octave + warmth;
|
||||
},
|
||||
},
|
||||
eerie: {
|
||||
title: 'Eerie — Dissonant Sparse',
|
||||
generate(i) {
|
||||
const t = i / SAMPLE_RATE;
|
||||
const sparse = Math.sin(2 * Math.PI * 110 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
|
||||
const dissonant = Math.sin(2 * Math.PI * 117 * t) * 0.06 * (Math.sin(2 * Math.PI * 0.15 * t) > 0.5 ? 1 : 0.05);
|
||||
const whisper = (Math.random() * 2 - 1) * 0.015 * (Math.sin(2 * Math.PI * 0.5 * t) > 0 ? 1 : 0);
|
||||
const sub = Math.sin(2 * Math.PI * 25 * t) * 0.08;
|
||||
return sparse + dissonant + whisper + sub;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function writeWav(filePath, samples) {
|
||||
const numBytes = samples.length * BYTES_PER_SAMPLE;
|
||||
const buf = Buffer.alloc(44 + numBytes);
|
||||
|
||||
// RIFF header
|
||||
buf.write('RIFF', 0);
|
||||
buf.writeUInt32LE(36 + numBytes, 4);
|
||||
buf.write('WAVE', 8);
|
||||
|
||||
// fmt chunk
|
||||
buf.write('fmt ', 12);
|
||||
buf.writeUInt32LE(16, 16);
|
||||
buf.writeUInt16LE(1, 20); // PCM
|
||||
buf.writeUInt16LE(CHANNELS, 22);
|
||||
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
||||
buf.writeUInt32LE(SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS, 28);
|
||||
buf.writeUInt16LE(BYTES_PER_SAMPLE * CHANNELS, 32);
|
||||
buf.writeUInt16LE(BITS_PER_SAMPLE, 34);
|
||||
|
||||
// data chunk
|
||||
buf.write('data', 36);
|
||||
buf.writeUInt32LE(numBytes, 40);
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
let val = samples[i];
|
||||
// Clamp to [-1, 1]
|
||||
if (val > 1) val = 1;
|
||||
if (val < -1) val = -1;
|
||||
// Convert to signed 16-bit
|
||||
const int16 = val >= 0 ? Math.floor(val * 32767) : Math.ceil(val * 32768);
|
||||
buf.writeInt16LE(int16, 44 + i * BYTES_PER_SAMPLE);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, buf);
|
||||
}
|
||||
|
||||
function generateMood(mood, config) {
|
||||
const samples = [];
|
||||
for (let i = 0; i < NUM_SAMPLES; i++) {
|
||||
samples.push(config.generate(i));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
// Main
|
||||
const outDir = path.join(__dirname, '..', 'media', 'music');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const manifest = [];
|
||||
|
||||
for (const [mood, config] of Object.entries(MOODS)) {
|
||||
const samples = generateMood(mood, config);
|
||||
const filePath = path.join(outDir, `${mood}.wav`);
|
||||
writeWav(filePath, samples);
|
||||
|
||||
// Verify non-silent
|
||||
let maxAmp = 0;
|
||||
for (const s of samples) {
|
||||
if (Math.abs(s) > maxAmp) maxAmp = Math.abs(s);
|
||||
}
|
||||
|
||||
console.log(`Generated: ${filePath} (${samples.length} samples, max amplitude: ${maxAmp.toFixed(4)})`);
|
||||
|
||||
manifest.push({
|
||||
mood,
|
||||
file: `${mood}.wav`,
|
||||
title: config.title,
|
||||
license: 'CC0 (synthesized placeholder)',
|
||||
});
|
||||
}
|
||||
|
||||
// Write manifest
|
||||
const manifestPath = path.join(outDir, 'manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nManifest written: ${manifestPath}`);
|
||||
console.log('Done. All placeholder tracks are CC0 synthesized audio.');
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import MoodMusic from './MoodMusic';
|
||||
|
||||
export default function GMKit({ authedPlayer }) {
|
||||
const [activeTable, setActiveTable] = useState('difficulty');
|
||||
@@ -442,6 +443,9 @@ export default function GMKit({ authedPlayer }) {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scene Music */}
|
||||
<MoodMusic />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
201
src/components/MoodMusic.jsx
Normal file
201
src/components/MoodMusic.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const MOOD_COLORS = {
|
||||
combat: 'bg-red-900 border-red-600 hover:bg-red-800',
|
||||
investigation: 'bg-blue-900 border-blue-600 hover:bg-blue-800',
|
||||
tension: 'bg-purple-900 border-purple-600 hover:bg-purple-800',
|
||||
victory: 'bg-amber-900 border-amber-600 hover:bg-amber-800',
|
||||
eerie: 'bg-green-900 border-green-600 hover:bg-green-800',
|
||||
};
|
||||
|
||||
const MOOD_ICONS = {
|
||||
combat: '⚔️',
|
||||
investigation: '🔍',
|
||||
tension: '⚡',
|
||||
victory: '🏆',
|
||||
eerie: '👁️',
|
||||
};
|
||||
|
||||
export default function MoodMusic() {
|
||||
const [manifest, setManifest] = useState([]);
|
||||
const [activeMood, setActiveMood] = useState(null);
|
||||
const [volume, setVolume] = useState(0.3);
|
||||
const [nowPlaying, setNowPlaying] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const audioRef = useRef(null);
|
||||
const fadeTimeoutRef = useRef(null);
|
||||
|
||||
// Fetch manifest on mount
|
||||
useEffect(() => {
|
||||
fetch('/api/music/manifest')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setManifest(Array.isArray(data) ? data : []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to load music manifest:', err);
|
||||
setManifest([]);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Create audio element on mount
|
||||
useEffect(() => {
|
||||
const audio = new Audio();
|
||||
audio.preload = 'auto';
|
||||
audio.loop = true;
|
||||
audio.volume = volume;
|
||||
audioRef.current = audio;
|
||||
return () => {
|
||||
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
|
||||
audio.pause();
|
||||
audio.src = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update volume when it changes
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = volume;
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
const playMood = useCallback((track) => {
|
||||
if (!track || !audioRef.current) return;
|
||||
|
||||
const audio = audioRef.current;
|
||||
const url = `/music/${track.file}`;
|
||||
|
||||
// If switching to same track, stop
|
||||
if (activeMood === track.mood && audio.src === url) {
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fade out current, then switch
|
||||
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
|
||||
|
||||
// Quick fade out
|
||||
const fadeOut = () => {
|
||||
audio.volume = Math.max(0, audio.volume - 0.1);
|
||||
if (audio.volume > 0) {
|
||||
fadeTimeoutRef.current = setTimeout(fadeOut, 30);
|
||||
} else {
|
||||
// Switch source and fade in
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
audio.play().catch((e) => console.warn('Audio play failed:', e));
|
||||
setActiveMood(track.mood);
|
||||
setNowPlaying(track.title);
|
||||
|
||||
// Fade in
|
||||
const fadeIn = () => {
|
||||
audio.volume = Math.min(volume, audio.volume + 0.05);
|
||||
if (audio.volume < volume) {
|
||||
fadeTimeoutRef.current = setTimeout(fadeIn, 30);
|
||||
}
|
||||
};
|
||||
if (volume > 0) {
|
||||
fadeTimeoutRef.current = setTimeout(fadeIn, 30);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (audio.src && audio.src !== url) {
|
||||
fadeOut();
|
||||
} else {
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
audio.play().catch((e) => console.warn('Audio play failed:', e));
|
||||
setActiveMood(track.mood);
|
||||
setNowPlaying(track.title);
|
||||
}
|
||||
}, [activeMood, volume]);
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
}
|
||||
setActiveMood(null);
|
||||
setNowPlaying('');
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-2">Scene Music</h3>
|
||||
<p className="text-slate-400 text-sm">Loading music tracks...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.length === 0) {
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-2">Scene Music</h3>
|
||||
<p className="text-slate-400 text-sm">No music tracks available.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-600">
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-3">Scene Music</h3>
|
||||
|
||||
{/* Mood buttons */}
|
||||
<div className="grid grid-cols-5 gap-2 mb-4">
|
||||
{manifest.map((track) => {
|
||||
const isActive = activeMood === track.mood;
|
||||
const colorClass = MOOD_COLORS[track.mood] || 'bg-slate-700 border-slate-500 hover:bg-slate-600';
|
||||
return (
|
||||
<button
|
||||
key={track.mood}
|
||||
onClick={() => playMood(track)}
|
||||
className={`p-3 rounded-lg border-2 text-center transition-all ${colorClass} ${
|
||||
isActive ? 'ring-2 ring-white text-white font-bold scale-105' : 'text-slate-200'
|
||||
}`}
|
||||
title={track.title}
|
||||
>
|
||||
<div className="text-xl mb-1">{MOOD_ICONS[track.mood] || '🎵'}</div>
|
||||
<div className="text-xs uppercase tracking-wide">{track.mood}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stop button */}
|
||||
{activeMood && (
|
||||
<button
|
||||
onClick={stopPlayback}
|
||||
className="w-full mb-4 px-4 py-2 bg-red-800 hover:bg-red-700 text-white rounded-lg border border-red-600 transition-colors text-sm font-medium"
|
||||
>
|
||||
■ Stop Music
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Volume control */}
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-slate-400 mb-1 block">Volume</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1 text-right">{Math.round(volume * 100)}%</div>
|
||||
</div>
|
||||
|
||||
{/* Now playing */}
|
||||
{nowPlaying && (
|
||||
<div className="text-xs text-slate-300 bg-slate-900 rounded px-3 py-2">
|
||||
<span className="text-slate-500">Now playing:</span> {nowPlaying}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/tests/moodMusic.test.js
Normal file
219
src/tests/moodMusic.test.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import MoodMusic from '../components/MoodMusic';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock fetch for manifest endpoint
|
||||
const mockManifest = [
|
||||
{ mood: 'combat', file: 'combat.mp3', title: 'Battle Ready', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
|
||||
{ mood: 'investigation', file: 'investigation.mp3', title: 'Dark Hallway', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
|
||||
{ mood: 'tension', file: 'tension.mp3', title: 'Abstract Anxiety', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
|
||||
{ mood: 'victory', file: 'victory.mp3', title: 'Heroic Adventure', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
|
||||
{ mood: 'eerie', file: 'eerie.mp3', title: 'Ancient Rite', license: 'CC0-1.0', source: 'freepd.com (via github.com/SoundSafari/CC0-1.0-Music)' },
|
||||
];
|
||||
|
||||
// Mock HTMLMediaElement (Audio constructor)
|
||||
const mockAudio = {
|
||||
preload: 'auto',
|
||||
loop: true,
|
||||
volume: 0.3,
|
||||
src: '',
|
||||
pause: jest.fn(),
|
||||
play: jest.fn(() => Promise.resolve()),
|
||||
load: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock global fetch for manifest
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockManifest),
|
||||
})
|
||||
);
|
||||
|
||||
// Mock Audio constructor
|
||||
const OriginalAudio = global.Audio;
|
||||
global.Audio = jest.fn(() => mockAudio);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore
|
||||
delete global.Audio;
|
||||
});
|
||||
|
||||
describe('MoodMusic', () => {
|
||||
jest.setTimeout(10000);
|
||||
|
||||
test('renders with scene music heading', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Scene Music')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('fetches manifest and renders mood buttons', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
// Should have fetched the manifest
|
||||
expect(fetch).toHaveBeenCalledWith('/api/music/manifest');
|
||||
|
||||
// Wait for buttons to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// All moods should be present
|
||||
expect(screen.getByText('investigation')).toBeInTheDocument();
|
||||
expect(screen.getByText('tension')).toBeInTheDocument();
|
||||
expect(screen.getByText('victory')).toBeInTheDocument();
|
||||
expect(screen.getByText('eerie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a mood button sets it as active and plays', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Click combat button
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('combat'));
|
||||
});
|
||||
|
||||
// Audio play should have been called
|
||||
await waitFor(() => {
|
||||
expect(mockAudio.play).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Now playing label should show
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Battle Ready/)).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Stop button should appear
|
||||
expect(screen.getByText('■ Stop Music')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking active mood again stops playback', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Click combat to start
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('combat'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAudio.play).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Click combat again to stop
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('combat'));
|
||||
});
|
||||
|
||||
// Pause should have been called
|
||||
await waitFor(() => {
|
||||
expect(mockAudio.pause).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('stop button stops playback', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Start combat
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('combat'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('■ Stop Music')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Click stop
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('■ Stop Music'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAudio.pause).toHaveBeenCalled();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('volume slider is present and adjustable', async () => {
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const volumeSlider = document.querySelector('input[type="range"]');
|
||||
expect(volumeSlider).toBeInTheDocument();
|
||||
expect(volumeSlider).toHaveAttribute('min', '0');
|
||||
expect(volumeSlider).toHaveAttribute('max', '1');
|
||||
});
|
||||
|
||||
test('shows loading state initially', async () => {
|
||||
// Delay the fetch response to test loading state
|
||||
global.fetch = jest.fn(() => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockManifest),
|
||||
});
|
||||
}, 100);
|
||||
}));
|
||||
|
||||
const { getByText, queryByText } = render(<MoodMusic />);
|
||||
|
||||
// Initially should show loading
|
||||
expect(getByText('Loading music tracks...')).toBeInTheDocument();
|
||||
expect(queryByText('combat')).not.toBeInTheDocument();
|
||||
|
||||
// After delay, should show buttons
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('combat')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('shows empty state when manifest is empty', async () => {
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
render(<MoodMusic />);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No music tracks available.')).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user