Merge branch 'refs/heads/main' into adding_icons_and_metadata
# Conflicts: # Metro/Metro_RP2350_CircuitPython_Matrix/code.py
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.
|
||||
|
|
@ -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!")
|
||||
|
|
|
|||
27
Adafruit_IO_Reed_Switch/rpi-pico-w112213141.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
83
Adafruit_IO_Template_Emails/rpi-pico-w-potty-training.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
72
Adafruit_IO_Template_Emails/template.liquid
Normal 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 %}
|
||||
0
Computer_Space/.feather_m4_express.test.only
Normal file
1120
Computer_Space/Computer_Space.ino
Normal 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);
|
||||
}
|
||||
88
Fruit_Jam/Fruit_Jam_Startups/GameBoy_Startup/code.py
Normal 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
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
107
Fruit_Jam/Fruit_Jam_Startups/Mac_Startup/code.py
Normal 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
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
116
Fruit_Jam/Larsio_Paint_Music/code.py
Executable 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}")
|
||||
353
Fruit_Jam/Larsio_Paint_Music/control_panel.py
Executable 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)
|
||||
85
Fruit_Jam/Larsio_Paint_Music/cursor_manager.py
Executable 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
|
||||
64
Fruit_Jam/Larsio_Paint_Music/display_manager.py
Executable 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
|
||||
221
Fruit_Jam/Larsio_Paint_Music/input_handler.py
Executable 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
|
||||
BIN
Fruit_Jam/Larsio_Paint_Music/lpm_icon.bmp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
4
Fruit_Jam/Larsio_Paint_Music/metadata.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"title": "LarsioPant",
|
||||
"icon": "lpm_icon.bmp"
|
||||
}
|
||||
425
Fruit_Jam/Larsio_Paint_Music/note_manager.py
Executable 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 = {}
|
||||
143
Fruit_Jam/Larsio_Paint_Music/playback_controller.py
Executable 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)
|
||||
BIN
Fruit_Jam/Larsio_Paint_Music/samples/chat_01.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/crash_01.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/kick_01.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_A4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_B3.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_B4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_C4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_C5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_D4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_D5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_E4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_E5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_F4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_F5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_G4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/larso_G5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_A4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B3.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_B4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_C5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_D5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_E5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_F5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G4.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/musicnote16_G5.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/ohat_01.wav
Executable file
BIN
Fruit_Jam/Larsio_Paint_Music/samples/snare_01.wav
Executable file
613
Fruit_Jam/Larsio_Paint_Music/sound_manager.py
Executable 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")
|
||||
219
Fruit_Jam/Larsio_Paint_Music/sprite_manager.py
Executable 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
|
||||
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/bot.bmp
Executable file
|
After Width: | Height: | Size: 246 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/clear_down.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/clear_up.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/drum_note.bmp
Executable file
|
After Width: | Height: | Size: 246 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/heart_note.bmp
Executable file
|
After Width: | Height: | Size: 246 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/lars_note.bmp
Executable file
|
After Width: | Height: | Size: 348 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/loop_down.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/loop_up.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/meatball.bmp
Executable file
|
After Width: | Height: | Size: 246 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/play_down.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/play_up.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/star.bmp
Executable file
|
After Width: | Height: | Size: 246 B |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/stop_down.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
Fruit_Jam/Larsio_Paint_Music/sprites/stop_up.bmp
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
220
Fruit_Jam/Larsio_Paint_Music/staff_view.py
Executable 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
|
||||
644
Fruit_Jam/Larsio_Paint_Music/ui_manager.py
Executable 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
|
|
@ -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
|
||||
BIN
LED_Matrix_Clock/nice-alarm.wav
Normal file
BIN
LED_Matrix_Clock/square-alarm.wav
Normal 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())
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
Metro/Metro_RP2350_Matching_Game/bitmaps/foreground.bmp
Executable file
|
After Width: | Height: | Size: 150 KiB |
BIN
Metro/Metro_RP2350_Matching_Game/bitmaps/game_sprites.bmp
Executable file
|
After Width: | Height: | Size: 12 KiB |
BIN
Metro/Metro_RP2350_Matching_Game/bitmaps/mouse_cursor.bmp
Executable file
|
After Width: | Height: | Size: 128 B |
264
Metro/Metro_RP2350_Matching_Game/code.py
Executable 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
|
||||
41
Metro/Metro_RP2350_Matching_Game/eventbutton.py
Executable 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
|
||||
525
Metro/Metro_RP2350_Matching_Game/gamelogic.py
Executable 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
|
||||