diff --git a/SecondaryApps/MelodyBuilders/all_melodies.txt b/SecondaryApps/MelodyBuilders/all_melodies.txt new file mode 100644 index 0000000..77a8f8b --- /dev/null +++ b/SecondaryApps/MelodyBuilders/all_melodies.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..c228cb5 --- /dev/null +++ b/SecondaryApps/MelodyBuilders/builtin_melodies.txt @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..f61815a --- /dev/null +++ b/SecondaryApps/MelodyBuilders/convert_to_bin.py @@ -0,0 +1,215 @@ +#!/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 new file mode 100644 index 0000000..7ee77c8 --- /dev/null +++ b/SecondaryApps/MelodyBuilders/convert_to_builtin.py @@ -0,0 +1,300 @@ +#!/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 new file mode 100644 index 0000000..e6604cf --- /dev/null +++ b/SecondaryApps/MelodyBuilders/melodies.h @@ -0,0 +1,475 @@ +/* + * 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 new file mode 100644 index 0000000..bc77804 Binary files /dev/null and b/SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm differ diff --git a/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm b/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm new file mode 100644 index 0000000..8426808 Binary files /dev/null and b/SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm differ diff --git a/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt b/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt new file mode 100644 index 0000000..6f5f749 --- /dev/null +++ b/SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..7941d67 --- /dev/null +++ b/SecondaryApps/SpeedCalc/requirements.txt @@ -0,0 +1,3 @@ +PyQt6>=6.4.0 +numpy>=1.21.0 +sounddevice>=0.4.6 diff --git a/SecondaryApps/SpeedCalc/speedcalc.py b/SecondaryApps/SpeedCalc/speedcalc.py new file mode 100644 index 0000000..a3d79bf --- /dev/null +++ b/SecondaryApps/SpeedCalc/speedcalc.py @@ -0,0 +1,1067 @@ +#!/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/__init__.py b/backend/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/builder/database.py b/backend/builder/database.py new file mode 100644 index 0000000..d07d157 --- /dev/null +++ b/backend/builder/database.py @@ -0,0 +1,90 @@ +import json +import logging +from mqtt.database import get_db + +logger = logging.getLogger("builder.database") + + +async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None: + db = await get_db() + await db.execute( + """INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids) + VALUES (?, ?, ?, ?, ?)""", + (melody_id, name, pid, steps, json.dumps([])), + ) + await db.commit() + + +async def update_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None: + db = await get_db() + await db.execute( + """UPDATE built_melodies + SET name = ?, pid = ?, steps = ?, updated_at = datetime('now') + WHERE id = ?""", + (name, pid, steps, melody_id), + ) + await db.commit() + + +async def update_binary_path(melody_id: str, binary_path: str) -> None: + db = await get_db() + await db.execute( + """UPDATE built_melodies + SET binary_path = ?, updated_at = datetime('now') + WHERE id = ?""", + (binary_path, melody_id), + ) + await db.commit() + + +async def update_progmem_code(melody_id: str, progmem_code: str) -> None: + db = await get_db() + await db.execute( + """UPDATE built_melodies + SET progmem_code = ?, updated_at = datetime('now') + WHERE id = ?""", + (progmem_code, melody_id), + ) + await db.commit() + + +async def update_assigned_melody_ids(melody_id: str, assigned_ids: list) -> None: + db = await get_db() + await db.execute( + """UPDATE built_melodies + SET assigned_melody_ids = ?, updated_at = datetime('now') + WHERE id = ?""", + (json.dumps(assigned_ids), melody_id), + ) + await db.commit() + + +async def get_built_melody(melody_id: str) -> dict | None: + db = await get_db() + rows = await db.execute_fetchall( + "SELECT * FROM built_melodies WHERE id = ?", (melody_id,) + ) + if not rows: + return None + row = dict(rows[0]) + row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]") + return row + + +async def list_built_melodies() -> list[dict]: + db = await get_db() + rows = await db.execute_fetchall( + "SELECT * FROM built_melodies ORDER BY updated_at DESC" + ) + results = [] + for row in rows: + r = dict(row) + r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]") + results.append(r) + return results + + +async def delete_built_melody(melody_id: str) -> None: + db = await get_db() + await db.execute("DELETE FROM built_melodies WHERE id = ?", (melody_id,)) + await db.commit() diff --git a/backend/builder/models.py b/backend/builder/models.py new file mode 100644 index 0000000..6c6f2d5 --- /dev/null +++ b/backend/builder/models.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel +from typing import List, Optional + + +class BuiltMelodyCreate(BaseModel): + name: str + pid: str + steps: str # raw step string e.g. "1,2,2+1,1,2,3+1" + + +class BuiltMelodyUpdate(BaseModel): + name: Optional[str] = None + pid: Optional[str] = None + steps: Optional[str] = None + + +class BuiltMelodyInDB(BaseModel): + id: str + name: str + pid: str + steps: str + binary_path: Optional[str] = None + binary_url: Optional[str] = None + progmem_code: Optional[str] = None + assigned_melody_ids: List[str] = [] + created_at: str + updated_at: str + + @property + def step_count(self) -> int: + if not self.steps: + return 0 + return len(self.steps.split(",")) + + +class BuiltMelodyListResponse(BaseModel): + melodies: List[BuiltMelodyInDB] + total: int diff --git a/backend/builder/router.py b/backend/builder/router.py new file mode 100644 index 0000000..526371e --- /dev/null +++ b/backend/builder/router.py @@ -0,0 +1,124 @@ +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from auth.models import TokenPayload +from auth.dependencies import require_permission +from builder.models import ( + BuiltMelodyCreate, + BuiltMelodyUpdate, + BuiltMelodyInDB, + BuiltMelodyListResponse, +) +from builder import service + +router = APIRouter(prefix="/api/builder/melodies", tags=["builder"]) + + +@router.get("", response_model=BuiltMelodyListResponse) +async def list_built_melodies( + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + melodies = await service.list_built_melodies() + return BuiltMelodyListResponse(melodies=melodies, total=len(melodies)) + +@router.get("/for-melody/{firestore_melody_id}") +async def get_for_firestore_melody( + firestore_melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + """Get the built melody assigned to a given Firestore melody ID. Returns null if none found.""" + result = await service.get_built_melody_for_firestore_id(firestore_melody_id) + if result is None: + return None + return result.model_dump() + + + +@router.get("/{melody_id}", response_model=BuiltMelodyInDB) +async def get_built_melody( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + return await service.get_built_melody(melody_id) + + +@router.post("", response_model=BuiltMelodyInDB, status_code=201) +async def create_built_melody( + body: BuiltMelodyCreate, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + return await service.create_built_melody(body) + + +@router.put("/{melody_id}", response_model=BuiltMelodyInDB) +async def update_built_melody( + melody_id: str, + body: BuiltMelodyUpdate, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + return await service.update_built_melody(melody_id, body) + + +@router.delete("/{melody_id}", status_code=204) +async def delete_built_melody( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "delete")), +): + await service.delete_built_melody(melody_id) + + +@router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB) +async def build_binary( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Build the .bsm binary file from the stored steps.""" + return await service.build_binary(melody_id) + + +@router.post("/{melody_id}/build-builtin", response_model=BuiltMelodyInDB) +async def build_builtin_code( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Generate PROGMEM C code and store it in the database.""" + return await service.build_builtin_code(melody_id) + + +@router.get("/{melody_id}/download") +async def download_binary( + melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "view")), +): + """Download the .bsm binary file.""" + path = await service.get_binary_path(melody_id) + if not path: + raise HTTPException(status_code=404, detail="Binary not built yet for this melody") + + melody = await service.get_built_melody(melody_id) + filename = f"{melody.name}.bsm" + + return FileResponse( + path=str(path), + media_type="application/octet-stream", + filename=filename, + ) + + +@router.post("/{melody_id}/assign", response_model=BuiltMelodyInDB) +async def assign_to_melody( + melody_id: str, + firestore_melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Mark this built melody as assigned to a Firestore melody.""" + return await service.assign_to_melody(melody_id, firestore_melody_id) + + +@router.post("/{melody_id}/unassign", response_model=BuiltMelodyInDB) +async def unassign_from_melody( + melody_id: str, + firestore_melody_id: str, + _user: TokenPayload = Depends(require_permission("melodies", "edit")), +): + """Remove a Firestore melody assignment from this built melody.""" + return await service.unassign_from_melody(melody_id, firestore_melody_id) diff --git a/backend/builder/service.py b/backend/builder/service.py new file mode 100644 index 0000000..bec2aa4 --- /dev/null +++ b/backend/builder/service.py @@ -0,0 +1,257 @@ +import logging +import uuid +from pathlib import Path +from datetime import datetime +from typing import List, Optional + +from builder import database as db +from builder.models import BuiltMelodyCreate, BuiltMelodyUpdate, BuiltMelodyInDB +from fastapi import HTTPException + +logger = logging.getLogger("builder.service") + +# Storage directory for built .bsm files +STORAGE_DIR = Path("storage/built_melodies") + + +def _ensure_storage_dir(): + STORAGE_DIR.mkdir(parents=True, exist_ok=True) + + +def _binary_url(melody_id: str) -> str: + """Returns the API URL to download the binary for a given melody id.""" + return f"/api/builder/melodies/{melody_id}/download" + + +def _row_to_built_melody(row: dict) -> BuiltMelodyInDB: + binary_path = row.get("binary_path") + binary_url = _binary_url(row["id"]) if binary_path else None + return BuiltMelodyInDB( + id=row["id"], + name=row["name"], + pid=row["pid"], + steps=row["steps"], + binary_path=binary_path, + binary_url=binary_url, + progmem_code=row.get("progmem_code"), + assigned_melody_ids=row.get("assigned_melody_ids", []), + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +# ============================================================================ +# Conversion Logic (ported from SecondaryApps/MelodyBuilders/) +# ============================================================================ + +def parse_bell_notation(notation: str) -> int: + """Convert human-readable bell notation to uint16 bit flag value. + + Examples: + "2+8" → bells 2,8 → bits 1,7 → 0x0082 (130) + "4" → bell 4 → bit 3 → 0x0008 (8) + "0" → silence → 0x0000 (0) + """ + notation = notation.strip() + if notation == "0" or not notation: + return 0 + + value = 0 + for bell_str in notation.split("+"): + bell_str = bell_str.strip() + try: + bell_num = int(bell_str) + if bell_num == 0: + continue + if bell_num < 1 or bell_num > 16: + logger.warning(f"Bell number {bell_num} out of range (1-16), skipping") + continue + value |= 1 << (bell_num - 1) + except ValueError: + logger.warning(f"Invalid bell token '{bell_str}', skipping") + return value + + +def steps_string_to_values(steps: str) -> List[int]: + """Parse raw steps string (e.g. '1,2+3,0,4') into list of uint16 values.""" + return [parse_bell_notation(s) for s in steps.split(",")] + + +def format_melody_array(name: str, values: List[int], values_per_line: int = 8) -> str: + """Format values as C PROGMEM array declaration.""" + array_name = f"melody_builtin_{name.lower()}" + lines = [f"const uint16_t PROGMEM {array_name}[] = {{"] + for i in range(0, len(values), values_per_line): + chunk = values[i : i + values_per_line] + hex_vals = [f"0x{v:04X}" for v in chunk] + suffix = "," if i + len(chunk) < len(values) else "" + lines.append(" " + ", ".join(hex_vals) + suffix) + lines.append("};") + return "\n".join(lines) + + +def generate_progmem_code(name: str, pid: str, values: List[int]) -> str: + """Generate standalone PROGMEM C code for a single melody.""" + array_name = f"melody_builtin_{name.lower()}" + id_name = pid if pid else f"builtin_{name.lower()}" + display_name = name.replace("_", " ").title() + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + parts = [ + f"// Generated: {timestamp}", + f"// Melody: {display_name} | PID: {id_name}", + "", + format_melody_array(name, values), + "", + "// --- Add this entry to your MELODY_LIBRARY[] array: ---", + "// {", + f'// "{display_name}",', + f'// "{id_name}",', + f"// {array_name},", + f"// sizeof({array_name}) / sizeof(uint16_t)", + "// }", + ] + return "\n".join(parts) + + +# ============================================================================ +# CRUD +# ============================================================================ + +async def list_built_melodies() -> List[BuiltMelodyInDB]: + rows = await db.list_built_melodies() + return [_row_to_built_melody(r) for r in rows] + + +async def get_built_melody(melody_id: str) -> BuiltMelodyInDB: + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + return _row_to_built_melody(row) + + +async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB: + melody_id = str(uuid.uuid4()) + await db.insert_built_melody( + melody_id=melody_id, + name=data.name, + pid=data.pid, + steps=data.steps, + ) + return await get_built_melody(melody_id) + + +async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB: + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + + new_name = data.name if data.name is not None else row["name"] + new_pid = data.pid if data.pid is not None else row["pid"] + new_steps = data.steps if data.steps is not None else row["steps"] + + await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps) + return await get_built_melody(melody_id) + + +async def delete_built_melody(melody_id: str) -> None: + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + + # Delete the .bsm file if it exists + if row.get("binary_path"): + bsm_path = Path(row["binary_path"]) + if bsm_path.exists(): + bsm_path.unlink() + + await db.delete_built_melody(melody_id) + + +# ============================================================================ +# Build Actions +# ============================================================================ + +async def build_binary(melody_id: str) -> BuiltMelodyInDB: + """Parse steps and write a .bsm binary file to storage.""" + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + + _ensure_storage_dir() + values = steps_string_to_values(row["steps"]) + + bsm_path = STORAGE_DIR / f"{melody_id}.bsm" + with open(bsm_path, "wb") as f: + for value in values: + value = value & 0xFFFF + f.write(value.to_bytes(2, byteorder="big")) + + await db.update_binary_path(melody_id, str(bsm_path)) + logger.info(f"Built binary for '{row['name']}' → {bsm_path} ({len(values)} steps)") + return await get_built_melody(melody_id) + + +async def build_builtin_code(melody_id: str) -> BuiltMelodyInDB: + """Generate PROGMEM C code and store it in the database.""" + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + + values = steps_string_to_values(row["steps"]) + code = generate_progmem_code(row["name"], row["pid"], values) + + await db.update_progmem_code(melody_id, code) + logger.info(f"Built builtin code for '{row['name']}'") + return await get_built_melody(melody_id) + + +async def get_binary_path(melody_id: str) -> Optional[Path]: + """Return the filesystem path to the .bsm file, or None if not built.""" + row = await db.get_built_melody(melody_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found") + if not row.get("binary_path"): + return None + path = Path(row["binary_path"]) + if not path.exists(): + return None + return path + + +# ============================================================================ +# Assignment +# ============================================================================ + +async def assign_to_melody(built_id: str, firestore_melody_id: str) -> BuiltMelodyInDB: + """Add a Firestore melody ID to this built melody's assignment list.""" + row = await db.get_built_melody(built_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{built_id}' not found") + + assigned = row.get("assigned_melody_ids", []) + if firestore_melody_id not in assigned: + assigned.append(firestore_melody_id) + await db.update_assigned_melody_ids(built_id, assigned) + + return await get_built_melody(built_id) + + +async def unassign_from_melody(built_id: str, firestore_melody_id: str) -> BuiltMelodyInDB: + """Remove a Firestore melody ID from this built melody's assignment list.""" + row = await db.get_built_melody(built_id) + if not row: + raise HTTPException(status_code=404, detail=f"Built melody '{built_id}' not found") + + assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id] + await db.update_assigned_melody_ids(built_id, assigned) + return await get_built_melody(built_id) + + +async def get_built_melody_for_firestore_id(firestore_melody_id: str) -> Optional[BuiltMelodyInDB]: + """Find the built melody assigned to a given Firestore melody ID (first match).""" + rows = await db.list_built_melodies() + for row in rows: + if firestore_melody_id in row.get("assigned_melody_ids", []): + return _row_to_built_melody(row) + return None diff --git a/backend/main.py b/backend/main.py index 0d8fbf9..c51358e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,6 +12,7 @@ from mqtt.router import router as mqtt_router from equipment.router import router as equipment_router from staff.router import router as staff_router from helpdesk.router import router as helpdesk_router +from builder.router import router as builder_router from mqtt.client import mqtt_manager from mqtt import database as mqtt_db from melodies import service as melody_service @@ -40,6 +41,7 @@ app.include_router(mqtt_router) app.include_router(equipment_router) app.include_router(helpdesk_router) app.include_router(staff_router) +app.include_router(builder_router) @app.on_event("startup") diff --git a/backend/mqtt/database.py b/backend/mqtt/database.py index 455ab22..e17e196 100644 --- a/backend/mqtt/database.py +++ b/backend/mqtt/database.py @@ -53,6 +53,18 @@ SCHEMA_STATEMENTS = [ updated_at TEXT NOT NULL DEFAULT (datetime('now')) )""", "CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)", + # Built melodies table (local melody builder) + """CREATE TABLE IF NOT EXISTS built_melodies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + pid TEXT NOT NULL, + steps TEXT NOT NULL, + binary_path TEXT, + progmem_code TEXT, + assigned_melody_ids TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", ] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8efc189..8a4159d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,8 @@ import MelodyList from "./melodies/MelodyList"; import MelodyDetail from "./melodies/MelodyDetail"; import MelodyForm from "./melodies/MelodyForm"; import MelodySettings from "./melodies/MelodySettings"; +import BuilderList from "./melodies/builder/BuilderList"; +import BuilderForm from "./melodies/builder/BuilderForm"; import DeviceList from "./devices/DeviceList"; import DeviceDetail from "./devices/DeviceDetail"; import DeviceForm from "./devices/DeviceForm"; @@ -116,6 +118,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx index 8528a86..5f67ba9 100644 --- a/frontend/src/layout/Sidebar.jsx +++ b/frontend/src/layout/Sidebar.jsx @@ -9,6 +9,7 @@ const navItems = [ permission: "melodies", children: [ { to: "/melodies", label: "Editor" }, + { to: "/melodies/builder", label: "Builder" }, { to: "/melodies/settings", label: "Settings" }, ], }, diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 105c79d..74c4df6 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; +import SpeedCalculatorModal from "./SpeedCalculatorModal"; import { getLocalizedValue, getLanguageName, @@ -36,6 +37,9 @@ export default function MelodyDetail() { const [actionLoading, setActionLoading] = useState(false); const [displayLang, setDisplayLang] = useState("en"); const [melodySettings, setMelodySettings] = useState(null); + const [builtMelody, setBuiltMelody] = useState(null); + const [codeCopied, setCodeCopied] = useState(false); + const [showSpeedCalc, setShowSpeedCalc] = useState(false); useEffect(() => { api.get("/settings/melody").then((ms) => { @@ -57,6 +61,13 @@ export default function MelodyDetail() { ]); setMelody(m); setFiles(f); + // Load built melody assignment (non-fatal if it fails) + try { + const bm = await api.get(`/builder/melodies/for-melody/${id}`); + setBuiltMelody(bm || null); + } catch { + setBuiltMelody(null); + } } catch (err) { setError(err.message); } finally { @@ -189,6 +200,13 @@ export default function MelodyDetail() { Unpublish )} + + +
+            {builtMelody.progmem_code}
+          
+ + )} + + setShowSpeedCalc(false)} + onSaved={() => { + setShowSpeedCalc(false); + loadData(); + }} + /> + { api.get("/settings/melody").then((ms) => { setMelodySettings(ms); @@ -553,9 +558,29 @@ export default function MelodyForm() {

Files

- + {existingFiles.binary_url && (

Current file uploaded. Selecting a new file will replace it.

)} - setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} /> + setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} /> + {isEdit && ( +
+ + +
+ )}
@@ -582,6 +607,31 @@ export default function MelodyForm() { languages={languages} multiline={translationModal.multiline} /> + + {isEdit && ( + <> + setShowSelectBuilt(false)} + onSuccess={() => { + setShowSelectBuilt(false); + loadMelody(); + }} + /> + setShowBuildOnTheFly(false)} + onSuccess={() => { + setShowBuildOnTheFly(false); + loadMelody(); + }} + /> + + )}
); } diff --git a/frontend/src/melodies/SpeedCalculatorModal.jsx b/frontend/src/melodies/SpeedCalculatorModal.jsx new file mode 100644 index 0000000..03ce2d0 --- /dev/null +++ b/frontend/src/melodies/SpeedCalculatorModal.jsx @@ -0,0 +1,562 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import api from "../api/client"; + +// ============================================================================ +// Web Audio Engine +// ============================================================================ + +/** + * Bell frequencies: Bell 1 = highest (880 Hz A5), each subsequent bell 2 semitones lower. + * freq = 880 * (2^(1/12))^(-2*(bell-1)) + */ +function bellFrequency(bellNumber) { + return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1)); +} + +function playStep(audioCtx, stepValue, beatDurationMs) { + if (!stepValue || !audioCtx) return; + const now = audioCtx.currentTime; + const duration = beatDurationMs / 1000; + const fadeIn = 0.005; + const fadeOut = 0.03; + + for (let bit = 0; bit < 16; bit++) { + if (stepValue & (1 << bit)) { + const bellNum = bit + 1; + const freq = bellFrequency(bellNum); + + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.connect(gain); + gain.connect(audioCtx.destination); + + osc.type = "sine"; + osc.frequency.setValueAtTime(freq, now); + + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(0.3, now + fadeIn); + gain.gain.setValueAtTime(0.3, now + duration - fadeOut); + gain.gain.linearRampToValueAtTime(0, now + duration); + + osc.start(now); + osc.stop(now + duration); + } + } +} + +// ============================================================================ +// Parse raw steps string into list of uint16 values +// ============================================================================ + +function parseBellNotation(notation) { + notation = notation.trim(); + if (notation === "0" || !notation) return 0; + let value = 0; + for (const part of notation.split("+")) { + const n = parseInt(part.trim(), 10); + if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1); + } + return value; +} + +function parseStepsString(stepsStr) { + if (!stepsStr || !stepsStr.trim()) return []; + return stepsStr.trim().split(",").map((s) => parseBellNotation(s)); +} + +function getActiveBells(stepValue) { + const bells = []; + for (let bit = 0; bit < 16; bit++) { + if (stepValue & (1 << bit)) bells.push(bit + 1); + } + return bells; +} + +// ============================================================================ +// Speed math +// MIN is derived so that at 50%, speed = normal (geometric mean) +// normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX +// ============================================================================ + +function calcMin(normal, max) { + if (!normal || !max || max <= 0) return null; + return Math.round((normal * normal) / max); +} + +// ============================================================================ +// Component +// ============================================================================ + +const labelStyle = { color: "var(--text-secondary)" }; +const mutedStyle = { color: "var(--text-muted)" }; + +export default function SpeedCalculatorModal({ open, melody, onClose, onSaved }) { + const info = melody?.information || {}; + + // Raw steps input (not stored in Firestore — only used locally for playback) + const [stepsInput, setStepsInput] = useState(""); + const [steps, setSteps] = useState([]); // parsed uint16 values + + // Playback + const audioCtxRef = useRef(null); + const playbackRef = useRef(null); // { timer, stepIndex } + const [playing, setPlaying] = useState(false); + const [paused, setPaused] = useState(false); + const [loop, setLoop] = useState(false); + const [currentStep, setCurrentStep] = useState(-1); + + // Sliders + const [stepDelay, setStepDelay] = useState(500); // ms + const [beatDuration, setBeatDuration] = useState(100); // ms + + const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20)); + + // Speed capture + const [capturedMax, setCapturedMax] = useState(null); + const [capturedNormal, setCapturedNormal] = useState(null); + const derivedMin = calcMin(capturedNormal, capturedMax); + + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + const [saveSuccess, setSaveSuccess] = useState(false); + + // Warnings + const maxWarning = capturedMax !== null && capturedMax < 100; + const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal > capturedMax; + + // Pre-fill existing speeds from melody + useEffect(() => { + if (open) { + setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null); + setCapturedNormal(null); + setStepsInput(""); + setSteps([]); + setCurrentStep(-1); + setPlaying(false); + setPaused(false); + } + }, [open]); + + const stopPlayback = useCallback(() => { + if (playbackRef.current) { + clearTimeout(playbackRef.current.timer); + playbackRef.current = null; + } + setPlaying(false); + setPaused(false); + setCurrentStep(-1); + }, []); + + // Stop when modal closes + useEffect(() => { + if (!open) stopPlayback(); + }, [open, stopPlayback]); + + const ensureAudioCtx = () => { + if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { + audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)(); + } + if (audioCtxRef.current.state === "suspended") { + audioCtxRef.current.resume(); + } + return audioCtxRef.current; + }; + + const scheduleStep = useCallback( + (stepIndex, startFrom = 0) => { + if (!steps.length) return; + + const playFrom = stepIndex % steps.length; + if (!loop && playFrom === 0 && stepIndex > 0) { + stopPlayback(); + return; + } + + const ctx = ensureAudioCtx(); + const stepValue = steps[playFrom]; + + setCurrentStep(playFrom); + playStep(ctx, stepValue, effectiveBeat); + + const timer = setTimeout(() => { + const next = playFrom + 1; + if (next >= steps.length) { + if (loop) { + scheduleStep(0); + } else { + stopPlayback(); + } + } else { + scheduleStep(next); + } + }, stepDelay); + + playbackRef.current = { timer, stepIndex: playFrom }; + }, + [steps, stepDelay, effectiveBeat, loop, stopPlayback] + ); + + const handlePlay = () => { + if (!steps.length) return; + if (paused && playbackRef.current) { + // Resume from current step + setPaused(false); + setPlaying(true); + scheduleStep(playbackRef.current.stepIndex); + return; + } + setPlaying(true); + setPaused(false); + scheduleStep(0); + }; + + const handlePause = () => { + if (playbackRef.current) { + clearTimeout(playbackRef.current.timer); + } + setPaused(true); + setPlaying(false); + }; + + const handleStop = () => { + stopPlayback(); + }; + + const handleStepsParse = () => { + const parsed = parseStepsString(stepsInput); + setSteps(parsed); + stopPlayback(); + }; + + const handleSetSliderToCapture = (value) => { + if (value !== null) setStepDelay(value); + }; + + const handleSave = async () => { + if (!capturedMax || !derivedMin) return; + setSaving(true); + setSaveError(""); + setSaveSuccess(false); + try { + await api.put(`/melodies/${melody.id}`, { + information: { + ...info, + minSpeed: derivedMin, + maxSpeed: capturedMax, + }, + default_settings: melody.default_settings, + type: melody.type, + url: melody.url, + uid: melody.uid, + pid: melody.pid, + }); + setSaveSuccess(true); + setTimeout(() => { + onSaved(); + }, 800); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + }; + + if (!open) return null; + + const totalSteps = steps.length; + const allBellsUsed = steps.reduce((set, v) => { + getActiveBells(v).forEach((b) => set.add(b)); + return set; + }, new Set()); + const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : []; + + return ( +
e.target === e.currentTarget && !playing && onClose()} + > +
+ {/* Header */} +
+
+

Speed Calculator

+

+ Play the melody at different speeds to find the right MIN / MAX. +

+
+ +
+ +
+ {/* Steps Input */} +
+ +
+