From 9d54ea4c493cbddd0fbd777fef2abb413a99e404 Mon Sep 17 00:00:00 2001 From: sergystepanov Date: Sun, 14 Dec 2025 16:24:35 +0300 Subject: [PATCH] Add and use Speex audio resampler --- Dockerfile | 1 + README.md | 6 +- go.mod | 29 ++--- go.sum | 30 +++++ pkg/config/config.yaml | 6 +- pkg/worker/media/buffer.go | 237 ++++++++++++++++--------------------- 6 files changed, 155 insertions(+), 154 deletions(-) diff --git a/Dockerfile b/Dockerfile index b3da7099..3b3f23c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/* diff --git a/README.md b/README.md index 1f357d4a..1054d2ab 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/go.mod b/go.mod index d5ebd227..67bdd591 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 55499af2..a8bf22f5 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/config/config.yaml b/pkg/config/config.yaml index 275eea59..9b142aff 100644 --- a/pkg/config/config.yaml +++ b/pkg/config/config.yaml @@ -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 diff --git a/pkg/worker/media/buffer.go b/pkg/worker/media/buffer.go index 836e1c53..223c0e43 100644 --- a/pkg/worker/media/buffer.go +++ b/pkg/worker/media/buffer.go @@ -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 +}