File size: 3,758 Bytes
93fbe4b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
"""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())