Initial commit

This commit is contained in:
thib8956 committed 2026-10-27 22:10:43 +01:00
commit 91a1e2e312
22 files changed
+885

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
__pycache__/
+34
View File
@@ -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
+49
View File
@@ -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()
+53
View File
@@ -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()
+73
View File
@@ -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)
+68
View File
@@ -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()
+73
View File
@@ -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
+99
View File
@@ -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()
+36
View File
@@ -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