package audio import ( "context" "fmt" "os" "os/exec" "strconv" "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 // MaxSeconds caps the converted length when positive. The duration limit is a claim // in the file's header and can be lied about (a 2 h file claiming 10 s converted to a // 120-minute WAV); this bounds what a lie can cost. A minute of slack is added so a // file at the limit is never cut, and the worker measures the result. MaxSeconds int } // lengthSlackSeconds is added to MaxSeconds so ffmpeg's rounding never clips a file that // is really at the limit. const lengthSlackSeconds = 60 // 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" } // Input restrictions go BEFORE -i: placed after it they would apply to the output. args := append([]string{"-y", "-v", "error"}, inputRestrictions...) args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le") if f.MaxSeconds > 0 { // After -i: an OUTPUT cap. Before it, it would limit what is read, not written. args = append(args, "-t", strconv.Itoa(f.MaxSeconds+lengthSlackSeconds)) } args = append(args, "-f", "wav", dst) cmd := exec.CommandContext(ctx, bin, args...) cmd.WaitDelay = killGrace // Only a bounded head of ffmpeg's output is kept: a hostile file made it print ~0.4 // bytes per input byte, all of which CombinedOutput would buffer and copy (#49). out := &headWriter{max: maxStderr} cmd.Stdout, cmd.Stderr = out, out if err := cmd.Run(); 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(out.buf.String())) } // 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 }