67 lines
2.6 KiB
Go
67 lines
2.6 KiB
Go
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
|
|
}
|