awacke1 commited on
Commit
e4d973d
·
verified ·
1 Parent(s): d2ed540

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +463 -0
app.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ import mido
4
+ from queue import Queue
5
+ import threading
6
+
7
+ # We'll store inbound hardware MIDI in a queue and dispatch them to the browser.
8
+ if 'incoming_midi' not in st.session_state:
9
+ st.session_state.incoming_midi = Queue()
10
+
11
+ # A callback for Mido hardware input:
12
+ def midi_in_callback(msg):
13
+ """
14
+ Called in background when hardware MIDI messages arrive.
15
+ We'll just forward note_on / note_off to the session's queue.
16
+ """
17
+ if msg.type in ['note_on', 'note_off']:
18
+ event_type = 'noteOn' if msg.type == 'note_on' else 'noteOff'
19
+ st.session_state.incoming_midi.put({
20
+ 'type': event_type,
21
+ 'note': msg.note,
22
+ 'velocity': msg.velocity
23
+ })
24
+
25
+ def open_midi_input(port_name: str):
26
+ if port_name == "None":
27
+ return None
28
+ return mido.open_input(port_name, callback=midi_in_callback)
29
+
30
+ def open_midi_output(port_name: str):
31
+ if port_name == "None":
32
+ return None
33
+ return mido.open_output(port_name)
34
+
35
+ def main():
36
+ st.title("Browser-based Synth with Tone.js (5-Octave + 16 Pads)")
37
+ st.write("""
38
+ This demo uses **Tone.js** in the browser to produce audio, so no ALSA or JACK
39
+ is required on the server side. Hardware MIDI input is captured by Python
40
+ (Mido) and forwarded to the browser so Tone.js can play it.
41
+ """)
42
+
43
+ # 1. Let user pick hardware MIDI in/out
44
+ in_ports = ["None"] + mido.get_input_names()
45
+ out_ports = ["None"] + mido.get_output_names()
46
+ in_choice = st.selectbox("MIDI Input Port", in_ports)
47
+ out_choice = st.selectbox("MIDI Output Port", out_ports)
48
+
49
+ # Keep references to the open ports in session state
50
+ if 'midi_in' not in st.session_state:
51
+ st.session_state.midi_in = None
52
+ if 'midi_out' not in st.session_state:
53
+ st.session_state.midi_out = None
54
+
55
+ # If changed, close old and open new
56
+ if st.session_state.midi_in and st.session_state.midi_in.name != in_choice:
57
+ st.session_state.midi_in.close()
58
+ st.session_state.midi_in = open_midi_input(in_choice)
59
+ elif (not st.session_state.midi_in) and in_choice != "None":
60
+ st.session_state.midi_in = open_midi_input(in_choice)
61
+
62
+ if st.session_state.midi_out and st.session_state.midi_out.name != out_choice:
63
+ st.session_state.midi_out.close()
64
+ st.session_state.midi_out = open_midi_output(out_choice)
65
+ elif (not st.session_state.midi_out) and out_choice != "None":
66
+ st.session_state.midi_out = open_midi_output(out_choice)
67
+
68
+ # 2. We'll embed a large HTML with Tone.js for the UI & sound.
69
+ # It will:
70
+ # - Create a poly synth for the 5-octave keys
71
+ # - Create a sampler or player for 16 drum pads
72
+ # - Provide an optional arpeggiator toggling
73
+ # - Post "noteOn"/"noteOff"/"drum" messages to Python
74
+ # - Listen for messages from Python to trigger notes
75
+ # For brevity, let's place it in a separate variable or file.
76
+ tone_html = get_tone_html()
77
+
78
+ # 3. Insert the custom HTML component
79
+ # We'll give it enough vertical space for the layout, say 600px
80
+ components.html(tone_html, height=600, scrolling=True)
81
+
82
+ # 4. We'll poll for inbound hardware MIDI messages from st.session_state.incoming_midi
83
+ # and send them to the browser using st.session_state.js_events
84
+ if 'js_events' not in st.session_state:
85
+ # We'll store "events to the browser" in a queue or list
86
+ st.session_state.js_events = Queue()
87
+
88
+ # We also define a function to dispatch note events to the hardware out
89
+ def send_to_midi_out(evt):
90
+ if st.session_state.midi_out is not None:
91
+ from mido import Message
92
+ if evt['type'] == 'noteOn':
93
+ msg = Message('note_on', note=evt['note'], velocity=evt['velocity'])
94
+ st.session_state.midi_out.send(msg)
95
+ elif evt['type'] == 'noteOff':
96
+ msg = Message('note_off', note=evt['note'], velocity=0)
97
+ st.session_state.midi_out.send(msg)
98
+ # if drum events => you can decide how you want to handle them
99
+
100
+ # 5. We'll add a small "polling" approach to handle new hardware MIDI events
101
+ def poll_hardware_midi():
102
+ while not st.session_state.incoming_midi.empty():
103
+ evt = st.session_state.incoming_midi.get_nowait()
104
+ # Forward to browser
105
+ st.session_state.js_events.put(evt)
106
+
107
+ poll_hardware_midi()
108
+
109
+ # 6. We'll also add a placeholder to show the events from the browser
110
+ debug_placeholder = st.empty()
111
+
112
+ # 7. Hidden HTML snippet to route browser postMessage -> streamlit
113
+ # This snippet listens for "streamlit:message" from the iframe and puts them in python queues
114
+ # We'll handle them in real-time in the main loop
115
+ components.html("""
116
+ <script>
117
+ // This script listens for postMessage from the Tone.js UI
118
+ // ("window.parent.postMessage(...)"), extracts data, and re-sends
119
+ // to the Streamlit host using the "type: 'streamlit:message'" approach.
120
+ window.addEventListener('message', function(e) {
121
+ if (e.data && e.data.type === 'toneEvent') {
122
+ // forward to streamlit
123
+ window.parent.postMessage({
124
+ type: 'streamlit:message',
125
+ data: {
126
+ eventType: e.data.eventType,
127
+ note: e.data.note,
128
+ velocity: e.data.velocity,
129
+ padIndex: e.data.padIndex
130
+ }
131
+ }, '*');
132
+ }
133
+ });
134
+ </script>
135
+ """, height=0)
136
+
137
+ # 8. We'll create a function to handle inbound "toneEvent" from the browser
138
+ # (these are user clicks on the on-screen keys/pads).
139
+ # We can forward them to hardware MIDI out if desired.
140
+ if 'incoming_browser' not in st.session_state:
141
+ from collections import deque
142
+ st.session_state.incoming_browser = deque()
143
+
144
+ def handle_browser_event(e):
145
+ # e = {eventType, note, velocity, padIndex}
146
+ debug_placeholder.write(f"Browser event: {e}")
147
+ # If it's a noteOn / noteOff, optionally forward to MIDI out
148
+ if e['eventType'] in ['noteOn', 'noteOff']:
149
+ send_to_midi_out({
150
+ 'type': e['eventType'],
151
+ 'note': e['note'],
152
+ 'velocity': e['velocity']
153
+ })
154
+ elif e['eventType'] == 'drum':
155
+ # Could also forward to a hardware sampler via MIDI out (e.g., note_on)
156
+ pass
157
+
158
+ # 9. We'll do a second poll to read from st.session_state.incoming_browser
159
+ # and handle them.
160
+ while st.session_state.incoming_browser:
161
+ ev = st.session_state.incoming_browser.popleft()
162
+ handle_browser_event(ev)
163
+
164
+ # 10. We'll also do a final step:
165
+ # * If we have events in st.session_state.js_events (from hardware),
166
+ # we embed them in a <script> tag so the Tone.js page sees them.
167
+ # * We do this by writing a small custom component with <script> that calls
168
+ # e.data with postMessage.
169
+ #
170
+ # A simpler approach is to create a dynamic script each run that sends
171
+ # all queued events to the browser:
172
+ js_payload = []
173
+ while not st.session_state.js_events.empty():
174
+ event = st.session_state.js_events.get_nowait()
175
+ js_payload.append(event)
176
+
177
+ if js_payload:
178
+ # We'll embed them as a list of note events in the next script call
179
+ script_code = "<script>\n"
180
+ for evt in js_payload:
181
+ script_code += f"""
182
+ window.postMessage({{
183
+ type: 'hardwareMidi',
184
+ data: {{
185
+ eventType: '{evt['type']}',
186
+ note: {evt['note']},
187
+ velocity: {evt.get('velocity', 100)}
188
+ }}
189
+ }}, '*');
190
+ """
191
+ script_code += "\n</script>\n"
192
+ components.html(script_code, height=0)
193
+
194
+ st.write("Press keys on the on-screen 5-octave keyboard or 16 pads in the browser UI. "
195
+ "If a MIDI Input is selected, hardware note_on/off will also trigger Tone.js. "
196
+ "If a MIDI Output is selected, on-screen or inbound hardware events can be echoed back out.")
197
+
198
+
199
+ # 11. On session end, close ports
200
+ def cleanup():
201
+ if st.session_state.midi_in:
202
+ st.session_state.midi_in.close()
203
+ if st.session_state.midi_out:
204
+ st.session_state.midi_out.close()
205
+
206
+ st.on_session_end(cleanup)
207
+
208
+ def get_tone_html():
209
+ """
210
+ Returns an HTML/JS string that:
211
+ - loads Tone.js
212
+ - creates a 5-octave key + 16 drum pads UI
213
+ - sets up an arpeggiator if desired
214
+ - listens for hardwareMidi messages from Python
215
+ - uses postMessage to send 'toneEvent' to Python
216
+ For brevity, we’ll put everything inline. In production, put it in a separate .html file.
217
+ """
218
+ return r"""
219
+ <!DOCTYPE html>
220
+ <html>
221
+ <head>
222
+ <meta charset="utf-8"/>
223
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/Tone.js"></script>
224
+ <style>
225
+ body { font-family: sans-serif; margin: 10px; }
226
+ .keyboard-container, .drumpad-container { margin-bottom: 20px; }
227
+ .key { display: inline-block; width: 30px; height: 120px; margin: 1px; background: #fff; border: 1px solid #666; cursor: pointer; }
228
+ .key.black { width: 20px; height: 80px; background: #000; position: relative; margin-left: -10px; margin-right: -10px; z-index: 2; }
229
+ .key.active { background: #ff6961 !important; }
230
+ .drumpad-grid { display: grid; grid-template-columns: repeat(4, 60px); gap: 10px; }
231
+ .drumpad { width: 60px; height: 60px; background: #666; color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 5px;}
232
+ .drumpad.active { background: #ff6961; }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <h2>Browser Synth with Tone.js</h2>
237
+ <div>
238
+ <p>All audio is generated client-side.
239
+ Python just handles hardware MIDI and forwards it here.</p>
240
+ </div>
241
+
242
+ <!-- Keyboard -->
243
+ <div class="keyboard-container" id="keyboard"></div>
244
+
245
+ <!-- Drum pads -->
246
+ <h3>Drum Pads</h3>
247
+ <div class="drumpad-container">
248
+ <div class="drumpad-grid" id="drumpads">
249
+ </div>
250
+ </div>
251
+
252
+ <script>
253
+ // ======================================================
254
+ // 1) Create Tone.js instruments
255
+ // - a poly synth for melodic keys
256
+ // - a sampler or a couple of loaded drum samples
257
+ // ======================================================
258
+ const synth = new Tone.PolySynth(Tone.Synth, {
259
+ oscillator: { type: 'triangle' },
260
+ envelope: { attack: 0.01, decay: 0.2, sustain: 0.4, release: 1 }
261
+ }).toDestination();
262
+
263
+ // For drums, let's just do a simple Sampler
264
+ const drumSampler = new Tone.Sampler({
265
+ C1: "https://tonejs.github.io/audio/drum-samples/breakbeat/kick.mp3",
266
+ D1: "https://tonejs.github.io/audio/drum-samples/breakbeat/snare.mp3",
267
+ E1: "https://tonejs.github.io/audio/drum-samples/breakbeat/hh.mp3",
268
+ F1: "https://tonejs.github.io/audio/drum-samples/breakbeat/hho.mp3"
269
+ }, { onload: () => console.log("Drum samples loaded") }
270
+ ).toDestination();
271
+
272
+ // We'll map 16 pads to 4 sample notes repeatedly:
273
+ const padMapping = [
274
+ "C1","D1","E1","F1",
275
+ "C1","D1","E1","F1",
276
+ "C1","D1","E1","F1",
277
+ "C1","D1","E1","F1",
278
+ ];
279
+
280
+ // ======================================================
281
+ // 2) Build a 5-octave keyboard from, say, C3 to C7
282
+ // We'll do a naive approach for demonstration
283
+ // ======================================================
284
+ const noteArray = [
285
+ // One octave C, C#, D, D#, E, F, F#, G, G#, A, A#, B
286
+ // We'll build for C3..B7
287
+ ];
288
+ const startOctave = 3;
289
+ const endOctave = 7;
290
+
291
+ function buildNotes() {
292
+ const noteNames = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
293
+ for (let octave = startOctave; octave <= endOctave; octave++) {
294
+ for (let n of noteNames) {
295
+ noteArray.push(n + octave);
296
+ }
297
+ }
298
+ }
299
+ buildNotes();
300
+
301
+ const keyboardDiv = document.getElementById('keyboard');
302
+ noteArray.forEach((note, idx) => {
303
+ // Determine if black or white
304
+ const hasSharp = note.includes("#");
305
+ let keyDiv = document.createElement('div');
306
+ keyDiv.classList.add('key');
307
+ if (hasSharp) {
308
+ keyDiv.classList.add('black');
309
+ }
310
+ keyDiv.dataset.note = note;
311
+ keyboardDiv.appendChild(keyDiv);
312
+
313
+ keyDiv.addEventListener('mousedown', () => {
314
+ playNote(note, 100);
315
+ keyDiv.classList.add('active');
316
+ });
317
+ keyDiv.addEventListener('mouseup', () => {
318
+ releaseNote(note);
319
+ keyDiv.classList.remove('active');
320
+ });
321
+ // For mobile / touch
322
+ keyDiv.addEventListener('touchstart', (e) => {
323
+ e.preventDefault();
324
+ playNote(note, 100);
325
+ keyDiv.classList.add('active');
326
+ });
327
+ keyDiv.addEventListener('touchend', (e) => {
328
+ e.preventDefault();
329
+ releaseNote(note);
330
+ keyDiv.classList.remove('active');
331
+ });
332
+ });
333
+
334
+ // ======================================================
335
+ // 3) Build 16 drum pads
336
+ // ======================================================
337
+ const drumpadsDiv = document.getElementById('drumpads');
338
+ for (let i=0; i<16; i++){
339
+ let pad = document.createElement('div');
340
+ pad.classList.add('drumpad');
341
+ pad.innerText = `Pad ${i+1}`;
342
+ pad.dataset.pad = i;
343
+ drumpadsDiv.appendChild(pad);
344
+
345
+ pad.addEventListener('mousedown', () => {
346
+ triggerDrum(i, 100);
347
+ pad.classList.add('active');
348
+ setTimeout(() => { pad.classList.remove('active'); }, 200);
349
+ });
350
+ // Touch
351
+ pad.addEventListener('touchstart', (e)=>{
352
+ e.preventDefault();
353
+ triggerDrum(i, 100);
354
+ pad.classList.add('active');
355
+ setTimeout(()=> pad.classList.remove('active'), 200);
356
+ });
357
+ }
358
+
359
+ // ======================================================
360
+ // 4) Tone.js note on/off
361
+ // ======================================================
362
+ function playNote(noteName, velocity){
363
+ // velocity is 0..127 => scale 0..1
364
+ let vol = velocity / 127;
365
+ // start an audio context, required in some browsers
366
+ Tone.context.resume();
367
+ synth.triggerAttack(noteName, Tone.now(), vol);
368
+ // Send message to python
369
+ window.parent.postMessage({
370
+ type: 'toneEvent',
371
+ eventType: 'noteOn',
372
+ note: midiNoteNumber(noteName),
373
+ velocity: velocity
374
+ }, '*');
375
+ }
376
+
377
+ function releaseNote(noteName){
378
+ synth.triggerRelease(noteName, Tone.now());
379
+ // Send message to python
380
+ window.parent.postMessage({
381
+ type: 'toneEvent',
382
+ eventType: 'noteOff',
383
+ note: midiNoteNumber(noteName),
384
+ velocity: 0
385
+ }, '*');
386
+ }
387
+
388
+ function triggerDrum(padIndex, velocity){
389
+ Tone.context.resume();
390
+ let note = padMapping[padIndex % padMapping.length];
391
+ let vol = velocity / 127;
392
+ drumSampler.triggerAttack(note, Tone.now(), vol);
393
+ // Also send to python
394
+ window.parent.postMessage({
395
+ type: 'toneEvent',
396
+ eventType: 'drum',
397
+ padIndex: padIndex,
398
+ velocity: velocity
399
+ }, '*');
400
+ }
401
+
402
+ // ======================================================
403
+ // 5) Convert from note name to approximate MIDI number
404
+ // We'll do a naive approach. If you want a robust
405
+ // approach, store a dictionary or parse note name.
406
+ // ======================================================
407
+ function midiNoteNumber(noteStr){
408
+ // parse e.g. "C#4" => base + semitone + octave
409
+ // There's a standard formula: midi = 12 * (octave + 1) + noteIndex
410
+ // We can do a small map:
411
+ const noteMap = {'C':0,'C#':1,'D':2,'D#':3,'E':4,'F':5,'F#':6,'G':7,'G#':8,'A':9,'A#':10,'B':11};
412
+ let match = noteStr.match(/^([A-G]#?)(\d)$/);
413
+ if(!match) return 60;
414
+ let base = noteMap[match[1]];
415
+ let oct = parseInt(match[2]);
416
+ return 12*(oct+1) + base;
417
+ }
418
+
419
+ // ======================================================
420
+ // 6) Listen for "hardwareMidi" messages from Python
421
+ // i.e. user played a note on their real keyboard
422
+ // We'll automatically call "playNote"/"releaseNote" in Tone
423
+ // so it comes out the browser audio
424
+ // ======================================================
425
+ window.addEventListener('message', (e)=>{
426
+ if(e.data && e.data.type === 'hardwareMidi'){
427
+ let evt = e.data.data;
428
+ if(evt.eventType === 'noteOn'){
429
+ // We'll need to map MIDI note -> note name
430
+ // We'll do a quick reverse map. For robust code, do a real function or table.
431
+ let noteName = midiToNoteName(evt.note);
432
+ playNote(noteName, evt.velocity);
433
+ } else if(evt.eventType === 'noteOff'){
434
+ let noteName = midiToNoteName(evt.note);
435
+ releaseNote(noteName);
436
+ }
437
+ }
438
+ });
439
+
440
+ function midiToNoteName(midiNum){
441
+ const noteNames = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
442
+ let octave = Math.floor(midiNum / 12) - 1;
443
+ let noteIndex = midiNum % 12;
444
+ return noteNames[noteIndex] + octave;
445
+ }
446
+
447
+ // ======================================================
448
+ // 7) (Optional) In-browser arpeggiator, if you want it
449
+ // You could do something like keep track of notes held
450
+ // in an array and a Tone.Pattern or Tone.Sequence
451
+ // ======================================================
452
+ // For demonstration, we'll skip a full arpeggiator code snippet here.
453
+ // If you want to do it, you'd maintain an array of held notes, then
454
+ // create a Tone.Pattern or Tone.Loop that triggers them in sequence.
455
+
456
+ // End of HTML
457
+ </script>
458
+ </body>
459
+ </html>
460
+ """.strip()
461
+
462
+ if __name__ == "__main__":
463
+ main()