"""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())