Added SpeedCalc and MelodyBuilder. Evaluation Pending

This commit is contained in:
2026-02-22 13:17:54 +02:00
parent 8a8c665dfd
commit 8703c4fe26
27 changed files with 4075 additions and 3 deletions

View 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

View 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

View 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()

View 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()

View 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

View 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

View File

@@ -0,0 +1,3 @@
PyQt6>=6.4.0
numpy>=1.21.0
sounddevice>=0.4.6

File diff suppressed because it is too large Load Diff

View File

View 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
View 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
View 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
View 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

View File

@@ -12,6 +12,7 @@ from mqtt.router import router as mqtt_router
from equipment.router import router as equipment_router from equipment.router import router as equipment_router
from staff.router import router as staff_router from staff.router import router as staff_router
from helpdesk.router import router as helpdesk_router from helpdesk.router import router as helpdesk_router
from builder.router import router as builder_router
from mqtt.client import mqtt_manager from mqtt.client import mqtt_manager
from mqtt import database as mqtt_db from mqtt import database as mqtt_db
from melodies import service as melody_service from melodies import service as melody_service
@@ -40,6 +41,7 @@ app.include_router(mqtt_router)
app.include_router(equipment_router) app.include_router(equipment_router)
app.include_router(helpdesk_router) app.include_router(helpdesk_router)
app.include_router(staff_router) app.include_router(staff_router)
app.include_router(builder_router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -53,6 +53,18 @@ SCHEMA_STATEMENTS = [
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)""", )""",
"CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)", "CREATE INDEX IF NOT EXISTS idx_melody_drafts_status ON melody_drafts(status)",
# Built melodies table (local melody builder)
"""CREATE TABLE IF NOT EXISTS built_melodies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
pid TEXT NOT NULL,
steps TEXT NOT NULL,
binary_path TEXT,
progmem_code TEXT,
assigned_melody_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)""",
] ]

View File

@@ -6,6 +6,8 @@ import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail"; import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm"; import MelodyForm from "./melodies/MelodyForm";
import MelodySettings from "./melodies/MelodySettings"; import MelodySettings from "./melodies/MelodySettings";
import BuilderList from "./melodies/builder/BuilderList";
import BuilderForm from "./melodies/builder/BuilderForm";
import DeviceList from "./devices/DeviceList"; import DeviceList from "./devices/DeviceList";
import DeviceDetail from "./devices/DeviceDetail"; import DeviceDetail from "./devices/DeviceDetail";
import DeviceForm from "./devices/DeviceForm"; import DeviceForm from "./devices/DeviceForm";
@@ -116,6 +118,9 @@ export default function App() {
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} /> <Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} /> <Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} /> <Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
<Route path="melodies/builder" element={<PermissionGate section="melodies" action="edit"><BuilderList /></PermissionGate>} />
<Route path="melodies/builder/new" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} />
<Route path="melodies/builder/:id" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} />
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} /> <Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} /> <Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />

View File

