No description
  • Go 92.4%
  • C 7.6%
Find a file
Sergio Rubio 4b4be77b48 AAudio and PipeWire sources: capture on Android and Linux without PortAudio
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.
2026-10-26 21:14:36 +00:00
aaudio AAudio and PipeWire sources: capture on Android and Linux without PortAudio 2026-10-26 21:14:36 +00:00
audiotest initial release 2026-10-08 15:36:24 +02:00
pipewire AAudio and PipeWire sources: capture on Android and Linux without PortAudio 2026-10-26 21:14:36 +00:00
portaudio Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
.gitignore initial release 2026-10-08 15:36:24 +02:00
.kata.toml initial release 2026-10-08 15:36:24 +02:00
clip.go initial release 2026-10-08 15:36:24 +02:00
clip_test.go initial release 2026-10-08 15:36:24 +02:00
doc.go Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
example_test.go initial release 2026-10-08 15:36:24 +02:00
format.go initial release 2026-10-08 15:36:24 +02:00
format_test.go initial release 2026-10-08 15:36:24 +02:00
go.mod initial release 2026-10-08 15:36:24 +02:00
go.sum initial release 2026-10-08 15:36:24 +02:00
LICENSE initial release 2026-10-08 15:36:24 +02:00
README.md AAudio and PipeWire sources: capture on Android and Linux without PortAudio 2026-10-26 21:14:36 +00:00
recorder.go initial release 2026-10-08 15:36:24 +02:00
recorder_test.go initial release 2026-10-08 15:36:24 +02:00
resample.go Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
resample_test.go Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
resampler.go Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
resampler_test.go Resample when a device can't capture at the requested rate 2026-10-08 18:02:03 +02:00
segments.go initial release 2026-10-08 15:36:24 +02:00
segments_test.go initial release 2026-10-08 15:36:24 +02:00
source.go initial release 2026-10-08 15:36:24 +02:00
take.go initial release 2026-10-08 15:36:24 +02:00
wav.go initial release 2026-10-08 15:36:24 +02:00
wav_test.go initial release 2026-10-08 15:36:24 +02:00

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

  • -race and multiple streams. In a race-enabled (checkptr) binary, opening a second PortAudio stream in the same process aborts it with checkptr: 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 Recorder doesn'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