Merge branch 'refs/heads/main' into adding_icons_and_metadata

# Conflicts:
#	Metro/Metro_RP2350_CircuitPython_Matrix/code.py
This commit is contained in:
foamyguy 2025-06-17 15:07:31 -05:00
commit edfec3d15f
144 changed files with 129569 additions and 161 deletions

View file

@ -109,16 +109,32 @@ void setup() {
digitalWrite(ESP32_RESETN, HIGH);
pixel.setPixelColor(0, 20, 20, 0); pixel.show();
delay(100);
#if defined(LED_BUILTIN)
pinMode(LED_BUILTIN, OUTPUT);
#endif
}
void loop() {
while (Serial.available()) {
#if defined(ARDUINO_ARCH_RP2040) // Neopixel is blocking and this annoys esptool
#if defined(LED_BUILTIN)
digitalWrite(LED_BUILTIN, HIGH);
#endif
#else
pixel.setPixelColor(0, 10, 0, 0); pixel.show();
#endif
SerialESP32.write(Serial.read());
}
while (SerialESP32.available()) {
#if defined(ARDUINO_ARCH_RP2040) // Neopixel is blocking and this annoys esptool
#if defined(LED_BUILTIN)
digitalWrite(LED_BUILTIN, LOW);
#endif
#else
pixel.setPixelColor(0, 0, 0, 10); pixel.show();
#endif
Serial.write(SerialESP32.read());
}
}

View file

@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2025 Limor Fried for Adafruit Industries
//
// SPDX-License-Identifier: MIT
#include "config.h"
#include <Adafruit_BME280.h>
#include <Adafruit_NeoPixel.h>
Adafruit_BME280 bme; // I2C
AdafruitIO_Feed *temperature = io.feed("temperature");
AdafruitIO_Feed *humidity = io.feed("humidity");
AdafruitIO_Feed *pressure = io.feed("pressure");
float temp, humid, pres;
Adafruit_NeoPixel pixel(1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800);
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH);
// wait for serial monitor to open
//while(! Serial);
// turn on neopixel
pinMode(NEOPIXEL_POWER, OUTPUT);
digitalWrite(NEOPIXEL_POWER, HIGH);
pixel.begin(); // INITIALIZE NeoPixel strip object (REQUIRED)
pixel.setBrightness(10); // not so bright
pixel.setPixelColor(0, 0xFF0000); // red
pixel.show();
if (! bme.begin()) {
Serial.println("Could not find a valid BME280 sensor, check wiring, address, sensor ID!");
deepSleep();
}
Serial.println("Found BME280");
float temp = bme.readTemperature();
float pres = bme.readPressure() / 100.0F;
float hum = bme.readHumidity();
// shhh time to close your eyes
bme.setSampling(Adafruit_BME280::MODE_SLEEP,
Adafruit_BME280::SAMPLING_X16, Adafruit_BME280::SAMPLING_X16, Adafruit_BME280::SAMPLING_X16,
Adafruit_BME280::FILTER_OFF,
Adafruit_BME280::STANDBY_MS_1000);
Serial.print("Connecting to Adafruit IO");
pixel.setPixelColor(0, 0xFFFF00); // yellow
pixel.show();
// connect to io.adafruit.com
io.connect();
// wait for a connection
while(io.status() < AIO_CONNECTED) {
Serial.print(".");
delay(100);
}
// we are connected
pixel.setPixelColor(0, 0x00FF00); // green
pixel.show();
Serial.println();
Serial.println(io.statusText());
io.run();
temp = temp * 9.0 / 5.0 + 32;
Serial.print("Temperature = ");
Serial.print(temp);
Serial.println(" *F");
temperature->save(temp);
Serial.print("Pressure = ");
Serial.print(pres);
Serial.println(" hPa");
pressure->save(pres);
Serial.print("Humidity = ");
Serial.print(hum);
Serial.println(" %");
humidity->save(hum);
Serial.println();
deepSleep();
}
void loop() {
// we never get here!
}
void deepSleep() {
pinMode(NEOPIXEL_POWER, OUTPUT);
digitalWrite(NEOPIXEL_POWER, LOW); // off
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, LOW);
esp_sleep_enable_timer_wakeup(300000000); // 5 minutes
esp_deep_sleep_start();
}

View file

@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2025 Limor Fried for Adafruit Industries
//
// SPDX-License-Identifier: MIT
#define IO_USERNAME "your-aio-username"
#define IO_KEY "your-aio-token"
#define WIFI_SSID "your-wifi-ssid"
#define WIFI_PASS "your-wifi-pass"
#include "AdafruitIO_WiFi.h"
AdafruitIO_WiFi io(IO_USERNAME, IO_KEY, WIFI_SSID, WIFI_PASS);

View file

