Files
transcriptor-ai/internal/capture/capture.go
T
sttlab-tech 3525e6b8fb initial commit: live meeting transcription pipeline
Go orchestrator: spawns audiotee for capture (system audio + mic, two
tracks), segments with an energy-threshold VAD, transcribes each segment
via qwen-asr (subprocess per segment, vendored as a pinned git submodule
in third_party/qwen-asr), and serves the live transcript over SSE while
also writing it durably to a text file. Includes a glossary/prompt-leakage
guard (internal/asr/leak.go) that discards a segment if the model echoes
the biasing prompt instead of transcribing.

cmd/transcriptor-ai is the main orchestrator; cmd/transcript-tail is a
minimal terminal SSE client. See CLAUDE.md for the full architecture and
the reasoning behind each choice (Go over Python/Swift/Rust, SSE over
WebSocket, why the VAD is a hand-rolled heuristic, etc).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 13:18:57 +02:00

239 lines
6.5 KiB
Go

// Package capture wraps the audiotee subprocess and turns its two audio
// outputs (system audio on stdout, microphone on a FIFO) into a single
// stream of tagged chunks that downstream VAD/ASR stages can consume.
package capture
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sync"
"syscall"
"time"
)
// Track identifies which audio source a Chunk came from.
type Track string
const (
TrackSystem Track = "system"
TrackMic Track = "mic"
)
// Chunk is a slice of raw PCM audio (16-bit signed, little-endian, mono, at
// Config.SampleRate) read off one of audiotee's two outputs. Timestamp is
// when this process read the chunk, not when audiotee captured it — audiotee
// does not emit per-chunk timestamps (see transcriptor-ai/CLAUDE.md), so this
// is an approximation good enough for live display, not sample-accurate
// realignment.
type Chunk struct {
Track Track
Data []byte
Timestamp time.Time
}
// Config controls how the audiotee subprocess is launched.
type Config struct {
// AudioteePath is the path to the audiotee binary. If empty, it's
// resolved via PATH and then $HOME/bin/audiotee.
AudioteePath string
// SampleRate is passed to audiotee's --sample-rate flag.
SampleRate int
// ChunkBytes is the read buffer size used per Chunk. Must be even
// (16-bit samples). Defaults to 3200 bytes (~100ms at 16kHz mono).
ChunkBytes int
// CaptureMic controls whether audiotee is asked to also capture the
// mic track (--capture-mic --mic-output). audiotee has no equivalent
// flag to skip the system tap — it's always captured — so there is no
// "mic only" at this layer; a caller that only wants mic output must
// still receive (and can simply ignore) TrackSystem chunks. See
// CLAUDE.md for why this is a known, accepted limitation rather than
// something fixed here.
CaptureMic bool
}
// Capturer runs audiotee and streams both its tracks as Chunks.
type Capturer struct {
cfg Config
cmd *exec.Cmd
fifoPath string
micFile *os.File
chunks chan Chunk
errs chan error
wg sync.WaitGroup
done chan struct{} // closed once both track readers have hit EOF
}
// New creates a Capturer. Call Start to launch audiotee.
func New(cfg Config) *Capturer {
if cfg.ChunkBytes <= 0 {
cfg.ChunkBytes = 3200
}
if cfg.ChunkBytes%2 != 0 {
cfg.ChunkBytes++
}
return &Capturer{
cfg: cfg,
chunks: make(chan Chunk, 64),
errs: make(chan error, 4),
}
}
// Chunks returns the channel both tracks' audio is delivered on. Closed once
// both tracks have stopped delivering data (audiotee exited or Stop was
// called).
func (c *Capturer) Chunks() <-chan Chunk { return c.chunks }
// Errs returns non-fatal errors encountered while reading either track.
func (c *Capturer) Errs() <-chan error { return c.errs }
// Start resolves the audiotee binary, launches it with system+mic capture
// enabled, and begins streaming both tracks. It returns once audiotee has
// been launched; reading happens in background goroutines.
func (c *Capturer) Start(ctx context.Context) error {
audioteePath, err := resolveAudioteePath(c.cfg.AudioteePath)
if err != nil {
return err
}
args := []string{"--sample-rate", fmt.Sprintf("%d", c.cfg.SampleRate)}
var fifoPath string
if c.cfg.CaptureMic {
fifoPath = filepath.Join(os.TempDir(), fmt.Sprintf("transcriptor-ai-mic-%d.fifo", os.Getpid()))
if err := syscall.Mkfifo(fifoPath, 0o600); err != nil {
return fmt.Errorf("create mic fifo: %w", err)
}
c.fifoPath = fifoPath
args = append(args, "--capture-mic", "--mic-output", fifoPath)
}
cmd := exec.CommandContext(ctx, audioteePath, args...)
cmd.Stderr = os.Stderr // audiotee's JSON logs, useful as-is while iterating
stdout, err := cmd.StdoutPipe()
if err != nil {
c.cleanupFifo()
return fmt.Errorf("attach stdout pipe: %w", err)
}
// Open the FIFO for reading in the background before starting audiotee:
// opening a FIFO blocks until the other end is opened too, so this must
// not block Start() itself, and audiotee (the writer) hasn't been
// spawned yet at this point.
var micOpened chan struct{}
var micFile *os.File
var micOpenErr error
if c.cfg.CaptureMic {
micOpened = make(chan struct{})
go func() {
defer close(micOpened)
micFile, micOpenErr = os.OpenFile(fifoPath, os.O_RDONLY, 0)
}()
}
if err := cmd.Start(); err != nil {
c.cleanupFifo()
return fmt.Errorf("start audiotee: %w", err)
}
c.cmd = cmd
if c.cfg.CaptureMic {
<-micOpened
if micOpenErr != nil {
return fmt.Errorf("open mic fifo: %w", micOpenErr)
}
c.micFile = micFile
}
c.done = make(chan struct{})
c.wg.Add(1)
go c.readTrack(TrackSystem, stdout)
if c.cfg.CaptureMic {
c.wg.Add(1)
go c.readTrack(TrackMic, micFile)
}
go func() {
c.wg.Wait()
close(c.chunks)
close(c.done)
}()
return nil
}
func (c *Capturer) readTrack(track Track, r io.Reader) {
defer c.wg.Done()
buf := make([]byte, c.cfg.ChunkBytes)
for {
n, err := io.ReadFull(r, buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
c.chunks <- Chunk{Track: track, Data: data, Timestamp: time.Now()}
}
if err != nil {
if err != io.EOF && err != io.ErrUnexpectedEOF {
select {
case c.errs <- fmt.Errorf("%s track: %w", track, err):
default:
}
}
return
}
}
}
// Stop terminates audiotee gracefully (SIGINT, matching the fix in audiotee
// itself so shutdown doesn't hang) and cleans up the FIFO.
func (c *Capturer) Stop() error {
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Process.Signal(syscall.SIGINT)
}
if c.done != nil {
// audiotee exiting closes its stdout pipe and the mic FIFO write
// end, which is what makes readTrack see EOF — wait for that before
// reaping the process. Calling cmd.Wait() first can close the pipe
// out from under an in-progress read (see os/exec's StdoutPipe doc).
<-c.done
}
if c.cmd != nil && c.cmd.Process != nil {
_ = c.cmd.Wait()
}
if c.micFile != nil {
_ = c.micFile.Close()
}
c.cleanupFifo()
return nil
}
func (c *Capturer) cleanupFifo() {
if c.fifoPath != "" {
_ = os.Remove(c.fifoPath)
}
}
func resolveAudioteePath(configured string) (string, error) {
if configured != "" {
return configured, nil
}
if p, err := exec.LookPath("audiotee"); err == nil {
return p, nil
}
home, err := os.UserHomeDir()
if err == nil {
candidate := filepath.Join(home, "bin", "audiotee")
if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("audiotee binary not found on PATH or in ~/bin — build it via audiotee/scripts/build-signed.sh")
}