Add isolated /models lab page for synth comparisons.
This commit is contained in:
+503
@@ -0,0 +1,503 @@
|
|||||||
|
<!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 — Models (lab)</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0b0f;
|
||||||
|
--surface: rgba(0,0,0,0.18);
|
||||||
|
--border: rgba(255,255,255,0.12);
|
||||||
|
--text: rgba(255,255,255,0.9);
|
||||||
|
--muted: rgba(255,255,255,0.7);
|
||||||
|
--accent: #00ff88;
|
||||||
|
--accent2: #ff3cf5;
|
||||||
|
--radius: 14px;
|
||||||
|
--shadow: 0 26px 90px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
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(--text);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
margin: 0;
|
||||||
|
padding: 48px 20px;
|
||||||
|
}
|
||||||
|
.container { max-width: 960px; margin: 0 auto; }
|
||||||
|
.top {
|
||||||
|
display:flex; align-items:center; gap: 12px; justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
margin:0;
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow: 0 0 18px rgba(0,255,136,0.18);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sub { margin: 0 0 16px 0; color: var(--muted); }
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.row { display:flex; gap: 10px; align-items:center; flex-wrap: wrap; }
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1 1 360px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(0,0,0,0.28);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
table { width:100%; border-collapse: collapse; }
|
||||||
|
th, td { padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.08); text-align:left; }
|
||||||
|
tr.selected { background: rgba(0,255,136,0.06); }
|
||||||
|
|
||||||
|
.toggle { display:flex; gap: 8px; flex-wrap: wrap; align-items:center; }
|
||||||
|
.model-btn {
|
||||||
|
display:inline-flex; align-items:center; gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: rgba(255,255,255,0.86);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.model-btn .dot { width: 7px; height: 7px; border-radius: 999px; background: rgba(255,255,255,0.35); }
|
||||||
|
.model-btn[aria-pressed="true"] {
|
||||||
|
border-color: rgba(0,255,136,0.35);
|
||||||
|
background: linear-gradient(90deg, rgba(0,255,136,0.16), rgba(255,60,245,0.10));
|
||||||
|
}
|
||||||
|
.model-btn[aria-pressed="true"] .dot { background: rgba(0,255,136,0.85); box-shadow: 0 0 14px rgba(0,255,136,0.22); }
|
||||||
|
.hint { color: rgba(255,255,255,0.65); font-size: 12px; }
|
||||||
|
.vol { display:flex; gap: 8px; align-items:center; }
|
||||||
|
.vol label { color: rgba(255,255,255,0.65); font-size: 12px; }
|
||||||
|
input[type="range"] { width: 170px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="top">
|
||||||
|
<h1 class="title">MOTIF — Models</h1>
|
||||||
|
<a class="pill" href="/" aria-label="Back to main app">Home</a>
|
||||||
|
</div>
|
||||||
|
<p class="sub">Lab page. Choose a synthesis model, then click Generate. This page does not modify the main engine.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<input id="songInput" type="text" placeholder="Enter song name…" />
|
||||||
|
<button id="searchBtn" class="primary">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status">Ready.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultsCard" class="card" style="display:none;">
|
||||||
|
<div class="row" style="justify-content: space-between;">
|
||||||
|
<strong>Search results</strong>
|
||||||
|
<span class="hint">Pick one MIDI, then compare models.</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:10px"></div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Title</th><th>Source</th><th>Duration</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="resultsBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="playerCard" class="card" style="display:none;">
|
||||||
|
<div class="row" style="justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div id="selectedTitle" style="font-weight:700;">No MIDI selected</div>
|
||||||
|
<div id="selectedMeta" class="hint">Select a result to enable playback.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:14px"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button id="previewPlayBtn" class="primary" disabled>Play Preview</button>
|
||||||
|
<button id="previewStopBtn" disabled>Stop</button>
|
||||||
|
<div class="vol">
|
||||||
|
<label for="previewVol">Preview vol</label>
|
||||||
|
<input id="previewVol" type="range" min="0" max="1" step="0.01" value="0.8" />
|
||||||
|
</div>
|
||||||
|
<button id="nextResultBtn" disabled>Try Next</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:14px"></div>
|
||||||
|
<div style="height:1px; background: rgba(255,255,255,0.08);"></div>
|
||||||
|
<div style="height:14px"></div>
|
||||||
|
|
||||||
|
<div class="row" style="justify-content: space-between; align-items: baseline;">
|
||||||
|
<strong>Motif (model test)</strong>
|
||||||
|
<span id="modelHint" class="hint">Choose a model, then Generate.</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:10px"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="toggle" role="group" aria-label="Synthesis model">
|
||||||
|
<button class="model-btn" id="modelPre8" type="button" data-model="pre8bit" aria-pressed="false">
|
||||||
|
<span class="dot" aria-hidden="true"></span> Pre‑8bit
|
||||||
|
</button>
|
||||||
|
<button class="model-btn" id="modelNesGb" type="button" data-model="nes_gb" aria-pressed="true">
|
||||||
|
<span class="dot" aria-hidden="true"></span> NES / GB
|
||||||
|
</button>
|
||||||
|
<button class="model-btn" id="modelSnes" type="button" data-model="snes" aria-pressed="false">
|
||||||
|
<span class="dot" aria-hidden="true"></span> SNES
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:12px"></div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<button id="motifPlayBtn" class="primary" disabled>Generate & Play</button>
|
||||||
|
<button id="motifStopBtn" disabled>Stop</button>
|
||||||
|
<div class="vol">
|
||||||
|
<label for="motifVol">Motif vol</label>
|
||||||
|
<input id="motifVol" type="range" min="0" max="1" step="0.01" value="0.3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/models.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<!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; }
|
||||||
|
.model-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.model-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: rgba(255,255,255,0.86);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.model-btn .dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
.model-btn:hover { border-color: rgba(255,255,255,0.22); box-shadow: 0 14px 40px rgba(0,0,0,0.35); }
|
||||||
|
.model-btn[aria-pressed="true"] {
|
||||||
|
border-color: rgba(0,255,136,0.35);
|
||||||
|
background: linear-gradient(90deg, rgba(0,255,136,0.16), rgba(255,60,245,0.10));
|
||||||
|
}
|
||||||
|
.model-btn[aria-pressed="true"] .dot {
|
||||||
|
background: rgba(0,255,136,0.85);
|
||||||
|
box-shadow: 0 0 14px rgba(0,255,136,0.22);
|
||||||
|
}
|
||||||
|
.model-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,255,136,0.25), 0 14px 40px rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>Model</label>
|
||||||
|
<div class="model-toggle" role="group" aria-label="Synthesis model">
|
||||||
|
<button class="model-btn" id="modelBtnPre8" type="button" data-model="pre8bit" aria-pressed="false">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
Pre‑8bit
|
||||||
|
</button>
|
||||||
|
<button class="model-btn" id="modelBtnNesGb" type="button" data-model="nes_gb" aria-pressed="true">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
NES / GB
|
||||||
|
</button>
|
||||||
|
<button class="model-btn" id="modelBtnSnes" type="button" data-model="snes_ish" aria-pressed="false">
|
||||||
|
<span class="dot" aria-hidden="true"></span>
|
||||||
|
SNES‑ish
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span id="modelHint" style="color: rgba(255,255,255,0.65); font-size: 12px;">Chip: square/triangle/saw flavor, tight envelope, crisp attacks.</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>
|
||||||
|
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
import { MIDIService } from './services/MIDIService';
|
||||||
|
import { MIDIParser } from './midi/MIDIParser';
|
||||||
|
import { SoundfontMIDIPlayer } from './synthesis/SoundfontMIDIPlayer';
|
||||||
|
import { getAudioContext, unlockAudio } from './utils/audioUnlock';
|
||||||
|
import { MIDIProcessor } from './midi/MIDIProcessor';
|
||||||
|
import { RoleMapper } from './core/RoleMapper';
|
||||||
|
import { TestModelSynthesisEngine, type SynthModel } from './synthesis/TestModelSynthesisEngine';
|
||||||
|
import type { NoteEvent } from './types';
|
||||||
|
|
||||||
|
class ModelsApp {
|
||||||
|
private midiService = new MIDIService();
|
||||||
|
private midiProcessor = new MIDIProcessor();
|
||||||
|
private roleMapper = new RoleMapper();
|
||||||
|
|
||||||
|
private soundfontPlayer: SoundfontMIDIPlayer | null = null;
|
||||||
|
private testEngine: TestModelSynthesisEngine | null = null;
|
||||||
|
|
||||||
|
private songInput!: HTMLInputElement;
|
||||||
|
private searchBtn!: HTMLButtonElement;
|
||||||
|
private status!: HTMLElement;
|
||||||
|
|
||||||
|
private resultsCard!: HTMLElement;
|
||||||
|
private resultsBody!: HTMLElement;
|
||||||
|
|
||||||
|
private playerCard!: HTMLElement;
|
||||||
|
private selectedTitle!: HTMLElement;
|
||||||
|
private selectedMeta!: HTMLElement;
|
||||||
|
|
||||||
|
private previewPlayBtn!: HTMLButtonElement;
|
||||||
|
private previewStopBtn!: HTMLButtonElement;
|
||||||
|
private previewVol!: HTMLInputElement;
|
||||||
|
private nextResultBtn!: HTMLButtonElement;
|
||||||
|
|
||||||
|
private motifPlayBtn!: HTMLButtonElement;
|
||||||
|
private motifStopBtn!: HTMLButtonElement;
|
||||||
|
private motifVol!: HTMLInputElement;
|
||||||
|
|
||||||
|
private modelHint!: HTMLElement;
|
||||||
|
private modelButtons: HTMLButtonElement[] = [];
|
||||||
|
private currentModel: SynthModel = 'nes_gb';
|
||||||
|
|
||||||
|
private searchResults: any[] = [];
|
||||||
|
private selectedResultIndex = 0;
|
||||||
|
private currentMIDI: { events: NoteEvent[]; metadata: any } | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.bindUI();
|
||||||
|
this.bindEvents();
|
||||||
|
this.setModel('nes_gb');
|
||||||
|
this.updateStatus('Ready.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindUI(): void {
|
||||||
|
this.songInput = document.getElementById('songInput') as HTMLInputElement;
|
||||||
|
this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement;
|
||||||
|
this.status = document.getElementById('status')!;
|
||||||
|
|
||||||
|
this.resultsCard = document.getElementById('resultsCard')!;
|
||||||
|
this.resultsBody = document.getElementById('resultsBody')!;
|
||||||
|
|
||||||
|
this.playerCard = document.getElementById('playerCard')!;
|
||||||
|
this.selectedTitle = document.getElementById('selectedTitle')!;
|
||||||
|
this.selectedMeta = document.getElementById('selectedMeta')!;
|
||||||
|
|
||||||
|
this.previewPlayBtn = document.getElementById('previewPlayBtn') as HTMLButtonElement;
|
||||||
|
this.previewStopBtn = document.getElementById('previewStopBtn') as HTMLButtonElement;
|
||||||
|
this.previewVol = document.getElementById('previewVol') as HTMLInputElement;
|
||||||
|
this.nextResultBtn = document.getElementById('nextResultBtn') as HTMLButtonElement;
|
||||||
|
|
||||||
|
this.motifPlayBtn = document.getElementById('motifPlayBtn') as HTMLButtonElement;
|
||||||
|
this.motifStopBtn = document.getElementById('motifStopBtn') as HTMLButtonElement;
|
||||||
|
this.motifVol = document.getElementById('motifVol') as HTMLInputElement;
|
||||||
|
|
||||||
|
this.modelHint = document.getElementById('modelHint')!;
|
||||||
|
this.modelButtons = [
|
||||||
|
document.getElementById('modelPre8') as HTMLButtonElement,
|
||||||
|
document.getElementById('modelNesGb') as HTMLButtonElement,
|
||||||
|
document.getElementById('modelSnes') as HTMLButtonElement,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents(): void {
|
||||||
|
this.searchBtn.addEventListener('click', () => void this.handleSearch());
|
||||||
|
this.songInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') void this.handleSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.previewPlayBtn.addEventListener('click', () => void this.handlePreviewPlay());
|
||||||
|
this.previewStopBtn.addEventListener('click', () => this.handlePreviewStop());
|
||||||
|
this.previewVol.addEventListener('input', () => this.soundfontPlayer?.setVolume(parseFloat(this.previewVol.value)));
|
||||||
|
this.nextResultBtn.addEventListener('click', () => void this.selectResult((this.selectedResultIndex + 1) % this.searchResults.length));
|
||||||
|
|
||||||
|
for (const btn of this.modelButtons) {
|
||||||
|
btn.addEventListener('click', () => this.setModel((btn.dataset.model as SynthModel) || 'nes_gb'));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.motifPlayBtn.addEventListener('click', () => void this.handleMotifGeneratePlay());
|
||||||
|
this.motifStopBtn.addEventListener('click', () => this.handleMotifStop());
|
||||||
|
this.motifVol.addEventListener('input', () => this.testEngine?.setVolume(parseFloat(this.motifVol.value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateStatus(message: string): void {
|
||||||
|
this.status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setModel(model: SynthModel): void {
|
||||||
|
this.currentModel = model;
|
||||||
|
for (const btn of this.modelButtons) {
|
||||||
|
btn.setAttribute('aria-pressed', btn.dataset.model === model ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint =
|
||||||
|
model === 'pre8bit'
|
||||||
|
? 'Pre‑8bit: very limited voices, hard gates, square/triangle + noise feel.'
|
||||||
|
: model === 'snes'
|
||||||
|
? 'SNES: sample-voice feel, 8 voices, ADSR, echo/reverb + downsample vibe.'
|
||||||
|
: 'NES/GB: classic chip oscillators (square/triangle/saw), tighter envelopes.';
|
||||||
|
|
||||||
|
this.modelHint.textContent = hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureAudioReady(): Promise<SoundfontMIDIPlayer> {
|
||||||
|
const ctx = await unlockAudio();
|
||||||
|
if (!this.soundfontPlayer) this.soundfontPlayer = new SoundfontMIDIPlayer(ctx);
|
||||||
|
return this.soundfontPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSearch(): Promise<void> {
|
||||||
|
const q = this.songInput.value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
|
this.handleMotifStop();
|
||||||
|
this.handlePreviewStop();
|
||||||
|
|
||||||
|
this.updateStatus('Searching…');
|
||||||
|
this.searchBtn.disabled = true;
|
||||||
|
this.resultsCard.style.display = 'none';
|
||||||
|
this.playerCard.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await this.midiService.search(q);
|
||||||
|
if (!results.length) {
|
||||||
|
this.updateStatus('No MIDI found. Try another query.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(3, results.length); i++) {
|
||||||
|
const meta = await this.midiService.parseMIDI(results[i].midiUrl);
|
||||||
|
if (meta) results[i].parsed = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchResults = results;
|
||||||
|
this.selectedResultIndex = 0;
|
||||||
|
this.renderResults();
|
||||||
|
this.resultsCard.style.display = 'block';
|
||||||
|
this.updateStatus(`Found ${results.length}. Select one to compare models.`);
|
||||||
|
} catch (e) {
|
||||||
|
this.updateStatus(`Search error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
} finally {
|
||||||
|
this.searchBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderResults(): void {
|
||||||
|
this.resultsBody.innerHTML = '';
|
||||||
|
this.searchResults.forEach((r, idx) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
if (idx === this.selectedResultIndex) tr.classList.add('selected');
|
||||||
|
tr.innerHTML = `<td>${r.title}</td><td>${r.source}</td><td>${r.parsed ? Math.round(r.parsed.durationSec) + 's' : '?'}</td>`;
|
||||||
|
tr.addEventListener('click', () => void this.selectResult(idx));
|
||||||
|
this.resultsBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
void this.selectResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async selectResult(index: number): Promise<void> {
|
||||||
|
if (index < 0 || index >= this.searchResults.length) return;
|
||||||
|
this.handleMotifStop();
|
||||||
|
this.handlePreviewStop();
|
||||||
|
|
||||||
|
this.selectedResultIndex = index;
|
||||||
|
const rows = this.resultsBody.querySelectorAll('tr');
|
||||||
|
rows.forEach((row, i) => row.classList.toggle('selected', i === index));
|
||||||
|
|
||||||
|
const result = this.searchResults[index];
|
||||||
|
this.updateStatus('Loading MIDI…');
|
||||||
|
this.disablePlayback();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buf = await this.midiService.fetchMIDI(result.midiUrl);
|
||||||
|
if (!buf) throw new Error('Failed to fetch MIDI');
|
||||||
|
|
||||||
|
const events = MIDIParser.parseMIDI(buf);
|
||||||
|
const metadata = result.parsed || MIDIParser.getMIDIInfo(buf);
|
||||||
|
const duration = metadata.duration || metadata.durationSec || (events.length ? Math.max(...events.map(e => e.time + e.duration)) : 0);
|
||||||
|
|
||||||
|
this.currentMIDI = { events, metadata: { ...metadata, duration } };
|
||||||
|
|
||||||
|
const player = await this.ensureAudioReady();
|
||||||
|
await player.load(events);
|
||||||
|
player.setVolume(parseFloat(this.previewVol.value));
|
||||||
|
|
||||||
|
this.selectedTitle.textContent = result.title;
|
||||||
|
this.selectedMeta.innerHTML =
|
||||||
|
`<strong>Source:</strong> ${result.source} | <strong>Duration:</strong> ${Math.round(duration)}s | ` +
|
||||||
|
`<strong>Tracks:</strong> ${metadata.trackCount} | <strong>Notes:</strong> ${events.length} | <strong>Tempo:</strong> ${metadata.tempo}bpm`;
|
||||||
|
|
||||||
|
this.playerCard.style.display = 'block';
|
||||||
|
this.enablePlayback();
|
||||||
|
this.updateStatus('Ready. Preview or generate with a chosen model.');
|
||||||
|
} catch (e) {
|
||||||
|
this.updateStatus(`Load error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private disablePlayback(): void {
|
||||||
|
this.previewPlayBtn.disabled = true;
|
||||||
|
this.previewStopBtn.disabled = true;
|
||||||
|
this.nextResultBtn.disabled = true;
|
||||||
|
this.motifPlayBtn.disabled = true;
|
||||||
|
this.motifStopBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enablePlayback(): void {
|
||||||
|
this.previewPlayBtn.disabled = false;
|
||||||
|
this.nextResultBtn.disabled = this.searchResults.length <= 1;
|
||||||
|
this.motifPlayBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handlePreviewPlay(): Promise<void> {
|
||||||
|
if (!this.currentMIDI) return;
|
||||||
|
try {
|
||||||
|
const player = await this.ensureAudioReady();
|
||||||
|
await player.play();
|
||||||
|
this.previewPlayBtn.disabled = true;
|
||||||
|
this.previewStopBtn.disabled = false;
|
||||||
|
this.updateStatus('Preview playing…');
|
||||||
|
} catch (e) {
|
||||||
|
this.updateStatus(`Preview error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePreviewStop(): void {
|
||||||
|
this.soundfontPlayer?.stop();
|
||||||
|
this.previewPlayBtn.disabled = false;
|
||||||
|
this.previewStopBtn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMotifGeneratePlay(): Promise<void> {
|
||||||
|
if (!this.currentMIDI) return;
|
||||||
|
try {
|
||||||
|
await unlockAudio();
|
||||||
|
this.handleMotifStop(); // isolate each run
|
||||||
|
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
const features = this.midiProcessor.extractFeatures(this.currentMIDI.events);
|
||||||
|
const assignments = this.roleMapper.assignRoles(features, this.currentMIDI.events);
|
||||||
|
|
||||||
|
this.testEngine = new TestModelSynthesisEngine(ctx, this.currentModel);
|
||||||
|
this.testEngine.setupLayers(assignments);
|
||||||
|
this.testEngine.setVolume(parseFloat(this.motifVol.value));
|
||||||
|
this.testEngine.start();
|
||||||
|
|
||||||
|
this.motifPlayBtn.disabled = true;
|
||||||
|
this.motifStopBtn.disabled = false;
|
||||||
|
this.updateStatus(`Motif playing (${this.currentModel})…`);
|
||||||
|
} catch (e) {
|
||||||
|
this.updateStatus(`Motif error: ${e instanceof Error ? e.message : 'Unknown error'}`);
|
||||||
|
this.motifPlayBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMotifStop(): void {
|
||||||
|
this.testEngine?.stop();
|
||||||
|
this.testEngine = null;
|
||||||
|
this.motifPlayBtn.disabled = false;
|
||||||
|
this.motifStopBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new ModelsApp();
|
||||||
|
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import type { RoleAssignment, Role, SynthLayer, NoteEvent, ChordEvent } from '../types';
|
||||||
|
|
||||||
|
export type SynthModel = 'pre8bit' | 'nes_gb' | 'snes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TestModelSynthesisEngine
|
||||||
|
* -----------------------
|
||||||
|
* Used ONLY by /models to compare different synthesis models.
|
||||||
|
*
|
||||||
|
* IMPORTANT:
|
||||||
|
* - This is intentionally isolated from the main `SynthesisEngine`.
|
||||||
|
* - Do not import this into the main app.
|
||||||
|
*/
|
||||||
|
export class TestModelSynthesisEngine {
|
||||||
|
private audioContext: AudioContext;
|
||||||
|
private model: SynthModel;
|
||||||
|
|
||||||
|
private masterGain: GainNode;
|
||||||
|
private postGain: GainNode;
|
||||||
|
|
||||||
|
private layers: Map<Role, SynthLayer> = new Map();
|
||||||
|
private roleAssignments: Map<Role, RoleAssignment> = new Map();
|
||||||
|
|
||||||
|
private isPlaying = false;
|
||||||
|
private schedulerIntervalId: number | null = null;
|
||||||
|
private startTime = 0;
|
||||||
|
private nextEventIndex = new Map<Role, number>();
|
||||||
|
|
||||||
|
private activeVoiceCount = 0;
|
||||||
|
private maxVoices: number;
|
||||||
|
|
||||||
|
// Simplified scheduling (fine for the lab page)
|
||||||
|
private lookaheadTime = 0.12;
|
||||||
|
private scheduleInterval = 25;
|
||||||
|
private fadeTime = 0.05;
|
||||||
|
|
||||||
|
// SNES-ish “APU constraints”
|
||||||
|
private snesSampleRate = 32000;
|
||||||
|
private snesBitDepth = 12;
|
||||||
|
|
||||||
|
constructor(audioContext: AudioContext, model: SynthModel) {
|
||||||
|
this.audioContext = audioContext;
|
||||||
|
this.model = model;
|
||||||
|
|
||||||
|
this.masterGain = audioContext.createGain();
|
||||||
|
this.postGain = audioContext.createGain();
|
||||||
|
this.postGain.connect(audioContext.destination);
|
||||||
|
|
||||||
|
this.masterGain.gain.value = 0.3;
|
||||||
|
this.postGain.gain.value = 1.0;
|
||||||
|
|
||||||
|
// Voice count: SNES = 8 voices. Pre-8bit = ~2. NES/GB = “few”, but keep 8 for fun.
|
||||||
|
this.maxVoices = model === 'pre8bit' ? 2 : model === 'snes' ? 8 : 8;
|
||||||
|
|
||||||
|
this.configureMasterChain();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(volume: number): void {
|
||||||
|
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLayers(assignments: RoleAssignment[]): void {
|
||||||
|
this.cleanupLayers();
|
||||||
|
this.activeVoiceCount = 0;
|
||||||
|
|
||||||
|
const filtered = this.filterAssignments(assignments);
|
||||||
|
|
||||||
|
let earliestTime = Infinity;
|
||||||
|
for (const assignment of filtered) {
|
||||||
|
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 filtered) {
|
||||||
|
for (const e of assignment.events) e.time -= earliestTime;
|
||||||
|
for (const c of assignment.chords) c.time -= earliestTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const assignment of filtered) {
|
||||||
|
const layer = this.createSynthLayer(assignment.role);
|
||||||
|
this.layers.set(assignment.role, layer);
|
||||||
|
this.roleAssignments.set(assignment.role, assignment);
|
||||||
|
this.nextEventIndex.set(assignment.role, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.isPlaying) return;
|
||||||
|
this.isPlaying = true;
|
||||||
|
this.startTime = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
for (const role of this.roleAssignments.keys()) {
|
||||||
|
this.nextEventIndex.set(role, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.schedulerIntervalId = window.setInterval(() => this.scheduleEvents(), this.scheduleInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
this.isPlaying = false;
|
||||||
|
|
||||||
|
if (this.schedulerIntervalId) {
|
||||||
|
clearInterval(this.schedulerIntervalId);
|
||||||
|
this.schedulerIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fadeOutAllLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private configureMasterChain(): void {
|
||||||
|
try { this.masterGain.disconnect(); } catch {}
|
||||||
|
|
||||||
|
// dry always
|
||||||
|
const dry = this.audioContext.createGain();
|
||||||
|
dry.gain.value = 1.0;
|
||||||
|
this.masterGain.connect(dry);
|
||||||
|
dry.connect(this.postGain);
|
||||||
|
|
||||||
|
if (this.model !== 'snes') return;
|
||||||
|
|
||||||
|
// SNES-ish echo (APU has echo buffer + 8-tap FIR). Approx with feedback delay + lowpass.
|
||||||
|
const wet = this.audioContext.createGain();
|
||||||
|
const delay = this.audioContext.createDelay(1.0);
|
||||||
|
const feedback = this.audioContext.createGain();
|
||||||
|
const fbFilter = this.audioContext.createBiquadFilter();
|
||||||
|
|
||||||
|
wet.gain.value = 0.28;
|
||||||
|
delay.delayTime.value = 0.165;
|
||||||
|
feedback.gain.value = 0.32;
|
||||||
|
fbFilter.type = 'lowpass';
|
||||||
|
fbFilter.frequency.value = 1800;
|
||||||
|
fbFilter.Q.value = 0.7;
|
||||||
|
|
||||||
|
this.masterGain.connect(delay);
|
||||||
|
delay.connect(wet);
|
||||||
|
wet.connect(this.postGain);
|
||||||
|
|
||||||
|
delay.connect(fbFilter);
|
||||||
|
fbFilter.connect(feedback);
|
||||||
|
feedback.connect(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterAssignments(assignments: RoleAssignment[]): RoleAssignment[] {
|
||||||
|
if (this.model !== 'pre8bit') return assignments;
|
||||||
|
const keep: Role[] = ['melody', 'bass'];
|
||||||
|
const kept = assignments.filter(a => keep.includes(a.role));
|
||||||
|
if (kept.length > 0) return kept.slice(0, 2);
|
||||||
|
return assignments.slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSynthLayer(role: Role): SynthLayer {
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
const filterNode = this.audioContext.createBiquadFilter();
|
||||||
|
|
||||||
|
if (this.model === 'snes') {
|
||||||
|
filterNode.type = 'lowpass';
|
||||||
|
filterNode.frequency.value = 2600;
|
||||||
|
filterNode.Q.value = 0.9;
|
||||||
|
} else {
|
||||||
|
filterNode.type = 'lowpass';
|
||||||
|
filterNode.frequency.value = 5200;
|
||||||
|
filterNode.Q.value = 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterNode.connect(gainNode);
|
||||||
|
gainNode.connect(this.masterGain);
|
||||||
|
|
||||||
|
this.configureLayerForRole(gainNode, filterNode, role);
|
||||||
|
|
||||||
|
return { role, oscillators: [], gainNode, filterNode };
|
||||||
|
}
|
||||||
|
|
||||||
|
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
|
||||||
|
switch (role) {
|
||||||
|
case 'bass':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.55 : 0.4;
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 260 : 220;
|
||||||
|
break;
|
||||||
|
case 'drone':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.2;
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 520 : 400;
|
||||||
|
break;
|
||||||
|
case 'ostinato':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.25 : 0.3;
|
||||||
|
filter.type = 'highpass';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 220 : 300;
|
||||||
|
break;
|
||||||
|
case 'texture':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.0 : 0.1;
|
||||||
|
filter.type = 'bandpass';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 900 : 800;
|
||||||
|
break;
|
||||||
|
case 'accents':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.35 : 0.5;
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 1200 : 1000;
|
||||||
|
break;
|
||||||
|
case 'melody':
|
||||||
|
gain.gain.value = this.model === 'pre8bit' ? 0.42 : 0.35;
|
||||||
|
filter.type = 'lowpass';
|
||||||
|
filter.frequency.value = this.model === 'snes' ? 3200 : 4200;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private midiToFrequency(midiNote: number): number {
|
||||||
|
return 440 * Math.pow(2, (midiNote - 69) / 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleEvents(): void {
|
||||||
|
if (!this.isPlaying) return;
|
||||||
|
const currentTime = this.audioContext.currentTime;
|
||||||
|
const scheduleUntil = currentTime + this.lookaheadTime;
|
||||||
|
|
||||||
|
for (const [role, assignment] of this.roleAssignments) {
|
||||||
|
this.scheduleRoleEvents(role, assignment, scheduleUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleRoleEvents(role: Role, assignment: RoleAssignment, scheduleUntil: number): void {
|
||||||
|
const events = assignment.events;
|
||||||
|
const chords = assignment.chords;
|
||||||
|
if (!events.length && !chords.length) return;
|
||||||
|
|
||||||
|
if (this.model === 'pre8bit') {
|
||||||
|
this.scheduleSingleEvents(role, events, scheduleUntil);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
|
||||||
|
this.scheduleChordEvents(role, chords, scheduleUntil);
|
||||||
|
} else {
|
||||||
|
this.scheduleSingleEvents(role, events, scheduleUntil);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleChordEvents(role: Role, chords: ChordEvent[], scheduleUntil: number): void {
|
||||||
|
let chordIndex = this.nextEventIndex.get(role) || 0;
|
||||||
|
|
||||||
|
while (chordIndex < chords.length) {
|
||||||
|
const chord = chords[chordIndex];
|
||||||
|
const eventTime = this.startTime + chord.time;
|
||||||
|
if (eventTime > scheduleUntil) break;
|
||||||
|
|
||||||
|
if (eventTime >= this.audioContext.currentTime) {
|
||||||
|
this.scheduleChord(role, chord.pitches, Math.max(0.05, chord.duration), chord.velocity, eventTime);
|
||||||
|
}
|
||||||
|
chordIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nextEventIndex.set(role, chordIndex);
|
||||||
|
if (chordIndex >= chords.length) this.loopIfEnded(chords.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSingleEvents(role: Role, events: NoteEvent[], scheduleUntil: number): void {
|
||||||
|
let eventIndex = this.nextEventIndex.get(role) || 0;
|
||||||
|
|
||||||
|
while (eventIndex < events.length) {
|
||||||
|
const event = events[eventIndex];
|
||||||
|
const eventTime = this.startTime + event.time;
|
||||||
|
if (eventTime > scheduleUntil) break;
|
||||||
|
|
||||||
|
if (eventTime >= this.audioContext.currentTime) {
|
||||||
|
this.scheduleNote(role, event.pitch, Math.max(0.05, event.duration), event.velocity, eventTime);
|
||||||
|
}
|
||||||
|
eventIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nextEventIndex.set(role, eventIndex);
|
||||||
|
if (eventIndex >= events.length) this.loopIfEnded(events.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loopIfEnded(length: number): void {
|
||||||
|
if (length <= 0) return;
|
||||||
|
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0 || idx >= length)) {
|
||||||
|
for (const role of this.nextEventIndex.keys()) this.nextEventIndex.set(role, 0);
|
||||||
|
this.startTime = this.audioContext.currentTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
|
||||||
|
const layer = this.layers.get(role);
|
||||||
|
if (!layer) return;
|
||||||
|
|
||||||
|
if (this.model === 'pre8bit') {
|
||||||
|
if (role !== 'bass' && role !== 'melody') return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeVoiceCount >= this.maxVoices) return;
|
||||||
|
|
||||||
|
const osc = this.audioContext.createOscillator();
|
||||||
|
const envelope = this.audioContext.createGain();
|
||||||
|
osc.frequency.value = this.midiToFrequency(pitch);
|
||||||
|
|
||||||
|
if (this.model === 'pre8bit') {
|
||||||
|
osc.type = role === 'bass' ? 'triangle' : 'square';
|
||||||
|
} else if (this.model === 'snes') {
|
||||||
|
if (role === 'bass') osc.type = 'triangle';
|
||||||
|
else if (role === 'melody') osc.type = 'sawtooth';
|
||||||
|
else if (role === 'drone') osc.type = 'sawtooth';
|
||||||
|
else if (role === 'ostinato') osc.type = 'triangle';
|
||||||
|
else osc.type = 'sine';
|
||||||
|
osc.detune.value = (Math.random() - 0.5) * 8;
|
||||||
|
} else {
|
||||||
|
if (role === 'bass') osc.type = 'square';
|
||||||
|
else if (role === 'drone') osc.type = 'sawtooth';
|
||||||
|
else if (role === 'ostinato') osc.type = 'triangle';
|
||||||
|
else if (role === 'melody') osc.type = 'triangle';
|
||||||
|
else osc.type = 'sine';
|
||||||
|
}
|
||||||
|
|
||||||
|
osc.connect(envelope);
|
||||||
|
|
||||||
|
// Per-voice crunch for SNES-ish
|
||||||
|
const out: AudioNode = this.model === 'snes' ? this.makeSnesCrunchNode(envelope) : envelope;
|
||||||
|
out.connect(layer.filterNode);
|
||||||
|
|
||||||
|
const gainValue = velocity * 0.5;
|
||||||
|
const attackBase = this.model === 'pre8bit' ? 0.003 : this.model === 'snes' ? 0.01 : 0.005;
|
||||||
|
const releaseBase = this.model === 'pre8bit' ? 0.008 : this.model === 'snes' ? 0.14 : 0.01;
|
||||||
|
const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
|
||||||
|
const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes' ? 0.22 : 0.12, 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);
|
||||||
|
this.activeVoiceCount++;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
osc.disconnect();
|
||||||
|
envelope.disconnect();
|
||||||
|
if (out !== envelope) out.disconnect();
|
||||||
|
} catch {}
|
||||||
|
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
||||||
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
|
||||||
|
const layer = this.layers.get(role);
|
||||||
|
if (!layer) return;
|
||||||
|
if (this.activeVoiceCount >= this.maxVoices) return;
|
||||||
|
|
||||||
|
const chordPitches = this.model === 'snes' ? pitches.slice(0, 4) : pitches.slice(0, 3);
|
||||||
|
for (const pitch of chordPitches) {
|
||||||
|
if (this.activeVoiceCount >= this.maxVoices) break;
|
||||||
|
|
||||||
|
const osc = this.audioContext.createOscillator();
|
||||||
|
const envelope = this.audioContext.createGain();
|
||||||
|
osc.frequency.value = this.midiToFrequency(pitch);
|
||||||
|
|
||||||
|
if (this.model === 'snes') {
|
||||||
|
osc.type = role === 'bass' ? 'triangle' : role === 'melody' ? 'sawtooth' : 'sine';
|
||||||
|
osc.detune.value = (Math.random() - 0.5) * 8;
|
||||||
|
} else {
|
||||||
|
osc.type = role === 'bass' ? 'square' : role === 'drone' ? 'sawtooth' : 'triangle';
|
||||||
|
}
|
||||||
|
|
||||||
|
osc.connect(envelope);
|
||||||
|
const out: AudioNode = this.model === 'snes' ? this.makeSnesCrunchNode(envelope) : envelope;
|
||||||
|
out.connect(layer.filterNode);
|
||||||
|
|
||||||
|
const gainValue = (velocity * 0.3) / Math.max(chordPitches.length * 0.5, 1);
|
||||||
|
const attackBase = this.model === 'snes' ? 0.01 : 0.005;
|
||||||
|
const releaseBase = this.model === 'snes' ? 0.16 : 0.01;
|
||||||
|
const attackTime = Math.max(attackBase, Math.min(0.06, duration * 0.1));
|
||||||
|
const releaseTime = Math.max(releaseBase, Math.min(this.model === 'snes' ? 0.24 : 0.12, 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);
|
||||||
|
this.activeVoiceCount++;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
osc.disconnect();
|
||||||
|
envelope.disconnect();
|
||||||
|
if (out !== envelope) out.disconnect();
|
||||||
|
} catch {}
|
||||||
|
this.activeVoiceCount = Math.max(0, this.activeVoiceCount - 1);
|
||||||
|
}, (duration + releaseTime + 0.1) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SNES “crunch” approximation:
|
||||||
|
* - downsample to ~32kHz
|
||||||
|
* - quantize to ~12-bit
|
||||||
|
*
|
||||||
|
* Implemented via ScriptProcessorNode for broad compatibility (lab-only).
|
||||||
|
*/
|
||||||
|
private makeSnesCrunchNode(input: AudioNode): AudioNode {
|
||||||
|
const sp = this.audioContext.createScriptProcessor(1024, 1, 1);
|
||||||
|
const gain = this.audioContext.createGain();
|
||||||
|
gain.gain.value = 1.0;
|
||||||
|
|
||||||
|
const targetRate = this.snesSampleRate;
|
||||||
|
const ratio = this.audioContext.sampleRate / targetRate;
|
||||||
|
const step = Math.max(1, Math.round(ratio));
|
||||||
|
const levels = Math.pow(2, this.snesBitDepth);
|
||||||
|
|
||||||
|
let hold = 0;
|
||||||
|
let last = 0;
|
||||||
|
|
||||||
|
sp.onaudioprocess = (e) => {
|
||||||
|
const inp = e.inputBuffer.getChannelData(0);
|
||||||
|
const out = e.outputBuffer.getChannelData(0);
|
||||||
|
for (let i = 0; i < inp.length; i++) {
|
||||||
|
if (hold-- <= 0) {
|
||||||
|
hold = step;
|
||||||
|
const q = Math.max(-1, Math.min(1, inp[i]));
|
||||||
|
last = Math.round(q * (levels / 2)) / (levels / 2);
|
||||||
|
}
|
||||||
|
out[i] = last;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.connect(gain);
|
||||||
|
gain.connect(sp);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fadeOutAllLayers(): void {
|
||||||
|
const when = this.audioContext.currentTime;
|
||||||
|
for (const layer of this.layers.values()) {
|
||||||
|
layer.gainNode.gain.linearRampToValueAtTime(0, when + this.fadeTime);
|
||||||
|
}
|
||||||
|
setTimeout(() => this.cleanupLayers(), this.fadeTime * 1000 + 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupLayers(): void {
|
||||||
|
for (const layer of this.layers.values()) {
|
||||||
|
for (const osc of layer.oscillators) {
|
||||||
|
try {
|
||||||
|
osc.stop();
|
||||||
|
osc.disconnect();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
try { layer.gainNode.disconnect(); } catch {}
|
||||||
|
try { layer.filterNode.disconnect(); } catch {}
|
||||||
|
}
|
||||||
|
this.layers.clear();
|
||||||
|
this.roleAssignments.clear();
|
||||||
|
this.nextEventIndex.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
"src": "/embed",
|
"src": "/embed",
|
||||||
"dest": "/embed.html"
|
"dest": "/embed.html"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/models",
|
||||||
|
"dest": "/models.html"
|
||||||
|
},
|
||||||
{ "handle": "filesystem" },
|
{ "handle": "filesystem" },
|
||||||
{
|
{
|
||||||
"src": "/(.*)",
|
"src": "/(.*)",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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