diff --git a/.envrc b/.envrc index 3550a30..d31a3ff 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ +nix_direnv_watch_file pyproject.toml poetry.lock use flake diff --git a/.gitignore b/.gitignore index 5e6f9f6..b11d093 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /result /ideas.md __pycache__/ +/boardie.yaml +/sounds/ diff --git a/boardie/__init__.py b/boardie/__init__.py index f70e22c..23b397e 100644 --- a/boardie/__init__.py +++ b/boardie/__init__.py @@ -1,8 +1,161 @@ -import shutil +import argparse +import os.path +import random import evdev import pyaudio import pydub +import yaml + +class Sound: + def __init__(self, combo, files, bit_depth=16, sample_rate=44100, channels=2): + keys = [] + for k in combo.upper().split('-'): + match k: + case 'SHIFT': + k = 'LEFTSHIFT' + keys.append(evdev.ecodes.ecodes[f'KEY_{k}']) + self.held = set(keys[:-1]) + self.key = keys[-1] + + self.sounds = [] + for fname in files: + self.sounds.append(( + fname, + pydub.AudioSegment.from_file(fname) + .set_sample_width(bit_depth // 8) + .set_frame_rate(sample_rate) + .set_channels(channels) + .normalize())) + + self.active = None + + def stop(self): + self.active = None + + def play(self): + name, self.active = random.choice(self.sounds) + print(f'Playing {name}') + + def next_chunk(self, frame_count): + if not self.active: + return pydub.AudioSegment.silent(0) + + chunk = self.active.get_sample_slice(end_sample=frame_count) + self.active = self.active.get_sample_slice(start_sample=frame_count) + if self.active.frame_count == 0: + self.active = None + return chunk + +class Boardie: + bit_depth = 16 + channels = 2 + + def __init__(self, config_file, device, audio_device=None): + self.config_file = config_file + self.sounds = [] + + self.device = evdev.InputDevice(device) + self.device.grab() + + self.pa = pyaudio.PyAudio() + dev_info = self.pa.get_device_info_by_index(audio_device) + self.sample_rate = int(dev_info['defaultSampleRate']) + self.stream = self.pa.open( + output_device_index=audio_device, + format=self.pa.get_format_from_width(self.bit_depth // 8), + rate=self.sample_rate, + channels=self.channels, + output=True, + stream_callback=self._acallback) + + self.reload() + + def __enter__(self): + return self + def __exit__(self, exc_type, exc_value, tb): + for s in self.sounds: + s.stop() + + self.stream.close() + self.pa.terminate() + + self.device.ungrab() + self.device.close() + + def reload(self): + print('Loading config') + for s in self.sounds: + s.stop() + + with open(self.config_file) as f: + data = yaml.full_load(f) + + dir_ = data['dir'] + self.sounds = [] + for combo, fnames in data['sounds'].items(): + if not isinstance(fnames, list): + fnames = [fnames] + files = list(map(lambda f: os.path.join(dir_, f), fnames)) + self.sounds.append(Sound( + combo, files, + bit_depth=self.bit_depth, sample_rate=self.sample_rate, channels=self.channels)) + + def _acallback(self, _in_data, frame_count, time_info, status): + seg = pydub.AudioSegment( + data=frame_count * self.channels * (self.bit_depth // 2) * b'\0', + frame_rate=self.sample_rate, + sample_width=self.bit_depth // 8, + channels=self.channels) + + for s in self.sounds: + seg = seg.overlay(s.next_chunk(frame_count)) + + return seg.raw_data, pyaudio.paContinue + + def run(self): + for ev in self.device.read_loop(): + if ev.type != evdev.ecodes.EV_KEY: + continue + ev = evdev.categorize(ev) + if ev.keystate != ev.key_down: + continue + + held = set(self.device.active_keys()) & {evdev.ecodes.KEY_LEFTSHIFT, evdev.ecodes.KEY_LEFTCTRL} + if ev.scancode == evdev.ecodes.KEY_ESC: + if evdev.ecodes.KEY_LEFTSHIFT in held: + self.reload() + else: + for s in self.sounds: + s.stop() + continue + for s in self.sounds: + if ev.scancode == s.key and s.held == held: + s.play() + break def main(): - print(f'ffmpeg: {shutil.which("ffmpeg")}') + parser = argparse.ArgumentParser( + 'boardie', description='Linux soundboard', + formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-a', '--audio-device', help='Audio device to use, pass ? to list') + parser.add_argument('-f', '--config', help='Config file', default='boardie.yaml') + parser.add_argument('device', help='Keyboard event device') + + args = parser.parse_args() + if args.audio_device == '?': + pa = pyaudio.PyAudio() + apis = [] + for i in range(pa.get_host_api_count()): + apis.append(pa.get_host_api_info_by_index(i)['name']) + for i in range(pa.get_device_count()): + info = pa.get_device_info_by_index(i) + if info['maxOutputChannels'] == 0: + continue + + print(f"{info['index']}: ({apis[info['hostApi']]}) {info['name']}") + return + + audio_dev = int(args.audio_device) if args.audio_device is not None else None + with Boardie(args.config, args.device, audio_device=audio_dev) as boardie: + boardie.run() diff --git a/flake.nix b/flake.nix index ab56ee2..2768400 100644 --- a/flake.nix +++ b/flake.nix @@ -49,8 +49,9 @@ { devShells.default = pkgs.devshell.mkShell { packages = with pkgs; [ - poetry ffmpeg + + poetry (pkgs.poetry2nix.mkPoetryEnv { projectDir = ./.; overrides = pyOverrides pkgs; diff --git a/poetry.lock b/poetry.lock index 8695e46..11aa681 100644 --- a/poetry.lock +++ b/poetry.lock @@ -47,7 +47,57 @@ files = [ {file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"}, ] +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "main" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, + {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, + {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, + {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, + {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, +] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a3a55972f5afbae28f3b04b8c2d51919bce6524c0afcf43afa8a5590691a5c06" +content-hash = "1d6c1451eb78fd670ce9f340757bd5bf486b5407da603f6ca2f19441451633fc" diff --git a/pyproject.toml b/pyproject.toml index 84b1ad0..590f815 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ python = "^3.10" evdev = "^1.6.1" pydub = "^0.25.1" pyaudio = "^0.2.13" +pyyaml = "^6.0" [tool.poetry.scripts] boardie = 'boardie:main'