diff --git a/Fruit_Jam/Larsio_Paint_Music/code.py b/Fruit_Jam/Larsio_Paint_Music/code.py new file mode 100755 index 000000000..4328fa54d --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/code.py @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +Larsio Paint Music +Fruit Jam w mouse, HDMI, audio out +or Metro RP2350 with EYESPI DVI breakout and TLV320DAC3100 breakout on STEMMA_I2C, +pin D7 reset, 9/10/11 = BCLC/WSEL/DIN +""" +# pylint: disable=invalid-name,too-few-public-methods,broad-except,redefined-outer-name + +# Main application file for Larsio Paint Music + +import time +import gc +from sound_manager import SoundManager +from note_manager import NoteManager +from ui_manager import UIManager + +# Configuration +AUDIO_OUTPUT = "i2s" # Options: "pwm" or "i2s" + +class MusicStaffApp: + """Main application class that ties everything together""" + + def __init__(self, audio_output="pwm"): + # Initialize the sound manager with selected audio output + # Calculate tempo parameters + BPM = 120 # Beats per minute + SECONDS_PER_BEAT = 60 / BPM + SECONDS_PER_EIGHTH = SECONDS_PER_BEAT / 2 + + # Initialize components in a specific order + # First, force garbage collection to free memory + gc.collect() + + # Initialize the sound manager + print("Initializing sound manager...") + self.sound_manager = SoundManager( + audio_output=audio_output, + seconds_per_eighth=SECONDS_PER_EIGHTH + ) + + # Give hardware time to stabilize + time.sleep(0.5) + gc.collect() + + # Initialize the note manager + print("Initializing note manager...") + self.note_manager = NoteManager( + start_margin=25, # START_MARGIN + staff_y_start=int(240 * 0.1), # STAFF_Y_START + line_spacing=int((240 - int(240 * 0.1) - int(240 * 0.2)) * 0.95) // 8 # LINE_SPACING + ) + + gc.collect() + + # Initialize the UI manager + print("Initializing UI manager...") + self.ui_manager = UIManager(self.sound_manager, self.note_manager) + + def run(self): + """Set up and run the application""" + # Setup the display and UI + print("Setting up display...") + self.ui_manager.setup_display() + + # Give hardware time to stabilize + time.sleep(0.5) + gc.collect() + + # Try to find the mouse with multiple attempts + MAX_ATTEMPTS = 5 + RETRY_DELAY = 1 # seconds + + mouse_found = False + for attempt in range(MAX_ATTEMPTS): + print(f"Mouse detection attempt {attempt+1}/{MAX_ATTEMPTS}") + if self.ui_manager.find_mouse(): + mouse_found = True + print("Mouse found successfully!") + break + + print(f"Mouse detection attempt {attempt+1} failed, retrying...") + time.sleep(RETRY_DELAY) + + if not mouse_found: + print("WARNING: Mouse not found after multiple attempts.") + print("The application will run, but mouse control may be limited.") + + # Enter the main loop + self.ui_manager.main_loop() + + +# Create and run the application +if __name__ == "__main__": + # Start with garbage collection + gc.collect() + print("Starting Music Staff Application...") + + try: + app = MusicStaffApp(audio_output=AUDIO_OUTPUT) + app.run() + except Exception as e: # pylint: disable=broad-except + print(f"Error with I2S audio: {e}") + + # Force garbage collection + gc.collect() + time.sleep(1) + + # Fallback to PWM + try: + app = MusicStaffApp(audio_output="pwm") + app.run() + except Exception as e2: # pylint: disable=broad-except + print(f"Fatal error: {e2}") diff --git a/Fruit_Jam/Larsio_Paint_Music/control_panel.py b/Fruit_Jam/Larsio_Paint_Music/control_panel.py new file mode 100755 index 000000000..e49ef1e11 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/control_panel.py @@ -0,0 +1,353 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# control_panel.py: CircuitPython Music Staff Application component +""" + +# pylint: disable=import-error +from displayio import Group, Bitmap, Palette, TileGrid +from adafruit_display_text.bitmap_label import Label +import terminalio + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements, trailing-whitespace +class ControlPanel: + """Manages transport controls and channel selectors""" + + def __init__(self, screen_width, screen_height): + self.SCREEN_WIDTH = screen_width + self.SCREEN_HEIGHT = screen_height + + # Button dimensions + self.BUTTON_WIDTH = 64 # Updated for bitmap buttons + self.BUTTON_HEIGHT = 48 # Updated for bitmap buttons + self.BUTTON_SPACING = 10 + + # Channel button dimensions + self.CHANNEL_BUTTON_SIZE = 20 + self.CHANNEL_BUTTON_SPACING = 5 + self.CHANNEL_BUTTON_Y = 5 + + # Transport area + self.TRANSPORT_AREA_Y = (int(screen_height * 0.1) + + int((screen_height - int(screen_height * 0.1) - + int(screen_height * 0.2)) * 0.95) + 10) + + # State + self.is_playing = False + self.loop_enabled = False + + # Channel colors (reduced from 8 to 6) + self.CHANNEL_COLORS = [ + 0x000000, # Channel 1: Black (default) + 0xFF0000, # Channel 2: Red + 0x00FF00, # Channel 3: Green + 0x0000FF, # Channel 4: Blue + 0xFF00FF, # Channel 5: Magenta + 0xFFAA00, # Channel 6: Orange + ] + self.current_channel = 0 + + # UI elements + self.play_button = None + self.stop_button = None + self.loop_button = None + self.clear_button = None + self.play_button_bitmap = None + self.stop_button_bitmap = None + self.loop_button_bitmap = None + self.clear_button_bitmap = None + self.channel_selector = None + + # For bitmap buttons + self.button_sprites = None + + # Center points for fallback play/loop buttons + self.play_center_x = self.BUTTON_WIDTH // 2 + self.play_center_y = self.BUTTON_HEIGHT // 2 + self.play_size = 10 + self.loop_center_x = self.BUTTON_WIDTH // 2 + self.loop_center_y = self.BUTTON_HEIGHT // 2 + self.loop_radius = 6 + + def create_channel_buttons(self): + """Create channel selector buttons at the top of the screen using sprites""" + channel_group = Group() + + # Add a highlight indicator for the selected channel (yellow outline only) + # Create bitmap for channel selector with appropriate dimensions + btn_size = self.CHANNEL_BUTTON_SIZE + channel_select_bitmap = Bitmap(btn_size + 6, btn_size + 6, 2) + channel_select_palette = Palette(2) + channel_select_palette[0] = 0x444444 # Same as background color (dark gray) + channel_select_palette[1] = 0xFFFF00 # Yellow highlight + channel_select_palette.make_transparent(0) # Make background transparent + + # Draw just the outline (no filled background) + bitmap_size = btn_size + 6 + for x in range(bitmap_size): + for y in range(bitmap_size): + # Draw only the border pixels + if (x == 0 or x == bitmap_size - 1 or + y == 0 or y == bitmap_size - 1): + channel_select_bitmap[x, y] = 1 # Yellow outline + else: + channel_select_bitmap[x, y] = 0 # Transparent background + + self.channel_selector = TileGrid( + channel_select_bitmap, + pixel_shader=channel_select_palette, + x=7, + y=self.CHANNEL_BUTTON_Y - 3 + ) + channel_group.append(self.channel_selector) + + return channel_group, self.channel_selector + + def create_transport_controls(self, sprite_manager): + """Create transport controls using bitmap buttons""" + transport_group = Group() + + # Check if button sprites were successfully loaded + if (sprite_manager.play_up is None or sprite_manager.stop_up is None or + sprite_manager.loop_up is None or sprite_manager.clear_up is None): + print("Warning: Button sprites not loaded, using fallback buttons") + return self._create_fallback_transport_controls() + + # Button spacing based on the new size (64x48) + button_spacing = 10 + button_y = self.SCREEN_HEIGHT - 50 # Allow some margin at bottom + + # Create TileGrids for each button using the "up" state initially + self.stop_button = TileGrid( + sprite_manager.stop_up, + pixel_shader=sprite_manager.stop_up_palette, + x=10, + y=button_y + ) + + self.play_button = TileGrid( + sprite_manager.play_up, + pixel_shader=sprite_manager.play_up_palette, + x=10 + 64 + button_spacing, + y=button_y + ) + + self.loop_button = TileGrid( + sprite_manager.loop_up, + pixel_shader=sprite_manager.loop_up_palette, + x=10 + 2 * (64 + button_spacing), + y=button_y + ) + + self.clear_button = TileGrid( + sprite_manager.clear_up, + pixel_shader=sprite_manager.clear_up_palette, + x=10 + 3 * (64 + button_spacing), + y=button_y + ) + + # Store references to the button bitmaps and palettes + self.button_sprites = { + 'play': { + 'up': (sprite_manager.play_up, sprite_manager.play_up_palette), + 'down': (sprite_manager.play_down, sprite_manager.play_down_palette) + }, + 'stop': { + 'up': (sprite_manager.stop_up, sprite_manager.stop_up_palette), + 'down': (sprite_manager.stop_down, sprite_manager.stop_down_palette) + }, + 'loop': { + 'up': (sprite_manager.loop_up, sprite_manager.loop_up_palette), + 'down': (sprite_manager.loop_down, sprite_manager.loop_down_palette) + }, + 'clear': { + 'up': (sprite_manager.clear_up, sprite_manager.clear_up_palette), + 'down': (sprite_manager.clear_down, sprite_manager.clear_down_palette) + } + } + + # Save the button dimensions + self.BUTTON_WIDTH = 64 + self.BUTTON_HEIGHT = 48 + + # Add buttons to the group + transport_group.append(self.stop_button) + transport_group.append(self.play_button) + transport_group.append(self.loop_button) + transport_group.append(self.clear_button) + + return (transport_group, self.play_button, self.stop_button, + self.loop_button, self.clear_button) + + # pylint: disable=too-many-locals + def _create_fallback_transport_controls(self): + """Create fallback transport controls using drawn buttons (original implementation)""" + transport_group = Group() + + # Create button bitmaps + self.play_button_bitmap = Bitmap(self.BUTTON_WIDTH, self.BUTTON_HEIGHT, 3) + self.stop_button_bitmap = Bitmap(self.BUTTON_WIDTH, self.BUTTON_HEIGHT, 3) + self.loop_button_bitmap = Bitmap(self.BUTTON_WIDTH, self.BUTTON_HEIGHT, 3) + self.clear_button_bitmap = Bitmap(self.BUTTON_WIDTH, self.BUTTON_HEIGHT, 3) + + # Button palettes with custom colors + play_button_palette = Palette(3) + play_button_palette[0] = 0x444444 # Dark gray background + play_button_palette[1] = 0x000000 # Black text/border + play_button_palette[2] = 0xFFD700 # Golden yellow for active state + + stop_button_palette = Palette(3) + stop_button_palette[0] = 0x444444 # Dark gray background + stop_button_palette[1] = 0x000000 # Black text/border + stop_button_palette[2] = 0xFF00FF # Magenta for active state + + loop_button_palette = Palette(3) + loop_button_palette[0] = 0x444444 # Dark gray background + loop_button_palette[1] = 0x000000 # Black text/border + loop_button_palette[2] = 0xFFD700 # Golden yellow for active state + + clear_button_palette = Palette(3) + clear_button_palette[0] = 0x444444 # Dark gray background + clear_button_palette[1] = 0x000000 # Black text/border + clear_button_palette[2] = 0xFF0000 # Red for pressed state + + # Create Stop button + for x in range(self.BUTTON_WIDTH): + for y in range(self.BUTTON_HEIGHT): + # Draw border + if (x == 0 or x == self.BUTTON_WIDTH - 1 or + y == 0 or y == self.BUTTON_HEIGHT - 1): + self.stop_button_bitmap[x, y] = 1 + # Fill with magenta (active state) + else: + self.stop_button_bitmap[x, y] = 2 + + # Create Play button + for x in range(self.BUTTON_WIDTH): + for y in range(self.BUTTON_HEIGHT): + # Draw border + if (x == 0 or x == self.BUTTON_WIDTH - 1 or + y == 0 or y == self.BUTTON_HEIGHT - 1): + self.play_button_bitmap[x, y] = 1 + # Fill with gray (inactive state) + else: + self.play_button_bitmap[x, y] = 0 + + # Draw play symbol (triangle) + for y in range( + self.play_center_y - self.play_size//2, + self.play_center_y + self.play_size//2 + ): + width = (y - (self.play_center_y - self.play_size//2)) // 2 + for x in range( + self.play_center_x - self.play_size//4, + self.play_center_x - self.play_size//4 + width + ): + if 0 <= x < self.BUTTON_WIDTH and 0 <= y < self.BUTTON_HEIGHT: + self.play_button_bitmap[x, y] = 1 + + # Create Loop button + for x in range(self.BUTTON_WIDTH): + for y in range(self.BUTTON_HEIGHT): + # Draw border + if (x == 0 or x == self.BUTTON_WIDTH - 1 or + y == 0 or y == self.BUTTON_HEIGHT - 1): + self.loop_button_bitmap[x, y] = 1 + # Fill with gray (inactive state) + else: + self.loop_button_bitmap[x, y] = 0 + + # Draw loop symbol (circle with arrow) + for x in range(self.BUTTON_WIDTH): + for y in range(self.BUTTON_HEIGHT): + dx = x - self.loop_center_x + dy = y - self.loop_center_y + # Draw circle outline + if self.loop_radius - 1 <= (dx*dx + dy*dy)**0.5 <= self.loop_radius + 1: + if 0 <= x < self.BUTTON_WIDTH and 0 <= y < self.BUTTON_HEIGHT: + self.loop_button_bitmap[x, y] = 1 + + # Add arrow to loop symbol + for i in range(4): + x = self.loop_center_x + int(self.loop_radius * 0.7) - i + y = self.loop_center_y - self.loop_radius - 1 + i + if 0 <= x < self.BUTTON_WIDTH and 0 <= y < self.BUTTON_HEIGHT: + self.loop_button_bitmap[x, y] = 1 + + x = self.loop_center_x + int(self.loop_radius * 0.7) - i + y = self.loop_center_y - self.loop_radius - 1 - i + 2 + if 0 <= x < self.BUTTON_WIDTH and 0 <= y < self.BUTTON_HEIGHT: + self.loop_button_bitmap[x, y] = 1 + + # Create Clear button + for x in range(self.BUTTON_WIDTH): + for y in range(self.BUTTON_HEIGHT): + # Draw border + if (x == 0 or x == self.BUTTON_WIDTH - 1 or + y == 0 or y == self.BUTTON_HEIGHT - 1): + self.clear_button_bitmap[x, y] = 1 + # Fill with gray background + else: + self.clear_button_bitmap[x, y] = 0 + + # Create button TileGrids + x_offset = 10 + y_pos = self.SCREEN_HEIGHT - 40 + + self.stop_button = TileGrid( + self.stop_button_bitmap, + pixel_shader=stop_button_palette, + x=x_offset, + y=y_pos + ) + + x_offset += self.BUTTON_WIDTH + self.BUTTON_SPACING + self.play_button = TileGrid( + self.play_button_bitmap, + pixel_shader=play_button_palette, + x=x_offset, + y=y_pos + ) + + x_offset += self.BUTTON_WIDTH + self.BUTTON_SPACING + self.loop_button = TileGrid( + self.loop_button_bitmap, + pixel_shader=loop_button_palette, + x=x_offset, + y=y_pos + ) + + x_offset += self.BUTTON_WIDTH + self.BUTTON_SPACING + self.clear_button = TileGrid( + self.clear_button_bitmap, + pixel_shader=clear_button_palette, + x=x_offset, + y=y_pos + ) + + # Add buttons to group + transport_group.append(self.stop_button) + transport_group.append(self.play_button) + transport_group.append(self.loop_button) + transport_group.append(self.clear_button) + + # Add "CLEAR" text to clear button + text_color = 0x000000 # Black text + label_x = self.clear_button.x + self.BUTTON_WIDTH // 2 + label_y = self.clear_button.y + self.BUTTON_HEIGHT // 2 + + clear_label = Label( + terminalio.FONT, + text="CLEAR", + color=text_color, + scale=1 + ) + clear_label.anchor_point = (0.5, 0.5) # Center the text + clear_label.anchored_position = (label_x, label_y) + transport_group.append(clear_label) + + return (transport_group, self.play_button, self.stop_button, + self.loop_button, self.clear_button) diff --git a/Fruit_Jam/Larsio_Paint_Music/cursor_manager.py b/Fruit_Jam/Larsio_Paint_Music/cursor_manager.py new file mode 100755 index 000000000..995824ed4 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/cursor_manager.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# cursor_manager.py: CircuitPython Music Staff Application component +""" + +# pylint: disable=import-error +from displayio import Bitmap, Palette, TileGrid + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes +# pylint: disable=too-many-arguments,too-many-branches,too-many-statements +class CursorManager: + """Manages cursor appearance and position""" + + def __init__(self, bg_color=0x8AAD8A): + self.bg_color = bg_color + + # Cursors + self.crosshair_cursor = None + self.triangle_cursor = None + self.current_cursor = None + + self.create_cursors() + + def create_cursors(self): + """Create custom cursor bitmaps for different areas""" + # Regular crosshair cursor for staff area + crosshair_cursor_bitmap = Bitmap(8, 8, 2) + crosshair_cursor_palette = Palette(2) + crosshair_cursor_palette[0] = self.bg_color # Background color (sage green) + crosshair_cursor_palette[1] = 0x000000 # Cursor color (black) + crosshair_cursor_palette.make_transparent(0) # Make background transparent + + for i in range(8): + crosshair_cursor_bitmap[i, 3] = 1 + crosshair_cursor_bitmap[i, 4] = 1 + crosshair_cursor_bitmap[3, i] = 1 + crosshair_cursor_bitmap[4, i] = 1 + + # Triangle cursor for controls area + triangle_cursor_bitmap = Bitmap(12, 12, 2) + triangle_cursor_palette = Palette(2) + triangle_cursor_palette[0] = 0x000000 # Background color + triangle_cursor_palette[1] = 0x000000 # Cursor color (black) + triangle_cursor_palette.make_transparent(0) # Make background transparent + + # Draw a triangle cursor + for y in range(12): + width = y // 2 + 1 # Triangle gets wider as y increases + for x in range(width): + triangle_cursor_bitmap[x, y] = 1 + + # Create a TileGrid for each cursor type + self.crosshair_cursor = TileGrid( + crosshair_cursor_bitmap, + pixel_shader=crosshair_cursor_palette + ) + self.triangle_cursor = TileGrid( + triangle_cursor_bitmap, + pixel_shader=triangle_cursor_palette + ) + + # Initially use crosshair cursor + self.current_cursor = self.crosshair_cursor + self.triangle_cursor.hidden = True + + return self.crosshair_cursor, self.triangle_cursor + + def set_cursor_position(self, x, y): + """Set the position of the current cursor""" + self.current_cursor.x = x + self.current_cursor.y = y + + def switch_cursor(self, use_triangle=False): + """Switch between crosshair and triangle cursor""" + if use_triangle and self.current_cursor != self.triangle_cursor: + self.crosshair_cursor.hidden = True + self.triangle_cursor.hidden = False + self.current_cursor = self.triangle_cursor + elif not use_triangle and self.current_cursor != self.crosshair_cursor: + self.triangle_cursor.hidden = True + self.crosshair_cursor.hidden = False + self.current_cursor = self.crosshair_cursor diff --git a/Fruit_Jam/Larsio_Paint_Music/display_manager.py b/Fruit_Jam/Larsio_Paint_Music/display_manager.py new file mode 100755 index 000000000..9699c3476 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/display_manager.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# display_manager.py: CircuitPython Music Staff Application component +""" +# pylint: disable=import-error,invalid-name,no-member,too-many-instance-attributes,too-many-arguments,too-many-branches,too-many-statements + +import displayio +import picodvi +import framebufferio +import board + + + +class DisplayManager: + """Manages the display initialization and basic display operations""" + + + def __init__(self, width=320, height=240): + self.SCREEN_WIDTH = width + self.SCREEN_HEIGHT = height + self.display = None + self.main_group = None + + def initialize_display(self): + """Initialize the DVI display""" + # Release any existing displays + displayio.release_displays() + + # Initialize the DVI framebuffer + fb = picodvi.Framebuffer(self.SCREEN_WIDTH, self.SCREEN_HEIGHT, + clk_dp=board.CKP, clk_dn=board.CKN, + red_dp=board.D0P, red_dn=board.D0N, + green_dp=board.D1P, green_dn=board.D1N, + blue_dp=board.D2P, blue_dn=board.D2N, + color_depth=16) + + # Create the display + self.display = framebufferio.FramebufferDisplay(fb) + + # Create main group + self.main_group = displayio.Group() + + # Set the display's root group + self.display.root_group = self.main_group + + return self.main_group, self.display + + def create_background(self, color=0x888888): + """Create a background with the given color""" + bg_bitmap = displayio.Bitmap(self.SCREEN_WIDTH, self.SCREEN_HEIGHT, 1) + bg_palette = displayio.Palette(1) + bg_palette[0] = color + + # Fill the bitmap with the background color + for x in range(self.SCREEN_WIDTH): + for y in range(self.SCREEN_HEIGHT): + bg_bitmap[x, y] = 0 + + # Create a TileGrid with the background bitmap + bg_grid = displayio.TileGrid(bg_bitmap, pixel_shader=bg_palette, x=0, y=0) + + return bg_grid diff --git a/Fruit_Jam/Larsio_Paint_Music/input_handler.py b/Fruit_Jam/Larsio_Paint_Music/input_handler.py new file mode 100755 index 000000000..1fb7e16d8 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/input_handler.py @@ -0,0 +1,221 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# input_handler.py: CircuitPython Music Staff Application component +""" + +import array +import time +import gc + +# pylint: disable=import-error +import usb.core + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements,broad-except +# pylint: disable=too-many-nested-blocks,too-many-locals,no-self-use +class InputHandler: + """Handles user input through mouse and interactions with UI elements""" + + def __init__(self, screen_width, screen_height, staff_y_start, staff_height): + self.SCREEN_WIDTH = screen_width + self.SCREEN_HEIGHT = screen_height + self.STAFF_Y_START = staff_y_start + self.STAFF_HEIGHT = staff_height + + # Mouse state + self.last_left_button_state = 0 + self.last_right_button_state = 0 + self.left_button_pressed = False + self.right_button_pressed = False + self.mouse = None + self.buf = None + self.in_endpoint = None + + # Mouse position + self.mouse_x = screen_width // 2 + self.mouse_y = screen_height // 2 + + def find_mouse(self): + """Find the mouse device with multiple retry attempts""" + MAX_ATTEMPTS = 5 + RETRY_DELAY = 1 # seconds + + for attempt in range(MAX_ATTEMPTS): + try: + print(f"Mouse detection attempt {attempt+1}/{MAX_ATTEMPTS}") + + # Constants for USB control transfers + DIR_OUT = 0 + # DIR_IN = 0x80 # Unused variable + REQTYPE_CLASS = 1 << 5 + REQREC_INTERFACE = 1 << 0 + HID_REQ_SET_PROTOCOL = 0x0B + + # Find all USB devices + devices_found = False + for device in usb.core.find(find_all=True): + devices_found = True + print(f"Found device: {device.idVendor:04x}:{device.idProduct:04x}") + + try: + # Try to get device info + try: + manufacturer = device.manufacturer + product = device.product + except Exception: # pylint: disable=broad-except + manufacturer = "Unknown" + product = "Unknown" + + # Just use whatever device we find + self.mouse = device + + # Try to detach kernel driver + try: + has_kernel_driver = hasattr(device, 'is_kernel_driver_active') + if has_kernel_driver and device.is_kernel_driver_active(0): + device.detach_kernel_driver(0) + except Exception as e: # pylint: disable=broad-except + print(f"Error detaching kernel driver: {e}") + + # Set configuration + try: + device.set_configuration() + except Exception as e: # pylint: disable=broad-except + print(f"Error setting configuration: {e}") + continue # Try next device + + # Just assume endpoint 0x81 (common for mice) + self.in_endpoint = 0x81 + print(f"Using mouse: {manufacturer}, {product}") + + # Set to report protocol mode + try: + bmRequestType = DIR_OUT | REQTYPE_CLASS | REQREC_INTERFACE + bRequest = HID_REQ_SET_PROTOCOL + wValue = 1 # 1 = report protocol + wIndex = 0 # First interface + + buf = bytearray(1) + device.ctrl_transfer(bmRequestType, bRequest, wValue, wIndex, buf) + print("Set to report protocol mode") + except Exception as e: # pylint: disable=broad-except + print(f"Could not set protocol: {e}") + + # Buffer for reading data + self.buf = array.array("B", [0] * 4) + print("Created 4-byte buffer for mouse data") + + # Verify mouse works by reading from it + try: + # Try to read some data with a short timeout + data = device.read(self.in_endpoint, self.buf, timeout=100) + print(f"Mouse test read successful: {data} bytes") + return True + except usb.core.USBTimeoutError: + # Timeout is normal if mouse isn't moving + print("Mouse connected but not sending data (normal)") + return True + except Exception as e: # pylint: disable=broad-except + print(f"Mouse test read failed: {e}") + # Continue to try next device or retry + self.mouse = None + self.in_endpoint = None + continue + + except Exception as e: # pylint: disable=broad-except + print(f"Error initializing device: {e}") + continue + + if not devices_found: + print("No USB devices found") + + # If we get here without returning, no suitable mouse was found + print(f"No working mouse found on attempt {attempt+1}, retrying...") + gc.collect() + time.sleep(RETRY_DELAY) + + except Exception as e: # pylint: disable=broad-except + print(f"Error during mouse detection: {e}") + gc.collect() + time.sleep(RETRY_DELAY) + + print("Failed to find a working mouse after multiple attempts") + return False + + def process_mouse_input(self): + """Process mouse input - simplified version without wheel support""" + try: + # Attempt to read data from the mouse (10ms timeout) + count = self.mouse.read(self.in_endpoint, self.buf, timeout=10) + + if count >= 3: # We need at least buttons, X and Y + # Extract mouse button states + buttons = self.buf[0] + x = self.buf[1] + y = self.buf[2] + + # Convert to signed values if needed + if x > 127: + x = x - 256 + if y > 127: + y = y - 256 + + # Extract button states + current_left_button_state = buttons & 0x01 + current_right_button_state = (buttons & 0x02) >> 1 + + # Detect button presses + if current_left_button_state == 1 and self.last_left_button_state == 0: + self.left_button_pressed = True + else: + self.left_button_pressed = False + + if current_right_button_state == 1 and self.last_right_button_state == 0: + self.right_button_pressed = True + else: + self.right_button_pressed = False + + # Update button states + self.last_left_button_state = current_left_button_state + self.last_right_button_state = current_right_button_state + + # Update position + self.mouse_x += x + self.mouse_y += y + + # Ensure position stays within bounds + self.mouse_x = max(0, min(self.SCREEN_WIDTH - 1, self.mouse_x)) + self.mouse_y = max(0, min(self.SCREEN_HEIGHT - 1, self.mouse_y)) + + return True + + return False + + except usb.core.USBError as e: + # Handle timeouts silently + if e.errno == 110: # Operation timed out + return False + + # Handle disconnections + if e.errno == 19: # No such device + print("Mouse disconnected") + self.mouse = None + self.in_endpoint = None + gc.collect() + + return False + except Exception as e: # pylint: disable=broad-except + print(f"Error reading mouse: {type(e).__name__}") + return False + + def point_in_rect(self, x, y, rect_x, rect_y, rect_width, rect_height): + """Check if a point is inside a rectangle""" + return (rect_x <= x < rect_x + rect_width and + rect_y <= y < rect_y + rect_height) + + def is_over_staff(self, y): + """Check if mouse is over the staff area""" + return self.STAFF_Y_START <= y <= self.STAFF_Y_START + self.STAFF_HEIGHT diff --git a/Fruit_Jam/Larsio_Paint_Music/lpm_icon.bmp b/Fruit_Jam/Larsio_Paint_Music/lpm_icon.bmp new file mode 100644 index 000000000..864c33fa0 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/lpm_icon.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/metadata.json b/Fruit_Jam/Larsio_Paint_Music/metadata.json new file mode 100644 index 000000000..338fedaba --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/metadata.json @@ -0,0 +1,4 @@ +{ + "title": "LarsioPant", + "icon": "lpm_icon.bmp" +} diff --git a/Fruit_Jam/Larsio_Paint_Music/note_manager.py b/Fruit_Jam/Larsio_Paint_Music/note_manager.py new file mode 100755 index 000000000..9a6a9dad3 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/note_manager.py @@ -0,0 +1,425 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# note_manager.py: CircuitPython Music Staff Application component +""" + +# pylint: disable=import-error +from displayio import Group, Bitmap, Palette, TileGrid + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements,protected-access,too-many-locals +# pylint: disable=trailing-whitespace +class NoteManager: + """Manages notes, their positions, and related data""" + + def __init__(self, start_margin, staff_y_start, line_spacing): + self.note_data = [] # List of (x_position, y_position, midi_note, midi_channel) + self.notes_group = Group() + self.ledger_lines_group = Group() + self.note_to_ledger = {} # Mapping from note indices to ledger line indices + + # Key staff parameters + self.START_MARGIN = start_margin + self.STAFF_Y_START = staff_y_start + self.LINE_SPACING = line_spacing + + # Note positions and their MIDI values + self.note_positions = self._create_note_positions() + self.x_positions = [] # Will be populated by the UI manager + + # Create note bitmaps + self.NOTE_WIDTH = (line_spacing // 2) - 2 + self.NOTE_HEIGHT = (line_spacing // 2) - 2 + self.note_bitmap = self._create_note_bitmap() + + # Create ledger line bitmap + self.ledger_line_width = 14 + self.ledger_line_height = 2 + self.ledger_bitmap = Bitmap(self.ledger_line_width, self.ledger_line_height, 2) + for x in range(self.ledger_line_width): + for y in range(self.ledger_line_height): + self.ledger_bitmap[x, y] = 1 + + self.ledger_palette = Palette(2) + self.ledger_palette[0] = 0x8AAD8A # Transparent (sage green background) + self.ledger_palette[1] = 0x000000 # Black for ledger lines + + # MIDI note mapping for each position + self.midi_notes = { + 0: 59, # B3 + 1: 60, # C4 (middle C) + 2: 62, # D4 + 3: 64, # E4 + 4: 65, # F4 + 5: 67, # G4 + 6: 69, # A4 + 7: 71, # B4 + 8: 72, # C5 + 9: 74, # D5 + 10: 76, # E5 + 11: 77, # F5 + 12: 79 # G5 + } + + # Map of positions to note names (for treble clef) + self.note_names = { + 0: "B3", # B below middle C (ledger line) + 1: "C4", # Middle C (ledger line below staff) + 2: "D4", # Space below staff + 3: "E4", # Bottom line + 4: "F4", # First space + 5: "G4", # Second line + 6: "A4", # Second space + 7: "B4", # Middle line + 8: "C5", # Third space + 9: "D5", # Fourth line + 10: "E5", # Fourth space + 11: "F5", # Top line + 12: "G5" # Space above staff + } + + def _create_note_positions(self): + """Create the vertical positions for notes on the staff""" + note_positions = [] + + # Calculate positions from the bottom up + bottom_line_y = self.STAFF_Y_START + 5 * self.LINE_SPACING # Bottom staff line (E) + + # B3 (ledger line below staff) + note_positions.append(bottom_line_y + self.LINE_SPACING + self.LINE_SPACING // 2) + + # Middle C4 (ledger line below staff) + note_positions.append(bottom_line_y + self.LINE_SPACING) + + # D4 (space below staff) + note_positions.append(bottom_line_y + self.LINE_SPACING // 2) + + # E4 (bottom line) + note_positions.append(bottom_line_y) + + # F4 (first space) + note_positions.append(bottom_line_y - self.LINE_SPACING // 2) + + # G4 (second line) + note_positions.append(bottom_line_y - self.LINE_SPACING) + + # A4 (second space) + note_positions.append(bottom_line_y - self.LINE_SPACING - self.LINE_SPACING // 2) + + # B4 (middle line) + note_positions.append(bottom_line_y - 2 * self.LINE_SPACING) + + # C5 (third space) + note_positions.append(bottom_line_y - 2 * self.LINE_SPACING - self.LINE_SPACING // 2) + + # D5 (fourth line) + note_positions.append(bottom_line_y - 3 * self.LINE_SPACING) + + # E5 (fourth space) + note_positions.append(bottom_line_y - 3 * self.LINE_SPACING - self.LINE_SPACING // 2) + + # F5 (top line) + note_positions.append(bottom_line_y - 4 * self.LINE_SPACING) + + # G5 (space above staff) + note_positions.append(bottom_line_y - 4 * self.LINE_SPACING - self.LINE_SPACING // 2) + + return note_positions + + def _create_note_bitmap(self): + """Create a bitmap for a quarter note (circular shape)""" + note_bitmap = Bitmap(self.NOTE_WIDTH, self.NOTE_HEIGHT, 2) + + # Draw a circular shape for the note head + cx = self.NOTE_WIDTH // 2 + cy = self.NOTE_HEIGHT // 2 + radius = self.NOTE_WIDTH // 2 + + for y in range(self.NOTE_HEIGHT): + for x in range(self.NOTE_WIDTH): + # Use the circle equation (x-cx)² + (y-cy)² ≤ r² to determine if pixel is in circle + if ((x - cx) ** 2 + (y - cy) ** 2) <= (radius ** 2): + note_bitmap[x, y] = 1 + + return note_bitmap + + def find_closest_position(self, y): + """Find the closest valid note position to a given y-coordinate""" + closest_pos = 0 + min_distance = abs(y - self.note_positions[0]) + + for i, pos in enumerate(self.note_positions): + distance = abs(y - pos) + if distance < min_distance: + min_distance = distance + closest_pos = i + + return closest_pos + + def find_closest_x_position(self, x): + """Find the closest valid horizontal position""" + # Only allow positions after the double bar at beginning + if x < self.START_MARGIN: + return self.x_positions[0] # Return first valid position + + closest_x = self.x_positions[0] + min_distance = abs(x - closest_x) + + for pos in self.x_positions: + distance = abs(x - pos) + if distance < min_distance: + min_distance = distance + closest_x = pos + + return closest_x + + def note_exists_at_position(self, x_pos, y_pos, mario_head, mario_palette): + """Check if a note exists at the exact position (for adding new notes)""" + # Only check for exact overlap, not proximity + for note_tg in self.notes_group: + # Check if this is a Mario head note or a regular note + is_mario = (hasattr(note_tg.pixel_shader, "_palette") and + len(note_tg.pixel_shader._palette) > 1 and + note_tg.pixel_shader._palette[0] == mario_palette[0]) + + if is_mario: + note_width = mario_head.width + note_height = mario_head.height + else: + note_width = self.NOTE_WIDTH + note_height = self.NOTE_HEIGHT + + note_x = note_tg.x + note_width // 2 + note_y = note_tg.y + note_height // 2 + + # Only prevent notes from being in the exact same position (with a tiny tolerance) + if abs(note_x - x_pos) < 2 and abs(note_y - y_pos) < 2: + return True + return False + + def find_note_at(self, x, y, mario_head, mario_palette): + """Check if a note already exists at a position and return its index""" + for i, note_tg in enumerate(self.notes_group): + # Check if this is a Mario head note or a regular note + is_mario = (hasattr(note_tg.pixel_shader, "_palette") and + len(note_tg.pixel_shader._palette) > 1 and + note_tg.pixel_shader._palette[0] == mario_palette[0]) + + if is_mario: + note_width = mario_head.width + note_height = mario_head.height + else: + note_width = self.NOTE_WIDTH + note_height = self.NOTE_HEIGHT + + # Check if the note's center is within a reasonable distance of the cursor + note_center_x = note_tg.x + note_width // 2 + note_center_y = note_tg.y + note_height // 2 + + # Use a slightly larger hit box for easier clicking + hit_box_width = max(self.NOTE_WIDTH, note_width) + hit_box_height = max(self.NOTE_HEIGHT, note_height) + + if (abs(x-note_center_x) < hit_box_width) and (abs(y - note_center_y) < hit_box_height): + return i + return None + + def add_note( + self, + x, + y, + current_channel, + note_palettes, + mario_head, + mario_palette, + heart_note, + heart_palette, + sound_manager + ): + """Add a note at the specified position""" + # Enforce the minimum x position (after the double bar at beginning) + if x < self.START_MARGIN: + return (False, "Notes must be after the double bar") + + # Find the closest valid position + position_index = self.find_closest_position(y) + y_position = self.note_positions[position_index] + + # Find the closest valid horizontal position + x_position = self.find_closest_x_position(x) + + # Check if a note already exists at this exact position + if self.note_exists_at_position(x_position, y_position, mario_head, mario_palette): + return (False, "Note already exists here") + + # Get the corresponding MIDI note number + midi_note = self.midi_notes[position_index] + + # Create a TileGrid for the note based on channel + if current_channel == 0: # Channel 1 (index 0) uses Mario head + note_tg = TileGrid(mario_head, pixel_shader=mario_palette) + # Adjust position offset based on the size of mario_head bitmap + note_width = mario_head.width + note_height = mario_head.height + note_tg.x = x_position - note_width // 2 + note_tg.y = y_position - note_height // 2 + elif current_channel == 1: # Channel 2 uses Heart note + note_tg = TileGrid(heart_note, pixel_shader=heart_palette) + # Adjust position offset based on the size of heart_note bitmap + note_width = heart_note.width + note_height = heart_note.height + note_tg.x = x_position - note_width // 2 + note_tg.y = y_position - note_height // 2 + elif current_channel == 2: # Channel 3 uses Drum note + note_tg = TileGrid(mario_head, pixel_shader=mario_palette) + # Adjust position offset based on the size + note_width = mario_head.width + note_height = mario_head.height + note_tg.x = x_position - note_width // 2 + note_tg.y = y_position - note_height // 2 + elif current_channel in (3, 4, 5): # Channels 4-6 use custom sprites + # We'll pass appropriate sprites in ui_manager + note_tg = TileGrid(mario_head, pixel_shader=mario_palette) + note_width = mario_head.width + note_height = mario_head.height + note_tg.x = x_position - note_width // 2 + note_tg.y = y_position - note_height // 2 + else: # Other channels use the colored circle + note_tg = TileGrid(self.note_bitmap, pixel_shader=note_palettes[current_channel]) + note_tg.x = x_position - self.NOTE_WIDTH // 2 + note_tg.y = y_position - self.NOTE_HEIGHT // 2 + + # Play the appropriate sound + sound_manager.play_note(midi_note, current_channel) + + # Add the note to the notes group + note_index = len(self.notes_group) + self.notes_group.append(note_tg) + + # Store the note data for playback with channel information + self.note_data.append((x_position, y_position, midi_note, current_channel)) + + # Add a ledger line if it's the B3 or C4 below staff + if position_index <= 1: # B3 or C4 + ledger_tg = TileGrid(self.ledger_bitmap, pixel_shader=self.ledger_palette) + ledger_tg.x = x_position - self.ledger_line_width // 2 + ledger_tg.y = y_position + ledger_index = len(self.ledger_lines_group) + self.ledger_lines_group.append(ledger_tg) + + # Track association between note and its ledger line + self.note_to_ledger[note_index] = ledger_index + + note_name = self.note_names[position_index] + return (True, f"Added: Ch{current_channel+1} {note_name}") + + def erase_note(self, x, y, mario_head, mario_palette, sound_manager=None): + """Erase a note at the clicked position""" + # Try to find a note at the click position + note_index = self.find_note_at(x, y, mario_head, mario_palette) + + if note_index is not None: + # Get the position of the note + note_tg = self.notes_group[note_index] + + # Check if this is a Mario head note or a regular note + is_mario = (hasattr(note_tg.pixel_shader, "_palette") and + len(note_tg.pixel_shader._palette) > 1 and + note_tg.pixel_shader._palette[0] == mario_palette[0]) + + if is_mario: + note_width = mario_head.width + note_height = mario_head.height + else: + note_width = self.NOTE_WIDTH + note_height = self.NOTE_HEIGHT + + note_x = note_tg.x + note_width // 2 + note_y = note_tg.y + note_height // 2 + + # Find the corresponding note data + found_data_index = None + # found_channel = None # Unused variable + + for i, (x_pos, y_pos, _midi_note, _channel) in enumerate(self.note_data): + # Increased tolerance for position matching + if abs(x_pos - note_x) < 5 and abs(y_pos - note_y) < 5: + found_data_index = i + break + + # If we found the note data and have a sound manager reference + if found_data_index is not None and sound_manager is not None: + # Extract note data + x_pos, y_pos, _midi_note, channel = self.note_data[found_data_index] + + # If this is a sample-based note (channels 0, 1, or 2), stop it + if channel in [0, 1, 2]: + sound_manager.stop_sample_at_position(x_pos, y_pos, channel) + + # Remove the note data + self.note_data.pop(found_data_index) + print(f"Erased note at position ({x_pos}, {y_pos}) ch {channel+1}") + else: + # Still remove the note data if found (for backward compatibility) + if found_data_index is not None: + self.note_data.pop(found_data_index) + + # Check if this note has an associated ledger line + if note_index in self.note_to_ledger: + ledger_index = self.note_to_ledger[note_index] + + # Remove the ledger line + self.ledger_lines_group.pop(ledger_index) + + # Update ledger line mappings after removing a ledger line + new_note_to_ledger = {} + + # Process each mapping + for n_idx, l_idx in self.note_to_ledger.items(): + # Skip the note we're removing + if n_idx != note_index: + # Adjust indices for ledger lines after the removed one + if l_idx > ledger_index: + new_note_to_ledger[n_idx] = l_idx - 1 + else: + new_note_to_ledger[n_idx] = l_idx + + self.note_to_ledger = new_note_to_ledger + + # Remove the note + self.notes_group.pop(note_index) + + # Update mappings for notes with higher indices + new_note_to_ledger = {} + for n_idx, l_idx in self.note_to_ledger.items(): + if n_idx > note_index: + new_note_to_ledger[n_idx - 1] = l_idx + else: + new_note_to_ledger[n_idx] = l_idx + + self.note_to_ledger = new_note_to_ledger + + return (True, "Note erased") + + return (False, "No note found at this position") + + def clear_all_notes(self, sound_manager=None): + """Clear all notes from the staff""" + # Stop all sample playback if we have a sound manager + if sound_manager is not None: + sound_manager.stop_all_notes() + + # Remove all notes + while len(self.notes_group) > 0: + self.notes_group.pop() + + # Remove all ledger lines + while len(self.ledger_lines_group) > 0: + self.ledger_lines_group.pop() + + # Clear note data and ledger line mappings + self.note_data = [] + self.note_to_ledger = {} diff --git a/Fruit_Jam/Larsio_Paint_Music/playback_controller.py b/Fruit_Jam/Larsio_Paint_Music/playback_controller.py new file mode 100755 index 000000000..f6810ae3e --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/playback_controller.py @@ -0,0 +1,143 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +Playback controller for CircuitPython Music Staff Application. +Manages the playback state, button displays, and sound triggering. +""" + +import time + +# pylint: disable=trailing-whitespace, too-many-instance-attributes +class PlaybackController: + """Manages playback state and controls""" + + def __init__(self, sound_manager, note_manager, seconds_per_eighth=0.25): + """Initialize the playback controller with sound and note managers""" + self.sound_manager = sound_manager + self.note_manager = note_manager + self.seconds_per_eighth = seconds_per_eighth + + # Playback state + self.is_playing = False + self.playhead_position = -1 + self.last_playhead_time = 0 + self.loop_enabled = False + + # UI elements (to be set externally) + self.playhead = None + self.play_button = None + self.play_button_bitmap = None + self.stop_button = None + self.stop_button_bitmap = None + + # Button sprites (will be set in set_ui_elements) + self.button_sprites = None + + def set_ui_elements(self, playhead, play_button, stop_button, button_sprites=None): + """Set references to UI elements needed for playback control""" + self.playhead = playhead + self.play_button = play_button + self.stop_button = stop_button + self.button_sprites = button_sprites + + def start_playback(self, start_margin=25): + """Start playback""" + self.is_playing = True + self.playhead_position = -1 # Start at -1 so first note plays immediately + self.last_playhead_time = time.monotonic() + + # Set playhead position to just before the first note + self.playhead.x = start_margin - 5 + + # Update button states using bitmaps + if hasattr(self, 'button_sprites') and self.button_sprites is not None: + # Update play button to "down" state + self.play_button.bitmap = self.button_sprites['play']['down'][0] + self.play_button.pixel_shader = self.button_sprites['play']['down'][1] + + # Update stop button to "up" state + self.stop_button.bitmap = self.button_sprites['stop']['up'][0] + self.stop_button.pixel_shader = self.button_sprites['stop']['up'][1] + else: + # Fallback implementation for drawn buttons + # Note: This section is for backward compatibility but has issues + # Ideally, button_sprites should always be provided + print("Warning: Using fallback button display (not fully supported)") + # The fallback code is intentionally omitted as it has errors + # and requires refactoring of the bitmap handling + + print("Playback started") + + def stop_playback(self): + """Stop playback""" + self.sound_manager.stop_all_notes() + self.is_playing = False + self.playhead.x = -10 # Move off-screen + + # Update button states using bitmaps + if hasattr(self, 'button_sprites') and self.button_sprites is not None: + # Update play button to "up" state + self.play_button.bitmap = self.button_sprites['play']['up'][0] + self.play_button.pixel_shader = self.button_sprites['play']['up'][1] + + # Update stop button to "down" state + self.stop_button.bitmap = self.button_sprites['stop']['down'][0] + self.stop_button.pixel_shader = self.button_sprites['stop']['down'][1] + else: + # Fallback implementation for drawn buttons + # Note: This section is for backward compatibility but has issues + # Ideally, button_sprites should always be provided + print("Warning: Using fallback button display (not fully supported)") + # The fallback code is intentionally omitted as it has errors + # and requires refactoring of the bitmap handling + + print("Playback stopped") + + def set_tempo(self, seconds_per_eighth): + """Update the playback tempo""" + self.seconds_per_eighth = seconds_per_eighth + print(f"Playback tempo updated: {60 / (seconds_per_eighth * 2)} BPM") + + def update_playback(self, x_positions): + """Update playback state and play notes at current position""" + if not self.is_playing: + return + + current_time = time.monotonic() + elapsed = current_time - self.last_playhead_time + + # Move at tempo rate + if elapsed >= self.seconds_per_eighth: + # Stop all current active notes + self.sound_manager.stop_all_notes() + + # Move playhead to next eighth note position + self.playhead_position += 1 + self.last_playhead_time = current_time + + # Check if we've reached the end + if self.playhead_position >= len(x_positions): + if self.loop_enabled: + # Loop back to the beginning + self.playhead_position = 0 + self.playhead.x = x_positions[0] - 1 + else: + # Stop playback if not looping + self.stop_playback() + return + + # Update playhead position + self.playhead.x = x_positions[self.playhead_position] - 1 + + # Find all notes at current playhead position + current_x = x_positions[self.playhead_position] + notes_at_position = [] + + for x_pos, y_pos, midi_note, channel in self.note_manager.note_data: + if abs(x_pos - current_x) < 2: # Note is at current position + notes_at_position.append((x_pos, y_pos, midi_note, channel)) + + # Play all notes at the current position + if notes_at_position: + self.sound_manager.play_notes_at_position(notes_at_position) diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/chat_01.wav b/Fruit_Jam/Larsio_Paint_Music/samples/chat_01.wav new file mode 100755 index 000000000..f4606c333 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/chat_01.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/crash_01.wav b/Fruit_Jam/Larsio_Paint_Music/samples/crash_01.wav new file mode 100755 index 000000000..3d0c64acb Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/crash_01.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/kick_01.wav b/Fruit_Jam/Larsio_Paint_Music/samples/kick_01.wav new file mode 100755 index 000000000..f404cf804 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/kick_01.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_A4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_A4.wav new file mode 100755 index 000000000..0e8f56621 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_A4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_B3.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_B3.wav new file mode 100755 index 000000000..92331cc65 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_B3.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_B4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_B4.wav new file mode 100755 index 000000000..b71c7f80e Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_B4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_C4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_C4.wav new file mode 100755 index 000000000..f0ca34ec1 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_C4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_C5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_C5.wav new file mode 100755 index 000000000..da435ddc6 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_C5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_D4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_D4.wav new file mode 100755 index 000000000..e9120da70 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_D4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_D5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_D5.wav new file mode 100755 index 000000000..872406794 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_D5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_E4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_E4.wav new file mode 100755 index 000000000..151a141ed Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_E4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_E5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_E5.wav new file mode 100755 index 000000000..8d8c45c19 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_E5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_F4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_F4.wav new file mode 100755 index 000000000..419c8e4bb Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_F4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_F5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_F5.wav new file mode 100755 index 000000000..7fd14ff10 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_F5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_G4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_G4.wav new file mode 100755 index 000000000..eab079aa1 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_G4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/larso_G5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/larso_G5.wav new file mode 100755 index 000000000..22cdae793 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/larso_G5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_A4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_A4.wav new file mode 100755 index 000000000..4173e55c4 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_A4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B3.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B3.wav new file mode 100755 index 000000000..da6d8f84d Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B3.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B4.wav new file mode 100755 index 000000000..7a2ea264a Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C4.wav new file mode 100755 index 000000000..b870ac3c4 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C5.wav new file mode 100755 index 000000000..50febe988 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D4.wav new file mode 100755 index 000000000..843ada638 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D5.wav new file mode 100755 index 000000000..f43f9a1e3 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E4.wav new file mode 100755 index 000000000..609095e70 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E5.wav new file mode 100755 index 000000000..561b45931 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F4.wav new file mode 100755 index 000000000..081be3cb2 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F5.wav new file mode 100755 index 000000000..96b4b6640 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G4.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G4.wav new file mode 100755 index 000000000..31f19a0e1 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G4.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G5.wav b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G5.wav new file mode 100755 index 000000000..7a0b5fd8c Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G5.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/ohat_01.wav b/Fruit_Jam/Larsio_Paint_Music/samples/ohat_01.wav new file mode 100755 index 000000000..3c41e6a3a Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/ohat_01.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/samples/snare_01.wav b/Fruit_Jam/Larsio_Paint_Music/samples/snare_01.wav new file mode 100755 index 000000000..004be30a8 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/samples/snare_01.wav differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sound_manager.py b/Fruit_Jam/Larsio_Paint_Music/sound_manager.py new file mode 100755 index 000000000..5746f873b --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/sound_manager.py @@ -0,0 +1,613 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# sound_manager.py: CircuitPython Music Staff Application component +""" +# pylint: disable=import-error, trailing-whitespace +# +import math +import time +import array +import gc +import os +import digitalio +import busio + + +import adafruit_midi +import audiocore +import audiopwmio +import audiobusio +import audiomixer +import synthio +import board +import adafruit_tlv320 +from adafruit_midi.note_on import NoteOn +from adafruit_midi.note_off import NoteOff +import usb_midi + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements,too-many-locals,broad-except +# pylint: disable=cell-var-from-loop,undefined-loop-variable +class SoundManager: + """Handles playback of both MIDI notes and WAV samples, and synthio for channels 3-5""" + + def __init__(self, audio_output="pwm", seconds_per_eighth=0.25): + """ + Initialize the sound manager + + Parameters: + audio_output (str): The type of audio output to use - "pwm" or "i2s" + seconds_per_eighth (float): Duration of an eighth note in seconds + """ + # Initialize USB MIDI + self.midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) + self.active_notes = {} # {note_number: channel} + + # Store timing information + self.seconds_per_eighth = seconds_per_eighth + + # Initialize audio output based on selected type + self.audio_output_type = audio_output + self.tlv = None + + # Initialize these variables to avoid use-before-assignment issues + i2c = None + bclck_pin = None + wsel_pin = None + din_pin = None + + if self.audio_output_type == "pwm": + # Setup PWM audio output on D10 + self.audio = audiopwmio.PWMAudioOut(board.D10) + else: # i2s + try: + # Import libraries needed for I2S + #check for Metro RP2350 vs. Fruit Jam + board_type = os.uname().machine + + if 'Metro RP2350' in board_type: + print("Metro setup") + reset_pin = digitalio.DigitalInOut(board.D7) + reset_pin.direction = digitalio.Direction.OUTPUT + reset_pin.value = False # Set low to reset + time.sleep(0.1) # Pause 100ms + reset_pin.value = True # Set high to release from reset + + i2c = board.STEMMA_I2C() # initialize I2C + + bclck_pin = board.D9 + wsel_pin = board.D10 + din_pin = board.D11 + + elif 'Fruit Jam' in board_type: + print("Fruit Jam setup") + reset_pin = digitalio.DigitalInOut(board.PERIPH_RESET) + reset_pin.direction = digitalio.Direction.OUTPUT + reset_pin.value = False + time.sleep(0.1) + reset_pin.value = True + + i2c = busio.I2C(board.SCL, board.SDA) + + bclck_pin = board.I2S_BCLK + wsel_pin = board.I2S_WS + din_pin = board.I2S_DIN + + # Initialize TLV320 + self.tlv = adafruit_tlv320.TLV320DAC3100(i2c) + self.tlv.configure_clocks(sample_rate=11025, bit_depth=16) + self.tlv.headphone_output = True + self.tlv.headphone_volume = -15 # dB + + # Setup I2S audio output - important to do this AFTER configuring the DAC + self.audio = audiobusio.I2SOut( + bit_clock=bclck_pin, + word_select=wsel_pin, + data=din_pin + ) + + print("TLV320 I2S DAC initialized successfully") + except Exception as e: + print(f"Error initializing TLV320 DAC: {e}") + print("Falling back to PWM audio output") + # Fallback to PWM if I2S initialization fails + self.audio = audiopwmio.PWMAudioOut(board.D10) + + # Create an audio mixer with multiple voices + self.mixer = audiomixer.Mixer( + voice_count=6, + sample_rate=11025, + channel_count=1, + bits_per_sample=16, + samples_signed=True + ) + self.audio.play(self.mixer) + + # Track which voices are being used for samples + # First 3 for regular samples, next 3 for playback-only + self.active_voices = [False, False, False, False, False, False] + + # Track which note position corresponds to which voice + # This will help us stop samples when notes are erased + self.position_to_voice = {} # {(x_pos, y_pos): voice_index} + + # Track which voice is used for which channel during playback + self.playback_voice_mapping = {} # {(x_pos, y_pos, channel): voice_index} + + # Load multiple WAV samples at different pitches + try: + # Channel 1 samples + self.samples = { + 59: audiocore.WaveFile("/samples/larso_B3.wav"), # B3 + 60: audiocore.WaveFile("/samples/larso_C4.wav"), # C4 + 62: audiocore.WaveFile("/samples/larso_D4.wav"), # D4 + 64: audiocore.WaveFile("/samples/larso_E4.wav"), # E4 + 65: audiocore.WaveFile("/samples/larso_F4.wav"), # F4 + 67: audiocore.WaveFile("/samples/larso_G4.wav"), # G4 + 69: audiocore.WaveFile("/samples/larso_A4.wav"), # A4 + 71: audiocore.WaveFile("/samples/larso_B4.wav"), # B4 + 72: audiocore.WaveFile("/samples/larso_C5.wav"), # C5 + 74: audiocore.WaveFile("/samples/larso_D5.wav"), # D5 + 76: audiocore.WaveFile("/samples/larso_E5.wav"), # E5 + 77: audiocore.WaveFile("/samples/larso_F5.wav"), # F5 + 79: audiocore.WaveFile("/samples/larso_G5.wav"), # G5 + } + print("Loaded channel 1 WAV samples") + + # Load samples for channel 2 + self.heart_samples = { + 59: audiocore.WaveFile("/samples/musicnote16_B3.wav"), # B3 + 60: audiocore.WaveFile("/samples/musicnote16_C4.wav"), # C4 + 62: audiocore.WaveFile("/samples/musicnote16_D4.wav"), # D4 + 64: audiocore.WaveFile("/samples/musicnote16_E4.wav"), # E4 + 65: audiocore.WaveFile("/samples/musicnote16_F4.wav"), # F4 + 67: audiocore.WaveFile("/samples/musicnote16_G4.wav"), # G4 + 69: audiocore.WaveFile("/samples/musicnote16_A4.wav"), # A4 + 71: audiocore.WaveFile("/samples/musicnote16_B4.wav"), # B4 + 72: audiocore.WaveFile("/samples/musicnote16_C5.wav"), # C5 + 74: audiocore.WaveFile("/samples/musicnote16_D5.wav"), # D5 + 76: audiocore.WaveFile("/samples/musicnote16_E5.wav"), # E5 + 77: audiocore.WaveFile("/samples/musicnote16_F5.wav"), # F5 + 79: audiocore.WaveFile("/samples/musicnote16_G5.wav"), # G5 + } + print("Loaded channel 2 WAV samples") + + # Load samples for channel 3 (drum samples) + self.drum_samples = {} + try: + self.drum_samples = { + 59: audiocore.WaveFile("/samples/kick_01.wav"), + 60: audiocore.WaveFile("/samples/kick_01.wav"), + 62: audiocore.WaveFile("/samples/kick_01.wav"), + 64: audiocore.WaveFile("/samples/snare_01.wav"), + 65: audiocore.WaveFile("/samples/snare_01.wav"), + 67: audiocore.WaveFile("/samples/snare_01.wav"), + 69: audiocore.WaveFile("/samples/chat_01.wav"), + 71: audiocore.WaveFile("/samples/chat_01.wav"), + 72: audiocore.WaveFile("/samples/chat_01.wav"), + 74: audiocore.WaveFile("/samples/ohat_01.wav"), + 76: audiocore.WaveFile("/samples/ohat_01.wav"), + 77: audiocore.WaveFile("/samples/crash_01.wav"), + 79: audiocore.WaveFile("/samples/crash_01.wav"), + } + print("Loaded channel 3 WAV samples (drums)") + except Exception as e: + print(f"Error loading drum samples: {e}") + # Fallback - use the same samples as channel 1 + self.drum_samples = self.samples + print("Using fallback samples for channel 3") + + except Exception as e: + print(f"Error loading WAV samples: {e}") + # Fallback to basic samples if there's an error + self.samples = { + 65: audiocore.WaveFile("/samples/musicnote01.wav"), # Default sample + } + self.heart_samples = self.samples # Use same samples as fallback + self.drum_samples = self.samples # Use same samples as fallback + + # Initialize synthio for channels 4-6 + self.synth = synthio.Synthesizer(sample_rate=11025) + # Use the last voice for synthio + self.mixer.voice[5].play(self.synth) + + # Set lower volume for synthio channel + self.mixer.voice[5].level = 0.3 + + # Create waveforms for different synthio channels + SAMPLE_SIZE = 512 + SAMPLE_VOLUME = 30000 # Slightly lower to avoid overflow + half_period = SAMPLE_SIZE // 2 + + # Sine wave for channel 4 + self.wave_sine = array.array("h", [0] * SAMPLE_SIZE) + for i in range(SAMPLE_SIZE): + # Use max() and min() to ensure we stay within bounds + value = int(math.sin(math.pi * 2 * (i/2) / SAMPLE_SIZE) * SAMPLE_VOLUME) + self.wave_sine[i] = max(-32768, min(32767, value)) + + # Triangle wave for channel 5 + self.wave_tri = array.array("h", [0] * SAMPLE_SIZE) + for i in range(SAMPLE_SIZE): + if i < half_period: + value = int(((i / (half_period)) * 2 - 1) * SAMPLE_VOLUME) + else: + value = int(((2 - (i / (half_period)) * 2)) * SAMPLE_VOLUME) + self.wave_tri[i] = max(-32768, min(32767, value)) + + # Sawtooth wave for channel 6 + self.wave_saw = array.array("h", [0] * SAMPLE_SIZE) + for i in range(SAMPLE_SIZE): + value = int(((i / SAMPLE_SIZE) * 2 - 1) * SAMPLE_VOLUME) + self.wave_saw[i] = max(-32768, min(32767, value)) + + # Map channels to waveforms + self.channel_waveforms = { + 3: self.wave_sine, # Channel 4: Sine wave (soft, pure tone) + 4: self.wave_tri, # Channel 5: Triangle wave (mellow, soft) + 5: self.wave_saw, # Channel 6: Sawtooth wave (brassy, sharp) + } + + # Set different amplitudes for each waveform to balance volumes + self.channel_amplitudes = { + 3: 1.0, # Sine wave - normal volume + 4: 0.8, # Triangle wave - slightly quieter + 5: 0.3, # Sawtooth wave - much quieter (harmonically rich) + } + + # Track active synth notes by channel and note + self.active_synth_notes = { + 3: [], # Channel 4 + 4: [], # Channel 5 + 5: [], # Channel 6 + } + + # Variables for timed release of preview notes + self.note_release_time = 0 + self.note_to_release = None + self.note_to_release_channel = None + self.preview_mode = False + + def play_note(self, midi_note, channel): + """Play a note using either MIDI, WAV, or synthio based on channel""" + if channel == 0: # Channel 1 uses WAV samples + self.play_multi_sample(midi_note, channel) + elif channel == 1: # Channel 2 uses Heart note WAV samples + self.play_multi_sample(midi_note, channel) + elif channel == 2: # Channel 3 uses Drum WAV samples + self.play_multi_sample(midi_note, channel) + elif channel in [3, 4, 5]: # Channels 4-6 use synthio with different waveforms + self.preview_mode = True + self.play_synth_note(midi_note, channel) + # Schedule note release + self.note_release_time = time.monotonic() + self.seconds_per_eighth + self.note_to_release_channel = channel + else: + # Send note on the correct MIDI channel (channels are 0-based in adafruit_midi) + self.midi.send(NoteOn(midi_note, 100), channel=channel) + # Store note with its channel for proper Note Off later + self.active_notes[midi_note] = channel + # print(f"Playing note: {midi_note} on channel {channel + 1}") + + def play_notes_at_position(self, notes_data): + """Play all notes at a specific position simultaneously""" + # Stop all sample voices first + for i in range(5): # Use first 5 voices for WAV samples (0-4) + self.mixer.voice[i].stop() + self.active_voices[i] = False + + # Clear the position to voice mapping + self.position_to_voice = {} + self.playback_voice_mapping = {} + + # Group notes by channel type + sample_notes = { + 0: [], # Channel 1 (Lars WAV samples) + 1: [], # Channel 2 (Heart WAV samples) + 2: [] # Channel 3 (Drum WAV samples) + } + + # Synthio channels (4-6) + synth_notes = { + 3: [], # Channel 4 (Sine wave) + 4: [], # Channel 5 (Triangle wave) + 5: [], # Channel 6 (Sawtooth wave) + } + + midi_notes = {} # Other channels (MIDI) + + for x_pos, y_pos, note_val, channel in notes_data: + if channel in [0, 1, 2]: # Sample-based channels + sample_notes[channel].append((x_pos, y_pos, note_val)) + elif channel in [3, 4, 5]: # Synthio channels + synth_notes[channel].append(note_val) + else: # Other channels (MIDI) + midi_notes[note_val] = channel + + # Voice allocation - we have 5 voices to distribute among sample notes + remaining_voices = 5 + voice_index = 0 + + # Play sample notes for channels 1-3 + for channel, notes in sample_notes.items(): + for x_pos, y_pos, midi_note in notes: + if remaining_voices <= 0: + print(f"Warning: No more voices available for channel {channel+1}") + break + + # Get the appropriate sample set + sample_set = None + if channel == 0: + sample_set = self.samples + elif channel == 1: + sample_set = self.heart_samples + elif channel == 2: + sample_set = self.drum_samples + + # Find the closest sample + closest_note = min(sample_set.keys(), key=lambda x: abs(x - midi_note)) + sample = sample_set[closest_note] + + # Play the sample + self.mixer.voice[voice_index].play(sample, loop=False) + self.active_voices[voice_index] = True + + # Store the position to voice mapping + position_key = (x_pos, y_pos) + self.position_to_voice[position_key] = voice_index + self.playback_voice_mapping[(x_pos, y_pos, channel)] = voice_index + + # Adjust volume + total_notes = sum(len(notes) for notes in sample_notes.values()) + volume_factor = 0.9 if total_notes <= 3 else 0.7 if total_notes <= 6 else 0.5 + self.mixer.voice[voice_index].level = 0.7 * volume_factor + + voice_index += 1 + remaining_voices -= 1 + + # Log what we're playing + # Channel names commented out as it was unused + # channel_names = ["Lars", "Heart", "Drum"] + # print(f"Playing {channel_names[channel]} sample {closest_note} for note {midi_note}") + + # Play synth notes for each channel (4-6) + self.preview_mode = False + for channel, notes in synth_notes.items(): + for note in notes: + self.play_synth_note(note, channel) + + # Play MIDI notes + for midi_note, channel in midi_notes.items(): + self.midi.send(NoteOn(midi_note, 100), channel=channel) + self.active_notes[midi_note] = channel + + def play_multi_sample(self, midi_note, channel=0): + """Play the most appropriate sample for the given MIDI note""" + try: + # Find an available voice (use first 3 voices for interactive play) + voice_index = -1 + for i in range(3): # Only use the first 3 voices for interactive playback + if not self.active_voices[i]: + voice_index = i + break + + # If all voices are active, use the first one + if voice_index == -1: + voice_index = 0 + + # Stop any currently playing sample in this voice + self.mixer.voice[voice_index].stop() + + # Select the appropriate sample set based on channel + if channel == 1: # Heart samples + sample_set = self.heart_samples + elif channel == 2: # Drum samples + sample_set = self.drum_samples + else: # Default to channel 1 samples + sample_set = self.samples + + # Find the closest sample + closest_note = min(sample_set.keys(), key=lambda x: abs(x - midi_note)) + + # Get the sample + sample = sample_set[closest_note] + + # Play the sample + self.mixer.voice[voice_index].play(sample, loop=False) + self.active_voices[voice_index] = True + + # Adjust volume based on which sample we're using + if closest_note == 65: # F4 + self.mixer.voice[voice_index].level = 0.8 + elif closest_note == 69: # A4 + self.mixer.voice[voice_index].level = 0.7 + elif closest_note == 72: # C5 + self.mixer.voice[voice_index].level = 0.6 + else: + self.mixer.voice[voice_index].level = 0.7 + + except Exception as e: + print(f"Error playing multi-sample: {e}") + # Try to play any available sample as a fallback + if len(self.samples) > 0: + first_sample = next(iter(self.samples.values())) + self.mixer.voice[0].play(first_sample, loop=False) + + def play_synth_note(self, midi_note, channel): + """Play a note using synthio with different waveforms per channel""" + try: + # Convert MIDI note to frequency + frequency = 440 * math.pow(2, (midi_note - 69) / 12) + + # Get the appropriate waveform for this channel + waveform = self.channel_waveforms.get(channel, self.wave_sine) + + # Get the appropriate amplitude for this channel + amplitude = self.channel_amplitudes.get(channel, 1.0) + + # Create synthio note with the specific waveform and amplitude + note = synthio.Note( + frequency, + waveform=waveform, + amplitude=amplitude + ) + + # Add to synth + self.synth.press(note) + + # If we have an existing preview note to release, release it first + if self.preview_mode and self.note_to_release and self.note_to_release_channel==channel: + try: + self.synth.release(self.note_to_release) + except Exception as e: + print(f"Error releasing previous note: {e}") + + # Store the new note for scheduled release if in preview mode + if self.preview_mode: + self.note_to_release = note + self.note_to_release_channel = channel + else: + self.active_synth_notes[channel].append(note) + + except Exception as e: + print(f"Error playing synthio note: {e}") + # If there's an error with custom waveforms, fall back to default note + try: + frequency = 440 * math.pow(2, (midi_note - 69) / 12) + note = synthio.Note(frequency) + self.synth.press(note) + + # Store for later release + if self.preview_mode: + self.note_to_release = note + self.note_to_release_channel = channel + else: + self.active_synth_notes[channel].append(note) + + except Exception as e2: + print(f"Fallback note error: {e2}") + + def stop_sample_at_position(self, x_pos, y_pos, channel): + """Stop a sample that's playing at the given position for a specific channel""" + position_key = (x_pos, y_pos, channel) + if position_key in self.playback_voice_mapping: + voice_index = self.playback_voice_mapping[position_key] + + # Stop the sample + self.mixer.voice[voice_index].stop() + self.active_voices[voice_index] = False + + # Remove from mappings + del self.playback_voice_mapping[position_key] + return True + + # Also check the simple position mapping + simple_key = (x_pos, y_pos) + if simple_key in self.position_to_voice: + voice_index = self.position_to_voice[simple_key] + + # Stop the sample + self.mixer.voice[voice_index].stop() + self.active_voices[voice_index] = False + + # Remove from mapping + del self.position_to_voice[simple_key] + return True + + return False + + def update(self): + """Update function to handle timed note releases""" + # Check if we need to release a preview note + if self.note_to_release and time.monotonic() >= self.note_release_time: + try: + self.synth.release(self.note_to_release) + self.note_to_release = None + self.note_to_release_channel = None + except Exception as e: + print(f"Error releasing preview note: {e}") + self.note_to_release = None + self.note_to_release_channel = None + + def stop_all_notes(self): + """Stop all currently playing notes""" + # Stop all MIDI notes + for note_number, channel in self.active_notes.items(): + self.midi.send(NoteOff(note_number, 0), channel=channel) + self.active_notes = {} + + # Stop all WAV samples + for i in range(5): # Use first 5 voices for WAV samples + self.mixer.voice[i].stop() + self.active_voices[i] = False + + # Clear position mappings + self.position_to_voice = {} + self.playback_voice_mapping = {} + + # Stop all synth notes + try: + # Release notes from all channels + for channel, notes in self.active_synth_notes.items(): + for note in notes: + self.synth.release(note) + self.active_synth_notes[channel] = [] + + # Also release preview note if there is one + if self.note_to_release: + self.synth.release(self.note_to_release) + self.note_to_release = None + self.note_to_release_channel = None + + except Exception as e: + print(f"Error releasing synth notes: {e}") + # Reinitialize the synth as a fallback + try: + self.synth.deinit() + self.synth = synthio.Synthesizer(sample_rate=11025) + self.mixer.voice[5].play(self.synth) + + # Reset all active notes + self.active_synth_notes = { + 3: [], # Channel 4 + 4: [], # Channel 5 + 5: [], # Channel 6 + } + except Exception as e2: + print(f"Error reinitializing synth: {e2}") + + def deinit(self): + """Clean up resources when shutting down""" + # Stop all sounds + self.stop_all_notes() + + # Clean up audio resources + try: + self.audio.deinit() + except Exception: + pass + + # Power down the TLV320 if applicable + if self.tlv: + try: + # For TLV320DAC3100, headphone_output = False will power down the output + self.tlv.headphone_output = False + except Exception: + pass + + # Clean up synth + try: + self.synth.deinit() + except Exception: + pass + + # Force garbage collection + gc.collect() + + def set_tempo(self, seconds_per_eighth): + """Update the playback tempo""" + self.seconds_per_eighth = seconds_per_eighth + print(f"Playback tempo updated: {60 / (seconds_per_eighth * 2)} BPM") diff --git a/Fruit_Jam/Larsio_Paint_Music/sprite_manager.py b/Fruit_Jam/Larsio_Paint_Music/sprite_manager.py new file mode 100755 index 000000000..1e6a513c9 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/sprite_manager.py @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +Sprite manager for CircuitPython Music Staff Application. +Handles loading and managing sprite images and palettes. +""" + +# pylint: disable=import-error, trailing-whitespace +import adafruit_imageload +from displayio import Palette, TileGrid + + +# pylint: disable=too-many-instance-attributes,invalid-name,broad-except +class SpriteManager: + """Manages sprites and palettes for note display""" + + def __init__(self, bg_color=0x8AAD8A): + """Initialize the sprite manager""" + self.bg_color = bg_color + + # Initialize palettes as empty lists first + self.note_palettes = [] + self.preview_palettes = [] + + # Sprites + self.mario_head = None + self.mario_palette = None + self.heart_note = None + self.heart_palette = None + self.drum_note = None + self.drum_palette = None + # Add new sprite variables + self.meatball_note = None + self.meatball_palette = None + self.star_note = None + self.star_palette = None + self.bot_note = None + self.bot_palette = None + + # Channel colors (still need these for palette management) + self.channel_colors = [ + 0x000000, # Channel 1: Black (default) + 0xFF0000, # Channel 2: Red + 0x00FF00, # Channel 3: Green + 0x0000FF, # Channel 4: Blue + 0xFF00FF, # Channel 5: Magenta + 0xFFAA00, # Channel 6: Orange + ] + + # Add button sprites + self.play_up = None + self.play_up_palette = None + self.play_down = None + self.play_down_palette = None + self.stop_up = None + self.stop_up_palette = None + self.stop_down = None + self.stop_down_palette = None + self.loop_up = None + self.loop_up_palette = None + self.loop_down = None + self.loop_down_palette = None + self.clear_up = None + self.clear_up_palette = None + self.clear_down = None + self.clear_down_palette = None + + # Load sprites + self.load_sprites() + + # Load button sprites + self.load_button_sprites() + + # Create palettes + self.create_palettes() + + def load_sprites(self): + """Load sprite images""" + try: + # Load the Lars note bitmap for channel 1 notes + self.mario_head, self.mario_palette = adafruit_imageload.load( + "/sprites/lars_note.bmp" + ) + # Make the background color transparent (not just the same color) + self.mario_palette.make_transparent(0) + + # Load the Heart note bitmap for channel 2 notes + self.heart_note, self.heart_palette = adafruit_imageload.load( + "/sprites/heart_note.bmp" + ) + # Make the background color transparent + self.heart_palette.make_transparent(0) + + # Load the Drum note bitmap for channel 3 notes + self.drum_note, self.drum_palette = adafruit_imageload.load( + "/sprites/drum_note.bmp" + ) + # Make the background color transparent + self.drum_palette.make_transparent(0) + + # Load the new sprites for channels 4, 5, and 6 + # Meatball for channel 4 + self.meatball_note, self.meatball_palette = adafruit_imageload.load( + "/sprites/meatball.bmp" + ) + self.meatball_palette.make_transparent(0) + + # Star for channel 5 + self.star_note, self.star_palette = adafruit_imageload.load( + "/sprites/star.bmp" + ) + self.star_palette.make_transparent(0) + + # Bot for channel 6 + self.bot_note, self.bot_palette = adafruit_imageload.load("/sprites/bot.bmp") + self.bot_palette.make_transparent(0) + + except Exception as e: + print(f"Error loading sprites: {e}") + + def create_palettes(self): + """Create palettes for notes and preview""" + # Create a palette for music notes with multiple colors + for channel_color in self.channel_colors: + palette = Palette(2) + palette[0] = self.bg_color # Transparent (sage green background) + palette[1] = channel_color # Note color for this channel + self.note_palettes.append(palette) + + # Create a preview palette with multiple colors + for channel_color in self.channel_colors: + palette = Palette(2) + palette[0] = self.bg_color # Transparent (sage green background) + # For preview, use a lighter version of the channel color + r = ((channel_color >> 16) & 0xFF) // 2 + 0x40 + g = ((channel_color >> 8) & 0xFF) // 2 + 0x40 + b = (channel_color & 0xFF) // 2 + 0x40 + preview_color = (r << 16) | (g << 8) | b + palette[1] = preview_color + self.preview_palettes.append(palette) + + def create_preview_note(self, current_channel, note_bitmap): + """Create preview note based on channel""" + if current_channel == 0: # Channel 1 uses Lars note + preview_tg = TileGrid(self.mario_head, pixel_shader=self.mario_palette) + elif current_channel == 1: # Channel 2 uses Heart note + preview_tg = TileGrid(self.heart_note, pixel_shader=self.heart_palette) + elif current_channel == 2: # Channel 3 uses Drum note + preview_tg = TileGrid(self.drum_note, pixel_shader=self.drum_palette) + elif current_channel == 3: # Channel 4 uses Meatball + preview_tg = TileGrid(self.meatball_note, pixel_shader=self.meatball_palette) + elif current_channel == 4: # Channel 5 uses Star + preview_tg = TileGrid(self.star_note, pixel_shader=self.star_palette) + elif current_channel == 5: # Channel 6 uses Bot + preview_tg = TileGrid(self.bot_note, pixel_shader=self.bot_palette) + else: # Fallback to colored circle + preview_tg = TileGrid( + note_bitmap, + pixel_shader=self.preview_palettes[current_channel] + ) + + preview_tg.x = 0 + preview_tg.y = 0 + preview_tg.hidden = True # Start with preview hidden + + return preview_tg + + def load_button_sprites(self): + """Load button sprites for transport controls""" + try: + # Load play button images + self.play_up, self.play_up_palette = adafruit_imageload.load( + "/sprites/play_up.bmp" + ) + self.play_up_palette.make_transparent(0) + + self.play_down, self.play_down_palette = adafruit_imageload.load( + "/sprites/play_down.bmp" + ) + self.play_down_palette.make_transparent(0) + + # Load stop button images + self.stop_up, self.stop_up_palette = adafruit_imageload.load( + "/sprites/stop_up.bmp" + ) + self.stop_up_palette.make_transparent(0) + + self.stop_down, self.stop_down_palette = adafruit_imageload.load( + "/sprites/stop_down.bmp" + ) + self.stop_down_palette.make_transparent(0) + + # Load loop button images + self.loop_up, self.loop_up_palette = adafruit_imageload.load( + "/sprites/loop_up.bmp" + ) + self.loop_up_palette.make_transparent(0) + + self.loop_down, self.loop_down_palette = adafruit_imageload.load( + "/sprites/loop_down.bmp" + ) + self.loop_down_palette.make_transparent(0) + + # Load clear button images + self.clear_up, self.clear_up_palette = adafruit_imageload.load( + "/sprites/clear_up.bmp" + ) + self.clear_up_palette.make_transparent(0) + + self.clear_down, self.clear_down_palette = adafruit_imageload.load( + "/sprites/clear_down.bmp" + ) + self.clear_down_palette.make_transparent(0) + + return True + except Exception as e: + print(f"Error loading button sprites: {e}") + return False diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/bot.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/bot.bmp new file mode 100755 index 000000000..d409f1fa2 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/bot.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/clear_down.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/clear_down.bmp new file mode 100755 index 000000000..248c261e5 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/clear_down.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/clear_up.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/clear_up.bmp new file mode 100755 index 000000000..70fdb13c6 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/clear_up.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/drum_note.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/drum_note.bmp new file mode 100755 index 000000000..a01c582e2 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/drum_note.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/heart_note.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/heart_note.bmp new file mode 100755 index 000000000..c3c460cd7 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/heart_note.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/lars_note.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/lars_note.bmp new file mode 100755 index 000000000..0c1f3e873 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/lars_note.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/loop_down.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/loop_down.bmp new file mode 100755 index 000000000..297786d09 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/loop_down.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/loop_up.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/loop_up.bmp new file mode 100755 index 000000000..2d1a0aa86 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/loop_up.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/meatball.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/meatball.bmp new file mode 100755 index 000000000..6d93e0dd6 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/meatball.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/play_down.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/play_down.bmp new file mode 100755 index 000000000..077c05552 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/play_down.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/play_up.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/play_up.bmp new file mode 100755 index 000000000..d9e3e53a3 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/play_up.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/star.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/star.bmp new file mode 100755 index 000000000..18247a132 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/star.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/stop_down.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/stop_down.bmp new file mode 100755 index 000000000..44cf026ae Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/stop_down.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/sprites/stop_up.bmp b/Fruit_Jam/Larsio_Paint_Music/sprites/stop_up.bmp new file mode 100755 index 000000000..28b07b908 Binary files /dev/null and b/Fruit_Jam/Larsio_Paint_Music/sprites/stop_up.bmp differ diff --git a/Fruit_Jam/Larsio_Paint_Music/staff_view.py b/Fruit_Jam/Larsio_Paint_Music/staff_view.py new file mode 100755 index 000000000..75ea89388 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/staff_view.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# staff_view.py: Larsio Paint Music component +""" + +# pylint: disable=import-error, trailing-whitespace +from displayio import Group, Bitmap, Palette, TileGrid + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements,too-many-locals,too-many-nested-blocks +class StaffView: + """Manages the music staff display and related elements""" + + def __init__(self, screen_width, screen_height, note_manager): + self.SCREEN_WIDTH = screen_width + self.SCREEN_HEIGHT = screen_height + self.note_manager = note_manager + + # Staff dimensions + self.TOP_MARGIN = int(self.SCREEN_HEIGHT * 0.1) + self.BOTTOM_MARGIN = int(self.SCREEN_HEIGHT * 0.2) + self.STAFF_HEIGHT = int((self.SCREEN_HEIGHT - self.TOP_MARGIN - self.BOTTOM_MARGIN) * 0.95) + self.STAFF_Y_START = self.TOP_MARGIN + self.LINE_SPACING = self.STAFF_HEIGHT // 8 + + # Margins and spacing + self.START_MARGIN = 25 # Pixels from left edge for the double bar + + # Note spacing + self.EIGHTH_NOTE_SPACING = self.SCREEN_WIDTH // 40 + self.QUARTER_NOTE_SPACING = self.EIGHTH_NOTE_SPACING * 2 + + # Measure settings + self.NOTES_PER_MEASURE = 4 + self.MEASURE_WIDTH = self.QUARTER_NOTE_SPACING * self.NOTES_PER_MEASURE + self.MEASURES_PER_LINE = 4 + + # Playback elements + self.playhead = None + self.highlight_grid = None + + # X positions for notes + self.x_positions = [] + self._generate_x_positions() + + def _generate_x_positions(self): + """Generate horizontal positions for notes""" + self.x_positions = [] + for measure in range(self.MEASURES_PER_LINE): + measure_start = self.START_MARGIN + (measure * self.MEASURE_WIDTH) + for eighth_pos in range(8): + x_pos = (measure_start + (eighth_pos * self.EIGHTH_NOTE_SPACING) + + self.EIGHTH_NOTE_SPACING // 2) + if x_pos < self.SCREEN_WIDTH: + self.x_positions.append(x_pos) + + # Share positions with note manager + self.note_manager.x_positions = self.x_positions + + def create_staff(self): + """Create the staff with lines and background""" + staff_group = Group() + + # Create staff background + staff_bg_bitmap = Bitmap(self.SCREEN_WIDTH, self.STAFF_HEIGHT, 2) + staff_bg_palette = Palette(2) + staff_bg_palette[0] = 0xF5F5DC # Light beige (transparent) + staff_bg_palette[1] = 0x657c95 # 8AAD8A + + # Fill staff background with sage green + for x in range(self.SCREEN_WIDTH): + for y in range(self.STAFF_HEIGHT): + staff_bg_bitmap[x, y] = 1 + + # Create a TileGrid for staff background + staff_bg_grid = TileGrid( + staff_bg_bitmap, + pixel_shader=staff_bg_palette, + x=0, + y=self.STAFF_Y_START + ) + staff_group.append(staff_bg_grid) + + # Create staff lines + staff_bitmap = Bitmap(self.SCREEN_WIDTH, self.STAFF_HEIGHT, 4) + staff_palette = Palette(4) + staff_palette[0] = 0x657c95 # + staff_palette[1] = 0x000000 # Black for horizontal staff lines + staff_palette[2] = 0x888888 # Medium gray for measure bar lines + staff_palette[3] = 0xAAAAAA # Lighter gray for quarter note dividers + + # Draw 5 horizontal staff lines + for i in range(5): + y_pos = (i + 1) * self.LINE_SPACING + for x in range(self.SCREEN_WIDTH): + staff_bitmap[x, y_pos] = 1 + + # Add double bar at the beginning + for x in range(self.START_MARGIN - 5, self.START_MARGIN - 2): + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + staff_bitmap[x, y] = 1 + + for x in range(self.START_MARGIN - 1, self.START_MARGIN + 2): + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + staff_bitmap[x, y] = 1 + + # Add measure bar lines (thicker, darker) + bar_line_width = 2 + + # For each measure (except after the last one) + for i in range(1, self.MEASURES_PER_LINE): + # Calculate measure bar position + measure_bar_x = self.START_MARGIN + (i * self.MEASURE_WIDTH) + + if measure_bar_x < self.SCREEN_WIDTH: + # Draw the measure bar line + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + for thickness in range(bar_line_width): + if measure_bar_x + thickness < self.SCREEN_WIDTH: + staff_bitmap[measure_bar_x + thickness, y] = 2 + + # Add quarter note divider lines within each measure + for measure in range(self.MEASURES_PER_LINE): + measure_start_x = self.START_MARGIN + (measure * self.MEASURE_WIDTH) + + # Calculate quarter note positions (divide measure into 4 equal parts) + quarter_width = self.MEASURE_WIDTH // 4 + + # Draw lines at the first, second, and third quarter positions + for q in range(1, 4): # Draw at positions 1, 2, and 3 (not at 0 or 4) + quarter_x = measure_start_x + (q * quarter_width) + + if quarter_x < self.SCREEN_WIDTH: + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + staff_bitmap[quarter_x, y] = 3 # Use color 3 (light gray) + + # Add double bar line at the end + double_bar_width = 5 + double_bar_x = self.START_MARGIN + (self.MEASURES_PER_LINE * self.MEASURE_WIDTH) + 5 + if double_bar_x + double_bar_width < self.SCREEN_WIDTH: + # First thick line + for x in range(3): + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + staff_bitmap[double_bar_x + x, y] = 1 + + # Second thick line (with gap) + for x in range(3): + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + staff_bitmap[double_bar_x + x + 4, y] = 1 + + # Create a TileGrid with the staff bitmap + staff_grid = TileGrid( + staff_bitmap, + pixel_shader=staff_palette, + x=0, + y=self.STAFF_Y_START + ) + staff_group.append(staff_grid) + + return staff_group + + def create_grid_lines(self): + """Add vertical grid lines to show note spacing""" + grid_bitmap = Bitmap(self.SCREEN_WIDTH, self.STAFF_HEIGHT, 2) + grid_palette = Palette(2) + grid_palette[0] = 0x657c95 # Transparent + grid_palette[1] = 0xAAAAAA # Faint grid lines (light gray) + + # Draw vertical grid lines at each eighth note position + for x_pos in self.x_positions: + for y in range(self.STAFF_HEIGHT): + if self.LINE_SPACING <= y <= 5 * self.LINE_SPACING: + grid_bitmap[x_pos, y] = 1 + + return TileGrid(grid_bitmap, pixel_shader=grid_palette, x=0, y=self.STAFF_Y_START) + + def create_playhead(self): + """Create a playhead indicator""" + playhead_bitmap = Bitmap(2, self.STAFF_HEIGHT, 2) + playhead_palette = Palette(2) + playhead_palette[0] = 0x657c95 # Transparent + playhead_palette[1] = 0xFF0000 # Red playhead line + + for y in range(self.STAFF_HEIGHT): + playhead_bitmap[0, y] = 1 + playhead_bitmap[1, y] = 1 + + self.playhead = TileGrid( + playhead_bitmap, + pixel_shader=playhead_palette, + x=0, + y=self.STAFF_Y_START + ) + self.playhead.x = -10 # Start off-screen + + return self.playhead + + def create_highlight(self): + """Create a highlight marker for the closest valid note position""" + highlight_bitmap = Bitmap(self.SCREEN_WIDTH, 3, 2) + highlight_palette = Palette(2) + highlight_palette[0] = 0x657c95 # Transparent + highlight_palette[1] = 0x007700 # Highlight color (green) + + for x in range(self.SCREEN_WIDTH): + highlight_bitmap[x, 1] = 1 + + self.highlight_grid = TileGrid(highlight_bitmap, pixel_shader=highlight_palette) + self.highlight_grid.y = self.note_manager.note_positions[0] # Start at first position + + return self.highlight_grid diff --git a/Fruit_Jam/Larsio_Paint_Music/ui_manager.py b/Fruit_Jam/Larsio_Paint_Music/ui_manager.py new file mode 100755 index 000000000..00c953945 --- /dev/null +++ b/Fruit_Jam/Larsio_Paint_Music/ui_manager.py @@ -0,0 +1,644 @@ +# SPDX-FileCopyrightText: 2025 John Park and Claude AI for Adafruit Industries +# +# SPDX-License-Identifier: MIT +""" +# ui_manager.py: CircuitPython Music Staff Application component +""" + +import time +import gc + +# pylint: disable=import-error, trailing-whitespace, line-too-long, superfluous-parens +from adafruit_display_text.bitmap_label import Label +import terminalio +from displayio import TileGrid + +from display_manager import DisplayManager +from staff_view import StaffView +from control_panel import ControlPanel +from input_handler import InputHandler +from sprite_manager import SpriteManager +from cursor_manager import CursorManager +from playback_controller import PlaybackController + + +# pylint: disable=invalid-name,no-member,too-many-instance-attributes,too-many-arguments +# pylint: disable=too-many-branches,too-many-statements,too-many-public-methods +# pylint: disable=too-many-locals,attribute-defined-outside-init +# pylint: disable=consider-using-in,too-many-return-statements,no-else-return +class UIManager: + """Manages the UI elements, input, and user interaction""" + + def __init__(self, sound_manager, note_manager): + """Initialize the UI manager with sound and note managers""" + self.sound_manager = sound_manager + self.note_manager = note_manager + + # Screen dimensions + self.SCREEN_WIDTH = 320 + self.SCREEN_HEIGHT = 240 + + # Staff dimensions + self.TOP_MARGIN = int(self.SCREEN_HEIGHT * 0.1) + self.BOTTOM_MARGIN = int(self.SCREEN_HEIGHT * 0.2) + self.STAFF_HEIGHT = int((self.SCREEN_HEIGHT - self.TOP_MARGIN - self.BOTTOM_MARGIN) * 0.95) + self.STAFF_Y_START = self.TOP_MARGIN + self.LINE_SPACING = self.STAFF_HEIGHT // 8 + + # Start margin + self.START_MARGIN = 25 + + # Tempo and timing + self.BPM = 120 + self.SECONDS_PER_BEAT = 60 / self.BPM + self.SECONDS_PER_EIGHTH = self.SECONDS_PER_BEAT / 2 + + # Initialize components + self.display_manager = DisplayManager(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + self.staff_view = StaffView(self.SCREEN_WIDTH, self.SCREEN_HEIGHT, self.note_manager) + self.control_panel = ControlPanel(self.SCREEN_WIDTH, self.SCREEN_HEIGHT) + self.input_handler = InputHandler(self.SCREEN_WIDTH, self.SCREEN_HEIGHT, + self.STAFF_Y_START, self.STAFF_HEIGHT) + self.sprite_manager = SpriteManager() + self.cursor_manager = CursorManager() + self.playback_controller = PlaybackController(self.sound_manager, self.note_manager, + self.SECONDS_PER_EIGHTH) + + # UI elements + self.main_group = None + self.note_name_label = None + self.tempo_label = None + self.preview_tg = None + self.highlight_grid = None + self.playhead = None + self.channel_buttons = [] + self.channel_selector = None + + # Initialize attributes that will be defined later + self.display = None + self.play_button = None + self.stop_button = None + self.loop_button = None + self.clear_button = None + self.crosshair_cursor = None + self.triangle_cursor = None + self.tempo_minus_label = None + self.tempo_plus_label = None + + # Channel setting + self.current_channel = 0 + + def setup_display(self): + """Initialize the display and create visual elements""" + # Initialize display + self.main_group, self.display = self.display_manager.initialize_display() + + # Create background + bg_grid = self.display_manager.create_background() + self.main_group.append(bg_grid) + + # Create staff + staff_group = self.staff_view.create_staff() + self.main_group.append(staff_group) + + # Create grid lines + grid_tg = self.staff_view.create_grid_lines() + self.main_group.insert(1, grid_tg) # Insert before staff so it appears behind + + # Create channel buttons using sprites + self._create_sprite_channel_buttons() + + # Create transport controls + transport_group, self.play_button, self.stop_button, self.loop_button, self.clear_button = \ + self.control_panel.create_transport_controls(self.sprite_manager) + self.main_group.append(transport_group) + + # Create cursors + self.crosshair_cursor, self.triangle_cursor = self.cursor_manager.create_cursors() + self.main_group.append(self.crosshair_cursor) + self.main_group.append(self.triangle_cursor) + + # Create note name label + self._create_note_name_label() + + # Create tempo display + self._create_tempo_display() + + # Create highlight + self.highlight_grid = self.staff_view.create_highlight() + self.main_group.append(self.highlight_grid) + + # Create playhead + self.playhead = self.staff_view.create_playhead() + self.main_group.append(self.playhead) + + # Set playback controller elements + self.playback_controller.set_ui_elements( + self.playhead, + self.play_button, + self.stop_button, + self.control_panel.button_sprites + ) + + # Create preview note + self.preview_tg = self.sprite_manager.create_preview_note( + self.current_channel, self.note_manager.note_bitmap) + self.main_group.append(self.preview_tg) + + # Add note groups to main group + self.main_group.append(self.note_manager.notes_group) + self.main_group.append(self.note_manager.ledger_lines_group) + + def _create_sprite_channel_buttons(self): + """Create channel buttons using sprites instead of numbered boxes""" + # Get a reference to the channel selector from control panel + channel_group, self.channel_selector = self.control_panel.create_channel_buttons() + + # Add sprite-based channel buttons + button_sprites = [ + (self.sprite_manager.mario_head, self.sprite_manager.mario_palette), + (self.sprite_manager.heart_note, self.sprite_manager.heart_palette), + (self.sprite_manager.drum_note, self.sprite_manager.drum_palette), + (self.sprite_manager.meatball_note, self.sprite_manager.meatball_palette), + (self.sprite_manager.star_note, self.sprite_manager.star_palette), + (self.sprite_manager.bot_note, self.sprite_manager.bot_palette) + ] + + # Create and position the sprite buttons + self.channel_buttons = [] + + for i, (sprite, palette) in enumerate(button_sprites): + button_x = 10 + i * (self.control_panel.CHANNEL_BUTTON_SIZE + + self.control_panel.CHANNEL_BUTTON_SPACING) + + # Create TileGrid for the sprite + button_tg = TileGrid( + sprite, + pixel_shader=palette, + x=button_x, + y=self.control_panel.CHANNEL_BUTTON_Y + ) + + # Center the sprite if it's not exactly the button size + if sprite.width != self.control_panel.CHANNEL_BUTTON_SIZE: + offset_x = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite.width) // 2 + button_tg.x += offset_x + + if sprite.height != self.control_panel.CHANNEL_BUTTON_SIZE: + offset_y = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite.height) // 2 + button_tg.y += offset_y + + self.channel_buttons.append(button_tg) + channel_group.append(button_tg) + + # Add the channel_group to main_group + self.main_group.append(channel_group) + + def _create_note_name_label(self): + """Create a label to show the current note name""" + self.note_name_label = Label( + terminalio.FONT, + text="", + color=0x000000, # Black text for beige background + scale=1 + ) + self.note_name_label.anchor_point = (0, 0) + self.note_name_label.anchored_position = (10, self.SCREEN_HEIGHT - 70) + self.main_group.append(self.note_name_label) + + def _create_tempo_display(self): + """Create a label for the tempo display with + and - buttons""" + gc.collect() # Force garbage collection before creating the label + + # Create plus and minus buttons for tempo adjustment + self.tempo_minus_label = Label( + terminalio.FONT, + text="-", + color=0xaaaaaa, # White text + background_color=0x444444, # Dark gray background + scale=1 + ) + self.tempo_minus_label.anchor_point = (0.5, 0.5) + self.tempo_minus_label.anchored_position = (self.SCREEN_WIDTH - 24, 10) + self.main_group.append(self.tempo_minus_label) + + self.tempo_plus_label = Label( + terminalio.FONT, + text="+", + color=0xaaaaaa, # gray text + background_color=0x444444, # Dark gray background + scale=1 + ) + self.tempo_plus_label.anchor_point = (0.5, 0.5) + self.tempo_plus_label.anchored_position = (self.SCREEN_WIDTH - 7, 10) + self.main_group.append(self.tempo_plus_label) + + # Create the tempo display label + self.tempo_label = Label( + terminalio.FONT, + text=f"Tempo~ {self.BPM} BPM", + color=0x222222, # gray text + scale=1 + ) + self.tempo_label.anchor_point = (0, 0.5) + self.tempo_label.anchored_position = (self.SCREEN_WIDTH - 114, 10) + self.main_group.append(self.tempo_label) + + print(f"Created tempo display: {self.tempo_label.text}") + + def find_mouse(self): + """Find the mouse device""" + return self.input_handler.find_mouse() + + def change_channel(self, channel_idx): + """Change the current MIDI channel""" + if 0 <= channel_idx < 6: # Ensure valid channel index + self.current_channel = channel_idx + + # Update channel selector position + channel_offset = (self.control_panel.CHANNEL_BUTTON_SIZE + + self.control_panel.CHANNEL_BUTTON_SPACING) + self.channel_selector.x = 7 + channel_idx * channel_offset + + # Update preview note color/image based on channel + self.main_group.remove(self.preview_tg) + self.preview_tg = self.sprite_manager.create_preview_note( + self.current_channel, self.note_manager.note_bitmap) + self.main_group.append(self.preview_tg) + + # Update status text + channel_names = ["Lars", "Heart", "Drum", "Meatball", "Star", "Bot"] + channel_text = f"Channel {self.current_channel + 1}: {channel_names[self.current_channel]}" + self.note_name_label.text = f"{channel_text} selected" + + print(f"Changed to MIDI channel {self.current_channel + 1}") + + def toggle_loop(self): + """Toggle loop button state""" + self.playback_controller.loop_enabled = not self.playback_controller.loop_enabled + self.control_panel.loop_enabled = self.playback_controller.loop_enabled + + # Update loop button appearance using bitmap if button_sprites are available + if hasattr(self.control_panel, 'button_sprites') and self.control_panel.button_sprites is not None: + state = 'down' if self.playback_controller.loop_enabled else 'up' + loop_bitmap, loop_palette = self.control_panel.button_sprites['loop'][state] + self.loop_button.bitmap = loop_bitmap + self.loop_button.pixel_shader = loop_palette + else: + # Fallback to original implementation + for x in range(1, self.control_panel.BUTTON_WIDTH - 1): + for y in range(1, self.control_panel.BUTTON_HEIGHT - 1): + skip_corners = (x, y) in [ + (0, 0), + (0, self.control_panel.BUTTON_HEIGHT-1), + (self.control_panel.BUTTON_WIDTH-1, 0), + (self.control_panel.BUTTON_WIDTH-1, self.control_panel.BUTTON_HEIGHT-1) + ] + + if not skip_corners: + # Skip pixels that are part of the loop symbol + dx = x - self.control_panel.BUTTON_WIDTH // 2 + dy = y - self.control_panel.BUTTON_HEIGHT // 2 + # Is pixel on the circle outline? + is_on_circle = (self.control_panel.loop_radius - 1 <= + (dx*dx + dy*dy)**0.5 <= + self.control_panel.loop_radius + 1) + + # Calculate arrow point positions + arrow_y1 = (self.control_panel.BUTTON_HEIGHT // 2 - + self.control_panel.loop_radius - 1) + arrow_y2 = arrow_y1 + 2 + + # Is pixel part of the arrow? + arrow_x = (self.control_panel.BUTTON_WIDTH // 2 + + int(self.control_panel.loop_radius * 0.7)) + is_arrow = x == arrow_x and (y == arrow_y1 or y == arrow_y2) + + if not (is_on_circle or is_arrow): + # Fill with active color if loop enabled, else inactive + val = 2 if self.playback_controller.loop_enabled else 0 + self.control_panel.loop_button_bitmap[x, y] = val + + self.note_name_label.text = "Loop: " + ("ON" if self.playback_controller.loop_enabled else "OFF") + + def press_clear_button(self): + """Handle clear button pressing effect""" + # Show pressed state + if hasattr(self.control_panel, 'button_sprites') and self.control_panel.button_sprites is not None: + self.clear_button.bitmap = self.control_panel.button_sprites['clear']['down'][0] + self.clear_button.pixel_shader = self.control_panel.button_sprites['clear']['down'][1] + else: + # Fallback to original implementation + for x in range(1, self.control_panel.BUTTON_WIDTH - 1): + for y in range(1, self.control_panel.BUTTON_HEIGHT - 1): + self.control_panel.clear_button_bitmap[x, y] = 2 # Red + + # Small delay for visual feedback + time.sleep(0.1) + + # Return to up state + if hasattr(self.control_panel, 'button_sprites') and self.control_panel.button_sprites is not None: + self.clear_button.bitmap = self.control_panel.button_sprites['clear']['up'][0] + self.clear_button.pixel_shader = self.control_panel.button_sprites['clear']['up'][1] + else: + # Fallback to original implementation + for x in range(1, self.control_panel.BUTTON_WIDTH - 1): + for y in range(1, self.control_panel.BUTTON_HEIGHT - 1): + self.control_panel.clear_button_bitmap[x, y] = 0 # Gray + + def clear_all_notes(self): + """Clear all notes""" + # Stop playback if it's running + if self.playback_controller.is_playing: + self.playback_controller.stop_playback() + + # Visual feedback for button press + self.press_clear_button() + + # Clear notes using note manager + self.note_manager.clear_all_notes(self.sound_manager) + + self.note_name_label.text = "All notes cleared" + + def adjust_tempo(self, direction): + """Adjust the tempo based on button press""" + # direction should be +1 for increase, -1 for decrease + + # Adjust BPM + new_bpm = self.BPM + (direction * 5) # Change by 5 BPM increments + + # Constrain to valid range + new_bpm = max(40, min(280, new_bpm)) + + # Only update if changed + if new_bpm != self.BPM: + self.BPM = new_bpm + self.SECONDS_PER_BEAT = 60 / self.BPM + self.SECONDS_PER_EIGHTH = self.SECONDS_PER_BEAT / 2 + + # Update playback controller with new tempo + self.playback_controller.set_tempo(self.SECONDS_PER_EIGHTH) + + # Update display + self.tempo_label.text = f"Tempo~ {self.BPM} BPM" + + print(f"Tempo adjusted to {self.BPM} BPM") + + def handle_mouse_position(self): + """Handle mouse movement and cursor updates""" + mouse_x = self.input_handler.mouse_x + mouse_y = self.input_handler.mouse_y + + # Check if mouse is over channel buttons area + is_over_channel_buttons = ( + self.control_panel.CHANNEL_BUTTON_Y <= mouse_y <= + self.control_panel.CHANNEL_BUTTON_Y + self.control_panel.CHANNEL_BUTTON_SIZE + ) + + # Check if we're over the staff area or transport controls area + is_over_staff = self.input_handler.is_over_staff(mouse_y) + is_over_transport = (mouse_y >= self.control_panel.TRANSPORT_AREA_Y) + + # Switch cursor based on area + self.cursor_manager.switch_cursor(use_triangle=(is_over_transport or is_over_channel_buttons)) + self.cursor_manager.set_cursor_position(mouse_x, mouse_y) + + # Handle staff area differently from other areas + if not is_over_staff: + # Hide highlight and preview when not over staff + self.highlight_grid.hidden = True + self.preview_tg.hidden = True + + # Show channel info if over channel buttons + if is_over_channel_buttons: + self._update_channel_button_info(mouse_x, mouse_y) + return + + # Process staff area interactions + # Find closest position and update highlight + closest_pos = self.note_manager.find_closest_position(mouse_y) + y_position = self.note_manager.note_positions[closest_pos] + self.highlight_grid.y = y_position - 1 # Center the highlight + self.highlight_grid.hidden = False + + # Find closest horizontal position (enforce minimum x position) + x_position = self.note_manager.find_closest_x_position(mouse_x) + + # Define sprite dimensions for each channel + sprite_width, sprite_height = self._get_sprite_dimensions(self.current_channel) + + # Update preview note position + self.preview_tg.x = x_position - sprite_width // 2 + self.preview_tg.y = y_position - sprite_height // 2 + self.preview_tg.hidden = False + + # Update note name label + if x_position < self.START_MARGIN: + self.note_name_label.text = "Invalid position - after double bar only" + else: + channel_names = ["Lars", "Heart", "Drum", "Meatball", "Star", "Bot"] + channel_text = f"Ch{self.current_channel+1} ({channel_names[self.current_channel]})" + note_text = self.note_manager.note_names[closest_pos] + self.note_name_label.text = f"{channel_text}: {note_text}" + + def _update_channel_button_info(self, mouse_x, mouse_y): + """Update the note name label based on which channel button the mouse is over""" + # Calculate which channel button we're over (if any) + for i in range(6): + button_x = 10 + i * (self.control_panel.CHANNEL_BUTTON_SIZE + + self.control_panel.CHANNEL_BUTTON_SPACING) + + # Get sprite dimensions for hit testing + sprite_width, sprite_height = self._get_sprite_dimensions(i) + + # Calculate the centered position of the sprite + offset_x = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite_width) // 2 + offset_y = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite_height) // 2 + sprite_x = button_x + offset_x + sprite_y = self.control_panel.CHANNEL_BUTTON_Y + offset_y + + # Check if mouse is over the sprite + rect_check = self.input_handler.point_in_rect( + mouse_x, mouse_y, sprite_x, sprite_y, + sprite_width, sprite_height) + + if rect_check: + channel_names = ["Lars", "Heart", "Drum", "Meatball", "Star", "Bot"] + self.note_name_label.text = f"Channel {i+1}: {channel_names[i]}" + break + + def _get_sprite_dimensions(self, channel_idx): + """Get the width and height of a sprite based on channel index""" + if channel_idx == 0: + return self.sprite_manager.mario_head.width, self.sprite_manager.mario_head.height + if channel_idx == 1: + return self.sprite_manager.heart_note.width, self.sprite_manager.heart_note.height + if channel_idx == 2: + return self.sprite_manager.drum_note.width, self.sprite_manager.drum_note.height + if channel_idx == 3: + return self.sprite_manager.meatball_note.width, self.sprite_manager.meatball_note.height + if channel_idx == 4: + return self.sprite_manager.star_note.width, self.sprite_manager.star_note.height + if channel_idx == 5: + return self.sprite_manager.bot_note.width, self.sprite_manager.bot_note.height + # Default fallback if channel_idx is out of range + return self.note_manager.NOTE_WIDTH, self.note_manager.NOTE_HEIGHT + + def handle_mouse_buttons(self): + """Handle mouse button presses""" + mouse_x = self.input_handler.mouse_x + mouse_y = self.input_handler.mouse_y + + # Check for staff area + is_over_staff = self.input_handler.is_over_staff(mouse_y) + + if self.input_handler.left_button_pressed: + # Check for tempo button clicks + minus_button_x, minus_button_y = self.tempo_minus_label.anchored_position + plus_button_x, plus_button_y = self.tempo_plus_label.anchored_position + button_radius = 8 # Allow a bit of space around the button for easier clicking + + if ((mouse_x - minus_button_x)**2 + (mouse_y - minus_button_y)**2) < button_radius**2: + # Clicked minus button - decrease tempo + self.adjust_tempo(-1) + return + + if ((mouse_x - plus_button_x)**2 + (mouse_y - plus_button_y)**2) < button_radius**2: + # Clicked plus button - increase tempo + self.adjust_tempo(1) + return + + # Check if a channel button was clicked + channel_clicked = False + for i in range(6): + button_x = 10 + i * (self.control_panel.CHANNEL_BUTTON_SIZE + + self.control_panel.CHANNEL_BUTTON_SPACING) + + # Get sprite dimensions for hit testing + sprite_width, sprite_height = self._get_sprite_dimensions(i) + + # Calculate the centered position of the sprite + offset_x = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite_width) // 2 + offset_y = (self.control_panel.CHANNEL_BUTTON_SIZE - sprite_height) // 2 + sprite_x = button_x + offset_x + sprite_y = self.control_panel.CHANNEL_BUTTON_Y + offset_y + + # Check if click is within the sprite area + if self.input_handler.point_in_rect( + mouse_x, mouse_y, sprite_x, sprite_y, + sprite_width, sprite_height): + self.change_channel(i) + channel_clicked = True + break + + if not channel_clicked: + # Handle play/stop button clicks + if self.input_handler.point_in_rect( + mouse_x, mouse_y, self.play_button.x, self.play_button.y, + self.control_panel.BUTTON_WIDTH, self.control_panel.BUTTON_HEIGHT): + if not self.playback_controller.is_playing: + self.playback_controller.start_playback(self.START_MARGIN) + else: + self.playback_controller.stop_playback() + elif self.input_handler.point_in_rect( + mouse_x, mouse_y, self.stop_button.x, self.stop_button.y, + self.control_panel.BUTTON_WIDTH, self.control_panel.BUTTON_HEIGHT): + self.playback_controller.stop_playback() + elif self.input_handler.point_in_rect( + mouse_x, mouse_y, self.loop_button.x, self.loop_button.y, + self.control_panel.BUTTON_WIDTH, self.control_panel.BUTTON_HEIGHT): + self.toggle_loop() + elif self.input_handler.point_in_rect( + mouse_x, mouse_y, self.clear_button.x, self.clear_button.y, + self.control_panel.BUTTON_WIDTH, self.control_panel.BUTTON_HEIGHT): + self.clear_all_notes() + # Handle staff area clicks - left button adds notes only + elif is_over_staff: + self._add_note_based_on_channel(mouse_x, mouse_y) + + # Handle right mouse button for note deletion + elif self.input_handler.right_button_pressed and is_over_staff: + _, message = self.note_manager.erase_note( + mouse_x, mouse_y, + self.sprite_manager.mario_head, self.sprite_manager.mario_palette, + self.sound_manager + ) + self.note_name_label.text = message + + def _add_note_based_on_channel(self, x, y): + """Add a note based on the current channel""" + if self.current_channel == 0: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.mario_head, self.sprite_manager.mario_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + elif self.current_channel == 1: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + elif self.current_channel == 2: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.drum_note, self.sprite_manager.drum_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + elif self.current_channel == 3: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.meatball_note, self.sprite_manager.meatball_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + elif self.current_channel == 4: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.star_note, self.sprite_manager.star_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + elif self.current_channel == 5: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.bot_note, self.sprite_manager.bot_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + else: + _, message = self.note_manager.add_note( + x, y, self.current_channel, + self.sprite_manager.note_palettes, + self.sprite_manager.mario_head, self.sprite_manager.mario_palette, + self.sprite_manager.heart_note, self.sprite_manager.heart_palette, + self.sound_manager + ) + self.note_name_label.text = message + + def main_loop(self): + """Main application loop""" + while True: + # Update playback if active + if self.playback_controller.is_playing: + self.playback_controller.update_playback(self.staff_view.x_positions) + + # Update sound manager for timed releases + self.sound_manager.update() + + # Process mouse input - simplified version without wheel tracking + if self.input_handler.process_mouse_input(): + # Handle mouse position and update cursor + self.handle_mouse_position() + + # Handle mouse button presses + self.handle_mouse_buttons()