diff --git a/.env.example b/.env.example index 9378b3a..1e0af00 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,7 @@ MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd # App BACKEND_CORS_ORIGINS=["http://localhost:5173"] DEBUG=true + +# Local file storage (override if you want to store data elsewhere) +SQLITE_DB_PATH=./mqtt_data.db +BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies diff --git a/.gitignore b/.gitignore index 2b352ba..236a97e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ .env firebase-service-account.json +# Persistent runtime data (lives outside docker, not in git) +/data/* +!/data/.gitkeep +!/data/built_melodies/.gitkeep + # Python __pycache__/ *.pyc @@ -20,5 +25,4 @@ dist/ .DS_Store Thumbs.db -MAIN-APP-REFERENCE/ -SecondaryApps/ \ No newline at end of file +.MAIN-APP-REFERENCE/ \ No newline at end of file diff --git a/SecondaryApps/MelodyBuilders/all_melodies.txt b/SecondaryApps/MelodyBuilders/all_melodies.txt deleted file mode 100644 index 77a8f8b..0000000 --- a/SecondaryApps/MelodyBuilders/all_melodies.txt +++ /dev/null @@ -1 +0,0 @@ -Esperinos-Adamn-1k: 1,0,1,0,1,0,0,0,1,0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0 Esperinos-Eortastikos-1k: 1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0 Orthros-1k: 1,0,1,0,1,1,0 Doksologia-1k: 1,0,1,1,0,1,0 Panigiriko-1k: 1,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1,1,1,1,1,1,0,1,0 Xarmosino-1k: 1,0,1,1,1,1,1,0 Despotiko-1k: 1,1,1,1,1,1,1,1,1,1,0,0 Katixitiko-1k: 1,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0 Penthimo-Koino-1k: 1,0,0,0,0,0,0,1,1,0,0,0,0,0,0 Penthimo-Meg-Par-1k: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0 Esperinos-Adam-kamp2-2k: 2,2,2,0,2,2,2,0,2,2,2,2,2,2,2,0 Esperinos-Adam-kamp1-2k: 1,1,1,0,1,1,1,0,1,1,1,1,1,1,1,0 Esperinos-Eortastikos-2k: 1,2,1,2,1,2,0,0,1,2,1,2,1,2,0,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,0,0 Orthros-2k: 1,0,2,0,1,2,0 Doksologia-2k: 1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0 Panigiriko-2k: 1,0,0,2,0,0,1,0,0,2,0,0,1,0,0,2,0,0,1,0,0,2,0,0,1,0,2,0,1,0,2,0,1,0,2,0,1,0,2,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2 Xarmosyno-2k: 1,1,2,2,1,2,1,2 Agioritiko-2k: 1,2,1,1,2,1,1,2,1,1,2,1,2 MiaMia-2k: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,1,2,1,2,2,1,1,2,1,1,2,1,1,1,2,1,1,2,1,2,2,1,1,2,1,1,2,1,1,1,2,1,1,2,1,2,2,1,1,2,1,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2 Penthimo-Koino-2k: 1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0 Penthimo-Meg-Par-2k: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0 Erotastiko-2k: 1,2,1,2 Doksologia-2k: 2,0,1,2,0,1,0,2,0,1,2,0,1,0,2,0,1,2,0,1,0,2,1,2,1,2,0,1,0 Doksologia-Pan-2k: 2,0,2,2,0,2,0 Talanto-2k: 1,1,1,2,1,1,1,2,1,2,1,2,1,1,1,2 Katixitiko-2k: 2,0,2,0,2,0,2,0,2,0,0,0,0,0,0,0 Esperinos-Adam-kamp3-3k: 3,3,3,0,3,3,3,0,3,3,3,3,3,3,3,0 Esperinos-Eortastikos-3k: 2,3,2,3,2,3,0,0,2,3,2,3,2,3,0,0,2,3,2,3,2,3,2,3,2,3,2,3,2,3,0,0 Orthros-2k: 1,0,2,0,1,2,0 Doksologia-3k: 1,2,3,0,1,2,3,0,1,2,1,2,1,2,3,0 Panigiriko-3k: 1,0,0,2,0,0,3,0,0,1,0,0,2,0,0,3,0,0,1,0,0,2,0,0,3,0,1,0,2,0,3,0,1,0,2,0,3,0,1,0,2,0,3,0,1,0,2,0,3,0,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3,1,2,3 Xarmosyno-3k: 1,2,3,1,2 Agioritiko-3k: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3 MiaMia-3k: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2 Penthimo-Meg-Par-3k: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0 Esperinos-Panigirikos-3k: 1,2,1,2,1,3,0,0,1,2,1,2,1,3,0,0,1,2,1,2,1,2,1,2,1,2,1,2,1,3,0,0 Doksologia-3k: 1,0,2,1,0,3+1,0,1,0,2,1,0,3+1,0,1,0,2,1,0,3+1,0,1,2,1,2,1,0,3+1,0 Doksologia-Pan-3k: 1,2,2+1,1,2,3+1 Katixitiko-3k: 3,0,3,0,3,0,3,0,3,0,0,0,0,0,0,0 Doksologia-4k: 2,3,4,0,2,3,4,0,2,3,2,3,2,3,4,0 Panigiriko-4k: 2,0,0,3,0,0,4,0,0,2,0,0,3,0,0,4,0,0,2,0,0,3,0,0,4,0,2,0,3,0,4,0,2,0,3,0,4,0,2,0,3,0,4,0,2,0,3,0,4,0,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4,2,3,4 Xarmosyno-4k: 1,2,3,4,2,3 Agioritiko-4k: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,0,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1 MiaMia-4k: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,3,4+1,3,2,1,2,3,4+1,3,2,1,2,3,4+1,3,2,1,2,3,4+1,3,2,1,2,3,4+1,3,2,1,2,3,4+1,3,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2 Penthimo-Meg-Par-4k: 2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,0,0,0,0,0,0,4,4,0,0,0,0,0,0 Esperinos-Pan-4k: 2,3,2,3,2,4,0,0,2,3,2,3,2,4,0,0,2,3,2,3,2,3,2,3,2,3,2,3,2,4,0,0 Doksologia-4k: 2,0,3,2,0,4+1,0,2,0,3,2,0,4+1,0,2,0,3,2,0,4+1,0,2,3,2,3,2,0,4+1,0 Xarmosyno-4k: 1,2,3,4+1,2,1,3,4+1 1B-test: 1 \ No newline at end of file diff --git a/SecondaryApps/MelodyBuilders/builtin_melodies.txt b/SecondaryApps/MelodyBuilders/builtin_melodies.txt deleted file mode 100644 index c228cb5..0000000 --- a/SecondaryApps/MelodyBuilders/builtin_melodies.txt +++ /dev/null @@ -1,26 +0,0 @@ -Doxology_Traditional: 1,2,1,2,1,3,0,0,1,2,1,2,1,4,0,0,1,2,1,2,1,2,1,2,1,2,1,3,1,4,0,0 -Doxology_Alternative: 1,0,2,3,0,4+5,0,1,0,2,3,0,4+5,0,1,0,2,3,0,4+5,0,1,2,1,2,3,0,4+5,0 -Doxology_Festive: 2,3,4+1,3,2,3,5+1,3,2,3,6+1,3,2,3,5+1,3 -Vesper_Traditional: 1,2,3,0,1,2,3,0,1,2,1,2,1,2,3,0 -Vesper_Alternative: 1,2,0,0,1,2,0,0,1,3,0,0,0,0,0,0,1,2,0,0,1,2,0,0,1,4,0,0,0,0,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,3,0,0,1,4,0,0,0,0,0,0 -Catehetical: 1,2,3,4,5 -Orthros_Traditional: 1,0,2,0,3,4,0,5,0,6,0,7,8,0 -Orthros_Alternative: 1,0,2,1,0,2,0,1,0,1,2,1,0,3,0 -Mournfull_Toll: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,0,0,0,0,0,0 -Mournfull_Toll_Alternative: 1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,4,0,0,0,0,0,0 -Mournfull_Toll_Meg_Par: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,0,0,0,0,0,0,4,4,0,0,0,0,0,0 -Sematron: 1,1,1,2,1,1,1,4,1,1,1,2,1,3,1,4 -Sematron_Alternative: 1,1,1,2,1,1,1,4,1,2,1,3,1,1,1,4 -Athonite_1_2_Voices: 1,2,1,1,2,1,1,2,1,1,2,1,2 -Athonite_3_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3 -Athonite_3_4_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,0,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1 -Athonite_4_8_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,0,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4+1,2,1,5+1,2,1,6+2,2,1,8+1,2,1,4+2,2,1,7+1,2,1,5+2,2,1,6+1,2,1,8+2,2,1,4+1,2,1,7+2,2,1,5+1,2,1,6+2,2,1,8+1,2,1,4+2,2,1,7+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,0,0,0 -OneByOne_2_3_Voices: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2 -OneByOne_4_8_Voices: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,2,3,4,3,2,5+1,2,3,4,3,2,6+1,2,3,4,3,2,7+1,2,3,4,3,2,8+1,2,3,4,3,2,7+1,2,3,4,3,2,6+1,2,3,4,3,2,7+1,2,3,4,3,2,8+1,2,3,4,3,2,7+1,2,3,4,3,2,6+1,2,3,4,3,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,0 -Festive_1Voice: 1,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1,1,1,1,1,1,0,1,0 -Festive_4Voices: 1,2,3,4+1,2,1,3,4+1 -Festive_5Voices: 1,2,3,2,1,2,1,2,4,2,1,2,1,2,3,2,1,2,1,2,5,2,1,2 -Festive_5Voice_Alternative: 3,2,4,1,3,3,2,4,1,5,3,2,4,1,3,3,2,4,1,5+1,3,2,4,1,3,3,2,4,1,5+1,3,2,4,1,3+1,3,2,4,1,5+1,3,2,4,1,3+1,3,2,4,1,5+1,3,2,4,1,3,3,2,4,1,5,3,2,4,1,3,3,2,4,1,5 -Festive_6Voices: 1,2,3,2,1,2,1,2,4,2,1,2,1,2,3,2,1,2,1,2,5,2,1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2 -Festive_8Voices: 1,2,3,4,5,6,7,8 -Ormilia: 2,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,0,1,2,1,0,1,2,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,0,1,0,1,0,1,0,1,2,4+1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,3+1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,4+1,0,1,2,3+1,0,1,2,4+1,0,1,2,3+1,0,1,2,5+1,2,1,2,6+1,2,1,2,5+1,2,1,2,6+1,2,7+1,2,8+1,2,4+1,2,7+1,2,8+1,2,4+1,2,7+1,2,8+1,2,3+1,2,1,0 \ No newline at end of file diff --git a/SecondaryApps/MelodyBuilders/convert_to_bin.py b/SecondaryApps/MelodyBuilders/convert_to_bin.py deleted file mode 100644 index f61815a..0000000 --- a/SecondaryApps/MelodyBuilders/convert_to_bin.py +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env python3 -""" -Bell Melody Converter -Converts human-readable bell notation to binary .bsm files for ESP32 - -Format: MELODY_NAME: 1,2,3+4,0,5+6+7 -Output: MELODY_NAME.bsm (binary file, uint16_t big-endian) -""" - -import sys -import re -from pathlib import Path -from typing import List, Tuple - - -def parse_bell_notation(notation: str) -> int: - """ - Convert human-readable bell notation to bit flag value - - Examples: - "2+8" → bells 2,8 → bits 1,7 → 0x0082 (130) - "4" → bell 4 → bit 3 → 0x0008 (8) - "1+2+3" → bells 1,2,3 → bits 0,1,2 → 0x0007 (7) - "0" → no bells → 0x0000 (0) - - Formula: Bell #N → Bit (N-1) → Value = 1 << (N-1) - """ - notation = notation.strip() - - # Handle zero/silence - if notation == '0' or not notation: - return 0 - - # Split by + to get individual bell numbers - bell_numbers = notation.split('+') - - value = 0 - for bell_str in bell_numbers: - try: - bell_num = int(bell_str.strip()) - - if bell_num == 0: - continue # Bell 0 means silence, contributes nothing - - if bell_num < 1 or bell_num > 16: - print(f"Warning: Bell number {bell_num} out of range (1-16), skipping") - continue - - # Convert bell number to bit position (1-indexed to 0-indexed) - bit_position = bell_num - 1 - bit_value = 1 << bit_position - value |= bit_value - - except ValueError: - print(f"Warning: Invalid bell number '{bell_str}', skipping") - continue - - return value - - -def parse_melody_line(line: str) -> Tuple[str, List[int]]: - """ - Parse a melody line in format: MELODY_NAME: step,step,step - - Returns: - (melody_name, list of uint16_t values) - """ - line = line.strip() - - if not line or line.startswith('#'): - return None, [] - - # Split by colon - if ':' not in line: - print(f"Warning: Invalid format (missing ':'): {line}") - return None, [] - - parts = line.split(':', 1) - melody_name = parts[0].strip() - steps_str = parts[1].strip() - - if not melody_name: - print(f"Warning: Empty melody name in line: {line}") - return None, [] - - # Parse steps (comma-separated) - step_strings = steps_str.split(',') - values = [] - - for i, step_str in enumerate(step_strings): - value = parse_bell_notation(step_str) - values.append(value) - - return melody_name, values - - -def write_binary_melody(filepath: str, values: List[int]): - """ - Write melody values as binary file (uint16_t, big-endian) - - Args: - filepath: Output file path - values: List of uint16_t values (0-65535) - """ - with open(filepath, 'wb') as f: - for value in values: - # Ensure value fits in uint16_t - if value > 0xFFFF: - print(f"Warning: Value {value} exceeds uint16_t range, truncating") - value = value & 0xFFFF - - # Write as 2 bytes, big-endian (MSB first) - f.write(value.to_bytes(2, byteorder='big')) - - -def convert_melodies_file(input_path: str, output_dir: str = '.'): - """ - Convert multi-melody file to individual .bsm binary files - - Args: - input_path: Path to input text file - output_dir: Directory for output .bsm files - """ - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - melodies_created = 0 - total_steps = 0 - - try: - with open(input_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - print(f"Reading from: {input_path}") - print(f"Output directory: {output_path.absolute()}\n") - - for line_num, line in enumerate(lines, 1): - melody_name, values = parse_melody_line(line) - - if melody_name and values: - # Create output filename - output_file = output_path / f"{melody_name}.bsm" - - # Write binary file - write_binary_melody(str(output_file), values) - - # Calculate file size - file_size = len(values) * 2 # 2 bytes per uint16_t - - # Show what bells are used - all_bells = set() - for value in values: - for bit in range(16): - if value & (1 << bit): - all_bells.add(bit + 1) # Convert back to 1-indexed - - bells_str = ','.join(map(str, sorted(all_bells))) if all_bells else 'none' - - print(f"✓ {melody_name}.bsm") - print(f" Steps: {len(values)}") - print(f" Size: {file_size} bytes") - print(f" Bells used: {bells_str}") - print() - - melodies_created += 1 - total_steps += len(values) - - print(f"{'='*50}") - print(f"✓ Successfully created {melodies_created} melody files") - print(f" Total steps: {total_steps}") - print(f" Total size: {total_steps * 2} bytes") - - return True - - except FileNotFoundError: - print(f"Error: Input file '{input_path}' not found") - return False - except Exception as e: - print(f"Error: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - """Main entry point""" - print("=== Bell Melody Converter ===") - print("Creates binary .bsm files for ESP32\n") - - # Default input file - input_file = "all_melodies.txt" - output_dir = "." - - # Check if file exists - if not Path(input_file).exists(): - print(f"Error: '{input_file}' not found in current directory!") - print("\nPlease create 'all_melodies.txt' with format:") - print(" MELODY_NAME: step,step,step,...") - print("\nStep notation:") - print(" 0 - Silence") - print(" 4 - Bell #4 only") - print(" 2+8 - Bells #2 and #8 together") - print(" 1+2+3 - Bells #1, #2, and #3 together") - print("\nExample:") - print(" JINGLE_BELLS: 4,4,4,0,4,4,4,0,4,8,1,2,4") - print(" ALARM: 2+8,0,2+8,0,2+8,0") - print(" HAPPY_BIRTHDAY: 1,1,2,1,4,3,0,1,1,2,1,8,4") - sys.exit(1) - - success = convert_melodies_file(input_file, output_dir) - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/SecondaryApps/MelodyBuilders/convert_to_builtin.py b/SecondaryApps/MelodyBuilders/convert_to_builtin.py deleted file mode 100644 index 7ee77c8..0000000 --- a/SecondaryApps/MelodyBuilders/convert_to_builtin.py +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env python3 -""" -Bell Melody to C Header Converter -Converts human-readable bell notation to C header file with PROGMEM arrays - -Input Format: MELODY_NAME: 1,2,3+4,0,5+6+7 -Output: melodies.h (C header with const uint16_t PROGMEM arrays) -""" - -import sys -import re -from pathlib import Path -from typing import List, Tuple -from datetime import datetime - - -def parse_bell_notation(notation: str) -> int: - """ - Convert human-readable bell notation to bit flag value - - Examples: - "2+8" → bells 2,8 → bits 1,7 → 0x0082 (130) - "4" → bell 4 → bit 3 → 0x0008 (8) - "1+2+3" → bells 1,2,3 → bits 0,1,2 → 0x0007 (7) - "0" → no bells → 0x0000 (0) - - Formula: Bell #N → Bit (N-1) → Value = 1 << (N-1) - """ - notation = notation.strip() - - # Handle zero/silence - if notation == '0' or not notation: - return 0 - - # Split by + to get individual bell numbers - bell_numbers = notation.split('+') - - value = 0 - for bell_str in bell_numbers: - try: - bell_num = int(bell_str.strip()) - - if bell_num == 0: - continue # Bell 0 means silence, contributes nothing - - if bell_num < 1 or bell_num > 16: - print(f"Warning: Bell number {bell_num} out of range (1-16), skipping") - continue - - # Convert bell number to bit position (1-indexed to 0-indexed) - bit_position = bell_num - 1 - bit_value = 1 << bit_position - value |= bit_value - - except ValueError: - print(f"Warning: Invalid bell number '{bell_str}', skipping") - continue - - return value - - -def parse_melody_line(line: str) -> Tuple[str, List[int]]: - """ - Parse a melody line in format: MELODY_NAME: step,step,step - - Returns: - (melody_name, list of uint16_t values) - """ - line = line.strip() - - if not line or line.startswith('#'): - return None, [] - - # Split by colon - if ':' not in line: - return None, [] - - parts = line.split(':', 1) - melody_name = parts[0].strip() - steps_str = parts[1].strip() - - if not melody_name: - return None, [] - - # Parse steps (comma-separated) - step_strings = steps_str.split(',') - values = [] - - for step_str in step_strings: - value = parse_bell_notation(step_str) - values.append(value) - - return melody_name, values - - -def format_melody_array(melody_name: str, values: List[int], values_per_line: int = 8) -> str: - """ - Format melody values as C PROGMEM array - - Args: - melody_name: Name of the melody (will be prefixed with "builtin_") - values: List of uint16_t values - values_per_line: Number of hex values per line - - Returns: - Formatted C array declaration - """ - array_name = f"melody_builtin_{melody_name.lower()}" - - lines = [] - lines.append(f"const uint16_t PROGMEM {array_name}[] = {{") - - # Format values in rows - for i in range(0, len(values), values_per_line): - chunk = values[i:i + values_per_line] - hex_values = [f"0x{val:04X}" for val in chunk] - - # Add comma after each value except the last one overall - if i + len(chunk) < len(values): - line = " " + ", ".join(hex_values) + "," - else: - line = " " + ", ".join(hex_values) - - lines.append(line) - - lines.append("};") - - return "\n".join(lines) - - -def format_melody_info_entry(melody_name: str, display_name: str, array_size: int) -> str: - """ - Format a single MelodyInfo struct entry - - Args: - melody_name: Technical name (will be prefixed with "builtin_") - display_name: Human-readable name - array_size: Number of elements in the melody array - - Returns: - Formatted struct entry - """ - array_name = f"melody_builtin_{melody_name.lower()}" - id_name = f"builtin_{melody_name.lower()}" - - return f""" {{ - "{display_name}", - "{id_name}", - {array_name}, - sizeof({array_name}) / sizeof(uint16_t) - }}""" - - -def convert_to_header(input_path: str, output_path: str = "melodies.h"): - """ - Convert multi-melody file to C header file - - Args: - input_path: Path to input text file - output_path: Path to output .h file - """ - melodies = [] # List of (name, display_name, values) - - try: - with open(input_path, 'r', encoding='utf-8') as f: - lines = f.readlines() - - print(f"Reading from: {input_path}") - print(f"Output file: {output_path}\n") - - # Parse all melodies - for line_num, line in enumerate(lines, 1): - melody_name, values = parse_melody_line(line) - - if melody_name and values: - # Create display name (convert underscores to spaces, title case) - display_name = melody_name.replace('_', ' ').title() - melodies.append((melody_name, display_name, values)) - - print(f"✓ Parsed: {display_name} ({len(values)} steps)") - - if not melodies: - print("Error: No valid melodies found in input file") - return False - - # Generate header file - print(f"\n{'='*50}") - print(f"Generating C header file...\n") - - with open(output_path, 'w', encoding='utf-8') as f: - # Header guard and comments - f.write("/*\n") - f.write(" * Bell Melodies - Auto-generated\n") - f.write(f" * Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - f.write(f" * Source: {input_path}\n") - f.write(" * \n") - f.write(" * This file contains built-in melody definitions for the ESP32 bell controller\n") - f.write(" */\n\n") - f.write("#ifndef MELODIES_H\n") - f.write("#define MELODIES_H\n\n") - f.write("#include \n\n") - - # Write melody arrays - f.write("// ========================================\n") - f.write("// Melody Data Arrays\n") - f.write("// ========================================\n\n") - - for melody_name, display_name, values in melodies: - f.write(f"// {display_name}\n") - f.write(format_melody_array(melody_name, values)) - f.write("\n\n") - - # Write MelodyInfo structure definition - f.write("// ========================================\n") - f.write("// Melody Information Structure\n") - f.write("// ========================================\n\n") - f.write("struct MelodyInfo {\n") - f.write(" const char* display_name;\n") - f.write(" const char* id;\n") - f.write(" const uint16_t* data;\n") - f.write(" size_t length;\n") - f.write("};\n\n") - - # Write melody library array - f.write("// ========================================\n") - f.write("// Melody Library\n") - f.write("// ========================================\n\n") - f.write("const MelodyInfo MELODY_LIBRARY[] = {\n") - - for i, (melody_name, display_name, values) in enumerate(melodies): - entry = format_melody_info_entry(melody_name, display_name, len(values)) - - # Add comma except for last entry - if i < len(melodies) - 1: - f.write(entry + ",\n") - else: - f.write(entry + "\n") - - f.write("};\n\n") - - # Add library size constant - f.write(f"const size_t MELODY_LIBRARY_SIZE = {len(melodies)};\n\n") - - # Close header guard - f.write("#endif // MELODIES_H\n") - - # Summary - print(f"✓ Successfully created {output_path}") - print(f" Melodies: {len(melodies)}") - - total_steps = sum(len(values) for _, _, values in melodies) - print(f" Total steps: {total_steps}") - print(f" Estimated PROGMEM usage: {total_steps * 2} bytes") - - print(f"\n{'='*50}") - print("Done! Include this file in your ESP32 project.") - - return True - - except FileNotFoundError: - print(f"Error: Input file '{input_path}' not found") - return False - except Exception as e: - print(f"Error: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - """Main entry point""" - print("=== Bell Melody to C Header Converter ===") - print("Creates melodies.h for ESP32 firmware\n") - - # Default input file - input_file = "builtin_melodies.txt" - output_file = "melodies.h" - - # Check if file exists - if not Path(input_file).exists(): - print(f"Error: '{input_file}' not found in current directory!") - print("\nPlease create 'builtin_melodies.txt' with format:") - print(" MELODY_NAME: step,step,step,...") - print("\nStep notation:") - print(" 0 - Silence") - print(" 4 - Bell #4 only") - print(" 2+8 - Bells #2 and #8 together") - print(" 1+2+3 - Bells #1, #2, and #3 together") - print("\nExample:") - print(" JINGLE_BELLS: 4,4,4,0,4,4,4,0,4,8,1,2,4") - print(" ALARM: 2+8,0,2+8,0,2+8,0") - print(" HAPPY_BIRTHDAY: 1,1,2,1,4,3,0,1,1,2,1,8,4") - sys.exit(1) - - success = convert_to_header(input_file, output_file) - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/SecondaryApps/MelodyBuilders/melodies.h b/SecondaryApps/MelodyBuilders/melodies.h deleted file mode 100644 index e6604cf..0000000 --- a/SecondaryApps/MelodyBuilders/melodies.h +++ /dev/null @@ -1,475 +0,0 @@ -/* - * Bell Melodies - Auto-generated - * Generated: 2026-01-20 09:19:43 - * Source: builtin_melodies.txt - * - * This file contains built-in melody definitions for the ESP32 bell controller - */ - -#ifndef MELODIES_H -#define MELODIES_H - -#include - -// ======================================== -// Melody Data Arrays -// ======================================== - -// Doxology Traditional -const uint16_t PROGMEM melody_builtin_doxology_traditional[] = { - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0008, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008, 0x0000, 0x0000 -}; - -// Doxology Alternative -const uint16_t PROGMEM melody_builtin_doxology_alternative[] = { - 0x0001, 0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, - 0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0000, - 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0002, 0x0001, - 0x0002, 0x0004, 0x0000, 0x0018, 0x0000 -}; - -// Doxology Festive -const uint16_t PROGMEM melody_builtin_doxology_festive[] = { - 0x0002, 0x0004, 0x0009, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004, - 0x0002, 0x0004, 0x0021, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004 -}; - -// Vesper Traditional -const uint16_t PROGMEM melody_builtin_vesper_traditional[] = { - 0x0001, 0x0002, 0x0004, 0x0000, 0x0001, 0x0002, 0x0004, 0x0000, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0000 -}; - -// Vesper Alternative -const uint16_t PROGMEM melody_builtin_vesper_alternative[] = { - 0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000, - 0x0001, 0x0004, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000, - 0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000, - 0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0004, 0x0000, 0x0000, - 0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000 -}; - -// Catehetical -const uint16_t PROGMEM melody_builtin_catehetical[] = { - 0x0001, 0x0002, 0x0004, 0x0008, 0x0010 -}; - -// Orthros Traditional -const uint16_t PROGMEM melody_builtin_orthros_traditional[] = { - 0x0001, 0x0000, 0x0002, 0x0000, 0x0004, 0x0008, 0x0000, 0x0010, - 0x0000, 0x0020, 0x0000, 0x0040, 0x0080, 0x0000 -}; - -// Orthros Alternative -const uint16_t PROGMEM melody_builtin_orthros_alternative[] = { - 0x0001, 0x0000, 0x0002, 0x0001, 0x0000, 0x0002, 0x0000, 0x0001, - 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0004, 0x0000 -}; - -// Mournfull Toll -const uint16_t PROGMEM melody_builtin_mournfull_toll[] = { - 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000 -}; - -// Mournfull Toll Alternative -const uint16_t PROGMEM melody_builtin_mournfull_toll_alternative[] = { - 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, - 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0002, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0008, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000 -}; - -// Mournfull Toll Meg Par -const uint16_t PROGMEM melody_builtin_mournfull_toll_meg_par[] = { - 0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0001, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0002, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, - 0x0008, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000 -}; - -// Sematron -const uint16_t PROGMEM melody_builtin_sematron[] = { - 0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008, - 0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008 -}; - -// Sematron Alternative -const uint16_t PROGMEM melody_builtin_sematron_alternative[] = { - 0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0001, 0x0001, 0x0008 -}; - -// Athonite 1 2 Voices -const uint16_t PROGMEM melody_builtin_athonite_1_2_voices[] = { - 0x0001, 0x0002, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0002, - 0x0001, 0x0001, 0x0002, 0x0001, 0x0002 -}; - -// Athonite 3 Voices -const uint16_t PROGMEM melody_builtin_athonite_3_voices[] = { - 0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004 -}; - -// Athonite 3 4 Voices -const uint16_t PROGMEM melody_builtin_athonite_3_4_voices[] = { - 0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005, - 0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005, - 0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0005, 0x0002, 0x0001, - 0x000A, 0x0002, 0x0001, 0x0006, 0x0002, 0x0001, 0x0009, 0x0002, - 0x0001, 0x0005, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0006, - 0x0002, 0x0001, 0x0009 -}; - -// Athonite 4 8 Voices -const uint16_t PROGMEM melody_builtin_athonite_4_8_voices[] = { - 0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005, - 0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005, - 0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001, - 0x0022, 0x0002, 0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002, - 0x0001, 0x0041, 0x0002, 0x0001, 0x0012, 0x0002, 0x0001, 0x0021, - 0x0002, 0x0001, 0x0082, 0x0002, 0x0001, 0x0009, 0x0002, 0x0001, - 0x0042, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001, 0x0022, 0x0002, - 0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0041, - 0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005, - 0x0002, 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0000, 0x0000, - 0x0000 -}; - -// Onebyone 2 3 Voices -const uint16_t PROGMEM melody_builtin_onebyone_2_3_voices[] = { - 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, - 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002 -}; - -// Onebyone 4 8 Voices -const uint16_t PROGMEM melody_builtin_onebyone_4_8_voices[] = { - 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, - 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, - 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0011, 0x0002, 0x0004, - 0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004, - 0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081, - 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004, - 0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004, - 0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081, - 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004, - 0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, - 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, - 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, - 0x0002, 0x0001, 0x0000 -}; - -// Festive 1Voice -const uint16_t PROGMEM melody_builtin_festive_1voice[] = { - 0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001, - 0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0000 -}; - -// Festive 4Voices -const uint16_t PROGMEM melody_builtin_festive_4voices[] = { - 0x0001, 0x0002, 0x0004, 0x0009, 0x0002, 0x0001, 0x0004, 0x0009 -}; - -// Festive 5Voices -const uint16_t PROGMEM melody_builtin_festive_5voices[] = { - 0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002 -}; - -// Festive 5Voice Alternative -const uint16_t PROGMEM melody_builtin_festive_5voice_alternative[] = { - 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004, 0x0002, 0x0008, - 0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004, - 0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001, - 0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002, - 0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011, - 0x0004, 0x0002, 0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008, - 0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004, - 0x0002, 0x0008, 0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001, - 0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0010 -}; - -// Festive 6Voices -const uint16_t PROGMEM melody_builtin_festive_6voices[] = { - 0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, - 0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002, - 0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002, - 0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002, - 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, - 0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002, - 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, - 0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002, - 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, - 0x0001, 0x0002 -}; - -// Festive 8Voices -const uint16_t PROGMEM melody_builtin_festive_8voices[] = { - 0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020, 0x0040, 0x0080 -}; - -// Ormilia -const uint16_t PROGMEM melody_builtin_ormilia[] = { - 0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0009, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0005, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, - 0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001, - 0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001, - 0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0001, - 0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0041, - 0x0002, 0x0081, 0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081, - 0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081, 0x0002, 0x0005, - 0x0002, 0x0001, 0x0000 -}; - -// ======================================== -// Melody Information Structure -// ======================================== - -struct MelodyInfo { - const char* display_name; - const char* id; - const uint16_t* data; - size_t length; -}; - -// ======================================== -// Melody Library -// ======================================== - -const MelodyInfo MELODY_LIBRARY[] = { - { - "Doxology Traditional", - "builtin_doxology_traditional", - melody_builtin_doxology_traditional, - sizeof(melody_builtin_doxology_traditional) / sizeof(uint16_t) - }, - { - "Doxology Alternative", - "builtin_doxology_alternative", - melody_builtin_doxology_alternative, - sizeof(melody_builtin_doxology_alternative) / sizeof(uint16_t) - }, - { - "Doxology Festive", - "builtin_doxology_festive", - melody_builtin_doxology_festive, - sizeof(melody_builtin_doxology_festive) / sizeof(uint16_t) - }, - { - "Vesper Traditional", - "builtin_vesper_traditional", - melody_builtin_vesper_traditional, - sizeof(melody_builtin_vesper_traditional) / sizeof(uint16_t) - }, - { - "Vesper Alternative", - "builtin_vesper_alternative", - melody_builtin_vesper_alternative, - sizeof(melody_builtin_vesper_alternative) / sizeof(uint16_t) - }, - { - "Catehetical", - "builtin_catehetical", - melody_builtin_catehetical, - sizeof(melody_builtin_catehetical) / sizeof(uint16_t) - }, - { - "Orthros Traditional", - "builtin_orthros_traditional", - melody_builtin_orthros_traditional, - sizeof(melody_builtin_orthros_traditional) / sizeof(uint16_t) - }, - { - "Orthros Alternative", - "builtin_orthros_alternative", - melody_builtin_orthros_alternative, - sizeof(melody_builtin_orthros_alternative) / sizeof(uint16_t) - }, - { - "Mournfull Toll", - "builtin_mournfull_toll", - melody_builtin_mournfull_toll, - sizeof(melody_builtin_mournfull_toll) / sizeof(uint16_t) - }, - { - "Mournfull Toll Alternative", - "builtin_mournfull_toll_alternative", - melody_builtin_mournfull_toll_alternative, - sizeof(melody_builtin_mournfull_toll_alternative) / sizeof(uint16_t) - }, - { - "Mournfull Toll Meg Par", - "builtin_mournfull_toll_meg_par", - melody_builtin_mournfull_toll_meg_par, - sizeof(melody_builtin_mournfull_toll_meg_par) / sizeof(uint16_t) - }, - { - "Sematron", - "builtin_sematron", - melody_builtin_sematron, - sizeof(melody_builtin_sematron) / sizeof(uint16_t) - }, - { - "Sematron Alternative", - "builtin_sematron_alternative", - melody_builtin_sematron_alternative, - sizeof(melody_builtin_sematron_alternative) / sizeof(uint16_t) - }, - { - "Athonite 1 2 Voices", - "builtin_athonite_1_2_voices", - melody_builtin_athonite_1_2_voices, - sizeof(melody_builtin_athonite_1_2_voices) / sizeof(uint16_t) - }, - { - "Athonite 3 Voices", - "builtin_athonite_3_voices", - melody_builtin_athonite_3_voices, - sizeof(melody_builtin_athonite_3_voices) / sizeof(uint16_t) - }, - { - "Athonite 3 4 Voices", - "builtin_athonite_3_4_voices", - melody_builtin_athonite_3_4_voices, - sizeof(melody_builtin_athonite_3_4_voices) / sizeof(uint16_t) - }, - { - "Athonite 4 8 Voices", - "builtin_athonite_4_8_voices", - melody_builtin_athonite_4_8_voices, - sizeof(melody_builtin_athonite_4_8_voices) / sizeof(uint16_t) - }, - { - "Onebyone 2 3 Voices", - "builtin_onebyone_2_3_voices", - melody_builtin_onebyone_2_3_voices, - sizeof(melody_builtin_onebyone_2_3_voices) / sizeof(uint16_t) - }, - { - "Onebyone 4 8 Voices", - "builtin_onebyone_4_8_voices", - melody_builtin_onebyone_4_8_voices, - sizeof(melody_builtin_onebyone_4_8_voices) / sizeof(uint16_t) - }, - { - "Festive 1Voice", - "builtin_festive_1voice", - melody_builtin_festive_1voice, - sizeof(melody_builtin_festive_1voice) / sizeof(uint16_t) - }, - { - "Festive 4Voices", - "builtin_festive_4voices", - melody_builtin_festive_4voices, - sizeof(melody_builtin_festive_4voices) / sizeof(uint16_t) - }, - { - "Festive 5Voices", - "builtin_festive_5voices", - melody_builtin_festive_5voices, - sizeof(melody_builtin_festive_5voices) / sizeof(uint16_t) - }, - { - "Festive 5Voice Alternative", - "builtin_festive_5voice_alternative", - melody_builtin_festive_5voice_alternative, - sizeof(melody_builtin_festive_5voice_alternative) / sizeof(uint16_t) - }, - { - "Festive 6Voices", - "builtin_festive_6voices", - melody_builtin_festive_6voices, - sizeof(melody_builtin_festive_6voices) / sizeof(uint16_t) - }, - { - "Festive 8Voices", - "builtin_festive_8voices", - melody_builtin_festive_8voices, - sizeof(melody_builtin_festive_8voices) / sizeof(uint16_t) - }, - { - "Ormilia", - "builtin_ormilia", - melody_builtin_ormilia, - sizeof(melody_builtin_ormilia) / sizeof(uint16_t) - } -}; - -const size_t MELODY_LIBRARY_SIZE = 26; - -#endif // MELODIES_H diff --git a/SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm b/SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm deleted file mode 100644 index bc77804..0000000 Binary files a/SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm and /dev/null differ diff --git a/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm b/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm deleted file mode 100644 index 8426808..0000000 Binary files a/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm and /dev/null differ diff --git a/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt b/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt deleted file mode 100644 index 6f5f749..0000000 --- a/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt +++ /dev/null @@ -1,3 +0,0 @@ -Esperinos-Adamn-1k [min 1426 / mid 572 / max 194]: 1,0,1,0,1,0,0,0,1,0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0 -Esperinos-Eortastikos-1k: 1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0 -Orthros-1k [min 552 / mid 1402 / max 2229]: 1,0,1,0,1,1,0 diff --git a/SecondaryApps/SpeedCalc/requirements.txt b/SecondaryApps/SpeedCalc/requirements.txt deleted file mode 100644 index 7941d67..0000000 --- a/SecondaryApps/SpeedCalc/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -PyQt6>=6.4.0 -numpy>=1.21.0 -sounddevice>=0.4.6 diff --git a/SecondaryApps/SpeedCalc/speedcalc.py b/SecondaryApps/SpeedCalc/speedcalc.py deleted file mode 100644 index a3d79bf..0000000 --- a/SecondaryApps/SpeedCalc/speedcalc.py +++ /dev/null @@ -1,1067 +0,0 @@ -#!/usr/bin/env python3 -""" -SpeedCalc - Bell Melody Speed Calculator -A tool to find optimal MIN/MID/MAX playback speeds for bell melodies. -""" - -import sys -import re -import struct -import threading -import time -from pathlib import Path -from dataclasses import dataclass -from typing import List, Dict, Optional, Tuple - -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QListWidget, QListWidgetItem, QPushButton, QSlider, QLabel, - QFileDialog, QGroupBox, QFrame, QMessageBox, QStyleFactory -) -from PyQt6.QtCore import Qt, pyqtSignal, QObject -from PyQt6.QtGui import QColor, QPalette - -# Use pygame.mixer for low-latency audio playback -import numpy as np -import pygame - - -# ============================================================================ -# Custom Widgets -# ============================================================================ - -class ClickableSlider(QSlider): - """A slider that jumps to the clicked position instead of stepping.""" - - def mousePressEvent(self, event): - if event.button() == Qt.MouseButton.LeftButton: - # Calculate the value based on click position - if self.orientation() == Qt.Orientation.Horizontal: - value = self.minimum() + (self.maximum() - self.minimum()) * event.position().x() / self.width() - else: - value = self.minimum() + (self.maximum() - self.minimum()) * (self.height() - event.position().y()) / self.height() - self.setValue(int(value)) - event.accept() - super().mousePressEvent(event) - - -# ============================================================================ -# Data Classes -# ============================================================================ - -@dataclass -class Melody: - """Represents a single melody with its steps and speed settings.""" - name: str - steps: List[int] # Each step is a 16-bit value (bit flags for bells) - min_speed: Optional[int] = None # ms (slowest) - mid_speed: Optional[int] = None # ms (normal) - max_speed: Optional[int] = None # ms (fastest) - source_line: int = 0 # Line number in source file (for editing) - - def get_bells_used(self) -> set: - """Return set of bell numbers (1-16) used in this melody.""" - bells = set() - for step in self.steps: - for bit in range(16): - if step & (1 << bit): - bells.add(bit + 1) - return bells - - -# ============================================================================ -# Audio Engine -# ============================================================================ - -class AudioEngine: - """Handles audio generation and playback for bells using pygame.mixer.""" - - # Bell 1 = highest pitch, Bell 16 = lowest pitch - BASE_FREQ = 880 # A5 for bell 1 (highest) - SAMPLE_RATE = 44100 - - def __init__(self): - # Initialize pygame mixer with low latency settings - pygame.mixer.pre_init( - frequency=self.SAMPLE_RATE, - size=-16, # 16-bit signed - channels=1, # Mono - buffer=1024 # Reasonable buffer for low latency - ) - pygame.mixer.init() - - self.bell_frequencies = self._generate_bell_frequencies() - self._current_sound: Optional[pygame.mixer.Sound] = None - - def _generate_bell_frequencies(self) -> Dict[int, float]: - """Generate frequencies for bells 1-16 (1=highest, 16=lowest).""" - frequencies = {} - for bell in range(1, 17): - # Bell 1 is highest, so we subtract semitones as bell number increases - semitones_down = (bell - 1) * 2 # 2 semitones (whole step) per bell - freq = self.BASE_FREQ * (2 ** (-semitones_down / 12)) - frequencies[bell] = freq - return frequencies - - def _generate_sound(self, bells: List[int], duration_ms: int) -> pygame.mixer.Sound: - """Generate a pygame Sound object for the given bells.""" - num_samples = int(self.SAMPLE_RATE * duration_ms / 1000) - num_samples = max(num_samples, 100) # Minimum samples - - t = np.linspace(0, duration_ms / 1000, num_samples, dtype=np.float64) - - signal = np.zeros(num_samples, dtype=np.float64) - for bell in bells: - if bell in self.bell_frequencies: - freq = self.bell_frequencies[bell] - signal += np.sin(2 * np.pi * freq * t) - - if len(bells) > 0: - signal = signal / len(bells) - - # Normalize and convert to 16-bit signed integers - signal = signal * 0.8 * 32767 - - # Quick fade in/out to avoid clicks (2ms each) - fade_samples = min(int(self.SAMPLE_RATE * 0.002), num_samples // 4) - if fade_samples > 0: - fade_in = np.linspace(0, 1, fade_samples) - fade_out = np.linspace(1, 0, fade_samples) - signal[:fade_samples] *= fade_in - signal[-fade_samples:] *= fade_out - - # Convert to 16-bit integer array and create Sound from raw bytes - signal = signal.astype(np.int16) - sound = pygame.mixer.Sound(buffer=signal.tobytes()) - return sound - - def play_tone_blocking(self, bells: List[int], duration_ms: int): - """Play a tone for the given bells and wait for it to complete.""" - if duration_ms <= 0 or not bells: - # For silence, just sleep - if duration_ms > 0: - time.sleep(duration_ms / 1000.0) - return - - try: - # Keep a reference to the sound to prevent garbage collection - self._current_sound = self._generate_sound(bells, duration_ms) - self._current_sound.play() - - # Wait for the duration (more reliable than polling get_busy) - time.sleep(duration_ms / 1000.0) - except Exception as e: - print(f"Audio error: {e}") - - def stop(self): - """Stop any currently playing audio.""" - try: - pygame.mixer.stop() - except: - pass - - def cleanup(self): - """Clean up audio resources.""" - self.stop() - try: - pygame.mixer.quit() - except: - pass - - -# ============================================================================ -# File Parser -# ============================================================================ - -class MelodyParser: - """Parses melody files (both .txt and .bsm formats).""" - - # Regex to match speed settings in melody name: [min XXX / mid XXX / max XXX] - # Note: We avoid colons in the format because the parser splits on ':' first - SPEED_PATTERN = re.compile( - r'\[\s*min\s+(\d+)\s*/\s*mid\s+(\d+)\s*/\s*max\s+(\d+)\s*\]' - ) - - @staticmethod - def parse_txt_file(filepath: str) -> Tuple[List[Melody], List[str]]: - """ - Parse a .txt file containing multiple melodies. - Returns (list of Melody objects, original lines for editing). - """ - melodies = [] - original_lines = [] - - with open(filepath, 'r', encoding='utf-8') as f: - original_lines = f.readlines() - - for line_num, line in enumerate(original_lines): - line_stripped = line.strip() - - if not line_stripped or line_stripped.startswith('#'): - continue - - if ':' not in line_stripped: - continue - - # Split name and steps - parts = line_stripped.split(':', 1) - name_part = parts[0].strip() - steps_str = parts[1].strip() - - # Check for existing speed settings in name - min_speed = mid_speed = max_speed = None - speed_match = MelodyParser.SPEED_PATTERN.search(name_part) - if speed_match: - min_speed = int(speed_match.group(1)) - mid_speed = int(speed_match.group(2)) - max_speed = int(speed_match.group(3)) - # Remove speed info from name for display - name = MelodyParser.SPEED_PATTERN.sub('', name_part).strip() - else: - name = name_part - - # Parse steps - steps = [] - for step_str in steps_str.split(','): - step_str = step_str.strip() - if not step_str: - continue - - if '+' in step_str: - # Multiple bells: "2+8" -> bits 1 and 7 - value = 0 - for bell_str in step_str.split('+'): - try: - bell_num = int(bell_str.strip()) - if 1 <= bell_num <= 16: - value |= (1 << (bell_num - 1)) - except ValueError: - pass - steps.append(value) - else: - # Single bell or zero - try: - bell_num = int(step_str) - if bell_num == 0: - steps.append(0) - elif 1 <= bell_num <= 16: - steps.append(1 << (bell_num - 1)) - else: - steps.append(0) - except ValueError: - steps.append(0) - - if steps: - melody = Melody( - name=name, - steps=steps, - min_speed=min_speed, - mid_speed=mid_speed, - max_speed=max_speed, - source_line=line_num - ) - melodies.append(melody) - - return melodies, original_lines - - @staticmethod - def parse_bsm_file(filepath: str) -> Melody: - """Parse a binary .bsm file.""" - name = Path(filepath).stem - steps = [] - - with open(filepath, 'rb') as f: - data = f.read() - - # Each step is 2 bytes, big-endian - for i in range(0, len(data), 2): - if i + 1 < len(data): - value = struct.unpack('>H', data[i:i+2])[0] - steps.append(value) - - return Melody(name=name, steps=steps) - - @staticmethod - def save_txt_file(filepath: str, melodies: List[Melody], original_lines: List[str]): - """Save melodies back to .txt file, preserving format and updating speeds.""" - new_lines = original_lines.copy() - - for melody in melodies: - if melody.source_line < len(new_lines): - line = new_lines[melody.source_line] - - if ':' in line: - parts = line.split(':', 1) - name_part = parts[0] - steps_part = parts[1] - - # Remove existing speed info if any - name_clean = MelodyParser.SPEED_PATTERN.sub('', name_part).strip() - - # Add new speed info if all values are set - # Note: We use spaces instead of colons to avoid breaking the parser - # (since the parser splits on ':' to separate name from steps) - if melody.min_speed and melody.mid_speed and melody.max_speed: - speed_str = f" [min {melody.min_speed} / mid {melody.mid_speed} / max {melody.max_speed}]" - new_name = name_clean + speed_str - else: - new_name = name_clean - - new_lines[melody.source_line] = f"{new_name}:{steps_part}" - - with open(filepath, 'w', encoding='utf-8') as f: - f.writelines(new_lines) - - -# ============================================================================ -# Playback Controller -# ============================================================================ - -class PlaybackSignals(QObject): - """Signals for playback thread communication.""" - step_changed = pyqtSignal(int) - playback_finished = pyqtSignal() - beat_truncated = pyqtSignal(int) # Emits actual beat duration when truncated - - -class PlaybackController: - """Controls melody playback.""" - - def __init__(self, audio_engine: AudioEngine): - self.audio = audio_engine - self.signals = PlaybackSignals() - self.is_playing = False - self.is_looping = False - self.current_step = 0 - self.melody: Optional[Melody] = None - self.step_delay_ms = 500 - self.beat_duration_ms = 100 - self._playback_thread: Optional[threading.Thread] = None - self._stop_event = threading.Event() - - def set_melody(self, melody: Melody): - """Set the melody to play.""" - self.stop() - self.melody = melody - self.current_step = 0 - - def set_speed(self, delay_ms: int): - """Set the delay between steps in milliseconds.""" - self.step_delay_ms = delay_ms - - def set_beat_duration(self, duration_ms: int): - """Set the duration of each beat/tone.""" - self.beat_duration_ms = duration_ms - - def play(self, loop: bool = False): - """Start playback.""" - if not self.melody or not self.melody.steps: - return - - self.stop() - self.is_playing = True - self.is_looping = loop - self._stop_event.clear() - - self._playback_thread = threading.Thread(target=self._playback_loop, daemon=True) - self._playback_thread.start() - - def stop(self): - """Stop playback.""" - self.is_playing = False - self._stop_event.set() - self.audio.stop() - - if self._playback_thread and self._playback_thread.is_alive(): - self._playback_thread.join(timeout=0.5) - self._playback_thread = None - - def _playback_loop(self): - """Main playback loop (runs in separate thread).""" - import time - - BUFFER_MS = 20 # Gap between tones to prevent blending - - while self.is_playing and self.melody: - if self._stop_event.is_set(): - break - - # Get current step - step_value = self.melody.steps[self.current_step] - - # Convert step value to list of bell numbers - bells = [] - for bit in range(16): - if step_value & (1 << bit): - bells.append(bit + 1) - - # Signal UI about current step - self.signals.step_changed.emit(self.current_step) - - # Calculate actual beat duration (truncate if needed) - max_beat_ms = max(BUFFER_MS, self.step_delay_ms - BUFFER_MS) - actual_beat_ms = min(self.beat_duration_ms, max_beat_ms) - is_truncated = actual_beat_ms < self.beat_duration_ms - - # Signal if truncated (only on first step to avoid spam) - if is_truncated and self.current_step == 0: - self.signals.beat_truncated.emit(actual_beat_ms) - - # Record start time for accurate timing - step_start = time.perf_counter() - - # Play the tone (blocking - waits for tone to finish) - self.audio.play_tone_blocking(bells, actual_beat_ms) - - if self._stop_event.is_set(): - break - - # Calculate remaining wait time after tone played - elapsed_ms = (time.perf_counter() - step_start) * 1000 - remaining_wait_ms = self.step_delay_ms - elapsed_ms - - # Wait for remaining time in small increments for responsive stopping - if remaining_wait_ms > 0: - wait_end = time.perf_counter() + (remaining_wait_ms / 1000.0) - while time.perf_counter() < wait_end and not self._stop_event.is_set(): - time.sleep(0.005) # 5ms increments - - if self._stop_event.is_set(): - break - - # Move to next step - self.current_step += 1 - if self.current_step >= len(self.melody.steps): - if self.is_looping: - self.current_step = 0 - else: - break - - self.is_playing = False - self.signals.playback_finished.emit() - - -# ============================================================================ -# Main Window -# ============================================================================ - -class SpeedCalcWindow(QMainWindow): - """Main application window.""" - - def __init__(self): - super().__init__() - - self.audio = AudioEngine() - self.playback = PlaybackController(self.audio) - self.melodies: List[Melody] = [] - self.original_lines: List[str] = [] - self.current_file: Optional[str] = None - self.current_melody: Optional[Melody] = None - - self._setup_ui() - self._connect_signals() - - # Enable drag and drop - self.setAcceptDrops(True) - - def dragEnterEvent(self, event): - """Handle drag enter - accept if it contains file URLs.""" - if event.mimeData().hasUrls(): - # Check if any of the files are .txt or .bsm - for url in event.mimeData().urls(): - filepath = url.toLocalFile() - if filepath.lower().endswith(('.txt', '.bsm')): - event.acceptProposedAction() - return - event.ignore() - - def dropEvent(self, event): - """Handle file drop.""" - txt_files = [] - bsm_files = [] - - for url in event.mimeData().urls(): - filepath = url.toLocalFile() - if filepath.lower().endswith('.txt'): - txt_files.append(filepath) - elif filepath.lower().endswith('.bsm'): - bsm_files.append(filepath) - - # Load txt file first (only the first one if multiple) - if txt_files: - self._load_txt_file_path(txt_files[0]) - - # Add any bsm files - if bsm_files: - self._add_bsm_files(bsm_files) - - event.acceptProposedAction() - - def _setup_ui(self): - """Set up the user interface.""" - self.setWindowTitle("SpeedCalc - Bell Melody Speed Calculator") - self.setMinimumSize(900, 600) - - # Main widget and layout - main_widget = QWidget() - self.setCentralWidget(main_widget) - main_layout = QHBoxLayout(main_widget) - main_layout.setSpacing(15) - main_layout.setContentsMargins(15, 15, 15, 15) - - # Left panel - Melody list - left_panel = self._create_melody_list_panel() - main_layout.addWidget(left_panel, stretch=1) - - # Right panel - Controls - right_panel = self._create_controls_panel() - main_layout.addWidget(right_panel, stretch=2) - - def _create_melody_list_panel(self) -> QWidget: - """Create the melody list panel.""" - panel = QGroupBox("Melodies") - layout = QVBoxLayout(panel) - - # File buttons - btn_layout = QHBoxLayout() - - self.btn_load_txt = QPushButton("Load .txt File") - self.btn_load_txt.setMinimumHeight(35) - btn_layout.addWidget(self.btn_load_txt) - - self.btn_add_bsm = QPushButton("Add .bsm Files") - self.btn_add_bsm.setMinimumHeight(35) - btn_layout.addWidget(self.btn_add_bsm) - - layout.addLayout(btn_layout) - - # Melody list - self.melody_list = QListWidget() - self.melody_list.setMinimumWidth(250) - self.melody_list.setAlternatingRowColors(True) - layout.addWidget(self.melody_list) - - # Info label - self.lbl_melody_info = QLabel("No melody loaded") - self.lbl_melody_info.setWordWrap(True) - self.lbl_melody_info.setStyleSheet("color: #666; padding: 5px;") - layout.addWidget(self.lbl_melody_info) - - return panel - - def _create_controls_panel(self) -> QWidget: - """Create the controls panel.""" - panel = QWidget() - layout = QVBoxLayout(panel) - layout.setSpacing(15) - - # Playback controls - playback_group = QGroupBox("Playback") - playback_layout = QVBoxLayout(playback_group) - - # Play buttons - btn_row = QHBoxLayout() - - self.btn_play = QPushButton("▶ Play") - self.btn_play.setMinimumHeight(45) - self.btn_play.setEnabled(False) - btn_row.addWidget(self.btn_play) - - self.btn_loop = QPushButton("🔁 Loop") - self.btn_loop.setMinimumHeight(45) - self.btn_loop.setCheckable(True) - self.btn_loop.setEnabled(False) - btn_row.addWidget(self.btn_loop) - - self.btn_stop = QPushButton("⏹ Stop") - self.btn_stop.setMinimumHeight(45) - self.btn_stop.setEnabled(False) - btn_row.addWidget(self.btn_stop) - - playback_layout.addLayout(btn_row) - - # Progress label - self.lbl_progress = QLabel("Step: - / -") - self.lbl_progress.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.lbl_progress.setStyleSheet("font-size: 14px; padding: 5px;") - playback_layout.addWidget(self.lbl_progress) - - layout.addWidget(playback_group) - - # Speed control - speed_group = QGroupBox("Step Delay (Speed)") - speed_layout = QVBoxLayout(speed_group) - - self.lbl_speed = QLabel("500 ms") - self.lbl_speed.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.lbl_speed.setStyleSheet("font-size: 24px; font-weight: bold; color: #2196F3;") - speed_layout.addWidget(self.lbl_speed) - - self.slider_speed = ClickableSlider(Qt.Orientation.Horizontal) - self.slider_speed.setMinimum(50) - self.slider_speed.setMaximum(3000) - self.slider_speed.setValue(500) - self.slider_speed.setTickPosition(ClickableSlider.TickPosition.TicksBelow) - self.slider_speed.setTickInterval(250) - speed_layout.addWidget(self.slider_speed) - - speed_labels = QHBoxLayout() - speed_labels.addWidget(QLabel("50ms (Fast)")) - speed_labels.addStretch() - speed_labels.addWidget(QLabel("3000ms (Slow)")) - speed_layout.addLayout(speed_labels) - - layout.addWidget(speed_group) - - # Beat duration control - beat_group = QGroupBox("Beat Duration") - beat_layout = QVBoxLayout(beat_group) - - self.lbl_beat = QLabel("100 ms") - self.lbl_beat.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.lbl_beat.setStyleSheet("font-size: 18px; font-weight: bold; color: #4CAF50;") - beat_layout.addWidget(self.lbl_beat) - - self.slider_beat = ClickableSlider(Qt.Orientation.Horizontal) - self.slider_beat.setMinimum(20) - self.slider_beat.setMaximum(500) - self.slider_beat.setValue(100) - self.slider_beat.setTickPosition(ClickableSlider.TickPosition.TicksBelow) - self.slider_beat.setTickInterval(50) - beat_layout.addWidget(self.slider_beat) - - beat_labels = QHBoxLayout() - beat_labels.addWidget(QLabel("20ms (Short)")) - beat_labels.addStretch() - beat_labels.addWidget(QLabel("500ms (Long)")) - beat_layout.addLayout(beat_labels) - - # Truncation warning label - use fixed height to prevent layout shift - self.lbl_truncated = QLabel("") - self.lbl_truncated.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.lbl_truncated.setStyleSheet("color: #f44336; font-weight: bold; padding: 2px;") - self.lbl_truncated.setFixedHeight(20) # Reserve space even when empty - beat_layout.addWidget(self.lbl_truncated) - - layout.addWidget(beat_group) - - # Speed capture buttons - capture_group = QGroupBox("Capture Speed Settings") - capture_layout = QVBoxLayout(capture_group) - - # Current values display - values_layout = QHBoxLayout() - - # MIN - min_frame = QFrame() - min_frame.setFrameStyle(QFrame.Shape.StyledPanel) - min_layout = QVBoxLayout(min_frame) - min_layout.addWidget(QLabel("MIN (Slowest)")) - self.lbl_min = QLabel("---") - self.lbl_min.setStyleSheet("font-size: 20px; font-weight: bold; color: #f44336;") - self.lbl_min.setAlignment(Qt.AlignmentFlag.AlignCenter) - min_layout.addWidget(self.lbl_min) - min_btn_layout = QHBoxLayout() - self.btn_set_min = QPushButton("Set") - self.btn_set_min.setEnabled(False) - self.btn_set_min.setToolTip("Load saved MIN value to slider") - min_btn_layout.addWidget(self.btn_set_min) - self.btn_save_min = QPushButton("Save") - self.btn_save_min.setEnabled(False) - self.btn_save_min.setToolTip("Save current slider value as MIN") - min_btn_layout.addWidget(self.btn_save_min) - min_layout.addLayout(min_btn_layout) - values_layout.addWidget(min_frame) - - # MID - mid_frame = QFrame() - mid_frame.setFrameStyle(QFrame.Shape.StyledPanel) - mid_layout = QVBoxLayout(mid_frame) - mid_layout.addWidget(QLabel("MID (Normal)")) - self.lbl_mid = QLabel("---") - self.lbl_mid.setStyleSheet("font-size: 20px; font-weight: bold; color: #ff9800;") - self.lbl_mid.setAlignment(Qt.AlignmentFlag.AlignCenter) - mid_layout.addWidget(self.lbl_mid) - mid_btn_layout = QHBoxLayout() - self.btn_set_mid = QPushButton("Set") - self.btn_set_mid.setEnabled(False) - self.btn_set_mid.setToolTip("Load saved MID value to slider") - mid_btn_layout.addWidget(self.btn_set_mid) - self.btn_save_mid = QPushButton("Save") - self.btn_save_mid.setEnabled(False) - self.btn_save_mid.setToolTip("Save current slider value as MID") - mid_btn_layout.addWidget(self.btn_save_mid) - mid_layout.addLayout(mid_btn_layout) - values_layout.addWidget(mid_frame) - - # MAX - max_frame = QFrame() - max_frame.setFrameStyle(QFrame.Shape.StyledPanel) - max_layout = QVBoxLayout(max_frame) - max_layout.addWidget(QLabel("MAX (Fastest)")) - self.lbl_max = QLabel("---") - self.lbl_max.setStyleSheet("font-size: 20px; font-weight: bold; color: #4CAF50;") - self.lbl_max.setAlignment(Qt.AlignmentFlag.AlignCenter) - max_layout.addWidget(self.lbl_max) - max_btn_layout = QHBoxLayout() - self.btn_set_max = QPushButton("Set") - self.btn_set_max.setEnabled(False) - self.btn_set_max.setToolTip("Load saved MAX value to slider") - max_btn_layout.addWidget(self.btn_set_max) - self.btn_save_max = QPushButton("Save") - self.btn_save_max.setEnabled(False) - self.btn_save_max.setToolTip("Save current slider value as MAX") - max_btn_layout.addWidget(self.btn_save_max) - max_layout.addLayout(max_btn_layout) - values_layout.addWidget(max_frame) - - capture_layout.addLayout(values_layout) - - # Clear and Save buttons - action_layout = QHBoxLayout() - - self.btn_clear = QPushButton("Clear Values") - self.btn_clear.setMinimumHeight(40) - self.btn_clear.setEnabled(False) - action_layout.addWidget(self.btn_clear) - - self.btn_save = QPushButton("Save to File") - self.btn_save.setMinimumHeight(40) - self.btn_save.setEnabled(False) - self.btn_save.setStyleSheet("font-weight: bold;") - action_layout.addWidget(self.btn_save) - - capture_layout.addLayout(action_layout) - - layout.addWidget(capture_group) - - # Add stretch at bottom - layout.addStretch() - - return panel - - def _connect_signals(self): - """Connect all signals and slots.""" - # File loading - self.btn_load_txt.clicked.connect(self._load_txt_file) - self.btn_add_bsm.clicked.connect(self._add_bsm_files_dialog) - - # Melody selection - self.melody_list.currentItemChanged.connect(self._on_melody_selected) - - # Playback controls - self.btn_play.clicked.connect(self._on_play) - self.btn_loop.clicked.connect(self._on_loop) - self.btn_stop.clicked.connect(self._on_stop) - - # Sliders - self.slider_speed.valueChanged.connect(self._on_speed_changed) - self.slider_beat.valueChanged.connect(self._on_beat_changed) - - # Capture buttons - Set loads saved value to slider, Save captures slider value - self.btn_set_min.clicked.connect(lambda: self._load_speed_to_slider('min')) - self.btn_set_mid.clicked.connect(lambda: self._load_speed_to_slider('mid')) - self.btn_set_max.clicked.connect(lambda: self._load_speed_to_slider('max')) - self.btn_save_min.clicked.connect(lambda: self._save_speed_value('min')) - self.btn_save_mid.clicked.connect(lambda: self._save_speed_value('mid')) - self.btn_save_max.clicked.connect(lambda: self._save_speed_value('max')) - self.btn_clear.clicked.connect(self._clear_values) - self.btn_save.clicked.connect(self._save_file) - - # Playback signals - self.playback.signals.step_changed.connect(self._on_step_changed) - self.playback.signals.playback_finished.connect(self._on_playback_finished) - self.playback.signals.beat_truncated.connect(self._on_beat_truncated) - - def _load_txt_file(self): - """Load a .txt melody file via dialog.""" - filepath, _ = QFileDialog.getOpenFileName( - self, - "Open Melody Text File", - "", - "Text Files (*.txt);;All Files (*)" - ) - - if filepath: - self._load_txt_file_path(filepath) - - def _load_txt_file_path(self, filepath: str): - """Load a .txt melody file from a path.""" - try: - self.melodies, self.original_lines = MelodyParser.parse_txt_file(filepath) - self.current_file = filepath - self._update_melody_list() - self.btn_save.setEnabled(True) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to load file:\n{e}") - - def _add_bsm_files_dialog(self): - """Add .bsm binary files via dialog.""" - filepaths, _ = QFileDialog.getOpenFileNames( - self, - "Add Binary Melody Files", - "", - "BSM Files (*.bsm);;All Files (*)" - ) - - if filepaths: - self._add_bsm_files(filepaths) - - def _add_bsm_files(self, filepaths: List[str]): - """Add .bsm binary files to the melody list (appends, doesn't replace).""" - for filepath in filepaths: - try: - melody = MelodyParser.parse_bsm_file(filepath) - self.melodies.append(melody) - except Exception as e: - QMessageBox.warning(self, "Warning", f"Failed to load {filepath}:\n{e}") - - self._update_melody_list() - - def _update_melody_list(self): - """Update the melody list widget.""" - self.melody_list.clear() - - for melody in self.melodies: - # Show checkmark if speeds are set - if melody.min_speed and melody.mid_speed and melody.max_speed: - prefix = "✓ " - else: - prefix = " " - - item = QListWidgetItem(f"{prefix}{melody.name}") - item.setData(Qt.ItemDataRole.UserRole, melody) - self.melody_list.addItem(item) - - if self.melodies: - self.melody_list.setCurrentRow(0) - - def _on_melody_selected(self, current: QListWidgetItem, previous: QListWidgetItem): - """Handle melody selection change.""" - if not current: - return - - self.playback.stop() - melody = current.data(Qt.ItemDataRole.UserRole) - self.current_melody = melody - self.playback.set_melody(melody) - - # Update info - bells = melody.get_bells_used() - bells_str = ', '.join(map(str, sorted(bells))) if bells else 'none' - self.lbl_melody_info.setText( - f"Steps: {len(melody.steps)}\n" - f"Bells used: {bells_str}" - ) - - # Update speed displays - self.lbl_min.setText(f"{melody.min_speed} ms" if melody.min_speed else "---") - self.lbl_mid.setText(f"{melody.mid_speed} ms" if melody.mid_speed else "---") - self.lbl_max.setText(f"{melody.max_speed} ms" if melody.max_speed else "---") - - # If melody has saved speeds, set the slider to the mid speed (or min if no mid) - if melody.mid_speed: - self.slider_speed.setValue(melody.mid_speed) - elif melody.min_speed: - self.slider_speed.setValue(melody.min_speed) - elif melody.max_speed: - self.slider_speed.setValue(melody.max_speed) - - # Enable controls - self.btn_play.setEnabled(True) - self.btn_loop.setEnabled(True) - # Set buttons are only enabled if the melody has that speed value saved - self.btn_set_min.setEnabled(melody.min_speed is not None) - self.btn_set_mid.setEnabled(melody.mid_speed is not None) - self.btn_set_max.setEnabled(melody.max_speed is not None) - # Save buttons are always enabled when a melody is selected - self.btn_save_min.setEnabled(True) - self.btn_save_mid.setEnabled(True) - self.btn_save_max.setEnabled(True) - self.btn_clear.setEnabled(True) - - # Reset progress - self.lbl_progress.setText(f"Step: 0 / {len(melody.steps)}") - - def _on_play(self): - """Start playback.""" - if self.playback.is_playing: - self.playback.stop() - self.btn_play.setText("▶ Play") - else: - # Reset to beginning if we're at the end - if self.current_melody and self.playback.current_step >= len(self.current_melody.steps): - self.playback.current_step = 0 - self.playback.play(loop=self.btn_loop.isChecked()) - self.btn_play.setText("⏸ Pause") - self.btn_stop.setEnabled(True) - - def _on_loop(self): - """Toggle loop mode.""" - if self.playback.is_playing: - self.playback.is_looping = self.btn_loop.isChecked() - - def _on_stop(self): - """Stop playback.""" - self.playback.stop() - self.playback.current_step = 0 - self.btn_play.setText("▶ Play") - self.btn_stop.setEnabled(False) - self.lbl_truncated.setText("") - if self.current_melody: - self.lbl_progress.setText(f"Step: 0 / {len(self.current_melody.steps)}") - - def _on_speed_changed(self, value: int): - """Handle speed slider change.""" - self.lbl_speed.setText(f"{value} ms") - self.playback.set_speed(value) - self.lbl_truncated.setText("") # Hide warning when speed changes - - def _on_beat_changed(self, value: int): - """Handle beat duration slider change.""" - self.lbl_beat.setText(f"{value} ms") - self.playback.set_beat_duration(value) - - def _on_step_changed(self, step: int): - """Handle step change during playback.""" - if self.current_melody: - self.lbl_progress.setText(f"Step: {step + 1} / {len(self.current_melody.steps)}") - - def _on_playback_finished(self): - """Handle playback finished.""" - self.btn_play.setText("▶ Play") - self.btn_stop.setEnabled(False) - self.lbl_truncated.setText("") - - def _on_beat_truncated(self, actual_ms: int): - """Handle beat truncation warning.""" - self.lbl_truncated.setText(f"⚠ Beat truncated to {actual_ms}ms (speed too fast)") - - def _save_speed_value(self, which: str): - """Save current slider value to a speed setting.""" - if not self.current_melody: - return - - value = self.slider_speed.value() - - if which == 'min': - self.current_melody.min_speed = value - self.lbl_min.setText(f"{value} ms") - self.btn_set_min.setEnabled(True) - elif which == 'mid': - self.current_melody.mid_speed = value - self.lbl_mid.setText(f"{value} ms") - self.btn_set_mid.setEnabled(True) - elif which == 'max': - self.current_melody.max_speed = value - self.lbl_max.setText(f"{value} ms") - self.btn_set_max.setEnabled(True) - - # Update list item - self._update_current_list_item() - - def _load_speed_to_slider(self, which: str): - """Load a saved speed value to the slider.""" - if not self.current_melody: - return - - value = None - if which == 'min': - value = self.current_melody.min_speed - elif which == 'mid': - value = self.current_melody.mid_speed - elif which == 'max': - value = self.current_melody.max_speed - - if value is not None: - self.slider_speed.setValue(value) - - def _clear_values(self): - """Clear all speed values for current melody.""" - if not self.current_melody: - return - - self.current_melody.min_speed = None - self.current_melody.mid_speed = None - self.current_melody.max_speed = None - - self.lbl_min.setText("---") - self.lbl_mid.setText("---") - self.lbl_max.setText("---") - - # Disable Set buttons since there are no values to load - self.btn_set_min.setEnabled(False) - self.btn_set_mid.setEnabled(False) - self.btn_set_max.setEnabled(False) - - self._update_current_list_item() - - def _update_current_list_item(self): - """Update the current list item to show checkmark status.""" - item = self.melody_list.currentItem() - if item and self.current_melody: - if (self.current_melody.min_speed and - self.current_melody.mid_speed and - self.current_melody.max_speed): - prefix = "✓ " - else: - prefix = " " - item.setText(f"{prefix}{self.current_melody.name}") - - def _save_file(self): - """Save speed settings back to the .txt file.""" - if not self.current_file: - QMessageBox.warning( - self, - "Cannot Save", - "Can only save to .txt files. Binary files cannot be modified." - ) - return - - try: - MelodyParser.save_txt_file(self.current_file, self.melodies, self.original_lines) - QMessageBox.information( - self, - "Saved", - f"Speed settings saved to:\n{self.current_file}" - ) - except Exception as e: - QMessageBox.critical(self, "Error", f"Failed to save file:\n{e}") - - def closeEvent(self, event): - """Handle window close.""" - self.playback.stop() - self.audio.cleanup() - event.accept() - - -# ============================================================================ -# Main Entry Point -# ============================================================================ - -def main(): - app = QApplication(sys.argv) - - # Try to use Fusion style for consistent cross-platform look - app.setStyle(QStyleFactory.create("Fusion")) - - # Set up a pleasant color palette - palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(245, 245, 245)) - palette.setColor(QPalette.ColorRole.WindowText, QColor(33, 33, 33)) - palette.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255)) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(240, 240, 240)) - palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240)) - palette.setColor(QPalette.ColorRole.ButtonText, QColor(33, 33, 33)) - palette.setColor(QPalette.ColorRole.Highlight, QColor(33, 150, 243)) - palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255)) - app.setPalette(palette) - - window = SpeedCalcWindow() - window.show() - - sys.exit(app.exec()) - - -if __name__ == "__main__": - main() diff --git a/backend/builder/service.py b/backend/builder/service.py index 17feb47..c20f7c4 100644 --- a/backend/builder/service.py +++ b/backend/builder/service.py @@ -7,11 +7,12 @@ from typing import List, Optional from builder import database as db from builder.models import BuiltMelodyCreate, BuiltMelodyUpdate, BuiltMelodyInDB from fastapi import HTTPException +from config import settings logger = logging.getLogger("builder.service") -# Storage directory for built .bsm files -STORAGE_DIR = Path(__file__).parent.parent / "storage" / "built_melodies" +# Storage directory for built .bsm files — configurable via BUILT_MELODIES_STORAGE_PATH env var +STORAGE_DIR = Path(settings.built_melodies_storage_path) def _ensure_storage_dir(): diff --git a/backend/config.py b/backend/config.py index 8a12a62..ce59aa4 100644 --- a/backend/config.py +++ b/backend/config.py @@ -24,6 +24,9 @@ class Settings(BaseSettings): sqlite_db_path: str = "./mqtt_data.db" mqtt_data_retention_days: int = 90 + # Local file storage + built_melodies_storage_path: str = "./storage/built_melodies" + # App backend_cors_origins: str = '["http://localhost:5173"]' debug: bool = True diff --git a/backend/devices/models.py b/backend/devices/models.py index 23f513c..b53c56e 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -1,5 +1,5 @@ from pydantic import BaseModel, Field -from typing import List, Optional +from typing import Any, Dict, List, Optional from enum import Enum @@ -133,9 +133,10 @@ class DeviceUpdate(BaseModel): device_photo: Optional[str] = None device_location: Optional[str] = None is_Online: Optional[bool] = None - device_attributes: Optional[DeviceAttributes] = None - device_subscription: Optional[DeviceSubInformation] = None - device_stats: Optional[DeviceStatistics] = None + # Use raw dicts so only the fields actually sent are present — no Pydantic defaults + device_attributes: Optional[Dict[str, Any]] = None + device_subscription: Optional[Dict[str, Any]] = None + device_stats: Optional[Dict[str, Any]] = None events_on: Optional[bool] = None device_location_coordinates: Optional[str] = None device_melodies_all: Optional[List[MelodyMainItem]] = None diff --git a/backend/devices/service.py b/backend/devices/service.py index d3bbac9..4e58f86 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -15,6 +15,33 @@ COLLECTION = "devices" SN_CHARS = string.ascii_uppercase + string.digits SN_SEGMENT_LEN = 4 +# Clock/silence/backlight fields stored as Firestore Timestamps (written as datetime) +_TIMESTAMP_FIELD_NAMES = { + "daySilenceFrom", "daySilenceTo", + "nightSilenceFrom", "nightSilenceTo", + "backlightTurnOnTime", "backlightTurnOffTime", +} + + +def _restore_timestamps(d: dict) -> dict: + """Recursively convert ISO 8601 strings for known timestamp fields to datetime objects. + + Firestore stores Python datetime objects as native Timestamps, which Flutter + reads as DateTime. Plain strings would break the Flutter app. + """ + result = {} + for k, v in d.items(): + if isinstance(v, dict): + result[k] = _restore_timestamps(v) + elif isinstance(v, str) and k in _TIMESTAMP_FIELD_NAMES: + try: + result[k] = datetime.fromisoformat(v.replace("Z", "+00:00")) + except ValueError: + result[k] = v + else: + result[k] = v + return result + def _generate_serial_number() -> str: """Generate a unique serial number in the format BS-XXXX-XXXX.""" @@ -139,6 +166,17 @@ def create_device(data: DeviceCreate) -> DeviceInDB: return DeviceInDB(id=doc_ref.id, **doc_data) +def _deep_merge(base: dict, overrides: dict) -> dict: + """Recursively merge overrides into base, preserving unmentioned nested keys.""" + result = dict(base) + for k, v in overrides.items(): + if isinstance(v, dict) and isinstance(result.get(k), dict): + result[k] = _deep_merge(result[k], v) + else: + result[k] = v + return result + + def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB: """Update an existing device document. Only provided fields are updated.""" db = get_db() @@ -149,16 +187,16 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB: update_data = data.model_dump(exclude_none=True) - # For nested structs, merge with existing data rather than replacing + # Deep-merge nested structs so unmentioned sub-fields are preserved existing = doc.to_dict() nested_keys = ( "device_attributes", "device_subscription", "device_stats", ) for key in nested_keys: - if key in update_data and key in existing: - merged = {**existing[key], **update_data[key]} - update_data[key] = merged + if key in update_data and isinstance(existing.get(key), dict): + update_data[key] = _deep_merge(existing[key], update_data[key]) + update_data = _restore_timestamps(update_data) doc_ref.update(update_data) updated_doc = doc_ref.get() diff --git a/backend/mqtt_data.db b/backend/mqtt_data.db index e69de29..113c724 100644 Binary files a/backend/mqtt_data.db and b/backend/mqtt_data.db differ diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 79f7430..1083d9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,10 @@ services: env_file: .env volumes: - ./backend:/app + # Persistent data - lives outside the container + - ./data/mqtt_data.db:/app/mqtt_data.db + - ./data/built_melodies:/app/storage/built_melodies + - ./data/firebase-service-account.json:/app/firebase-service-account.json:ro ports: - "8000:8000" depends_on: [] diff --git a/frontend/public/devices/Strikers/striker_size_1.png b/frontend/public/devices/Strikers/striker_size_1.png new file mode 100644 index 0000000..369973b Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_1.png differ diff --git a/frontend/public/devices/Strikers/striker_size_2.png b/frontend/public/devices/Strikers/striker_size_2.png new file mode 100644 index 0000000..22eab14 Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_2.png differ diff --git a/frontend/public/devices/Strikers/striker_size_3.png b/frontend/public/devices/Strikers/striker_size_3.png new file mode 100644 index 0000000..45dbcb1 Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_3.png differ diff --git a/frontend/public/devices/Strikers/striker_size_4.png b/frontend/public/devices/Strikers/striker_size_4.png new file mode 100644 index 0000000..4d07a82 Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_4.png differ diff --git a/frontend/public/devices/Strikers/striker_size_5.png b/frontend/public/devices/Strikers/striker_size_5.png new file mode 100644 index 0000000..4f942e1 Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_5.png differ diff --git a/frontend/public/devices/Strikers/striker_size_6.png b/frontend/public/devices/Strikers/striker_size_6.png new file mode 100644 index 0000000..6e5f8a8 Binary files /dev/null and b/frontend/public/devices/Strikers/striker_size_6.png differ diff --git a/frontend/public/image-assets/certified_logo.png b/frontend/public/image-assets/certified_logo.png new file mode 100644 index 0000000..b3f47b1 Binary files /dev/null and b/frontend/public/image-assets/certified_logo.png differ diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index 020bd1c..5d1aff3 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -20,37 +20,57 @@ L.Icon.Default.mergeOptions({ // --- Helper components --- function Field({ label, children }) { + const isUnavailableText = + typeof children === "string" && + (children.toLowerCase() === "unavailable info" || children.toLowerCase() === "info unavailable"); + return ( -
-
- {label} -
-
- {children || "-"} +
+
{label}
+
+ {children == null ? ( + Unavailable info + ) : isUnavailableText ? ( + {children} + ) : ( + children + )}
); } function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) { + const isMissing = typeof value !== "boolean"; + const text = isMissing ? "Unavailable info" : value ? yesLabel : noLabel; + const tone = isMissing ? "muted" : value ? "positive" : "negative"; return ( - - {value ? yesLabel : noLabel} + + {text} ); } -function SectionCard({ title, children }) { +function SectionCard({ title, children, onEdit }) { return ( -
-

{title}

+
+
+

{title}

+ {onEdit && ( + + )} +
{children}
); @@ -65,17 +85,1044 @@ function Subsection({ title, children, isFirst = false }) { ); } -/** A grid row of fields — Nth items align across rows within a subsection */ +/** A grid row of fields — Nth items align across rows within a subsection */ function FieldRow({ children, columns }) { const childArray = Array.isArray(children) ? children.filter(Boolean) : [children]; const count = columns || childArray.length; return ( -
+
{children}
); } +function EmptyCell() { + return