File: midi_sine.py

package info (click to toggle)
python-jack-client 0.5.3-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, bullseye, sid, trixie
  • size: 356 kB
  • sloc: python: 2,123; makefile: 6
file content (146 lines) | stat: -rwxr-xr-x 3,960 bytes parent folder | download | duplicates (3)
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
#!/usr/bin/env python3
"""Very basic MIDI synthesizer.

This only works in Python 3.x because it uses memoryview.cast() and a
few other sweet Python-3-only features.

This is inspired by the JACK example program "jack_midisine":
http://github.com/jackaudio/jack2/blob/master/example-clients/midisine.c

But it is actually better:

+ ASR envelope
+ unlimited polyphony (well, "only" limited by CPU and memory)
+ arbitrarily many MIDI events per block
+ can handle NoteOn and NoteOff event of the same pitch in one block

It is also worse:

- horribly inefficient (dynamic allocations, sample-wise processing)
- unpredictable because of garbage collection (?)

It sounds a little better than the original, but still quite boring.

"""
import jack
import math
import operator
import threading

# First 4 bits of status byte:
NOTEON = 0x9
NOTEOFF = 0x8

attack = 0.01  # seconds
release = 0.2  # seconds

fs = None
voices = {}

client = jack.Client('MIDI-Sine')
midiport = client.midi_inports.register('midi_in')
audioport = client.outports.register('audio_out')
event = threading.Event()


def m2f(note):
    """Convert MIDI note number to frequency in Hertz.

    See https://en.wikipedia.org/wiki/MIDI_Tuning_Standard.

    """
    return 2 ** ((note - 69) / 12) * 440


class Voice:

    def __init__(self, pitch):
        self.time = 0
        self.time_increment = m2f(pitch) / fs
        self.weight = 0

        self.target_weight = 0
        self.weight_step = 0
        self.compare = None

    def trigger(self, vel):
        if vel:
            dur = attack * fs
        else:
            dur = release * fs
        self.target_weight = vel / 127
        self.weight_step = (self.target_weight - self.weight) / dur
        self.compare = operator.ge if self.weight_step > 0 else operator.le

    def update(self):
        """Increment weight."""
        if self.weight_step:
            self.weight += self.weight_step
            if self.compare(self.weight, self.target_weight):
                self.weight = self.target_weight
                self.weight_step = 0


@client.set_process_callback
def process(frames):
    """Main callback."""
    events = {}
    buf = memoryview(audioport.get_buffer()).cast('f')
    for offset, data in midiport.incoming_midi_events():
        if len(data) == 3:
            status, pitch, vel = bytes(data)
            # MIDI channel number is ignored!
            status >>= 4
            if status == NOTEON and vel > 0:
                events.setdefault(offset, []).append((pitch, vel))
            elif status in (NOTEON, NOTEOFF):
                # NoteOff velocity is ignored!
                events.setdefault(offset, []).append((pitch, 0))
            else:
                pass  # ignore
        else:
            pass  # ignore
    for i in range(len(buf)):
        buf[i] = 0
        try:
            eventlist = events[i]
        except KeyError:
            pass
        else:
            for pitch, vel in eventlist:
                if pitch not in voices:
                    if not vel:
                        break
                    voices[pitch] = Voice(pitch)
                voices[pitch].trigger(vel)
        for voice in voices.values():
            voice.update()
            if voice.weight > 0:
                buf[i] += voice.weight * math.sin(2 * math.pi * voice.time)
                voice.time += voice.time_increment
                if voice.time >= 1:
                    voice.time -= 1
    dead = [k for k, v in voices.items() if v.weight <= 0]
    for pitch in dead:
        del voices[pitch]


@client.set_samplerate_callback
def samplerate(samplerate):
    global fs
    fs = samplerate
    voices.clear()


@client.set_shutdown_callback
def shutdown(status, reason):
    print('JACK shutdown:', reason, status)
    event.set()


with client:
    print('Press Ctrl+C to stop')
    try:
        event.wait()
    except KeyboardInterrupt:
        print('\nInterrupted by user')