- Go 92.4%
- C 7.6%
Two Source packages beside portaudio, so a program can record where PortAudio isn't available: - aaudio opens the Android microphone through libaaudio, loaded with dlopen so a binary built for any API level runs and only the capture itself needs API 26: a float, mono, shared, low-latency stream with the voice-recognition preset, blocking reads with a timeout and an xrun count. New resamples when the device won't run at the requested rate. Granted and Request check and ask for RECORD_AUDIO through JNI; the VM and activity handles they take are only valid inside the native call that produced them, which the doc says, since a cached activity reference went stale after the permission dialog and aborted the process. - pipewire runs pw-record, or parec on a plain PulseAudio, as a subprocess per Start and Stop, reading raw float32 at the requested rate and channel count. ErrNoTool when neither is installed; WithCommand stands a fake in for tests. Checked on a Pixel 10 Pro XL: 3 s captured at 48 kHz and resampled to 16 kHz with no overflows, quiet-room floor around -65 dBFS. |
||
|---|---|---|
| aaudio | ||
| audiotest | ||
| pipewire | ||
| portaudio | ||
| .gitignore | ||
| .kata.toml | ||
| clip.go | ||
| clip_test.go | ||
| doc.go | ||
| example_test.go | ||
| format.go | ||
| format_test.go | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
| recorder.go | ||
| recorder_test.go | ||
| resample.go | ||
| resample_test.go | ||
| resampler.go | ||
| resampler_test.go | ||
| segments.go | ||
| segments_test.go | ||
| source.go | ||
| take.go | ||
| wav.go | ||
| wav_test.go | ||
audiorec
Record audio from a microphone in Go.
rec, err := portaudio.New() // default input device, 16 kHz mono
if err != nil {
log.Fatal(err)
}
defer rec.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
clip, err := rec.Record(ctx) // until Ctrl+C
if err != nil {
log.Fatal(err)
}
clip.WriteWAV(f)
Install
go get code.rbel.co/rubiojr/audiorec
The core package is pure Go. Device capture lives in audiorec/portaudio, which uses CGo and needs PortAudio's development files, plus JACK's on Linux:
| Platform | Packages |
|---|---|
| Fedora | portaudio-devel pipewire-jack-audio-connection-kit-devel |
| Debian, Ubuntu | portaudio19-dev libjack-jackd2-dev |
| macOS (Homebrew) | portaudio |
Two more sources need no audio libraries at all. audiorec/pipewire runs
pw-record, or parec where only PulseAudio's tools are installed, as a
subprocess on Linux; both exist on every desktop and phone that runs
PipeWire or PulseAudio. audiorec/aaudio captures on Android through the
NDK's AAudio API, loaded at run time so any API level builds, and needs
Android 8 or later; aaudio.Granted and aaudio.Request handle the
RECORD_AUDIO permission through the app's activity, taking the Java VM
and activity the way Fyne's driver.AndroidContext gives them. Off
Android the package compiles to stubs that report aaudio.ErrUnsupported.
rec, err := pipewire.New(pipewire.WithSampleRate(16000)) // Linux, no CGo
rec, err := aaudio.New() // Android
Three ways to capture
Record until told to stop. Blocks until the context is done or the source runs out. Cancelling is the normal way to stop, so it's not an error.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
clip, err := rec.Record(ctx)
Start and stop, for push-to-talk buttons and the like:
take, err := rec.Start()
// ...
level := take.Peak() // poll this for a level meter
// ...
clip, err := take.Stop()
take.Done() closes if capture ends on its own, say because the mic got unplugged; Stop then returns the audio captured so far along with the error.
Fixed-length segments, for streaming transcription or long recordings:
for seg, err := range rec.Segments(ctx, 30*time.Second) {
if err != nil {
return err
}
go transcribe(seg) // exactly 30s each, no gaps, last one shorter
}
Breaking out of the loop stops capture, and so does cancelling the context, but then the segments already captured are still delivered. Segments queue up while the loop body works; a consumer that's slower than real time eventually stalls the capture, and the device then drops audio. That's reported in Clip.Overflows rather than buffering forever.
A Recorder runs one capture at a time (ErrBusy otherwise) and keeps the device open between captures, so consecutive takes start right away. All its methods are safe to call from any goroutine, Close included: closing mid-capture still hands back what was recorded.
Clips
A Clip is interleaved float32 samples plus a Format (sample rate and channels).
clip.Duration() // 1.5s
clip.Peak() // 0.42
clip.Normalize(0.9) // scale so the peak is 0.9; leaves silence alone
clip.Resample(16000) // convert to another sample rate
clip.WriteWAV(w) // 16-bit PCM
audiorec.ReadWAV(r) // 8/16/24/32-bit PCM and 32-bit float, extensible format too
Devices
devs, _ := portaudio.InputDevices()
for _, d := range devs {
fmt.Println(d.Name, d.HostAPI, d.DefaultSampleRate, d.Default)
}
rec, err := portaudio.New(
portaudio.WithDevice("Ryzen HD Audio Controller Stereo Microphone"),
portaudio.WithSampleRate(48000),
portaudio.WithChannels(2),
)
Many devices only capture at their own rate: raw ALSA hw: devices, and every device under PortAudio's JACK host API, which runs at the JACK or PipeWire server's rate. When the device doesn't support the rate you ask for, New opens it at its own rate and resamples, so you always get the rate you asked for. portaudio.Open never resamples; it fails with "Invalid sample rate" instead and tells you the rate the device wants.
Testing without a microphone
audiotest.Source plays a Clip as if it was being captured, and audiotest.Sine makes test tones:
format := audiorec.Format{SampleRate: 16000, Channels: 1}
rec, _ := audiorec.NewRecorder(&audiotest.Source{
Clip: audiotest.Sine(format, 440, 0.5, 2*time.Second),
Realtime: true, // pace reads like a real device
Err: errors.New("unplugged"), // fail once the clip runs out
})
Other backends
portaudio is just one audiorec.Source. Anything with this shape works:
type Source interface {
Format() Format
Start() error
Read(buf []float32) (n int, err error) // blocks; ErrOverflow and io.EOF have meaning
Stop() error
Close() error
}
The Recorder never calls these concurrently, so implementations don't need locks. See the Source docs for the full contract.
audiorec.Resample(src, 16000) wraps any Source to deliver another sample rate. portaudio.New uses it when a device can't capture at the rate you ask for.
Development
go test -race ./...
AUDIOREC_HARDWARE_TEST=1 go test ./portaudio/ # records from the default mic
AUDIOREC_HARDWARE_TEST=1 AUDIOREC_TEST_DEVICE="..." go test ./portaudio/
Known issues
-raceand multiple streams. In a race-enabled (checkptr) binary, opening a second PortAudio stream in the same process aborts it withcheckptr: pointer arithmetic computed bad pointer value. The gordonklaus/portaudio binding passes small integer stream IDs to C as pointers. Normal builds are fine. The hardware test skips its second device config under-race.- Resampled captures lose about a millisecond at the end. The filter holds that much back, and a capture stopped by its
Recorderdoesn't wait for it. Captures that end because the source ran out or failed get all of it. - Large buffers add latency, and audio still in the device's buffers when a capture stops isn't collected, so takes come out shorter. The default 20 ms buffer is a good fit for most uses.
License
MIT