diff --git a/README.md b/README.md index 5715c7a..13e7d27 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFP - `Datenumwandlung`: Schreibt die TonUINO-Ordnerstruktur. MP3-Dateien je Folge können zu `001.mp3` zusammengeführt oder getrennt als `001.mp3`, `002.mp3`, ... exportiert werden. - `SD-Karte`: Prüft eine ausgewählte TonUINO-SD-Karte und führt `dot_clean` aus. -- `MP3Gain`: Wendet MP3Gain mit `-r -k -d 4` rekursiv auf alle MP3-Dateien in einem gewählten Ordner an. +- `MP3Gain`: Wendet MP3Gain rekursiv auf alle MP3-Dateien in einem gewählten Ordner an. Track-/Album-Modus, Clip-Schutz und dB-Offset sind konfigurierbar. - `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner. ## Erwartete Input-Struktur @@ -59,11 +59,12 @@ Die SD-Karten-Prüfung kontrolliert: - macOS-Metadaten wie `.DS_Store` und `._*` - unerwartete Dateien oder Ordner im Hauptverzeichnis -Die MP3Gain-Funktion setzt voraus, dass `mp3gain` installiert ist, z. B. unter `/opt/homebrew/bin/mp3gain`. Verwendet wird: +Die MP3Gain-Funktion setzt voraus, dass `mp3gain` installiert ist, z. B. unter `/opt/homebrew/bin/mp3gain`. -```bash -mp3gain -r -k -d 4 -``` +- `-r`: Track-Gain. Jede Datei wird auf gleiche Lautheit gebracht. +- `-a`: Album-Gain. Alle Dateien werden als Album behandelt; relative Lautheitsunterschiede bleiben erhalten. +- `-k`: senkt Gain automatisch ab, wenn sonst Clipping entstehen würde. +- `-d `: zusätzlicher dB-Offset auf den vorgeschlagenen Gain, z. B. `4`. Das ID3-Tag-Werkzeug zeigt vorhandene Tags im Protokoll an. Es kann alle ID3-Tags löschen oder gezielt Titel, Interpret, Album, Tracknummer, Jahr, Genre, Kommentar und eingebettete Cover/Bilder entfernen. Optional wird anschließend ein neuer Kommentar in die MP3-Dateien geschrieben. Die Tag-Suche findet MP3-Dateien über ID3v2-/ID3v1-Titel, Album und Künstler und zeigt den Ordner der Datei im Protokoll. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9649688..aadf172 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -398,11 +398,22 @@ private struct MP3GainToolView: View { @State private var targetURL: URL? @State private var messages: [ExportMessage] = [] @State private var isWorking = false + @State private var gainMode: MP3GainNormalizer.Options.GainMode = .track + @State private var preventClipping = true + @State private var dbOffset = "4" private var canRun: Bool { targetURL != nil && !isWorking } + private var options: MP3GainNormalizer.Options { + MP3GainNormalizer.Options( + gainMode: gainMode, + preventClipping: preventClipping, + dbOffset: dbOffset + ) + } + var body: some View { VStack(spacing: 0) { HStack(spacing: 10) { @@ -434,10 +445,34 @@ private struct MP3GainToolView: View { Text("MP3Gain Normalisierung") .font(.title2.weight(.semibold)) PathRow(label: "Ordner", url: targetURL) - Text("Verwendet rekursiv: mp3gain -r -k -d 4") + Text("Generierter Befehl: \(options.commandDescription)") .foregroundStyle(.secondary) } + VStack(alignment: .leading, spacing: 10) { + Text("Optionen") + .font(.headline) + + Picker("Modus", selection: $gainMode) { + Text("Track-Gain (-r)").tag(MP3GainNormalizer.Options.GainMode.track) + Text("Album-Gain (-a)").tag(MP3GainNormalizer.Options.GainMode.album) + } + .pickerStyle(.segmented) + .frame(width: 300) + + Toggle("Clipping vermeiden (-k)", isOn: $preventClipping) + .toggleStyle(.checkbox) + + HStack(spacing: 8) { + Text("dB-Offset (-d)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + TextField("", text: $dbOffset) + .textFieldStyle(.roundedBorder) + .frame(width: 80) + } + } + VStack(alignment: .leading, spacing: 10) { Text("Protokoll") .font(.headline) @@ -455,10 +490,11 @@ private struct MP3GainToolView: View { guard let targetURL else { return } isWorking = true - messages = [.info("MP3Gain gestartet: -r -k -d 4")] + let currentOptions = options + messages = [.info("MP3Gain gestartet: \(currentOptions.commandDescription)")] DispatchQueue.global(qos: .userInitiated).async { - let result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: targetURL) + let result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: targetURL, options: currentOptions) DispatchQueue.main.async { messages = result diff --git a/Sources/TonUinoLogic.swift b/Sources/TonUinoLogic.swift index eaa5f0f..1e53804 100644 --- a/Sources/TonUinoLogic.swift +++ b/Sources/TonUinoLogic.swift @@ -426,17 +426,59 @@ struct SDCardCleaner { } struct MP3GainNormalizer { + struct Options { + enum GainMode: Hashable { + case track + case album + } + + let gainMode: GainMode + let preventClipping: Bool + let dbOffset: String + + var arguments: [String] { + var result: [String] = [] + + switch gainMode { + case .track: + result.append("-r") + case .album: + result.append("-a") + } + + if preventClipping { + result.append("-k") + } + + let trimmedOffset = dbOffset.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedOffset.isEmpty { + result.append(contentsOf: ["-d", trimmedOffset.replacingOccurrences(of: ",", with: ".")]) + } + + return result + } + + var commandDescription: String { + (["mp3gain"] + arguments).joined(separator: " ") + } + } + private let fileManager: FileManager init(fileManager: FileManager) { self.fileManager = fileManager } - func normalize(rootURL: URL) -> [ExportMessage] { + func normalize(rootURL: URL, options: Options) -> [ExportMessage] { guard let mp3gainURL = mp3gainExecutableURL() else { return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")] } + let validationMessages = validate(options: options) + if !validationMessages.isEmpty { + return validationMessages + } + do { let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) guard !mp3Files.isEmpty else { @@ -447,7 +489,7 @@ struct MP3GainNormalizer { var processed = 0 for batch in mp3Files.chunked(into: 50) { - let result = runMP3Gain(executableURL: mp3gainURL, files: batch) + let result = runMP3Gain(executableURL: mp3gainURL, options: options, files: batch) messages.append(contentsOf: result.messages) if result.success { processed += batch.count @@ -461,10 +503,27 @@ struct MP3GainNormalizer { } } - private func runMP3Gain(executableURL: URL, files: [URL]) -> (success: Bool, messages: [ExportMessage]) { + private func validate(options: Options) -> [ExportMessage] { + let trimmedOffset = options.dbOffset.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedOffset.isEmpty else { + return [] + } + + guard Double(trimmedOffset.replacingOccurrences(of: ",", with: ".")) != nil else { + return [.error("dB-Offset muss eine Zahl sein, z. B. 4 oder -2.5.")] + } + + return [] + } + + private func runMP3Gain( + executableURL: URL, + options: Options, + files: [URL] + ) -> (success: Bool, messages: [ExportMessage]) { let process = Process() process.executableURL = executableURL - process.arguments = ["-r", "-k", "-d", "4"] + files.map { $0.path(percentEncoded: false) } + process.arguments = options.arguments + files.map { $0.path(percentEncoded: false) } let outputPipe = Pipe() let errorPipe = Pipe()