#!/usr/bin/env python3
import tkinter as tk
from tkinter import filedialog, messagebox, ttk, scrolledtext
import re
import os
import sys
import math
import numpy as np
import sounddevice as sd
from scipy.io import wavfile
import threading
import time
from scipy.signal import butter, sosfilt
import mido
import pygame
import pygame.midi

FREQ_TABLE = {
    "0": 0, "C3": 130.81, "C3#": 138.59, "D3b": 138.59, "D3": 146.83, "D3#": 155.56,
    "E3b": 155.56, "E3": 164.81, "F3": 174.61, "F3#": 185.00, "G3b": 185.00, "G3": 196.00,
    "G3#": 207.65, "A3b": 207.65, "A3": 220.00, "A3#": 233.08, "B3b": 233.08, "B3": 246.94,
    "C4": 261.63, "C4#": 277.18, "D4b": 277.18, "D4": 293.66, "D4#": 311.13, "E4b": 311.13,
    "E4": 329.63, "F4": 349.23, "F4#": 369.99, "G4b": 369.99, "G4": 392.00, "G4#": 415.30,
    "A4b": 415.30, "A4": 440, "A4#": 466.16, "B4b": 466.16, "B4": 493.88, "C5": 523.25,
    "C5#": 554.37, "D5b": 554.37, "D5": 587.33, "D5#": 622.25, "E5b": 622.25, "E5": 659.25,
    "F5": 698.46, "F5#": 739.99, "G6b": 739.99, "G5": 783.99, "G5#": 830.61, "A5b": 830.61,
    "A5": 880.00, "A5#": 932.33, "B5b": 932.33, "B5": 987.77,
}

NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']

