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 }