3525e6b8fb
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>
239 lines
6.5 KiB
Go
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")
|
|
}
|