Files
Captioneer/main.go
T
2026-07-16 11:42:51 +03:00

212 lines
3.5 KiB
Go

package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"log"
"math"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
)
const (
sampleRate = 48000
channels = 2
bytesPerSample = 2 // Signed 16-bit PCM
windowDuration = 100 * time.Millisecond
minimumDBFS = -96.0
meterWidth = 50
)
func main() {
for _, program := range []string{"pactl", "parec"} {
if _, err := exec.LookPath(program); err != nil {
log.Fatalf("%s was not found in PATH: %v", program, err)
}
}
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
defaultSink, err := commandOutput(
ctx,
"pactl",
"get-default-sink",
)
if err != nil {
log.Fatalf("get default output sink: %v", err)
}
monitorSource := defaultSink + ".monitor"
fmt.Printf("Default output: %s\n", defaultSink)
fmt.Printf("Capturing from: %s\n", monitorSource)
fmt.Println("Press Ctrl+C to stop.")
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 {
log.Fatalf("open parec output: %v", err)
}
if err := cmd.Start(); err != nil {
log.Fatalf("start parec: %v", err)
}
framesPerWindow := int(
float64(sampleRate) * windowDuration.Seconds(),
)
buffer := make(
[]byte,
framesPerWindow*channels*bytesPerSample,
)
for {
n, readErr := io.ReadFull(stdout, buffer)
if n > 0 {
rmsDBFS, peakDBFS := measurePCM16LE(buffer[:n])
fmt.Printf(
"\rRMS %6.1f dBFS Peak %6.1f dBFS |%s|",
rmsDBFS,
peakDBFS,
meter(rmsDBFS, -60, 0, meterWidth),
)
}
if readErr != nil {
if ctx.Err() != nil ||
errors.Is(readErr, io.EOF) ||
errors.Is(readErr, io.ErrUnexpectedEOF) {
break
}
log.Fatalf("read PCM audio: %v", readErr)
}
}
fmt.Println()
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
log.Fatalf("parec stopped unexpectedly: %v", err)
}
}
func commandOutput(
ctx context.Context,
name string,
args ...string,
) (string, error) {
output, err := exec.CommandContext(
ctx,
name,
args...,
).Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func measurePCM16LE(pcm []byte) (
rmsDBFS float64,
peakDBFS float64,
) {
sampleCount := len(pcm) / bytesPerSample
if sampleCount == 0 {
return minimumDBFS, minimumDBFS
}
var sumSquares float64
var peak float64
for offset := 0; offset+1 < len(pcm); offset += bytesPerSample {
sample := int16(
binary.LittleEndian.Uint16(
pcm[offset : offset+2],
),
)
value := float64(sample) / 32768.0
absolute := math.Abs(value)
sumSquares += value * value
if absolute > peak {
peak = absolute
}
}
rms := math.Sqrt(
sumSquares / float64(sampleCount),
)
return amplitudeToDBFS(rms), amplitudeToDBFS(peak)
}
func amplitudeToDBFS(amplitude float64) float64 {
if amplitude <= 0 {
return minimumDBFS
}
db := 20 * math.Log10(amplitude)
if db < minimumDBFS {
return minimumDBFS
}
return db
}
func meter(
db float64,
minimum float64,
maximum float64,
width int,
) string {
ratio := (db - minimum) / (maximum - minimum)
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
filled := int(
math.Round(ratio * float64(width)),
)
return strings.Repeat("#", filled) +
strings.Repeat(" ", width-filled)
}