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)
+3 -3
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<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 -->
<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:image" content="/warioX.png">
<meta property="og:image:width" content="1472">
@@ -16,7 +16,7 @@
<!-- Twitter Card -->
<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:image" content="/warioX.png">
+3 -3
View File
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8" />
<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" />
<!-- 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:image" content="https://motif-self.vercel.app/warioX.png" />
<meta property="og:image:width" content="1472" />
@@ -17,7 +17,7 @@
<!-- Twitter Card -->
<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: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 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
? `I made ${sharedTitle} Game Boy version. Click to listen or generate your own.`
: 'Turn any song into a Game Boy version';
+43
View File
@@ -184,6 +184,49 @@ export class MotifEngine {
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[] {
// Generate procedural MIDI based on song name hash
const hash = this.simpleHash(songName);
+227
View File
@@ -505,4 +505,231 @@ export class SynthesisEngine {
}
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);
}
}
}
+309 -242
View File
@@ -2,140 +2,201 @@
<html lang="en">
<head>
<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>
<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 {
--bg: #08080c;
--surface: #101014;
--surface-2: #18181f;
--border: rgba(255,255,255,0.08);
--text: #e8e8e8;
--text-dim: #888;
--neon-green: #00ff88;
--neon-pink: #ff00aa;
--neon-blue: #00aaff;
--neon-orange: #ff8800;
/* Game Boy palette - light mode (authentic LCD) */
--gb-lightest: #E0E8C8; /* Screen background */
--gb-light: #A8B89A; /* Surface/cards */
--gb-medium: #6B7B5A; /* Dim text, borders */
--gb-dark: #3A4A2A; /* Text */
--gb-darkest: #1D2A1D; /* Darkest accents */
/* Semantic mapping - LIGHT background, DARK text */
--color-bg: #C8D8B0;
--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;
image-rendering: pixelated;
}
body {
margin: 0;
padding: 24px 16px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: calc(var(--spacing-unit) * 3);
background: var(--color-bg);
color: var(--color-text);
font-family: 'Press Start 2P', monospace;
line-height: 2;
font-size: 10px;
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 {
max-width: 480px;
max-width: 600px;
margin: 0 auto;
}
/* Header */
header {
.hero {
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 {
font-size: 2em;
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;
.hero h1 {
color: var(--gb-darkest);
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-box {
display: flex;
gap: 8px;
margin-bottom: 16px;
gap: calc(var(--spacing-unit));
margin-bottom: calc(var(--spacing-unit) * 2);
}
input[type="text"] {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 14px 16px;
border-radius: 10px;
font-size: 15px;
transition: border-color 0.15s;
background: var(--gb-lightest);
color: var(--gb-darkest);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 14px;
border-radius: 0;
font-family: 'Press Start 2P', monospace;
font-size: 10px;
box-shadow: inset 3px 3px 0 var(--gb-medium);
}
input[type="text"]:focus {
outline: none;
border-color: var(--neon-green);
box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.1);
border-color: var(--gb-darkest);
background: #fff;
}
input[type="text"]::placeholder {
color: #555;
color: var(--gb-medium);
}
button {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 14px 20px;
border-radius: 10px;
background: var(--color-surface);
color: var(--color-text);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 16px;
border-radius: 0;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
font-family: 'Press Start 2P', monospace;
font-size: 8px;
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) {
border-color: rgba(255,255,255,0.2);
background: var(--surface-2);
background: var(--gb-lightest);
color: var(--gb-darkest);
}
button:active:not(:disabled) {
box-shadow: inset 3px 3px 0 var(--gb-medium);
}
button:disabled {
opacity: 0.4;
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--neon-green);
color: #000;
border-color: var(--neon-green);
font-weight: 600;
background: var(--gb-dark);
color: var(--gb-lightest);
border-color: var(--gb-darkest);
box-shadow:
inset -3px -3px 0 var(--gb-darkest),
inset 3px 3px 0 var(--gb-medium);
}
button.primary:hover:not(:disabled) {
box-shadow: 0 0 24px rgba(0, 255, 136, 0.4);
transform: translateY(-1px);
background: var(--gb-darkest);
color: var(--gb-lightest);
}
/* Status */
.status {
text-align: center;
font-size: 13px;
color: var(--text-dim);
margin-bottom: 24px;
min-height: 20px;
padding: calc(var(--spacing-unit) * 2);
background: var(--gb-lightest);
border: var(--pixel-border) solid var(--gb-dark);
color: var(--gb-darkest);
font-size: 8px;
margin-bottom: calc(var(--spacing-unit) * 3);
}
.status.error {
color: var(--neon-pink);
background: var(--gb-dark);
color: var(--gb-lightest);
}
/* Results */
.results {
display: none;
margin-bottom: 24px;
margin-bottom: calc(var(--spacing-unit) * 3);
}
.results.visible {
@@ -143,52 +204,55 @@
}
.results-label {
font-size: 11px;
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 8px;
color: var(--gb-dark);
margin-bottom: calc(var(--spacing-unit));
}
.result-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
background: var(--color-surface);
border: var(--pixel-border) solid var(--gb-dark);
padding: 12px 14px;
margin-bottom: 6px;
margin-bottom: 4px;
cursor: pointer;
transition: all 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
}
.result-item:hover {
border-color: rgba(255,255,255,0.15);
background: var(--surface-2);
background: var(--gb-lightest);
}
.result-item.selected {
border-color: var(--neon-green);
box-shadow: 0 0 20px rgba(0, 255, 136, 0.15);
background: var(--gb-dark);
color: var(--gb-lightest);
}
.result-item.selected::before {
content: '▶ ';
}
.result-title {
font-size: 14px;
font-weight: 500;
font-size: 8px;
}
.result-meta {
font-size: 12px;
color: var(--text-dim);
font-size: 8px;
color: var(--gb-medium);
}
.result-item.selected .result-meta {
color: var(--gb-light);
}
/* Player Card */
.player-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 20px;
background: var(--gb-dark);
border: var(--pixel-border) solid var(--gb-darkest);
padding: calc(var(--spacing-unit) * 3);
display: none;
}
@@ -198,21 +262,21 @@
.now-playing {
text-align: center;
margin-bottom: 16px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.now-playing-label {
font-size: 11px;
font-size: 8px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
color: var(--gb-light);
margin-bottom: 4px;
}
.now-playing-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
font-size: 12px;
color: var(--gb-lightest);
word-break: break-word;
}
/* EQ Visualizer */
@@ -221,50 +285,59 @@
align-items: flex-end;
justify-content: center;
gap: 2px;
height: 80px;
background: rgba(0,0,0,0.5);
border-radius: 8px;
padding: 10px;
margin-bottom: 16px;
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;
height: 60px;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
padding: 8px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.eq-bar {
width: 6px;
min-height: 3px;
background: linear-gradient(to top, var(--neon-green) 0%, var(--neon-blue) 100%);
border-radius: 2px;
transition: height 0.05s ease-out;
box-shadow: 0 0 8px rgba(0, 255, 136, 0.4);
width: 4px;
min-height: 2px;
background: var(--gb-light);
}
.eq-bar.hot {
background: linear-gradient(to top, var(--neon-orange) 0%, var(--neon-pink) 100%);
box-shadow: 0 0 8px rgba(255, 0, 170, 0.4);
background: var(--gb-lightest);
}
/* 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 {
display: flex;
gap: 10px;
gap: calc(var(--spacing-unit));
justify-content: center;
margin-bottom: 16px;
margin-bottom: calc(var(--spacing-unit) * 2);
}
.player-controls button {
@@ -275,18 +348,28 @@
/* Share row */
.share-row {
display: flex;
gap: 10px;
gap: calc(var(--spacing-unit));
justify-content: center;
margin-bottom: 10px;
margin-bottom: calc(var(--spacing-unit));
}
.share-row button {
flex: 1;
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 {
margin: 0 0 16px 0;
font-size: 12px;
color: var(--text-dim);
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
font-size: 8px;
color: var(--gb-light);
min-height: 16px;
text-align: center;
}
@@ -295,12 +378,14 @@
.volume-row {
display: flex;
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 {
font-size: 12px;
color: var(--text-dim);
font-size: 8px;
color: var(--gb-light);
min-width: 50px;
}
@@ -308,85 +393,94 @@
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--surface-2);
border-radius: 2px;
height: 8px;
background: var(--gb-darkest);
border: 2px solid var(--gb-medium);
border-radius: 0;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--neon-green);
width: 12px;
height: 12px;
border-radius: 0;
background: var(--gb-lightest);
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;
background: transparent;
color: var(--text-dim);
font-size: 13px;
font-weight: 500;
border-radius: 6px;
}
input[type="range"]::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 0;
background: var(--gb-lightest);
cursor: pointer;
transition: all 0.15s;
}
.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);
border: none;
}
/* Footer */
footer {
.footer {
margin-top: calc(var(--spacing-unit) * 4);
text-align: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--border);
padding-top: calc(var(--spacing-unit) * 2);
border-top: 2px solid var(--gb-medium);
}
footer p {
font-size: 12px;
color: var(--text-dim);
.footer p {
font-size: 8px;
color: var(--gb-dark);
margin: 0;
}
footer a {
color: var(--neon-green);
text-decoration: none;
.footer a {
color: var(--gb-darkest);
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>
</head>
<body>
<div class="container">
<header>
<div class="hero">
<h1>WARIO SYNTH</h1>
<p class="tagline">Turn any song into retro game console music</p>
</header>
<p class="tagline">Turn any song into Game Boy music</p>
<span class="test-badge">UX Test Page</span>
</div>
<div class="search-box">
<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 id="status" class="status">Search for a song to get started</div>
@@ -429,9 +523,13 @@
</div>
</div>
<footer>
<div class="footer">
<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>
<script type="module">
@@ -480,7 +578,7 @@
for (let i = 0; i < NUM_BARS; i++) {
const bar = document.createElement('div');
bar.className = 'eq-bar';
bar.style.height = '3px';
bar.style.height = '2px';
eqVisualizer.appendChild(bar);
}
}
@@ -494,7 +592,7 @@
if (!analyser || !dataArray) {
bars.forEach((bar) => {
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';
if (value > 180) bar.classList.add('hot');
else bar.classList.remove('hot');
@@ -512,7 +610,7 @@
sum += dataArray[i * step + j] || 0;
}
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';
if (value > 180) {
@@ -532,7 +630,7 @@
}
const bars = eqVisualizer.querySelectorAll('.eq-bar');
bars.forEach(bar => {
bar.style.height = '3px';
bar.style.height = '2px';
bar.classList.remove('hot');
});
}
@@ -686,10 +784,7 @@
if (!currentEvents) return;
try {
downloadMp3Btn.disabled = true;
shareHint.textContent = 'Preparing MP3…';
// Ensure audio unlocked (user gesture)
const audioContext = await unlockAudio();
shareHint.textContent = 'Rendering audio…';
// Stop any current playback first
stopPlayback();
@@ -697,65 +792,36 @@
// Ensure Motif engine exists
if (!motifEngine) motifEngine = new MotifEngine();
// Use synth output (the "Gameboy" render)
await motifEngine.generateFromMIDI(currentEvents, 'procedural');
motifEngine.setVolume(1);
// Render offline (faster than real-time!)
const sampleRate = 44100;
const audioBuffer = await motifEngine.renderOffline(currentEvents, 'procedural', sampleRate);
shareHint.textContent = 'Encoding MP3…';
// Lazy-load MP3 encoder
const lameMod = await import('lamejs');
const Mp3Encoder = lameMod.Mp3Encoder || (lameMod.default && lameMod.default.Mp3Encoder);
if (!Mp3Encoder) throw new Error('MP3 encoder not available');
const channels = 2;
const sampleRate = audioContext.sampleRate;
const channels = audioBuffer.numberOfChannels;
const kbps = 128;
const encoder = new Mp3Encoder(channels, sampleRate, kbps);
const mp3Chunks = [];
// Tap audio via ScriptProcessorNode
const bufferSize = 4096;
const processor = audioContext.createScriptProcessor(bufferSize, channels, channels);
const sink = audioContext.createGain();
sink.gain.value = 0; // avoid doubling audible output
processor.connect(sink);
sink.connect(audioContext.destination);
// Get audio data from buffer
const leftData = audioBuffer.getChannelData(0);
const rightData = channels > 1 ? audioBuffer.getChannelData(1) : leftData;
const left = floatToInt16(leftData);
const right = floatToInt16(rightData);
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
const frameSize = 1152;
for (let i = 0; i < left.length; i += frameSize) {
const l = left.subarray(i, i + frameSize);
const r = right.subarray(i, i + frameSize);
const buf = encoder.encodeBuffer(l, r);
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();
// Encode in 1152-sample frames
const frameSize = 1152;
for (let i = 0; i < left.length; i += frameSize) {
const l = left.subarray(i, i + frameSize);
const r = right.subarray(i, i + frameSize);
const buf = encoder.encodeBuffer(l, r);
if (buf && buf.length) mp3Chunks.push(buf);
}
const tail = encoder.flush();
if (tail && tail.length) mp3Chunks.push(tail);
@@ -767,6 +833,7 @@
window.setTimeout(() => (shareHint.textContent = ''), 1400);
} catch (e) {
shareHint.textContent = `MP3 failed: ${e && e.message ? e.message : 'Unknown error'}`;
console.error('MP3 download error:', e);
} finally {
downloadMp3Btn.disabled = false;
}