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

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
-