Eine Desktop-Anwendung, mit Python Tkinter programmiert. Sie übersetzt Noten und Audio-Frequenzen in G-Code, um Schrittmotoren einer CNC-Maschine eine Melodie spielen zu lassen.
Konfiguration der Achsen und direkte Live-Audio-Simulation, um den G-Code vorab zu testen.
Liest MIDI-Dateien ein und wandelt Akkorde in CNC-taugliche Einzelnoten um.
Überwacht eingehende Signale von angeschlossenen MIDI-Keyboards in Echtzeit und speichert sie für die spätere Umwandlung.
#!/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()