diff --git a/mp3_merger.py b/mp3_merger.py new file mode 100644 index 0000000..dac3a6f --- /dev/null +++ b/mp3_merger.py @@ -0,0 +1,320 @@ +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("