From 2492ab833a927462838213882f3392bb1358fe99 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:32:27 +0000 Subject: [PATCH] =?UTF-8?q?Add=20offline=20MP3=20rendering,=20Game=20Boy?= =?UTF-8?q?=20styling=20for=20UX=20test,=20fix=2016-bit=20=E2=86=92=208-bi?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 2 +- index.html | 6 +- play.html | 6 +- server/src/server.ts | 2 +- src/core/MotifEngine.ts | 43 +++ src/synthesis/SynthesisEngine.ts | 227 +++++++++++++ ux-test.html | 551 +++++++++++++++++-------------- 7 files changed, 587 insertions(+), 250 deletions(-) diff --git a/README.md b/README.md index 41ac770..34e22a2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Wario Synthesis Engine 16-Bit Midi +# Wario Synthesis Engine 8-Bit Midi ![Wario Synth Logo](public/wariosynthlogo.png) diff --git a/index.html b/index.html index bfd82db..3f2d19a 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,10 @@ - Wario Synthesis Engine 16-Bit Midi + Wario Synthesis Engine 8-Bit Midi - + @@ -16,7 +16,7 @@ - + diff --git a/play.html b/play.html index 81ce467..20e93ef 100644 --- a/play.html +++ b/play.html @@ -3,11 +3,11 @@ - Wario Synthesis Engine 16-Bit Midi + Wario Synthesis Engine 8-Bit Midi - + @@ -17,7 +17,7 @@ - + diff --git a/server/src/server.ts b/server/src/server.ts index fba325b..0e3cf86 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -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'; diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts index 1347a25..962f1e5 100644 --- a/src/core/MotifEngine.ts +++ b/src/core/MotifEngine.ts @@ -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 { + 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); diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts index a9fe55c..65277c3 100644 --- a/src/synthesis/SynthesisEngine.ts +++ b/src/synthesis/SynthesisEngine.ts @@ -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 { + // 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); + } + } } \ No newline at end of file diff --git a/ux-test.html b/ux-test.html index c652256..c8b57d7 100644 --- a/ux-test.html +++ b/ux-test.html @@ -2,140 +2,201 @@ - + WARIO SYNTH - UX Test
-
+

WARIO SYNTH

-

Turn any song into retro game console music

-
+

Turn any song into Game Boy music

+ UX Test Page +
Search for a song to get started
@@ -429,9 +523,13 @@ - + + +