From cb18297d1187c2a40826f9cb7b75c12598249a50 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 8 Aug 2026 21:31:52 +0200 Subject: [PATCH] add find-and-enflac pcm compressor --- find-and-enflac/.gitignore | 1 + find-and-enflac/Makefile | 33 ++++++ find-and-enflac/encode.go | 211 +++++++++++++++++++++++++++++++++++++ find-and-enflac/go.mod | 3 + find-and-enflac/main.go | 121 +++++++++++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 find-and-enflac/.gitignore create mode 100644 find-and-enflac/Makefile create mode 100644 find-and-enflac/encode.go create mode 100644 find-and-enflac/go.mod create mode 100644 find-and-enflac/main.go diff --git a/find-and-enflac/.gitignore b/find-and-enflac/.gitignore new file mode 100644 index 0000000..6ff6e26 --- /dev/null +++ b/find-and-enflac/.gitignore @@ -0,0 +1 @@ +/find-and-enflac diff --git a/find-and-enflac/Makefile b/find-and-enflac/Makefile new file mode 100644 index 0000000..6cf03e2 --- /dev/null +++ b/find-and-enflac/Makefile @@ -0,0 +1,33 @@ +BIN := find-and-enflac +PREFIX := $(HOME) + +.PHONY: default install build check test lint fmt fmt-check clean + +default: install + +install: build + install -d $(PREFIX)/bin + install -m 0755 $(BIN) $(PREFIX)/bin/$(BIN) + +build: + go build -o $(BIN) . + +check: fmt-check lint test + +test: + go test ./... + +lint: + go vet ./... + +fmt: + gofmt -s -w . + +fmt-check: + @out="$$(gofmt -s -l .)"; \ + if [ -n "$$out" ]; then \ + echo "unformatted files:"; echo "$$out"; exit 1; \ + fi + +clean: + rm -f $(BIN) diff --git a/find-and-enflac/encode.go b/find-and-enflac/encode.go new file mode 100644 index 0000000..42dea6c --- /dev/null +++ b/find-and-enflac/encode.go @@ -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 +} diff --git a/find-and-enflac/go.mod b/find-and-enflac/go.mod new file mode 100644 index 0000000..303f0cd --- /dev/null +++ b/find-and-enflac/go.mod @@ -0,0 +1,3 @@ +module git.eeqj.de/sneak/hacks/find-and-enflac + +go 1.26 diff --git a/find-and-enflac/main.go b/find-and-enflac/main.go new file mode 100644 index 0000000..5988033 --- /dev/null +++ b/find-and-enflac/main.go @@ -0,0 +1,121 @@ +// Command find-and-enflac walks a directory tree, finds every +// uncompressed PCM audio file, and losslessly compresses them all in +// parallel. An original is deleted only after its encoded replacement +// has been verified to decode back to bit-identical audio. +// +// Integer PCM becomes FLAC. IEEE float PCM becomes WavPack, which is +// the only common lossless codec that stores float samples exactly; +// FLAC has no float sample type at all. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/exec" + "os/signal" + "runtime" + "sync" + "sync/atomic" + "syscall" +) + +func main() { + jobs := flag.Int("jobs", runtime.NumCPU(), "files to encode concurrently") + threads := flag.Int("threads", runtime.NumCPU(), "threads per ffmpeg") + dryRun := flag.Bool("n", false, "report what would be done, change nothing") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "usage: %s [flags] [directory]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Losslessly compress PCM audio under directory (default \".\").\n\n") + flag.PrintDefaults() + } + flag.Parse() + + if flag.NArg() > 1 { + flag.Usage() + os.Exit(2) + } + + root := "." + if flag.NArg() == 1 { + root = flag.Arg(0) + } + + if err := run(root, *jobs, *threads, *dryRun); err != nil { + slog.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(root string, jobs, threads int, dryRun bool) error { + for _, tool := range []string{"ffmpeg", "ffprobe"} { + if _, err := exec.LookPath(tool); err != nil { + return fmt.Errorf("%s not found in PATH: %w", tool, err) + } + } + + if jobs < 1 || threads < 1 { + return fmt.Errorf("jobs and threads must be at least 1") + } + + info, err := os.Stat(root) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("not a directory: %s", root) + } + + // Ctrl-C cancels in-flight ffmpeg runs; a killed encode fails + // verification, so its partial output is removed and its source + // is left alone. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + srcs, err := findPCM(root) + if err != nil { + return err + } + if len(srcs) == 0 { + slog.Info("no PCM audio found", "root", root) + return nil + } + slog.Info("found PCM audio", "files", len(srcs), "jobs", jobs, "threads", threads) + + enc := &encoder{threads: threads, dryRun: dryRun} + + var failed atomic.Int64 + queue := make(chan string) + var wg sync.WaitGroup + + for range jobs { + wg.Go(func() { + for src := range queue { + if err := enc.encodeOne(ctx, src); err != nil { + slog.Error("failed", "src", src, "err", err) + failed.Add(1) + } + } + }) + } + + for _, src := range srcs { + if ctx.Err() != nil { + break + } + queue <- src + } + close(queue) + wg.Wait() + + if n := failed.Load(); n > 0 { + return fmt.Errorf("%d of %d file(s) failed", n, len(srcs)) + } + if ctx.Err() != nil { + return ctx.Err() + } + return nil +}