""" PianoMaster 3000 - Ultimate Bell Melody Recorder Created for ESP32 Bell System """ import tkinter as tk from tkinter import ttk, messagebox, filedialog import keyboard import time import threading from math import gcd from functools import reduce from collections import defaultdict class PianoMaster3000: def __init__(self, root): self.root = root self.root.title("PianoMaster 3000 🎹") self.root.geometry("1400x900") self.root.configure(bg='#1a1a1a') # Recording state self.is_recording = False self.recording_started = False self.raw_presses = [] # List of (timestamp, keys_pressed) self.start_time = None # Settings self.num_keys = 8 self.tempo_mode = tk.StringVar(value="AUTO") self.manual_tempo = tk.IntVar(value=100) self.precision_mode = tk.StringVar(value="AUTO") self.precision_ms = tk.IntVar(value=10) self.precision_percent = tk.IntVar(value=5) self.chord_window = tk.IntVar(value=50) # Chord detection window in ms # Metronome self.metronome_running = False self.metronome_thread = None # Create UI self.create_ui() # Keyboard monitoring self.key_states = {str(i+1): False for i in range(self.num_keys)} self.setup_keyboard_listeners() def create_ui(self): # Title title_label = tk.Label( self.root, text="🎹 PianoMaster 3000 🎹", font=("Arial", 24, "bold"), bg='#1a1a1a', fg='#00ff00' ) title_label.pack(pady=10) subtitle = tk.Label( self.root, text="The Ultimate Bell Melody Recorder", font=("Arial", 12), bg='#1a1a1a', fg='#888888' ) subtitle.pack() # Settings Frame settings_frame = tk.Frame(self.root, bg='#2a2a2a', relief=tk.RAISED, borderwidth=2) settings_frame.pack(pady=15, padx=20, fill=tk.X) # Tempo Settings tempo_frame = tk.LabelFrame(settings_frame, text="Tempo Settings", bg='#2a2a2a', fg='white', font=("Arial", 10, "bold")) tempo_frame.grid(row=0, column=0, padx=10, pady=10, sticky='new') tk.Radiobutton(tempo_frame, text="AUTO", variable=self.tempo_mode, value="AUTO", bg='#2a2a2a', fg='white', selectcolor='#3a3a3a', command=self.update_tempo_mode).pack(anchor='w', padx=5, pady=2) manual_frame = tk.Frame(tempo_frame, bg='#2a2a2a') manual_frame.pack(anchor='w', padx=5, pady=2) tk.Radiobutton(manual_frame, text="Manual:", variable=self.tempo_mode, value="MANUAL", bg='#2a2a2a', fg='white', selectcolor='#3a3a3a', command=self.update_tempo_mode).pack(side=tk.LEFT) tk.Entry(manual_frame, textvariable=self.manual_tempo, width=8).pack(side=tk.LEFT, padx=5) tk.Label(manual_frame, text="ms", bg='#2a2a2a', fg='white').pack(side=tk.LEFT) # Precision Settings precision_frame = tk.LabelFrame(settings_frame, text="Precision Settings", bg='#2a2a2a', fg='white', font=("Arial", 10, "bold")) precision_frame.grid(row=0, column=1, padx=10, pady=10, sticky='new') tk.Radiobutton(precision_frame, text="AUTO", variable=self.precision_mode, value="AUTO", bg='#2a2a2a', fg='white', selectcolor='#3a3a3a').pack(anchor='w', padx=5, pady=2) ms_frame = tk.Frame(precision_frame, bg='#2a2a2a') ms_frame.pack(anchor='w', padx=5, pady=2) tk.Radiobutton(ms_frame, text="±", variable=self.precision_mode, value="MS", bg='#2a2a2a', fg='white', selectcolor='#3a3a3a').pack(side=tk.LEFT) tk.Entry(ms_frame, textvariable=self.precision_ms, width=6).pack(side=tk.LEFT, padx=5) tk.Label(ms_frame, text="ms", bg='#2a2a2a', fg='white').pack(side=tk.LEFT) percent_frame = tk.Frame(precision_frame, bg='#2a2a2a') percent_frame.pack(anchor='w', padx=5, pady=2) tk.Radiobutton(percent_frame, text="±", variable=self.precision_mode, value="PERCENT", bg='#2a2a2a', fg='white', selectcolor='#3a3a3a').pack(side=tk.LEFT) tk.Entry(percent_frame, textvariable=self.precision_percent, width=6).pack(side=tk.LEFT, padx=5) tk.Label(percent_frame, text="% of tempo", bg='#2a2a2a', fg='white').pack(side=tk.LEFT) # Chord Detection Settings chord_frame = tk.LabelFrame(settings_frame, text="Chord Detection", bg='#2a2a2a', fg='white', font=("Arial", 10, "bold")) chord_frame.grid(row=0, column=2, padx=10, pady=10, sticky='new') chord_label_frame = tk.Frame(chord_frame, bg='#2a2a2a') chord_label_frame.pack(anchor='w', padx=5, pady=5) tk.Label(chord_label_frame, text="Combine within:", bg='#2a2a2a', fg='white').pack(anchor='w') chord_input_frame = tk.Frame(chord_frame, bg='#2a2a2a') chord_input_frame.pack(anchor='w', padx=5, pady=2) tk.Entry(chord_input_frame, textvariable=self.chord_window, width=6).pack(side=tk.LEFT) tk.Label(chord_input_frame, text="ms", bg='#2a2a2a', fg='white').pack(side=tk.LEFT, padx=5) tk.Label(chord_frame, text="(Lower = stricter)", bg='#2a2a2a', fg='#888888', font=("Arial", 8)).pack(anchor='w', padx=5, pady=2) settings_frame.columnconfigure(0, weight=1) settings_frame.columnconfigure(1, weight=1) settings_frame.columnconfigure(2, weight=1) # Piano Keys Display keys_frame = tk.Frame(self.root, bg='#1a1a1a') keys_frame.pack(pady=20) tk.Label(keys_frame, text="Press keys 1-8 on your keyboard", font=("Arial", 10), bg='#1a1a1a', fg='#888888').pack() self.key_buttons = [] keys_container = tk.Frame(keys_frame, bg='#1a1a1a') keys_container.pack(pady=10) for i in range(self.num_keys): btn = tk.Label( keys_container, text=str(i+1), width=6, height=8, bg='white', fg='black', font=("Arial", 16, "bold"), relief=tk.RAISED, borderwidth=3 ) btn.pack(side=tk.LEFT, padx=3) self.key_buttons.append(btn) # Status Display status_frame = tk.Frame(self.root, bg='#2a2a2a', relief=tk.SUNKEN, borderwidth=2) status_frame.pack(pady=10, padx=20, fill=tk.BOTH, expand=True) self.status_label = tk.Label( status_frame, text="Ready to record! Press START RECORDING, then play your melody.", font=("Arial", 11), bg='#2a2a2a', fg='#00ff00', wraplength=800 ) self.status_label.pack(pady=5) self.info_label = tk.Label( status_frame, text="Steps: 0 | Time: 0.00s", font=("Arial", 10), bg='#2a2a2a', fg='#ffff00' ) self.info_label.pack(pady=5) # Melody preview self.melody_text = tk.Text( status_frame, height=8, width=80, bg='#1a1a1a', fg='#00ff00', font=("Courier", 10), wrap=tk.WORD ) self.melody_text.pack(pady=10, padx=10) self.melody_text.insert('1.0', "Melody will appear here as you play...") self.melody_text.config(state=tk.DISABLED) # Control Buttons button_frame = tk.Frame(self.root, bg='#1a1a1a') button_frame.pack(pady=15) self.record_btn = tk.Button( button_frame, text="🔴 START RECORDING", command=self.start_recording, bg='#ff0000', fg='white', font=("Arial", 12, "bold"), width=20, height=2 ) self.record_btn.pack(side=tk.LEFT, padx=5) self.stop_btn = tk.Button( button_frame, text="⏹ STOP RECORDING", command=self.stop_recording, bg='#666666', fg='white', font=("Arial", 12, "bold"), width=20, height=2, state=tk.DISABLED ) self.stop_btn.pack(side=tk.LEFT, padx=5) self.save_btn = tk.Button( button_frame, text="💾 SAVE MELODY", command=self.save_melody, bg='#0066cc', fg='white', font=("Arial", 12, "bold"), width=20, height=2, state=tk.DISABLED ) self.save_btn.pack(side=tk.LEFT, padx=5) # Metronome indicator self.metronome_indicator = tk.Label( self.root, text="", font=("Arial", 20, "bold"), bg='#1a1a1a', fg='#ff0000' ) self.metronome_indicator.pack(pady=5) def update_tempo_mode(self): """Update UI based on tempo mode selection""" if self.tempo_mode.get() == "MANUAL" and not self.is_recording: # Could start metronome here if desired pass def setup_keyboard_listeners(self): """Set up keyboard event listeners for keys 1-8""" for i in range(self.num_keys): key = str(i + 1) keyboard.on_press_key(key, lambda e, k=key: self.on_key_press(k)) keyboard.on_release_key(key, lambda e, k=key: self.on_key_release(k)) def on_key_press(self, key): """Handle key press event""" if not self.is_recording: return # Ignore if key is already marked as pressed (prevents duplicates) if self.key_states[key]: return # Start timing on first key press if not self.recording_started: self.recording_started = True self.start_time = time.time() self.update_status("Recording... Play your melody!") # Mark key as pressed self.key_states[key] = True # Visual feedback key_idx = int(key) - 1 self.key_buttons[key_idx].config(bg='#ffff00') # Record the press current_time = time.time() timestamp = int((current_time - self.start_time) * 1000) # milliseconds # Get all currently pressed keys pressed_keys = sorted([k for k, v in self.key_states.items() if v]) # Add to raw presses self.raw_presses.append((timestamp, pressed_keys.copy())) # Update info elapsed = current_time - self.start_time self.info_label.config(text=f"Presses: {len(self.raw_presses)} | Time: {elapsed:.2f}s") def on_key_release(self, key): """Handle key release event""" if not self.is_recording: return self.key_states[key] = False # Visual feedback key_idx = int(key) - 1 self.key_buttons[key_idx].config(bg='white') def start_recording(self): """Start recording mode""" self.is_recording = True self.recording_started = False self.raw_presses = [] self.start_time = None # Reset key states for key in self.key_states: self.key_states[key] = False # Update UI self.record_btn.config(state=tk.DISABLED) self.stop_btn.config(state=tk.NORMAL) self.save_btn.config(state=tk.DISABLED) self.update_status("Waiting for first key press... Start playing!") self.info_label.config(text="Steps: 0 | Time: 0.00s") # Clear melody display self.melody_text.config(state=tk.NORMAL) self.melody_text.delete('1.0', tk.END) self.melody_text.config(state=tk.DISABLED) # Start metronome if manual tempo if self.tempo_mode.get() == "MANUAL": self.start_metronome() def stop_recording(self): """Stop recording and process the melody""" self.is_recording = False self.recording_started = False # Stop metronome self.stop_metronome() # Update UI self.record_btn.config(state=tk.NORMAL) self.stop_btn.config(state=tk.DISABLED) self.save_btn.config(state=tk.NORMAL) if not self.raw_presses: self.update_status("No notes recorded!") return # Process the recording self.update_status("Processing melody...") self.process_melody() def process_melody(self): """Process raw presses into tempo-quantized melody""" if not self.raw_presses: return # Merge simultaneous presses (within chord detection window) merged_presses = self.merge_simultaneous_presses(self.raw_presses, window=self.chord_window.get()) # Determine tempo if self.tempo_mode.get() == "AUTO": tempo = self.calculate_auto_tempo(merged_presses) else: tempo = self.manual_tempo.get() # Quantize to steps melody_steps = self.quantize_to_steps(merged_presses, tempo) # Format output melody_string = self.format_melody(melody_steps) # Display results output = f"Tempo: {tempo}ms\n" output += f"Melody: {melody_string}\n" output += f"\n--- Raw Recording Data (for troubleshooting) ---\n" for timestamp, keys in merged_presses: keys_str = '+'.join(keys) if keys else '0' output += f"Button(s) {keys_str} pressed at: {timestamp}ms\n" self.melody_text.config(state=tk.NORMAL) self.melody_text.delete('1.0', tk.END) self.melody_text.insert('1.0', output) self.melody_text.config(state=tk.DISABLED) self.update_status(f"✅ Melody processed! Tempo: {tempo}ms, Steps: {len(melody_steps)}") # Store for saving self.processed_tempo = tempo self.processed_melody = melody_string self.processed_raw = merged_presses def merge_simultaneous_presses(self, presses, window=50): """Merge presses that occur within a time window (for chord detection)""" if not presses: return [] merged = [] i = 0 while i < len(presses): current_time, current_keys = presses[i] grouped_keys = set(current_keys) # Look ahead for keys pressed within the window j = i + 1 while j < len(presses) and presses[j][0] - current_time <= window: grouped_keys.update(presses[j][1]) j += 1 # Add merged group merged.append((current_time, sorted(list(grouped_keys)))) # Skip all merged presses i = j return merged def calculate_auto_tempo(self, presses): """Calculate tempo using intelligent interval analysis""" if len(presses) < 2: return 100 # Default # Get all intervals between consecutive presses intervals = [] for i in range(1, len(presses)): interval = presses[i][0] - presses[i-1][0] if interval > 0: intervals.append(interval) print(f"\n=== DEBUG: Tempo Calculation ===") print(f"Number of presses: {len(presses)}") print(f"Intervals between presses: {intervals}") if not intervals: return 100 # Find GCD of all intervals tempo_gcd = reduce(gcd, intervals) print(f"GCD of intervals: {tempo_gcd}") # Get unique intervals and their frequency interval_counts = {} for interval in intervals: interval_counts[interval] = interval_counts.get(interval, 0) + 1 print(f"Interval frequency: {interval_counts}") # Calculate average and median intervals avg_interval = sum(intervals) / len(intervals) sorted_intervals = sorted(intervals) median_interval = sorted_intervals[len(sorted_intervals) // 2] min_interval = min(intervals) print(f"Average interval: {avg_interval:.2f}ms") print(f"Median interval: {median_interval}ms") print(f"Min interval: {min_interval}ms") # SMARTER APPROACH: If GCD is tiny compared to actual intervals, # use a clustering/rounding approach instead if tempo_gcd < avg_interval * 0.1: print(f"GCD too small ({tempo_gcd} < {avg_interval * 0.1:.2f}), using smarter detection...") # Strategy: Find a tempo that minimizes total quantization error # Try candidate tempos from min_interval down to reasonable values best_tempo = min_interval best_error = float('inf') # Try different candidate tempos for candidate in range(max(10, min_interval // 2), min_interval + 200, 10): total_error = 0 for interval in intervals: # Find nearest multiple nearest_step = round(interval / candidate) quantized_value = nearest_step * candidate error = abs(interval - quantized_value) total_error += error avg_error = total_error / len(intervals) print(f" Testing tempo {candidate}ms: avg error = {avg_error:.2f}ms") if avg_error < best_error: best_error = avg_error best_tempo = candidate tempo = best_tempo print(f"Best tempo found: {tempo}ms with average error {best_error:.2f}ms") else: tempo = tempo_gcd print(f"Using GCD as tempo: {tempo}ms") # Apply user precision settings if specified if self.precision_mode.get() == "MS": precision = self.precision_ms.get() # Round tempo to nearest multiple of precision tempo = max(precision, round(tempo / precision) * precision) print(f"After precision adjustment: {tempo}ms") # Ensure tempo is reasonable (at least 10ms, at most 10s) tempo = max(10, min(tempo, 10000)) print(f"Final tempo: {tempo}ms") print(f"=== END DEBUG ===\n") return tempo def quantize_to_steps(self, presses, tempo): """Convert timestamps to tempo-based steps with pauses""" if not presses: return [] steps = [] last_step = 0 for timestamp, keys in presses: # Calculate which step this press should be on step = round(timestamp / tempo) # Add pauses (0s) if needed while last_step < step: steps.append([]) last_step += 1 # Add the actual notes steps.append(keys) last_step = step + 1 return steps def format_melody(self, steps): """Format steps into melody string""" formatted = [] for step in steps: if not step: formatted.append('0') elif len(step) == 1: formatted.append(step[0]) else: formatted.append('+'.join(step)) return ','.join(formatted) def save_melody(self): """Save melody to file""" if not hasattr(self, 'processed_melody'): messagebox.showerror("Error", "No melody to save!") return filename = filedialog.asksaveasfilename( defaultextension=".txt", filetypes=[("Text files", "*.txt"), ("All files", "*.*")], initialfile="melody.txt" ) if not filename: return try: with open(filename, 'w') as f: f.write(f"Tempo: {self.processed_tempo}ms\n") f.write(f"Melody: {self.processed_melody}\n") f.write(f"\n--- Raw Recording Data (for troubleshooting) ---\n") for timestamp, keys in self.processed_raw: keys_str = '+'.join(keys) if keys else '0' f.write(f"Button(s) {keys_str} pressed at: {timestamp}ms\n") messagebox.showinfo("Success", f"Melody saved to {filename}") self.update_status(f"✅ Saved to {filename}") except Exception as e: messagebox.showerror("Error", f"Failed to save: {str(e)}") def start_metronome(self): """Start metronome ticking""" if self.metronome_running: return self.metronome_running = True self.metronome_thread = threading.Thread(target=self.metronome_loop, daemon=True) self.metronome_thread.start() def stop_metronome(self): """Stop metronome""" self.metronome_running = False self.metronome_indicator.config(text="") def metronome_loop(self): """Metronome loop running in separate thread""" tempo = self.manual_tempo.get() beat = 0 while self.metronome_running: # Visual indicator if beat % 4 == 0: self.metronome_indicator.config(text="♪", fg='#ff0000') else: self.metronome_indicator.config(text="♪", fg='#ffff00') time.sleep(tempo / 1000.0) self.metronome_indicator.config(text="") time.sleep(0.05) beat += 1 def update_status(self, message): """Update status label""" self.status_label.config(text=message) def main(): print("Starting PianoMaster 3000...") print("Creating window...") try: root = tk.Tk() print("Window created!") print("Initializing app...") app = PianoMaster3000(root) print("App initialized!") print("Starting main loop...") root.mainloop() print("Main loop ended.") except Exception as e: print(f"ERROR: {e}") import traceback traceback.print_exc() finally: # Cleanup keyboard hooks try: keyboard.unhook_all() print("Keyboard hooks cleaned up.") except: pass if __name__ == "__main__": print("PianoMaster 3000 - Starting...") main() print("PianoMaster 3000 - Exiting.") input("\nPress ENTER to close...")