You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
13 KiB
320 lines
13 KiB
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("<h2>MP3 Merger</h2>", alignment=Qt.AlignmentFlag.AlignCenter))
|
|
|
|
# File selection
|
|
self.select_files_btn = QPushButton("Select MP3 Files")
|
|
self.select_files_btn.clicked.connect(self.select_files)
|
|
self.layout.addWidget(self.select_files_btn)
|
|
|
|
self.file_list = QListWidget()
|
|
self.layout.addWidget(self.file_list)
|
|
|
|
# Output directory
|
|
self.output_dir_layout = QHBoxLayout()
|
|
self.output_dir_label = QLabel("Output Directory:")
|
|
self.output_dir_edit = QLineEdit(os.getcwd())
|
|
self.output_dir_btn = QPushButton("Browse")
|
|
self.output_dir_btn.clicked.connect(self.select_output_dir)
|
|
self.output_dir_layout.addWidget(self.output_dir_label)
|
|
self.output_dir_layout.addWidget(self.output_dir_edit)
|
|
self.output_dir_layout.addWidget(self.output_dir_btn)
|
|
self.layout.addLayout(self.output_dir_layout)
|
|
|
|
# Output filename
|
|
self.output_file_label = QLabel("Output Filename:")
|
|
self.output_file_edit = QLineEdit("merged_output.mp3")
|
|
self.layout.addWidget(self.output_file_label)
|
|
self.layout.addWidget(self.output_file_edit)
|
|
|
|
# Zoom controls
|
|
self.zoom_layout = QHBoxLayout()
|
|
self.zoom_label = QLabel("Timeline Zoom:")
|
|
self.zoom_in_btn = QPushButton("+")
|
|
self.zoom_in_btn.clicked.connect(self.zoom_in)
|
|
self.zoom_out_btn = QPushButton("−")
|
|
self.zoom_out_btn.clicked.connect(self.zoom_out)
|
|
self.zoom_layout.addWidget(self.zoom_label)
|
|
self.zoom_layout.addWidget(self.zoom_in_btn)
|
|
self.zoom_layout.addWidget(self.zoom_out_btn)
|
|
self.zoom_layout.addStretch()
|
|
self.layout.addLayout(self.zoom_layout)
|
|
|
|
# Timeline (initially empty)
|
|
self.timeline_scroll = QScrollArea()
|
|
self.timeline_scroll.setWidgetResizable(True)
|
|
self.timeline_widget = TimelineWidget([], 0)
|
|
self.timeline_scroll.setWidget(self.timeline_widget)
|
|
self.timeline_scroll.setMinimumHeight(120)
|
|
self.layout.addWidget(self.timeline_scroll)
|
|
|
|
# Progress bar
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setValue(0)
|
|
self.layout.addWidget(self.progress_bar)
|
|
|
|
# Merge button
|
|
self.merge_btn = QPushButton("Merge MP3s")
|
|
self.merge_btn.clicked.connect(self.start_merge)
|
|
self.layout.addWidget(self.merge_btn)
|
|
|
|
# Status label
|
|
self.status_label = QLabel("")
|
|
self.status_label.setWordWrap(True)
|
|
self.layout.addWidget(self.status_label)
|
|
|
|
self.input_files = []
|
|
self.merge_thread = MergeThread([], "") # Dummy thread for parsing in select_files
|
|
|
|
def check_ffmpeg(self):
|
|
try:
|
|
subprocess.run(["ffmpeg", "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
|
|
return True
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return False
|
|
|
|
def select_files(self):
|
|
files, _ = QFileDialog.getOpenFileNames(self, "Select MP3 Files", "", "MP3 Files (*.mp3)")
|
|
if files:
|
|
self.input_files = files
|
|
self.file_list.clear()
|
|
for file in files:
|
|
self.file_list.addItem(os.path.basename(file))
|
|
# Update timeline with preliminary file info
|
|
try:
|
|
file_info = []
|
|
earliest_time = None
|
|
for file_path in files:
|
|
filename = os.path.basename(file_path)
|
|
timestamp = self.merge_thread.parse_timestamp(filename)
|
|
duration = self.merge_thread.get_audio_duration(file_path)
|
|
if earliest_time is None:
|
|
earliest_time = timestamp
|
|
file_info.append({
|
|
'filename': filename,
|
|
'timestamp': timestamp,
|
|
'duration': duration,
|
|
'start_offset': (timestamp - earliest_time).total_seconds()
|
|
})
|
|
total_duration = max(info['start_offset'] + info['duration'] for info in file_info)
|
|
self.timeline_widget = TimelineWidget(file_info, total_duration)
|
|
self.timeline_scroll.setWidget(self.timeline_widget)
|
|
except Exception as e:
|
|
self.status_label.setText(f"Error parsing files: {str(e)}")
|
|
|
|
def select_output_dir(self):
|
|
directory = QFileDialog.getExistingDirectory(self, "Select Output Directory")
|
|
if directory:
|
|
self.output_dir_edit.setText(directory)
|
|
|
|
def zoom_in(self):
|
|
self.timeline_widget.set_zoom(self.timeline_widget.zoom_factor * 1.5)
|
|
self.timeline_scroll.horizontalScrollBar().setValue(0) # Reset scroll position
|
|
|
|
def zoom_out(self):
|
|
self.timeline_widget.set_zoom(self.timeline_widget.zoom_factor / 1.5)
|
|
self.timeline_scroll.horizontalScrollBar().setValue(0) # Reset scroll position
|
|
|
|
def start_merge(self):
|
|
output_file = self.output_file_edit.text()
|
|
output_dir = self.output_dir_edit.text()
|
|
if not output_file.endswith('.mp3'):
|
|
QMessageBox.critical(self, "Error", "Output filename must end with .mp3")
|
|
return
|
|
if not os.path.isdir(output_dir):
|
|
QMessageBox.critical(self, "Error", "Invalid output directory")
|
|
return
|
|
output_path = os.path.join(output_dir, output_file)
|
|
self.status_label.setText("Merging MP3s...")
|
|
self.merge_btn.setEnabled(False)
|
|
|
|
# Start merge thread
|
|
self.merge_thread = MergeThread(self.input_files, output_path)
|
|
self.merge_thread.progress.connect(self.progress_bar.setValue)
|
|
self.merge_thread.status.connect(self.handle_error)
|
|
self.merge_thread.finished.connect(self.handle_finished)
|
|
self.merge_thread.start()
|
|
|
|
def handle_error(self, message):
|
|
self.status_label.setText(message)
|
|
QMessageBox.critical(self, "Error", message)
|
|
self.merge_btn.setEnabled(True)
|
|
self.progress_bar.setValue(0)
|
|
|
|
def handle_finished(self, message, file_info, total_duration):
|
|
self.status_label.setText(message)
|
|
QMessageBox.information(self, "Success", message)
|
|
self.merge_btn.setEnabled(True)
|
|
self.progress_bar.setValue(0)
|
|
# Update timeline with final file info
|
|
self.timeline_widget = TimelineWidget(file_info, total_duration)
|
|
self.timeline_scroll.setWidget(self.timeline_widget)
|
|
|
|
if __name__ == "__main__":
|
|
app = QApplication(sys.argv)
|
|
window = MP3MergerWindow()
|
|
window.show()
|
|
sys.exit(app.exec())
|
|
|