Added SpeedCalc and MelodyBuilder. Evaluation Pending
This commit is contained in:
1
SecondaryApps/MelodyBuilders/all_melodies.txt
Normal file
1
SecondaryApps/MelodyBuilders/all_melodies.txt
Normal file
@@ -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
|
||||||
26
SecondaryApps/MelodyBuilders/builtin_melodies.txt
Normal file
26
SecondaryApps/MelodyBuilders/builtin_melodies.txt
Normal file
@@ -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
|
||||||
215
SecondaryApps/MelodyBuilders/convert_to_bin.py
Normal file
215
SecondaryApps/MelodyBuilders/convert_to_bin.py
Normal file
@@ -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()
|
||||||
300
SecondaryApps/MelodyBuilders/convert_to_builtin.py
Normal file
300
SecondaryApps/MelodyBuilders/convert_to_builtin.py
Normal file
@@ -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 <Arduino.h>\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()
|
||||||
475
SecondaryApps/MelodyBuilders/melodies.h
Normal file
475
SecondaryApps/MelodyBuilders/melodies.h
Normal file
@@ -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 <Arduino.h>
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 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
|
||||||
BIN
SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm
Normal file
BIN
SecondaryApps/SpeedCalc/melodies_in_binary/1N_Esperinos_Adam.bsm
Normal file
Binary file not shown.
BIN
SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm
Normal file
BIN
SecondaryApps/SpeedCalc/melodies_in_binary/3N_MiaMia.bsm
Normal file
Binary file not shown.
3
SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt
Normal file
3
SecondaryApps/SpeedCalc/melodies_in_txt/melodies.txt
Normal file
@@ -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
|
||||||
3
SecondaryApps/SpeedCalc/requirements.txt
Normal file
3
SecondaryApps/SpeedCalc/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
PyQt6>=6.4.0
|
||||||
|
numpy>=1.21.0
|
||||||
|
sounddevice>=0.4.6
|
||||||
1067
SecondaryApps/SpeedCalc/speedcalc.py
Normal file
1067
SecondaryApps/SpeedCalc/speedcalc.py
Normal file
File diff suppressed because it is too large
Load Diff
0
backend/builder/__init__.py
Normal file
0
backend/builder/__init__.py
Normal file
90
backend/builder/database.py
Normal file
90
backend/builder/database.py
Normal file
@@ -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()
|
||||||
38
backend/builder/models.py
Normal file
38
backend/builder/models.py
Normal file
@@ -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
|
||||||
124
backend/builder/router.py
Normal file
124
backend/builder/router.py
Normal file
@@ -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)
|
||||||
257
backend/builder/service.py
Normal file
257
backend/builder/service.py
Normal file
@@ -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
|
||||||
@@ -12,6 +12,7 @@ from mqtt.router import router as mqtt_router
|
|||||||
from equipment.router import router as equipment_router
|
from equipment.router import router as equipment_router
|
||||||
from staff.router import router as staff_router
|
from staff.router import router as staff_router
|
||||||
from helpdesk.router import router as helpdesk_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.client import mqtt_manager
|
||||||
from mqtt import database as mqtt_db
|
from mqtt import database as mqtt_db
|
||||||
from melodies import service as melody_service
|
from melodies import service as melody_service
|
||||||
@@ -40,6 +41,7 @@ app.include_router(mqtt_router)
|
|||||||
app.include_router(equipment_router)
|
app.include_router(equipment_router)
|
||||||
app.include_router(helpdesk_router)
|
app.include_router(helpdesk_router)
|
||||||
app.include_router(staff_router)
|
app.include_router(staff_router)
|
||||||
|
app.include_router(builder_router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ SCHEMA_STATEMENTS = [
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
)""",
|
)""",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)",
|
"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'))
|
||||||
|
)""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import MelodyList from "./melodies/MelodyList";
|
|||||||
import MelodyDetail from "./melodies/MelodyDetail";
|
import MelodyDetail from "./melodies/MelodyDetail";
|
||||||
import MelodyForm from "./melodies/MelodyForm";
|
import MelodyForm from "./melodies/MelodyForm";
|
||||||
import MelodySettings from "./melodies/MelodySettings";
|
import MelodySettings from "./melodies/MelodySettings";
|
||||||
|
import BuilderList from "./melodies/builder/BuilderList";
|
||||||
|
import BuilderForm from "./melodies/builder/BuilderForm";
|
||||||
import DeviceList from "./devices/DeviceList";
|
import DeviceList from "./devices/DeviceList";
|
||||||
import DeviceDetail from "./devices/DeviceDetail";
|
import DeviceDetail from "./devices/DeviceDetail";
|
||||||
import DeviceForm from "./devices/DeviceForm";
|
import DeviceForm from "./devices/DeviceForm";
|
||||||
@@ -116,6 +118,9 @@ export default function App() {
|
|||||||
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
|
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
|
||||||
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
|
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
|
||||||
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
|
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
|
||||||
|
<Route path="melodies/builder" element={<PermissionGate section="melodies" action="edit"><BuilderList /></PermissionGate>} />
|
||||||
|
<Route path="melodies/builder/new" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} />
|
||||||
|
<Route path="melodies/builder/:id" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} />
|
||||||
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
|
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
|
||||||
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
|
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const navItems = [
|
|||||||
permission: "melodies",
|
permission: "melodies",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/melodies", label: "Editor" },
|
{ to: "/melodies", label: "Editor" },
|
||||||
|
{ to: "/melodies/builder", label: "Builder" },
|
||||||
{ to: "/melodies/settings", label: "Settings" },
|
{ to: "/melodies/settings", label: "Settings" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
|||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import ConfirmDialog from "../components/ConfirmDialog";
|
import ConfirmDialog from "../components/ConfirmDialog";
|
||||||
|
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -36,6 +37,9 @@ export default function MelodyDetail() {
|
|||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [displayLang, setDisplayLang] = useState("en");
|
const [displayLang, setDisplayLang] = useState("en");
|
||||||
const [melodySettings, setMelodySettings] = useState(null);
|
const [melodySettings, setMelodySettings] = useState(null);
|
||||||
|
const [builtMelody, setBuiltMelody] = useState(null);
|
||||||
|
const [codeCopied, setCodeCopied] = useState(false);
|
||||||
|
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
@@ -57,6 +61,13 @@ export default function MelodyDetail() {
|
|||||||
]);
|
]);
|
||||||
setMelody(m);
|
setMelody(m);
|
||||||
setFiles(f);
|
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) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -189,6 +200,13 @@ export default function MelodyDetail() {
|
|||||||
Unpublish
|
Unpublish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSpeedCalc(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Speed Calculator
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/melodies/${id}/edit`)}
|
onClick={() => navigate(`/melodies/${id}/edit`)}
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
@@ -375,6 +393,63 @@ export default function MelodyDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
|
||||||
|
{builtMelody?.progmem_code && (
|
||||||
|
<section
|
||||||
|
className="rounded-lg p-6 border mt-6"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
PROGMEM code for built-in firmware playback · PID: <span className="font-mono">{builtMelody.pid}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(builtMelody.progmem_code).then(() => {
|
||||||
|
setCodeCopied(true);
|
||||||
|
setTimeout(() => setCodeCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: codeCopied ? "var(--success-bg)" : "var(--bg-card-hover)",
|
||||||
|
color: codeCopied ? "var(--success-text)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{codeCopied ? "Copied!" : "Copy Code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
className="p-4 text-xs overflow-x-auto rounded-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
maxHeight: "360px",
|
||||||
|
overflowY: "auto",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{builtMelody.progmem_code}
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SpeedCalculatorModal
|
||||||
|
open={showSpeedCalc}
|
||||||
|
melody={melody}
|
||||||
|
onClose={() => setShowSpeedCalc(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowSpeedCalc(false);
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDelete}
|
open={showDelete}
|
||||||
title="Delete Melody"
|
title="Delete Melody"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
import TranslationModal from "./TranslationModal";
|
import TranslationModal from "./TranslationModal";
|
||||||
|
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
|
||||||
|
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
|
||||||
import {
|
import {
|
||||||
getLocalizedValue,
|
getLocalizedValue,
|
||||||
getLanguageName,
|
getLanguageName,
|
||||||
@@ -80,6 +82,9 @@ export default function MelodyForm() {
|
|||||||
multiline: false,
|
multiline: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [showSelectBuilt, setShowSelectBuilt] = useState(false);
|
||||||
|
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
setMelodySettings(ms);
|
setMelodySettings(ms);
|
||||||
@@ -553,9 +558,29 @@ export default function MelodyForm() {
|
|||||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bin)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
|
||||||
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
|
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
|
||||||
<input type="file" accept=".bin" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
||||||
|
{isEdit && (
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSelectBuilt(true)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Select Built Melody
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBuildOnTheFly(true)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Build on the Fly
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
|
||||||
@@ -582,6 +607,31 @@ export default function MelodyForm() {
|
|||||||
languages={languages}
|
languages={languages}
|
||||||
multiline={translationModal.multiline}
|
multiline={translationModal.multiline}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<>
|
||||||
|
<SelectBuiltMelodyModal
|
||||||
|
open={showSelectBuilt}
|
||||||
|
melodyId={id}
|
||||||
|
onClose={() => setShowSelectBuilt(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowSelectBuilt(false);
|
||||||
|
loadMelody();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<BuildOnTheFlyModal
|
||||||
|
open={showBuildOnTheFly}
|
||||||
|
melodyId={id}
|
||||||
|
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
||||||
|
defaultPid={pid}
|
||||||
|
onClose={() => setShowBuildOnTheFly(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowBuildOnTheFly(false);
|
||||||
|
loadMelody();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
562
frontend/src/melodies/SpeedCalculatorModal.jsx
Normal file
562
frontend/src/melodies/SpeedCalculatorModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !playing && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl rounded-lg border shadow-xl"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "90vh", overflowY: "auto" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Speed Calculator</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={mutedStyle}>
|
||||||
|
Play the melody at different speeds to find the right MIN / MAX.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Steps Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>
|
||||||
|
Melody Steps
|
||||||
|
<span className="font-normal ml-2" style={mutedStyle}>(paste your step notation)</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={stepsInput}
|
||||||
|
onChange={(e) => setStepsInput(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..."
|
||||||
|
className="flex-1 px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{ fontFamily: "monospace", resize: "none" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleStepsParse}
|
||||||
|
className="px-4 py-2 text-sm rounded-md self-start transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
|
||||||
|
{totalSteps} steps loaded · {allBellsUsed.size} unique bell{allBellsUsed.size !== 1 ? "s" : ""} used
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bell visualizer */}
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-2" style={mutedStyle}>Bell indicator (current step)</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Array.from({ length: Math.max(...Array.from(allBellsUsed), 1) }, (_, i) => i + 1).map((b) => {
|
||||||
|
const isActive = currentBells.includes(b);
|
||||||
|
const isUsed = allBellsUsed.has(b);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b}
|
||||||
|
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive
|
||||||
|
? "var(--accent)"
|
||||||
|
: isUsed
|
||||||
|
? "var(--bg-card-hover)"
|
||||||
|
: "var(--bg-primary)",
|
||||||
|
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||||
|
border: `2px solid ${isActive ? "var(--accent)" : isUsed ? "var(--border-primary)" : "var(--border-primary)"}`,
|
||||||
|
transform: isActive ? "scale(1.2)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{currentStep >= 0 && (
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>
|
||||||
|
Step {currentStep + 1} / {totalSteps}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playback Controls */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!playing ? (
|
||||||
|
<button
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={!totalSteps}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{paused ? "Resume" : "Play"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handlePause}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={!playing && !paused}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<label className="flex items-center gap-2 ml-2 text-sm cursor-pointer" style={labelStyle}>
|
||||||
|
<input type="checkbox" checked={loop} onChange={(e) => setLoop(e.target.checked)} className="h-4 w-4 rounded" />
|
||||||
|
Loop
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Delay Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="50"
|
||||||
|
max="3000"
|
||||||
|
step="10"
|
||||||
|
value={stepDelay}
|
||||||
|
onChange={(e) => setStepDelay(Number(e.target.value))}
|
||||||
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
|
||||||
|
<span>50ms (fastest)</span>
|
||||||
|
<span>3000ms (slowest)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Beat Duration Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||||
|
<span className="text-sm" style={mutedStyle}>
|
||||||
|
{effectiveBeat} ms
|
||||||
|
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="500"
|
||||||
|
step="5"
|
||||||
|
value={beatDuration}
|
||||||
|
onChange={(e) => setBeatDuration(Number(e.target.value))}
|
||||||
|
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
|
||||||
|
<span>20ms (short)</span>
|
||||||
|
<span>500ms (long)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed Capture Panel */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
|
||||||
|
<p className="text-xs" style={mutedStyle}>
|
||||||
|
Find the fastest speed the controller can handle (MAX), then find the speed that feels "normal" (Normal).
|
||||||
|
MIN is auto-calculated so that 50% = Normal speed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* MAX */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>MAX (fastest safe)</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold w-20" style={{ color: capturedMax ? "var(--accent)" : "var(--text-muted)" }}>
|
||||||
|
{capturedMax !== null ? `${capturedMax} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetSliderToCapture(capturedMax)}
|
||||||
|
disabled={capturedMax === null}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||||
|
title="Load this value to slider"
|
||||||
|
>
|
||||||
|
Set ↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCapturedMax(stepDelay)}
|
||||||
|
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Normal */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>Normal (50% speed feel)</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold w-20" style={{ color: capturedNormal ? "var(--accent)" : "var(--text-muted)" }}>
|
||||||
|
{capturedNormal !== null ? `${capturedNormal} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetSliderToCapture(capturedNormal)}
|
||||||
|
disabled={capturedNormal === null}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||||
|
title="Load this value to slider"
|
||||||
|
>
|
||||||
|
Set ↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCapturedNormal(stepDelay)}
|
||||||
|
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Capture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MIN (auto-calculated) */}
|
||||||
|
<div className="pt-1 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<span className="text-xs font-medium block mb-0.5" style={mutedStyle}>MIN (auto-calculated)</span>
|
||||||
|
<span className="text-lg font-bold" style={{ color: derivedMin ? "var(--text-secondary)" : "var(--text-muted)" }}>
|
||||||
|
{derivedMin !== null ? `${derivedMin} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
{derivedMin !== null && (
|
||||||
|
<span className="text-xs ml-2" style={mutedStyle}>(normal² / max)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{maxWarning && (
|
||||||
|
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||||
|
Warning: MAX speed of {capturedMax}ms is very fast (<100ms). This could damage the device.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{orderWarning && (
|
||||||
|
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||||
|
Warning: Normal speed ({capturedNormal}ms) is slower than MAX ({capturedMax}ms). Please check your values.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
{saveError && (
|
||||||
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{saveSuccess && (
|
||||||
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
|
||||||
|
Speeds saved to melody!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPlayback(); onClose(); }}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!capturedMax || !capturedNormal || !derivedMin || saving || orderWarning}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "#16a34a", color: "#fff" }}
|
||||||
|
title={!capturedMax || !capturedNormal ? "Capture MAX and Normal speeds first" : ""}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save to Melody"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
Normal file
152
frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
const labelStyle = { color: "var(--text-secondary)" };
|
||||||
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
|
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||||
|
|
||||||
|
function countSteps(stepsStr) {
|
||||||
|
if (!stepsStr || !stepsStr.trim()) return 0;
|
||||||
|
return stepsStr.trim().split(",").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaultPid, onClose, onSuccess }) {
|
||||||
|
const [name, setName] = useState(defaultName || "");
|
||||||
|
const [pid, setPid] = useState(defaultPid || "");
|
||||||
|
const [steps, setSteps] = useState("");
|
||||||
|
const [building, setBuilding] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [statusMsg, setStatusMsg] = useState("");
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleBuildAndUpload = async () => {
|
||||||
|
if (!name.trim()) { setError("Name is required."); return; }
|
||||||
|
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||||
|
setBuilding(true);
|
||||||
|
setError("");
|
||||||
|
setStatusMsg("");
|
||||||
|
|
||||||
|
let builtId = null;
|
||||||
|
try {
|
||||||
|
// Step 1: Create the built melody record
|
||||||
|
setStatusMsg("Creating melody record...");
|
||||||
|
const created = await api.post("/builder/melodies", {
|
||||||
|
name: name.trim(),
|
||||||
|
pid: pid.trim(),
|
||||||
|
steps: steps.trim(),
|
||||||
|
});
|
||||||
|
builtId = created.id;
|
||||||
|
|
||||||
|
// Step 2: Build the binary
|
||||||
|
setStatusMsg("Building binary...");
|
||||||
|
const built = await api.post(`/builder/melodies/${builtId}/build-binary`);
|
||||||
|
|
||||||
|
// Step 3: Fetch the .bsm file and upload to Firebase Storage
|
||||||
|
setStatusMsg("Uploading to cloud storage...");
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const res = await fetch(`/api${built.binary_url}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], `${name.trim()}.bsm`, { type: "application/octet-stream" });
|
||||||
|
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||||
|
|
||||||
|
// Step 4: Assign to this melody
|
||||||
|
setStatusMsg("Linking to melody...");
|
||||||
|
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
||||||
|
|
||||||
|
setStatusMsg("Done!");
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setStatusMsg("");
|
||||||
|
} finally {
|
||||||
|
setBuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !building && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-xl rounded-lg border shadow-xl"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Build on the Fly</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={mutedStyle}>Enter steps, build binary, and upload — all in one step.</p>
|
||||||
|
</div>
|
||||||
|
{!building && (
|
||||||
|
<button onClick={onClose} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
|
||||||
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID</label>
|
||||||
|
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
|
||||||
|
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={steps}
|
||||||
|
onChange={(e) => setSteps(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple: 2+3+1\n• Silence: 0"}
|
||||||
|
className={inputClass}
|
||||||
|
style={{ fontFamily: "monospace", resize: "vertical" }}
|
||||||
|
disabled={building}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusMsg && !error && (
|
||||||
|
<div className="text-sm rounded-md p-2 text-center" style={{ color: "var(--text-muted)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
{statusMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={building}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBuildAndUpload}
|
||||||
|
disabled={building}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{building ? "Building & Uploading..." : "Build & Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
frontend/src/melodies/builder/BuilderForm.jsx
Normal file
312
frontend/src/melodies/builder/BuilderForm.jsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
||||||
|
const labelStyle = { color: "var(--text-secondary)" };
|
||||||
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
|
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||||
|
|
||||||
|
function countSteps(stepsStr) {
|
||||||
|
if (!stepsStr || !stepsStr.trim()) return 0;
|
||||||
|
return stepsStr.trim().split(",").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuilderForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEdit = Boolean(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [pid, setPid] = useState("");
|
||||||
|
const [steps, setSteps] = useState("");
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [buildingBinary, setBuildingBinary] = useState(false);
|
||||||
|
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [successMsg, setSuccessMsg] = useState("");
|
||||||
|
|
||||||
|
const [binaryBuilt, setBinaryBuilt] = useState(false);
|
||||||
|
const [binaryUrl, setBinaryUrl] = useState(null);
|
||||||
|
const [progmemCode, setProgmemCode] = useState("");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const codeRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEdit) loadMelody();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadMelody = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/builder/melodies/${id}`);
|
||||||
|
setName(data.name || "");
|
||||||
|
setPid(data.pid || "");
|
||||||
|
setSteps(data.steps || "");
|
||||||
|
setBinaryBuilt(Boolean(data.binary_path));
|
||||||
|
setBinaryUrl(data.binary_url || null);
|
||||||
|
setProgmemCode(data.progmem_code || "");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) { setError("Name is required."); return; }
|
||||||
|
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSuccessMsg("");
|
||||||
|
try {
|
||||||
|
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
||||||
|
if (isEdit) {
|
||||||
|
await api.put(`/builder/melodies/${id}`, body);
|
||||||
|
setSuccessMsg("Saved.");
|
||||||
|
} else {
|
||||||
|
const created = await api.post("/builder/melodies", body);
|
||||||
|
navigate(`/melodies/builder/${created.id}`, { replace: true });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuildBinary = async () => {
|
||||||
|
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||||
|
setBuildingBinary(true);
|
||||||
|
setError("");
|
||||||
|
setSuccessMsg("");
|
||||||
|
try {
|
||||||
|
const data = await api.post(`/builder/melodies/${id}/build-binary`);
|
||||||
|
setBinaryBuilt(Boolean(data.binary_path));
|
||||||
|
setBinaryUrl(data.binary_url || null);
|
||||||
|
setSuccessMsg("Binary built successfully.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBuildingBinary(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBuildBuiltin = async () => {
|
||||||
|
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||||
|
setBuildingBuiltin(true);
|
||||||
|
setError("");
|
||||||
|
setSuccessMsg("");
|
||||||
|
try {
|
||||||
|
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
|
||||||
|
setProgmemCode(data.progmem_code || "");
|
||||||
|
setSuccessMsg("PROGMEM code generated.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBuildingBuiltin(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!progmemCode) return;
|
||||||
|
navigator.clipboard.writeText(progmemCode).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<button onClick={() => navigate("/melodies/builder")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
||||||
|
← Back to Builder
|
||||||
|
</button>
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{isEdit ? "Edit Built Melody" : "New Built Melody"}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/melodies/builder")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* --- Info Section --- */}
|
||||||
|
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||||
|
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Melody Info</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
|
||||||
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
||||||
|
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier</p>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
|
||||||
|
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={steps}
|
||||||
|
onChange={(e) => setSteps(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"}
|
||||||
|
className={inputClass}
|
||||||
|
style={{ fontFamily: "monospace", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>
|
||||||
|
Each value = one step. Bell numbers 1–16 (1 = highest). Combine with +. Silence = 0.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Build Actions Section --- */}
|
||||||
|
{isEdit && (
|
||||||
|
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||||
|
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
|
||||||
|
<p className="text-sm mb-4" style={mutedStyle}>
|
||||||
|
Save any changes above before building. Rebuilding will overwrite previous output.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Binary */}
|
||||||
|
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
|
||||||
|
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
|
||||||
|
</div>
|
||||||
|
{binaryBuilt && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||||
|
Built
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleBuildBinary}
|
||||||
|
disabled={buildingBinary}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
||||||
|
</button>
|
||||||
|
{binaryUrl && (
|
||||||
|
<a
|
||||||
|
href={`/api${binaryUrl}`}
|
||||||
|
className="block text-center text-xs underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
Download {name}.bsm
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Builtin Code */}
|
||||||
|
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
|
||||||
|
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
|
||||||
|
</div>
|
||||||
|
{progmemCode && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||||
|
Generated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleBuildBuiltin}
|
||||||
|
disabled={buildingBuiltin}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PROGMEM Code Block */}
|
||||||
|
{progmemCode && (
|
||||||
|
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
|
||||||
|
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
|
||||||
|
PROGMEM C Code — copy into your firmware
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="text-xs px-3 py-1 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
|
||||||
|
color: copied ? "var(--success-text)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
ref={codeRef}
|
||||||
|
className="p-4 text-xs overflow-x-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-primary)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
maxHeight: "400px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{progmemCode}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}>
|
||||||
|
Build actions (Binary + PROGMEM Code) will be available after saving.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
frontend/src/melodies/builder/BuilderList.jsx
Normal file
181
frontend/src/melodies/builder/BuilderList.jsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../../api/client";
|
||||||
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
|
|
||||||
|
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
||||||
|
|
||||||
|
export default function BuilderList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [melodies, setMelodies] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMelodies();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMelodies = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data = await api.get("/builder/melodies");
|
||||||
|
setMelodies(data.melodies || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/builder/melodies/${deleteTarget.id}`);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
loadMelodies();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const countSteps = (stepsStr) => {
|
||||||
|
if (!stepsStr) return 0;
|
||||||
|
return stepsStr.split(",").length;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Melody Builder
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Build binary (.bsm) files and firmware PROGMEM code from melody step notation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/melodies/builder/new")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
+ Add Melody
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : melodies.length === 0 ? (
|
||||||
|
<div className="rounded-lg border p-12 text-center" style={sectionStyle}>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No built melodies yet.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/melodies/builder/new")}
|
||||||
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Add Your First Melody
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border overflow-hidden" style={sectionStyle}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card-hover)" }}>
|
||||||
|
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>PID</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Steps</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Binary</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Builtin Code</th>
|
||||||
|
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Assigned</th>
|
||||||
|
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Updated</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{melodies.map((m, idx) => (
|
||||||
|
<tr
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => navigate(`/melodies/builder/${m.id}`)}
|
||||||
|
className="border-b cursor-pointer transition-colors hover:bg-[var(--bg-card-hover)]"
|
||||||
|
style={{ borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{m.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<span className="font-mono text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)" }}>
|
||||||
|
{m.pid || "-"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{countSteps(m.steps)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{m.binary_path ? (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||||
|
Built
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{m.progmem_code ? (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||||
|
Generated
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{m.assigned_melody_ids?.length > 0 ? (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa" }}>
|
||||||
|
{m.assigned_melody_ids.length} melody{m.assigned_melody_ids.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{new Date(m.updated_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
|
||||||
|
className="px-2 py-1 text-xs rounded transition-colors"
|
||||||
|
style={{ color: "var(--danger)", backgroundColor: "var(--danger-bg)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
title="Delete Built Melody"
|
||||||
|
message={`Are you sure you want to delete "${deleteTarget?.name}"? This will also delete the .bsm binary file if it exists. This action cannot be undone.`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx
Normal file
121
frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import api from "../../api/client";
|
||||||
|
|
||||||
|
export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSuccess }) {
|
||||||
|
const [melodies, setMelodies] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [assigning, setAssigning] = useState(null); // id of the one being assigned
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) loadMelodies();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const loadMelodies = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data = await api.get("/builder/melodies");
|
||||||
|
// Only show those with a built binary
|
||||||
|
setMelodies((data.melodies || []).filter((m) => m.binary_path));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = async (builtMelody) => {
|
||||||
|
setAssigning(builtMelody.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
// 1. Fetch the .bsm file from the builder endpoint
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const res = await fetch(`/api${builtMelody.binary_url}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Failed to download binary: ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], `${builtMelody.name}.bsm`, { type: "application/octet-stream" });
|
||||||
|
|
||||||
|
// 2. Upload to Firebase Storage via the existing melody upload endpoint
|
||||||
|
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||||
|
|
||||||
|
// 3. Mark this built melody as assigned to this Firestore melody
|
||||||
|
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setAssigning(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center z-50"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-2xl rounded-lg border shadow-xl"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Select Built Melody</h2>
|
||||||
|
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
|
) : melodies.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No built binaries found. Go to <strong>Melody Builder</strong> to create one first.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
{melodies.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between rounded-lg px-4 py-3 border transition-colors"
|
||||||
|
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{m.name}</p>
|
||||||
|
<p className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
|
||||||
|
PID: {m.pid || "—"} · {m.steps?.split(",").length || 0} steps
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(m)}
|
||||||
|
disabled={Boolean(assigning)}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{assigning === m.id ? "Uploading..." : "Select & Upload"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://backend:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user