Use direct libav bindings via dlopen instead of ffmpeg subprocess

Loads libavformat + libavdevice at runtime — no dev packages, no
subprocess, no pipe buffering. Falls back gracefully to ffmpeg
subprocess then demo mode if .so files aren't present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
rolandnsharp
2026-04-05 19:33:52 +10:00
parent 3c9a9c7c89
commit 3fc7ed1c4e
2 changed files with 265 additions and 27 deletions

View File

@@ -1,23 +1,39 @@
## Audio capture: tries ffmpeg (PulseAudio monitor) → parec → demo signal.
## Audio capture via libavdevice/libavformat (direct C bindings),
## with fallback to ffmpeg subprocess, then demo signal.
import osproc, streams, strutils, math
import scope
type
AudioMode* = enum
amLive ## Capturing real audio via ffmpeg/parec
amDemo ## Built-in synthesized waveforms
# ── libav C helper bindings ──────────────────────────────────────────
AudioCapture* = object
mode*: AudioMode
process: Process
stream: Stream
phase: float
demoFreqL*, demoFreqR*: float
demoPreset*: int
{.compile: "avhelper.c".}
{.passL: "-ldl".}
type
AVFormatContext = object # opaque, only used as pointer
AVPacket = object # opaque, only used as pointer
proc av_helper_init(): cint {.importc, cdecl.}
proc av_helper_open_pulse(ctx: ptr ptr AVFormatContext,
device: cstring): cint {.importc, cdecl.}
proc av_helper_find_stream_info(ctx: ptr AVFormatContext): cint
{.importc, cdecl.}
proc av_helper_find_audio_stream(ctx: ptr AVFormatContext): cint
{.importc, cdecl.}
proc av_helper_read_frame(ctx: ptr AVFormatContext,
pkt: ptr AVPacket): cint {.importc, cdecl.}
proc av_helper_packet_stream(pkt: ptr AVPacket): cint {.importc, cdecl.}
proc av_helper_packet_data(pkt: ptr AVPacket): ptr UncheckedArray[uint8]
{.importc, cdecl.}
proc av_helper_packet_size(pkt: ptr AVPacket): cint {.importc, cdecl.}
proc av_helper_packet_alloc(): ptr AVPacket {.importc, cdecl.}
proc av_helper_packet_unref(pkt: ptr AVPacket) {.importc, cdecl.}
proc av_helper_packet_free(pkt: ptr ptr AVPacket) {.importc, cdecl.}
proc av_helper_close(ctx: ptr ptr AVFormatContext) {.importc, cdecl.}
# ── Monitor source detection ─────────────────────────────────────────
proc findMonitorSource(): string =
## Find the PulseAudio monitor for the default audio sink.
try:
let inspect = execProcess("wpctl",
args = ["inspect", "@DEFAULT_AUDIO_SINK@"],
@@ -30,10 +46,51 @@ proc findMonitorSource(): string =
except: discard
""
# ── Audio capture types ──────────────────────────────────────────────
type
AudioMode* = enum
amLibav ## Direct libav capture (fastest, no subprocess)
amLive ## ffmpeg/parec subprocess fallback
amDemo ## Built-in synthesized waveforms
AudioCapture* = object
mode*: AudioMode
# libav state
fmtCtx: ptr AVFormatContext
packet: ptr AVPacket
streamIdx: cint
# subprocess fallback
process: Process
stream: Stream
# demo state
phase: float
demoFreqL*, demoFreqR*: float
demoPreset*: int
# ── Start / stop ─────────────────────────────────────────────────────
proc startAudio*(): AudioCapture =
## Try real audio capture, fall back to demo.
let monitor = findMonitorSource()
if monitor.len > 0:
# Try direct libav first (dlopen at runtime, no dev packages needed)
block libav:
if av_helper_init() < 0: break libav
var ctx: ptr AVFormatContext = nil
if av_helper_open_pulse(addr ctx, monitor.cstring) < 0: break libav
if av_helper_find_stream_info(ctx) < 0:
av_helper_close(addr ctx)
break libav
let idx = av_helper_find_audio_stream(ctx)
let pkt = av_helper_packet_alloc()
if pkt != nil:
return AudioCapture(
mode: amLibav, fmtCtx: ctx, packet: pkt,
streamIdx: idx.cint,
demoFreqL: 440.0, demoFreqR: 330.0)
av_helper_close(addr ctx)
# Fallback: ffmpeg subprocess
try:
let p = startProcess("ffmpeg",
args = ["-f", "pulse", "-i", monitor,
@@ -45,27 +102,31 @@ proc startAudio*(): AudioCapture =
demoFreqL: 440.0, demoFreqR: 330.0)
except OSError: discard
try:
let p = startProcess("parec",
args = ["--format=s16le", "--channels=2", "--rate=44100",
"--latency-msec=20"],
options = {poUsePath})
return AudioCapture(mode: amLive, process: p, stream: p.outputStream,
demoFreqL: 440.0, demoFreqR: 330.0)
except OSError: discard
# Fallback: demo
AudioCapture(mode: amDemo, demoFreqL: 440.0, demoFreqR: 330.0)
proc stop*(cap: var AudioCapture) =
if cap.mode == amLive:
case cap.mode
of amLibav:
if cap.packet != nil:
av_helper_packet_free(addr cap.packet)
if cap.fmtCtx != nil:
av_helper_close(addr cap.fmtCtx)
of amLive:
cap.process.terminate()
cap.process.close()
of amDemo:
discard
proc sourceLabel*(cap: AudioCapture): string =
if cap.mode == amLive: "LIVE" else: "DEMO"
case cap.mode
of amLibav: "LIVE"
of amLive: "LIVE"
of amDemo: "DEMO"
# ── Preset cycling ───────────────────────────────────────────────────
proc cyclePreset*(cap: var AudioCapture) =
## Cycle through demo frequency ratios for interesting Lissajous patterns.
if cap.mode != amDemo: return
cap.demoPreset = (cap.demoPreset + 1) mod 4
case cap.demoPreset
@@ -75,10 +136,35 @@ proc cyclePreset*(cap: var AudioCapture) =
of 3: cap.demoFreqL = 440.0; cap.demoFreqR = 293.3 # 3:2
else: discard
# ── Sample reading ───────────────────────────────────────────────────
proc readSamples*(cap: var AudioCapture, scope: var Scope) =
case cap.mode
of amLibav:
# Read frames directly from libav — no subprocess, no pipe
const frameSize = 4 # 2ch × 16-bit
var totalSamples = 0
while totalSamples < scope.samplesL.len:
let ret = av_helper_read_frame(cap.fmtCtx, cap.packet)
if ret < 0: break
if av_helper_packet_stream(cap.packet) == cap.streamIdx:
let data = av_helper_packet_data(cap.packet)
let size = av_helper_packet_size(cap.packet)
let frames = size div frameSize
for i in 0..<frames:
if totalSamples >= scope.samplesL.len: break
let off = i * frameSize
let left = cast[int16]((data[off + 1].uint16 shl 8) or data[off].uint16)
let right = cast[int16]((data[off + 3].uint16 shl 8) or data[off + 2].uint16)
scope.samplesL[totalSamples] = left.float / 32768.0
scope.samplesR[totalSamples] = right.float / 32768.0
totalSamples += 1
av_helper_packet_unref(cap.packet)
if totalSamples > 0: break # got some data, render it
scope.sampleCount = totalSamples
of amLive:
const frameSize = 4 # 2 channels × 16-bit
const frameSize = 4
const maxFrames = 2048
var buf: array[maxFrames * frameSize, uint8]
let bytesRead = cap.stream.readData(addr buf[0], maxFrames * frameSize)