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 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
@@ -0,0 +1,5 @@
1
+ """Fish Audio's S1 DAC: 10 codebooks at 21.5 frames/s, 44.1 kHz mono."""
2
+
3
+ from .dac import DAC
4
+
5
+ __all__ = ["DAC"]