// 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") }