Revert "Add test models page and synth presets."
This reverts commit b7a79e93c9.
This commit is contained in:
+2
-10
@@ -84,13 +84,8 @@
|
|||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.topbar-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.faq-link {
|
.faq-link {
|
||||||
|
margin-left: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
color: rgba(255,255,255,0.85);
|
color: rgba(255,255,255,0.85);
|
||||||
@@ -825,10 +820,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h1>🎵 MOTIF</h1>
|
<h1>🎵 MOTIF</h1>
|
||||||
<div class="topbar-actions">
|
<button id="faqBtn" class="faq-link" type="button">FAQ</button>
|
||||||
<a class="faq-link" href="/models" style="text-decoration:none;" aria-label="Open test models page">Models</a>
|
|
||||||
<button id="faqBtn" class="faq-link" type="button">FAQ</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p>Procedural Music Synthesis from MIDI Structure</p>
|
<p>Procedural Music Synthesis from MIDI Structure</p>
|
||||||
|
|
||||||
|
|||||||
-258
@@ -1,258 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
||||||
<title>MOTIF — Test models</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--color-bg: #0b0b0f;
|
|
||||||
--color-surface: rgba(255, 255, 255, 0.06);
|
|
||||||
--color-surface-2: rgba(255, 255, 255, 0.09);
|
|
||||||
--color-border: rgba(255, 255, 255, 0.12);
|
|
||||||
--color-text: rgba(255, 255, 255, 0.9);
|
|
||||||
--color-text-muted: rgba(255, 255, 255, 0.7);
|
|
||||||
--color-accent-primary: #00ff88;
|
|
||||||
--color-accent-secondary: #ff3cf5;
|
|
||||||
--radius: 12px;
|
|
||||||
--shadow-sm: 0 12px 34px rgba(0,0,0,0.45);
|
|
||||||
--glow-primary: 0 0 18px rgba(0, 255, 136, 0.18);
|
|
||||||
--spacing-unit: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: radial-gradient(1000px 600px at 20% 10%, rgba(255,60,245,0.10), transparent 55%),
|
|
||||||
radial-gradient(900px 500px at 80% 20%, rgba(0,255,136,0.09), transparent 50%),
|
|
||||||
linear-gradient(180deg, #07070a, #0b0b0f 60%, #060609);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
margin: 0;
|
|
||||||
padding: calc(var(--spacing-unit) * 6) calc(var(--spacing-unit) * 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container { max-width: 960px; margin: 0 auto; }
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: var(--color-accent-primary);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0 0 calc(var(--spacing-unit) * 4) 0;
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-shadow: var(--glow-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: calc(var(--spacing-unit) * 2);
|
|
||||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
|
||||||
}
|
|
||||||
.topbar h1 { margin: 0; }
|
|
||||||
.topbar-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.nav-link {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
color: rgba(255,255,255,0.85);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: none;
|
|
||||||
font-size: 13px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.nav-link:hover { box-shadow: var(--shadow-sm); border-color: rgba(255,255,255,0.22); }
|
|
||||||
|
|
||||||
.controls { margin: calc(var(--spacing-unit) * 3) 0; text-align: center; }
|
|
||||||
input[type="text"] {
|
|
||||||
width: min(600px, 100%);
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: rgba(0,0,0,0.28);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
border-radius: 10px;
|
|
||||||
color: var(--color-text);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
color: rgba(255,255,255,0.86);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button.primary {
|
|
||||||
background: linear-gradient(90deg, rgba(0,255,136,0.18), rgba(255,60,245,0.10));
|
|
||||||
border-color: rgba(0,255,136,0.28);
|
|
||||||
}
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
#status {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.10);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.20);
|
|
||||||
color: rgba(255,255,255,0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section {
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
border-radius: 18px;
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
padding: 14px;
|
|
||||||
box-shadow: 0 26px 90px rgba(0,0,0,0.35);
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.section-header { display:flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
|
||||||
.section-title { margin: 0; font-size: 18px; }
|
|
||||||
.section-subtitle { margin: 0; color: rgba(255,255,255,0.65); font-size: 13px; }
|
|
||||||
.divider { height: 1px; background: rgba(255,255,255,0.08); margin: 12px 0; }
|
|
||||||
|
|
||||||
.results-section { display: none; }
|
|
||||||
.results-section.visible { display: block; }
|
|
||||||
.results-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.results-table th, .results-table td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
|
|
||||||
.results-table tr.selected { background: rgba(0,255,136,0.06); }
|
|
||||||
|
|
||||||
.player-section { display: none; }
|
|
||||||
.player-section.visible { display: block; }
|
|
||||||
|
|
||||||
.preview-controls, .motif-controls { display:flex; gap: 10px; align-items:center; flex-wrap: wrap; }
|
|
||||||
.volume-control { display:flex; align-items:center; gap: 8px; }
|
|
||||||
.volume-control label { color: rgba(255,255,255,0.65); font-size: 12px; }
|
|
||||||
input[type="range"] { width: 160px; }
|
|
||||||
|
|
||||||
.model-row { display:flex; align-items:center; gap: 10px; flex-wrap: wrap; }
|
|
||||||
.model-row label { color: rgba(255,255,255,0.7); font-size: 12px; }
|
|
||||||
select {
|
|
||||||
background: rgba(0,0,0,0.28);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ios-audio-banner {
|
|
||||||
margin-top: 10px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.10);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
display:none;
|
|
||||||
}
|
|
||||||
.ios-audio-banner .row { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
|
|
||||||
.ios-audio-banner .state { margin-top: 6px; font-size: 12px; color: rgba(255,255,255,0.65); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="topbar">
|
|
||||||
<h1>🎵 MOTIF</h1>
|
|
||||||
<div class="topbar-actions">
|
|
||||||
<a class="nav-link" href="/" aria-label="Back to main app">Home</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style="text-align:center; color: rgba(255,255,255,0.7); margin-top: 0;">Test models: Pre‑8bit → NES/GB → SNES‑ish</p>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<input type="text" id="songInput" placeholder="Enter song name..." />
|
|
||||||
<button id="searchBtn">Search</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="status">Ready. Enter a song name to search for MIDI files.</div>
|
|
||||||
|
|
||||||
<div id="resultsSection" class="section results-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3 class="section-title">Search Results</h3>
|
|
||||||
<p class="section-subtitle">Pick a MIDI, then compare models.</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
<table id="resultsTable" class="results-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="resultsBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="playerSection" class="player-section">
|
|
||||||
<div id="selectedInfo" class="section">
|
|
||||||
<div style="display:flex; flex-direction:column; gap: 6px;">
|
|
||||||
<h4 id="selectedTitle" style="margin:0;">No MIDI selected</h4>
|
|
||||||
<p id="selectedMeta" style="margin:0; color: rgba(255,255,255,0.7); font-size: 13px;">Select a MIDI file from search results to enable playback</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3 class="section-title">Preview</h3>
|
|
||||||
<p class="section-subtitle">Soundfont MIDI preview (baseline).</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
<div class="preview-controls">
|
|
||||||
<button id="soundfontPlayBtn" class="primary" disabled>Play Preview</button>
|
|
||||||
<button id="soundfontStopBtn" disabled>Stop</button>
|
|
||||||
<div class="volume-control">
|
|
||||||
<label for="soundfontVolume">Volume</label>
|
|
||||||
<input type="range" id="soundfontVolume" min="0" max="1" step="0.01" value="0.8" />
|
|
||||||
</div>
|
|
||||||
<button id="nextResultBtn" disabled>Try Next Result</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3 class="section-title">Motif generation</h3>
|
|
||||||
<p class="section-subtitle">Same MIDI → different synth models.</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="model-row">
|
|
||||||
<label for="modelSelect">Model</label>
|
|
||||||
<select id="modelSelect">
|
|
||||||
<option value="pre8bit">Pre‑8bit (beep / sparse)</option>
|
|
||||||
<option value="nes_gb" selected>NES / Game Boy (chip)</option>
|
|
||||||
<option value="snes_ish">SNES‑ish (warm + echo)</option>
|
|
||||||
</select>
|
|
||||||
<span id="modelHint" style="color: rgba(255,255,255,0.65); font-size: 12px;">Square/triangle/noise vibe, tight envelope.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<div class="motif-controls">
|
|
||||||
<button id="motifBtn" class="primary" disabled>Generate & Play</button>
|
|
||||||
<button id="motifStopBtn" disabled>Stop</button>
|
|
||||||
<div class="volume-control">
|
|
||||||
<label for="motifVolume">Volume</label>
|
|
||||||
<input type="range" id="motifVolume" min="0" max="1" step="0.01" value="0.3" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="iosAudioBanner" class="ios-audio-banner" aria-live="polite">
|
|
||||||
<div class="row">
|
|
||||||
<div class="copy" style="color: rgba(255,255,255,0.8); font-size: 13px;">
|
|
||||||
<strong>iOS Safari:</strong> tap once to enable audio, then press <strong>Generate & Play</strong>.
|
|
||||||
</div>
|
|
||||||
<button id="enableAudioBtn" type="button">Enable Audio</button>
|
|
||||||
</div>
|
|
||||||
<div id="iosAudioState" class="state">Audio: …</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="/src/models.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { NoteEvent, MotifConfig, SynthModel } from '../types';
|
import type { NoteEvent, MotifConfig } from '../types';
|
||||||
import { MIDIProcessor } from '../midi/MIDIProcessor';
|
import { MIDIProcessor } from '../midi/MIDIProcessor';
|
||||||
import { MIDIParser } from '../midi/MIDIParser';
|
import { MIDIParser } from '../midi/MIDIParser';
|
||||||
import { MIDIService } from '../services/MIDIService';
|
import { MIDIService } from '../services/MIDIService';
|
||||||
@@ -27,17 +27,13 @@ export class MotifEngine {
|
|||||||
this.roleMapper = new RoleMapper();
|
this.roleMapper = new RoleMapper();
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateFromMIDI(
|
async generateFromMIDI(events: NoteEvent[], transformMode: 'passthrough' | 'procedural' = 'passthrough'): Promise<void> {
|
||||||
events: NoteEvent[],
|
|
||||||
transformMode: 'passthrough' | 'procedural' = 'passthrough',
|
|
||||||
model: SynthModel = 'nes_gb'
|
|
||||||
): Promise<void> {
|
|
||||||
// Initialize audio context using shared unlock (iOS compatibility)
|
// Initialize audio context using shared unlock (iOS compatibility)
|
||||||
if (!this.audioContext) {
|
if (!this.audioContext) {
|
||||||
this.audioContext = await unlockAudio();
|
this.audioContext = await unlockAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, model);
|
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
||||||
|
|
||||||
if (transformMode === 'passthrough') {
|
if (transformMode === 'passthrough') {
|
||||||
// Direct playback mode - play MIDI as-is without transformations
|
// Direct playback mode - play MIDI as-is without transformations
|
||||||
@@ -111,7 +107,7 @@ export class MotifEngine {
|
|||||||
this.audioContext = await unlockAudio();
|
this.audioContext = await unlockAudio();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config, 'nes_gb');
|
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
|
||||||
this.synthesisEngine.setupLayers(roleAssignments);
|
this.synthesisEngine.setupLayers(roleAssignments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-378
@@ -1,378 +0,0 @@
|
|||||||
import { MotifEngine } from './core/MotifEngine';
|
|
||||||
import { MIDIService } from './services/MIDIService';
|
|
||||||
import { MIDIParser } from './midi/MIDIParser';
|
|
||||||
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
|
||||||
import { getAudioContext, isAudioReady, unlockAudio } from './utils/audioUnlock';
|
|
||||||
import type { NoteEvent, SynthModel } from './types';
|
|
||||||
|
|
||||||
class ModelsApp {
|
|
||||||
private motifEngine: MotifEngine;
|
|
||||||
private midiService: MIDIService;
|
|
||||||
|
|
||||||
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
|
|
||||||
|
|
||||||
private searchBtn!: HTMLButtonElement;
|
|
||||||
private songInput!: HTMLInputElement;
|
|
||||||
private status!: HTMLElement;
|
|
||||||
|
|
||||||
private resultsSection!: HTMLElement;
|
|
||||||
private resultsBody!: HTMLElement;
|
|
||||||
private playerSection!: HTMLElement;
|
|
||||||
|
|
||||||
private selectedTitle!: HTMLElement;
|
|
||||||
private selectedMeta!: HTMLElement;
|
|
||||||
|
|
||||||
private soundfontPlayBtn!: HTMLButtonElement;
|
|
||||||
private soundfontStopBtn!: HTMLButtonElement;
|
|
||||||
private soundfontVolumeSlider!: HTMLInputElement;
|
|
||||||
|
|
||||||
private motifBtn!: HTMLButtonElement;
|
|
||||||
private motifStopBtn!: HTMLButtonElement;
|
|
||||||
private motifVolumeSlider!: HTMLInputElement;
|
|
||||||
|
|
||||||
private modelSelect!: HTMLSelectElement;
|
|
||||||
private modelHint!: HTMLElement;
|
|
||||||
|
|
||||||
private iosAudioBanner!: HTMLElement;
|
|
||||||
private enableAudioBtn!: HTMLButtonElement;
|
|
||||||
private iosAudioState!: HTMLElement;
|
|
||||||
|
|
||||||
private nextResultBtn!: HTMLButtonElement;
|
|
||||||
|
|
||||||
private searchResults: any[] = [];
|
|
||||||
private selectedResultIndex = 0;
|
|
||||||
private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.motifEngine = new MotifEngine();
|
|
||||||
this.midiService = new MIDIService();
|
|
||||||
this.initializeUI();
|
|
||||||
this.setupEventListeners();
|
|
||||||
this.syncModelHint();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
|
|
||||||
const audioContext = await unlockAudio();
|
|
||||||
if (!this.soundfontPlayer) {
|
|
||||||
this.soundfontPlayer = new SoundfontMIDIPlayer(audioContext);
|
|
||||||
}
|
|
||||||
return this.soundfontPlayer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initializeUI(): void {
|
|
||||||
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
|
||||||
this.songInput = document.getElementById('songInput') as HTMLInputElement;
|
|
||||||
this.status = document.getElementById('status')!;
|
|
||||||
|
|
||||||
this.resultsSection = document.getElementById('resultsSection')!;
|
|
||||||
this.resultsBody = document.getElementById('resultsBody')!;
|
|
||||||
this.playerSection = document.getElementById('playerSection')!;
|
|
||||||
|
|
||||||
this.selectedTitle = document.getElementById('selectedTitle')!;
|
|
||||||
this.selectedMeta = document.getElementById('selectedMeta')!;
|
|
||||||
|
|
||||||
this.soundfontPlayBtn = document.getElementById('soundfontPlayBtn') as HTMLButtonElement;
|
|
||||||
this.soundfontStopBtn = document.getElementById('soundfontStopBtn') as HTMLButtonElement;
|
|
||||||
this.soundfontVolumeSlider = document.getElementById('soundfontVolume') as HTMLInputElement;
|
|
||||||
|
|
||||||
this.motifBtn = document.getElementById('motifBtn') as HTMLButtonElement;
|
|
||||||
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
|
||||||
this.motifVolumeSlider = document.getElementById('motifVolume') as HTMLInputElement;
|
|
||||||
|
|
||||||
this.modelSelect = document.getElementById('modelSelect') as HTMLSelectElement;
|
|
||||||
this.modelHint = document.getElementById('modelHint')!;
|
|
||||||
|
|
||||||
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
|
|
||||||
|
|
||||||
this.iosAudioBanner = document.getElementById('iosAudioBanner')!;
|
|
||||||
this.enableAudioBtn = document.getElementById('enableAudioBtn') as HTMLButtonElement;
|
|
||||||
this.iosAudioState = document.getElementById('iosAudioState')!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupEventListeners(): void {
|
|
||||||
this.searchBtn.addEventListener('click', () => void this.handleSearch());
|
|
||||||
this.songInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') void this.handleSearch();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.soundfontPlayBtn.addEventListener('click', () => void this.handleSoundfontPlay());
|
|
||||||
this.soundfontStopBtn.addEventListener('click', () => this.handleSoundfontStop());
|
|
||||||
this.soundfontVolumeSlider.addEventListener('input', (e) => {
|
|
||||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
|
||||||
this.soundfontPlayer?.setVolume(volume);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.motifBtn.addEventListener('click', () => void this.handleMotif());
|
|
||||||
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
|
||||||
this.motifVolumeSlider.addEventListener('input', (e) => {
|
|
||||||
const volume = parseFloat((e.target as HTMLInputElement).value);
|
|
||||||
this.motifEngine.setVolume(volume);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.modelSelect.addEventListener('change', () => this.syncModelHint());
|
|
||||||
|
|
||||||
this.nextResultBtn.addEventListener('click', () => this.handleNextResult());
|
|
||||||
|
|
||||||
const enable = () => void this.handleEnableAudio();
|
|
||||||
this.enableAudioBtn.addEventListener('click', enable);
|
|
||||||
this.enableAudioBtn.addEventListener('touchend', enable, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
private getModel(): SynthModel {
|
|
||||||
const v = (this.modelSelect.value || 'nes_gb') as SynthModel;
|
|
||||||
if (v === 'pre8bit' || v === 'nes_gb' || v === 'snes_ish') return v;
|
|
||||||
return 'nes_gb';
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncModelHint(): void {
|
|
||||||
const model = this.getModel();
|
|
||||||
const hint =
|
|
||||||
model === 'pre8bit'
|
|
||||||
? 'Ultra-sparse: mostly square/triangle, short gates, minimal polyphony.'
|
|
||||||
: model === 'snes_ish'
|
|
||||||
? 'Warmer: gentler filter + echo. More voices, smoother release.'
|
|
||||||
: 'Chip: square/triangle/saw flavor, tight envelope, crisp attacks.';
|
|
||||||
|
|
||||||
this.modelHint.textContent = hint;
|
|
||||||
}
|
|
||||||
|
|
||||||
private isIOSLike(): boolean {
|
|
||||||
const ua = navigator.userAgent || '';
|
|
||||||
const iOS = /iPad|iPhone|iPod/.test(ua);
|
|
||||||
const iPadOS13Plus = /Macintosh/.test(ua) && (navigator as any).maxTouchPoints > 1;
|
|
||||||
return iOS || iPadOS13Plus;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateIOSAudioBanner(): void {
|
|
||||||
if (!this.isIOSLike()) {
|
|
||||||
this.iosAudioBanner.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = isAudioReady();
|
|
||||||
this.iosAudioBanner.style.display = ready ? 'none' : 'block';
|
|
||||||
|
|
||||||
const ctx = (() => {
|
|
||||||
try { return getAudioContext(); } catch { return null; }
|
|
||||||
})();
|
|
||||||
if (!ready && ctx) {
|
|
||||||
this.iosAudioState.style.display = 'block';
|
|
||||||
this.iosAudioState.textContent = `Audio: ${ctx.state} @ ${ctx.sampleRate}Hz`;
|
|
||||||
} else {
|
|
||||||
this.iosAudioState.style.display = 'none';
|
|
||||||
this.iosAudioState.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleEnableAudio(): Promise<void> {
|
|
||||||
try {
|
|
||||||
this.enableAudioBtn.disabled = true;
|
|
||||||
this.iosAudioState.style.display = 'block';
|
|
||||||
this.iosAudioState.textContent = 'Audio: enabling…';
|
|
||||||
|
|
||||||
await unlockAudio();
|
|
||||||
|
|
||||||
const ctx = getAudioContext();
|
|
||||||
if (ctx.state !== 'running') {
|
|
||||||
this.enableAudioBtn.disabled = false;
|
|
||||||
this.iosAudioState.textContent = 'Audio still locked. Tap Enable Audio again.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.iosAudioState.textContent = `Audio: running @ ${ctx.sampleRate}Hz`;
|
|
||||||
window.setTimeout(() => this.updateIOSAudioBanner(), 250);
|
|
||||||
} catch {
|
|
||||||
this.enableAudioBtn.disabled = false;
|
|
||||||
this.iosAudioState.style.display = 'block';
|
|
||||||
this.iosAudioState.textContent = 'Audio enable failed. Tap again, or disable Silent Mode.';
|
|
||||||
} finally {
|
|
||||||
this.enableAudioBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateStatus(message: string): void {
|
|
||||||
this.status.textContent = message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private hideResults(): void {
|
|
||||||
this.resultsSection.classList.remove('visible');
|
|
||||||
this.playerSection.classList.remove('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
private enablePlayerControls(): void {
|
|
||||||
this.soundfontPlayBtn.disabled = false;
|
|
||||||
this.motifBtn.disabled = false;
|
|
||||||
this.nextResultBtn.disabled = this.searchResults.length <= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private disablePlayerControls(): void {
|
|
||||||
this.soundfontPlayBtn.disabled = true;
|
|
||||||
this.soundfontStopBtn.disabled = true;
|
|
||||||
this.motifBtn.disabled = true;
|
|
||||||
this.motifStopBtn.disabled = true;
|
|
||||||
this.nextResultBtn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSearch(): Promise<void> {
|
|
||||||
const songName = this.songInput.value.trim();
|
|
||||||
if (!songName) return;
|
|
||||||
|
|
||||||
this.handleMotifStop();
|
|
||||||
this.updateStatus('Searching for MIDI files...');
|
|
||||||
this.searchBtn.disabled = true;
|
|
||||||
this.hideResults();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await this.midiService.search(songName);
|
|
||||||
if (results.length === 0) {
|
|
||||||
this.updateStatus('No MIDI files found. Try a different search.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.searchResults = results;
|
|
||||||
this.selectedResultIndex = 0;
|
|
||||||
|
|
||||||
this.updateStatus('Analyzing MIDI files...');
|
|
||||||
for (let i = 0; i < Math.min(results.length, 3); i++) {
|
|
||||||
const metadata = await this.midiService.parseMIDI(results[i].midiUrl);
|
|
||||||
if (metadata) results[i].parsed = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.displayResults();
|
|
||||||
this.updateStatus(`Found ${results.length} MIDI files. Select one to compare models.`);
|
|
||||||
this.updateIOSAudioBanner();
|
|
||||||
} catch (error) {
|
|
||||||
this.updateStatus(`Search error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
} finally {
|
|
||||||
this.searchBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private displayResults(): void {
|
|
||||||
this.resultsBody.innerHTML = '';
|
|
||||||
|
|
||||||
this.searchResults.forEach((result, index) => {
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
if (index === this.selectedResultIndex) row.classList.add('selected');
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td>${result.title}</td>
|
|
||||||
<td>${result.source}</td>
|
|
||||||
<td>${result.parsed ? Math.round(result.parsed.durationSec) + 's' : '?'}</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.addEventListener('click', () => void this.selectResult(index));
|
|
||||||
this.resultsBody.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.resultsSection.classList.add('visible');
|
|
||||||
if (this.searchResults.length > 0) void this.selectResult(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async selectResult(index: number): Promise<void> {
|
|
||||||
if (index < 0 || index >= this.searchResults.length) return;
|
|
||||||
|
|
||||||
this.handleMotifStop();
|
|
||||||
|
|
||||||
this.selectedResultIndex = index;
|
|
||||||
const result = this.searchResults[index];
|
|
||||||
|
|
||||||
const rows = this.resultsBody.querySelectorAll('tr');
|
|
||||||
rows.forEach((row, i) => row.classList.toggle('selected', i === index));
|
|
||||||
|
|
||||||
this.updateStatus('Loading MIDI file...');
|
|
||||||
this.disablePlayerControls();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const midiBuffer = await this.midiService.fetchMIDI(result.midiUrl);
|
|
||||||
if (!midiBuffer) throw new Error('Failed to fetch MIDI file');
|
|
||||||
|
|
||||||
const events = MIDIParser.parseMIDI(midiBuffer);
|
|
||||||
const metadata = result.parsed || MIDIParser.getMIDIInfo(midiBuffer);
|
|
||||||
|
|
||||||
let actualDuration = metadata.duration || metadata.durationSec || 0;
|
|
||||||
if (actualDuration === 0 && events.length > 0) {
|
|
||||||
actualDuration = Math.max(...events.map(e => e.time + e.duration));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentMIDI = { events, metadata: { ...metadata, duration: actualDuration } };
|
|
||||||
|
|
||||||
const player = await this.ensureAudioReady();
|
|
||||||
await player.load(events);
|
|
||||||
|
|
||||||
this.selectedTitle.textContent = result.title;
|
|
||||||
this.selectedMeta.innerHTML = `
|
|
||||||
<strong>Source:</strong> ${result.source} |
|
|
||||||
<strong>Duration:</strong> ${Math.round(actualDuration)}s |
|
|
||||||
<strong>Tracks:</strong> ${metadata.trackCount} |
|
|
||||||
<strong>Notes:</strong> ${events.length} |
|
|
||||||
<strong>Tempo:</strong> ${metadata.tempo}bpm
|
|
||||||
`;
|
|
||||||
|
|
||||||
this.updateIOSAudioBanner();
|
|
||||||
this.playerSection.classList.add('visible');
|
|
||||||
this.enablePlayerControls();
|
|
||||||
this.updateStatus('MIDI loaded. Preview, then switch models and generate.');
|
|
||||||
} catch (error) {
|
|
||||||
this.updateStatus(`Load error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSoundfontPlay(): Promise<void> {
|
|
||||||
if (!this.currentMIDI) return;
|
|
||||||
try {
|
|
||||||
const player = await this.ensureAudioReady();
|
|
||||||
await player.play();
|
|
||||||
this.soundfontPlayBtn.disabled = true;
|
|
||||||
this.soundfontStopBtn.disabled = false;
|
|
||||||
this.updateStatus('Previewing MIDI...');
|
|
||||||
} catch (error) {
|
|
||||||
this.updateStatus(`Preview error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
this.updateIOSAudioBanner();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleSoundfontStop(): void {
|
|
||||||
this.soundfontPlayer?.stop();
|
|
||||||
this.soundfontPlayBtn.disabled = false;
|
|
||||||
this.soundfontStopBtn.disabled = true;
|
|
||||||
this.updateStatus('Preview stopped.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMotif(): Promise<void> {
|
|
||||||
if (!this.currentMIDI) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await unlockAudio();
|
|
||||||
this.updateIOSAudioBanner();
|
|
||||||
|
|
||||||
const model = this.getModel();
|
|
||||||
this.updateStatus(`Generating Motif (${model})...`);
|
|
||||||
this.motifBtn.disabled = true;
|
|
||||||
|
|
||||||
await this.motifEngine.generateFromMIDI(this.currentMIDI.events, 'procedural', model);
|
|
||||||
await this.motifEngine.play();
|
|
||||||
|
|
||||||
this.motifStopBtn.disabled = false;
|
|
||||||
this.updateStatus(`Playing Motif (${model})...`);
|
|
||||||
} catch (error) {
|
|
||||||
this.updateStatus(`Motif error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
this.motifBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMotifStop(): void {
|
|
||||||
this.motifEngine.stop();
|
|
||||||
this.motifBtn.disabled = false;
|
|
||||||
this.motifStopBtn.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNextResult(): void {
|
|
||||||
const nextIndex = (this.selectedResultIndex + 1) % this.searchResults.length;
|
|
||||||
void this.selectResult(nextIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = new ModelsApp();
|
|
||||||
(window as any).modelsApp = app;
|
|
||||||
|
|
||||||
@@ -1,52 +1,31 @@
|
|||||||
import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent, SynthModel } from '../types';
|
import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } from '../types';
|
||||||
|
|
||||||
export class SynthesisEngine {
|
export class SynthesisEngine {
|
||||||
private audioContext: AudioContext;
|
private audioContext: AudioContext;
|
||||||
private config: MotifConfig;
|
private config: MotifConfig;
|
||||||
private masterGain: GainNode;
|
private masterGain: GainNode;
|
||||||
private postGain: GainNode;
|
|
||||||
private model: SynthModel;
|
|
||||||
private layers: Map<Role, SynthLayer> = new Map();
|
private layers: Map<Role, SynthLayer> = new Map();
|
||||||
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
private schedulerIntervalId: number | null = null;
|
private schedulerIntervalId: number | null = null;
|
||||||
private startTime = 0;
|
private startTime = 0;
|
||||||
private nextEventIndex = new Map<Role, number>();
|
private nextEventIndex = new Map<Role, number>();
|
||||||
private activeVoiceCount = 0;
|
|
||||||
private maxVoices: number;
|
|
||||||
private effectCleanup: (() => void) | null = null;
|
|
||||||
|
|
||||||
constructor(audioContext: AudioContext, config: MotifConfig, model: SynthModel = 'nes_gb') {
|
constructor(audioContext: AudioContext, config: MotifConfig) {
|
||||||
this.audioContext = audioContext;
|
this.audioContext = audioContext;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.masterGain = audioContext.createGain();
|
this.masterGain = audioContext.createGain();
|
||||||
this.postGain = audioContext.createGain();
|
this.masterGain.connect(audioContext.destination);
|
||||||
this.postGain.connect(audioContext.destination);
|
|
||||||
|
|
||||||
this.model = model;
|
|
||||||
this.maxVoices = this.getMaxVoicesForModel(model, config.maxOscillators);
|
|
||||||
|
|
||||||
// Default overall level
|
|
||||||
this.masterGain.gain.value = 0.3;
|
this.masterGain.gain.value = 0.3;
|
||||||
this.postGain.gain.value = 1.0;
|
|
||||||
|
|
||||||
// Route + optional effects
|
|
||||||
this.effectCleanup = this.configureMasterChain(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupLayers(assignments: RoleAssignment[]): void {
|
setupLayers(assignments: RoleAssignment[]): void {
|
||||||
// Clean up existing layers
|
// Clean up existing layers
|
||||||
this.cleanupLayers();
|
this.cleanupLayers();
|
||||||
|
|
||||||
// Reset voice budgeting per setup (important when switching models)
|
|
||||||
this.activeVoiceCount = 0;
|
|
||||||
|
|
||||||
// Apply model role filtering
|
|
||||||
const filteredAssignments = this.filterAssignmentsForModel(assignments);
|
|
||||||
|
|
||||||
// Find the earliest event time across all assignments
|
// Find the earliest event time across all assignments
|
||||||
let earliestTime = Infinity;
|
let earliestTime = Infinity;
|
||||||
for (const assignment of filteredAssignments) {
|
for (const assignment of assignments) {
|
||||||
if (assignment.events.length > 0) {
|
if (assignment.events.length > 0) {
|
||||||
earliestTime = Math.min(earliestTime, assignment.events[0].time);
|
earliestTime = Math.min(earliestTime, assignment.events[0].time);
|
||||||
}
|
}
|
||||||
@@ -58,7 +37,7 @@ export class SynthesisEngine {
|
|||||||
// If we found events, normalize times to start at 0
|
// If we found events, normalize times to start at 0
|
||||||
if (earliestTime !== Infinity && earliestTime > 0) {
|
if (earliestTime !== Infinity && earliestTime > 0) {
|
||||||
console.log('Normalizing event times, earliest was:', earliestTime);
|
console.log('Normalizing event times, earliest was:', earliestTime);
|
||||||
for (const assignment of filteredAssignments) {
|
for (const assignment of assignments) {
|
||||||
// Normalize note events
|
// Normalize note events
|
||||||
for (const event of assignment.events) {
|
for (const event of assignment.events) {
|
||||||
event.time -= earliestTime;
|
event.time -= earliestTime;
|
||||||
@@ -71,7 +50,7 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store role assignments and create layers
|
// Store role assignments and create layers
|
||||||
for (const assignment of filteredAssignments) {
|
for (const assignment of assignments) {
|
||||||
const layer = this.createSynthLayer(assignment.role);
|
const layer = this.createSynthLayer(assignment.role);
|
||||||
this.layers.set(assignment.role, layer);
|
this.layers.set(assignment.role, layer);
|
||||||
this.roleAssignments.set(assignment.role, assignment);
|
this.roleAssignments.set(assignment.role, assignment);
|
||||||
@@ -196,62 +175,37 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
|
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
|
||||||
// Default per-model filter shaping (keeps presets recognizable)
|
|
||||||
if (this.model === 'pre8bit') {
|
|
||||||
// Very bright, very simple
|
|
||||||
filter.type = 'lowpass';
|
|
||||||
filter.frequency.value = 6000;
|
|
||||||
filter.Q.value = 0.8;
|
|
||||||
} else if (this.model === 'snes_ish') {
|
|
||||||
// Warmer, a little more body
|
|
||||||
filter.type = 'lowpass';
|
|
||||||
filter.frequency.value = 2400;
|
|
||||||
filter.Q.value = 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'bass':
|
case 'bass':
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.5 : 0.4;
|
gain.gain.value = 0.4;
|
||||||
if (this.model !== 'pre8bit') {
|
filter.type = 'lowpass';
|
||||||
filter.type = 'lowpass';
|
filter.frequency.value = 200;
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 260 : 200;
|
|
||||||
} else {
|
|
||||||
filter.frequency.value = 350;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'drone':
|
case 'drone':
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2;
|
gain.gain.value = 0.2;
|
||||||
if (this.model !== 'pre8bit') {
|
filter.type = 'bandpass';
|
||||||
filter.type = 'bandpass';
|
filter.frequency.value = 400;
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 520 : 400;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'ostinato':
|
case 'ostinato':
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3;
|
gain.gain.value = 0.3;
|
||||||
if (this.model !== 'pre8bit') {
|
filter.type = 'highpass';
|
||||||
filter.type = 'highpass';
|
filter.frequency.value = 300;
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 220 : 300;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'texture':
|
case 'texture':
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1;
|
gain.gain.value = 0.1;
|
||||||
if (this.model !== 'pre8bit') {
|
filter.type = 'bandpass';
|
||||||
filter.type = 'bandpass';
|
filter.frequency.value = 800;
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 900 : 800;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'accents':
|
case 'accents':
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5;
|
gain.gain.value = 0.5;
|
||||||
if (this.model !== 'pre8bit') {
|
filter.type = 'peaking';
|
||||||
filter.type = 'peaking';
|
filter.frequency.value = 1000;
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 1200 : 1000;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'melody':
|
case 'melody':
|
||||||
// Passthrough mode - balanced sound for all notes
|
// Passthrough mode - balanced sound for all notes
|
||||||
gain.gain.value = this.model === 'pre8bit' ? 0.4 : 0.35;
|
gain.gain.value = 0.35;
|
||||||
filter.type = 'lowpass';
|
filter.type = 'lowpass';
|
||||||
filter.frequency.value = this.model === 'snes_ish' ? 3200 : 4000; // Brighter for chip, warmer for SNES-ish
|
filter.frequency.value = 4000; // Brighter sound for full range
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,13 +217,6 @@ export class SynthesisEngine {
|
|||||||
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
|
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
|
||||||
const layer = this.layers.get(role);
|
const layer = this.layers.get(role);
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
// Model gating: pre8bit runs intentionally sparse
|
|
||||||
if (this.model === 'pre8bit') {
|
|
||||||
if (role !== 'bass' && role !== 'melody') return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeVoiceCount >= this.maxVoices) return;
|
|
||||||
|
|
||||||
const osc = this.audioContext.createOscillator();
|
const osc = this.audioContext.createOscillator();
|
||||||
const envelope = this.audioContext.createGain();
|
const envelope = this.audioContext.createGain();
|
||||||
@@ -278,50 +225,33 @@ export class SynthesisEngine {
|
|||||||
const frequency = this.midiToFrequency(pitch);
|
const frequency = this.midiToFrequency(pitch);
|
||||||
osc.frequency.value = frequency;
|
osc.frequency.value = frequency;
|
||||||
|
|
||||||
// Choose oscillator type based on model + role
|
// Choose oscillator type based on role
|
||||||
if (this.model === 'pre8bit') {
|
switch (role) {
|
||||||
osc.type = role === 'bass' ? 'triangle' : 'square';
|
case 'bass':
|
||||||
} else if (this.model === 'snes_ish') {
|
osc.type = 'square';
|
||||||
// Warmer “sample-ish” feel: richer waveforms + filtering + echo on master
|
break;
|
||||||
if (role === 'bass') osc.type = 'triangle';
|
case 'drone':
|
||||||
else if (role === 'drone') osc.type = 'sawtooth';
|
osc.type = 'sawtooth';
|
||||||
else if (role === 'ostinato') osc.type = 'triangle';
|
break;
|
||||||
else if (role === 'melody') osc.type = 'sawtooth';
|
case 'ostinato':
|
||||||
else osc.type = 'sine';
|
osc.type = 'triangle';
|
||||||
|
break;
|
||||||
// Subtle detune (feels less “pure chip”)
|
case 'melody':
|
||||||
osc.detune.value = (Math.random() - 0.5) * 8; // ±4 cents
|
osc.type = 'triangle';
|
||||||
} else {
|
break;
|
||||||
// nes_gb (current default)
|
case 'texture':
|
||||||
switch (role) {
|
case 'accents':
|
||||||
case 'bass':
|
osc.type = 'sine';
|
||||||
osc.type = 'square';
|
break;
|
||||||
break;
|
|
||||||
case 'drone':
|
|
||||||
osc.type = 'sawtooth';
|
|
||||||
break;
|
|
||||||
case 'ostinato':
|
|
||||||
osc.type = 'triangle';
|
|
||||||
break;
|
|
||||||
case 'melody':
|
|
||||||
osc.type = 'triangle';
|
|
||||||
break;
|
|
||||||
case 'texture':
|
|
||||||
case 'accents':
|
|
||||||
osc.type = 'sine';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
osc.connect(envelope);
|
osc.connect(envelope);
|
||||||
envelope.connect(layer.filterNode);
|
envelope.connect(layer.filterNode);
|
||||||
|
|
||||||
// Envelope tuned per model (still with minimum times to prevent clicks)
|
// Envelope based on velocity and duration with minimum times to prevent clicks
|
||||||
const gainValue = velocity * 0.5; // Scale velocity
|
const gainValue = velocity * 0.5; // Scale velocity
|
||||||
const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes_ish' ? 0.01 : 0.005;
|
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
|
||||||
const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes_ish' ? 0.14 : 0.01;
|
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
|
||||||
const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
|
|
||||||
const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes_ish' ? 0.22 : 0.12, duration * 0.3));
|
|
||||||
|
|
||||||
envelope.gain.setValueAtTime(0, when);
|
envelope.gain.setValueAtTime(0, when);
|
||||||
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
||||||
@@ -330,8 +260,6 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
osc.start(when);
|
osc.start(when);
|
||||||
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
||||||
|
|
||||||
this.activeVoiceCount++;
|
|
||||||
|
|
||||||
// Clean up after note ends
|
// Clean up after note ends
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -341,7 +269,6 @@ export class SynthesisEngine {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already disconnected
|
// Already disconnected
|
||||||
}
|
}
|
||||||
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
|
||||||
}, (duration + releaseTime + 0.1) * 1000);
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,12 +290,6 @@ export class SynthesisEngine {
|
|||||||
|
|
||||||
if (!events.length && !chords.length) return;
|
if (!events.length && !chords.length) return;
|
||||||
|
|
||||||
// pre8bit is intentionally simple: no chord scheduling
|
|
||||||
if (this.model === 'pre8bit') {
|
|
||||||
this.scheduleSingleEvents(role, events, scheduleUntil);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For roles that support polyphony (drone, texture), prefer chords
|
// For roles that support polyphony (drone, texture), prefer chords
|
||||||
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
||||||
this.scheduleChordEvents(role, chords, scheduleUntil);
|
this.scheduleChordEvents(role, chords, scheduleUntil);
|
||||||
@@ -471,13 +392,9 @@ export class SynthesisEngine {
|
|||||||
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
|
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
|
||||||
const layer = this.layers.get(role);
|
const layer = this.layers.get(role);
|
||||||
if (!layer) return;
|
if (!layer) return;
|
||||||
|
|
||||||
if (this.activeVoiceCount >= this.maxVoices) return;
|
|
||||||
|
|
||||||
// Create oscillator for each pitch in the chord (unless constrained)
|
// Create oscillator for each pitch in the chord
|
||||||
const chordPitches = this.model === 'snes_ish' ? pitches.slice(0, 4) : pitches.slice(0, 3);
|
for (const pitch of pitches) {
|
||||||
for (const pitch of chordPitches) {
|
|
||||||
if (this.activeVoiceCount >= this.maxVoices) break;
|
|
||||||
const osc = this.audioContext.createOscillator();
|
const osc = this.audioContext.createOscillator();
|
||||||
const envelope = this.audioContext.createGain();
|
const envelope = this.audioContext.createGain();
|
||||||
|
|
||||||
@@ -485,46 +402,35 @@ export class SynthesisEngine {
|
|||||||
const frequency = this.midiToFrequency(pitch);
|
const frequency = this.midiToFrequency(pitch);
|
||||||
osc.frequency.value = frequency;
|
osc.frequency.value = frequency;
|
||||||
|
|
||||||
// Choose oscillator type based on model + role (same as note path)
|
// Choose oscillator type based on role
|
||||||
if (this.model === 'snes_ish') {
|
switch (role) {
|
||||||
if (role === 'bass') osc.type = 'triangle';
|
case 'bass':
|
||||||
else if (role === 'drone') osc.type = 'sawtooth';
|
osc.type = 'square';
|
||||||
else if (role === 'ostinato') osc.type = 'triangle';
|
break;
|
||||||
else if (role === 'melody') osc.type = 'sawtooth';
|
case 'drone':
|
||||||
else osc.type = 'sine';
|
osc.type = 'sawtooth';
|
||||||
osc.detune.value = (Math.random() - 0.5) * 8;
|
break;
|
||||||
} else {
|
case 'ostinato':
|
||||||
switch (role) {
|
osc.type = 'triangle';
|
||||||
case 'bass':
|
break;
|
||||||
osc.type = 'square';
|
case 'texture':
|
||||||
break;
|
osc.type = 'sine';
|
||||||
case 'drone':
|
break;
|
||||||
osc.type = 'sawtooth';
|
case 'melody':
|
||||||
break;
|
osc.type = 'triangle';
|
||||||
case 'ostinato':
|
break;
|
||||||
osc.type = 'triangle';
|
case 'accents':
|
||||||
break;
|
osc.type = 'sine';
|
||||||
case 'texture':
|
break;
|
||||||
osc.type = 'sine';
|
|
||||||
break;
|
|
||||||
case 'melody':
|
|
||||||
osc.type = 'triangle';
|
|
||||||
break;
|
|
||||||
case 'accents':
|
|
||||||
osc.type = 'sine';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
osc.connect(envelope);
|
osc.connect(envelope);
|
||||||
envelope.connect(layer.filterNode);
|
envelope.connect(layer.filterNode);
|
||||||
|
|
||||||
// Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
|
// Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
|
||||||
const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1); // Scale down for chords
|
const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords
|
||||||
const attackBase = this.model === 'snes_ish' ? 0.01 : 0.005;
|
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
|
||||||
const releaseBase = this.model === 'snes_ish' ? 0.16 : 0.01;
|
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
|
||||||
const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
|
|
||||||
const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes_ish' ? 0.24 : 0.12, duration * 0.3));
|
|
||||||
|
|
||||||
envelope.gain.setValueAtTime(0, when);
|
envelope.gain.setValueAtTime(0, when);
|
||||||
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
|
||||||
@@ -534,8 +440,6 @@ export class SynthesisEngine {
|
|||||||
osc.start(when);
|
osc.start(when);
|
||||||
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
|
||||||
|
|
||||||
this.activeVoiceCount++;
|
|
||||||
|
|
||||||
// Clean up after note ends
|
// Clean up after note ends
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
@@ -544,7 +448,6 @@ export class SynthesisEngine {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already disconnected
|
// Already disconnected
|
||||||
}
|
}
|
||||||
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
|
||||||
}, (duration + releaseTime + 0.1) * 1000);
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,76 +467,4 @@ export class SynthesisEngine {
|
|||||||
}
|
}
|
||||||
this.layers.clear();
|
this.layers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMaxVoicesForModel(model: SynthModel, defaultMax: number): number {
|
|
||||||
if (model === 'pre8bit') return Math.min(2, defaultMax);
|
|
||||||
if (model === 'snes_ish') return Math.max(12, defaultMax);
|
|
||||||
return defaultMax;
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterAssignmentsForModel(assignments: RoleAssignment[]): RoleAssignment[] {
|
|
||||||
if (this.model === 'pre8bit') {
|
|
||||||
// Keep it intentionally simple and sparse.
|
|
||||||
const keep: Role[] = ['melody', 'bass'];
|
|
||||||
const kept = assignments.filter(a => keep.includes(a.role));
|
|
||||||
// If role mapper didn’t produce those roles, fall back to first available assignment.
|
|
||||||
if (kept.length > 0) return kept.slice(0, 2);
|
|
||||||
return assignments.slice(0, 1);
|
|
||||||
}
|
|
||||||
return assignments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private configureMasterChain(model: SynthModel): () => void {
|
|
||||||
// Disconnect any previous chain
|
|
||||||
try { this.masterGain.disconnect(); } catch {}
|
|
||||||
if (this.effectCleanup) {
|
|
||||||
try { this.effectCleanup(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: dry only
|
|
||||||
if (model !== 'snes_ish') {
|
|
||||||
this.masterGain.connect(this.postGain);
|
|
||||||
return () => {
|
|
||||||
try { this.masterGain.disconnect(); } catch {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// SNES-ish: add a simple echo/reverb-like feedback delay with filtering.
|
|
||||||
const dry = this.audioContext.createGain();
|
|
||||||
const wet = this.audioContext.createGain();
|
|
||||||
const delay = this.audioContext.createDelay(1.0);
|
|
||||||
const feedback = this.audioContext.createGain();
|
|
||||||
const fbFilter = this.audioContext.createBiquadFilter();
|
|
||||||
|
|
||||||
dry.gain.value = 0.85;
|
|
||||||
wet.gain.value = 0.28;
|
|
||||||
delay.delayTime.value = 0.165; // ~165ms echo
|
|
||||||
feedback.gain.value = 0.32;
|
|
||||||
fbFilter.type = 'lowpass';
|
|
||||||
fbFilter.frequency.value = 1800;
|
|
||||||
fbFilter.Q.value = 0.7;
|
|
||||||
|
|
||||||
// master -> dry -> post
|
|
||||||
this.masterGain.connect(dry);
|
|
||||||
dry.connect(this.postGain);
|
|
||||||
|
|
||||||
// master -> delay -> wet -> post
|
|
||||||
this.masterGain.connect(delay);
|
|
||||||
delay.connect(wet);
|
|
||||||
wet.connect(this.postGain);
|
|
||||||
|
|
||||||
// feedback loop: delay -> filter -> feedback -> delay
|
|
||||||
delay.connect(fbFilter);
|
|
||||||
fbFilter.connect(feedback);
|
|
||||||
feedback.connect(delay);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
try { this.masterGain.disconnect(); } catch {}
|
|
||||||
try { dry.disconnect(); } catch {}
|
|
||||||
try { wet.disconnect(); } catch {}
|
|
||||||
try { delay.disconnect(); } catch {}
|
|
||||||
try { feedback.disconnect(); } catch {}
|
|
||||||
try { fbFilter.disconnect(); } catch {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -38,9 +38,6 @@ export interface StructuralFeatures {
|
|||||||
|
|
||||||
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody';
|
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody';
|
||||||
|
|
||||||
// Procedural synth model presets (test page uses these)
|
|
||||||
export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes_ish';
|
|
||||||
|
|
||||||
export interface RoleAssignment {
|
export interface RoleAssignment {
|
||||||
role: Role;
|
role: Role;
|
||||||
sourceTrack: number;
|
sourceTrack: number;
|
||||||
|
|||||||
@@ -26,10 +26,6 @@
|
|||||||
"src": "/embed",
|
"src": "/embed",
|
||||||
"dest": "/embed.html"
|
"dest": "/embed.html"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"src": "/models",
|
|
||||||
"dest": "/models.html"
|
|
||||||
},
|
|
||||||
{ "handle": "filesystem" },
|
{ "handle": "filesystem" },
|
||||||
{
|
{
|
||||||
"src": "/(.*)",
|
"src": "/(.*)",
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default defineConfig({
|
|||||||
input: {
|
input: {
|
||||||
main: resolve(__dirname, 'index.html'),
|
main: resolve(__dirname, 'index.html'),
|
||||||
embed: resolve(__dirname, 'embed.html'),
|
embed: resolve(__dirname, 'embed.html'),
|
||||||
models: resolve(__dirname, 'models.html'),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user