Last active
June 17, 2019 20:49
-
-
Save iitalics/8841b43d5ac9a3ecb1664abfaa948bf5 to your computer and use it in GitHub Desktop.
Script for breaking up single-file album audio into multiple files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#lang racket/base | |
(require | |
racket/string | |
racket/port | |
racket/system | |
racket/match | |
racket/format) | |
(module+ test | |
(require | |
rackunit racket/port)) | |
;; ----------------------------------------------------------------------------- | |
;; Utils | |
(define-syntax-rule (regexp-case str-expr | |
[re formals body ...] | |
... | |
[#t else-body ...]) | |
(let ([str str-expr]) | |
(cond | |
[(regexp-match re str) | |
=> (λ (groups) | |
(apply (λ formals body ...) | |
(cdr groups)))] | |
... | |
[else else-body ...]))) | |
;; ----------------------------------------------------------------------------- | |
;; Parameters | |
(define current-get-info-file-path | |
(make-parameter | |
(λ (audio-file-path) | |
(path-replace-extension audio-file-path | |
#".info")))) | |
(define current-get-output-dir-path | |
(make-parameter | |
(λ (audio-file-path) | |
(path-replace-extension audio-file-path #"")))) | |
(define current-find-album-art-path | |
(make-parameter | |
(λ (audio-file-path) | |
(for*/first ([ext (in-list '(#".png" #".jpg" #".jpeg" #".tiff"))] | |
[path (in-value (path-replace-extension audio-file-path ext))] | |
#:when (file-exists? path)) | |
path)))) | |
(define current-output-extension | |
(make-parameter #".mp3")) | |
(define current-ffmpeg | |
(make-parameter "/usr/bin/ffmpeg")) | |
(define (get-info-file-path af) | |
((current-get-info-file-path) af)) | |
(define (get-output-dir-path af) | |
((current-get-output-dir-path) af)) | |
(define (find-album-art-path af) | |
((current-find-album-art-path) af)) | |
(define (path-add-output-extension p) | |
(path-add-extension p (current-output-extension) #".")) | |
(module+ test | |
(check-equal? (get-info-file-path "bar/foo.flac") | |
(string->path "bar/foo.info")) | |
(check-equal? (get-output-dir-path "bar/foo.mp3") | |
(string->path "bar/foo")) | |
(check-equal? (path-add-output-extension "bar/foo/one") | |
(string->path "bar/foo/one.mp3"))) | |
;; -------------------------------------------------------------------------------- | |
;; Info files | |
;; meta : (hasheq symbol => string) | |
;; tracks : (listof track-info) | |
(struct album-info [meta tracks] | |
#:transparent) | |
;; title : string | |
;; number start end : nat | |
(struct track-info [title number start end] | |
#:transparent) | |
;; (read-album-info [port]) -> album-info | |
;; port : input-port | |
(define (read-album-info [port (current-input-port)]) | |
;; #px"([0-9]+:)*[0-9]+" -> nat | |
(define (time-string->seconds ts) | |
(for/fold ([acc 0]) | |
([part (in-list (string-split ts ":"))]) | |
(+ (* acc 60) | |
(string->number part)))) | |
; parse lines | |
(define-values [tracks-unsorted meta] | |
(for/fold ([tracks '()] | |
[meta (hasheq)]) | |
([line (in-lines port)]) | |
(regexp-case | |
line | |
[#px"^\\*\\s+([a-z]*)\\s+(.*)" | |
(key value) | |
(define meta* (hash-set meta | |
(string->symbol key) | |
(string-trim value))) | |
(values tracks meta*)] | |
[#px"^([^*].+)\\s((\\d{1,2}:)+\\d{2})" | |
(title start _) | |
(define track (track-info (string-trim title) | |
#f ; number | |
(time-string->seconds start) ; start | |
#f)) ; end | |
(values (cons track tracks) meta)] | |
[#t | |
(values tracks meta)]))) | |
; sort tracks by starting time | |
(define tracks | |
(sort tracks-unsorted < | |
#:key track-info-start)) | |
; fill in track-info-number and track-info-end | |
(define tracks* | |
(for/list ([t (in-list tracks)] | |
[t* (in-list (append (cdr tracks) (list #f)))] | |
[i (in-naturals 1)]) | |
(struct-copy track-info t | |
[number i] | |
[end (and t* (track-info-start t*))]))) | |
(album-info meta tracks*)) | |
(module+ test | |
(define t1 (+ 23 (* 60 1))) | |
(define t2 (+ 45 (* 60 23) (* 60 60 1))) | |
(check-equal? | |
(with-input-from-string | |
(string-append "* foo hello world\n" | |
"SO NG 1:23\n" | |
"* bar hey \n" | |
"SAN G 01:23:45\n" | |
"S ING 0:00\n") | |
read-album-info) | |
(album-info | |
(hasheq 'foo "hello world" | |
'bar "hey") | |
(list (track-info "S ING" 1 0 t1) | |
(track-info "SO NG" 2 t1 t2) | |
(track-info "SAN G" 3 t2 #f))))) | |
;; -------------------------------------------------------------------------------- | |
;; String manip | |
;; track-title->file-part : string -> string | |
(define (track-title->file-part title-string) | |
; remove parenthesis and the text inside of them (doesn't support nesting, but that is | |
; okay) | |
(define (remove-parens s) | |
(regexp-case | |
s | |
[#px"^(.+)[(\\[].+?[)\\]](.*)" | |
(before after) | |
(remove-parens (string-append (string-trim before) | |
after))] | |
[#t s])) | |
; group into words (groups of alphabetic chars) | |
(define (split-words s) | |
(let loop ([acc '()] [chars (string->list s)]) | |
(cond | |
[(null? chars) (list (reverse acc))] | |
; important chars | |
[(or (char-alphabetic? (car chars)) | |
(char-numeric? (car chars))) | |
(loop (cons (car chars) acc) | |
(cdr chars))] | |
; ignored chars | |
[(member (car chars) '(#\')) | |
(loop acc | |
(cdr chars))] | |
; everything else breaks works | |
[else | |
(cons (reverse acc) | |
(loop '() (cdr chars)))]))) | |
; write chars to current-output-port in titlecase | |
(define (write-titlecase chars) | |
(unless (null? chars) | |
(write-char (char-upcase (car chars))) | |
(for ([c (in-list (cdr chars))]) | |
(write-char (char-downcase c))))) | |
(with-output-to-string | |
(λ () | |
(for-each write-titlecase | |
(split-words (remove-parens title-string)))))) | |
;; track-info -> path | |
(define (track-info-file-name trk) | |
(path-add-output-extension | |
(format "~a.~a" | |
(~a (track-info-number trk) | |
#:pad-string "0" #:width 2 #:align 'right) | |
(track-title->file-part (track-info-title trk))))) | |
(module+ test | |
(check-equal? | |
(track-title->file-part "foo (ft. somebody) bar") | |
"FooBar") | |
(check-equal? | |
(track-title->file-part "foo (ft. somebody) (remix)") | |
"Foo") | |
(check-equal? | |
(track-title->file-part "the, quick: brown fox!! (ft. somebody) [remix]") | |
"TheQuickBrownFox") | |
(check-equal? | |
(track-title->file-part "igor's theme") | |
"IgorsTheme") | |
(check-equal? | |
(track-info-file-name | |
(track-info "foo bar" 4 0 #f)) | |
(string->path "04.FooBar.mp3"))) | |
;; -------------------------------------------------------------------------------- | |
;; IO | |
(struct exn:fail:ffmpeg exn:fail [status output]) | |
(define (raise-ffmpeg-error sc out) | |
(make-exn:fail (format "ffmpeg process failed with status ~a" sc) | |
(current-continuation-marks) | |
sc out)) | |
;; (clip-audio ..) -> string | |
;; input-file-path, output-file-path : path | |
;; art-file-path : (or path #f) | |
;; start : nat | |
;; end : (or nat #f) | |
;; meta : (hasheq symbol => string) | |
;; -- | |
;; raises exn:fail:ffmpeg | |
(define (clip-audio #:in input-file-path | |
#:out output-file-path | |
#:art [art-file-path #f] | |
#:meta meta | |
start end) | |
(define (seconds->string n) | |
(format "~as" n)) | |
(define general-flags | |
`("-y")) | |
(define infile-flags | |
(append `("-ss" ,(seconds->string start)) | |
(if end | |
(list "-t" (seconds->string (- end start))) | |
(list)))) | |
(define outfile-flags | |
(append (apply append | |
(for/list ([(k v) (in-hash meta)]) | |
`("-metadata:g" ,(format "~a=~a" k v)))) | |
(list "-map" "0" | |
"-map" "1"))) | |
(define-values [sp sp-out sp-in sp-err] | |
(apply subprocess `(#f #f #f | |
,(current-ffmpeg) | |
,@general-flags | |
,@infile-flags | |
"-i" ,(path->string input-file-path) | |
,@(if art-file-path | |
(list "-i" (path->string art-file-path)) | |
(list)) | |
,@outfile-flags | |
,(path->string output-file-path)))) | |
(subprocess-wait sp) | |
(define sc (subprocess-status sp)) | |
(define out (string-append | |
(port->string sp-err) | |
(port->string sp-out))) | |
(if (zero? sc) | |
out | |
(raise-ffmpeg-error sc out))) | |
;; (format-tracks path) -> void | |
(define (format-tracks audio-file-path) | |
(define info-file-path (get-info-file-path audio-file-path)) | |
(define art-file-path (find-album-art-path audio-file-path)) | |
(define output-dir-path (get-output-dir-path audio-file-path)) | |
(unless (file-exists? audio-file-path) | |
(error (format "audio file ~s does not exist!" | |
(path->string audio-file-path)))) | |
(define alb | |
(with-handlers ([exn:fail:filesystem? | |
(λ (e) | |
(error (format "info file ~s does not exist!" | |
(path->string info-file-path))))]) | |
(with-input-from-file info-file-path | |
read-album-info))) | |
(with-handlers ([exn:fail:filesystem? | |
(λ (e) | |
(error (format "output directory ~s already exists!" | |
(path->string output-dir-path))))]) | |
(make-directory output-dir-path)) | |
;; ----- | |
(define notifs (make-channel)) | |
(when art-file-path | |
(printf "using album art: ~a\n" | |
(path->string art-file-path))) | |
(printf "processing ~a tracks for album ~s\n----------------------------------------\n" | |
(length (album-info-tracks alb)) | |
(hash-ref (album-info-meta alb) | |
'album | |
"(no title given)")) | |
(for ([trk (in-list (album-info-tracks alb))]) | |
(define dest-path | |
(build-path output-dir-path | |
(track-info-file-name trk))) | |
(define meta | |
(hash-set* (album-info-meta alb) | |
'track (number->string (track-info-number trk)) | |
'title (track-info-title trk))) | |
(thread | |
(λ () | |
(with-handlers ([exn:fail? | |
(λ (ex) (channel-put notifs `(failed ,trk ,ex)))]) | |
(clip-audio #:in audio-file-path | |
#:art art-file-path | |
#:out dest-path | |
#:meta meta | |
(track-info-start trk) | |
(track-info-end trk)) | |
(channel-put notifs `(ok ,trk)))))) | |
(let loop ([n 0]) | |
(when (< n (length (album-info-tracks alb))) | |
(match (channel-get notifs) | |
[`(failed ,trk ,ex) | |
(printf "FAILED: ~s\n" (track-info-title trk)) | |
(with-output-to-file "log.txt" | |
#:exists 'append | |
(λ () | |
(match ex | |
[(exn:fail:ffmpeg _ _ sc out) | |
(printf "STATUS CODE: ~a\n" sc) | |
(write-string out)] | |
[(exn msg _) | |
(write-string msg)]))) | |
(loop (add1 n))] | |
[`(ok ,trk) | |
(printf "finished ~s\n" (track-info-title trk)) | |
(loop (add1 n))]))) | |
(printf "----------------------------------------\n") | |
(printf "all finished\n")) | |
;; ============================================================================= | |
(module+ main | |
(require racket/cmdline) | |
(command-line | |
#:once-each | |
[("-d" "--info-file") | |
info-file "Info file (defaults to audio file, with extension `.info'" | |
(current-get-info-file-path (λ (_) (string->path info-file)))] | |
[("-i" "--output-dir") | |
info-file "Output directory (defaults to audio file with no extension" | |
(current-get-output-dir-path (λ (_) (string->path info-file)))] | |
[("-a" "--art") | |
art-file "Album art file" | |
(current-find-album-art-path (λ (_) (string->path art-file)))] | |
#:args (audio-file) | |
(format-tracks (string->path audio-file)))) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment