add find-and-enflac pcm compressor
This commit is contained in:
211
find-and-enflac/encode.go
Normal file
211
find-and-enflac/encode.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Container extensions holding uncompressed PCM. Raw headerless .pcm is
|
||||
// excluded on purpose: sample rate, width and channel count cannot be
|
||||
// recovered from the file itself.
|
||||
var pcmExts = map[string]bool{
|
||||
".wav": true,
|
||||
".wave": true,
|
||||
".bwf": true,
|
||||
".rf64": true,
|
||||
".bw64": true,
|
||||
".w64": true,
|
||||
".aif": true,
|
||||
".aiff": true,
|
||||
".aifc": true,
|
||||
}
|
||||
|
||||
// findPCM returns every regular file under root whose extension names a
|
||||
// PCM container. Symlinks are not followed.
|
||||
func findPCM(root string) ([]string, error) {
|
||||
var found []string
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if pcmExts[strings.ToLower(filepath.Ext(path))] {
|
||||
found = append(found, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Strings(found)
|
||||
return found, nil
|
||||
}
|
||||
|
||||
type encoder struct {
|
||||
threads int
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
// encodeOne compresses src, verifies the result decodes back to
|
||||
// identical audio, and only then removes src.
|
||||
func (e *encoder) encodeOne(ctx context.Context, src string) error {
|
||||
codecName, err := probeCodec(ctx, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var dst, codec, level string
|
||||
switch {
|
||||
case strings.HasPrefix(codecName, "pcm_f32"), strings.HasPrefix(codecName, "pcm_f64"):
|
||||
dst, codec, level = replaceExt(src, ".wv"), "wavpack", "3"
|
||||
case strings.HasPrefix(codecName, "pcm_"):
|
||||
dst, codec, level = replaceExt(src, ".flac"), "flac", "12"
|
||||
default:
|
||||
slog.Warn("skipping, not PCM", "src", src, "codec", codecName)
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", dst)
|
||||
}
|
||||
|
||||
if e.dryRun {
|
||||
slog.Info("would encode", "src", src, "dst", dst, "codec", codec)
|
||||
return nil
|
||||
}
|
||||
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ffmpeg(ctx,
|
||||
"-threads", fmt.Sprint(e.threads),
|
||||
"-i", src,
|
||||
"-map", "0:a:0",
|
||||
"-c:a", codec,
|
||||
"-compression_level", level,
|
||||
dst,
|
||||
); err != nil {
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
|
||||
srcSum, err := audioMD5(ctx, src)
|
||||
if err != nil {
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
dstSum, err := audioMD5(ctx, dst)
|
||||
if err != nil {
|
||||
os.Remove(dst)
|
||||
return err
|
||||
}
|
||||
if srcSum != dstSum {
|
||||
os.Remove(dst)
|
||||
return fmt.Errorf("verification failed, original kept: %s != %s", srcSum, dstSum)
|
||||
}
|
||||
|
||||
dstInfo, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("encoded",
|
||||
"src", src,
|
||||
"dst", dst,
|
||||
"ratio", fmt.Sprintf("%.3f", float64(dstInfo.Size())/float64(srcInfo.Size())),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeCodec reports the codec of the file's first audio stream, e.g.
|
||||
// pcm_s24le or pcm_f32le.
|
||||
func probeCodec(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "ffprobe",
|
||||
"-v", "error",
|
||||
"-select_streams", "a:0",
|
||||
"-show_entries", "stream=codec_name",
|
||||
"-of", "default=nw=1:nokey=1",
|
||||
path,
|
||||
)
|
||||
cmd.Stdin = nil
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("ffprobe: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
codec := strings.TrimSpace(stdout.String())
|
||||
if codec == "" {
|
||||
return "", fmt.Errorf("no audio stream")
|
||||
}
|
||||
return codec, nil
|
||||
}
|
||||
|
||||
// audioMD5 hashes the decoded audio, forced to 64-bit float so that
|
||||
// neither 32-bit integer nor 32-bit float samples lose anything on the
|
||||
// way to the hash. Without an explicit codec the md5 muxer would
|
||||
// default to pcm_s16le and hide real differences.
|
||||
func audioMD5(ctx context.Context, path string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-nostdin", "-v", "error",
|
||||
"-i", path,
|
||||
"-map", "0:a:0",
|
||||
"-c:a", "pcm_f64le",
|
||||
"-f", "md5", "-",
|
||||
)
|
||||
cmd.Stdin = nil
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("ffmpeg md5 of %s: %w: %s", path, err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
|
||||
sum := strings.TrimSpace(stdout.String())
|
||||
if !strings.HasPrefix(sum, "MD5=") {
|
||||
return "", fmt.Errorf("unexpected md5 output for %s: %q", path, sum)
|
||||
}
|
||||
return strings.TrimPrefix(sum, "MD5="), nil
|
||||
}
|
||||
|
||||
func ffmpeg(ctx context.Context, args ...string) error {
|
||||
full := append([]string{"-nostdin", "-v", "error"}, args...)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
|
||||
cmd.Stdin = nil
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceExt(path, ext string) string {
|
||||
return strings.TrimSuffix(path, filepath.Ext(path)) + ext
|
||||
}
|
||||
Reference in New Issue
Block a user