Hezu06 commited on
Commit
5136ade
·
1 Parent(s): 741f5d6

refactor WebSocket integration for real-time MIDI generation, enhance AI music generation logic, and implement MIDI pitch clamping

Browse files
Files changed (2) hide show
  1. index.html +92 -47
  2. main.py +81 -267
index.html CHANGED
@@ -5,6 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Bio-Vibe Web</title>
7
  <link rel="icon" href="data:,">
 
8
  <style>
9
  body {
10
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
@@ -452,66 +453,110 @@
452
  // ===============================
453
  // GENERATE MUSIC
454
  // ===============================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  btnGenerate.addEventListener('click', async () => {
456
  if (currentBpm === 0) {
457
  alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.');
458
  return;
459
  }
460
 
461
- if (currentAudio) {
462
- currentAudio.pause();
463
- currentAudio.currentTime = 0;
464
- bpmDisplay.style.animation = 'none';
465
- }
466
-
467
- setStatus(`Đang gửi ${currentBpm} BPM cho AI`, 'info');
468
- btnGenerate.textContent = 'AI đang sáng tác…';
469
- btnGenerate.disabled = true;
470
- btnStop.disabled = true;
471
-
472
- try {
473
- const res = await fetch('/generate-vibe', {
474
- method: 'POST',
475
- headers: { 'Content-Type': 'application/json' },
476
- body: JSON.stringify({ bpm: currentBpm })
477
- });
478
- const data = await res.json();
479
-
480
- if (data.status === 'success') {
481
- currentAudio = new Audio(data.audio_url);
482
- currentAudio.play();
483
- bpmDisplay.style.animation = 'pulse 1s infinite';
484
- setStatus('🎵 Đang phát nhạc…', 'ok');
485
- btnStop.disabled = false;
486
-
487
- currentAudio.onended = () => {
488
- bpmDisplay.style.animation = 'none';
489
- btnStop.disabled = true;
490
- setStatus('Nhạc đã kết thúc.', 'info');
491
- };
492
- } else {
493
- setStatus('Lỗi AI: ' + (data.message || 'unknown'), 'err');
494
- }
495
- } catch (err) {
496
- log('Generate error: ' + err.message);
497
- setStatus('Mất kết nối server!', 'err');
498
- } finally {
 
 
499
  btnGenerate.textContent = '2. Sinh nhạc AI';
500
- btnGenerate.disabled = false;
501
- }
 
 
502
  });
503
 
504
  // ===============================
505
- // STOP MUSIC
506
  // ===============================
507
  btnStop.addEventListener('click', () => {
508
- if (currentAudio) {
509
- currentAudio.pause();
510
- currentAudio.currentTime = 0;
511
- bpmDisplay.style.animation = 'none';
512
- btnStop.disabled = true;
513
- setStatus('Đã dừng nhạc.', 'info');
514
  }
 
 
515
  });
516
  </script>
517
  </body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>Bio-Vibe Web</title>
7
  <link rel="icon" href="data:,">
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
9
  <style>
