beepr

This has got to be one of the most obnoxious things I've done.

I've always had a thing for lo-fi, chip-style audio. I'd been wanting to do something of the sort for android, but I've been pretty up-to-the-neck with other stuff, so I've not had much time.

Then, just last night, as I was wrapping up another code project, I thought I might as well throw in some sort of music maker. And I tried. Boy, did I try. Working with C, though, means messing with external audio libraries or trying to work directly with pcspeaker via ioctl. Nothing worked, and I decided I hate C forever (I don't).

Good thing python simply sort of works with just about anything under the sun. So I ditched C and started over, this time using pygame, and tkinter for the gui frontend.

And it worked. I'm using the pc keyboard, but it's also drawing an on-screen keyboard so you can either clickity-click or typity-doo. The UI is nothing out of this world: just an on-screen qwerty-layout keyboard (minus controls), and a small 'screen' to display typed characters. I assigned a frequency value to each key, with 'z' being the lowest (C3), and each key after (in row order) being exactly one semitone higher, so that the entire pc keyboard covers about 3 octaves.

I tried and failed many times to generate the notes via code, so the dumbed down solution was to generate individual .wav files and have each key trigger an aplay command to play them, based on the key bindings assigned to each key. The result is an obnoxious little keyboard to annoy the crap out of anyone within hearing range.

The reason I call it obnoxious is that it's ridiculously loud, and lowering the volume does nothing to ameliorate it, but I guess that's kinda part of the charm. It's not meant to be pretty. It's supposed to be lo-fi and chip-tune-like, and forcing the laptop speakers distorts the sound a little bit, so it comes off as a sort of square wave synth sound (ish).

I won't add any pictures because the UI is really just an on-screen keyboard and is like zero-percent descriptive of what the thing does.

You'll just have to trust me.

Code:

Project Page on GitHub:

#!/usr/bin/env python3

"""
beepr v0.1 - a keyboard based synthesizer made just for fun.

by Jonathan Torres

This program is free software: you can redistribute it and/or modify it 
under the terms of the GNU General Public License as published by the 
Free Software Foundation, either version 3 of the License, or 
(at your option) any later version.
"""


import sys
import math
import time
import threading
import os
import tkinter as tk
from tkinter import ttk
import numpy as np
import wave
import subprocess
import tempfile
import shutil

# Configuration
SAMPLE_RATE = 44100
BASE_FREQ = 130.81  # C3
KEYS_LAYOUT = [
    "1234567890",
    "qwertyuiop",
    "asdfghjkl",
    "zxcvbnm,./"
]


# z-row (index 3) is lowest, 1-row (index 0) is highest
ALL_KEYS = KEYS_LAYOUT[3] + KEYS_LAYOUT[2] + KEYS_LAYOUT[1] + KEYS_LAYOUT[0]

class BeeprApp:
    def __init__(self, root):
        self.root = root
        self.root.title("Beepr")
        # Set initial window size: Width x Height
        self.root.geometry("410x230")
        self.root.resizable(False, False)

        # Temp directory for wav files
        self.temp_dir = tempfile.mkdtemp(prefix="beepr_")
        self.sounds = {}
        self.generate_sounds()

        self.setup_ui()
        self.setup_bindings()

        # Pipe support
        if not sys.stdin.isatty():
            self.pipe_thread = threading.Thread(target=self.read_pipe, daemon=True)
            self.pipe_thread.start()

        self.root.protocol("WM_DELETE_WINDOW", self.on_exit)

    def generate_sounds(self):
        #Pre-generate WAV files for each key.
        duration = 0.2 #I set this to 0.2 to keep the notes short.  at 0.5 it's pretty muddy
        t = np.linspace(0, duration, int(SAMPLE_RATE * duration), False)
        
        fade_duration = int(SAMPLE_RATE * 0.05)
        fade_env = np.ones_like(t)
        fade_env[:fade_duration] = np.linspace(0, 1, fade_duration)
        fade_env[-fade_duration:] = np.linspace(1, 0, fade_duration)
        
        for i, char in enumerate(ALL_KEYS):
            freq = BASE_FREQ * (2 ** (i / 12.0))
            """tried to reduce volume by multiplying amplitude by 0.4
            (sounds just as obnoxiously loud to me anyway)"""
            tone = np.sin(2 * np.pi * freq * t) * fade_env * 0.4
            audio = (tone * 32767).astype(np.int16)
            
            wav_path = os.path.join(self.temp_dir, f"note_{i}.wav")
            with wave.open(wav_path, 'wb') as wf:
                wf.setnchannels(1)
                wf.setsampwidth(2)
                wf.setframerate(SAMPLE_RATE)
                wf.writeframes(audio.tobytes())
            
            self.sounds[char] = wav_path

    def setup_ui(self):
        self.display_var = tk.StringVar()
        self.display = ttk.Entry(self.root, textvariable=self.display_var, font=("Monospace", 14), state='readonly')
        self.display.pack(fill=tk.X, padx=10, pady=10)

        self.kb_frame = ttk.Frame(self.root)
        self.kb_frame.pack(padx=10, pady=10)

        self.buttons = {}
        for row_idx, row_chars in enumerate(KEYS_LAYOUT):
            row_frame = ttk.Frame(self.kb_frame)
            row_frame.pack(side=tk.TOP, anchor=tk.W)
            
            if row_idx == 1:
                ttk.Label(row_frame, text="  ").pack(side=tk.LEFT)
            elif row_idx == 2:
                ttk.Label(row_frame, text="    ").pack(side=tk.LEFT)
            elif row_idx == 3:
                ttk.Label(row_frame, text="      ").pack(side=tk.LEFT)

            for char in row_chars:
                btn = ttk.Button(row_frame, text=char, width=3,
                               command=lambda c=char: self.play_key(c))
                btn.pack(side=tk.LEFT, padx=1)
                self.buttons[char] = btn

    def setup_bindings(self):
        self.root.bind("<Key>", self.on_key_press)

    def on_key_press(self, event):
        char = event.char.lower()
        if char in self.sounds:
            self.play_key(char)

    def play_key(self, char):
        if char in self.sounds:
            #Use aplay directly cuz nothing else worked (or maybe I just suck at this)
            subprocess.Popen(["aplay", "-q", self.sounds[char]], 
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

        current_text = self.display_var.get()
        if len(current_text) > 40:
            current_text = current_text[1:]
        self.display_var.set(current_text + char)
        
        if char in self.buttons:
            btn = self.buttons[char]
            btn.state(['pressed'])
            self.root.after(100, lambda: btn.state(['!pressed']))

    def read_pipe(self):
        while True:
            line = sys.stdin.readline()
            if not line:
                break
            for char in line:
                c = char.lower()
                if c in self.sounds:
                    self.root.after(0, lambda x=c: self.play_key(x))
                    time.sleep(0.15)
                elif c == ' ':
                    time.sleep(0.15)
            time.sleep(0.1)

    def on_exit(self):
        shutil.rmtree(self.temp_dir)
        self.root.destroy()

def main():
    root = tk.Tk()
    app = BeeprApp(root)
    root.mainloop()

if __name__ == "__main__":
    main()