from pydub import AudioSegment import os from datetime import datetime import re import sys from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QFileDialog, QListWidget, QLineEdit, QLabel, QProgressBar, QMessageBox, QScrollArea) from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QPainter, QColor import subprocess class TimelineWidget(QWidget): def __init__(self, file_info, total_duration, parent=None): super().__init__(parent) self.file_info = file_info # List of dicts with filename, timestamp, duration self.total_duration = total_duration # Total duration in seconds self.zoom_factor = 1.0 # Default zoom (1.0 = full timeline fits window) self.setMinimumHeight(100) def set_zoom(self, zoom_factor): self.zoom_factor = max(0.1, min(zoom_factor, 10.0)) # Limit zoom range # Adjust widget size for scrolling if self.total_duration > 0: self.setMinimumWidth(int(self.total_duration * self.zoom_factor * 10)) # Pixels per second self.update() def paintEvent(self, event): painter = QPainter(self) width = self.width() height = self.height() if not self.file_info or self.total_duration <= 0: return # Scale factor: pixels per second, adjusted by zoom visible_duration = self.total_duration / self.zoom_factor scale = width / visible_duration if visible_duration > 0 else 1 painter.setPen(Qt.PenStyle.SolidLine) painter.setBrush(QColor(100, 150, 255)) # Draw each file as a rectangle for info in self.file_info: start_offset = info['start_offset'] duration = info['duration'] x = int(start_offset * scale) w = max(int(duration * scale), 2) # Ensure minimum width painter.drawRect(x, 20, w, 30) # Draw filename and timestamp timestamp_str = f"{info['filename']} ({start_offset:.1f}s)" painter.drawText(x, 15, timestamp_str) # Draw time markers (adjust interval based on zoom) painter.setPen(QColor(0, 0, 0)) marker_interval = max(60 / self.zoom_factor, 10) # Seconds between markers for t in range(0, int(self.total_duration), int(marker_interval)): x = int(t * scale) painter.drawLine(x, 50, x, 60) painter.drawText(x, 65, f"{t}s") class MergeThread(QThread): progress = pyqtSignal(int) status = pyqtSignal(str) finished = pyqtSignal(str, list, float) # Includes file_info and total_duration def __init__(self, input_files, output_path): super().__init__() self.input_files = input_files self.output_path = output_path def parse_timestamp(self, filename): match = re.match(r'rec_(\d{2})-(\d{2})-(\d{4})_(\d{2})(\d{2})(\d{2})\.mp3', filename) if not match: raise ValueError(f"Invalid filename format: {filename}") month, day, year, hour, minute, second = map(int, match.groups()) return datetime(year, month, day, hour, minute, second) def get_audio_duration(self, file_path): audio = AudioSegment.from_mp3(file_path) return len(audio) / 1000.0 def run(self): try: if not self.input_files: self.status.emit("Error: No MP3 files selected") return # Parse and sort files file_info = [] for file_path in self.input_files: filename = os.path.basename(file_path) timestamp = self.parse_timestamp(filename) duration = self.get_audio_duration(file_path) file_info.append({ 'filename': filename, 'timestamp': timestamp, 'duration': duration, 'path': file_path }) file_info.sort(key=lambda x: x['timestamp']) # Calculate total duration and start offsets earliest_time = file_info[0]['timestamp'] total_duration = 0 for info in file_info: info['start_offset'] = (info['timestamp'] - earliest_time).total_seconds() end_time = info['start_offset'] + info['duration'] total_duration = max(total_duration, end_time) # Create output audio total_duration_ms = int(total_duration * 1000) output_audio = AudioSegment.silent(duration=total_duration_ms) # Overlay audio files with progress updates total_files = len(file_info) for i, info in enumerate(file_info): audio = AudioSegment.from_mp3(info['path']) offset_ms = int(info['start_offset'] * 1000) output_audio = output_audio.overlay(audio, position=offset_ms) self.progress.emit(int((i + 1) / total_files * 100)) # Save output output_audio.export(self.output_path, format='mp3') # Generate .cue file cue_path = os.path.splitext(self.output_path)[0] + '.cue' with open(cue_path, 'w') as f: f.write(f'FILE "{os.path.basename(self.output_path)}" MP3\n') for i, info in enumerate(file_info): minutes = int(info['start_offset'] // 60) seconds = int(info['start_offset'] % 60) frames = int((info['start_offset'] % 1) * 75) # CD frames (75 per second) f.write(f'TRACK {i+1:02d} AUDIO\n') f.write(f' TITLE "{info["filename"]}"\n') f.write(f' INDEX 01 {minutes:02d}:{seconds:02d}:{frames:02d}\n') # Optional: Generate text file with timestamps # txt_path = os.path.splitext(self.output_path)[0] + '.txt' # with open(txt_path, 'w') as f: # for info in file_info: # f.write(f"{info['filename']}: {info['start_offset']:.1f}s\n") self.finished.emit(f"Merged MP3 saved as {self.output_path}\nCUE file saved as {cue_path}", file_info, total_duration) except Exception as e: self.status.emit(f"Error: {str(e)}") class MP3MergerWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("MP3 Merger") self.setMinimumSize(600, 500) # Allow resizing self.resize(800, 600) # Default size # Check for ffmpeg if not self.check_ffmpeg(): QMessageBox.critical(self, "Error", "ffmpeg is not installed or not found. Please install it using 'sudo pacman -S ffmpeg'.") sys.exit(1) # Main widget and layout self.central_widget = QWidget() self.setCentralWidget(self.central_widget) self.layout = QVBoxLayout(self.central_widget) # Title self.layout.addWidget(QLabel("