gbx-codecs 0.1.0__py3-none-any.whl
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/__init__.py +8 -0
- gbx_codecs/__main__.py +98 -0
- gbx_codecs/audio.py +88 -0
- gbx_codecs/fish_s1/__init__.py +5 -0
- gbx_codecs/fish_s1/dac.py +1561 -0
- gbx_codecs/protocol.py +44 -0
- gbx_codecs/worker.py +135 -0
- gbx_codecs-0.1.0.dist-info/METADATA +73 -0
- gbx_codecs-0.1.0.dist-info/RECORD +13 -0
- gbx_codecs-0.1.0.dist-info/WHEEL +4 -0
- gbx_codecs-0.1.0.dist-info/entry_points.txt +2 -0
- gbx_codecs-0.1.0.dist-info/licenses/LICENSE +21 -0
- gbx_codecs-0.1.0.dist-info/licenses/licenses/LICENSE.mlx-audio.txt +21 -0
gbx_codecs/__init__.py
ADDED
|
@@ -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"
|
gbx_codecs/__main__.py
ADDED
|
@@ -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()
|
gbx_codecs/audio.py
ADDED
|
@@ -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
|