refactor: move source files to src/ directory and rename project to Captioneer

Restructure Go source code into a dedicated `src/` directory, rename the project from "audio-listen" to "Captioneer", and update README accordingly. Add `.gitignore` entry for the built binary `captioneer`.
This commit is contained in:
2026-07-16 12:01:51 +03:00
parent d042637351
commit ce2161b7e6
12 changed files with 385 additions and 327 deletions
+3
View File
@@ -1,3 +1,6 @@
# Downloaded local speech-recognition models. # Downloaded local speech-recognition models.
models/parakeet-tdt-v2/ models/parakeet-tdt-v2/
models/silero_vad_v5.onnx models/silero_vad_v5.onnx
# Locally built executable.
/captioneer
+22 -11
View File
@@ -1,4 +1,4 @@
# audio-listen # Captioneer
Local, live English captions for the audio playing through your default PulseAudio/PipeWire output device. It captures that output's monitor source, runs the Parakeet TDT v2 model locally, and writes captions to the terminal. Local, live English captions for the audio playing through your default PulseAudio/PipeWire output device. It captures that output's monitor source, runs the Parakeet TDT v2 model locally, and writes captions to the terminal.
@@ -24,7 +24,7 @@ Clone the project, then download the required models once:
```bash ```bash
git clone <your-repository-url> git clone <your-repository-url>
cd audio-listen cd captioneer
./scripts/download-models.sh ./scripts/download-models.sh
``` ```
@@ -35,20 +35,20 @@ The download installs the Parakeet TDT v2 int8 model and Silero VAD into `models
Use VAD mode for normal live captions. It shows a temporary `…` caption while speech is active, then prints a timestamped final caption after 0.5 seconds of silence: Use VAD mode for normal live captions. It shows a temporary `…` caption while speech is active, then prints a timestamped final caption after 0.5 seconds of silence:
```bash ```bash
go run . --mode=vad go run ./src --mode=vad
``` ```
Use fixed mode to decode exact time slices, even through silence: Use fixed mode to decode exact time slices, even through silence:
```bash ```bash
go run . --mode=fixed --chunk-duration=1s go run ./src --mode=fixed --chunk-duration=1s
``` ```
Build a reusable executable instead of using `go run`: Build a reusable executable instead of using `go run`:
```bash ```bash
go build -o audio-listen . go build -o captioneer ./src
./audio-listen --mode=vad ./captioneer --mode=vad
``` ```
Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop. Pending audio is flushed before exit. Press <kbd>Ctrl</kbd>+<kbd>C</kbd> to stop. Pending audio is flushed before exit.
@@ -70,17 +70,28 @@ Examples:
```bash ```bash
# Faster fixed chunks # Faster fixed chunks
go run . --mode=fixed --chunk-duration=500ms go run ./src --mode=fixed --chunk-duration=500ms
# Fewer VAD preview redraws and four CPU threads # Fewer VAD preview redraws and four CPU threads
go run . --mode=vad --chunk-duration=2s --threads=4 go run ./src --mode=vad --chunk-duration=2s --threads=4
# Store or reuse models outside this repository # Store or reuse models outside this repository
go run . --mode=vad --models-dir=/path/to/models go run ./src --mode=vad --models-dir=/path/to/models
``` ```
There are no environment variables to configure. All supported configuration is supplied with command-line options. There are no environment variables to configure. All supported configuration is supplied with command-line options.
## Source layout
All Go code lives in `src/`. It remains one executable package, split into focused files:
- `main.go` coordinates startup and shutdown.
- `config.go` defines and validates command-line options.
- `capture.go` reads the default system-audio monitor.
- `audio.go` contains PCM conversion and audio calculations.
- `models.go` validates and initializes Parakeet and Silero.
- `captions.go` handles fixed chunks, VAD segments, and terminal output.
## Output and troubleshooting ## Output and troubleshooting
Final captions go to standard output in this form: Final captions go to standard output in this form:
@@ -92,7 +103,7 @@ Final captions go to standard output in this form:
Startup messages and overload warnings go to standard error, so you can save final captions separately: Startup messages and overload warnings go to standard error, so you can save final captions separately:
```bash ```bash
go run . --mode=vad > captions.txt go run ./src --mode=vad > captions.txt
``` ```
- **Missing model files:** run `./scripts/download-models.sh` again. - **Missing model files:** run `./scripts/download-models.sh` again.
@@ -104,5 +115,5 @@ go run . --mode=vad > captions.txt
```bash ```bash
go test ./... go test ./...
go build ./... go build -o captioneer ./src
``` ```
+1 -1
View File
@@ -1,4 +1,4 @@
module audio-meter module tea.chunkbyte.com/kato/captioneer
go 1.26.2 go 1.26.2
-270
View File
@@ -1,270 +0,0 @@
package main
import (
"context"
"encoding/binary"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
sherpa "github.com/k2-fsa/sherpa-onnx-go-linux"
)
const (
sampleRate = 16000
channels = 1
bytesPerSample = 2
packetDuration = 100 * time.Millisecond
defaultModelsDir = "models"
)
type settings struct {
mode string
chunkDuration time.Duration
modelsDir string
threads int
previewThresholdDB float64
}
func main() {
cfg := parseFlags()
if err := validateSettings(cfg); err != nil {
log.Fatal(err)
}
if err := validatePrograms(); err != nil {
log.Fatal(err)
}
paths, err := validateModels(cfg.modelsDir, cfg.mode)
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
recognizer := newRecognizer(paths, cfg.threads)
defer sherpa.DeleteOfflineRecognizer(recognizer)
var vad *sherpa.VoiceActivityDetector
if cfg.mode == "vad" {
vad = newVAD(paths.vad, cfg.threads)
defer sherpa.DeleteVoiceActivityDetector(vad)
}
monitorSource, err := defaultMonitorSource(ctx)
if err != nil {
log.Fatalf("find default output monitor: %v", err)
}
fmt.Fprintf(os.Stderr, "Capturing from: %s\n", monitorSource)
fmt.Fprintln(os.Stderr, "Press Ctrl+C to stop.")
packets, waitCapture, err := capturePackets(ctx, monitorSource)
if err != nil {
log.Fatalf("start system-audio capture: %v", err)
}
processor := newCaptionProcessor(cfg, recognizer, vad, os.Stdout)
for packet := range packets {
processor.accept(packet)
}
processor.flush()
if err := waitCapture(); err != nil && ctx.Err() == nil {
log.Fatalf("audio capture stopped unexpectedly: %v", err)
}
}
func parseFlags() settings {
var cfg settings
flag.StringVar(&cfg.mode, "mode", "", "caption mode: vad or fixed (required)")
flag.DurationVar(&cfg.chunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
flag.StringVar(&cfg.modelsDir, "models-dir", defaultModelsDir, "directory containing downloaded Sherpa models")
flag.IntVar(&cfg.threads, "threads", 2, "CPU threads used by recognition and VAD")
flag.Float64Var(&cfg.previewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS threshold used to begin VAD previews")
flag.Parse()
return cfg
}
func validateSettings(cfg settings) error {
if cfg.mode != "fixed" && cfg.mode != "vad" {
return errors.New("--mode is required and must be either fixed or vad")
}
if cfg.chunkDuration <= 0 {
return errors.New("--chunk-duration must be positive")
}
if cfg.threads <= 0 {
return errors.New("--threads must be positive")
}
return nil
}
func validatePrograms() error {
for _, program := range []string{"pactl", "parec"} {
if _, err := exec.LookPath(program); err != nil {
return fmt.Errorf("%s was not found in PATH: %w", program, err)
}
}
return nil
}
type modelPaths struct {
encoder string
decoder string
joiner string
tokens string
vad string
}
func validateModels(modelsDir, mode string) (modelPaths, error) {
parakeetDir := filepath.Join(modelsDir, "parakeet-tdt-v2")
paths := modelPaths{
encoder: filepath.Join(parakeetDir, "encoder.int8.onnx"),
decoder: filepath.Join(parakeetDir, "decoder.int8.onnx"),
joiner: filepath.Join(parakeetDir, "joiner.int8.onnx"),
tokens: filepath.Join(parakeetDir, "tokens.txt"),
vad: filepath.Join(modelsDir, "silero_vad_v5.onnx"),
}
required := []string{paths.encoder, paths.decoder, paths.joiner, paths.tokens}
if mode == "vad" {
required = append(required, paths.vad)
}
var missing []string
for _, path := range required {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
missing = append(missing, path)
}
}
if len(missing) > 0 {
return modelPaths{}, fmt.Errorf("missing required model files:\n %s\nRun ./scripts/download-models.sh", strings.Join(missing, "\n "))
}
return paths, nil
}
func newRecognizer(paths modelPaths, threads int) *sherpa.OfflineRecognizer {
recognizer := sherpa.NewOfflineRecognizer(&sherpa.OfflineRecognizerConfig{
FeatConfig: sherpa.FeatureConfig{SampleRate: sampleRate, FeatureDim: 80},
ModelConfig: sherpa.OfflineModelConfig{
Transducer: sherpa.OfflineTransducerModelConfig{
Encoder: paths.encoder,
Decoder: paths.decoder,
Joiner: paths.joiner,
},
Tokens: paths.tokens,
NumThreads: threads,
Provider: "cpu",
ModelType: "nemo_transducer",
},
DecodingMethod: "greedy_search",
})
if recognizer == nil {
log.Fatal("create Parakeet recognizer: Sherpa returned nil")
}
return recognizer
}
func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
vad := sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{
SileroVad: sherpa.SileroVadModelConfig{
Model: model,
Threshold: 0.5,
MinSilenceDuration: 0.5,
MinSpeechDuration: 0.25,
WindowSize: 512,
MaxSpeechDuration: 30,
},
SampleRate: sampleRate,
NumThreads: threads,
Provider: "cpu",
}, 60)
if vad == nil {
log.Fatal("create Silero VAD: Sherpa returned nil")
}
return vad
}
func defaultMonitorSource(ctx context.Context) (string, error) {
output, err := exec.CommandContext(ctx, "pactl", "get-default-sink").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)) + ".monitor", nil
}
func capturePackets(ctx context.Context, monitorSource string) (<-chan []float32, func() error, error) {
cmd := exec.CommandContext(ctx, "parec",
"--device="+monitorSource,
"--format=s16le",
fmt.Sprintf("--rate=%d", sampleRate),
fmt.Sprintf("--channels=%d", channels),
"--raw",
"--latency-msec=50",
)
cmd.Stderr = os.Stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
}
if err := cmd.Start(); err != nil {
return nil, nil, err
}
packets := make(chan []float32, 50) // Five seconds at 100 ms per packet.
var waitOnce sync.Once
var waitErr error
wait := func() error {
waitOnce.Do(func() { waitErr = cmd.Wait() })
return waitErr
}
go func() {
defer close(packets)
packetBytes := make([]byte, samplesPerPacket()*bytesPerSample)
for {
n, readErr := io.ReadFull(stdout, packetBytes)
if n > 0 {
packet := pcm16LEToFloat32(packetBytes[:n])
select {
case packets <- packet:
default:
// Keep the newest audio so captions recover quickly after overload.
select {
case <-packets:
default:
}
select {
case packets <- packet:
default:
}
fmt.Fprintln(os.Stderr, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
}
}
if readErr != nil {
if ctx.Err() == nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
fmt.Fprintf(os.Stderr, "audio capture read error: %v\n", readErr)
}
return
}
}
}()
return packets, wait, nil
}
func samplesPerPacket() int { return int(sampleRate * packetDuration / time.Second) }
func pcm16LEToFloat32(pcm []byte) []float32 {
samples := make([]float32, len(pcm)/bytesPerSample)
for i := range samples {
samples[i] = float32(int16(binary.LittleEndian.Uint16(pcm[i*2:]))) / 32768
}
return samples
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"encoding/binary"
"fmt"
"math"
"time"
)
const (
sampleRate = 16000
channels = 1
bytesPerSample = 2
packetDuration = 100 * time.Millisecond
)
func samplesPerPacket() int {
return durationSamples(packetDuration)
}
func pcm16LEToFloat32(pcm []byte) []float32 {
samples := make([]float32, len(pcm)/bytesPerSample)
for i := range samples {
samples[i] = float32(int16(binary.LittleEndian.Uint16(pcm[i*2:]))) / 32768
}
return samples
}
func durationSamples(duration time.Duration) int {
return int(duration * sampleRate / time.Second)
}
func rmsDBFS(samples []float32) float64 {
if len(samples) == 0 {
return math.Inf(-1)
}
var sum float64
for _, sample := range samples {
sum += float64(sample * sample)
}
rms := math.Sqrt(sum / float64(len(samples)))
if rms == 0 {
return math.Inf(-1)
}
return 20 * math.Log10(rms)
}
func formatAudioTime(samples int) string {
return fmt.Sprintf("%.2fs", float64(samples)/sampleRate)
}
-20
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"encoding/binary" "encoding/binary"
"math" "math"
"strings"
"testing" "testing"
"time" "time"
) )
@@ -39,22 +38,3 @@ func TestRMSDBFS(t *testing.T) {
t.Fatalf("silence = %f dBFS, want -Inf", got) t.Fatalf("silence = %f dBFS, want -Inf", got)
} }
} }
func TestValidateSettings(t *testing.T) {
if err := validateSettings(settings{mode: "fixed", chunkDuration: time.Second, threads: 1}); err != nil {
t.Fatalf("valid settings failed: %v", err)
}
if err := validateSettings(settings{mode: "", chunkDuration: time.Second, threads: 1}); err == nil {
t.Fatal("missing mode unexpectedly passed")
}
}
func TestValidateModelsExplainsHowToInstallThem(t *testing.T) {
_, err := validateModels(t.TempDir(), "vad")
if err == nil {
t.Fatal("missing models unexpectedly passed validation")
}
if !strings.Contains(err.Error(), "./scripts/download-models.sh") {
t.Fatalf("missing-model error did not provide setup command: %v", err)
}
}
-25
View File
@@ -3,9 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"math"
"strings" "strings"
"time"
sherpa "github.com/k2-fsa/sherpa-onnx-go-linux" sherpa "github.com/k2-fsa/sherpa-onnx-go-linux"
) )
@@ -129,26 +127,3 @@ func (p *captionProcessor) decode(samples []float32) string {
p.recognizer.Decode(stream) p.recognizer.Decode(stream)
return strings.TrimSpace(stream.GetResult().Text) return strings.TrimSpace(stream.GetResult().Text)
} }
func durationSamples(duration time.Duration) int {
return int(duration * sampleRate / time.Second)
}
func rmsDBFS(samples []float32) float64 {
if len(samples) == 0 {
return math.Inf(-1)
}
var sum float64
for _, sample := range samples {
sum += float64(sample * sample)
}
rms := math.Sqrt(sum / float64(len(samples)))
if rms == 0 {
return math.Inf(-1)
}
return 20 * math.Log10(rms)
}
func formatAudioTime(samples int) string {
return fmt.Sprintf("%.2fs", float64(samples)/sampleRate)
}
+93
View File
@@ -0,0 +1,93 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
)
func validatePrograms() error {
for _, program := range []string{"pactl", "parec"} {
if _, err := exec.LookPath(program); err != nil {
return fmt.Errorf("%s was not found in PATH: %w", program, err)
}
}
return nil
}
func defaultMonitorSource(ctx context.Context) (string, error) {
output, err := exec.CommandContext(ctx, "pactl", "get-default-sink").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)) + ".monitor", nil
}
func capturePackets(ctx context.Context, monitorSource string) (<-chan []float32, func() error, error) {
cmd := exec.CommandContext(ctx, "parec",
"--device="+monitorSource,
"--format=s16le",
fmt.Sprintf("--rate=%d", sampleRate),
fmt.Sprintf("--channels=%d", channels),
"--raw",
"--latency-msec=50",
)
cmd.Stderr = os.Stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
}
if err := cmd.Start(); err != nil {
return nil, nil, err
}
packets := make(chan []float32, 50) // Five seconds at 100 ms per packet.
var waitOnce sync.Once
var waitErr error
wait := func() error {
waitOnce.Do(func() { waitErr = cmd.Wait() })
return waitErr
}
go func() {
defer close(packets)
packetBytes := make([]byte, samplesPerPacket()*bytesPerSample)
for {
n, readErr := io.ReadFull(stdout, packetBytes)
if n > 0 {
enqueueLatest(packets, pcm16LEToFloat32(packetBytes[:n]))
}
if readErr != nil {
if ctx.Err() == nil && !errors.Is(readErr, io.EOF) && !errors.Is(readErr, io.ErrUnexpectedEOF) {
fmt.Fprintf(os.Stderr, "audio capture read error: %v\n", readErr)
}
return
}
}
}()
return packets, wait, nil
}
func enqueueLatest(packets chan []float32, packet []float32) {
select {
case packets <- packet:
return
default:
}
// Keep the newest audio so captions recover quickly after overload.
select {
case <-packets:
default:
}
select {
case packets <- packet:
default:
}
fmt.Fprintln(os.Stderr, "warning: caption processing overloaded; dropped 100 ms of oldest audio")
}
+41
View File
@@ -0,0 +1,41 @@
package main
import (
"errors"
"flag"
"time"
)
const defaultModelsDir = "models"
type settings struct {
mode string
chunkDuration time.Duration
modelsDir string
threads int
previewThresholdDB float64
}
func parseFlags() settings {
var cfg settings
flag.StringVar(&cfg.mode, "mode", "", "caption mode: vad or fixed (required)")
flag.DurationVar(&cfg.chunkDuration, "chunk-duration", time.Second, "fixed chunk size or VAD preview refresh interval")
flag.StringVar(&cfg.modelsDir, "models-dir", defaultModelsDir, "directory containing downloaded Sherpa models")
flag.IntVar(&cfg.threads, "threads", 2, "CPU threads used by recognition and VAD")
flag.Float64Var(&cfg.previewThresholdDB, "preview-threshold-dbfs", -45, "RMS dBFS threshold used to begin VAD previews")
flag.Parse()
return cfg
}
func validateSettings(cfg settings) error {
if cfg.mode != "fixed" && cfg.mode != "vad" {
return errors.New("--mode is required and must be either fixed or vad")
}
if cfg.chunkDuration <= 0 {
return errors.New("--chunk-duration must be positive")
}
if cfg.threads <= 0 {
return errors.New("--threads must be positive")
}
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"strings"
"testing"
"time"
)
func TestValidateSettings(t *testing.T) {
if err := validateSettings(settings{mode: "fixed", chunkDuration: time.Second, threads: 1}); err != nil {
t.Fatalf("valid settings failed: %v", err)
}
if err := validateSettings(settings{mode: "", chunkDuration: time.Second, threads: 1}); err == nil {
t.Fatal("missing mode unexpectedly passed")
}
}
func TestValidateModelsExplainsHowToInstallThem(t *testing.T) {
_, err := validateModels(t.TempDir(), "vad")
if err == nil {
t.Fatal("missing models unexpectedly passed validation")
}
if !strings.Contains(err.Error(), "./scripts/download-models.sh") {
t.Fatalf("missing-model error did not provide setup command: %v", err)
}
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
sherpa "github.com/k2-fsa/sherpa-onnx-go-linux"
)
func main() {
cfg := parseFlags()
if err := validateSettings(cfg); err != nil {
log.Fatal(err)
}
if err := validatePrograms(); err != nil {
log.Fatal(err)
}
paths, err := validateModels(cfg.modelsDir, cfg.mode)
if err != nil {
log.Fatal(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
recognizer := newRecognizer(paths, cfg.threads)
defer sherpa.DeleteOfflineRecognizer(recognizer)
var vad *sherpa.VoiceActivityDetector
if cfg.mode == "vad" {
vad = newVAD(paths.vad, cfg.threads)
defer sherpa.DeleteVoiceActivityDetector(vad)
}
monitorSource, err := defaultMonitorSource(ctx)
if err != nil {
log.Fatalf("find default output monitor: %v", err)
}
fmt.Fprintf(os.Stderr, "Capturing from: %s\n", monitorSource)
fmt.Fprintln(os.Stderr, "Press Ctrl+C to stop.")
packets, waitCapture, err := capturePackets(ctx, monitorSource)
if err != nil {
log.Fatalf("start system-audio capture: %v", err)
}
processor := newCaptionProcessor(cfg, recognizer, vad, os.Stdout)
for packet := range packets {
processor.accept(packet)
}
processor.flush()
if err := waitCapture(); err != nil && ctx.Err() == nil {
log.Fatalf("audio capture stopped unexpectedly: %v", err)
}
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
sherpa "github.com/k2-fsa/sherpa-onnx-go-linux"
)
type modelPaths struct {
encoder string
decoder string
joiner string
tokens string
vad string
}
func validateModels(modelsDir, mode string) (modelPaths, error) {
parakeetDir := filepath.Join(modelsDir, "parakeet-tdt-v2")
paths := modelPaths{
encoder: filepath.Join(parakeetDir, "encoder.int8.onnx"),
decoder: filepath.Join(parakeetDir, "decoder.int8.onnx"),
joiner: filepath.Join(parakeetDir, "joiner.int8.onnx"),
tokens: filepath.Join(parakeetDir, "tokens.txt"),
vad: filepath.Join(modelsDir, "silero_vad_v5.onnx"),
}
required := []string{paths.encoder, paths.decoder, paths.joiner, paths.tokens}
if mode == "vad" {
required = append(required, paths.vad)
}
var missing []string
for _, path := range required {
info, err := os.Stat(path)
if err != nil || info.IsDir() {
missing = append(missing, path)
}
}
if len(missing) > 0 {
return modelPaths{}, fmt.Errorf("missing required model files:\n %s\nRun ./scripts/download-models.sh", strings.Join(missing, "\n "))
}
return paths, nil
}
func newRecognizer(paths modelPaths, threads int) *sherpa.OfflineRecognizer {
recognizer := sherpa.NewOfflineRecognizer(&sherpa.OfflineRecognizerConfig{
FeatConfig: sherpa.FeatureConfig{SampleRate: sampleRate, FeatureDim: 80},
ModelConfig: sherpa.OfflineModelConfig{
Transducer: sherpa.OfflineTransducerModelConfig{
Encoder: paths.encoder,
Decoder: paths.decoder,
Joiner: paths.joiner,
},
Tokens: paths.tokens,
NumThreads: threads,
Provider: "cpu",
ModelType: "nemo_transducer",
},
DecodingMethod: "greedy_search",
})
if recognizer == nil {
log.Fatal("create Parakeet recognizer: Sherpa returned nil")
}
return recognizer
}
func newVAD(model string, threads int) *sherpa.VoiceActivityDetector {
vad := sherpa.NewVoiceActivityDetector(&sherpa.VadModelConfig{
SileroVad: sherpa.SileroVadModelConfig{
Model: model,
Threshold: 0.5,
MinSilenceDuration: 0.5,
MinSpeechDuration: 0.25,
WindowSize: 512,
MaxSpeechDuration: 30,
},
SampleRate: sampleRate,
NumThreads: threads,
Provider: "cpu",
}, 60)
if vad == nil {
log.Fatal("create Silero VAD: Sherpa returned nil")
}
return vad
}