├── LICENSE ├── README.md ├── assets ├── 133741.jpg ├── combo.jpg ├── demo.mp3 └── midi │ ├── 01 in.mid │ ├── 02 out03.mid │ ├── 03 out18.mid │ ├── 04 final.mid │ ├── fix_instruments.py │ └── midi2bin.py ├── enclosure ├── case_black.stl ├── case_black.stp ├── case_led.stl ├── case_led.stp ├── case_lid.stl ├── case_lid.stp ├── case_main.stl ├── case_main.stp ├── encoder_knob.stl ├── encoder_knob.stp ├── encoder_main.stl ├── encoder_main.stp ├── support_lcd.stl └── support_lcd.stp ├── firmware ├── README.md ├── assets │ ├── arpai.bin │ ├── audio │ │ ├── 909_36.wav │ │ ├── 909_38.wav │ │ ├── 909_42.wav │ │ ├── 909_57.wav │ │ └── waveforms_akwf_granular.py │ └── images │ │ ├── 10x10.bmp │ │ ├── 1x4.bmp │ │ ├── 4x4.bmp │ │ ├── ai&a_beat.bmp │ │ ├── arp0.bmp │ │ ├── arp1.bmp │ │ ├── arp2.bmp │ │ ├── arp3.bmp │ │ ├── arpai.bmp │ │ ├── chord0.bmp │ │ ├── chord1.bmp │ │ ├── chord2.bmp │ │ ├── chord3.bmp │ │ ├── empty.bmp │ │ └── generate.bmp ├── boot.py ├── code.py ├── config │ ├── config.py │ └── menu.txt ├── lib │ ├── adafruit_imageload │ │ ├── __init__.mpy │ │ ├── bmp │ │ │ ├── __init__.mpy │ │ │ ├── indexed.mpy │ │ │ ├── negative_height_check.mpy │ │ │ └── truecolor.mpy │ │ ├── displayio_types.mpy │ │ ├── gif.mpy │ │ ├── jpg.mpy │ │ ├── png.mpy │ │ ├── pnm │ │ │ ├── __init__.mpy │ │ │ ├── pbm_ascii.mpy │ │ │ ├── pbm_binary.mpy │ │ │ ├── pgm │ │ │ │ ├── __init__.mpy │ │ │ │ ├── ascii.mpy │ │ │ │ └── binary.mpy │ │ │ ├── ppm_ascii.mpy │ │ │ └── ppm_binary.mpy │ │ └── tilegrid_inflator.mpy │ ├── adafruit_midi │ │ ├── __init__.mpy │ │ ├── active_sensing.mpy │ │ ├── channel_pressure.mpy │ │ ├── control_change.mpy │ │ ├── control_change_values.mpy │ │ ├── midi_continue.mpy │ │ ├── midi_message.mpy │ │ ├── mtc_quarter_frame.mpy │ │ ├── note_off.mpy │ │ ├── note_on.mpy │ │ ├── pitch_bend.mpy │ │ ├── polyphonic_key_pressure.mpy │ │ ├── program_change.mpy │ │ ├── start.mpy │ │ ├── stop.mpy │ │ ├── system_exclusive.mpy │ │ └── timing_clock.mpy │ ├── adafruit_st7735r.mpy │ ├── adafruit_wave.mpy │ ├── font.bmp │ └── neopixel.mpy └── src │ ├── common │ ├── audio.py │ ├── hw.py │ ├── menu.py │ ├── network.py │ ├── song.py │ └── ui.py │ └── core │ ├── arp.py │ ├── boss.py │ ├── chords.py │ ├── pattern.py │ └── pitch.py └── hardware ├── _pcb.png ├── _schematic.pdf ├── gerber.zip ├── leetai2.kicad_pcb ├── leetai2.kicad_pro ├── leetai2.kicad_sch └── leetai2_libraries.zip /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Johan von Konow 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎹 Leet AI – A Tiny Synth Ensemble with AI-Powered Inspiration 2 | 3 | Leet AI is a playful, pocket-sized synthesizer concept that explores what happens when many simple instruments work together—like a miniature electronic orchestra. Unlike traditional synths that try to do everything, Leet AI embraces a modular philosophy: multiple open-source, affordable units, each with a specific voice, combining to create something greater than the sum of their parts. 4 | 5 | ![group photo](/assets/133741.jpg) 6 | 7 | --- 8 | 9 | ## 🌟 Why Leet AI? 10 | 11 | Synthesizers can be intimidating. Menus, shift-buttons, and buried features can kill creative flow. Leet AI is an attempt to bring back the fun—tiny instruments you can pick up, jam with, and let the AI surprise you with new melodic ideas. Whether you're a tinkerer, a musician, or just curious about AI and music, this project is for you. 12 | 13 | --- 14 | 15 | ## ✨ Features 16 | 17 | - 🎵 **AI-powered inspiration** 18 | Generate unique melodies, drum patterns, and variations using a diffusion-based AI model. 19 | 20 | - 🔧 **Hackable and open-source** 21 | Built with CircuitPython on an ESP32-S2—plug it in, and you're editing the firmware like a USB stick. 22 | 23 | - 🎛️ **Minimalist design with deep control** 24 | Dual rotary encoders with tilt navigation, RGB-backlit keys, and a full-color display keep you focused and in control. 25 | 26 | - 📶 **Wireless orchestration** 27 | A low-latency protocol keeps multiple units in sync. 28 | 29 | - 🔋 **Fully portable** 30 | Runs on a rechargeable 1000mAh LiPo battery. Jam anywhere. 31 | 32 | --- 33 | 34 | ## 📦 Hardware Specs 35 | 36 | - **Size:** 86 x 60 x 15 mm (smaller than a deck of cards!) 37 | - **Keys:** 16 RGB-backlit mechanical keys 38 | - **Microcontroller:** ESP32-S2 (CircuitPython firmware) 39 | - **Display:** 1.8” 160x128 TFT color screen 40 | - **Encoders:** Dual magnetic rotary encoders with tilt function 41 | - **Audio:** High-quality 112dB SNR DAC 42 | - **Connectivity:** MIDI over USB-C, ESP-Now wireless sync 43 | - **Power:** 1000mAh LiPo battery with onboard charging 44 | 45 | --- 46 | 47 | # ▶️ **Demo video** 48 | [![demo](https://img.youtube.com/vi/MnzYHhDXu_o/default.jpg )](https://youtu.be/MnzYHhDXu_o) 49 | 50 | --- 51 | 52 | ## 🧪 Build Difficulty 53 | 54 | **Medium.** You'll need SMD soldering skills, a 3D printer, and some patience and curiosity. 55 | 56 | --- 57 | 58 | ## 🛠️ Development History 59 | 60 | Leet AI combines the best aspects of three earlier open hardware projects: [**leet**](https://vonkonow.com/leet-synthesizer/), [**leet modular**](https://vonkonow.com/leet-modular/), and [**chip champ**](https://vonkonow.com/chipchamp/). Prototypes have evolved through countless iterations—from hand-wired mock-ups to a 12-key prototype and finally to the 16-key design that bridges sequencer and keyboard layouts. 61 | 62 | --- 63 | 64 | ## 🚧 Limitations (For Now) 65 | 66 | This is still a conceptual prototype. While it’s fully functional for the demo, many features are hardcoded. Here’s what’s next on the roadmap: 67 | 68 | - 🎚️ Real-time editing and melody creation 69 | - 🎵 Higher-fidelity audio engine and custom graphic module 70 | - 🎛️ Instrument selection and sound design (ADSR, filters, etc.) 71 | - 🧠 Smarter AI integration with automatic Wi-Fi server connection 72 | - 🎹 Stacked octave mode 73 | - 🔊 Optional speaker 74 | - 🎶 AMY synth library support? 75 | 76 | --- 77 | 78 | ## 🛒 BOM / Estimated Cost 79 | 80 | **Total cost per unit:** ~$24 81 | 82 | --- 83 | 84 | ## 💡 Contributing 85 | 86 | Got ideas? Want to help improve the synth engine, optimize graphics, or design new enclosures? All contributions—code, hardware, or creative—are welcome! 87 | 88 | ### ➕ How to Contribute 89 | 90 | 1. Fork the repo 91 | 2. Try the demo firmware 92 | 3. Submit issues or pull requests 93 | 4. Join the discussion on hardware, AI models, or interface design 94 | 95 | --- 96 | 97 | ## 📁 Repository Structure 98 | 99 | ``` 100 | / 101 | ├── firmware/ → CircuitPython source code 102 | ├── hardware/ → KiCad files, schematics, and PCB layout 103 | ├── enclosure/ → 3D models (Rhino & STL) 104 | ├── ai-server/ → getMusic setup, scripts and generation tools 105 | ├── docs/ → Build guide, BOM, and reference images 106 | └── README.md → Project overview 107 | ``` 108 | 109 | --- 110 | 111 | ## ❤️ Special Thanks 112 | 113 | Huge thanks to the creators and maintainers of open-source tools that made this project possible. 114 | Special gratitude to [**Adafruit**](https://www.adafruit.com/) for developing [**CircuitPython**](https://circuitpython.org/board/lolin_s2_mini/), which made firmware development approachable, flexible, and fun—even for hardware newcomers. 115 | Thanks also to the team behind [**synthio**](https://docs.circuitpython.org/en/latest/shared-bindings/synthio/), whose powerful audio capabilities laid the groundwork for expressive, real-time sound generation on microcontrollers. 116 | And to Microsoft Research for [**getMusic**](https://github.com/microsoft/muzic), and to all the explorers at the intersection of music, AI, and creative technology—your curiosity and generosity continue to inspire. 117 | 118 | 119 | --- 120 | 121 | ## 🧠 Imagine What’s Next 122 | 123 | Leet AI is still early, but the concept is alive. With your feedback, forks, and experiments, this can grow into a truly modular, generative instrument playground. 124 | 125 | --- 126 | 127 | ## 🔗 [Visit the project website ](https://vonkonow.com/leetai/) 128 | ## 📂 [Explore the GitHub repository ](https://github.com/vonkonow/leetai) 129 | ## 📸 [Share your builds and music!](https://vonkonow.com/community/) 130 | 131 | 132 | ![DnB](/assets/combo.jpg "DnB") -------------------------------------------------------------------------------- /assets/133741.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/133741.jpg -------------------------------------------------------------------------------- /assets/combo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/combo.jpg -------------------------------------------------------------------------------- /assets/demo.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/demo.mp3 -------------------------------------------------------------------------------- /assets/midi/01 in.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/midi/01 in.mid -------------------------------------------------------------------------------- /assets/midi/02 out03.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/midi/02 out03.mid -------------------------------------------------------------------------------- /assets/midi/03 out18.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/midi/03 out18.mid -------------------------------------------------------------------------------- /assets/midi/04 final.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/assets/midi/04 final.mid -------------------------------------------------------------------------------- /assets/midi/fix_instruments.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # Inspect and change instruments in MIDI files for GetMusic AI compatibility. 4 | # Example: change instrument 0 to piano: python fix_instruments.py test.mid 0 p 5 | # MIT License (attribution optional, but appreciated - Johan von Konow :) 6 | # ****************************************************************************** 7 | 8 | #!/usr/bin/env python3 9 | import sys 10 | import argparse 11 | from miditoolkit.midi import parser as mid_parser 12 | 13 | # Instrument mapping for shorthand codes 14 | INSTRUMENT_MAP = { 15 | "p": (0, "Piano"), 16 | "g": (25, "Guitar"), 17 | "b": (32, "Bass"), 18 | "s": (48, "String"), 19 | "l": (80, "Lead"), 20 | "d": (None, "Drums") # Special case for drums 21 | } 22 | 23 | def display_midi_info(midi_obj): 24 | """Display information about the MIDI file.""" 25 | print(f"-- Ticks per beat: {midi_obj.ticks_per_beat}") 26 | 27 | # Signatures 28 | print(f"-- Time signatures: {len(midi_obj.time_signature_changes)} | {midi_obj.time_signature_changes[0]}") 29 | print(f"-- Key signatures: {len(midi_obj.key_signature_changes)}") 30 | 31 | # Sample note timing 32 | if midi_obj.instruments and midi_obj.instruments[0].notes: 33 | note = midi_obj.instruments[0].notes[min(20, len(midi_obj.instruments[0].notes)-1)] 34 | mapping = midi_obj.get_tick_to_time_mapping() 35 | tick = note.start 36 | sec = mapping[tick] 37 | print(f"-- Length: {tick} tick at {sec} seconds") 38 | 39 | # Tempo changes 40 | print(f"-- Tempo changes: {len(midi_obj.tempo_changes)} | {midi_obj.tempo_changes[0]}") 41 | 42 | # Markers 43 | print(f"-- Markers: {len(midi_obj.markers)}") 44 | if midi_obj.markers: 45 | print(midi_obj.markers[0]) 46 | 47 | # Instruments 48 | print("\n-- Instruments --") 49 | for idx, instrument in enumerate(midi_obj.instruments): 50 | print(f"{idx} {instrument}") 51 | 52 | def change_instrument(midi_obj, instrument_idx, new_instrument, custom_name=None): 53 | """Change the instrument for a specific MIDI track.""" 54 | try: 55 | instrument_idx = int(instrument_idx) 56 | if instrument_idx < 0 or instrument_idx >= len(midi_obj.instruments): 57 | print(f"Error: Instrument index {instrument_idx} out of range (0-{len(midi_obj.instruments)-1})") 58 | return False 59 | except ValueError: 60 | print(f"Error: Instrument index must be an integer, got {instrument_idx}") 61 | return False 62 | 63 | instrument = midi_obj.instruments[instrument_idx] 64 | 65 | # Handle instrument shorthand codes 66 | if new_instrument in INSTRUMENT_MAP: 67 | program, name = INSTRUMENT_MAP[new_instrument] 68 | if new_instrument == "d": 69 | instrument.is_drum = True 70 | else: 71 | instrument.program = program 72 | instrument.name = name 73 | # Handle numeric program changes 74 | elif new_instrument.isnumeric(): 75 | program = int(new_instrument) 76 | if program < 0 or program > 127: 77 | print(f"Error: Program number must be between 0-127, got {program}") 78 | return False 79 | instrument.program = program 80 | instrument.name = custom_name if custom_name else f"Instrument {program}" 81 | else: 82 | print(f"Error: Unknown instrument code '{new_instrument}'") 83 | return False 84 | 85 | print(f"Changed instrument {instrument_idx} to {instrument.name} (program: {instrument.program})") 86 | return True 87 | 88 | def parse_arguments(): 89 | """Parse command line arguments.""" 90 | parser = argparse.ArgumentParser(description="MIDI file inspector and instrument modifier") 91 | parser.add_argument("midi_file", help="Path to the MIDI file") 92 | parser.add_argument("instrument_idx", nargs='?', help="Index of the instrument to modify (0-based)") 93 | parser.add_argument("new_instrument", nargs='?', 94 | help="New instrument code (p=Piano, g=Guitar, b=Bass, s=String, l=Lead, d=Drums) or program number (0-127)") 95 | parser.add_argument("custom_name", nargs='?', help="Custom name for the instrument (only used with numeric program)") 96 | return parser.parse_args() 97 | 98 | def main(): 99 | args = parse_arguments() 100 | 101 | try: 102 | # Load MIDI file 103 | midi_obj = mid_parser.MidiFile(args.midi_file) 104 | except Exception as e: 105 | print(f"Error loading MIDI file: {e}") 106 | return 1 107 | 108 | # Display MIDI information 109 | display_midi_info(midi_obj) 110 | 111 | # Change instrument if arguments provided 112 | if args.instrument_idx and args.new_instrument: 113 | print(f"\nChanging instrument {args.instrument_idx} to {args.new_instrument}") 114 | if change_instrument(midi_obj, args.instrument_idx, args.new_instrument, args.custom_name): 115 | try: 116 | midi_obj.dump(args.midi_file) 117 | print(f"Updated MIDI file saved to {args.midi_file}") 118 | except Exception as e: 119 | print(f"Error saving MIDI file: {e}") 120 | return 1 121 | 122 | return 0 123 | 124 | if __name__ == "__main__": 125 | sys.exit(main()) -------------------------------------------------------------------------------- /assets/midi/midi2bin.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # Converts a midi song to a binary format, an image and a json with midi details. 4 | # Binary file format: 5 | # - Header (11 bytes): ticks/beat (2), nr ticks (4), tempo (2), numerator (1), 6 | # denominator (1), nr instruments (1) 7 | # - Song: start_tick (2), end_tick (2), instrument (1), pitch (1), velocity (1) 8 | # 9 | # MIT License (attribution optional, but appreciated - Johan von Konow :) 10 | # ****************************************************************************** 11 | 12 | import sys 13 | import json 14 | import argparse 15 | import logging 16 | from miditoolkit.midi import parser as mid_parser 17 | from PIL import Image 18 | 19 | # Constants 20 | JSON_EXTENSION = ".json" 21 | BIN_EXTENSION = ".bin" 22 | BMP_EXTENSION = ".bmp" 23 | NUM_SECTIONS = 160 24 | 25 | # Configure logging 26 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 27 | 28 | def parse_arguments(): 29 | """Parse command line arguments.""" 30 | parser = argparse.ArgumentParser(description="Convert a MIDI song to binary format, image, and JSON.") 31 | parser.add_argument("midi_file", help="Path to the MIDI file") 32 | return parser.parse_args() 33 | 34 | def serialize_midi_data(mido_obj, data_type): 35 | """Generic function to serialize MIDI data based on the data type.""" 36 | if data_type == "time_signatures": 37 | return [{"numerator": t.numerator, "denominator": t.denominator, "time": t.time} for t in mido_obj.time_signature_changes] 38 | elif data_type == "key_signatures": 39 | return [{"key_name": k.key_name, "time": k.time} for k in mido_obj.key_signature_changes] 40 | elif data_type == "tempo_changes": 41 | return [{"tempo": t.tempo, "time": t.time} for t in mido_obj.tempo_changes] 42 | elif data_type == "markers": 43 | return [{"text": m.text, "time": m.time} for m in mido_obj.markers] 44 | elif data_type == "instruments": 45 | instruments = [] 46 | note_array = [] 47 | for id, inst in enumerate(mido_obj.instruments): 48 | instruments.append({ 49 | "id": id, 50 | "type": str(inst.program), 51 | "name": str(inst.name), 52 | "drum": inst.is_drum, 53 | "notes": [] 54 | }) 55 | for n in inst.notes: 56 | note_array.append([n.start, n.end, id, n.pitch, n.velocity]) 57 | return instruments, note_array 58 | else: 59 | raise ValueError(f"Unknown data type: {data_type}") 60 | 61 | def hsv_to_rgb(h: float, s: float, v: float) -> tuple: 62 | """Convert HSV to RGB.""" 63 | if s: 64 | if h == 1.0: h = 0.0 65 | i = int(h*6.0); f = h*6.0 - i 66 | w = int(255*( v * (1.0 - s) )) 67 | q = int(255*( v * (1.0 - s * f) )) 68 | t = int(255*( v * (1.0 - s * (1.0 - f)) )) 69 | v = int(255*v) 70 | if i==0: return (v, t, w) 71 | if i==1: return (q, v, w) 72 | if i==2: return (w, v, t) 73 | if i==3: return (w, q, v) 74 | if i==4: return (t, w, v) 75 | if i==5: return (v, w, q) 76 | else: v = int(255*v); return (v, v, v) 77 | 78 | def create_byte_song(mido_obj, note_array, time_tempo, time_numerator, time_denominator): 79 | """Create a byte array representing the song.""" 80 | byte_song = bytearray() 81 | byte_song.extend(mido_obj.ticks_per_beat.to_bytes(2, byteorder='big')) 82 | byte_song.extend(mido_obj.max_tick.to_bytes(4, byteorder='big')) 83 | byte_song.extend(round(time_tempo).to_bytes(2, byteorder='big')) 84 | byte_song.extend(time_numerator.to_bytes(1, byteorder='big')) 85 | byte_song.extend(time_denominator.to_bytes(1, byteorder='big')) 86 | byte_song.extend(len(mido_obj.instruments).to_bytes(1, byteorder='big')) 87 | for n in note_array: 88 | byte_song.extend(n[0].to_bytes(2, byteorder ='big')) 89 | byte_song.extend(n[1].to_bytes(2, byteorder ='big')) 90 | byte_song.extend(n[2:5]) 91 | return byte_song 92 | 93 | def generate_image(note_array, mido_obj, file_name): 94 | """Generate an image showing the number of notes per section for each instrument.""" 95 | pixel_array = [[] * NUM_SECTIONS for i in range(NUM_SECTIONS)] 96 | song_index = 0 97 | max_count = [0] * len(mido_obj.instruments) 98 | for x in range(NUM_SECTIONS): 99 | note_count = [0] * len(mido_obj.instruments) 100 | while note_array[song_index][0] < x*(mido_obj.max_tick / NUM_SECTIONS): 101 | note_count[note_array[song_index][2]] += 1 102 | song_index += 1 103 | if song_index > len(note_array) - 4: 104 | break 105 | for id, n in enumerate(note_count): 106 | if n > max_count[id]: 107 | max_count[id] = n 108 | pixel_array[x] = list(note_count) 109 | img = Image.new('RGB', (NUM_SECTIONS, 64), "black") 110 | pixels = img.load() 111 | for x, instrument_count in enumerate(pixel_array): 112 | for y, intensity in enumerate(instrument_count): 113 | for l in range(4): 114 | pixels[x, y*4+l] = hsv_to_rgb(y/len(max_count), 0.8, intensity/max_count[y]) 115 | img.save(file_name + BMP_EXTENSION) 116 | 117 | def main(): 118 | args = parse_arguments() 119 | file_name = args.midi_file.split(".")[0] 120 | try: 121 | mido_obj = mid_parser.MidiFile(args.midi_file) 122 | logging.info(mido_obj) 123 | song = { 124 | "ticks": str(mido_obj.ticks_per_beat), 125 | "max_tick": str(mido_obj.max_tick), 126 | "time_signatures": serialize_midi_data(mido_obj, "time_signatures"), 127 | "key_signatures": serialize_midi_data(mido_obj, "key_signatures"), 128 | "tempo_changes": serialize_midi_data(mido_obj, "tempo_changes"), 129 | "markers": serialize_midi_data(mido_obj, "markers"), 130 | "instruments": [] 131 | } 132 | song["instruments"], note_array = serialize_midi_data(mido_obj, "instruments") 133 | with open(file_name + JSON_EXTENSION, "w") as f: 134 | f.write(json.dumps(song, indent=4)) 135 | note_array.sort(key=lambda n: n[0]) 136 | time_tempo = mido_obj.tempo_changes[0].tempo 137 | time_numerator = mido_obj.time_signature_changes[0].numerator 138 | time_denominator = mido_obj.time_signature_changes[0].denominator 139 | byte_song = create_byte_song(mido_obj, note_array, time_tempo, time_numerator, time_denominator) 140 | with open(file_name + BIN_EXTENSION, "wb") as binary_file: 141 | binary_file.write(byte_song) 142 | generate_image(note_array, mido_obj, file_name) 143 | except Exception as e: 144 | logging.error(f"An error occurred: {e}") 145 | 146 | if __name__ == "__main__": 147 | main() -------------------------------------------------------------------------------- /enclosure/case_black.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/case_black.stl -------------------------------------------------------------------------------- /enclosure/case_led.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/case_led.stl -------------------------------------------------------------------------------- /enclosure/case_lid.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/case_lid.stl -------------------------------------------------------------------------------- /enclosure/case_main.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/case_main.stl -------------------------------------------------------------------------------- /enclosure/encoder_knob.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/encoder_knob.stl -------------------------------------------------------------------------------- /enclosure/encoder_main.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/encoder_main.stl -------------------------------------------------------------------------------- /enclosure/support_lcd.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/enclosure/support_lcd.stl -------------------------------------------------------------------------------- /firmware/README.md: -------------------------------------------------------------------------------- 1 | # 🎛️ CircuitPython code repository for the LeetAi devices. 2 | ## Usage: 3 | 0. First time, you have to connect the device and flash a [CircuitPython image for ESP32S2](https://circuitpython.org/board/lolin_s2_mini/) with [this tool](https://adafruit.github.io/Adafruit_WebSerial_ESPTool/). 4 | 1. The device then appears as a new drive. Copy all files and folders in this directory to the device. 5 | 2. The code will run automatically and you now have a synth. Edit config/config.py to change its function (see below). 6 | 7 | --- 8 | 9 | ## ⚙️ Configuration 10 | 11 | The file `config.py` defines each unit's role: 12 | 13 | | Function | Description | 14 | |----------|-------------| 15 | | `boss` | Manages the overall song structure | 16 | | `pitch` | Handles piano and bass lines | 17 | | `pattern`| Drives drum patterns | 18 | | `chords` | Generates chord progressions | 19 | | `arp` | Plays arpeggios that follow chords | 20 | 21 | --- 22 | ## 📁 folder structure: 23 | ``` 24 | firmware/ 25 | ├── README.md # CircuitPython usage & setup instructions 26 | ├── boot.py # Auto-run stub to launch main code on reset 27 | ├── code.py # Entry-point script that wires everything together 28 | │ 29 | ├── config/ # User-tweakable settings and menus 30 | │ ├── config.py # Role definitions (boss, arp, chords, etc.) & parameters 31 | │ └── menu.txt # Text menu definitions shown on the display 32 | │ 33 | ├── src/ # Core application code broken into two layers 34 | │ ├── core/ # High-level “unit” drivers (boss, arp, pitch, …) 35 | │ │ ├── boss.py # Master song/section manager 36 | │ │ ├── chords.py # Chord progression engine 37 | │ │ ├── pattern.py # Drum-pattern sequencer 38 | │ │ ├── pitch.py # Melody & bass line handler 39 | │ │ └── arp.py # Real-time arpeggiator 40 | │ │ 41 | │ └── common/ # Shared utilities & hardware abstractions 42 | │ ├── audio.py # Synth/audio helper routines 43 | │ ├── hw.py # Pin/encoder/button abstraction 44 | │ ├── menu.py # On-screen menu rendering & logic 45 | │ ├── network.py # Networking (Wi-Fi/OSC/etc.) support 46 | │ ├── song.py # Song data structures & serialization 47 | │ └── ui.py # Display & UI primitives 48 | │ 49 | ├── lib/ # Bundled 3rd-party CircuitPython libraries/drivers 50 | │ ├── adafruit_midi/ # MIDI bindings 51 | │ ├── adafruit_imageload/ # Image-loading helper 52 | │ ├── adafruit_st7735r.mpy # ST7735 display driver 53 | │ ├── adafruit_wave.mpy # WAV-sample playback driver 54 | │ ├── neopixel.mpy # WS2812 “NeoPixel” LED driver 55 | │ └── font.bmp # Built-in font bitmap 56 | │ 57 | └── assets/ # Static resources bundled into filesystem 58 | ├── images/ # UI graphics, icons, etc. 59 | ├── audio/ # Samples and wavetable files 60 | └── arpai.bin # Demo song (used by boss) 61 | ``` 62 | --- 63 | 64 | ## 🧰 Hardware Versions 65 | 66 | | Version | Features | 67 | |---------|----------| 68 | | **1.0** | 12 keys + analog encoders | 69 | | **1.1** | 16 keys (new layout) + digital encoders | 70 | 71 | --- 72 | 73 | ## 🧪 Development Notes 74 | 75 | I've used AI extensively for code refactoring, improvements and error handling. It's a work in progress and likely a few bugs left — but it's definitely more robust than before :) 76 | 77 | --- 78 | ## ❤️ Special Thanks 79 | 80 | Huge thanks to the creators and maintainers of open-source tools that made this project possible. 81 | - Special gratitude to [**Adafruit**](https://www.adafruit.com/) for developing [**CircuitPython**](https://circuitpython.org/board/lolin_s2_mini/), which made firmware development approachable, flexible, and fun—even for hardware newcomers. 82 | - Thanks also to the team behind [**synthio**](https://docs.circuitpython.org/en/latest/shared-bindings/synthio/), whose powerful audio capabilities laid the groundwork for expressive, real-time sound generation on microcontrollers. 83 | - And to Microsoft Research for [**getMusic**](https://github.com/microsoft/muzic), and to all the explorers at the intersection of music, AI, and creative technology—your curiosity and generosity continue to inspire. 84 | 85 | --- 86 | ## 🚀 Future Development 87 | 88 | Plenty remains to be done. The current demo uses mostly hard-coded elements, so unlocking its full potential will require: 89 | 90 | - 🎼 **Melody & pattern editor** 91 | - 🎧 **Audio optimization** *(Better sample rates, pitch control, lower CPU usage)* 92 | - 🖼️ **Optimized graphics** *(Smoother animations with less CPU load)* 93 | - 🎚️ **Instrument sound selection and parameter tuning** *(Velocity, bend, swing, ADSR, filters)* 94 | - 🔀 **Stacked octave mode** *(Multiple units act as one extended synth)* 95 | - 🌐 **Ai server connection** *(Currently manual)* 96 | - 🔊 **Maybe a built-in speaker?** 97 | - 🎵 **AMY synth library integration?** 98 | 99 | I will probably start with a custom circuitpython fork with new libraries for audio and graphics... 100 | 101 | --- 102 | ## 💡 Contributing 103 | 104 | Got ideas? Want to help improve the synth engine, optimize graphics, or design new enclosures? All contributions—code, hardware, or creative—are welcome! 105 | 106 | ### ➕ How to Contribute 107 | 108 | 1. Fork the repo 109 | 2. Try the demo firmware 110 | 3. Submit issues or pull requests 111 | 4. Join the discussion on hardware, AI models, or interface design 112 | 113 | ### 🧠 Imagine What's Next 114 | 115 | Leet AI is still early, but the concept is alive. With your feedback, forks, and experiments, this can grow into a truly modular, generative instrument playground. 116 | 117 | --- 118 | 119 | 🔗 [Visit the project website ](https://vonkonow.com/leetai/) 120 | 📂 [Explore the GitHub repository ](https://github.com/vonkonow/leetai) 121 | 📸 [Share your builds and music!](https://vonkonow.com/community/) -------------------------------------------------------------------------------- /firmware/assets/arpai.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/arpai.bin -------------------------------------------------------------------------------- /firmware/assets/audio/909_36.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/audio/909_36.wav -------------------------------------------------------------------------------- /firmware/assets/audio/909_38.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/audio/909_38.wav -------------------------------------------------------------------------------- /firmware/assets/audio/909_42.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/audio/909_42.wav -------------------------------------------------------------------------------- /firmware/assets/audio/909_57.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/audio/909_57.wav -------------------------------------------------------------------------------- /firmware/assets/images/10x10.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/10x10.bmp -------------------------------------------------------------------------------- /firmware/assets/images/1x4.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/1x4.bmp -------------------------------------------------------------------------------- /firmware/assets/images/4x4.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/4x4.bmp -------------------------------------------------------------------------------- /firmware/assets/images/ai&a_beat.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/ai&a_beat.bmp -------------------------------------------------------------------------------- /firmware/assets/images/arp0.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/arp0.bmp -------------------------------------------------------------------------------- /firmware/assets/images/arp1.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/arp1.bmp -------------------------------------------------------------------------------- /firmware/assets/images/arp2.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/arp2.bmp -------------------------------------------------------------------------------- /firmware/assets/images/arp3.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/arp3.bmp -------------------------------------------------------------------------------- /firmware/assets/images/arpai.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/arpai.bmp -------------------------------------------------------------------------------- /firmware/assets/images/chord0.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/chord0.bmp -------------------------------------------------------------------------------- /firmware/assets/images/chord1.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/chord1.bmp -------------------------------------------------------------------------------- /firmware/assets/images/chord2.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/chord2.bmp -------------------------------------------------------------------------------- /firmware/assets/images/chord3.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/chord3.bmp -------------------------------------------------------------------------------- /firmware/assets/images/empty.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/empty.bmp -------------------------------------------------------------------------------- /firmware/assets/images/generate.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/assets/images/generate.bmp -------------------------------------------------------------------------------- /firmware/boot.py: -------------------------------------------------------------------------------- 1 | import usb_hid, usb_midi 2 | usb_hid.disable() 3 | usb_midi.enable() -------------------------------------------------------------------------------- /firmware/code.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # 4 | # ██╗ ███████╗███████╗████████╗ █████╗ ██╗ 5 | # ██║ ██╔════╝██╔════╝╚══██╔══╝██╔══██╗██║ 6 | # ██║ █████╗ █████╗ ██║ ███████║██║ 7 | # ██║ ██╔══╝ ██╔══╝ ██║ ██╔══██║██║ 8 | # ███████╗███████╗███████╗ ██║ ██║ ██║██║ 9 | # ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝ 10 | # 11 | # Description: 12 | # Loader file that loads the program specified in ==> config/mode.py <== 13 | # 14 | # This code is open source under MIT License. 15 | # (attribution is optional, but always appreciated - Johan von Konow ;) 16 | # ****************************************************************************** 17 | import config.config as config # type: ignore 18 | import src.core.boss 19 | import src.core.pitch 20 | import src.core.pattern 21 | import src.core.chords 22 | import src.core.arp 23 | 24 | def main(): 25 | eval("src.core." + config.MODE + ".main()") # load mode selected in config.py 26 | 27 | if __name__ == "__main__": 28 | main() -------------------------------------------------------------------------------- /firmware/config/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | config.py - Central configuration for hardware, display, and application settings. 3 | 4 | This file contains all hardware pin assignments, display settings, and application-level 5 | constants for the project. By centralizing these settings, you can easily adapt the codebase 6 | to new hardware or change system-wide parameters in one place. 7 | 8 | Sections: 9 | - Device mode selection 10 | - Common configuration (network, MIDI, etc.) 11 | - Display settings 12 | - Menu settings 13 | - Hardware pin assignments (import board required) 14 | - Mode-specific configuration (e.g., boss, chords) 15 | - UI settings 16 | - Audio settings 17 | - Network settings 18 | 19 | Usage: 20 | Import this module as `import config.config as config` and use the variables directly. 21 | Example: `config.AN1_PIN`, `config.DISPLAY_WIDTH`, `config.KEY_RESET`, etc. 22 | """ 23 | import board 24 | # Device mode selection 25 | # Choose the mode for this device (boss, pitch, time, chords, scale) 26 | # MODE = "boss" 27 | MODE = "pitch" # piano roll 28 | # MODE = "pattern" # drums / pad 29 | # MODE = "chords" # chord progression 30 | # MODE = "arp" # arpeggio 31 | 32 | 33 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 34 | # Mode-specific configuration 35 | # These settings may override or extend the above depending on the selected MODE. 36 | if MODE == 'boss': 37 | # Hardware settings 38 | HW_VERSION = 1.0 39 | KEY_RESET = 10 40 | KEY_PLAY_PAUSE = 11 41 | INSTRUMENT_ID = 1 42 | # Song settings 43 | SONG_NAME = "arpai" 44 | SONG_PATH = "assets/" 45 | 46 | if MODE == 'pitch': 47 | # LEAD: 48 | INSTRUMENT_ID = 2 49 | MEDIAN_OCTAVE = 7 50 | # BASS 51 | # INSTRUMENT_ID = 1 52 | # MEDIAN_OCTAVE = 5 53 | HW_VERSION = 1.1 54 | PLAYBACK_STATES = { 55 | "paused": 0, 56 | "playing": 1, 57 | "reset": 2, 58 | "clear": 3 59 | } 60 | FIELD_X_MAX = 40 61 | FIELD_Y_MAX = 16 62 | FIELD_TILE_WIDTH = 4 63 | FIELD_TILE_HEIGHT = 4 64 | PIXELS_PER_BEAT = 4 65 | KEY_PCB_TO_NOTE = [1, 3, -1, 6, 8, 10, -1, 13, 0, 2, 4, 5, 7, 9, 11, 12] 66 | KEY_NOTE_TO_PCB = [8, 0, 9, 1, 10, 11, 3, 12, 4, 13, 5, 14, 2, 6, 7, 15] 67 | 68 | if MODE == 'pattern': 69 | INSTRUMENT_ID = 3 70 | MEDIAN_OCTAVE = 1 71 | HW_VERSION = 1.1 72 | FIELD_X_MAX = 16 73 | FIELD_Y_MAX = 4 74 | FIELD_TILE_WIDTH = 10 75 | FIELD_TILE_HEIGHT = 10 76 | XSTART = 0 77 | YSTART = 0 78 | 79 | # Mode configuration 80 | MODES = { 81 | 'paused': 0, 82 | 'playing': 1, 83 | 'reset': 2, 84 | 'clear': 3 85 | } 86 | 87 | # Drum configuration 88 | DRUM_NOTES = [36, 38, 42, 57] # bass, snare, hi-hat, crash 89 | DRUM_NAMES = [ 90 | "bass (36)", 91 | "snare (38)", 92 | "closed hi-hat (42)", 93 | "crash (57)" 94 | ] 95 | 96 | # Menu configuration 97 | MENU_ACTIONS = { 98 | 'send_song': lambda: None, # Will be set in pattern.py 99 | 'set_channel': lambda: None, 100 | 'show_my_ip': lambda: None, 101 | 'send_pair': lambda: None, 102 | 'show_paired_devices': lambda: None, 103 | 'set_mode': lambda mode_id: None 104 | } 105 | 106 | if MODE == 'chords': 107 | INSTRUMENT_ID = 1 108 | MEDIAN_OCTAVE = 3 # (0:5 1:3 2:7) 109 | HW_VERSION = 1.0 110 | 111 | if MODE == 'arp': 112 | INSTRUMENT_ID = 0 113 | MEDIAN_OCTAVE = 5 114 | HW_VERSION = 1.0 115 | FIELD_X_MAX = 8 116 | FIELD_Y_MAX = 8 117 | FIELD_TILE_WIDTH = 10 118 | FIELD_TILE_HEIGHT = 10 119 | PIXELS_PER_BEAT = 4 120 | PLAYBACK_STATES = { 121 | "paused": 0, 122 | "playing": 1, 123 | "reset": 2, 124 | "clear": 3 125 | } 126 | # Arpeggiator constants 127 | TICKS_PER_BEAT = 480 128 | MAX_TICKS = 47522 129 | TEMPO = 160 130 | DENOMINATOR = 4 131 | MAX_NOTE = 66 132 | MIN_NOTE = 55 133 | DEFAULT_ARP_PATTERN = [0, 1, 2, 1] 134 | XSTART = 40 135 | YSTART = 0 136 | BACKGROUND_Y_OFFSET = 81 137 | DEFAULT_TILE_VALUE = 3 138 | PATTERN_ACTIVE_TILE = 1 139 | PATTERN_CURRENT_TILE = 2 140 | LED_OFF_COLOR = 0x000000 141 | LED_ACTIVE_COLOR = 0x200010 142 | SCALE_C = 60 143 | SCALE_G = 55 144 | SCALE_A = 57 145 | SCALE_F = 65 146 | 147 | 148 | 149 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 150 | # Common configuration 151 | # Network and MIDI settings 152 | # MAC_BROADCAST: Broadcast MAC address for ESP-NOW 153 | # EXTRA_PAYLOAD: Used to pad packets for robustness 154 | # MIDI_CHANNEL: Default MIDI channel 155 | # DEFAULT_INTENSITY: Default velocity for MIDI events 156 | MAC_BROADCAST = b'\xFF\xFF\xFF\xFF\xFF\xFF' 157 | EXTRA_PAYLOAD = "PAYLOAD TO MAKE A PACKET MORE ROBUST..." 158 | MIDI_CHANNEL = 1 # pick your MIDI channel here 159 | DEFAULT_INTENSITY = 100 # midi intensity for live input 160 | 161 | # Display settings 162 | # DISPLAY_WIDTH/HEIGHT: Physical display dimensions in pixels 163 | DISPLAY_WIDTH = 160 164 | DISPLAY_HEIGHT = 128 165 | 166 | # Menu settings 167 | # MENU_FILE: Path to the menu configuration file 168 | MENU_FILE = "config/menu.txt" 169 | 170 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 171 | 172 | if HW_VERSION == 1.0: 173 | NEOPIXEL_NUM = 12 174 | KEY_NUM = 16 175 | KEY_COL = 8 176 | NR_KEYS = 12 177 | KEY_BACK = 13 178 | KEY_SELECT = 12 179 | ROTATION_CW = 3 180 | ROTATION_CCW = 2 181 | ROTATION_TOP_CW = 1 182 | ROTATION_TOP_CCW = 0 183 | 184 | # Key matrix column pins (8 columns) 185 | COL_PINS = [ 186 | board.IO8, board.IO7, board.IO6, board.IO5, 187 | board.IO4, board.IO3, board.IO2, board.IO1 188 | ] 189 | 190 | if HW_VERSION == 1.1: 191 | NEOPIXEL_NUM = 16 192 | KEY_NUM = 20 193 | KEY_COL = 10 194 | NR_KEYS = 16 195 | KEY_BACK = 19 196 | KEY_SELECT = 18 197 | 198 | # Key matrix column pins (10 columns) 199 | COL_PINS = [ 200 | board.IO8, board.IO7, board.IO6, board.IO5, board.IO4, 201 | board.IO3, board.IO2, board.IO1, board.IO13, board.IO14 202 | ] 203 | SCL0_PIN = board.IO9 204 | SDA0_PIN = board.IO16 205 | SCL1_PIN = board.IO17 206 | SDA1_PIN = board.IO18 207 | 208 | 209 | # Analog input pins for rotary encoders or analog controls 210 | AN1_PIN = board.IO14 211 | AN2_PIN = board.IO13 212 | 213 | # Key matrix row pins 214 | ROW0_PIN = board.IO33 215 | ROW1_PIN = board.IO34 216 | 217 | # Neopixel (RGB LED) settings 218 | NEOPIXEL_PIN = board.IO21 219 | 220 | # Built-in LED pin 221 | BUILTIN_LED_PIN = board.IO15 222 | 223 | # Display interface pins 224 | PICO_PIN = board.IO35 # data 225 | CLK_PIN = board.IO36 # clock 226 | RST_PIN = board.IO37 # reset 227 | CS_PIN = board.IO40 # chip select 228 | DC_PIN = board.IO39 # data/command 229 | BL_PIN = board.IO38 # backlight 230 | 231 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 232 | # UI Settings 233 | UI_COLORS = { 234 | 'off': (0, 0, 0), 235 | 'mute': (0, 10, 0), 236 | 'pause': (30, 0, 10), 237 | 'selected': (18, 70, 23), 238 | 'current': (200, 200, 200), 239 | 'current_bg': (14, 0, 20), 240 | 'key_off': (20, 0, 10) 241 | } 242 | 243 | # Display settings 244 | DISPLAY_REFRESH_RATE = 250 245 | 246 | 247 | # Text field positions 248 | TEXT_FIELD_POSITIONS = { 249 | 'title': (10, 80), 250 | 'channel': (20, 90), 251 | 'note': (50, 90), 252 | 'intensity': (80, 90), 253 | 'menu': (10, 110), 254 | 'debug': (10, 120) 255 | } 256 | 257 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 258 | # Audio Settings 259 | DEFAULT_SAMPLE_RATE = 16000 260 | DEFAULT_MIXER_VOICES = 8 261 | DEFAULT_MIXER_VOLUME = 0.2 262 | 263 | # Audio I2S pins 264 | I2S_BCLK_PIN = board.IO12 265 | I2S_WS_PIN = board.IO10 266 | I2S_DATA_PIN = board.IO11 267 | 268 | # Audio mixer configuration 269 | MIXER_CHANNEL_COUNT = 1 270 | MIXER_BITS_PER_SAMPLE = 16 271 | MIXER_SAMPLES_SIGNED = True 272 | 273 | # Wavetable channel configuration 274 | WAVETABLE_VOICE_NUMBERS = [4, 5, 6] # Voice numbers for melodic instruments 275 | WAVETABLE_WAVEFORM_INDICES = [0, 4, 3] # Indices into waveforms array for each channel 276 | 277 | # Sample configuration 278 | SAMPLE_FILES = [ 279 | "assets/audio/909_36.wav", 280 | "assets/audio/909_38.wav", 281 | "assets/audio/909_42.wav", 282 | "assets/audio/909_57.wav" 283 | ] 284 | SAMPLE_VOLUMES = {36: 0.4, 38: 0.1, 42: 0.1, 57: 0.1} 285 | SAMPLE_MAP = {36: 0, 38: 1, 42: 2, 57: 3} 286 | 287 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 288 | # Network Settings 289 | NETWORK_PACKET_TYPES = { 290 | 'event': ord('e'), 291 | 'live': ord('l'), 292 | 'pair': ord('p'), 293 | 'tick': ord('t'), 294 | 'begin': ord('b'), 295 | 'stop': ord('s'), 296 | 'header': ord('h'), 297 | 'mute': ord('m'), 298 | 'update': ord('u'), 299 | 'reset': ord('r'), 300 | 'clear': ord('c'), 301 | 'scale': ord('n') # Added scale packet type 302 | } 303 | 304 | NETWORK_PACKET_RETRANSMISSION = 1 305 | NETWORK_PACKET_DELAY = 0.004 306 | 307 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 308 | # Song Settings 309 | DEFAULT_SONG_METADATA = { 310 | 'ticks_per_beat': 480, 311 | 'max_ticks': 0, 312 | 'tempo': 160, 313 | 'numerator': 4, 314 | 'denominator': 4, 315 | 'nr_instruments': 1 316 | } 317 | 318 | # Provide MODES mapping if not defined (fallback to PLAYBACK_STATES) 319 | if 'MODES' not in globals(): 320 | MODES = PLAYBACK_STATES 321 | -------------------------------------------------------------------------------- /firmware/config/menu.txt: -------------------------------------------------------------------------------- 1 | Menu 2 | Test 3 | Settings 4 | send song|send_song 5 | set channel|set_channel 6 | my ip|show_my_ip 7 | pair request|send_pair 8 | paired devices|show_paired_devices 9 | set device mode 10 | conductor|set_mode:0 11 | melodic|set_mode:1 12 | chord|set_mode:2 13 | arpeggio|set_mode:3 14 | drum|set_mode:4 -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/__init__.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/__init__.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/bmp/__init__.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/bmp/__init__.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/bmp/indexed.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/bmp/indexed.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/bmp/negative_height_check.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/bmp/negative_height_check.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/bmp/truecolor.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/bmp/truecolor.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/displayio_types.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/displayio_types.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/gif.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/gif.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/jpg.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/jpg.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/png.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/png.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/__init__.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/__init__.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/pbm_ascii.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/pbm_ascii.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/pbm_binary.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/pbm_binary.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/pgm/__init__.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/pgm/__init__.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/pgm/ascii.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/pgm/ascii.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/pgm/binary.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/pgm/binary.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/ppm_ascii.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/ppm_ascii.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/pnm/ppm_binary.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/pnm/ppm_binary.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_imageload/tilegrid_inflator.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_imageload/tilegrid_inflator.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/__init__.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/__init__.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/active_sensing.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/active_sensing.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/channel_pressure.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/channel_pressure.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/control_change.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/control_change.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/control_change_values.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/control_change_values.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/midi_continue.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/midi_continue.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/midi_message.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/midi_message.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/mtc_quarter_frame.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/mtc_quarter_frame.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/note_off.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/note_off.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/note_on.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/note_on.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/pitch_bend.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/pitch_bend.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/polyphonic_key_pressure.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/polyphonic_key_pressure.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/program_change.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/program_change.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/start.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/start.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/stop.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/stop.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/system_exclusive.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/system_exclusive.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_midi/timing_clock.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_midi/timing_clock.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_st7735r.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_st7735r.mpy -------------------------------------------------------------------------------- /firmware/lib/adafruit_wave.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/adafruit_wave.mpy -------------------------------------------------------------------------------- /firmware/lib/font.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/font.bmp -------------------------------------------------------------------------------- /firmware/lib/neopixel.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/lib/neopixel.mpy -------------------------------------------------------------------------------- /firmware/src/common/audio.py: -------------------------------------------------------------------------------- 1 | """ 2 | Audio components for the core module. 3 | 4 | This module provides audio-related classes for sample playback, wavetable synthesis, 5 | and MIDI control. 6 | """ 7 | 8 | import audiocore 9 | import audiomixer 10 | import audiobusio 11 | import synthio 12 | import board 13 | from assets.audio.waveforms_akwf_granular import waveforms 14 | import adafruit_midi 15 | from adafruit_midi.note_on import NoteOn 16 | from adafruit_midi.note_off import NoteOff 17 | import usb_midi 18 | from src.common.ui import colorwheel 19 | import config.config as config 20 | 21 | class AudioManager: 22 | """ 23 | A class for managing all audio components. 24 | 25 | This class handles the initialization and management of: 26 | - Audio output (I2S) 27 | - Mixer configuration 28 | - Channel setup (wavetable and sample) 29 | - Envelope settings 30 | - Playback control 31 | 32 | Attributes: 33 | sample_rate: Audio sample rate in Hz 34 | mixer: Audio mixer instance 35 | channels: List of audio channels 36 | envelopes: Dictionary of envelope settings 37 | """ 38 | 39 | def __init__(self, hw): 40 | """Initialize a new audio manager.""" 41 | self.sample_rate = config.DEFAULT_SAMPLE_RATE 42 | self.mixer = audiomixer.Mixer( 43 | voice_count=config.DEFAULT_MIXER_VOICES, 44 | sample_rate=self.sample_rate, 45 | channel_count=config.MIXER_CHANNEL_COUNT, 46 | bits_per_sample=config.MIXER_BITS_PER_SAMPLE, 47 | samples_signed=config.MIXER_SAMPLES_SIGNED 48 | ) 49 | self.channels = [] 50 | self.envelopes = { 51 | 'fast': synthio.Envelope( 52 | attack_time=0.05, 53 | decay_time=0.1, 54 | release_time=0.1, 55 | attack_level=1.0, 56 | sustain_level=0.8 57 | ) 58 | } 59 | self.hw = hw 60 | self._initialize_audio() 61 | self._initialize_channels() # Initialize channels and create play attribute 62 | 63 | def _initialize_audio(self): 64 | """Initialize audio output and mixer.""" 65 | try: 66 | self.audio = audiobusio.I2SOut( 67 | config.I2S_BCLK_PIN, # BCLK 68 | config.I2S_WS_PIN, # LRCK (Word Select) 69 | config.I2S_DATA_PIN # DIN (Data In) 70 | ) 71 | self.audio.play(self.mixer) 72 | self.mixer.voice[0].level = config.DEFAULT_MIXER_VOLUME 73 | except Exception as e: 74 | print(f"Audio initialization error: {e}") 75 | raise 76 | 77 | def _initialize_channels(self): 78 | """ 79 | Initialize all audio channels. 80 | 81 | Sets up: 82 | - 3 wavetable channels for melodic instruments 83 | - 1 sample channel for drums 84 | """ 85 | # Wavetable channels for melodic instruments 86 | for voice_num, waveform_idx in zip(config.WAVETABLE_VOICE_NUMBERS, config.WAVETABLE_WAVEFORM_INDICES): 87 | channel = Wavetable( 88 | synthio.Synthesizer( 89 | sample_rate=self.sample_rate, 90 | waveform=waveforms[waveform_idx], 91 | envelope=self.envelopes['fast'] 92 | ), 93 | voice_num, 94 | self.mixer 95 | ) 96 | self.channels.append(channel) 97 | 98 | # Sample channel for drums 99 | ch3 = Sample( 100 | config.SAMPLE_FILES, 101 | config.SAMPLE_VOLUMES, 102 | config.SAMPLE_MAP, 103 | self.mixer 104 | ) 105 | 106 | self.channels.append(ch3) 107 | self.play = Play(self.channels, self.mixer, self.hw) 108 | 109 | def get_channel_count(self) -> int: 110 | """ 111 | Get the number of audio channels. 112 | 113 | Returns: 114 | int: Number of channels 115 | """ 116 | return len(self.channels) 117 | 118 | def toggle_mute(self, channel_index: int) -> bool: 119 | """ 120 | Toggle mute state for a channel. 121 | 122 | Args: 123 | channel_index: Index of the channel to toggle 124 | 125 | Returns: 126 | bool: New mute state 127 | """ 128 | if 0 <= channel_index < len(self.channels): 129 | self.play.mute[channel_index] ^= 1 130 | return self.play.mute[channel_index] 131 | return False 132 | 133 | def get_mute_states(self) -> list: 134 | """ 135 | Get the mute states of all channels. 136 | 137 | Returns: 138 | list: List of mute states 139 | """ 140 | return self.play.mute 141 | 142 | def play_event(self, channel: int, note: int, intensity: int): 143 | """ 144 | Play a note event on a channel. 145 | 146 | Args: 147 | channel: Channel number 148 | note: Note number 149 | intensity: Note intensity (0-127) 150 | """ 151 | self.play.event(channel, note, intensity) 152 | 153 | def stop_all_notes(self): 154 | """Stop all currently playing notes.""" 155 | self.play.stop_all_notes() 156 | 157 | class Sample: 158 | """ 159 | A class for playing audio samples. 160 | 161 | Attributes: 162 | last: Index of the last used voice 163 | sample: List of loaded audio samples 164 | volume_dict: Dictionary mapping note numbers to volume levels 165 | sample_map: Dictionary mapping note numbers to sample indices 166 | """ 167 | 168 | def __init__(self, sample_files: list, volume_dict: dict, sample_map: dict, mixer: audiomixer.Mixer): 169 | """ 170 | Initialize a new sample player. 171 | 172 | Args: 173 | sample_files: List of paths to sample files 174 | volume_dict: Dictionary mapping note numbers to volume levels 175 | sample_map: Dictionary mapping note numbers to sample indices 176 | mixer: Audio mixer to play through 177 | """ 178 | self.last = 0 179 | self.sample = [audiocore.WaveFile(open(f, "rb")) for f in sample_files] 180 | self.volume_dict = volume_dict 181 | self.sample_map = sample_map 182 | self.mixer = mixer 183 | 184 | def on(self, note: int) -> None: 185 | """ 186 | Play a sample for a given note. 187 | 188 | Args: 189 | note: MIDI note number 190 | """ 191 | sample_id = self.sample_map[note] 192 | self.last = (self.last + 1) % len(self.sample) 193 | self.mixer.voice[self.last].play(self.sample[sample_id]) 194 | self.mixer.voice[self.last].level = self.volume_dict[note] 195 | 196 | def off(self, note: int) -> None: 197 | """ 198 | Stop playing a sample for a given note. 199 | 200 | Args: 201 | note: MIDI note number 202 | """ 203 | pass 204 | 205 | class Wavetable: 206 | """ 207 | A class for wavetable synthesis. 208 | 209 | Attributes: 210 | synth: The synthio synthesizer instance 211 | """ 212 | 213 | def __init__(self, synth: synthio.Synthesizer, voice: int, mixer: audiomixer.Mixer): 214 | """ 215 | Initialize a new wavetable synthesizer. 216 | 217 | Args: 218 | synth: synthio synthesizer instance 219 | voice: Voice number to use 220 | mixer: Audio mixer to play through 221 | """ 222 | self.synth = synth 223 | self.voice = voice 224 | mixer.voice[voice].play(self.synth) 225 | mixer.voice[voice].level = config.DEFAULT_MIXER_VOLUME 226 | 227 | def on(self, note: int) -> None: 228 | """ 229 | Play a note on the synthesizer. 230 | 231 | Args: 232 | note: MIDI note number 233 | """ 234 | self.synth.press([note]) 235 | 236 | def off(self, note: int) -> None: 237 | """ 238 | Stop playing a note on the synthesizer. 239 | 240 | Args: 241 | note: MIDI note number 242 | """ 243 | self.synth.release([note]) 244 | 245 | class Silent: 246 | """ 247 | A silent instrument that does nothing. 248 | 249 | This class is used as a placeholder for unused instrument slots. 250 | """ 251 | 252 | def __init__(self): 253 | """Initialize a new silent instrument.""" 254 | pass 255 | 256 | def on(self, note: int) -> None: 257 | """ 258 | Do nothing when a note is played. 259 | 260 | Args: 261 | note: MIDI note number 262 | """ 263 | pass 264 | 265 | def off(self, note: int) -> None: 266 | """ 267 | Do nothing when a note is released. 268 | 269 | Args: 270 | note: MIDI note number 271 | """ 272 | pass 273 | 274 | class Play: 275 | """ 276 | A class for managing multiple instruments and MIDI output. 277 | 278 | Attributes: 279 | instruments: List of instrument instances 280 | mixer: Audio mixer instance 281 | hw: Hardware interface instance 282 | mute: List of mute states for each instrument 283 | playing: List of currently playing notes for each instrument 284 | midi_enabled: Whether MIDI output is enabled 285 | midi: List of MIDI output channels 286 | """ 287 | 288 | def __init__(self, instruments: list, mixer: audiomixer.Mixer, hw, midi_enabled: bool = True): 289 | """ 290 | Initialize a new play manager. 291 | 292 | Args: 293 | instruments: List of instrument instances 294 | mixer: Audio mixer instance 295 | hw: Hardware interface instance 296 | midi_enabled: Whether to enable MIDI output 297 | """ 298 | self.instruments = instruments 299 | self.mixer = mixer 300 | self.hw = hw 301 | self.mute = [False] * len(instruments) 302 | self.playing = [[] for _ in instruments] 303 | self.midi_enabled = midi_enabled 304 | self.midi = [] 305 | if midi_enabled: 306 | for ch in range(len(instruments)): 307 | self.midi.append(adafruit_midi.MIDI(midi_out=usb_midi.ports[1], 308 | out_channel=ch)) 309 | 310 | def event(self, ch: int, note: int, intensity: int) -> None: 311 | """ 312 | Handle a MIDI event. 313 | 314 | Args: 315 | ch: MIDI channel 316 | note: MIDI note number 317 | intensity: Note intensity (0-127) 318 | """ 319 | if intensity == 0: 320 | self.off(ch, note) 321 | else: 322 | self.on(ch, note, intensity) 323 | 324 | def on(self, ch: int, note: int, intensity: int) -> None: 325 | """ 326 | Handle a note-on event. 327 | 328 | Args: 329 | ch: MIDI channel 330 | note: MIDI note number 331 | intensity: Note intensity (0-127) 332 | """ 333 | if not self.mute[ch]: 334 | if self.midi_enabled: 335 | self.midi[ch].send(NoteOn(note, intensity)) 336 | self.instruments[ch].on(note) 337 | self.hw.pixels[ch] = colorwheel(note * 2 & 255) 338 | self.playing[ch].append(note) 339 | 340 | def off(self, ch: int, note: int) -> None: 341 | """ 342 | Handle a note-off event. 343 | 344 | Args: 345 | ch: MIDI channel 346 | note: MIDI note number 347 | """ 348 | if not self.mute[ch]: 349 | if self.midi_enabled: 350 | self.midi[ch].send(NoteOff(note, 0)) 351 | self.instruments[ch].off(note) 352 | self.hw.pixels[ch] = 0x000000 353 | try: 354 | self.playing[ch].remove(note) 355 | except: 356 | pass 357 | 358 | def stop_all_notes(self) -> None: 359 | """Stop all currently playing notes.""" 360 | for ch, inst in enumerate(self.instruments): 361 | for note in self.playing[ch]: 362 | if self.midi_enabled: 363 | self.midi[ch].send(NoteOff(note, 0)) 364 | self.instruments[ch].off(note) 365 | self.playing[ch] = [] 366 | if not self.mute[ch]: 367 | self.hw.pixels[ch] = 0x000000 -------------------------------------------------------------------------------- /firmware/src/common/hw.py: -------------------------------------------------------------------------------- 1 | import config.config as config 2 | import board # input output 3 | import digitalio # input output config 4 | import analogio 5 | import busio # SPI for display 6 | import displayio # display module 7 | from adafruit_st7735r import ST7735R # display driver 8 | import adafruit_imageload # import .bmp sprite map 9 | import neopixel 10 | import bitbangio # type: ignore 11 | 12 | if config.HW_VERSION == 1.0: 13 | AN1 = analogio.AnalogIn(config.AN1_PIN) 14 | AN2 = analogio.AnalogIn(config.AN2_PIN) 15 | HYSTERESS = 10 16 | wheel1_old = int(AN1.value/256) 17 | wheel1_edge = True 18 | wheel2_old = int(AN2.value/256) 19 | wheel2_edge = True 20 | prev = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 21 | pressed=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 22 | 23 | if config.HW_VERSION == 1.1: 24 | i2c0 = bitbangio.I2C(config.SCL0_PIN, config.SDA0_PIN) # scl, sda 25 | wheel0_old = 0 26 | i2c1 = bitbangio.I2C(config.SCL1_PIN, config.SDA1_PIN) # scl, sda 27 | wheel1_old = 0 28 | prev = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 29 | pressed=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] 30 | 31 | # Define column pins 32 | col=[] 33 | col_pin=config.COL_PINS 34 | for b in col_pin: 35 | col.append(digitalio.DigitalInOut(b)) 36 | col[-1].direction = digitalio.Direction.INPUT 37 | col[-1].pull=digitalio.Pull.UP 38 | 39 | row0 = digitalio.DigitalInOut(config.ROW0_PIN) 40 | row1 = digitalio.DigitalInOut(config.ROW1_PIN) 41 | row0.pull = digitalio.Pull.UP 42 | row1.pull = digitalio.Pull.UP 43 | row0.direction = digitalio.Direction.OUTPUT 44 | row0.value = False 45 | row1.direction = digitalio.Direction.OUTPUT 46 | row1.value = False 47 | 48 | def check_keys(): 49 | row1.direction = digitalio.Direction.OUTPUT 50 | row0.direction = digitalio.Direction.INPUT 51 | for i in range(config.KEY_COL): 52 | if col[i].value == 0: 53 | pressed[i] = 1 54 | prev[i] = 1 55 | else: 56 | pressed[i] = 0 57 | prev[i] = 0 58 | row0.direction = digitalio.Direction.OUTPUT 59 | row1.direction = digitalio.Direction.INPUT 60 | for i in range(config.KEY_COL): 61 | if col[i].value == 0: 62 | pressed[config.KEY_COL+i] = 1 63 | prev[config.KEY_COL+i] = 1 64 | else: 65 | pressed[config.KEY_COL+i] = 0 66 | prev[config.KEY_COL+i] = 0 67 | return(pressed) 68 | 69 | # new key pressed since last? 70 | def key_new(pos): 71 | if pos < config.KEY_COL: 72 | row1.direction = digitalio.Direction.OUTPUT 73 | row0.direction = digitalio.Direction.INPUT 74 | row = pos 75 | else: 76 | row0.direction = digitalio.Direction.OUTPUT 77 | row1.direction = digitalio.Direction.INPUT 78 | row = pos - config.KEY_COL 79 | if not col[row].value and prev[pos] == 0: 80 | prev[pos] = 1 81 | return True 82 | if col[row].value: 83 | prev[pos] = 0 84 | return False 85 | 86 | # new key changed since last? 87 | def key_change(pos): 88 | if pos < config.KEY_COL: 89 | row1.direction = digitalio.Direction.OUTPUT 90 | row0.direction = digitalio.Direction.INPUT 91 | row = pos 92 | else: 93 | row0.direction = digitalio.Direction.OUTPUT 94 | row1.direction = digitalio.Direction.INPUT 95 | row = pos - config.KEY_COL 96 | val = not col[row].value 97 | new = True if col[row].value == prev[pos] else False 98 | prev[pos] = not col[row].value 99 | return((val, new)) 100 | 101 | 102 | def get_enc1(): 103 | result = bytearray(2) 104 | while not i2c1.try_lock(): 105 | pass 106 | try: 107 | i2c1.writeto_then_readfrom(0x36, bytes([0x0c]), result) 108 | finally: 109 | i2c1.unlock() 110 | return(result) 111 | 112 | def get_enc0(): 113 | result = bytearray(2) 114 | while not i2c0.try_lock(): 115 | pass 116 | try: 117 | i2c0.writeto_then_readfrom(0x36, bytes([0x0c]), result) 118 | finally: 119 | i2c0.unlock() 120 | return(result) 121 | 122 | # wheel 0 is the lower and wheel 1 is the upper. dist is the hysteress before triggering event. 123 | def check_rotation(wheel, dist): 124 | global wheel0_old, wheel1_old 125 | try: 126 | if wheel == 1: 127 | wheel_new = int.from_bytes(get_enc1()) 128 | wheel_old = wheel1_old 129 | else: 130 | wheel_new = int.from_bytes(get_enc0()) 131 | wheel_old = wheel0_old 132 | diff = abs(wheel_old - wheel_new) 133 | # this test is not needed, but no/small rotation is most likely => faster if exit here... 134 | if diff < dist: 135 | return 0 136 | 137 | # test counter clockwise (increased value) 138 | edge = wheel_old + dist 139 | if wheel_new > edge and diff < 2048: 140 | if wheel == 0: 141 | wheel0_old = wheel_new 142 | else: 143 | wheel1_old = wheel_new 144 | return -1 145 | elif edge > 4095: 146 | edge -= 4095 147 | if wheel_new > edge and wheel_new < 2048: 148 | if wheel == 0: 149 | wheel0_old = wheel_new 150 | else: 151 | wheel1_old = wheel_new 152 | return -1 153 | 154 | # test clockwise (decreased value) 155 | edge = wheel_old - dist 156 | if wheel_new < edge and diff < 2048: 157 | if wheel == 0: 158 | wheel0_old = wheel_new 159 | else: 160 | wheel1_old = wheel_new 161 | return 1 162 | elif edge < 0: 163 | edge += 4095 164 | if wheel_new < edge and wheel_new > 2048: 165 | if wheel == 0: 166 | wheel0_old = wheel_new 167 | else: 168 | wheel1_old = wheel_new 169 | return 1 170 | return 0 171 | except Exception as e: 172 | print("Error in check_rotation:", e) 173 | # If I2C communication fails, return 0 (no rotation) 174 | # This prevents the TypeError and allows the system to continue functioning 175 | return 0 176 | 177 | def check_analog_rotation(dir): 178 | # check if analog wheel has rotated more than the HYSTERESS since last time 179 | # handles edge case when wheel goes from max to zero (increase edge), or zero to max (decrease edge) 180 | # dir: 0=wheel1 inc, 1=wheel1 dec, 2=wheel2 inc, 3=wheel2 dec 181 | global wheel1_old 182 | global wheel1_edge 183 | global wheel2_old 184 | global wheel2_edge 185 | if dir < 2: 186 | sample = int(AN1.value/256) 187 | old = wheel1_old 188 | edge = wheel1_edge 189 | else: 190 | sample = int(AN2.value/256) 191 | old = wheel2_old 192 | edge = wheel2_edge 193 | 194 | if sample > (old + HYSTERESS): 195 | if old > HYSTERESS: 196 | # print("inc", sample, old) 197 | if dir < 2: 198 | wheel1_edge = True 199 | wheel1_old = sample 200 | else: 201 | wheel2_edge = True 202 | wheel2_old = sample 203 | if dir == 0 or dir == 2: # increase (ccw) 204 | return True 205 | elif edge: 206 | # print("dec edge", sample, old) 207 | if dir < 2: 208 | wheel1_edge = False 209 | wheel1_old = sample 210 | else: 211 | wheel2_edge = False 212 | wheel2_old = sample 213 | if dir == 1 or dir == 3: # decrease (cw, edge case) 214 | return True 215 | if dir < 2: 216 | wheel1_old = sample 217 | else: 218 | wheel2_old = sample 219 | if sample < (old - HYSTERESS): 220 | if old < (205 - HYSTERESS): 221 | # print("dec", sample, old) 222 | if dir < 2: 223 | wheel1_edge = True 224 | wheel1_old = sample 225 | else: 226 | wheel2_edge = True 227 | wheel2_old = sample 228 | if dir == 1 or dir == 3: # decr��? (cw) 229 | return True 230 | elif edge: 231 | # print("inc edge", sample, old) 232 | if dir < 2: 233 | wheel1_edge = False 234 | wheel1_old = sample 235 | else: 236 | wheel2_edge = False 237 | wheel2_old = sample 238 | if dir == 0 or dir == 2: # increase (ccw, edge case) 239 | return True 240 | if dir < 2: 241 | wheel1_old = sample 242 | else: 243 | wheel2_old = sample 244 | return False 245 | 246 | num_pixels = config.NEOPIXEL_NUM 247 | pixels = neopixel.NeoPixel(config.NEOPIXEL_PIN, num_pixels, brightness=0.2) 248 | 249 | 250 | # Setup builtin (blue) LED 251 | builtinLed = digitalio.DigitalInOut(config.BUILTIN_LED_PIN) # Builtin LED 252 | builtinLed.direction = digitalio.Direction.OUTPUT # Set as output 253 | 254 | # Define display pins 255 | PICO_PIN = config.PICO_PIN # data 256 | CLK_PIN = config.CLK_PIN # clock 257 | RST_PIN = config.RST_PIN # reset 258 | CS_PIN = config.CS_PIN # chip select 259 | DC_PIN = config.DC_PIN # data/command 260 | BL_PIN = config.BL_PIN # backlight 261 | 262 | # Initialize the display 263 | displayio.release_displays() 264 | spi = busio.SPI(clock=CLK_PIN, MOSI=PICO_PIN) 265 | dispWidth = config.DISPLAY_WIDTH 266 | dispHeight = config.DISPLAY_HEIGHT 267 | display_bus = displayio.FourWire(spi, command=DC_PIN, chip_select=CS_PIN, reset=RST_PIN) 268 | display = ST7735R( 269 | display_bus, 270 | width=dispWidth, 271 | height=dispHeight, 272 | bgr=True, 273 | rowstart=1, # 0 on some panels... 274 | colstart=2, # 0 on some panels... 275 | rotation=270, 276 | auto_refresh=False 277 | ) 278 | bl = digitalio.DigitalInOut(BL_PIN) 279 | bl.direction = digitalio.Direction.OUTPUT 280 | bl.value = True # Turn on backlight 281 | 282 | # Create displayIO group to hold all sprites 283 | displayGroup = displayio.Group(scale=1) 284 | #display.show(displayGroup) 285 | display.root_group = displayGroup 286 | 287 | class SpriteText: 288 | font_sheet, bwPalette = adafruit_imageload.load("lib/font.bmp",bitmap=displayio.Bitmap,palette=displayio.Palette) 289 | def __init__(self, x, y, str): 290 | self.group = displayio.Group() 291 | self.group.x = x 292 | self.group.y = y 293 | self.str = str 294 | self.array=[] 295 | displayGroup.append(self.group) 296 | for i in range(len(str)): 297 | self.array.append(displayio.TileGrid(self.font_sheet, pixel_shader=self.bwPalette,width = 1,height = 1,tile_width = 8,tile_height = 8)) 298 | self.group.append(self.array[i]) 299 | self.array[i].x = i * 7 300 | self.array[i].y = 0 301 | self.array[i][0] = ord(str[i]) - 32 302 | def showValue(self, value): 303 | tmp = value 304 | for s in reversed(self.array): 305 | s[0] = 16 + tmp % 10 306 | tmp = int(tmp/10) 307 | def showText(self, text): 308 | for i in range(len(self.array)): 309 | if i < len(text) and (ord(text[i]) - 32) > 0: 310 | self.array[i][0] = ord(text[i]) - 32 311 | else: 312 | self.array[i][0] = 0 313 | def delete(self): 314 | for g in self.group: 315 | self.group.pop() 316 | self.array.pop() 317 | 318 | # define destructor... 319 | -------------------------------------------------------------------------------- /firmware/src/common/menu.py: -------------------------------------------------------------------------------- 1 | """ 2 | Menu components for the core module. 3 | 4 | This module provides functionality for menu navigation and management. 5 | """ 6 | 7 | class MenuManager: 8 | """ 9 | A class for managing menu navigation and state. 10 | 11 | Attributes: 12 | menu: Dictionary containing menu structure and state 13 | display_manager: DisplayManager instance for UI updates 14 | dispatch_table: Dictionary mapping action names to functions 15 | """ 16 | 17 | def __init__(self, menu_file, display_manager, dispatch_table=None): 18 | """ 19 | Initialize a new menu manager. 20 | 21 | Args: 22 | menu_file: Path to menu configuration file 23 | display_manager: DisplayManager instance 24 | dispatch_table: Dictionary mapping action names to functions 25 | """ 26 | self.display_manager = display_manager 27 | self.menu = self._parse_menu(menu_file) 28 | self.dispatch_table = dispatch_table 29 | 30 | def _parse_menu(self, menu_file): 31 | """ 32 | Parse menu configuration file. 33 | 34 | Args: 35 | menu_file: Path to menu configuration file 36 | 37 | Returns: 38 | dict: Menu structure and state 39 | """ 40 | txt = [] 41 | jmp = [] 42 | lvl = [] 43 | # Read all lines and strip trailing newlines 44 | with open(menu_file, "r", encoding="utf-8") as f: 45 | lines = f.read().splitlines() 46 | # Parse each non-empty line 47 | for raw in lines: 48 | if not raw.strip(): 49 | continue 50 | # Determine indentation level (tabs) 51 | level = len(raw) - len(raw.lstrip('\t')) 52 | # Strip leading tabs 53 | stripped = raw.lstrip('\t') 54 | # Split text and function on '|' 55 | if '|' in stripped: 56 | text, func = stripped.split('|', 1) 57 | else: 58 | text, func = stripped, '' 59 | txt.append(text) 60 | jmp.append(func) 61 | lvl.append(level) 62 | 63 | # Create navigation arrays in a single pass 64 | pre = [] 65 | nxt = [] 66 | sel = [] 67 | bck = [] 68 | 69 | for i in range(len(lvl)): 70 | # Previous item at same level 71 | pre.append(i) 72 | for j in range(i-1, -1, -1): 73 | if lvl[j] == lvl[i]: 74 | pre[i] = j 75 | break 76 | 77 | # Next item at same level 78 | nxt.append(i) 79 | for j in range(i+1, len(lvl)): 80 | if lvl[j] == lvl[i]: 81 | nxt[i] = j 82 | break 83 | 84 | # Select target 85 | sel.append(i) 86 | if i+1 < len(lvl) and lvl[i+1] == lvl[i] + 1: 87 | sel[i] = i+1 88 | elif jmp[i]: 89 | sel[i] = '' 90 | 91 | # Back target 92 | bck.append(i) 93 | for j in range(i-1, -1, -1): 94 | if lvl[j] < lvl[i]: 95 | bck[i] = j 96 | break 97 | 98 | return { 99 | "line": 0, 100 | "txt": txt, 101 | "function": jmp, 102 | "back": bck, 103 | "previous": pre, 104 | "next": nxt, 105 | "select": sel 106 | } 107 | 108 | def handle_rotation(self, rotation): 109 | """ 110 | Handle menu rotation. 111 | 112 | Args: 113 | rotation: Rotation direction (1 for CW, -1 for CCW) 114 | """ 115 | if rotation == 1: # CW 116 | self.menu["line"] = self.menu["next"][self.menu["line"]] 117 | elif rotation == -1: # CCW 118 | self.menu["line"] = self.menu["previous"][self.menu["line"]] 119 | self.display_manager.update_menu_text(self.menu["txt"][self.menu["line"]]) 120 | 121 | def handle_back(self): 122 | """Handle back button press: go to previous item.""" 123 | idx = self.menu["previous"][self.menu["line"]] 124 | self.menu["line"] = idx 125 | self.display_manager.update_menu_text(self.menu["txt"][idx]) 126 | 127 | def handle_select(self): 128 | """Handle select button press using dispatch table.""" 129 | if self.menu["select"][self.menu["line"]] != "": 130 | self.menu["line"] = self.menu["select"][self.menu["line"]] 131 | self.display_manager.update_menu_text(self.menu["txt"][self.menu["line"]]) 132 | else: 133 | action = self.menu["function"][self.menu["line"]].strip() 134 | if self.dispatch_table is not None: 135 | if ':' in action: 136 | func_name, arg = action.split(':', 1) 137 | func_name = func_name.strip() 138 | func = self.dispatch_table.get(func_name) 139 | if func: 140 | func(int(arg)) 141 | else: 142 | print(f"Menu action '{func_name}' not found.") 143 | else: 144 | func = self.dispatch_table.get(action) 145 | if func: 146 | func() 147 | else: 148 | print(f"Menu action '{action}' not found.") 149 | else: 150 | print("No dispatch table provided for menu actions.") 151 | 152 | def get_current_text(self): 153 | """ 154 | Get current menu item text. 155 | 156 | Returns: 157 | str: Current menu item text 158 | """ 159 | return self.menu["txt"][self.menu["line"]] -------------------------------------------------------------------------------- /firmware/src/common/network.py: -------------------------------------------------------------------------------- 1 | """ 2 | Network components for the core module. 3 | 4 | This module provides network communication functionality for the application. 5 | """ 6 | 7 | import time 8 | import config.config as config 9 | 10 | class NetworkError(Exception): 11 | """Base class for network-related errors.""" 12 | pass 13 | 14 | class PacketSendError(NetworkError): 15 | """Error occurred while sending a packet.""" 16 | pass 17 | 18 | class PacketReadError(NetworkError): 19 | """Error occurred while reading a packet.""" 20 | pass 21 | 22 | class SongSendError(NetworkError): 23 | """Error occurred while sending a song.""" 24 | pass 25 | 26 | class PacketHandler: 27 | """ 28 | A class for handling network packet communication. 29 | 30 | Attributes: 31 | esp: ESP-NOW instance 32 | instrument_id: ID of the current instrument 33 | peer_broadcast: Broadcast peer for sending packets 34 | """ 35 | 36 | def __init__(self, esp, instrument_id: int, peer_broadcast): 37 | """ 38 | Initialize a new packet handler. 39 | 40 | Args: 41 | esp: ESP-NOW instance 42 | instrument_id: ID of the current instrument 43 | peer_broadcast: Broadcast peer for sending packets 44 | """ 45 | self.esp = esp 46 | self.instrument_id = instrument_id 47 | self.peer_broadcast = peer_broadcast 48 | self.PAYLOAD = config.EXTRA_PAYLOAD 49 | 50 | def send_packet(self, id: str, payload: bytes, addr=None, retransmission: int = None): 51 | """ 52 | Send a packet over the network, with optional retransmission. 53 | 54 | Args: 55 | id: Packet type identifier 56 | payload: Packet payload 57 | addr: Target address (defaults to broadcast) 58 | retransmission: Number of times to retransmit (defaults to config value) 59 | 60 | Raises: 61 | PacketSendError: If packet sending fails 62 | """ 63 | try: 64 | packet = bytearray(id, 'utf-8') 65 | packet.extend(payload) 66 | target = addr if addr else self.peer_broadcast 67 | retransmission = retransmission or config.NETWORK_PACKET_RETRANSMISSION 68 | for _ in range(retransmission): 69 | self.esp.send(packet, target) 70 | if retransmission > 1: 71 | time.sleep(config.NETWORK_PACKET_DELAY) 72 | except Exception as e: 73 | raise PacketSendError(f"Failed to send packet {id}: {e}") from e 74 | 75 | def send_verified_packet(self, id: str, payload: bytes, addr=None): 76 | """ 77 | Send a packet and wait for an 'a' (ack) response, retrying up to 10 times. 78 | 79 | Args: 80 | id: Packet type identifier 81 | payload: Packet payload 82 | addr: Target address (defaults to broadcast) 83 | 84 | Returns: 85 | bool: True if ack received, False otherwise 86 | 87 | Raises: 88 | PacketSendError: If packet sending fails 89 | """ 90 | try: 91 | packet = bytearray(id, 'utf-8') 92 | packet.extend(payload) 93 | target = addr if addr else self.peer_broadcast 94 | for retries in range(10): 95 | self.esp.send(packet, target) 96 | send_time = time.monotonic() 97 | while time.monotonic() < (send_time + 0.5): 98 | try: 99 | read_packet = self.esp.read() 100 | except Exception: 101 | continue 102 | else: 103 | if read_packet and read_packet.msg[0] == ord('a'): 104 | return True 105 | return False 106 | except Exception as e: 107 | raise PacketSendError(f"Failed to send verified packet {id}: {e}") from e 108 | 109 | def send_song(self, id, byte_song, debug, hw): 110 | """ 111 | Send a song to a device. 112 | 113 | Args: 114 | id: Target device ID 115 | byte_song: Song data 116 | debug: Debug display object 117 | hw: Hardware interface 118 | 119 | Raises: 120 | SongSendError: If song sending fails 121 | """ 122 | try: 123 | # broadcast clear song 124 | self.send_packet('c', [id], retransmission=2) 125 | time.sleep(0.01) 126 | debug.show_debug_message("sending") 127 | hw.display.refresh() 128 | 129 | # send header 130 | packet = bytearray('h','utf-8') 131 | packet.extend(byte_song[:11]) 132 | self.esp.send(bytearray(packet), self.peer_broadcast) 133 | 134 | # send song events 135 | for i in range(11, len(byte_song), 7): 136 | packet = bytearray('e','utf-8') 137 | if byte_song[i + 4] == id or id == 255: 138 | packet.extend(byte_song[i: i + 7]) 139 | self.esp.send(bytearray(packet), self.peer_broadcast) 140 | # prevent packet loss 141 | time.sleep(0.004) 142 | 143 | debug.show_debug_message("done") 144 | hw.display.refresh() 145 | 146 | # broadcast display update 147 | self.send_packet('u', self.PAYLOAD, retransmission=2) 148 | 149 | # broadcast reset 150 | self.send_packet('r', self.PAYLOAD, retransmission=2) 151 | except Exception as e: 152 | raise SongSendError(f"Failed to send song to device {id}: {e}") from e 153 | 154 | def send_pair(self) -> None: 155 | """Send a pair request packet.""" 156 | self.send_packet('p', [self.instrument_id]) 157 | 158 | def read_packet(self): 159 | """ 160 | Read a packet from the network. 161 | 162 | Returns: 163 | The received packet or None if no packet is available 164 | 165 | Raises: 166 | PacketReadError: If packet reading fails 167 | """ 168 | try: 169 | return self.esp.read() 170 | except Exception as e: 171 | raise PacketReadError(f"Failed to read packet: {e}") from e 172 | 173 | def send_scale(self, scale_start: int, scale: list): 174 | """ 175 | Send a scale packet to update the scale and chord notes. 176 | 177 | Args: 178 | scale_start: Starting note of the scale (e.g., 60 for C4) 179 | scale: List of scale intervals (e.g., [0, 2, 4, 5, 7, 9, 11] for major scale) 180 | 181 | Raises: 182 | PacketSendError: If packet sending fails 183 | """ 184 | try: 185 | # Format: 'n' + reserved + scale_start + scale_intervals 186 | packet = bytearray('n', 'utf-8') 187 | packet.append(0) # Reserved byte 188 | packet.append(scale_start) 189 | packet.extend(scale[:7]) # Ensure we only send 7 intervals 190 | self.send_packet('n', packet[1:]) 191 | except Exception as e: 192 | raise PacketSendError(f"Failed to send scale packet: {e}") from e 193 | 194 | def handle_packet(self, packet): 195 | """ 196 | Handle a received packet. 197 | 198 | Args: 199 | packet: The received packet 200 | 201 | Returns: 202 | tuple: Packet type and arguments, or None if packet is invalid 203 | """ 204 | if not packet: 205 | return None 206 | 207 | packet_types = config.NETWORK_PACKET_TYPES 208 | msg = packet.msg 209 | 210 | # check if the packet is an "event" packet 211 | if msg[0] == packet_types['event']: 212 | tick_start = int.from_bytes(msg[1:3], "big") 213 | tick_end = int.from_bytes(msg[3:5], "big") 214 | ch = msg[5] 215 | note = msg[6] 216 | intensity = msg[7] 217 | return ('event', ch, tick_start, tick_end, note, intensity) 218 | 219 | # check if the packet is a "live" packet 220 | elif msg[0] == packet_types['live']: 221 | ch = msg[1] 222 | note = msg[2] 223 | intensity = msg[3] 224 | return ('live', ch, note, intensity) 225 | 226 | # check if the packet is a "pair_request" packet 227 | elif msg[0] == packet_types['pair']: 228 | id = msg[1] 229 | return ('pair', id) 230 | 231 | # check if the packet is a "tick" packet 232 | elif msg[0] == packet_types['tick']: 233 | tick = int.from_bytes(msg[1:3], "big") 234 | return ('tick', tick) 235 | 236 | # check if the packet is a "begin" packet 237 | elif msg[0] == packet_types['begin']: 238 | return ('begin',) 239 | 240 | # check if the packet is a "stop" packet 241 | elif msg[0] == packet_types['stop']: 242 | return ('stop',) 243 | 244 | # check if the packet is a "header" packet 245 | elif msg[0] == packet_types['header']: 246 | ticks_per_beat = int.from_bytes(msg[1:3], "big") 247 | max_ticks = int.from_bytes(msg[3:7], "big") 248 | tempo = int.from_bytes(msg[7:9], "big") 249 | numerator = msg[9] 250 | denominator = msg[10] 251 | nr_instruments = msg[11] 252 | return ('header', ticks_per_beat, max_ticks, tempo, numerator, denominator, nr_instruments) 253 | 254 | # check if the packet is a "mute" packet 255 | elif msg[0] == packet_types['mute']: 256 | ch = msg[1] 257 | intensity = msg[2] 258 | return ('mute', ch, intensity) 259 | 260 | # check if the packet is an "update" packet 261 | elif msg[0] == packet_types['update']: 262 | return ('update',) 263 | 264 | # check if the packet is a "reset" packet 265 | elif msg[0] == packet_types['reset']: 266 | return ('reset',) 267 | 268 | # check if the packet is a "clear" packet 269 | elif msg[0] == packet_types['clear']: 270 | id = msg[1] 271 | return ('clear', id) 272 | 273 | # check if the packet is a "scale" packet 274 | elif msg[0] == packet_types['scale']: 275 | if len(msg) >= 10: # Ensure we have enough bytes 276 | scale_start = msg[2] 277 | scale = list(msg[3:10]) # Get 7 scale intervals 278 | return ('scale', scale_start, scale) 279 | return None 280 | 281 | return None 282 | 283 | def check_packets(self): 284 | """ 285 | Check for and handle incoming packets. 286 | 287 | Returns: 288 | bool: True if a packet was handled, False otherwise 289 | """ 290 | if not self.esp: 291 | return False 292 | 293 | try: 294 | packet = self.read_packet() 295 | if packet: 296 | result = self.handle_packet(packet) 297 | return result is not None 298 | except ValueError: 299 | pass 300 | return False -------------------------------------------------------------------------------- /firmware/src/common/song.py: -------------------------------------------------------------------------------- 1 | """ 2 | Song handling components for the core module. 3 | 4 | This module provides functionality for loading, managing, and playing song data. 5 | """ 6 | 7 | import time 8 | from .audio import Play 9 | from .network import PacketHandler 10 | import config.config as config 11 | 12 | class Song: 13 | """ 14 | A class for managing song data and metadata. 15 | 16 | Attributes: 17 | file_path: Path to the song file (optional) 18 | file_name: Name of the song file (without extension, optional) 19 | instrument_id: ID of the instrument this song is for (optional) 20 | events: List of song events 21 | metadata: Dictionary of song metadata 22 | """ 23 | 24 | def __init__(self, file_path: str = None, file_name: str = None, instrument_id: int = None): 25 | """ 26 | Initialize a new song. 27 | 28 | Args: 29 | file_path: Path to the song file (optional) 30 | file_name: Name of the song file (without extension, optional) 31 | instrument_id: ID of the instrument this song is for (optional) 32 | """ 33 | self.file_path = file_path 34 | self.file_name = file_name 35 | self.instrument_id = instrument_id 36 | self.events = [] 37 | self.metadata = config.DEFAULT_SONG_METADATA.copy() 38 | self._update_metadata() 39 | 40 | if file_path and file_name: 41 | self.load() 42 | 43 | def load(self): 44 | """Load song from file and parse metadata and events.""" 45 | with open(self.file_path + self.file_name + ".bin", 'rb') as f: 46 | data = f.read() 47 | self._parse_header(data) 48 | self._parse_events(data) 49 | 50 | def _parse_header(self, data: bytes): 51 | """ 52 | Parse song header metadata. 53 | 54 | Args: 55 | data: Raw song data 56 | """ 57 | self.metadata = { 58 | 'ticks_per_beat': int.from_bytes(data[0:2], "big"), 59 | 'max_ticks': int.from_bytes(data[2:6], "big"), 60 | 'tempo': int.from_bytes(data[6:8], "big"), 61 | 'numerator': data[8], 62 | 'denominator': data[9], 63 | 'nr_instruments': data[10] 64 | } 65 | self._update_metadata() 66 | 67 | def _update_metadata(self): 68 | """Update derived metadata values.""" 69 | # Calculate basic timing values 70 | self.metadata['tick_to_time'] = 60 * 4 / (self.metadata['tempo'] * 71 | self.metadata['ticks_per_beat'] * 72 | self.metadata['denominator']) 73 | self.metadata['song_length'] = self.metadata['max_ticks'] * self.metadata['tick_to_time'] 74 | 75 | # Ensure max_ticks is valid before calculating sprite values 76 | if self.metadata['max_ticks'] > 0: 77 | self.metadata['sprite_tick'] = self.metadata['max_ticks'] / (160-4) 78 | self.metadata['sprite_time'] = self.metadata['sprite_tick'] * self.metadata['tick_to_time'] 79 | else: 80 | # Set safe default values if max_ticks is invalid 81 | self.metadata['sprite_tick'] = 1 82 | self.metadata['sprite_time'] = self.metadata['tick_to_time'] 83 | 84 | def _parse_events(self, data: bytes): 85 | """ 86 | Parse song events. 87 | 88 | Args: 89 | data: Raw song data 90 | """ 91 | for i in range(11, len(data), 7): 92 | tick_start = int.from_bytes(data[i + 0: i + 2], "big") 93 | tick_end = int.from_bytes(data[i + 2: i + 4], "big") 94 | ch = data[i + 4] 95 | note = data[i + 5] 96 | intensity = data[i + 6] 97 | add_event_last(self.events, [tick_start, ch, note, intensity]) 98 | add_event_last(self.events, [tick_end, ch, note, 0]) 99 | 100 | def add_event(self, tick_start: int, tick_end: int, note: int, intensity: int): 101 | """ 102 | Add an event to the song. 103 | 104 | Args: 105 | tick_start: Start tick of the event 106 | tick_end: End tick of the event 107 | note: Note number 108 | intensity: Note intensity 109 | """ 110 | if self.instrument_id is not None: 111 | ch = self.instrument_id 112 | else: 113 | ch = 0 # Default channel if no instrument_id specified 114 | 115 | # Add note on event 116 | add_event_last(self.events, [tick_start, ch, note, intensity]) 117 | # Add note off event 118 | add_event_last(self.events, [tick_end, ch, note, 0]) 119 | # Update max_ticks if needed 120 | if tick_end > self.metadata['max_ticks']: 121 | self.metadata['max_ticks'] = tick_end 122 | self._update_metadata() 123 | 124 | def get_event(self, index: int) -> tuple: 125 | """ 126 | Get event at specified index. 127 | 128 | Args: 129 | index: Event index 130 | 131 | Returns: 132 | tuple: (tick, channel, note, intensity) 133 | """ 134 | if 0 <= index < len(self.events): 135 | return tuple(self.events[index]) 136 | return None 137 | 138 | def get_metadata(self, key: str) -> any: 139 | """ 140 | Get metadata value. 141 | 142 | Args: 143 | key: Metadata key 144 | 145 | Returns: 146 | any: Metadata value 147 | """ 148 | return self.metadata.get(key) 149 | 150 | def get_event_count(self) -> int: 151 | """ 152 | Get total number of events. 153 | 154 | Returns: 155 | int: Number of events 156 | """ 157 | return len(self.events) 158 | 159 | def clear(self): 160 | """Clear all events and reset metadata.""" 161 | self.events = [] 162 | self.metadata['max_ticks'] = 0 163 | self._update_metadata() 164 | 165 | class SongPlayer: 166 | """ 167 | A class for managing song playback. 168 | 169 | Attributes: 170 | song: Song instance 171 | audio_system: Play instance for audio output 172 | network_manager: PacketHandler instance for network communication 173 | playback_state: Current playback state 174 | song_index: Current position in song 175 | start_time: Time when playback started 176 | sprite_last_pos: Last sprite position 177 | """ 178 | 179 | def __init__(self, song: Song, audio_system: Play, network_manager: PacketHandler): 180 | """ 181 | Initialize a new song player. 182 | 183 | Args: 184 | song: Song instance 185 | audio_system: Play instance for audio output 186 | network_manager: PacketHandler instance for network communication 187 | """ 188 | self.song = song 189 | self.audio_system = audio_system 190 | self.network_manager = network_manager 191 | self.playback_state = "paused" 192 | self.song_index = 0 193 | self.start_time = 0 194 | self.sprite_last_pos = 0 195 | 196 | def play(self): 197 | """Start or resume playback.""" 198 | if self.playback_state == "paused": 199 | self.start_time = time.monotonic() - self.song.get_event(self.song_index)[0] * self.song.get_metadata('tick_to_time') 200 | self.playback_state = "playing" 201 | self.network_manager.send_packet('b', self.network_manager.PAYLOAD, retransmission=2) 202 | 203 | def pause(self): 204 | """Pause playback.""" 205 | if self.playback_state == "playing": 206 | self.playback_state = "paused" 207 | self.network_manager.send_packet('s', self.network_manager.PAYLOAD, retransmission=2) 208 | self.audio_system.stop_all_notes() 209 | 210 | def reset(self): 211 | """Reset to beginning.""" 212 | self.song_index = 0 213 | self.sprite_last_pos = 0 214 | self.start_time = time.monotonic() 215 | self.network_manager.send_packet('r', self.network_manager.PAYLOAD, retransmission=2) 216 | 217 | def update(self): 218 | """ 219 | Update playback state. 220 | 221 | Returns: 222 | tuple: (sprite_pos, channel, note, intensity) if an event was processed, 223 | None otherwise 224 | """ 225 | if self.playback_state != "playing": 226 | return None 227 | 228 | # Check if we've reached the end of the song 229 | if self.song_index >= self.song.get_event_count(): 230 | print("end of song") 231 | # Stop playback 232 | self.playback_state = "paused" 233 | # Stop all notes 234 | self.audio_system.stop_all_notes() 235 | # Reset song position 236 | self.song_index = 0 237 | self.sprite_last_pos = 0 238 | # Send stop packet 239 | self.network_manager.send_packet('s', self.network_manager.PAYLOAD, retransmission=2) 240 | # Return final position update 241 | return (0, 0, 0, 0) 242 | 243 | # Process events 244 | while self.song_index < self.song.get_event_count(): 245 | event = self.song.get_event(self.song_index) 246 | now = time.monotonic() 247 | 248 | if now >= (self.start_time + event[0] * self.song.get_metadata('tick_to_time')): 249 | ch, note, intensity = event[1:] 250 | self.audio_system.event(ch, note, intensity) 251 | if intensity > 0: 252 | self.network_manager.send_packet('t', 253 | bytearray(event[0].to_bytes(2, 'big')), 254 | retransmission=1) 255 | self.song_index += 1 256 | 257 | # Calculate sprite position safely 258 | try: 259 | sprite_pos = int((now - self.start_time) / self.song.get_metadata('sprite_time')) 260 | return (sprite_pos, ch, note, intensity) 261 | except (ZeroDivisionError, TypeError): 262 | # If calculation fails, return a safe position 263 | return (0, ch, note, intensity) 264 | 265 | return None 266 | 267 | class LocalSong: 268 | """ 269 | A lightweight song class for instruments that only need to handle their own events. 270 | 271 | Attributes: 272 | instrument_id: ID of the instrument this song is for 273 | events: List of song events for this instrument 274 | metadata: Dictionary of song metadata 275 | pattern_roll: List of lists for pattern display 276 | """ 277 | 278 | def __init__(self, instrument_id: int): 279 | """ 280 | Initialize a new local song. 281 | 282 | Args: 283 | instrument_id: ID of the instrument this song is for 284 | """ 285 | self.instrument_id = instrument_id 286 | self.events = [] 287 | self.pattern_roll = [] 288 | self.metadata = config.DEFAULT_SONG_METADATA.copy() 289 | self.metadata.update({ 290 | 'pixels_per_beat': 4, 291 | 'tick_to_time': 0, 292 | 'time_to_tick': 0, 293 | 'song_length': 0, 294 | 'sprite_tick': 0, 295 | 'sprite_time': 0, 296 | 'ticks_per_pixel': 0, 297 | 'max_pixels': 0 298 | }) 299 | self._update_metadata() 300 | 301 | def _update_metadata(self): 302 | """Update derived metadata values.""" 303 | self.metadata['tick_to_time'] = 60 * 4 / (self.metadata['tempo'] * 304 | self.metadata['ticks_per_beat'] * 305 | self.metadata['denominator']) 306 | self.metadata['time_to_tick'] = (self.metadata['tempo'] * 307 | self.metadata['ticks_per_beat'] * 308 | self.metadata['denominator']) / (60 * 4) 309 | self.metadata['song_length'] = self.metadata['max_ticks'] * self.metadata['tick_to_time'] 310 | self.metadata['sprite_tick'] = self.metadata['max_ticks'] / (160-4) 311 | self.metadata['sprite_time'] = self.metadata['sprite_tick'] * self.metadata['tick_to_time'] 312 | self.metadata['ticks_per_pixel'] = self.metadata['ticks_per_beat'] / self.metadata['pixels_per_beat'] 313 | self.metadata['max_pixels'] = int(self.metadata['max_ticks'] / self.metadata['ticks_per_pixel']) 314 | 315 | # Initialize pattern roll if needed 316 | if self.metadata['max_pixels'] > 0 and len(self.pattern_roll) != self.metadata['max_pixels']: 317 | self.pattern_roll = [[] for _ in range(self.metadata['max_pixels'])] 318 | 319 | def update_header(self, ticks_per_beat: int, max_ticks: int, tempo: int, 320 | numerator: int, denominator: int, nr_instruments: int): 321 | """ 322 | Update song metadata from header packet. 323 | 324 | Args: 325 | ticks_per_beat: Number of ticks per beat 326 | max_ticks: Maximum number of ticks in song 327 | tempo: Song tempo in BPM 328 | numerator: Time signature numerator 329 | denominator: Time signature denominator 330 | nr_instruments: Number of instruments 331 | """ 332 | self.metadata.update({ 333 | 'ticks_per_beat': ticks_per_beat, 334 | 'max_ticks': max_ticks, 335 | 'tempo': tempo, 336 | 'numerator': numerator, 337 | 'denominator': denominator, 338 | 'nr_instruments': nr_instruments 339 | }) 340 | self._update_metadata() 341 | 342 | def add_event(self, tick_start: int, tick_end: int, note: int, intensity: int): 343 | """ 344 | Add an event to the song and pattern roll. 345 | 346 | Args: 347 | tick_start: Start tick of the event 348 | tick_end: End tick of the event 349 | note: Note number 350 | intensity: Note intensity 351 | """ 352 | # Add note on event 353 | add_event_last(self.events, [tick_start, self.instrument_id, note, intensity]) 354 | # Add note off event 355 | add_event_last(self.events, [tick_end, self.instrument_id, note, 0]) 356 | 357 | # Update max_ticks if needed 358 | if tick_end > self.metadata['max_ticks']: 359 | self.metadata['max_ticks'] = tick_end 360 | self._update_metadata() 361 | 362 | # Add to pattern roll 363 | pixel_start = int(tick_start / self.metadata['ticks_per_pixel']) 364 | if pixel_start < len(self.pattern_roll): 365 | if note not in self.pattern_roll[pixel_start]: 366 | self.pattern_roll[pixel_start].append(note) 367 | 368 | def clear(self): 369 | """Clear all events and reset metadata.""" 370 | self.events = [] 371 | self.pattern_roll = [] 372 | self.metadata['max_ticks'] = 0 373 | self._update_metadata() 374 | 375 | def get_event(self, index: int) -> tuple: 376 | """ 377 | Get event at specified index. 378 | 379 | Args: 380 | index: Event index 381 | 382 | Returns: 383 | tuple: (tick, channel, note, intensity) 384 | """ 385 | if 0 <= index < len(self.events): 386 | return tuple(self.events[index]) 387 | return None 388 | 389 | def get_metadata(self, key: str) -> any: 390 | """ 391 | Get metadata value. 392 | 393 | Args: 394 | key: Metadata key 395 | 396 | Returns: 397 | any: Metadata value 398 | """ 399 | return self.metadata.get(key) 400 | 401 | def get_event_count(self) -> int: 402 | """ 403 | Get total number of events. 404 | 405 | Returns: 406 | int: Number of events 407 | """ 408 | return len(self.events) 409 | 410 | def remove_note(self, pixel_pos: int, note: int): 411 | """ 412 | Remove a note from the pattern roll at the specified position. 413 | 414 | Args: 415 | pixel_pos: Position in pattern roll 416 | note: Note to remove 417 | """ 418 | if 0 <= pixel_pos < len(self.pattern_roll): 419 | if note in self.pattern_roll[pixel_pos]: 420 | self.pattern_roll[pixel_pos].remove(note) 421 | 422 | def add_event_last(array: list, packet: list) -> None: 423 | """ 424 | Add an event packet to a sorted array of events. 425 | Keeps events sorted by tick time and skips redundant events. 426 | """ 427 | # If array empty, append 428 | if not array: 429 | array.append(packet) 430 | return 431 | # Fast path: new latest tick 432 | if packet[0] > array[-1][0]: 433 | array.append(packet) 434 | return 435 | # Find insertion index 436 | p = len(array) - 1 437 | # Move backwards while packet tick is less than current 438 | while p >= 0 and packet[0] < array[p][0]: 439 | p -= 1 440 | # Skip redundant packets at same tick 441 | while p >= 0 and packet[0] == array[p][0]: 442 | if packet[1:] == array[p][1:]: 443 | return # redundant event 444 | p -= 1 445 | # Insert after last smaller tick (or at front if p == -1) 446 | array.insert(p+1, packet) 447 | -------------------------------------------------------------------------------- /firmware/src/common/ui.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/firmware/src/common/ui.py -------------------------------------------------------------------------------- /firmware/src/core/arp.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # 4 | # /$$$$$$ /$$$$$$ /$$$$$$ 5 | # |____ $$ /$$__ $$ /$$__ $$ 6 | # /$$$$$$$| $$ \__/| $$ \ $$ 7 | # /$$__ $$| $$ | $$ | $$ 8 | # | $$$$$$$| $$ | $$$$$$$/ 9 | # \_______/|__/ | $$____/ 10 | # | $$ 11 | # |__/ 12 | # Mockup to show how an arpeggiator could work, (hardcoded for demovideo) 13 | # It recieves "scale packets" from chords unit that transposes the arpeggio 14 | # 15 | # Todo: 16 | # * proper graphics (uses fake images today) 17 | # * add support for 1.1 hardware (only tested on 1.0) 18 | # Done: 19 | # 250501: 20 | # x major code refactoring and cleanup: 21 | # directory structure, common modules, manager classes and error handling 22 | # 23 | # ****************************************************************************** 24 | import config.config as config 25 | from src.common import hw 26 | import espnow # type: ignore 27 | import time # type: ignore 28 | import displayio # type: ignore 29 | import adafruit_imageload # type: ignore 30 | from src.common.ui import DisplayManager, colorwheel 31 | from src.common.network import PacketHandler, NetworkError, PacketSendError, PacketReadError, SongSendError 32 | from src.common.menu import MenuManager 33 | 34 | 35 | class Field: 36 | """Handles the display field for the arpeggiator pattern""" 37 | def __init__(self, display_group=None, sprite_sheet=None, palette=None, x_start=40, y_start=0, x_max=8, y_max=8, tile_width=10, tile_height=10): 38 | self.display_group = display_group 39 | self.sprite_sheet = sprite_sheet 40 | self.palette = palette 41 | self.x_start = x_start 42 | self.y_start = y_start 43 | self.x_max = x_max 44 | self.y_max = y_max 45 | self.tile_width = tile_width 46 | self.tile_height = tile_height 47 | 48 | # Create sprite grid 49 | self.grid = displayio.TileGrid( 50 | self.sprite_sheet, 51 | pixel_shader=self.palette, 52 | width=self.x_max, 53 | height=self.y_max, 54 | tile_width=self.tile_width, 55 | tile_height=self.tile_height, 56 | x=self.x_start, 57 | y=self.y_start 58 | ) 59 | self.display_group.append(self.grid) 60 | 61 | def reset(self, value=0): 62 | """Reset all tiles to the given value""" 63 | for y in range(self.y_max): 64 | for x in range(self.x_max): 65 | self.grid[x, y] = value 66 | 67 | def setBlock(self, x, y, value): 68 | """Set a specific tile to the given value""" 69 | self.grid[x, y] = value 70 | 71 | def hLine(self): 72 | """Draw a horizontal line""" 73 | for x in range(self.x_max): 74 | self.grid[x, 0] = config.DEFAULT_TILE_VALUE 75 | 76 | class Image: 77 | """Handles background images for the arpeggiator""" 78 | def __init__(self, filename, display_group=None): 79 | self.display_group = display_group 80 | self.filename = f"assets/images/{filename}" # Add assets/images/ prefix 81 | self.sprite = None 82 | self.sprite_grid = None 83 | self.load_image() 84 | 85 | def load_image(self): 86 | """Load the background image""" 87 | if self.sprite_grid: 88 | self.display_group.remove(self.sprite_grid) 89 | self.sprite = displayio.OnDiskBitmap(self.filename) 90 | self.sprite_grid = displayio.TileGrid( 91 | self.sprite, 92 | pixel_shader=self.sprite.pixel_shader, 93 | x=0, 94 | y=config.BACKGROUND_Y_OFFSET # Use config value for y-offset 95 | ) 96 | self.display_group.append(self.sprite_grid) 97 | 98 | def replace(self, filename): 99 | """Replace the current background image""" 100 | self.filename = f"assets/images/{filename}" # Add assets/images/ prefix 101 | self.load_image() 102 | 103 | class ArpeggiatorState: 104 | """Manages the arpeggiator's internal state""" 105 | def __init__(self): 106 | self.mode = config.PLAYBACK_STATES["clear"] 107 | self.mute = 0 108 | self.ticks_per_beat = config.TICKS_PER_BEAT 109 | self.max_ticks = config.MAX_TICKS 110 | self.tempo = config.TEMPO 111 | self.denominator = config.DENOMINATOR 112 | self.start_time = time.monotonic() 113 | self.old_note = 0 114 | self.arp_pos = 0 115 | self.arp_notes = [] 116 | self.chord_notes = [] 117 | self.scale_notes = [] 118 | self.arp2d = [[],[],[],[]] 119 | self.pos = 0 120 | 121 | # Calculate time conversion factors 122 | self.tick_to_time = (60 * 4) / (self.tempo * self.ticks_per_beat * self.denominator) 123 | self.time_to_tick = (self.tempo * self.ticks_per_beat * self.denominator) / (60 * 4) 124 | 125 | def update_scale(self, scale_start, scale): 126 | """Update scale and chord notes based on new scale""" 127 | self.scale_notes = [s + scale_start for s in scale] 128 | 129 | # Update chord notes 130 | self.chord_notes = [] 131 | for a in range(0,5,2): 132 | note = scale[a] + scale_start 133 | while note > config.MAX_NOTE: 134 | note -= 12 135 | while note < config.MIN_NOTE: 136 | note += 12 137 | self.chord_notes.append(note) 138 | self.chord_notes.sort() 139 | 140 | # Update arpeggiator notes 141 | arp = config.DEFAULT_ARP_PATTERN 142 | self.arp_notes = [self.chord_notes[a] for a in arp] 143 | 144 | def reset(self): 145 | """Reset state to initial values""" 146 | self.start_time = time.monotonic() 147 | self.arp_pos = len(self.arp_notes) - 1 if self.arp_notes else 0 148 | self.pos = 0 149 | self.arp2d = [[],[],[],[]] 150 | 151 | def initialize_scale(self, scale_start=60): 152 | """Initialize scale with default values""" 153 | scale = [0, 2, 4, 5, 7, 9, 11] 154 | self.update_scale(scale_start, scale) 155 | 156 | class ArpeggiatorUI: 157 | """Handles all UI-related operations for the arpeggiator""" 158 | def __init__(self, display_manager, hw): 159 | self.display_manager = display_manager 160 | self.hw = hw 161 | self.field = None 162 | self.background = None 163 | 164 | # Load sprite sheet 165 | self.sprite_sheet, self.palette = adafruit_imageload.load( 166 | "assets/images/10x10.bmp", 167 | bitmap=displayio.Bitmap, 168 | palette=displayio.Palette 169 | ) 170 | 171 | self.initialize_ui() 172 | 173 | def initialize_ui(self): 174 | """Initialize UI components""" 175 | # Create field 176 | self.field = Field( 177 | display_group=self.hw.displayGroup, 178 | sprite_sheet=self.sprite_sheet, 179 | palette=self.palette, 180 | x_start=config.XSTART, 181 | y_start=config.YSTART, 182 | x_max=config.FIELD_X_MAX, 183 | y_max=config.FIELD_Y_MAX, 184 | tile_width=config.FIELD_TILE_WIDTH, 185 | tile_height=config.FIELD_TILE_HEIGHT 186 | ) 187 | 188 | # Create background 189 | self.background = Image("arp0.bmp", self.hw.displayGroup) 190 | self.hw.display.refresh() 191 | 192 | def update_pattern(self, field, arp2d, pos): 193 | """Update the pattern display""" 194 | field.reset(0) 195 | for y in range(4): 196 | for x in arp2d[y]: 197 | field.setBlock(x, y, config.PATTERN_ACTIVE_TILE) 198 | field.setBlock(pos % config.FIELD_X_MAX, int(pos / config.FIELD_X_MAX), config.PATTERN_CURRENT_TILE) 199 | self.hw.display.refresh() 200 | 201 | def update_leds(self, notes, active_notes=None): 202 | """Update LED states""" 203 | if active_notes is None: 204 | active_notes = [] 205 | 206 | # Clear all LEDs first 207 | for i in range(config.NEOPIXEL_NUM): 208 | self.hw.pixels[i] = config.LED_OFF_COLOR 209 | 210 | # Update LEDs for active notes 211 | for note in active_notes: 212 | self.hw.pixels[note % config.NEOPIXEL_NUM] = config.LED_ACTIVE_COLOR 213 | 214 | # Update LEDs for arpeggiator notes 215 | for note in notes: 216 | self.hw.pixels[note % config.NEOPIXEL_NUM] = config.LED_ACTIVE_COLOR 217 | 218 | def update_background(self, scale_start): 219 | """Update background image based on scale""" 220 | if scale_start == config.SCALE_C: 221 | self.background.replace("arp0.bmp") 222 | elif scale_start == config.SCALE_G: 223 | self.background.replace("arp1.bmp") 224 | elif scale_start == config.SCALE_A: 225 | self.background.replace("arp2.bmp") 226 | elif scale_start == config.SCALE_F: 227 | self.background.replace("arp3.bmp") 228 | self.hw.display.refresh() 229 | 230 | class ArpeggiatorNetwork: 231 | """Handles all network-related operations for the arpeggiator""" 232 | def __init__(self, hw, instrument_id, peer_broadcast): 233 | self.hw = hw 234 | self.instrument_id = instrument_id 235 | self.peer_broadcast = peer_broadcast 236 | self.esp = None 237 | self.packet_handler = None 238 | self.initialize_network() 239 | 240 | def initialize_network(self): 241 | """Initialize ESP-NOW and packet handler""" 242 | if config.MODE == "arp": 243 | try: 244 | self.esp = espnow.ESPNow() 245 | self.peer_broadcast = espnow.Peer(mac=config.MAC_BROADCAST) 246 | self.esp.peers.append(self.peer_broadcast) 247 | self.packet_handler = PacketHandler(self.esp, self.instrument_id, self.peer_broadcast) 248 | except RuntimeError as e: 249 | if "Already running" in str(e): 250 | # ESP-NOW is already initialized, reuse the instance 251 | self.esp = espnow.ESPNow() 252 | self.packet_handler = PacketHandler(self.esp, self.instrument_id, self.peer_broadcast) 253 | else: 254 | raise 255 | 256 | def handle_packet(self, packet): 257 | """Process incoming network packet""" 258 | try: 259 | return self.packet_handler.handle_packet(packet) 260 | except PacketReadError as e: 261 | raise 262 | except Exception as e: 263 | raise 264 | 265 | def send_packet(self, packet_type, args): 266 | """Send a packet over the network""" 267 | try: 268 | self.packet_handler.send_packet(packet_type, args) 269 | except PacketSendError as e: 270 | raise 271 | except Exception as e: 272 | raise 273 | 274 | def send_note(self, note, intensity): 275 | """Send a note packet""" 276 | self.send_packet('l', [self.instrument_id, note, intensity]) 277 | 278 | class ArpeggiatorInput: 279 | """Handles all input-related operations for the arpeggiator""" 280 | def __init__(self, hw, state, ui, network): 281 | self.hw = hw 282 | self.state = state 283 | self.ui = ui 284 | self.network = network 285 | 286 | def handle_key_input(self): 287 | """Process key input events""" 288 | for i in range(12): 289 | val, new = self.hw.key_change(i) 290 | if new and val: 291 | self.state.arp2d, self.state.pos, old_pos, remove, add = newKey(self.state.arp2d, i, self.state.pos) 292 | self.ui.update_pattern(self.ui.field, self.state.arp2d, self.state.pos) 293 | if not self.state.mute: 294 | self.network.send_note(self.state.arp_notes[i], config.DEFAULT_INTENSITY) 295 | 296 | elif new and not val: 297 | if not self.state.mute: 298 | self.network.send_note(self.state.arp_notes[i], 0) 299 | 300 | def handle_menu_input(self, menu_manager): 301 | """Process menu navigation input""" 302 | rotation = 0 # Initialize rotation to 0 303 | if config.HW_VERSION == 1.1: 304 | rotation = self.hw.check_rotation(0, 512) 305 | elif config.HW_VERSION == 1.0: 306 | if self.hw.check_analog_rotation(config.ROTATION_CW): 307 | rotation = 1 308 | elif self.hw.check_analog_rotation(config.ROTATION_CCW): 309 | rotation = -1 310 | 311 | if rotation: 312 | menu_manager.handle_rotation(rotation) 313 | if self.hw.key_new(config.KEY_BACK): 314 | menu_manager.handle_back() 315 | if self.hw.key_new(config.KEY_SELECT): 316 | menu_manager.handle_select() 317 | 318 | class ArpeggiatorPlayback: 319 | """Handles all playback-related operations for the arpeggiator""" 320 | def __init__(self, hw, state, ui, network): 321 | self.hw = hw 322 | self.state = state 323 | self.ui = ui 324 | self.network = network 325 | 326 | def update(self): 327 | """Update playback state""" 328 | if self.state.mode != config.PLAYBACK_STATES["playing"]: 329 | return 330 | 331 | now = time.monotonic() 332 | arp_tick = int((now - self.state.start_time) * self.state.time_to_tick % (self.state.ticks_per_beat*2)) 333 | arp_pos_new = int(arp_tick * 2 / self.state.ticks_per_beat) 334 | 335 | if arp_pos_new > self.state.arp_pos or (self.state.arp_pos == len(self.state.arp_notes)-1 and arp_pos_new == 0): 336 | self._play_note(arp_pos_new) 337 | 338 | def _play_note(self, arp_pos_new): 339 | """Play a new note and update UI""" 340 | packet = bytearray() 341 | 342 | # Turn off previous note 343 | if self.state.old_note != 0: 344 | ch = config.INSTRUMENT_ID 345 | note = self.state.old_note 346 | intensity = 0 347 | packet.append(ord('l')) 348 | packet.append(ch) 349 | packet.append(note) 350 | packet.append(intensity) 351 | i = note % 12 352 | if not self.state.mute: 353 | if note in self.state.arp_notes: 354 | self.hw.pixels[i] = 0x200010 355 | else: 356 | self.hw.pixels[i] = 0x000000 357 | 358 | # Send new note 359 | ch = config.INSTRUMENT_ID 360 | note = self.state.arp_notes[arp_pos_new] 361 | intensity = config.DEFAULT_INTENSITY 362 | packet.append(ord('l')) 363 | packet.append(ch) 364 | packet.append(note) 365 | packet.append(intensity) 366 | i = note % 12 367 | if not self.state.mute: 368 | self.hw.pixels[i] = colorwheel(int(note / 12) * 20 & 255) 369 | self.network.esp.send(packet, self.network.peer_broadcast) 370 | 371 | # Update pattern display 372 | for n in self.state.arp2d[self.state.arp_pos]: 373 | self.ui.field.setBlock(n, self.state.arp_pos, 2) 374 | for n in self.state.arp2d[arp_pos_new]: 375 | self.ui.field.setBlock(n, arp_pos_new, 1) 376 | 377 | # Update state 378 | self.state.arp_pos = arp_pos_new 379 | self.state.old_note = note 380 | 381 | # Update display 382 | self.ui.display_manager.update_channel_info(ch, note, intensity) 383 | self.hw.display.refresh() 384 | 385 | class Arpeggiator: 386 | """Main class coordinating all arpeggiator components""" 387 | def __init__(self, hw, display_manager, instrument_id, peer_broadcast): 388 | try: 389 | self.hw = hw 390 | self.display_manager = display_manager 391 | self.instrument_id = instrument_id 392 | self.peer_broadcast = peer_broadcast 393 | 394 | # Initialize components 395 | self.state = ArpeggiatorState() 396 | self.ui = ArpeggiatorUI(display_manager, hw) 397 | self.network = ArpeggiatorNetwork(hw, instrument_id, peer_broadcast) 398 | self.input_handler = ArpeggiatorInput(hw, self.state, self.ui, self.network) 399 | self.playback = ArpeggiatorPlayback(hw, self.state, self.ui, self.network) 400 | 401 | # Initialize menu 402 | self.menu_actions = { 403 | 'send_song': lambda: self.network.packet_handler.send_song() if self.network.packet_handler else None, 404 | 'set_channel': lambda: set_channel(), 405 | 'show_my_ip': lambda: show_device_ip(), 406 | 'send_pair': lambda: self.network.packet_handler.send_pair() if self.network.packet_handler else None, 407 | 'show_paired_devices': lambda: show_connected_devices(), 408 | 'set_mode': lambda mode_id: set_device_mode(mode_id) 409 | } 410 | self.menu_manager = MenuManager(config.MENU_FILE, display_manager, self.menu_actions) 411 | 412 | # Initialize state 413 | self.state.initialize_scale() 414 | self.send_pair() 415 | except Exception as e: 416 | report_error(display_manager, f"Arpeggiator initialization error: {e}", True) 417 | raise 418 | 419 | def run(self): 420 | """Main loop""" 421 | while True: 422 | try: 423 | # Update playback 424 | self.playback.update() 425 | 426 | # Handle network packets 427 | if self.network.esp: 428 | try: 429 | packet = self.network.packet_handler.read_packet() 430 | if packet: 431 | result = self.network.handle_packet(packet) 432 | if result: 433 | self._handle_packet_result(result) 434 | 435 | except PacketReadError as e: 436 | report_error(self.display_manager, f"Packet read error: {e}") 437 | except Exception as e: 438 | report_error(self.display_manager, f"Network error: {e}") 439 | 440 | # Handle input 441 | try: 442 | self.input_handler.handle_key_input() 443 | self.input_handler.handle_menu_input(self.menu_manager) 444 | except Exception as e: 445 | report_error(self.display_manager, f"Input error: {e}") 446 | 447 | except KeyboardInterrupt: 448 | report_error(self.display_manager, "User interrupted") 449 | break 450 | except Exception as e: 451 | report_error(self.display_manager, f"Main loop error: {e}") 452 | # Continue running despite errors 453 | 454 | def _handle_packet_result(self, result): 455 | """Handle packet processing result""" 456 | try: 457 | packet_type, *args = result 458 | 459 | # Handle tick packet 460 | if packet_type == 'tick': 461 | tick = args[0] 462 | now_tick = int(self.state.time_to_tick * (time.monotonic() - self.state.start_time)) 463 | tick_delta = tick - now_tick 464 | 465 | # Handle begin packet 466 | elif packet_type == 'begin': 467 | self.state.start_time = time.monotonic() 468 | self.state.mode = config.PLAYBACK_STATES["playing"] 469 | self.display_manager.show_debug_message("playing") 470 | 471 | # Handle stop packet 472 | elif packet_type == 'stop': 473 | self.state.mode = config.PLAYBACK_STATES["paused"] 474 | self.display_manager.show_debug_message("paused") 475 | if self.state.old_note != 0: 476 | self.network.send_note(self.state.old_note, 0) 477 | 478 | # Handle header packet 479 | elif packet_type == 'header': 480 | self.state.ticks_per_beat, self.state.max_ticks, self.state.tempo, numerator, self.state.denominator, nr_instruments = args 481 | self.state.tick_to_time = 60 * 4 / (self.state.tempo * self.state.ticks_per_beat * self.state.denominator) 482 | self.state.time_to_tick = (self.state.tempo * self.state.ticks_per_beat * self.state.denominator) / (60 * 4) 483 | song_length = self.state.max_ticks * self.state.tick_to_time 484 | self.state.mode = config.PLAYBACK_STATES["paused"] 485 | 486 | # Handle mute packet 487 | elif packet_type == 'mute': 488 | ch, intensity = args 489 | if ch == self.instrument_id: 490 | self.state.mute = intensity 491 | if self.state.mute: 492 | for i in range(12): 493 | self.hw.pixels[i] = (0,20,0) 494 | self.display_manager.show_debug_message("muted") 495 | else: 496 | for i in range(12): 497 | self.hw.pixels[i] = (0,0,0) 498 | self.display_manager.show_debug_message(" ") 499 | 500 | # Handle update packet 501 | elif packet_type == 'update': 502 | self.display_manager.show_debug_message("update") 503 | self.display_manager.show_debug_message(" ") 504 | redraw() 505 | self.display_manager.update_channel_info(0, 0, 0) 506 | 507 | # Handle reset packet 508 | elif packet_type == 'reset': 509 | self.state.reset() 510 | self.display_manager.show_debug_message("reset") 511 | self.ui.background.replace("arp0.bmp") 512 | self.display_manager.update_channel_info(0, 0, 0) 513 | if not self.state.mute: 514 | self.ui.update_leds(self.state.arp_notes) 515 | self.ui.update_pattern(self.ui.field, self.state.arp2d, 0) 516 | 517 | # Handle clear packet 518 | elif packet_type == 'clear': 519 | id = args[0] 520 | if id == self.instrument_id or id == 255: 521 | self.display_manager.show_debug_message("receiving") 522 | self.state.arp_notes = [55, 60, 64, 60] 523 | if not self.state.mute: 524 | self.ui.update_leds(self.state.arp_notes) 525 | self.state.mode = config.PLAYBACK_STATES["clear"] 526 | 527 | # Handle scale packet 528 | elif packet_type == 'scale': 529 | scale_start, scale = args 530 | self.state.update_scale(scale_start, scale) 531 | if not self.state.mute: 532 | self.ui.update_leds(self.state.arp_notes) 533 | # Update background based on scale_start 534 | if scale_start == 60: 535 | self.ui.background.replace("arp0.bmp") 536 | elif scale_start == 55: 537 | self.ui.background.replace("arp1.bmp") 538 | elif scale_start == 57: 539 | self.ui.background.replace("arp2.bmp") 540 | elif scale_start == 65: 541 | self.ui.background.replace("arp3.bmp") 542 | self.hw.display.refresh() 543 | self.display_manager.show_debug_message(f"scale: {scale_start}") 544 | 545 | except Exception as e: 546 | report_error(self.display_manager, f"Packet handling error: {e}") 547 | 548 | def send_pair(self): 549 | """Send a pair packet to broadcast address""" 550 | if self.network.esp: 551 | packet = bytearray('p', 'utf-8') 552 | packet.append(self.instrument_id) 553 | self.network.esp.send(packet, self.peer_broadcast) 554 | 555 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 556 | 557 | def newKey(arp2d, key, pos): 558 | remove = [] 559 | add = [] 560 | if key in arp2d[pos]: 561 | arp2d[pos].remove(key) 562 | remove.append(key) 563 | else: 564 | arp2d[pos].append(key) 565 | add.append(key) 566 | old_pos = pos 567 | pos += 1 568 | if pos >= len(arp2d): 569 | pos = 0 570 | return(arp2d, pos, old_pos, remove, add) 571 | 572 | # unused... 573 | def update_ui(field, arp2d, pos): 574 | # clear all leds 575 | for i in range(12): 576 | hw.pixels[i] = (0,0,0) 577 | # clear field (slow, but fast enought) 578 | for y in range(config.FIELD_Y_MAX): 579 | for x in range(config.FIELD_X_MAX): 580 | field.setBlock(x, y, 0) 581 | # add new sprites and leds 582 | for y, a in enumerate(arp2d): 583 | for x in a: 584 | if y != pos: 585 | field.setBlock(x, y, 2) 586 | hw.pixels[x] = 0x200010 587 | for n in arp2d[pos]: 588 | field.setBlock(n, pos, 1) 589 | hw.pixels[n] = (0,20,10) 590 | hw.display.refresh() 591 | 592 | # unused... 593 | def send_packet(id, payload, addr): 594 | packet = bytearray(id, 'utf-8') 595 | packet.extend(payload) 596 | esp.send(packet, addr) 597 | 598 | # unused... 599 | def send_pair(): 600 | send_packet('p', [config.INSTRUMENT_ID], peer_broadcast) 601 | #send_packet('p', bytearray(wifi.radio.mac_address), peer_broadcast) 602 | 603 | # Create and remove a black sprite to refresh the screen 604 | # unused... 605 | def redraw(): 606 | black_sprite = displayio.TileGrid(displayio.Bitmap(160, 128, 1), pixel_shader=displayio.Palette(1), x=0, y=0) 607 | hw.displayGroup.append(black_sprite) 608 | hw.displayGroup.pop() 609 | hw.display.refresh() 610 | 611 | def report_error(display_manager, error_msg, is_fatal=False): 612 | """ 613 | Report an error to the display and console. 614 | 615 | Args: 616 | display_manager: DisplayManager instance 617 | error_msg: Error message to display 618 | is_fatal: Whether this is a fatal error 619 | """ 620 | # Always print to serial console 621 | print(f"{'FATAL ERROR' if is_fatal else 'Error'}: {error_msg}") 622 | 623 | # Print to display if available 624 | if display_manager: 625 | try: 626 | prefix = "FATAL: " if is_fatal else "Error: " 627 | display_manager.show_debug_message(prefix + error_msg[:18]) 628 | except Exception as e: 629 | print(f"Error displaying error message: {e}") 630 | 631 | def cleanup(display_manager, esp): 632 | """Cleanup resources.""" 633 | try: 634 | # Reset display state 635 | if display_manager: 636 | try: 637 | display_manager.show_debug_message("Shutting down...") 638 | except Exception as e: 639 | report_error(display_manager, f"Display reset error: {e}") 640 | 641 | # Cleanup network 642 | if esp: 643 | try: 644 | esp.deinit() 645 | except Exception as e: 646 | report_error(display_manager, f"Network cleanup error: {e}") 647 | 648 | except Exception as e: 649 | report_error(display_manager, f"Cleanup error: {e}") 650 | 651 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 652 | 653 | def main(): 654 | """Main application entry point.""" 655 | # Initialize managers as None to ensure proper cleanup 656 | display_manager = None 657 | esp = None 658 | packet_handler = None 659 | menu_manager = None 660 | arpeggiator = None 661 | 662 | try: 663 | # Initialize display manager 664 | display_manager = DisplayManager(hw.display, hw) 665 | 666 | # Load sprite_sheets from file 667 | sprite_sheet, palette = adafruit_imageload.load("assets/images/10x10.bmp", bitmap=displayio.Bitmap, palette=displayio.Palette) 668 | 669 | # Initialize text fields 670 | display_manager.create_text_fields(config.MODE + " (CH" + str(config.INSTRUMENT_ID) + ")") 671 | 672 | # Create a black background 673 | black_sprite = displayio.TileGrid(displayio.Bitmap(160, 128, 1), pixel_shader=displayio.Palette(1), x=0, y=0) 674 | hw.displayGroup.append(black_sprite) 675 | 676 | # Create peer broadcast object 677 | peer_broadcast = espnow.Peer(mac=config.MAC_BROADCAST) 678 | 679 | # Initialize and run arpeggiator 680 | arpeggiator = Arpeggiator(hw, display_manager, config.INSTRUMENT_ID, peer_broadcast) 681 | arpeggiator.run() 682 | 683 | except Exception as e: 684 | report_error(display_manager, f"Fatal error: {e}", True) 685 | finally: 686 | cleanup(display_manager, esp) 687 | 688 | if __name__ == "__main__": 689 | main() 690 | -------------------------------------------------------------------------------- /firmware/src/core/boss.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # ______ ______ ____ _____ _____ 4 | # \ \|\ \ ____\_ \__ _____\ \ _____\ \ 5 | # | |\| | / / \ / / \ | / / \ | 6 | # | |/____ / / /\ | | | /___/| | | /___/| 7 | # | |\ \ | | | | ____\ \ | || ____\ \ | || 8 | # | | | || | | | / /\ \|___|// /\ \|___|/ 9 | # | | | || | / /|| |/ \ \ | |/ \ \ 10 | # /_____/|/_____/||\ \_____/ ||\____\ /____/| |\____\ /____/| 11 | # | ||| | || \_____\ | / | | || | | | | || | | 12 | # |____|/|_____|/ \ | |___|/ \|___||____|/ \|___||____|/ 13 | # \|____| 14 | # 15 | # Boss hadles the playback of a song and does the following: 16 | # > sends song events and tick packets wirelessly to other devices 17 | # > outputs audio playback using the i2s DAC and a basic synth engine 18 | # > outputs MIDI events over USB 19 | # > visualizes the song playback and allows channels to be muted 20 | # 21 | # Todo: 22 | # * add support for 1.1 hardware (only tested on 1.0) 23 | # * increase sample frequency of i2s mixer (16kHz samples and wavetable) 24 | # * replace broadcast with network discovery and setup? 25 | # * custom library for more efficient display handling 26 | # * custom library for audio rendering (wavetable and samples) 27 | # 28 | # Done: 29 | # 250424: 30 | # x major code refactoring and cleanup: 31 | # directory structure, common modules, manager classes and error handling 32 | # 250329: 33 | # x stop active notes on mute 34 | # x fix pcb to use i2s ldo instead of s2mini (and ground i2s scl) 35 | # x fix standalone playback (without USB midi) 36 | # 241226: 37 | # x build menu system 38 | # x implement file header on synths 39 | # 240929: 40 | # x implement live keyboard backchannel 41 | # 240922: 42 | # x added keyboard and led_pixel support 43 | # x implemented mute on synths 44 | # ****************************************************************************** 45 | 46 | import config.config as config 47 | import espnow 48 | from src.common import hw 49 | import time 50 | from src.common.audio import AudioManager 51 | from src.common.ui import DisplayManager 52 | from src.common.network import PacketHandler, NetworkError, PacketSendError, PacketReadError, SongSendError 53 | from src.common.song import Song, SongPlayer 54 | from src.common.menu import MenuManager 55 | 56 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 57 | 58 | # load song 59 | song_name = "arpai.bin" 60 | #song_name = "ai&a_beat.bin" 61 | song_name = song_name.split('.') 62 | song_path = "assets/" 63 | 64 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 65 | 66 | def initialize_audio(): 67 | """Initialize audio system.""" 68 | try: 69 | audio_manager = AudioManager(hw) 70 | return audio_manager 71 | except Exception as e: 72 | report_error(None, f"Audio init error: {e}", True) 73 | raise 74 | 75 | def initialize_display(): 76 | """Initialize display system.""" 77 | try: 78 | display_manager = DisplayManager(hw.display, hw) 79 | display_manager.load_song_background(config.SONG_NAME) 80 | display_manager.create_position_sprite() 81 | display_manager.create_text_fields(config.SONG_NAME) 82 | 83 | return display_manager 84 | except Exception as e: 85 | report_error(None, f"Display init error: {e}", True) 86 | raise 87 | 88 | def initialize_menu(display_manager): 89 | """Initialize menu system.""" 90 | try: 91 | return MenuManager(config.MENU_FILE, display_manager) 92 | except Exception as e: 93 | report_error(display_manager, f"Menu init error: {e}", True) 94 | raise 95 | 96 | def initialize_network(): 97 | """Initialize network system.""" 98 | try: 99 | if config.MODE == "boss": 100 | esp = espnow.ESPNow() 101 | BROADCAST = espnow.Peer(mac=config.MAC_BROADCAST) 102 | esp.peers.append(BROADCAST) 103 | packet_handler = PacketHandler(esp, config.INSTRUMENT_ID, BROADCAST) 104 | return esp, packet_handler 105 | return None, None 106 | except Exception as e: 107 | report_error(None, f"Network init error: {e}", True) 108 | raise 109 | 110 | def handle_song_playback(player, display_manager): 111 | """Handle song playback updates.""" 112 | try: 113 | result = player.update() 114 | if result: 115 | sprite_pos, ch, note, intensity = result 116 | if sprite_pos > player.sprite_last_pos: 117 | player.sprite_last_pos = sprite_pos 118 | display_manager.update_playback_position(sprite_pos) 119 | display_manager.update_channel_info(ch, note, intensity) 120 | return result 121 | except IndexError as e: 122 | # Handle out of range errors gracefully 123 | report_error(display_manager, "Playback position out of range") 124 | return None 125 | except Exception as e: 126 | report_error(display_manager, f"Playback error: {e}") 127 | return None 128 | 129 | def handle_input(player, audio_manager, display_manager, menu_manager, song): 130 | """Handle user input.""" 131 | try: 132 | # Handle reset button 133 | if hw.key_new(config.KEY_RESET): 134 | handle_reset(player, audio_manager, display_manager) 135 | return 136 | 137 | # Handle play/pause button 138 | if hw.key_new(config.KEY_PLAY_PAUSE): 139 | handle_play_pause(player, display_manager) 140 | return 141 | 142 | # Handle mute toggles 143 | for k in range(song.get_metadata('nr_instruments')): 144 | if hw.key_new(k): 145 | handle_mute_toggle(k, audio_manager, display_manager) 146 | return 147 | 148 | # Handle menu navigation 149 | handle_menu_navigation(menu_manager) 150 | except Exception as e: 151 | report_error(display_manager, f"Input error: {e}") 152 | 153 | def handle_reset(player, audio_manager, display_manager): 154 | """Handle reset button press.""" 155 | player.reset() 156 | display_manager.update_mute_leds(audio_manager.get_mute_states()) 157 | display_manager.update_channel_info(0, 0, 0) 158 | display_manager.update_playback_position(0) 159 | 160 | def handle_play_pause(player, display_manager): 161 | """Handle play/pause button press.""" 162 | if player.playback_state == "playing": 163 | player.pause() 164 | display_manager.set_pause_led(True) 165 | else: 166 | player.play() 167 | display_manager.set_pause_led(False) 168 | 169 | def handle_mute_toggle(channel, audio_manager, display_manager): 170 | """Handle mute toggle for a channel.""" 171 | audio_manager.toggle_mute(channel) 172 | display_manager.update_mute_leds(audio_manager.get_mute_states()) 173 | 174 | def handle_menu_navigation(menu_manager): 175 | """Handle menu navigation input.""" 176 | rotation = 0 # Initialize rotation to 0 177 | 178 | if config.HW_VERSION == 1.1: 179 | rotation = hw.check_rotation(0, 512) 180 | elif config.HW_VERSION == 1.0: 181 | if hw.check_analog_rotation(config.ROTATION_CW): 182 | rotation = 1 183 | elif hw.check_analog_rotation(config.ROTATION_CCW): 184 | rotation = -1 185 | 186 | if rotation: 187 | menu_manager.handle_rotation(rotation) 188 | if hw.key_new(config.KEY_BACK): 189 | menu_manager.handle_back() 190 | if hw.key_new(config.KEY_SELECT): 191 | menu_manager.handle_select() 192 | 193 | def handle_network_packets(esp, packet_handler, audio_manager, display_manager, player): 194 | """Handle incoming network packets.""" 195 | if not esp: 196 | return 197 | 198 | try: 199 | packet = esp.read() 200 | if not packet: 201 | return 202 | 203 | result = packet_handler.handle_packet(packet) 204 | if not result: 205 | return 206 | 207 | if result[0] == 'live': 208 | _, ch, note, intensity = result 209 | audio_manager.play.event(ch, note, intensity) 210 | display_manager.update_channel_info(ch, note, intensity) 211 | elif result[0] == 'pair': 212 | _, id = result 213 | # Read the song file and send it to the requesting device 214 | with open(player.song.file_path + player.song.file_name + ".bin", 'rb') as f: 215 | byte_song = f.read() 216 | packet_handler.send_song(id, byte_song, display_manager, hw) 217 | except PacketReadError as e: 218 | report_error(display_manager, f"Packet read error: {e}") 219 | except PacketSendError as e: 220 | report_error(display_manager, f"Packet send error: {e}") 221 | except SongSendError as e: 222 | report_error(display_manager, f"Song send error: {e}") 223 | except NetworkError as e: 224 | report_error(display_manager, f"Network error: {e}") 225 | except Exception as e: 226 | report_error(display_manager, f"Unexpected error: {e}") 227 | 228 | def cleanup(audio_manager, display_manager, esp): 229 | """Cleanup resources.""" 230 | try: 231 | # Stop all audio playback 232 | if audio_manager: 233 | try: 234 | audio_manager.play.stop_all_notes() 235 | except Exception as e: 236 | report_error(display_manager, f"Audio stop error: {e}") 237 | 238 | # Reset display state 239 | if display_manager: 240 | try: 241 | display_manager.set_pause_led(True) 242 | display_manager.show_debug_message("Shutting down...") 243 | except Exception as e: 244 | report_error(display_manager, f"Display reset error: {e}") 245 | 246 | # Cleanup network 247 | if esp: 248 | try: 249 | esp.deinit() 250 | except Exception as e: 251 | report_error(display_manager, f"Network cleanup error: {e}") 252 | 253 | except Exception as e: 254 | report_error(display_manager, f"Cleanup error: {e}") 255 | 256 | def report_error(display_manager, error_msg, is_fatal=False): 257 | """ 258 | Report an error to the display and console. 259 | 260 | Args: 261 | display_manager: DisplayManager instance 262 | error_msg: Error message to display 263 | is_fatal: Whether this is a fatal error 264 | """ 265 | # Always print to serial console 266 | print(f"{'FATAL ERROR' if is_fatal else 'Error'}: {error_msg}") 267 | 268 | # Print to display if available 269 | if display_manager: 270 | try: 271 | prefix = "FATAL: " if is_fatal else "Error: " 272 | display_manager.show_debug_message(prefix + error_msg[:18]) 273 | except Exception as e: 274 | print(f"Error displaying error message: {e}") 275 | 276 | 277 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 278 | 279 | def main(): 280 | """Main application entry point.""" 281 | # Initialize managers as None to ensure proper cleanup 282 | audio_manager = None 283 | display_manager = None 284 | esp = None 285 | packet_handler = None 286 | menu_manager = None 287 | player = None 288 | 289 | try: 290 | # Initialize systems in order of dependency 291 | display_manager = initialize_display() 292 | audio_manager = initialize_audio() 293 | menu_manager = initialize_menu(display_manager) 294 | esp, packet_handler = initialize_network() 295 | 296 | # Initialize song and player 297 | try: 298 | song = Song(config.SONG_PATH, config.SONG_NAME) 299 | player = SongPlayer(song, audio_manager.play, packet_handler) 300 | except Exception as e: 301 | report_error(display_manager, f"Song init error: {e}", True) 302 | raise 303 | 304 | # Set initial LED states 305 | display_manager.set_pause_led(True) 306 | display_manager.update_mute_leds(audio_manager.get_mute_states()) 307 | 308 | # Main loop 309 | while True: 310 | try: 311 | handle_song_playback(player, display_manager) 312 | handle_input(player, audio_manager, display_manager, menu_manager, song) 313 | handle_network_packets(esp, packet_handler, audio_manager, display_manager, player) 314 | except KeyboardInterrupt: 315 | report_error(display_manager, "User interrupted") 316 | break 317 | except Exception as e: 318 | report_error(display_manager, f"Main loop error: {e}") 319 | # Continue running despite errors 320 | 321 | except Exception as e: 322 | report_error(display_manager, f"Fatal error: {e}", True) 323 | finally: 324 | cleanup(audio_manager, display_manager, esp) 325 | 326 | if __name__ == "__main__": 327 | main() 328 | -------------------------------------------------------------------------------- /firmware/src/core/chords.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # 4 | # _/ _/ 5 | # _/_/_/ _/_/_/ _/_/ _/ _/_/ _/_/_/ _/_/_/ 6 | # _/ _/ _/ _/ _/ _/_/ _/ _/ _/_/ 7 | # _/ _/ _/ _/ _/ _/ _/ _/ _/_/ 8 | # _/_/_/ _/ _/ _/_/ _/ _/_/_/ _/_/_/ 9 | # 10 | # Chords is a mockup that generates a chord progression (hardcoded for demovideo) 11 | # It sends "chord packets" to an arp unit that transposes the arpeggio. 12 | # 13 | # Todo: 14 | # * proper graphics (uses fake images today) 15 | # * add support for 1.1 hardware (only tested on 1.0) 16 | # * fix mute leds 17 | # * fix menu and debug graphics (reduce markov size) 18 | # 19 | # Done: 20 | # 250426: 21 | # x major code refactoring and cleanup: 22 | # directory structure, common modules, manager classes and error handling 23 | # 250303 24 | # * updated LEDs 25 | # 250308 26 | # * hard coded UI 27 | # * code clean (removed unused functions and variables) 28 | # 29 | # ****************************************************************************** 30 | import config.config as config 31 | import espnow # type: ignore 32 | from src.common import hw # type: ignore 33 | import time # type: ignore 34 | import usb_midi 35 | import adafruit_midi 36 | from src.common.network import PacketHandler 37 | from src.common.ui import DisplayManager, Sprite, Image, colorwheel 38 | from src.common.menu import MenuManager 39 | 40 | class ChordConfig: 41 | """Configuration management for chord playback.""" 42 | def __init__(self): 43 | self.mode = config.MODE 44 | self.instrument_id = config.INSTRUMENT_ID 45 | self.hw_version = config.HW_VERSION 46 | self.nr_keys = config.NR_KEYS 47 | self.midi_channel = config.MIDI_CHANNEL 48 | self.default_intensity = config.DEFAULT_INTENSITY 49 | self.mac_broadcast = config.MAC_BROADCAST 50 | 51 | # Initialize configuration 52 | chord_config = ChordConfig() 53 | 54 | class ChordLEDManager: 55 | """Manages LED states for chord visualization.""" 56 | def __init__(self, nr_keys): 57 | self.nr_keys = nr_keys 58 | 59 | def clear_all(self): 60 | """Clear all LEDs.""" 61 | for i in range(self.nr_keys): 62 | hw.pixels[i] = 0x000000 63 | 64 | def set_chord_leds(self, chord): 65 | """Set LEDs for a chord pattern.""" 66 | # Clear all LEDs first 67 | self.clear_all() 68 | 69 | # Set chord pattern LEDs 70 | for i in range(2, 9): 71 | hw.pixels[(chord[1] + chord[i]) % 12] = 0x200010 72 | 73 | # Set root note LED with color based on octave 74 | hw.pixels[chord[1] % 12] = colorwheel(int(chord[1] / 12) * 20 & 255) 75 | 76 | class ChordNetworkHandler: 77 | """Handles all network-related operations for chord playback.""" 78 | def __init__(self, esp, instrument_id, peer_broadcast): 79 | self.packet_handler = PacketHandler(esp, instrument_id, peer_broadcast) 80 | self.esp = esp 81 | self.peer_broadcast = peer_broadcast 82 | 83 | def send_chord(self, chord): 84 | """Send a chord packet.""" 85 | packet = bytearray() 86 | packet.append(ord('n')) 87 | packet.extend(bytearray(chord)) 88 | self.esp.send(packet, self.peer_broadcast) 89 | 90 | def send_note(self, note, intensity): 91 | """Send a note packet.""" 92 | packet = bytearray() 93 | packet.append(ord('l')) 94 | packet.append(chord_config.instrument_id) 95 | packet.append(note) 96 | packet.append(intensity) 97 | self.esp.send(packet, self.peer_broadcast) 98 | 99 | def read_packet(self): 100 | """Read a packet from the network.""" 101 | return self.packet_handler.read_packet() 102 | 103 | def handle_packet(self, packet): 104 | """Handle a received packet.""" 105 | return self.packet_handler.handle_packet(packet) 106 | 107 | def send_pair(self): 108 | """Send a pair request packet.""" 109 | self.packet_handler.send_pair() 110 | 111 | # Initialize ESP-NOW if in chords mode 112 | if chord_config.mode == "chords": 113 | esp = espnow.ESPNow() 114 | peer_broadcast = espnow.Peer(mac=chord_config.mac_broadcast) 115 | esp.peers.append(peer_broadcast) 116 | network_handler = ChordNetworkHandler(esp, chord_config.instrument_id, peer_broadcast) 117 | 118 | # Initialize LED manager 119 | led_manager = ChordLEDManager(chord_config.nr_keys) 120 | 121 | midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=chord_config.midi_channel-1) 122 | old_note = 0 123 | 124 | class TimingManager: 125 | """Manages timing and synchronization.""" 126 | def __init__(self): 127 | self.tick_delta = 0 128 | self.ticks_per_beat = 480 129 | self.max_tick = 47522 130 | self.tempo = 160 131 | self.denominator = 4 132 | self.start_time = time.monotonic() 133 | self._update_timing() 134 | 135 | def _update_timing(self): 136 | """Update timing calculations.""" 137 | self.tick_to_time = (60 * 4) / (self.tempo * self.ticks_per_beat * self.denominator) 138 | self.time_to_tick = (self.tempo * self.ticks_per_beat * self.denominator) / (60 * 4) 139 | 140 | def update_from_packet(self, args): 141 | """Update timing from packet data.""" 142 | self.ticks_per_beat = args[0] 143 | self.max_tick = args[1] 144 | self.tempo = args[2] 145 | self.numerator = args[3] 146 | self.denominator = args[4] 147 | self.nr_instruments = args[5] 148 | self._update_timing() 149 | 150 | def get_current_tick(self): 151 | """Get current tick based on elapsed time.""" 152 | return int(self.time_to_tick * (time.monotonic() - self.start_time)) 153 | 154 | def reset(self): 155 | """Reset timing to start.""" 156 | self.start_time = time.monotonic() 157 | 158 | class ChordState: 159 | """State management for chord playback.""" 160 | def __init__(self): 161 | self.old_note = 0 162 | self.mute = 0 163 | self.modes = {"paused": 0, "playing": 1, "reset": 2, "clear": 3} 164 | self.mode = self.modes["clear"] 165 | self.bar_count = 0 166 | self.arp_pos = 3 167 | self.arp_pos_new = 0 168 | self.tick_offset = 250 169 | self.timing = TimingManager() 170 | 171 | class ChordManager: 172 | """Manages chord progression and playback.""" 173 | def __init__(self): 174 | self.chords = self._initialize_chords() 175 | 176 | def _initialize_chords(self): 177 | """Initialize chord progression.""" 178 | return [ 179 | [36, 60, 0, 2, 4, 5, 7, 9, 11], # C major 180 | [40, 55, 0, 2, 4, 5, 7, 9, 11], # Em7 181 | [45, 57, 0, 2, 3, 5, 7, 8, 10], # Am 182 | [41, 65, 0, 2, 4, 5, 7, 9, 11] # F major 183 | ] 184 | 185 | def get_chord(self, index): 186 | """Get chord at specified index.""" 187 | return self.chords[index % len(self.chords)] 188 | 189 | def get_current_chord(self, bar_count): 190 | """Get current chord based on bar count.""" 191 | return self.get_chord(bar_count) 192 | 193 | class ChordPlayer: 194 | """Handles chord playback and state management.""" 195 | 196 | def __init__(self, display_manager): 197 | """Initialize the chord player with default settings.""" 198 | self.display_manager = display_manager 199 | self.state = ChordState() 200 | self.chord_manager = ChordManager() 201 | 202 | def _update_display(self, chord, arp_pos): 203 | """Update display with current chord and position.""" 204 | if not self.display_manager: 205 | return 206 | 207 | self.display_manager.sprites['background'].replace(f"assets/images/chord{arp_pos}.bmp") 208 | self.display_manager.update_channel_info(chord[1], chord[2], chord[3]) 209 | 210 | def handle_playback(self): 211 | """Handle chord playback and display updates.""" 212 | if self.state.mode != self.state.modes["playing"]: 213 | return 214 | 215 | now = time.monotonic() 216 | arp_tick = int(((now - self.state.timing.start_time) * self.state.timing.time_to_tick + self.state.tick_offset) % 217 | (self.state.timing.ticks_per_beat * 16)) 218 | self.state.arp_pos_new = int(arp_tick/(self.state.timing.ticks_per_beat*4)) 219 | 220 | if self.state.arp_pos_new > self.state.arp_pos or (self.state.arp_pos == 3 and self.state.arp_pos_new == 0): 221 | chord = self.chord_manager.get_current_chord(self.state.bar_count) 222 | new_chord(chord, False) 223 | 224 | print("arp_tick", arp_tick, "arp_pos_new", self.state.arp_pos_new, "arp_pos", self.state.arp_pos) 225 | self.state.arp_pos = self.state.arp_pos_new 226 | self.state.bar_count += 1 227 | 228 | if self.display_manager: 229 | self._update_display(chord, self.state.arp_pos_new) 230 | 231 | def handle_packet(self, packet): 232 | """Handle incoming network packets.""" 233 | if not packet or not network_handler: 234 | return 235 | 236 | result = network_handler.handle_packet(packet) 237 | if not result: 238 | return 239 | 240 | packet_type, *args = result 241 | 242 | if packet_type == 'tick': 243 | tick = args[0] 244 | now_tick = self.state.timing.get_current_tick() 245 | self.state.timing.tick_delta = tick - now_tick 246 | 247 | elif packet_type == 'begin': 248 | self.state.timing.reset() 249 | self.state.mode = self.state.modes["playing"] 250 | if self.display_manager: 251 | self.display_manager.show_debug_message("playing") 252 | 253 | elif packet_type == 'stop': 254 | self.state.mode = self.state.modes["paused"] 255 | if self.display_manager: 256 | self.display_manager.show_debug_message("paused") 257 | 258 | elif packet_type == 'header': 259 | self.state.timing.update_from_packet(args) 260 | 261 | elif packet_type == 'live': 262 | ch, note, intensity = args 263 | if ch == chord_config.instrument_id: 264 | self.state.mute = intensity 265 | if self.display_manager: 266 | self.display_manager.show_debug_message("muted" if self.state.mute else " ") 267 | 268 | elif packet_type == 'mute': 269 | ch, intensity = args 270 | if ch == chord_config.instrument_id: 271 | self.state.mute = intensity 272 | if self.display_manager: 273 | self.display_manager.show_debug_message("muted" if self.state.mute else " ") 274 | 275 | elif packet_type == 'update': 276 | if self.display_manager: 277 | self.display_manager.show_debug_message("update") 278 | self.display_manager.show_debug_message(" ") 279 | self.display_manager.sprites['background'].clear(0) 280 | self.display_manager.update_channel_info(0, 0, 0) 281 | 282 | elif packet_type == 'reset': 283 | self.state.timing.reset() 284 | if self.display_manager: 285 | self.display_manager.show_debug_message("reset") 286 | self.state.arp_pos = 3 287 | self.state.bar_count = 0 288 | chord = self.chord_manager.get_chord(0) 289 | new_chord(chord, True) 290 | if self.display_manager: 291 | self.display_manager.update_channel_info(0, 0, 0) 292 | self.display_manager.sprites['background'].replace("assets/images/chord0.bmp") 293 | 294 | elif packet_type == 'clear': 295 | if args[0] == chord_config.instrument_id or args[0] == 255: 296 | self.state.mode = self.state.modes["clear"] 297 | 298 | def report_error(display_manager, error_msg, is_fatal=False): 299 | """ 300 | Report an error to the display and console. 301 | 302 | Args: 303 | display_manager: DisplayManager instance 304 | error_msg: Error message to display 305 | is_fatal: Whether this is a fatal error 306 | """ 307 | # Always print to serial console 308 | print(f"{'FATAL ERROR' if is_fatal else 'Error'}: {error_msg}") 309 | 310 | # Print to display if available 311 | if display_manager: 312 | try: 313 | prefix = "FATAL: " if is_fatal else "Error: " 314 | display_manager.show_debug_message(prefix + error_msg[:18]) 315 | except Exception as e: 316 | print(f"Error displaying error message: {e}") 317 | 318 | def initialize_display(): 319 | """Initialize display components.""" 320 | try: 321 | display_manager = DisplayManager(hw.display, hw) 322 | 323 | # Load initial background 324 | display_manager.sprites['background'] = Image(display_manager.display_group, "assets/images/chord0.bmp") 325 | 326 | return display_manager 327 | except Exception as e: 328 | report_error(None, f"Display initialization error: {e}", True) 329 | raise 330 | 331 | 332 | def new_chord(chord, mute): 333 | """Handle new chord events.""" 334 | global old_note 335 | print("chord", chord) 336 | 337 | # Send chord packet 338 | if network_handler: 339 | network_handler.send_chord(chord) 340 | 341 | if not mute: 342 | if old_note != 0: 343 | # Turn off previous note 344 | if network_handler: 345 | network_handler.send_note(old_note, 0) 346 | 347 | # Send new note 348 | if network_handler: 349 | network_handler.send_note(chord[0], chord_config.default_intensity) 350 | old_note = chord[0] 351 | 352 | # Update LEDs using LED manager 353 | if led_manager: 354 | led_manager.set_chord_leds(chord) 355 | 356 | # ------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 357 | 358 | def main(): 359 | """Main function for chord playback and control.""" 360 | try: 361 | # Initialize display 362 | display_manager = initialize_display() 363 | if not display_manager: 364 | report_error(None, "Failed to initialize display", True) 365 | return 366 | 367 | # Initialize menu manager 368 | menu_manager = MenuManager(config.MENU_FILE, display_manager) 369 | if not menu_manager: 370 | report_error(display_manager, "Failed to initialize menu manager", True) 371 | return 372 | 373 | print("waiting for packets...") 374 | 375 | # Initialize chord player 376 | player = ChordPlayer(display_manager) 377 | if not player: 378 | report_error(display_manager, "Failed to initialize chord player", True) 379 | return 380 | 381 | # Send pair request if network is initialized 382 | if 'network_handler' in globals(): 383 | network_handler.send_pair() 384 | 385 | while True: 386 | try: 387 | # Handle playback 388 | if player: 389 | player.handle_playback() 390 | 391 | # Handle network packets 392 | if esp and 'network_handler' in globals(): 393 | packet = network_handler.read_packet() 394 | if packet: 395 | print(packet) 396 | if player: 397 | player.handle_packet(packet) 398 | 399 | # Handle menu navigation 400 | if menu_manager: 401 | try: 402 | rotation = 0 # Initialize rotation to 0 403 | 404 | if chord_config.hw_version == 1.1: 405 | rotation = hw.check_rotation(0, 512) 406 | elif chord_config.hw_version == 1.0: 407 | if hw.check_rotation(config.ROTATION_CW): 408 | rotation = 1 409 | elif hw.check_rotation(config.ROTATION_CCW): 410 | rotation = -1 411 | 412 | if rotation: 413 | menu_manager.handle_rotation(rotation) 414 | if hw.key_new(config.KEY_BACK): 415 | menu_manager.handle_back() 416 | if hw.key_new(config.KEY_SELECT): 417 | menu_manager.handle_select() 418 | except Exception as e: 419 | report_error(display_manager, f"Menu navigation error: {e}") 420 | 421 | except Exception as e: 422 | report_error(display_manager, f"Main loop error: {e}") 423 | time.sleep(0.1) # Brief pause to prevent tight error loop 424 | 425 | except Exception as e: 426 | report_error(display_manager, f"Fatal error: {e}", True) 427 | try: 428 | if 'display_manager' in locals() and display_manager: 429 | display_manager.show_debug_message("Fatal error") 430 | except: 431 | pass # Ignore any errors in error handling 432 | 433 | if __name__ == "__main__": 434 | main() 435 | -------------------------------------------------------------------------------- /firmware/src/core/pitch.py: -------------------------------------------------------------------------------- 1 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 2 | # ****************************************************************************** 3 | # 4 | # ██████╗ ██╗████████╗ ██████╗██╗ ██╗ 5 | # ██╔══██╗██║╚══██╔══╝██╔════╝██║ ██║ 6 | # ██████╔╝██║ ██║ ██║ ███████║ 7 | # ██╔═══╝ ██║ ██║ ██║ ██╔══██║ 8 | # ██║ ██║ ██║ ╚██████╗██║ ██║ 9 | # ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ 10 | # 11 | # Pitch is a synthesizer with a piano roll (suitable for BASS and Lead synths) 12 | # It recieves data wirelessly from the Boss unit that also renders the audio. 13 | # 14 | # Todo: 15 | # * visulalise oktaves on pianoroll. 16 | # * custom library for more efficient display handling. 17 | # * support for editing the song and upload it to the Boss unit. 18 | # 19 | # Done: 20 | # 250430: 21 | # x major code refactoring and cleanup: 22 | # directory structure, common modules, manager classes and error handling 23 | # 250104: 24 | # x optimized visualization (so it can cach up) 25 | # x real time clock with wireless sync 26 | # x file transfer including header on pair request 27 | # 240925: 28 | # x live playback 29 | # 240831: 30 | # x implemented pianoroll visualization 31 | # x switched to circuitpython 9.1.3 32 | # 240825 33 | # x optimized loading (using bytearray instead of textfile) 34 | # 35 | # ****************************************************************************** 36 | import config.config as config 37 | import espnow 38 | import time 39 | import displayio 40 | import adafruit_imageload 41 | import usb_midi 42 | import adafruit_midi 43 | from adafruit_midi.note_on import NoteOn 44 | from adafruit_midi.note_off import NoteOff 45 | from src.common import hw 46 | from src.common.audio import Sample, Wavetable, Silent, Play 47 | from src.common.ui import Sprite, Field, Image, DisplayManager, colorwheel 48 | from src.common.network import PacketHandler, PacketReadError 49 | from src.common.menu import MenuManager 50 | from src.common.song import LocalSong 51 | 52 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 53 | class MIDIController: 54 | """Handles MIDI input/output operations""" 55 | def __init__(self, channel): 56 | self.midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=channel-1) 57 | 58 | def send_note_on(self, note, intensity): 59 | """Send a MIDI note on message""" 60 | self.midi.send(NoteOn(note, intensity)) 61 | 62 | def send_note_off(self, note): 63 | """Send a MIDI note off message""" 64 | self.midi.send(NoteOff(note, 0)) 65 | 66 | class NetworkManager: 67 | """Handles network communication""" 68 | def __init__(self, instrument_id, mac_broadcast): 69 | self.esp = espnow.ESPNow() 70 | self.peer_broadcast = espnow.Peer(mac=mac_broadcast) 71 | self.esp.peers.append(self.peer_broadcast) 72 | self.network = PacketHandler(self.esp, instrument_id, self.peer_broadcast) 73 | 74 | def send_packet(self, packet_type, data): 75 | """Send a network packet""" 76 | self.network.send_packet(packet_type, data, self.peer_broadcast) 77 | 78 | def read_packet(self): 79 | """Read an incoming network packet""" 80 | return self.network.read_packet() 81 | 82 | def handle_packet(self, packet): 83 | """Handle an incoming packet""" 84 | return self.network.handle_packet(packet) 85 | 86 | def send_pair(self): 87 | """Send a pair request""" 88 | self.network.send_pair() 89 | 90 | class DisplayController: 91 | """Manages display and visualization""" 92 | def __init__(self, display, hw): 93 | self.display_manager = DisplayManager(display, hw) 94 | self._initialize_display() 95 | 96 | def _initialize_display(self): 97 | """Initialize the display with sprite sheet and text fields""" 98 | try: 99 | sprite_sheet, palette = adafruit_imageload.load( 100 | "assets/images/4x4.bmp", 101 | bitmap=displayio.Bitmap, 102 | palette=displayio.Palette 103 | ) 104 | self.display_manager.x_max = config.FIELD_X_MAX 105 | self.display_manager.y_max = config.FIELD_Y_MAX 106 | self.display_manager.initialize_pattern_field(sprite_sheet, palette, 0, 0) 107 | self.display_manager.create_text_fields(f"{config.MODE} (CH{config.INSTRUMENT_ID})") 108 | except Exception as e: 109 | print(f"Display initialization error: {e}") # Print to console first 110 | self.display_manager.show_debug_message("init error") 111 | hw.display.refresh() 112 | raise # Re-raise the original exception 113 | 114 | def show_debug_message(self, message): 115 | """Show a debug message on the display""" 116 | self.display_manager.show_debug_message(message) 117 | 118 | def set_pause_led(self, state): 119 | """Set the pause LED state""" 120 | self.display_manager.set_pause_led(state) 121 | 122 | def update_mute_leds(self, states): 123 | """Update the mute LED states""" 124 | self.display_manager.update_mute_leds(states) 125 | 126 | def update_roll(self, pixel_pos, old_pixel_pos, piano_roll): 127 | """Update the piano roll display""" 128 | pixel_pos = max(0, pixel_pos) 129 | pixel_pos = min(pixel_pos, len(piano_roll)) 130 | 131 | offset = (config.MEDIAN_OCTAVE - 2) * 12 # left edge of visible pianoroll 132 | delta = pixel_pos - old_pixel_pos 133 | 134 | for y in range(config.FIELD_Y_MAX): 135 | i = pixel_pos + y 136 | if i < len(piano_roll): 137 | # Clear old notes 138 | for p in piano_roll[i - delta]: 139 | if p not in piano_roll[i]: 140 | x = self._clamp(p - offset, 0, config.FIELD_X_MAX - 1) 141 | if 0 <= x < config.FIELD_X_MAX and 0 <= y < config.FIELD_Y_MAX: 142 | self.display_manager.field.setBlock(x, y, 0) 143 | # Draw new notes 144 | for p in piano_roll[i]: 145 | if p not in piano_roll[i - delta]: 146 | x = self._clamp(p - offset, 0, config.FIELD_X_MAX - 1) 147 | if 0 <= x < config.FIELD_X_MAX and 0 <= y < config.FIELD_Y_MAX: 148 | self.display_manager.field.setBlock(x, y, 1) 149 | hw.display.refresh() 150 | 151 | def _clamp(self, val, min_val, max_val): 152 | """Clamp a value between min and max""" 153 | return max(min_val, min(val, max_val)) 154 | 155 | class InputHandler: 156 | """Handles user input and key presses""" 157 | def __init__(self, hw, midi_controller, network_manager): 158 | self.hw = hw 159 | self.midi = midi_controller 160 | self.network = network_manager 161 | 162 | def process_input(self): 163 | """Process all user input""" 164 | try: 165 | for i in range(16): 166 | key = self.hw.key_change(i) 167 | if key[1]: 168 | self._handle_key_press(i, key) 169 | except Exception as e: 170 | print(f"Input processing error: {e}") 171 | 172 | def _handle_key_press(self, i, key): 173 | """Handle a single key press event""" 174 | try: 175 | if config.KEY_PCB_TO_NOTE[i] != -1: 176 | note = config.MEDIAN_OCTAVE * 16 + config.KEY_PCB_TO_NOTE[i] 177 | packet = bytearray() 178 | packet.append(ord('l')) 179 | packet.append(config.INSTRUMENT_ID) 180 | packet.append(note) 181 | 182 | if key[0]: 183 | self.hw.pixels[i] = colorwheel(config.MEDIAN_OCTAVE * 20 & 255) 184 | self.midi.send_note_on(note, config.DEFAULT_INTENSITY) 185 | packet.append(config.DEFAULT_INTENSITY) 186 | else: 187 | self.hw.pixels[i] = config.UI_COLORS['key_off'] 188 | self.midi.send_note_off(note) 189 | packet.append(0) 190 | 191 | self.network.send_packet('l', packet[1:]) 192 | except Exception as e: 193 | print(f"Key press error: {e}") 194 | 195 | class PitchSynth: 196 | """Main instrument class that coordinates all components""" 197 | def __init__(self): 198 | """Initialize the PitchSynth instrument with all necessary components.""" 199 | self.display = None # Initialize display attribute to None 200 | try: 201 | self.midi = MIDIController(config.MIDI_CHANNEL) 202 | self.network = NetworkManager(config.INSTRUMENT_ID, config.MAC_BROADCAST) 203 | self.display = DisplayController(hw.display, hw) 204 | self.input = InputHandler(hw, self.midi, self.network) 205 | 206 | self.song = LocalSong(config.INSTRUMENT_ID) 207 | self.song_index = 0 208 | self.mute = 0 209 | self.pixel_pos = 0 210 | self.old_pixel_pos = 0 211 | self.tick_delta = 0 212 | self.start_time = time.monotonic() 213 | self.piano_roll = [[] for _ in range(1000)] 214 | self.mode = config.PLAYBACK_STATES["clear"] 215 | 216 | self._initialize_menu() 217 | self._initialize_network() 218 | self._initialize_keys() 219 | except Exception as e: 220 | print(f"Initialization error: {e}") # Print to console first 221 | if self.display: 222 | try: 223 | self.display.show_debug_message("init error") 224 | except Exception as display_error: 225 | print(f"Failed to display error: {display_error}") 226 | raise # Re-raise the original exception 227 | 228 | def _handle_error(self, context, error, fatal=False): 229 | """Handle errors consistently across the application. 230 | 231 | Args: 232 | context: String describing where the error occurred 233 | error: The exception object 234 | fatal: If True, raises the error after handling""" 235 | print(f"Error in {context}: {error}") # Always print to console 236 | if self.display: 237 | try: 238 | self.display.show_debug_message(f"{context} error") 239 | except Exception as display_error: 240 | print(f"Failed to display error: {display_error}") 241 | if fatal: 242 | raise # Re-raise the original exception 243 | 244 | def _initialize_menu(self) -> None: 245 | """Initialize the menu system with dispatch table.""" 246 | try: 247 | menu_dispatch = { 248 | 'send_song': lambda: self.network.send_packet('s', bytearray(), self.network.peer_broadcast), 249 | 'set_channel': lambda: self.network.send_packet('c', bytearray([config.INSTRUMENT_ID]), self.network.peer_broadcast), 250 | 'show_my_ip': lambda: self.display.show_debug_message(f"IP: {hw.ip}"), 251 | 'send_pair': lambda: self.network.send_pair(), 252 | 'show_paired_devices': lambda: self.display.show_debug_message(f"Paired: {len(self.network.esp.peers)}"), 253 | 'set_mode': lambda mode: setattr(config, 'MODE', ['conductor', 'melodic', 'chord', 'arpeggio', 'drum'][mode]) 254 | } 255 | self.menu_manager = MenuManager(config.MENU_FILE, self.display.display_manager, menu_dispatch) 256 | except Exception as e: 257 | self._handle_error("menu initialization", e, fatal=True) 258 | 259 | def _initialize_network(self) -> None: 260 | """Initialize network connection if in pitch mode.""" 261 | try: 262 | if config.MODE in ("pitch", "pitch2"): 263 | self.network.send_pair() 264 | except Exception as e: 265 | self._handle_error("network initialization", e, fatal=True) 266 | 267 | def _initialize_keys(self) -> None: 268 | """Initialize the key states and LED colors.""" 269 | try: 270 | for i in range(16): 271 | if i < 12: 272 | hw.pixels[config.KEY_NOTE_TO_PCB[i]] = config.UI_COLORS['key_off'] 273 | else: 274 | hw.pixels[config.KEY_NOTE_TO_PCB[i]] = (0, 0, 0) 275 | except Exception as e: 276 | self._handle_error("key initialization", e, fatal=True) 277 | 278 | def handle_packet(self, packet_type, *args): 279 | """Handle incoming network packets based on their type.""" 280 | try: 281 | packet_handlers = { 282 | 'tick': self._handle_tick_packet, 283 | 'begin': self._handle_begin_packet, 284 | 'stop': self._handle_stop_packet, 285 | 'event': self._handle_event_packet, 286 | 'header': self._handle_header_packet, 287 | 'mute': self._handle_mute_packet, 288 | 'update': self._handle_update_packet, 289 | 'reset': self._handle_reset_packet, 290 | 'clear': self._handle_clear_packet 291 | } 292 | 293 | handler = packet_handlers.get(packet_type) 294 | if handler: 295 | handler(*args) 296 | else: 297 | print(f"Unknown packet type: {packet_type}") 298 | except Exception as e: 299 | self._handle_error(f"packet {packet_type}", e) 300 | 301 | def run(self) -> None: 302 | """Main execution loop.""" 303 | try: 304 | while True: 305 | self._process_playback() 306 | self._process_network() 307 | self._process_input() 308 | self._process_menu() 309 | except Exception as e: 310 | self._handle_error("main loop", e) 311 | 312 | def _process_playback(self) -> None: 313 | """Process song playback if in playing state.""" 314 | try: 315 | if self.mode == config.PLAYBACK_STATES["playing"] and self.song_index < self.song.get_event_count(): 316 | self._handle_playback() 317 | except Exception as e: 318 | self._handle_error("playback", e) 319 | self.mode = config.PLAYBACK_STATES["paused"] 320 | 321 | def _handle_playback(self) -> None: 322 | """Handle the playback of a single event.""" 323 | try: 324 | now = time.monotonic() 325 | event = self.song.get_event(self.song_index) 326 | if now >= (self.start_time + (event[0] - self.tick_delta) * self.song.get_metadata('tick_to_time')): 327 | self._play_event(event) 328 | self.song_index += 1 329 | 330 | # Update display 331 | self.pixel_pos = int((self.song.get_metadata('time_to_tick') * (now - self.start_time) + self.tick_delta) / 332 | (self.song.get_metadata('ticks_per_beat') / config.PIXELS_PER_BEAT)) 333 | if self.pixel_pos > self.old_pixel_pos: 334 | self.display.update_roll(self.pixel_pos, self.old_pixel_pos, self.piano_roll) 335 | self.old_pixel_pos = self.pixel_pos 336 | except Exception as e: 337 | self._handle_error("playback handling", e) 338 | self.mode = config.PLAYBACK_STATES["paused"] 339 | 340 | def _play_event(self, event): 341 | """Play a single MIDI event.""" 342 | try: 343 | ch, note, intensity = event[1], event[2], event[3] 344 | if ch == config.INSTRUMENT_ID: 345 | i = note % 12 346 | if intensity == 0: 347 | self.midi.send_note_off(note) 348 | if not self.mute: 349 | hw.pixels[config.KEY_NOTE_TO_PCB[i]] = config.UI_COLORS['key_off'] 350 | else: 351 | if not self.mute: 352 | self.midi.send_note_on(note, intensity) 353 | hw.pixels[config.KEY_NOTE_TO_PCB[i]] = colorwheel(int(note / 12) * 20 & 255) 354 | except Exception as e: 355 | self._handle_error("event playback", e) 356 | 357 | def _process_network(self) -> None: 358 | """Process incoming network packets.""" 359 | if config.MODE in ("pitch", "pitch2"): 360 | try: 361 | packet = self.network.read_packet() 362 | if packet: 363 | result = self.network.handle_packet(packet) 364 | if result: 365 | self.handle_packet(*result) 366 | except PacketReadError: 367 | self._handle_error("packet read", PacketReadError()) 368 | except Exception as e: 369 | self._handle_error("network processing", e) 370 | 371 | def _process_input(self) -> None: 372 | """Process user input from keys.""" 373 | self.input.process_input() 374 | 375 | def _process_menu(self) -> None: 376 | """Process menu navigation and selection.""" 377 | try: 378 | rotation = self._get_rotation() 379 | if rotation == 1: 380 | self.menu_manager.handle_rotation(rotation) 381 | elif rotation == -1: 382 | self.menu_manager.handle_rotation(rotation) 383 | 384 | if hw.key_new(19): 385 | self.menu_manager.handle_back() 386 | if hw.key_new(18): 387 | self.menu_manager.handle_select() 388 | except Exception as e: 389 | self._handle_error("menu processing", e) 390 | 391 | def _get_rotation(self): 392 | """Get the current rotation value from the hardware.""" 393 | try: 394 | if config.HW_VERSION == 1.1: 395 | return hw.check_rotation(0, 512) 396 | elif config.HW_VERSION == 1.0: 397 | if hw.check_analog_rotation(config.ROTATION_CW): 398 | return 1 399 | if hw.check_analog_rotation(config.ROTATION_CCW): 400 | return -1 401 | return 0 402 | except Exception as e: 403 | self._handle_error("rotation", e) 404 | return 0 405 | 406 | def _handle_tick_packet(self, tick): 407 | """Handle tick packet for synchronization.""" 408 | try: 409 | now_tick = int(self.song.get_metadata('time_to_tick') * (time.monotonic() - self.start_time)) 410 | self.tick_delta = tick - now_tick 411 | except Exception as e: 412 | self._handle_error("tick packet", e) 413 | 414 | def _handle_begin_packet(self): 415 | """Handle begin packet to start playback.""" 416 | try: 417 | if self.song.get_event_count() > 0: 418 | self.start_time = time.monotonic() - self.song.get_event(self.song_index)[0] * self.song.get_metadata('tick_to_time') 419 | self.mode = config.PLAYBACK_STATES["playing"] 420 | self.display.show_debug_message("playing") 421 | self.display.set_pause_led(False) 422 | except Exception as e: 423 | self._handle_error("begin packet", e) 424 | 425 | def _handle_stop_packet(self): 426 | """Handle stop packet to pause playback.""" 427 | try: 428 | self.mode = config.PLAYBACK_STATES["paused"] 429 | self.display.show_debug_message("paused") 430 | self.display.set_pause_led(True) 431 | except Exception as e: 432 | self._handle_error("stop packet", e) 433 | 434 | def _handle_event_packet(self, ch, tick_start, tick_end, note, intensity): 435 | """Handle event packet containing note information.""" 436 | try: 437 | if ch == config.INSTRUMENT_ID: 438 | self.song.add_event(tick_start, tick_end, note, intensity) 439 | pixel_start = int(tick_start / (self.song.get_metadata('ticks_per_beat') / config.PIXELS_PER_BEAT)) 440 | self.piano_roll[pixel_start].append(note) 441 | except Exception as e: 442 | self._handle_error("event packet", e) 443 | 444 | def _handle_header_packet(self, ticks_per_beat, max_ticks, tempo, numerator, denominator, nr_instruments): 445 | """Handle header packet containing song metadata.""" 446 | try: 447 | self.song.update_header(ticks_per_beat, max_ticks, tempo, numerator, denominator, nr_instruments) 448 | if not self.piano_roll: 449 | max_pixels = int(max_ticks / (ticks_per_beat / config.PIXELS_PER_BEAT)) 450 | self.piano_roll = [[] * max_pixels for _ in range(max_pixels)] 451 | self.mode = config.PLAYBACK_STATES["paused"] 452 | except Exception as e: 453 | self._handle_error("header packet", e) 454 | 455 | def _handle_mute_packet(self, ch, intensity): 456 | """Handle mute packet to control audio output.""" 457 | try: 458 | if ch == config.INSTRUMENT_ID: 459 | self.mute = intensity 460 | if self.mute: 461 | self.display.update_mute_leds([True] * 16) 462 | self.display.show_debug_message("muted") 463 | else: 464 | self.display.update_mute_leds([False] * 16) 465 | self.display.show_debug_message(" ") 466 | hw.display.refresh() 467 | except Exception as e: 468 | self._handle_error("mute packet", e) 469 | 470 | def _handle_update_packet(self): 471 | """Handle update packet to refresh display.""" 472 | try: 473 | self.display.show_debug_message("update") 474 | self.display.update_roll(self.pixel_pos, self.old_pixel_pos, self.piano_roll) 475 | except Exception as e: 476 | self._handle_error("update packet", e) 477 | 478 | def _handle_reset_packet(self): 479 | """Handle reset packet to restart playback.""" 480 | try: 481 | self._clear_roll(self.old_pixel_pos, self.piano_roll) 482 | self.song_index = 0 483 | if self.song.get_event_count() > 0: 484 | self.start_time = time.monotonic() - self.song.get_event(self.song_index)[0] * self.song.get_metadata('tick_to_time') 485 | self.pixel_pos = 1 486 | self.old_pixel_pos = 0 487 | self.display.show_debug_message("reset") 488 | self.display.update_roll(self.pixel_pos, self.old_pixel_pos, self.piano_roll) 489 | self.old_pixel_pos = self.pixel_pos 490 | except Exception as e: 491 | self._handle_error("reset packet", e) 492 | 493 | def _handle_clear_packet(self, ch): 494 | """Handle clear packet to reset the song.""" 495 | try: 496 | if ch == config.INSTRUMENT_ID or ch == 255: 497 | self._clear_roll(self.old_pixel_pos, self.piano_roll) 498 | self.song.clear() 499 | self.song_index = 0 500 | self.piano_roll = [] 501 | self.display.show_debug_message("receiving") 502 | hw.display.refresh() 503 | self.mode = config.PLAYBACK_STATES["clear"] 504 | except Exception as e: 505 | self._handle_error("clear packet", e) 506 | 507 | def _clear_roll(self, pixel_pos, piano_roll): 508 | """Clear the piano roll display at the given position.""" 509 | try: 510 | offset = (config.MEDIAN_OCTAVE - 2) * 12 # left edge of visible pianoroll 511 | for y in range(config.FIELD_Y_MAX): 512 | i = pixel_pos + y 513 | if i < len(piano_roll): 514 | for p in piano_roll[i]: 515 | x = self._clamp(p - offset, 0, config.FIELD_X_MAX - 1) 516 | if 0 <= x < config.FIELD_X_MAX and 0 <= y < config.FIELD_Y_MAX: 517 | self.display.display_manager.field.setBlock(x, y, 0) 518 | except Exception as e: 519 | self._handle_error("clear roll", e) 520 | 521 | def _clamp(self, val, min_val, max_val): 522 | """Clamp a value between min and max.""" 523 | return max(min_val, min(val, max_val)) 524 | 525 | #------10|-------20|-------30|-------40|-------50|-------60|-------70|-------80| 526 | 527 | def main(): 528 | """Main entry point for the application.""" 529 | try: 530 | instrument = PitchSynth() 531 | instrument.run() 532 | except Exception as e: 533 | print(f"Fatal error: {e}") 534 | if hw.display: 535 | try: 536 | # Create a basic display manager for error handling 537 | display_manager = DisplayManager(hw.display, hw) 538 | display_manager.create_text_fields("Fatal Error") 539 | display_manager.show_debug_message(str(e)[:18]) 540 | hw.display.refresh() 541 | except Exception as display_error: 542 | print(f"Failed to display error: {display_error}") 543 | 544 | if __name__ == "__main__": 545 | main() 546 | -------------------------------------------------------------------------------- /hardware/_pcb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/hardware/_pcb.png -------------------------------------------------------------------------------- /hardware/_schematic.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/hardware/_schematic.pdf -------------------------------------------------------------------------------- /hardware/gerber.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/hardware/gerber.zip -------------------------------------------------------------------------------- /hardware/leetai2.kicad_pro: -------------------------------------------------------------------------------- 1 | { 2 | "board": { 3 | "3dviewports": [], 4 | "design_settings": { 5 | "defaults": { 6 | "apply_defaults_to_fp_fields": false, 7 | "apply_defaults_to_fp_shapes": false, 8 | "apply_defaults_to_fp_text": false, 9 | "board_outline_line_width": 0.05, 10 | "copper_line_width": 0.2, 11 | "copper_text_italic": false, 12 | "copper_text_size_h": 1.5, 13 | "copper_text_size_v": 1.5, 14 | "copper_text_thickness": 0.3, 15 | "copper_text_upright": false, 16 | "courtyard_line_width": 0.05, 17 | "dimension_precision": 4, 18 | "dimension_units": 3, 19 | "dimensions": { 20 | "arrow_length": 1270000, 21 | "extension_offset": 500000, 22 | "keep_text_aligned": true, 23 | "suppress_zeroes": false, 24 | "text_position": 0, 25 | "units_format": 1 26 | }, 27 | "fab_line_width": 0.1, 28 | "fab_text_italic": false, 29 | "fab_text_size_h": 1.0, 30 | "fab_text_size_v": 1.0, 31 | "fab_text_thickness": 0.15, 32 | "fab_text_upright": false, 33 | "other_line_width": 0.1, 34 | "other_text_italic": false, 35 | "other_text_size_h": 1.0, 36 | "other_text_size_v": 1.0, 37 | "other_text_thickness": 0.15, 38 | "other_text_upright": false, 39 | "pads": { 40 | "drill": 0.762, 41 | "height": 1.524, 42 | "width": 1.524 43 | }, 44 | "silk_line_width": 0.1, 45 | "silk_text_italic": false, 46 | "silk_text_size_h": 1.0, 47 | "silk_text_size_v": 1.0, 48 | "silk_text_thickness": 0.1, 49 | "silk_text_upright": false, 50 | "zones": { 51 | "min_clearance": 0.5 52 | } 53 | }, 54 | "diff_pair_dimensions": [], 55 | "drc_exclusions": [], 56 | "meta": { 57 | "version": 2 58 | }, 59 | "rule_severities": { 60 | "annular_width": "error", 61 | "clearance": "error", 62 | "connection_width": "warning", 63 | "copper_edge_clearance": "error", 64 | "copper_sliver": "warning", 65 | "courtyards_overlap": "error", 66 | "diff_pair_gap_out_of_range": "error", 67 | "diff_pair_uncoupled_length_too_long": "error", 68 | "drill_out_of_range": "error", 69 | "duplicate_footprints": "warning", 70 | "extra_footprint": "warning", 71 | "footprint": "error", 72 | "footprint_symbol_mismatch": "warning", 73 | "footprint_type_mismatch": "ignore", 74 | "hole_clearance": "error", 75 | "hole_near_hole": "error", 76 | "holes_co_located": "warning", 77 | "invalid_outline": "error", 78 | "isolated_copper": "warning", 79 | "item_on_disabled_layer": "error", 80 | "items_not_allowed": "error", 81 | "length_out_of_range": "error", 82 | "lib_footprint_issues": "warning", 83 | "lib_footprint_mismatch": "warning", 84 | "malformed_courtyard": "error", 85 | "microvia_drill_out_of_range": "error", 86 | "missing_courtyard": "ignore", 87 | "missing_footprint": "warning", 88 | "net_conflict": "warning", 89 | "npth_inside_courtyard": "ignore", 90 | "padstack": "warning", 91 | "pth_inside_courtyard": "ignore", 92 | "shorting_items": "error", 93 | "silk_edge_clearance": "warning", 94 | "silk_over_copper": "warning", 95 | "silk_overlap": "warning", 96 | "skew_out_of_range": "error", 97 | "solder_mask_bridge": "error", 98 | "starved_thermal": "error", 99 | "text_height": "warning", 100 | "text_thickness": "warning", 101 | "through_hole_pad_without_hole": "error", 102 | "too_many_vias": "error", 103 | "track_dangling": "warning", 104 | "track_width": "error", 105 | "tracks_crossing": "error", 106 | "unconnected_items": "error", 107 | "unresolved_variable": "error", 108 | "via_dangling": "warning", 109 | "zones_intersect": "error" 110 | }, 111 | "rules": { 112 | "max_error": 0.005, 113 | "min_clearance": 0.0, 114 | "min_connection": 0.0, 115 | "min_copper_edge_clearance": 0.5, 116 | "min_hole_clearance": 0.25, 117 | "min_hole_to_hole": 0.25, 118 | "min_microvia_diameter": 0.2, 119 | "min_microvia_drill": 0.1, 120 | "min_resolved_spokes": 2, 121 | "min_silk_clearance": 0.0, 122 | "min_text_height": 0.8, 123 | "min_text_thickness": 0.08, 124 | "min_through_hole_diameter": 0.3, 125 | "min_track_width": 0.0, 126 | "min_via_annular_width": 0.1, 127 | "min_via_diameter": 0.5, 128 | "solder_mask_to_copper_clearance": 0.0, 129 | "use_height_for_length_calcs": true 130 | }, 131 | "teardrop_options": [ 132 | { 133 | "td_onpadsmd": true, 134 | "td_onroundshapesonly": false, 135 | "td_ontrackend": false, 136 | "td_onviapad": true 137 | } 138 | ], 139 | "teardrop_parameters": [ 140 | { 141 | "td_allow_use_two_tracks": true, 142 | "td_curve_segcount": 0, 143 | "td_height_ratio": 1.0, 144 | "td_length_ratio": 0.5, 145 | "td_maxheight": 2.0, 146 | "td_maxlen": 1.0, 147 | "td_on_pad_in_zone": false, 148 | "td_target_name": "td_round_shape", 149 | "td_width_to_size_filter_ratio": 0.9 150 | }, 151 | { 152 | "td_allow_use_two_tracks": true, 153 | "td_curve_segcount": 0, 154 | "td_height_ratio": 1.0, 155 | "td_length_ratio": 0.5, 156 | "td_maxheight": 2.0, 157 | "td_maxlen": 1.0, 158 | "td_on_pad_in_zone": false, 159 | "td_target_name": "td_rect_shape", 160 | "td_width_to_size_filter_ratio": 0.9 161 | }, 162 | { 163 | "td_allow_use_two_tracks": true, 164 | "td_curve_segcount": 0, 165 | "td_height_ratio": 1.0, 166 | "td_length_ratio": 0.5, 167 | "td_maxheight": 2.0, 168 | "td_maxlen": 1.0, 169 | "td_on_pad_in_zone": false, 170 | "td_target_name": "td_track_end", 171 | "td_width_to_size_filter_ratio": 0.9 172 | } 173 | ], 174 | "track_widths": [], 175 | "tuning_pattern_settings": { 176 | "diff_pair_defaults": { 177 | "corner_radius_percentage": 80, 178 | "corner_style": 1, 179 | "max_amplitude": 1.0, 180 | "min_amplitude": 0.2, 181 | "single_sided": false, 182 | "spacing": 1.0 183 | }, 184 | "diff_pair_skew_defaults": { 185 | "corner_radius_percentage": 80, 186 | "corner_style": 1, 187 | "max_amplitude": 1.0, 188 | "min_amplitude": 0.2, 189 | "single_sided": false, 190 | "spacing": 0.6 191 | }, 192 | "single_track_defaults": { 193 | "corner_radius_percentage": 80, 194 | "corner_style": 1, 195 | "max_amplitude": 1.0, 196 | "min_amplitude": 0.2, 197 | "single_sided": false, 198 | "spacing": 0.6 199 | } 200 | }, 201 | "via_dimensions": [], 202 | "zones_allow_external_fillets": false 203 | }, 204 | "ipc2581": { 205 | "dist": "", 206 | "distpn": "", 207 | "internal_id": "", 208 | "mfg": "", 209 | "mpn": "" 210 | }, 211 | "layer_presets": [], 212 | "viewports": [] 213 | }, 214 | "boards": [], 215 | "cvpcb": { 216 | "equivalence_files": [] 217 | }, 218 | "erc": { 219 | "erc_exclusions": [], 220 | "meta": { 221 | "version": 0 222 | }, 223 | "pin_map": [ 224 | [ 225 | 0, 226 | 0, 227 | 0, 228 | 0, 229 | 0, 230 | 0, 231 | 1, 232 | 0, 233 | 0, 234 | 0, 235 | 0, 236 | 2 237 | ], 238 | [ 239 | 0, 240 | 2, 241 | 0, 242 | 1, 243 | 0, 244 | 0, 245 | 1, 246 | 0, 247 | 2, 248 | 2, 249 | 2, 250 | 2 251 | ], 252 | [ 253 | 0, 254 | 0, 255 | 0, 256 | 0, 257 | 0, 258 | 0, 259 | 1, 260 | 0, 261 | 1, 262 | 0, 263 | 1, 264 | 2 265 | ], 266 | [ 267 | 0, 268 | 1, 269 | 0, 270 | 0, 271 | 0, 272 | 0, 273 | 1, 274 | 1, 275 | 2, 276 | 1, 277 | 1, 278 | 2 279 | ], 280 | [ 281 | 0, 282 | 0, 283 | 0, 284 | 0, 285 | 0, 286 | 0, 287 | 1, 288 | 0, 289 | 0, 290 | 0, 291 | 0, 292 | 2 293 | ], 294 | [ 295 | 0, 296 | 0, 297 | 0, 298 | 0, 299 | 0, 300 | 0, 301 | 0, 302 | 0, 303 | 0, 304 | 0, 305 | 0, 306 | 2 307 | ], 308 | [ 309 | 1, 310 | 1, 311 | 1, 312 | 1, 313 | 1, 314 | 0, 315 | 1, 316 | 1, 317 | 1, 318 | 1, 319 | 1, 320 | 2 321 | ], 322 | [ 323 | 0, 324 | 0, 325 | 0, 326 | 1, 327 | 0, 328 | 0, 329 | 1, 330 | 0, 331 | 0, 332 | 0, 333 | 0, 334 | 2 335 | ], 336 | [ 337 | 0, 338 | 2, 339 | 1, 340 | 2, 341 | 0, 342 | 0, 343 | 1, 344 | 0, 345 | 2, 346 | 2, 347 | 2, 348 | 2 349 | ], 350 | [ 351 | 0, 352 | 2, 353 | 0, 354 | 1, 355 | 0, 356 | 0, 357 | 1, 358 | 0, 359 | 2, 360 | 0, 361 | 0, 362 | 2 363 | ], 364 | [ 365 | 0, 366 | 2, 367 | 1, 368 | 1, 369 | 0, 370 | 0, 371 | 1, 372 | 0, 373 | 2, 374 | 0, 375 | 0, 376 | 2 377 | ], 378 | [ 379 | 2, 380 | 2, 381 | 2, 382 | 2, 383 | 2, 384 | 2, 385 | 2, 386 | 2, 387 | 2, 388 | 2, 389 | 2, 390 | 2 391 | ] 392 | ], 393 | "rule_severities": { 394 | "bus_definition_conflict": "error", 395 | "bus_entry_needed": "error", 396 | "bus_to_bus_conflict": "error", 397 | "bus_to_net_conflict": "error", 398 | "conflicting_netclasses": "error", 399 | "different_unit_footprint": "error", 400 | "different_unit_net": "error", 401 | "duplicate_reference": "error", 402 | "duplicate_sheet_names": "error", 403 | "endpoint_off_grid": "warning", 404 | "extra_units": "error", 405 | "global_label_dangling": "warning", 406 | "hier_label_mismatch": "error", 407 | "label_dangling": "error", 408 | "lib_symbol_issues": "warning", 409 | "missing_bidi_pin": "warning", 410 | "missing_input_pin": "warning", 411 | "missing_power_pin": "error", 412 | "missing_unit": "warning", 413 | "multiple_net_names": "warning", 414 | "net_not_bus_member": "warning", 415 | "no_connect_connected": "warning", 416 | "no_connect_dangling": "warning", 417 | "pin_not_connected": "error", 418 | "pin_not_driven": "error", 419 | "pin_to_pin": "warning", 420 | "power_pin_not_driven": "error", 421 | "similar_labels": "warning", 422 | "simulation_model_issue": "ignore", 423 | "unannotated": "error", 424 | "unit_value_mismatch": "error", 425 | "unresolved_variable": "error", 426 | "wire_dangling": "error" 427 | } 428 | }, 429 | "libraries": { 430 | "pinned_footprint_libs": [ 431 | "lib" 432 | ], 433 | "pinned_symbol_libs": [] 434 | }, 435 | "meta": { 436 | "filename": "leetai2.kicad_pro", 437 | "version": 1 438 | }, 439 | "net_settings": { 440 | "classes": [ 441 | { 442 | "bus_width": 12, 443 | "clearance": 0.2, 444 | "diff_pair_gap": 0.25, 445 | "diff_pair_via_gap": 0.25, 446 | "diff_pair_width": 0.2, 447 | "line_style": 0, 448 | "microvia_diameter": 0.3, 449 | "microvia_drill": 0.1, 450 | "name": "Default", 451 | "pcb_color": "rgba(0, 0, 0, 0.000)", 452 | "schematic_color": "rgba(0, 0, 0, 0.000)", 453 | "track_width": 0.2, 454 | "via_diameter": 0.6, 455 | "via_drill": 0.3, 456 | "wire_width": 6 457 | } 458 | ], 459 | "meta": { 460 | "version": 3 461 | }, 462 | "net_colors": null, 463 | "netclass_assignments": null, 464 | "netclass_patterns": [] 465 | }, 466 | "pcbnew": { 467 | "last_paths": { 468 | "gencad": "", 469 | "idf": "", 470 | "netlist": "", 471 | "plot": "gerber/", 472 | "pos_files": "", 473 | "specctra_dsn": "", 474 | "step": "leetai2.step", 475 | "svg": "", 476 | "vrml": "" 477 | }, 478 | "page_layout_descr_file": "" 479 | }, 480 | "schematic": { 481 | "annotate_start_num": 0, 482 | "bom_export_filename": "", 483 | "bom_fmt_presets": [], 484 | "bom_fmt_settings": { 485 | "field_delimiter": ",", 486 | "keep_line_breaks": false, 487 | "keep_tabs": false, 488 | "name": "CSV", 489 | "ref_delimiter": ",", 490 | "ref_range_delimiter": "", 491 | "string_delimiter": "\"" 492 | }, 493 | "bom_presets": [], 494 | "bom_settings": { 495 | "exclude_dnp": false, 496 | "fields_ordered": [ 497 | { 498 | "group_by": false, 499 | "label": "Reference", 500 | "name": "Reference", 501 | "show": true 502 | }, 503 | { 504 | "group_by": true, 505 | "label": "Value", 506 | "name": "Value", 507 | "show": true 508 | }, 509 | { 510 | "group_by": false, 511 | "label": "Datasheet", 512 | "name": "Datasheet", 513 | "show": true 514 | }, 515 | { 516 | "group_by": false, 517 | "label": "Footprint", 518 | "name": "Footprint", 519 | "show": true 520 | }, 521 | { 522 | "group_by": false, 523 | "label": "Qty", 524 | "name": "${QUANTITY}", 525 | "show": true 526 | }, 527 | { 528 | "group_by": true, 529 | "label": "DNP", 530 | "name": "${DNP}", 531 | "show": true 532 | } 533 | ], 534 | "filter_string": "", 535 | "group_symbols": true, 536 | "name": "Grouped By Value", 537 | "sort_asc": true, 538 | "sort_field": "Reference" 539 | }, 540 | "connection_grid_size": 50.0, 541 | "drawing": { 542 | "dashed_lines_dash_length_ratio": 12.0, 543 | "dashed_lines_gap_length_ratio": 3.0, 544 | "default_line_thickness": 6.0, 545 | "default_text_size": 50.0, 546 | "field_names": [], 547 | "intersheets_ref_own_page": false, 548 | "intersheets_ref_prefix": "", 549 | "intersheets_ref_short": false, 550 | "intersheets_ref_show": false, 551 | "intersheets_ref_suffix": "", 552 | "junction_size_choice": 3, 553 | "label_size_ratio": 0.375, 554 | "operating_point_overlay_i_precision": 3, 555 | "operating_point_overlay_i_range": "~A", 556 | "operating_point_overlay_v_precision": 3, 557 | "operating_point_overlay_v_range": "~V", 558 | "overbar_offset_ratio": 1.23, 559 | "pin_symbol_size": 25.0, 560 | "text_offset_ratio": 0.15 561 | }, 562 | "legacy_lib_dir": "", 563 | "legacy_lib_list": [], 564 | "meta": { 565 | "version": 1 566 | }, 567 | "net_format_name": "", 568 | "page_layout_descr_file": "", 569 | "plot_directory": "", 570 | "spice_current_sheet_as_root": false, 571 | "spice_external_command": "spice \"%I\"", 572 | "spice_model_current_sheet_as_root": true, 573 | "spice_save_all_currents": false, 574 | "spice_save_all_dissipations": false, 575 | "spice_save_all_voltages": false, 576 | "subpart_first_id": 65, 577 | "subpart_id_separator": 0 578 | }, 579 | "sheets": [ 580 | [ 581 | "ce2df37b-bcd1-4bb1-a893-465255c05689", 582 | "Root" 583 | ] 584 | ], 585 | "text_variables": {} 586 | } 587 | -------------------------------------------------------------------------------- /hardware/leetai2_libraries.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vonkonow/LeetAI/dd744a208487d3c6bc83606b5994b796d523bbdb/hardware/leetai2_libraries.zip --------------------------------------------------------------------------------