add find-and-enflac pcm compressor

This commit is contained in:
2026-08-08 21:31:52 +02:00
parent 2fe5db60c2
commit cb18297d11
5 changed files with 369 additions and 0 deletions

1
find-and-enflac/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/find-and-enflac

33
find-and-enflac/Makefile Normal file
View File

@@ -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)

211
find-and-enflac/encode.go Normal file
View 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
}

3
find-and-enflac/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module git.eeqj.de/sneak/hacks/find-and-enflac
go 1.26

121
find-and-enflac/main.go Normal file
View File

@@ -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
}