243 lines
6.4 KiB
Go
243 lines
6.4 KiB
Go
package player
|
|
|
|
import (
|
|
"math"
|
|
"sync/atomic"
|
|
|
|
"github.com/gopxl/beep/v2"
|
|
)
|
|
|
|
// Time-stretching constants tuned for natural speech and music, adapted from
|
|
// SoundTouch's proven defaults. The long sequence length means crossfade
|
|
// events are infrequent (~12/sec), and most output is a direct source copy.
|
|
const (
|
|
tsSeq = 3584 // sequence: ~81ms @44.1kHz — time between crossfades
|
|
tsOvlp = 512 // overlap: ~12ms — crossfade region
|
|
tsWin = tsSeq + tsOvlp // source window per frame (4096)
|
|
tsSearch = 1024 // search: ±~23ms — covers multiple pitch periods
|
|
)
|
|
|
|
// Pre-computed linear crossfade table: alpha[i] = i / tsOvlp.
|
|
// Avoids per-sample division in the hot crossfade loop.
|
|
var tsAlpha [tsOvlp]float64
|
|
|
|
func init() {
|
|
for i := range tsAlpha {
|
|
tsAlpha[i] = float64(i) / float64(tsOvlp)
|
|
}
|
|
}
|
|
|
|
// speedStreamer wraps a beep.Streamer and adjusts playback speed without
|
|
// changing pitch, using WSOLA (Waveform Similarity Overlap-Add) time-stretching.
|
|
// The speed ratio is stored atomically so the UI thread can change it
|
|
// while the audio thread reads it.
|
|
type speedStreamer struct {
|
|
s beep.Streamer
|
|
speed *atomic.Uint64 // ratio as Float64bits; 1.0 = normal
|
|
|
|
in [][2]float64 // source buffer
|
|
inN int // valid sample count
|
|
inPos float64 // fractional analysis cursor
|
|
|
|
out [][2]float64 // output ring buffer
|
|
outRd int
|
|
outWr int
|
|
|
|
tail [tsOvlp][2]float64 // previous frame's trailing samples for crossfade
|
|
// No tail data exists until the first frame writes to it;
|
|
// outWr == 0 && outRd == 0 signals this initial state.
|
|
}
|
|
|
|
func newSpeedStreamer(s beep.Streamer, speed *atomic.Uint64) *speedStreamer {
|
|
return &speedStreamer{
|
|
s: s,
|
|
speed: speed,
|
|
in: make([][2]float64, 16384),
|
|
out: make([][2]float64, 8192),
|
|
}
|
|
}
|
|
|
|
// Stream produces output samples. At speed 1.0x it passes through directly.
|
|
// At other speeds it applies WSOLA time-stretching to preserve pitch.
|
|
func (ss *speedStreamer) Stream(samples [][2]float64) (int, bool) {
|
|
speed := math.Float64frombits(ss.speed.Load())
|
|
if speed <= 0 || speed == 1.0 {
|
|
return ss.passthrough(samples)
|
|
}
|
|
|
|
for ss.outWr-ss.outRd < len(samples) {
|
|
if !ss.wsolaFrame(speed) {
|
|
break
|
|
}
|
|
}
|
|
n := ss.drainOut(samples)
|
|
return n, n > 0
|
|
}
|
|
|
|
func (ss *speedStreamer) passthrough(samples [][2]float64) (int, bool) {
|
|
d := ss.drainOut(samples)
|
|
if d == len(samples) {
|
|
return d, true
|
|
}
|
|
// Drain unconsumed source samples before switching to direct reads.
|
|
srcStart := int(math.Round(ss.inPos))
|
|
if srcAvail := ss.inN - srcStart; srcAvail > 0 {
|
|
n := min(len(samples)-d, srcAvail)
|
|
copy(samples[d:d+n], ss.in[srcStart:srcStart+n])
|
|
d += n
|
|
ss.inPos += float64(n)
|
|
if d == len(samples) {
|
|
return d, true
|
|
}
|
|
}
|
|
// Reset WSOLA state for clean re-entry.
|
|
ss.outRd = 0
|
|
ss.outWr = 0
|
|
ss.inN = 0
|
|
ss.inPos = 0
|
|
n, ok := ss.s.Stream(samples[d:])
|
|
total := d + n
|
|
return total, ok || total > 0
|
|
}
|
|
|
|
func (ss *speedStreamer) drainOut(dst [][2]float64) int {
|
|
avail := ss.outWr - ss.outRd
|
|
n := min(len(dst), avail)
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
copy(dst[:n], ss.out[ss.outRd:ss.outRd+n])
|
|
ss.outRd += n
|
|
if ss.outRd > 8192 {
|
|
rem := ss.outWr - ss.outRd
|
|
if rem > 0 {
|
|
copy(ss.out, ss.out[ss.outRd:ss.outWr])
|
|
}
|
|
ss.outRd = 0
|
|
ss.outWr = rem
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (ss *speedStreamer) fillSource(need int) bool {
|
|
if drop := int(ss.inPos) - tsSearch; drop > 0 {
|
|
keep := ss.inN - drop
|
|
if keep > 0 {
|
|
copy(ss.in[:keep], ss.in[drop:ss.inN])
|
|
} else {
|
|
keep = 0
|
|
}
|
|
ss.inN = keep
|
|
ss.inPos -= float64(drop)
|
|
}
|
|
for ss.inN < need {
|
|
toRead := max(need-ss.inN, 4096)
|
|
if ss.inN+toRead > cap(ss.in) {
|
|
newIn := make([][2]float64, ss.inN+toRead)
|
|
copy(newIn[:ss.inN], ss.in[:ss.inN])
|
|
ss.in = newIn
|
|
}
|
|
n, _ := ss.s.Stream(ss.in[ss.inN : ss.inN+toRead])
|
|
ss.inN += n
|
|
if n == 0 {
|
|
return ss.inN >= need
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// wsolaFrame produces one synthesis frame of tsSeq output samples.
|
|
//
|
|
// Frame layout in source:
|
|
//
|
|
// [crossfade tsOvlp][direct copy tsSeq-tsOvlp][tail tsOvlp]
|
|
// |<----------- tsSeq (output) ------------>||<-- saved -->|
|
|
// |<------------------ tsWin (source read) ---------------->|
|
|
func (ss *speedStreamer) wsolaFrame(speed float64) bool {
|
|
expected := int(math.Round(ss.inPos))
|
|
needed := expected + tsWin + tsSearch + 1
|
|
if !ss.fillSource(needed) && expected+tsSeq > ss.inN {
|
|
return false
|
|
}
|
|
|
|
first := ss.outWr == 0 && ss.outRd == 0
|
|
|
|
srcOff := expected
|
|
if !first {
|
|
srcOff = ss.searchBestOffset(expected)
|
|
}
|
|
|
|
if srcOff+tsWin > ss.inN {
|
|
srcOff = max(0, ss.inN-tsWin)
|
|
}
|
|
if srcOff+tsSeq > ss.inN {
|
|
return false
|
|
}
|
|
|
|
// Grow output buffer if needed.
|
|
if ss.outWr+tsSeq > cap(ss.out) {
|
|
newOut := make([][2]float64, ss.outWr+tsSeq+4096)
|
|
copy(newOut[:ss.outWr], ss.out[:ss.outWr])
|
|
ss.out = newOut
|
|
}
|
|
|
|
if first {
|
|
copy(ss.out[ss.outWr:ss.outWr+tsSeq], ss.in[srcOff:srcOff+tsSeq])
|
|
} else {
|
|
// Crossfade the overlap region using pre-computed alpha table.
|
|
for i := range tsOvlp {
|
|
a := tsAlpha[i]
|
|
b := 1 - a
|
|
ss.out[ss.outWr+i] = [2]float64{
|
|
b*ss.tail[i][0] + a*ss.in[srcOff+i][0],
|
|
b*ss.tail[i][1] + a*ss.in[srcOff+i][1],
|
|
}
|
|
}
|
|
// Direct copy the rest — unmodified source samples.
|
|
copy(ss.out[ss.outWr+tsOvlp:ss.outWr+tsSeq],
|
|
ss.in[srcOff+tsOvlp:srcOff+tsSeq])
|
|
}
|
|
ss.outWr += tsSeq
|
|
|
|
// Save tail for next frame's crossfade.
|
|
copy(ss.tail[:], ss.in[srcOff+tsSeq:srcOff+tsWin])
|
|
|
|
ss.inPos += float64(tsSeq) * speed
|
|
return true
|
|
}
|
|
|
|
// searchBestOffset finds the source position near expected whose start best
|
|
// matches the previous tail, using normalized cross-correlation.
|
|
// Normalizing prevents bias toward loud sections. If no good match is found
|
|
// (all correlations negative or silent), falls back to the expected offset.
|
|
func (ss *speedStreamer) searchBestOffset(expected int) int {
|
|
lo := max(0, expected-tsSearch)
|
|
hi := max(min(ss.inN-tsWin, expected+tsSearch), lo)
|
|
|
|
bestOff := min(max(expected, lo), hi)
|
|
var bestScore float64
|
|
|
|
for off := lo; off <= hi; off++ {
|
|
var corr, norm float64
|
|
for i := range tsOvlp {
|
|
corr += ss.tail[i][0]*ss.in[off+i][0] + ss.tail[i][1]*ss.in[off+i][1]
|
|
norm += ss.in[off+i][0]*ss.in[off+i][0] + ss.in[off+i][1]*ss.in[off+i][1]
|
|
}
|
|
if norm < 1e-9 || corr <= 0 {
|
|
continue
|
|
}
|
|
// corr^2/norm avoids sqrt; equivalent ranking to corr/sqrt(norm).
|
|
score := corr * corr / norm
|
|
if score > bestScore {
|
|
bestScore = score
|
|
bestOff = off
|
|
}
|
|
}
|
|
return bestOff
|
|
}
|
|
|
|
// Err forwards to the wrapped streamer's error method.
|
|
func (ss *speedStreamer) Err() error {
|
|
return ss.s.Err()
|
|
}
|