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:
@@ -1,4 +1,4 @@
|
||||
# Wario Synthesis Engine 16-Bit Midi
|
||||
# Wario Synthesis Engine 8-Bit Midi
|
||||
|
||||

|
||||
|
||||
|
||||
+3
-3
@@ -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,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" />
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user