1068 lines
38 KiB
Python
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()
|