muscriptor-large-fork / plus /plus_render.py
Nokia123543's picture
PLUS: plus/plus_render.py
93fbe4b verified
Raw History Blame Contribute Delete
3.76 kB
"""PLUS render: MIDI в WAV без внешних синтезаторов, чтобы слушать до DAW.
Синтез аддитивный: три партиала с экспоненциальным спадом, стерео-панорама по
высоте, мягкий лимитер. Никаких сэмплов и GPU, только numpy и soundfile.
"""
from __future__ import annotations
import argparse
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from plus_core import load_midi, step # noqa: E402
__version__ = "1.0"
PARTIALS = ((1.0, 1.00), (2.0, 0.42), (3.0, 0.18), (4.01, 0.09))
def note_wave(freq: float, seconds: float, rate: int, velocity: float) -> np.ndarray:
"""Одна нота: сумма партиалов с атакой 4 мс и экспоненциальным спадом."""
count = max(1, int(seconds * rate))
time_axis = np.arange(count) / rate
wave = np.zeros(count, dtype=np.float32)
for multiple, amplitude in PARTIALS:
if freq * multiple > rate / 2 - 100:
continue
wave += amplitude * np.sin(2 * np.pi * freq * multiple * time_axis)
decay = np.exp(-3.0 * time_axis / max(0.12, seconds))
attack = np.clip(time_axis / 0.004, 0.0, 1.0)
tail = np.clip((count - np.arange(count)) / max(1, int(0.05 * rate)), 0.0, 1.0)
return (wave * decay * attack * tail * velocity).astype(np.float32)
def render(piece, rate: int = 44100, gain: float = 0.28, stop: float | None = None) -> np.ndarray:
total = float(stop if stop else piece.duration) + 0.6
frames = int(total * rate)
left = np.zeros(frames, dtype=np.float32)
right = np.zeros(frames, dtype=np.float32)
for note in piece.notes:
if stop and note.start > stop:
continue
freq = 440.0 * 2 ** ((note.pitch - 69) / 12.0)
seconds = min(note.dur + 0.25, max(0.05, total - note.start))
wave = note_wave(freq, seconds, rate, note.velocity / 127.0)
start = int(note.start * rate)
end = min(frames, start + wave.size)
if start >= frames or end <= start:
continue
chunk = wave[: end - start]
pan = float(np.clip((note.pitch - 60) / 36.0, -1.0, 1.0))
left[start:end] += chunk * (0.5 * (1 - pan) + 0.25)
right[start:end] += chunk * (0.5 * (1 + pan) + 0.25)
mix = np.stack([left, right], axis=1) * gain
peak = float(np.abs(mix).max())
if peak > 0.98:
mix *= 0.98 / peak
return mix
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="PLUS render: MIDI в WAV")
parser.add_argument("--midi", required=True)
parser.add_argument("--out", help="выходной WAV")
parser.add_argument("--rate", type=int, default=44100)
parser.add_argument("--gain", type=float, default=0.28)
parser.add_argument("--stop", type=float, help="рендерить только первые N секунд")
args = parser.parse_args(argv)
import soundfile as sf
out_path = args.out or os.path.splitext(args.midi)[0] + "_preview.wav"
piece = load_midi(args.midi)
step(1, 3, "нот %d, %.1f с" % (len(piece.notes), piece.duration))
audio = render(piece, args.rate, args.gain, args.stop)
step(2, 3, "свожу %d сэмплов" % audio.shape[0])
os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
sf.write(out_path, audio, args.rate, subtype="PCM_16")
peak = float(np.abs(audio).max())
rms = float(np.sqrt((audio ** 2).mean()))
step(3, 3, "готово: %s | peak %.2f | rms %.3f" % (out_path, peak, rms))
return 0
if __name__ == "__main__":
sys.exit(main())