@@ -9,6 +9,7 @@ const navItems = [
permission: "melodies", permission: "melodies",
children: [ children: [
{ to: "/melodies", label: "Editor" }, { to: "/melodies", label: "Editor" },
{ to: "/melodies/builder", label: "Builder" },
{ to: "/melodies/settings", label: "Settings" }, { to: "/melodies/settings", label: "Settings" },
], ],
}, },

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog"; import ConfirmDialog from "../components/ConfirmDialog";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import { import {
getLocalizedValue, getLocalizedValue,
getLanguageName, getLanguageName,
@@ -36,6 +37,9 @@ export default function MelodyDetail() {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [displayLang, setDisplayLang] = useState("en"); const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null); const [melodySettings, setMelodySettings] = useState(null);
const [builtMelody, setBuiltMelody] = useState(null);
const [codeCopied, setCodeCopied] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => { api.get("/settings/melody").then((ms) => {
@@ -57,6 +61,13 @@ export default function MelodyDetail() {
]); ]);
setMelody(m); setMelody(m);
setFiles(f); setFiles(f);
// Load built melody assignment (non-fatal if it fails)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
setBuiltMelody(bm || null);
} catch {
setBuiltMelody(null);
}
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -189,6 +200,13 @@ export default function MelodyDetail() {
Unpublish Unpublish
</button> </button>
)} )}
<button
onClick={() => setShowSpeedCalc(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Speed Calculator
</button>
<button <button
onClick={() => navigate(`/melodies/${id}/edit`)} onClick={() => navigate(`/melodies/${id}/edit`)}
className="px-4 py-2 text-sm rounded-md transition-colors" className="px-4 py-2 text-sm rounded-md transition-colors"
@@ -375,6 +393,63 @@ export default function MelodyDetail() {
</div> </div>
</div> </div>
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
{builtMelody?.progmem_code && (
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
PROGMEM code for built-in firmware playback &nbsp;·&nbsp; PID: <span className="font-mono">{builtMelody.pid}</span>
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(builtMelody.progmem_code).then(() => {
setCodeCopied(true);
setTimeout(() => setCodeCopied(false), 2000);
});
}}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: codeCopied ? "var(--success-bg)" : "var(--bg-card-hover)",
color: codeCopied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{codeCopied ? "Copied!" : "Copy Code"}
</button>
</div>
<pre
className="p-4 text-xs overflow-x-auto rounded-lg"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "360px",
overflowY: "auto",
border: "1px solid var(--border-primary)",
}}
>
{builtMelody.progmem_code}
</pre>
</section>
)}
<SpeedCalculatorModal
open={showSpeedCalc}
melody={melody}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => {
setShowSpeedCalc(false);
loadData();
}}
/>
<ConfirmDialog <ConfirmDialog
open={showDelete} open={showDelete}
title="Delete Melody" title="Delete Melody"

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import TranslationModal from "./TranslationModal"; import TranslationModal from "./TranslationModal";
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
import { import {
getLocalizedValue, getLocalizedValue,
getLanguageName, getLanguageName,
@@ -80,6 +82,9 @@ export default function MelodyForm() {
multiline: false, multiline: false,
}); });
const [showSelectBuilt, setShowSelectBuilt] = useState(false);
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => { api.get("/settings/melody").then((ms) => {
setMelodySettings(ms); setMelodySettings(ms);
@@ -553,9 +558,29 @@ export default function MelodyForm() {
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2> <h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bin)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)} {existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
<input type="file" accept=".bin" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} /> <input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
{isEdit && (
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => setShowSelectBuilt(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Select Built Melody
</button>
<button
type="button"
onClick={() => setShowBuildOnTheFly(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Build on the Fly
</button>
</div>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
@@ -582,6 +607,31 @@ export default function MelodyForm() {
languages={languages} languages={languages}
multiline={translationModal.multiline} multiline={translationModal.multiline}
/> />
{isEdit && (
<>
<SelectBuiltMelodyModal
open={showSelectBuilt}
melodyId={id}
onClose={() => setShowSelectBuilt(false)}
onSuccess={() => {
setShowSelectBuilt(false);
loadMelody();
}}
/>
<BuildOnTheFlyModal
open={showBuildOnTheFly}
melodyId={id}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)}
onSuccess={() => {
setShowBuildOnTheFly(false);
loadMelody();
}}
/>
</>
)}
</div> </div>
); );
} }

View 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}>&times;</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 &nbsp;·&nbsp; {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 (&lt;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>
);
}

View 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}>&times;</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>
);
}

View 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)" }}>
&larr; 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 116 (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>
);
}

View 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>
);
}

View 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)" }}>&times;</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 || "—"} &nbsp;·&nbsp; {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>
);
}

View File

@@ -9,7 +9,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: 'http://backend:8000',
changeOrigin: true, changeOrigin: true,
}, },
}, },