--- /dev/null
+__pycache__/
+venv
--- /dev/null
+from dataclasses import dataclass
+from enum import Enum, auto
+from functools import partial, reduce
+from math import pi, sin
+from operator import mul, truediv
+from typing import Callable
+
+from utils import ReadOnlyDescriptor
+
+tau = 2 * pi
+rtruediv = lambda b, a: truediv(a, b)
+
+
+class PitchLookup:
+ PITCH_PER_NAME = {"A": 9, "B": 11, "C": 0, "D": 2, "E": 4, "F": 5, "G": 7}
+ transformers: list[Callable[[float], float]] = [
+ partial(rtruediv, 2 ** (3 / 4)), # C
+ partial(rtruediv, 2 ** (2 / 3)),
+ partial(rtruediv, 2 ** (7 / 12)), # D
+ partial(rtruediv, 2 ** (1 / 2)),
+ partial(rtruediv, 2 ** (5 / 12)), # E
+ partial(rtruediv, 2 ** (1 / 3)), # F
+ partial(rtruediv, 2 ** (1 / 4)),
+ partial(rtruediv, 2 ** (1 / 6)), # G
+ partial(rtruediv, 2 ** (1 / 12)),
+ lambda a: a, # A
+ partial(mul, 2 ** (1 / 12)),
+ partial(mul, 2 ** (1 / 6)), # C
+ ]
+
+ def __init__(self, a_four=440):
+ self.a_refs = {4: a_four}
+ self.lowest_a_ref = 4
+ self.highest_a_ref = 4
+
+ def __call__(self, spn):
+ if spn[-1].isdigit():
+ pitch_str, octave_number = spn[:-1], int(spn[-1])
+ else:
+ pitch_str, octave_number = spn, 4
+ pitch_mod = 0
+ while pitch_str[-1] in ("#", "b"):
+ pitch_str, mod = pitch_str[:-1], {"#": 1, "b": -1}[pitch_str[-1]]
+ pitch_mod += mod
+ pitch = self.PITCH_PER_NAME[pitch_str] + pitch_mod
+ # get A in that octave
+ while octave_number < self.lowest_a_ref:
+ new_lowest = self.lowest_a_ref - 1
+ self.a_refs[new_lowest] = self.a_refs[self.lowest_a_ref] / 2
+ self.lowest_a_ref = new_lowest
+ while octave_number > self.highest_a_ref:
+ new_highest = self.highest_a_ref + 1
+ self.a_refs[new_highest] = self.a_refs[self.highest_a_ref] * 2
+ self.highest_a_ref = new_highest
+ return self.transformers[pitch](self.a_refs[octave_number])
+
+
+class SineWave:
+ # A sine wave, at sample_rate
+ def __init__(self, sample_rate):
+ self.sample_rate = sample_rate
+
+ def __call__(self, n):
+ if not isinstance(n, (int, float)):
+ print(n)
+ return sin(n * tau / self.sample_rate)
+
+
+class Tone:
+ # A wave, at a frequency and amplitude
+ def __init__(self, wave, frequency, amplitude, adsr=None):
+ self.wave = wave
+ assert not isinstance(frequency, str)
+ self.frequency = frequency
+ self.amplitude = amplitude
+ self.adsr = adsr
+ self.signal = True
+
+ def __call__(self, n):
+ value = self.wave(self.frequency * n) * self.amplitude
+ if self.adsr is not None:
+ value *= self.adsr(self.signal)
+ return value
+
+ def has_signal(self):
+ if self.adsr is not None:
+ return self.signal | self.adsr.has_signal()
+ return self.signal
+
+ def release(self):
+ self.signal = False
+
+ def push(self):
+ self.signal = True
+
+
+class ADSR:
+ """
+ ADSR by time, so if the intensity does not start at 0,
+ attack, decay and release still take the specified amount of time
+ """
+
+ @dataclass
+ class Envelope:
+ attack: ReadOnlyDescriptor[int]
+ decay: ReadOnlyDescriptor[int]
+ sustain: ReadOnlyDescriptor[float]
+ release: ReadOnlyDescriptor[int]
+
+ def __post_init__(self):
+ assert isinstance(self.attack, int) and self.attack >= 0
+ assert isinstance(self.decay, int) and self.decay >= 0
+ assert isinstance(
+ self.sustain, (int, float)
+ ) and self.sustain >= 0 and self.sustain <= 1
+ assert isinstance(self.release, int) and self.release >= 0
+
+ class State(Enum):
+ OFF = auto()
+ ATTACK = auto()
+ DECAY = auto()
+ SUSTAIN = auto()
+ RELEASE = auto()
+
+ def __init__(self, envelope):
+ self.envelope = envelope
+ self.prev_signal = False
+ self.pos = 0
+ self.velocity = 0
+ self.state = self.State.OFF
+
+ def __call__(self, signal):
+ if self.prev_signal and not signal:
+ self.velocity = -self.pos / self.envelope.release
+ self.state = self.State.RELEASE
+ elif signal and not self.prev_signal:
+ self.velocity = (1 - self.pos) / self.envelope.attack
+ self.state = self.State.ATTACK
+ self.pos += self.velocity
+ # attack -> decay
+ if self.state == self.State.ATTACK and self.pos >= 1:
+ self.pos = 1
+ self.velocity = (self.envelope.sustain - 1) / self.envelope.decay
+ self.state = self.State.DECAY
+ # decay -> sustain
+ elif (
+ self.state == self.State.DECAY
+ and self.pos <= self.envelope.sustain
+ ):
+ self.pos = self.envelope.sustain
+ self.velocity = 0
+ self.state = self.State.SUSTAIN if self.pos > 0 else self.State.OFF
+ # release -> off
+ elif self.state == self.State.RELEASE and self.pos <= 0:
+ self.pos = self.velocity = 0
+ self.state = self.State.OFF
+ self.prev_signal = signal
+ return self.pos
+
+ def has_signal(self):
+ return self.state != self.State.OFF
--- /dev/null
+#!/usr/bin/env python3
+
+import numpy
+import pygame
+
+from music import ADSR, PitchLookup, SineWave, Tone
+from piano_keys import PianoKeys
+from visualization import KeyVisualization
+
+FPS = 60
+CHUNKS_PER_SECOND = 20
+
+
+class ChannelManager:
+ def __init__(self, chunks_per_second):
+ self.sample_rate, sample_format = pygame.mixer.get_init()[:2]
+ self.channel = None
+ s = lambda f: int(f * self.sample_rate)
+ self.chunk_size = s(1 / chunks_per_second)
+ basic_sample_type = {-8: numpy.int8, -16: numpy.int16}[sample_format]
+ self.sample_type = numpy.dtype((basic_sample_type, 2))
+ self.wave = SineWave(self.sample_rate)
+ self.amplitude = 2 ** (abs(sample_format) - 1) - 1
+ self.envelope = ADSR.Envelope(s(.2), s(.4), .75, s(.5))
+ self.tones = {}
+ self.counter = 0
+
+ @staticmethod
+ def duplicate_channel(g):
+ return ((e, e) for e in g)
+
+ @staticmethod
+ def queue(channel, *args):
+ channel.queue(
+ pygame.sndarray.make_sound(
+ numpy.fromiter(*args)
+ )
+ )
+
+ def update(self):
+ for frequency in {
+ frequency for frequency, tone in self.tones.items()
+ if not tone.has_signal()
+ }:
+ self.tones.pop(frequency)
+ if not self.tones:
+ if self.channel:
+ self.channel = None
+ self.counter = 0
+ return
+ if self.channel is None:
+ self.channel = pygame.mixer.Channel(True)
+ if self.channel.get_busy() and self.channel.get_queue() is not None:
+ return
+ sample_slice = slice(self.counter, self.counter + self.chunk_size)
+ self.queue(
+ self.channel,
+ self.duplicate_channel(
+ int(
+ sum(tone(n) for tone in self.tones.values())
+ / len(self.tones)
+ )
+ for n in range(sample_slice.start, sample_slice.stop)
+ ),
+ self.sample_type
+ )
+ self.counter = sample_slice.stop
+
+ def update_tones(self, frequencies):
+ have_keys = {frequency for frequency, tone in self.tones.items() if tone.signal}
+ if frequencies == have_keys:
+ return
+ for frequency in frequencies - have_keys:
+ if frequency in self.tones:
+ self.tones[frequency].push()
+ continue
+ self.tones[frequency] = Tone(
+ self.wave,
+ frequency,
+ self.amplitude,
+ ADSR(self.envelope),
+ )
+ for frequency in have_keys - frequencies:
+ self.tones[frequency].release()
+
+
+class MusicBox:
+ def __init__(self):
+ pygame.init()
+ assert hasattr(pygame.constants, "FINGERMOTION")
+ self.surf = pygame.display.set_mode((2200, 1400))
+ self.running = True
+ self.dirty = False
+ self.clock = pygame.time.Clock()
+ self.channel_manager = ChannelManager(CHUNKS_PER_SECOND)
+ size = self.surf.get_size()
+ middle = size[1] // 2
+ self.piano_keys = PianoKeys(
+ pygame.Rect((0, 0), (size[0], middle)),
+ PitchLookup(432),
+ 2,
+ )
+ self.pressed_keys = set()
+ self.fingers = {}
+ self.key_vis = KeyVisualization(
+ self.piano_keys,
+ self.pressed_keys,
+ pygame.Rect((0, middle), (size[0], middle))
+ )
+
+ def finger_coords(self, ev):
+ size = self.surf.get_size()
+ return (int(ev.x * size[0]), int(ev.y * size[1]))
+
+ def handle_event(self, ev):
+ if ev.type == pygame.QUIT:
+ self.running = False
+ elif ev.type == pygame.KEYDOWN:
+ if ev.key == pygame.K_ESCAPE:
+ self.running = False
+ elif ev.type == pygame.WINDOWEXPOSED:
+ self.dirty = True
+ elif ev.type == pygame.MOUSEBUTTONDOWN:
+ if ev.button == 1:
+ self.fingers[None] = ev.pos
+ elif ev.type == pygame.MOUSEBUTTONUP:
+ self.fingers.pop(None, None)
+ elif ev.type == pygame.MOUSEMOTION:
+ if None in self.fingers:
+ self.fingers[None] = ev.pos
+ elif ev.type == pygame.FINGERDOWN:
+ self.fingers[(ev.touch_id, ev.finger_id)] = self.finger_coords(ev)
+ elif ev.type == pygame.FINGERUP:
+ self.fingers.pop((ev.touch_id, ev.finger_id), None)
+ elif ev.type == pygame.FINGERMOTION:
+ self.fingers[(ev.touch_id, ev.finger_id)] = self.finger_coords(ev)
+
+ def draw(self):
+ self.surf.fill("darkgray")
+ self.piano_keys.draw(self.surf)
+ self.key_vis.draw(self.surf)
+ pygame.display.update()
+
+ def update(self):
+ self.pressed_keys.clear()
+ self.pressed_keys.update((
+ key for key in (
+ self.piano_keys.find_key(pos) for pos in self.fingers.values()
+ ) if key
+ ))
+ self.channel_manager.update_tones(
+ {key.frequency for key in self.pressed_keys}
+ )
+ self.channel_manager.update()
+ self.key_vis.update()
+ self.dirty = True
+
+ def run(self):
+ while True:
+ for ev in pygame.event.get():
+ self.handle_event(ev)
+ if not self.running:
+ break
+ self.update()
+ if not self.running:
+ break
+ elif self.dirty:
+ self.draw()
+ self.dirty = False
+ self.clock.tick(FPS)
+
+
+if __name__ == "__main__":
+ MusicBox().run()
--- /dev/null
+#!/usr/bin/env python3
+
+import pygame
+
+
+class Key:
+ def __init__(self, frequency, color, polygon):
+ self.frequency = frequency
+ self.color = color
+ self.polygon = polygon
+
+
+class PianoKeys:
+ STYLE = {
+ "border": 10,
+ "black_overlap": 1 / 4,
+ "black_height": 1 / 2,
+ }
+
+ WHITE_KEY_NAMES = ("C", "D", "E", "F", "G", "A", "B")
+ BLACK_KEY_NAMES = ("C#", "D#", None, "F#", "G#", "A#", None)
+
+ # white width is width / (8 * num_octaves + 1)
+ # black key width is border + 2 * black_overlap
+
+ def __init__(self, rect, pitch_lookup, num_octaves=1, start_octave=4):
+ self.num_white = int(7 * num_octaves + 1)
+ self.rect = rect
+ self.keys = []
+ border_pixels = (self.num_white + 1) * self.STYLE["border"]
+ white_width = (rect.width - border_pixels) / self.num_white
+ x = self.STYLE["border"]
+ for n in range(self.num_white):
+ r = pygame.Rect((int(x), 0), (int(white_width), rect.height))
+ key_name = self.WHITE_KEY_NAMES[n % len(self.WHITE_KEY_NAMES)]
+ octave = start_octave + n // len(self.WHITE_KEY_NAMES)
+ self.keys.append(
+ Key(
+ pitch_lookup(f"{key_name}{octave}"),
+ "white",
+ [r.bottomleft, r.topleft, r.topright, r.bottomright],
+ )
+ )
+ x += white_width + self.STYLE["border"]
+ ins_off = 0
+ for n in range(len(self.keys) - 1):
+ key_name = self.BLACK_KEY_NAMES[n % len(self.BLACK_KEY_NAMES)]
+ if key_name is None:
+ continue
+ key = self.keys[n + ins_off]
+ # add a black key to the right of key.
+ r = pygame.Rect(
+ (
+ int(
+ key.polygon[-2][0] + self.STYLE["border"] / 2 - (
+ white_width * self.STYLE["black_overlap"]
+ )
+ ),
+ 0,
+ ),
+ (
+ int(2 * white_width * self.STYLE["black_overlap"]),
+ int(rect.height * self.STYLE["black_height"])
+ ),
+ )
+ octave = start_octave + n // len(self.BLACK_KEY_NAMES)
+ ins_off += 1
+ self.keys.insert(
+ n + ins_off,
+ Key(
+ pitch_lookup(f"{key_name}{octave}"),
+ "black",
+ [r.bottomleft, r.topleft, r.topright, r.bottomright],
+ )
+ )
+ # cut off top right piece from key
+ index = len(key.polygon) - 2
+ key.polygon[index] = (
+ r.left - self.STYLE["border"], key.polygon[index][1]
+ )
+ below = r.bottom + self.STYLE["border"]
+ key.polygon.insert(index + 1, (key.polygon[index][0], below))
+ key.polygon.insert(index + 2, (key.polygon[-1][0], below))
+ # cut off top left piece from next key
+ next_key = self.keys[n + ins_off + 1]
+ next_key.polygon[1] = (r.right + self.STYLE["border"], next_key.polygon[1][1])
+ next_key.polygon.insert(1, (next_key.polygon[1][0], below))
+ next_key.polygon.insert(1, (next_key.polygon[0][0], below))
+
+ def find_key(self, pos):
+ for key in self.keys:
+ if len(key.polygon) == 4:
+ if pygame.Rect(
+ key.polygon[1],
+ (
+ key.polygon[3][0] - key.polygon[1][0],
+ key.polygon[3][1] - key.polygon[1][1],
+ ),
+ ).collidepoint(pos):
+ return key
+ elif len(key.polygon) in (6, 8):
+ y = max((key.polygon[1][1], key.polygon[4][1]))
+ top_points = tuple(k for k in key.polygon if k[1] == 0)
+ if pygame.Rect(
+ (key.polygon[0][0], y),
+ (
+ key.polygon[-1][0] - key.polygon[0][0],
+ key.polygon[0][1] - y,
+ ),
+ ).collidepoint(pos) or pygame.Rect(
+ top_points[0],
+ (top_points[1][0] - top_points[0][0], y),
+ ).collidepoint(pos):
+ return key
+ return None
+
+ def draw(self, surf):
+ for key in self.keys:
+ pygame.draw.polygon(surf, key.color, key.polygon)
+
+
+def main():
+ surf = pygame.display.set_mode((1800, 1000))
+ running = True
+ clock = pygame.time.Clock()
+ pk = PianoKeys(surf.get_rect())
+ pk.draw(surf)
+ pygame.display.update()
+ while True:
+ for ev in pygame.event.get():
+ if ev.type == pygame.QUIT:
+ running = False
+ break
+ elif ev.type == pygame.WINDOWEXPOSED:
+ pygame.display.update()
+ elif ev.type == pygame.KEYDOWN:
+ if ev.key == pygame.K_ESCAPE:
+ running = False
+ break
+ elif ev.type == pygame.MOUSEBUTTONDOWN:
+ if ev.button == 1:
+ print(pk.find_key(ev.pos).name)
+ if not running:
+ break
+ clock.tick(60)
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+from typing import Generic, TypeVar
+
+T = TypeVar("T")
+
+
+class ReadOnlyDescriptor(Generic[T]):
+ def __init__(self):
+ self._name: str
+
+ def __set_name__(self, owner, name: str):
+ self._name = f"_{name}"
+
+ def __get__(self, obj, _) -> T:
+ return getattr(obj, self._name)
+
+ def __set__(self, obj, value: T):
+ if hasattr(obj, self._name):
+ raise AttributeError(f"{obj}.{self._name[1:]} is read-only")
+ setattr(obj, self._name, value)
--- /dev/null
+from colorsys import hsv_to_rgb
+from time import time
+
+import pygame
+
+
+class KeyVisualization:
+ PX_PER_SEC = 50
+
+ class Segment:
+ def __init__(self, index, start_time, end_time=None):
+ self.index = index
+ self.start_time = start_time
+ self.end_time = end_time
+
+ def __init__(self, piano_keys, pressed_keys, rect):
+ self.piano_keys = piano_keys
+ self.pressed_keys = pressed_keys
+ self.rect = rect
+ self.segments = []
+
+ def draw(self, surf):
+ t = time()
+ num_piano_keys = len(self.piano_keys.keys)
+ width = self.rect.width // num_piano_keys
+ top = self.rect.top + 15
+ for s in self.segments:
+ s_top = top
+ if s.end_time:
+ s_top += (t - s.end_time) * self.PX_PER_SEC
+ height = (s.end_time - s.start_time) * self.PX_PER_SEC
+ else:
+ height = (t - s.start_time) * self.PX_PER_SEC
+
+ rect = pygame.Rect(
+ (s.index * width + 5, s_top), (width - 10, height)
+ )
+ rgb = tuple(
+ 255 * v
+ for v in hsv_to_rgb(s.index / num_piano_keys, 1, 1)
+ )
+ pygame.draw.rect(surf, rgb, rect)
+ def update(self):
+ running_segments_per_index = {
+ s.index: s for s in self.segments if s.end_time is None
+ }
+ t = time()
+ for pressed_key in self.pressed_keys:
+ index = self.piano_keys.keys.index(pressed_key)
+ if index not in running_segments_per_index:
+ self.segments.append(self.Segment(index, t))
+ else:
+ running_segments_per_index.pop(index)
+ for segment in running_segments_per_index.values():
+ segment.end_time = t