Add offline MP3 rendering, Game Boy styling for UX test, fix 16-bit → 8-bit

- Add renderOffline() to SynthesisEngine for faster-than-realtime audio rendering
- Add renderOffline() to MotifEngine wrapping the synthesis engine
- Update ux-test.html MP3 download to use offline rendering (no more real-time capture)
- Restyle ux-test.html with Game Boy LCD aesthetic to match main site
- Fix "16-Bit" to "8-Bit" across all titles and share cards (Game Boy is 8-bit!)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
b1rdmania
2026-01-01 10:32:27 +00:00
parent d43b3d5daf
commit 2492ab833a
7 changed files with 587 additions and 250 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Wario Synthesis Engine 16-Bit Midi # Wario Synthesis Engine 8-Bit Midi
![Wario Synth Logo](public/wariosynthlogo.png) ![Wario Synth Logo](public/wariosynthlogo.png)
+3 -3
View File
@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Wario Synthesis Engine 16-Bit Midi</title> <title>Wario Synthesis Engine 8-Bit Midi</title>
<!-- OpenGraph / Social Media Preview --> <!-- OpenGraph / Social Media Preview -->
<meta property="og:title" content="Wario Synthesis Engine 16-Bit Midi"> <meta property="og:title" content="Wario Synthesis Engine 8-Bit Midi">
<meta property="og:description" content="Turn any song into a Game Boy version"> <meta property="og:description" content="Turn any song into a Game Boy version">
<meta property="og:image" content="/warioX.png"> <meta property="og:image" content="/warioX.png">
<meta property="og:image:width" content="1472"> <meta property="og:image:width" content="1472">
@@ -16,7 +16,7 @@
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Wario Synthesis Engine 16-Bit Midi"> <meta name="twitter:title" content="Wario Synthesis Engine 8-Bit Midi">
<meta name="twitter:description" content="Turn any song into a Game Boy version"> <meta name="twitter:description" content="Turn any song into a Game Boy version">
<meta name="twitter:image" content="/warioX.png"> <meta name="twitter:image" content="/warioX.png">
+3 -3
View File
@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Wario Synthesis Engine 16-Bit Midi</title> <title>Wario Synthesis Engine 8-Bit Midi</title>
<meta name="description" content="Tap to play this Gameboy tune" /> <meta name="description" content="Tap to play this Gameboy tune" />
<!-- OpenGraph / Social Media Preview --> <!-- OpenGraph / Social Media Preview -->
<meta property="og:title" content="Wario Synthesis Engine 16-Bit Midi" /> <meta property="og:title" content="Wario Synthesis Engine 8-Bit Midi" />
<meta property="og:description" content="Turn any song into a Game Boy version" /> <meta property="og:description" content="Turn any song into a Game Boy version" />
<meta property="og:image" content="https://motif-self.vercel.app/warioX.png" /> <meta property="og:image" content="https://motif-self.vercel.app/warioX.png" />
<meta property="og:image:width" content="1472" /> <meta property="og:image:width" content="1472" />
@@ -17,7 +17,7 @@
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Wario Synthesis Engine 16-Bit Midi" /> <meta name="twitter:title" content="Wario Synthesis Engine 8-Bit Midi" />
<meta name="twitter:description" content="Turn any song into a Game Boy version" /> <meta name="twitter:description" content="Turn any song into a Game Boy version" />
<meta name="twitter:image" content="https://motif-self.vercel.app/warioX.png" /> <meta name="twitter:image" content="https://motif-self.vercel.app/warioX.png" />
+1 -1
View File
@@ -220,7 +220,7 @@ app.get('/s/:code', async (req, res) => {
const imageUrl = origin ? `${origin}/warioX.png` : '/warioX.png'; const imageUrl = origin ? `${origin}/warioX.png` : '/warioX.png';
const sharedTitle = (payload.title || '').trim(); const sharedTitle = (payload.title || '').trim();
const ogTitle = sharedTitle ? `${sharedTitle} - Wario Synth` : 'Wario Synth 16-Bit Midi'; const ogTitle = sharedTitle ? `${sharedTitle} - Wario Synth` : 'Wario Synth 8-Bit Midi';
const ogDescription = sharedTitle const ogDescription = sharedTitle
? `I made ${sharedTitle} Game Boy version. Click to listen or generate your own.` ? `I made ${sharedTitle} Game Boy version. Click to listen or generate your own.`
: 'Turn any song into a Game Boy version'; : 'Turn any song into a Game Boy version';
+43
View File
@@ -184,6 +184,49 @@ export class MotifEngine {
return 0; return 0;
} }
/**
* Render audio offline (faster than real-time) to an AudioBuffer.
* This does not require play() - it pre-schedules all events and renders in one go.
*/
async renderOffline(
events: NoteEvent[],
transformMode: 'passthrough' | 'procedural' = 'passthrough',
sampleRate = 44100
): Promise<AudioBuffer> {
let roleAssignments;
if (transformMode === 'passthrough') {
// Direct playback mode - play MIDI as-is without transformations
roleAssignments = [{
role: 'melody' as const,
sourceTrack: 0,
events: [...events], // Clone to avoid mutation
chords: [],
confidence: 1.0,
features: {
medianPitch: 60,
pitchRange: 48,
noteDensity: 1.0,
polyphonyRatio: 0.5,
averageDuration: 0.5,
repetitionScore: 0.5,
isMonophonic: false,
hasPhraseContinuity: true,
register: 'mid' as const
}
}];
} else {
// Procedural mode - transform the MIDI with role mapping
// Clone events to avoid mutating originals
const clonedEvents = events.map(e => ({ ...e }));
const features = this.midiProcessor.extractFeatures(clonedEvents);
roleAssignments = this.roleMapper.assignRoles(features, clonedEvents);
}
console.log(`Motif: Offline rendering in ${transformMode} mode`);
return SynthesisEngine.renderOffline(roleAssignments, this.config, sampleRate);
}
private generateSyntheticMIDI(songName: string): NoteEvent[] { private generateSyntheticMIDI(songName: string): NoteEvent[] {
// Generate procedural MIDI based on song name hash // Generate procedural MIDI based on song name hash
const hash = this.simpleHash(songName); const hash = this.simpleHash(songName);
+227
View File
@@ -505,4 +505,231 @@ export class SynthesisEngine {
} }
this.layers.clear(); this.layers.clear();
} }
/**
* Render audio offline (faster than real-time) to an AudioBuffer.
* This is a static method that creates its own offline context and scheduling.
*/
static async renderOffline(
assignments: RoleAssignment[],
_config: MotifConfig,
sampleRate = 44100
): Promise<AudioBuffer> {
// Calculate duration from assignments
let maxDuration = 0;
for (const assignment of assignments) {
for (const event of assignment.events) {
const eventEnd = event.time + event.duration;
maxDuration = Math.max(maxDuration, eventEnd);
}
for (const chord of assignment.chords) {
const chordEnd = chord.time + chord.duration;
maxDuration = Math.max(maxDuration, chordEnd);
}
}
// Add a little padding for release envelopes
const totalDuration = maxDuration + 0.5;
const totalSamples = Math.ceil(totalDuration * sampleRate);
// Create offline context
const offlineCtx = new OfflineAudioContext(2, totalSamples, sampleRate);
// Create master gain
const masterGain = offlineCtx.createGain();
masterGain.connect(offlineCtx.destination);
masterGain.gain.value = 0.3;
// Normalize times (same logic as setupLayers)
let earliestTime = Infinity;
for (const assignment of assignments) {
if (assignment.events.length > 0) {
earliestTime = Math.min(earliestTime, assignment.events[0].time);
}
if (assignment.chords.length > 0) {
earliestTime = Math.min(earliestTime, assignment.chords[0].time);
}
}
if (earliestTime !== Infinity && earliestTime > 0) {
for (const assignment of assignments) {
for (const event of assignment.events) {
event.time -= earliestTime;
}
for (const chord of assignment.chords) {
chord.time -= earliestTime;
}
}
}
// Schedule all events for each assignment
for (const assignment of assignments) {
const { role, events, chords } = assignment;
// Create layer nodes for this role
const { filterNode } = SynthesisEngine.createOfflineLayer(offlineCtx, masterGain, role);
// For roles that support polyphony, prefer chords
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
for (const chord of chords) {
SynthesisEngine.scheduleOfflineChord(
offlineCtx,
filterNode,
role,
chord.pitches,
Math.max(0.05, chord.duration),
chord.velocity,
chord.time
);
}
} else {
for (const event of events) {
SynthesisEngine.scheduleOfflineNote(
offlineCtx,
filterNode,
role,
event.pitch,
Math.max(0.05, event.duration),
event.velocity,
event.time
);
}
}
}
// Render and return
return offlineCtx.startRendering();
}
private static createOfflineLayer(
ctx: OfflineAudioContext,
masterGain: GainNode,
role: Role
): { gainNode: GainNode; filterNode: BiquadFilterNode } {
const gainNode = ctx.createGain();
const filterNode = ctx.createBiquadFilter();
filterNode.connect(gainNode);
gainNode.connect(masterGain);
// Set gain based on role
switch (role) {
case 'bass': gainNode.gain.value = 0.4; break;
case 'drone': gainNode.gain.value = 0.2; break;
case 'ostinato': gainNode.gain.value = 0.3; break;
case 'texture': gainNode.gain.value = 0.1; break;
case 'accents': gainNode.gain.value = 0.5; break;
case 'melody': gainNode.gain.value = 0.35; break;
default: gainNode.gain.value = 0.3;
}
// Configure filter based on role
switch (role) {
case 'bass':
filterNode.type = 'lowpass';
filterNode.frequency.value = 200;
break;
case 'drone':
filterNode.type = 'bandpass';
filterNode.frequency.value = 400;
break;
case 'ostinato':
filterNode.type = 'highpass';
filterNode.frequency.value = 300;
break;
case 'texture':
filterNode.type = 'bandpass';
filterNode.frequency.value = 800;
break;
case 'accents':
filterNode.type = 'peaking';
filterNode.frequency.value = 1000;
break;
case 'melody':
filterNode.type = 'lowpass';
filterNode.frequency.value = 4000;
break;
}
return { gainNode, filterNode };
}
private static midiToFreq(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
private static getOscillatorType(role: Role): OscillatorType {
switch (role) {
case 'bass': return 'square';
case 'drone': return 'sawtooth';
case 'ostinato': return 'triangle';
case 'melody': return 'triangle';
case 'texture': return 'sine';
case 'accents': return 'sine';
default: return 'sine';
}
}
private static scheduleOfflineNote(
ctx: OfflineAudioContext,
filterNode: BiquadFilterNode,
role: Role,
pitch: number,
duration: number,
velocity: number,
when: number
): void {
const osc = ctx.createOscillator();
const envelope = ctx.createGain();
osc.frequency.value = SynthesisEngine.midiToFreq(pitch);
osc.type = SynthesisEngine.getOscillatorType(role);
osc.connect(envelope);
envelope.connect(filterNode);
const gainValue = velocity * 0.5;
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1));
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3));
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01);
}
private static scheduleOfflineChord(
ctx: OfflineAudioContext,
filterNode: BiquadFilterNode,
role: Role,
pitches: number[],
duration: number,
velocity: number,
when: number
): void {
for (const pitch of pitches) {
const osc = ctx.createOscillator();
const envelope = ctx.createGain();
osc.frequency.value = SynthesisEngine.midiToFreq(pitch);
osc.type = SynthesisEngine.getOscillatorType(role);
osc.connect(envelope);
envelope.connect(filterNode);
const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1);
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1));
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3));
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01);
}
}
} }
+301 -234
View File
@@ -2,140 +2,201 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>WARIO SYNTH - UX Test</title> <title>WARIO SYNTH - UX Test</title>
<style> <style>
/* Local Pixel Font */
@font-face {
font-family: 'Press Start 2P';
src: url('/fonts/PressStart2P.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
:root { :root {
--bg: #08080c; /* Game Boy palette - light mode (authentic LCD) */
--surface: #101014; --gb-lightest: #E0E8C8; /* Screen background */
--surface-2: #18181f; --gb-light: #A8B89A; /* Surface/cards */
--border: rgba(255,255,255,0.08); --gb-medium: #6B7B5A; /* Dim text, borders */
--text: #e8e8e8; --gb-dark: #3A4A2A; /* Text */
--text-dim: #888; --gb-darkest: #1D2A1D; /* Darkest accents */
--neon-green: #00ff88;
--neon-pink: #ff00aa; /* Semantic mapping - LIGHT background, DARK text */
--neon-blue: #00aaff; --color-bg: #C8D8B0;
--neon-orange: #ff8800; --color-surface: #A8B89A;
--color-border: var(--gb-dark);
--color-text: var(--gb-darkest);
--color-text-dim: var(--gb-medium);
--spacing-unit: 8px;
--pixel-border: 4px;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
image-rendering: pixelated;
} }
body { body {
margin: 0; margin: 0;
padding: 24px 16px; padding: calc(var(--spacing-unit) * 3);
background: var(--bg); background: var(--color-bg);
color: var(--text); color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Press Start 2P', monospace;
line-height: 2;
font-size: 10px;
min-height: 100vh; min-height: 100vh;
line-height: 1.5; -webkit-font-smoothing: none;
-moz-osx-font-smoothing: unset;
}
/* LCD Screen Effect */
body::before {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.03) 0px,
rgba(0, 0, 0, 0.03) 2px,
transparent 2px,
transparent 4px
);
pointer-events: none;
z-index: 9999;
} }
.container { .container {
max-width: 480px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
/* Header */ /* Header */
header { .hero {
text-align: center; text-align: center;
margin-bottom: 32px; margin: 0 0 calc(var(--spacing-unit) * 3) 0;
padding: calc(var(--spacing-unit) * 2);
border: var(--pixel-border) solid var(--gb-dark);
background: var(--color-surface);
} }
h1 { .hero h1 {
font-size: 2em; color: var(--gb-darkest);
margin: 0 0 8px 0;
color: var(--neon-green);
text-shadow: 0 0 30px rgba(0, 255, 136, 0.4);
letter-spacing: 0.05em;
}
.tagline {
color: var(--text-dim);
font-size: 15px;
margin: 0; margin: 0;
font-size: 16px;
font-weight: normal;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.hero .tagline {
margin: calc(var(--spacing-unit)) 0 0 0;
color: var(--gb-dark);
font-size: 8px;
}
.hero .test-badge {
display: inline-block;
margin-top: calc(var(--spacing-unit));
padding: 4px 8px;
background: var(--gb-dark);
color: var(--gb-lightest);
font-size: 8px;
} }
/* Search */ /* Search */
.search-box { .search-box {
display: flex; display: flex;
gap: 8px; gap: calc(var(--spacing-unit));
margin-bottom: 16px; margin-bottom: calc(var(--spacing-unit) * 2);
} }
input[type="text"] { input[type="text"] {
flex: 1; flex: 1;
background: var(--surface); background: var(--gb-lightest);
border: 1px solid var(--border); color: var(--gb-darkest);
color: var(--text); border: var(--pixel-border) solid var(--gb-dark);
padding: 14px 16px; padding: 12px 14px;
border-radius: 10px; border-radius: 0;
font-size: 15px; font-family: 'Press Start 2P', monospace;
transition: border-color 0.15s; font-size: 10px;
box-shadow: inset 3px 3px 0 var(--gb-medium);
} }
input[type="text"]:focus { input[type="text"]:focus {
outline: none; outline: none;
border-color: var(--neon-green); border-color: var(--gb-darkest);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1); background: #fff;
} }
input[type="text"]::placeholder { input[type="text"]::placeholder {
color: #555; color: var(--gb-medium);
} }
button { button {
background: var(--surface); background: var(--color-surface);
color: var(--text); color: var(--color-text);
border: 1px solid var(--border); border: var(--pixel-border) solid var(--gb-dark);
padding: 14px 20px; padding: 12px 16px;
border-radius: 10px; border-radius: 0;
cursor: pointer; cursor: pointer;
font-size: 14px; font-family: 'Press Start 2P', monospace;
font-weight: 500; font-size: 8px;
transition: all 0.15s ease; text-transform: uppercase;
box-shadow:
inset -3px -3px 0 var(--gb-medium),
inset 3px 3px 0 var(--gb-lightest);
transition: none;
} }
button:hover:not(:disabled) { button:hover:not(:disabled) {
border-color: rgba(255,255,255,0.2); background: var(--gb-lightest);
background: var(--surface-2); color: var(--gb-darkest);
}
button:active:not(:disabled) {
box-shadow: inset 3px 3px 0 var(--gb-medium);
} }
button:disabled { button:disabled {
opacity: 0.4; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
button.primary { button.primary {
background: var(--neon-green); background: var(--gb-dark);
color: #000; color: var(--gb-lightest);
border-color: var(--neon-green); border-color: var(--gb-darkest);
font-weight: 600; box-shadow:
inset -3px -3px 0 var(--gb-darkest),
inset 3px 3px 0 var(--gb-medium);
} }
button.primary:hover:not(:disabled) { button.primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 255, 136, 0.4); background: var(--gb-darkest);
transform: translateY(-1px); color: var(--gb-lightest);
} }
/* Status */ /* Status */
.status { .status {
text-align: center; padding: calc(var(--spacing-unit) * 2);
font-size: 13px; background: var(--gb-lightest);
color: var(--text-dim); border: var(--pixel-border) solid var(--gb-dark);
margin-bottom: 24px; color: var(--gb-darkest);
min-height: 20px; font-size: 8px;
margin-bottom: calc(var(--spacing-unit) * 3);
} }
.status.error { .status.error {
color: var(--neon-pink); background: var(--gb-dark);
color: var(--gb-lightest);
} }
/* Results */ /* Results */
.results { .results {
display: none; display: none;
margin-bottom: 24px; margin-bottom: calc(var(--spacing-unit) * 3);
} }
.results.visible { .results.visible {
@@ -143,52 +204,55 @@
} }
.results-label { .results-label {
font-size: 11px; font-size: 8px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--text-dim); color: var(--gb-dark);
margin-bottom: 8px; margin-bottom: calc(var(--spacing-unit));
} }
.result-item { .result-item {
background: var(--surface); background: var(--color-surface);
border: 1px solid var(--border); border: var(--pixel-border) solid var(--gb-dark);
border-radius: 10px;
padding: 12px 14px; padding: 12px 14px;
margin-bottom: 6px; margin-bottom: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.15s;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.result-item:hover { .result-item:hover {
border-color: rgba(255,255,255,0.15); background: var(--gb-lightest);
background: var(--surface-2);
} }
.result-item.selected { .result-item.selected {
border-color: var(--neon-green); background: var(--gb-dark);
box-shadow: 0 0 20px rgba(0, 255, 136, 0.15); color: var(--gb-lightest);
}
.result-item.selected::before {
content: '▶ ';
} }
.result-title { .result-title {
font-size: 14px; font-size: 8px;
font-weight: 500;
} }
.result-meta { .result-meta {
font-size: 12px; font-size: 8px;
color: var(--text-dim); color: var(--gb-medium);
}
.result-item.selected .result-meta {
color: var(--gb-light);
} }
/* Player Card */ /* Player Card */
.player-card { .player-card {
background: var(--surface); background: var(--gb-dark);
border: 1px solid var(--border); border: var(--pixel-border) solid var(--gb-darkest);
border-radius: 14px; padding: calc(var(--spacing-unit) * 3);
padding: 20px;
display: none; display: none;
} }
@@ -198,21 +262,21 @@
.now-playing { .now-playing {
text-align: center; text-align: center;
margin-bottom: 16px; margin-bottom: calc(var(--spacing-unit) * 2);
} }
.now-playing-label { .now-playing-label {
font-size: 11px; font-size: 8px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--text-dim); color: var(--gb-light);
margin-bottom: 4px; margin-bottom: 4px;
} }
.now-playing-title { .now-playing-title {
font-size: 16px; font-size: 12px;
font-weight: 600; color: var(--gb-lightest);
color: var(--text); word-break: break-word;
} }
/* EQ Visualizer */ /* EQ Visualizer */
@@ -221,50 +285,59 @@
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
gap: 2px; gap: 2px;
height: 80px; height: 60px;
background: rgba(0,0,0,0.5); background: var(--gb-darkest);
border-radius: 8px; border: 2px solid var(--gb-medium);
padding: 10px; padding: 8px;
margin-bottom: 16px; margin-bottom: calc(var(--spacing-unit) * 2);
position: relative;
overflow: hidden;
}
.eq-visualizer::before {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.1) 2px,
rgba(0,0,0,0.1) 4px
);
pointer-events: none;
z-index: 10;
} }
.eq-bar { .eq-bar {
width: 6px; width: 4px;
min-height: 3px; min-height: 2px;
background: linear-gradient(to top, var(--neon-green) 0%, var(--neon-blue) 100%); background: var(--gb-light);
border-radius: 2px;
transition: height 0.05s ease-out;
box-shadow: 0 0 8px rgba(0, 255, 136, 0.4);
} }
.eq-bar.hot { .eq-bar.hot {
background: linear-gradient(to top, var(--neon-orange) 0%, var(--neon-pink) 100%); background: var(--gb-lightest);
box-shadow: 0 0 8px rgba(255, 0, 170, 0.4); }
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
padding: 4px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.mode-btn {
flex: 1;
padding: 10px 8px;
border: none;
background: transparent;
color: var(--gb-medium);
font-size: 8px;
box-shadow: none;
cursor: pointer;
}
.mode-btn:hover {
color: var(--gb-light);
background: transparent;
}
.mode-btn.active {
background: var(--gb-dark);
color: var(--gb-lightest);
} }
/* Player Controls */ /* Player Controls */
.player-controls { .player-controls {
display: flex; display: flex;
gap: 10px; gap: calc(var(--spacing-unit));
justify-content: center; justify-content: center;
margin-bottom: 16px; margin-bottom: calc(var(--spacing-unit) * 2);
} }
.player-controls button { .player-controls button {
@@ -275,18 +348,28 @@
/* Share row */ /* Share row */
.share-row { .share-row {
display: flex; display: flex;
gap: 10px; gap: calc(var(--spacing-unit));
justify-content: center; justify-content: center;
margin-bottom: 10px; margin-bottom: calc(var(--spacing-unit));
} }
.share-row button { .share-row button {
flex: 1; flex: 1;
max-width: 140px; max-width: 140px;
background: var(--gb-darkest);
color: var(--gb-light);
border-color: var(--gb-medium);
} }
.share-row button:hover:not(:disabled) {
background: var(--gb-medium);
color: var(--gb-lightest);
}
.share-hint { .share-hint {
margin: 0 0 16px 0; margin: 0 0 calc(var(--spacing-unit) * 2) 0;
font-size: 12px; font-size: 8px;
color: var(--text-dim); color: var(--gb-light);
min-height: 16px; min-height: 16px;
text-align: center; text-align: center;
} }
@@ -295,12 +378,14 @@
.volume-row { .volume-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: calc(var(--spacing-unit) * 1.5);
padding-top: calc(var(--spacing-unit) * 2);
border-top: 2px solid var(--gb-medium);
} }
.volume-label { .volume-label {
font-size: 12px; font-size: 8px;
color: var(--text-dim); color: var(--gb-light);
min-width: 50px; min-width: 50px;
} }
@@ -308,85 +393,94 @@
flex: 1; flex: 1;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 4px; height: 8px;
background: var(--surface-2); background: var(--gb-darkest);
border-radius: 2px; border: 2px solid var(--gb-medium);
border-radius: 0;
outline: none; outline: none;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 16px; width: 12px;
height: 16px; height: 12px;
border-radius: 50%; border-radius: 0;
background: var(--neon-green); background: var(--gb-lightest);
cursor: pointer; cursor: pointer;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
}
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--surface-2);
border-radius: 8px;
padding: 4px;
margin-bottom: 16px;
}
.mode-btn {
flex: 1;
padding: 10px 12px;
border: none; border: none;
background: transparent; }
color: var(--text-dim);
font-size: 13px; input[type="range"]::-moz-range-thumb {
font-weight: 500; width: 12px;
border-radius: 6px; height: 12px;
border-radius: 0;
background: var(--gb-lightest);
cursor: pointer; cursor: pointer;
transition: all 0.15s; border: none;
}
.mode-btn:hover {
color: var(--text);
}
.mode-btn.active {
background: var(--surface);
color: var(--neon-green);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
} }
/* Footer */ /* Footer */
footer { .footer {
margin-top: calc(var(--spacing-unit) * 4);
text-align: center; text-align: center;
margin-top: 32px; padding-top: calc(var(--spacing-unit) * 2);
padding-top: 24px; border-top: 2px solid var(--gb-medium);
border-top: 1px solid var(--border);
} }
footer p { .footer p {
font-size: 12px; font-size: 8px;
color: var(--text-dim); color: var(--gb-dark);
margin: 0; margin: 0;
} }
footer a { .footer a {
color: var(--neon-green); color: var(--gb-darkest);
text-decoration: none; text-decoration: underline;
}
.footer-mascot {
margin-top: calc(var(--spacing-unit) * 3);
text-align: center;
}
.wario-sprite {
width: 120px;
height: auto;
mix-blend-mode: multiply;
opacity: 0.7;
}
/* Mobile adjustments */
@media (max-width: 480px) {
body {
font-size: 11px;
}
.hero h1 {
font-size: 14px;
}
button {
font-size: 9px;
padding: 14px 16px;
}
.mode-btn {
font-size: 7px;
padding: 10px 6px;
}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <div class="hero">
<h1>WARIO SYNTH</h1> <h1>WARIO SYNTH</h1>
<p class="tagline">Turn any song into retro game console music</p> <p class="tagline">Turn any song into Game Boy music</p>
</header> <span class="test-badge">UX Test Page</span>
</div>
<div class="search-box"> <div class="search-box">
<input type="text" id="songInput" placeholder="Search any song..." value="Hotel California" /> <input type="text" id="songInput" placeholder="Search any song..." value="Hotel California" />
<button id="searchBtn">Search</button> <button id="searchBtn" class="primary">Search</button>
</div> </div>
<div id="status" class="status">Search for a song to get started</div> <div id="status" class="status">Search for a song to get started</div>
@@ -429,9 +523,13 @@
</div> </div>
</div> </div>
<footer> <div class="footer">
<p>UX Test Page · <a href="/">Back to main</a></p> <p>UX Test Page · <a href="/">Back to main</a></p>
</footer> </div>
<div class="footer-mascot">
<img src="/wario-sprite.png" alt="Wario" class="wario-sprite" />
</div>
</div> </div>
<script type="module"> <script type="module">
@@ -480,7 +578,7 @@
for (let i = 0; i < NUM_BARS; i++) { for (let i = 0; i < NUM_BARS; i++) {
const bar = document.createElement('div'); const bar = document.createElement('div');
bar.className = 'eq-bar'; bar.className = 'eq-bar';
bar.style.height = '3px'; bar.style.height = '2px';
eqVisualizer.appendChild(bar); eqVisualizer.appendChild(bar);
} }
} }
@@ -494,7 +592,7 @@
if (!analyser || !dataArray) { if (!analyser || !dataArray) {
bars.forEach((bar) => { bars.forEach((bar) => {
const value = isPlaying ? Math.floor(Math.random() * 255) : 0; const value = isPlaying ? Math.floor(Math.random() * 255) : 0;
const height = Math.max(3, (value / 255) * 60); const height = Math.max(2, (value / 255) * 40);
bar.style.height = height + 'px'; bar.style.height = height + 'px';
if (value > 180) bar.classList.add('hot'); if (value > 180) bar.classList.add('hot');
else bar.classList.remove('hot'); else bar.classList.remove('hot');
@@ -512,7 +610,7 @@
sum += dataArray[i * step + j] || 0; sum += dataArray[i * step + j] || 0;
} }
const value = sum / step; const value = sum / step;
const height = Math.max(3, (value / 255) * 60); const height = Math.max(2, (value / 255) * 40);
bar.style.height = height + 'px'; bar.style.height = height + 'px';
if (value > 180) { if (value > 180) {
@@ -532,7 +630,7 @@
} }
const bars = eqVisualizer.querySelectorAll('.eq-bar'); const bars = eqVisualizer.querySelectorAll('.eq-bar');
bars.forEach(bar => { bars.forEach(bar => {
bar.style.height = '3px'; bar.style.height = '2px';
bar.classList.remove('hot'); bar.classList.remove('hot');
}); });
} }
@@ -686,10 +784,7 @@
if (!currentEvents) return; if (!currentEvents) return;
try { try {
downloadMp3Btn.disabled = true; downloadMp3Btn.disabled = true;
shareHint.textContent = 'Preparing MP3…'; shareHint.textContent = 'Rendering audio…';
// Ensure audio unlocked (user gesture)
const audioContext = await unlockAudio();
// Stop any current playback first // Stop any current playback first
stopPlayback(); stopPlayback();
@@ -697,35 +792,27 @@
// Ensure Motif engine exists // Ensure Motif engine exists
if (!motifEngine) motifEngine = new MotifEngine(); if (!motifEngine) motifEngine = new MotifEngine();
// Use synth output (the "Gameboy" render) // Render offline (faster than real-time!)
await motifEngine.generateFromMIDI(currentEvents, 'procedural'); const sampleRate = 44100;
motifEngine.setVolume(1); const audioBuffer = await motifEngine.renderOffline(currentEvents, 'procedural', sampleRate);
shareHint.textContent = 'Encoding MP3…';
// Lazy-load MP3 encoder // Lazy-load MP3 encoder
const lameMod = await import('lamejs'); const lameMod = await import('lamejs');
const Mp3Encoder = lameMod.Mp3Encoder || (lameMod.default && lameMod.default.Mp3Encoder); const Mp3Encoder = lameMod.Mp3Encoder || (lameMod.default && lameMod.default.Mp3Encoder);
if (!Mp3Encoder) throw new Error('MP3 encoder not available'); if (!Mp3Encoder) throw new Error('MP3 encoder not available');
const channels = 2; const channels = audioBuffer.numberOfChannels;
const sampleRate = audioContext.sampleRate;
const kbps = 128; const kbps = 128;
const encoder = new Mp3Encoder(channels, sampleRate, kbps); const encoder = new Mp3Encoder(channels, sampleRate, kbps);
const mp3Chunks = []; const mp3Chunks = [];
// Tap audio via ScriptProcessorNode // Get audio data from buffer
const bufferSize = 4096; const leftData = audioBuffer.getChannelData(0);
const processor = audioContext.createScriptProcessor(bufferSize, channels, channels); const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData;
const sink = audioContext.createGain(); const left = floatToInt16(leftData);
sink.gain.value = 0; // avoid doubling audible output const right = floatToInt16(rightData);
processor.connect(sink);
sink.connect(audioContext.destination);
let recording = true;
processor.onaudioprocess = (e) => {
if (!recording) return;
const ib = e.inputBuffer;
const left = floatToInt16(ib.getChannelData(0));
const right = ib.numberOfChannels > 1 ? floatToInt16(ib.getChannelData(1)) : left;
// Encode in 1152-sample frames // Encode in 1152-sample frames
const frameSize = 1152; const frameSize = 1152;
@@ -735,27 +822,6 @@
const buf = encoder.encodeBuffer(l, r); const buf = encoder.encodeBuffer(l, r);
if (buf && buf.length) mp3Chunks.push(buf); if (buf && buf.length) mp3Chunks.push(buf);
} }
};
motifEngine.connectOutput(processor);
// Start playback (audible) while we capture/encode
isPlaying = true;
await motifEngine.play();
updateVisualizer();
const durationSec = Math.max(1, motifEngine.getDuration());
shareHint.textContent = `Recording… (${Math.round(durationSec)}s)`;
await new Promise((resolve) => setTimeout(resolve, (durationSec + 0.25) * 1000));
recording = false;
try { motifEngine.disconnectOutput(processor); } catch {}
try { processor.disconnect(); } catch {}
try { sink.disconnect(); } catch {}
// Stop playback after capture
stopPlayback();
const tail = encoder.flush(); const tail = encoder.flush();
if (tail && tail.length) mp3Chunks.push(tail); if (tail && tail.length) mp3Chunks.push(tail);
@@ -767,6 +833,7 @@
window.setTimeout(() => (shareHint.textContent = ''), 1400); window.setTimeout(() => (shareHint.textContent = ''), 1400);
} catch (e) { } catch (e) {
shareHint.textContent = `MP3 failed: ${e && e.message ? e.message : 'Unknown error'}`; shareHint.textContent = `MP3 failed: ${e && e.message ? e.message : 'Unknown error'}`;
console.error('MP3 download error:', e);
} finally { } finally {
downloadMp3Btn.disabled = false; downloadMp3Btn.disabled = false;
} }