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 staff.router import router as staff_router
|
||||
from helpdesk.router import router as helpdesk_router
|
||||
from builder.router import router as builder_router
|
||||
from mqtt.client import mqtt_manager
|
||||
from mqtt import database as mqtt_db
|
||||
from melodies import service as melody_service
|
||||
@@ -40,6 +41,7 @@ app.include_router(mqtt_router)
|
||||
app.include_router(equipment_router)
|
||||
app.include_router(helpdesk_router)
|
||||
app.include_router(staff_router)
|
||||
app.include_router(builder_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -53,6 +53,18 @@ SCHEMA_STATEMENTS = [
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)",
|
||||
# Built melodies table (local melody builder)
|
||||
"""CREATE TABLE IF NOT EXISTS built_melodies (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
pid TEXT NOT NULL,
|
||||
steps TEXT NOT NULL,
|
||||
binary_path TEXT,
|
||||
progmem_code TEXT,
|
||||
assigned_melody_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)""",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import MelodyList from "./melodies/MelodyList";
|
||||
import MelodyDetail from "./melodies/MelodyDetail";
|
||||
import MelodyForm from "./melodies/MelodyForm";
|
||||
import MelodySettings from "./melodies/MelodySettings";
|
||||
import BuilderList from "./melodies/builder/BuilderList";
|
||||
import BuilderForm from "./melodies/builder/BuilderForm";
|
||||
import DeviceList from "./devices/DeviceList";
|
||||
import DeviceDetail from "./devices/DeviceDetail";
|
||||
import DeviceForm from "./devices/DeviceForm";
|
||||
@@ -116,6 +118,9 @@ export default function App() {
|
||||
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></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/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/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const navItems = [
|
||||
permission: "melodies",
|
||||
children: [
|
||||
{ to: "/melodies", label: "Editor" },
|
||||
{ to: "/melodies/builder", label: "Builder" },
|
||||
{ to: "/melodies/settings", label: "Settings" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import ConfirmDialog from "../components/ConfirmDialog";
|
||||
import SpeedCalculatorModal from "./SpeedCalculatorModal";
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getLanguageName,
|
||||
@@ -36,6 +37,9 @@ export default function MelodyDetail() {
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [displayLang, setDisplayLang] = useState("en");
|
||||
const [melodySettings, setMelodySettings] = useState(null);
|
||||
const [builtMelody, setBuiltMelody] = useState(null);
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/settings/melody").then((ms) => {
|
||||
@@ -57,6 +61,13 @@ export default function MelodyDetail() {
|
||||
]);
|
||||
setMelody(m);
|
||||
setFiles(f);
|
||||
// Load built melody assignment (non-fatal if it fails)
|
||||
try {
|
||||
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
|
||||
setBuiltMelody(bm || null);
|
||||
} catch {
|
||||
setBuiltMelody(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -189,6 +200,13 @@ export default function MelodyDetail() {
|
||||
Unpublish
|
||||
</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
|
||||
onClick={() => navigate(`/melodies/${id}/edit`)}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
@@ -375,6 +393,63 @@ export default function MelodyDetail() {
|
||||
</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
|
||||
open={showDelete}
|
||||
title="Delete Melody"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
import TranslationModal from "./TranslationModal";
|
||||
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
|
||||
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
|
||||
import {
|
||||
getLocalizedValue,
|
||||
getLanguageName,
|
||||
@@ -80,6 +82,9 @@ export default function MelodyForm() {
|
||||
multiline: false,
|
||||
});
|
||||
|
||||
const [showSelectBuilt, setShowSelectBuilt] = useState(false);
|
||||
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/settings/melody").then((ms) => {
|
||||
setMelodySettings(ms);
|
||||
@@ -553,9 +558,29 @@ export default function MelodyForm() {
|
||||
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
|
||||
<div className="space-y-4">
|
||||
<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>)}
|
||||
<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>
|
||||
<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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user