##
# @class CNCStudioApp
# @brief Main Application class for the CNC Studio Tool.
# Handles GUI, Audio processing, MIDI conversion and G-Code generation.
class CNCStudioApp:
    ##
    # @brief Constructor for the CNCStudioApp.
    # @param root The Tkinter root window.
    def __init__(self, root):
        self.recorded_events = []
        self.start_time = 0

        self.root = root
        pygame.midi.init()
        self.root.title("CNC Studio")
        self.center_window(750, 850)
        
        # State variables for new features
        self.fullscreen_state = False
        self.monitoring_active = False
        self.monitor_thread = None

        # --- Menu Bar ---
        self.create_menubar()

        # --- Tabs ---
        self.notebook = ttk.Notebook(self.root)
        self.notebook.pack(fill="both", expand=True)

        self.tab_main = tk.Frame(self.notebook)
        self.notebook.add(self.tab_main, text="CNC Studio")

        self.tab_midi = tk.Frame(self.notebook)
        self.notebook.add(self.tab_midi, text="MIDI Converter")

        self.tab_monitor = tk.Frame(self.notebook)
        self.notebook.add(self.tab_monitor, text="MIDI Monitor")

        self.notebook.bind("<<NotebookTabChanged>>", self.on_tab_change)

        # --- Variables ---
        self.filepath_var = tk.StringVar()
        
        self.use_x = tk.BooleanVar(value=True)
        self.steps_x = tk.IntVar(value=320)
        self.limit_x = tk.IntVar(value=700)

        self.use_y = tk.BooleanVar(value=True) 
        self.steps_y = tk.IntVar(value=320) 
        self.limit_y = tk.IntVar(value=400)

        self.use_z = tk.BooleanVar(value=True) 
        self.steps_z = tk.IntVar(value=800) 
        self.limit_z = tk.IntVar(value=150)

        self.tempo_var = tk.DoubleVar(value=120.0) 
        self.speed_factor_var = tk.DoubleVar(value=1.0)
        self.wav_threshold_var = tk.DoubleVar(value=0.1)
        
        self.sim_mode_var = tk.StringVar(value="gcode")

        self.freq_split_x = (40, 250)    
        self.freq_split_y = (250, 1500)  
        self.freq_split_z = (1500, 6000) 

        # --- Initialize Tabs ---
        self.create_widgets(self.tab_main)
        self.create_midi_tab(self.tab_midi)
        self.create_monitor_tab(self.tab_monitor)

    ##
    # @brief Creates the main menu bar with File, Edit, View, Options, Help.
    def create_menubar(self):
        menubar = tk.Menu(self.root)

        # 1. File Menu
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Open", command=self.load_file)
        file_menu.add_command(label="Save", command=self.save_gcode)
        file_menu.add_command(label="Save Set", command=self.dummy_action)
        file_menu.add_separator()
        file_menu.add_command(label="Quit", command=self.root.quit)
        menubar.add_cascade(label="File", menu=file_menu)

        # 2. Edit Menu
        edit_menu = tk.Menu(menubar, tearoff=0)
        edit_menu.add_command(label="Undo", command=self.dummy_action)
        edit_menu.add_command(label="Redo", command=self.dummy_action)
        edit_menu.add_separator()
        edit_menu.add_command(label="Apply selected items to all tabs", command=self.dummy_action)
        menubar.add_cascade(label="Edit", menu=edit_menu)

        # 3. View Menu
        view_menu = tk.Menu(menubar, tearoff=0)
        view_menu.add_command(label="Maximize", command=self.maximize_window)
        view_menu.add_command(label="Minimize", command=self.root.iconify)
        view_menu.add_command(label="Fullscreen", command=self.toggle_fullscreen)
        menubar.add_cascade(label="View", menu=view_menu)

        # 4. Options Menu
        options_menu = tk.Menu(menubar, tearoff=0)
        options_menu.add_command(label="Settings", command=self.dummy_action)
        menubar.add_cascade(label="Options", menu=options_menu)

        # 5. Help Menu
        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="About the program", command=lambda: messagebox.showinfo("About", "CNC Studio v1.0"))
        help_menu.add_command(label="Help", command=self.dummy_action)
        menubar.add_cascade(label="Help", menu=help_menu)

        self.root.config(menu=menubar)

    def dummy_action(self):
        print("Feature not implemented yet.")

    def maximize_window(self):
        try:
            self.root.state('zoomed')
        except:
            self.root.attributes('-zoomed', True)

    def toggle_fullscreen(self):
        self.fullscreen_state = not self.fullscreen_state
        self.root.attributes("-fullscreen", self.fullscreen_state)

    def center_window(self, width, height):
        sw = self.root.winfo_screenwidth()
        sh = self.root.winfo_screenheight()
        x = (sw // 2) - (width // 2)
        y = (sh // 2) - (height // 2)
        self.root.geometry(f'{width}x{height}+{x}+{y}')

    def create_widgets(self, parent):
        frame_file = tk.LabelFrame(parent, text="1. Select File", padx=10, pady=10)
        frame_file.pack(fill="x", padx=10, pady=5)
        tk.Entry(frame_file, textvariable=self.filepath_var, width=50).pack(side=tk.LEFT, padx=5)
        tk.Button(frame_file, text="Search...", command=self.load_file).pack(side=tk.LEFT)

        frame_cnc = tk.LabelFrame(parent, text="2. Machine settings", padx=10, pady=10)
        frame_cnc.pack(fill="x", padx=10, pady=5)
        
        tk.Label(frame_cnc, text="Axis", font=("Arial", 9, "bold")).grid(row=0, column=0)
        tk.Label(frame_cnc, text="Active", font=("Arial", 9, "bold")).grid(row=0, column=1)
        tk.Label(frame_cnc, text="Gear ratio [Steps/mm]", font=("Arial", 9, "bold")).grid(row=0, column=2)
        tk.Label(frame_cnc, text="Limit [mm]", font=("Arial", 9, "bold")).grid(row=0, column=3)
        tk.Label(frame_cnc, text="Maximum Speed [mm/min]", font=("Arial", 9, "bold")).grid(row=0, column=4)

        self.max_feed_x = tk.IntVar(value=5000)
        tk.Entry(frame_cnc, textvariable=self.max_feed_x, width=8).grid(row=1, column=4)

        self.max_feed_y = tk.IntVar(value=2500) 
        tk.Entry(frame_cnc, textvariable=self.max_feed_y, width=8).grid(row=2, column=4)

        self.max_feed_z = tk.IntVar(value=469) 
        tk.Entry(frame_cnc, textvariable=self.max_feed_z, width=8).grid(row=3, column=4)

        tk.Label(frame_cnc, text="X").grid(row=1, column=0)
        tk.Checkbutton(frame_cnc, variable=self.use_x).grid(row=1, column=1)
        tk.Entry(frame_cnc, textvariable=self.steps_x, width=8).grid(row=1, column=2)
        tk.Entry(frame_cnc, textvariable=self.limit_x, width=8).grid(row=1, column=3)
        tk.Label(frame_cnc, text="Low", fg="blue").grid(row=1, column=5, sticky="w")

        tk.Label(frame_cnc, text="Y").grid(row=2, column=0)
        tk.Checkbutton(frame_cnc, variable=self.use_y).grid(row=2, column=1)
        tk.Entry(frame_cnc, textvariable=self.steps_y, width=8).grid(row=2, column=2)
        tk.Entry(frame_cnc, textvariable=self.limit_y, width=8).grid(row=2, column=3)
        tk.Label(frame_cnc, text="Mid", fg="red", font=("Arial", 9, "bold")).grid(row=2, column=5, sticky="w")

        tk.Label(frame_cnc, text="Z (inv.)").grid(row=3, column=0)
        tk.Checkbutton(frame_cnc, variable=self.use_z).grid(row=3, column=1)
        tk.Entry(frame_cnc, textvariable=self.steps_z, width=8).grid(row=3, column=2)
        tk.Entry(frame_cnc, textvariable=self.limit_z, width=8).grid(row=3, column=3)
        tk.Label(frame_cnc, text="Hi", fg="green").grid(row=3, column=5, sticky="w")

        frame_p = tk.Frame(frame_cnc); frame_p.grid(row=4, column=0, columnspan=5, pady=10, sticky="w")
        tk.Label(frame_p, text="Tempo (BPM for TXT):").pack(side=tk.LEFT, padx=5)
        tk.Entry(frame_p, textvariable=self.tempo_var, width=6).pack(side=tk.LEFT)
        tk.Label(frame_p, text="   WAV-Threshold:").pack(side=tk.LEFT, padx=5)
        tk.Entry(frame_p, textvariable=self.wav_threshold_var, width=6).pack(side=tk.LEFT)

        frame_sim = tk.LabelFrame(parent, text="3. Audio sim", padx=10, pady=10)
        frame_sim.pack(fill="x", padx=10, pady=5)
        
        frame_sim_top = tk.Frame(frame_sim)
        frame_sim_top.pack(fill="x", pady=5)
        tk.Label(frame_sim_top, text="Speed:").pack(side=tk.LEFT, padx=5)
        tk.Entry(frame_sim_top, textvariable=self.speed_factor_var, width=6).pack(side=tk.LEFT)
        tk.Label(frame_sim_top, text="   Modus:").pack(side=tk.LEFT, padx=15)
        tk.Radiobutton(frame_sim_top, text="(Gcode) CNC-Sim", variable=self.sim_mode_var, value="gcode").pack(side=tk.LEFT, padx=5)
        tk.Radiobutton(frame_sim_top, text="Letter Notes Sim", variable=self.sim_mode_var, value="clean").pack(side=tk.LEFT, padx=5)

        frame_sim_bot = tk.Frame(frame_sim)
        frame_sim_bot.pack(fill="x", pady=5)
        tk.Button(frame_sim_bot, text="▶ START AUDIO", command=self.play_audio_live, bg="#ccffcc", width=20).pack(side=tk.LEFT, padx=20)
        tk.Button(frame_sim_bot, text="⬛ STOP", command=self.stop_audio_live, bg="#ffcccc", width=10).pack(side=tk.LEFT, padx=5)

        frame_ex = tk.LabelFrame(parent, text="4. Export", padx=10, pady=10)
        frame_ex.pack(fill="x", padx=10, pady=5)
        tk.Button(frame_ex, text="Save G-Code", command=self.save_gcode, bg="#dddddd", height=2).pack(fill="x")

        frame_bottom = tk.Frame(parent)
        frame_bottom.pack(fill="both", expand=True, padx=10, pady=5)

        frame_log = tk.LabelFrame(frame_bottom, text="Log", padx=5, pady=5)
        frame_log.pack(side=tk.LEFT, fill="both", expand=True, padx=(0, 5))
        
        self.log_text = tk.Text(frame_log, height=8, state='disabled', bg="#f0f0f0", width=40) 
        self.log_text.pack(fill="both", expand=True)

        frame_preview = tk.LabelFrame(frame_bottom, text="G-Code", padx=5, pady=5)
        frame_preview.pack(side=tk.LEFT, fill="both", expand=True, padx=(5, 0))
        
        self.gcode_text = tk.Text(frame_preview, height=8, bg="white", width=40)
        self.gcode_text.pack(fill="both", expand=True)
        
        self.gcode_text.tag_config("highlight", background="#ffff99", foreground="black")

    def create_midi_tab(self, parent):
        lbl_info = tk.Label(parent, text="Select an MIDI File.\nThe tool automatically creates a .txt file.", pady=10)
        lbl_info.pack()

        btn_frame = tk.Frame(parent)
        btn_frame.pack(pady=10)

        self.btn_load_midi = tk.Button(btn_frame, text="Select MIDI File", command=self.process_midi_file, bg="#dddddd", height=2, width=30)
        self.btn_load_midi.pack()

        tk.Label(parent, text="Log:", anchor="w").pack(fill="x", padx=10)
        
        self.txt_log_midi = scrolledtext.ScrolledText(parent, height=15, state='disabled')
        self.txt_log_midi.pack(fill="both", expand=True, padx=10, pady=5)

    ##
    # @brief Creates the content for the MIDI Monitor Tab.
    def create_monitor_tab(self, parent):
        # 1. Device & Filter Selection
        frame_sel = tk.Frame(parent, pady=10)
        frame_sel.pack(fill="x", padx=10)
        
        # -- Geräte Auswahl --
        tk.Label(frame_sel, text="MIDI Gerät:", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
        self.combo_midi_ports = ttk.Combobox(frame_sel, state="readonly", width=30)
        self.combo_midi_ports.pack(side=tk.LEFT, padx=5)
        
        tk.Button(frame_sel, text="⟳", command=self.refresh_midi_ports, width=3).pack(side=tk.LEFT, padx=(0, 20))

        # -- Filter Auswahl --
        tk.Label(frame_sel, text="Filter:", font=("Arial", 10, "bold")).pack(side=tk.LEFT)
        self.combo_filter = ttk.Combobox(frame_sel, state="readonly", width=20)
        self.combo_filter['values'] = ["Alle anzeigen", "Clock (248) ignorieren"]
        self.combo_filter.current(1) 
        self.combo_filter.pack(side=tk.LEFT, padx=5)

        # 2. Log Area
        tk.Label(parent, text="Incoming MIDI Messages:", anchor="w").pack(fill="x", padx=10, pady=(5,0))
        self.monitor_log = scrolledtext.ScrolledText(parent, height=18, state='disabled', bg="#f0f0f0")
        self.monitor_log.pack(fill="both", expand=True, padx=10, pady=5)

        # 3. Controls
        btn_frame = tk.Frame(parent, pady=10)
        btn_frame.pack(fill="x")

        self.btn_mon_start = tk.Button(btn_frame, text="Start Record", command=self.start_monitor_record, bg="#ccffcc", width=15)
        self.btn_mon_start.pack(side=tk.LEFT, padx=20)

        self.btn_mon_stop = tk.Button(btn_frame, text="Stop", command=self.stop_monitor_record, bg="#ffcccc", width=10, state='disabled')
        self.btn_mon_stop.pack(side=tk.LEFT, padx=5)

        self.btn_mon_clear = tk.Button(btn_frame, text="🗑 Clear Log", command=self.clear_monitor_log, bg="#dddddd", width=12)
        self.btn_mon_clear.pack(side=tk.RIGHT, padx=20)

        self.btn_mon_save = tk.Button(btn_frame, text="💾 Save MIDI", command=self.save_captured_midi, bg="#dddddd", width=12)
        self.btn_mon_save.pack(side=tk.RIGHT, padx=5)

    def log_monitor(self, msg):
        self.monitor_log.config(state='normal')
        self.monitor_log.insert(tk.END, str(msg) + "\n")
        self.monitor_log.see(tk.END)
        self.monitor_log.config(state='disabled')

    def clear_monitor_log(self):
        self.monitor_log.config(state='normal')
        self.monitor_log.delete('1.0', tk.END)
        self.monitor_log.config(state='disabled')

    def on_tab_change(self, event):
        # Prüfen, welcher Tab aktiv ist
        selected_tab_id = self.notebook.select()
        # Vergleiche ID mit dem Monitor-Widget
        if selected_tab_id == str(self.tab_monitor):
            self.refresh_midi_ports()

    def refresh_midi_ports(self):
        if self.monitoring_active:
            messagebox.showwarning("Warnung", "Bitte erst die Aufnahme stoppen!")
            return
        
        try:
            pygame.midi.quit()
            pygame.midi.init()
        except Exception as e:
            self.log_monitor(f"Fehler beim MIDI-Reset: {e}")

        self.combo_midi_ports['values'] = []
        inputs = []
        try:
            count = pygame.midi.get_count()
            for i in range(count):
                r = pygame.midi.get_device_info(i)
                # r = (interface, name, input, output, opened)
                if r and r[2] == 1:
                    try:
                        name = r[1].decode("utf-8")
                    except:
                        name = str(r[1]) # Fallback
                        
                    inputs.append(f"{i}: {name}")
            
            self.combo_midi_ports['values'] = inputs
            
            if inputs:
                current = self.combo_midi_ports.get()
                if current not in inputs:
                    self.combo_midi_ports.current(0)
            else:
                self.combo_midi_ports.set("Keine MIDI-Geräte gefunden")

        except Exception as e:
            self.log_monitor(f"Fehler bei Gerätesuche: {e}")

    def save_captured_midi(self):
        if not self.recorded_events:
            messagebox.showwarning("Leer", "Keine Daten aufgenommen!")
            return

        path = filedialog.asksaveasfilename(defaultextension=".mid", filetypes=[("MIDI File", "*.mid")])
        if not path: return

        try:
            mid = mido.MidiFile()
            track = mido.MidiTrack()
            mid.tracks.append(track)
            
            ticks_per_beat = 480 
            bpm = 120 
            mid.ticks_per_beat = ticks_per_beat
            
            # Startzeitpunkt für Delta-Berechnung setzen
            last_timestamp = self.start_timestamp
            
            for data, timestamp in self.recorded_events:
                # 1. Delta-Zeit berechnen (in Sekunden umwandeln)
                delta_ms = timestamp - last_timestamp
                if delta_ms < 0: delta_ms = 0
                
                # Sekunden -> Ticks
                delta_seconds = delta_ms / 1000.0
                ticks = int(mido.second2tick(delta_seconds, ticks_per_beat, mido.bpm2tempo(bpm)))
                
                # 2. Daten korrekt zuschneiden
                status = data[0]
                bytes_data = []

                if status >= 0xF0:
                    bytes_data = [status]
                elif 0xC0 <= status <= 0xDF:
                    bytes_data = data[:2]
                else:
                    bytes_data = data[:3]

                try:
                    msg = mido.Message.from_bytes(bytes_data)
                    msg.time = ticks
                    track.append(msg)
                    
                    last_timestamp = timestamp 
                    
                except ValueError as ve:
                    print(f"Skipped invalid msg: {data} -> {bytes_data} ({ve})")

            mid.save(path)
            messagebox.showinfo("Erfolg", f"Datei gespeichert ({len(self.recorded_events)} Events):\n{path}")
            
        except Exception as e:
            messagebox.showerror("Fehler", f"Konnte MIDI nicht speichern: {e}")

    def start_monitor_record(self):
        selection = self.combo_midi_ports.get()
        if not selection or selection == "Keine MIDI-Geräte gefunden":
            messagebox.showerror("Error", "Bitte wähle ein gültiges MIDI-Gerät aus!")
            self.refresh_midi_ports()
            return
        
        try:
            device_id = int(selection.split(":")[0])
        except:
            return

        try:
            pygame.midi.quit()
            pygame.midi.init()
        except: 
            pass

        self.recorded_events = []
        self.start_timestamp = pygame.midi.time() 
        
        self.btn_mon_start.config(state='disabled')
        self.btn_mon_stop.config(state='normal')
        self.combo_midi_ports.config(state='disabled')
        self.monitoring_active = True
        
        self.log_monitor(f"--- Listening on Device ID: {device_id} ---")
        self.monitor_thread = threading.Thread(target=self._monitor_loop, args=(device_id,), daemon=True)
        self.monitor_thread.start()

    def stop_monitor_record(self):
        self.monitoring_active = False
        self.btn_mon_start.config(state='normal')
        self.btn_mon_stop.config(state='disabled')
        self.combo_midi_ports.config(state='readonly')
        
        time.sleep(0.2) 
        
        # Input schließen
        if hasattr(self, 'pyg_input') and self.pyg_input:
            self.pyg_input.close()
            self.pyg_input = None
            
        pygame.midi.quit()
        pygame.midi.init()
            
        self.log_monitor("--- Stopped ---")

    def _monitor_loop(self, device_id):
        try:
            self.pyg_input = pygame.midi.Input(device_id)
            
            while self.monitoring_active:
                if self.pyg_input.poll():
                    midi_events = self.pyg_input.read(1024)
                    
                    filter_mode = self.combo_filter.get()
                    
                    for midi_ev in midi_events:
                        # midi_ev Struktur: [[status, data1, data2, data3], timestamp]
                        data = midi_ev[0]
                        timestamp = midi_ev[1]
                        status_byte = data[0]

                        if filter_mode == "Clock (248)" and status_byte == 248:
                            continue

                        if status_byte == 254: 
                            continue

                        # Speichern: Rohdaten + Zeitstempel
                        self.recorded_events.append((data, timestamp))

                        try:
                            msg = mido.Message.from_bytes(data[:3])
                            self.root.after(0, lambda m=msg: self.log_monitor(m))
                        except:
                            self.root.after(0, lambda d=data: self.log_monitor(f"Raw: {d}"))
                
                time.sleep(0.001) 
                    
        except Exception as e:
            self.root.after(0, lambda err=e: self.log_monitor(f"Error: {err}"))
            self.root.after(0, self.stop_monitor_record)

    def log_midi(self, msg):
        self.txt_log_midi.config(state='normal')
        self.txt_log_midi.insert(tk.END, msg + "\n")
        self.txt_log_midi.see(tk.END)
        self.txt_log_midi.config(state='disabled')
        self.root.update()

    def midi_note_to_custom_format(self, note_number):
        """
        Converts MIDI 60 to 'C4'.
        Format: 'Note + Octave + #' (e.g. 'D5#').
        """
        octave = note_number // 12 - 1
        note_index = note_number % 12
        raw_name = NOTE_NAMES[note_index]
        
        if '#' in raw_name:
            return f"{raw_name[0]}{octave}#"
        else:
            return f"{raw_name}{octave}"

    def ticks_to_fraction(self, ticks, ticks_per_beat):
        """
        Verbesserte Quantisierung: Unterstützt nun auch Triolen und 1/32 Noten.
        """
        if ticks_per_beat == 0 or ticks == 0: return "1/16"
        
        length = ticks / ticks_per_beat
        
        known_lengths = [
            (4.0, "1/1"), 
            (3.0, "1/2."), (2.0, "1/2"), 
            (1.5, "1/4."), (1.0, "1/4"), 
            (0.75, "1/8."), (0.6666, "1/6"), # Viertel-Triole
            (0.5, "1/8"), 
            (0.375, "1/16."), (0.3333, "1/12"), # Achtel-Triole
            (0.25, "1/16"), 
            (0.1666, "1/24"), # Sechzehntel-Triole
            (0.125, "1/32")
        ]
        
        best_match = "1/16"
        min_diff = 999.0
        
        for val, name in known_lengths:
            diff = abs(length - val)
            if diff < min_diff:
                min_diff = diff
                best_match = name
        
        if length > 3.8: return "1/1"
        if length < 0.08: return "1/32" 
        
        return best_match

    def process_midi_file(self):
        filepath = filedialog.askopenfilename(filetypes=[("MIDI File", "*.mid *.midi"), ("All Files", "*.*")])
        if not filepath: return

        self.log_midi(f"--- Processing: {os.path.basename(filepath)} ---")
        
        try:
            mid = mido.MidiFile(filepath)
            ticks_per_beat = mid.ticks_per_beat
            all_notes = []
            
            for track in mid.tracks:
                curr_ticks = 0
                active_notes = {}
                
                for msg in track:
                    curr_ticks += msg.time
                    
                    if msg.type == 'note_on' and msg.velocity > 0:
                        active_notes[msg.note] = curr_ticks
                    elif (msg.type == 'note_off') or (msg.type == 'note_on' and msg.velocity == 0):
                        if msg.note in active_notes:
                            start_t = active_notes.pop(msg.note)
                            end_t = curr_ticks
                            if end_t - start_t > 0:
                                all_notes.append([start_t, end_t, msg.note])

            if not all_notes:
                self.log_midi("Error: Keine Noten gefunden.")
                return
            
            all_notes.sort(key=lambda x: (x[0], -x[2]))

            clean_notes = []
            if all_notes:
                current = all_notes[0]
                
                for i in range(1, len(all_notes)):
                    next_note = all_notes[i]
                    
                    if next_note[0] < current[1]:
                        current[1] = next_note[0]

                    if current[1] > current[0]:
                        clean_notes.append(current)
                    
                    if next_note[0] >= current[0]: 
                        current = next_note

                if current[1] > current[0]:
                    clean_notes.append(current)

            output_lines = []
            last_end_ticks = 0
            
            for start, end, note_val in clean_notes:
                gap = start - last_end_ticks
                if gap > (ticks_per_beat * 0.1):
                    frac = self.ticks_to_fraction(gap, ticks_per_beat)
                    if frac != "1/16":
                        output_lines.append(f"0\t{frac}")
                
                dur = end - start
                frac = self.ticks_to_fraction(dur, ticks_per_beat)
                note_str = self.midi_note_to_custom_format(note_val)
                
                output_lines.append(f"{note_str}\t{frac}")
                self.log_midi(f"{note_str}\t{frac}")
                
                last_end_ticks = start + dur

            out_path = os.path.splitext(filepath)[0] + ".txt"
            with open(out_path, "w") as f:
                f.write("\n".join(output_lines))
            
            self.log_midi(f"\nSaved: {os.path.basename(out_path)}")
            messagebox.showinfo("Fertig", "Datei erfolgreich konvertiert!")

        except Exception as e:
            self.log_midi(f"ERROR: {e}")

    def log(self, msg):
        self.log_text.config(state='normal')
        self.log_text.insert(tk.END, msg + "\n")
        self.log_text.see(tk.END)
        self.log_text.config(state='disabled')
        self.root.update_idletasks()

    def load_file(self):
        filetypes = [("Supported", "*.txt *.wav *.gcode *.nc"), ("All Files", "*.*")]
        fn = filedialog.askopenfilename(filetypes=filetypes)
        if fn:
            self.filepath_var.set(fn)
            self.log(f"File: {os.path.basename(fn)}")
            
            self.log("Generating preview...")
            self.root.update_idletasks()
            
            lines = self.calculate_gcode()
            if lines:
                self.update_gcode_view(lines)
                self.log("Preview updated.")
             
    def get_axis_config(self):
        cfg = {}
        if self.use_x.get(): cfg['X'] = {'steps': self.steps_x.get(), 'limit': self.limit_x.get(), 'max_feed': self.max_feed_x.get(), 'range': self.freq_split_x}
        if self.use_y.get(): cfg['Y'] = {'steps': self.steps_y.get(), 'limit': self.limit_y.get(), 'max_feed': self.max_feed_y.get(), 'range': self.freq_split_y}
        if self.use_z.get(): cfg['Z'] = {'steps': self.steps_z.get(), 'limit': self.limit_z.get(), 'max_feed': self.max_feed_z.get(), 'range': self.freq_split_z}
        return cfg
    
    def freq_to_midi(self, f): return 0 if f<=0 else 69+12*math.log2(f/440.0)
    def midi_to_freq(self, m): return 0 if m<=0 else 440.0*2**((m-69)/12.0)
    def quantize(self, f):
        if f < 20: return 0
        return self.midi_to_freq(round(self.freq_to_midi(f)))

    def calculate_gcode(self):
        infile = self.filepath_var.get()
        if not infile: return None
        ext = infile.lower().rsplit('.',1)[-1]
        
        if ext in ['gcode','nc','tap','ngc']:
            try:
                with open(infile,'r') as f: return [l.strip() for l in f]
            except: return None
            
        axes = self.get_axis_config()
        if not axes:
            self.log("No axis active!"); return None

        if ext == 'wav': return self.convert_wav_poly(infile, axes)
        else: return self.convert_txt(infile, axes)

    def convert_txt(self, fn, axes):
        self.log(f"Converting TXT...")
        try:
            bpm = self.tempo_var.get()
            if bpm <= 0: bpm = 120
            
            axis_state = {}
            for ax in axes:
                steps_per_mm = axes[ax]['steps']
                max_feed_mm_min = axes[ax]['max_feed'] 
                
                if steps_per_mm <= 0: steps_per_mm = 1
                
                axis_state[ax] = {
                    'pos': 0.0, 'dir': True,
                    'steps_per_mm': steps_per_mm,
                    'limit_feed_mm': max_feed_mm_min, 
                    'limit_pos': axes[ax]['limit']
                }

            lines = ["M5", "G21", "G90", "G92 " + " ".join([f"{ax}0" for ax in axes]), "G91"]
            
            with open(fn, 'r') as f: content = f.readlines()

            for line in content:
                line = line.strip()
                if not line or line.startswith("#"): continue
                
                try:
                    m = re.findall(r"(.*?)\s+(\d/?\d*)(\.?\.?\.?)", line)[0]
                    note, note_str_val, dots = m[0], m[1], m[2]
                    
                    try: raw_val = float(eval(note_str_val + ".0"))
                    except: raw_val = 4.0

                    if raw_val >= 2: note_fraction = 1.0 / raw_val
                    else: note_fraction = raw_val

                    if dots == 0: dot_multiplier = 1
                    elif dots == 1: dot_multiplier = 1.5
                    elif dots == 2: dot_multiplier = 1.75
                    else: dot_multiplier = 1

                    duration_sec = note_fraction * dot_multiplier * (240.0 / bpm)
                    duration_min = duration_sec / 60.0
                    frequency = FREQ_TABLE.get(note, 0)

                    if frequency == 0:
                        lines.append(f"G04 P{round(duration_sec * 1000)}")
                    else:
                        cmd = "G01"
                        feeds = []
                        for ax, state in axis_state.items():
                            calc_feed = frequency * (60.0 / state['steps_per_mm'])
                            
                            if calc_feed > state['limit_feed_mm']:
                                calc_feed = state['limit_feed_mm']
                            
                            distance = calc_feed * duration_min
                            if not state['dir']: distance = -distance
                            
                            projected = state['pos'] + distance
                            if projected > state['limit_pos']:
                                state['dir'] = False; distance = -distance
                                projected = state['pos'] + distance
                            elif projected < 0:
                                state['dir'] = True; distance = -distance
                                projected = state['pos'] + distance

                            state['pos'] = projected
                            cmd += f" {ax}{distance:.3f}"
                            feeds.append(calc_feed)

                        if feeds:
                            cmd += f" F{int(feeds[0])}"
                            lines.append(cmd)

                except Exception as e: pass

            cmd = "G00"
            for ax, state in axis_state.items(): cmd += f" {ax}{-state['pos']:.3f}"
            lines.append(cmd); lines.append("M5"); lines.append("G90")
            return lines

        except Exception as e:
            self.log(f"Error: {e}")
            return None

    def butter_bandpass_filter(self, data, lowcut, highcut, fs, order=5):
        nyq = 0.5 * fs
        low = lowcut / nyq if lowcut else 0.001
        high = highcut / nyq if highcut else 0.99
        if low <= 0: low = 0.001
        if high >= 1: high = 0.99
        sos = butter(order, [low, high], btype='band', output='sos')
        y = sosfilt(sos, data)
        return y

    def convert_wav_poly(self, fn, axes):
        self.log(f"Starting WAV analysis...")
        try:
            thresh = self.wav_threshold_var.get()
            sr, data = wavfile.read(fn)
            if len(data.shape) > 1: data = data.mean(axis=1)
            
            max_val = np.max(np.abs(data))
            if max_val > 0: data = data / max_val
            
            bands = {}
            if 'X' in axes: bands['X'] = self.butter_bandpass_filter(data, 20, 250, sr, order=4)
            if 'Y' in axes: bands['Y'] = self.butter_bandpass_filter(data, 250, 2000, sr, order=4)
            if 'Z' in axes: bands['Z'] = self.butter_bandpass_filter(data, 2000, 8000, sr, order=4)

            lines = ["M5","G21","G90","G92 X0 Y0 Z0","G91"]
            
            chunk_ms = 50 
            chunk_sz = int(sr * (chunk_ms / 1000.0))
            num_chunks = len(data) // chunk_sz
            
            pos = {ax: 0.0 for ax in axes}
            direc = {ax: 1 for ax in axes}
            
            window = np.hanning(chunk_sz)
            freqs_spect = np.fft.fftfreq(chunk_sz, 1/sr)[:chunk_sz//2]

            for i in range(num_chunks):
                start_idx = i * chunk_sz
                end_idx = (i + 1) * chunk_sz
                
                active_freqs = {} 

                for ax, ax_data in bands.items():
                    chunk = ax_data[start_idx:end_idx] * window
                    rms = np.sqrt(np.mean(chunk**2))
                    if rms < thresh:
                        active_freqs[ax] = 0; continue
                    
                    w = np.fft.fft(chunk)
                    magnitudes = np.abs(w[:chunk_sz//2])
                    if len(magnitudes) == 0: active_freqs[ax] = 0; continue
                        
                    peak_idx = np.argmax(magnitudes)
                    raw_f = freqs_spect[peak_idx]
                    note_f = self.quantize(raw_f)
                    
                    if note_f > 10: active_freqs[ax] = note_f
                    else: active_freqs[ax] = 0

                if all(f == 0 for f in active_freqs.values()):
                    lines.append(f"G04 P{chunk_ms:.2f}")
                    continue
                
                cmd = "G01"
                feeds_calc = []
                dur_sec = chunk_ms / 1000.0
                dist_components = {}
                
                for ax in axes:
                    freq = active_freqs.get(ax, 0)
                    if freq > 0:
                        steps_mm = axes[ax]['steps']
                        limit_feed_mm = axes[ax]['max_feed']
                        
                        target_feed_mm = freq * (60.0 / steps_mm)
                        final_feed = min(target_feed_mm, limit_feed_mm)
                        
                        dist = final_feed * dur_sec
                        dist_components[ax] = dist
                        feeds_calc.append(final_feed)
                    else:
                        dist_components[ax] = 0
                        feeds_calc.append(0)
                
                vec_feed = math.sqrt(sum([f**2 for f in feeds_calc]))
                
                if vec_feed < 1: 
                    lines.append(f"G04 P{chunk_ms:.2f}")
                    continue

                for ax in axes:
                    d_abs = dist_components[ax]
                    if d_abs > 0:
                        if direc[ax] == 1:
                            if pos[ax] + d_abs > axes[ax]['limit']:
                                direc[ax] = -1; d_signed = -d_abs
                            else: d_signed = d_abs
                        else:
                            if pos[ax] - d_abs < 0:
                                direc[ax] = 1; d_signed = d_abs
                            else: d_signed = -d_abs
                        
                        pos[ax] += d_signed
                        val_out = -d_signed if ax == 'Z' else d_signed
                        cmd += f" {ax}{val_out:.3f}"
                
                cmd += f" F{int(vec_feed)}"
                lines.append(cmd)

            cmd = "G00"
            for ax in axes:
                val = -pos[ax]
                val_out = -val if ax == 'Z' else val
                cmd += f" {ax}{val_out:.3f}"
            lines.append(cmd); lines.append("M5"); lines.append("G90")
            
            return lines

        except Exception as e:
            self.log(f"WAV Error: {e}")
            return None
        
    def update_gcode_view(self, lines):
        """Fill the right window with the generated G-code."""
        self.gcode_text.delete(1.0, tk.END)
        self.gcode_text.insert(tk.END, "\n".join(lines))

    def highlight_line(self, line_idx):
        """Highlight the line in the G-code window"""
        try:
            self.gcode_text.tag_remove("highlight", "1.0", tk.END)
            start = f"{line_idx + 1}.0"
            end = f"{line_idx + 1}.end"
            self.gcode_text.tag_add("highlight", start, end)
            self.gcode_text.see(start)
        except Exception:
            pass

    def play_audio_live(self):
        self.stop_audio_live()
        
        mode = self.sim_mode_var.get()
        fn = self.filepath_var.get()
        if not fn: return

        if mode == "clean":
            threading.Thread(target=self._play_clean_thread, args=(fn,), daemon=True).start()
            return

        self.log("Calculating G-Code...")
        lines = self.calculate_gcode() 
        if not lines: return
        
        self.update_gcode_view(lines)
        
        self.log("Starting simulation...")
        self.stop_flag = False
        
        self.audio_thread = threading.Thread(target=self._play_sim_thread, args=(lines,), daemon=True)
        self.audio_thread.start()

    def _play_sim_thread(self, lines):
        """Generates all audio and synchronizes GUI"""
        try:
            self.log("Generating audio buffer...")
            axes = self.get_axis_config()
            speed = self.speed_factor_var.get()
            sr = 44100
            
            full_audio = []
            line_timestamps = [] 
            current_sample = 0
            
            cur_feed = 0

            for i, line in enumerate(lines):
                if self.stop_flag: return

                line_u = line.upper()
                chunk = None
                
                fm = re.search(r'F(\d+\.?\d*)', line_u)
                if fm: cur_feed = float(fm.group(1))

                if 'G01' in line_u or (line_u.startswith('G1') and 'G10' not in line_u):
                    if cur_feed > 0:
                        dists = {}
                        vec_dist = 0
                        for ax in ['X','Y','Z']:
                            m = re.search(fr'{ax}([-]?\d+\.?\d*)', line_u)
                            if m: 
                                d = abs(float(m.group(1)))
                                dists[ax] = d; vec_dist += d**2
                            else: dists[ax] = 0
                        
                        if vec_dist > 0:
                            vec_dist = math.sqrt(vec_dist)
                            dur = (vec_dist / cur_feed) * 60.0
                            dur /= speed
                            num_samples = int(sr * dur)
                            
                            if num_samples > 0:
                                t = np.linspace(0, dur, num_samples, endpoint=False)
                                mixed_wave = np.zeros_like(t)
                                
                                for ax, d in dists.items():
                                    if d > 0 and ax in axes:
                                        scale = 60.0 / axes[ax]['steps']
                                        ax_feed = cur_feed * (d / vec_dist) 
                                        freq = ax_feed / scale
                                        saw = 2*(freq*t%1)-1
                                        mixed_wave += (0.3 * saw)
                                
                                fl = min(200, num_samples//2)
                                if fl>0: 
                                    mixed_wave[:fl]*=np.linspace(0,1,fl)
                                    mixed_wave[-fl:]*=np.linspace(1,0,fl)
                                chunk = mixed_wave

                elif 'G04' in line_u or 'G4 ' in line_u:
                    p = re.search(r'[P|X](\d+\.?\d*)', line_u)
                    if p:
                        val = float(p.group(1))
                        dur = (val/1000.0) / speed
                        chunk = np.zeros(int(sr*dur))
                
                if chunk is not None:
                    line_timestamps.append((current_sample, i))
                    full_audio.append(chunk)
                    current_sample += len(chunk)
                else:
                    line_timestamps.append((current_sample, i))

            if not full_audio:
                self.log("No audio generated.")
                return

            total_wave = np.concatenate(full_audio)
            
            m = np.max(np.abs(total_wave))
            if m > 1: total_wave /= m

            self.log(f"Playing ({len(total_wave)/sr:.1f}s)...")
            
            sd.play(total_wave, sr)
            
            start_time = time.time()
            idx_pointer = 0
            max_idx = len(line_timestamps) - 1
            
            while sd.get_stream().active and not self.stop_flag:
                elapsed = time.time() - start_time
                current_play_sample = elapsed * sr
                
                while idx_pointer < max_idx and line_timestamps[idx_pointer+1][0] < current_play_sample:
                    idx_pointer += 1
                
                line_idx = line_timestamps[idx_pointer][1]
                
                self.root.after(0, lambda idx=line_idx: self.highlight_line(idx))
                
                time.sleep(0.05)

            if self.stop_flag:
                sd.stop()
            
            self.root.after(0, lambda: self.log("Done."))

        except Exception as e:
            self.root.after(0, lambda: self.log(f"Sim Thread Error: {e}"))

    def _play_clean_thread(self, fn):
        """Thread for Preview Modus"""
        try:
            self.root.after(0, lambda: self.play_clean_preview(fn))
        except Exception as e:
            print(e)

    def stop_audio_live(self):
        self.stop_flag = True
        sd.stop()
        self.log("Stop.")

    def play_clean_preview(self, fn):
        try:
            bpm = self.tempo_var.get()
            speed = self.speed_factor_var.get()
            if bpm <= 0: bpm = 120
            
            sr = 44100
            audio = []
            
            with open(fn,'r') as f: content = f.readlines()
            
            for line in content:
                line = line.strip()
                if not line or line.startswith("#"): continue
                try:
                    m = re.findall(r"(.*?)\s+(\d/?\d*)(\.?\.?\.?)", line)[0]
                    note, val_str, dots = m[0], m[1], m[2]
                    
                    # Punktierung berechnen
                    dm = 1.0
                    if "." in dots: dm = 1.5
                    if ".." in dots: dm = 1.75

                    if "/" in val_str:
                        num, den = val_str.split("/")
                        base_val = float(num) / float(den)
                    else:
                        try:
                            v = float(val_str)
                            if v >= 1 and v <= 32: base_val = 1.0 / v
                            else: base_val = v
                        except: base_val = 0.25

                    # Berechnung der Dauer in Sekunden
                    dur = (240.0 / bpm) * base_val * dm
                    dur /= speed

                    num_samples = int(sr * dur)
                    if num_samples <= 0: continue

                    freq = FREQ_TABLE.get(note, 0)
                    
                    if freq > 0:
                        t = np.linspace(0, dur, num_samples, endpoint=False)
                        wave = 0.3 * np.sin(2 * np.pi * freq * t)
                        
                        fade = min(500, num_samples//2)
                        if fade > 0:
                            wave[:fade] *= np.linspace(0, 1, fade)
                            wave[-fade:] *= np.linspace(1, 0, fade)
                        audio.append(wave)
                    else:
                        # Pause
                        audio.append(np.zeros(num_samples))

                except Exception as e: 
                    print(f"Skipped line: {line} ({e})")
            
            if audio:
                full = np.concatenate(audio)
                self.log(f"Preview: {len(full)/sr:.1f}s @ {bpm} BPM")
                sd.play(full, sr)
            else:
                self.log("Keine abspielbaren Noten gefunden.")

        except Exception as e: self.log(f"Preview Error: {e}")

    def save_gcode(self):
        lines = self.calculate_gcode()
        if not lines: return
        fn = self.filepath_var.get()
        if not fn: 
             messagebox.showerror("Error", "No file loaded")
             return
        d = os.path.splitext(fn)[0]+".gcode"
        p = filedialog.asksaveasfilename(initialfile=os.path.basename(d), defaultextension=".gcode")
        if p:
            with open(p,"w") as f: f.write('\n'.join(lines))
            self.log(f"Saved: {p}"); messagebox.showinfo("OK","Finish!")

if __name__ == "__main__":
    try: 
        import sounddevice
        import mido
        import pygame
    except ImportError as e: 
        root=tk.Tk(); root.withdraw()
        messagebox.showerror("Missing library", f"Error: {e}\nPlease install:\npip install sounddevice mido pygame")
        sys.exit(1)
        
    root = tk.Tk()
    app = CNCStudioApp(root)
    root.mainloop()