#!/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()