]> git.mar77i.info Git - musicbox/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Tue, 9 Jul 2024 23:19:14 +0000 (01:19 +0200)
committermar77i <mar77i@protonmail.ch>
Tue, 9 Jul 2024 23:19:14 +0000 (01:19 +0200)
.gitignore [new file with mode: 0644]
music.py [new file with mode: 0644]
musicbox.py [new file with mode: 0755]
piano_keys.py [new file with mode: 0644]
utils.py [new file with mode: 0644]
visualization.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..932765a
--- /dev/null
@@ -0,0 +1,2 @@
+__pycache__/
+venv
diff --git a/music.py b/music.py
new file mode 100644 (file)
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 (executable)
index 0000000..0b9687e
--- /dev/null
@@ -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 (file)
index 0000000..8c07b23
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..1846511
--- /dev/null
@@ -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