pachet commited on
Commit
014c4ac
·
1 Parent(s): 954412a

Improve browsing controls and MIDI output

Browse files
README.md CHANGED
@@ -15,4 +15,11 @@ FastAPI web app for browsing the musical theme dictionary, playing catalog
15
  themes, generating new themes with variable-order Markov or transformer engines,
16
  and exporting MIDI, ABC, MusicXML, and Verovio-rendered SVG scores.
17
 
 
 
 
 
 
 
 
18
  The Docker image starts `apps.theme_lab.app` on port 7860.
 
15
  themes, generating new themes with variable-order Markov or transformer engines,
16
  and exporting MIDI, ABC, MusicXML, and Verovio-rendered SVG scores.
17
 
18
+ This Space is based on Harold Barlow and Sam Morgenstern's *A Dictionary of
19
+ Musical Themes* (Crown Publishers, New York, 1949), a printed reference catalog
20
+ of classical themes intended for identifying works from short melodic incipits.
21
+ The digitized working corpus lives in the GitHub repository
22
+ [`fpachet/musical_themes`](https://github.com/fpachet/musical_themes), including
23
+ the `midis/` directory used by the app.
24
+
25
  The Docker image starts `apps.theme_lab.app` on port 7860.
apps/theme_lab/app.py CHANGED
@@ -142,6 +142,37 @@ def stats() -> dict[str, object]:
142
  }
143
 
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  @app.get("/api/themes")
146
  def list_themes(
147
  q: str = "",
 
142
  }
143
 
144
 
145
+ @app.get("/api/composers")
146
+ def list_composers(
147
+ q: str = "",
148
+ limit: int = Query(12, ge=1, le=40),
149
+ ) -> dict[str, object]:
150
+ params: list[object] = []
151
+ where = ["parse_error IS NULL", "composer IS NOT NULL", "composer != ''"]
152
+ if q.strip():
153
+ words = [word for word in q.strip().split() if word]
154
+ for word in words:
155
+ where.append("composer LIKE ?")
156
+ params.append(f"%{word}%")
157
+ params.append(limit)
158
+ sql = f"""
159
+ SELECT composer, COUNT(*) AS count
160
+ FROM themes
161
+ WHERE {' AND '.join(where)}
162
+ GROUP BY composer
163
+ ORDER BY
164
+ CASE WHEN composer LIKE ? THEN 0 ELSE 1 END,
165
+ count DESC,
166
+ composer
167
+ LIMIT ?
168
+ """
169
+ starts_with = f"{q.strip()}%" if q.strip() else "%"
170
+ query_params = [*params[:-1], starts_with, params[-1]]
171
+ with connect_db() as conn:
172
+ rows = conn.execute(sql, query_params).fetchall()
173
+ return {"items": [dict(row) for row in rows]}
174
+
175
+
176
  @app.get("/api/themes")
