Files
bellsystems-cp/SecondaryApps/SpeedCalc/speedcalc.py

1068 lines
38 KiB
Python

#!/usr/bin/env python3
"""
SpeedCalc - Bell Melody Speed Calculator
A tool to find optimal MIN/MID/MAX playback speeds for bell melodies.
"""
import sys
import re
import struct
import threading
import time
from pathlib import Path
from dataclasses import dataclass
from typing import List, Dict, Optional, Tuple
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QListWidget, QListWidgetItem, QPushButton, QSlider, QLabel,
QFileDialog, QGroupBox, QFrame, QMessageBox, QStyleFactory
)
from PyQt6.QtCore import Qt, pyqtSignal, QObject
from PyQt6.QtGui import QColor, QPalette
# Use pygame.mixer for low-latency audio playback
import numpy as np
import pygame
# ============================================================================
# Custom Widgets
# ============================================================================
class ClickableSlider(QSlider):
"""A slider that jumps to the clicked position instead of stepping."""
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
# Calculate the value based on click position
if self.orientation() == Qt.Orientation.Horizontal:
value = self.minimum() + (self.maximum() - self.minimum()) * event.position().x() / self.width()
else:
value = self.minimum() + (self.maximum() - self.minimum()) * (self.height() - event.position().y()) / self.height()
self.setValue(int(value))
event.accept()
super().mousePressEvent(event)
# ============================================================================
# Data Classes
# ============================================================================
@dataclass
class Melody:
"""Represents a single melody with its steps and speed settings."""
name: str
steps: List[int] # Each step is a 16-bit value (bit flags for bells)
min_speed: Optional[int] = None # ms (slowest)
mid_speed: Optional[int] = None # ms (normal)
max_speed: Optional[int] = None # ms (fastest)
source_line: int = 0 # Line number in source file (for editing)
def get_bells_used(self) -> set:
"""Return set of bell numbers (1-16) used in this melody."""
bells = set()
for step in self.steps:
for bit in range(16):
if step & (1 << bit):
bells.add(bit + 1)
return bells
# ============================================================================
# Audio Engine
# ============================================================================
class AudioEngine:
"""Handles audio generation and playback for bells using pygame.mixer."""
# Bell 1 = highest pitch, Bell 16 = lowest pitch
BASE_FREQ = 880 # A5 for bell 1 (highest)
SAMPLE_RATE = 44100
def __init__(self):
# Initialize pygame mixer with low latency settings
pygame.mixer.pre_init(
frequency=self.SAMPLE_RATE,
size=-16, # 16-bit signed
channels=1, # Mono
buffer=1024 # Reasonable buffer for low latency
)
pygame.mixer.init()
self.bell_frequencies = self._generate_bell_frequencies()
self._current_sound: Optional[pygame.mixer.Sound] = None
def _generate_bell_frequencies(self) -> Dict[int, float]:
"""Generate frequencies for bells 1-16 (1=highest, 16=lowest)."""
frequencies = {}
for bell in range(1, 17):
# Bell 1 is highest, so we subtract semitones as bell number increases
semitones_down = (bell - 1) * 2 # 2 semitones (whole step) per bell
freq = self.BASE_FREQ * (2 ** (-semitones_down / 12))
frequencies[bell] = freq
return frequencies
def _generate_sound(self, bells: List[int], duration_ms: int) -> pygame.mixer.Sound:
"""Generate a pygame Sound object for the given bells."""
num_samples = int(self.SAMPLE_RATE * duration_ms / 1000)
num_samples = max(num_samples, 100) # Minimum samples
t = np.linspace(0, duration_ms / 1000, num_samples, dtype=np.float64)
signal = np.zeros(num_samples, dtype=np.float64)
for bell in bells:
if bell in self.bell_frequencies:
freq = self.bell_frequencies[bell]
signal += np.sin(2 * np.pi * freq * t)
if len(bells) > 0:
signal = signal / len(bells)
# Normalize and convert to 16-bit signed integers
signal = signal * 0.8 * 32767
# Quick fade in/out to avoid clicks (2ms each)
fade_samples = min(int(self.SAMPLE_RATE * 0.002), num_samples // 4)
if fade_samples > 0:
fade_in = np.linspace(0, 1, fade_samples)
fade_out = np.linspace(1, 0, fade_samples)
signal[:fade_samples] *= fade_in
signal[-fade_samples:] *= fade_out
# Convert to 16-bit integer array and create Sound from raw bytes
signal = signal.astype(np.int16)
sound = pygame.mixer.Sound(buffer=signal.tobytes())
return sound
def play_tone_blocking(self, bells: List[int], duration_ms: int):
"""Play a tone for the given bells and wait for it to complete."""
if duration_ms <= 0 or not bells:
# For silence, just sleep
if duration_ms > 0:
time.sleep(duration_ms / 1000.0)
return
try:
# Keep a reference to the sound to prevent garbage collection
self._current_sound = self._generate_sound(bells, duration_ms)
self._current_sound.play()
# Wait for the duration (more reliable than polling get_busy)
time.sleep(duration_ms / 1000.0)
except Exception as e:
print(f"Audio error: {e}")
def stop(self):
"""Stop any currently playing audio."""
try:
pygame.mixer.stop()
except:
pass
def cleanup(self):
"""Clean up audio resources."""
self.stop()
try:
pygame.mixer.quit()
except:
pass
# ============================================================================
# File Parser
# ============================================================================
class MelodyParser:
"""Parses melody files (both .txt and .bsm formats)."""
# Regex to match speed settings in melody name: [min XXX / mid XXX / max XXX]
# Note: We avoid colons in the format because the parser splits on ':' first
SPEED_PATTERN = re.compile(
r'\[\s*min\s+(\d+)\s*/\s*mid\s+(\d+)\s*/\s*max\s+(\d+)\s*\]'
)
@staticmethod
def parse_txt_file(filepath: str) -> Tuple[List[Melody], List[str]]:
"""
Parse a .txt file containing multiple melodies.
Returns (list of Melody objects, original lines for editing).
"""
melodies = []
original_lines = []
with open(filepath, 'r', encoding='utf-8') as f:
original_lines = f.readlines()
for line_num, line in enumerate(original_lines):
line_stripped = line.strip()
if not line_stripped or line_stripped.startswith('#'):
continue
if ':' not in line_stripped:
continue
# Split name and steps
parts = line_stripped.split(':', 1)
name_part = parts[0].strip()
steps_str = parts[1].strip()
# Check for existing speed settings in name
min_speed = mid_speed = max_speed = None
speed_match = MelodyParser.SPEED_PATTERN.search(name_part)
if speed_match:
min_speed = int(speed_match.group(1))
mid_speed = int(speed_match.group(2))
max_speed = int(speed_match.group(3))
# Remove speed info from name for display
name = MelodyParser.SPEED_PATTERN.sub('', name_part).strip()
else:
name = name_part
# Parse steps
steps = []
for step_str in steps_str.split(','):
step_str = step_str.strip()
if not step_str:
continue
if '+' in step_str:
# Multiple bells: "2+8" -> bits 1 and 7
value = 0
for bell_str in step_str.split('+'):
try:
bell_num = int(bell_str.strip())
if 1 <= bell_num <= 16:
value |= (1 << (bell_num - 1))
except ValueError:
pass
steps.append(value)
else:
# Single bell or zero
try:
bell_num = int(step_str)
if bell_num == 0:
steps.append(0)
elif 1 <= bell_num <= 16:
steps.append(1 << (bell_num - 1))
else:
steps.append(0)
except ValueError:
steps.append(0)
if steps:
melody = Melody(
name=name,
steps=steps,
min_speed=min_speed,
mid_speed=mid_speed,
max_speed=max_speed,
source_line=line_num
)
melodies.append(melody)
return melodies, original_lines
@staticmethod
def parse_bsm_file(filepath: str) -> Melody:
"""Parse a binary .bsm file."""
name = Path(filepath).stem
steps = []
with open(filepath, 'rb') as f:
data = f.read()
# Each step is 2 bytes, big-endian
for i in range(0, len(data), 2):
if i + 1 < len(data):
value = struct.unpack('>H', data[i:i+2])[0]
steps.append(value)
return Melody(name=name, steps=steps)
@staticmethod
def save_txt_file(filepath: str, melodies: List[Melody], original_lines: List[str]):
"""Save melodies back to .txt file, preserving format and updating speeds."""
new_lines = original_lines.copy()
for melody in melodies:
if melody.source_line < len(new_lines):
line = new_lines[melody.source_line]
if ':' in line:
parts = line.split(':', 1)
name_part = parts[0]
steps_part = parts[1]
# Remove existing speed info if any
name_clean = MelodyParser.SPEED_PATTERN.sub('', name_part).strip()
# Add new speed info if all values are set
# Note: We use spaces instead of colons to avoid breaking the parser
# (since the parser splits on ':' to separate name from steps)
if melody.min_speed and melody.mid_speed and melody.max_speed:
speed_str = f" [min {melody.min_speed} / mid {melody.mid_speed} / max {melody.max_speed}]"
new_name = name_clean + speed_str
else:
new_name = name_clean
new_lines[melody.source_line] = f"{new_name}:{steps_part}"
with open(filepath, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
# ============================================================================
# Playback Controller
# ============================================================================
class PlaybackSignals(QObject):
"""Signals for playback thread communication."""
step_changed = pyqtSignal(int)
playback_finished = pyqtSignal()
beat_truncated = pyqtSignal(int) # Emits actual beat duration when truncated
class PlaybackController:
"""Controls melody playback."""
def __init__(self, audio_engine: AudioEngine):
self.audio = audio_engine
self.signals = PlaybackSignals()
self.is_playing = False
self.is_looping = False
self.current_step = 0
self.melody: Optional[Melody] = None
self.step_delay_ms = 500
self.beat_duration_ms = 100
self._playback_thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
def set_melody(self, melody: Melody):
"""Set the melody to play."""
self.stop()
self.melody = melody
self.current_step = 0
def set_speed(self, delay_ms: int):
"""Set the delay between steps in milliseconds."""
self.step_delay_ms = delay_ms
def set_beat_duration(self, duration_ms: int):
"""Set the duration of each beat/tone."""
self.beat_duration_ms = duration_ms
def play(self, loop: bool = False):
"""Start playback."""
if not self.melody or not self.melody.steps:
return
self.stop()
self.is_playing = True
self.is_looping = loop
self._stop_event.clear()
self._playback_thread = threading.Thread(target=self._playback_loop, daemon=True)
self._playback_thread.start()
def stop(self):
"""Stop playback."""
self.is_playing = False
self._stop_event.set()
self.audio.stop()
if self._playback_thread and self._playback_thread.is_alive():
self._playback_thread.join(timeout=0.5)
self._playback_thread = None
def _playback_loop(self):
"""Main playback loop (runs in separate thread)."""
import time
BUFFER_MS = 20 # Gap between tones to prevent blending
while self.is_playing and self.melody:
if self._stop_event.is_set():
break
# Get current step
step_value = self.melody.steps[self.current_step]
# Convert step value to list of bell numbers
bells = []
for bit in range(16):
if step_value & (1 << bit):
bells.append(bit + 1)
# Signal UI about current step
self.signals.step_changed.emit(self.current_step)
# Calculate actual beat duration (truncate if needed)
max_beat_ms = max(BUFFER_MS, self.step_delay_ms - BUFFER_MS)
actual_beat_ms = min(self.beat_duration_ms, max_beat_ms)
is_truncated = actual_beat_ms < self.beat_duration_ms
# Signal if truncated (only on first step to avoid spam)
if is_truncated and self.current_step == 0:
self.signals.beat_truncated.emit(actual_beat_ms)
# Record start time for accurate timing
step_start = time.perf_counter()
# Play the tone (blocking - waits for tone to finish)
self.audio.play_tone_blocking(bells, actual_beat_ms)
if self._stop_event.is_set():
break
# Calculate remaining wait time after tone played
elapsed_ms = (time.perf_counter() - step_start) * 1000
remaining_wait_ms = self.step_delay_ms - elapsed_ms
# Wait for remaining time in small increments for responsive stopping
if remaining_wait_ms > 0:
wait_end = time.perf_counter() + (remaining_wait_ms / 1000.0)
while time.perf_counter() < wait_end and not self._stop_event.is_set():
time.sleep(0.005) # 5ms increments
if self._stop_event.is_set():
break
# Move to next step
self.current_step += 1
if self.current_step >= len(self.melody.steps):
if self.is_looping:
self.current_step = 0
else:
break
self.is_playing = False
self.signals.playback_finished.emit()
# ============================================================================
# Main Window
# ============================================================================
class SpeedCalcWindow(QMainWindow):
"""Main application window."""
def __init__(self):
super().__init__()
self.audio = AudioEngine()
self.playback = PlaybackController(self.audio)
self.melodies: List[Melody] = []
self.original_lines: List[str] = []
self.current_file: Optional[str] = None
self.current_melody: Optional[Melody] = None
self._setup_ui()
self._connect_signals()
# Enable drag and drop
self.setAcceptDrops(True)
def dragEnterEvent(self, event):
"""Handle drag enter - accept if it contains file URLs."""
if event.mimeData().hasUrls():
# Check if any of the files are .txt or .bsm
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath.lower().endswith(('.txt', '.bsm')):
event.acceptProposedAction()
return
event.ignore()
def dropEvent(self, event):
"""Handle file drop."""
txt_files = []
bsm_files = []
for url in event.mimeData().urls():
filepath = url.toLocalFile()
if filepath.lower().endswith('.txt'):
txt_files.append(filepath)
elif filepath.lower().endswith('.bsm'):
bsm_files.append(filepath)
# Load txt file first (only the first one if multiple)
if txt_files:
self._load_txt_file_path(txt_files[0])
# Add any bsm files
if bsm_files:
self._add_bsm_files(bsm_files)
event.acceptProposedAction()
def _setup_ui(self):
"""Set up the user interface."""
self.setWindowTitle("SpeedCalc - Bell Melody Speed Calculator")
self.setMinimumSize(900, 600)
# Main widget and layout
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QHBoxLayout(main_widget)
main_layout.setSpacing(15)
main_layout.setContentsMargins(15, 15, 15, 15)
# Left panel - Melody list
left_panel = self._create_melody_list_panel()
main_layout.addWidget(left_panel, stretch=1)
# Right panel - Controls
right_panel = self._create_controls_panel()
main_layout.addWidget(right_panel, stretch=2)
def _create_melody_list_panel(self) -> QWidget:
"""Create the melody list panel."""
panel = QGroupBox("Melodies")
layout = QVBoxLayout(panel)
# File buttons
btn_layout = QHBoxLayout()
self.btn_load_txt = QPushButton("Load .txt File")
self.btn_load_txt.setMinimumHeight(35)
btn_layout.addWidget(self.btn_load_txt)
self.btn_add_bsm = QPushButton("Add .bsm Files")
self.btn_add_bsm.setMinimumHeight(35)
btn_layout.addWidget(self.btn_add_bsm)
layout.addLayout(btn_layout)
# Melody list
self.melody_list = QListWidget()
self.melody_list.setMinimumWidth(250)
self.melody_list.setAlternatingRowColors(True)
layout.addWidget(self.melody_list)
# Info label
self.lbl_melody_info = QLabel("No melody loaded")
self.lbl_melody_info.setWordWrap(True)
self.lbl_melody_info.setStyleSheet("color: #666; padding: 5px;")
layout.addWidget(self.lbl_melody_info)
return panel
def _create_controls_panel(self) -> QWidget:
"""Create the controls panel."""
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setSpacing(15)
# Playback controls
playback_group = QGroupBox("Playback")
playback_layout = QVBoxLayout(playback_group)
# Play buttons
btn_row = QHBoxLayout()
self.btn_play = QPushButton("▶ Play")
self.btn_play.setMinimumHeight(45)
self.btn_play.setEnabled(False)
btn_row.addWidget(self.btn_play)
self.btn_loop = QPushButton("🔁 Loop")
self.btn_loop.setMinimumHeight(45)
self.btn_loop.setCheckable(True)
self.btn_loop.setEnabled(False)
btn_row.addWidget(self.btn_loop)
self.btn_stop = QPushButton("⏹ Stop")
self.btn_stop.setMinimumHeight(45)
self.btn_stop.setEnabled(False)
btn_row.addWidget(self.btn_stop)
playback_layout.addLayout(btn_row)
# Progress label
self.lbl_progress = QLabel("Step: - / -")
self.lbl_progress.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_progress.setStyleSheet("font-size: 14px; padding: 5px;")
playback_layout.addWidget(self.lbl_progress)
layout.addWidget(playback_group)
# Speed control
speed_group = QGroupBox("Step Delay (Speed)")
speed_layout = QVBoxLayout(speed_group)
self.lbl_speed = QLabel("500 ms")
self.lbl_speed.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_speed.setStyleSheet("font-size: 24px; font-weight: bold; color: #2196F3;")
speed_layout.addWidget(self.lbl_speed)
self.slider_speed = ClickableSlider(Qt.Orientation.Horizontal)
self.slider_speed.setMinimum(50)
self.slider_speed.setMaximum(3000)
self.slider_speed.setValue(500)
self.slider_speed.setTickPosition(ClickableSlider.TickPosition.TicksBelow)
self.slider_speed.setTickInterval(250)
speed_layout.addWidget(self.slider_speed)
speed_labels = QHBoxLayout()
speed_labels.addWidget(QLabel("50ms (Fast)"))
speed_labels.addStretch()
speed_labels.addWidget(QLabel("3000ms (Slow)"))
speed_layout.addLayout(speed_labels)
layout.addWidget(speed_group)
# Beat duration control
beat_group = QGroupBox("Beat Duration")
beat_layout = QVBoxLayout(beat_group)
self.lbl_beat = QLabel("100 ms")
self.lbl_beat.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_beat.setStyleSheet("font-size: 18px; font-weight: bold; color: #4CAF50;")
beat_layout.addWidget(self.lbl_beat)
self.slider_beat = ClickableSlider(Qt.Orientation.Horizontal)
self.slider_beat.setMinimum(20)
self.slider_beat.setMaximum(500)
self.slider_beat.setValue(100)
self.slider_beat.setTickPosition(ClickableSlider.TickPosition.TicksBelow)
self.slider_beat.setTickInterval(50)
beat_layout.addWidget(self.slider_beat)
beat_labels = QHBoxLayout()
beat_labels.addWidget(QLabel("20ms (Short)"))
beat_labels.addStretch()
beat_labels.addWidget(QLabel("500ms (Long)"))
beat_layout.addLayout(beat_labels)
# Truncation warning label - use fixed height to prevent layout shift
self.lbl_truncated = QLabel("")
self.lbl_truncated.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.lbl_truncated.setStyleSheet("color: #f44336; font-weight: bold; padding: 2px;")
self.lbl_truncated.setFixedHeight(20) # Reserve space even when empty
beat_layout.addWidget(self.lbl_truncated)
layout.addWidget(beat_group)
# Speed capture buttons
capture_group = QGroupBox("Capture Speed Settings")
capture_layout = QVBoxLayout(capture_group)
# Current values display
values_layout = QHBoxLayout()
# MIN
min_frame = QFrame()
min_frame.setFrameStyle(QFrame.Shape.StyledPanel)
min_layout = QVBoxLayout(min_frame)
min_layout.addWidget(QLabel("MIN (Slowest)"))
self.lbl_min = QLabel("---")
self.lbl_min.setStyleSheet("font-size: 20px; font-weight: bold; color: #f44336;")
self.lbl_min.setAlignment(Qt.AlignmentFlag.AlignCenter)
min_layout.addWidget(self.lbl_min)
min_btn_layout = QHBoxLayout()
self.btn_set_min = QPushButton("Set")
self.btn_set_min.setEnabled(False)
self.btn_set_min.setToolTip("Load saved MIN value to slider")
min_btn_layout.addWidget(self.btn_set_min)
self.btn_save_min = QPushButton("Save")
self.btn_save_min.setEnabled(False)
self.btn_save_min.setToolTip("Save current slider value as MIN")
min_btn_layout.addWidget(self.btn_save_min)
min_layout.addLayout(min_btn_layout)
values_layout.addWidget(min_frame)
# MID
mid_frame = QFrame()
mid_frame.setFrameStyle(QFrame.Shape.StyledPanel)
mid_layout = QVBoxLayout(mid_frame)
mid_layout.addWidget(QLabel("MID (Normal)"))
self.lbl_mid = QLabel("---")
self.lbl_mid.setStyleSheet("font-size: 20px; font-weight: bold; color: #ff9800;")
self.lbl_mid.setAlignment(Qt.AlignmentFlag.AlignCenter)
mid_layout.addWidget(self.lbl_mid)
mid_btn_layout = QHBoxLayout()
self.btn_set_mid = QPushButton("Set")
self.btn_set_mid.setEnabled(False)
self.btn_set_mid.setToolTip("Load saved MID value to slider")
mid_btn_layout.addWidget(self.btn_set_mid)
self.btn_save_mid = QPushButton("Save")
self.btn_save_mid.setEnabled(False)
self.btn_save_mid.setToolTip("Save current slider value as MID")
mid_btn_layout.addWidget(self.btn_save_mid)
mid_layout.addLayout(mid_btn_layout)
values_layout.addWidget(mid_frame)
# MAX
max_frame = QFrame()
max_frame.setFrameStyle(QFrame.Shape.StyledPanel)
max_layout = QVBoxLayout(max_frame)
max_layout.addWidget(QLabel("MAX (Fastest)"))
self.lbl_max = QLabel("---")
self.lbl_max.setStyleSheet("font-size: 20px; font-weight: bold; color: #4CAF50;")
self.lbl_max.setAlignment(Qt.AlignmentFlag.AlignCenter)
max_layout.addWidget(self.lbl_max)
max_btn_layout = QHBoxLayout()
self.btn_set_max = QPushButton("Set")
self.btn_set_max.setEnabled(False)
self.btn_set_max.setToolTip("Load saved MAX value to slider")
max_btn_layout.addWidget(self.btn_set_max)
self.btn_save_max = QPushButton("Save")
self.btn_save_max.setEnabled(False)
self.btn_save_max.setToolTip("Save current slider value as MAX")
max_btn_layout.addWidget(self.btn_save_max)
max_layout.addLayout(max_btn_layout)
values_layout.addWidget(max_frame)
capture_layout.addLayout(values_layout)
# Clear and Save buttons
action_layout = QHBoxLayout()
self.btn_clear = QPushButton("Clear Values")
self.btn_clear.setMinimumHeight(40)
self.btn_clear.setEnabled(False)
action_layout.addWidget(self.btn_clear)
self.btn_save = QPushButton("Save to File")
self.btn_save.setMinimumHeight(40)
self.btn_save.setEnabled(False)
self.btn_save.setStyleSheet("font-weight: bold;")
action_layout.addWidget(self.btn_save)
capture_layout.addLayout(action_layout)
layout.addWidget(capture_group)
# Add stretch at bottom
layout.addStretch()
return panel
def _connect_signals(self):
"""Connect all signals and slots."""
# File loading
self.btn_load_txt.clicked.connect(self._load_txt_file)
self.btn_add_bsm.clicked.connect(self._add_bsm_files_dialog)
# Melody selection
self.melody_list.currentItemChanged.connect(self._on_melody_selected)
# Playback controls
self.btn_play.clicked.connect(self._on_play)
self.btn_loop.clicked.connect(self._on_loop)
self.btn_stop.clicked.connect(self._on_stop)
# Sliders
self.slider_speed.valueChanged.connect(self._on_speed_changed)
self.slider_beat.valueChanged.connect(self._on_beat_changed)
# Capture buttons - Set loads saved value to slider, Save captures slider value
self.btn_set_min.clicked.connect(lambda: self._load_speed_to_slider('min'))
self.btn_set_mid.clicked.connect(lambda: self._load_speed_to_slider('mid'))
self.btn_set_max.clicked.connect(lambda: self._load_speed_to_slider('max'))
self.btn_save_min.clicked.connect(lambda: self._save_speed_value('min'))
self.btn_save_mid.clicked.connect(lambda: self._save_speed_value('mid'))
self.btn_save_max.clicked.connect(lambda: self._save_speed_value('max'))
self.btn_clear.clicked.connect(self._clear_values)
self.btn_save.clicked.connect(self._save_file)
# Playback signals
self.playback.signals.step_changed.connect(self._on_step_changed)
self.playback.signals.playback_finished.connect(self._on_playback_finished)
self.playback.signals.beat_truncated.connect(self._on_beat_truncated)
def _load_txt_file(self):
"""Load a .txt melody file via dialog."""
filepath, _ = QFileDialog.getOpenFileName(
self,
"Open Melody Text File",
"",
"Text Files (*.txt);;All Files (*)"
)
if filepath:
self._load_txt_file_path(filepath)
def _load_txt_file_path(self, filepath: str):
"""Load a .txt melody file from a path."""
try:
self.melodies, self.original_lines = MelodyParser.parse_txt_file(filepath)
self.current_file = filepath
self._update_melody_list()
self.btn_save.setEnabled(True)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load file:\n{e}")
def _add_bsm_files_dialog(self):
"""Add .bsm binary files via dialog."""
filepaths, _ = QFileDialog.getOpenFileNames(
self,
"Add Binary Melody Files",
"",
"BSM Files (*.bsm);;All Files (*)"
)
if filepaths:
self._add_bsm_files(filepaths)
def _add_bsm_files(self, filepaths: List[str]):
"""Add .bsm binary files to the melody list (appends, doesn't replace)."""
for filepath in filepaths:
try:
melody = MelodyParser.parse_bsm_file(filepath)
self.melodies.append(melody)
except Exception as e:
QMessageBox.warning(self, "Warning", f"Failed to load {filepath}:\n{e}")
self._update_melody_list()
def _update_melody_list(self):
"""Update the melody list widget."""
self.melody_list.clear()
for melody in self.melodies:
# Show checkmark if speeds are set
if melody.min_speed and melody.mid_speed and melody.max_speed:
prefix = ""
else:
prefix = " "
item = QListWidgetItem(f"{prefix}{melody.name}")
item.setData(Qt.ItemDataRole.UserRole, melody)
self.melody_list.addItem(item)
if self.melodies:
self.melody_list.setCurrentRow(0)
def _on_melody_selected(self, current: QListWidgetItem, previous: QListWidgetItem):
"""Handle melody selection change."""
if not current:
return
self.playback.stop()
melody = current.data(Qt.ItemDataRole.UserRole)
self.current_melody = melody
self.playback.set_melody(melody)
# Update info
bells = melody.get_bells_used()
bells_str = ', '.join(map(str, sorted(bells))) if bells else 'none'
self.lbl_melody_info.setText(
f"Steps: {len(melody.steps)}\n"
f"Bells used: {bells_str}"
)
# Update speed displays
self.lbl_min.setText(f"{melody.min_speed} ms" if melody.min_speed else "---")
self.lbl_mid.setText(f"{melody.mid_speed} ms" if melody.mid_speed else "---")
self.lbl_max.setText(f"{melody.max_speed} ms" if melody.max_speed else "---")
# If melody has saved speeds, set the slider to the mid speed (or min if no mid)
if melody.mid_speed:
self.slider_speed.setValue(melody.mid_speed)
elif melody.min_speed:
self.slider_speed.setValue(melody.min_speed)
elif melody.max_speed:
self.slider_speed.setValue(melody.max_speed)
# Enable controls
self.btn_play.setEnabled(True)
self.btn_loop.setEnabled(True)
# Set buttons are only enabled if the melody has that speed value saved
self.btn_set_min.setEnabled(melody.min_speed is not None)
self.btn_set_mid.setEnabled(melody.mid_speed is not None)
self.btn_set_max.setEnabled(melody.max_speed is not None)
# Save buttons are always enabled when a melody is selected
self.btn_save_min.setEnabled(True)
self.btn_save_mid.setEnabled(True)
self.btn_save_max.setEnabled(True)
self.btn_clear.setEnabled(True)
# Reset progress
self.lbl_progress.setText(f"Step: 0 / {len(melody.steps)}")
def _on_play(self):
"""Start playback."""
if self.playback.is_playing:
self.playback.stop()
self.btn_play.setText("▶ Play")
else:
# Reset to beginning if we're at the end
if self.current_melody and self.playback.current_step >= len(self.current_melody.steps):
self.playback.current_step = 0
self.playback.play(loop=self.btn_loop.isChecked())
self.btn_play.setText("⏸ Pause")
self.btn_stop.setEnabled(True)
def _on_loop(self):
"""Toggle loop mode."""
if self.playback.is_playing:
self.playback.is_looping = self.btn_loop.isChecked()
def _on_stop(self):
"""Stop playback."""
self.playback.stop()
self.playback.current_step = 0
self.btn_play.setText("▶ Play")
self.btn_stop.setEnabled(False)
self.lbl_truncated.setText("")
if self.current_melody:
self.lbl_progress.setText(f"Step: 0 / {len(self.current_melody.steps)}")
def _on_speed_changed(self, value: int):
"""Handle speed slider change."""
self.lbl_speed.setText(f"{value} ms")
self.playback.set_speed(value)
self.lbl_truncated.setText("") # Hide warning when speed changes
def _on_beat_changed(self, value: int):
"""Handle beat duration slider change."""
self.lbl_beat.setText(f"{value} ms")
self.playback.set_beat_duration(value)
def _on_step_changed(self, step: int):
"""Handle step change during playback."""
if self.current_melody:
self.lbl_progress.setText(f"Step: {step + 1} / {len(self.current_melody.steps)}")
def _on_playback_finished(self):
"""Handle playback finished."""
self.btn_play.setText("▶ Play")
self.btn_stop.setEnabled(False)
self.lbl_truncated.setText("")
def _on_beat_truncated(self, actual_ms: int):
"""Handle beat truncation warning."""
self.lbl_truncated.setText(f"⚠ Beat truncated to {actual_ms}ms (speed too fast)")
def _save_speed_value(self, which: str):
"""Save current slider value to a speed setting."""
if not self.current_melody:
return
value = self.slider_speed.value()
if which == 'min':
self.current_melody.min_speed = value
self.lbl_min.setText(f"{value} ms")
self.btn_set_min.setEnabled(True)
elif which == 'mid':
self.current_melody.mid_speed = value
self.lbl_mid.setText(f"{value} ms")
self.btn_set_mid.setEnabled(True)
elif which == 'max':
self.current_melody.max_speed = value
self.lbl_max.setText(f"{value} ms")
self.btn_set_max.setEnabled(True)
# Update list item
self._update_current_list_item()
def _load_speed_to_slider(self, which: str):
"""Load a saved speed value to the slider."""
if not self.current_melody:
return
value = None
if which == 'min':
value = self.current_melody.min_speed
elif which == 'mid':
value = self.current_melody.mid_speed
elif which == 'max':
value = self.current_melody.max_speed
if value is not None:
self.slider_speed.setValue(value)
def _clear_values(self):
"""Clear all speed values for current melody."""
if not self.current_melody:
return
self.current_melody.min_speed = None
self.current_melody.mid_speed = None
self.current_melody.max_speed = None
self.lbl_min.setText("---")
self.lbl_mid.setText("---")
self.lbl_max.setText("---")
# Disable Set buttons since there are no values to load
self.btn_set_min.setEnabled(False)
self.btn_set_mid.setEnabled(False)
self.btn_set_max.setEnabled(False)
self._update_current_list_item()
def _update_current_list_item(self):
"""Update the current list item to show checkmark status."""
item = self.melody_list.currentItem()
if item and self.current_melody:
if (self.current_melody.min_speed and
self.current_melody.mid_speed and
self.current_melody.max_speed):
prefix = ""
else:
prefix = " "
item.setText(f"{prefix}{self.current_melody.name}")
def _save_file(self):
"""Save speed settings back to the .txt file."""
if not self.current_file:
QMessageBox.warning(
self,
"Cannot Save",
"Can only save to .txt files. Binary files cannot be modified."
)
return
try:
MelodyParser.save_txt_file(self.current_file, self.melodies, self.original_lines)
QMessageBox.information(
self,
"Saved",
f"Speed settings saved to:\n{self.current_file}"
)
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save file:\n{e}")
def closeEvent(self, event):
"""Handle window close."""
self.playback.stop()
self.audio.cleanup()
event.accept()
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
app = QApplication(sys.argv)
# Try to use Fusion style for consistent cross-platform look
app.setStyle(QStyleFactory.create("Fusion"))
# Set up a pleasant color palette
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(245, 245, 245))
palette.setColor(QPalette.ColorRole.WindowText, QColor(33, 33, 33))
palette.setColor(QPalette.ColorRole.Base, QColor(255, 255, 255))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(240, 240, 240))
palette.setColor(QPalette.ColorRole.Button, QColor(240, 240, 240))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(33, 33, 33))
palette.setColor(QPalette.ColorRole.Highlight, QColor(33, 150, 243))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(255, 255, 255))
app.setPalette(palette)
window = SpeedCalcWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()