add find-and-enflac pcm compressor
This commit is contained in:
1
find-and-enflac/.gitignore
vendored
Normal file
1
find-and-enflac/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/find-and-enflac
|
||||||
33
find-and-enflac/Makefile
Normal file
33
find-and-enflac/Makefile
Normal 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
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
|
||||||
|
}
|
||||||
3
find-and-enflac/go.mod
Normal file
3
find-and-enflac/go.mod
Normal 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
121
find-and-enflac/main.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user