diff --git a/internal/adapters/audio/ffmpeg.go b/internal/adapters/audio/ffmpeg.go new file mode 100644 index 0000000..ee04971 --- /dev/null +++ b/internal/adapters/audio/ffmpeg.go @@ -0,0 +1,45 @@ +package audio + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// FFmpeg converts any audio container to the strict format whisper wants: 16 kHz +// mono signed 16-bit PCM WAV (ADR-033). It ALWAYS transcodes, even when the input +// already is that format — a Teams export often is, and the transcode is then +// nearly free; assuming it would be the bug. This replaces whisper-server's +// --convert, whose --tmp-dir is created at process start and rots under macOS +// periodic cleanup. +type FFmpeg struct { + // Bin is the ffmpeg executable ("ffmpeg" resolves via PATH). + Bin string +} + +// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the +// file has already been probed as audio-only, but a cover-art stream must not end +// up in the WAV. +func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error { + bin := f.Bin + if bin == "" { + bin = "ffmpeg" + } + cmd := exec.CommandContext(ctx, bin, + "-y", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", "-f", "wav", dst) + cmd.WaitDelay = killGrace + if out, err := cmd.CombinedOutput(); err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("audio: ffmpeg: %w", ctxErr) + } + return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(string(out))) + } + // A zero exit that wrote nothing is still a failure: never hand whisper a + // missing file and let the error surface three layers later. + if info, err := os.Stat(dst); err != nil || info.Size() == 0 { + return fmt.Errorf("audio: ffmpeg wrote no output at %s", dst) + } + return nil +} diff --git a/internal/adapters/audio/ffmpeg_test.go b/internal/adapters/audio/ffmpeg_test.go index 1aaf6d1..4442c80 100644 --- a/internal/adapters/audio/ffmpeg_test.go +++ b/internal/adapters/audio/ffmpeg_test.go @@ -18,7 +18,9 @@ func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) { // Always transcode, even when the input already is 16 kHz mono: a Teams export // often is, and the point is to not ASSUME (ADR-033). Records the argv. rec := filepath.Join(t.TempDir(), "argv") - bin := stub(t, `printf '%s\n' "$@" > `+rec+`; : > "$(eval echo \${$#})"`) + bin := stub(t, `printf '%s\n' "$@" > `+rec+` +for a; do last="$a"; done +printf data > "$last"`) err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))