Parent directory

audio.go

4292 bytes
  1package main
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/binary"
  7	"fmt"
  8	"math"
  9	"math/rand"
 10	"os"
 11	"os/exec"
 12	"strconv"
 13	"strings"
 14	"time"
 15)
 16
 17const (
 18	sampleRate   = 44100
 19	searchWindow = 30 // seconds of file decoded to find a good start point
 20)
 21
 22// decodeWindow decodes maxSec seconds of a file starting at offsetSec into
 23// mono s16le PCM.
 24func decodeWindow(path string, offsetSec, maxSec int) ([]int16, error) {
 25	cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
 26		"-ss", strconv.Itoa(offsetSec),
 27		"-i", path,
 28		"-t", strconv.Itoa(maxSec),
 29		"-ac", "1",
 30		"-ar", strconv.Itoa(sampleRate),
 31		"-f", "s16le", "-")
 32
 33	var out bytes.Buffer
 34	cmd.Stdout = &out
 35	if err := cmd.Run(); err != nil {
 36		return nil, fmt.Errorf("ffmpeg decode: %w", err)
 37	}
 38
 39	data := out.Bytes()
 40	n := len(data) / 2
 41	samples := make([]int16, n)
 42	for i := 0; i < n; i++ {
 43		samples[i] = int16(binary.LittleEndian.Uint16(data[2*i:]))
 44	}
 45	return samples, nil
 46}
 47
 48// findStart returns the sample offset where the RMS level first crosses the
 49// average RMS of the decoded window, so silent stretches are skipped. It
 50// scans in 10ms windows and falls back to 0 if the audio is silent throughout.
 51func findStart(samples []int16) int {
 52	win := sampleRate / 100 // 10ms
 53
 54	rmsVals := make([]float64, 0, len(samples)/win)
 55	var sum float64
 56	for i := 0; i+win <= len(samples); i += win {
 57		v := rms(samples[i : i+win])
 58		rmsVals = append(rmsVals, v)
 59		sum += v
 60	}
 61	if len(rmsVals) == 0 {
 62		return 0
 63	}
 64	avg := sum / float64(len(rmsVals))
 65	for i, v := range rmsVals {
 66		if v >= avg {
 67			return i * win
 68		}
 69	}
 70	return 0
 71}
 72
 73// rms computes the root-mean-square amplitude of a sample slice.
 74func rms(s []int16) float64 {
 75	if len(s) == 0 {
 76		return 0
 77	}
 78	var sum int64
 79	for _, v := range s {
 80		sum += int64(v) * int64(v)
 81	}
 82	return math.Sqrt(float64(sum) / float64(len(s)))
 83}
 84
 85// duration returns the total length of path in seconds.
 86func duration(path string) (int, error) {
 87	cmd := exec.Command("ffprobe", "-v", "error",
 88		"-show_entries", "format=duration",
 89		"-of", "default=noprint_wrappers=1:nokey=1", path)
 90	var out bytes.Buffer
 91	cmd.Stdout = &out
 92	if err := cmd.Run(); err != nil {
 93		return 0, fmt.Errorf("ffprobe: %w", err)
 94	}
 95	var d float64
 96	if _, err := fmt.Sscanf(strings.TrimSpace(out.String()), "%f", &d); err != nil {
 97		return 0, fmt.Errorf("parse duration: %w", err)
 98	}
 99	return int(d), nil
100}
101
102// pickStart chooses a random searchWindow-second window within the song and
103// returns the offset (in seconds) of a point in it where the volume is above
104// the window's average, so silent stretches are skipped.
105func pickStart(songPath string) (int, error) {
106	total, err := duration(songPath)
107	if err != nil {
108		return 0, err
109	}
110
111	maxOffset := total - searchWindow
112	if maxOffset < 0 {
113		maxOffset = 0
114	}
115	offset := rand.Intn(maxOffset + 1)
116
117	samples, err := decodeWindow(songPath, offset, searchWindow)
118	if err != nil {
119		return 0, err
120	}
121	if len(samples) == 0 {
122		return 0, fmt.Errorf("no audio decoded from %s", songPath)
123	}
124
125	start := findStart(samples)
126	return offset + start/sampleRate, nil
127}
128
129// generateSegment decodes `seconds` of audio starting at startSec into an
130// in-memory mono WAV buffer at sampleRate Hz. The bytes can be played locally
131// with aplay (TUI) or served over HTTP (web UI).
132func generateSegment(songPath string, startSec, seconds int) ([]byte, error) {
133	cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
134		"-ss", strconv.Itoa(startSec), "-t", strconv.Itoa(seconds),
135		"-i", songPath, "-ac", "1", "-ar", strconv.Itoa(sampleRate),
136		"-f", "wav", "-")
137	var out bytes.Buffer
138	cmd.Stdout = &out
139	if err := cmd.Run(); err != nil {
140		return nil, fmt.Errorf("ffmpeg encode: %w", err)
141	}
142	return out.Bytes(), nil
143}
144
145// playSegment decodes a segment and plays it with aplay (used by the TUI).
146func playSegment(songPath string, startSec, seconds int) error {
147	wav, err := generateSegment(songPath, startSec, seconds)
148	if err != nil {
149		return err
150	}
151	// timeout to fix aplay hang
152	timeout := time.Duration(seconds)*time.Second + 200*time.Millisecond
153	ctx, cancel := context.WithTimeout(context.Background(), timeout)
154	defer cancel()
155	cmd := exec.CommandContext(ctx, "aplay", "-q")
156	cmd.Stdin = bytes.NewReader(wav)
157	cmd.Stdout = os.Stderr
158	cmd.Stderr = os.Stderr
159	return cmd.Run()
160}