10
  body {
11
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 
453
  // ===============================
454
  // GENERATE MUSIC
455
  // ===============================
456
+ // ===============================
457
+ // THIẾT LẬP NHẠC CỤ (TONE.JS)
458
+ // ===============================
459
+ // 1. Tạo hiệu ứng: Reverb rộng rãi và Lowpass Filter (cắt treble chói)
460
+ const filter = new Tone.Filter(800, "lowpass").toDestination();
461
+ const reverb = new Tone.Reverb({ decay: 5, wet: 0.6 }).connect(filter);
462
+
463
+ // 2. Dùng PolySynth (nhạc cụ ảo) thay vì Sampler (file mp3)
464
+ // FMSynth tạo ra âm sắc giống chuông/piano điện, rất êm ái
465
+ const piano = new Tone.PolySynth(Tone.FMSynth, {
466
+ harmonicity: 8,
467
+ modulationIndex: 2,
468
+ oscillator: {
469
+ type: "sine" // Sóng sine nghe êm nhất
470
+ },
471
+ envelope: {
472
+ attack: 0.05,
473
+ decay: 0.3,
474
+ sustain: 0.1,
475
+ release: 1.2 // Vang nhẹ khi buông nốt
476
+ },
477
+ modulation: {
478
+ type: "square"
479
+ },
480
+ modulationEnvelope: {
481
+ attack: 0.01,
482
+ decay: 0.5,
483
+ sustain: 0.2,
484
+ release: 0.1
485
+ }
486
+ }).connect(reverb);
487
+
488
+ // Do không cần tải file, coi như nhạc cụ luôn sẵn sàng
489
+ log("🎹 Đã khởi tạo Synthesizer.");
490
+ setStatus("Sẵn sàng sinh nhạc AI", "ok");
491
+
492
+ let ws = null; // Biến giữ kết nối WebSocket
493
+
494
+ // ===============================
495
+ // GENERATE MUSIC (CẬP NHẬT WEBSOCKET)
496
+ // ===============================
497
  btnGenerate.addEventListener('click', async () => {
498
  if (currentBpm === 0) {
499
  alert('Chưa có BPM! Kết nối đồng hồ hoặc nhập thủ công.');
500
  return;
501
  }
502
 
503
+ // Bắt buộc trình duyệt cho phép phát âm thanh
504
+ await Tone.start();
505
+
506
+ if (ws) ws.close(); // Đóng kết nối cũ nếu có
507
+
508
+ setStatus(`Đang kết nối não AI với ${currentBpm} BPM…`, 'info');
509
+ btnGenerate.textContent = 'AI đang truyền nốt…';
510
+ btnGenerate.disabled = true;
511
+ btnStop.disabled = false;
512
+ bpmDisplay.style.animation = 'pulse 1s infinite';
513
+
514
+ // Mở kết nối WebSocket tới Server
515
+ const wsUrl = `ws://${window.location.host}/ws/vibe`;
516
+ ws = new WebSocket(wsUrl);
517
+
518
+ ws.onopen = () => {
519
+ setStatus('🎵 Đang phát nhạc Real-time vô tận…', 'ok');
520
+ // Gửi BPM lên server để AI lấy tempo
521
+ ws.send(JSON.stringify({ bpm: currentBpm }));
522
+ };
523
+
524
+ ws.onmessage = (event) => {
525
+ const data = JSON.parse(event.data);
526
+
527
+ // Tone.js hỗ trợ đọc trực tiếp số MIDI
528
+ const freq = Tone.Frequency(data.note, "midi").toNote();
529
+
530
+ // Velocity của Tone.js nằm trong khoảng 0.0 - 1.0
531
+ const normalizedVelocity = data.velocity / 127;
532
+
533
+ // Đánh nốt đàn ngay lập tức
534
+ piano.triggerAttackRelease(freq, data.duration, Tone.now(), normalizedVelocity);
535
+ };
536
+
537
+ ws.onerror = (err) => {
538
+ log('WebSocket Error: ' + err);
539
+ setStatus('Lỗi kết nối tới AI!', 'err');
540
+ };
541
+
542
+ ws.onclose = () => {
543
  btnGenerate.textContent = '2. Sinh nhạc AI';
544
+ btnGenerate.disabled = false;
545
+ btnStop.disabled = true;
546
+ bpmDisplay.style.animation = 'none';
547
+ };
548
  });
549
 
550
  // ===============================
551
+ // STOP MUSIC (CẬP NHẬT WEBSOCKET)
552
  // ===============================
553
  btnStop.addEventListener('click', () => {
554
+ if (ws) {
555
+ ws.close(); // Cắt kết nối, Server sẽ tự động dừng suy luận
556
+ ws = null;
 
 
 
557
  }
558
+ piano.releaseAll(); // Ngắt ngay lập tức các âm vang còn sót lại
559
+ setStatus('Đã dừng nhạc.', 'info');
560
  });
561
  </script>
562
  </body>
main.py CHANGED
@@ -2,6 +2,9 @@ from fastapi import FastAPI, Request
2
  from fastapi.staticfiles import StaticFiles
3
  from fastapi.responses import FileResponse
4
  from pydantic import BaseModel
 
 
 
5
  from pydub import AudioSegment
6
  from pydub.effects import normalize, compress_dynamic_range
7
  import mido
@@ -77,275 +80,86 @@ model.eval() # Chuyển sang chế độ suy luận
77
  # Nạp kho dữ liệu cũ để lấy Seed (đoạn nhạc mồi)
78
  with open('notes_corpus.pkl', 'rb') as f:
79
  corpus = pickle.load(f)
80
-
81
- def generate_ai_midi(bpm: int, output_file: str, num_notes: int = 40):
82
- temperature = max(0.5, min(1.1, 0.6 + (bpm - 60) / 100))
83
-
84
- PENTATONIC_MIDI = [60, 62, 64, 67, 69, 72, 74, 76] # C D E G A (2 octave)
85
-
86
- # Chord prog: (alberti notes, bass midi)
87
- CHORD_PROG = [
88
- ([60, 64, 67], 48), # C major → bass C3
89
- ([57, 60, 64], 45), # A minor → bass A2
90
- ([53, 57, 60], 41), # F major → bass F2
91
- ([55, 59, 62], 43), # G major → bass G2
92
- ]
93
- BEATS_PER_CHORD = 4
94
-
95
- RHYTHM_PATTERNS = [
96
- [1.0, 1.0, 1.0, 1.0],
97
- [0.5, 0.5, 1.0, 1.0, 1.0],
98
- [1.0, 0.5, 0.5, 1.0, 1.0],
99
- [1.5, 0.5, 1.0, 1.0],
100
- ]
101
-
102
- # ── Tốc độ: seed 50, tensor tái dùng, inference_mode ──────────────────
103
- SEQ_LEN = 50
104
- seed = random.sample(corpus, SEQ_LEN)
105
- pattern = [note_to_int[n] for n in seed]
106
- inp_tensor = torch.zeros(1, SEQ_LEN, dtype=torch.long)
107
-
108
- raw_generated = []
109
- with torch.inference_mode():
110
- for _ in range(num_notes):
111
- inp_tensor[0] = torch.tensor(pattern, dtype=torch.long)
112
- pred = model(inp_tensor)
113
- probs = torch.softmax(pred / temperature, dim=1).numpy()[0]
114
- top_idx = np.argsort(probs)[-8:]
115
- top_p = probs[top_idx]; top_p /= top_p.sum()
116
- idx = np.random.choice(top_idx, p=top_p)
117
- raw_generated.append(int_to_note[idx])
118
- pattern = pattern[1:] + [idx]
119
-
120
- # ── Extract MIDI pitch ─────────────────────────────────────────────────
121
- def token_to_midi(tok):
122
- parts = tok.split("_")
123
- try:
124
- if parts[0] == "NOTE":
125
- return note.Note(parts[1]).pitch.midi
126
- elif parts[0] == "CHORD":
127
- return note.Note(parts[1].split(".")[0]).pitch.midi
128
- except:
129
- pass
130
- return None
131
-
132
- raw_midis = [m for tok in raw_generated if (m := token_to_midi(tok)) is not None]
133
-
134
- # ── BUG FIX: Fallback nếu model gen toàn REST/lỗi ─────────────────────
135
- if len(raw_midis) < 8:
136
- print(f"⚠️ raw_midis chỉ có {len(raw_midis)} nốt → dùng pentatonic fallback")
137
- raw_midis = [random.choice(PENTATONIC_MIDI) for _ in range(40)]
138
-
139
- # ── Snap + smooth ──────────────────────────────────────────────────────
140
- def snap(midi_val):
141
- return min(PENTATONIC_MIDI, key=lambda p: abs(p - midi_val))
142
-
143
- def build_phrase(chunk):
144
- snapped = [snap(m) for m in chunk]
145
- smoothed = [snapped[0]]
146
- for curr in snapped[1:]:
147
- prev = smoothed[-1]
148
- if abs(curr - prev) > 5:
149
- curr = snap(prev + random.choice([-2, 2, 3, -3]))
150
- smoothed.append(curr)
151
-
152
- rhythm = random.choice(RHYTHM_PATTERNS)[:]
153
- while len(rhythm) < len(smoothed):
154
- rhythm += random.choice(RHYTHM_PATTERNS)
155
- rhythm = rhythm[:len(smoothed)]
156
-
157
- n = len(smoothed)
158
- velocities = [
159
- int(62 + 28 * (i / max(n-1,1) / 0.6)) if i/max(n-1,1) < 0.6
160
- else int(90 - 25 * ((i/max(n-1,1) - 0.6) / 0.4))
161
- for i in range(n)
162
- ]
163
- return list(zip(smoothed, rhythm, velocities))
164
-
165
- PHRASE_LEN = 8
166
- phrases = [
167
- build_phrase(raw_midis[i:i+PHRASE_LEN])
168
- for i in range(0, len(raw_midis), PHRASE_LEN)
169
- if len(raw_midis[i:i+PHRASE_LEN]) >= 4 # bỏ phrase quá ngắn
170
- ]
171
-
172
- if not phrases:
173
- phrases = [build_phrase(PENTATONIC_MIDI)]
174
-
175
- # ══════════════════════════════════════════════════════════════════════
176
- # VIẾT MIDI BẰNG MIDO (nhanh hơn music21 ~10×)
177
- # ══════════════════════════════════════════════════════════════════════
178
- midi = MidiFile(type=1, ticks_per_beat=480)
179
- tempo_val = mido.bpm2tempo(max(55, min(bpm, 110)))
180
-
181
- def beats_to_ticks(beats, tpb=480):
182
- return int(beats * tpb)
183
-
184
- # ── Track 0: tempo ─────────────────────────────────────────────────────
185
- tempo_track = MidiTrack()
186
- midi.tracks.append(tempo_track)
187
- tempo_track.append(mido.MetaMessage('set_tempo', tempo=tempo_val, time=0))
188
-
189
- # ── Track 1: Melody (channel 0) ────────────────────────────────────────
190
- mel_track = MidiTrack()
191
- midi.tracks.append(mel_track)
192
- mel_track.append(Message('program_change', channel=0, program=0, time=0))
193
-
194
- # Build absolute events → convert sang delta
195
- mel_events = [] # (abs_tick, 'on'/'off', pitch, velocity)
196
- abs_tick = 0
197
- anchor_phrase = None
198
-
199
- for p_idx, phrase in enumerate(phrases):
200
- if p_idx > 0 and p_idx % 3 == 0 and anchor_phrase:
201
- phrase = anchor_phrase
202
-
203
- for midi_val, dur, vel in phrase:
204
- dur_ticks = beats_to_ticks(dur)
205
- mel_events.append((abs_tick, 'on', midi_val, vel))
206
- mel_events.append((abs_tick+dur_ticks, 'off', midi_val, 0))
207
- abs_tick += dur_ticks
208
-
209
- if p_idx == 0:
210
- anchor_phrase = list(phrase)
211
-
212
- abs_tick += beats_to_ticks(random.choice([0.5, 1.0])) # breath
213
-
214
- total_ticks = abs_tick
215
-
216
- mel_events.sort(key=lambda e: e[0])
217
- prev = 0
218
- for abs_t, kind, pitch, vel in mel_events:
219
- delta = abs_t - prev
220
- if kind == 'on':
221
- mel_track.append(Message('note_on', channel=0, note=pitch, velocity=vel, time=delta))
222
- else:
223
- mel_track.append(Message('note_off', channel=0, note=pitch, velocity=0, time=delta))
224
- prev = abs_t
225
-
226
- # ── Track 2: Alberti bass (channel 1) ─────────────────────────────────
227
- acc_track = MidiTrack()
228
- midi.tracks.append(acc_track)
229
- acc_track.append(Message('program_change', channel=1, program=0, time=0))
230
-
231
- acc_events = []
232
- acc_tick = 0
233
- chord_idx = 0
234
-
235
- while acc_tick < total_ticks:
236
- alberti, bass_midi = CHORD_PROG[chord_idx % len(CHORD_PROG)]
237
- lo, mid_n, hi = alberti
238
-
239
- # Alberti: lo–hi–mid–hi mỗi 0.5 beat
240
- seq = [lo, hi, mid_n, hi] * BEATS_PER_CHORD
241
- for i, p in enumerate(seq):
242
- t = acc_tick + beats_to_ticks(i * 0.5)
243
- if t >= total_ticks:
244
- break
245
- dur = beats_to_ticks(0.4)
246
- acc_events.append((t, 'on', p, 40))
247
- acc_events.append((t + dur, 'off', p, 0))
248
-
249
- # Bass root
250
- acc_events.append((acc_tick, 'on', bass_midi, 50))
251
- acc_events.append((acc_tick + beats_to_ticks(BEATS_PER_CHORD), 'off', bass_midi, 0))
252
-
253
- acc_tick += beats_to_ticks(BEATS_PER_CHORD)
254
- chord_idx += 1
255
-
256
- acc_events.sort(key=lambda e: e[0])
257
- prev = 0
258
- for abs_t, kind, pitch, vel in acc_events:
259
- delta = abs_t - prev
260
- if kind == 'on':
261
- acc_track.append(Message('note_on', channel=1, note=pitch, velocity=vel, time=delta))
262
- else:
263
- acc_track.append(Message('note_off', channel=1, note=pitch, velocity=0, time=delta))
264
- prev = abs_t
265
-
266
- midi.save(output_file)
267
- print(f"✅ MIDI saved: {output_file} | phrases={len(phrases)} | notes={len(raw_midis)}")
268
  # ==========================================
269
  # 4. API ENDPOINT
270
- # ==========================================
271
- # Low-pass filter
272
- def apply_audio_pipeline(wav_path):
273
- sound = AudioSegment.from_wav(wav_path)
274
-
275
- # =============================
276
- # 1. NORMALIZE
277
- # =============================
278
- sound = normalize(sound)
279
-
280
- # =============================
281
- # 2. COMPRESSOR (giống YouTube)
282
- # =============================
283
- sound = compress_dynamic_range(sound, threshold=-20, ratio=4)
284
-
285
- # =============================
286
- # 3. EQ (ấm + bớt chói)
287
- # =============================
288
- low = sound.low_pass_filter(3000)
289
- mid = sound.high_pass_filter(120)
290
- sound = low.overlay(mid)
291
-
292
- # =============================
293
- # 4. FAKE REVERB (delay thủ công)
294
- # =============================
295
- delay_ms = 80
296
-
297
- delayed = AudioSegment.silent(duration=delay_ms) + sound - 8
298
- delayed2 = AudioSegment.silent(duration=delay_ms * 2) + sound - 12
299
-
300
- sound = sound.overlay(delayed)
301
- sound = sound.overlay(delayed2)
302
-
303
- # =============================
304
- # 5. FADE (mượt)
305
- # =============================
306
- dur = len(sound)
307
- fade_in = min(2000, int(dur * 0.1))
308
- fade_out = min(3000, int(dur * 0.1))
309
-
310
- sound = sound.fade_in(fade_in).fade_out(fade_out)
311
-
312
- # =============================
313
- # 6. BOOST VOLUME
314
- # =============================
315
- sound = sound + 4
316
-
317
- sound.export(wav_path, format="wav")
318
- @app.post("/generate-vibe")
319
- async def generate_vibe(data: HeartRateData, request: Request):
320
- # Đường dẫn file
321
- midi_file = f"music/ai_vibe_{data.bpm}.mid"
322
- wav_file = f"music/ai_vibe_{data.bpm}.wav"
323
- soundfont_path = "soundfonts/softPiano.sf2" # Đường dẫn tới file sf2 bạn vừa tải
324
-
325
- # Bước 1: PyTorch sinh ra cấu trúc nốt nhạc (MIDI)
326
- generate_ai_midi(data.bpm, midi_file)
327
 
328
- # Bước 2: Tự viết lệnh Render bằng Subprocess chuẩn xác cho FluidSynth 2.5+
329
- print("Đang render MIDI thành âm thanh Piano...")
330
  try:
331
- subprocess.run([
332
- "fluidsynth",
333
- "-ni",
334
- "-g", "1.5",
335
- "-r", "44100",
336
- "-F", wav_file,
337
- soundfont_path,
338
- midi_file
339
- ], check=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- # --- BƯỚC MỚI: LỌC ÂM THANH NHỨC ÓC ---
342
- apply_audio_pipeline(wav_file)
343
- # -------------------------------------
344
- except Exception as e:
345
- print(f"Lỗi khi render âm thanh: {e}")
346
- return {"status": "error", "message": "Render failed"}
347
-
348
- server_url = str(request.base_url).rstrip("/")
349
-
350
- # Bước 3: Trả về link file âm thanh xịn xò cho App Flutter
351
- return {"status": "success", "audio_url": f"{server_url}/{wav_file}"}
 
2
  from fastapi.staticfiles import StaticFiles
3
  from fastapi.responses import FileResponse
4
  from pydantic import BaseModel
5
+ from fastapi import WebSocket, WebSocketDisconnect
6
+ import asyncio
7
+ import math
8
  from pydub import AudioSegment
9
  from pydub.effects import normalize, compress_dynamic_range
10
  import mido
 
80
  # Nạp kho dữ liệu cũ để lấy Seed (đoạn nhạc mồi)
81
  with open('notes_corpus.pkl', 'rb') as f:
82
  corpus = pickle.load(f)
83
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  # ==========================================
85
  # 4. API ENDPOINT
86
+ # ==========================================
87
+ @app.websocket("/ws/vibe")
88
+ async def websocket_vibe(websocket: WebSocket):
89
+ await websocket.accept()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
 
 
 
91
  try:
92
+ # 1. Nhận BPM từ Frontend
93
+ data = await websocket.receive_json()
94
+ bpm = int(data.get("bpm", 60))
95
+
96
+ # 2. Khởi tạo trạng thái AI
97
+ SEQ_LEN = 50
98
+ seed = random.sample(corpus, SEQ_LEN)
99
+ pattern = [note_to_int[n] for n in seed]
100
+ inp_tensor = torch.zeros(1, SEQ_LEN, dtype=torch.long)
101
+
102
+ base_temperature = max(0.5, min(1.1, 0.6 + (bpm - 60) / 100))
103
+ note_count = 0
104
+
105
+ PENTATONIC_MIDI = [60, 62, 64, 67, 69, 72, 74, 76]
106
+
107
+ # Hàm ép cao độ để nhạc không chói (giới hạn ở nốt G5 = 79)
108
+ def snap_and_clamp(midi_val):
109
+ snapped = min(PENTATONIC_MIDI, key=lambda p: abs(p - midi_val))
110
+ while snapped > 79:
111
+ snapped -= 12
112
+ return snapped
113
+
114
+ # 3. Vòng lặp VÔ TẬN (chạy cho đến khi user tắt nhạc)
115
+ while True:
116
+ # Nhiệt độ dao động theo hình sin (tạo cảm giác thở/sáng tạo)
117
+ temp = base_temperature + 0.15 * math.sin(note_count / 10.0)
118
+
119
+ # Suy luận AI
120
+ inp_tensor[0] = torch.tensor(pattern, dtype=torch.long)
121
+ with torch.inference_mode():
122
+ pred = model(inp_tensor)
123
+ probs = torch.softmax(pred / temp, dim=1).numpy()[0]
124
+ top_idx = np.argsort(probs)[-8:]
125
+ top_p = probs[top_idx]; top_p /= top_p.sum()
126
+ idx = np.random.choice(top_idx, p=top_p)
127
+
128
+ tok = int_to_note[idx]
129
+ pattern = pattern[1:] + [idx] # Cập nhật cửa sổ trượt
130
+
131
+ # Giải mã Token thành MIDI Pitch
132
+ midi_val = None
133
+ try:
134
+ parts = tok.split("_")
135
+ if parts[0] == "NOTE":
136
+ midi_val = note.Note(parts[1]).pitch.midi
137
+ except: pass
138
+
139
+ if midi_val:
140
+ midi_val = snap_and_clamp(midi_val)
141
+
142
+ # Tính toán lực đánh (Velocity) êm ái, dao động nhẹ
143
+ # Scale từ 0-127. 40-60 là lực đánh rất nhẹ (Soft Piano)
144
+ velocity = int(45 + 15 * math.sin(note_count / 5.0))
145
+
146
+ # Gửi thẳng nốt nhạc xuống Frontend
147
+ await websocket.send_json({
148
+ "note": midi_val,
149
+ "velocity": velocity,
150
+ "duration": "8n" # Độ dài cơ bản (1/8 nốt)
151
+ })
152
+ else:
153
+ # Nếu AI ra REST, gửi nốt tĩnh (hoặc không gửi)
154
+ pass
155
+
156
+ note_count += 1
157
+
158
+ # Tốc độ đẩy nốt nhạc phụ thuộc vào BPM
159
+ # BPM càng cao, sleep càng ngắn -> nốt nhạc rơi xuống càng nhanh
160
+ sleep_time = 30.0 / bpm
161
+ await asyncio.sleep(sleep_time)
162
+
163
+ except WebSocketDisconnect:
164
+ print("Client đã đóng kết nối WebSocket.")
165