gbx-codecs 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gbx_codecs-0.1.0/.github/workflows/test.yml +16 -0
- gbx_codecs-0.1.0/.gitignore +6 -0
- gbx_codecs-0.1.0/LICENSE +21 -0
- gbx_codecs-0.1.0/PKG-INFO +73 -0
- gbx_codecs-0.1.0/README.md +53 -0
- gbx_codecs-0.1.0/licenses/LICENSE.mlx-audio.txt +21 -0
- gbx_codecs-0.1.0/pyproject.toml +39 -0
- gbx_codecs-0.1.0/src/gbx_codecs/__init__.py +8 -0
- gbx_codecs-0.1.0/src/gbx_codecs/__main__.py +98 -0
- gbx_codecs-0.1.0/src/gbx_codecs/audio.py +88 -0
- gbx_codecs-0.1.0/src/gbx_codecs/fish_s1/__init__.py +5 -0
- gbx_codecs-0.1.0/src/gbx_codecs/fish_s1/dac.py +1561 -0
- gbx_codecs-0.1.0/src/gbx_codecs/protocol.py +44 -0
- gbx_codecs-0.1.0/src/gbx_codecs/worker.py +135 -0
- gbx_codecs-0.1.0/tests/test_audio.py +38 -0
- gbx_codecs-0.1.0/tests/test_fish_s1.py +52 -0
- gbx_codecs-0.1.0/tests/test_protocol.py +44 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
branches: [main]
|
|
5
|
+
pull_request:
|
|
6
|
+
jobs:
|
|
7
|
+
test:
|
|
8
|
+
runs-on: macos-14
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with:
|
|
13
|
+
python-version: "3.12"
|
|
14
|
+
- run: pip install ".[test]" "ruff==0.17.3"
|
|
15
|
+
- run: ruff check . && ruff format --check .
|
|
16
|
+
- run: pytest -q tests
|
gbx_codecs-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GREENBITAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gbx-codecs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Audio codecs for speech models served by gbx-lm, run as a companion process on Apple silicon
|
|
5
|
+
Project-URL: Homepage, https://github.com/GreenBitAI/gbx-codecs
|
|
6
|
+
Author: GreenBitAI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
License-File: licenses/LICENSE.mlx-audio.txt
|
|
10
|
+
Classifier: Operating System :: MacOS
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: miniaudio>=1.59
|
|
15
|
+
Requires-Dist: mlx>=0.29
|
|
16
|
+
Requires-Dist: numpy>=1.24
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest; extra == 'test'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# gbx-codecs
|
|
22
|
+
|
|
23
|
+
Audio codecs for the speech models that [gbx-lm](https://github.com/GreenBitAI/gbx-lm) serves, on Apple silicon.
|
|
24
|
+
|
|
25
|
+
A speech model generated by gbx-lm produces **codes**, not audio. This package turns codes into audio, and reference audio into codes. It runs as a **separate process** that gbx-lm starts and talks to over a pipe, so the server itself needs none of these dependencies, and the codec can be installed, upgraded or removed without touching gbx-lm.
|
|
26
|
+
|
|
27
|
+
| codec | used by | rate | codebooks |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| `fish_s1` (Fish Audio S1 DAC) | Fish Audio Drama 3 | 44.1 kHz mono, 21.5 frames/s | 10 |
|
|
30
|
+
|
|
31
|
+
The codec weights come with the model repository; this package holds code only.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
Python 3.10 or newer on an Apple silicon Mac. A virtual environment of its own is recommended:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 -m venv ~/.gbx-codecs
|
|
39
|
+
~/.gbx-codecs/bin/pip install gbx-codecs
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Dependencies: `mlx`, `numpy`, and `miniaudio` (to read reference audio in wav, flac or mp3). No PyTorch and no ffmpeg. The process needs about 3 GB of memory while decoding (Fish S1: 1.75 GB of weights, decoded 8 frames at a time).
|
|
43
|
+
|
|
44
|
+
Check it against a downloaded model:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
~/.gbx-codecs/bin/gbx-codecs check /path/to/the/model
|
|
48
|
+
# codec OK: 10 codebooks, 44 frames for 2 s, 44100 Hz, round-trip correlation 0.9..
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Then tell gbx-lm which Python to start it with (`--codec_python ~/.gbx-codecs/bin/python`, or `GBX__SERVER__CODEC_PYTHON`). See the model card of the speech model for the full setup.
|
|
52
|
+
|
|
53
|
+
## Command line
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
gbx-codecs encode MODEL_DIR reference.wav codes.npy # audio -> int32 codes [codebooks, frames]
|
|
57
|
+
gbx-codecs decode MODEL_DIR codes.npy out.wav # codes -> 16-bit wav
|
|
58
|
+
gbx-codecs serve # the companion process gbx-lm starts
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Protocol
|
|
62
|
+
|
|
63
|
+
`gbx-codecs serve` reads requests on stdin and writes replies on stdout; logs go to stderr. Each message is `GBXC`, a 4-byte header length and an 8-byte payload length (big-endian), a UTF-8 JSON header, then the payload. Operations: `hello`, `load` (codec, model directory), `encode` (audio file to int32 codes), `begin` (output sample rate), `decode` (int32 codes to 16-bit PCM, streamed), `end`, `quit`. See `src/gbx_codecs/protocol.py` and `src/gbx_codecs/worker.py`.
|
|
64
|
+
|
|
65
|
+
Decoding is streaming: frames sent a piece at a time decode to the same audio whatever the size of the pieces, to within one 16-bit step, and resampling (for example to 24 kHz for OpenAI-style `pcm` output) carries its state across pieces.
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT, see [LICENSE](LICENSE).
|
|
70
|
+
|
|
71
|
+
The Fish Audio S1 DAC implementation in `src/gbx_codecs/fish_s1/dac.py` is ported from [mlx-audio](https://github.com/Blaizzy/mlx-audio) (MIT License, Copyright (c) 2024 Prince Canuma; see [licenses/LICENSE.mlx-audio.txt](licenses/LICENSE.mlx-audio.txt)). The codec weights are distributed with the models under the model's own license; this package does not include them.
|
|
72
|
+
|
|
73
|
+
Not affiliated with or endorsed by Fish Audio.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# gbx-codecs
|
|
2
|
+
|
|
3
|
+
Audio codecs for the speech models that [gbx-lm](https://github.com/GreenBitAI/gbx-lm) serves, on Apple silicon.
|
|
4
|
+
|
|
5
|
+
A speech model generated by gbx-lm produces **codes**, not audio. This package turns codes into audio, and reference audio into codes. It runs as a **separate process** that gbx-lm starts and talks to over a pipe, so the server itself needs none of these dependencies, and the codec can be installed, upgraded or removed without touching gbx-lm.
|
|
6
|
+
|
|
7
|
+
| codec | used by | rate | codebooks |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `fish_s1` (Fish Audio S1 DAC) | Fish Audio Drama 3 | 44.1 kHz mono, 21.5 frames/s | 10 |
|
|
10
|
+
|
|
11
|
+
The codec weights come with the model repository; this package holds code only.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Python 3.10 or newer on an Apple silicon Mac. A virtual environment of its own is recommended:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 -m venv ~/.gbx-codecs
|
|
19
|
+
~/.gbx-codecs/bin/pip install gbx-codecs
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Dependencies: `mlx`, `numpy`, and `miniaudio` (to read reference audio in wav, flac or mp3). No PyTorch and no ffmpeg. The process needs about 3 GB of memory while decoding (Fish S1: 1.75 GB of weights, decoded 8 frames at a time).
|
|
23
|
+
|
|
24
|
+
Check it against a downloaded model:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
~/.gbx-codecs/bin/gbx-codecs check /path/to/the/model
|
|
28
|
+
# codec OK: 10 codebooks, 44 frames for 2 s, 44100 Hz, round-trip correlation 0.9..
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then tell gbx-lm which Python to start it with (`--codec_python ~/.gbx-codecs/bin/python`, or `GBX__SERVER__CODEC_PYTHON`). See the model card of the speech model for the full setup.
|
|
32
|
+
|
|
33
|
+
## Command line
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
gbx-codecs encode MODEL_DIR reference.wav codes.npy # audio -> int32 codes [codebooks, frames]
|
|
37
|
+
gbx-codecs decode MODEL_DIR codes.npy out.wav # codes -> 16-bit wav
|
|
38
|
+
gbx-codecs serve # the companion process gbx-lm starts
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Protocol
|
|
42
|
+
|
|
43
|
+
`gbx-codecs serve` reads requests on stdin and writes replies on stdout; logs go to stderr. Each message is `GBXC`, a 4-byte header length and an 8-byte payload length (big-endian), a UTF-8 JSON header, then the payload. Operations: `hello`, `load` (codec, model directory), `encode` (audio file to int32 codes), `begin` (output sample rate), `decode` (int32 codes to 16-bit PCM, streamed), `end`, `quit`. See `src/gbx_codecs/protocol.py` and `src/gbx_codecs/worker.py`.
|
|
44
|
+
|
|
45
|
+
Decoding is streaming: frames sent a piece at a time decode to the same audio whatever the size of the pieces, to within one 16-bit step, and resampling (for example to 24 kHz for OpenAI-style `pcm` output) carries its state across pieces.
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT, see [LICENSE](LICENSE).
|
|
50
|
+
|
|
51
|
+
The Fish Audio S1 DAC implementation in `src/gbx_codecs/fish_s1/dac.py` is ported from [mlx-audio](https://github.com/Blaizzy/mlx-audio) (MIT License, Copyright (c) 2024 Prince Canuma; see [licenses/LICENSE.mlx-audio.txt](licenses/LICENSE.mlx-audio.txt)). The codec weights are distributed with the models under the model's own license; this package does not include them.
|
|
52
|
+
|
|
53
|
+
Not affiliated with or endorsed by Fish Audio.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Prince Canuma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gbx-codecs"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Audio codecs for speech models served by gbx-lm, run as a companion process on Apple silicon"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE", "licenses/*"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "GreenBitAI" }]
|
|
14
|
+
dependencies = ["mlx>=0.29", "numpy>=1.24", "miniaudio>=1.59"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Operating System :: MacOS",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/GreenBitAI/gbx-codecs"
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
gbx-codecs = "gbx_codecs.__main__:main"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/gbx_codecs"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
line-length = 110
|
|
35
|
+
# Ported as upstream wrote it, so the two can be diffed.
|
|
36
|
+
extend-exclude = ["src/gbx_codecs/fish_s1/dac.py"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff.lint]
|
|
39
|
+
select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Audio codecs for models served by gbx-lm, run beside the server.
|
|
2
|
+
|
|
3
|
+
gbx-lm generates codes; this package turns codes into audio and audio into
|
|
4
|
+
codes, in a process of its own (``python -m gbx_codecs serve``) so the server
|
|
5
|
+
needs none of its dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""gbx-codecs command line.
|
|
2
|
+
|
|
3
|
+
python -m gbx_codecs serve # the process gbx-lm starts
|
|
4
|
+
python -m gbx_codecs check MODEL_DIR # load the codec, round-trip a tone
|
|
5
|
+
python -m gbx_codecs encode MODEL_DIR IN.wav OUT.npy
|
|
6
|
+
python -m gbx_codecs decode MODEL_DIR IN.npy OUT.wav
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
import wave
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .worker import CACHE_LIMIT, MAX_DECODE_FRAMES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _codec(model_dir: str):
|
|
21
|
+
from .fish_s1 import DAC
|
|
22
|
+
|
|
23
|
+
return DAC.from_pretrained(model_dir)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _decode(codec, codes: np.ndarray) -> np.ndarray:
|
|
27
|
+
import mlx.core as mx
|
|
28
|
+
|
|
29
|
+
codec.reset_streaming_state()
|
|
30
|
+
parts = [
|
|
31
|
+
np.array(
|
|
32
|
+
codec.streaming_step(mx.array(codes[:, b : b + MAX_DECODE_FRAMES])[None])[0, 0].astype(mx.float32)
|
|
33
|
+
)
|
|
34
|
+
for b in range(0, codes.shape[1], MAX_DECODE_FRAMES)
|
|
35
|
+
]
|
|
36
|
+
return np.concatenate(parts) if parts else np.zeros(0, np.float32)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _write_wav(path: str, samples: np.ndarray, rate: int) -> None:
|
|
40
|
+
from .audio import to_pcm16
|
|
41
|
+
|
|
42
|
+
with wave.open(path, "wb") as w:
|
|
43
|
+
w.setnchannels(1)
|
|
44
|
+
w.setsampwidth(2)
|
|
45
|
+
w.setframerate(rate)
|
|
46
|
+
w.writeframes(to_pcm16(samples))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main(argv: list[str] | None = None) -> None:
|
|
50
|
+
ap = argparse.ArgumentParser(prog="python -m gbx_codecs")
|
|
51
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
52
|
+
sub.add_parser("serve", help="speak the codec protocol on stdin/stdout")
|
|
53
|
+
c = sub.add_parser("check", help="load the codec and round-trip a test tone")
|
|
54
|
+
c.add_argument("model_dir")
|
|
55
|
+
e = sub.add_parser("encode", help="audio file to codes")
|
|
56
|
+
e.add_argument("model_dir")
|
|
57
|
+
e.add_argument("audio")
|
|
58
|
+
e.add_argument("out")
|
|
59
|
+
d = sub.add_parser("decode", help="codes to a wav file")
|
|
60
|
+
d.add_argument("model_dir")
|
|
61
|
+
d.add_argument("codes")
|
|
62
|
+
d.add_argument("out")
|
|
63
|
+
a = ap.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
if a.cmd == "serve":
|
|
66
|
+
from .worker import main as serve
|
|
67
|
+
|
|
68
|
+
serve()
|
|
69
|
+
return
|
|
70
|
+
import mlx.core as mx
|
|
71
|
+
|
|
72
|
+
mx.set_cache_limit(CACHE_LIMIT)
|
|
73
|
+
codec = _codec(a.model_dir)
|
|
74
|
+
if a.cmd == "check":
|
|
75
|
+
rate = codec.sample_rate
|
|
76
|
+
t = np.arange(rate * 2) / rate
|
|
77
|
+
tone = (0.3 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
|
78
|
+
idx, lengths = codec.encode(mx.array(tone)[None, None, :])
|
|
79
|
+
codes = np.array(idx[0, :, : int(lengths[0].item())]).astype(np.int32)
|
|
80
|
+
back = _decode(codec, codes)[: len(tone)]
|
|
81
|
+
corr = float(np.corrcoef(back, tone[: len(back)])[0, 1])
|
|
82
|
+
print(
|
|
83
|
+
f"codec OK: {codes.shape[0]} codebooks, {codes.shape[1]} frames for 2 s, "
|
|
84
|
+
f"{rate} Hz, round-trip correlation {corr:.3f}"
|
|
85
|
+
)
|
|
86
|
+
sys.exit(0 if corr > 0.5 else 1)
|
|
87
|
+
if a.cmd == "encode":
|
|
88
|
+
from .audio import read_audio
|
|
89
|
+
|
|
90
|
+
audio = read_audio(a.audio, codec.sample_rate)
|
|
91
|
+
idx, lengths = codec.encode(mx.array(audio)[None, None, :])
|
|
92
|
+
np.save(a.out, np.array(idx[0, :, : int(lengths[0].item())]).astype(np.int32))
|
|
93
|
+
elif a.cmd == "decode":
|
|
94
|
+
_write_wav(a.out, _decode(codec, np.load(a.codes).astype(np.int32)), codec.sample_rate)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Reading reference audio, resampling, and 16-bit PCM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_audio(path: str | Path, sample_rate: int) -> np.ndarray:
|
|
12
|
+
"""Mono float32 samples of an audio file (wav, flac, mp3, ...) at ``sample_rate``."""
|
|
13
|
+
import miniaudio
|
|
14
|
+
|
|
15
|
+
decoded = miniaudio.decode_file(
|
|
16
|
+
str(path),
|
|
17
|
+
output_format=miniaudio.SampleFormat.FLOAT32,
|
|
18
|
+
nchannels=1,
|
|
19
|
+
sample_rate=sample_rate,
|
|
20
|
+
)
|
|
21
|
+
return np.frombuffer(decoded.samples, dtype=np.float32).copy()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def to_pcm16(samples: np.ndarray) -> bytes:
|
|
25
|
+
"""Little-endian signed 16-bit PCM."""
|
|
26
|
+
return (np.clip(samples, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Resampler:
|
|
30
|
+
"""Streaming band-limited resampling by a rational factor.
|
|
31
|
+
|
|
32
|
+
Windowed-sinc interpolation with a Kaiser window, computed for every output
|
|
33
|
+
phase once. Feeding a signal in pieces gives exactly what feeding it whole
|
|
34
|
+
does: the input the next piece's outputs still need is carried over.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, source_rate: int, target_rate: int, half_width: int = 16, beta: float = 8.6):
|
|
38
|
+
g = math.gcd(source_rate, target_rate)
|
|
39
|
+
self.up, self.down = target_rate // g, source_rate // g
|
|
40
|
+
self.identity = self.up == self.down
|
|
41
|
+
self.half_width = half_width
|
|
42
|
+
cutoff = min(1.0, self.up / self.down) * 0.95
|
|
43
|
+
# taps[p, j]: weight of input (base + j - half_width + 1) for an output at
|
|
44
|
+
# fractional input position base + p / up.
|
|
45
|
+
offsets = (
|
|
46
|
+
np.arange(-half_width + 1, half_width + 1)[None, :] - (np.arange(self.up) / self.up)[:, None]
|
|
47
|
+
)
|
|
48
|
+
window = np.kaiser(2 * half_width + 1, beta)
|
|
49
|
+
x = offsets / half_width
|
|
50
|
+
w = np.interp(x, np.linspace(-1, 1, 2 * half_width + 1), window)
|
|
51
|
+
self.taps = (cutoff * np.sinc(cutoff * offsets) * w).astype(np.float32)
|
|
52
|
+
self.reset()
|
|
53
|
+
|
|
54
|
+
def reset(self) -> None:
|
|
55
|
+
# Leading zeros stand for the signal before the first sample.
|
|
56
|
+
self._buffer = np.zeros(self.half_width - 1, np.float32)
|
|
57
|
+
self._consumed = 0 # input samples dropped from the buffer's front
|
|
58
|
+
self._next = 0 # index of the next output sample
|
|
59
|
+
|
|
60
|
+
def __call__(self, samples: np.ndarray, final: bool = False) -> np.ndarray:
|
|
61
|
+
if self.identity:
|
|
62
|
+
return np.asarray(samples, np.float32)
|
|
63
|
+
buf = np.concatenate([self._buffer, np.asarray(samples, np.float32)])
|
|
64
|
+
if final:
|
|
65
|
+
buf = np.concatenate([buf, np.zeros(self.half_width, np.float32)])
|
|
66
|
+
lead = self.half_width - 1
|
|
67
|
+
available = self._consumed + len(buf) - lead - self.half_width # last usable centre
|
|
68
|
+
# The last output whose centre, floor(n * down / up), is below `available`.
|
|
69
|
+
last = (available * self.up - 1) // self.down if available > 0 else -1
|
|
70
|
+
if final:
|
|
71
|
+
total_in = self._consumed + len(buf) - lead - self.half_width
|
|
72
|
+
last = min(last, math.ceil(total_in * self.up / self.down) - 1)
|
|
73
|
+
n = np.arange(self._next, last + 1)
|
|
74
|
+
if len(n) == 0:
|
|
75
|
+
out = np.zeros(0, np.float32)
|
|
76
|
+
else:
|
|
77
|
+
pos = n * self.down
|
|
78
|
+
base = pos // self.up - self._consumed + lead # index into buf of the centre
|
|
79
|
+
phase = pos % self.up
|
|
80
|
+
idx = base[:, None] + np.arange(-self.half_width + 1, self.half_width + 1)[None, :]
|
|
81
|
+
out = (buf[idx] * self.taps[phase]).sum(axis=1).astype(np.float32)
|
|
82
|
+
self._next = int(last) + 1
|
|
83
|
+
# Keep what the next output still reads.
|
|
84
|
+
keep_from = (self._next * self.down) // self.up - self._consumed + lead - (self.half_width - 1)
|
|
85
|
+
keep_from = max(0, min(int(keep_from), len(buf)))
|
|
86
|
+
self._buffer = buf[keep_from:]
|
|
87
|
+
self._consumed += keep_from
|
|
88
|
+
return out
|