Add MP3Gain normalization tool
This commit is contained in:
@@ -5,7 +5,7 @@ Kleines macOS-SwiftUI-Tool zum Vorbereiten von Hörspielordnern für TonUINO/DFP
|
|||||||
## Werkzeuge
|
## Werkzeuge
|
||||||
|
|
||||||
- `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, um macOS-Metadaten-Dateien zu bereinigen.
|
- `SD-Karte`: Prüft eine ausgewählte TonUINO-SD-Karte, führt `dot_clean` aus und kann MP3Gain mit `-r -k -d 4` auf alle MP3-Dateien anwenden.
|
||||||
- `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
|
||||||
@@ -58,6 +58,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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mp3gain -r -k -d 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.
|
||||||
|
|
||||||
## Starten
|
## Starten
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ private struct SDCardToolView: View {
|
|||||||
}
|
}
|
||||||
.disabled(!canRun)
|
.disabled(!canRun)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
normalizeMP3Gain()
|
||||||
|
} label: {
|
||||||
|
Label("MP3Gain", systemImage: "waveform")
|
||||||
|
}
|
||||||
|
.disabled(!canRun)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
cleanSDCard()
|
cleanSDCard()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -386,6 +393,23 @@ private struct SDCardToolView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func normalizeMP3Gain() {
|
||||||
|
guard let sdCardURL else { return }
|
||||||
|
|
||||||
|
isWorking = true
|
||||||
|
messages = [.info("MP3Gain gestartet: -r -k -d 4")]
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
var result = MP3GainNormalizer(fileManager: .default).normalize(rootURL: sdCardURL)
|
||||||
|
result.append(contentsOf: SDCardValidator(fileManager: .default).validate(rootURL: sdCardURL))
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
messages = result
|
||||||
|
isWorking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ID3TagToolView: View {
|
private struct ID3TagToolView: View {
|
||||||
|
|||||||
@@ -425,6 +425,93 @@ struct SDCardCleaner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MP3GainNormalizer {
|
||||||
|
private let fileManager: FileManager
|
||||||
|
|
||||||
|
init(fileManager: FileManager) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalize(rootURL: URL) -> [ExportMessage] {
|
||||||
|
guard let mp3gainURL = mp3gainExecutableURL() else {
|
||||||
|
return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")]
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
|
||||||
|
guard !mp3Files.isEmpty else {
|
||||||
|
return [.warning("Keine MP3-Dateien für MP3Gain gefunden.")]
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages: [ExportMessage] = []
|
||||||
|
var processed = 0
|
||||||
|
|
||||||
|
for batch in mp3Files.chunked(into: 50) {
|
||||||
|
let result = runMP3Gain(executableURL: mp3gainURL, files: batch)
|
||||||
|
messages.append(contentsOf: result.messages)
|
||||||
|
if result.success {
|
||||||
|
processed += batch.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.insert(.info("MP3Gain abgeschlossen: \(processed) von \(mp3Files.count) Datei(en) verarbeitet."), at: 0)
|
||||||
|
return messages
|
||||||
|
} catch {
|
||||||
|
return [.error("MP3Gain-Dateisuche fehlgeschlagen: \(error.localizedDescription)")]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runMP3Gain(executableURL: URL, files: [URL]) -> (success: Bool, messages: [ExportMessage]) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = executableURL
|
||||||
|
process.arguments = ["-r", "-k", "-d", "4"] + files.map { $0.path(percentEncoded: false) }
|
||||||
|
|
||||||
|
let outputPipe = Pipe()
|
||||||
|
let errorPipe = Pipe()
|
||||||
|
process.standardOutput = outputPipe
|
||||||
|
process.standardError = errorPipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
} catch {
|
||||||
|
return (false, [.error("mp3gain konnte nicht gestartet werden: \(error.localizedDescription)")])
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = outputPipe.readString()
|
||||||
|
let errorOutput = errorPipe.readString()
|
||||||
|
var messages: [ExportMessage] = []
|
||||||
|
|
||||||
|
if process.terminationStatus == 0 {
|
||||||
|
messages.append(.info("MP3Gain Batch verarbeitet: \(files.count) Datei(en)"))
|
||||||
|
} else {
|
||||||
|
messages.append(.error("MP3Gain beendet mit Code \(process.terminationStatus)."))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !output.isEmpty {
|
||||||
|
messages.append(.info(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errorOutput.isEmpty {
|
||||||
|
messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (process.terminationStatus == 0, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mp3gainExecutableURL() -> URL? {
|
||||||
|
let candidates = [
|
||||||
|
"/opt/homebrew/bin/mp3gain",
|
||||||
|
"/usr/local/bin/mp3gain",
|
||||||
|
"/usr/bin/mp3gain"
|
||||||
|
]
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.map(URL.init(fileURLWithPath:))
|
||||||
|
.first { fileManager.isExecutableFile(atPath: $0.path(percentEncoded: false)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SDCardValidator {
|
struct SDCardValidator {
|
||||||
private let fileManager: FileManager
|
private let fileManager: FileManager
|
||||||
|
|
||||||
@@ -1253,6 +1340,18 @@ private extension Array where Element == ExportMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
func chunked(into size: Int) -> [[Element]] {
|
||||||
|
guard size > 0 else {
|
||||||
|
return [self]
|
||||||
|
}
|
||||||
|
|
||||||
|
return stride(from: 0, to: count, by: size).map {
|
||||||
|
Array(self[$0..<Swift.min($0 + size, count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension Array where Element == URL {
|
private extension Array where Element == URL {
|
||||||
func sortedByLocalizedName() -> [URL] {
|
func sortedByLocalizedName() -> [URL] {
|
||||||
sorted {
|
sorted {
|
||||||
|
|||||||
Reference in New Issue
Block a user