File size: 3,031 Bytes
79ce1f6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Audio Engine for Harmonic Catalyst
Synthesizes browser-playable WAV audio from MIDI note data.
Pure Python — numpy + wave, no external audio libraries required.
"""

import io
import wave
import numpy as np


class AudioEngine:
    SAMPLE_RATE = 44100

    @staticmethod
    def _note_to_freq(midi_note):
        return 440.0 * (2 ** ((midi_note - 69) / 12))

    @staticmethod
    def _synthesize(lh_notes, rh_notes, duration_sec, sample_rate=44100):
        n = int(sample_rate * duration_sec)
        t = np.linspace(0, duration_sec, n, endpoint=False)
        audio = np.zeros(n)

        # LH: warm, soft, slower decay — bass character
        for note in lh_notes:
            freq = AudioEngine._note_to_freq(note)
            env = 0.55 * np.exp(-1.8 * t)
            audio += env * np.sin(2 * np.pi * freq * t)
            audio += env * 0.25 * np.sin(2 * np.pi * 2 * freq * t)

        # RH: brighter, more harmonic content, slightly faster decay — chord character
        for note in rh_notes:
            freq = AudioEngine._note_to_freq(note)
            env = np.exp(-2.2 * t)
            audio += env * np.sin(2 * np.pi * freq * t)
            audio += env * 0.35 * np.sin(2 * np.pi * 2 * freq * t)
            audio += env * 0.15 * np.sin(2 * np.pi * 3 * freq * t)

        return audio

    @staticmethod
    def _to_wav_bytes(audio, sample_rate=44100):
        peak = np.max(np.abs(audio))
        if peak > 0:
            audio = audio / peak * 0.8
        pcm = (audio * 32767).astype(np.int16)
        buf = io.BytesIO()
        with wave.open(buf, 'wb') as wf:
            wf.setnchannels(1)
            wf.setsampwidth(2)
            wf.setframerate(sample_rate)
            wf.writeframes(pcm.tobytes())
        buf.seek(0)
        return buf.read()

    @staticmethod
    def chord_to_wav(lh_notes, rh_notes, beats=4, bpm=120):
        """WAV bytes for a single chord at given beat duration and tempo"""
        duration_sec = (beats / bpm) * 60
        # Cap at 4 seconds for per-chord preview to keep it snappy
        duration_sec = min(duration_sec, 4.0)
        audio = AudioEngine._synthesize(lh_notes, rh_notes, duration_sec)
        return AudioEngine._to_wav_bytes(audio)

    @staticmethod
    def progression_to_wav(progression_data, bpm=120):
        """WAV bytes for a full progression — all chords concatenated in sequence"""
        sample_rate = AudioEngine.SAMPLE_RATE
        segments = []
        for chord in progression_data:
            beats = chord.get('beats', 4)
            duration_sec = (beats / bpm) * 60
            seg = AudioEngine._synthesize(
                chord.get('lh', []), chord.get('rh', []),
                duration_sec, sample_rate
            )
            # Small silence gap between chords (20ms)
            gap = np.zeros(int(sample_rate * 0.02))
            segments.append(np.concatenate([seg, gap]))

        full = np.concatenate(segments) if segments else np.zeros(sample_rate)
        return AudioEngine._to_wav_bytes(full, sample_rate)