Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(chatterbox-s3g): reset Metal scheduler between decodes by jhen0409 · Pull Request #1 · mybigday/codec.cpp · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/runtime/graph.cpp
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,6 +62,23 @@ void codec_graph_release(codec_context * ctx) {
if (ctx == nullptr) {
return;
}

// The scheduler retains pointers into an allocated graph, so release its
// graph state while the eval context is still alive. A scheduler reset is
// sufficient for most graphs, but Metal can retain stale allocations for
// Chatterbox S3G's large decode graph and return zeroes on the next decode.
// Recreate that scheduler when codec_sched_ensure_capacity is called again.
if (ctx->sched != nullptr && ctx->eval_graph_allocated) {
if (ctx->eval_entry != nullptr &&
ctx->eval_entry->key.kind == CODEC_GRAPH_CHATTERBOX_S3G_DECODE) {
ggml_backend_sched_free(ctx->sched);
ctx->sched = nullptr;
ctx->sched_reserved_graph_size = 0;
} else {
ggml_backend_sched_reset(ctx->sched);
}
}

if (ctx->eval_ctx != nullptr) {
ggml_free(ctx->eval_ctx);
ctx->eval_ctx = nullptr;
Expand Down
187 changes: 137 additions & 50 deletions tests/e2e/chatterbox_s3g_decode_smoke.py
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
#!/usr/bin/env python3
"""End-to-end smoke test for the public Chatterbox-S3G decode API.
"""End-to-end smoke test for repeated Chatterbox-S3G decode calls.

Calls codec_decode through the standard codec-cli (or, simpler, a small driver)
to confirm the public API path: tokens → mel → wav. Compares to a reference
generated by running the chatterbox PyTorch flow + HiFT and reports basic
audio-domain metrics."""
Loads the model with GPU offload and calls codec_decode twice on the same
context. This catches scheduler allocations retained across eval-graph releases,
which caused the second Metal decode to return silent PCM.
"""

from __future__ import annotations

import os
import struct
import ctypes
import subprocess
import sys
import tempfile
Expand All@@ -20,43 +19,105 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
CONVERT = REPO_ROOT / "scripts" / "convert-to-gguf.py"
CODEC_CLI = REPO_ROOT / "build" / "codec-cli"
CHATTERBOX_DIR = REPO_ROOT / "models" / "chatterbox"


class codec_model_params(ctypes.Structure):
_fields_ = [("use_gpu", ctypes.c_bool), ("n_threads", ctypes.c_int32)]


class codec_context_params(ctypes.Structure):
_fields_ = [("seed", ctypes.c_int32)]


class codec_decode_params(ctypes.Structure):
_fields_ = [("n_threads", ctypes.c_int32), ("n_q", ctypes.c_int32)]


class codec_token_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_int32)),
("n_tokens", ctypes.c_int32),
("n_frames", ctypes.c_int32),
("n_q", ctypes.c_int32),
("codebook_size", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("hop_size", ctypes.c_int32),
]


class codec_pcm_buffer(ctypes.Structure):
_fields_ = [
("data", ctypes.POINTER(ctypes.c_float)),
("n_samples", ctypes.c_int32),
("sample_rate", ctypes.c_int32),
("n_channels", ctypes.c_int32),
]


def run(cmd: list[str]) -> bytes:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
if proc.returncode != 0:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.decode(errors='replace')[:4096]}")
return proc.stdout


def read_wav(path: Path) -> tuple[np.ndarray, int]:
with open(path, "rb") as f:
data = f.read()
sr = None
pcm = None
bits = None
pos = 12
while pos < len(data):
cid = data[pos:pos + 4]
size = struct.unpack_from("<I", data, pos + 4)[0]
body = data[pos + 8: pos + 8 + size]
if cid == b"fmt ":
sr = struct.unpack_from("<I", body, 4)[0]
bits = struct.unpack_from("<H", body, 14)[0]
elif cid == b"data":
pcm = body
pos += 8 + size
if size % 2:
pos += 1
assert pcm is not None and bits == 16
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0, sr
def find_libcodec() -> Path | None:
for name in ("libcodec.dylib", "libcodec.so"):
path = REPO_ROOT / "build" / name
if path.is_file():
return path
return None


def bind(lib_path: Path) -> ctypes.CDLL:
lib = ctypes.CDLL(str(lib_path))
lib.codec_model_load_from_file.argtypes = [ctypes.c_char_p, codec_model_params]
lib.codec_model_load_from_file.restype = ctypes.c_void_p
lib.codec_model_free.argtypes = [ctypes.c_void_p]
lib.codec_init_from_model.argtypes = [ctypes.c_void_p, codec_context_params]
lib.codec_init_from_model.restype = ctypes.c_void_p
lib.codec_free.argtypes = [ctypes.c_void_p]
lib.codec_decode.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(codec_token_buffer),
ctypes.POINTER(codec_pcm_buffer),
codec_decode_params,
]
lib.codec_decode.restype = ctypes.c_int
lib.codec_pcm_buffer_free.argtypes = [ctypes.POINTER(codec_pcm_buffer)]
lib.codec_get_last_error.argtypes = [ctypes.c_void_p]
lib.codec_get_last_error.restype = ctypes.c_char_p
return lib