177
  def list_themes(
178
  q: str = "",
apps/theme_lab/static/app.js CHANGED
@@ -5,6 +5,9 @@ const state = {
5
  currentSource: null,
6
  audioContext: null,
7
  scheduled: [],
 
 
 
8
  };
9
 
10
  const $ = (id) => document.getElementById(id);
@@ -19,6 +22,11 @@ function setStatus(message, ok = true) {
19
  el.className = ok ? "status-ok" : "status-bad";
20
  }
21
 
 
 
 
 
 
22
  async function api(path, options = {}) {
23
  const response = await fetch(path, {
24
  headers: { "Content-Type": "application/json" },
@@ -37,6 +45,19 @@ async function api(path, options = {}) {
37
  return response.json();
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  async function loadStats() {
41
  const [stats, health] = await Promise.all([api("/api/stats"), api("/api/health")]);
42
  $("statsLine").textContent = `${stats.themes.toLocaleString()} themes, ${stats.notes.toLocaleString()} notes`;
@@ -138,15 +159,109 @@ function midiToHz(pitch) {
138
  function stopPlayback() {
139
  for (const node of state.scheduled) {
140
  try {
141
- node.stop();
 
 
 
 
142
  } catch {
143
  // Already stopped.
144
  }
145
  }
146
  state.scheduled = [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
 
149
  async function playNotes(notes, bpm = 120) {
 
 
 
 
 
 
150
  stopPlayback();
151
  if (!state.audioContext) {
152
  state.audioContext = new AudioContext();
@@ -286,6 +401,13 @@ function bindEvents() {
286
  $("playTheme").addEventListener("click", playCurrentTheme);
287
  $("stopPlayback").addEventListener("click", stopPlayback);
288
  $("generateButton").addEventListener("click", generate);
 
 
 
 
 
 
 
289
  }
290
 
291
  async function init() {
 
5
  currentSource: null,
6
  audioContext: null,
7
  scheduled: [],
8
+ midiAccess: null,
9
+ midiOutputId: "",
10
+ composerSuggestTimer: null,
11
  };
12
 
13
  const $ = (id) => document.getElementById(id);
 
22
  el.className = ok ? "status-ok" : "status-bad";
23
  }
24
 
25
+ function debounceComposerSuggestions() {
26
+ window.clearTimeout(state.composerSuggestTimer);
27
+ state.composerSuggestTimer = window.setTimeout(loadComposerSuggestions, 160);
28
+ }
29
+
30
  async function api(path, options = {}) {
31
  const response = await fetch(path, {
32
  headers: { "Content-Type": "application/json" },
 
45
  return response.json();
46
  }
47
 
48
+ async function loadComposerSuggestions() {
49
+ const query = $("composerInput").value.trim();
50
+ const payload = await api(`/api/composers?${new URLSearchParams({ q: query, limit: 14 })}`);
51
+ const list = $("composerSuggestions");
52
+ list.replaceChildren();
53
+ for (const item of payload.items) {
54
+ const option = document.createElement("option");
55
+ option.value = item.composer;
56
+ option.label = `${item.count} themes`;
57
+ list.append(option);
58
+ }
59
+ }
60
+
61
  async function loadStats() {
62
  const [stats, health] = await Promise.all([api("/api/stats"), api("/api/health")]);
63
  $("statsLine").textContent = `${stats.themes.toLocaleString()} themes, ${stats.notes.toLocaleString()} notes`;
 
159
  function stopPlayback() {
160
  for (const node of state.scheduled) {
161
  try {
162
+ if (typeof node === "number") {
163
+ window.clearTimeout(node);
164
+ } else {
165
+ node.stop();
166
+ }
167
  } catch {
168
  // Already stopped.
169
  }
170
  }
171
  state.scheduled = [];
172
+ const output = selectedMidiOutput();
173
+ if (output) {
174
+ if (typeof output.clear === "function") output.clear();
175
+ for (let channel = 0; channel < 16; channel += 1) {
176
+ output.send([0xb0 + channel, 123, 0]);
177
+ output.send([0xb0 + channel, 120, 0]);
178
+ }
179
+ }
180
+ }
181
+
182
+ function selectedMidiOutput() {
183
+ if (!state.midiAccess || !state.midiOutputId) return null;
184
+ return state.midiAccess.outputs.get(state.midiOutputId) || null;
185
+ }
186
+
187
+ async function requestMidiAccess() {
188
+ if (!("requestMIDIAccess" in navigator)) {
189
+ setStatus("Web MIDI is not available in this browser", false);
190
+ return null;
191
+ }
192
+ if (!state.midiAccess) {
193
+ state.midiAccess = await navigator.requestMIDIAccess();
194
+ state.midiAccess.addEventListener("statechange", renderMidiOutputs);
195
+ }
196
+ renderMidiOutputs();
197
+ return state.midiAccess;
198
+ }
199
+
200
+ function renderMidiOutputs() {
201
+ const select = $("midiOutputSelect");
202
+ const previous = select.value || state.midiOutputId;
203
+ select.replaceChildren();
204
+
205
+ const browser = document.createElement("option");
206
+ browser.value = "";
207
+ browser.textContent = "Browser audio";
208
+ select.append(browser);
209
+
210
+ if (state.midiAccess) {
211
+ for (const output of state.midiAccess.outputs.values()) {
212
+ const option = document.createElement("option");
213
+ option.value = output.id;
214
+ option.textContent = output.name || output.manufacturer || output.id;
215
+ select.append(option);
216
+ }
217
+ }
218
+
219
+ if ([...select.options].some((option) => option.value === previous)) {
220
+ select.value = previous;
221
+ }
222
+ state.midiOutputId = select.value;
223
+ }
224
+
225
+ async function refreshMidiOutputs() {
226
+ try {
227
+ await requestMidiAccess();
228
+ const count = state.midiAccess ? state.midiAccess.outputs.size : 0;
229
+ setStatus(count ? `${count} MIDI output${count === 1 ? "" : "s"}` : "No MIDI outputs found", Boolean(count));
230
+ } catch (error) {
231
+ setStatus(error.message, false);
232
+ }
233
+ }
234
+
235
+ function playMidiNotes(notes, bpm = 120, output) {
236
+ stopPlayback();
237
+ const secondsPerBeat = 60 / bpm;
238
+ const startAt = performance.now() + 70;
239
+ for (const note of notes) {
240
+ const pitch = Number(note.pitch);
241
+ const velocity = Math.max(1, Math.min(127, Number(note.velocity || 72)));
242
+ const begin = startAt + Number(note.start_beat) * secondsPerBeat * 1000;
243
+ const duration = Math.max(0.04, Number(note.duration_beats) * secondsPerBeat);
244
+ const end = begin + duration * 1000;
245
+ output.send([0x90, pitch, velocity], begin);
246
+ output.send([0x80, pitch, 0], end);
247
+ }
248
+ const lastEnd = Math.max(
249
+ 0,
250
+ ...notes.map((note) => (Number(note.start_beat) + Number(note.duration_beats)) * secondsPerBeat * 1000),
251
+ );
252
+ const timeout = window.setTimeout(() => {
253
+ state.scheduled = state.scheduled.filter((item) => item !== timeout);
254
+ }, lastEnd + 200);
255
+ state.scheduled.push(timeout);
256
  }
257
 
258
  async function playNotes(notes, bpm = 120) {
259
+ const midiOutput = selectedMidiOutput();
260
+ if (midiOutput) {
261
+ playMidiNotes(notes, bpm, midiOutput);
262
+ return;
263
+ }
264
+
265
  stopPlayback();
266
  if (!state.audioContext) {
267
  state.audioContext = new AudioContext();
 
401
  $("playTheme").addEventListener("click", playCurrentTheme);
402
  $("stopPlayback").addEventListener("click", stopPlayback);
403
  $("generateButton").addEventListener("click", generate);
404
+ $("refreshMidiOutputs").addEventListener("click", refreshMidiOutputs);
405
+ $("midiOutputSelect").addEventListener("change", (event) => {
406
+ state.midiOutputId = event.target.value;
407
+ stopPlayback();
408
+ });
409
+ $("composerInput").addEventListener("input", debounceComposerSuggestions);
410
+ $("composerInput").addEventListener("focus", loadComposerSuggestions);
411
  }
412
 
413
  async function init() {
apps/theme_lab/static/index.html CHANGED
@@ -25,7 +25,8 @@
25
  <div class="field-row">
26
  <label>
27
  <span>Composer</span>
28
- <input id="composerInput" type="search" placeholder="Bach" autocomplete="off" />
 
29
  </label>
30
  <label>
31
  <span>Key</span>
@@ -69,6 +70,15 @@
69
  <button id="stopPlayback" class="secondary">Stop</button>
70
  </div>
71
  </div>
 
 
 
 
 
 
 
 
 
72
  <div class="score-surface">
73
  <img id="scoreImage" alt="" />
74
  <div id="emptyScore" class="empty-state">No score selected</div>
 
25
  <div class="field-row">
26
  <label>
27
  <span>Composer</span>
28
+ <input id="composerInput" type="search" placeholder="Bach" list="composerSuggestions" autocomplete="off" />
29
+ <datalist id="composerSuggestions"></datalist>
30
  </label>
31
  <label>
32
  <span>Key</span>
 
70
  <button id="stopPlayback" class="secondary">Stop</button>
71
  </div>
72
  </div>
73
+ <div class="playback-row">
74
+ <label>
75
+ <span>Output</span>
76
+ <select id="midiOutputSelect">
77
+ <option value="">Browser audio</option>
78
+ </select>
79
+ </label>
80
+ <button id="refreshMidiOutputs" type="button" class="secondary">Scan MIDI</button>
81
+ </div>
82
  <div class="score-surface">
83
  <img id="scoreImage" alt="" />
84
  <div id="emptyScore" class="empty-state">No score selected</div>
apps/theme_lab/static/styles.css CHANGED
@@ -25,6 +25,12 @@ body {
25
  letter-spacing: 0;
26
  }
27
 
 
 
 
 
 
 
28
  button,
29
  input,
30
  select {
@@ -73,7 +79,8 @@ a:hover {
73
  .app-shell {
74
  display: grid;
75
  grid-template-columns: minmax(320px, 390px) 1fr;
76
- min-height: 100vh;
 
77
  }
78
 
79
  .sidebar {
@@ -81,7 +88,9 @@ a:hover {
81
  flex-direction: column;
82
  gap: 14px;
83
  min-width: 0;
 
84
  padding: 18px;
 
85
  background: #eef1f5;
86
  border-right: 1px solid var(--line);
87
  }
@@ -159,7 +168,8 @@ select {
159
 
160
  .field-row,
161
  .toolbar,
162
- .export-row {
 
163
  display: flex;
164
  gap: 8px;
165
  align-items: center;
@@ -171,9 +181,12 @@ select {
171
 
172
  .theme-list {
173
  display: grid;
 
174
  gap: 8px;
 
175
  overflow: auto;
176
  padding-right: 2px;
 
177
  }
178
 
179
  .theme-item {
@@ -233,6 +246,8 @@ select {
233
  grid-template-rows: minmax(380px, 58vh) minmax(320px, auto);
234
  gap: 16px;
235
  min-width: 0;
 
 
236
  padding: 18px;
237
  }
238
 
@@ -255,6 +270,7 @@ select {
255
  flex: 1;
256
  min-height: 260px;
257
  overflow: auto;
 
258
  border: 1px solid var(--line);
259
  border-radius: 8px;
260
  background: #fff;
@@ -287,6 +303,15 @@ select {
287
  flex-wrap: wrap;
288
  }
289
 
 
 
 
 
 
 
 
 
 
290
  .export-row a {
291
  border: 1px solid var(--line);
292
  border-radius: 6px;
@@ -361,15 +386,23 @@ select {
361
  @media (max-width: 940px) {
362
  .app-shell {
363
  grid-template-columns: 1fr;
 
 
364
  }
365
 
366
  .sidebar {
367
  border-right: 0;
368
  border-bottom: 1px solid var(--line);
 
 
 
 
 
369
  }
370
 
371
  .workspace {
372
  grid-template-rows: auto auto;
 
373
  }
374
 
375
  .generator-grid {
@@ -379,7 +412,8 @@ select {
379
 
380
  @media (max-width: 520px) {
381
  .field-row,
382
- .toolbar {
 
383
  flex-wrap: wrap;
384
  }
385
 
 
25
  letter-spacing: 0;
26
  }
27
 
28
+ @media (min-width: 941px) {
29
+ body {
30
+ overflow: hidden;
31
+ }
32
+ }
33
+
34
  button,
35
  input,
36
  select {
 
79
  .app-shell {
80
  display: grid;
81
  grid-template-columns: minmax(320px, 390px) 1fr;
82
+ height: 100vh;
83
+ min-height: 0;
84
  }
85
 
86
  .sidebar {
 
88
  flex-direction: column;
89
  gap: 14px;
90
  min-width: 0;
91
+ min-height: 0;
92
  padding: 18px;
93
+ overflow: hidden;
94
  background: #eef1f5;
95
  border-right: 1px solid var(--line);
96
  }
 
168
 
169
  .field-row,
170
  .toolbar,
171
+ .export-row,
172
+ .playback-row {
173
  display: flex;
174
  gap: 8px;
175
  align-items: center;
 
181
 
182
  .theme-list {
183
  display: grid;
184
+ flex: 1;
185
  gap: 8px;
186
+ min-height: 0;
187
  overflow: auto;
188
  padding-right: 2px;
189
+ overscroll-behavior: contain;
190
  }
191
 
192
  .theme-item {
 
246
  grid-template-rows: minmax(380px, 58vh) minmax(320px, auto);
247
  gap: 16px;
248
  min-width: 0;
249
+ min-height: 0;
250
+ overflow: auto;
251
  padding: 18px;
252
  }
253
 
 
270
  flex: 1;
271
  min-height: 260px;
272
  overflow: auto;
273
+ overscroll-behavior: contain;
274
  border: 1px solid var(--line);
275
  border-radius: 8px;
276
  background: #fff;
 
303
  flex-wrap: wrap;
304
  }
305
 
306
+ .playback-row {
307
+ align-items: end;
308
+ }
309
+
310
+ .playback-row label {
311
+ max-width: 320px;
312
+ flex: 1;
313
+ }
314
+
315
  .export-row a {
316
  border: 1px solid var(--line);
317
  border-radius: 6px;
 
386
  @media (max-width: 940px) {
387
  .app-shell {
388
  grid-template-columns: 1fr;
389
+ height: auto;
390
+ min-height: 100vh;
391
  }
392
 
393
  .sidebar {
394
  border-right: 0;
395
  border-bottom: 1px solid var(--line);
396
+ overflow: visible;
397
+ }
398
+
399
+ .theme-list {
400
+ max-height: 45vh;
401
  }
402
 
403
  .workspace {
404
  grid-template-rows: auto auto;
405
+ overflow: visible;
406
  }
407
 
408
  .generator-grid {
 
412
 
413
  @media (max-width: 520px) {
414
  .field-row,
415
+ .toolbar,
416
+ .playback-row {
417
  flex-wrap: wrap;
418
  }
419