@ -0,0 +1,110 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""
CircuitPython example for deep sleep and BME280 sensor sending data
to Adafruit IO.
"""
from os import getenv
import time
import alarm
import board
import digitalio
import neopixel
import wifi
from adafruit_bme280 import advanced as adafruit_bme280
import adafruit_connection_manager
import adafruit_requests
from adafruit_io.adafruit_io import IO_HTTP, AdafruitIO_RequestError
# enable power to NeoPixels.
np_power = digitalio.DigitalInOut(board.NEOPIXEL_POWER)
np_power.switch_to_output(value=True)
# standard LED
builtin_led = digitalio.DigitalInOut(board.LED)
builtin_led.switch_to_output(value=True)
# neopixel to use for status
status_pixel = neopixel.NeoPixel(
board.NEOPIXEL, 1, brightness=0.1, pixel_order=neopixel.GRB, auto_write=True
)
status_pixel[0] = 0xFF0000
# Create sensor object, using the board's default I2C bus.
i2c = board.I2C() # uses board.SCL and board.SDA
bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c)
print("Found BME280")
# change this to match the location's pressure (hPa) at sea level
bme280.sea_level_pressure = 1013.25
# temperature converted to F
temperature = bme280.temperature * 9 / 5 + 32
humidity = bme280.relative_humidity
pressure = bme280.pressure
print("\nTemperature: %0.1f F" % temperature)
print("Humidity: %0.1f %%" % humidity)
print("Pressure: %0.1f hPa" % pressure)
bme280.mode = adafruit_bme280.MODE_SLEEP
bme280.overscan_temperature = adafruit_bme280.OVERSCAN_X16
bme280.overscan_humidity = adafruit_bme280.OVERSCAN_X16
bme280.overscan_pressure = adafruit_bme280.OVERSCAN_X16
bme280.iir_filter = adafruit_bme280.IIR_FILTER_DISABLE
bme280.standby_period = adafruit_bme280.STANDBY_TC_1000
# set status pixel to yellow
status_pixel[0] = 0xFFFF00
print("Connecting to AdafruitIO")
# Get WiFi details and Adafruit IO keys, ensure these are setup in settings.toml
# (visit io.adafruit.com if you need to create an account, or if you need your Adafruit IO key.)
ssid = getenv("WIFI_SSID")
password = getenv("WIFI_PASSWORD")
aio_username = getenv("ADAFRUIT_AIO_USERNAME")
aio_key = getenv("ADAFRUIT_AIO_KEY")
print("Connecting to %s" % ssid)
wifi.radio.connect(ssid, password)
print("Connected to %s!" % ssid)
# setup socket pool and requests session
pool = adafruit_connection_manager.get_radio_socketpool(wifi.radio)
ssl_context = adafruit_connection_manager.get_radio_ssl_context(wifi.radio)
requests = adafruit_requests.Session(pool, ssl_context)
# Initialize an Adafruit IO HTTP API object
io = IO_HTTP(aio_username, aio_key, requests)
# set status pixel to green
status_pixel[0] = 0x00FF00
try:
# Get the feeds from Adafruit IO
temperature_feed = io.get_feed("temperature")
humidity_feed = io.get_feed("humidity")
pressure_feed = io.get_feed("pressure")
# send data to the feeds
io.send_data(temperature_feed["key"], temperature)
io.send_data(humidity_feed["key"], humidity)
io.send_data(pressure_feed["key"], pressure)
except AdafruitIO_RequestError as e:
print(e)
print(
"You must create feeds on AdafruitIO for: temperature, humidity, and pressure"
)
# turn off the neopixel and builtin LED
np_power.value = False
builtin_led.value = False
# Create an alarm that will trigger 5 minutes from now.
time_alarm = alarm.time.TimeAlarm(monotonic_time=time.monotonic() + (5 * 60))
# Exit the program, and then deep sleep until the alarm wakes us.
alarm.exit_and_deep_sleep_until_alarms(time_alarm)
# Does not return, so we never get here.

View file

@ -11,7 +11,7 @@ LOOP = False # Update to True loop WAV playback. False plays once.
audio = audiobusio.I2SOut(board.A2, board.A1, board.A0)
with open("chikken.wav", "rb") as wave_file:
with open("booploop.wav", "rb") as wave_file:
wav = audiocore.WaveFile(wave_file)
print("Playing wav file!")

View file

@ -0,0 +1,27 @@
{
"exportVersion": "1.0.0",
"exportedBy": "tyeth_demo",
"exportedAt": "2025-05-02T17:08:03.857Z",
"exportedFromDevice": {
"board": "rpi-pico-w",
"firmwareVersion": "1.0.0-beta.100"
},
"components": [
{
"name": "Reed Switch",
"pinName": "D13",
"type": "reed_switch",
"mode": "DIGITAL",
"direction": "INPUT",
"period": 0,
"pull": "UP",
"isPin": true,
"visualization": {
"offLabel": "Open",
"offIcon": "fa6:solid:door-open",
"onLabel": "Closed",
"onIcon": "fa6:regular:door-closed"
}
}
]
}

View file

@ -98,11 +98,11 @@ def on_message(client, feed_id, payload):
def on_relay_msg(client, topic, message):
# Method called whenever user/feeds/relay has a new value
if message == "morning":
print("Morning - turning outlet ON")
if message == "1":
print("Received 1 - turning outlet ON")
power_pin.value = True
elif message == "night":
print("Night - turning outlet OFF")
elif message == "0":
print("Received 0 - turning outlet OFF")
power_pin.value = False
else:
print("Unexpected value received on relay feed.")

View file

@ -0,0 +1,83 @@
{
"exportVersion": "1.0.0",
"exportedBy": "tyeth",
"exportedAt": "2025-06-10T18:13:03.071Z",
"exportedFromDevice": {
"board": "rpi-pico-w",
"firmwareVersion": "1.0.0-beta.100"
},
"components": [
{
"name": "💦 Wee Button",
"pinName": "D18",
"type": "push_button",
"mode": "DIGITAL",
"direction": "INPUT",
"period": 0,
"pull": "UP",
"isPin": true
},
{
"name": "💦 Wee LED",
"pinName": "D2",
"type": "led",
"mode": "DIGITAL",
"direction": "OUTPUT",
"isPin": true
},
{
"name": "💩 Poo Button",
"pinName": "D19",
"type": "push_button",
"mode": "DIGITAL",
"direction": "INPUT",
"period": 0,
"pull": "UP",
"isPin": true
},
{
"name": "💩 Poo LED",
"pinName": "D3",
"type": "led",
"mode": "DIGITAL",
"direction": "OUTPUT",
"isPin": true
},
{
"name": "❌ Didn't Go Button",
"pinName": "D20",
"type": "push_button",
"mode": "DIGITAL",
"direction": "INPUT",
"period": 0,
"pull": "UP",
"isPin": true
},
{
"name": "❌ Didn't Go LED",
"pinName": "D4",
"type": "led",
"mode": "DIGITAL",
"direction": "OUTPUT",
"isPin": true
},
{
"name": "🔔 Tell Adult Button",
"pinName": "D21",
"type": "push_button",
"mode": "DIGITAL",
"direction": "INPUT",
"period": 0,
"pull": "UP",
"isPin": true
},
{
"name": "🔔 Tell Adult LED",
"pinName": "D5",
"type": "led",
"mode": "DIGITAL",
"direction": "OUTPUT",
"isPin": true
}
]
}

View file

@ -0,0 +1,72 @@
<b>🌟 Daily Potty Training Report 🌟</b>
Hi there! Here's how our little superstar did:
---
<b>🚽 Successful Wees: {{ vars.wee_progress }}</b>
{% for i in (1..vars.wee_progress) %}💧{% endfor %}
{% if vars.wee_progress <= 5 -%}
Great start! Every success counts, and they're building good habits one wee at a time! 🌱
{% elsif vars.wee_progress <= 15 -%}
Fantastic progress! They're really getting the hang of this - keep up the amazing work! 🎯
{% else -%}
SUPERSTAR ALERT! 🌟 Absolutely crushing it with those wee successes! They're a potty champion! 🏆
{% endif %}---
<b>💩 Successful Poos: {{ vars.poo_progress }}</b>
{% for i in (1..vars.poo_progress) %}🟤{% endfor %}
{% if vars.poo_progress == 0 -%}
Poos can be tricky, but they're being so brave! Every try is a step forward! 💪
{% elsif vars.poo_progress < 2 -%}
Look at them go! They're becoming a real poo pro - that's awesome progress! 🎉
{% else -%}
POO CHAMPION! 🏅 They've mastered one of the trickiest parts - so proud! 🎊
{% endif %}---
<b>🤝 Told an Adult: {{ vars.informed_progress }}</b>
{% for i in (1..vars.informed_progress) %}🗣️{% endfor %}
{% if vars.informed_progress <= 5 -%}
Communication is key! Keep practicing saying when they need to go - They're doing great! 📢
{% elsif vars.informed_progress <= 15 -%}
Wonderful communication skills! They're really good at letting us know - that's so helpful! 👏
{% else -%}
COMMUNICATION SUPERSTAR! 🌟 They're amazing at telling adults - that's such a big kid skill! 🎯
{% endif %}---
<b>👻 Nothing Happened: {{ vars.nothing_progress }}</b>
{% for i in (1..vars.nothing_progress) %}⭕{% endfor %}
{% if vars.nothing_progress <= 3 -%}
That's okay! Trying is what matters, and their body will let them know when it's ready! 🌈
{% else -%}
So patient and persistent! Even when nothing happens, they keep trying - that's real determination! 💫
{% endif %}---
<b>📊 Daily Summary:</b>
{% capture total_tries -%}{{ vars.wee_progress | plus: vars.poo_progress | plus: vars.nothing_progress }}{% endcapture -%}
{% capture successes -%}{{ vars.wee_progress | plus: vars.poo_progress }}{% endcapture -%}
{% capture success_rate -%}{% if total_tries != "0" -%}{{ successes | times: 100 | divided_by: total_tries }}{% else -%}100{% endif -%}{% endcapture -%}
{% capture bar_filled -%}{{ success_rate | divided_by: 10 }}{% endcapture -%}
{% capture bar_empty -%}{{ 10 | minus: bar_filled }}{% endcapture -%}
Total potty visits: {{ total_tries }}
Success rate: {{ success_rate }}% [{%- for i in (1..bar_filled) -%}█{%- endfor -%}{%- for i in (1..bar_empty) -%}░{%- endfor -%}]
{%- assign total_events = vars.wee_progress | plus: vars.poo_progress | plus: vars.nothing_progress | plus: vars.informed_progress -%}
({{ total_events }} events today )
{% if total_events <= 3 %}
💝 <b>Today:</b> They're doing such a great job learning! Every day gets a little easier...
{% elsif total_events <= 5 %}
🌟 <b>Today:</b> Ayee! They're really getting the hang of this potty training thing! Keep it up!
{% elsif total_events <= 8 %}
🌟 <b>Today:</b> WOW! Look at all that practice! They're becoming such a potty expert.
{% else %}
🏆 <b>Today:</b> INCREDIBLE DAY! They're absolutely rocking this potty training journey! 🎊🎉
{%- endif %}
Keep being awesome!
With love and high-fives! 🙌
---
{%- assign event_mod = total_events | modulo: 3 %}
{% if event_mod == 0 -%}
<i>P.S. Remember: Every expert was once a beginner - they're doing brilliantly! 🌟</i>
{% elsif event_mod == 1 -%}
<i>P.S. Fun fact: Even superheroes had to learn to use the potty! 🦸</i>
{% else -%}
<i>P.S. Remember: accidents are just practice in disguise! They're doing amazingly! 💕</i>
{% endif %}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,78 @@
// SPDX-FileCopyrightText: 2025 Limor Fried for Adafruit Industries
//
// SPDX-License-Identifier: MIT
#include <Arduino.h>
#include "WiFi.h"
#include <Adafruit_TestBed.h>
#include "ESP_I2S.h"
extern Adafruit_TestBed TB;
// I2S pin definitions
const uint8_t I2S_SCK = 14; // BCLK
const uint8_t I2S_WS = 12; // LRCLK
const uint8_t I2S_DIN = 13; // DATA_IN
I2SClass i2s;
// the setup routine runs once when you press reset:
void setup() {
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH);
i2s.setPins(I2S_SCK, I2S_WS, -1, I2S_DIN);
if (!i2s.begin(I2S_MODE_STD, 44100, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO, I2S_STD_SLOT_LEFT)) {
Serial.println("Failed to initialize I2S bus!");
return;
}
// TestBed will handle the neopixel swirl for us
TB.neopixelPin = PIN_NEOPIXEL;
TB.neopixelNum = 1;
TB.begin();
// Set WiFi to station mode and disconnect from an AP if it was previously connected
WiFi.mode(WIFI_STA);
WiFi.disconnect();
}
// the loop routine runs over and over again forever:
uint8_t wheelColor=0;
void loop() {
if (wheelColor == 0) {
// Test WiFi Scan!
// WiFi.scanNetworks will return the number of networks found
int n = WiFi.scanNetworks();
Serial.print("WiFi AP scan done...");
if (n == 0) {
Serial.println("no networks found");
} else {
Serial.print(n);
Serial.println(" networks found");
for (int i = 0; i < n; ++i) {
// Print SSID and RSSI for each network found
Serial.print(i + 1);
Serial.print(": ");
Serial.print(WiFi.SSID(i));
Serial.print(" (");
Serial.print(WiFi.RSSI(i));
Serial.print(")");
Serial.println((WiFi.encryptionType(i) == WIFI_AUTH_OPEN)?" ":"*");
delay(10);
}
}
Serial.println("");
for (int i=0; i < 5; i++) {
int32_t sample = i2s.read();
if (sample >= 0){
Serial.print("Amplitude: ");
Serial.println(sample);
// Delay to avoid printing too quickly
delay(200);
}
}
}
TB.setColor(TB.Wheel(wheelColor++)); // swirl NeoPixel
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
delay(5);
}

View file

@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
import time
from audiocore import WaveFile
import audiobusio
import board
from displayio import Group, TileGrid, Bitmap, Palette
import supervisor
import adafruit_imageload
import adafruit_tlv320
from adafruit_fruitjam.peripherals import request_display_config
# how long between animation frames
ANIMATE_INTERVAL = 1 / 45
background_color = 0xE1F7CE
i2c = board.I2C()
dac = adafruit_tlv320.TLV320DAC3100(i2c)
dac.configure_clocks(sample_rate=44100, bit_depth=16)
# for headphone jack ouput
dac.headphone_output = True
dac.headphone_volume = -15 # dB
# for speaker JST output
# dac.speaker_output = True
# dac.speaker_volume = -15 # dB
wave_file = open("gameboy_startup/gameboy_pling.wav", "rb")
wave = WaveFile(wave_file)
audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
# display setup
request_display_config(320, 240)
display = supervisor.runtime.display
# group to hold all visual elements
main_group = Group()
# Bitmap for background color
bg_bmp = Bitmap(display.width // 20, display.height // 20, 1)
bg_palette = Palette(1)
bg_palette[0] = background_color
bg_tg = TileGrid(bg_bmp, pixel_shader=bg_palette)
# group to scale the background bitmap up to display size
bg_group = Group(scale=20)
bg_group.append(bg_tg)
main_group.append(bg_group)
# Bitmap for logo
logo, palette = adafruit_imageload.load("gameboy_startup/gameboy_logo.bmp")
logo_tg = TileGrid(logo, pixel_shader=palette)
main_group.append(logo_tg)
# place it in the center horizontally and above the top of the display
logo_tg.x = display.width // 2 - logo_tg.tile_width // 2
logo_tg.y = -logo_tg.tile_height
# y pixel location to stop logo at
STOP_Y = display.height * 0.4 - logo_tg.tile_height // 2
display.root_group = main_group
time.sleep(1.5)
last_animate_time = time.monotonic()
played_audio = False
display.auto_refresh = False
while True:
now = time.monotonic()
# if it's time to animate and the logo isn't to the
# stopping position yet
if last_animate_time + ANIMATE_INTERVAL <= now and logo_tg.y < STOP_Y:
# update the timestamp
last_animate_time = now
# move the logo down by a pixel
logo_tg.y += 1
display.refresh()
# if the logo has reached the stop position
if logo_tg.y >= STOP_Y and not played_audio:
played_audio = True
# play the audio pling
audio.play(wave)
while audio.playing:
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

View file

@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: 2025 Tim Cocks for Adafruit Industries
# SPDX-License-Identifier: MIT
import time
from audiocore import WaveFile
import audiobusio
import board
import supervisor
from displayio import Group, TileGrid, OnDiskBitmap
import adafruit_tlv320
from adafruit_fruitjam.peripherals import request_display_config
from adafruit_progressbar.horizontalprogressbar import (
HorizontalFillDirection,
HorizontalProgressBar,
)
# DAC setup
i2c = board.I2C()
dac = adafruit_tlv320.TLV320DAC3100(i2c)
dac.configure_clocks(sample_rate=44100, bit_depth=16)
# for headphone jack ouput
dac.headphone_output = True
dac.headphone_volume = -15 # dB
# for speaker JST output
# dac.speaker_output = True
# dac.speaker_volume = -15 # dB
# Chime audio setup
wave_file = open("mac_startup/mac_chime.wav", "rb")
wave = WaveFile(wave_file)
audio = audiobusio.I2SOut(board.I2S_BCLK, board.I2S_WS, board.I2S_DIN)
# Display setup
request_display_config(640, 480)
display = supervisor.runtime.display
display.auto_refresh = False
# group to hold visual all elements
main_group = Group()
display.root_group = main_group
display.refresh()
# background image
bg_bmp = OnDiskBitmap("mac_startup/mac_startup_bg.bmp")
bg_tg = TileGrid(bg_bmp, pixel_shader=bg_bmp.pixel_shader)
main_group.append(bg_tg)
# Icons for bottom left
icons = []
for i in range(6):
odb = OnDiskBitmap("mac_startup/mac_startup_icon{0}.bmp".format(i))
tg = TileGrid(odb, pixel_shader=odb.pixel_shader)
icons.append(
{
"bmp": odb,
"tg": tg,
}
)
tg.x = 10 + ((33 + 8) * i)
tg.y = display.height - tg.tile_height - 10
tg.hidden = True
if i < 5:
odb.pixel_shader.make_transparent(0)
main_group.append(tg)
# progress bar in the welcome box
progress_bar = HorizontalProgressBar(
(147, 138),
(346, 7),
direction=HorizontalFillDirection.LEFT_TO_RIGHT,
min_value=0,
max_value=800,
fill_color=0xC7BEFD,
outline_color=0x000000,
bar_color=0x3F3F3F,
margin_size=0,
)
main_group.append(progress_bar)
# play the chime sound
audio.play(wave)
while audio.playing:
pass
# start drawing the visual elements
display.auto_refresh = True
time.sleep(1)
start_time = time.monotonic()
while True:
elapsed = time.monotonic() - start_time
# if we haven't reached the end yet
if elapsed * 100 <= 800:
# update the progress bar
progress_bar.value = elapsed * 100
else: # reached the end animation
# set progress bar to max value
progress_bar.value = 800
# loop over all icons
for index, icon in enumerate(icons):
# if it's time for the current icon to show, and it's still hidden
if (elapsed - 1) > index and icon["tg"].hidden:
# make the current icon visible
icon["tg"].hidden = False

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -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}")

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,4 @@
{
"title": "LarsioPant",
"icon": "lpm_icon.bmp"
}

View file

@ -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 = {}

View file

@ -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)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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")

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

View file

@ -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

View file

@ -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()

468
LED_Matrix_Clock/code.py Normal file
View file

@ -0,0 +1,468 @@
# SPDX-FileCopyrightText: 2025 Liz Clark for Adafruit Industries
# SPDX-License-Identifier: MIT
'''LED Matrix Alarm Clock with Scrolling Wake Up Text and Winking Eyes'''
import os
import ssl
import time
import random
import wifi
import socketpool
import microcontroller
import board
import audiocore
import audiobusio
import audiomixer
import adafruit_is31fl3741
from adafruit_is31fl3741.adafruit_rgbmatrixqt import Adafruit_RGBMatrixQT
import adafruit_ntp
from adafruit_ticks import ticks_ms, ticks_add, ticks_diff
from rainbowio import colorwheel
from adafruit_seesaw import digitalio, rotaryio, seesaw
from adafruit_debouncer import Button
# Configuration
timezone = -4
alarm_hour = 11
alarm_min = 36
alarm_volume = .2
hour_12 = True
no_alarm_plz = False
BRIGHTNESS_DAY = 200
BRIGHTNESS_NIGHT = 50
# I2S pins for Audio BFF
DATA = board.A0
LRCLK = board.A1
BCLK = board.A2
# Connect to WIFI
wifi.radio.connect(os.getenv("CIRCUITPY_WIFI_SSID"), os.getenv("CIRCUITPY_WIFI_PASSWORD"))
print(f"Connected to {os.getenv('CIRCUITPY_WIFI_SSID')}")
context = ssl.create_default_context()
pool = socketpool.SocketPool(wifi.radio)
ntp = adafruit_ntp.NTP(pool, tz_offset=timezone, cache_seconds=3600)
# Initialize I2C and displays
i2c = board.STEMMA_I2C()
matrix1 = Adafruit_RGBMatrixQT(i2c, address=0x30, allocate=adafruit_is31fl3741.PREFER_BUFFER)
matrix2 = Adafruit_RGBMatrixQT(i2c, address=0x31, allocate=adafruit_is31fl3741.PREFER_BUFFER)
# Configure displays
for m in [matrix1, matrix2]:
m.global_current = 0x05
m.set_led_scaling(BRIGHTNESS_DAY)
m.enable = True
m.fill(0x000000)
m.show()
# Audio setup
audio = audiobusio.I2SOut(BCLK, LRCLK, DATA)
wavs = ["/"+f for f in os.listdir('/') if f.lower().endswith('.wav') and not f.startswith('.')]
mixer = audiomixer.Mixer(voice_count=1, sample_rate=22050, channel_count=1,
bits_per_sample=16, samples_signed=True, buffer_size=32768)
mixer.voice[0].level = alarm_volume
audio.play(mixer)
def open_audio():
"""Open a random WAV file"""
filename = random.choice(wavs)
return audiocore.WaveFile(open(filename, "rb"))
def update_brightness(hour_24):
"""Update LED brightness based on time of day"""
brightness = BRIGHTNESS_NIGHT if (hour_24 >= 20 or hour_24 < 7) else BRIGHTNESS_DAY
matrix1.set_led_scaling(brightness)
matrix2.set_led_scaling(brightness)
return brightness
# Seesaw setup for encoder and button
seesaw = seesaw.Seesaw(i2c, addr=0x36)
seesaw.pin_mode(24, seesaw.INPUT_PULLUP)
button = Button(digitalio.DigitalIO(seesaw, 24), long_duration_ms=1000)
encoder = rotaryio.IncrementalEncoder(seesaw)
last_position = 0
# Font definitions
FONT_5X7 = {
'0': [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
'1': [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
'2': [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
'3': [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
'4': [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
'5': [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
'6': [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
'7': [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
'8': [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
'9': [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
' ': [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
'W': [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
'A': [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
'K': [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
'E': [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
'U': [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'P': [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
'O': [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
'N': [0b10001, 0b11001, 0b10101, 0b10101, 0b10011, 0b10001, 0b10001],
'F': [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000]
}
# Eye patterns
EYE_OPEN = [0b10101, 0b01110, 0b10001, 0b10101, 0b10001, 0b01110, 0b00000]
EYE_CLOSED = [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000]
class Display:
"""Handle all display operations"""
def __init__(self, m1, m2):
self.matrix1 = m1
self.matrix2 = m2
def clear(self):
"""Clear both displays"""
self.matrix1.fill(0x000000)
self.matrix2.fill(0x000000)
def show(self):
"""Update both displays"""
self.matrix1.show()
self.matrix2.show()
def pixel(self, matrix, x, y, color): # pylint: disable=no-self-use
"""Draw a pixel with 180-degree rotation"""
fx, fy = 12 - x, 8 - y
if 0 <= fx < 13 and 0 <= fy < 9:
matrix.pixel(fx, fy, color)
def draw_char(self, matrix, char, x, y, color):
"""Draw a character at position x,y"""
if char.upper() in FONT_5X7:
bitmap = FONT_5X7[char.upper()]
for row in range(7):
for col in range(5):
if bitmap[row] & (1 << (4 - col)):
self.pixel(matrix, x + col, y + row, color)
def draw_colon(self, y, color, is_pm=False):
"""Draw colon split between displays with optional PM indicator"""
# Two dots for the colon
for dy in [(1, 2), (4, 5)]:
for offset in dy:
self.pixel(self.matrix1, 12, y + offset, color)
self.pixel(self.matrix2, 0, y + offset, color)
# PM indicator dot
if is_pm:
self.pixel(self.matrix1, 12, y + 6, color)
self.pixel(self.matrix2, 0, y + 6, color)
def draw_time(self, time_str, color, is_pm=False):
"""Draw time display across both matrices"""
self.clear()
y = 1
# Draw digits
if len(time_str) >= 5:
self.draw_char(self.matrix1, time_str[0], 0, y, color)
self.draw_char(self.matrix1, time_str[1], 6, y, color)
self.draw_colon(y, color, is_pm)
self.draw_char(self.matrix2, time_str[3], 2, y, color)
self.draw_char(self.matrix2, time_str[4], 8, y, color)
self.show()
def draw_scrolling_text(self, text, offset, color):
"""Draw scrolling text across both matrices"""
self.clear()
char_width = 6
total_width = 26
# Calculate position for smooth scrolling
y = 1
for i, char in enumerate(text):
# Start from right edge and move left
char_x = total_width - offset + (i * char_width)
# Draw character if any part is visible
if -6 < char_x < total_width:
if char_x < 13: # On matrix1
self.draw_char(self.matrix1, char, char_x, y, color)
else: # On matrix2
self.draw_char(self.matrix2, char, char_x - 13, y, color)
self.show()
def draw_eye(self, matrix, pattern, color):
"""Draw eye pattern centered on matrix"""
x, y = 4, 1 # Center position
for row in range(7):
for col in range(5):
if pattern[row] & (1 << (4 - col)):
self.pixel(matrix, x + col, y + row, color)
def wink_animation(self, color):
"""Perform winking animation"""
# Sequence: open -> left wink -> open -> right wink -> open
sequences = [
(EYE_OPEN, EYE_OPEN),
(EYE_CLOSED, EYE_OPEN),
(EYE_OPEN, EYE_OPEN),
(EYE_OPEN, EYE_CLOSED),
(EYE_OPEN, EYE_OPEN)
]
for left_eye, right_eye in sequences:
self.clear()
self.draw_eye(self.matrix1, left_eye, color)
self.draw_eye(self.matrix2, right_eye, color)
self.show()
time.sleep(0.3)
def blink_time(self, time_str, color, is_pm=False, count=3):
"""Blink time display for mode changes"""
for _ in range(count):
self.clear()
self.show()
time.sleep(0.2)
self.draw_time(time_str, color, is_pm)
time.sleep(0.2)
# Initialize display handler
display = Display(matrix1, matrix2)
# State variables
class State:
"""Track all state variables"""
def __init__(self):
self.color_value = 0
self.color = colorwheel(0)
self.is_pm = False
self.alarm_is_pm = False
self.time_str = "00:00"
self.set_alarm = 0
self.active_alarm = False
self.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
self.current_brightness = BRIGHTNESS_DAY
# Timers
self.refresh_timer = Timer(3600000) # 1 hour
self.clock_timer = Timer(1000) # 1 second
self.wink_timer = Timer(30000) # 30 seconds
self.scroll_timer = Timer(80) # Scroll speed
self.blink_timer = Timer(500) # Blink speed
self.alarm_status_timer = Timer(100) # Status scroll
# Display state
self.scroll_offset = 0
self.blink_state = True
self.showing_status = False
self.status_start_time = 0
self.alarm_start_time = 0
# Time tracking
self.first_run = True
self.seconds = 0
self.mins = 0
self.am_pm_hour = 0
class Timer:
"""Simple timer helper"""
def __init__(self, interval):
self.interval = interval
self.last_tick = ticks_ms()
def check(self):
"""Check if timer has elapsed"""
if ticks_diff(ticks_ms(), self.last_tick) >= self.interval:
self.last_tick = ticks_add(self.last_tick, self.interval)
return True
return False
def reset(self):
"""Reset timer"""
self.last_tick = ticks_ms()
# Initialize state
state = State()
def format_time_display(hour_24, minute, use_12hr=True):
"""Format time for display with AM/PM detection"""
if use_12hr:
hour = hour_24 % 12
if hour == 0:
hour = 12
is_pm = hour_24 >= 12
else:
hour = hour_24
is_pm = False
return f"{hour:02}:{minute:02}", is_pm
def sync_time():
"""Sync with NTP server"""
try:
print("Getting time from internet!")
now = ntp.datetime
state.am_pm_hour = now.tm_hour
state.mins = now.tm_min
state.seconds = now.tm_sec
state.time_str, state.is_pm = format_time_display(state.am_pm_hour, state.mins, hour_12)
update_brightness(state.am_pm_hour)
if not state.active_alarm and not state.showing_status:
display.draw_time(state.time_str, state.color, state.is_pm)
print(f"Time: {state.time_str}")
state.first_run = False
return True
except Exception as e: # pylint: disable=broad-except
print(f"Error syncing time: {e}")
return False
# Main loop
while True:
button.update()
# Handle button presses
if button.long_press:
if state.set_alarm == 0 and not state.active_alarm:
# Enter alarm setting mode
state.blink_timer.reset()
state.set_alarm = 1
state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
display.blink_time(hour_str[:2] + ": ", state.color, state.alarm_is_pm)
# Draw the alarm hour after blinking to keep it displayed
display.draw_time(hour_str[:2] + ": ", state.color, state.alarm_is_pm)
elif state.active_alarm:
# Stop alarm
mixer.voice[0].stop()
state.active_alarm = False
update_brightness(state.am_pm_hour)
state.scroll_offset = 0
# Immediately redraw the current time
display.draw_time(state.time_str, state.color, state.is_pm)
print("Alarm silenced")
if button.short_count == 1: # Changed from == 1 to >= 1 for better detection
# Cycle through alarm setting modes
state.set_alarm = (state.set_alarm + 1) % 3
if state.set_alarm == 0:
# Exiting alarm setting mode - redraw current time
state.wink_timer.reset()
display.draw_time(state.time_str, state.color, state.is_pm)
elif state.set_alarm == 1:
# Entering hour setting
hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
display.draw_time(hour_str[:2] + ": ", state.color, state.alarm_is_pm)
# Reset timer to prevent immediate blinking
elif state.set_alarm == 2:
# Entering minute setting
display.blink_time(f" :{alarm_min:02}", state.color, state.alarm_is_pm)
# Draw the minutes after blinking to keep them displayed
display.draw_time(f" :{alarm_min:02}", state.color, state.alarm_is_pm)
# Reset timer to prevent immediate blinking
if button.short_count == 3: # Changed for better detection
# Toggle alarm on/off
no_alarm_plz = not no_alarm_plz
print(f"Alarm disabled: {no_alarm_plz}")
state.showing_status = True
state.status_start_time = ticks_ms()
state.scroll_offset = 0
# Handle encoder (your existing code)
position = -encoder.position
if position != last_position:
delta = 1 if position > last_position else -1
if state.set_alarm == 0:
# Change color
state.color_value = (state.color_value + delta * 5) % 255
state.color = colorwheel(state.color_value)
display.draw_time(state.time_str, state.color, state.is_pm)
elif state.set_alarm == 1:
# Change hour
alarm_hour = (alarm_hour + delta) % 24
state.alarm_is_pm = alarm_hour >= 12 if hour_12 else False
hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
display.draw_time(hour_str[:2] + ": ", state.color, state.alarm_is_pm)
elif state.set_alarm == 2:
# Change minute
alarm_min = (alarm_min + delta) % 60
display.draw_time(f" :{alarm_min:02}", state.color, state.alarm_is_pm)
state.alarm_str = f"{alarm_hour:02}:{alarm_min:02}"
last_position = position
# Handle alarm status display
if state.showing_status:
if state.alarm_status_timer.check():
status_text = "OFF " if no_alarm_plz else "ON "
display.draw_scrolling_text(status_text, state.scroll_offset, state.color)
text_width = 4*6 if no_alarm_plz else 3*6
state.scroll_offset += 1
# Reset when text has completely scrolled off
if state.scroll_offset > text_width + 18:
state.scroll_offset = 0
state.showing_status = False
if state.set_alarm == 0 and not state.active_alarm:
display.draw_time(state.time_str, state.color, state.is_pm)
# Handle active alarm scrolling
if state.active_alarm:
# Auto-silence alarm after 1 minute
if ticks_diff(ticks_ms(), state.alarm_start_time) >= 60000:
mixer.voice[0].stop()
state.active_alarm = False
update_brightness(state.am_pm_hour)
state.scroll_offset = 0
display.draw_time(state.time_str, state.color, state.is_pm)
print("Alarm auto-silenced")
elif state.scroll_timer.check():
display.draw_scrolling_text("WAKE UP ", state.scroll_offset, state.color)
text_width = 8 * 6 # "WAKE UP " is 8 characters
state.scroll_offset += 1
# Reset when text has completely scrolled off
if state.scroll_offset > text_width + 26:
state.scroll_offset = 0
# Handle alarm setting mode blinking
elif state.set_alarm > 0:
# Only blink if enough time has passed since mode change
if state.blink_timer.check():
state.blink_state = not state.blink_state
if state.blink_state:
# Redraw during the "on" part of blink
if state.set_alarm == 1:
hour_str, _ = format_time_display(alarm_hour, 0, hour_12)
display.draw_time(hour_str[:2] + ": ", state.color, state.alarm_is_pm)
else:
display.draw_time(f" :{alarm_min:02}", state.color, state.alarm_is_pm)
else:
# Only clear display during the "off" part of blink
display.clear()
display.show()
# Normal mode operations
else: # state.set_alarm == 0
# Winking animation
if not state.active_alarm and not state.showing_status and state.wink_timer.check():
print("Winking!")
display.wink_animation(state.color)
display.draw_time(state.time_str, state.color, state.is_pm)
# Time sync
if state.refresh_timer.check() or state.first_run:
if not sync_time():
time.sleep(10)
microcontroller.reset()
# Local timekeeping
if state.clock_timer.check():
state.seconds += 1
if state.seconds > 59:
state.seconds = 0
state.mins += 1
if state.mins > 59:
state.mins = 0
state.am_pm_hour = (state.am_pm_hour + 1) % 24
update_brightness(state.am_pm_hour)
# Update display
state.time_str, state.is_pm = format_time_display(state.am_pm_hour,
state.mins, hour_12)
if not state.active_alarm and not state.showing_status:
display.draw_time(state.time_str, state.color, state.is_pm)
# Check alarm
if f"{state.am_pm_hour:02}:{state.mins:02}" == state.alarm_str and not no_alarm_plz:
print("ALARM!")
wave = open_audio()
mixer.voice[0].play(wave, loop=True)
state.active_alarm = True
state.alarm_start_time = ticks_ms()
state.scroll_offset = 0

Binary file not shown.

Binary file not shown.

View file

@ -172,7 +172,7 @@ bool Adafruit_PyCamera::initSD(void) {
}
Serial.println("Card successfully initialized");
uint32_t size = sd.card()->cardSize();
uint32_t size = sd.card()->sectorCount();
if (size == 0) {
Serial.println("Can't determine the card size");
} else {
@ -497,7 +497,7 @@ bool Adafruit_PyCamera::takePhoto(const char *filename_base,
return false;
}
if (!sd.card() || (sd.card()->cardSize() == 0)) {
if (!sd.card() || (sd.card()->sectorCount() == 0)) {
Serial.println("No SD card found");
// try to initialize?
if (!initSD())

View file

@ -6,7 +6,7 @@
#include <Adafruit_AW9523.h>
#include <Adafruit_NeoPixel.h>
#include <Adafruit_ST7789.h> // Hardware-specific library for ST7789
#include <SdFat.h>
#include <SdFat_Adafruit_Fork.h>
#ifndef TAG
#define TAG "PYCAM"

View file

@ -172,7 +172,7 @@ bool Adafruit_PyCamera::initSD(void) {
}
Serial.println("Card successfully initialized");
uint32_t size = sd.card()->cardSize();
uint32_t size = sd.card()->sectorCount();
if (size == 0) {
Serial.println("Can't determine the card size");
} else {
@ -497,7 +497,7 @@ bool Adafruit_PyCamera::takePhoto(const char *filename_base,
return false;
}
if (!sd.card() || (sd.card()->cardSize() == 0)) {
if (!sd.card() || (sd.card()->sectorCount() == 0)) {
Serial.println("No SD card found");
// try to initialize?
if (!initSD())

View file

@ -17,7 +17,9 @@ from tilepalettemapper import TilePaletteMapper
from adafruit_fruitjam.peripherals import request_display_config
import adafruit_imageload
request_display_config(320,240)
# use the built-in HSTX display
request_display_config(320, 240)
display = supervisor.runtime.display
# screen size in tiles, tiles are 16x16
@ -65,7 +67,12 @@ for i in range(0, len(COLORS)):
shader_palette[i + 1] = COLORS[i]
# mapper to change colors of tiles within the grid
grid_color_shader = TilePaletteMapper(shader_palette, 2, SCREEN_WIDTH, SCREEN_HEIGHT)
if sys.implementation.version[0] == 9:
grid_color_shader = TilePaletteMapper(
shader_palette, 2, SCREEN_WIDTH, SCREEN_HEIGHT
)
elif sys.implementation.version[0] >= 10:
grid_color_shader = TilePaletteMapper(shader_palette, 2)
# load the spritesheet
katakana_bmp, katakana_pixelshader = adafruit_imageload.load("matrix_characters.bmp")

View file

@ -9,6 +9,7 @@ import array
import atexit
import io
import os
import sys
import time
import board
@ -217,9 +218,11 @@ for i in range(2):
# create tile palette mappers
for i in range(2):
palette_mapper = TilePaletteMapper(remap_palette, 3, 1, 1)
# remap index 2 to each of the colors in mouse colors list
palette_mapper[0] = [0, 1, i + 3]
if sys.implementation.version[0] == 9:
palette_mapper = TilePaletteMapper(remap_palette, 3, 1, 1)
elif sys.implementation.version[0] >= 10:
palette_mapper = TilePaletteMapper(remap_palette, 3)
palette_mappers.append(palette_mapper)
# create tilegrid for each mouse
@ -228,6 +231,9 @@ for i in range(2):
mouse_tg.y = display.height // scale_factor // 2
mouse_tgs.append(mouse_tg)
# remap index 2 to each of the colors in mouse colors list
palette_mapper[0] = [0, 1, i + 3]
# USB info lists
mouse_interface_indexes = []
mouse_endpoint_addresses = []

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: MIT
import os
import random
import sys
import time
from io import BytesIO
@ -133,7 +134,10 @@ class Match3Card(Group):
def __init__(self, card_tuple, **kwargs):
# tile palette mapper to color the card
self._mapper = TilePaletteMapper(kwargs["pixel_shader"], 5, 1, 1)
if sys.implementation.version[0] == 9:
self._mapper = TilePaletteMapper(kwargs["pixel_shader"], 5, 1, 1)
elif sys.implementation.version[0] >= 10:
self._mapper = TilePaletteMapper(kwargs["pixel_shader"], 5)
kwargs["pixel_shader"] = self._mapper
# tile grid to for the visible sprite
self._tilegrid = TileGrid(**kwargs)
@ -580,9 +584,11 @@ class Match3Game(Group):
# if 3 cards have been clicked
if len(self.clicked_cards) == 3:
# check if the 3 cards make a valid set
valid_set = validate_set(self.clicked_cards[0],
self.clicked_cards[1],
self.clicked_cards[2])
valid_set = validate_set(
self.clicked_cards[0],
self.clicked_cards[1],
self.clicked_cards[2],
)
# if they are a valid set
if valid_set:
@ -660,7 +666,7 @@ class Match3Game(Group):
# load the game from the given game state
self.load_from_game_state(self.game_state)
# hide the title screen
self.title_screen.hidden = True # pylint: disable=attribute-defined-outside-init
self.title_screen.hidden = True
# set the current state to open play
self.cur_state = STATE_PLAYING_OPEN
@ -676,7 +682,7 @@ class Match3Game(Group):
# initialize a new game
self.init_new_game()
# hide the title screen
self.title_screen.hidden = True # pylint: disable=attribute-defined-outside-init
self.title_screen.hidden = True
# set the current state to open play
self.cur_state = STATE_PLAYING_OPEN
@ -727,6 +733,7 @@ class Match3TitleScreen(Group):
def __init__(self, display_size):
super().__init__()
self.hidden = False
self.display_size = display_size
# background bitmap color
bg_bmp = Bitmap(display_size[0] // 10, display_size[1] // 10, 1)

View file

@ -1,6 +1,7 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 Tim Cocks for Adafruit Industries
#
# SPDX-License-Identifier: MIT
import sys
import supervisor
from displayio import Group, OnDiskBitmap, TileGrid
@ -20,16 +21,28 @@ display.root_group = main_group
spritesheet_bmp = OnDiskBitmap("match3_cards_spritesheet.bmp")
# create a TilePaletteMapper
tile_palette_mapper = TilePaletteMapper(
spritesheet_bmp.pixel_shader, # input pixel_shader
5, # input color count
3, # grid width
1 # grid height
)
if sys.implementation.version[0] == 9:
tile_palette_mapper = TilePaletteMapper(
spritesheet_bmp.pixel_shader, # input pixel_shader
5, # input color count
3, # grid width
1, # grid height
)
elif sys.implementation.version[0] >= 10:
tile_palette_mapper = TilePaletteMapper(
spritesheet_bmp.pixel_shader, # input pixel_shader
5, # input color count
)
# create a TileGrid to show some cards
cards_tilegrid = TileGrid(spritesheet_bmp, pixel_shader=tile_palette_mapper,
width=3, height=1, tile_width=24, tile_height=32)
cards_tilegrid = TileGrid(
spritesheet_bmp,
pixel_shader=tile_palette_mapper,
width=3,
height=1,
tile_width=24,
tile_height=32,
)
# set each tile in the grid to a different sprite index
cards_tilegrid[0, 0] = 10

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1,264 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
"""
An implementation of a match3 jewel swap game. The idea is to move one character at a time
to line up at least 3 characters.
"""
import time
from displayio import Group, OnDiskBitmap, TileGrid, Bitmap, Palette
from adafruit_display_text.bitmap_label import Label
from adafruit_display_text.text_box import TextBox
from eventbutton import EventButton
import supervisor
import terminalio
from adafruit_usb_host_mouse import find_and_init_boot_mouse
from gamelogic import GameLogic, SELECTOR_SPRITE, EMPTY_SPRITE, GAMEBOARD_POSITION
GAMEBOARD_SIZE = (8, 7)
HINT_TIMEOUT = 10 # seconds before hint is shown
GAME_PIECES = 7 # Number of different game pieces (set between 3 and 8)
# pylint: disable=ungrouped-imports
if hasattr(supervisor.runtime, "display") and supervisor.runtime.display is not None:
# use the built-in HSTX display for Metro RP2350
display = supervisor.runtime.display
else:
# pylint: disable=ungrouped-imports
from displayio import release_displays
import picodvi
import board
import framebufferio
# initialize display
release_displays()
fb = picodvi.Framebuffer(
320,
240,
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,
)
display = framebufferio.FramebufferDisplay(fb)
def get_color_index(color, shader=None):
for index, palette_color in enumerate(shader):
if palette_color == color:
return index
return None
# Load the spritesheet
sprite_sheet = OnDiskBitmap("/bitmaps/game_sprites.bmp")
sprite_sheet.pixel_shader.make_transparent(
get_color_index(0x00ff00, sprite_sheet.pixel_shader)
)
# Main group will hold all the visual layers
main_group = Group()
display.root_group = main_group
# Add Background to the Main Group
background = Bitmap(display.width, display.height, 1)
bg_color = Palette(1)
bg_color[0] = 0x333333
main_group.append(TileGrid(
background,
pixel_shader=bg_color
))
# Add Game grid, which holds the game board, to the main group
game_grid = TileGrid(
sprite_sheet,
pixel_shader=sprite_sheet.pixel_shader,
width=GAMEBOARD_SIZE[0],
height=GAMEBOARD_SIZE[1],
tile_width=32,
tile_height=32,
x=GAMEBOARD_POSITION[0],
y=GAMEBOARD_POSITION[1],
default_tile=EMPTY_SPRITE,
)
main_group.append(game_grid)
# Add a special selection groupd to highlight the selected piece and allow animation
selected_piece_group = Group()
selected_piece = TileGrid(
sprite_sheet,
pixel_shader=sprite_sheet.pixel_shader,
width=1,
height=1,
tile_width=32,
tile_height=32,
x=0,
y=0,
default_tile=EMPTY_SPRITE,
)
selected_piece_group.append(selected_piece)
selector = TileGrid(
sprite_sheet,
pixel_shader=sprite_sheet.pixel_shader,
width=1,
height=1,
tile_width=32,
tile_height=32,
x=0,
y=0,
default_tile=SELECTOR_SPRITE,
)
selected_piece_group.append(selector)
selected_piece_group.hidden = True
main_group.append(selected_piece_group)
# Add a group for the swap piece to help with animation
swap_piece = TileGrid(
sprite_sheet,
pixel_shader=sprite_sheet.pixel_shader,
width=1,
height=1,
tile_width=32,
tile_height=32,
x=0,
y=0,
default_tile=EMPTY_SPRITE,
)
swap_piece.hidden = True
main_group.append(swap_piece)
# Add foreground
foreground_bmp = OnDiskBitmap("/bitmaps/foreground.bmp")
foreground_bmp.pixel_shader.make_transparent(0)
foreground_tg = TileGrid(foreground_bmp, pixel_shader=foreground_bmp.pixel_shader)
foreground_tg.x = 0
foreground_tg.y = 0
main_group.append(foreground_tg)
# Add a group for the UI Elements
ui_group = Group()
main_group.append(ui_group)
# Create the mouse graphics and add to the main group
time.sleep(1) # Allow time for USB host to initialize
mouse = find_and_init_boot_mouse("/bitmaps/mouse_cursor.bmp")
if mouse is None:
raise RuntimeError("No mouse found connected to USB Host")
main_group.append(mouse.tilegrid)
# Create the game logic object
# pylint: disable=no-value-for-parameter, too-many-function-args
game_logic = GameLogic(
display,
mouse,
game_grid,
swap_piece,
selected_piece_group,
GAME_PIECES,
HINT_TIMEOUT
)
def update_ui():
# Update the UI elements with the current game state
score_label.text = f"Score:\n{game_logic.score}"
waiting_for_release = False
game_over_shown = False
# Create the UI Elements
# Label for the Score
score_label = Label(
terminalio.FONT,
color=0xffff00,
x=5,
y=10,
)
ui_group.append(score_label)
message_dialog = Group()
message_dialog.hidden = True
def reset():
global game_over_shown # pylint: disable=global-statement
# Reset the game logic
game_logic.reset()
message_dialog.hidden = True
game_over_shown = False
def hide_group(group):
group.hidden = True
reset()
reset_button = EventButton(
reset,
label="Reset",
width=40,
height=16,
x=5,
y=50,
style=EventButton.RECT,
)
ui_group.append(reset_button)
message_label = TextBox(
terminalio.FONT,
text="",
color=0x333333,
background_color=0xEEEEEE,
width=display.width // 3,
height=90,
align=TextBox.ALIGN_CENTER,
padding_top=5,
)
message_label.anchor_point = (0, 0)
message_label.anchored_position = (
display.width // 2 - message_label.width // 2,
display.height // 2 - message_label.height // 2,
)
message_dialog.append(message_label)
message_button = EventButton(
(hide_group, message_dialog),
label="OK",
width=40,
height=16,
x=display.width // 2 - 20,
y=display.height // 2 - message_label.height // 2 + 60,
style=EventButton.RECT,
)
message_dialog.append(message_button)
ui_group.append(message_dialog)
# main loop
while True:
update_ui()
# update mouse
game_logic.update_mouse()
if not message_dialog.hidden:
if message_button.handle_mouse(
(mouse.x, mouse.y),
game_logic.pressed_btns and "left" in game_logic.pressed_btns,
waiting_for_release
):
game_logic.waiting_for_release = True
continue
if reset_button.handle_mouse(
(mouse.x, mouse.y),
game_logic.pressed_btns is not None and "left" in game_logic.pressed_btns,
game_logic.waiting_for_release
):
game_logic.waiting_for_release = True
# process gameboard click if no menu
game_logic.update()
game_over = game_logic.check_for_game_over()
if game_over and not game_over_shown:
message_label.text = ("No more moves available. your final score is:\n"
+ str(game_logic.score))
message_dialog.hidden = False
game_over_shown = True

View file

@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
from adafruit_button import Button
class EventButton(Button):
"""A button that can be used to trigger a callback when clicked.
:param callback: The callback function to call when the button is clicked.
A tuple can be passed with an argument that will be passed to the
callback function. The first element of the tuple should be the
callback function, and the remaining elements will be passed as
arguments to the callback function.
"""
def __init__(self, callback, *args, **kwargs):
super().__init__(*args, **kwargs)
self.args = []
self.selected = False
if isinstance(callback, tuple):
self.callback = callback[0]
self.args = callback[1:]
else:
self.callback = callback
def click(self):
"""Call the function when the button is pressed."""
self.callback(*self.args)
def handle_mouse(self, point, clicked, waiting_for_release):
if waiting_for_release:
return False
# Handle mouse events for the button
if self.contains(point):
self.selected = True
if clicked:
self.click()
return True
else:
self.selected = False
return False

View file

@ -0,0 +1,525 @@
# SPDX-FileCopyrightText: 2025 Melissa LeBlanc-Williams for Adafruit Industries
# SPDX-License-Identifier: MIT
import random
import time
from adafruit_ticks import ticks_ms
GAMEBOARD_POSITION = (55, 8)
SELECTOR_SPRITE = 9
EMPTY_SPRITE = 10
DEBOUNCE_TIME = 0.2 # seconds for debouncing mouse clicks
class GameBoard:
"Contains the game board"
def __init__(self, game_grid, swap_piece, selected_piece_group):
self.x = GAMEBOARD_POSITION[0]
self.y = GAMEBOARD_POSITION[1]
self._game_grid = game_grid
self._selected_coords = None
self._selected_piece = selected_piece_group[0]
self._selector = selected_piece_group[1]
self._swap_piece = swap_piece
self.selected_piece_group = selected_piece_group
def add_game_piece(self, column, row, piece_type):
if 0 <= column < self.columns and 0 <= row < self.rows:
if self._game_grid[(column, row)] != EMPTY_SPRITE:
raise ValueError("Position already occupied")
self._game_grid[(column, row)] = piece_type
else:
raise IndexError("Position out of bounds")
def remove_game_piece(self, column, row):
if 0 <= column < self.columns and 0 <= row < self.rows:
self._game_grid[(column, row)] = EMPTY_SPRITE
else:
raise IndexError("Position out of bounds")
def reset(self):
for column in range(self.columns):
for row in range(self.rows):
if self._game_grid[(column, row)] != EMPTY_SPRITE:
self.remove_game_piece(column, row)
# Hide the animation TileGrids
self._selector.hidden = True
self._swap_piece.hidden = True
self.selected_piece_group.hidden = True
def move_game_piece(self, old_x, old_y, new_x, new_y):
if 0 <= old_x < self.columns and 0 <= old_y < self.rows:
if 0 <= new_x < self.columns and 0 <= new_y < self.rows:
if self._game_grid[(new_x, new_y)] == EMPTY_SPRITE:
self._game_grid[(new_x, new_y)] = self._game_grid[(old_x, old_y)]
self._game_grid[(old_x, old_y)] = EMPTY_SPRITE
else:
raise ValueError("New position already occupied")
else:
raise IndexError("New position out of bounds")
else:
raise IndexError("Old position out of bounds")
@property
def columns(self):
return self._game_grid.width
@property
def rows(self):
return self._game_grid.height
@property
def selected_piece(self):
if self._selected_coords is not None and self._selected_piece[0] != EMPTY_SPRITE:
return self._selected_piece[0]
return None
@property
def swap_piece(self):
return self._swap_piece
def set_swap_piece(self, column, row):
# Set the swap piece to the piece at the specified coordinates
piece = self.get_piece(column, row)
if self._swap_piece[0] is None and self._swap_piece[0] == EMPTY_SPRITE:
raise ValueError("Can't swap an empty piece")
if self._swap_piece.hidden:
self._swap_piece[0] = piece
self._swap_piece.x = column * 32 + self.x
self._swap_piece.y = row * 32 + self.y
self._swap_piece.hidden = False
self._game_grid[(column, row)] = EMPTY_SPRITE
else:
self._game_grid[(column, row)] = self._swap_piece[0]
self._swap_piece[0] = EMPTY_SPRITE
self._swap_piece.hidden = True
@property
def selected_coords(self):
if self._selected_coords is not None:
return self._selected_coords
return None
@property
def selector_hidden(self):
return self._selector.hidden
@selector_hidden.setter
def selector_hidden(self, value):
# Set the visibility of the selector
self._selector.hidden = value
def set_selected_coords(self, column, row):
# Set the selected coordinates to the specified column and row
if 0 <= column < self.columns and 0 <= row < self.rows:
self._selected_coords = (column, row)
self.selected_piece_group.x = column * 32 + self.x
self.selected_piece_group.y = row * 32 + self.y
else:
raise IndexError("Selected coordinates out of bounds")
def select_piece(self, column, row, show_selector=True):
# Take care of selecting a piece
piece = self.get_piece(column, row)
if self.selected_piece is None and piece == EMPTY_SPRITE:
# If no piece is selected and the clicked piece is empty, do nothing
return
if (self.selected_piece is not None and
(self._selected_coords[0] != column or self._selected_coords[1] != row)):
# If a piece is already selected and the coordinates don't match, do nothing
return
if self.selected_piece is None:
# No piece selected, so select the specified piece
self._selected_piece[0] = self.get_piece(column, row)
self._selected_coords = (column, row)
self.selected_piece_group.x = column * 32 + self.x
self.selected_piece_group.y = row * 32 + self.y
self.selected_piece_group.hidden = False
self.selector_hidden = not show_selector
self._game_grid[(column, row)] = EMPTY_SPRITE
else:
self._game_grid[(column, row)] = self._selected_piece[0]
self._selected_piece[0] = EMPTY_SPRITE
self.selected_piece_group.hidden = True
self._selected_coords = None
def get_piece(self, column, row):
if 0 <= column < self.columns and 0 <= row < self.rows:
return self._game_grid[(column, row)]
return None
@property
def game_grid_copy(self):
# Return a copy of the game grid as a 2D list
return [[self._game_grid[(x, y)] for x in range(self.columns)] for y in range(self.rows)]
class GameLogic:
"Contains the Logic to examine the game board and determine if a move is valid."
def __init__(self, display, mouse, game_grid, swap_piece,
selected_piece_group, game_pieces, hint_timeout):
self._display = display
self._mouse = mouse
self.game_board = GameBoard(game_grid, swap_piece, selected_piece_group)
self._score = 0
self._available_moves = []
if not 3 <= game_pieces <= 8:
raise ValueError("game_pieces must be between 3 and 8")
self._game_pieces = game_pieces # Number of different game pieces
self._hint_timeout = hint_timeout
self._last_update_time = ticks_ms() # For hint timing
self._last_click_time = ticks_ms() # For debouncing mouse clicks
self.pressed_btns = None
self.waiting_for_release = False
def update_mouse(self):
self.pressed_btns = self._mouse.update()
if self.waiting_for_release and not self.pressed_btns:
# If both buttons are released, we can process the next click
self.waiting_for_release = False
def update(self):
gb = self.game_board
if (gb.x <= self._mouse.x <= gb.x + gb.columns * 32 and
gb.y <= self._mouse.y <= gb.y + gb.rows * 32 and
not self.waiting_for_release):
piece_coords = ((self._mouse.x - gb.x) // 32, (self._mouse.y - gb.y) // 32)
if self.pressed_btns and "left" in self.pressed_btns:
self._piece_clicked(piece_coords)
self.waiting_for_release = True
if self.time_since_last_update > self._hint_timeout:
self.show_hint()
def _piece_clicked(self, coords):
""" Handle a piece click event. """
if ticks_ms() <= self._last_click_time:
self._last_click_time -= 2**29 # ticks_ms() wraps around after 2**29 ms
if ticks_ms() <= self._last_click_time + (DEBOUNCE_TIME * 1000):
print("Debouncing click, too soon after last click.")
return
self._last_click_time = ticks_ms() # Update last click time
column, row = coords
self._last_update_time = ticks_ms()
# Check if the clicked piece is valid
if not 0 <= column < self.game_board.columns or not 0 <= row < self.game_board.rows:
print(f"Clicked coordinates ({column}, {row}) are out of bounds.")
return
# If clicked piece is empty and no piece is selected, do nothing
if (self.game_board.get_piece(column, row) == EMPTY_SPRITE and
self.game_board.selected_piece is None):
print(f"No piece at ({column}, {row}) and no piece selected.")
return
if self.game_board.selected_piece is None:
# If no piece is selected, select the piece at the clicked coordinates
self.game_board.select_piece(column, row)
return
if (self.game_board.selected_coords is not None and
(self.game_board.selected_coords[0] == column and
self.game_board.selected_coords[1] == row)):
# If the clicked piece is already selected, deselect it
self.game_board.select_piece(column, row)
return
# If piece is selected and the new coordinates are 1 position
# away horizontally or vertically, swap the pieces
if self.game_board.selected_coords is not None:
previous_x, previous_y = self.game_board.selected_coords
if ((abs(previous_x - column) == 1 and previous_y == row) or
(previous_x == column and abs(previous_y - row) == 1)):
# Swap the pieces
self._swap_selected_piece(column, row)
def show_hint(self):
""" Show a hint by selecting a random available
move and swapping the pieces back and forth. """
if self._available_moves:
move = random.choice(self._available_moves)
from_coords = move['from']
to_coords = move['to']
self.game_board.select_piece(from_coords[0], from_coords[1])
self._animate_swap(to_coords[0], to_coords[1])
self.game_board.select_piece(from_coords[0], from_coords[1])
self._animate_swap(to_coords[0], to_coords[1])
self._last_update_time = ticks_ms() # Reset hint timer
def _swap_selected_piece(self, column, row):
""" Swap the selected piece with the piece at the specified column and row.
If the swap is not valid, revert to the previous selection. """
old_coords = self.game_board.selected_coords
self._animate_swap(column, row)
if not self._update_board():
self.game_board.select_piece(column, row, show_selector=False)
self._animate_swap(old_coords[0], old_coords[1])
def _animate_swap(self, column, row):
""" Copy the pieces to separate tilegrids, animate the swap, and update the game board. """
if 0 <= column < self.game_board.columns and 0 <= row < self.game_board.rows:
selected_coords = self.game_board.selected_coords
if selected_coords is None:
print("No piece selected to swap.")
return
# Set the swap piece value to the column, row value
self.game_board.set_swap_piece(column, row)
self.game_board.selector_hidden = True
# Calculate the steps for animation to move the pieces in the correct direction
selected_piece_steps = (
(self.game_board.swap_piece.x - self.game_board.selected_piece_group.x) // 32,
(self.game_board.swap_piece.y - self.game_board.selected_piece_group.y) // 32
)
swap_piece_steps = (
(self.game_board.selected_piece_group.x - self.game_board.swap_piece.x) // 32,
(self.game_board.selected_piece_group.y - self.game_board.swap_piece.y) // 32
)
# Move the tilegrids in small steps to create an animation effect
for _ in range(32):
# Move the selected piece tilegrid to the swap piece position
self.game_board.selected_piece_group.x += selected_piece_steps[0]
self.game_board.selected_piece_group.y += selected_piece_steps[1]
# Move the swap piece tilegrid to the selected piece position
self.game_board.swap_piece.x += swap_piece_steps[0]
self.game_board.swap_piece.y += swap_piece_steps[1]
time.sleep(0.002)
# Set the existing selected piece coords to the swap piece value
self.game_board.set_swap_piece(selected_coords[0], selected_coords[1])
# Update the selected piece coordinates to the new column, row
self.game_board.set_selected_coords(column, row)
# Deselect the selected piece (which sets the value)
self.game_board.select_piece(column, row)
def _apply_gravity(self):
""" Go through each column from the bottom up and move pieces down
continue until there are no more pieces to move """
# pylint:disable=too-many-nested-blocks
while True:
self.pressed_btns = self._mouse.update()
moved = False
for x in range(self.game_board.columns):
for y in range(self.game_board.rows - 1, -1, -1):
piece = self.game_board.get_piece(x, y)
if piece != EMPTY_SPRITE:
# Check if the piece can fall
for new_y in range(y + 1, self.game_board.rows):
if self.game_board.get_piece(x, new_y) == EMPTY_SPRITE:
# Move the piece down
self.game_board.move_game_piece(x, y, x, new_y)
moved = True
break
# If the piece was in the top slot before falling, add a new piece
if y == 0 and self.game_board.get_piece(x, 0) == EMPTY_SPRITE:
self.game_board.add_game_piece(x, 0, random.randint(0, self._game_pieces))
moved = True
if not moved:
break
def _check_for_matches(self):
""" Scan the game board for matches of 3 or more in a row or column """
matches = []
for x in range(self.game_board.columns):
for y in range(self.game_board.rows):
piece = self.game_board.get_piece(x, y)
if piece != EMPTY_SPRITE:
# Check horizontal matches
horizontal_match = [(x, y)]
for dx in range(1, 3):
if (x + dx < self.game_board.columns and
self.game_board.get_piece(x + dx, y) == piece):
horizontal_match.append((x + dx, y))
else:
break
if len(horizontal_match) >= 3:
matches.append(horizontal_match)
# Check vertical matches
vertical_match = [(x, y)]
for dy in range(1, 3):
if (y + dy < self.game_board.rows and
self.game_board.get_piece(x, y + dy) == piece):
vertical_match.append((x, y + dy))
else:
break
if len(vertical_match) >= 3:
matches.append(vertical_match)
return matches
def _update_board(self):
""" Update the game logic, check for matches, and apply gravity. """
matches_found = False
multiplier = 1
matches = self._check_for_matches()
while matches:
if matches:
for match in matches:
for x, y in match:
self.game_board.remove_game_piece(x, y)
self._score += 10 * multiplier * len(matches) * (len(match) - 2)
time.sleep(0.5) # Pause to show the match removal
self._apply_gravity()
matches_found = True
matches = self._check_for_matches()
multiplier += 1
self._available_moves = self._find_all_possible_matches()
print(f"{len(self._available_moves)} available moves found.")
return matches_found
def reset(self):
""" Reset the game board and score. """
print("Reset started")
self.game_board.reset()
self._score = 0
self._last_update_time = ticks_ms()
self._apply_gravity()
self._update_board()
print("Reset completed")
def _check_match_after_move(self, row, column, direction, move_type='horizontal'):
""" Move the piece in a copy of the board to see if it creates a match."""
if move_type == 'horizontal':
new_row, new_column = row, column + direction
else: # vertical
new_row, new_column = row + direction, column
# Check if move is within bounds
if (new_row < 0 or new_row >= self.game_board.rows or
new_column < 0 or new_column >= self.game_board.columns):
return False, False
# Create a copy of the grid with the moved piece
new_grid = self.game_board.game_grid_copy
piece = new_grid[row][column]
new_grid[row][column], new_grid[new_row][new_column] = new_grid[new_row][new_column], piece
# Check for horizontal matches at the new position
horizontal_match = self._check_horizontal_match(new_grid, new_row, new_column, piece)
# Check for vertical matches at the new position
vertical_match = self._check_vertical_match(new_grid, new_row, new_column, piece)
# Also check the original position for matches after the swap
original_piece = new_grid[row][column]
horizontal_match_orig = self._check_horizontal_match(new_grid, row, column, original_piece)
vertical_match_orig = self._check_vertical_match(new_grid, row, column, original_piece)
all_matches = (horizontal_match + vertical_match +
horizontal_match_orig + vertical_match_orig)
return True, len(all_matches) > 0
@staticmethod
def _check_horizontal_match(grid, row, column, piece):
"""Check for horizontal 3-in-a-row matches centered
around or including the given position."""
matches = []
columns = len(grid[0])
# Check all possible 3-piece horizontal combinations that include this position
for start_column in range(max(0, column - 2), min(columns - 2, column + 1)):
if (start_column + 2 < columns and
grid[row][start_column] == piece and
grid[row][start_column + 1] == piece and
grid[row][start_column + 2] == piece):
matches.append([(row, start_column),
(row, start_column + 1),
(row, start_column + 2)])
return matches
@staticmethod
def _check_vertical_match(grid, row, column, piece):
"""Check for vertical 3-in-a-row matches centered around or including the given position."""
matches = []
rows = len(grid)
# Check all possible 3-piece vertical combinations that include this position
for start_row in range(max(0, row - 2), min(rows - 2, row + 1)):
if (start_row + 2 < rows and
grid[start_row][column] == piece and
grid[start_row + 1][column] == piece and
grid[start_row + 2][column] == piece):
matches.append([(start_row, column),
(start_row + 1, column),
(start_row + 2, column)])
return matches
def check_for_game_over(self):
""" Check if there are no available moves left on the game board. """
if not self._available_moves:
return True
return False
def _find_all_possible_matches(self):
"""
Scan the entire game board to find all possible moves that would create a 3-in-a-row match.
"""
possible_moves = []
for row in range(self.game_board.rows):
for column in range(self.game_board.columns):
# Check move right
can_move, creates_match = self._check_match_after_move(
row, column, 1, 'horizontal')
if can_move and creates_match:
possible_moves.append({
'from': (column, row),
'to': (column + 1, row),
})
# Check move left
can_move, creates_match = self._check_match_after_move(
row, column, -1, 'horizontal')
if can_move and creates_match:
possible_moves.append({
'from': (column, row),
'to': (column - 1, row),
})
# Check move down
can_move, creates_match = self._check_match_after_move(
row, column, 1, 'vertical')
if can_move and creates_match:
possible_moves.append({
'from': (column, row),
'to': (column, row + 1),
})
# Check move up
can_move, creates_match = self._check_match_after_move(
row, column, -1, 'vertical')
if can_move and creates_match:
possible_moves.append({
'from': (column, row),
'to': (column, row - 1),
})
# Remove duplicates because from and to can be reversed
unique_moves = set()
for move in possible_moves:
from_coords = tuple(move['from'])
to_coords = tuple(move['to'])
if from_coords > to_coords:
unique_moves.add((to_coords, from_coords))
else:
unique_moves.add((from_coords, to_coords))
possible_moves = [{'from': move[0], 'to': move[1]} for move in unique_moves]
return possible_moves
@property
def score(self):
return self._score
@property
def time_since_last_update(self):
return (ticks_ms() - self._last_update_time) / 1000.0

Some files were not shown because too many files have changed in this diff Show more