def decode(lib: ctypes.CDLL, ctx: int, tokens: np.ndarray) -> tuple[np.ndarray, int]:
token_buf = codec_token_buffer(
tokens.ctypes.data_as(ctypes.POINTER(ctypes.c_int32)),
tokens.size,
tokens.size,
1,
0,
0,
0,
)
pcm_buf = codec_pcm_buffer()
status = lib.codec_decode(
ctx, ctypes.byref(token_buf), ctypes.byref(pcm_buf), codec_decode_params(4, 0)
)
if status != 0:
error = lib.codec_get_last_error(ctx).decode(errors="replace")
raise RuntimeError(f"codec_decode failed ({status}): {error}")
pcm = np.ctypeslib.as_array(pcm_buf.data, shape=(pcm_buf.n_samples,)).copy()
sample_rate = pcm_buf.sample_rate
lib.codec_pcm_buffer_free(ctypes.byref(pcm_buf))
return pcm, sample_rate


def main() -> int:
if not CODEC_CLI.is_file():
print(f"SKIP: {CODEC_CLI} not built")
lib_path = find_libcodec()
if lib_path is None:
print("SKIP: build/libcodec shared library not built")
return 0
if not (CHATTERBOX_DIR / "s3gen.safetensors").is_file() or not (CHATTERBOX_DIR / "conds.pt").is_file():
print(f"SKIP: {CHATTERBOX_DIR} not present")
Expand All@@ -67,26 +128,52 @@ def main() -> int:
gguf = td / "s3g.gguf"
run([sys.executable, str(CONVERT), "--checkpoint-path", str(CHATTERBOX_DIR), "--model-type", "chatterbox_s3g", "--output", str(gguf)])

# codec-cli decode takes tokens as an .npy file [n_q, n_frames] int32.
toks = np.array([[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600]], dtype=np.int32)
npy_path = td / "tokens.npy"
np.save(npy_path, toks)

out_wav = td / "out.wav"
run([str(CODEC_CLI), "decode", "--model", str(gguf), "--codes", str(npy_path), "--out", str(out_wav)])

wav, sr = read_wav(out_wav)

print(f" decoded {wav.shape[0]} samples at {sr} Hz; rms={float(np.sqrt(np.mean(wav**2))):.4f}, peak={float(np.max(np.abs(wav))):.4f}")
if not np.all(np.isfinite(wav)):
print("FAIL: non-finite samples")
return 1
if np.max(np.abs(wav)) > 1.0:
print("FAIL: clip exceeded ±1.0")
tokens = np.ascontiguousarray([
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000,
1100, 1200, 1300, 1400, 1500, 1600,
], dtype=np.int32)

lib = bind(lib_path)
model = lib.codec_model_load_from_file(
str(gguf).encode(), codec_model_params(True, 4)
)
if not model:
print("FAIL: model load")
return 1
ctx = lib.codec_init_from_model(model, codec_context_params(0))
if not ctx:
lib.codec_model_free(model)
print("FAIL: context init")
return 1

try:
results = [decode(lib, ctx, tokens) for _ in range(2)]
finally:
lib.codec_free(ctx)
lib.codec_model_free(model)

for index, (pcm, sample_rate) in enumerate(results, start=1):
rms = float(np.sqrt(np.mean(pcm**2)))
peak = float(np.max(np.abs(pcm)))
print(
f" run {index}: decoded {pcm.size} samples at {sample_rate} Hz; "
f"rms={rms:.6f}, peak={peak:.6f}"
)
if not np.all(np.isfinite(pcm)):
print(f"FAIL: run {index} contains non-finite samples")
return 1
if peak > 1.0:
print(f"FAIL: run {index} clip exceeded ±1.0")
return 1
if rms < 1e-4:
print(f"FAIL: run {index} is silent")
return 1

if results[0][1] != results[1][1] or results[0][0].shape != results[1][0].shape:
print("FAIL: repeated same-context decodes have different output shapes")
return 1
if np.sqrt(np.mean(wav**2)) < 1e-4:
print("FAIL: silent output")
if not np.allclose(results[0][0], results[1][0], rtol=1e-5, atol=1e-6):
print("FAIL: repeated same-context decodes differ")
return 1
print("chatterbox S3G decode end-to-end smoke test passed")
return 0
Expand Down
Loading