78 lines
3.0 KiB
Python
78 lines
3.0 KiB
Python
# SonicForge Studio VST / VSTi Engine Service (22_CLIENT_DESK.md §3)
|
|
import numpy as np
|
|
|
|
def midi_note_to_freq(note_number: int) -> float:
|
|
"""Quy đổi số nốt MIDI (0 - 127) sang tần số Hertz (Hz)."""
|
|
return 440.0 * (2.0 ** ((note_number - 69) / 12.0))
|
|
|
|
def render_midi_events_to_audio(midi_events: list, sr: int = 44100, bpm: float = 120.0, instrument: str = 'synth') -> np.ndarray:
|
|
"""
|
|
Tổng hợp mảng âm thanh NumPy Stereo từ sự kiện MIDI Piano Roll (22_CLIENT_DESK.md §3.1 & §3.2).
|
|
|
|
Args:
|
|
midi_events: Danh sách nốt MIDI [{"note": 60, "start_beat": 0, "duration_beats": 1, "velocity": 100}, ...]
|
|
sr: Tần số lấy mẫu (Sample Rate)
|
|
bpm: Nhịp BPM của dự án
|
|
instrument: Loại nhạc cụ tổng hợp
|
|
|
|
Returns:
|
|
np.ndarray: Mảng 2D Stereo Float32 [2, num_samples]
|
|
"""
|
|
beat_duration_sec = 60.0 / max(30.0, bpm)
|
|
max_duration_sec = 2.0
|
|
|
|
for event in midi_events:
|
|
start_beat = event.get('start_beat', 0.0)
|
|
dur_beats = event.get('duration_beats', 1.0)
|
|
end_sec = (start_beat + dur_beats) * beat_duration_sec
|
|
if end_sec > max_duration_sec:
|
|
max_duration_sec = end_sec
|
|
|
|
total_samples = int((max_duration_sec + 0.5) * sr)
|
|
out_l = np.zeros(total_samples, dtype=np.float32)
|
|
out_r = np.zeros(total_samples, dtype=np.float32)
|
|
|
|
for event in midi_events:
|
|
note = event.get('note', 60)
|
|
velocity = event.get('velocity', 100) / 127.0
|
|
start_beat = event.get('start_beat', 0.0)
|
|
dur_beats = event.get('duration_beats', 1.0)
|
|
|
|
start_sample = int(start_beat * beat_duration_sec * sr)
|
|
dur_samples = int(dur_beats * beat_duration_sec * sr)
|
|
end_sample = min(total_samples, start_sample + dur_samples)
|
|
actual_len = end_sample - start_sample
|
|
|
|
if actual_len <= 0 or start_sample >= total_samples:
|
|
continue
|
|
|
|
freq = midi_note_to_freq(note)
|
|
t = np.arange(actual_len) / float(sr)
|
|
|
|
# Synth tone + fundamental harmonics
|
|
tone = 0.6 * np.sin(2 * np.pi * freq * t) + 0.3 * np.sin(2 * np.pi * freq * 2 * t) + 0.1 * np.sin(2 * np.pi * freq * 3 * t)
|
|
|
|
# ADSR Envelope
|
|
attack = min(int(0.01 * sr), actual_len // 4)
|
|
release = min(int(0.05 * sr), actual_len // 4)
|
|
sustain_len = actual_len - attack - release
|
|
|
|
env = np.ones(actual_len, dtype=np.float32)
|
|
if attack > 0:
|
|
env[:attack] = np.linspace(0.0, 1.0, attack)
|
|
if release > 0:
|
|
env[-release:] = np.linspace(1.0, 0.0, release)
|
|
|
|
signal = tone * env * velocity
|
|
|
|
out_l[start_sample:end_sample] += signal
|
|
out_r[start_sample:end_sample] += signal
|
|
|
|
# Clamping normalization to prevent clipping
|
|
max_peak = max(np.max(np.abs(out_l)), np.max(np.abs(out_r)))
|
|
if max_peak > 1.0:
|
|
out_l /= max_peak
|
|
out_r /= max_peak
|
|
|
|
return np.vstack([out_l, out_r])
|