From: mar77i Date: Tue, 9 Jul 2024 23:19:14 +0000 (+0200) Subject: initial commit X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=fd6687e3c74a26e32d631fed2d05a05bd7346bde;p=musicbox initial commit --- fd6687e3c74a26e32d631fed2d05a05bd7346bde diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..932765a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +venv diff --git a/music.py b/music.py new file mode 100644 index 0000000..19699f9 --- /dev/null +++ b/music.py @@ -0,0 +1,161 @@ +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 diff --git a/musicbox.py b/musicbox.py new file mode 100755 index 0000000..0b9687e --- /dev/null +++ b/musicbox.py @@ -0,0 +1,174 @@ +#!/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() diff --git a/piano_keys.py b/piano_keys.py new file mode 100644 index 0000000..8c07b23 --- /dev/null +++ b/piano_keys.py @@ -0,0 +1,149 @@ +#!/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() diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..a132402 --- /dev/null +++ b/utils.py @@ -0,0 +1,19 @@ +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) diff --git a/visualization.py b/visualization.py new file mode 100644 index 0000000..1846511 --- /dev/null +++ b/visualization.py @@ -0,0 +1,55 @@ +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