Initial commit
This commit is contained in:
commit
91a1e2e312
22 files changed
+885
No files matched your search
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import fcntl
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
import termios
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
@contextmanager
|
||||
def setup_term(*args, **kwargs):
|
||||
fd = sys.stdin.fileno()
|
||||
oldterm = termios.tcgetattr(fd)
|
||||
newattr = termios.tcgetattr(fd)
|
||||
newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO
|
||||
termios.tcsetattr(fd, termios.TCSANOW, newattr)
|
||||
oldflags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, oldflags | os.O_NONBLOCK)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# restore terminal settings
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, oldflags)
|
||||
|
||||
|
||||
def getch(timeout):
|
||||
# sys.stdin.read is blocking, use select instead
|
||||
# NOTE: this will not work on windows as select only accepts sockets, not all file descriptors
|
||||
# TODO: use python selectors module?
|
||||
o, _, _ = select.select([sys.stdin], [], [], timeout)
|
||||
if o != []:
|
||||
assert len(o) == 1
|
||||
return o[0].read(1)
|
||||
return None
|
||||
@@ -0,0 +1,49 @@
|
||||
import math
|
||||
import pyray as ray
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
BUFFER_SIZE = 2048
|
||||
|
||||
progress = 0.0 # progress within a cycle of a periodic function (0 to 1)
|
||||
|
||||
|
||||
def tick(freq):
|
||||
global progress
|
||||
progress = (progress + freq / SAMPLE_RATE) % 1.0
|
||||
return progress
|
||||
|
||||
|
||||
def sine(p):
|
||||
return math.sin(2 * math.pi * p)
|
||||
|
||||
|
||||
def square(p):
|
||||
return 1.0 if p < 0.5 else -1.0
|
||||
|
||||
|
||||
def saw(p):
|
||||
return 2.0 * p - 1.0
|
||||
|
||||
|
||||
def tri(p):
|
||||
return 4 * abs(p - 0.5) - 1.0
|
||||
|
||||
|
||||
ray.init_window(800, 450, "Hello")
|
||||
ray.init_audio_device()
|
||||
ray.set_target_fps(60)
|
||||
ray.set_audio_stream_buffer_size_default(BUFFER_SIZE)
|
||||
stream = ray.load_audio_stream(SAMPLE_RATE, 32, 1)
|
||||
ray.play_audio_stream(stream)
|
||||
buffer = ray.ffi.new("float[]", BUFFER_SIZE)
|
||||
while not ray.window_should_close():
|
||||
if ray.is_key_down(ray.KeyboardKey.KEY_SPACE) and ray.is_audio_stream_processed(stream):
|
||||
for i in range(BUFFER_SIZE):
|
||||
buffer[i] = 0.1 * sine(tick(440))
|
||||
ray.update_audio_stream(stream, buffer, BUFFER_SIZE)
|
||||
ray.begin_drawing()
|
||||
ray.clear_background((0x0B, 0x0B, 0x0B, 0xFF))
|
||||
ray.draw_text("Hello world", 190, 200, 20, ray.VIOLET)
|
||||
ray.end_drawing()
|
||||
ray.close_window()
|
||||
ray.close_audio_device()
|
||||
@@ -0,0 +1,53 @@
|
||||
from queue import Empty, Queue
|
||||
from shlex import split
|
||||
from subprocess import PIPE, Popen
|
||||
from sys import stderr
|
||||
from threading import Event, Thread
|
||||
|
||||
# FFPLAY_CMD = "ffplay -f f32le -ar 44100 -i -"
|
||||
|
||||
|
||||
class AplaySink:
|
||||
def __init__(self, sample_rate: int) -> None:
|
||||
self.cmd = f"aplay -t raw -f FLOAT_LE -r {sample_rate}"
|
||||
self.process = Popen(split(self.cmd), stdin=PIPE)
|
||||
self.queue = Queue()
|
||||
self.stop_event = Event()
|
||||
self.feeder_thred = Thread(
|
||||
target=self._consume, name="aplay-feeder", daemon=False
|
||||
)
|
||||
print(f"Starting {self.feeder_thred.name} thread...", file=stderr)
|
||||
self.feeder_thred.start()
|
||||
|
||||
def submit(self, samples: bytes):
|
||||
self.queue.put(samples)
|
||||
|
||||
def close(self):
|
||||
self.stop_event.set()
|
||||
self.feeder_thred.join()
|
||||
if self.process.stdin is not None:
|
||||
self.process.stdin.close()
|
||||
self.process.wait()
|
||||
print(f"Stopped {self.feeder_thred.name} thread...", file=stderr)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.close()
|
||||
|
||||
def _consume(self, timeout=0.1):
|
||||
while not self.stop_event.is_set():
|
||||
samples = []
|
||||
try:
|
||||
samples = self.queue.get(timeout=timeout)
|
||||
except Empty:
|
||||
pass
|
||||
if not samples:
|
||||
continue
|
||||
if self.process.poll() is not None:
|
||||
raise RuntimeError("aplay process has terminated")
|
||||
if self.process.stdin is None:
|
||||
raise RuntimeError("stdin is None")
|
||||
self.process.stdin.write(samples)
|
||||
self.process.stdin.flush()
|
||||
@@ -0,0 +1,73 @@
|
||||
import ctypes
|
||||
|
||||
"""
|
||||
Quick and dirty wrapper for ALSA.
|
||||
"""
|
||||
|
||||
c_void_pp = ctypes.POINTER(ctypes.c_void_p)
|
||||
_alsa = ctypes.cdll.LoadLibrary("libasound.so.2")
|
||||
|
||||
_alsa.snd_pcm_open.restype = ctypes.c_int
|
||||
_alsa.snd_pcm_open.argtypes = [c_void_pp, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
|
||||
_alsa.snd_pcm_set_params.restype = ctypes.c_int
|
||||
_alsa.snd_pcm_set_params.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_uint, ctypes.c_uint, ctypes.c_int, ctypes.c_uint]
|
||||
SND_PCM_FORMAT_S16_LE = 2
|
||||
SND_PCM_ACCESS_RW_INTERLEAVED = 3
|
||||
|
||||
# snd_pcm_sframes_t snd_pcm_writei(snd_pcm_t *pcm, const void *buffer, snd_pcm_uframes_t size);
|
||||
_alsa.snd_pcm_writei.restype = ctypes.c_long
|
||||
_alsa.snd_pcm_writei.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong]
|
||||
|
||||
# int snd_pcm_drain(snd_pcm_t *pcm);
|
||||
_alsa.snd_pcm_drain.restype = ctypes.c_int
|
||||
_alsa.snd_pcm_drain.argtypes = [ctypes.c_void_p]
|
||||
|
||||
|
||||
class Alsa:
|
||||
def __init__(self, sample_rate, latency):
|
||||
self.sample_rate = sample_rate
|
||||
self.handle = ctypes.c_void_p()
|
||||
ret = _alsa.snd_pcm_open(ctypes.byref(self.handle), b"default", 0, 0)
|
||||
assert ret >= 0, "snd_pcm_open failed"
|
||||
ret = _alsa.snd_pcm_set_params(self.handle, SND_PCM_FORMAT_S16_LE, SND_PCM_ACCESS_RW_INTERLEAVED, 1, sample_rate, 1, latency)
|
||||
|
||||
def write(self, samples):
|
||||
BufferType = ctypes.c_int16 * len(samples)
|
||||
buf = BufferType(*samples)
|
||||
ret = _alsa.snd_pcm_writei(self.handle, buf, len(samples))
|
||||
assert ret == len(samples), "snd_pcm_writei failed"
|
||||
return ret
|
||||
|
||||
def drain(self):
|
||||
ret = _alsa.snd_pcm_drain(self.handle)
|
||||
assert ret >= 0
|
||||
|
||||
|
||||
#_alsa.snd_seq_open.argtypes = [c_void_pp, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
|
||||
class AlsaSeq:
|
||||
def __init__(self):
|
||||
self.handle = ctypes.c_void_p()
|
||||
ret = _alsa.snd_seq_open(ctypes.byref(self.handle), b"default", 2, 0)
|
||||
if ret < 0:
|
||||
raise RuntimeError("snd_seq_open failed")
|
||||
ret = _alsa.snd_seq_set_client_name(self.handle, b"Midi Listener");
|
||||
assert ret >= 0, "snd_seq_set_client_name failed"
|
||||
self.port = _alsa.snd_seq_create_simple_port(self.handle, b"listen:in", 2 | 64, 1 << 20)
|
||||
assert self.port >= 0, "snd_seq_create_simple_port failed"
|
||||
print(self.handle, self.port)
|
||||
err = _alsa.snd_seq_connect_from(self.handle, self.port, 20, 0)
|
||||
assert err >= 0, "snd_seq_connect_from failed"
|
||||
|
||||
def get_event(self):
|
||||
evt = ctypes.c_void_p()
|
||||
err = _alsa.snd_seq_event_input(self.handle, ctypes.byref(evt))
|
||||
assert err >= 0, "snd_seq_event_input failed"
|
||||
return evt
|
||||
|
||||
if __name__ == "__main__":
|
||||
seq = AlsaSeq()
|
||||
while True:
|
||||
evt = seq.get_event()
|
||||
print(evt)
|
||||
@@ -0,0 +1,68 @@
|
||||
import math
|
||||
import struct
|
||||
from itertools import chain
|
||||
from operator import mul
|
||||
from random import random
|
||||
|
||||
from player import AplaySink
|
||||
|
||||
SAMPLE_RATE = 44100
|
||||
|
||||
|
||||
def sin(f, amplitude, duration):
|
||||
samples = (amplitude*math.sin(2*math.pi*f*(t/SAMPLE_RATE)) for t in range(int(duration*SAMPLE_RATE)))
|
||||
return samples
|
||||
|
||||
|
||||
def square(f, amplitude, duration):
|
||||
period = SAMPLE_RATE / f
|
||||
half_period = period / 2
|
||||
for t in range(int(duration*SAMPLE_RATE)):
|
||||
if t % period < half_period:
|
||||
yield amplitude
|
||||
else:
|
||||
yield -amplitude
|
||||
|
||||
|
||||
def noise(amplitude, duration):
|
||||
return (amplitude*(2*random()-1) for _ in range(int(duration*SAMPLE_RATE)))
|
||||
|
||||
|
||||
def envelope(samples, attack_duration, release_duration):
|
||||
# TODO: rewrite ADSR envelope
|
||||
def compose(a, b):
|
||||
return map(lambda x: mul(*x), zip(a, b))
|
||||
|
||||
a = int(attack_duration * SAMPLE_RATE)
|
||||
r = int(release_duration * SAMPLE_RATE)
|
||||
samples = list(samples)
|
||||
attack = (x/a for x in range(0, a))
|
||||
sustain = len(samples) - a - r
|
||||
release = (x/r for x in range(r, -1, -1))
|
||||
|
||||
return chain(
|
||||
compose(samples[:a], attack),
|
||||
samples[a:a+sustain],
|
||||
compose(samples[a+sustain:], release))
|
||||
|
||||
|
||||
def mix(*sources):
|
||||
return (sum(x) for x in zip(*sources))
|
||||
|
||||
|
||||
def pack(samples) -> bytes:
|
||||
return b"".join(struct.pack("<f", sample) for sample in samples)
|
||||
|
||||
|
||||
def main():
|
||||
with AplaySink(SAMPLE_RATE) as sink:
|
||||
data = mix(
|
||||
sin(440, 0.05, 1),
|
||||
sin(440*2**(4/12), 0.05, 1),
|
||||
sin(440*2**(7/12), 0.05, 1))
|
||||
packed = pack(data)
|
||||
sink.submit(packed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
import sys
|
||||
from math import pi, sin, tanh
|
||||
|
||||
from keyboard import setup_term
|
||||
from pyalsa import Alsa
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
LATENCY = 50_000 # 50ms
|
||||
INT16_MAX = 2**15 - 1
|
||||
alsa = Alsa(SAMPLE_RATE, LATENCY)
|
||||
|
||||
keys = "azertyuiop"
|
||||
gain = 0.5
|
||||
base_freq = 440.0
|
||||
freqs = [0.0] * len(keys)
|
||||
phases = [0.0] * len(keys)
|
||||
mix_mode = "voice_count"
|
||||
buf_size = 512
|
||||
|
||||
|
||||
def tanh_mix(voices, gain):
|
||||
sample = tanh(gain * sum(voices)) # apply tanh with gain (soft clipping)
|
||||
return sample
|
||||
|
||||
|
||||
def voice_count_mix(voices, gain):
|
||||
sample = sum(voices) / len(voices) # normalize to [0, 1] range
|
||||
sample = sample * gain
|
||||
return sample
|
||||
|
||||
|
||||
def clamp(x, a, b):
|
||||
return max(a, min(b, x))
|
||||
|
||||
|
||||
os.set_blocking(sys.stdin.fileno(), False)
|
||||
with setup_term():
|
||||
while True:
|
||||
buf = [0] * buf_size
|
||||
for i in range(buf_size):
|
||||
voices = [0.0] * len(keys)
|
||||
for j in range(len(keys)):
|
||||
phase, freq = phases[j], freqs[j]
|
||||
if freq == 0:
|
||||
continue
|
||||
voices[j] = sin(phase)
|
||||
phases[j] += 2 * pi * freq / SAMPLE_RATE
|
||||
|
||||
if voices == []:
|
||||
continue
|
||||
|
||||
# mixing
|
||||
mixfn = tanh_mix if mix_mode == "tanh" else voice_count_mix
|
||||
mix = int(mixfn(voices, gain) * INT16_MAX)
|
||||
buf[i] = clamp(
|
||||
mix, -INT16_MAX - 1, INT16_MAX
|
||||
) # final clamp to int16 range, just in case
|
||||
alsa.write(buf) # this is blocking
|
||||
|
||||
# input handling
|
||||
key = sys.stdin.read(1)
|
||||
if key == "q":
|
||||
break
|
||||
elif key == " ":
|
||||
mix_mode = "tanh" if mix_mode == "voice_count" else "voice_count"
|
||||
print(mix_mode)
|
||||
elif key is not None and key != "" and key in keys:
|
||||
i = keys.index(key)
|
||||
if freqs[i] == 0:
|
||||
freqs[i] = base_freq * (2 ** (i / 12))
|
||||
else:
|
||||
freqs[i] = 0
|
||||
@@ -0,0 +1,99 @@
|
||||
import importlib
|
||||
from tkinter import Frame, Tk
|
||||
|
||||
import synth
|
||||
from pyalsa import Alsa
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
LATENCY = 50_000 # 50ms
|
||||
INT16_MAX = 2**15 - 1
|
||||
alsa = Alsa(SAMPLE_RATE, LATENCY)
|
||||
|
||||
keys = "qzsedftgyhujk"
|
||||
base_freq = 261.6256 # C4, piano middle C
|
||||
keys_map = [0] * len(keys)
|
||||
|
||||
|
||||
def keydown(e):
|
||||
if e.keysym in keys:
|
||||
i = keys.index(e.keysym)
|
||||
keys_map[i] = 1
|
||||
|
||||
|
||||
def keyup(e):
|
||||
if e.keysym in keys:
|
||||
i = keys.index(e.keysym)
|
||||
keys_map[i] = 0
|
||||
|
||||
|
||||
def key_to_freq(i):
|
||||
if 0 <= i < len(keys):
|
||||
return base_freq * (2 ** (i / 12))
|
||||
return 0
|
||||
|
||||
|
||||
root = Tk()
|
||||
|
||||
|
||||
# note parameters: freq, volume
|
||||
# note state: phase, volume
|
||||
class Note:
|
||||
def __init__(self):
|
||||
self.freq = 0
|
||||
self.vol = 0
|
||||
self.phase = 0
|
||||
self.gain = 0
|
||||
self.playing = False
|
||||
self.waveform = "sine"
|
||||
self.attack = 0.01
|
||||
self.release = 0.05
|
||||
self.waveshaping = False
|
||||
self.lowpass = False
|
||||
self.lp = 0
|
||||
|
||||
|
||||
def reload_synth(event):
|
||||
importlib.reload(synth)
|
||||
print("reloaded synth module")
|
||||
|
||||
|
||||
def update(root, note):
|
||||
pressed_key = keys_map.index(1) if 1 in keys_map else -1
|
||||
note_freq = key_to_freq(pressed_key)
|
||||
if note_freq != note.freq:
|
||||
note.freq = note_freq
|
||||
if not note.playing and note_freq != 0:
|
||||
note.playing = True
|
||||
if note.playing and note_freq == 0:
|
||||
note.playing = False
|
||||
buf = synth.synth(note)
|
||||
alsa.write(buf) # send sound to alsa
|
||||
root.after(0, update, root, note) # schedule next update
|
||||
|
||||
|
||||
def toggle_note_waveform(e):
|
||||
waves = ["sine", "saw", "square", "triangle"]
|
||||
current = waves.index(note.waveform)
|
||||
note.waveform = waves[(current + 1) % len(waves)]
|
||||
print("waveform:", note.waveform)
|
||||
|
||||
def toggle_note_waveshaping(e):
|
||||
note.waveshaping = not note.waveshaping
|
||||
print("waveshaping:", note.waveshaping)
|
||||
|
||||
def toggle_note_lowpass(e):
|
||||
note.lowpass = not note.lowpass
|
||||
print("lowpass:", note.lowpass)
|
||||
|
||||
note = Note()
|
||||
frame = Frame(root, width=100, height=100)
|
||||
frame.bind("<KeyPress>", keydown)
|
||||
frame.bind("<KeyRelease>", keyup)
|
||||
root.bind_all("<Control-r>", reload_synth)
|
||||
root.bind_all("w", toggle_note_waveform)
|
||||
root.bind_all("x", toggle_note_waveshaping)
|
||||
root.bind_all("c", toggle_note_lowpass)
|
||||
frame.pack()
|
||||
frame.focus_set()
|
||||
root.after(100, update, root, note)
|
||||
root.mainloop()
|
||||
@@ -0,0 +1,36 @@
|
||||
from math import pi, sin
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
INT16_MAX = 2**15 - 1
|
||||
|
||||
def synth(note):
|
||||
buf = [0] * 512
|
||||
for i, _ in enumerate(buf):
|
||||
note.phase = (note.phase + note.freq / SAMPLE_RATE) % 1.0
|
||||
# oscillator
|
||||
match note.waveform:
|
||||
case "saw": sample = 2 * note.phase - 1.0
|
||||
case "square": sample = 1.0 if note.phase < 0.5 else -1.0
|
||||
case "sine": sample = sin(2 * pi * note.phase)
|
||||
case "triangle": sample = 4 * abs(note.phase - 0.5) - 1.0
|
||||
case _: sample = 0.0
|
||||
# envelope
|
||||
target = 1 if note.playing else 0
|
||||
if target == 1:
|
||||
note.gain = min(note.gain + 1 / (0.010 * SAMPLE_RATE), 0.2) # attack
|
||||
else:
|
||||
note.gain = max(note.gain - 1 / (0.050 * SAMPLE_RATE), 0) # release
|
||||
|
||||
# signal processing
|
||||
if note.waveshaping:
|
||||
drive = 2
|
||||
sample *= abs(sample) * drive
|
||||
#sample = tanh(drive * sample)
|
||||
if note.lowpass:
|
||||
note.lp += 0.05 * (sample - note.lp)
|
||||
sample = note.lp * note.gain
|
||||
else:
|
||||
sample = sample * note.gain
|
||||
# convert to 16-bit signed integer and feed to buffer
|
||||
buf[i] = int(sample * INT16_MAX)
|
||||
return buf
|
||||
Reference in new issue
Block a user