Make MP3Gain options configurable

This commit is contained in:
Mikei386
2026-06-07 13:45:15 +02:00
parent 1b38395af9
commit a2aa4e36d2
3 changed files with 108 additions and 12 deletions
+6 -5
View File
@@ -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. - `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. - `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. - `ID3-Tags`: Bearbeitet MP3-Tags rekursiv auf einer SD-Karte oder in einem ausgewählten Ordner.
## Erwartete Input-Struktur ## Erwartete Input-Struktur
@@ -59,11 +59,12 @@ Die SD-Karten-Prüfung kontrolliert:
- macOS-Metadaten wie `.DS_Store` und `._*` - macOS-Metadaten wie `.DS_Store` und `._*`
- unerwartete Dateien oder Ordner im Hauptverzeichnis - 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 - `-r`: Track-Gain. Jede Datei wird auf gleiche Lautheit gebracht.
mp3gain -r -k -d 4 - `-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 <n>`: 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. 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.
+39 -3
View File
@@ -398,11 +398,22 @@ private struct MP3GainToolView: View {
@State private var targetURL: URL? @State private var targetURL: URL?
@State private var messages: [ExportMessage] = [] @State private var messages: [ExportMessage] = []
@State private var isWorking = false @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 { private var canRun: Bool {
targetURL != nil && !isWorking targetURL != nil && !isWorking
} }
private var options: MP3GainNormalizer.Options {
MP3GainNormalizer.Options(
gainMode: gainMode,
preventClipping: preventClipping,
dbOffset: dbOffset
)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(spacing: 10) { HStack(spacing: 10) {
@@ -434,10 +445,34 @@ private struct MP3GainToolView: View {
Text("MP3Gain Normalisierung") Text("MP3Gain Normalisierung")
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
PathRow(label: "Ordner", url: targetURL) PathRow(label: "Ordner", url: targetURL)
Text("Verwendet rekursiv: mp3gain -r -k -d 4") Text("Generierter Befehl: \(options.commandDescription)")
.foregroundStyle(.secondary) .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) { VStack(alignment: .leading, spacing: 10) {
Text("Protokoll") Text("Protokoll")
.font(.headline) .font(.headline)
@@ -455,10 +490,11 @@ private struct MP3GainToolView: View {
guard let targetURL else { return } guard let targetURL else { return }
isWorking = true isWorking = true
messages = [.info("MP3Gain gestartet: -r -k -d 4")] let currentOptions = options
messages = [.info("MP3Gain gestartet: \(currentOptions.commandDescription)")]
DispatchQueue.global(qos: .userInitiated).async { 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 { DispatchQueue.main.async {
messages = result messages = result
+63 -4
View File
@@ -426,17 +426,59 @@ struct SDCardCleaner {
} }
struct MP3GainNormalizer { 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 private let fileManager: FileManager
init(fileManager: FileManager) { init(fileManager: FileManager) {
self.fileManager = fileManager self.fileManager = fileManager
} }
func normalize(rootURL: URL) -> [ExportMessage] { func normalize(rootURL: URL, options: Options) -> [ExportMessage] {
guard let mp3gainURL = mp3gainExecutableURL() else { guard let mp3gainURL = mp3gainExecutableURL() else {
return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")] return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")]
} }
let validationMessages = validate(options: options)
if !validationMessages.isEmpty {
return validationMessages
}
do { do {
let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL) let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
guard !mp3Files.isEmpty else { guard !mp3Files.isEmpty else {
@@ -447,7 +489,7 @@ struct MP3GainNormalizer {
var processed = 0 var processed = 0
for batch in mp3Files.chunked(into: 50) { 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) messages.append(contentsOf: result.messages)
if result.success { if result.success {
processed += batch.count 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() let process = Process()
process.executableURL = executableURL 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 outputPipe = Pipe()
let errorPipe = Pipe() let errorPipe = Pipe()