Files
motif/v2.html
T
b1rdmania 6dec8bf7cd Simplify v2 - remove GBC/GBA/Clean presets
- Removed sound mode buttons from UI
- Simplified colorizer to just one clean GB sound
- Less complexity, cleaner interface
2026-01-20 19:56:35 +00:00

805 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wario Synth v2 - Game Boy Sound Engine</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<style>
:root {
--bg-dark: #0f380f;
--bg-panel: #306230;
--gb-green: #9bbc0f;
--gb-dark-green: #0f380f;
--gb-light-green: #8bac0f;
--gb-cream: #e0f8d0;
--text: #9bbc0f;
--text-dim: #306230;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Press Start 2P', monospace;
background: var(--bg-dark);
color: var(--text);
min-height: 100vh;
padding: 1rem;
font-size: 10px;
line-height: 1.6;
}
.container {
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
padding: 2rem 0;
border-bottom: 4px solid var(--gb-light-green);
margin-bottom: 2rem;
}
h1 {
font-size: 1.4rem;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 0 #0a280a;
}
.subtitle {
font-size: 0.7rem;
color: var(--gb-light-green);
}
.version-badge {
display: inline-block;
background: var(--gb-green);
color: var(--bg-dark);
padding: 0.3rem 0.6rem;
font-size: 0.6rem;
margin-top: 0.5rem;
}
.panel {
background: var(--bg-panel);
border: 4px solid var(--gb-light-green);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.panel h2 {
font-size: 0.8rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--gb-light-green);
}
.upload-zone {
border: 3px dashed var(--gb-light-green);
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
background: rgba(155, 188, 15, 0.1);
}
.upload-zone.dragover {
background: rgba(155, 188, 15, 0.2);
border-color: var(--gb-green);
}
.upload-zone p {
margin-bottom: 1rem;
font-size: 0.7rem;
}
.upload-zone .icon {
font-size: 2rem;
margin-bottom: 1rem;
}
input[type="file"] {
display: none;
}
button {
background: var(--bg-dark);
color: var(--gb-green);
border: 3px solid var(--gb-green);
padding: 0.75rem 1.5rem;
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s;
}
button:hover:not(:disabled) {
background: var(--gb-green);
color: var(--bg-dark);
}
button:active:not(:disabled) {
transform: scale(0.98);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--gb-green);
color: var(--bg-dark);
}
button.primary:hover:not(:disabled) {
background: var(--gb-cream);
}
.controls {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.volume-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.volume-control input[type="range"] {
width: 100px;
accent-color: var(--gb-green);
}
.status {
padding: 0.3rem 0.6rem;
font-size: 0.6rem;
background: var(--bg-dark);
border: 2px solid var(--gb-light-green);
}
.status.playing {
background: var(--gb-green);
color: var(--bg-dark);
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.channels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 1rem;
}
.channel {
background: var(--bg-dark);
border: 2px solid var(--gb-light-green);
padding: 0.5rem;
text-align: center;
font-size: 0.6rem;
}
.channel.active {
border-color: var(--gb-green);
background: rgba(155, 188, 15, 0.2);
}
.channel .id {
font-size: 0.7rem;
margin-bottom: 0.3rem;
}
.channel .role {
color: var(--gb-light-green);
font-size: 0.5rem;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.info-item {
background: var(--bg-dark);
padding: 0.75rem;
border: 2px solid var(--gb-light-green);
}
.info-item .label {
font-size: 0.55rem;
color: var(--gb-light-green);
margin-bottom: 0.3rem;
}
.info-item .value {
font-size: 0.8rem;
}
.log {
background: #000;
border: 2px solid var(--gb-light-green);
padding: 0.75rem;
height: 150px;
overflow-y: auto;
font-family: monospace;
font-size: 0.65rem;
color: var(--gb-green);
}
.log-entry {
margin-bottom: 0.25rem;
line-height: 1.4;
}
.log-entry.error { color: #ff6b6b; }
.log-entry.success { color: var(--gb-cream); }
footer {
text-align: center;
padding: 2rem 0;
font-size: 0.6rem;
color: var(--gb-light-green);
}
footer a {
color: var(--gb-green);
}
.quick-test {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.quick-test button {
padding: 0.5rem 0.75rem;
font-size: 0.6rem;
}
.quick-test button.active {
background: var(--gb-green);
color: var(--bg-dark);
}
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-box input[type="text"] {
flex: 1;
background: var(--bg-dark);
border: 3px solid var(--gb-green);
color: var(--gb-green);
padding: 0.75rem;
font-family: inherit;
font-size: 0.7rem;
}
.search-box input[type="text"]::placeholder {
color: var(--gb-light-green);
opacity: 0.7;
}
.search-box input[type="text"]:focus {
outline: none;
background: rgba(155, 188, 15, 0.1);
}
.results-list {
margin-top: 1rem;
max-height: 200px;
overflow-y: auto;
}
.result-item {
background: var(--bg-dark);
border: 2px solid var(--gb-light-green);
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item:hover {
background: rgba(155, 188, 15, 0.15);
border-color: var(--gb-green);
}
.result-item.selected {
background: rgba(155, 188, 15, 0.25);
border-color: var(--gb-green);
}
.result-item .title {
font-size: 0.65rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
}
.result-item .confidence {
font-size: 0.5rem;
color: var(--gb-light-green);
}
.divider {
text-align: center;
font-size: 0.6rem;
color: var(--gb-light-green);
margin: 1rem 0;
position: relative;
}
.divider::before,
.divider::after {
content: '';
position: absolute;
top: 50%;
width: 40%;
height: 2px;
background: var(--gb-light-green);
}
.divider::before { left: 0; }
.divider::after { right: 0; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎮 WARIO SYNTH</h1>
<p class="subtitle">Game Boy Sound Engine</p>
<span class="version-badge">v2.0 BETA</span>
</header>
<div class="panel">
<h2>SEARCH BITMIDI</h2>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search for a song...">
<button id="btn-search">SEARCH</button>
</div>
<div id="results-list" class="results-list"></div>
<div class="divider">OR</div>
<div class="upload-zone" id="upload-zone">
<div class="icon">🎵</div>
<p>Drop a MIDI file here or click to browse</p>
<button>Choose File</button>
<input type="file" id="file-input" accept=".mid,.midi">
</div>
</div>
<div class="panel">
<h2>PLAYBACK</h2>
<div class="controls">
<button id="btn-play" class="primary" disabled>▶ PLAY</button>
<button id="btn-stop" disabled>■ STOP</button>
<div class="volume-control">
<span>VOL:</span>
<input type="range" id="volume" min="0" max="100" value="70">
</div>
<span id="status" class="status">READY</span>
</div>
<div class="info-grid">
<div class="info-item">
<div class="label">DURATION</div>
<div class="value" id="duration">--:--</div>
</div>
<div class="info-item">
<div class="label">NOTES</div>
<div class="value" id="notes">---</div>
</div>
<div class="info-item">
<div class="label">TEMPO</div>
<div class="value" id="tempo">--- BPM</div>
</div>
<div class="info-item">
<div class="label">TRACKS</div>
<div class="value" id="tracks">---</div>
</div>
</div>
</div>
<div class="panel">
<h2>CHANNELS</h2>
<div class="channels">
<div class="channel" id="ch-p1"><div class="id">P1</div><div class="role">-</div></div>
<div class="channel" id="ch-p2"><div class="id">P2</div><div class="role">-</div></div>
<div class="channel" id="ch-p3"><div class="id">P3</div><div class="role">-</div></div>
<div class="channel" id="ch-p4"><div class="id">P4</div><div class="role">-</div></div>
<div class="channel" id="ch-w1"><div class="id">W1</div><div class="role">-</div></div>
<div class="channel" id="ch-w2"><div class="id">W2</div><div class="role">-</div></div>
<div class="channel" id="ch-n1"><div class="id">N1</div><div class="role">-</div></div>
<div class="channel" id="ch-n2"><div class="id">N2</div><div class="role">-</div></div>
</div>
<div class="quick-test">
<span style="font-size: 0.6rem; opacity: 0.7;">Quick test:</span>
<button id="btn-c4">C4</button>
<button id="btn-chord">Chord</button>
<button id="btn-bass">Bass</button>
<button id="btn-drum">Drum</button>
</div>
<div class="quick-test" style="margin-top: 0.5rem;">
<span style="font-size: 0.6rem; opacity: 0.7;">Arranger:</span>
<button id="btn-arranger" class="active" style="background: var(--gb-green); color: var(--bg-dark);">ON</button>
<span style="font-size: 0.5rem; opacity: 0.6; margin-left: 0.5rem;">Makes sparse MIDIs sound full</span>
</div>
</div>
<div class="panel">
<h2>LOG</h2>
<div id="log" class="log"></div>
</div>
<footer>
<p>Wario Synth v2 - Authentic Game Boy Sound</p>
<p><a href="/">Back to v1</a> | <a href="/v2-test.html">Sound Tests</a></p>
</footer>
</div>
<script type="module">
import { GameBoyPlayer } from './src-v2/core/GameBoyPlayer.ts';
import { MIDIService } from './src/services/MIDIService.ts';
// Initialize player and services
const player = new GameBoyPlayer();
const midiService = new MIDIService();
let currentMIDI = null;
let searchResults = [];
let selectedResultIndex = -1;
// DOM elements
const searchInput = document.getElementById('search-input');
const btnSearch = document.getElementById('btn-search');
const resultsList = document.getElementById('results-list');
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const btnPlay = document.getElementById('btn-play');
const btnStop = document.getElementById('btn-stop');
const volumeSlider = document.getElementById('volume');
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
// Info elements
const durationEl = document.getElementById('duration');
const notesEl = document.getElementById('notes');
const tempoEl = document.getElementById('tempo');
const tracksEl = document.getElementById('tracks');
// Channel elements
const channelEls = {
p1: document.getElementById('ch-p1'),
p2: document.getElementById('ch-p2'),
p3: document.getElementById('ch-p3'),
p4: document.getElementById('ch-p4'),
w1: document.getElementById('ch-w1'),
w2: document.getElementById('ch-w2'),
n1: document.getElementById('ch-n1'),
n2: document.getElementById('ch-n2'),
};
function log(message, type = 'info') {
const entry = document.createElement('div');
entry.className = `log-entry ${type}`;
entry.textContent = `> ${message}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
console.log(message);
}
function setStatus(status, playing = false) {
statusEl.textContent = status;
statusEl.className = `status ${playing ? 'playing' : ''}`;
}
function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function updateInfo(info) {
durationEl.textContent = formatTime(info.duration);
notesEl.textContent = info.noteCount.toString();
tempoEl.textContent = `${Math.round(info.bpm)} BPM`;
tracksEl.textContent = info.trackCount.toString();
}
function updateChannels(assignments) {
// Reset all
Object.values(channelEls).forEach(el => {
el.classList.remove('active');
el.querySelector('.role').textContent = '-';
});
// Highlight assigned channels
for (const assignment of assignments) {
const el = channelEls[assignment.channelId];
if (el) {
el.classList.add('active');
// Get role from track analysis (simplified)
let role = assignment.channelId.startsWith('p') ? 'PULSE' :
assignment.channelId.startsWith('w') ? 'WAVE' : 'NOISE';
if (assignment.shouldArpeggiate) role = 'ARP';
el.querySelector('.role').textContent = role;
}
}
}
// File upload handling
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', async (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) await loadMIDI(file);
});
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (file) await loadMIDI(file);
});
async function loadMIDI(file) {
try {
log(`Loading: ${file.name}`);
const buffer = await file.arrayBuffer();
currentMIDI = buffer;
// Analyze without playing
const info = player.analyzeMIDI(buffer);
updateInfo(info);
updateChannels(info.assignments);
log(`Loaded: ${info.noteCount} notes, ${info.trackCount} tracks`, 'success');
btnPlay.disabled = false;
setStatus('LOADED');
} catch (error) {
log(`Error: ${error.message}`, 'error');
}
}
// Playback controls
btnPlay.addEventListener('click', async () => {
if (!currentMIDI) return;
try {
await player.resume();
const info = await player.playMIDI(currentMIDI);
setStatus('PLAYING', true);
btnPlay.disabled = true;
btnStop.disabled = false;
log('Playback started', 'success');
// Auto-reset when done
setTimeout(() => {
if (!player.getIsPlaying()) {
setStatus('FINISHED');
btnPlay.disabled = false;
btnStop.disabled = true;
}
}, (info.duration + 1) * 1000);
} catch (error) {
log(`Playback error: ${error.message}`, 'error');
}
});
btnStop.addEventListener('click', () => {
player.stop();
setStatus('STOPPED');
btnPlay.disabled = false;
btnStop.disabled = true;
log('Playback stopped');
});
// Volume control
volumeSlider.addEventListener('input', (e) => {
player.setVolume(e.target.value / 100);
});
// Quick test buttons
document.getElementById('btn-c4').addEventListener('click', async () => {
await player.resume();
const apu = player.getAPU();
apu.getPulseChannel('p1')?.playNote(60, 0.3, 100);
});
document.getElementById('btn-chord').addEventListener('click', async () => {
await player.resume();
const apu = player.getAPU();
const now = apu.getCurrentTime();
apu.getPulseChannel('p1')?.playNote(60, 0.4, 80, now);
apu.getPulseChannel('p2')?.playNote(64, 0.4, 80, now);
apu.getPulseChannel('p3')?.playNote(67, 0.4, 80, now);
});
document.getElementById('btn-bass').addEventListener('click', async () => {
await player.resume();
const apu = player.getAPU();
apu.getWaveChannel('w1')?.playNote(36, 0.5, 100);
});
document.getElementById('btn-drum').addEventListener('click', async () => {
await player.resume();
const apu = player.getAPU();
apu.getNoiseChannel('n1')?.playKick(100);
});
// ===== ARRANGER TOGGLE =====
const arrangerBtn = document.getElementById('btn-arranger');
let arrangerEnabled = true;
arrangerBtn.addEventListener('click', () => {
arrangerEnabled = !arrangerEnabled;
player.setArrangerEnabled(arrangerEnabled);
if (arrangerEnabled) {
arrangerBtn.textContent = 'ON';
arrangerBtn.style.background = 'var(--gb-green)';
arrangerBtn.style.color = 'var(--bg-dark)';
log('Arranger ON - sparse MIDIs will be enhanced');
} else {
arrangerBtn.textContent = 'OFF';
arrangerBtn.style.background = 'transparent';
arrangerBtn.style.color = 'var(--gb-green)';
log('Arranger OFF - playing raw MIDI data');
}
});
// ===== BITMIDI SEARCH =====
async function handleSearch() {
const query = searchInput.value.trim();
if (!query) {
log('Enter a song name to search');
return;
}
log(`Searching: "${query}"...`);
setStatus('SEARCHING');
btnSearch.disabled = true;
resultsList.innerHTML = '';
try {
const results = await midiService.search(query);
searchResults = results;
if (results.length === 0) {
log('No MIDI files found. Try a different search.', 'error');
setStatus('NO RESULTS');
return;
}
log(`Found ${results.length} results`, 'success');
setStatus('SELECT MIDI');
displayResults();
} catch (error) {
log(`Search error: ${error.message}`, 'error');
setStatus('ERROR');
} finally {
btnSearch.disabled = false;
}
}
function displayResults() {
resultsList.innerHTML = '';
for (let i = 0; i < Math.min(searchResults.length, 10); i++) {
const result = searchResults[i];
const item = document.createElement('div');
item.className = 'result-item';
item.setAttribute('role', 'button');
item.setAttribute('tabindex', '0');
item.innerHTML = `
<span class="title">${result.title}</span>
<span class="confidence">${Math.round((result.confidence || 0) * 100)}%</span>
`;
item.addEventListener('click', () => selectResult(i));
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
selectResult(i);
}
});
resultsList.appendChild(item);
}
}
async function selectResult(index) {
if (index < 0 || index >= searchResults.length) return;
selectedResultIndex = index;
const result = searchResults[index];
// Update UI to show selection
document.querySelectorAll('.result-item').forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
log(`Loading: ${result.title}...`);
setStatus('LOADING');
try {
const midiBuffer = await midiService.fetchMIDI(result.midiUrl);
if (!midiBuffer) {
throw new Error('Failed to fetch MIDI');
}
currentMIDI = midiBuffer;
// Analyze without playing
const info = player.analyzeMIDI(midiBuffer);
updateInfo(info);
updateChannels(info.assignments);
log(`Loaded: ${info.noteCount} notes, ${info.trackCount} tracks`, 'success');
btnPlay.disabled = false;
setStatus('READY');
} catch (error) {
log(`Load error: ${error.message}`, 'error');
setStatus('ERROR');
}
}
// Search event listeners
btnSearch.addEventListener('click', handleSearch);
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleSearch();
});
// Initialize
log('Wario Synth v2 initialized', 'success');
log('Search BitMidi or drop a MIDI file');
</script>
</body>
</html>