Add and use Speex audio resampler

This commit is contained in:
sergystepanov 2025-12-14 16:24:35 +03:00
parent 671e875f12
commit 9d54ea4c49
6 changed files with 155 additions and 154 deletions

View file

@ -54,6 +54,7 @@ RUN apt-get -q update && apt-get -q install --no-install-recommends -y \
libyuv-dev \
libjpeg-turbo8-dev \
libx264-dev \
libspeexdsp-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*

View file

@ -60,13 +60,13 @@ a better sense of performance.
```
# Ubuntu / Windows (WSL2)
apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev
apt-get install -y make gcc pkg-config libvpx-dev libx264-dev libopus-dev libsdl2-dev libyuv-dev libjpeg-turbo8-dev libspeexdsp-dev
# MacOS
brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo
brew install pkg-config libvpx x264 opus sdl2 jpeg-turbo speexdsp
# Windows (MSYS2)
pacman -Sy --noconfirm --needed git make mingw-w64-ucrt-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,SDL2,libyuv,libjpeg-turbo}
pacman -Sy --noconfirm --needed git make mingw-w64-ucrt-x86_64-{gcc,pkgconf,dlfcn,libvpx,opus,libx264,SDL2,libyuv,libjpeg-turbo,speexdsp}
```
(You don't need to download libyuv on macOS)

29
go.mod
View file

@ -4,6 +4,7 @@ go 1.25
require (
github.com/VictoriaMetrics/metrics v1.40.2
github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225
github.com/cavaliergopher/grab/v3 v3.0.1
github.com/fsnotify/fsnotify v1.9.0
github.com/goccy/go-json v0.10.5
@ -12,15 +13,15 @@ require (
github.com/knadh/koanf/maps v0.1.2
github.com/knadh/koanf/v2 v2.3.0
github.com/minio/minio-go/v7 v7.0.97
github.com/pion/ice/v4 v4.0.10
github.com/pion/ice/v4 v4.1.0
github.com/pion/interceptor v0.1.42
github.com/pion/logging v0.2.4
github.com/pion/webrtc/v4 v4.1.6
github.com/pion/webrtc/v4 v4.1.8
github.com/rs/xid v1.6.0
github.com/rs/zerolog v1.34.0
github.com/veandco/go-sdl2 v0.4.40
golang.org/x/crypto v0.45.0
golang.org/x/image v0.30.0
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
gopkg.in/yaml.v3 v3.0.1
)
@ -29,7 +30,7 @@ require (
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@ -40,23 +41,23 @@ require (
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.7 // indirect
github.com/pion/dtls/v3 v3.0.9 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.8.25 // indirect
github.com/pion/sctp v1.8.40 // indirect
github.com/pion/rtp v1.8.26 // indirect
github.com/pion/sctp v1.8.41 // indirect
github.com/pion/sdp/v3 v3.0.16 // indirect
github.com/pion/srtp/v3 v3.0.8 // indirect
github.com/pion/stun/v3 v3.0.1 // indirect
github.com/pion/srtp/v3 v3.0.9 // indirect
github.com/pion/stun/v3 v3.0.2 // indirect
github.com/pion/transport/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.3 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/tinylib/msgp v1.5.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/valyala/fastrand v1.1.0 // indirect
github.com/valyala/histogram v1.2.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

30
go.sum
View file

@ -1,5 +1,7 @@
github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225 h1:qZ9Sv2nmB9oFAZLdhsvjpBW4NyKexrSnCzjQJPfcaTU=
github.com/aam335/speexdsp v0.0.0-20190116080032-198c2d2ba225/go.mod h1:gui3wdg1cup88TpLbUDkl88CPrD+b9ICs886eDh2hOQ=
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
@ -24,6 +26,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@ -60,8 +64,12 @@ github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
github.com/pion/dtls/v3 v3.0.9 h1:4AijfFRm8mAjd1gfdlB1wzJF3fjjR/VPIpJgkEtvYmM=
github.com/pion/dtls/v3 v3.0.9/go.mod h1:abApPjgadS/ra1wvUzHLc3o2HvoxppAh+NZkyApL4Os=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/ice/v4 v4.1.0 h1:YlxIii2bTPWyC08/4hdmtYq4srbrY0T9xcTsTjldGqU=
github.com/pion/ice/v4 v4.1.0/go.mod h1:5gPbzYxqenvn05k7zKPIZFuSAufolygiy6P1U9HzvZ4=
github.com/pion/interceptor v0.1.42 h1:0/4tvNtruXflBxLfApMVoMubUMik57VZ+94U0J7cmkQ=
github.com/pion/interceptor v0.1.42/go.mod h1:g6XYTChs9XyolIQFhRHOOUS+bGVGLRfgTCUzH29EfVU=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
@ -74,20 +82,30 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.8.25 h1:b8+y44GNbwOJTYWuVan7SglX/hMlicVCAtL50ztyZHw=
github.com/pion/rtp v1.8.25/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/rtp v1.8.26 h1:VB+ESQFQhBXFytD+Gk8cxB6dXeVf2WQzg4aORvAvAAc=
github.com/pion/rtp v1.8.26/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8=
github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo=
github.com/pion/sctp v1.8.41 h1:20R4OHAno4Vky3/iE4xccInAScAa83X6nWUfyc65MIs=
github.com/pion/sctp v1.8.41/go.mod h1:2wO6HBycUH7iCssuGyc2e9+0giXVW0pyCv3ZuL8LiyY=
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM=
github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg=
github.com/pion/srtp/v3 v3.0.9 h1:lRGF4G61xxj+m/YluB3ZnBpiALSri2lTzba0kGZMrQY=
github.com/pion/srtp/v3 v3.0.9/go.mod h1:E+AuWd7Ug2Fp5u38MKnhduvpVkveXJX6J4Lq4rxUYt8=
github.com/pion/stun/v3 v3.0.1 h1:jx1uUq6BdPihF0yF33Jj2mh+C9p0atY94IkdnW174kA=
github.com/pion/stun/v3 v3.0.1/go.mod h1:RHnvlKFg+qHgoKIqtQWMOJF52wsImCAf/Jh5GjX+4Tw=
github.com/pion/stun/v3 v3.0.2 h1:BJuGEN2oLrJisiNEJtUTJC4BGbzbfp37LizfqswblFU=
github.com/pion/stun/v3 v3.0.2/go.mod h1:JFJKfIWvt178MCF5H/YIgZ4VX3LYE77vca4b9HP60SA=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/turn/v4 v4.1.3 h1:jVNW0iR05AS94ysEtvzsrk3gKs9Zqxf6HmnsLfRvlzA=
github.com/pion/turn/v4 v4.1.3/go.mod h1:TD/eiBUf5f5LwXbCJa35T7dPtTpCHRJ9oJWmyPLVT3A=
github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw=
github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU=
github.com/pion/webrtc/v4 v4.1.8 h1:ynkjfiURDQ1+8EcJsoa60yumHAmyeYjz08AaOuor+sk=
github.com/pion/webrtc/v4 v4.1.8/go.mod h1:KVaARG2RN0lZx0jc7AWTe38JpPv+1/KicOZ9jN52J/s=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -101,6 +119,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
@ -111,17 +131,27 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View file

@ -341,9 +341,9 @@ encoder:
frames:
- 10
- 5
# linear (1) or nearest neighbour (0) audio resampler
# linear should sound slightly better
resampler: 1
# speex (2), linear (1) or nearest neighbour (0) audio resampler
# linear should sound slightly better than 0
resampler: 2
video:
# h264, vpx (vp8) or vp9
codec: h264

View file

@ -1,28 +1,28 @@
package media
import "errors"
import (
"errors"
"github.com/aam335/speexdsp"
)
type ResampleAlgo uint8
const (
ResampleNearest ResampleAlgo = iota
ResampleLinear
ResampleSpeex
)
// preallocated scratch buffer for resampling output
// size for max Opus frame: 60ms at 48kHz stereo = 48000 * 0.06 * 2 = 5760 samples
var stretchBuf = make(samples, 5760)
// buffer is a simple non-concurrent safe buffer for audio samples.
type buffer struct {
useResample bool
algo ResampleAlgo
srcHz int
raw samples
buckets []bucket
bi int
raw samples
scratch samples
buckets []bucket
resampler *speexdsp.Resampler
srcHz int
dstHz int
bi int
algo ResampleAlgo
}
type bucket struct {
@ -33,181 +33,150 @@ type bucket struct {
}
func newBuffer(frames []float32, hz int) (*buffer, error) {
if hz < 2000 {
return nil, errors.New("hz should be > 2000")
}
if len(frames) == 0 {
return nil, errors.New("frames list is empty")
if hz < 2000 || len(frames) == 0 {
return nil, errors.New("invalid params")
}
buf := buffer{srcHz: hz}
totalSize := 0
var totalSize int
for _, f := range frames {
totalSize += frameStereoSamples(hz, f)
totalSize += stereoSamples(hz, f)
}
if totalSize == 0 {
return nil, errors.New("calculated buffer size is 0, check params")
return nil, errors.New("zero buffer size")
}
buf.raw = make(samples, totalSize)
buf := &buffer{
raw: make(samples, totalSize),
scratch: make(samples, 5760),
srcHz: hz,
dstHz: hz,
}
// map buckets to the raw continuous array
offset := 0
for _, f := range frames {
size := frameStereoSamples(hz, f)
buf.buckets = append(buf.buckets, bucket{
mem: buf.raw[offset : offset+size],
ms: f,
})
size := stereoSamples(hz, f)
buf.buckets = append(buf.buckets, bucket{mem: buf.raw[offset : offset+size], ms: f, dst: size})
offset += size
}
// start with the largest bucket (last one, assuming frames are sorted ascending)
buf.bi = len(buf.buckets) - 1
return &buf, nil
return buf, nil
}
// cur returns the current bucket pointer
func (b *buffer) cur() *bucket { return &b.buckets[b.bi] }
func (b *buffer) close() {
if b.resampler != nil {
b.resampler.Destroy()
b.resampler = nil
}
}
func (b *buffer) resample(targetHz int, algo ResampleAlgo) error {
b.algo = algo
b.dstHz = targetHz
for i := range b.buckets {
b.buckets[i].dst = stereoSamples(targetHz, b.buckets[i].ms)
}
if algo == ResampleSpeex {
var err error
if b.resampler, err = speexdsp.ResamplerInit(2, b.srcHz, targetHz, speexdsp.QualityDesktop); err != nil {
return err
}
}
return nil
}
func (b *buffer) write(s samples, onFull func(samples, float32)) int {
read := 0
for read < len(s) {
cur := &b.buckets[b.bi]
n := copy(cur.mem[cur.p:], s[read:])
read += n
cur.p += n
if cur.p == len(cur.mem) {
onFull(b.stretch(cur.mem, cur.dst), cur.ms)
b.choose(len(s) - read)
b.buckets[b.bi].p = 0
}
}
return read
}
// choose selects the best bucket for the remaining samples.
// It picks the largest bucket that can be completely filled.
// Buckets should be sorted by size ascending for this to work optimally.
func (b *buffer) choose(remaining int) {
// search from largest to smallest
for i := len(b.buckets) - 1; i >= 0; i-- {
if remaining >= len(b.buckets[i].mem) {
b.bi = i
return
}
}
// fall back to smallest bucket if remaining < all bucket sizes
b.bi = 0
}
// resample enables resampling to target Hz with specified algorithm
func (b *buffer) resample(targetHz int, algo ResampleAlgo) {
b.useResample = true
b.algo = algo
for i := range b.buckets {
b.buckets[i].dst = frameStereoSamples(targetHz, b.buckets[i].ms)
}
}
// stretch applies the selected resampling algorithm
func (b *buffer) stretch(src samples, dstSize int) samples {
switch b.algo {
case ResampleNearest:
return stretchNearest(src, dstSize)
case ResampleLinear:
return stretchLinear(src, dstSize)
default:
return stretchLinear(src, dstSize)
}
}
// write fills the buffer and calls onFull when a complete frame is ready.
// returns the number of samples consumed.
func (b *buffer) write(s samples, onFull func(samples, float32)) int {
read := 0
for read < len(s) {
cur := b.cur()
// copy all samples into current bucket
n := copy(cur.mem[cur.p:], s[read:])
read += n
cur.p += n
// bucket is full - emit frame
if cur.p == len(cur.mem) {
if b.useResample {
onFull(b.stretch(cur.mem, cur.dst), cur.ms)
} else {
onFull(cur.mem, cur.ms)
case ResampleSpeex:
if b.resampler != nil {
if _, out, err := b.resampler.PocessIntInterleaved(src); err == nil {
if len(out) == dstSize {
return out
}
src = out // use speex output for linear correction
}
// select next bucket and reset write position
b.choose(len(s) - read)
b.cur().p = 0
}
fallthrough
case ResampleLinear:
return b.linear(src, dstSize)
case ResampleNearest:
return b.nearest(src, dstSize)
default:
return b.linear(src, dstSize)
}
return read
}
// frameStereoSamples calculates stereo frame size in samples.
// e.g., 48000 Hz * 20ms = 960 samples/channel * 2 channels = 1920 total samples
func frameStereoSamples(hz int, ms float32) int {
samplesPerChannel := int(float32(hz)*ms/1000 + 0.5) // round to nearest
return samplesPerChannel * 2 // stereo
}
// stretchLinear resamples stereo audio using linear interpolation.
func stretchLinear(src samples, dstSize int) samples {
func (b *buffer) linear(src samples, dstSize int) samples {
srcLen := len(src)
if srcLen < 2 || dstSize < 2 {
return stretchBuf[:dstSize]
return b.scratch[:dstSize]
}
out := stretchBuf[:dstSize]
srcPairs := srcLen / 2
dstPairs := dstSize / 2
// Fixed-point ratio for precision (16.16 fixed point)
out := b.scratch[:dstSize]
srcPairs, dstPairs := srcLen/2, dstSize/2
ratio := ((srcPairs - 1) << 16) / (dstPairs - 1)
for i := 0; i < dstPairs; i++ {
// Calculate source position in fixed-point
pos := i * ratio
srcIdx := pos >> 16
frac := pos & 0xFFFF
idx, frac := (pos>>16)*2, pos&0xFFFF
di := i * 2
dstIdx := i * 2
if srcIdx >= srcPairs-1 {
// Last sample - no interpolation
out[dstIdx] = src[srcLen-2]
out[dstIdx+1] = src[srcLen-1]
if idx >= srcLen-2 {
out[di], out[di+1] = src[srcLen-2], src[srcLen-1]
} else {
// Linear interpolation for both channels
srcBase := srcIdx * 2
// Left channel
l0 := int32(src[srcBase])
l1 := int32(src[srcBase+2])
out[dstIdx] = int16(l0 + ((l1-l0)*int32(frac))>>16)
// Right channel
r0 := int32(src[srcBase+1])
r1 := int32(src[srcBase+3])
out[dstIdx+1] = int16(r0 + ((r1-r0)*int32(frac))>>16)
out[di] = int16(int32(src[idx]) + ((int32(src[idx+2])-int32(src[idx]))*int32(frac))>>16)
out[di+1] = int16(int32(src[idx+1]) + ((int32(src[idx+3])-int32(src[idx+1]))*int32(frac))>>16)
}
}
return out
}
// stretchNearest is a faster nearest-neighbor version if quality isn't critical
func stretchNearest(src samples, dstSize int) samples {
func (b *buffer) nearest(src samples, dstSize int) samples {
srcLen := len(src)
if srcLen < 2 || dstSize < 2 {
return stretchBuf[:dstSize]
return b.scratch[:dstSize]
}
out := stretchBuf[:dstSize]
srcPairs := srcLen / 2
dstPairs := dstSize / 2
out := b.scratch[:dstSize]
srcPairs, dstPairs := srcLen/2, dstSize/2
for i := 0; i < dstPairs; i++ {
srcIdx := (i * srcPairs / dstPairs) * 2
dstIdx := i * 2
out[dstIdx] = src[srcIdx]
out[dstIdx+1] = src[srcIdx+1]
si := (i * srcPairs / dstPairs) * 2
di := i * 2
out[di], out[di+1] = src[si], src[si+1]
}
return out
}
func stereoSamples(hz int, ms float32) int {
return int(float32(hz)*ms/1000+0.5) * 2
}