nativeaudio

audio playback for Go
Log | Files | Refs | README | LICENSE

audio_ffmpeg.go (6897B)


      1 // This file is a temporary audio implementation for macOS and Linux
      2 // platforms. For Linux I suspect ffmpeg will be the defacto, but macOS
      3 // ships an AAC decoder that we can access directly. That is preferable
      4 // to relying on the ffmpeg binary being present since we'd have to
      5 // provide one or hope for the best.
      6 
      7 package nativeaudio
      8 
      9 import (
     10 	"bytes"
     11 	"encoding/json"
     12 	"errors"
     13 	"fmt"
     14 	"io"
     15 	"os"
     16 	"os/exec"
     17 	"strconv"
     18 	"strings"
     19 	"sync"
     20 	"time"
     21 )
     22 
     23 // FFmpegLoad raw PCM with ffmpeg.
     24 //
     25 //	ffmpeg -i <path> -f s16le -
     26 //
     27 // s16le is the PCM format specifier, the final dash means "pipe to
     28 // stdout".
     29 func FFmpegLoad(path string) ([]byte, Format, error) {
     30 	f, err := probe(path)
     31 	if err != nil {
     32 		return nil, f, fmt.Errorf("probing file for metadata: %w", err)
     33 	}
     34 	buffer := bytes.NewBuffer(nil)
     35 	stderr := bytes.NewBuffer(nil)
     36 	cmd := exec.Command(
     37 		"ffmpeg",
     38 		"-i", path,
     39 		"-f", "s16le",
     40 		"-",
     41 	)
     42 	cmd.Stdout = buffer
     43 	cmd.Stderr = stderr
     44 	if err := cmd.Run(); err != nil {
     45 		return nil, Format{}, fmt.Errorf("ffmpeg: %w: %s", err, stderr.String())
     46 	}
     47 	return buffer.Bytes(), f, nil
     48 }
     49 
     50 // FFmpegDecode raw PCM with ffmpeg.
     51 //
     52 //	ffmpeg -f m4a -i pipe: -f s16le -
     53 //
     54 // s16le is the PCM format specifier, the final dash means "pipe to
     55 // stdout".
     56 //
     57 // NOTE(jfm): unfortunately, some formats cannot be piped, so we will
     58 // create a temporary file instead.
     59 func FFmpegDecode(by []byte) ([]byte, Format, error) {
     60 	// CreateTemp picks a unique name, so concurrent decodes in this or any
     61 	// other process cannot collide.
     62 	tmp, err := os.CreateTemp("", "nativeaudio-*")
     63 	if err != nil {
     64 		return nil, Format{}, fmt.Errorf("creating tmp file: %w", err)
     65 	}
     66 	defer os.Remove(tmp.Name())
     67 	if _, err := tmp.Write(by); err != nil {
     68 		tmp.Close()
     69 		return nil, Format{}, fmt.Errorf("writing tmp file: %w", err)
     70 	}
     71 	if err := tmp.Close(); err != nil {
     72 		return nil, Format{}, fmt.Errorf("closing tmp file: %w", err)
     73 	}
     74 	return FFmpegLoad(tmp.Name())
     75 }
     76 
     77 // probe queries the format information for a given audio file by parsing
     78 // ffprobe results.
     79 //
     80 //	ffprobe -i <path> -v quiet -print_format json -show_format -show_streams
     81 func probe(path string) (Format, error) {
     82 	stdout := bytes.NewBuffer(nil)
     83 	cmd := exec.Command(
     84 		"ffprobe",
     85 		"-i", path,
     86 		"-v", "quiet",
     87 		"-print_format", "json",
     88 		"-show_format",
     89 		"-show_streams",
     90 	)
     91 	cmd.Stdout = stdout
     92 	cmd.Stderr = stdout
     93 	if err := cmd.Run(); err != nil {
     94 		return Format{}, fmt.Errorf("%q: %w %s", strings.Join(cmd.Args, " "), err, stdout.String())
     95 	}
     96 	var f md
     97 	if err := json.Unmarshal(stdout.Bytes(), &f); err != nil {
     98 		return Format{}, fmt.Errorf("unmarshalling json: %w", err)
     99 	}
    100 	s := f.FirstAudioStream()
    101 	if s == nil {
    102 		return Format{}, fmt.Errorf("file has no audio streams")
    103 	}
    104 	return s.Format()
    105 }
    106 
    107 // md partially describes the structured metadata output from ffprobe.
    108 type md struct {
    109 	// Streams contains all streams, we will filter for "first audio stream".
    110 	Streams []stream `json:"streams"`
    111 }
    112 
    113 // stream description.
    114 type stream struct {
    115 	// CodecType is the "major" type: {audio,video}.
    116 	CodecType string `json:"codec_type"`
    117 	// SampleRate in Hz.
    118 	SampleRate string `json:"sample_rate"`
    119 	// Channel count.
    120 	Channels int `json:"channels"`
    121 }
    122 
    123 // FirstAudioStream returns the first audio stream described, if any.
    124 func (f md) FirstAudioStream() *stream {
    125 	for ii, s := range f.Streams {
    126 		if s.CodecType == "audio" {
    127 			return &f.Streams[ii]
    128 		}
    129 	}
    130 	return nil
    131 }
    132 
    133 // Format returns the unified Format struct from stream info.
    134 func (s stream) Format() (Format, error) {
    135 	if s.Channels < 1 || s.Channels > 2 {
    136 		return Format{}, fmt.Errorf("can only handle {1,2} channels got %d", s.Channels)
    137 	}
    138 	sr, err := strconv.Atoi(s.SampleRate)
    139 	if err != nil {
    140 		return Format{}, fmt.Errorf("invalid sample rate: must be number got %q", s.SampleRate)
    141 	}
    142 	return Format{
    143 		SampleRate: sr,
    144 		Channels:   s.Channels,
    145 		// We are going to tell ffmpeg to output s16le, though there
    146 		// might be a better place to make this assumption.
    147 		BytesPerSample: 2,
    148 	}, nil
    149 }
    150 
    151 // ffmpegStream pipes PCM out of a running ffmpeg process.
    152 type ffmpegStream struct {
    153 	cmd      *exec.Cmd
    154 	stdout   io.ReadCloser
    155 	stderr   *bytes.Buffer
    156 	waitOnce sync.Once
    157 	waitErr  error
    158 	drained  bool
    159 }
    160 
    161 // reap waits for ffmpeg exactly once, whoever gets there first.
    162 func (f *ffmpegStream) reap() error {
    163 	f.waitOnce.Do(func() { f.waitErr = f.cmd.Wait() })
    164 	return f.waitErr
    165 }
    166 
    167 func (f *ffmpegStream) Read(p []byte) (int, error) {
    168 	n, err := f.stdout.Read(p)
    169 	if errors.Is(err, io.EOF) {
    170 		// The pipe closing means ffmpeg is finished, so collect its exit
    171 		// status: a decode that failed halfway still reaches EOF here.
    172 		f.drained = true
    173 		if werr := f.reap(); werr != nil {
    174 			return n, fmt.Errorf("ffmpeg: %w: %s", werr, f.stderr.String())
    175 		}
    176 	}
    177 	return n, err
    178 }
    179 
    180 // reapTimeout bounds how long Close waits for an abandoned ffmpeg to go
    181 // away before leaving it to finish on its own.
    182 const reapTimeout = 5 * time.Second
    183 
    184 func (f *ffmpegStream) Close() error {
    185 	if f.drained {
    186 		return f.reap()
    187 	}
    188 
    189 	// Abandoned early. Kill it rather than leave ffmpeg blocked writing
    190 	// into a pipe nobody is reading.
    191 	_ = f.cmd.Process.Kill()
    192 
    193 	// Waiting on a command wants its pipes drained first, and the wait
    194 	// itself only returns once nothing holds the far end. Neither is
    195 	// guaranteed when ffmpeg is reached through a launcher shim, since
    196 	// killing the shim leaves the real process running and holding the
    197 	// pipe. CI found exactly that: closing an abandoned stream never
    198 	// returned on a runner whose ffmpeg came from a package manager that
    199 	// installs one.
    200 	//
    201 	// So the teardown gets a deadline. Whatever is still holding the pipe
    202 	// finishes decoding shortly and exits on its own, which makes this a
    203 	// brief leak rather than a caller that never returns.
    204 	done := make(chan struct{})
    205 	go func() {
    206 		defer close(done)
    207 		_, _ = io.Copy(io.Discard, f.stdout)
    208 		_ = f.reap()
    209 	}()
    210 
    211 	select {
    212 	case <-done:
    213 	case <-time.After(reapTimeout):
    214 	}
    215 	return nil
    216 }
    217 
    218 // FFmpegStream decodes an audio file with ffmpeg, returning PCM through
    219 // an [io.Reader] as ffmpeg produces it.
    220 //
    221 //	ffmpeg -i <path> -f s16le -
    222 //
    223 // Unlike FFmpegLoad this never holds the whole decode in memory. The
    224 // returned Stream must be closed, including when abandoned early.
    225 func FFmpegStream(path string) (*Stream, error) {
    226 	format, err := probe(path)
    227 	if err != nil {
    228 		return nil, fmt.Errorf("probing file for metadata: %w", err)
    229 	}
    230 	cmd := exec.Command(
    231 		"ffmpeg",
    232 		"-i", path,
    233 		"-f", "s16le",
    234 		"-",
    235 	)
    236 	stdout, err := cmd.StdoutPipe()
    237 	if err != nil {
    238 		return nil, fmt.Errorf("attaching to ffmpeg output: %w", err)
    239 	}
    240 	stderr := bytes.NewBuffer(nil)
    241 	cmd.Stderr = stderr
    242 	if err := cmd.Start(); err != nil {
    243 		return nil, fmt.Errorf("starting ffmpeg: %w", err)
    244 	}
    245 	return &Stream{
    246 		r:      &ffmpegStream{cmd: cmd, stdout: stdout, stderr: stderr},
    247 		format: format,
    248 	}, nil
    249 }