From 8677b017ecaf007536ca228defc1fc1887f24106 Mon Sep 17 00:00:00 2001 From: bonamin Date: Wed, 11 Feb 2026 17:47:41 +0200 Subject: [PATCH] Code Import from the last project --- main.py | 642 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 642 insertions(+) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..b930c31 --- /dev/null +++ b/main.py @@ -0,0 +1,642 @@ +""" +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...") \ No newline at end of file