import tkinter as tk from tkinter import filedialog, messagebox, simpledialog from mutagen.mp3 import MP3, EasyMP3 import base64 import tempfile import json import os import pygame from mutagen.id3 import ID3, APIC class LevelDesigner: def __init__(self, root): self.root = root self.root.title("Level Designer") initial_width = 600 initial_height = 400 self.root.geometry( f"{initial_width}x{initial_height}" ) # Set initial window size self.root.minsize(width=initial_width, height=initial_height) self.song_path = "" self.notes = [] self.select_button = tk.Button( root, text="Select Song", command=self.select_song ) self.select_button.pack() self.canvas_frame = tk.Frame(root) self.canvas_frame.pack(fill="both", expand=True) self.canvas = tk.Canvas(self.canvas_frame, bg="white") self.canvas.pack(side="top", fill="both", expand=True) self.h_scrollbar = tk.Scrollbar( self.canvas_frame, orient="horizontal", command=self.canvas.xview ) self.h_scrollbar.pack(side="bottom", fill="x") self.canvas.configure(xscrollcommand=self.h_scrollbar.set) self.canvas.bind("", self.add_note) self.canvas.bind("", self.delete_note) self.root.bind("", self.auto_add_note) self.save_button_visible = False self.save_button_height = 0 self.arrows_visible = False self.save_button = tk.Button(root, text="Save Level", command=self.save_level) self.save_button.pack() self.save_button_height = self.save_button.winfo_height() self.save_button.pack_forget() self.arrow_buttons_frame = tk.Frame(root) self.arrow_buttons_frame.pack() self.song = None self.meta = None self.left_button = tk.Button( self.arrow_buttons_frame, text="←", command=lambda: self.select_arrow("left"), bg="red", ) self.left_button.pack(side="left") self.up_button = tk.Button( self.arrow_buttons_frame, text="↑", command=lambda: self.select_arrow("up"), bg="green", ) self.up_button.pack(side="left") self.down_button = tk.Button( self.arrow_buttons_frame, text="↓", command=lambda: self.select_arrow("down"), bg="blue", ) self.down_button.pack(side="left") self.right_button = tk.Button( self.arrow_buttons_frame, text="→", command=lambda: self.select_arrow("right"), bg="yellow", ) self.right_button.pack(side="left") self.canvas_frame.bind("", self.update_canvas_height) self.canvas.bind("", self.redraw_notes) self.add_controls() self.current_pos: float = 0.0 pygame.mixer.init() self.song_playing = False def init_song(self): if self.song_path: pygame.mixer.music.load(self.song_path) self.song_playing = False self.cursor = None def update_cursor(self): self.canvas.delete("cursor") if self.song_playing: current_time = self.current_pos + pygame.mixer.music.get_pos() / 1000.0 else: current_time = self.current_pos x = current_time / self.song_length * self.canvas_width canvas_width = self.canvas.winfo_width() # Get current canvas width visible_x1 = self.canvas.canvasx(0) # Get the leftmost visible x coordinate visible_x2 = visible_x1 + canvas_width # Get the rightmost visible x coordinate # # Calculate the new scroll region based on the cursor's position if x < visible_x1 + 50: # If cursor is about to leave from the left new_x1 = max(0, x - canvas_width / 2) self.canvas.xview_moveto(new_x1 / self.canvas_width) elif x > visible_x2 - 50: # If cursor is about to leave from the right new_x1 = max(0, x - canvas_width / 2) self.canvas.xview_moveto(new_x1 / self.canvas_width) # Draw the cursor self.cursor = self.canvas.create_line( x, 0, x, self.canvas.winfo_height(), fill="red", tags="cursor" ) if self.song_playing: self.root.after(100, self.update_cursor) def play_pause_song(self): if self.song_playing: print("Paused") pygame.mixer.music.pause() self.current_pos += pygame.mixer.music.get_pos() / 1000.0 self.song_playing = False else: print("Unpaused") pygame.mixer.music.play(start=self.current_pos) self.song_playing = True self.update_cursor() def move_cursor(self, amount): if self.song_playing: self.current_pos += pygame.mixer.music.get_pos() / 1000.0 self.current_pos = max(0, min(self.current_pos + amount, self.song_length)) pygame.mixer.music.play(start=self.current_pos) else: self.current_pos = max(0, min(self.current_pos + amount, self.song_length)) self.update_cursor() def backward_1_second(self): self.move_cursor(-1) def backward_half_second(self): self.move_cursor(-0.5) def forward_1_second(self): self.move_cursor(1) def forward_half_second(self): self.move_cursor(0.5) def backward_5_seconds(self): self.move_cursor(-5) def forward_5_seconds(self): self.move_cursor(5) def add_controls(self): control_frame = tk.Frame(self.root) control_frame.pack() backward_half_second_button = tk.Button( control_frame, text="<< 0.5s", command=self.backward_half_second ) backward_half_second_button.pack(side="left") backward_1_second_button = tk.Button( control_frame, text="<< 1s", command=self.backward_1_second ) backward_1_second_button.pack(side="left") backward_button = tk.Button( control_frame, text="<< 5s", command=self.backward_5_seconds ) backward_button.pack(side="left") play_pause_button = tk.Button( control_frame, text="PP", command=self.play_pause_song ) play_pause_button.pack(side="left") forward_button = tk.Button( control_frame, text=">> 5s", command=self.forward_5_seconds ) forward_button.pack(side="left") forward_1_second_button = tk.Button( control_frame, text=">> 1s", command=self.forward_1_second ) forward_1_second_button.pack(side="left") forward_half_second_button = tk.Button( control_frame, text=">> 0.5s", command=self.forward_half_second ) forward_half_second_button.pack(side="left") def get_canvas_width(self): canvas_width = self.canvas.winfo_width() return canvas_width def redraw_notes(self, event): self.canvas.delete("notes") canvas_height = ( self.canvas_frame.winfo_height() - self.arrow_buttons_frame.winfo_height() ) self.canvas_height = canvas_height # Store canvas height for future use for note_time, arrow in self.notes: x = ( note_time / float(MP3(self.song_path).info.length) * self.get_canvas_width() ) color = self.get_arrow_color(arrow) self.canvas.create_rectangle( x - 2, 0, x + 2, self.canvas_height, fill=color, tags="notes" ) def update_canvas_height(self, event): canvas_height = ( self.canvas_frame.winfo_height() - self.arrow_buttons_frame.winfo_height() - self.save_button_height ) self.canvas.config(height=canvas_height) self.generate_visualizations() def select_song(self): file_path = filedialog.askopenfilename( filetypes=[("MP3 files", "*.mp3"), ("RHYS files", "*.rhys")] ) if file_path: self.song_path = file_path # Assign the song path here if file_path.endswith(".rhys"): self.load_rhys_file(file_path) self.init_song() self.generate_visualizations() self.save_button.pack() self.arrow_buttons_frame.pack() def generate_visualizations(self): audio = MP3(self.song_path) meta = EasyMP3(self.song_path) song_length = audio.info.length song_name = meta["title"][0] song_artist = meta["artist"][0] self.song_length = song_length self.canvas.delete("all") mins = int(song_length / 60) secs = song_length % 60 self.canvas.create_text( 20, 10, text=f"{song_name}", anchor="nw", font=("Arial", 12) ) self.canvas.create_text( 20, 25, text=f"By: {song_artist}", anchor="nw", font=("Arial", 12) ) self.canvas.create_text( 20, 40, text=f"Song Length: {mins}m:{secs}:s", anchor="nw", font=("Arial", 12), ) canvas_height = ( self.canvas_frame.winfo_height() - self.arrow_buttons_frame.winfo_height() ) self.canvas.config(height=canvas_height) self.canvas_width = song_length * 100 self.canvas.config(scrollregion=(0, 0, self.canvas_width, canvas_height)) for sec in range(int(song_length) + 1): x = sec * 100 self.canvas.create_line(x, 0, x, canvas_height, fill="gray") if sec % 5 == 0: self.canvas.create_text( x + 10, canvas_height - 20, text=f"{sec}s", font=("Arial", 10), anchor="nw", ) elif sec % 1 == 0: self.canvas.create_line(x, 0, x, canvas_height, fill="lightgray") self.update_cursor() def load_rhys_file(self, file_path): with open(file_path, "r") as f: data = json.load(f) encoded_audio = data["audio"] self.notes = data["notes"] # Decode the base64 audio and save it temporarily decoded_audio = base64.b64decode(encoded_audio) temp_audio_file = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") temp_audio_file.write(decoded_audio) temp_audio_file.close() self.song_path = ( temp_audio_file.name ) # Update the song_path to the temporary file self.generate_visualizations() for note_time, arrow in self.notes: x = note_time * 100 color = self.get_arrow_color(arrow) self.canvas.create_rectangle(x - 2, 0, x + 2, 200, fill=color) def auto_add_note(self, event): print("Auto add") if self.song_playing: current_time = self.current_pos + pygame.mixer.music.get_pos() / 1000.0 else: current_time = self.current_pos x = current_time / self.song_length * self.canvas_width time = x / self.get_canvas_width() * float(MP3(self.song_path).info.length) self.notes.append((time, self.selected_arrow)) color = self.get_arrow_color(self.selected_arrow) self.canvas.create_rectangle( x - 2, 0, x + 2, self.canvas_frame.winfo_height(), fill=color, tags=f"{time}", ) def add_note(self, event): x = self.canvas.canvasx( event.x ) # Adjust x coordinate based on canvas scroll position time = x / self.get_canvas_width() * float(MP3(self.song_path).info.length) self.notes.append((time, self.selected_arrow)) color = self.get_arrow_color(self.selected_arrow) self.canvas.create_rectangle( x - 2, 0, x + 2, self.canvas_frame.winfo_height(), fill=color, tags=f"{time}", ) def delete_note(self, event): x = self.canvas.canvasx( event.x ) # Adjust x coordinate based on canvas scroll position canvas_width = self.get_canvas_width() closest_note = None closest_distance = float("inf") delta = 4 # Find the closest note to the mouse click for note_time, _ in self.notes: note_x = note_time / float(MP3(self.song_path).info.length) * canvas_width distance = abs(note_x - x) if distance < closest_distance and distance <= delta: closest_note = (note_time, _) self.canvas.delete(f"{note_time}") closest_distance = distance if closest_note: self.notes.remove(closest_note) self.redraw_notes(event) def select_arrow(self, direction): self.selected_arrow = direction def get_arrow_color(self, direction): if direction == "up": return "green" elif direction == "down": return "blue" elif direction == "left": return "red" elif direction == "right": return "yellow" def save_from_mp3(self): encoded_audio = "" with open(self.song_path, "rb") as f: encoded_audio = base64.b64encode(f.read()).decode("utf-8") # Get metadata information audio = EasyMP3(self.song_path) artist = audio.get("artist", [""])[0] album = audio.get("album", [""])[0] title = audio.get("title", [""])[0] track_time = int(audio.info.length) # Fallback to MP3 and ID3 to get cover art mp3_audio = MP3(self.song_path, ID3=ID3) cover_art_data = "" cover_art_filename = "" # Extract cover art for tag in mp3_audio.tags.values(): if isinstance(tag, APIC): cover_art_data = base64.b64encode(tag.data).decode("utf-8") cover_art_filename = ( f"{os.path.splitext(os.path.basename(self.song_path))[0]}_cover.jpg" ) formatted_notes = [{"t": note[0], "a": note[1]} for note in self.notes] # Fail-safe for missing metadata if not artist: artist = "Unknown Artist" if not album: album = "Unknown Album" if not title: title = "Unknown Track" song_data = { "notes": formatted_notes, "artist": artist, "album": album, "title": title, "track_time": track_time, "audio": encoded_audio, "cover_art": cover_art_data, } file_name = filedialog.asksaveasfilename( parent=root, title="Save as rhys:", filetypes=[("RHYS files", "*.rhys")] ) if file_name: with open(f"{file_name}", "w", encoding="utf-8") as f: json.dump(song_data, f) print(f"Level saved to {file_name}") messagebox.showinfo("Success", f"Level saved to {file_name}") else: messagebox.showerror("Error", "You must provide a filename") def update_rhys(self): with open(self.song_path, "r+", encoding="utf-8") as f: data = json.load(f) data["notes"] = self.notes f.seek(0) json.dump(data, f) f.truncate() print("Notes added to rhys file") def save_level(self): if self.song_path: file_extension = os.path.splitext(self.song_path)[1] if file_extension == ".mp3": self.save_from_mp3() elif file_extension == ".rhys": self.update_rhys() root = tk.Tk() app = LevelDesigner(root) root.mainloop()