From 7c623926ccae3e0a5bdf838642f87ee73493bb7c Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Wed, 9 Sep 2026 23:06:04 -0700 Subject: [PATCH 01/14] convert_pocket.py --fake group=fmt,...: a quality experiment for a weight format with no kernel behind it - the named groups (the backbone's attention projections and FFN matrices, the frame input projection, the speaker projection, the flow head, the codec) round through ggml's own quantizer (the built llama.cpp's libggml-base through ctypes, the exact blocks a K-quant plane would carry) and back to f32 before they store in the file's usual Q8_0 or f16 form, so the engine's existing lanes and the 200-sentence rig score the format's loss; a width the format's block does not divide is left alone and reported (the codec's convolutions and the head's 32-wide input projection stay under a 256-block format). The files are local experiments and need a --name; pocket.fake carries the spec. The ladder it ran on the English file (alba, 200 sentences, the q8 baseline 3.91 / 4.328): the backbone at Q6_K 4.09 / 4.327, the FFN alone at Q4_K 4.13 / 4.301, all four matrices at Q4_K 3.91 / 4.295, at Q4_0 3.73 / 4.284, at IQ4_XS 4.36 / 4.326 with seven percent more audio, at Q3_K 4.50 / 4.205; on the Q4_K backbone the head at Q8_0 4.09 / 4.281, at Q4_K 3.91 / 4.259, the codec transformers at Q4_K 3.68 / 4.309. The ruling: backbone Q4_K, head Q8_0, codec transformers Q4_K. Co-Authored-By: Claude Fable 5.1 --- modules/dasLLAMA/harness/convert_pocket.py | 98 ++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index d0a99f6cca..6f05812c95 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -100,6 +100,83 @@ def q8_conv(name, shape, stride, transposed): return is_conv and len(shape) == 3 and not transposed and stride == 1 and shape[0] % 32 == 0 and shape[1] % 32 == 0 +FAKE_GROUPS = ("attn", "ffn", "input", "speaker", "head", "codec") + + +def fake_group(name, shape): + """The tensor group a `--fake` spec names: the backbone's attention projections, its two FFN + matrices, the frame input projection, the speaker projection, the flow head's matrices, the + codec's GEMMs and convs. Norms, biases and the voices are never in a group.""" + if not name.endswith(".weight") or len(shape) < 2: + return None + if name.startswith("backbone."): + if ".self_attn." in name: + return "attn" + if ".linear1." in name or ".linear2." in name: + return "ffn" + return None + if name == "flow_lm.input_linear.weight": + return "input" + if name == "flow_lm.speaker_proj.weight": + return "speaker" + if name.startswith("head."): + return "head" + if name.startswith("mimi."): + return "codec" + return None + + +def parse_fake(spec): + """`group=fmt,group=fmt` - a format per group, ggml's names (q4_0, q4_k, q6_k, iq4_nl, ...).""" + out = {} + for item in filter(None, spec.split(",")): + group, fmt = item.split("=") + assert group in FAKE_GROUPS, (group, FAKE_GROUPS) + out[group] = fmt.upper() + return out + + +class FakeQuant: + """Round a float matrix through a ggml quant format and back: ggml's own quantizer (the + built llama.cpp's libggml-base) writes the blocks, gguf-py reads them back to f32. The rows + then store in the file's usual form, so the engine's lanes measure the format's loss with no + new kernel; a width the format's block does not divide is left as it is and reported.""" + + def __init__(self, llama_cpp, gguf_mod): + import ctypes + import glob + libs = glob.glob(os.path.join(llama_cpp, "build", "bin", "libggml-base.dylib")) + \ + glob.glob(os.path.join(llama_cpp, "build", "bin", "libggml-base.so")) + assert libs, "no built libggml-base beside llama.cpp/build/bin - build llama.cpp first" + self.lib = ctypes.CDLL(libs[0]) + self.lib.ggml_quantize_chunk.restype = ctypes.c_size_t + self.lib.ggml_quantize_chunk.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_float), ctypes.c_void_p, + ctypes.c_int64, ctypes.c_int64, ctypes.c_int64, ctypes.POINTER(ctypes.c_float)] + self.ctypes = ctypes + self.gguf = gguf_mod + self.skipped = [] + self.done = {} + + def apply(self, name, w32, fmt): + from gguf.quants import dequantize + t = self.gguf.GGMLQuantizationType[fmt] + block, type_size = self.gguf.GGML_QUANT_SIZES[t] + rows = np.ascontiguousarray(w32.reshape(-1, w32.shape[-1]), dtype=np.float32) + nrows, n_per_row = rows.shape + if n_per_row % block != 0: + self.skipped.append((name, fmt, n_per_row)) + return w32 + out = np.empty((nrows, (n_per_row // block) * type_size), dtype=np.uint8) + c = self.ctypes + n = self.lib.ggml_quantize_chunk(int(t), rows.ctypes.data_as(c.POINTER(c.c_float)), out.ctypes.data_as(c.c_void_p), + 0, nrows, n_per_row, None) + assert n == out.nbytes, (name, fmt, n, out.nbytes) + back = dequantize(out, t).reshape(w32.shape).astype(np.float32) + err = float(np.sqrt(((back - w32) ** 2).mean()) / max(np.sqrt((w32 ** 2).mean()), 1e-12)) + self.done[name] = (fmt, out.nbytes, err) + return back + + def canonical(name): if name.startswith("flow_lm.transformer.layers."): return "backbone." + name[len("flow_lm.transformer.layers."):] @@ -169,10 +246,15 @@ def main(): ap.add_argument("--llama-cpp", default=os.path.expanduser("~/Work/llama.cpp"), help="for gguf-py") ap.add_argument("--name", default=None, help="output file stem (default pocket-tts-)") ap.add_argument("--q8", action="store_true", help="the published form: the served GEMM weights as Q8_0 in the kernels' layout") + ap.add_argument("--fake", default="", help="quality experiment, never published: group=fmt[,group=fmt] (attn, ffn, input, speaker, head, " + "codec; ggml format names) - the group's weights round through that format before they are stored; needs --name") a = ap.parse_args() sys.path.insert(0, os.path.join(a.llama_cpp, "gguf-py")) import gguf from gguf.quants import quantize as gguf_quantize + fake = parse_fake(a.fake) + assert not fake or a.name, "--fake files are local experiments: give them a --name" + fq = FakeQuant(a.llama_cpp, gguf) if fake else None lang = a.language cfg = load_config(lang) @@ -196,6 +278,9 @@ def main(): name = canonical(k) assert len(name) < GGML_MAX_NAME, name assert name not in tensors, name + group = fake_group(name, v.shape) if fake else None + if group in fake: + v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group]) if a.q8 and q8_linear(name, v.shape): tensors[name] = ("q8", np.ascontiguousarray(v.astype(np.float32))) quantized.append(name) @@ -233,6 +318,8 @@ def main(): w = gguf.GGUFWriter(path, ARCH) w.add_name(f"Pocket TTS {lang}" + (" Q8_0" if a.q8 else "")) w.add_string("pocket.weights", "q8" if a.q8 else "f16") + if fake: + w.add_string("pocket.fake", a.fake) w.add_string("pocket.language", lang) w.add_string("pocket.revision", weights_rev) w.add_string("pocket.tokenizer_revision", tok_rev) @@ -305,6 +392,17 @@ def main(): f.write("see the dasLLAMA THIRD_PARTY_NOTICES.md\n") print(f"wrote {path}: {len(tensors)} tensors ({len(quantized)} as Q8_0), {len(pieces)} pieces, {len(voices)} voices " f"(default {default_voice}); {os.path.getsize(path)} bytes on disk") + if fq: + by_group = {} + for name, (fmt, nbytes, err) in fq.done.items(): + g = by_group.setdefault((fake_group(name, (1, 1)), fmt), [0, 0, 0.0]) + g[0] += 1 + g[1] += nbytes + g[2] = max(g[2], err) + for (group, fmt), (n, nbytes, err) in sorted(by_group.items()): + print(f" fake {group}={fmt}: {n} tensors, {nbytes / 1e6:.1f} MB as {fmt}, worst rms rel err {err:.4f}") + for name, fmt, width in fq.skipped: + print(f" fake skipped {name}: width {width} is not a whole number of {fmt} blocks") if __name__ == "__main__": From 08b34a1432c08e0348bbbc6d74d946a6c7afc1c9 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Wed, 9 Sep 2026 23:21:08 -0700 Subject: [PATCH 02/14] convert_pocket.py --fake gains the embed group (the text embedding table) and the strided group (the codec's strided, transposed and resampling convolutions the file keeps f16, rounded per output channel over their cin x k taps; the served convs stay in the codec group), and the f16 rungs on the ruled configuration (backbone Q4_K, head Q8_0, codec transformers Q4_K: 3.86 / 4.267 against the q8 file's 3.91 / 4.328): the embedding table at Q8_0 4.27 / 4.271, at Q4_K 4.00 / 4.262; the strided convolutions at Q8_0 3.86 / 4.257, at Q4_0 3.73 / 4.127 - the one rung the waveform side refuses Co-Authored-By: Claude Fable 5.1 --- modules/dasLLAMA/harness/convert_pocket.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index 6f05812c95..9ac5eda1de 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -100,13 +100,15 @@ def q8_conv(name, shape, stride, transposed): return is_conv and len(shape) == 3 and not transposed and stride == 1 and shape[0] % 32 == 0 and shape[1] % 32 == 0 -FAKE_GROUPS = ("attn", "ffn", "input", "speaker", "head", "codec") +FAKE_GROUPS = ("attn", "ffn", "input", "speaker", "embed", "head", "codec", "strided") -def fake_group(name, shape): +def fake_group(name, shape, conv_served=False): """The tensor group a `--fake` spec names: the backbone's attention projections, its two FFN - matrices, the frame input projection, the speaker projection, the flow head's matrices, the - codec's GEMMs and convs. Norms, biases and the voices are never in a group.""" + matrices, the frame input projection, the speaker projection, the text embedding table, the + flow head's matrices, the codec's GEMMs and the convs the engine serves q8 (`codec`), and the + codec's strided, transposed and resampling convs the file keeps f16 (`strided`). Norms, + biases and the voices are never in a group.""" if not name.endswith(".weight") or len(shape) < 2: return None if name.startswith("backbone."): @@ -119,9 +121,13 @@ def fake_group(name, shape): return "input" if name == "flow_lm.speaker_proj.weight": return "speaker" + if name == "flow_lm.conditioner.embed.weight": + return "embed" if name.startswith("head."): return "head" if name.startswith("mimi."): + if len(shape) == 3 and not conv_served: + return "strided" return "codec" return None @@ -161,7 +167,8 @@ def apply(self, name, w32, fmt): from gguf.quants import dequantize t = self.gguf.GGMLQuantizationType[fmt] block, type_size = self.gguf.GGML_QUANT_SIZES[t] - rows = np.ascontiguousarray(w32.reshape(-1, w32.shape[-1]), dtype=np.float32) + # a conv [cout][cin][k] rounds per output channel over its cin*k taps; a matrix per row + rows = np.ascontiguousarray(w32.reshape(w32.shape[0], -1) if w32.ndim == 3 else w32.reshape(-1, w32.shape[-1]), dtype=np.float32) nrows, n_per_row = rows.shape if n_per_row % block != 0: self.skipped.append((name, fmt, n_per_row)) @@ -278,7 +285,7 @@ def main(): name = canonical(k) assert len(name) < GGML_MAX_NAME, name assert name not in tensors, name - group = fake_group(name, v.shape) if fake else None + group = fake_group(name, v.shape, q8_conv(name, v.shape, conv_stride.get(name, 1), ".convtr." in name)) if fake else None if group in fake: v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group]) if a.q8 and q8_linear(name, v.shape): @@ -395,7 +402,7 @@ def main(): if fq: by_group = {} for name, (fmt, nbytes, err) in fq.done.items(): - g = by_group.setdefault((fake_group(name, (1, 1)), fmt), [0, 0, 0.0]) + g = by_group.setdefault((fake_group(name, (1, 1)) or "strided", fmt), [0, 0, 0.0]) g[0] += 1 g[1] += nbytes g[2] = max(g[2], err) From 1b5d0ab169f3c34e61be6a6a2f7ba706feaa8490 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Wed, 9 Sep 2026 23:26:17 -0700 Subject: [PATCH 03/14] convert_pocket.py --fake splits the codec group: codec is the two transformers' matrices, codecconv the served 32-wide convolutions, so the recipe the rig scored (the transformers at Q4_K, the served convolutions at Q8_0, the strided ones at Q8_0, the embedding at Q4_K) mints as one file; three sentences in alba and in bill_boerst through it and through the q8 file were played side by side Co-Authored-By: Claude Fable 5.1 --- modules/dasLLAMA/harness/convert_pocket.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index 9ac5eda1de..72d05f6616 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -100,15 +100,15 @@ def q8_conv(name, shape, stride, transposed): return is_conv and len(shape) == 3 and not transposed and stride == 1 and shape[0] % 32 == 0 and shape[1] % 32 == 0 -FAKE_GROUPS = ("attn", "ffn", "input", "speaker", "embed", "head", "codec", "strided") +FAKE_GROUPS = ("attn", "ffn", "input", "speaker", "embed", "head", "codec", "codecconv", "strided") def fake_group(name, shape, conv_served=False): """The tensor group a `--fake` spec names: the backbone's attention projections, its two FFN matrices, the frame input projection, the speaker projection, the text embedding table, the - flow head's matrices, the codec's GEMMs and the convs the engine serves q8 (`codec`), and the - codec's strided, transposed and resampling convs the file keeps f16 (`strided`). Norms, - biases and the voices are never in a group.""" + flow head's matrices, the codec transformers' GEMMs (`codec`), the convs the engine serves + q8 (`codecconv`), and the codec's strided, transposed and resampling convs the file keeps + f16 (`strided`). Norms, biases and the voices are never in a group.""" if not name.endswith(".weight") or len(shape) < 2: return None if name.startswith("backbone."): @@ -126,8 +126,8 @@ def fake_group(name, shape, conv_served=False): if name.startswith("head."): return "head" if name.startswith("mimi."): - if len(shape) == 3 and not conv_served: - return "strided" + if len(shape) == 3: + return "codecconv" if conv_served else "strided" return "codec" return None From 4a7192a05f239b5a5d0654be57819ad5b6c371b1 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 00:01:01 -0700 Subject: [PATCH 04/14] a Pocket file ships its roster as latent frames and can leave the codec encoder out, and storywish reads through one: convert_pocket.py stores each roster voice as voice_latents. (the clip through the package's own codec encoder, 16 KB a voice where a clip was a megabyte), takes --voices for the roster and --no-cloning to leave the encoder's 43 tensors out with pocket.cloning = false; the loader reads either roster form (the older clip form still encodes on first use, and a clip in a file without the encoder is refused by name), builds a stored voice's state through voice_state_from_latents (the clip path's second half), reports cloning = false in caps() for such a file and refuses tts_register_voice on it. test_pocket_no_encoder holds the shipped shape (pocket-tts-en-bill-q8.gguf, one voice, 121 MB at q8: caps, one spoken line, the refusal); the pocket suite 10/10. Storywish reads through that file - no packs, no phoneme step, bill_boerst - on the desktop (its smoke 8/8 tells and reads the story) and in the shell's argument; models.json gains a files list for a GGUF that is its own served form, which mint_models.py copies as it does the packs; the examples folder's architecture doc, the card and the deploy's comment say Pocket. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pages.yml | 2 +- examples/dasLLAMA/ARCHITECTURE.md | 9 +- examples/dasLLAMA/storywish/.das_package | 2 +- examples/dasLLAMA/storywish/main.das | 15 +-- examples/dasLLAMA/storywish/models.json | 8 +- examples/dasLLAMA/storywish/web_shell.html | 4 +- examples/dasLLAMA/wasm/mint_models.py | 10 +- modules/dasLLAMA/ARCHITECTURE_POCKET.md | 9 +- modules/dasLLAMA/dasllama/dasllama_pocket.das | 96 ++++++++++++++----- modules/dasLLAMA/dasllama/dasllama_tts.das | 4 +- modules/dasLLAMA/harness/convert_pocket.py | 62 ++++++++++-- modules/dasLLAMA/tests/test_storywish.das | 16 +--- modules/dasLLAMA/tests/test_tts_pocket.das | 35 +++++++ site-dasllama/examples.html | 2 +- 14 files changed, 199 insertions(+), 75 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index bd01846ffa..0cd194acee 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -334,7 +334,7 @@ jobs: # 7. dasllama.io/examples — dasLLAMA itself compiled to wasm64, one app per example # (storyteller: stories15M writes, KittenTTS reads; storywish: you type the words, - # tinystories-instruct-27M writes). Each .das_package turns the GPU modules off, so the + # tinystories-instruct-27M writes, Pocket TTS reads). Each .das_package turns the GPU modules off, so the # host needs no Metal/Vulkan. Non-fatal like the games; the dasllama.io stage step # stages a card's page only when all three outputs exist. The models are prepared .dlim # images the stage step MINTS for this very build: examples/dasLLAMA/wasm/dlim_config is diff --git a/examples/dasLLAMA/ARCHITECTURE.md b/examples/dasLLAMA/ARCHITECTURE.md index 403aff73bd..85647b1550 100644 --- a/examples/dasLLAMA/ARCHITECTURE.md +++ b/examples/dasLLAMA/ARCHITECTURE.md @@ -10,7 +10,8 @@ checklist is `REVIEW.md` beside this file. The engine these programs drive is do frame, KittenTTS reads each finished sentence. `main.das` is the whole program, `web_shell.html` the page around its canvas, `.das_package` the release, `models.json` its model set. - `storywish/` - a browser example: the typed words become a request in the TinyStoriesInstruct - corpus's layout, tinystories-instruct-27M writes the story, KittenTTS reads it. Same four files; + corpus's layout, tinystories-instruct-27M writes the story, Pocket TTS reads it in one baked + voice from a file without the codec encoder (text in, no packs, no cloning). Same four files; `wish.das` holds the request side pure (typed line -> words -> prompt, the field-line stop) so a test reaches it without a window. - `wasm/dlim_config/` - a wasm-only program: prints the running build's DlimConfiguration JSON. @@ -62,8 +63,10 @@ character range for a typed line, and repeats come from a hold timer. ### 3.4 The model set is minted for the build that ships it Each browser example's `models.json` names its source files by Hugging Face repository, file -and sha256. `wasm/mint_models.py` fetches them (cached by sha256), bakes each GGUF into a -`.dlim` image against the wasm64 build's own DlimConfiguration, copies the packs, writes +and sha256, in three lists: `images` (a GGUF the build bakes into a `.dlim`), `packs` (a +front-end pack) and `files` (a GGUF that is its own served form - a Pocket TTS file). `wasm/mint_models.py` +fetches them (cached by sha256), bakes each image against the wasm64 build's own +DlimConfiguration, copies the packs and files as they are, writes `models/manifest.json` (the file list, their sizes, the IMAGE_VERSION the images carry) and stamps that version into the page's `/* @image-version */ 0` slot. The shell reads the manifest, refuses a set minted for another version before fetching it, and shows a program abort's last diff --git a/examples/dasLLAMA/storywish/.das_package b/examples/dasLLAMA/storywish/.das_package index 75b726bcd3..08fd8fc56c 100644 --- a/examples/dasLLAMA/storywish/.das_package +++ b/examples/dasLLAMA/storywish/.das_package @@ -5,7 +5,7 @@ require daslib/daspkg [export] def package() { package_name("storywish") - package_description("Storywish: type the words, a story model trained to take requests writes a tale that uses them while KittenTTS reads it aloud - dasGlfw + dasOpenGL + dasAudio over dasLLAMA") + package_description("Storywish: type the words, a story model trained to take requests writes a tale that uses them while Pocket TTS reads it aloud - dasGlfw + dasOpenGL + dasAudio over dasLLAMA") } [export] diff --git a/examples/dasLLAMA/storywish/main.das b/examples/dasLLAMA/storywish/main.das index 35a415b34f..1090678b2b 100644 --- a/examples/dasLLAMA/storywish/main.das +++ b/examples/dasLLAMA/storywish/main.das @@ -23,16 +23,17 @@ require wish // the request side: typed line -> words -> prompt, // Storywish. You type the words you wish for - "dragon, cake, moon" - and Enter asks a story // model trained to take requests (tinystories-instruct-27M, a llama trained on the // TinyStoriesInstruct corpus) for a children's story that uses them; it writes on screen a few -// tokens per frame while KittenTTS reads each finished sentence aloud. Tab asks for dialogue in +// tokens per frame while Pocket TTS reads each finished sentence aloud. Tab asks for dialogue in // the story. Enter while a story is being told stops it and tells the next one; the sentences of // the old story still queued for speech are skipped. Escape quits. // // bin/daslang -jit examples/dasLLAMA/storywish/main.das -- --models [--words "dragon, cake, moon"] // -// holds the two models and the front-end packs - tts_g2p.bin (or its American-only twin -// tts_g2p_en_us.bin, which the web set ships) and tts_postag.bin. A model is its gguf (the -// default names) or a prepared .dlim image baked for the running build's identity -// (dasllama-convert --config) - the web build ships images only, the shell names them. +// holds the two models. The story model is its gguf (the default name) or a prepared +// .dlim image baked for the running build's identity (dasllama-convert --config) - the web +// build ships the image, the shell names it. The speech model is a Pocket TTS file with one +// voice and no codec encoder (the shipped form: it reads text, needs no packs, cannot clone); +// any TTS file the facade loads works here, a Kitten or Kokoro one with its packs beside it. // The language model runs on the frame thread in per-frame token budgets, so the loop never // blocks; speech synthesis runs on its own thread, fed sentences through one stream and // answering with PCM through another, and the frame thread plays the clips back to back. @@ -40,14 +41,14 @@ require wish // the request side: typed line -> words -> prompt, [CommandLineArgs] struct WishArgs { @clarg_short = "m" - @clarg_doc = "Directory holding the story model, the TTS model and its phoneme packs (default: the current directory)" + @clarg_doc = "Directory holding the story model and the TTS model (default: the current directory)" models : string = "." @clarg_doc = "The story model file inside --models" story_model : string = "tinystories-instruct-27M-Q8_0.gguf" @clarg_doc = "The TTS model file inside --models" - tts_model : string = "kitten-nano.gguf" + tts_model : string = "pocket-tts-en-bill-q8.gguf" @clarg_doc = "Voice name or alias (default: the model's last voice)" voice : string diff --git a/examples/dasLLAMA/storywish/models.json b/examples/dasLLAMA/storywish/models.json index 48dc96a770..d6a9086dfa 100644 --- a/examples/dasLLAMA/storywish/models.json +++ b/examples/dasLLAMA/storywish/models.json @@ -1,10 +1,8 @@ { "images": [ - { "file": "tinystories-instruct-27M-Q8_0.gguf", "repo": "borisbat/dasllama-stories", "sha256": "92c2b775070b76ee31b9921f7f339ec9fc0e08b54be0616418414d4eec65d282", "dlim": "tinystories-instruct-27M.dlim" }, - { "file": "kitten-nano.gguf", "repo": "borisbat/dasllama-tts", "sha256": "4556948c36a29e4be5ad521e597a20ae817059c404fea3c5d935afa73506d9da", "dlim": "kitten-nano.dlim" } + { "file": "tinystories-instruct-27M-Q8_0.gguf", "repo": "borisbat/dasllama-stories", "sha256": "92c2b775070b76ee31b9921f7f339ec9fc0e08b54be0616418414d4eec65d282", "dlim": "tinystories-instruct-27M.dlim" } ], - "packs": [ - { "file": "tts_g2p_en_us.bin", "repo": "borisbat/dasllama-tts", "sha256": "6f69d2e74565bd7d876b8d1f4042bf8c1c5b615387fa26ff45215cf447932154" }, - { "file": "tts_postag.bin", "repo": "borisbat/dasllama-tts", "sha256": "38c2e85f7fef3e57d561d2aa0af25fccda4276376ba1993c3dbc2ae0ebfa57b4" } + "files": [ + { "file": "pocket-tts-en-bill-q8.gguf", "repo": "borisbat/dasllama-tts", "sha256": "f8ffeeb5517aebb1c6a74572b53e57fe738eec23deca0c9c39dfb913030c5a28" } ] } diff --git a/examples/dasLLAMA/storywish/web_shell.html b/examples/dasLLAMA/storywish/web_shell.html index ef6f8f4d56..51f7234121 100644 --- a/examples/dasLLAMA/storywish/web_shell.html +++ b/examples/dasLLAMA/storywish/web_shell.html @@ -51,7 +51,7 @@ byte. Then it reads models/manifest.json - the list the deploy minted, stamped with the IMAGE_VERSION it minted at - and refuses a set minted for another version before fetching a megabyte of it: the program would decline the images anyway, this way the reason is on the - page. Otherwise it fetches the story model, the TTS model and its phoneme packs into MEMFS, + page. Otherwise it fetches the story model and the speech model into MEMFS, then waits for a click - browsers only let audio start from a gesture. Models come from ./models/ beside the page, or ?models=. ?force=unsupported shows the browser note on a supporting browser, for checking the page. A program that aborts after the click puts its @@ -210,7 +210,7 @@

storywish

}; return c; })(), - arguments: ['--models', '/models', '--story-model', 'tinystories-instruct-27M.dlim', '--tts-model', 'kitten-nano.dlim', '--autoplay'], + arguments: ['--models', '/models', '--story-model', 'tinystories-instruct-27M.dlim', '--tts-model', 'pocket-tts-en-bill-q8.gguf', '--autoplay'], print: function (text) { console.log(text); remember(text); }, printErr: function (text) { console.error(text); remember(text); }, onAbort: function (what) { showFailure(what || 'abort'); }, diff --git a/examples/dasLLAMA/wasm/mint_models.py b/examples/dasLLAMA/wasm/mint_models.py index 33bbd694ae..6728cb2a6e 100644 --- a/examples/dasLLAMA/wasm/mint_models.py +++ b/examples/dasLLAMA/wasm/mint_models.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -"""Stage a browser example's model set: fetch the GGUFs and packs its models.json names from -Hugging Face, mint each GGUF into a .dlim against the wasm64 build's DlimConfiguration, copy the -packs, and write models/manifest.json - the list the example's web shell reads, stamped with the +"""Stage a browser example's model set: fetch the files its models.json names from Hugging Face, +mint each GGUF under `images` into a .dlim against the wasm64 build's DlimConfiguration, copy the +`packs` and `files` as they are (a front-end pack; a Pocket TTS GGUF, which is its own served +form), and write models/manifest.json - the list the example's web shell reads, stamped with the IMAGE_VERSION the images carry. mint_models.py --example examples/dasLLAMA/storywish --config wasm64.json \ @@ -92,7 +93,8 @@ def main(): files.append({"name": entry["dlim"], "bytes": os.path.getsize(dlim), "sha256": sha256_of(dlim), "source": f"{entry['repo']}/{entry['file']}"}) print(f"minted {entry['dlim']} ({os.path.getsize(dlim) >> 20} MB, IMAGE_VERSION {version}) from {entry['file']}") - for entry in spec.get("packs", []): + # packs and files ship as they are: a front-end pack, or a GGUF that is its own served form (a Pocket file) + for entry in spec.get("packs", []) + spec.get("files", []): src = fetch(entry, a.cache) dst = os.path.join(a.out, entry["file"]) shutil.copyfile(src, dst) diff --git a/modules/dasLLAMA/ARCHITECTURE_POCKET.md b/modules/dasLLAMA/ARCHITECTURE_POCKET.md index 641fb2983c..23f3cace1f 100644 --- a/modules/dasLLAMA/ARCHITECTURE_POCKET.md +++ b/modules/dasLLAMA/ARCHITECTURE_POCKET.md @@ -55,8 +55,13 @@ The caches are sized for the clip plus 1024 rows and grow, the voice's rows kept text plus every frame its cap allows needs more - one unsplittable run of two hundred tokens is such a chunk. A clip is at most 60 s (`POCKET_MAX_VOICE_SECONDS`): the state is the clip's frames per layer, and the codec encoder's attention is a query block by the 250-key window it sees. -The roster's clips ride the GGUF and encode on first use; a cloned voice is the same path over a -caller's clip (`tts_register_voice`). The package's precomputed states differ from the clip path +The roster rides the GGUF as each clip's latent frames (`voice_latents.`, the package's +own codec encoder over the clip at conversion), and a voice's state is built from them on first +use - the second half of the clip path, no encoder needed; a file of the older form carries the +clips themselves (`voice.`) and encodes them on first use. A cloned voice is the whole clip +path over a caller's clip (`tts_register_voice`), so it needs the encoder: a file converted +`--no-cloning` leaves the encoder out, says so in `pocket.cloning`, reports `cloning = false` in +`caps()` and refuses a clip by name. The package's precomputed states differ from the clip path by 1.5e-2 (they come from another checkpoint revision; `harness/pocket_oracle.py` dumps both and `test_pocket_parity`'s voice cell compares the clip path); the clip path is the reference. diff --git a/modules/dasLLAMA/dasllama/dasllama_pocket.das b/modules/dasLLAMA/dasllama/dasllama_pocket.das index c2901a2afe..51d577162e 100644 --- a/modules/dasLLAMA/dasllama/dasllama_pocket.das +++ b/modules/dasLLAMA/dasllama/dasllama_pocket.das @@ -114,8 +114,10 @@ struct PocketModel { bos_before_voice : bool default_voice : string voice_names : array - voices : table> // the roster's clips, 24 kHz mono + voices : table> // a voice as a clip, 24 kHz mono: a cloned one, or a roster of the older file form + voice_latents : table> // a voice as its clip's latent frames [frames][latent_dim]: the roster's stored form voice_states : table + cloning : bool // the file carries the codec encoder, so a clip can become a voice text_emb : TtsWeight = TtsWeight() // [n_bins + 1][d] bos_emb : TtsWeight = TtsWeight() // [latent_dim]: the first frame's input bos_voice : TtsWeight = TtsWeight() // [d]: the row before the voice prompt @@ -173,6 +175,7 @@ var private g_stage_repack = false def finalize(var m : PocketModel) { delete m.voice_names delete m.voices + delete m.voice_latents delete m.voice_states delete m.text_emb delete m.bos_emb @@ -495,30 +498,34 @@ def private read_head(m : GGUFMeta; bytes : array | #; var hd : PocketHea } } -def private read_mimi(m : GGUFMeta; bytes : array | #; var mm : PocketMimi; n_layers, d, heads, ffn, context : int64; period : float) { +def private read_mimi(m : GGUFMeta; bytes : array | #; var mm : PocketMimi; n_layers, d, heads, ffn, context : int64; period : float; encoder : bool) { mm.ratios <- gguf_int_array(m, bytes, "pocket.mimi.ratios") let nr = long_length(mm.ratios) mm.hop = 1l for (r in mm.ratios) { mm.hop *= r } - // the encoder's stages read the ratios reversed: 1 -> 64 channels first, doubling per stage - mm.enc_in <- read_conv(m, bytes, "mimi.encoder.model.0.conv", 1l, 1l, false) - mm.enc_res |> resize(int(nr)) - mm.enc_down |> resize(int(nr)) - for (i in range64(nr)) { - let ratio = mm.ratios[nr - 1l - i] - mm.enc_res[i] <- read_res_conv(m, bytes, "mimi.encoder.model.{1l + 3l * i}") - mm.enc_down[i] <- read_conv(m, bytes, "mimi.encoder.model.{3l + 3l * i}.conv", ratio, 1l, false) - } - mm.enc_out <- read_conv(m, bytes, "mimi.encoder.model.{2l + 3l * nr}.conv", 1l, 1l, false) - mm.enc_tf <- read_transformer(m, bytes, "mimi.enc_tf", n_layers, d, heads, ffn, context, period, true) - mm.downsample <- read_conv(m, bytes, "mimi.downsample.conv.conv", 0l, 1l, false) - mm.frame_steps = mm.downsample.k / 2l - mm.downsample.stride = mm.frame_steps - mm.downsample.pad_l = 0l // replicate-padded by the caller, `frame_steps` rows mm.quant_proj <- read_conv(m, bytes, "mimi.quantizer.output_proj", 1l, 1l, false) + // the two resamplers are kernel 2 x stride and mirror each other; the upsampler is always in the file + mm.frame_steps = m.tensors[need_tensor(m, "mimi.upsample.convtr.convtr.weight")].dims[0] / 2l mm.upsample <- read_conv(m, bytes, "mimi.upsample.convtr.convtr", mm.frame_steps, d, true) + if (encoder) { + // the encoder's stages read the ratios reversed: 1 -> 64 channels first, doubling per stage + mm.enc_in <- read_conv(m, bytes, "mimi.encoder.model.0.conv", 1l, 1l, false) + mm.enc_res |> resize(int(nr)) + mm.enc_down |> resize(int(nr)) + for (i in range64(nr)) { + let ratio = mm.ratios[nr - 1l - i] + mm.enc_res[i] <- read_res_conv(m, bytes, "mimi.encoder.model.{1l + 3l * i}") + mm.enc_down[i] <- read_conv(m, bytes, "mimi.encoder.model.{3l + 3l * i}.conv", ratio, 1l, false) + } + mm.enc_out <- read_conv(m, bytes, "mimi.encoder.model.{2l + 3l * nr}.conv", 1l, 1l, false) + mm.enc_tf <- read_transformer(m, bytes, "mimi.enc_tf", n_layers, d, heads, ffn, context, period, true) + mm.downsample <- read_conv(m, bytes, "mimi.downsample.conv.conv", 0l, 1l, false) + verify(mm.downsample.k / 2l == mm.frame_steps) + mm.downsample.stride = mm.frame_steps + mm.downsample.pad_l = 0l // replicate-padded by the caller, `frame_steps` rows + } mm.dec_tf <- read_transformer(m, bytes, "mimi.dec_tf", n_layers, d, heads, ffn, context, period, true) mm.dec_in <- read_conv(m, bytes, "mimi.decoder.model.0.conv", 1l, 1l, false) mm.dec_up |> resize(int(nr)) @@ -537,8 +544,29 @@ def private lang_code_of(language : string) : string { return language } +// the roster: each voice as its stored latent frames, or as a clip in the older file form (which +// needs the encoder to hear it) +def private read_roster(g : GGUFMeta; bytes : array | #; var m : PocketModel; path : string) { + m.voice_names <- gguf_str_array(g, bytes, "pocket.voices") + for (v in m.voice_names) { + if (gguf_find_tensor(g, "voice_latents.{v}") >= 0) { + m.voice_latents[v] <- read_arr(g, bytes, "voice_latents.{v}") + } elif (m.cloning) { + m.voices[v] <- read_arr(g, bytes, "voice.{v}") + } else { + panic("dasLLAMA pocket: '{path}' stores voice '{v}' as a clip but carries no codec encoder to hear it with") + } + } +} + +//! Whether `name` is a voice this model speaks: a roster voice (stored as latent frames, or as +//! a clip in the older file form) or a cloned one. +def pocket_has_voice(m : PocketModel; name : string) : bool { + return key_exists(m.voice_latents, name) || key_exists(m.voices, name) +} + //! Load a converted Pocket TTS GGUF: the scalars from its `pocket.*` metadata, the weights -//! prepared for the rows kernels, the tokenizer, and the roster's clips (encoded on first use). +//! prepared for the rows kernels, the tokenizer, and the roster (its states built on first use). def load_pocket(path : string) : PocketModel { var inscope m = PocketModel() m.q8 = pocket_serve_q8_() @@ -572,10 +600,8 @@ def load_pocket(path : string) : PocketModel { if (g_stage_file_q8 && !m.q8) { to_log(LOG_INFO, "dasLLAMA pocket: '{path}' carries Q8_0 GEMM weights; the f32 lane serves them dequantized\n") } - m.voice_names <- gguf_str_array(g, bytes, "pocket.voices") - for (v in m.voice_names) { - m.voices[v] <- read_arr(g, bytes, "voice.{v}") - } + m.cloning = !gguf_has(g, "pocket.cloning") || gguf_int(g, bytes, "pocket.cloning") != 0l + read_roster(g, bytes, m, path) let d = gguf_int(g, bytes, "pocket.backbone.d_model") let heads = gguf_int(g, bytes, "pocket.backbone.heads") let layers = gguf_int(g, bytes, "pocket.backbone.layers") @@ -597,7 +623,7 @@ def load_pocket(path : string) : PocketModel { read_head(g, bytes, m.head, gguf_int(g, bytes, "pocket.head.depth")) read_mimi(g, bytes, m.mimi, gguf_int(g, bytes, "pocket.mimi.layers"), gguf_int(g, bytes, "pocket.mimi.d_model"), gguf_int(g, bytes, "pocket.mimi.heads"), gguf_int(g, bytes, "pocket.mimi.dim_feedforward"), - gguf_int(g, bytes, "pocket.mimi.context"), gguf_f32(g, bytes, "pocket.mimi.rope_max_period")) + gguf_int(g, bytes, "pocket.mimi.context"), gguf_f32(g, bytes, "pocket.mimi.rope_max_period"), m.cloning) m.frame_samples = m.mimi.hop * m.mimi.frame_steps m.unk_id = gguf_int(g, bytes, "tokenizer.ggml.unknown_token_id") m.byte_fallback = gguf_int(g, bytes, "tokenizer.ggml.byte_fallback") != 0l @@ -766,9 +792,15 @@ let POCKET_MAX_VOICE_SECONDS = 60l // the longest clip a voice is cloned from: //! front, run through the backbone at positions 0..; the state a synthesis continues from. [arch(at="../ARCHITECTURE_POCKET.md#pocket-voice-state")] def pocket_voice_state(m : PocketModel; pcm : array; var sc : PocketScratch) : PocketVoiceState { - var inscope vs = PocketVoiceState() var inscope lat : array let frames = pocket_encode_latents(m, pcm, sc, lat) + return <- voice_state_from_latents(m, lat, frames, sc) +} + +//! The voice state from a clip's latent frames [frames][latent_dim] - the stored roster form, +//! and the second half of the clip path: the speaker projection, the BOS row, the backbone. +def voice_state_from_latents(m : PocketModel; lat : array; frames : int64; var sc : PocketScratch) : PocketVoiceState { + var inscope vs = PocketVoiceState() let d = m.backbone.d let bos = m.bos_before_voice ? 1l : 0l var inscope rows : array @@ -791,6 +823,9 @@ def pocket_voice_state(m : PocketModel; pcm : array; var sc : PocketScrat //! `name` once its state is built (a clip the encoder refuses leaves no half voice behind), and a //! replaced voice's state is freed rather than left in the table's slot. def pocket_register_voice(var m : PocketModel; name : string; pcm : array; var sc : PocketScratch) { + if (!m.cloning) { + panic("dasLLAMA pocket: voice '{name}': this file carries no codec encoder, so it cannot clone a voice") + } if (empty(pcm)) { panic("dasLLAMA pocket: voice '{name}' has no samples") } @@ -804,16 +839,25 @@ def pocket_register_voice(var m : PocketModel; name : string; pcm : array } m.voice_states |> erase(name) } - if (!key_exists(m.voices, name)) { + if (!key_exists(m.voices, name) && !key_exists(m.voice_latents, name)) { m.voice_names |> push(name) } + m.voice_latents |> erase(name) m.voices[name] := pcm m.voice_states[name] <- vs } -// a roster voice's state, encoded on first use +// a roster voice's state, built on first use: from its stored latent frames, or from its clip def private ensure_voice_state(var m : PocketModel; name : string; var sc : PocketScratch) { return if (key_exists(m.voice_states, name)) + if (key_exists(m.voice_latents, name)) { + var inscope lat : array + m.voice_latents |> get(name) $(stored) { + lat := stored + } + m.voice_states[name] <- voice_state_from_latents(m, lat, long_length(lat) / m.latent_dim, sc) + return + } if (!key_exists(m.voices, name)) { panic("dasLLAMA pocket: unknown voice '{name}'") } diff --git a/modules/dasLLAMA/dasllama/dasllama_tts.das b/modules/dasLLAMA/dasllama/dasllama_tts.das index 6f79360e11..98f11141c7 100644 --- a/modules/dasLLAMA/dasllama/dasllama_tts.das +++ b/modules/dasLLAMA/dasllama/dasllama_tts.das @@ -163,7 +163,7 @@ def caps(m : TtsModel) : TtsCaps { //! What the loaded model offers: the canonical voice names the front end can drive, each with //! its language (a voice whose language it does not phonemize is left out; aliases resolve at //! synthesis), the PCM rate, the languages, whether it clones a voice, whether a speed applies. - var c = TtsCaps(sample_rate = sample_rate_of(m), cloning = m.kind == TtsKind.pocket, speed = m.kind != TtsKind.pocket) + var c = TtsCaps(sample_rate = sample_rate_of(m), cloning = m.kind == TtsKind.pocket && m.pocket.cloning, speed = m.kind != TtsKind.pocket) c.langs <- front_end_langs(m) let names & = unsafe(m.kind == TtsKind.pocket ? m.pocket.voice_names : m.model.voice_names) for (v in names) { @@ -440,7 +440,7 @@ def private synthesize_chunk(var m : TtsModel; norm : string; vname : string; sp def private resolve_voice(m : TtsModel; voice : string) : string { var vname = "" if (m.kind == TtsKind.pocket) { - vname = key_exists(m.pocket.voices, voice) ? voice : "" + vname = pocket_has_voice(m.pocket, voice) ? voice : "" } elif (m.kind == TtsKind.kitten) { vname = kitten_voice(m.model.kitten, m.model, voice) } elif (styletts2_has_voice(m.model, voice)) { diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index 72d05f6616..05fb444bde 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -15,9 +15,12 @@ - the unigram SentencePiece tokenizer as `tokenizer.ggml.model = "t5"` (upstream's name for a unigram model) with `tokenizer.ggml.tokens` / `scores` / `token_type` and the special ids; - the model's scalars as `pocket.*` metadata (from the config, not guessed); -- each bundled voice clip as `voice.` [samples] f32 PCM at 24 kHz mono - the language's - roster; the CC BY-NC clips of the package's English roster are left out (the sidecar names - every clip's source and licence). +- each bundled voice as `voice_latents.` [frames][latent_dim] f32 - the clip's frames + through the package's own codec encoder, the form a voice state is built from (the reader + also takes the older `voice.` PCM form, which needs the encoder); `--voices` picks the + roster, the CC BY-NC clips of the package's English roster are never in it (the sidecar names + every clip's source and licence); `--no-cloning` leaves the codec encoder out and says so in + `pocket.cloning`, so the file serves its roster and refuses to clone. Canonical names: `flow_lm.transformer.layers.N.*` -> `backbone.N.*`; `flow_lm.flow_net.*` -> `head.*`; `mimi.encoder_transformer.transformer.layers.N.*` -> `mimi.enc_tf.N.*`, the decoder @@ -184,6 +187,27 @@ def apply(self, name, w32, fmt): return back +def encoder_tensor(name): + """The codec encoder: the SEANet stages, the encoder transformer and the frame downsampler - + everything a clip goes through on its way to latents, and nothing a synthesis reads.""" + return name.startswith("mimi.encoder.") or name.startswith("mimi.enc_tf.") or name.startswith("mimi.downsample.") + + +def clip_encoder(language): + """The package's own codec encoder over a 24 kHz clip -> its latent frames [frames][latent_dim], + the form the roster is stored in: a voice is the backbone's memory of those frames, so the + file needs no clip samples and, with --no-cloning, no encoder.""" + import torch + from pocket_tts import TTSModel + model = TTSModel.load_model(language=language) + + def encode(pcm): + with torch.no_grad(): + lat = model.mimi.encode_to_latent(torch.from_numpy(np.ascontiguousarray(pcm, dtype=np.float32))[None, None]) + return np.ascontiguousarray(lat[0].numpy(), dtype=np.float32) + return encode + + def canonical(name): if name.startswith("flow_lm.transformer.layers."): return "backbone." + name[len("flow_lm.transformer.layers."):] @@ -255,6 +279,9 @@ def main(): ap.add_argument("--q8", action="store_true", help="the published form: the served GEMM weights as Q8_0 in the kernels' layout") ap.add_argument("--fake", default="", help="quality experiment, never published: group=fmt[,group=fmt] (attn, ffn, input, speaker, head, " "codec; ggml format names) - the group's weights round through that format before they are stored; needs --name") + ap.add_argument("--voices", default="", help="the roster as a comma list of the language's voice names (default: every voice the language ships)") + ap.add_argument("--no-cloning", action="store_true", help="leave the codec encoder out: the roster speaks from its stored latents, " + "tts_register_voice refuses, and the file is smaller by the encoder") a = ap.parse_args() sys.path.insert(0, os.path.join(a.llama_cpp, "gguf-py")) import gguf @@ -281,10 +308,14 @@ def main(): conv_stride["mimi.downsample.conv.conv.weight"] = st["mimi.downsample.conv.conv.weight"].shape[2] // 2 # kernel 2 x stride tensors = {} quantized = [] + dropped = [] for k, v in st.items(): name = canonical(k) assert len(name) < GGML_MAX_NAME, name assert name not in tensors, name + if a.no_cloning and encoder_tensor(name): + dropped.append(name) + continue group = fake_group(name, v.shape, q8_conv(name, v.shape, conv_stride.get(name, 1), ".convtr." in name)) if fake else None if group in fake: v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group]) @@ -301,6 +332,12 @@ def main(): voices = {} sources = {} roster = dict(ENGLISH_VOICES) if lang.startswith("english") else LANGUAGE_VOICES[lang] + if a.voices: + picked = [v.strip() for v in a.voices.split(",") if v.strip()] + unknown = [v for v in picked if v not in roster] + assert not unknown, f"--voices names {unknown}; the {lang} roster is {sorted(roster)}" + roster = {v: roster[v] for v in picked} + encoder = clip_encoder(lang) for vname, (rel, licence) in roster.items(): if rel.startswith("tts-voices:"): path = os.path.join(a.hub, "tts-voices", rel[len("tts-voices:"):]) @@ -312,10 +349,13 @@ def main(): print(f" voice {vname}: {path} missing - skipped", flush=True) continue pcm = read_clip(path) - voices[vname] = pcm + latents = encoder(pcm) + voices[vname] = latents sources[vname] = (rel, licence, len(pcm)) - tensors["voice." + vname] = pcm + tensors["voice_latents." + vname] = latents default_voice = DEFAULT_VOICE.get(lang, next(iter(voices))) + if default_voice not in voices: + default_voice = next(iter(voices)) assert default_voice in voices, (default_voice, list(voices)) fl = cfg["flow_lm"] @@ -325,6 +365,7 @@ def main(): w = gguf.GGUFWriter(path, ARCH) w.add_name(f"Pocket TTS {lang}" + (" Q8_0" if a.q8 else "")) w.add_string("pocket.weights", "q8" if a.q8 else "f16") + w.add_bool("pocket.cloning", not a.no_cloning) if fake: w.add_string("pocket.fake", a.fake) w.add_string("pocket.language", lang) @@ -387,18 +428,19 @@ def main(): w.write_tensors_to_file() w.close() with open(path + ".LICENSE", "w", encoding="utf8") as f: - f.write(f"{stem}.gguf - Kyutai Pocket TTS ({lang}) weights{' (the served GEMMs as Q8_0)' if a.q8 else ''}, CC BY 4.0 (Kyutai), converted from kyutai/pocket-tts " + f.write(f"{stem}.gguf - Kyutai Pocket TTS ({lang}) weights{' (the served GEMMs as Q8_0)' if a.q8 else ''}" + f"{' without the codec encoder' if a.no_cloning else ''}, CC BY 4.0 (Kyutai), converted from kyutai/pocket-tts " f"languages/{lang}/model.safetensors @ {weights_rev} and the unigram SentencePiece tokenizer @ {tok_rev} by " "modules/dasLLAMA/harness/convert_pocket.py; the reference implementation is MIT (github.com/kyutai-labs/pocket-tts). " - "Bundled voice clips:\n") + "Bundled voices (each the codec encoder's latent frames of the clip named):\n") for vname, (rel, licence, n) in sources.items(): - f.write(f" voice.{vname}: {rel} ({n / SAMPLE_RATE:.1f} s) - {licence}\n") + f.write(f" voice_latents.{vname}: {rel} ({n / SAMPLE_RATE:.1f} s) - {licence}\n") for vname, why in EXCLUDED_VOICES.items(): if lang.startswith("english"): f.write(f" not shipped: {vname} - {why}\n") f.write("see the dasLLAMA THIRD_PARTY_NOTICES.md\n") - print(f"wrote {path}: {len(tensors)} tensors ({len(quantized)} as Q8_0), {len(pieces)} pieces, {len(voices)} voices " - f"(default {default_voice}); {os.path.getsize(path)} bytes on disk") + print(f"wrote {path}: {len(tensors)} tensors ({len(quantized)} as Q8_0{f', {len(dropped)} encoder tensors left out' if dropped else ''}), " + f"{len(pieces)} pieces, {len(voices)} voices (default {default_voice}); {os.path.getsize(path)} bytes on disk") if fq: by_group = {} for name, (fmt, nbytes, err) in fq.done.items(): diff --git a/modules/dasLLAMA/tests/test_storywish.das b/modules/dasLLAMA/tests/test_storywish.das index 76ee7cb0c2..6796dab740 100644 --- a/modules/dasLLAMA/tests/test_storywish.das +++ b/modules/dasLLAMA/tests/test_storywish.das @@ -17,11 +17,11 @@ require ../../../examples/dasLLAMA/storywish/wish.das // layout, and a line the model writes is recognized as a field the corpus puts after a story. // The smoke cell spawns the example under --smoke with fixed words and reads the witness lines it // logs: the story starts with those words and is written and read out to the end. Model-gated on -// tinystories-instruct-27M-Q8_0.gguf (its ../performance/model_specs.das row names the source), -// kitten-nano.gguf and the front-end packs under models_dir(); the run opens the example's window -// for about a minute, so a box without a window server skips, and --null-audio keeps it silent. +// tinystories-instruct-27M-Q8_0.gguf (its ../performance/model_specs.das row names the source) +// and pocket-tts-en-bill-q8.gguf (the one-voice Pocket file the page ships) under models_dir(); +// the run opens the example's window for about a minute, so a box without a window server +// skips, and --null-audio keeps it silent. -let PACK_FILES <- ["tts_g2p.bin", "tts_postag.bin"] let SMOKE_WORDS = "dragon, cake, moon" let START_LINE = "storywish: story 1 starts at frame 0 - words: dragon, cake, moon" let DONE_LINE = "storywish: story 1 written and read out" @@ -66,15 +66,9 @@ def private ready(t : T?) : bool { t |> skip("no window server (DISPLAY and WAYLAND_DISPLAY unset) - the example opens a window") return false } - for (name in ["tinystories-instruct-27M-Q8_0.gguf", "kitten-nano.gguf"]) { + for (name in ["tinystories-instruct-27M-Q8_0.gguf", "pocket-tts-en-bill-q8.gguf"]) { return false if (!model_available(t, path_join(models_dir(), name))) } - for (name in PACK_FILES) { - if (!stat(path_join(models_dir(), name)).is_valid) { - t |> skip("{name} not present") - return false - } - } return true } diff --git a/modules/dasLLAMA/tests/test_tts_pocket.das b/modules/dasLLAMA/tests/test_tts_pocket.das index 421f068b78..5b8037fea5 100644 --- a/modules/dasLLAMA/tests/test_tts_pocket.das +++ b/modules/dasLLAMA/tests/test_tts_pocket.das @@ -828,6 +828,41 @@ def private pocket_refusals(t : T?; var m : TtsModel; clip : array) { t |> equal(find_index(c3.voices, "endless"), -1, "and left no half voice in the roster") } +// the file shape a demo ships: one voice stored as its latent frames, the codec encoder left out +// (`convert_pocket.py --voices bill_boerst --no-cloning --q8`) - it speaks its roster from the +// stored frames, says it cannot clone, and refuses a clip by name +[test] +def test_pocket_no_encoder(t : T?) { + let path = path_join(models_dir(), "pocket-tts-en-bill-q8.gguf") + if (!model_available(t, path)) { + return + } + with_job_que() { + setup_dasllama_jobque_() + t |> success(!tts_needs_packs(path), "a Pocket file needs no packs") + var inscope m <- load_tts_model(path) + var inscope c <- caps(m) + t |> success(!c.cloning, "a file without the encoder does not clone") + t |> success(!c.speed, "and has no speed to honour") + t |> equal(length(c.voices), 1, "one voice in the roster") + t |> equal(c.voices[0], "bill_boerst") + t |> equal(length(c.langs), 1) + t |> equal(c.langs[0], "en") + var inscope a <- synthesize(m, "Whose woods these are I think I know.", "bill_boerst") + let st = speech_stats(a.pcm) + t |> success(st.finite && st.rms > 0.01, "the stored voice speaks (rms {st.rms})") + t |> success(length(a.pcm) >= 24000 && length(a.pcm) <= 24000 * 8, "speech length {length(a.pcm)} samples") + var inscope clip : array + clip |> resize(24000) + let why = panic_text_of() { + tts_register_voice(m, "x", clip, 24000) + } + t |> success(why |> find("no codec encoder") >= 0, "a clip is refused by the missing encoder: {why}") + var inscope c2 <- caps(m) + t |> equal(length(c2.voices), 1, "and the roster is unchanged") + } +} + // a text the chunker cannot split - no sentence mark, no comma - is one chunk past the token // budget: the voice's caches grow to hold its text and every frame the cap allows, and the // synthesis runs to EOS instead of panicking on the cache diff --git a/site-dasllama/examples.html b/site-dasllama/examples.html index 34fb63282f..b2657da5ba 100644 --- a/site-dasllama/examples.html +++ b/site-dasllama/examples.html @@ -66,7 +66,7 @@

Storyteller

Storywish

llm + tts · in the browser -

Type the words you wish for and a story model trained to take requests - our own 27M llama on the TinyStoriesInstruct corpus, published on Hugging Face as borisbat/dasllama-stories - writes a children's tale that uses them while KittenTTS reads it aloud. The same wasm64 engine as the storyteller, a 32 MB model image.

+

Type the words you wish for and a story model trained to take requests - our own 27M llama on the TinyStoriesInstruct corpus, published on Hugging Face as borisbat/dasllama-stories - writes a children's tale that uses them while Pocket TTS reads it aloud in one baked voice, no phoneme front end in the loop. The same wasm64 engine as the storyteller, a 32 MB story image and the speech model's own file.

llmttswasm64threads
type · Enter tells the story · Tab asks for dialogueChrome or Edge 133+, Firefox 134+ (memory64)
From 360ac9f1d85dd6261893f018449ebaf55d1e2215 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 00:33:36 -0700 Subject: [PATCH 05/14] the K-quant lane: a Pocket file's Q4_K tensors serve as their own planes, and the settled recipe mints as a real file. convert_pocket.py --kq writes the backbone's and the codec transformers' matrices and the text embedding as Q4_K through ggml's own quantizer and the flow head as Q8_0, the rest as the q8 form does; TtsLinear gains a K-quant plane pair (kq_fmt names the kernel-layer format, kq_repacked whether the active backend's layout holds them; the serializer, the blob walk and the teardown grew with it), linear_rows_decode takes the engine's K-quant GEMV over a row requantized to the Q8_K form, linear_rows the batched kq GEMM where the backend carries the tile and the GEMV per row elsewhere, linear_vec routes a quantized vector layer to the decode entry, and the loader keeps the three lanes switchable: unpinned, a Q4_K tensor comes in through gguf_transcode_q4k as its own planes; pinned q8 or f32 it dequantizes into that lane, and a Q8_0 head matrix takes its blocks on the q8 lane. test_pocket_kq_file holds the lanes to each other on the real file (the kq lane against the q8 lane of the same file 2.6e-2 rms relative; the f16 reference 1.0e-1, the format's loss the rig gates) and the exact lane speaking; the rig on pocket-tts-en-kq.gguf's native lane reads WER 3.86 / UTMOS 4.295 / RTF 0.049 against the published q8 file's 3.91 / 4.328 / 0.051. Storywish reads through pocket-tts-en-stuart-kq.gguf (stuart_bell, no encoder, 65 MB); the pocket suite 11/11, its smoke 8/8, the wasm64 page rebuilt and its set restaged (a story read at RTF 0.66 on the portable tier, the codec decoder 60 percent of it); the architecture doc's file section carries the three lanes. Co-Authored-By: Claude Fable 5.1 --- examples/dasLLAMA/storywish/main.das | 2 +- examples/dasLLAMA/storywish/models.json | 2 +- examples/dasLLAMA/storywish/web_shell.html | 2 +- modules/dasLLAMA/ARCHITECTURE_POCKET.md | 10 ++ modules/dasLLAMA/dasllama/dasllama_pocket.das | 40 ++++- .../dasLLAMA/dasllama/dasllama_tts_blocks.das | 154 +++++++++++++++++- modules/dasLLAMA/harness/convert_pocket.py | 55 +++++-- modules/dasLLAMA/tests/test_storywish.das | 4 +- modules/dasLLAMA/tests/test_tts_pocket.das | 80 ++++++++- 9 files changed, 316 insertions(+), 33 deletions(-) diff --git a/examples/dasLLAMA/storywish/main.das b/examples/dasLLAMA/storywish/main.das index 1090678b2b..3e968d7794 100644 --- a/examples/dasLLAMA/storywish/main.das +++ b/examples/dasLLAMA/storywish/main.das @@ -48,7 +48,7 @@ struct WishArgs { story_model : string = "tinystories-instruct-27M-Q8_0.gguf" @clarg_doc = "The TTS model file inside --models" - tts_model : string = "pocket-tts-en-bill-q8.gguf" + tts_model : string = "pocket-tts-en-stuart-kq.gguf" @clarg_doc = "Voice name or alias (default: the model's last voice)" voice : string diff --git a/examples/dasLLAMA/storywish/models.json b/examples/dasLLAMA/storywish/models.json index d6a9086dfa..0a573a2a25 100644 --- a/examples/dasLLAMA/storywish/models.json +++ b/examples/dasLLAMA/storywish/models.json @@ -3,6 +3,6 @@ { "file": "tinystories-instruct-27M-Q8_0.gguf", "repo": "borisbat/dasllama-stories", "sha256": "92c2b775070b76ee31b9921f7f339ec9fc0e08b54be0616418414d4eec65d282", "dlim": "tinystories-instruct-27M.dlim" } ], "files": [ - { "file": "pocket-tts-en-bill-q8.gguf", "repo": "borisbat/dasllama-tts", "sha256": "f8ffeeb5517aebb1c6a74572b53e57fe738eec23deca0c9c39dfb913030c5a28" } + { "file": "pocket-tts-en-stuart-kq.gguf", "repo": "borisbat/dasllama-tts", "sha256": "bc9604b527066134354dc480e20c960f63f5c3538c1dd757ba409bd782cddac9" } ] } diff --git a/examples/dasLLAMA/storywish/web_shell.html b/examples/dasLLAMA/storywish/web_shell.html index 51f7234121..c1d3ae8788 100644 --- a/examples/dasLLAMA/storywish/web_shell.html +++ b/examples/dasLLAMA/storywish/web_shell.html @@ -210,7 +210,7 @@

storywish

}; return c; })(), - arguments: ['--models', '/models', '--story-model', 'tinystories-instruct-27M.dlim', '--tts-model', 'pocket-tts-en-bill-q8.gguf', '--autoplay'], + arguments: ['--models', '/models', '--story-model', 'tinystories-instruct-27M.dlim', '--tts-model', 'pocket-tts-en-stuart-kq.gguf', '--autoplay'], print: function (text) { console.log(text); remember(text); }, printErr: function (text) { console.error(text); remember(text); }, onAbort: function (what) { showFailure(what || 'abort'); }, diff --git a/modules/dasLLAMA/ARCHITECTURE_POCKET.md b/modules/dasLLAMA/ARCHITECTURE_POCKET.md index 23f3cace1f..c56478d580 100644 --- a/modules/dasLLAMA/ARCHITECTURE_POCKET.md +++ b/modules/dasLLAMA/ARCHITECTURE_POCKET.md @@ -96,6 +96,16 @@ another language takes the text as it is, since the normalizer reads English. ### 2.50 The published file carries the served quants {#pocket-q8-file} +A file has three lanes and its formats decide which it can take. A K-quant tensor +(`convert_pocket.py --kq`: the backbone's and the codec transformers' matrices and the text +embedding as Q4_K, the flow head as Q8_0, the rest as the q8 form writes it) serves as its own +planes unless a lane is pinned - `TtsLinear` holds the plane pair the GGUF transcoder wrote, +repacked where the backend carries kq kernels, and the frame loop's GEMV and the prompt's GEMM +take the engine's own K-quant entries (`linear_rows_decode`, `linear_rows_kq`), the rows +requantized to the Q8_K form the way the engine's own decode does. Pinned q8 or f32, the same +tensor dequantizes into that lane, so one file serves every lane and the rig compares them on +the same sentences. A vector layer the file stores Q8_0 (the head) runs its GEMV on the q8 lane. + Two lanes, as the StyleTTS2 families have: f32, the parity rail's reference, and q8, the served default - the transformer layers' four matrices, the frame input projection and every dense stride-1 codec conv on 32-wide channels as Q8_0 rows (`linear_prepare`, diff --git a/modules/dasLLAMA/dasllama/dasllama_pocket.das b/modules/dasLLAMA/dasllama/dasllama_pocket.das index 51d577162e..6c9041c75c 100644 --- a/modules/dasLLAMA/dasllama/dasllama_pocket.das +++ b/modules/dasLLAMA/dasllama/dasllama_pocket.das @@ -138,7 +138,8 @@ struct PocketModel { } // ===== the serving lane: f32 planes (the parity rail's form) or Q8_0 quants on the GEMMs, the -// same pin and policy as the StyleTTS2 families' ===== +// same pin and policy as the StyleTTS2 families'; unpinned, a K-quant tensor of the file serves +// as its own planes (the kq lane), so a file's formats are the lanes it can take ===== enum private PocketLane { unset @@ -154,9 +155,9 @@ def private pocket_serve_q8_() : bool { return true } -//! Pin the GEMM weights' format for subsequent loads: Q8_0 quants or the file's own f32. -//! ``reset_pocket_q8`` returns to the policy default. The facade spells these ``set_tts_q8`` / -//! ``reset_tts_q8`` for every family at once. +//! Pin the GEMM weights' format for subsequent loads: Q8_0 quants or the file's own f32 (a +//! K-quant tensor dequantizes into either); ``reset_pocket_q8`` returns to the policy default, +//! which serves a K-quant tensor as its own planes. The facade: ``set_tts_q8`` / ``reset_tts_q8``. def set_pocket_q8(on : bool) { g_pocket_q8_pin = on ? PocketLane.q8 : PocketLane.exact } @@ -168,9 +169,13 @@ def reset_pocket_q8() { //! Would the next load serve its GEMMs as q8 - the pin when set, the policy otherwise. def pocket_serves_q8() : bool => pocket_serve_q8_() +//! Would the next load serve a K-quant tensor as its own planes - true unless a lane is pinned. +def pocket_serves_native() : bool => g_pocket_q8_pin == PocketLane.unset + // what the readers mint while a load runs: the lane's quant choice and the backend's repack var private g_stage_q8 = false var private g_stage_repack = false +var private g_stage_native = false def finalize(var m : PocketModel) { delete m.voice_names @@ -311,10 +316,25 @@ def private read_linear(m : GGUFMeta; bytes : array | #; prefix : string; if (gguf_find_tensor(m, "{prefix}.bias") >= 0) { l.b <- read_arr(m, bytes, "{prefix}.bias") } - if (is_q8_tensor(m, wname) && (vec_only || l.nin % 32l != 0l || l.nout % 32l != 0l)) { - panic("dasLLAMA pocket: '{wname}' [{l.nout}][{l.nin}] is Q8_0, a shape the q8 lane does not serve ({vec_only ? "a vector-only layer" : "not 32-wide on both dims"})") + if (is_q8_tensor(m, wname) && (l.nin % 32l != 0l || l.nout % 32l != 0l)) { + panic("dasLLAMA pocket: '{wname}' [{l.nout}][{l.nin}] is Q8_0, a shape the q8 lane does not serve (not 32-wide on both dims)") + } + if (g_stage_native && gguf_tensor_type(m, wname) == GGML_TYPE_Q4_K) { + // the kq lane: the file's own K-quant planes, straight into the layer + let n = l.nout * l.nin + if (l.nin % 256l != 0l) { + panic("dasLLAMA pocket: '{wname}' [{l.nout}][{l.nin}] is Q4_K on a width that is not a whole number of 256-superblocks") + } + var inscope kq : array + var inscope ks : array + kq |> reserve_resize(n / 256l * kq_qsb(KqFmt.k4)) + ks |> reserve_resize(n / 256l * kq_ssb(KqFmt.k4)) + gguf_transcode_q4k(m, bytes, wname, kq, ks, 0l, n) + linear_take_kq(l, 4, kq, ks, g_stage_repack) + return <- l } - if (g_stage_q8 && !vec_only && is_q8_tensor(m, wname)) { + if (g_stage_q8 && is_q8_tensor(m, wname)) { + // a vector-only layer the file stores Q8_0 (the flow head) takes the blocks too: its GEMV runs on the q8 lane let n = l.nout * l.nin l.wq |> reserve_resize(n) l.wqs |> reserve_resize(n / 32l) @@ -325,7 +345,7 @@ def private read_linear(m : GGUFMeta; bytes : array | #; prefix : string; l.q8 = true return <- l } - l.w <- read_arr(m, bytes, wname) // a Q8_0 tensor dequantizes here: the f32 lane of a published file + l.w <- read_arr(m, bytes, wname) // a Q8_0 or K-quant tensor dequantizes here: the f32 lane of a published file, or the q8 lane pinned over a K-quant one linear_prepare(l, true, g_stage_q8 && !vec_only, g_stage_repack, vec_only) return <- l } @@ -571,8 +591,9 @@ def load_pocket(path : string) : PocketModel { var inscope m = PocketModel() m.q8 = pocket_serve_q8_() g_stage_q8 = m.q8 + g_stage_native = pocket_serves_native() g_stage_repack = m.q8 && select_matmul_backend_for_load_() - to_log(LOG_INFO, "dasLLAMA pocket: GEMM lane {m.q8 ? "q8" : "f32"} - {g_pocket_q8_pin != PocketLane.unset ? "pinned via set_tts_q8" : "the policy default"}\n") + to_log(LOG_INFO, "dasLLAMA pocket: GEMM lane {m.q8 ? "q8" : "f32"}{g_stage_native ? ", a K-quant tensor as its own planes" : ""} - {g_pocket_q8_pin != PocketLane.unset ? "pinned via set_tts_q8" : "the policy default"}\n") let f = fopen(path, "rb") if (f == null) { panic("dasLLAMA pocket: cannot open model '{path}'") @@ -636,6 +657,7 @@ def load_pocket(path : string) : PocketModel { } fclose(f) g_stage_file_q8 = false + g_stage_native = false m.tok <- load_tokenizer_gguf(path) return <- m } diff --git a/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das b/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das index 9dde515a2c..e144c9a8f6 100644 --- a/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das +++ b/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das @@ -8,6 +8,7 @@ require dasllama/dasllama_lint public require math require daslib/archive require dasllama/dasllama_math +require dasllama/dasllama_math_default // matmul_kq: the portable K-quant GEMV over disk-order planes require dasllama/dasllama_par require dasllama/dasllama_plane require dasllama/dasllama_convert @@ -134,6 +135,51 @@ def weight_slot_q8(var io : TtsBlobIo; var a : array; var span : TtsSpan) } } +//! `release_weight` for a byte plane (a K-quant quant or scale plane). +def release_weight_u8(var a : array) { + if (lock_count(a) != 0) { + unsafe { + _builtin_forget_temp_array(a) + } + } else { + delete a + } +} + +//! `blob_push_q8` for a byte plane: it rides the int8 quant blob byte for byte. +def blob_push_u8(var qblob : array; src : array) : TtsSpan { + let n = long_length(src) + return TtsSpan() if (n == 0l) + let off = (long_length(qblob) + BLOB_ALIGN_FLOATS - 1l) / BLOB_ALIGN_FLOATS * BLOB_ALIGN_FLOATS + qblob |> ensure_capacity(off + n) + qblob |> resize(off + n) + for (i in range64(n)) { + qblob[off + i] = int8(src[i]) + } + return TtsSpan(off = off, n = n) +} + +//! `bind_span_q8` for a byte plane over the int8 quant plane. +def bind_span_u8(qplane : PlaneI8; span : TtsSpan; var a : array) { + return if (span.n == 0l) + if (span.off < 0l || span.n > qplane.n || span.off > qplane.n - span.n) { + panic("dasLLAMA tts: kq span {span.off}+{span.n} lies outside the {qplane.n}-byte quant blob") + } + unsafe { + _builtin_make_temp_array_i64(a, reinterpret(plane_at(qplane, span.off)), span.n) + } +} + +//! `weight_slot` for a byte plane. +def weight_slot_u8(var io : TtsBlobIo; var a : array; var span : TtsSpan) { + if (io.staging) { + span = blob_push_u8(*io.qblob, a) + delete a + } else { + bind_span_u8(io.qplane, span, a) + } +} + //! A bare weight vector (an embedding table, a Snake alpha, the ISTFT window) with its span. struct TtsWeight { a : array @@ -144,7 +190,7 @@ struct TtsWeight { // overload; the counts below are the tripwires that catch a field added without a line. let private TTS_WEIGHT_META_FIELDS = 1 let private TTS_CONV1D_META_FIELDS = 21 -let private TTS_LINEAR_META_FIELDS = 8 +let private TTS_LINEAR_META_FIELDS = 12 let private TTS_NORM_META_FIELDS = 2 let private TTS_LSTM_META_FIELDS = 5 @@ -247,15 +293,21 @@ struct TtsLinear { wt : array wq : array // [nout][nin] Q8_0 quants, repacked for the backend - the q8 rows form's W wqs : array + kq : array // [nout][nin] as a K-quant plane pair (the file's own format), repacked for a backend that carries kq + ks : array b : array w_span : TtsSpan = TtsSpan() wt_span : TtsSpan = TtsSpan() wq_span : TtsSpan = TtsSpan() wqs_span : TtsSpan = TtsSpan() + kq_span : TtsSpan = TtsSpan() + ks_span : TtsSpan = TtsSpan() b_span : TtsSpan = TtsSpan() nout : int64 nin : int64 q8 : bool + kq_fmt : int // the kernel-layer format id of the kq planes (4 = Q4_K), 0 = the layer has none + kq_repacked : bool // the planes are in the active backend's layout (matmul_kq_active), else disk order (matmul_kq) } def serialize(var arch : Archive; var l : TtsLinear) { @@ -264,10 +316,14 @@ def serialize(var arch : Archive; var l : TtsLinear) { arch |> serialize(l.wt_span) arch |> serialize(l.wq_span) arch |> serialize(l.wqs_span) + arch |> serialize(l.kq_span) + arch |> serialize(l.ks_span) arch |> serialize(l.b_span) arch |> serialize_raw(l.nout) arch |> serialize_raw(l.nin) arch |> serialize_raw(l.q8) + arch |> serialize_raw(l.kq_fmt) + arch |> serialize_raw(l.kq_repacked) } def finalize(var l : TtsLinear) { @@ -275,6 +331,8 @@ def finalize(var l : TtsLinear) { release_weight(l.wt) release_weight_q8(l.wq) release_weight(l.wqs) + release_weight_u8(l.kq) + release_weight_u8(l.ks) release_weight(l.b) } @@ -283,6 +341,8 @@ def weights_walk(var io : TtsBlobIo; var l : TtsLinear) { weight_slot(io, l.wt, l.wt_span) weight_slot_q8(io, l.wq, l.wq_span) weight_slot(io, l.wqs, l.wqs_span) + weight_slot_u8(io, l.kq, l.kq_span) + weight_slot_u8(io, l.ks, l.ks_span) weight_slot(io, l.b, l.b_span) } @@ -1114,10 +1174,85 @@ def private linear_rows_add_bias(l : TtsLinear; t : int64; var y : array) } } -//! y [1][nout] = x [1][nin] . w^T + b for one row - a decode step: on the q8 lane the GEMV entry -//! (`matmul_q8q8`) instead of a one-row batch, else `linear_rows` at t = 1. The continuous-audio -//! family's frame loop calls this; the phoneme families' rows GEMMs never take it. +// ===== the K-quant lane: a layer whose weights are the file's own K-quant planes ===== + +var @scratch g_kq_xq : array +var @scratch g_kq_xs : array +var @scratch g_kq_xbs : array +var @scratch g_kq_row : array + +//! Give a layer the K-quant planes of its [nout][nin] weight (`fmt` the kernel-layer id, 4 = +//! Q4_K; the planes as the GGUF transcoder wrote them) and repack them for the active backend +//! where it carries kq kernels; nin is a whole number of 256-superblocks by the format's rule. +def linear_take_kq(var l : TtsLinear; fmt : int; var kq, ks : array; repack : bool) { + if (l.nin % 256l != 0l) { + panic("dasLLAMA tts: a K-quant layer [{l.nout}][{l.nin}] needs nin on 256") + } + l.kq <- kq + l.ks <- ks + l.kq_fmt = fmt + l.kq_repacked = false + if (repack && kernel_backend_has_kq()) { + repack_kq_weight(fmt, l.kq, l.ks, 0l, l.nin, l.nout) + l.kq_repacked = true + } + delete l.w +} + +// one row [nin] through the K-quant GEMV: the row requantized to the Q8_K form, then the +// backend's core over repacked planes or the portable one over disk order +def private kq_gemv_row(l : TtsLinear; x : array; xoff : int64; var y : array; yoff : int64) { + let nin = l.nin + var row & = g_kq_row + row |> reserve_resize(nin) + for (i in range64(nin)) { + row[i] = x[xoff + i] + } + var xq & = g_kq_xq + var xs & = g_kq_xs + var xbs & = g_kq_xbs + xq |> reserve_resize(nin) + xs |> reserve_resize(nin / 256l) + xbs |> reserve_resize(nin / 16l) + requant_rows_q8k_bs(row, nin, 1l, xq, xs, xbs, false) + if (l.kq_repacked) { + matmul_kq_active(l.kq_fmt, y, l.kq, l.ks, 0l, xq, xs, xbs, nin, l.nout, yoff) + } else { + matmul_kq(l.kq_fmt, y, l.kq, l.ks, 0l, xq, xs, xbs, nin, l.nout, yoff) + } +} + +// The K-quant lane of linear_rows: the rows requantized to the Q8_K form at once and one batched +// kq GEMM where the backend carries the tile, else the GEMV per row; then the bias. +def private linear_rows_kq(l : TtsLinear; x : array; t : int64; var y : array) { + let nin = l.nin + if (l.kq_repacked && kernel_backend_has_kq_batch()) { + var xq & = g_kq_xq + var xs & = g_kq_xs + var xbs & = g_kq_xbs + xq |> reserve_resize(t * nin) + xs |> reserve_resize(t * nin / 256l) + xbs |> reserve_resize(t * nin / 16l) + requant_rows_q8k_bs(x, nin, t, xq, xs, xbs, t * nin >= 65536l) + matmul_kq_batch(l.kq_fmt, y, l.kq, l.ks, 0l, xq, xs, xbs, nin, l.nout, t) + } else { + for (r in range64(t)) { + kq_gemv_row(l, x, r * nin, y, r * l.nout) + } + } + linear_rows_add_bias(l, t, y) +} + +//! y [1][nout] = x [1][nin] . w^T + b for one row - a decode step: the q8 lane's GEMV entry +//! (`matmul_q8q8`) or the K-quant lane's, never a one-row batch; else `linear_rows` at t = 1. +//! The continuous-audio family's frame loop calls this; the phoneme families' rows GEMMs never take it. def linear_rows_decode(l : TtsLinear; x : array; @scratch @exact_size var y : array) { + if (l.kq_fmt != 0) { + y |> reserve_resize(l.nout) + kq_gemv_row(l, x, 0l, y, 0l) + linear_rows_add_bias(l, 1l, y) + return + } if (!l.q8) { linear_rows(l, x, 1l, y) return @@ -1134,9 +1269,14 @@ def linear_rows_decode(l : TtsLinear; x : array; @scratch @exact_size var } //! y [t][nout] = x [t][nin] . w^T + b: the tiled GEMM over row blocks when `wt` was minted, -//! the batched dot form otherwise; a layer minted q8 runs the q8 lane. +//! the batched dot form otherwise; a layer minted q8 runs the q8 lane, a layer holding the +//! file's K-quant planes the kq lane. def linear_rows(l : TtsLinear; x : array; t : int64; @scratch @exact_size var y : array) { y |> reserve_resize(t * l.nout) + if (l.kq_fmt != 0) { + linear_rows_kq(l, x, t, y) + return + } if (l.q8) { linear_rows_q8(l, x, t, y) return @@ -1179,6 +1319,10 @@ def linear_rows(l : TtsLinear; x : array; t : int64; @scratch @exact_size //! y [nout] = w . x + b def linear_vec(l : TtsLinear; x : array; @scratch @exact_size var y : array) { + if (l.q8 || l.kq_fmt != 0) { // a vector layer the file stores quantized takes the decode entry's GEMV + linear_rows_decode(l, x, y) + return + } y |> reserve_resize(l.nout) matmul(y, l.w, x, l.nin, l.nout) return if (empty(l.b)) diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index 05fb444bde..06075ed983 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -166,21 +166,30 @@ def __init__(self, llama_cpp, gguf_mod): self.skipped = [] self.done = {} - def apply(self, name, w32, fmt): - from gguf.quants import dequantize + def quantize_bytes(self, name, rows, fmt): + """`rows` [nrows][n_per_row] f32 -> the format's blocks, [nrows][bytes per row] u8 (None + where the width is not a whole number of blocks).""" t = self.gguf.GGMLQuantizationType[fmt] block, type_size = self.gguf.GGML_QUANT_SIZES[t] - # a conv [cout][cin][k] rounds per output channel over its cin*k taps; a matrix per row - rows = np.ascontiguousarray(w32.reshape(w32.shape[0], -1) if w32.ndim == 3 else w32.reshape(-1, w32.shape[-1]), dtype=np.float32) nrows, n_per_row = rows.shape if n_per_row % block != 0: self.skipped.append((name, fmt, n_per_row)) - return w32 + return None out = np.empty((nrows, (n_per_row // block) * type_size), dtype=np.uint8) c = self.ctypes n = self.lib.ggml_quantize_chunk(int(t), rows.ctypes.data_as(c.POINTER(c.c_float)), out.ctypes.data_as(c.c_void_p), 0, nrows, n_per_row, None) assert n == out.nbytes, (name, fmt, n, out.nbytes) + return out + + def apply(self, name, w32, fmt): + from gguf.quants import dequantize + t = self.gguf.GGMLQuantizationType[fmt] + # a conv [cout][cin][k] rounds per output channel over its cin*k taps; a matrix per row + rows = np.ascontiguousarray(w32.reshape(w32.shape[0], -1) if w32.ndim == 3 else w32.reshape(-1, w32.shape[-1]), dtype=np.float32) + out = self.quantize_bytes(name, rows, fmt) + if out is None: + return w32 back = dequantize(out, t).reshape(w32.shape).astype(np.float32) err = float(np.sqrt(((back - w32) ** 2).mean()) / max(np.sqrt((w32 ** 2).mean()), 1e-12)) self.done[name] = (fmt, out.nbytes, err) @@ -208,6 +217,18 @@ def encode(pcm): return encode +def kq_tensor(name, shape): + """The tensors `--kq` stores as Q4_K: the backbone's and the codec transformers' matrices and + the text embedding table - every one 256-wide along its rows, the K-quant rule.""" + return fake_group(name, shape) in ("attn", "ffn", "codec", "embed") and len(shape) == 2 and shape[1] % 256 == 0 + + +def head_q8_linear(name, shape): + """The flow head's matrices, which `--kq` stores as Q8_0: the vector layers' GEMVs run on the + q8 lane where the file holds the blocks.""" + return name.startswith("head.") and name.endswith(".weight") and len(shape) == 2 and shape[0] % 32 == 0 and shape[1] % 32 == 0 + + def canonical(name): if name.startswith("flow_lm.transformer.layers."): return "backbone." + name[len("flow_lm.transformer.layers."):] @@ -279,6 +300,8 @@ def main(): ap.add_argument("--q8", action="store_true", help="the published form: the served GEMM weights as Q8_0 in the kernels' layout") ap.add_argument("--fake", default="", help="quality experiment, never published: group=fmt[,group=fmt] (attn, ffn, input, speaker, head, " "codec; ggml format names) - the group's weights round through that format before they are stored; needs --name") + ap.add_argument("--kq", action="store_true", help="the small form: the backbone's and the codec transformers' matrices and the text embedding " + "as Q4_K, the flow head as Q8_0, the rest as --q8 writes it (implies --q8)") ap.add_argument("--voices", default="", help="the roster as a comma list of the language's voice names (default: every voice the language ships)") ap.add_argument("--no-cloning", action="store_true", help="leave the codec encoder out: the roster speaks from its stored latents, " "tts_register_voice refuses, and the file is smaller by the encoder") @@ -288,7 +311,9 @@ def main(): from gguf.quants import quantize as gguf_quantize fake = parse_fake(a.fake) assert not fake or a.name, "--fake files are local experiments: give them a --name" - fq = FakeQuant(a.llama_cpp, gguf) if fake else None + if a.kq: + a.q8 = True + fq = FakeQuant(a.llama_cpp, gguf) if (fake or a.kq) else None lang = a.language cfg = load_config(lang) @@ -308,6 +333,7 @@ def main(): conv_stride["mimi.downsample.conv.conv.weight"] = st["mimi.downsample.conv.conv.weight"].shape[2] // 2 # kernel 2 x stride tensors = {} quantized = [] + quantized_k4 = [] dropped = [] for k, v in st.items(): name = canonical(k) @@ -319,7 +345,10 @@ def main(): group = fake_group(name, v.shape, q8_conv(name, v.shape, conv_stride.get(name, 1), ".convtr." in name)) if fake else None if group in fake: v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group]) - if a.q8 and q8_linear(name, v.shape): + if a.kq and kq_tensor(name, v.shape): + tensors[name] = ("k4", np.ascontiguousarray(v.astype(np.float32))) + quantized_k4.append(name) + elif a.q8 and (q8_linear(name, v.shape) or (a.kq and head_q8_linear(name, v.shape))): tensors[name] = ("q8", np.ascontiguousarray(v.astype(np.float32))) quantized.append(name) elif a.q8 and q8_conv(name, v.shape, conv_stride.get(name, 1), ".convtr." in name): @@ -365,6 +394,8 @@ def main(): w = gguf.GGUFWriter(path, ARCH) w.add_name(f"Pocket TTS {lang}" + (" Q8_0" if a.q8 else "")) w.add_string("pocket.weights", "q8" if a.q8 else "f16") + if a.kq: + w.add_string("pocket.kq", "q4_k") w.add_bool("pocket.cloning", not a.no_cloning) if fake: w.add_string("pocket.fake", a.fake) @@ -418,7 +449,10 @@ def main(): w.add_bool("tokenizer.ggml.byte_fallback", spec["byte_fallback"]) for name in sorted(tensors): t = tensors[name] - if isinstance(t, tuple): + if isinstance(t, tuple) and t[0] == "k4": + data = fq.quantize_bytes(name, t[1], "Q4_K") # ggml's own quantizer; the writer derives the element shape from the byte shape + w.add_tensor(name, data, raw_dtype=gguf.GGMLQuantizationType.Q4_K) + elif isinstance(t, tuple): data = gguf_quantize(t[1], gguf.GGMLQuantizationType.Q8_0) # the writer derives the element shape from the byte shape w.add_tensor(name, data, raw_dtype=gguf.GGMLQuantizationType.Q8_0) else: @@ -428,7 +462,7 @@ def main(): w.write_tensors_to_file() w.close() with open(path + ".LICENSE", "w", encoding="utf8") as f: - f.write(f"{stem}.gguf - Kyutai Pocket TTS ({lang}) weights{' (the served GEMMs as Q8_0)' if a.q8 else ''}" + f.write(f"{stem}.gguf - Kyutai Pocket TTS ({lang}) weights{' (the backbone and codec transformers as Q4_K, the head as Q8_0)' if a.kq else (' (the served GEMMs as Q8_0)' if a.q8 else '')}" f"{' without the codec encoder' if a.no_cloning else ''}, CC BY 4.0 (Kyutai), converted from kyutai/pocket-tts " f"languages/{lang}/model.safetensors @ {weights_rev} and the unigram SentencePiece tokenizer @ {tok_rev} by " "modules/dasLLAMA/harness/convert_pocket.py; the reference implementation is MIT (github.com/kyutai-labs/pocket-tts). " @@ -439,7 +473,8 @@ def main(): if lang.startswith("english"): f.write(f" not shipped: {vname} - {why}\n") f.write("see the dasLLAMA THIRD_PARTY_NOTICES.md\n") - print(f"wrote {path}: {len(tensors)} tensors ({len(quantized)} as Q8_0{f', {len(dropped)} encoder tensors left out' if dropped else ''}), " + print(f"wrote {path}: {len(tensors)} tensors ({len(quantized)} as Q8_0{f', {len(quantized_k4)} as Q4_K' if quantized_k4 else ''}" + f"{f', {len(dropped)} encoder tensors left out' if dropped else ''}), " f"{len(pieces)} pieces, {len(voices)} voices (default {default_voice}); {os.path.getsize(path)} bytes on disk") if fq: by_group = {} diff --git a/modules/dasLLAMA/tests/test_storywish.das b/modules/dasLLAMA/tests/test_storywish.das index 6796dab740..10690051c0 100644 --- a/modules/dasLLAMA/tests/test_storywish.das +++ b/modules/dasLLAMA/tests/test_storywish.das @@ -18,7 +18,7 @@ require ../../../examples/dasLLAMA/storywish/wish.das // The smoke cell spawns the example under --smoke with fixed words and reads the witness lines it // logs: the story starts with those words and is written and read out to the end. Model-gated on // tinystories-instruct-27M-Q8_0.gguf (its ../performance/model_specs.das row names the source) -// and pocket-tts-en-bill-q8.gguf (the one-voice Pocket file the page ships) under models_dir(); +// and pocket-tts-en-stuart-kq.gguf (the one-voice Pocket file the page ships) under models_dir(); // the run opens the example's window for about a minute, so a box without a window server // skips, and --null-audio keeps it silent. @@ -66,7 +66,7 @@ def private ready(t : T?) : bool { t |> skip("no window server (DISPLAY and WAYLAND_DISPLAY unset) - the example opens a window") return false } - for (name in ["tinystories-instruct-27M-Q8_0.gguf", "pocket-tts-en-bill-q8.gguf"]) { + for (name in ["tinystories-instruct-27M-Q8_0.gguf", "pocket-tts-en-stuart-kq.gguf"]) { return false if (!model_available(t, path_join(models_dir(), name))) } return true diff --git a/modules/dasLLAMA/tests/test_tts_pocket.das b/modules/dasLLAMA/tests/test_tts_pocket.das index 5b8037fea5..be5e652a7c 100644 --- a/modules/dasLLAMA/tests/test_tts_pocket.das +++ b/modules/dasLLAMA/tests/test_tts_pocket.das @@ -624,6 +624,78 @@ def test_pocket_q8_file(t : T?) { let Q8_FILE_BAR = 5.0e-2lf +def private kq_gguf_path() : string { + return path_join(models_dir(), "pocket-tts-en-kq.gguf") +} + +[test] +def test_pocket_kq_file(t : T?) { + //! The small form (`convert_pocket.py --kq`): the backbone's and the codec transformers' + //! matrices and the text embedding as Q4_K, the flow head as Q8_0. Unpinned, the K-quant + //! tensors serve as their own planes (the kq lane); pinned q8 or f32 they dequantize into + //! that lane. The three lanes of one file against each other and against the f16 reference. + let path = gguf_path() + let kqpath = kq_gguf_path() + if (!model_available(t, path) || !model_available(t, kqpath)) { + return + } + var inscope man <- load_manifest() + if (empty(man.cases)) { + t |> skip("no oracle dumps under {oracle_dir()}") + return + } + with_job_que() { + setup_dasllama_jobque_() + var inscope sc = PocketScratch() + defer() { + reset_pocket_q8() + } + reset_pocket_q8() + t |> success(pocket_serves_native(), "unpinned, a K-quant tensor serves as its own planes") + var inscope mk <- load_pocket(kqpath) + var kq_layers = 0 + for (l in mk.backbone.layers) { + kq_layers += (l.in_proj.kq_fmt == 4 && l.out_proj.kq_fmt == 4 && l.ffn1.kq_fmt == 4 && l.ffn2.kq_fmt == 4) ? 1 : 0 + } + t |> equal(kq_layers, length(mk.backbone.layers), "every backbone layer's four GEMMs came in as Q4_K planes") + t |> success(mk.mimi.dec_tf.layers[0].ffn1.kq_fmt == 4 && mk.mimi.enc_tf.layers[0].in_proj.kq_fmt == 4, "the codec transformers' matrices too") + t |> success(mk.head.blocks[0].mlp1.q8 && mk.head.final_linear.q8 && mk.head.cond_embed.q8, "the flow head's matrices came in as Q8_0 blocks") + t |> success(!mk.speaker_proj.q8 && mk.speaker_proj.kq_fmt == 0, "the speaker projection stays f32") + t |> equal(long_length(mk.text_emb.a), 4001l * mk.backbone.d, "the Q4_K embedding table dequantized into its f32 form") + // the same file on the q8 lane: every Q4_K tensor dequantized and requantized to Q8_0 - + // the same weights, so the two lanes differ by their activation quants alone + set_pocket_q8(true) + var inscope mq <- load_pocket(kqpath) + t |> success(mq.backbone.layers[0].in_proj.q8 && mq.backbone.layers[0].in_proj.kq_fmt == 0, "pinned q8, the K-quant tensor is Q8_0 blocks") + reset_pocket_q8() + var inscope mf <- load_pocket(path) + for (c in man.cases) { + continue if (!c.stages || c.voice != "alba") + var inscope a <- forced_latents(mk, c, man, sc) + var inscope b <- forced_latents(mq, c, man, sc) + let rel = rms_relative(a, b) + to_log(LOG_INFO, "pocket {c.id} kq lane vs the q8 lane of the same file: rms relative {rel}\n") + t |> success(rel < Q8_FILE_BAR, "{c.id}: the kq lane and the q8 lane of one file agree (rms relative {rel})") + var inscope poisoned := b + for (v in poisoned) { + v += 2.0 + } + t |> success(rms_relative(a, poisoned) >= Q8_FILE_BAR, "{c.id}: the bar discriminates a poisoned expectation") + var inscope f <- forced_latents(mf, c, man, sc) + to_log(LOG_INFO, "pocket {c.id} kq lane vs the f16 reference: rms relative {rms_relative(a, f)} (the format's loss; the rig is its gate)\n") + break + } + // the exact lane over the K-quant file: dequantized f32 planes + set_pocket_q8(false) + var inscope m32 <- load_pocket(kqpath) + t |> success(!m32.q8 && m32.backbone.layers[0].in_proj.kq_fmt == 0 && !m32.backbone.layers[0].in_proj.q8, "pinned f32, the K-quant tensor is an f32 plane") + var inscope f32 <- load_tts_model(kqpath) + var inscope spoken <- synthesize(f32, "The quick brown fox jumps over the lazy dog.", "alba") + let st = speech_stats(spoken.pcm) + t |> success(st.finite && st.rms > 0.01, "the exact lane of the K-quant file speaks (rms {st.rms})") + } +} + // ===== the other five languages: one Q8_0 file each, its own tokenizer, one default voice ===== struct private PocketLang { @@ -829,11 +901,11 @@ def private pocket_refusals(t : T?; var m : TtsModel; clip : array) { } // the file shape a demo ships: one voice stored as its latent frames, the codec encoder left out -// (`convert_pocket.py --voices bill_boerst --no-cloning --q8`) - it speaks its roster from the +// (`convert_pocket.py --voices stuart_bell --no-cloning --kq`) - it speaks its roster from the // stored frames, says it cannot clone, and refuses a clip by name [test] def test_pocket_no_encoder(t : T?) { - let path = path_join(models_dir(), "pocket-tts-en-bill-q8.gguf") + let path = path_join(models_dir(), "pocket-tts-en-stuart-kq.gguf") if (!model_available(t, path)) { return } @@ -845,10 +917,10 @@ def test_pocket_no_encoder(t : T?) { t |> success(!c.cloning, "a file without the encoder does not clone") t |> success(!c.speed, "and has no speed to honour") t |> equal(length(c.voices), 1, "one voice in the roster") - t |> equal(c.voices[0], "bill_boerst") + t |> equal(c.voices[0], "stuart_bell") t |> equal(length(c.langs), 1) t |> equal(c.langs[0], "en") - var inscope a <- synthesize(m, "Whose woods these are I think I know.", "bill_boerst") + var inscope a <- synthesize(m, "Whose woods these are I think I know.", "stuart_bell") let st = speech_stats(a.pcm) t |> success(st.finite && st.rms > 0.01, "the stored voice speaks (rms {st.rms})") t |> success(length(a.pcm) >= 24000 && length(a.pcm) <= 24000 * 8, "speech length {length(a.pcm)} samples") From f3c19d6dcbee6b9c7b480014e51b157bdaff9c9a Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 00:46:40 -0700 Subject: [PATCH 06/14] a browser example's page reloads when the browser restores it from its back-forward cache: Back to another page and Forward brought storywish back with its workers and the audio output frozen mid-frame and out of step, and the first sound was whatever the output ring held; the shells of storywish and storyteller start over on a persisted pageshow (the gate again, a click, a clean start), and the examples folder's architecture doc says why Co-Authored-By: Claude Fable 5.1 --- examples/dasLLAMA/ARCHITECTURE.md | 5 ++++- examples/dasLLAMA/storyteller/web_shell.html | 5 +++++ examples/dasLLAMA/storywish/web_shell.html | 5 +++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/examples/dasLLAMA/ARCHITECTURE.md b/examples/dasLLAMA/ARCHITECTURE.md index 85647b1550..340706edfe 100644 --- a/examples/dasLLAMA/ARCHITECTURE.md +++ b/examples/dasLLAMA/ARCHITECTURE.md @@ -38,7 +38,10 @@ the desktop and `requestAnimationFrame` in the browser. The `.das_package` disab for the wasm build, so the host needs no Metal or Vulkan, and names the shell that fetches the models and starts the program on a click (audio needs the gesture). The desktop run and the page therefore exercise the same code, which is why a browser-only failure is a language-runtime fact -worth a rule rather than an app bug. +worth a rule rather than an app bug. A page the browser restores from its back-forward cache +(Back to another page, then Forward) comes back with the program's workers and the audio output +frozen mid-frame and out of step, and the first sound is whatever the output ring held; the +shell reloads such a page (`pageshow` with `persisted`), so it starts from the gate again. ### 3.2 The speech thread and its stream {#speech-thread-stream} diff --git a/examples/dasLLAMA/storyteller/web_shell.html b/examples/dasLLAMA/storyteller/web_shell.html index a8ff586c5d..0b451789da 100644 --- a/examples/dasLLAMA/storyteller/web_shell.html +++ b/examples/dasLLAMA/storyteller/web_shell.html @@ -115,6 +115,11 @@

storyteller

return span.innerHTML; } + // a page the browser brings back from its back-forward cache returns with the program's + // workers and the audio output frozen mid-frame and out of step with each other, and the + // first thing heard is whatever sat in the output ring: such a page starts over instead + window.addEventListener('pageshow', function (e) { if (e.persisted) location.reload(); }); + if (!SUPPORTED) { if (FORCE === 'unsupported' || !HAS_WASM64) { showNote('needs a memory64 browser', diff --git a/examples/dasLLAMA/storywish/web_shell.html b/examples/dasLLAMA/storywish/web_shell.html index c1d3ae8788..5f18c03066 100644 --- a/examples/dasLLAMA/storywish/web_shell.html +++ b/examples/dasLLAMA/storywish/web_shell.html @@ -114,6 +114,11 @@

storywish

return span.innerHTML; } + // a page the browser brings back from its back-forward cache returns with the program's + // workers and the audio output frozen mid-frame and out of step with each other, and the + // first thing heard is whatever sat in the output ring: such a page starts over instead + window.addEventListener('pageshow', function (e) { if (e.persisted) location.reload(); }); + if (!SUPPORTED) { if (FORCE === 'unsupported' || !HAS_WASM64) { showNote('needs a memory64 browser', From 45590a5acc6300e43063677ab922fbeb7c059a74 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 01:39:05 -0700 Subject: [PATCH 07/14] parrot: a browser example that clones your voice - you press record and talk, Silero VAD ends the take two seconds after you go quiet (or the stop button, or the model's 60 s cap), Pocket TTS clones the voice from the take on the speech thread, and the text in the box (Frost's "Stopping by Woods on a Snowy Evening" to begin with, or whatever you type) is read aloud in it on the say button; recording again replaces the voice, and the page says in plain words that the recording stays in the tab. main.das is storywish's shape: one source for the desktop run and wasm64, archived Ask/Answer records over two streams (the take's PCM rides the record; the thread answers a say with its chunk count before the first clip, so the frame thread can tell the last clip from a pause), polled keys and a polled mouse against the buttons' rectangles (the cursor scaled from window points to framebuffer pixels), the microphone at the model's own 24 kHz drained per frame with a 16 kHz copy for the VAD. --clip clones from a file instead of the microphone: test_parrot.das spawns the example under --smoke with the tree's own jfk_ask_not.wav and reads the witness lines (cloned, said in voice you, read out); registered in the stocked suite and the tts + audio areas (the tts plan pins 11 -> 12). models.json carries the Pocket file with its codec encoder (pocket-tts-en-kq.gguf, 75 MB) and a new `tree` list for a file the repository itself ships (silero_vad.bin by repo-relative path and sha256), which mint_models.py copies from the checkout; a set with no image carries the version the deploy expects. The examples folder's rule doc admits the tree form, its architecture doc gets the charter and a section on the take, the deploy loops and the site's shell test cover the third example, the card sits on examples.html with the parrot poster. Found by the browser preview: dasAudio's AudioWorklet non-blocking patch left the CAPTURE descriptor at the requested sample rate while a capture AudioContext runs at the browser's 48 kHz, so a 24 kHz take arrived as 48 kHz frames counted as 24 kHz - twice as long and an octave down (a 9 s take cloned as 16 s). The patch now reads the context's own sampleRate into both descriptors and the device layer resamples; block 6 covers a pristine 0.11.25 header, block 6b a tree the previous script already patched, the guard keys on the new marker, and the two paths converge byte for byte. The patch script is a configure dependency of dasAudio's CMakeLists now, so a change to it reaches an already-fetched header. Verified in Chrome on the staged page with a synthetic microphone (the JFK clip as a MediaStream): the take clones as 8.4 s and the poem reads in the clone at RTF 0.65. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pages.yml | 7 +- examples/dasLLAMA/ARCHITECTURE.md | 54 +- examples/dasLLAMA/REVIEW.md | 7 +- examples/dasLLAMA/parrot/.das_package | 19 + examples/dasLLAMA/parrot/main.das | 725 ++++++++++++++++++ examples/dasLLAMA/parrot/models.json | 8 + examples/dasLLAMA/parrot/web_shell.html | 267 +++++++ examples/dasLLAMA/wasm/mint_models.py | 20 +- modules/dasAudio/CMakeLists.txt | 2 + .../dasAudio/patches/miniaudio_memory64.cmake | 58 +- modules/dasLLAMA/ENVIRONMENT.md | 2 +- modules/dasLLAMA/dasllama/dasllama_env.das | 2 +- modules/dasLLAMA/tests/_example_rail.das | 2 +- modules/dasLLAMA/tests/run.das | 5 +- modules/dasLLAMA/tests/test_parrot.das | 72 ++ modules/dasLLAMA/tests/test_run_suites.das | 4 +- site-dasllama/examples.html | 16 + .../files/examples/parrot-poster.jpg | Bin 0 -> 240407 bytes site-dasllama/test_metadata.py | 1 + 19 files changed, 1231 insertions(+), 40 deletions(-) create mode 100644 examples/dasLLAMA/parrot/.das_package create mode 100644 examples/dasLLAMA/parrot/main.das create mode 100644 examples/dasLLAMA/parrot/models.json create mode 100644 examples/dasLLAMA/parrot/web_shell.html create mode 100644 modules/dasLLAMA/tests/test_parrot.das create mode 100644 site-dasllama/files/examples/parrot-poster.jpg diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0cd194acee..0528be01e4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -334,7 +334,8 @@ jobs: # 7. dasllama.io/examples — dasLLAMA itself compiled to wasm64, one app per example # (storyteller: stories15M writes, KittenTTS reads; storywish: you type the words, - # tinystories-instruct-27M writes, Pocket TTS reads). Each .das_package turns the GPU modules off, so the + # tinystories-instruct-27M writes, Pocket TTS reads; parrot: you talk, Pocket TTS clones the + # voice and reads the text you type in it). Each .das_package turns the GPU modules off, so the # host needs no Metal/Vulkan. Non-fatal like the games; the dasllama.io stage step # stages a card's page only when all three outputs exist. The models are prepared .dlim # images the stage step MINTS for this very build: examples/dasLLAMA/wasm/dlim_config is @@ -342,7 +343,7 @@ jobs: # bakes against the identity the browser build actually wants (a set minted for another # IMAGE_VERSION is declined by the program - the failure that put a black canvas on the # storyteller page for a day). - for ex in storyteller storywish; do + for ex in storyteller storywish parrot; do if ./bin/daslang utils/daspkg/main.das -- \ release wasm --root "examples/dasLLAMA/$ex" --out "$REPO/web/output64/examples"; then echo "$ex wasm build OK" @@ -639,7 +640,7 @@ jobs: rm -rf "_site_dasllama/examples/$1/models" printf '%s' "$1 - building

This example is being rebuilt and will be available shortly.

" > "_site_dasllama/examples/$1/$1.html" } - for ex in storyteller storywish; do + for ex in storyteller storywish parrot; do mkdir -p "_site_dasllama/examples/$ex" if [ -f "web/output64/examples/$ex/$ex.html" ] \ && [ -f "web/output64/examples/$ex/$ex.js" ] \ diff --git a/examples/dasLLAMA/ARCHITECTURE.md b/examples/dasLLAMA/ARCHITECTURE.md index 340706edfe..7e8099b410 100644 --- a/examples/dasLLAMA/ARCHITECTURE.md +++ b/examples/dasLLAMA/ARCHITECTURE.md @@ -14,6 +14,11 @@ checklist is `REVIEW.md` beside this file. The engine these programs drive is do voice from a file without the codec encoder (text in, no packs, no cloning). Same four files; `wish.das` holds the request side pure (typed line -> words -> prompt, the field-line stop) so a test reaches it without a window. +- `parrot/` - a browser example: you press record and talk, Silero VAD ends the take when you go + quiet, Pocket TTS clones the voice from the take (a file with its codec encoder and no baked + roster), and the text in the box is read aloud in it on the say button; recording again + replaces the voice. Same four files. Nothing leaves the program: the take is cloned in memory + and never written. - `wasm/dlim_config/` - a wasm-only program: prints the running build's DlimConfiguration JSON. `wasm/mint_models.py` - the deploy's staging step for a browser example's model set. `wasm/run_node.js` - runs the wasm64 engine host under node. @@ -26,8 +31,8 @@ checklist is `REVIEW.md` beside this file. The engine these programs drive is do wasm` builds it to wasm64 for dasllama.io from the same `main.das` the desktop run uses. The checklist's rules about the browser build bind browser examples and nothing else. - **A witness line** is a line a browser example logs under its own name (`storyteller: ...`, - `storywish: ...`); a smoke test under `modules/dasLLAMA/tests/` matches such lines as - substrings, so their words and order are an interface. + `storywish: ...`, `parrot: ...`); a smoke test under `modules/dasLLAMA/tests/` matches such + lines as substrings, so their words and order are an interface. ## 3. Mechanisms @@ -46,14 +51,17 @@ shell reloads such a page (`pageshow` with `persisted`), so it starts from the g ### 3.2 The speech thread and its stream {#speech-thread-stream} Speech runs on its own thread so the frame loop never blocks on synthesis. The frame thread -pushes sentences into a stream as archived `Line` records and pops finished clips from a second -stream; a `SeqBox` carries the number of the story being told, so a queued sentence of a story -the user replaced is skipped instead of synthesized. The thread's own setup - the TTS model path -and the voice - rides the same sentence stream ahead of the first sentence. A string captured by -the thread's lambda would be a pointer into the frame thread's heap, which that thread reuses on -its own schedule; a browser worker starts slowly enough to read story text where the path was. -An archived message is copied out of the stream into the reader's heap, so the stream is the one -channel that is safe for a string. +pushes its requests into a stream as archived records (storywish's `Line` is a sentence; parrot's +`Ask` is a text to say or a take to clone, the PCM riding in the record) and pops finished clips +from a second stream; a `SeqBox` carries the number of the story (parrot: the say) being told, so +a queued sentence of one the user replaced is skipped instead of synthesized. The thread's own +setup - the TTS model path and the voice - rides the same request stream ahead of the first +request. Parrot's thread answers a say with its chunk count before the first clip, so the frame +thread can tell the last clip from a pause. A string captured by the thread's lambda would be a +pointer into the frame thread's heap, which that thread reuses on its own schedule; a browser +worker starts slowly enough to read story text where the path was. An archived message is copied +out of the stream into the reader's heap, so the stream is the one channel that is safe for a +string. ### 3.3 Input is polled {#polled-keys} @@ -61,16 +69,21 @@ A browser example reads the keyboard with `glfwGetKey` each frame, edge-detected never through a GLFW callback. In the browser build a callback lambda fires from a JavaScript event outside any frame of the program, where the example's state is not live, and the program traps. A printable GLFW key code is its upper-case ASCII, so the key range doubles as the -character range for a typed line, and repeats come from a hold timer. +character range for a typed line, and repeats come from a hold timer. The mouse is read the same +way: parrot's buttons are text, and a click is `glfwGetMouseButton` edge-detected against their +rectangles in design pixels. ### 3.4 The model set is minted for the build that ships it Each browser example's `models.json` names its source files by Hugging Face repository, file and sha256, in three lists: `images` (a GGUF the build bakes into a `.dlim`), `packs` (a -front-end pack) and `files` (a GGUF that is its own served form - a Pocket TTS file). `wasm/mint_models.py` -fetches them (cached by sha256), bakes each image against the wasm64 build's own -DlimConfiguration, copies the packs and files as they are, writes -`models/manifest.json` (the file list, their sizes, the IMAGE_VERSION the images carry) and +front-end pack) and `files` (a GGUF that is its own served form - a Pocket TTS file); a fourth +list, `tree`, names a file the repository itself carries by its repo-relative path and sha256 +(the voice-activity weights). `wasm/mint_models.py` +fetches the published ones (cached by sha256), bakes each image against the wasm64 build's own +DlimConfiguration, copies the packs, files and tree files as they are, writes +`models/manifest.json` (the file list, their sizes, the IMAGE_VERSION the images carry - a set +with no image carries the version the deploy expects) and stamps that version into the page's `/* @image-version */ 0` slot. The shell reads the manifest, refuses a set minted for another version before fetching it, and shows a program abort's last engine lines on the page. The configuration the mint bakes against comes from the wasm build @@ -83,6 +96,17 @@ is keyed by the build's identity and a set minted for a previous build is declin Its `.das_package` disables the GPU tiers exactly as the browser examples' do, so the configuration it prints is the one their programs run with. +### 3.6 Parrot's take {#the-take} + +The microphone is opened at the speech model's own rate, 24 kHz mono, and drained on the frame +thread every frame into the take; a copy of each drain, resampled to 16 kHz by linear +interpolation, feeds the Silero iterator, which is the only reader of that rate. The take ends on +the stop button, two seconds after the iterator's last speech end, or at the model's 60 s clip +cap; it is trimmed to the speech plus a quarter second at each end and sent to the speech thread +as a clone request, so the clone runs off the frame thread like a synthesis. A take with no +speech in it is dropped. The audio device is the capture's own, separate from playback, so a +recording can start while a clip is still playing. + ## 4. Exception ledger None. diff --git a/examples/dasLLAMA/REVIEW.md b/examples/dasLLAMA/REVIEW.md index f9f212081f..57287f18ba 100644 --- a/examples/dasLLAMA/REVIEW.md +++ b/examples/dasLLAMA/REVIEW.md @@ -16,6 +16,7 @@ smoke tests match witness lines as substrings, so the words and their order are (`ARCHITECTURE.md` sec. 2). **A diff that adds a model file to a browser example's `models.json` names it by the repository -it is published in and its sha256, never by a local path or a branch name.** The deploy fetches -the file by that name and refuses one whose hash moved; a local path stages nothing on the -runner (`ARCHITECTURE.md` sec. 3.4). +it is published in and its sha256 - or, for a file the repository itself carries, by its +repo-relative path under `tree` and its sha256 - never by a machine-local path or a branch +name.** The deploy fetches or copies the file by that name and refuses one whose hash moved; a +machine-local path stages nothing on the runner (`ARCHITECTURE.md` sec. 3.4). diff --git a/examples/dasLLAMA/parrot/.das_package b/examples/dasLLAMA/parrot/.das_package new file mode 100644 index 0000000000..40b18a980b --- /dev/null +++ b/examples/dasLLAMA/parrot/.das_package @@ -0,0 +1,19 @@ +options gen2 + +require daslib/daspkg + +[export] +def package() { + package_name("parrot") + package_description("Parrot: talk for a few seconds, Pocket TTS clones the voice, and the text you type is read aloud in it - dasGlfw + dasOpenGL + dasAudio over dasLLAMA") +} + +[export] +def release() { + release_main("main.das") // one source for the desktop run AND wasm64 (daspkg release wasm) + release_web_shell("web_shell.html") // fetches the models into MEMFS, then a click starts the program (audio needs the gesture) + // host-only GPU tiers: absent from the wasm build, so their guarded requires resolve as absent + release_wasm_disable_module("dasvulkan") + release_wasm_disable_module("dasmetal") + release_wasm_disable_module("dasaccelerate") +} diff --git a/examples/dasLLAMA/parrot/main.das b/examples/dasLLAMA/parrot/main.das new file mode 100644 index 0000000000..b64f028419 --- /dev/null +++ b/examples/dasLLAMA/parrot/main.das @@ -0,0 +1,725 @@ +options gen2 +options persistent_heap +options stack = 524288 // every dasLLAMA program root takes this budget (options stack does not unify up from libs) +options _dasllama_internal = true // the voice-activity detector lives beside the facade, not in it + +require dasllama/dasllama // the facade: the TTS model, its caps, the clone verb, the chunker, the synthesis +require dasllama/dasllama_vad // Silero: when a take has speech in it and when it stops +require daslib/jobque_boost +require daslib/strings_boost +require daslib/clargs +require daslib/fio +require daslib/archive +require glfw/glfw_boost +require live/glfw_live +require opengl/opengl_boost +require opengl/opengl_cache +require opengl/opengl_ttf +require audio/audio_boost +require audio/audio_record +require live_host +require math +require daslib/math_boost // ortho_rh / compose for the text layer +require strings + +// Parrot. You press record and talk for as long as you like; the take ends when you press stop +// or when you have been quiet for two seconds. Pocket TTS clones the voice from the take, and +// the text in the box - a poem to begin with, or whatever you type - is read aloud in it when +// you press say. Record again and the voice is replaced. Escape quits. +// +// bin/daslang -jit examples/dasLLAMA/parrot/main.das -- --models [--text "..."] [--clip voice.wav] +// +// holds the speech model - a Pocket TTS file with its codec encoder, which is what clones - +// and silero_vad.bin. The recording stays in the program: nothing leaves the machine, and in the +// browser build nothing leaves the tab. --clip clones from a file instead of the microphone (the +// smoke rail). The microphone is drained on the frame thread; the clone and the synthesis run on +// the speech thread, fed through one stream and answering through another, and the frame thread +// plays the clips back to back. + +[CommandLineArgs] +struct ParrotArgs { + @clarg_short = "m" + @clarg_doc = "Directory holding the speech model and the voice-activity model (default: the current directory)" + models : string = "." + + @clarg_doc = "The TTS model file inside --models (a Pocket TTS file with its codec encoder)" + tts_model : string = "pocket-tts-en-kq.gguf" + + @clarg_doc = "The voice-activity model file inside --models (default: the tree's own modules/dasLLAMA/models/silero_vad.bin)" + vad_model : string + + @clarg_short = "t" + @clarg_doc = "The text in the box at the start (default: a poem)" + text : string + + @clarg_doc = "Clone the voice from this clip instead of the microphone (wav, flac, mp3 or ogg)" + clip : string + + @clarg_doc = "Clone from --clip, say the text, quit when it has been read out - the smoke rail" + smoke : bool + + @clarg_doc = "Stop after this many frames (0 = never) - the smoke rail" + max_frames : int + + @clarg_short = "?" + @clarg_name = "show-help" + @clarg_doc = "Show this help and exit" + help : bool +} + +let POEM = "Whose woods these are I think I know.\nHis house is in the village though;\nHe will not see me stopping here\nTo watch his woods fill up with snow.\n\nMy little horse must think it queer\nTo stop without a farmhouse near\nBetween the woods and frozen lake\nThe darkest evening of the year.\n\nHe gives his harness bells a shake\nTo ask if there is some mistake.\nThe only other sound's the sweep\nOf easy wind and downy flake.\n\nThe woods are lovely, dark and deep,\nBut I have promises to keep,\nAnd miles to go before I sleep,\nAnd miles to go before I sleep." + +let MIC_RATE = 24000 //! the model's own rate: the take is cloned as recorded +let VAD_RATE = 16000 //! Silero listens at 16 kHz; the take is resampled for it alone +let SILENCE_ENDS_TAKE_S = 2.0 //! quiet this long after speech ends the take +let TAKE_CAP_S = 60.0 //! the model's clip cap +let TAKE_PAD_S = 0.25 //! kept around the speech at both ends +let WRAP_CHARS = 74 //! droidsansmono is monospace: characters are the wrap unit +let MAX_LINES = 24 +let TEXT_SIZE = 0.66 +let SMALL_SIZE = 0.5 +let VOICE_NAME = "you" +let READ_OUT_STATUS = "read out - change the text and say again, or record a new voice" + +enum Phase { + idle + recording + cloning +} + +//! frame thread -> speech thread: the setup, a clip to clone, a text to say, or the stop +struct Ask { + kind : int //! 0 = the model path, 1 = a clone, 2 = a text, 3 = stop + text : string + gen : int //! which say it belongs to: a chunk of a say the user replaced is skipped + pcm : array + rate : int +} + +//! speech thread -> frame thread: a voice cloned, a clip of a say, or how many clips a say will have +struct Answer { + kind : int //! 1 = cloned (seconds in text), 2 = a clip, 3 = the say's chunk count (in text) + text : string + gen : int + pcm : array + rate : int +} + +var g_args = ParrotArgs() +var g_phase = Phase.idle +var g_lines : array //! the text box, one line each +var g_status = "" +var g_voice_ready = false +var g_voice_seconds = 0.0 +var g_says = 0 +var g_chunks_owed = 0 //! clips of the current say still to play; the speech thread names the count first +var g_count_known = false +var g_read_out_say = 0 //! the last say whose read-out was logged, so the line is written once +var g_take : array //! the microphone as recorded, 24 kHz mono +var g_take_scratch : array +var g_vad_model = VadModel() +var g_vad = VadIter() +var g_speech_seen = false +var g_in_speech = false +var g_speech_start24 = 0l +var g_speech_end24 = 0l +var g_quiet_since_s = 0.0 +var g_level = 0.0 +var g_level_peak = 0.0 + +var g_ask : Stream? +var g_answer : Stream? +var g_say_now : SeqBox? +var g_speech_done : Channel? +var g_clips : array +var g_playing_sid = INVALID_SID +var g_elapsed_s = 0.0 +var g_speaking_until = 0.0 +var g_audio_initialized = false +var g_asch : AudioSystemChannels +var g_font : Font? +var g_frames = 0 +var g_key_was : bool[512] // GLFW key codes end at GLFW_KEY_MENU = 348 +var g_mouse_was = false +var g_backspace_held_s = 0.0 +var display_w = 0 +var display_h = 0 + +// ===== the speech thread ===== + +//! the speech thread's setup rides its own stream ahead of the asks: the model path +[arch(at = "../ARCHITECTURE.md#speech-thread-stream")] +def start_speech_thread(var ask, answer : Stream?; var now : SeqBox?; var done : Channel?) { + new_thread() <| @capture(= ask, = answer, = now, = done) { + setup_dasllama_jobque() // the fork-context pool is per context: without it every parallel kernel clones the program + var tts_path = "" + ask |> pop_archive() $(var a : Ask&) { + tts_path = clone_string(a.text) + } + var inscope m <- load_tts_model(tts_path) + var inscope c <- caps(m) + var voice = c.voices[0] + var running = true + while (running) { + ask |> pop_archive() $(var a : Ask&) { + if (a.kind == 3) { + running = false + } elif (a.kind == 1) { + tts_register_voice(m, VOICE_NAME, a.pcm, a.rate) + voice = VOICE_NAME + var cloned = Answer(kind = 1, text = "{float(length(a.pcm)) / float(a.rate)}", gen = a.gen, rate = a.rate) + answer |> push_archive(cloned) + } elif (a.kind == 2) { + var inscope chunks <- tts_chunks(m, a.text) + var count = Answer(kind = 3, text = "{length(chunks)}", gen = a.gen) + answer |> push_archive(count) + for (chunk in chunks) { + var current = a.gen + now |> read() $(gen : int) { + current = gen + } + break if (a.gen < current) // a say the user replaced: its clips would be dropped unheard + var inscope s <- synthesize(m, chunk, voice) + var clip = Answer(kind = 2, gen = a.gen, pcm := s.pcm, rate = s.sample_rate) + answer |> push_archive(clip) + } + } + } + } + ask |> release() + answer |> release() + now |> seq_box_release() + done |> notify_and_release() + } +} + +def poll_answers() { + g_answer |> try_pop() $(bytes) { + var a : Answer + mem_archive_load(bytes, a) + if (a.kind == 1) { + g_voice_ready = true + g_voice_seconds = to_float(a.text) + g_phase = Phase.idle + g_status = "your voice is ready - press say" + //! the smoke rail's witness lines, here and below: modules/dasLLAMA/tests/test_parrot.das reads them word for word + to_log(LOG_INFO, "parrot: cloned {a.text} s of speech\n") + if (g_args.smoke) { + say_text() + } + } elif (a.gen == g_says) { + if (a.kind == 3) { + g_chunks_owed = to_int(a.text) + g_count_known = true + } else { + g_clips |> emplace(a) + } + } + } + return if (g_elapsed_s < g_speaking_until) + g_playing_sid = INVALID_SID + if (!empty(g_clips)) { + let seconds = float(length(g_clips[0].pcm)) / float(max(g_clips[0].rate, 1)) + var pcm <- g_clips[0].pcm + g_playing_sid = play_sound_from_pcm(g_clips[0].rate, 1, pcm) + g_clips |> erase(0) + g_speaking_until = g_elapsed_s + seconds + g_chunks_owed-- + } elif (g_count_known && g_chunks_owed <= 0 && g_says > g_read_out_say) { + g_read_out_say = g_says + if (g_phase == Phase.idle) { + g_status = READ_OUT_STATUS + } + to_log(LOG_INFO, "parrot: say {g_says} is read out\n") + } +} + +// ===== the take ===== + +def start_take() { + return if (g_phase != Phase.idle) + if (!sound_record_start(MIC_RATE, 1, MIC_RATE * 4, -1)) { + g_status = "no microphone - is one connected, and allowed?" + return + } + g_take |> clear() + g_take |> reserve(int(TAKE_CAP_S) * MIC_RATE) + g_take_scratch |> resize(MIC_RATE) + vad_iter_reset(g_vad, default_vad_opts()) + g_speech_seen = false + g_in_speech = false + g_speech_start24 = 0l + g_speech_end24 = 0l + g_level = 0.0 + g_level_peak = 0.0 + g_phase = Phase.recording + g_status = "listening - talk for as long as you like, then press stop or go quiet" + to_log(LOG_INFO, "parrot: recording\n") +} + +//! 24 kHz to 16 kHz by linear interpolation - Silero's ear, never the clone's +def to_vad_rate(src : array; n : int) : array { + let m = n * VAD_RATE / MIC_RATE + var out : array + out |> resize(m) + let step = float(MIC_RATE) / float(VAD_RATE) + for (i in range(m)) { + let pos = float(i) * step + let j = min(int(pos), n - 1) + let k = min(j + 1, n - 1) + let f = pos - float(j) + out[i] = src[j] * (1.0 - f) + src[k] * f + } + return <- out +} + +[arch(at = "../ARCHITECTURE.md#the-take")] +def drain_take() { + let n = sound_record_read(g_take_scratch) + return if (n <= 0) + var ssq = 0.0 + let base = length(g_take) + g_take |> ensure_capacity(base + n) + g_take |> resize(base + n) + for (i in range(n)) { + let v = g_take_scratch[i] + g_take[base + i] = v + ssq += v * v + } + let rms = sqrt(ssq / float(n)) + g_level = max(rms, g_level * 0.85) + g_level_peak = max(g_level_peak, rms) + var inscope ears <- to_vad_rate(g_take_scratch, n) + vad_iter_feed(g_vad_model, g_vad, ears) $(ev) { + let at24 = ev.sample * int64(MIC_RATE) / int64(VAD_RATE) + if (ev.kind == VadEventKind.speech_start) { + if (!g_speech_seen) { + g_speech_start24 = at24 + } + g_speech_seen = true + g_in_speech = true + } else { + g_speech_end24 = at24 + g_in_speech = false + g_quiet_since_s = g_elapsed_s + } + } + if (g_speech_seen && !g_in_speech && g_elapsed_s - g_quiet_since_s >= SILENCE_ENDS_TAKE_S) { + stop_take() + } elif (float(length(g_take)) / float(MIC_RATE) >= TAKE_CAP_S) { + stop_take() + } +} + +def stop_take() { + return if (g_phase != Phase.recording) + sound_record_stop() + drain_rest() + if (!g_speech_seen) { + g_phase = Phase.idle + g_status = "nothing heard - press record and talk" + to_log(LOG_INFO, "parrot: nothing heard\n") + return + } + let pad = int64(TAKE_PAD_S * float(MIC_RATE)) + let total = long_length(g_take) + let from = max(0l, g_speech_start24 - pad) + let to = min(total, (g_in_speech ? total : g_speech_end24) + pad) + var clip : array + clip |> resize(to - from) + for (i in range64(to - from)) { + clip[i] = g_take[from + i] + } + clone_clip(clip, MIC_RATE) +} + +//! the ring's tail after the device stopped +def drain_rest() { + for (_i in range(8)) { + let n = sound_record_read(g_take_scratch) + break if (n <= 0) + let base = length(g_take) + g_take |> ensure_capacity(base + n) + g_take |> resize(base + n) + for (i in range(n)) { + g_take[base + i] = g_take_scratch[i] + } + } +} + +def clone_clip(var clip : array; rate : int) { + g_phase = Phase.cloning + g_status = "cloning your voice from {float(length(clip)) / float(rate)} seconds..." + var a = Ask(kind = 1, gen = g_says, pcm <- clip, rate = rate) + g_ask |> push_archive(a) +} + +// ===== saying ===== + +def say_text() { + let text = strip(join(g_lines, "\n")) + return if (empty(text) || g_phase == Phase.cloning) + if (g_playing_sid != INVALID_SID) { + stop(g_playing_sid, 0.05) + g_playing_sid = INVALID_SID + } + g_says++ + g_say_now |> publish(g_says) + g_clips |> clear() + g_speaking_until = g_elapsed_s + g_chunks_owed = 0 + g_count_known = false + var a = Ask(kind = 2, text = text, gen = g_says) + g_ask |> push_archive(a) + g_status = g_voice_ready ? "reading in your voice..." : "reading in the model's own voice - record to hear yours" + to_log(LOG_INFO, "parrot: say {g_says} in voice {g_voice_ready ? VOICE_NAME : "default"}, {length(text)} characters\n") +} + +// ===== the text box ===== + +def type_char(key : uint; shift : bool) { + return if (key >= 128u) + var c = int(key) + if (is_alpha(c)) { + c = shift ? c : c + 32 // GLFW hands the upper-case code + } elif (shift) { + c = c == '1' ? '!' : c == '/' ? '?' : c == ';' ? ':' : c == '\'' ? '"' : c == '9' ? '(' : c == '0' ? ')' : c == '-' ? '_' : c + } + let ok = is_alpha(c) || is_number(c) || c == ' ' || c == ',' || c == '.' || c == '\'' || c == '"' || c == '-' || c == ';' || c == ':' || c == '!' || c == '?' || c == '(' || c == ')' || c == '_' + return if (!ok) + let li = length(g_lines) - 1 + return if (length(g_lines[li]) >= WRAP_CHARS) + g_lines[li] = "{g_lines[li]}{to_char(c)}" +} + +def new_line() { + return if (length(g_lines) >= MAX_LINES) + g_lines |> push("") +} + +def erase_char() { + let li = length(g_lines) - 1 + let n = length(g_lines[li]) + if (n == 0) { + if (li > 0) { + g_lines |> pop() + } + return + } + g_lines[li] = n == 1 ? "" : clone_string(slice(g_lines[li], 0, n - 1)) // never a zero-length view of the string being replaced +} + +def set_text(text : string) { + delete g_lines + g_lines <- split(text, "\n") + if (empty(g_lines)) { + g_lines |> push("") + } +} + +//! down this frame and not the last +def key_pressed_now(key : int) : bool { + let down = glfwGetKey(live_window, key) == GLFW_PRESS + let was = g_key_was[key] + g_key_was[key] = down + return down && !was +} + +def key_down(key : int) : bool { + return glfwGetKey(live_window, key) == GLFW_PRESS +} + +//! Ctrl or Command: the say chord works the same on every desktop +def ctrl_down() : bool { + return (key_down(GLFW_KEY_LEFT_CONTROL) || key_down(GLFW_KEY_RIGHT_CONTROL) || key_down(GLFW_KEY_LEFT_SUPER) || key_down(GLFW_KEY_RIGHT_SUPER)) +} + +def shift_down() : bool { + return key_down(GLFW_KEY_LEFT_SHIFT) || key_down(GLFW_KEY_RIGHT_SHIFT) +} + +//! a printable GLFW key code is its upper-case ASCII, so the key range IS the character range +[arch(at = "../ARCHITECTURE.md#polled-keys")] +def poll_keys() { + let shift = shift_down() + for (key in range(GLFW_KEY_SPACE, GLFW_KEY_GRAVE_ACCENT + 1)) { + if (key_pressed_now(key)) { + type_char(uint(key), shift) + } + } + if (key_pressed_now(GLFW_KEY_BACKSPACE)) { + erase_char() + g_backspace_held_s = 0.0 + } elif (glfwGetKey(live_window, GLFW_KEY_BACKSPACE) == GLFW_PRESS) { + g_backspace_held_s += get_dt() + if (g_backspace_held_s > 0.4) { // held: a repeat every 60 ms after the first 400 + erase_char() + g_backspace_held_s = 0.34 + } + } + if (key_pressed_now(GLFW_KEY_ENTER) || key_pressed_now(GLFW_KEY_KP_ENTER)) { + if (ctrl_down()) { + say_text() + } else { + new_line() + } + } + if (key_pressed_now(GLFW_KEY_TAB)) { + toggle_record() + } +} + +def toggle_record() { + if (g_phase == Phase.recording) { + stop_take() + } else { + start_take() + } +} + +// ===== the screen ===== + +//! the text layer's coordinates are design pixels against a 1280x720 reference; the smaller of the two ratios +//! governs, so a window narrower than 16:9 shrinks the text instead of cutting the lines on the right +def hud_scale() : float { + let fit = min(float(display_w) / 1280.0, float(display_h) / 720.0) + return max(fit, 0.5) +} + +def design_height() : float { + return float(display_h) / hud_scale() +} + +def text_mvp(x, y, scale : float) : float4x4 { + let projection = ortho_rh(0.0, float(display_w), float(display_h), 0.0, -1.0, 1.0) + let model = compose(float3(x, y, 0.0), float4(0.0, 0.0, 0.0, 1.0), float3(scale, scale, 1.0)) + return projection * model +} + +def draw_text(text : string; x, y : float; size : float; tint : float3) { + return if (g_font == null || empty(text)) + var quads <- (*g_font) |> create_quads(text) + let s = hud_scale() * size + (*g_font) |> draw_quads(quads, text_mvp(x * hud_scale(), y * hud_scale(), s), tint) + delete quads +} + +//! a text's width in design pixels at `size`, from the font's own advance +def text_width(text : string; size : float) : float { + return 0.0 if (g_font == null || empty(text)) + var quads <- (*g_font) |> create_quads(text) + let w = quads_dim(quads).vmax.x + delete quads + return w * size +} + +//! a button: its label in a bracket, the rectangle it answers to in design pixels +struct Button { + text : string + x : float + y : float + w : float + h : float +} + +def button(caption : string; x, y : float) : Button { + let w = text_width("[ {caption} ]", TEXT_SIZE) + return Button(text = caption, x = x, y = y, w = w, h = 40.0) +} + +def draw_button(b : Button; lit : bool) { + draw_text("[ {b.text} ]", b.x, b.y, TEXT_SIZE, lit ? float3(0.98, 0.75, 0.35) : float3(0.75, 0.72, 0.66)) +} + +def meter_text() : string { + let cells = 24 + let lit = min(cells, int(g_level * 40.0 * float(cells))) + return build_string() $(var w) { + for (i in range(cells)) { + w |> write(i < lit ? "#" : ".") + } + } +} + +def draw_screen() { + let ink = float3(0.93, 0.9, 0.82) + let dim = float3(0.55, 0.52, 0.48) + let accent = float3(0.98, 0.75, 0.35) + draw_text("parrot", 60.0, 60.0, 0.6, dim) + draw_text("your recording stays in this window: nothing is uploaded, and the voice is gone when you close it", 60.0, 96.0, SMALL_SIZE, dim) + var y = 150.0 + let step = 30.0 + let bottom = design_height() - 150.0 + let visible = max(1, int((bottom - y) / step)) + let first = max(0, length(g_lines) - visible) + for (i in range(first, length(g_lines))) { + let caret = i == length(g_lines) - 1 && (g_frames / 30) % 2 == 0 ? "_" : "" + draw_text("{g_lines[i]}{caret}", 60.0, y, TEXT_SIZE, ink) + y += step + } + let row = design_height() - 120.0 + let rec = button(g_phase == Phase.recording ? "stop" : "record", 60.0, row) + draw_button(rec, g_phase == Phase.recording) + var x = rec.x + rec.w + 30.0 + if (g_phase == Phase.recording) { + draw_text(meter_text(), x, row, TEXT_SIZE, accent) + draw_text("{float(length(g_take)) / float(MIC_RATE)} s", x + text_width(meter_text(), TEXT_SIZE) + 20.0, row, TEXT_SIZE, dim) + } else { + let sayb = button("say", x, row) + draw_button(sayb, true) + x = sayb.x + sayb.w + 30.0 + draw_text(g_voice_ready ? "voice: yours, from {g_voice_seconds} s" : "voice: the model's own until you record", x, row, SMALL_SIZE, dim) + } + draw_text(g_status, 60.0, design_height() - 70.0, SMALL_SIZE, g_phase == Phase.idle ? accent : dim) + draw_text("Tab records and stops Ctrl+Enter says Enter is a new line", 60.0, design_height() - 40.0, SMALL_SIZE, dim) +} + +//! the two buttons answer a click: the cursor in design pixels against their rectangles +def poll_mouse() { + let down = glfwGetMouseButton(live_window, GLFW_MOUSE_BUTTON_1) == GLFW_PRESS + let pressed = down && !g_mouse_was + g_mouse_was = down + return if (!pressed) + var win_w, win_h : int + glfwGetWindowSize(live_window, unsafe(addr(win_w)), unsafe(addr(win_h))) + let c = glfwGetCursorPos(live_window) * float(display_w) / float(max(win_w, 1)) //! the cursor comes in window points; the text layer is laid out in framebuffer pixels, twice that on a retina screen + let scale = hud_scale() + let px = c.x / scale + let py = c.y / scale + let row = design_height() - 120.0 + let rec = button(g_phase == Phase.recording ? "stop" : "record", 60.0, row) + if (hit(rec, px, py)) { + toggle_record() + return + } + if (g_phase != Phase.recording) { + let sayb = button("say", rec.x + rec.w + 30.0, row) + if (hit(sayb, px, py)) { + say_text() + } + } +} + +def hit(b : Button; px, py : float) : bool { + return px >= b.x && px <= b.x + b.w && py >= b.y - 8.0 && py <= b.y + b.h +} + +// ===== the program ===== + +[export] +def init() { + var inscope r <- parse_args(type) + if (r |> is_err) { + panic("parrot: {r |> unwrap_err}") + } + g_args <- r |> move_unwrap + set_text(empty(g_args.text) ? POEM : g_args.text) + g_status = "press record and talk, then say" + live_create_window("Parrot", 1280, 720) + cache_ttf_objects() + g_font = cache_font("{get_das_root()}/modules/dasStbImage/fonts/droidsansmono.ttf") + if (!g_audio_initialized) { + g_asch = audio_system_create() + g_audio_initialized = true + } + create_job_que() + setup_dasllama_jobque() + delete g_vad_model + g_vad_model <- load_vad_model(empty(g_args.vad_model) ? "{get_das_root()}/modules/dasLLAMA/models/silero_vad.bin" : path_join(g_args.models, g_args.vad_model)) + g_ask = unsafe(stream_create()) + g_answer = unsafe(stream_create()) + g_say_now = seq_box_create() + g_say_now |> publish(g_says) + g_speech_done = unsafe(channel_create()) + g_speech_done |> append(1) + var setup = Ask(kind = 0, text = path_join(g_args.models, g_args.tts_model)) + g_ask |> push_archive(setup) + start_speech_thread(g_ask, g_answer, g_say_now, g_speech_done) + if (!empty(g_args.clip)) { + var clip <- load_audio_mono(g_args.clip, MIC_RATE) + if (empty(clip)) { + panic("parrot: {g_args.clip} did not decode") + } + to_log(LOG_INFO, "parrot: cloning from {g_args.clip}\n") + clone_clip(clip, MIC_RATE) + } +} + +[export] +def update() { + if (!live_begin_frame()) { + return + } + g_frames++ + g_elapsed_s += get_dt() + live_get_framebuffer_size(display_w, display_h) + glViewport(0, 0, display_w, display_h) + glClearColor(0.07, 0.06, 0.09, 1.0) + glClear(GL_COLOR_BUFFER_BIT) + glDisable(GL_DEPTH_TEST) + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + + if (glfwGetKey(live_window, GLFW_KEY_ESCAPE) == GLFW_PRESS) { + glfwSetWindowShouldClose(live_window, 1) + } + poll_keys() + poll_mouse() + if (g_phase == Phase.recording) { + drain_take() + } + poll_answers() + draw_screen() + live_end_frame() +} + +[export] +def shutdown() { + if (g_phase == Phase.recording) { + sound_record_stop() + } + var stop_ask = Ask(kind = 3) + g_ask |> push_archive(stop_ask) + g_speech_done |> join() + g_say_now |> seq_box_release() + unsafe { + channel_remove(g_speech_done) + stream_remove(g_ask) + stream_remove(g_answer) + } + if (g_audio_initialized) { + audio_system_finalize(g_asch.command, g_asch.next_sid) + g_audio_initialized = false + } + delete g_vad_model + destroy_job_que() + live_destroy_window() +} + +def read_out() : bool { + return g_says > 0 && g_count_known && g_chunks_owed <= 0 && empty(g_clips) && g_elapsed_s >= g_speaking_until +} + +def done_for_smoke() : bool { + return true if (g_args.max_frames > 0 && g_frames >= g_args.max_frames) + return g_args.smoke && read_out() +} + +// eval_main_loop drives the block once per frame: a blocking while-loop natively, the +// browser's requestAnimationFrame on the web - one main for both +[export] +def main() { + init() + eval_main_loop() { + update() + return false if (done_for_smoke()) + return !exit_requested() + } + let finished = read_out() + shutdown() + if (g_args.smoke) { + to_log(LOG_INFO, finished + ? "parrot: the text was read out after {g_frames} frames\n" + : "parrot: the frame cap stopped the run after {g_frames} frames\n") + } +} diff --git a/examples/dasLLAMA/parrot/models.json b/examples/dasLLAMA/parrot/models.json new file mode 100644 index 0000000000..0ffa920d7e --- /dev/null +++ b/examples/dasLLAMA/parrot/models.json @@ -0,0 +1,8 @@ +{ + "files": [ + { "file": "pocket-tts-en-kq.gguf", "repo": "borisbat/dasllama-tts", "sha256": "2475a1ed8d49eb72c9d9b8c38f10f91ef5b03c7cd6e9fe43fdf7ab00ae1a0a25" } + ], + "tree": [ + { "file": "silero_vad.bin", "path": "modules/dasLLAMA/models/silero_vad.bin", "sha256": "33d2121d08c033eeb08f73ce5b6130c02d2e84c34547f8e9f97aeb408fc5a82a" } + ] +} diff --git a/examples/dasLLAMA/parrot/web_shell.html b/examples/dasLLAMA/parrot/web_shell.html new file mode 100644 index 0000000000..6ce8c4f345 --- /dev/null +++ b/examples/dasLLAMA/parrot/web_shell.html @@ -0,0 +1,267 @@ + + + + + +parrot — dasllama.io + + + + + + + + + + + + + +
+ +
+

parrot

+

reading the model list...

+
+ +

The page asks for the microphone when you press record. Your recording stays in this tab: nothing is uploaded, and the voice is gone when you close it.

+ +
+
+ + + + + diff --git a/examples/dasLLAMA/wasm/mint_models.py b/examples/dasLLAMA/wasm/mint_models.py index 6728cb2a6e..9ee1c66c3e 100644 --- a/examples/dasLLAMA/wasm/mint_models.py +++ b/examples/dasLLAMA/wasm/mint_models.py @@ -2,8 +2,10 @@ """Stage a browser example's model set: fetch the files its models.json names from Hugging Face, mint each GGUF under `images` into a .dlim against the wasm64 build's DlimConfiguration, copy the `packs` and `files` as they are (a front-end pack; a Pocket TTS GGUF, which is its own served -form), and write models/manifest.json - the list the example's web shell reads, stamped with the -IMAGE_VERSION the images carry. +form), copy each `tree` file from the repository itself (a checked-in model such as the +voice-activity weights, named by its repo-relative path), and write models/manifest.json - the +list the example's web shell reads, stamped with the IMAGE_VERSION the images carry (a set with no +image carries the version the deploy expects). mint_models.py --example examples/dasLLAMA/storywish --config wasm64.json \ --daslang bin/daslang --out _site/examples/storywish/models \ @@ -100,9 +102,19 @@ def main(): shutil.copyfile(src, dst) files.append({"name": entry["file"], "bytes": os.path.getsize(dst), "sha256": entry["sha256"], "source": f"{entry['repo']}/{entry['file']}"}) - if len(versions) != 1: + # a file the tree itself carries: copied from the checkout, its hash held like a fetched file's + for entry in spec.get("tree", []): + src = os.path.join(repo_root, entry["path"]) + got = sha256_of(src) + if got != entry["sha256"]: + raise SystemExit(f"{entry['file']}: sha256 {got}, models.json says {entry['sha256']} - the tree file changed; update models.json") + dst = os.path.join(a.out, entry["file"]) + shutil.copyfile(src, dst) + files.append({"name": entry["file"], "bytes": os.path.getsize(dst), "sha256": entry["sha256"], "source": entry["path"]}) + + if len(versions) > 1: raise SystemExit(f"the minted images disagree on IMAGE_VERSION: {sorted(versions)}") - version = versions.pop() + version = versions.pop() if versions else a.expect_image_version if a.expect_image_version and version != a.expect_image_version: sys.stderr.write(f"minted images carry IMAGE_VERSION {version}, the tree says {a.expect_image_version} - the converter that minted them is not this tree's\n") sys.exit(3) # the deploy tells this exit apart: a version mismatch reds the run, every other failure stages a placeholder diff --git a/modules/dasAudio/CMakeLists.txt b/modules/dasAudio/CMakeLists.txt index 587110c9b4..fb41865186 100644 --- a/modules/dasAudio/CMakeLists.txt +++ b/modules/dasAudio/CMakeLists.txt @@ -20,6 +20,8 @@ IF ((NOT DAS_AUDIO_INCLUDED) AND ((NOT ${DAS_AUDIO_DISABLED}) OR (NOT DEFINED DA execute_process(COMMAND ${CMAKE_COMMAND} -DMINIAUDIO_H=${miniaudio_SOURCE_DIR}/miniaudio.h -P ${DAS_AUDIO_DIR}/patches/miniaudio_memory64.cmake) + # a change to the patch script reconfigures, so an already-fetched header receives it + SET_PROPERTY(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${DAS_AUDIO_DIR}/patches/miniaudio_memory64.cmake) SET(AUDIO_INCLUDE_DIR ${DAS_AUDIO_DIR}/src ${miniaudio_SOURCE_DIR}) SET(CIPIC_HRTF_INCLUDE_DIR ${DAS_AUDIO_DIR}/cipic-hrtf/include) diff --git a/modules/dasAudio/patches/miniaudio_memory64.cmake b/modules/dasAudio/patches/miniaudio_memory64.cmake index c3e829136c..32a8687b27 100644 --- a/modules/dasAudio/patches/miniaudio_memory64.cmake +++ b/modules/dasAudio/patches/miniaudio_memory64.cmake @@ -20,13 +20,13 @@ endif() file(READ "${MINIAUDIO_H}" _ma) -# Idempotency guard: key on the LAST-added patch marker (the worklet non-blocking -# patch), not the first (toPtr). Otherwise a tree already patched by an older -# version of this script (toPtr only) would early-return and never receive a -# newly-added block. With this marker, a toPtr-only tree still runs the script; -# the toPtr string(REPLACE)s are no-ops (targets already gone) and only the new -# worklet block applies. -string(FIND "${_ma}" "daslang non-blocking patch (see miniaudio_memory64.cmake)" _already) +# Idempotency guard: key on the LAST-added patch marker (the capture-rate patch), +# not the first (toPtr). Otherwise a tree already patched by an older version of +# this script would early-return and never receive a newly-added block. With this +# marker, an older-patched tree still runs the script; the earlier string(REPLACE)s +# are no-ops (targets already gone) and only the new block applies (block 6b is +# the form of block 6 that targets a tree carrying the previous non-blocking text). +string(FIND "${_ma}" "daslang capture-rate patch" _already) if(_already GREATER -1) message(STATUS "miniaudio_memory64.cmake: already patched, skipping ${MINIAUDIO_H}") return() @@ -78,6 +78,14 @@ string(REPLACE # Only compiled when MA_USE_AUDIO_WORKLETS (the threaded web build); inert # otherwise. Verified end-to-end: a 440Hz worklet tone plays in Chrome on # memory64+pthread+wasm-EH with no asyncify. +# +# The descriptors' sample rate is the context's own (the capture-rate patch): the +# generic ma_device_init reads the native rate back from the descriptor and sets up +# resampling between it and the requested one, and a capture context is created at +# the browser's rate (48 kHz in Chrome), not the requested one - left at the +# requested value, a 24 kHz capture arrives as 48 kHz frames counted as 24 kHz, +# an octave down and twice as long. A playback context is created at the +# requested rate, so its descriptor reads back the same value as before. string(REPLACE [==[ while (pDevice->webaudio.initResult == MA_BUSY) { emscripten_sleep(1); } /* We must wait for initialization to complete. We're just spinning here. The emscripten_sleep() call is why we need to build with `-sASYNCIFY`. */ @@ -89,12 +97,16 @@ string(REPLACE }]==] [==[ /* daslang non-blocking patch (see miniaudio_memory64.cmake): drop the emscripten_sleep busy-wait that forces -sASYNCIFY. Pre-fill descriptors - from config; the worklet connects asynchronously. */ + from config; the worklet connects asynchronously. The rate is the + context's own (daslang capture-rate patch): a capture context is created + at the browser's rate, and the device layer resamples to the requested one. */ { + ma_uint32 awRate = (ma_uint32)EM_ASM_INT({ return emscriptenGetAudioObject($0).sampleRate; }, pDevice->webaudio.audioContext); ma_uint32 awCh = (pDescriptorPlayback != NULL && pDescriptorPlayback->channels > 0) ? pDescriptorPlayback->channels : MA_DEFAULT_CHANNELS; if (pDescriptorPlayback != NULL) { pDescriptorPlayback->format = ma_format_f32; pDescriptorPlayback->channels = awCh; + if (awRate != 0) { pDescriptorPlayback->sampleRate = awRate; } ma_channel_map_init_standard(ma_standard_channel_map_webaudio, pDescriptorPlayback->channelMap, ma_countof(pDescriptorPlayback->channelMap), pDescriptorPlayback->channels); pDescriptorPlayback->periodSizeInFrames = 128; pDescriptorPlayback->periodCount = 1; @@ -103,6 +115,7 @@ string(REPLACE ma_uint32 awCapCh = (pDescriptorCapture->channels > 0) ? pDescriptorCapture->channels : MA_DEFAULT_CHANNELS; pDescriptorCapture->format = ma_format_f32; pDescriptorCapture->channels = awCapCh; + if (awRate != 0) { pDescriptorCapture->sampleRate = awRate; } ma_channel_map_init_standard(ma_standard_channel_map_webaudio, pDescriptorCapture->channelMap, ma_countof(pDescriptorCapture->channelMap), pDescriptorCapture->channels); pDescriptorCapture->periodSizeInFrames = 128; pDescriptorCapture->periodCount = 1; @@ -117,6 +130,35 @@ string(REPLACE }]==] _ma "${_ma}") +# 6b) The capture-rate patch on a tree the previous version of this script already +# patched (its block 6 text is present, without the rate): the same result as block 6. +# On a fresh tree block 6 has already written the rate lines, so none of these match. +string(REPLACE +[==[ from config; the worklet connects asynchronously. */ + { + ma_uint32 awCh = (pDescriptorPlayback != NULL && pDescriptorPlayback->channels > 0) ? pDescriptorPlayback->channels : MA_DEFAULT_CHANNELS;]==] +[==[ from config; the worklet connects asynchronously. The rate is the + context's own (daslang capture-rate patch): a capture context is created + at the browser's rate, and the device layer resamples to the requested one. */ + { + ma_uint32 awRate = (ma_uint32)EM_ASM_INT({ return emscriptenGetAudioObject($0).sampleRate; }, pDevice->webaudio.audioContext); + ma_uint32 awCh = (pDescriptorPlayback != NULL && pDescriptorPlayback->channels > 0) ? pDescriptorPlayback->channels : MA_DEFAULT_CHANNELS;]==] + _ma "${_ma}") +string(REPLACE +[==[ pDescriptorPlayback->channels = awCh; + ma_channel_map_init_standard(]==] +[==[ pDescriptorPlayback->channels = awCh; + if (awRate != 0) { pDescriptorPlayback->sampleRate = awRate; } + ma_channel_map_init_standard(]==] + _ma "${_ma}") +string(REPLACE +[==[ pDescriptorCapture->channels = awCapCh; + ma_channel_map_init_standard(]==] +[==[ pDescriptorCapture->channels = awCapCh; + if (awRate != 0) { pDescriptorCapture->sampleRate = awRate; } + ma_channel_map_init_standard(]==] + _ma "${_ma}") + # 7) AudioWorklet dangling-config fix. The processor-created callback runs long # after ma_device_init returns (non-blocking, block 6), so pConfig — a local of # the generic ma_device_init — is dead. Read the device type from the long-lived diff --git a/modules/dasLLAMA/ENVIRONMENT.md b/modules/dasLLAMA/ENVIRONMENT.md index f953b008c9..05412c13e7 100644 --- a/modules/dasLLAMA/ENVIRONMENT.md +++ b/modules/dasLLAMA/ENVIRONMENT.md @@ -247,7 +247,7 @@ Apple Accelerate / AMX float lane. `DASLLAMA_ACCEL` arms the whole group. | `DASLLAMA_TEST_FAMILY` | text | unset | Comma-separated filter restricting which model families run. | | `DASLLAMA_LLAMA2C_DIR` | path | unset | Directory of llama2.c reference checkpoints for the forward/decode parity tests. | | `DASLLAMA_WHISPER_DIR` | path | unset | Directory of whisper models for the audio tests. | -| `DISPLAY` | text | unset | Ambient platform variable; read only by the browser examples' smoke tests (storyteller, storywish) to tell whether a Linux box has a window server for the example's window. | +| `DISPLAY` | text | unset | Ambient platform variable; read only by the browser examples' smoke tests (storyteller, storywish, parrot) to tell whether a Linux box has a window server for the example's window. | | `WAYLAND_DISPLAY` | text | unset | Ambient platform variable (the Wayland twin of DISPLAY); read only by the browser examples' smoke tests. | | `DASLLAMA_CORPUS_DIR` | path | unset | Directory of audio corpus files for the transcription tests. | | `TMPDIR` | path | /tmp | Scratch directory for test artifacts; set by the OS on macOS. | diff --git a/modules/dasLLAMA/dasllama/dasllama_env.das b/modules/dasLLAMA/dasllama/dasllama_env.das index 3acada1f87..f6728ea976 100644 --- a/modules/dasLLAMA/dasllama/dasllama_env.das +++ b/modules/dasLLAMA/dasllama/dasllama_env.das @@ -698,7 +698,7 @@ struct public TestEnv { whisper_dir : string = "" @clarg_env = "DISPLAY" - @clarg_doc = "Ambient platform variable; read only by the browser examples' smoke tests (storyteller, storywish) to tell whether a Linux box has a window server for the example's window." + @clarg_doc = "Ambient platform variable; read only by the browser examples' smoke tests (storyteller, storywish, parrot) to tell whether a Linux box has a window server for the example's window." display : string = "" @clarg_env = "WAYLAND_DISPLAY" diff --git a/modules/dasLLAMA/tests/_example_rail.das b/modules/dasLLAMA/tests/_example_rail.das index 3f2a26bb6d..b06261e266 100644 --- a/modules/dasLLAMA/tests/_example_rail.das +++ b/modules/dasLLAMA/tests/_example_rail.das @@ -6,7 +6,7 @@ require dasllama/dasllama_env require daslib/fio require strings -// Shared by the browser examples' smoke tests (test_storyteller_restart.das, test_storywish.das): +// Shared by the browser examples' smoke tests (test_storyteller_restart.das, test_storywish.das, test_parrot.das): // each spawns its example as a child of this binary, and the example opens a GLFW window. //! this binary, as the child's argv[0] diff --git a/modules/dasLLAMA/tests/run.das b/modules/dasLLAMA/tests/run.das index 5d62020198..bca4ce7d90 100644 --- a/modules/dasLLAMA/tests/run.das +++ b/modules/dasLLAMA/tests/run.das @@ -196,6 +196,7 @@ def suite_files(name : string) : array { // nolint:STYLE038 - a flat s "modules/dasLLAMA/tests/test_mtp_gemma_drafter.das", "modules/dasLLAMA/tests/test_parity.das", "modules/dasLLAMA/tests/test_parity_pregate.das", + "modules/dasLLAMA/tests/test_parrot.das", "modules/dasLLAMA/tests/test_ple_modes.das", "modules/dasLLAMA/tests/test_prefill.das", "modules/dasLLAMA/tests/test_qwen3v.das", @@ -241,13 +242,13 @@ let TESTS_DIR = "modules/dasLLAMA/tests" def area_tests(area : string) : array { if (area == "audio") { return <- [ "test_asr_verbs.das", "test_audio.das", "test_audio_embedder.das", "test_dasllama_lint_contracts.das", - "test_tower_asr_kernels.das", "test_tower_helpers.das", "test_vad.das", "test_whisper.das" ] + "test_parrot.das", "test_tower_asr_kernels.das", "test_tower_helpers.das", "test_vad.das", "test_whisper.das" ] } elif (area == "vision") { return <- [ "test_attn_span.das", "test_gemma3v.das", "test_gemma4uv.das", "test_gemma4v.das", "test_qwen25v.das", "test_qwen3v.das", "test_tower_helpers.das", "test_vision.das", "test_vision_chat.das", "test_vision_embedder.das" ] } elif (area == "tts") { - return <- [ "test_storyteller_restart.das", "test_storywish.das", "test_tts_blocks.das", "test_tts_facade.das", + return <- [ "test_parrot.das", "test_storyteller_restart.das", "test_storywish.das", "test_tts_blocks.das", "test_tts_facade.das", "test_tts_g2p.das", "test_tts_kitten.das", "test_tts_kokoro.das", "test_tts_pocket.das", "test_tts_postag.das", "test_tts_textnorm.das" ] } elif (area == "infra") { diff --git a/modules/dasLLAMA/tests/test_parrot.das b/modules/dasLLAMA/tests/test_parrot.das new file mode 100644 index 0000000000..081604880a --- /dev/null +++ b/modules/dasLLAMA/tests/test_parrot.das @@ -0,0 +1,72 @@ +options gen2 +options stack = 524288 // every dasLLAMA program root takes this budget (options stack does not unify up from libs) +options _dasllama_internal = true + +require dastest/testing_boost public +require daslib/env_registry +require daslib/fio +require daslib/strings_boost +require strings +require math +require _model_tier +require _example_rail + +// Parrot (examples/dasLLAMA/parrot): the smoke cell spawns the example under --smoke with the +// tree's own clip in place of the microphone and reads the witness lines it logs: the voice is +// cloned from the clip, the text is said in it, and the say is read out to the end. Model-gated +// on pocket-tts-en-kq.gguf (the Pocket file with its codec encoder, the one the page ships) under +// models_dir(); the voice-activity model is the tree's own. The run opens the example's window +// for about half a minute, so a box without a window server skips, and --null-audio keeps it +// silent - and off the microphone, which --clip never opens anyway. + +let CLIP = "modules/dasLLAMA/models/jfk_ask_not.wav" +let SMOKE_TEXT = "The woods are lovely, dark and deep. But I have promises to keep." +let CLONED_LINE = "parrot: cloned " +let SAY_LINE = "parrot: say 1 in voice you, 65 characters" +let READ_LINE = "parrot: say 1 is read out" +let DONE_LINE = "parrot: the text was read out" + +//! the window server and the model file, each absence registering its own loud skip +def private ready(t : T?) : bool { + if (!has_window_server()) { + t |> skip("no window server (DISPLAY and WAYLAND_DISPLAY unset) - the example opens a window") + return false + } + return model_available(t, path_join(models_dir(), "pocket-tts-en-kq.gguf")) +} + +//! the example's smoke rail: the clip cloned, the text said in the clone, read out to the end (the spawn's wall clock is the guard, not a frame cap) +def private run_parrot(var out : string&) : int { + let prev_image = env_value_of("DASLLAMA_IMAGE") + set_env_variable("DASLLAMA_IMAGE", "0") //! never mint a sidecar beside the stocked models + let root = get_das_root() + let argv <- [example_daslang(), "-jit", "-dasroot", root, "{root}/examples/dasLLAMA/parrot/main.das", "--", + "--models", models_dir(), "--smoke", "--clip", path_join(root, CLIP), "--text", SMOKE_TEXT, "--null-audio"] + let rc = run_and_capture(argv, out, 300.0) + set_env_variable("DASLLAMA_IMAGE", prev_image) + return rc +} + +def private witness(out : string) : string { + var inscope lines <- split(out, "\n") + return join([for (line in lines); line; where find(line, "parrot:") >= 0], "\n") +} + +def private tail_of(out : string) : string { + return slice(out, max(0, length(out) - 600)) +} + +[test] +def test_smoke_clones_and_says(t : T?) { + t |> run("the smoke rail clones the voice from the clip, says the text in it and reads it out to the end") @(t : T?) { + return if (!ready(t)) + var out = "" + let rc = run_parrot(out) + return if (skip_without_window(t, rc, out)) + t |> equal(0, rc, "the smoke run exits clean: {tail_of(out)}") + t |> success(find(out, CLONED_LINE) >= 0, "the voice was cloned from the clip:\n{witness(out)}") + t |> success(find(out, SAY_LINE) >= 0, "the text was said in the cloned voice:\n{witness(out)}") + t |> success(find(out, READ_LINE) >= 0, "the say was read out:\n{witness(out)}") + t |> success(find(out, DONE_LINE) >= 0, "the rail ended on the read-out, not the frame cap:\n{witness(out)}") + } +} diff --git a/modules/dasLLAMA/tests/test_run_suites.das b/modules/dasLLAMA/tests/test_run_suites.das index a8e28a0085..d999ae43de 100644 --- a/modules/dasLLAMA/tests/test_run_suites.das +++ b/modules/dasLLAMA/tests/test_run_suites.das @@ -156,7 +156,7 @@ def test_areas_for_path(t : T?) { [test] def test_area_plan(t : T?) { let tts <- area_plan(["tts"]) - t |> equal(length(tts.files), 11, "tts plans its ten files and the image file its kitten arm rides") + t |> equal(length(tts.files), 12, "tts plans its eleven files and the image file its kitten arm rides") t |> equal(tts.image_arms, "kitten", "tts owns the kitten image arm") t |> equal(tts.files[length(tts.files) - 1], "{TESTS_DIR}/test_model_image.das", "the image file rides last") for (i in range(1, length(tts.files) - 1)) { @@ -178,7 +178,7 @@ def test_area_plan(t : T?) { t |> equal(length(suite.files), 1, "a suite run plans the suite table") t |> equal(suite.image_arms, "arm12", "a suite run passes --arm through") let area <- plan_files(cfg, ["tts"]) - t |> equal(length(area.files), 11, "an area run plans the area, whatever --suite says") + t |> equal(length(area.files), 12, "an area run plans the area, whatever --suite says") } //! the argument contracts: an area run refuses --arm/--full/--suite and an unknown area; a per-PR diff --git a/site-dasllama/examples.html b/site-dasllama/examples.html index b2657da5ba..fb1fff5255 100644 --- a/site-dasllama/examples.html +++ b/site-dasllama/examples.html @@ -72,6 +72,22 @@

Storywish

+ +
+ Parrot poster + +
+
+
+

Parrot

+ voice cloning · in the browser +
+

Press record and talk for a few seconds. Pocket TTS clones your voice from the take and reads the text in the box - a poem to begin with, or whatever you type - in it; Silero VAD ends the take when you go quiet, and you can record again at any time. The recording stays in the tab: nothing is uploaded. The same wasm64 engine, the speech model's 75 MB file with its codec encoder, and a 1 MB voice-activity model.

+
ttsvoice cloningwasm64threads
+
record · talk · sayChrome or Edge 133+, Firefox 134+ (memory64)
+
+
+

Source for every example lives under examples/dasLLAMA in the repository; the browser builds are made with daspkg release wasm from the same sources.

diff --git a/site-dasllama/files/examples/parrot-poster.jpg b/site-dasllama/files/examples/parrot-poster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5626cf44594d5938511fe9fad8134e844261af66 GIT binary patch literal 240407 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

q(v|Lt~2gDOgTT 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%0D~$U@x1J@pc=@)DHz;$#01#OHPw7S8xtpmF0u#z$ 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 literal 0 HcmV?d00001 diff --git a/site-dasllama/test_metadata.py b/site-dasllama/test_metadata.py index 8602c1cdbd..00d7a40e96 100644 --- a/site-dasllama/test_metadata.py +++ b/site-dasllama/test_metadata.py @@ -110,6 +110,7 @@ def test_caddy_redirects_explicit_index(self): EXAMPLE_SHELLS = { "storyteller": (REPO_ROOT / "examples" / "dasLLAMA" / "storyteller" / "web_shell.html", "runStoryteller"), "storywish": (REPO_ROOT / "examples" / "dasLLAMA" / "storywish" / "web_shell.html", "runStorywish"), + "parrot": (REPO_ROOT / "examples" / "dasLLAMA" / "parrot" / "web_shell.html", "runParrot"), } From 2bbf70efcabc7b9a6d04917a1f493acf998e72ca Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 01:41:30 -0700 Subject: [PATCH 08/14] The two small Pocket files join the publish set ahead of the upload: the model card's Files table, its use and provenance paragraphs and the licence row carry pocket-tts-en-kq.gguf (75 MB, the encoder and 19 voices, the browser examples' file) and pocket-tts-en-stuart-kq.gguf (65 MB, one voice, no encoder), publish_tts_hf.py lists them (a dry run stages both with their sidecars, hashes verified against the card). Ledger row 124 now records the small-format ladder that settled the recipe and what is left below Q4_K; row 128 is Ogg Opus in load_audio_mono (miniaudio's Ogg arm is Vorbis, the voices-dir help says "ogg"). The dead second block-alignment guard in gguf_transcode_q5_0_to_q8 is gone (the line above it already checks expect_n). Co-Authored-By: Claude Fable 5.1 --- modules/dasLLAMA/dasllama/dasllama_gguf.das | 3 -- modules/dasLLAMA/followup_general.md | 41 ++++++++++++++------- modules/dasLLAMA/harness/publish_tts_hf.py | 2 +- modules/dasLLAMA/harness/tts_model_card.md | 14 ++++++- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/modules/dasLLAMA/dasllama/dasllama_gguf.das b/modules/dasLLAMA/dasllama/dasllama_gguf.das index 8d22f581a9..2222aa4dc4 100644 --- a/modules/dasLLAMA/dasllama/dasllama_gguf.das +++ b/modules/dasLLAMA/dasllama/dasllama_gguf.das @@ -1601,9 +1601,6 @@ def gguf_transcode_q5_0_to_q8(m : GGUFMeta; srcbytes : array | #; name : if (src_off % 32l != 0l || expect_n % 32l != 0l) { panic("gguf: tensor '{name}' Q5_0 slice [{src_off}, +{expect_n}) is not block-aligned") } - if (expect_n % 32l != 0l) { - panic("gguf: tensor '{name}' asks for {expect_n} Q8_0 elements, not a whole number of 32-blocks") - } let nb = expect_n / 32l if (nb <= 0l) { return diff --git a/modules/dasLLAMA/followup_general.md b/modules/dasLLAMA/followup_general.md index 82fb6b47bf..b5dda0cd27 100644 --- a/modules/dasLLAMA/followup_general.md +++ b/modules/dasLLAMA/followup_general.md @@ -1420,20 +1420,26 @@ itself shows `reinterpret(13)`, an `int` widened to a pointer - a const node's whole vec4f is zero so it happens to work; that example wants a same-size spelling once the rule lands. -124. **Pocket TTS at 4 bits - part 2 of the Pocket arc (ruled 2026-09-09).** The q8 lane held - the reference's quality on the rig (alba, 200 sentences: WER 3.91 / UTMOS 4.328 on the - published Q8_0 file against the package's 5.00 / 4.393, the f32 lane at 4.32 / 4.366), and - that margin is the reason to expect a 4-bit lane to hold too. Try the engine's 4-bit weight - formats on the same GEMMs the q8 lane quantizes - the backbone's four matrices per layer, - the codec transformers, the 32-wide codec convs - through the kq plane machinery the LLM - prefill already runs (`matmul_kq_batch` over a Q8_K-requantized activation row block; - `dasllama_kqformat.das` names the formats: Q4_0, Q4_K, IQ4_NL, IQ4_XS and the rest): a - `wkq` plane beside `wq` on `TtsLinear` / `TtsConv1d`, `linear_rows_kq` and a - `conv1d_rows_dense_kq` over the same stacked tap rows, the decode step on the kq GEMV, the - published file as the winning format. One format at a time, each a rig row on both - lanes, the flow head left f32 throughout (it is the graph's sensitive part - a 1e-5 - epsilon in its timestep norm moved every latent one percent). The prize: the English file - from 152 MB to about 80, and the backbone's per-frame read from 75 MB to 38. +124. **Pocket TTS below Q4_K - what the small-format ladder left open.** The K-quant lane + is built (`TtsLinear` kq/ks planes, `linear_rows_kq` over `matmul_kq_batch`, the decode + step on `matmul_kq`; `convert_pocket.py --kq` writes the recipe as real tensors, `--fake` + scores any format through the existing lanes with no kernel behind it). The ladder that + settled the recipe, on the rig (alba, 200 sentences, the q8 file's WER 3.91 / UTMOS 4.328): + backbone Q6_K 4.09 / 4.327, Q4_K 3.91 / 4.295, Q4_0 3.73 / 4.284, IQ4_XS 4.36 / 4.326 with + seven percent more audio, Q3_K 4.50 / 4.205 (the cliff); on the Q4_K backbone the head at + Q8_0 4.09 / 4.281 and at Q4_K 3.91 / 4.259; the codec transformers at Q4_K 3.68 / 4.309; + the embedding table at Q4_K 4.00 / 4.262; the strided codec convolutions at Q8_0 3.86 / + 4.257 and at Q4_0 3.73 / 4.127 - the one rung the waveform side refuses. The recipe: + backbone Q4_K, flow head Q8_0, codec transformers Q4_K, strided codec convolutions Q8_0, + embedding Q4_K (the real file 3.86 / 4.295 on the native lane; a listen test of three + sentences in two voices against the q8 file heard no difference). The English file went + from 152 MB to 75 with the encoder and 19 voices, 65 with one voice and no encoder. Left + open, for a build that must be smaller still (the game embedding): the backbone at Q4_0 or + IQ4_XS costs 0.2 WER for the same bytes as Q4_K, so it only pays with a kernel that is + faster on the target; the head at Q4_K sits at the edge of the bar and wants the ear, not + the rig; the served 32-wide codec convolutions carry no K-quant block (width 32) and stay + Q8_0; the f16 projections and norms are untested at 8 bits. The rig row per rung and the + listen test are the gate, as before. 125. **A voice-clip upload route on dasllama-server (ruled 2026-09-09 as a ledger row).** The Pocket arc clones by NAMED voices only: the GGUF roster plus the clips of `tts_voices_dir`, read once at boot (`register_voice_clips` in `utils/dasllama-server/openai_server.das`). A @@ -1457,3 +1463,10 @@ whisper-large-v3-turbo, the language forced) is the scorer with no new tooling - a `--asr whisper` arm on the rig and one native sentence set per language (`tests/_tts_fixtures/pocket_sentences.json` has three each; the rig wants 50-200). +128. **Ogg Opus in `load_audio_mono`.** The clip loader (`dasllama/dasllama_audio_io.das`) + decodes through miniaudio, whose Ogg arm is Vorbis (stb_vorbis); an Ogg Opus file - what a + phone or a browser records as `.ogg` today - decodes to nothing, and the server's + `--tts-voices-dir` help and README say "ogg" without the distinction. Either an Opus decoder + behind the same call (libopus + the Ogg framing, a build dependency the module does not + carry yet) or the help text naming Vorbis; a clip that decodes to nothing is logged and + skipped either way. diff --git a/modules/dasLLAMA/harness/publish_tts_hf.py b/modules/dasLLAMA/harness/publish_tts_hf.py index 7a74714e31..df5c3ccc3a 100644 --- a/modules/dasLLAMA/harness/publish_tts_hf.py +++ b/modules/dasLLAMA/harness/publish_tts_hf.py @@ -16,7 +16,7 @@ CARD = os.path.join(HERE, "tts_model_card.md") FILES = ["kitten-nano.gguf", "kitten-mini.gguf", "kokoro-82m.gguf", "tts_g2p.bin", "tts_g2p_en_us.bin", "tts_postag.bin", "pocket-tts-en-q8.gguf", "pocket-tts-de-q8.gguf", "pocket-tts-es-q8.gguf", "pocket-tts-it-q8.gguf", - "pocket-tts-pt-q8.gguf", "pocket-tts-fr-q8.gguf"] + "pocket-tts-pt-q8.gguf", "pocket-tts-fr-q8.gguf", "pocket-tts-en-kq.gguf", "pocket-tts-en-stuart-kq.gguf"] LICENCES = ["LICENSE.APACHE-2.0", "LICENSE.CMUDICT", "LICENSE.UD_EWT", "LICENSE.SPACY", "LICENSE.STYLETTS2", "LICENSE.CC-BY-4.0", "LICENSE.POCKET_TTS"] diff --git a/modules/dasLLAMA/harness/tts_model_card.md b/modules/dasLLAMA/harness/tts_model_card.md index 1bd6dbe2e8..6e0f5dbed6 100644 --- a/modules/dasLLAMA/harness/tts_model_card.md +++ b/modules/dasLLAMA/harness/tts_model_card.md @@ -51,6 +51,8 @@ No espeak-ng, no phonemizer: the front end is data, and the data is in the two p | `pocket-tts-it-q8.gguf` | Pocket TTS Italian (6 layers), one voice (`giovanni`) | 134415072 | `3c5739d544b1b7c8284fd3df9d7122557cf700c91895d3dc45b3fdc5ef6e2670` | | `pocket-tts-pt-q8.gguf` | Pocket TTS Portuguese (6 layers), one voice (`rafael`) | 134667488 | `3375c31e742c8783c6dddbbd3bd152e8dff9d188fdaceb1cbb4d187514291c57` | | `pocket-tts-fr-q8.gguf` | Pocket TTS French (24 layers, the only French model Kyutai ships), one voice (`estelle`) | 375793696 | `f06ffac80b96a34d2e51ca40c41111469d8b44e0269b27a64e707a7a9be1ec20` | +| `pocket-tts-en-kq.gguf` | Pocket TTS English in the small form: the backbone and the codec transformers as Q4_K, the flow head and the codec convolutions as Q8_0, the embedding table Q4_K; its tokenizer, the codec encoder (so it clones) and the 19 voices as latent frames | 74970016 | `2475a1ed8d49eb72c9d9b8c38f10f91ef5b03c7cd6e9fe43fdf7ab00ae1a0a25` | +| `pocket-tts-en-stuart-kq.gguf` | the same small form with one voice (`stuart_bell`) as latent frames and no codec encoder: reads text in that voice, cannot clone | 65107520 | `bc9604b527066134354dc480e20c960f63f5c3538c1dd757ba409bd782cddac9` | The packs sit beside whichever GGUF you load; the loader reads them from the model's directory - `tts_g2p.bin` when it is there, else `tts_g2p_en_us.bin`. The GGUFs carry f32 weights: dasLLAMA quantizes the served layouts to Q8_0 at first @@ -76,6 +78,12 @@ only voice (German `juergen`, Spanish `lola`, Italian `giovanni`, Portuguese `ra `estelle`); the German, Spanish, Italian and Portuguese files are the six-layer models, French exists only as the 24-layer one. A voice cloned from any clip speaks the file's language with the clip's accent. Text in those languages is read as it is, since the normalizer is English. +`pocket-tts-en-kq.gguf` is the English model in the small form, 75 MB: the backbone and the +codec transformers as Q4_K, the flow head and the codec convolutions as Q8_0, the embedding +table Q4_K, the encoder and the 19 voices inside (on the rig at `alba`: WER 3.86 / UTMOS 4.295 +at a real-time factor of 0.049 on the same box); it is the file the browser examples on +dasllama.io fetch. `pocket-tts-en-stuart-kq.gguf` is that form with one voice, `stuart_bell`, +and no codec encoder, 65 MB: it reads text in that voice and cannot clone. Kitten nano is the phoneme families' served default: 59 MB, eight voices, a real-time factor of 0.03 on an Apple M1 Max (measured 2026-09-02 with the same rig). Its voices are `expr-voice-2-m` through @@ -109,6 +117,10 @@ voices. `kyutai/pocket-tts-without-voice-cloning` at `d29db7978e464fb90cb3359ee0c69a273b9142cc`; the voice clips from `kyutai/tts-voices` at `323332d33f997de8394f24a193e1a76df720e01a` (`voice-zero/`, `voice-donations/`, `vctk/`, `alba-mackenna/casual.wav`). +- `pocket-tts-en-kq.gguf` / `pocket-tts-en-stuart-kq.gguf`: the same sources through + `modules/dasLLAMA/harness/convert_pocket.py --kq` (the second with `--voices stuart_bell + --no-cloning`); the K-quant blocks are ggml's own quantizer, and each voice is stored as the + latent frames of its clip through the model's codec encoder. The whole set is rebuilt by `modules/dasLLAMA/performance/build_tts_data.das`. Parity against the reference implementations (block by block, and the front end sentence by sentence on a @@ -122,7 +134,7 @@ the reference implementations (block by block, and the front end sentence by sen | `kokoro-82m.gguf` | Apache-2.0 | hexgrad's weights and voices, converted; the architecture is StyleTTS2 (MIT, `LICENSE.STYLETTS2`) | | `tts_g2p.bin` | Apache-2.0 and BSD-2-Clause | misaki and g2p_en (Apache-2.0), CMUdict (`LICENSE.CMUDICT`, Carnegie Mellon University) | | `tts_postag.bin` | CC BY-SA 4.0 | the tagger weights are trained on UD English-EWT (`LICENSE.UD_EWT`); the exception table and the silver tags come from spaCy (MIT, `LICENSE.SPACY`); Gutenberg prose is public domain | -| `pocket-tts-en-q8.gguf` | CC BY 4.0 | Kyutai's weights and tokenizer, converted (`LICENSE.CC-BY-4.0`); the reference implementation is MIT (`LICENSE.POCKET_TTS`) and not included; the voice clips: `voice-zero` and `voice-donations` CC0, VCTK (CSTR, University of Edinburgh) and Alba Mackenna CC BY 4.0 - the sidecar lists each | +| `pocket-tts-*.gguf` (every Pocket file, the two `-kq` ones included) | CC BY 4.0 | Kyutai's weights and tokenizer, converted (`LICENSE.CC-BY-4.0`); the reference implementation is MIT (`LICENSE.POCKET_TTS`) and not included; the voice clips: `voice-zero` and `voice-donations` CC0, VCTK (CSTR, University of Edinburgh) and Alba Mackenna CC BY 4.0 - the sidecar lists each | Each `.LICENSE` sidecar beside a file names its sources; the full texts are in this repository. The engine that reads these files is under the daslang licence in its own repository. From 124aa1351bfb111137f5ddf6e356ef14777b424d Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 02:12:02 -0700 Subject: [PATCH 09/14] parrot: the buttons answer a click on their label. The hit test took a box below the pen position while the glyph quads rise above it, so a click on the visible text missed; a button now carries its label's own box (quads_dim at the text size) and answers inside it with a margin. In the browser the surface is the document viewport and the page's stage sits below the nav, so the picture is letterboxed; Emscripten maps a click through the canvas element's box with one ratio per axis, which is exact only when that box is the picture - the shell sizes the canvas element to the letterboxed box (max-width / max-height on the replaced element) instead of stretching it over the stage with object-fit. Verified on the staged page with mouse events at the labels: record, stop and say each fire. Co-Authored-By: Claude Fable 5.1 --- examples/dasLLAMA/ARCHITECTURE.md | 9 ++++-- examples/dasLLAMA/parrot/main.das | 39 +++++++++++++++---------- examples/dasLLAMA/parrot/web_shell.html | 5 +++- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/examples/dasLLAMA/ARCHITECTURE.md b/examples/dasLLAMA/ARCHITECTURE.md index 7e8099b410..40e10bffcf 100644 --- a/examples/dasLLAMA/ARCHITECTURE.md +++ b/examples/dasLLAMA/ARCHITECTURE.md @@ -70,8 +70,13 @@ never through a GLFW callback. In the browser build a callback lambda fires from event outside any frame of the program, where the example's state is not live, and the program traps. A printable GLFW key code is its upper-case ASCII, so the key range doubles as the character range for a typed line, and repeats come from a hold timer. The mouse is read the same -way: parrot's buttons are text, and a click is `glfwGetMouseButton` edge-detected against their -rectangles in design pixels. +way: parrot's buttons are text, and a click is `glfwGetMouseButton` edge-detected against the +label's own box (the glyph quads rise above the pen position) in design pixels. In the browser +the surface is the document viewport, and the page's stage sits below the nav, so the picture is +letterboxed; Emscripten maps a click through the canvas element's box with one ratio per axis, +which is exact only when that box is the picture - parrot's shell sizes the canvas element to +the letterboxed box (`max-width`/`max-height` on the replaced element) instead of stretching it +over the stage with `object-fit`. ### 3.4 The model set is minted for the build that ships it diff --git a/examples/dasLLAMA/parrot/main.das b/examples/dasLLAMA/parrot/main.das index b64f028419..cb0afd7bd7 100644 --- a/examples/dasLLAMA/parrot/main.das +++ b/examples/dasLLAMA/parrot/main.das @@ -504,27 +504,31 @@ def draw_text(text : string; x, y : float; size : float; tint : float3) { delete quads } -//! a text's width in design pixels at `size`, from the font's own advance -def text_width(text : string; size : float) : float { - return 0.0 if (g_font == null || empty(text)) +//! a text's box in design pixels at `size`, relative to the pen it is drawn at: left, top, right, bottom +//! (the glyphs sit above the baseline, so the top is negative) +def text_box(text : string; size : float) : float4 { + return float4(0.0) if (g_font == null || empty(text)) var quads <- (*g_font) |> create_quads(text) - let w = quads_dim(quads).vmax.x + let d = quads_dim(quads) delete quads - return w * size + return float4(d.vmin.x, d.vmin.y, d.vmax.x, d.vmax.y) * size } -//! a button: its label in a bracket, the rectangle it answers to in design pixels +//! a button: its label in a bracket, drawn at a pen position; it answers to the label's own box struct Button { text : string x : float y : float - w : float - h : float + box : float4 //! the label's box relative to the pen: left, top, right, bottom } def button(caption : string; x, y : float) : Button { - let w = text_width("[ {caption} ]", TEXT_SIZE) - return Button(text = caption, x = x, y = y, w = w, h = 40.0) + return Button(text = caption, x = x, y = y, box = text_box("[ {caption} ]", TEXT_SIZE)) +} + +//! the pen position after the button, with a gap +def after(b : Button) : float { + return b.x + b.box.z + 30.0 } def draw_button(b : Button; lit : bool) { @@ -560,14 +564,15 @@ def draw_screen() { let row = design_height() - 120.0 let rec = button(g_phase == Phase.recording ? "stop" : "record", 60.0, row) draw_button(rec, g_phase == Phase.recording) - var x = rec.x + rec.w + 30.0 + var x = after(rec) if (g_phase == Phase.recording) { - draw_text(meter_text(), x, row, TEXT_SIZE, accent) - draw_text("{float(length(g_take)) / float(MIC_RATE)} s", x + text_width(meter_text(), TEXT_SIZE) + 20.0, row, TEXT_SIZE, dim) + let meter = meter_text() + draw_text(meter, x, row, TEXT_SIZE, accent) + draw_text("{float(length(g_take)) / float(MIC_RATE)} s", x + text_box(meter, TEXT_SIZE).z + 20.0, row, TEXT_SIZE, dim) } else { let sayb = button("say", x, row) draw_button(sayb, true) - x = sayb.x + sayb.w + 30.0 + x = after(sayb) draw_text(g_voice_ready ? "voice: yours, from {g_voice_seconds} s" : "voice: the model's own until you record", x, row, SMALL_SIZE, dim) } draw_text(g_status, 60.0, design_height() - 70.0, SMALL_SIZE, g_phase == Phase.idle ? accent : dim) @@ -593,15 +598,17 @@ def poll_mouse() { return } if (g_phase != Phase.recording) { - let sayb = button("say", rec.x + rec.w + 30.0, row) + let sayb = button("say", after(rec), row) if (hit(sayb, px, py)) { say_text() } } } +//! inside the label's box, with a margin around it def hit(b : Button; px, py : float) : bool { - return px >= b.x && px <= b.x + b.w && py >= b.y - 8.0 && py <= b.y + b.h + let pad = 8.0 + return px >= b.x + b.box.x - pad && px <= b.x + b.box.z + pad && py >= b.y + b.box.y - pad && py <= b.y + b.box.w + pad } // ===== the program ===== diff --git a/examples/dasLLAMA/parrot/web_shell.html b/examples/dasLLAMA/parrot/web_shell.html index 6ce8c4f345..90cdcc0b5a 100644 --- a/examples/dasLLAMA/parrot/web_shell.html +++ b/examples/dasLLAMA/parrot/web_shell.html @@ -21,7 +21,10 @@ /* the site's nav sits above the stage; the stage takes the rest of the viewport */ .dio-nav { flex: none; } #stage { position: relative; flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; } - #canvas { width: 100%; height: 100%; object-fit: contain; border: 0; outline: none; display: block; } + /* the canvas element takes the letterboxed box itself (a replaced element keeps its ratio under + both max constraints), not the whole stage with object-fit: the program maps a click through + the element's box with one ratio per axis, so the box must be the picture */ + #canvas { width: auto; height: auto; max-width: 100%; max-height: 100%; border: 0; outline: none; display: block; } #gate { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; background: #12101a; cursor: default; From 4c75c6165284f5f8bba1a731e9d79205aa463c92 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 02:29:14 -0700 Subject: [PATCH 10/14] parrot: the page says what the microphone did. The shell wraps the browser's microphone request and writes its outcome over the picture's corner - the device the browser handed the program, or the refusal's reason - and a page a browser opened by address rather than on localhost or https, which has no microphone API at all, gets a stand-in that refuses with that reason instead of the program's request throwing in a callback. A take with no speech in it now reports the level it saw: silence throughout points at the microphone being refused or off, a low peak at a quiet room. Co-Authored-By: Claude Fable 5.1 --- examples/dasLLAMA/parrot/main.das | 7 ++++-- examples/dasLLAMA/parrot/web_shell.html | 30 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/examples/dasLLAMA/parrot/main.das b/examples/dasLLAMA/parrot/main.das index cb0afd7bd7..a52fc1d872 100644 --- a/examples/dasLLAMA/parrot/main.das +++ b/examples/dasLLAMA/parrot/main.das @@ -317,8 +317,11 @@ def stop_take() { drain_rest() if (!g_speech_seen) { g_phase = Phase.idle - g_status = "nothing heard - press record and talk" - to_log(LOG_INFO, "parrot: nothing heard\n") + let seconds = float(length(g_take)) / float(MIC_RATE) + g_status = (g_level_peak < 0.001 + ? "nothing heard - the microphone gave silence for {seconds} s: is it allowed for this page, and on?" + : "nothing heard - the microphone peaked at {g_level_peak} over {seconds} s, too quiet for speech: come closer and talk") + to_log(LOG_INFO, "parrot: nothing heard - peak {g_level_peak} over {seconds} s\n") return } let pad = int64(TAKE_PAD_S * float(MIC_RATE)) diff --git a/examples/dasLLAMA/parrot/web_shell.html b/examples/dasLLAMA/parrot/web_shell.html index 90cdcc0b5a..81af8724bf 100644 --- a/examples/dasLLAMA/parrot/web_shell.html +++ b/examples/dasLLAMA/parrot/web_shell.html @@ -41,6 +41,9 @@ #start:hover { background: #f2b955; color: #12101a; } #note { max-width: 560px; text-align: center; line-height: 1.6; } #mic { max-width: 520px; text-align: center; line-height: 1.6; font-size: 14px; } + /* the microphone's state in words, over the picture's top-right corner */ + #micnote { position: absolute; right: 14px; top: 8px; font-size: 13px; color: #8d8577; pointer-events: none; } + #micnote[hidden] { display: none; } #note b { color: #f4e9d2; font-weight: 500; } #note a { color: #f2b955; } #note code { color: #d6cfbf; font-size: 13px; } @@ -70,6 +73,7 @@

+

parrot

reading the model list...

@@ -149,6 +153,32 @@

parrot

function runParrot() { + // the microphone's state in words: the device the browser handed the program, or why it + // refused - the program itself hears silence either way. A browser gives a plain-http page + // the microphone only on localhost; opened by address it has no microphone API at all, so a + // stand-in refuses with that reason instead of the program's request throwing in a callback. + var micNote = document.getElementById('micnote'); + function setMicNote(text) { micNote.textContent = text; micNote.hidden = !text; } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { + getUserMedia: function () { + return Promise.reject(new DOMException('the browser allows the microphone only on localhost or https - this page was opened as ' + location.host, 'NotAllowedError')); + } + } }); + } + var askMicrophone = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = function (constraints) { + setMicNote('asking for the microphone...'); + return askMicrophone(constraints).then(function (stream) { + var track = stream.getAudioTracks()[0]; + setMicNote('microphone: ' + ((track && track.label) || 'an unnamed device')); + return stream; + }, function (err) { + setMicNote('no microphone: ' + ((err && (err.message || err.name)) || err)); + throw err; + }); + }; + var modelsBase = new URLSearchParams(location.search).get('models') || 'models/'; if (!modelsBase.endsWith('/')) modelsBase += '/'; From 683664670a84944906c6b80e04421b9d9956e789 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 02:44:57 -0700 Subject: [PATCH 11/14] The two small Pocket files are published: borisbat/dasllama-tts at be9630af1cba2f6f7946efa7b8ea194b9b093dc9 carries pocket-tts-en-kq.gguf and pocket-tts-en-stuart-kq.gguf beside the set, TTS_HF is pinned there, and the model-set table gains their rows with their recipes (the --kq form, and its one-voice no-encoder twin); the fetch verifier reads both back at the pinned commit with the table's size and sha256, and the server's catalog test counts the served set at fourteen. Co-Authored-By: Claude Fable 5.1 --- modules/dasLLAMA/performance/model_specs.das | 15 +++++++++++++-- utils/dasllama-server/test_model_catalog.das | 4 ++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/modules/dasLLAMA/performance/model_specs.das b/modules/dasLLAMA/performance/model_specs.das index bc7793e6c3..298b045a13 100644 --- a/modules/dasLLAMA/performance/model_specs.das +++ b/modules/dasLLAMA/performance/model_specs.das @@ -279,11 +279,14 @@ let REF_Q8_RECIPE = "derive: setup_asr_rig.das --refs quantizes it from the fp b let PARAKEET_V2_NOTE = "historic nemo-venv conversion (rail retired); present only on boxes that converted it - v3 is the fetched carrier" let CANARY_ENC_RECIPE = "convert: modules/dasLLAMA/harness/convert_canary_to_ggml.py over nvidia/canary-qwen-2.5b (nemo venv; encoder is a repack - sha canonical everywhere)" let CANARY_DEC_RECIPE = "convert: modules/dasLLAMA/harness/convert_canary_to_ggml.py over nvidia/canary-qwen-2.5b (nemo venv), then llama-quantize Q8_0 (a Q8_0 disk embedding lets the tied classifier serve cls_q8 on the Metal rail); bytes are per-arch (fp16 LoRA merge) - gate via asr_bench --text over jfk/jfk3/gb1 vs benchmarks/asr/canary_transcripts.expected" -let TTS_HF = "{HF}/borisbat/dasllama-tts/resolve/f3d4818a01076e3f235c2f77a5ac4227ccf355b2" // the published set, commit-pinned +let TTS_HF = "{HF}/borisbat/dasllama-tts/resolve/be9630af1cba2f6f7946efa7b8ea194b9b093dc9" // the published set, commit-pinned let TTS_KITTEN_RECIPE = "convert: modules/dasLLAMA/performance/build_tts_data.das -- --root --out runs harness/convert_kitten.py over KittenML/kitten-tts-nano-0.8 @ 7a1db645b1f3ab9420761d87428e042b9cec3f26 and kitten-tts-mini-0.8 @ c02725660cea441db4c383af69f1f26f5cd00947 (the ONNX weights into GGUF, f32); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" let TTS_KOKORO_RECIPE = "convert: modules/dasLLAMA/performance/build_tts_data.das -- --root --out runs harness/convert_kokoro.py over hexgrad/Kokoro-82M @ f3ff3571791e39611d31c381e3a41a3af07b4987 (kokoro-v1_0.pth + the voice packs into GGUF, f32); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" let TTS_POCKET_RECIPE = "convert: modules/dasLLAMA/harness/convert_pocket.py --language english_2026-04 --q8 --name pocket-tts-en-q8 over kyutai/pocket-tts languages/english_2026-04/model.safetensors @ 19f95fe2df36e79fbd9f10008595cc4c977a0fcc, the tokenizer of kyutai/pocket-tts-without-voice-cloning @ d29db7978e464fb90cb3359ee0c69a273b9142cc and the voice clips of kyutai/tts-voices @ 323332d33f997de8394f24a193e1a76df720e01a (the served GEMMs as Q8_0, the rest f16); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" let TTS_POCKET_LANG_RECIPE = "convert: modules/dasLLAMA/harness/convert_pocket.py --language --q8 --name pocket-tts--q8 over kyutai/pocket-tts languages//model.safetensors @ 39592ff23c9ef80098bb74895d104c26275fe2c9 (german, italian, spanish, portuguese, french_24l), the tokenizer of kyutai/pocket-tts-without-voice-cloning @ d29db7978e464fb90cb3359ee0c69a273b9142cc and the language's default clip (kyutai/pocket-tts @ 64ab7d24c479d736a83b8cc666c4a776fca30fda; estelle from kyutai/tts-voices @ 1fc7395b7e012e2bbebfca14b942a4ef62ccc899); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" +//! the small form the browser examples fetch: K-quant planes where the ladder held (backbone and codec transformers Q4_K, the embedding table Q4_K), Q8_0 where it did not (the flow head, the codec convolutions), the roster as latent frames +let TTS_POCKET_KQ_RECIPE = "convert: modules/dasLLAMA/harness/convert_pocket.py --language english_2026-04 --kq --name pocket-tts-en-kq over the sources of pocket-tts-en-q8.gguf's recipe (the backbone and the codec transformers as Q4_K through ggml's own quantizer, the flow head and the codec convolutions as Q8_0, the embedding table Q4_K, the codec encoder inside, the 19 voices as the latent frames of their clips); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" +let TTS_POCKET_STUART_RECIPE = "convert: modules/dasLLAMA/harness/convert_pocket.py --language english_2026-04 --kq --voices stuart_bell --no-cloning --name pocket-tts-en-stuart-kq over the sources of pocket-tts-en-q8.gguf's recipe (the small form of pocket-tts-en-kq.gguf with one voice as latent frames and no codec encoder: reads text in stuart_bell, cannot clone); THIRD_PARTY_NOTICES.md (repo root) carries the attribution" // the f16 twin the parity rail loads on its f32 lane: the same sources, no --q8; never published let TTS_POCKET_F16_RECIPE = "convert: modules/dasLLAMA/harness/convert_pocket.py --language english_2026-04 --name pocket-tts-en over the sources of pocket-tts-en-q8.gguf's recipe (every tensor f16; the parity rail's reference under tests/test_tts_pocket.das, local only)" let TTS_PACKS_RECIPE = "mint: modules/dasLLAMA/performance/build_tts_data.das -- --root --out runs harness/build_g2p_data.py (misaki 0.9.4 gold/silver + CMUdict 0.7a + the g2p_en 2.1.0 GRU + harness/g2p_local_additions.json into tts_g2p.bin) and harness/train_postag.py (UD English-EWT + spaCy-labelled silver prose into tts_postag.bin)" @@ -645,7 +648,15 @@ def model_specs() : array { // nolint:STYLE038 — flat model-set t url = "{TTS_HF}/pocket-tts-fr-q8.gguf", bytes = 375793696l, sha256 = "f06ffac80b96a34d2e51ca40c41111469d8b44e0269b27a64e707a7a9be1ec20", companions <- [ - ProvEntry(name = "tts_oracle/pocket_french_24l", root = "llm", recipe = TTS_ORACLE_RECIPE)]) + ProvEntry(name = "tts_oracle/pocket_french_24l", root = "llm", recipe = TTS_ORACLE_RECIPE)]), + // the small English forms behind the browser examples: the parrot page fetches the cloning one, + // the storywish page the one-voice one (tests/test_tts_pocket.das's kq cells hold both against the q8 file) + ModelSpec(file = "pocket-tts-en-kq.gguf", recipe = TTS_POCKET_KQ_RECIPE, serve_tts = true, + url = "{TTS_HF}/pocket-tts-en-kq.gguf", bytes = 74970016l, + sha256 = "2475a1ed8d49eb72c9d9b8c38f10f91ef5b03c7cd6e9fe43fdf7ab00ae1a0a25"), + ModelSpec(file = "pocket-tts-en-stuart-kq.gguf", recipe = TTS_POCKET_STUART_RECIPE, serve_tts = true, + url = "{TTS_HF}/pocket-tts-en-stuart-kq.gguf", bytes = 65107520l, + sha256 = "bc9604b527066134354dc480e20c960f63f5c3538c1dd757ba409bd782cddac9") ] } diff --git a/utils/dasllama-server/test_model_catalog.das b/utils/dasllama-server/test_model_catalog.das index 604a34dca1..38621a1648 100644 --- a/utils/dasllama-server/test_model_catalog.das +++ b/utils/dasllama-server/test_model_catalog.das @@ -92,10 +92,10 @@ def test_catalog_table(t : T?) { packs++ } } - t |> equal(length(set), 12, "the set is nine models (three phoneme families, six Pocket languages) and three packs") + t |> equal(length(set), 14, "the set is eleven models (three phoneme families, six Pocket languages, the two small English Pocket forms) and three packs") t |> equal(packs, 3, "three front-end packs: the full phoneme pack, its American-only twin, the tagger") for (want in ["kitten-nano.gguf", "kitten-mini.gguf", "kokoro-82m.gguf", "tts_g2p.bin", "tts_g2p_en_us.bin", "tts_postag.bin", - "pocket-tts-en-q8.gguf", "pocket-tts-de-q8.gguf", "pocket-tts-es-q8.gguf", "pocket-tts-it-q8.gguf", "pocket-tts-pt-q8.gguf", "pocket-tts-fr-q8.gguf"]) { + "pocket-tts-en-q8.gguf", "pocket-tts-de-q8.gguf", "pocket-tts-es-q8.gguf", "pocket-tts-it-q8.gguf", "pocket-tts-pt-q8.gguf", "pocket-tts-fr-q8.gguf", "pocket-tts-en-kq.gguf", "pocket-tts-en-stuart-kq.gguf"]) { t |> success(key_exists(names, want), "the set carries {want}") } } From 98fcd6516addc5f08ee9370dc0722fccc35d8740 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 04:26:54 -0700 Subject: [PATCH 12/14] The review round pays out on the small form. parrot's take logic moves into take.das, a pure module the test file reaches without a window: the continuous 24 kHz to 16 kHz resampler the VAD reads (a chunked feed equals the one-shot feed), the take-end rules (the button, two seconds of quiet after speech, six seconds with no frames at all), the clip window padded a quarter second around the speech and capped at sixty seconds, and the text box's typing rules; main.das drains the mic before it stops it, logs the recorder's overflow count, refuses a take while a clone runs, decodes a --clip before the window opens so a bad file refuses in one second, and cancels a running say when a take starts or the window closes. The engine: linear_rows_add_bias gets its scalar tail (a width off the float4 grid read past the row), linear_take_kq releases the f32 weight it replaced, kq_gemv_row quantizes its row in place (the module-global scratch row is gone), read_linear's K-quant branch is one guard and the load log names the kq arm, the repack stage is decided once per load, and read_roster, the frame-step check, ensure_voice_state and pocket_encode_latents on a file without the encoder panic by name; gguf_transcode_q8_0 refuses an unaligned width. convert_pocket.py: the speaker group matches flow_lm.speaker_proj_weight, --fake refuses a group named twice or a spec with no '=', the report reads the group each tensor was rounded under and refuses a --fake group that matched nothing, a --kq file's general.name says Q4_K; test_convert_pocket.py holds nine of those on numpy alone, wired into the extended checks. mint_models.py refuses an empty list and a zero-file stage. The tests: test_tts_blocks holds the kq rows and decode kernels to the leaf per row at 512x96 and 768x64 in both the repacked and the disk order, the bias tail at 256x6, an added poison, and the width refusal; test_tts_pocket splits the kq cell into the lanes (kq against q8 with a poisoned expectation, kq against the exact lane, the head over frames against the dump, the stored roster's latents against the f16 file's own encoder) and the clone over the roster (nineteen voices stay, alba speaks); test_parrot holds the resampler, the take rules and the text box model-free and the bad --clip refusal in sixty seconds. The rule documents answer the round: REVIEW.md's charter clause names a weight format and a serving lane, REVIEW_TTS.md splits the [hot_path] rule from the text front-end ban and drops the nolint clauses, REVIEW_PLACEMENT.md gives the caller-guard rule its own line, tests/REVIEW.md spells its loader list out, performance/REVIEW.md's companion rule reads in both directions, examples/dasLLAMA/REVIEW.md's canvas and bfcache rules say what the shell does; the architecture docs carry the latents charter, the three lanes and the take rules. PERF_LEDGER.md takes the small form's row (RSS and RTF, the kq file against the q8 file, both lanes of the kq file through the rig). The server's /catalog document says which speech files read the front-end packs: its tts list carries the two packs the route loads (the browser's American-only twin rides no ladder) and every served model with needs_packs - the engine's family test on the file once it is here, true until then - so the control page wires a Pocket file on disk with no packs, and a phoneme family waits for its packs; before this the card demanded every companion pack, the American twin included, before it offered any model, which the fixtures captured before those rows landed never showed. The four catalog fixtures are re-captured over the fourteen-row served set, the page's speech ladder has a spec for both arms, and the catalog test reads the list back over a planted file. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/extended_checks.yml | 2 + REVIEW_COMMON.md | 4 +- .../tutorials/dasLLAMA_16_text_to_speech.rst | 22 +- examples/dasLLAMA/ARCHITECTURE.md | 36 ++- examples/dasLLAMA/REVIEW.md | 25 +- examples/dasLLAMA/parrot/main.das | 262 +++++++++--------- examples/dasLLAMA/parrot/take.das | 116 ++++++++ examples/dasLLAMA/wasm/mint_models.py | 5 + examples/dasLLAMA/wasm/test_mint_models.py | 85 ++++++ modules/dasLLAMA/ARCHITECTURE_POCKET.md | 35 ++- modules/dasLLAMA/ARCHITECTURE_TTS.md | 14 +- modules/dasLLAMA/PERF_LEDGER.md | 21 ++ modules/dasLLAMA/REVIEW.das | 2 +- modules/dasLLAMA/REVIEW.md | 36 +-- modules/dasLLAMA/REVIEW_PLACEMENT.md | 25 +- modules/dasLLAMA/REVIEW_TTS.md | 60 ++-- modules/dasLLAMA/REVIEW_UPSTREAM.md | 9 +- modules/dasLLAMA/dasllama/dasllama_gguf.das | 4 +- modules/dasLLAMA/dasllama/dasllama_pocket.das | 45 ++- .../dasLLAMA/dasllama/dasllama_tts_blocks.das | 30 +- modules/dasLLAMA/followup_general.md | 10 + modules/dasLLAMA/harness/convert_pocket.py | 32 ++- .../dasLLAMA/harness/test_convert_pocket.py | 103 +++++++ modules/dasLLAMA/harness/tts_model_card.md | 6 +- modules/dasLLAMA/performance/REVIEW.md | 9 +- modules/dasLLAMA/tests/CLAUDE.md | 8 +- modules/dasLLAMA/tests/REVIEW.md | 14 +- modules/dasLLAMA/tests/test_parrot.das | 143 +++++++++- modules/dasLLAMA/tests/test_tts_blocks.das | 143 ++++++++++ modules/dasLLAMA/tests/test_tts_pocket.das | 106 +++++-- site-dasllama/REVIEW.md | 3 +- site-dasllama/_news/2026-09-10-parrot.md | 14 + site-dasllama/_news/2026-09-10-storywish.md | 4 +- site-dasllama/feed.xml | 18 +- site-dasllama/index.html | 15 +- utils/dasllama-server/README.md | 11 +- utils/dasllama-server/control.html | 39 +-- utils/dasllama-server/model_catalog.das | 18 +- utils/dasllama-server/openai_server.das | 3 +- utils/dasllama-server/test_model_catalog.das | 55 +++- .../tests/fixtures/catalog_done.json | 2 +- .../tests/fixtures/catalog_downloading.json | 2 +- .../tests/fixtures/catalog_empty.json | 2 +- .../tests/fixtures/catalog_idle.json | 2 +- utils/dasllama-server/tests/tts.spec.js | 29 ++ 45 files changed, 1264 insertions(+), 365 deletions(-) create mode 100644 examples/dasLLAMA/parrot/take.das create mode 100644 modules/dasLLAMA/harness/test_convert_pocket.py create mode 100644 site-dasllama/_news/2026-09-10-parrot.md diff --git a/.github/workflows/extended_checks.yml b/.github/workflows/extended_checks.yml index c70c2483fd..b93927bba3 100644 --- a/.github/workflows/extended_checks.yml +++ b/.github/workflows/extended_checks.yml @@ -642,6 +642,8 @@ jobs: run: | set -eux PYTHONDONTWRITEBYTECODE=1 python3 examples/dasLLAMA/wasm/test_mint_models.py + # the Pocket converter's pure predicates (which tensor lands in which form) - numpy only, no torch + PYTHONDONTWRITEBYTECODE=1 python3 modules/dasLLAMA/harness/test_convert_pocket.py - name: "Test pr-babysit verdict core" if: matrix.role != 'modules' diff --git a/REVIEW_COMMON.md b/REVIEW_COMMON.md index f4c5f305ff..bd0cf372f8 100644 --- a/REVIEW_COMMON.md +++ b/REVIEW_COMMON.md @@ -135,5 +135,5 @@ obligation is a rule, and it lives in the flat list above. **Adding a rule starts with reading the whole checklist** - duplication, drift, and homeless placement all start with a rule appended by an author who had not just read the file. -**A rule the diff adds that is longer than every rule already in the file is split, its -exception dissolved, or its extra prose moved to the architecture doc.** +**A rule the diff adds or lengthens that is longer than every other rule in the file is split, +or its extra prose moved to the architecture doc.** diff --git a/doc/source/reference/tutorials/dasLLAMA_16_text_to_speech.rst b/doc/source/reference/tutorials/dasLLAMA_16_text_to_speech.rst index 173a16fed1..84be1da32c 100644 --- a/doc/source/reference/tutorials/dasLLAMA_16_text_to_speech.rst +++ b/doc/source/reference/tutorials/dasLLAMA_16_text_to_speech.rst @@ -247,12 +247,13 @@ Cloning a voice =============== ``caps().cloning`` says whether the model takes a voice from a recording. -Pocket TTS does: a few seconds of one speaker, mono, at the model's own rate, -become a voice in the roster. ``load_audio_mono`` decodes a wav, flac, mp3 or -ogg file to that rate, and ``tts_register_voice`` adds the samples under the -name you give. From then on the name works like any bundled voice. A clip -longer than a minute is refused, and a phoneme model panics here: it has no -voice to take. +A Pocket TTS file with its codec encoder does: a few seconds of one speaker, +mono, at the model's own rate, become a voice in the roster. ``load_audio_mono`` +decodes a wav, flac, mp3 or ogg file to that rate, and ``tts_register_voice`` +adds the samples under the name you give. From then on the name works like any +bundled voice. A clip longer than a minute is refused; a Pocket file converted +without the encoder (a one-voice file for a page) reports ``cloning`` false and +refuses by name; a phoneme model panics here: it has no voice to take. .. code-block:: das @@ -262,15 +263,18 @@ voice to take. let mine <- synthesize(m, "daslang speaks in my voice.", "me") } -The two weight lanes -==================== +The weight lanes +================ The decoder and generator matrix multiplies are served from one of two prepared images beside the GGUF. The q8 lane holds those weights as Q8_0 quants and is what a load serves by default. The f32 lane holds the file's own planes; it is the reference the parity tests hold the q8 lane against. A published Pocket file already holds Q8_0 weights, so its q8 lane reads them as -they are and its f32 lane dequantizes them. +they are and its f32 lane dequantizes them. The small Pocket files hold Q4_K +planes for the backbone and the codec transformers too: an unpinned load serves +those planes as they are through the engine's K-quant kernels, a third lane, +while a pin to q8 or f32 requantizes or dequantizes them at load. ``tts_serves_q8`` answers which lane the next load takes. ``set_tts_q8`` pins it, and ``reset_tts_q8`` returns to the default. The pin is process-wide diff --git a/examples/dasLLAMA/ARCHITECTURE.md b/examples/dasLLAMA/ARCHITECTURE.md index 40e10bffcf..11599c7fea 100644 --- a/examples/dasLLAMA/ARCHITECTURE.md +++ b/examples/dasLLAMA/ARCHITECTURE.md @@ -15,8 +15,9 @@ checklist is `REVIEW.md` beside this file. The engine these programs drive is do `wish.das` holds the request side pure (typed line -> words -> prompt, the field-line stop) so a test reaches it without a window. - `parrot/` - a browser example: you press record and talk, Silero VAD ends the take when you go - quiet, Pocket TTS clones the voice from the take (a file with its codec encoder and no baked - roster), and the text in the box is read aloud in it on the say button; recording again + quiet, Pocket TTS clones the voice from the take (a file with its codec encoder and its + roster, which speaks until a take replaces it), and the text in the box is read aloud in it on + the say button; recording again replaces the voice. Same four files. Nothing leaves the program: the take is cloned in memory and never written. - `wasm/dlim_config/` - a wasm-only program: prints the running build's DlimConfiguration JSON. @@ -51,8 +52,8 @@ shell reloads such a page (`pageshow` with `persisted`), so it starts from the g ### 3.2 The speech thread and its stream {#speech-thread-stream} Speech runs on its own thread so the frame loop never blocks on synthesis. The frame thread -pushes its requests into a stream as archived records (storywish's `Line` is a sentence; parrot's -`Ask` is a text to say or a take to clone, the PCM riding in the record) and pops finished clips +pushes its requests into a stream as archived records (a sentence for the story examples; for +parrot a text to say or a take to clone, the PCM riding in the record) and pops finished clips from a second stream; a `SeqBox` carries the number of the story (parrot: the say) being told, so a queued sentence of one the user replaced is skipped instead of synthesized. The thread's own setup - the TTS model path and the voice - rides the same request stream ahead of the first @@ -74,9 +75,9 @@ way: parrot's buttons are text, and a click is `glfwGetMouseButton` edge-detecte label's own box (the glyph quads rise above the pen position) in design pixels. In the browser the surface is the document viewport, and the page's stage sits below the nav, so the picture is letterboxed; Emscripten maps a click through the canvas element's box with one ratio per axis, -which is exact only when that box is the picture - parrot's shell sizes the canvas element to -the letterboxed box (`max-width`/`max-height` on the replaced element) instead of stretching it -over the stage with `object-fit`. +which is exact only when that box is the picture - so a shell sizes the canvas element to the +letterboxed box (`max-width`/`max-height` on the replaced element) instead of stretching it over +the stage with `object-fit`. ### 3.4 The model set is minted for the build that ships it @@ -103,14 +104,19 @@ configuration it prints is the one their programs run with. ### 3.6 Parrot's take {#the-take} -The microphone is opened at the speech model's own rate, 24 kHz mono, and drained on the frame -thread every frame into the take; a copy of each drain, resampled to 16 kHz by linear -interpolation, feeds the Silero iterator, which is the only reader of that rate. The take ends on -the stop button, two seconds after the iterator's last speech end, or at the model's 60 s clip -cap; it is trimmed to the speech plus a quarter second at each end and sent to the speech thread -as a clone request, so the clone runs off the frame thread like a synthesis. A take with no -speech in it is dropped. The audio device is the capture's own, separate from playback, so a -recording can start while a clip is still playing. +The microphone is opened at the speech model's own rate (the speech thread reports it, with +whether the file clones, before the first take), mono, and drained on the frame thread every +frame into the take; the take is resampled to 16 kHz by linear interpolation with one running +position across drains, so the stream the Silero iterator hears has no seam where the drains +met, and the iterator is the only reader of that rate. The take ends on the stop button, two +seconds after the iterator's last speech end, at the model's 60 s clip cap, or when the device +has delivered nothing for six seconds (a refused microphone opens but never delivers); the ring's +tail is drained before the device stops, since stopping frees the ring. The clip is the speech +plus a quarter second at each end, never longer than the cap, and goes to the speech thread as a +clone request, so the clone runs off the frame thread like a synthesis. A take with no speech in +it is dropped, and the status says whether the device gave nothing, silence, or too little. A +take starts by cancelling a say in flight - a clip still playing would be recorded - and the +pure side of all this (`take.das`) is what the model-free cells test. ## 4. Exception ledger diff --git a/examples/dasLLAMA/REVIEW.md b/examples/dasLLAMA/REVIEW.md index 57287f18ba..bb0ffb29a5 100644 --- a/examples/dasLLAMA/REVIEW.md +++ b/examples/dasLLAMA/REVIEW.md @@ -6,17 +6,26 @@ A browser example is a subfolder here with a `web_shell.html` (`ARCHITECTURE.md` sec. 2); the rules below bind browser examples. -**Never install a GLFW callback - any `glfwSet*Callback` - in a browser example; poll each key -with `glfwGetKey` every frame and edge-detect it.** In the browser build a callback fires outside -any frame of the program and the program traps (`ARCHITECTURE.md` sec. 3.3). +**Never install a GLFW callback - any `glfwSet*Callback` - in a browser example; poll each input +every frame and edge-detect it - `glfwGetKey` for keys, `glfwGetMouseButton` for buttons.** In the +browser build a callback fires outside any frame of the program and the program traps +(`ARCHITECTURE.md` sec. 3.3). + +**A browser example's `web_shell.html` gives the canvas element `max-width` / `max-height` so the +element's box is exactly the rendered image - never `object-fit` on a canvas stretched to fill the +page area around it.** A click maps through the element's box with one ratio per axis, so a +stretched box mis-maps every click (`ARCHITECTURE.md` sec. 3.3). + +**A browser example's `web_shell.html` must reload a page the browser restored from its +back-forward cache - a `pageshow` handler that reloads when `persisted` is set.** Such a page +comes back with its workers and audio output frozen out of step (`ARCHITECTURE.md` sec. 3.1). **A diff that changes or drops a witness line - a line a browser example logs under its own name - updates every test under `modules/dasLLAMA/tests/` that matches it, in the same change.** The smoke tests match witness lines as substrings, so the words and their order are an interface (`ARCHITECTURE.md` sec. 2). -**A diff that adds a model file to a browser example's `models.json` names it by the repository -it is published in and its sha256 - or, for a file the repository itself carries, by its -repo-relative path under `tree` and its sha256 - never by a machine-local path or a branch -name.** The deploy fetches or copies the file by that name and refuses one whose hash moved; a -machine-local path stages nothing on the runner (`ARCHITECTURE.md` sec. 3.4). +**A diff that adds a model file to a browser example's `models.json` names its sha256 and a +location that cannot move - a Hugging Face repository, or a repo-relative path in this repository +under `tree` - never a machine-local path or a branch name.** The deploy fetches by that name and +refuses a file whose hash moved (`ARCHITECTURE.md` sec. 3.4). diff --git a/examples/dasLLAMA/parrot/main.das b/examples/dasLLAMA/parrot/main.das index a52fc1d872..0f89be2370 100644 --- a/examples/dasLLAMA/parrot/main.das +++ b/examples/dasLLAMA/parrot/main.das @@ -1,10 +1,9 @@ options gen2 options persistent_heap options stack = 524288 // every dasLLAMA program root takes this budget (options stack does not unify up from libs) -options _dasllama_internal = true // the voice-activity detector lives beside the facade, not in it -require dasllama/dasllama // the facade: the TTS model, its caps, the clone verb, the chunker, the synthesis -require dasllama/dasllama_vad // Silero: when a take has speech in it and when it stops +require dasllama/dasllama // the facade: the TTS model, its caps, the clone verb, the chunker, the synthesis, and Silero (when a take has speech and when it stops) +require take // the pure side: the take's numbers, the text box's rules, a button's box require daslib/jobque_boost require daslib/strings_boost require daslib/clargs @@ -69,13 +68,6 @@ struct ParrotArgs { let POEM = "Whose woods these are I think I know.\nHis house is in the village though;\nHe will not see me stopping here\nTo watch his woods fill up with snow.\n\nMy little horse must think it queer\nTo stop without a farmhouse near\nBetween the woods and frozen lake\nThe darkest evening of the year.\n\nHe gives his harness bells a shake\nTo ask if there is some mistake.\nThe only other sound's the sweep\nOf easy wind and downy flake.\n\nThe woods are lovely, dark and deep,\nBut I have promises to keep,\nAnd miles to go before I sleep,\nAnd miles to go before I sleep." -let MIC_RATE = 24000 //! the model's own rate: the take is cloned as recorded -let VAD_RATE = 16000 //! Silero listens at 16 kHz; the take is resampled for it alone -let SILENCE_ENDS_TAKE_S = 2.0 //! quiet this long after speech ends the take -let TAKE_CAP_S = 60.0 //! the model's clip cap -let TAKE_PAD_S = 0.25 //! kept around the speech at both ends -let WRAP_CHARS = 74 //! droidsansmono is monospace: characters are the wrap unit -let MAX_LINES = 24 let TEXT_SIZE = 0.66 let SMALL_SIZE = 0.5 let VOICE_NAME = "you" @@ -96,9 +88,9 @@ struct Ask { rate : int } -//! speech thread -> frame thread: a voice cloned, a clip of a say, or how many clips a say will have +//! speech thread -> frame thread: the model's facts, a voice cloned, a clip of a say, or how many clips a say will have struct Answer { - kind : int //! 1 = cloned (seconds in text), 2 = a clip, 3 = the say's chunk count (in text) + kind : int //! 0 = ready (the rate, cloning in gen, the roster size in text), 1 = cloned (seconds in text), 2 = a clip, 3 = the say's chunk count (in text) text : string gen : int pcm : array @@ -119,13 +111,23 @@ var g_take : array //! the microphone as recorded, 24 kHz mono var g_take_scratch : array var g_vad_model = VadModel() var g_vad = VadIter() +var g_ears = VadResampler() +var g_ears_out : array var g_speech_seen = false var g_in_speech = false var g_speech_start24 = 0l var g_speech_end24 = 0l var g_quiet_since_s = 0.0 +var g_take_started_s = 0.0 +var g_last_frames_s = 0.0 //! when the device last delivered a frame; a device that never does ends the take +var g_frames_seen = false var g_level = 0.0 var g_level_peak = 0.0 +var g_ready = false //! the speech thread has the model: its rate, whether it clones, its roster +var g_mic_rate = MIC_RATE +var g_can_clone = false +var g_roster = 0 +var g_pending_clip : array //! a --clip decoded before the model was ready var g_ask : Stream? var g_answer : Stream? @@ -158,7 +160,9 @@ def start_speech_thread(var ask, answer : Stream?; var now : SeqBox?; var done : } var inscope m <- load_tts_model(tts_path) var inscope c <- caps(m) - var voice = c.voices[0] + var voice = empty(c.voices) ? "" : c.voices[0] + var ready = Answer(kind = 0, text = "{length(c.voices)}", gen = c.cloning ? 1 : 0, rate = c.sample_rate) + answer |> push_archive(ready) var running = true while (running) { ask |> pop_archive() $(var a : Ask&) { @@ -197,7 +201,19 @@ def poll_answers() { g_answer |> try_pop() $(bytes) { var a : Answer mem_archive_load(bytes, a) - if (a.kind == 1) { + if (a.kind == 0) { + g_ready = true + g_mic_rate = a.rate + g_can_clone = a.gen != 0 + g_roster = to_int(a.text) + g_status = g_can_clone ? "press record and talk, then say" : "this file cannot clone - parrot needs a Pocket file with its codec encoder; say reads in the model's voice" + if (!empty(g_pending_clip)) { + if (!g_can_clone) { + panic("parrot: --clip needs a file that clones; {g_args.tts_model} carries no codec encoder") + } + clone_clip(g_pending_clip, g_mic_rate) + } + } elif (a.kind == 1) { g_voice_ready = true g_voice_seconds = to_float(a.text) g_phase = Phase.idle @@ -237,19 +253,37 @@ def poll_answers() { // ===== the take ===== def start_take() { + if (g_phase == Phase.cloning) { + g_status = "cloning your voice - a moment, then record again" + return + } return if (g_phase != Phase.idle) - if (!sound_record_start(MIC_RATE, 1, MIC_RATE * 4, -1)) { + if (!g_ready) { + g_status = "the model is still loading" + return + } + if (!g_can_clone) { + g_status = "this file cannot clone - parrot needs a Pocket file with its codec encoder" + return + } + cancel_say() // a clip still playing would be recorded + if (!sound_record_start(g_mic_rate, 1, g_mic_rate * 4, -1)) { g_status = "no microphone - is one connected, and allowed?" return } g_take |> clear() - g_take |> reserve(int(TAKE_CAP_S) * MIC_RATE) - g_take_scratch |> resize(MIC_RATE) + g_take |> reserve(int(TAKE_CAP_S) * g_mic_rate) + g_take_scratch |> resize(g_mic_rate) vad_iter_reset(g_vad, default_vad_opts()) + g_ears.pos = 0.0lf g_speech_seen = false g_in_speech = false g_speech_start24 = 0l g_speech_end24 = 0l + g_quiet_since_s = g_elapsed_s + g_take_started_s = g_elapsed_s + g_last_frames_s = g_elapsed_s + g_frames_seen = false g_level = 0.0 g_level_peak = 0.0 g_phase = Phase.recording @@ -257,26 +291,8 @@ def start_take() { to_log(LOG_INFO, "parrot: recording\n") } -//! 24 kHz to 16 kHz by linear interpolation - Silero's ear, never the clone's -def to_vad_rate(src : array; n : int) : array { - let m = n * VAD_RATE / MIC_RATE - var out : array - out |> resize(m) - let step = float(MIC_RATE) / float(VAD_RATE) - for (i in range(m)) { - let pos = float(i) * step - let j = min(int(pos), n - 1) - let k = min(j + 1, n - 1) - let f = pos - float(j) - out[i] = src[j] * (1.0 - f) + src[k] * f - } - return <- out -} - -[arch(at = "../ARCHITECTURE.md#the-take")] -def drain_take() { - let n = sound_record_read(g_take_scratch) - return if (n <= 0) +//! the frames the device has since delivered, onto the take +def append_frames(n : int) { var ssq = 0.0 let base = length(g_take) g_take |> ensure_capacity(base + n) @@ -289,64 +305,71 @@ def drain_take() { let rms = sqrt(ssq / float(n)) g_level = max(rms, g_level * 0.85) g_level_peak = max(g_level_peak, rms) - var inscope ears <- to_vad_rate(g_take_scratch, n) - vad_iter_feed(g_vad_model, g_vad, ears) $(ev) { - let at24 = ev.sample * int64(MIC_RATE) / int64(VAD_RATE) - if (ev.kind == VadEventKind.speech_start) { - if (!g_speech_seen) { - g_speech_start24 = at24 + g_frames_seen = true + g_last_frames_s = g_elapsed_s +} + +[arch(at = "../ARCHITECTURE.md#the-take")] +def drain_take() { + let n = sound_record_read(g_take_scratch) + if (n > 0) { + append_frames(n) + resample_step(g_ears, g_take, g_ears_out) + vad_iter_feed(g_vad_model, g_vad, g_ears_out) $(ev) { + let at24 = vad_to_mic(ev.sample) + if (ev.kind == VadEventKind.speech_start) { + if (!g_speech_seen) { + g_speech_start24 = at24 + } + g_speech_seen = true + g_in_speech = true + } else { + g_speech_end24 = at24 + g_in_speech = false + g_quiet_since_s = g_elapsed_s } - g_speech_seen = true - g_in_speech = true - } else { - g_speech_end24 = at24 - g_in_speech = false - g_quiet_since_s = g_elapsed_s } } - if (g_speech_seen && !g_in_speech && g_elapsed_s - g_quiet_since_s >= SILENCE_ENDS_TAKE_S) { - stop_take() - } elif (float(length(g_take)) / float(MIC_RATE) >= TAKE_CAP_S) { + let seconds = float(length(g_take)) / float(g_mic_rate) + if (take_ends(g_speech_seen, g_in_speech, g_elapsed_s - g_quiet_since_s, seconds, g_elapsed_s - g_last_frames_s)) { stop_take() } } def stop_take() { return if (g_phase != Phase.recording) + drain_rest() // before the device stops: stopping frees the ring sound_record_stop() - drain_rest() + let dropped = sound_record_overflow_frames() + if (dropped > 0l) { + to_log(LOG_WARNING, "parrot: {dropped} frames were dropped while the frame loop stalled - the take is spliced there\n") + } if (!g_speech_seen) { g_phase = Phase.idle - let seconds = float(length(g_take)) / float(MIC_RATE) - g_status = (g_level_peak < 0.001 - ? "nothing heard - the microphone gave silence for {seconds} s: is it allowed for this page, and on?" - : "nothing heard - the microphone peaked at {g_level_peak} over {seconds} s, too quiet for speech: come closer and talk") + let seconds = float(length(g_take)) / float(g_mic_rate) + g_status = (!g_frames_seen + ? "nothing heard - the microphone gave no audio at all: it was refused, or there is none" + : g_level_peak < 0.001 + ? "nothing heard - the microphone gave silence for {seconds} s: is it allowed for this page, and on?" + : "nothing heard - the microphone peaked at {g_level_peak} over {seconds} s, too quiet for speech: come closer and talk") to_log(LOG_INFO, "parrot: nothing heard - peak {g_level_peak} over {seconds} s\n") return } - let pad = int64(TAKE_PAD_S * float(MIC_RATE)) - let total = long_length(g_take) - let from = max(0l, g_speech_start24 - pad) - let to = min(total, (g_in_speech ? total : g_speech_end24) + pad) + let w = clip_window(g_speech_start24, g_speech_end24, long_length(g_take), g_in_speech) var clip : array - clip |> resize(to - from) - for (i in range64(to - from)) { - clip[i] = g_take[from + i] + clip |> resize(w.to - w.from) + for (i in range64(w.to - w.from)) { + clip[i] = g_take[w.from + i] } - clone_clip(clip, MIC_RATE) + clone_clip(clip, g_mic_rate) } -//! the ring's tail after the device stopped +//! the ring's tail before the device stops def drain_rest() { for (_i in range(8)) { let n = sound_record_read(g_take_scratch) break if (n <= 0) - let base = length(g_take) - g_take |> ensure_capacity(base + n) - g_take |> resize(base + n) - for (i in range(n)) { - g_take[base + i] = g_take_scratch[i] - } + append_frames(n) } } @@ -357,11 +380,9 @@ def clone_clip(var clip : array; rate : int) { g_ask |> push_archive(a) } -// ===== saying ===== - -def say_text() { - let text = strip(join(g_lines, "\n")) - return if (empty(text) || g_phase == Phase.cloning) +//! a say in flight is dropped: its clip stops, its queued clips go unplayed, and the speech thread +//! skips its remaining chunks at the next chunk boundary +def cancel_say() { if (g_playing_sid != INVALID_SID) { stop(g_playing_sid, 0.05) g_playing_sid = INVALID_SID @@ -372,6 +393,18 @@ def say_text() { g_speaking_until = g_elapsed_s g_chunks_owed = 0 g_count_known = false +} + +// ===== saying ===== + +def say_text() { + let text = strip(join(g_lines, "\n")) + return if (empty(text) || g_phase != Phase.idle || !g_ready) // a say while recording would be recorded + if (!g_voice_ready && g_roster == 0) { + g_status = "no voice to read in - record one" + return + } + cancel_say() var a = Ask(kind = 2, text = text, gen = g_says) g_ask |> push_archive(a) g_status = g_voice_ready ? "reading in your voice..." : "reading in the model's own voice - record to hear yours" @@ -380,44 +413,9 @@ def say_text() { // ===== the text box ===== -def type_char(key : uint; shift : bool) { - return if (key >= 128u) - var c = int(key) - if (is_alpha(c)) { - c = shift ? c : c + 32 // GLFW hands the upper-case code - } elif (shift) { - c = c == '1' ? '!' : c == '/' ? '?' : c == ';' ? ':' : c == '\'' ? '"' : c == '9' ? '(' : c == '0' ? ')' : c == '-' ? '_' : c - } - let ok = is_alpha(c) || is_number(c) || c == ' ' || c == ',' || c == '.' || c == '\'' || c == '"' || c == '-' || c == ';' || c == ':' || c == '!' || c == '?' || c == '(' || c == ')' || c == '_' - return if (!ok) - let li = length(g_lines) - 1 - return if (length(g_lines[li]) >= WRAP_CHARS) - g_lines[li] = "{g_lines[li]}{to_char(c)}" -} - -def new_line() { - return if (length(g_lines) >= MAX_LINES) - g_lines |> push("") -} - -def erase_char() { - let li = length(g_lines) - 1 - let n = length(g_lines[li]) - if (n == 0) { - if (li > 0) { - g_lines |> pop() - } - return - } - g_lines[li] = n == 1 ? "" : clone_string(slice(g_lines[li], 0, n - 1)) // never a zero-length view of the string being replaced -} - def set_text(text : string) { delete g_lines - g_lines <- split(text, "\n") - if (empty(g_lines)) { - g_lines |> push("") - } + g_lines <- lines_of(text) } //! down this frame and not the last @@ -447,16 +445,19 @@ def poll_keys() { let shift = shift_down() for (key in range(GLFW_KEY_SPACE, GLFW_KEY_GRAVE_ACCENT + 1)) { if (key_pressed_now(key)) { - type_char(uint(key), shift) + let c = typed_char(key, shift) + if (c >= 0) { + append_char(g_lines, c) + } } } if (key_pressed_now(GLFW_KEY_BACKSPACE)) { - erase_char() + erase_last(g_lines) g_backspace_held_s = 0.0 } elif (glfwGetKey(live_window, GLFW_KEY_BACKSPACE) == GLFW_PRESS) { g_backspace_held_s += get_dt() if (g_backspace_held_s > 0.4) { // held: a repeat every 60 ms after the first 400 - erase_char() + erase_last(g_lines) g_backspace_held_s = 0.34 } } @@ -464,7 +465,7 @@ def poll_keys() { if (ctrl_down()) { say_text() } else { - new_line() + new_line(g_lines) } } if (key_pressed_now(GLFW_KEY_TAB)) { @@ -610,8 +611,7 @@ def poll_mouse() { //! inside the label's box, with a margin around it def hit(b : Button; px, py : float) : bool { - let pad = 8.0 - return px >= b.x + b.box.x - pad && px <= b.x + b.box.z + pad && py >= b.y + b.box.y - pad && py <= b.y + b.box.w + pad + return inside_box(b.box, b.x, b.y, px, py, 8.0) } // ===== the program ===== @@ -625,6 +625,21 @@ def init() { g_args <- r |> move_unwrap set_text(empty(g_args.text) ? POEM : g_args.text) g_status = "press record and talk, then say" + if (!empty(g_args.clip)) { + // decoded before the window and the thread exist, so a clip that is not audio refuses here and the program ends + delete g_pending_clip + g_pending_clip <- load_audio_mono(g_args.clip, MIC_RATE) // cloned once the thread says the file can; the model's rate is 24 kHz + if (empty(g_pending_clip)) { + panic("parrot: {g_args.clip} did not decode") + } + let cap = int(TAKE_CAP_S) * MIC_RATE + if (length(g_pending_clip) > cap) { + to_log(LOG_INFO, "parrot: {g_args.clip} is longer than the {int(TAKE_CAP_S)} s a voice is cloned from - the first {int(TAKE_CAP_S)} s are taken\n") + g_pending_clip |> resize(cap) + } + to_log(LOG_INFO, "parrot: cloning from {g_args.clip}\n") + g_status = "cloning from {g_args.clip}..." + } live_create_window("Parrot", 1280, 720) cache_ttf_objects() g_font = cache_font("{get_das_root()}/modules/dasStbImage/fonts/droidsansmono.ttf") @@ -645,14 +660,6 @@ def init() { var setup = Ask(kind = 0, text = path_join(g_args.models, g_args.tts_model)) g_ask |> push_archive(setup) start_speech_thread(g_ask, g_answer, g_say_now, g_speech_done) - if (!empty(g_args.clip)) { - var clip <- load_audio_mono(g_args.clip, MIC_RATE) - if (empty(clip)) { - panic("parrot: {g_args.clip} did not decode") - } - to_log(LOG_INFO, "parrot: cloning from {g_args.clip}\n") - clone_clip(clip, MIC_RATE) - } } [export] @@ -688,6 +695,7 @@ def shutdown() { if (g_phase == Phase.recording) { sound_record_stop() } + cancel_say() // the thread leaves a say in flight at its next chunk instead of finishing it var stop_ask = Ask(kind = 3) g_ask |> push_archive(stop_ask) g_speech_done |> join() diff --git a/examples/dasLLAMA/parrot/take.das b/examples/dasLLAMA/parrot/take.das new file mode 100644 index 0000000000..cf28806b3c --- /dev/null +++ b/examples/dasLLAMA/parrot/take.das @@ -0,0 +1,116 @@ +options gen2 + +require math +require strings +require daslib/strings_boost + +// The pure side of parrot: what the take does with the numbers the microphone and the voice +// detector hand it, the text box's editing rules, and a button's hit box - none of it touches +// a device, a thread or the screen, so modules/dasLLAMA/tests/test_parrot.das holds it +// without a window or a model. main.das drives the devices and the speech thread. + +let MIC_RATE = 24000 //! the model's own rate: the take is cloned as recorded +let VAD_RATE = 16000 //! Silero listens at 16 kHz; the take is resampled for it alone +let SILENCE_ENDS_TAKE_S = 2.0 //! quiet this long after speech ends the take +let TAKE_CAP_S = 60.0 //! the model's clip cap: the take ends here, and the clip never exceeds it +let TAKE_PAD_S = 0.25 //! kept around the speech at both ends +let NO_FRAMES_ENDS_TAKE_S = 6.0 //! a device that delivers nothing this long ends the take: the microphone was refused, or is not there +let WRAP_CHARS = 74 //! droidsansmono is monospace: characters are the wrap unit +let MAX_LINES = 24 + +//! 24 kHz to 16 kHz by linear interpolation over the whole take: the next position to sample +//! carries across drains, so the stream Silero hears has no seam where the drains met +struct VadResampler { + pos : double //! the next 24 kHz position to sample +} + +//! the 16 kHz samples the take has grown enough to yield since the last call +def resample_step(var r : VadResampler; take : array; var out : array) { + out |> clear() + let n = long_length(take) + let step = double(MIC_RATE) / double(VAD_RATE) + while (int64(r.pos) + 1l < n) { + let j = int64(r.pos) + let f = float(r.pos - double(j)) + out |> push(take[j] * (1.0 - f) + take[j + 1l] * f) + r.pos += step + } +} + +//! a 16 kHz sample index of the detector as a 24 kHz index into the take +def vad_to_mic(sample16 : int64) : int64 { + return sample16 * int64(MIC_RATE) / int64(VAD_RATE) +} + +//! whether the take ends now: speech was heard and the quiet after it has lasted, the cap, or a +//! device that gave nothing for long enough to be gone +def take_ends(speech_seen, in_speech : bool; quiet_for_s, seconds, silent_device_for_s : float) : bool { + return (speech_seen && !in_speech && quiet_for_s >= SILENCE_ENDS_TAKE_S) || seconds >= TAKE_CAP_S || silent_device_for_s >= NO_FRAMES_ENDS_TAKE_S +} + +//! the clip's window in the take: the speech plus a pad at both ends, an open speech running to +//! the take's end, and never longer than the model's clip cap +def clip_window(start24, end24, total : int64; in_speech : bool) : tuple { + let pad = int64(TAKE_PAD_S * float(MIC_RATE)) + let cap = int64(TAKE_CAP_S) * int64(MIC_RATE) + let from = clamp(start24 - pad, 0l, total) + var to = min(total, (in_speech ? total : end24) + pad) + to = clamp(to, from, from + cap) + return (from = from, to = to) +} + +//! the character a key lands in the box as: letters follow shift, the list's punctuation and the +//! shifted digits that spell it, spaces; -1 for a key the box does not take +def typed_char(key : int; shift : bool) : int { + return -1 if (key < 0 || key >= 128) + var c = key + if (is_alpha(c)) { + c = shift ? c : c + 32 // GLFW hands the upper-case code + } elif (shift) { + c = c == '1' ? '!' : c == '/' ? '?' : c == ';' ? ':' : c == '\'' ? '"' : c == '9' ? '(' : c == '0' ? ')' : c == '-' ? '_' : c + } + let ok = is_alpha(c) || is_number(c) || c == ' ' || c == ',' || c == '.' || c == '\'' || c == '"' || c == '-' || c == ';' || c == ':' || c == '!' || c == '?' || c == '(' || c == ')' || c == '_' + return ok ? c : -1 +} + +//! a character onto the last line, unless that line is full +def append_char(var lines : array; c : int) { + return if (empty(lines)) + let li = length(lines) - 1 + return if (length(lines[li]) >= WRAP_CHARS) + lines[li] = "{lines[li]}{to_char(c)}" +} + +//! a new line after the last, unless the box is full +def new_line(var lines : array) { + return if (length(lines) >= MAX_LINES) + lines |> push("") +} + +//! the last character off the last line; an empty last line goes away unless it is the only one +def erase_last(var lines : array) { + return if (empty(lines)) + let li = length(lines) - 1 + let n = length(lines[li]) + if (n == 0) { + if (li > 0) { + lines |> pop() + } + return + } + lines[li] = n == 1 ? "" : clone_string(slice(lines[li], 0, n - 1)) // never a zero-length view of the string being replaced +} + +//! the box's lines from a text, one per line break, never empty +def lines_of(text : string) : array { + var lines <- split(text, "\n") + if (empty(lines)) { + lines |> push("") + } + return <- lines +} + +//! inside a label's box (left, top, right, bottom relative to the pen at x, y), with a margin around it +def inside_box(box : float4; x, y, px, py, pad : float) : bool { + return px >= x + box.x - pad && px <= x + box.z + pad && py >= y + box.y - pad && py <= y + box.w + pad +} diff --git a/examples/dasLLAMA/wasm/mint_models.py b/examples/dasLLAMA/wasm/mint_models.py index 9ee1c66c3e..70768748b3 100644 --- a/examples/dasLLAMA/wasm/mint_models.py +++ b/examples/dasLLAMA/wasm/mint_models.py @@ -77,6 +77,11 @@ def main(): spec = json.load(f) os.makedirs(a.out, exist_ok=True) files, versions = [], set() + for key in ("images", "packs", "files", "tree"): + if key in spec and not spec[key]: + raise SystemExit(f"models.json names an empty `{key}` list - drop the key or fill it; a set that mints nothing must say so by omission") + if not any(spec.get(key) for key in ("images", "packs", "files", "tree")): + raise SystemExit("models.json stages nothing") for entry in spec.get("images", []): gguf = fetch(entry, a.cache) diff --git a/examples/dasLLAMA/wasm/test_mint_models.py b/examples/dasLLAMA/wasm/test_mint_models.py index 30954b1fc2..c8ce1e90a7 100644 --- a/examples/dasLLAMA/wasm/test_mint_models.py +++ b/examples/dasLLAMA/wasm/test_mint_models.py @@ -169,5 +169,90 @@ def test_a_converter_that_fails_is_reported_with_its_exit(self): self.assertIn("boom", err) +class ServedAsIsTest(unittest.TestCase): + """the lists beside `images`: `files` (a GGUF that is its own served form) and `tree` (a file + the repository carries, named by its repo-relative path) - copied as they are, hash-held""" + + def with_repo(self, fx, spec, tree_bytes=None): + """the spec written, and a stand-in repository root four levels above a fake script path, + the way the script finds the checkout; `tree_bytes` lands at models/vad.bin under it""" + fx.spec = spec + (fx.example / "models.json").write_text(json.dumps(spec)) + repo = fx.root / "repo" + (repo / "models").mkdir(parents=True) + if tree_bytes is not None: + (repo / "models" / "vad.bin").write_bytes(tree_bytes) + return str(repo / "examples" / "dasLLAMA" / "wasm" / "mint_models.py") + + def run_with_file(self, fake_file, argv): + saved = mint_models.__file__ + mint_models.__file__ = fake_file + try: + return run_main(argv) + finally: + mint_models.__file__ = saved + + def test_a_files_entry_is_copied_as_it_is_beside_the_image(self): + with tempfile.TemporaryDirectory() as tmp: + fx = Fixture(tmp, image_version=35) + served = b"a Pocket file, served as it is" + (fx.cache / f"{sha(served)}-pocket.gguf").write_bytes(served) + spec = dict(fx.spec, files=[{"file": "pocket.gguf", "repo": "someone/tts", "sha256": sha(served)}]) + fake_file = self.with_repo(fx, spec) + code, out, err = self.run_with_file(fake_file, fx.argv("--expect-image-version", "35")) + self.assertIsNone(code, err) + manifest = json.loads((fx.out / "manifest.json").read_text()) + self.assertEqual([f["name"] for f in manifest["files"]], ["story.dlim", "pack.bin", "pocket.gguf"]) + self.assertEqual((fx.out / "pocket.gguf").read_bytes(), served) + self.assertEqual(manifest["files"][2]["source"], "someone/tts/pocket.gguf") + + def test_a_tree_entry_is_copied_from_the_checkout_and_named_by_its_path(self): + with tempfile.TemporaryDirectory() as tmp: + fx = Fixture(tmp, image_version=35) + vad = b"voice activity weights" + spec = dict(fx.spec, tree=[{"file": "vad.bin", "path": "models/vad.bin", "sha256": sha(vad)}]) + fake_file = self.with_repo(fx, spec, vad) + code, out, err = self.run_with_file(fake_file, fx.argv("--expect-image-version", "35")) + self.assertIsNone(code, err) + manifest = json.loads((fx.out / "manifest.json").read_text()) + self.assertEqual((fx.out / "vad.bin").read_bytes(), vad) + self.assertEqual(manifest["files"][-1], {"name": "vad.bin", "bytes": len(vad), "sha256": sha(vad), "source": "models/vad.bin"}) + + def test_a_tree_file_whose_hash_moved_stops_the_run(self): + with tempfile.TemporaryDirectory() as tmp: + fx = Fixture(tmp, image_version=35) + spec = dict(fx.spec, tree=[{"file": "vad.bin", "path": "models/vad.bin", "sha256": sha(b"the weights the page was built for")}]) + fake_file = self.with_repo(fx, spec, b"other weights checked in since") + code, out, err = self.run_with_file(fake_file, fx.argv()) + self.assertIsInstance(code, str) + self.assertIn("vad.bin: sha256", code) + self.assertIn("the tree file changed", code) + self.assertFalse((fx.out / "manifest.json").exists()) + + def test_a_set_with_no_image_carries_the_version_the_deploy_expects(self): + with tempfile.TemporaryDirectory() as tmp: + fx = Fixture(tmp) + served = b"a Pocket file" + (fx.cache / f"{sha(served)}-pocket.gguf").write_bytes(served) + spec = {"files": [{"file": "pocket.gguf", "repo": "someone/tts", "sha256": sha(served)}]} + fake_file = self.with_repo(fx, spec) + code, out, err = self.run_with_file(fake_file, fx.argv("--expect-image-version", "41", "--stamp-page", str(fx.page))) + self.assertIsNone(code, err) + self.assertEqual(json.loads((fx.out / "manifest.json").read_text())["image_version"], 41) + self.assertIn("/* @image-version */ 41", fx.page.read_text()) + self.assertFalse((fx.out / "story.dlim").exists(), "nothing was minted") + + def test_a_set_with_no_image_and_no_expected_version_is_unstamped(self): + with tempfile.TemporaryDirectory() as tmp: + fx = Fixture(tmp) + served = b"a Pocket file" + (fx.cache / f"{sha(served)}-pocket.gguf").write_bytes(served) + fake_file = self.with_repo(fx, {"files": [{"file": "pocket.gguf", "repo": "someone/tts", "sha256": sha(served)}]}) + code, out, err = self.run_with_file(fake_file, fx.argv("--stamp-page", str(fx.page))) + self.assertIsNone(code, err) + self.assertEqual(json.loads((fx.out / "manifest.json").read_text())["image_version"], 0) + self.assertIn("/* @image-version */ 0", fx.page.read_text(), "0 is the unstamped slot the page reads as no check") + + if __name__ == "__main__": unittest.main() diff --git a/modules/dasLLAMA/ARCHITECTURE_POCKET.md b/modules/dasLLAMA/ARCHITECTURE_POCKET.md index c56478d580..cf3cc9890d 100644 --- a/modules/dasLLAMA/ARCHITECTURE_POCKET.md +++ b/modules/dasLLAMA/ARCHITECTURE_POCKET.md @@ -14,9 +14,11 @@ The TTS block home, facade and phoneme families are `ARCHITECTURE_TTS.md`. assembly. The weight map of the converted GGUF (`harness/convert_pocket.py`: the canonical tensor names `backbone.N.*`, `head.*`, `mimi.enc_tf.N.*` / `mimi.dec_tf.N.*`, the rest as the bundle names them; the `pocket.*` scalars from the package's per-language config; the - unigram tokenizer under `tokenizer.ggml.model = "t5"`; the roster's clips as `voice.` - PCM tensors), the model (`PocketModel`: the causal backbone, the one-step flow head, the - Mimi-derived codec, the roster and its encoded voice states), the activation carrier + unigram tokenizer under `tokenizer.ggml.model = "t5"`; the roster as `voice_latents.` + latent frames - an older file carries `voice.` PCM instead, encoded on first use; + `pocket.cloning` says whether the codec encoder is inside), the model (`PocketModel`: the + causal backbone, the one-step flow head, the Mimi-derived codec with or without its encoder, + the roster and its voice states built from the stored frames), the activation carrier (`PocketScratch`), and the assembly - the voice prompt (sec.2.47), the text prompt, the frame loop (sec.2.48), the codec decoder over a chunk's latents (sec.2.46) - plus the reference driver's text preparation and chunker (sec.2.49). `pocket_speak` is the facade's entry; the @@ -96,18 +98,21 @@ another language takes the text as it is, since the normalizer reads English. ### 2.50 The published file carries the served quants {#pocket-q8-file} -A file has three lanes and its formats decide which it can take. A K-quant tensor -(`convert_pocket.py --kq`: the backbone's and the codec transformers' matrices and the text -embedding as Q4_K, the flow head as Q8_0, the rest as the q8 form writes it) serves as its own -planes unless a lane is pinned - `TtsLinear` holds the plane pair the GGUF transcoder wrote, -repacked where the backend carries kq kernels, and the frame loop's GEMV and the prompt's GEMM -take the engine's own K-quant entries (`linear_rows_decode`, `linear_rows_kq`), the rows -requantized to the Q8_K form the way the engine's own decode does. Pinned q8 or f32, the same -tensor dequantizes into that lane, so one file serves every lane and the rig compares them on -the same sentences. A vector layer the file stores Q8_0 (the head) runs its GEMV on the q8 lane. - -Two lanes, as the StyleTTS2 families have: f32, the parity rail's reference, and q8, the -served default - the transformer layers' four matrices, the frame input projection and every +A file has three lanes and its formats decide which it can take. A K-quant dense layer +(`convert_pocket.py --kq`: the backbone's and the codec transformers' matrices as Q4_K, the flow +head as Q8_0, the rest as the q8 form writes it) serves as its own planes unless a lane is +pinned - `TtsLinear` holds the plane pair the GGUF transcoder wrote, repacked where the backend +the load selected carries kq kernels (the load line says which arm), and the frame loop's GEMV +and the prompt's GEMM take the engine's own K-quant entries (`linear_rows_decode`, +`linear_rows_kq`), the rows requantized to the Q8_K form the way the engine's own decode does. +The text embedding table is Q4_K on disk only: it is a lookup, and dequantizes at load on every +lane. Pinned q8 or f32, a K-quant tensor dequantizes into that lane, so one file serves every +lane and the rig compares them on the same sentences. A vector layer the file stores Q8_0 (the +head) runs its GEMV on the q8 lane. + +The two lanes every Pocket file has, as the StyleTTS2 families have them: f32, the parity +rail's reference, and q8, the served default - the transformer layers' four matrices, the frame +input projection and every dense stride-1 codec conv on 32-wide channels as Q8_0 rows (`linear_prepare`, `conv1d_q8_eligible`), the decode step on the q8 GEMV entry. The published GGUF (`convert_pocket.py --q8`) stores exactly those tensors as Q8_0 in the layout the kernels read diff --git a/modules/dasLLAMA/ARCHITECTURE_TTS.md b/modules/dasLLAMA/ARCHITECTURE_TTS.md index 679fea547c..139c67ebe0 100644 --- a/modules/dasLLAMA/ARCHITECTURE_TTS.md +++ b/modules/dasLLAMA/ARCHITECTURE_TTS.md @@ -43,14 +43,13 @@ TTS files implement (sec.2.28-2.35, 2.43). `ARCHITECTURE_COMMON.md` (repo root) and takes no rewrite, which matters because the rewrite is not the identity on one: it reads the DRESS vowel before a linking rhotic as SQUARE, having nothing in the string to tell merry from Mary. A vowel the two lexicons give no evidence for before a dropped rhotic keeps that - rhotic rather than losing it. The bath-trap split reaches only lexicon - words. Loads a phoneme pack - `tts_g2p.bin` (both dialect tiers) or `tts_g2p_en_us.bin` (the - American tier alone, sec.2.43) - pack - version 2 (`harness/build_g2p_data.py`: the gold tier extended by + rhotic rather than losing it. The bath-trap split reaches only lexicon words. Loads a phoneme + pack - `tts_g2p.bin` (both dialect tiers) or `tts_g2p_en_us.bin` (the American tier alone, + sec.2.43) - pack version 2 (`harness/build_g2p_data.py`: the gold tier extended by `harness/g2p_local_additions.json`, the US and GB keys merged into one string table per tier, the GRU stored as f16, CMUdict pruned of the words both dialects' lexicons carry - - safe because the fallback reads the lexicon first), - searched in place as byte-sorted string tables; a version 1 pack is refused by name. The + safe because the fallback reads the lexicon first), searched in place as byte-sorted string + tables; a version 1 pack is refused by name. The 200-sentence fixtures under `tests/_tts_fixtures/` (American, minted by `harness/mint_tts_g2p_fixture.py` from the G2P fidelity experiment; British, minted by `harness/mint_tts_g2p_gb_fixture.py` from the reference's own British front end) are the @@ -74,7 +73,8 @@ TTS files implement (sec.2.28-2.35, 2.43). `ARCHITECTURE_COMMON.md` (repo root) squared; a cache grows with its fill kept), rope over rows, per-channel layer scale and the replicate left pad. A weight is an ONNX-layout array plus the served layout `conv1d_prepare` / `linear_prepare` mint for the consumer the reader names (`served_rows`, - `rows_only`, `vec_only`), the unread one dropped; beside every weight array sits its `TtsSpan` into the + `rows_only`, `vec_only`), the unread one dropped - or the file's own K-quant planes + (`linear_take_kq`), the kq lane beside f32 and q8; beside every weight array sits its `TtsSpan` into the model's blob, and `weights_walk` is the one walk that moves weights into a staging blob or binds them as borrowed views over a served plane (`release_weight` is the one teardown). One home: the block home holds the operators, and it names no family type. diff --git a/modules/dasLLAMA/PERF_LEDGER.md b/modules/dasLLAMA/PERF_LEDGER.md index 01c6973e34..d5f8fa930f 100644 --- a/modules/dasLLAMA/PERF_LEDGER.md +++ b/modules/dasLLAMA/PERF_LEDGER.md @@ -1351,3 +1351,24 @@ commits: direction-grade. grows `mtp_cat` to 2 x 9 x dim floats (Qwen3.8-27B: 370 KB) and `mtp_logits_b` to 9 x vocab floats once (about 5 MB). Decision: taken - the round's gain rides on the drafter's presence, and the sidecar is a fraction of a percent of the target it drafts for. + +### From the Pocket small form (2026-09-10) + +Instruments: `harness/tts_synth.das` (`-jit -module-cache`, alba, the first 60 sentences of the +rig corpus, M1 Max, the box's tune profile, JIT cache warm) with the process's resident set read +through `ps -o rss` every half second, two reps per file; `harness/tts_rig.py` (alba, the 200 +sentences, parakeet WER + UTMOS) per weight lane. Every pair here is two processes on one box: +direction-grade. + +- **The English file, the q8 form against the small form (`pocket-tts-en-q8.gguf` 152 MB, + `pocket-tts-en-kq.gguf` 75 MB; the one-voice `pocket-tts-en-stuart-kq.gguf` 65 MB):** the + compiled program before the load reads 1.87 GB on both; the load adds 0.66 GB on the q8 file + and 0.50 on the kq file; over the synthesis the q8 process holds 2.54 GB and the kq process + 2.70 - the kq lane steps up 0.33 GB at its first synthesis and stays there, the q8 lane does not + (reps within 2 MB). RTF over the 60 sentences: q8 0.0510 / 0.0507, kq 0.0435 / 0.0434 - the + small file decodes 15% faster and its process is 6% larger. The step is followup 129's. +- **The kq file's three lanes on the rig (the q8 file 3.91 / 4.328, its f32 lane through the f16 + file 4.32 / 4.366):** native 3.86 / 4.295, the q8 pin 3.73 / 4.295, the f32 pin 3.86 / 4.330. + The lanes agree within the rig's own spread; the small form loses nothing the rig can hear. + Decision: taken - the browser pages read the small forms (storywish the one-voice file, parrot + the 19-voice one), the q8 file stays the desktop default of the served set. diff --git a/modules/dasLLAMA/REVIEW.das b/modules/dasLLAMA/REVIEW.das index 5ab009a70e..f6cde0d661 100644 --- a/modules/dasLLAMA/REVIEW.das +++ b/modules/dasLLAMA/REVIEW.das @@ -545,7 +545,7 @@ let private IMAGE_FILE = "modules/dasLLAMA/dasllama/dasllama_image.das" // in file order. A closure change with IMAGE_VERSION unmoved is red; the finding prints the // value to re-stamp with. let private IMAGE_LAYOUT_STAMP_VERSION = 36 -let private IMAGE_LAYOUT_STAMP_HASH = 0x7665c61f19ad379ful +let private IMAGE_LAYOUT_STAMP_HASH = 0xbadc54ac04cbcad1ul // The helpers that decide WHERE bytes land: the page pad, the plane and total sizing, the // writer's append / zero-fill / header patch, and the header's scalar stores. Changing one diff --git a/modules/dasLLAMA/REVIEW.md b/modules/dasLLAMA/REVIEW.md index 1bbd82f5ff..8be9cb15e8 100644 --- a/modules/dasLLAMA/REVIEW.md +++ b/modules/dasLLAMA/REVIEW.md @@ -1,10 +1,11 @@ # dasLLAMA Code Review Checklist **Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture -docs: `ARCHITECTURE.md`, `ARCHITECTURE_ENGINE.md`, `ARCHITECTURE_RUNTIME.md`, `ARCHITECTURE_MEASUREMENT.md` -(the other companions belong to the routed checklists). Planned work: `followup_general.md`, -`followup_vulkan.md`, `followup_metal.md` (the Metal tier, and CPU work measured on macOS), -`PERF_LEDGER.md` (performance goes to the perf ledger, everything else to the followup ledgers). +docs: `ARCHITECTURE.md`, `ARCHITECTURE_ENGINE.md`, `ARCHITECTURE_RUNTIME.md`, +`ARCHITECTURE_MEASUREMENT.md` (the other companions belong to the routed checklists). Planned +work: `followup_general.md`, `followup_vulkan.md`, `followup_metal.md` (the Metal tier, and CPU +work measured on macOS), `PERF_LEDGER.md` (performance goes to the perf ledger, everything else +to the followup ledgers). **A dasLLAMA `[test]` file, wherever the diff puts it, answers to this module's `tests/REVIEW.md`.** @@ -78,8 +79,9 @@ function does not thereby pick up the other modality's checklist. file - one stage of the pass that turns text into phonemes (`dasllama/dasllama_textnorm.das`, `dasllama/dasllama_postag.das`, `dasllama/dasllama_g2p.das`) - the front-end packs' mint (`harness/build_g2p_data.py`, `harness/train_postag.py`, `harness/mint_postag_silver.py`, -`performance/build_tts_data.das`), or a call that pins the TTS weight lane (`set_tts_q8` / -`set_styletts2_q8`), wherever the diff puts it, applies `REVIEW_TTS.md`.** +`performance/build_tts_data.das`), the Pocket converter and its card (`harness/convert_pocket.py`, +`harness/tts_model_card.md`), or a call that pins a TTS weight lane (`set_tts_q8` / +`set_styletts2_q8` / `set_pocket_q8`), wherever the diff puts it, applies `REVIEW_TTS.md`.** **A diff that adds a file under `dasllama/`, or adds or moves a def, a `require`, or a module global in a file under `dasllama/`, applies `REVIEW_PLACEMENT.md`** - the what-lands-where rules. @@ -230,8 +232,7 @@ check licenses no names, the line says so. **Checked-in text under `modules/dasLLAMA/` - docs, comments, or string data, any language - that describes a mechanism of the reference build, or names that build, its binaries or its symbols, wherever the diff puts it, applies `REVIEW_UPSTREAM.md`.** The reference build is the -third-party engine this module measures itself against - the checkout -`benchmarks/setup_lcpp_ref.das` pins. +third-party engine this module measures itself against - `benchmarks/setup_lcpp_ref.das` pins it. **A diff that changes what authoring a new weight format entails - a step added or dropped, a file the author must touch, a fixture or probe entry the format must supply, or a gate it must @@ -239,10 +240,9 @@ pass - updates `HOW_TO_ADD_A_FORMAT.md` in the same change.** The how-to is the author's whole brief: a step dropped there is a step the next format silently skips. **Legal attribution - a third party's copyright line, licence name, or licence text - lives in -`THIRD_PARTY_NOTICES.md`, in the `LICENSE.*` files, in a model card - the provenance-and-licence -page published beside a released model or pack - or in a ledger row naming a licence as a -reason to adopt or reject a model, a dataset, or a dependency; anywhere else in prose it is a -defect.** +`THIRD_PARTY_NOTICES.md`, the `LICENSE.*` files, a model card (the provenance-and-licence page +published beside a released model or pack), or a ledger row naming a licence as a reason to +adopt or reject a model, a dataset, or a dependency; anywhere else in prose it is a defect.** **A def of a facade file - one whose defs reach a consumer through `require dasllama/dasllama`; `REVIEW.das`'s `FACADE_FILES` is the list - and a new OVERLOAD of one, is TAUGHT: demonstrated @@ -262,12 +262,12 @@ the renderer emits but the registry does not is caught by `tests/test_env_regist **Hand-editing `dasllama/dasllama_unicode.das`'s RANGES/WS tables is a defect - regenerate them by retranscoding `$LCPP/src/unicode-data.cpp` (the reference checkout) instead.** -**A diff that adds a file under `dasllama/`, moves a def, a `require`, or a module global -between files there, or changes what a file owns, lands the sec.1 edit that keeps the -charters true - in an `ARCHITECTURE_*.md` companion, never `ARCHITECTURE.md` - in the same -change.** A diff that adds a file to any folder where another file has its own -sec.1 charter line lands the new file's charter line too. A module-root doc file - a ledger, a -plan - has no charter line and needs no charter edit. +**A diff that adds a file under `dasllama/`, adds or moves a def, a `require`, or a module +global in a file there, or gives a file a weight format, a serving lane (the quant form a +tensor serves from) or a data structure its charter does not name, lands the sec.1 edit that +keeps the charters true - in an `ARCHITECTURE_*.md` companion, never `ARCHITECTURE.md` - in the +same change.** A diff that adds a file to any folder where another file has its own sec.1 +charter line lands the new file's charter line too; a module-root ledger has no charter line. **A diff that adds, removes, or moves a section of an `ARCHITECTURE_*.md` companion, or adds or removes a companion, lands `ARCHITECTURE.md`'s index line and section range, the diff --git a/modules/dasLLAMA/REVIEW_PLACEMENT.md b/modules/dasLLAMA/REVIEW_PLACEMENT.md index af0417ff4b..11dd3652da 100644 --- a/modules/dasLLAMA/REVIEW_PLACEMENT.md +++ b/modules/dasLLAMA/REVIEW_PLACEMENT.md @@ -1,7 +1,8 @@ # dasLLAMA Code Review Checklist - placement **Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture -docs: the `ARCHITECTURE_*.md` set beside this file - sec.1 in each is the per-file charters. +doc: `ARCHITECTURE.md`, the index of the `ARCHITECTURE_*.md` set beside this file - sec.1 in each +companion is the per-file charters. Planned work: `followup_general.md`. **Routed from `REVIEW.md`: a diff that checklist routes here applies this list together with it.** @@ -40,13 +41,16 @@ a template declared elsewhere is not a kernel body: it compiles its own PSO wher file, never sideways into a sibling.** **A piece two files both execute lands in their nearest shared file (its own file when none -exists) - never a second copy.** A predicate, a constant, or a helper spelled once in each of -two files drifts on the first edit to one copy; an enum-and-int twin of one predicate inside one -file is the tier's idiom, not a copy, and a test's CPU oracle that restates the arithmetic is a -witness, not a copy. A piece two folders outside each other both need lands in the folder that -owns the concern; one landing under `dasllama/` that code outside `modules/dasLLAMA/` drives -lands as a public entry module - one `dasllama/dasllama_lint.das` licenses a consumer to -require directly. +exists) - never a second copy: two spellings that can drift apart on the first edit to one.** An +enum-and-int twin of one predicate inside one file is the tier's idiom, and a test's CPU oracle +that restates the arithmetic is a witness - neither is a copy. + +**A caller never re-checks a guard its callee checks - drop the caller's copy and let the +callee's check stand.** + +**A piece two folders outside each other both need lands in the folder that owns the concern; +one landing under `dasllama/` that code outside `modules/dasLLAMA/` drives lands as a public +entry module** - one `dasllama/dasllama_lint.das` licenses a consumer to require directly. **A family gaining an arm for a media kind adds that kind's span markers to that family's chat template, never to a second renderer.** Span markers are the template text that opens and @@ -87,9 +91,8 @@ module to require. A program root (test, harness, benchmark, tool) requires the module it needs directly. **A `dasllama/` module whose `[init]` registers a hook the engine dispatches through gets its -side-effect require in the same change that adds it - in `dasllama/dasllama_transformer.das`, -or in `dasllama/dasllama_common.das` where the rule above seats it there** - a registration -neither file reaches never fires for a consumer of the `dasllama.das` facade. +side-effect require in the same change that adds it** - a registration no engine file reaches +never fires for a consumer of the `dasllama.das` facade; where it lands is the rule above's. **An architecture file (`dasllama/dasllama_arch_*.das`) that changes a forward loop, or tests a family name on a shared path, is a defect - it carries declarative registration only.** diff --git a/modules/dasLLAMA/REVIEW_TTS.md b/modules/dasLLAMA/REVIEW_TTS.md index e4e66570d3..e0863c9d31 100644 --- a/modules/dasLLAMA/REVIEW_TTS.md +++ b/modules/dasLLAMA/REVIEW_TTS.md @@ -7,17 +7,19 @@ docs: `ARCHITECTURE_TTS.md`, `ARCHITECTURE_POCKET.md`. Planned work: `followup_g `REVIEW.md`.** **A family's synthesis entry point (`styletts2_synthesize`, `pocket_synthesize`) carries -`[hot_path]`, and every model stage it drives - a rows kernel in -`dasllama/dasllama_tts_blocks.das`, the assembly in a family file (`dasllama/dasllama_styletts2.das`, -`dasllama/dasllama_pocket.das`), never the text front end - sizes every buffer through a -`@scratch` carrier so the annotation holds through it.** +`[hot_path]`.** -**A buffer reused across syntheses in a file this checklist routes that is not `@scratch` - -on its declaration, or on the callee parameter it grows through - is a defect.** A `nolint` -where the annotation fits is a defect. +**A text front-end stage (`dasllama/dasllama_textnorm.das`, `dasllama/dasllama_postag.das`, +`dasllama/dasllama_g2p.das`) called below a family's synthesis entry point is a defect - +phonemize before the entry point.** + +**A buffer reused across syntheses, or filled at load for syntheses to reuse, in a file this +checklist routes that is not `@scratch` - on its declaration, or on the callee parameter it +grows through - is a defect.** The annotation is what lets `[hot_path]` hold through every stage +the entry point drives. **A function that exists for debugging or profiling, in a file this checklist routes, that is -not `[cold_path]` is a defect.** A `nolint` where `[cold_path]` fits is a defect. +not `[cold_path]` is a defect.** **A GEMM in `dasllama/dasllama_styletts2.das` or a TTS family file that does not go through a kernel `dasllama/dasllama_tts_blocks.das` exports is a defect, hand-written dot-product @@ -33,25 +35,25 @@ checked against. is a defect.** How a rows kernel stays split-invariant is the "Two layouts, one oracle" section of `ARCHITECTURE_TTS.md`. -**A new rows kernel that dispatches its rows (`maybe_parallel_for` / `lanes_for_work`) ships -its `tests/test_tts_blocks.das` bit-equality cell on both axes that move the split - the batch -lane cap and the jobque worker limit - in the same change.** - -**A new serial rows kernel ships a `tests/test_tts_blocks.das` value cell in the same change, -against the leaf it applies per row or a double-precision form of its arithmetic.** +**A new arithmetic path in `dasllama/dasllama_tts_blocks.das` - a kernel, a weight lane of one, +a layout - ships a `tests/test_tts_blocks.das` numeric cell in the same change, against the leaf +it applies per row or a double-precision form of its arithmetic; a path whose rows split across +workers, wherever the split happens - its own `maybe_parallel_for` / `lanes_for_work`, or a +backend kernel it hands a row block to - also ships the bit-equality cell on both axes that move +the split, the batch lane cap and the jobque worker limit.** **A `read_*` call in `dasllama/dasllama_styletts2.das` that leaves a conv or linear on the channel-major default while the forward assembly runs it through a rows kernel is a defect - pass the consumer (`rows`, `rows_only`) so `conv1d_prepare` / `linear_prepare` drop the layout nobody reads.** -**A caller that pins the TTS weight lane (`set_tts_q8` / `set_styletts2_q8`) around a load -resets it (`reset_tts_q8` / `reset_styletts2_q8`) before returning, on every path out, panics -included - pin through `defer()` - and pins in the context that loads: a `new_thread` context -starts every module global at its declared default, so a worker that wants a lane pins where -it loads, never through the context that spawned it.** A pin that outlives its load silently -changes the lane of the next model loaded in the process; a pin set in another context never -arrives. +**A caller that pins a TTS weight lane (`set_tts_q8` / `set_styletts2_q8` / `set_pocket_q8`) +around a load resets it (`reset_tts_q8` / `reset_styletts2_q8` / `reset_pocket_q8`) before +returning, on every path out, panics included - pin through `defer()` - and pins in the context +that loads: a `new_thread` context starts every module global at its declared default, so a +worker that wants a lane pins where it loads, never through the context that spawned it.** A pin +that outlives its load silently changes the lane of the next model loaded in the process; a pin +set in another context never arrives. **A diff that reorders the float operations of `sine_source` or `source_resize` (`dasllama/dasllama_tts_blocks.das`), or changes the rounding of any step in the phase they @@ -76,8 +78,9 @@ field reads back zero from a mapped image. moves a phoneme of the rig corpus (the corpus-identity cell in `tests/test_tts_g2p.das` decides; an unmoved corpus pins the audio bit for bit), ships the WER and UTMOS of `harness/tts_rig.py`, before and after, on every model the change reaches, on every weight -lane that model serves (`--q8`, `--f32`), at the rig's voice, in the PR body.** A lane's -per-frame figures against the f32 oracle say nothing about the speech; only the rig does. +lane that model can take - the unpinned default and each pin - at the rig's voice, in the PR +body.** A lane's per-frame figures against the f32 oracle say nothing about the speech; only +the rig does. **A text normalization or grapheme-to-phoneme error `harness/tts_rig.py`'s transcripts expose lands as a failing-first case in `tests/test_tts_textnorm.das` or @@ -94,8 +97,9 @@ them conv state, a symmetric pad, or a trim of the output by hand is a defect.** a chunk in one shot (`ARCHITECTURE_POCKET.md`, "The codec runs a chunk in one shot"); `harness/pocket_oracle.py` checks the one-shot decode against the package's frame-by-frame output. -**A change to which Pocket tensors the published file stores as Q8_0, or to their layout -(`q8_linear` / `q8_conv` in `harness/convert_pocket.py`, `read_linear` / `read_conv_q8` in -`dasllama/dasllama_pocket.das`), ships both sides in the same diff, and weakening -`test_pocket_q8_file` in `tests/test_tts_pocket.das` is a defect** - the reader's eligibility -rule and the converter's are the same rule written twice. +**A change to which quant format a published file stores a Pocket tensor in, or to its layout +(`q8_linear` / `q8_conv` / `kq_tensor` / `head_q8_linear` in `harness/convert_pocket.py`, +`read_linear` / `read_conv_q8` and the K-quant branch in `dasllama/dasllama_pocket.das`), ships +both sides in the same diff, and weakening `test_pocket_q8_file` or `test_pocket_kq_file` in +`tests/test_tts_pocket.das` is a defect** - the reader's eligibility rule and the converter's are +the same rule written twice. diff --git a/modules/dasLLAMA/REVIEW_UPSTREAM.md b/modules/dasLLAMA/REVIEW_UPSTREAM.md index 347ec753b1..9bfe21e0fc 100644 --- a/modules/dasLLAMA/REVIEW_UPSTREAM.md +++ b/modules/dasLLAMA/REVIEW_UPSTREAM.md @@ -1,4 +1,4 @@ -# dasLLAMA Upstream-Naming Code Review Checklist +# dasLLAMA Reference-Build Naming Code Review Checklist **Read `REVIEW_COMMON.md` (repo root) first - its contract binds this checklist.** Architecture doc: `ARCHITECTURE_MEASUREMENT.md`. Planned work: `followup_general.md`, `followup_vulkan.md`, @@ -8,9 +8,10 @@ doc: `ARCHITECTURE_MEASUREMENT.md`. Planned work: `followup_general.md`, `follow it.** The reference build is the third-party engine this module measures itself against - the checkout `benchmarks/setup_lcpp_ref.das` pins. Reference-build work is locating a site in that build, patching it, running it, regenerating from it, or measuring against it - planned or -performed. The text this list binds is checked-in text under `modules/dasLLAMA/`; a document -anywhere else in the tree that carries a reference-build name is bound by these same rules, -routed here by the checklist covering its own folder. +performed; a library the build ships is part of it, and running that library to mint our own +artifact is reference-build work. The text this list binds is checked-in text under +`modules/dasLLAMA/`; a document anywhere else in the tree that carries a reference-build name is +bound by these same rules, routed here by the checklist covering its own folder. **A sentence whose job is not reference-build work describes an upstream mechanism in our own terms: no "lifted/ported verbatim from", and no name belonging to the reference build - symbol, diff --git a/modules/dasLLAMA/dasllama/dasllama_gguf.das b/modules/dasLLAMA/dasllama/dasllama_gguf.das index 2222aa4dc4..22e6f3188f 100644 --- a/modules/dasLLAMA/dasllama/dasllama_gguf.das +++ b/modules/dasLLAMA/dasllama/dasllama_gguf.das @@ -1547,8 +1547,8 @@ def gguf_transcode_q8_0(m : GGUFMeta; srcbytes : array | #; name : string if (src_off + expect_n > m.tensors[ti].n_elem) { panic("gguf: tensor '{name}' slice [{src_off}, {src_off + expect_n}) exceeds {m.tensors[ti].n_elem} elems") } - if (expect_n % 32l != 0l) { - panic("gguf: tensor '{name}' asks for {expect_n} Q8_0 elements, not a whole number of 32-blocks") + if (src_off % 32l != 0l || expect_n % 32l != 0l) { + panic("gguf: tensor '{name}' Q8_0 slice [{src_off}, +{expect_n}) is not block-aligned") } let nb = expect_n / 32l if (nb <= 0l) { diff --git a/modules/dasLLAMA/dasllama/dasllama_pocket.das b/modules/dasLLAMA/dasllama/dasllama_pocket.das index 6c9041c75c..e375084135 100644 --- a/modules/dasLLAMA/dasllama/dasllama_pocket.das +++ b/modules/dasLLAMA/dasllama/dasllama_pocket.das @@ -295,8 +295,8 @@ def private read_weight(m : GGUFMeta; bytes : array | #; name : string) : var private g_stage_file_q8 = false // the file's `pocket.weights` says q8: its Q8_0 tensors are in the kernels' layout // a Q8_0 tensor is read as the kernels' own blocks only where the converter wrote it that way: -// `pocket.weights = "q8"` names the layout, and the block-home rule (32-wide on both dims, a -// dense stride-1 conv) is what the converter's q8_linear / q8_conv wrote a second time +// `pocket.weights = "q8"` names the layout, and the block-home rule (32-wide on both dims, a dense +// stride-1 conv) is what q8_linear / q8_conv / head_q8_linear wrote a second time; Q4_K's disk form is ggml's own and needs no gate def private is_q8_tensor(m : GGUFMeta; name : string) : bool { if (gguf_tensor_type(m, name) != GGML_TYPE_Q8_0) { return false @@ -307,6 +307,7 @@ def private is_q8_tensor(m : GGUFMeta; name : string) : bool { return true } +[arch(at = "../ARCHITECTURE_POCKET.md#pocket-q8-file")] def private read_linear(m : GGUFMeta; bytes : array | #; prefix : string; vec_only : bool = false) : TtsLinear { var l = TtsLinear() let wname = "{prefix}.weight" @@ -320,17 +321,14 @@ def private read_linear(m : GGUFMeta; bytes : array | #; prefix : string; panic("dasLLAMA pocket: '{wname}' [{l.nout}][{l.nin}] is Q8_0, a shape the q8 lane does not serve (not 32-wide on both dims)") } if (g_stage_native && gguf_tensor_type(m, wname) == GGML_TYPE_Q4_K) { - // the kq lane: the file's own K-quant planes, straight into the layer + // the kq lane: the file's own K-quant planes, straight into the layer (the width rule is the layer's own) let n = l.nout * l.nin - if (l.nin % 256l != 0l) { - panic("dasLLAMA pocket: '{wname}' [{l.nout}][{l.nin}] is Q4_K on a width that is not a whole number of 256-superblocks") - } var inscope kq : array var inscope ks : array kq |> reserve_resize(n / 256l * kq_qsb(KqFmt.k4)) ks |> reserve_resize(n / 256l * kq_ssb(KqFmt.k4)) gguf_transcode_q4k(m, bytes, wname, kq, ks, 0l, n) - linear_take_kq(l, 4, kq, ks, g_stage_repack) + linear_take_kq(l, 4, kq, ks, true) // repacked wherever the backend the load selected carries kq kernels return <- l } if (g_stage_q8 && is_q8_tensor(m, wname)) { @@ -346,7 +344,7 @@ def private read_linear(m : GGUFMeta; bytes : array | #; prefix : string; return <- l } l.w <- read_arr(m, bytes, wname) // a Q8_0 or K-quant tensor dequantizes here: the f32 lane of a published file, or the q8 lane pinned over a K-quant one - linear_prepare(l, true, g_stage_q8 && !vec_only, g_stage_repack, vec_only) + linear_prepare(l, true, g_stage_q8, g_stage_repack, vec_only) // a vector layer mints nothing and keeps w return <- l } @@ -542,7 +540,9 @@ def private read_mimi(m : GGUFMeta; bytes : array | #; var mm : PocketMim mm.enc_out <- read_conv(m, bytes, "mimi.encoder.model.{2l + 3l * nr}.conv", 1l, 1l, false) mm.enc_tf <- read_transformer(m, bytes, "mimi.enc_tf", n_layers, d, heads, ffn, context, period, true) mm.downsample <- read_conv(m, bytes, "mimi.downsample.conv.conv", 0l, 1l, false) - verify(mm.downsample.k / 2l == mm.frame_steps) + if (mm.downsample.k / 2l != mm.frame_steps) { + panic("dasLLAMA pocket: the downsampler's kernel {mm.downsample.k} and the upsampler's {2l * mm.frame_steps} disagree on the frame stride") + } mm.downsample.stride = mm.frame_steps mm.downsample.pad_l = 0l // replicate-padded by the caller, `frame_steps` rows } @@ -569,7 +569,11 @@ def private lang_code_of(language : string) : string { def private read_roster(g : GGUFMeta; bytes : array | #; var m : PocketModel; path : string) { m.voice_names <- gguf_str_array(g, bytes, "pocket.voices") for (v in m.voice_names) { - if (gguf_find_tensor(g, "voice_latents.{v}") >= 0) { + let ti = gguf_find_tensor(g, "voice_latents.{v}") + if (ti >= 0) { + if (g.tensors[ti].dims[0] != m.latent_dim) { + panic("dasLLAMA pocket: '{path}' stores voice '{v}' as frames of {g.tensors[ti].dims[0]}, the codec's latent width is {m.latent_dim}") + } m.voice_latents[v] <- read_arr(g, bytes, "voice_latents.{v}") } elif (m.cloning) { m.voices[v] <- read_arr(g, bytes, "voice.{v}") @@ -592,8 +596,12 @@ def load_pocket(path : string) : PocketModel { m.q8 = pocket_serve_q8_() g_stage_q8 = m.q8 g_stage_native = pocket_serves_native() - g_stage_repack = m.q8 && select_matmul_backend_for_load_() - to_log(LOG_INFO, "dasLLAMA pocket: GEMM lane {m.q8 ? "q8" : "f32"}{g_stage_native ? ", a K-quant tensor as its own planes" : ""} - {g_pocket_q8_pin != PocketLane.unset ? "pinned via set_tts_q8" : "the policy default"}\n") + // the backend is selected for any quantized lane; its q8 layout may want a repack (g_stage_repack), and the + // K-quant planes repack wherever it carries kq kernels - linear_take_kq asks that itself + let selected = (m.q8 || g_stage_native) ? select_matmul_backend_for_load_() : false + g_stage_repack = m.q8 && selected + let kq_arm = !g_stage_native ? "" : (kernel_backend_has_kq() ? ", its K-quant planes repacked for {active_kernel_backend()}" : ", its K-quant planes in disk order (the portable GEMV)") + to_log(LOG_INFO, "dasLLAMA pocket: GEMM lane {m.q8 ? "q8" : "f32"}{g_stage_native ? ", a K-quant tensor as its own planes" : ""}{kq_arm} - {g_pocket_q8_pin != PocketLane.unset ? "pinned via set_tts_q8" : "the policy default"}\n") let f = fopen(path, "rb") if (f == null) { panic("dasLLAMA pocket: cannot open model '{path}'") @@ -753,6 +761,9 @@ def private res_conv_rows(b : PocketResConv; x : array; t : int64; var sc //! The clip's latent frames [frames][latent_dim] through the codec encoder - the voice prompt's //! source. `pcm` is 24 kHz mono; its tail pads to a whole frame with zeros as the reference does. def pocket_encode_latents(m : PocketModel; pcm : array; var sc : PocketScratch; @scratch @exact_size var latents : array) : int64 { + if (!m.cloning) { + panic("dasLLAMA pocket: this file carries no codec encoder, so it cannot encode a clip") + } let mm & = unsafe(m.mimi) let n = long_length(pcm) let frames = (n + m.frame_samples - 1l) / m.frame_samples @@ -864,7 +875,12 @@ def pocket_register_voice(var m : PocketModel; name : string; pcm : array if (!key_exists(m.voices, name) && !key_exists(m.voice_latents, name)) { m.voice_names |> push(name) } - m.voice_latents |> erase(name) + if (key_exists(m.voice_latents, name)) { + m.voice_latents |> get(name) $(var old) { + delete old // erase frees the slot, never the frames + } + m.voice_latents |> erase(name) + } m.voices[name] := pcm m.voice_states[name] <- vs } @@ -877,6 +893,9 @@ def private ensure_voice_state(var m : PocketModel; name : string; var sc : Pock m.voice_latents |> get(name) $(stored) { lat := stored } + if (long_length(lat) % m.latent_dim != 0l) { + panic("dasLLAMA pocket: voice '{name}' stores {long_length(lat)} latent values, not whole frames of {m.latent_dim}") + } m.voice_states[name] <- voice_state_from_latents(m, lat, long_length(lat) / m.latent_dim, sc) return } diff --git a/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das b/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das index e144c9a8f6..2e68a80888 100644 --- a/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das +++ b/modules/dasLLAMA/dasllama/dasllama_tts_blocks.das @@ -1112,7 +1112,7 @@ def conv1d_rows_transposed_depthwise(c : TtsConv1d; x : array; t_in : int //! Mint the tiled GEMM's B operand `wt` [nin][nout] when the width sits on the 16-column tile; //! a layer only `linear_rows` reads (`rows_only`) drops `w`, one with `q8` and per-32 widths takes -//! the Q8_0 form (`repack` for the backend), and `vec_only` - `linear_vec` reads `w` - mints none. +//! the Q8_0 form (`repack` for the backend), and `vec_only` mints none (`linear_vec` reads `w`, or the quant planes a reader handed the layer instead). def linear_prepare(var l : TtsLinear; rows_only : bool = false; q8 : bool = false; repack : bool = false; vec_only : bool = false) { return if (vec_only) @@ -1124,7 +1124,7 @@ def linear_prepare(var l : TtsLinear; rows_only : bool = false; q8 : bool = fals repack_q8q8_weight(l.wq, l.wqs, 0l, l.nin, l.nout) } l.q8 = true - delete l.w + release_weight(l.w) return } return if (l.nout % 16l != 0l) @@ -1161,14 +1161,19 @@ def private linear_rows_add_bias(l : TtsLinear; t : int64; var y : array) let nout = l.nout var yp = unsafe(addr(y[0])) let b4 = unsafe(addr(l.b[0])) + let bp = unsafe(addr(l.b[0])) maybe_parallel_for(0, int(t), lanes_for_work(t * nout, 0)) $(rb, re) { unsafe { let n4 = nout / 4l for (r in range64(int64(rb), int64(re))) { - var y4 = reinterpret(yp + r * nout) + var yrow = yp + r * nout + var y4 = reinterpret(yrow) for (j in range64(n4)) { y4[j] += b4[j] } + for (o in range64(n4 * 4l, nout)) { // the tail past the last float4: a width off 4 + yrow[o] += bp[o] + } } } } @@ -1179,7 +1184,6 @@ def private linear_rows_add_bias(l : TtsLinear; t : int64; var y : array) var @scratch g_kq_xq : array var @scratch g_kq_xs : array var @scratch g_kq_xbs : array -var @scratch g_kq_row : array //! Give a layer the K-quant planes of its [nout][nin] weight (`fmt` the kernel-layer id, 4 = //! Q4_K; the planes as the GGUF transcoder wrote them) and repack them for the active backend @@ -1196,25 +1200,22 @@ def linear_take_kq(var l : TtsLinear; fmt : int; var kq, ks : array; repa repack_kq_weight(fmt, l.kq, l.ks, 0l, l.nin, l.nout) l.kq_repacked = true } - delete l.w + release_weight(l.w) } -// one row [nin] through the K-quant GEMV: the row requantized to the Q8_K form, then the -// backend's core over repacked planes or the portable one over disk order +// one row [nin] through the K-quant GEMV: the row requantized to the Q8_K form where it sits, +// then the backend's core over repacked planes or the portable one over disk order def private kq_gemv_row(l : TtsLinear; x : array; xoff : int64; var y : array; yoff : int64) { let nin = l.nin - var row & = g_kq_row - row |> reserve_resize(nin) - for (i in range64(nin)) { - row[i] = x[xoff + i] - } var xq & = g_kq_xq var xs & = g_kq_xs var xbs & = g_kq_xbs xq |> reserve_resize(nin) xs |> reserve_resize(nin / 256l) xbs |> reserve_resize(nin / 16l) - requant_rows_q8k_bs(row, nin, 1l, xq, xs, xbs, false) + unsafe { + quantize_q8_k_into_ptr(addr(x[xoff]), nin, addr(xq[0]), addr(xs[0]), addr(xbs[0]), 0l, 0l, 0l) + } if (l.kq_repacked) { matmul_kq_active(l.kq_fmt, y, l.kq, l.ks, 0l, xq, xs, xbs, nin, l.nout, yoff) } else { @@ -1224,6 +1225,7 @@ def private kq_gemv_row(l : TtsLinear; x : array; xoff : int64; var y : a // The K-quant lane of linear_rows: the rows requantized to the Q8_K form at once and one batched // kq GEMM where the backend carries the tile, else the GEMV per row; then the bias. +[arch(at = "../ARCHITECTURE_POCKET.md#pocket-q8-file")] def private linear_rows_kq(l : TtsLinear; x : array; t : int64; var y : array) { let nin = l.nin if (l.kq_repacked && kernel_backend_has_kq_batch()) { @@ -1233,7 +1235,7 @@ def private linear_rows_kq(l : TtsLinear; x : array; t : int64; var y : a xq |> reserve_resize(t * nin) xs |> reserve_resize(t * nin / 256l) xbs |> reserve_resize(t * nin / 16l) - requant_rows_q8k_bs(x, nin, t, xq, xs, xbs, t * nin >= 65536l) + requant_rows_q8k_bs(x, nin, t, xq, xs, xbs, t * nin >= g_requant_par_threshold) matmul_kq_batch(l.kq_fmt, y, l.kq, l.ks, 0l, xq, xs, xbs, nin, l.nout, t) } else { for (r in range64(t)) { diff --git a/modules/dasLLAMA/followup_general.md b/modules/dasLLAMA/followup_general.md index b5dda0cd27..c7a4a54795 100644 --- a/modules/dasLLAMA/followup_general.md +++ b/modules/dasLLAMA/followup_general.md @@ -1470,3 +1470,13 @@ behind the same call (libopus + the Ogg framing, a build dependency the module does not carry yet) or the help text naming Vorbis; a clip that decodes to nothing is logged and skipped either way. +129. **The K-quant lane's first-synthesis step.** On `pocket-tts-en-kq.gguf` the process grows + 0.33 GB at its first synthesis and holds it; on `pocket-tts-en-q8.gguf` it grows nothing + (`PERF_LEDGER.md`, the small-form section: 2.70 GB against 2.54 over the run, the load itself + lighter by 0.16). A game embedding pays that step for a 75 MB file. Where to read: the + jobque forks' persistent heaps under the rows kernels (`linear_rows_kq` over + `matmul_kq_batch`, the parallel arm of `requant_rows_q8k_bs`), the `@scratch` rows of + `dasllama/dasllama_tts_blocks.das` sized by the first chunk, and what the load leaves behind + on the K-quant path (the f32 dequant `read_linear` hands `linear_take_kq`, released per tensor + but sized by the largest). The instrument is the resident set sampled per half second with the + `--limit` one and two forms, and the das leak profiler on the run. diff --git a/modules/dasLLAMA/harness/convert_pocket.py b/modules/dasLLAMA/harness/convert_pocket.py index 06075ed983..d94e232263 100644 --- a/modules/dasLLAMA/harness/convert_pocket.py +++ b/modules/dasLLAMA/harness/convert_pocket.py @@ -86,8 +86,9 @@ def q8_linear(name, shape): """The linears the engine serves through the rows GEMM as Q8_0: the transformer layers' - four matrices and the frame input projection, on widths that quantize per 32. The head's - GEMVs, the EOS head, the speaker projection and the norms stay f16.""" + four matrices and the frame input projection, on widths that quantize per 32. The EOS head, + the speaker projection and the norms stay f16; the flow head's GEMVs stay f16 in the --q8 + form and go Q8_0 in the --kq form (head_q8_linear).""" rows_served = (".self_attn." in name or ".linear1." in name or ".linear2." in name) and name.endswith(".weight") \ and (name.startswith("backbone.") or name.startswith("mimi.enc_tf.") or name.startswith("mimi.dec_tf.")) rows_served = rows_served or name == "flow_lm.input_linear.weight" @@ -112,6 +113,8 @@ def fake_group(name, shape, conv_served=False): flow head's matrices, the codec transformers' GEMMs (`codec`), the convs the engine serves q8 (`codecconv`), and the codec's strided, transposed and resampling convs the file keeps f16 (`strided`). Norms, biases and the voices are never in a group.""" + if name == "flow_lm.speaker_proj_weight": # the one matrix the bundle names without the ".weight" suffix + return "speaker" if len(shape) == 2 else None if not name.endswith(".weight") or len(shape) < 2: return None if name.startswith("backbone."): @@ -122,8 +125,6 @@ def fake_group(name, shape, conv_served=False): return None if name == "flow_lm.input_linear.weight": return "input" - if name == "flow_lm.speaker_proj.weight": - return "speaker" if name == "flow_lm.conditioner.embed.weight": return "embed" if name.startswith("head."): @@ -139,8 +140,10 @@ def parse_fake(spec): """`group=fmt,group=fmt` - a format per group, ggml's names (q4_0, q4_k, q6_k, iq4_nl, ...).""" out = {} for item in filter(None, spec.split(",")): + assert item.count("=") == 1, f"--fake wants group=fmt, got '{item}'" group, fmt = item.split("=") assert group in FAKE_GROUPS, (group, FAKE_GROUPS) + assert group not in out, f"--fake names {group} twice" out[group] = fmt.upper() return out @@ -182,17 +185,18 @@ def quantize_bytes(self, name, rows, fmt): assert n == out.nbytes, (name, fmt, n, out.nbytes) return out - def apply(self, name, w32, fmt): + def apply(self, name, w32, fmt, group): from gguf.quants import dequantize t = self.gguf.GGMLQuantizationType[fmt] - # a conv [cout][cin][k] rounds per output channel over its cin*k taps; a matrix per row + # a conv rounds per slice of its leading dim over the rest ([cout][cin][k] per output + # channel; a transposed conv's [cin][cout][k] per input channel); a matrix per row rows = np.ascontiguousarray(w32.reshape(w32.shape[0], -1) if w32.ndim == 3 else w32.reshape(-1, w32.shape[-1]), dtype=np.float32) out = self.quantize_bytes(name, rows, fmt) if out is None: return w32 back = dequantize(out, t).reshape(w32.shape).astype(np.float32) err = float(np.sqrt(((back - w32) ** 2).mean()) / max(np.sqrt((w32 ** 2).mean()), 1e-12)) - self.done[name] = (fmt, out.nbytes, err) + self.done[name] = (group, fmt, out.nbytes, err) return back @@ -298,8 +302,8 @@ def main(): ap.add_argument("--llama-cpp", default=os.path.expanduser("~/Work/llama.cpp"), help="for gguf-py") ap.add_argument("--name", default=None, help="output file stem (default pocket-tts-)") ap.add_argument("--q8", action="store_true", help="the published form: the served GEMM weights as Q8_0 in the kernels' layout") - ap.add_argument("--fake", default="", help="quality experiment, never published: group=fmt[,group=fmt] (attn, ffn, input, speaker, head, " - "codec; ggml format names) - the group's weights round through that format before they are stored; needs --name") + ap.add_argument("--fake", default="", help=f"quality experiment, never published: group=fmt[,group=fmt] (groups {', '.join(FAKE_GROUPS)}; " + "ggml format names) - the group's weights round through that format before they are stored; needs --name") ap.add_argument("--kq", action="store_true", help="the small form: the backbone's and the codec transformers' matrices and the text embedding " "as Q4_K, the flow head as Q8_0, the rest as --q8 writes it (implies --q8)") ap.add_argument("--voices", default="", help="the roster as a comma list of the language's voice names (default: every voice the language ships)") @@ -344,7 +348,7 @@ def main(): continue group = fake_group(name, v.shape, q8_conv(name, v.shape, conv_stride.get(name, 1), ".convtr." in name)) if fake else None if group in fake: - v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group]) + v = fq.apply(name, np.ascontiguousarray(v.astype(np.float32)), fake[group], group) if a.kq and kq_tensor(name, v.shape): tensors[name] = ("k4", np.ascontiguousarray(v.astype(np.float32))) quantized_k4.append(name) @@ -392,7 +396,7 @@ def main(): stem = a.name or (f"pocket-tts-{lang}-q8" if a.q8 else f"pocket-tts-{lang}") path = os.path.join(a.out, stem + ".gguf") w = gguf.GGUFWriter(path, ARCH) - w.add_name(f"Pocket TTS {lang}" + (" Q8_0" if a.q8 else "")) + w.add_name(f"Pocket TTS {lang}" + (" Q4_K" if a.kq else " Q8_0" if a.q8 else "")) w.add_string("pocket.weights", "q8" if a.q8 else "f16") if a.kq: w.add_string("pocket.kq", "q4_k") @@ -478,8 +482,8 @@ def main(): f"{len(pieces)} pieces, {len(voices)} voices (default {default_voice}); {os.path.getsize(path)} bytes on disk") if fq: by_group = {} - for name, (fmt, nbytes, err) in fq.done.items(): - g = by_group.setdefault((fake_group(name, (1, 1)) or "strided", fmt), [0, 0, 0.0]) + for name, (group, fmt, nbytes, err) in fq.done.items(): + g = by_group.setdefault((group, fmt), [0, 0, 0.0]) g[0] += 1 g[1] += nbytes g[2] = max(g[2], err) @@ -487,6 +491,8 @@ def main(): print(f" fake {group}={fmt}: {n} tensors, {nbytes / 1e6:.1f} MB as {fmt}, worst rms rel err {err:.4f}") for name, fmt, width in fq.skipped: print(f" fake skipped {name}: width {width} is not a whole number of {fmt} blocks") + unmatched = [group for group in fake if not any(g == group for g, _ in by_group) and not any(fake_group(n, (1, 1)) == group for n, _, _ in fq.skipped)] + assert not unmatched, f"--fake named groups no tensor belongs to: {unmatched}" if __name__ == "__main__": diff --git a/modules/dasLLAMA/harness/test_convert_pocket.py b/modules/dasLLAMA/harness/test_convert_pocket.py new file mode 100644 index 0000000000..7e989f3ce0 --- /dev/null +++ b/modules/dasLLAMA/harness/test_convert_pocket.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""convert_pocket.py's pure predicates - which tensor lands in which form - with no torch, no +safetensors and no network: the groups a `--fake` spec names, the `--kq` and head rules, the +encoder set `--no-cloning` leaves out. The converted files' contents are the das cells' claim +(tests/test_tts_pocket.das); this file holds the rules that decide them.""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import convert_pocket as cp # noqa: E402 + +ATTN = "backbone.0.self_attn.in_proj.weight" +FFN = "backbone.3.linear1.weight" +NORM = "backbone.3.norm1.weight" +INPUT = "flow_lm.input_linear.weight" +SPEAKER = "flow_lm.speaker_proj_weight" # the one matrix the bundle names without the ".weight" suffix +EMBED = "flow_lm.conditioner.embed.weight" +HEAD = "head.blocks.1.mlp.0.weight" +HEAD_IN = "head.input_proj.weight" +CODEC_TF = "mimi.dec_tf.2.self_attn.in_proj.weight" +CODEC_CONV = "mimi.decoder.model.4.conv.weight" +STRIDED = "mimi.encoder.model.3.conv.weight" +ENC_TF = "mimi.enc_tf.0.linear2.weight" +DOWNSAMPLE = "mimi.downsample.conv.conv.weight" +VOICE = "voice_latents.alba" + + +class FakeGroupTest(unittest.TestCase): + def test_every_group_is_reached_by_the_tensor_it_names(self): + self.assertEqual(cp.fake_group(ATTN, (3072, 1024)), "attn") + self.assertEqual(cp.fake_group(FFN, (4096, 1024)), "ffn") + self.assertEqual(cp.fake_group(INPUT, (1024, 32)), "input") + self.assertEqual(cp.fake_group(SPEAKER, (1024, 512)), "speaker") + self.assertEqual(cp.fake_group(EMBED, (4000, 1024)), "embed") + self.assertEqual(cp.fake_group(HEAD, (1024, 1024)), "head") + self.assertEqual(cp.fake_group(CODEC_TF, (1536, 512)), "codec") + self.assertEqual(cp.fake_group(CODEC_CONV, (512, 512, 3), conv_served=True), "codecconv") + self.assertEqual(cp.fake_group(STRIDED, (256, 128, 8)), "strided") + for group in ("attn", "ffn", "input", "speaker", "embed", "head", "codec", "codecconv", "strided"): + self.assertIn(group, cp.FAKE_GROUPS) + + def test_what_no_group_names(self): + self.assertIsNone(cp.fake_group(NORM, (1024,)), "a norm is one-dimensional") + self.assertIsNone(cp.fake_group("backbone.0.self_attn.in_proj.bias", (3072,)), "a bias is not a weight") + self.assertIsNone(cp.fake_group("backbone.0.norm1.weight", (1024, 1)), "a backbone tensor outside attention and the FFN") + self.assertIsNone(cp.fake_group(VOICE, (120, 32)), "a voice is never in a group") + + def test_a_spec_names_a_format_per_group(self): + self.assertEqual(cp.parse_fake("attn=q4_k,head=q8_0"), {"attn": "Q4_K", "head": "Q8_0"}) + self.assertEqual(cp.parse_fake(""), {}) + with self.assertRaises(AssertionError): + cp.parse_fake("norms=q4_k") + + +class SmallFormTest(unittest.TestCase): + def test_kq_takes_the_256_wide_matrices_of_the_backbone_the_codec_transformers_and_the_embedding(self): + self.assertTrue(cp.kq_tensor(ATTN, (3072, 1024))) + self.assertTrue(cp.kq_tensor(FFN, (4096, 1024))) + self.assertTrue(cp.kq_tensor(CODEC_TF, (1536, 512))) + self.assertTrue(cp.kq_tensor(ENC_TF, (512, 2048))) + self.assertTrue(cp.kq_tensor(EMBED, (4000, 1024))) + + def test_kq_leaves_the_rest_alone(self): + self.assertFalse(cp.kq_tensor(HEAD, (1024, 1024)), "the flow head stays Q8_0") + self.assertFalse(cp.kq_tensor(INPUT, (1024, 32)), "the frame input projection is 32 wide") + self.assertFalse(cp.kq_tensor(SPEAKER, (1024, 512)), "the speaker projection") + self.assertFalse(cp.kq_tensor(ATTN, (3072, 1000)), "a width the 256-superblock does not divide") + self.assertFalse(cp.kq_tensor(CODEC_CONV, (512, 512, 3)), "a conv is three-dimensional") + + def test_the_head_goes_to_q8_on_widths_that_quantize_per_32(self): + self.assertTrue(cp.head_q8_linear(HEAD, (1024, 1024))) + self.assertTrue(cp.head_q8_linear(HEAD_IN, (1024, 32))) + self.assertFalse(cp.head_q8_linear("head.blocks.1.mlp.0.bias", (1024,))) + self.assertFalse(cp.head_q8_linear(HEAD, (1024, 30))) + self.assertFalse(cp.head_q8_linear(ATTN, (3072, 1024)), "the backbone is not the head") + + def test_no_cloning_leaves_out_exactly_the_clip_path(self): + for name in (STRIDED, ENC_TF, DOWNSAMPLE, "mimi.encoder.model.0.conv.weight"): + self.assertTrue(cp.encoder_tensor(name), name) + for name in (CODEC_TF, CODEC_CONV, "mimi.upsample.convtr.convtr.weight", "mimi.quantizer.output_proj.weight", ATTN, HEAD, VOICE): + self.assertFalse(cp.encoder_tensor(name), name) + + +class PublishedFormTest(unittest.TestCase): + def test_q8_serves_the_rows_gemms_and_the_frame_input(self): + self.assertTrue(cp.q8_linear(ATTN, (3072, 1024))) + self.assertTrue(cp.q8_linear(ENC_TF, (512, 2048))) + self.assertTrue(cp.q8_linear(INPUT, (1024, 32))) + self.assertFalse(cp.q8_linear(HEAD, (1024, 1024)), "the head is the --kq form's alone") + self.assertFalse(cp.q8_linear(SPEAKER, (1024, 512))) + self.assertFalse(cp.q8_linear(ATTN, (3072, 1000))) + + def test_q8_convs_are_the_dense_forward_stride_one_ones(self): + self.assertTrue(cp.q8_conv(CODEC_CONV, (512, 512, 3), 1, False)) + self.assertTrue(cp.q8_conv("mimi.quantizer.output_proj.weight", (512, 256, 1), 1, False)) + self.assertFalse(cp.q8_conv(STRIDED, (256, 128, 8), 4, False), "a strided stage") + self.assertFalse(cp.q8_conv("mimi.decoder.model.5.convtr.convtr.weight", (256, 128, 8), 4, True), "a transposed stage") + self.assertFalse(cp.q8_conv("mimi.decoder.model.14.conv.weight", (1, 64, 7), 1, False), "the single-channel end") + + +if __name__ == "__main__": + unittest.main() diff --git a/modules/dasLLAMA/harness/tts_model_card.md b/modules/dasLLAMA/harness/tts_model_card.md index 6e0f5dbed6..b3d0f2e4b3 100644 --- a/modules/dasLLAMA/harness/tts_model_card.md +++ b/modules/dasLLAMA/harness/tts_model_card.md @@ -72,7 +72,8 @@ Pocket TTS English is the cloning model: 152 MB, 19 voices (`alba` the default, front of it. On the 200-sentence rig at `alba` this file reads WER 3.91 / UTMOS 4.328 at a real-time factor of 0.051 on an Apple M1 Max, against the reference package's 5.00 / 4.393 / 0.210 (measured 2026-09-09 with the module's `harness/tts_rig.py`, the engine under the JIT -tier with the box's tune profile, the reference package under torch on one thread). The five other +tier with the box's tune profile - `DAS_TUNE_POLICY` unset - on the `arm64-gen` kernel backend, +the reference package under torch on one thread). The five other languages are the same form, one file each with Kyutai's default clip for that language as its only voice (German `juergen`, Spanish `lola`, Italian `giovanni`, Portuguese `rafael`, French `estelle`); the German, Spanish, Italian and Portuguese files are the six-layer models, French @@ -81,7 +82,8 @@ the clip's accent. Text in those languages is read as it is, since the normalize `pocket-tts-en-kq.gguf` is the English model in the small form, 75 MB: the backbone and the codec transformers as Q4_K, the flow head and the codec convolutions as Q8_0, the embedding table Q4_K, the encoder and the 19 voices inside (on the rig at `alba`: WER 3.86 / UTMOS 4.295 -at a real-time factor of 0.049 on the same box); it is the file the browser examples on +at a real-time factor of 0.044, measured 2026-09-10 on the same box, tier, tune profile and +kernel backend, the file's Q4_K planes served as they are); it is the file the browser examples on dasllama.io fetch. `pocket-tts-en-stuart-kq.gguf` is that form with one voice, `stuart_bell`, and no codec encoder, 65 MB: it reads text in that voice and cannot clone. diff --git a/modules/dasLLAMA/performance/REVIEW.md b/modules/dasLLAMA/performance/REVIEW.md index 243975a79d..0e46ad0e03 100644 --- a/modules/dasLLAMA/performance/REVIEW.md +++ b/modules/dasLLAMA/performance/REVIEW.md @@ -109,8 +109,13 @@ or an unnamed table lookup. **A diff that adds a companion artifact - a file fetched or verified with a model and consumed beside it: a projector, a draft head, an assistant sidecar, an image fixture - puts it in the -`companions` of the row that pins its carrier, and names it from every other row that -consumes it.** +`companions` of the row that pins its carrier.** + +**A diff that adds a row whose test cell consumes a companion pinned on another row - a fixture, +an oracle, a twin file that cell reads beside it - names that companion in the new row's +`companions`, in the same change; a diff that adds a companion an existing row's cell consumes +names it from that row too.** A box that fetches the row alone gets its companions with it, and +the cell runs instead of skipping. **A diff that changes what any `serve_*` function in `model_specs.das` returns - a `serve_*` field on a row, the function's body, or a `companions` entry with a `url` on a row a `serve_*` diff --git a/modules/dasLLAMA/tests/CLAUDE.md b/modules/dasLLAMA/tests/CLAUDE.md index d1175e4980..b1083bbf0e 100644 --- a/modules/dasLLAMA/tests/CLAUDE.md +++ b/modules/dasLLAMA/tests/CLAUDE.md @@ -730,7 +730,13 @@ languages (`pocket-tts-{de,es,it,pt,fr}-q8.gguf`, oracle dirs `tts_oracle/pocket minted over `_tts_fixtures/pocket_sentences.json`, token fixtures `pocket_tokens_.json`): the language code and the one default voice, the tokenizer on the language's own sentences and the probes, the teacher-forced frames of every oracle case, one sentence through the -facade. +facade. Two more carriers gate their own cells: the small form `pocket-tts-en-kq.gguf` (the +K-quant file: its Q4_K tensors arrive as kq planes on the unpinned lane, the head as Q8_0; the +teacher-forced latents of the kq lane held to the q8 lane of the same file at the q8 bar with a +poisoned-expectation control, the f16 twin's distance logged, the exact lane speaking) and the +one-voice `pocket-tts-en-stuart-kq.gguf` (no codec encoder: `caps()` reports one voice and +`cloning = false`, the stored voice speaks from its latent frames, `tts_register_voice` refuses +by name). `test_tts_facade.das` - stocked suite; model-free cells: the sentence chunker (the reference driver's boundary rule, the cap counted in codepoints, the hard split of a whitespace-free run, the appended comma as Kitten's driver rule and the bare text Kokoro's sends), the normalizer the diff --git a/modules/dasLLAMA/tests/REVIEW.md b/modules/dasLLAMA/tests/REVIEW.md index 5691816b89..123cabd201 100644 --- a/modules/dasLLAMA/tests/REVIEW.md +++ b/modules/dasLLAMA/tests/REVIEW.md @@ -51,7 +51,7 @@ a run of skips is not the coverage the suite owes. **A diff that changes what a file covers - a cell added, removed or moved, its suite, an axis or bar an existing cell asserts - corrects that file's `CLAUDE.md` census entry, numbers included, in the same change.** A `{a,b}` shorthand naming several files at once, or a suite roster, -carries nothing to correct. +carries nothing to correct; a file with no census entry owes none, a file with one keeps it true. **A diff that changes the contract a gate pins - what its asserts hold fixed, an axis gained or lost - updates that gate's entry in this checklist's pinned set in the same change.** @@ -126,7 +126,9 @@ other stocked fixture gates on its own presence. **A test - or a program a test builds or spawns - whose subject is not the `.dlim` image rail never mints or maps a MODEL image: it either runs with `DASLLAMA_IMAGE=0` in its environment, -or calls no `load_model`, `load_model_cached`, or `load_model_image`.** +or calls no loader that bakes a `.dlim` - `load_model`, `load_model_cached`, `load_model_image`, +`load__tower`, `load__encoder`, `load__model`, `load_tts_model`, +`load_styletts2`.** **A predicate whose value the BOX decides (a device capability, a policy default) and that therefore cannot differ between two runs on one machine is never tested through its own @@ -239,10 +241,10 @@ procedurally and pins its expectations in-repo.** `DASLLAMA_VISION_DUMP` cannot preview, is a defect** - a red never requires adding instrumentation before a human can see what the model consumed. -**An audio clip a test feeds an embedder that the test does not build, and that is not one of -the clips stocked beside the models (`jfk.wav`, `gemma4a_test2.wav`), is a defect** - a clip -nobody else can play makes a red unreadable. A newly stocked clip joins this list in the same -change. +**An audio clip a test feeds an embedder that the test does not build, that the repository does +not track, and that is not one of the clips stocked beside the models (`jfk.wav`, +`gemma4a_test2.wav`), is a defect** - a clip nobody else can play makes a red unreadable. A newly +stocked clip joins this list in the same change. **A media fixture an embedder-parity cell regenerates in-test and compares against an oracle dump, with no exact-value generator - one whose values are exactly representable floats, so diff --git a/modules/dasLLAMA/tests/test_parrot.das b/modules/dasLLAMA/tests/test_parrot.das index 081604880a..4dba487a23 100644 --- a/modules/dasLLAMA/tests/test_parrot.das +++ b/modules/dasLLAMA/tests/test_parrot.das @@ -10,8 +10,11 @@ require strings require math require _model_tier require _example_rail +require ../../../examples/dasLLAMA/parrot/take.das -// Parrot (examples/dasLLAMA/parrot): the smoke cell spawns the example under --smoke with the +// Parrot (examples/dasLLAMA/parrot): the take's pure side (take.das) is tested directly - the +// resampler Silero hears through, the rule that ends a take, the clip's window, the text box's +// editing rules and a button's box. The smoke cell spawns the example under --smoke with the // tree's own clip in place of the microphone and reads the witness lines it logs: the voice is // cloned from the clip, the text is said in it, and the say is read out to the end. Model-gated // on pocket-tts-en-kq.gguf (the Pocket file with its codec encoder, the one the page ships) under @@ -19,6 +22,134 @@ require _example_rail // for about half a minute, so a box without a window server skips, and --null-audio keeps it // silent - and off the microphone, which --clip never opens anyway. +[test] +def test_vad_resampler(t : T?) { + t |> run("24 kHz to 16 kHz over the whole take: fed in drains of any size, the stream is the one fed at once") @(t : T?) { + var take : array + take |> resize(24000) + for (i in range(24000)) { + take[i] = sin(float(i) * 0.013) + 0.3 * cos(float(i) * 0.071) + } + var whole = VadResampler() + var at_once : array + resample_step(whole, take, at_once) + t |> equal(16000, length(at_once), "one second of 24 kHz is 16000 samples of 16 kHz (the last at position 23998.5, still inside the take)") + t |> equal(at_once[0], take[0], "the first sample is the take's first") + t |> success(abs(at_once[2] - take[3]) < 1.0e-6, "position 3.0 lands on the take's third sample") + t |> success(abs(at_once[1] - 0.5 * (take[1] + take[2])) < 1.0e-6, "position 1.5 is the mean of its neighbours") + var drained = VadResampler() + var stream : array + var partial : array + var fed = 0 + var chunk = 37 + while (fed < 24000) { + let n = min(chunk, 24000 - fed) + partial |> resize(fed + n) + for (i in range(fed, fed + n)) { + partial[i] = take[i] + } + fed += n + chunk = (chunk * 7 + 11) % 900 + 1 // drains of every size the device might hand over + var piece : array + resample_step(drained, partial, piece) + stream |> push_from(piece) + } + t |> equal(length(stream), length(at_once), "the same count") + var diffs = 0 + for (a, b in stream, at_once) { + diffs += a != b ? 1 : 0 + } + t |> equal(diffs, 0, "sample for sample the same stream - no seam where the drains met") + } + t |> run("a detector index maps back to the take") @(t : T?) { + t |> equal(vad_to_mic(16000l), 24000l) + t |> equal(vad_to_mic(0l), 0l) + t |> equal(vad_to_mic(512l), 768l) + } +} + +[test] +def test_take_rules(t : T?) { + t |> run("the take ends two seconds after speech, at the cap, or when the device gave nothing for long") @(t : T?) { + t |> success(!take_ends(false, false, 100.0, 10.0, 0.0), "no speech yet, nothing ends it") + t |> success(!take_ends(true, true, 100.0, 10.0, 0.0), "speech still going") + t |> success(!take_ends(true, false, 1.9, 10.0, 0.0), "quiet, not yet two seconds") + t |> success(take_ends(true, false, 2.0, 10.0, 0.0), "quiet for two seconds") + t |> success(take_ends(true, true, 0.0, 60.0, 0.0), "the cap, mid-speech") + t |> success(take_ends(false, false, 0.0, 60.0, 0.0), "the cap, no speech") + t |> success(!take_ends(false, false, 0.0, 0.0, 5.9), "a device silent for less than six seconds") + t |> success(take_ends(false, false, 0.0, 0.0, 6.0), "a device that gave nothing for six seconds") + } + t |> run("the clip's window: the speech plus a pad, an open speech to the end, never past the cap") @(t : T?) { + let pad = int64(TAKE_PAD_S * float(MIC_RATE)) + var w = clip_window(48000l, 96000l, 240000l, false) + t |> equal(w.from, 48000l - pad, "a quarter second before the speech") + t |> equal(w.to, 96000l + pad, "a quarter second after it") + w = clip_window(2000l, 96000l, 240000l, false) + t |> equal(w.from, 0l, "the pad never reaches before the take") + w = clip_window(48000l, 96000l, 100000l, false) + t |> equal(w.to, 100000l, "the pad never reaches past the take") + w = clip_window(48000l, 0l, 240000l, true) + t |> equal(w.to, 240000l, "speech still open at the stop runs to the take's end") + let cap = int64(TAKE_CAP_S) * int64(MIC_RATE) + w = clip_window(0l, 0l, cap + 24000l * 3l, true) + t |> equal(w.to - w.from, cap, "a take past the cap yields a clip of exactly the cap") + w = clip_window(240000l, 0l, 240000l, true) + t |> success(w.to >= w.from, "an empty window is never negative") + } +} + +[test] +def test_text_box(t : T?) { + t |> run("a key lands as its character: letters follow shift, the punctuation the poem needs, nothing else") @(t : T?) { + t |> equal(typed_char('A', false), 'a') + t |> equal(typed_char('A', true), 'A') + t |> equal(typed_char('1', false), '1') + t |> equal(typed_char('1', true), '!') + t |> equal(typed_char('/', true), '?') + t |> equal(typed_char(';', true), ':') + t |> equal(typed_char('\'', true), '"') + t |> equal(typed_char('-', true), '_') + t |> equal(typed_char(',', false), ',') + t |> equal(typed_char(' ', false), ' ') + t |> equal(typed_char('[', false), -1, "a bracket is not taken") + t |> equal(typed_char('=', false), -1, "nor an equals sign") + t |> equal(typed_char(300, false), -1, "nor a key past ASCII") + } + t |> run("the box: a line fills to its width, the box to its height, backspace walks back over lines") @(t : T?) { + var lines <- lines_of("ab\ncd") + t |> equal(length(lines), 2) + t |> equal(lines[1], "cd") + append_char(lines, 'e') + t |> equal(lines[1], "cde") + for (_i in range(WRAP_CHARS)) { + append_char(lines, 'x') + } + t |> equal(length(lines[1]), WRAP_CHARS, "a full line takes no more") + erase_last(lines) + t |> equal(length(lines[1]), WRAP_CHARS - 1) + for (_i in range(MAX_LINES + 5)) { + new_line(lines) + } + t |> equal(length(lines), MAX_LINES, "a full box takes no more lines") + for (_i in range(MAX_LINES)) { + erase_last(lines) + } + t |> equal(length(lines), 2, "backspace on an empty line drops it, down to the line above") + var one <- lines_of("") + t |> equal(length(one), 1, "an empty text is one empty line") + erase_last(one) + t |> equal(length(one), 1, "the only line stays") + } + t |> run("a button answers inside its label's box with a margin") @(t : T?) { + let box = float4(0.0, -20.0, 120.0, 4.0) // left, top, right, bottom relative to the pen + t |> success(inside_box(box, 60.0, 600.0, 100.0, 590.0, 8.0), "on the glyphs above the pen") + t |> success(inside_box(box, 60.0, 600.0, 55.0, 605.0, 8.0), "inside the margin") + t |> success(!inside_box(box, 60.0, 600.0, 100.0, 640.0, 8.0), "below the label") + t |> success(!inside_box(box, 60.0, 600.0, 200.0, 590.0, 8.0), "past its right edge") + } +} + let CLIP = "modules/dasLLAMA/models/jfk_ask_not.wav" let SMOKE_TEXT = "The woods are lovely, dark and deep. But I have promises to keep." let CLONED_LINE = "parrot: cloned " @@ -69,4 +200,14 @@ def test_smoke_clones_and_says(t : T?) { t |> success(find(out, READ_LINE) >= 0, "the say was read out:\n{witness(out)}") t |> success(find(out, DONE_LINE) >= 0, "the rail ended on the read-out, not the frame cap:\n{witness(out)}") } + t |> run("a --clip that is not audio is refused by name, before any window opens") @(t : T?) { + return if (!ready(t)) + var out = "" + let root = get_das_root() + let argv <- [example_daslang(), "-jit", "-dasroot", root, "{root}/examples/dasLLAMA/parrot/main.das", "--", + "--models", models_dir(), "--smoke", "--clip", "{root}/examples/dasLLAMA/parrot/models.json", "--null-audio"] + let rc = run_and_capture(argv, out, 60.0) + t |> success(rc != 0, "a clip that does not decode is a refusal, not a run") + t |> success(find(out, "did not decode") >= 0, "and it says so by name:\n{tail_of(out)}") + } } diff --git a/modules/dasLLAMA/tests/test_tts_blocks.das b/modules/dasLLAMA/tests/test_tts_blocks.das index 309c9997b4..945c78cfa2 100644 --- a/modules/dasLLAMA/tests/test_tts_blocks.das +++ b/modules/dasLLAMA/tests/test_tts_blocks.das @@ -6,8 +6,14 @@ options _dasllama_internal = true require dastest/testing_boost public require dasllama/dasllama_tts_blocks require dasllama/dasllama_math // setup_dasllama_jobque_ +require dasllama/dasllama_math_default // dot_k4q8, the K-quant leaf per row +require dasllama/dasllama_convert // transcode_q4k_superblock, quantize_q8_k_into_ptr +require dasllama/dasllama_gemm_schema // kq_qsb / kq_ssb by format id +require daslib/f16_cvt require daslib/jobque_boost require daslib/defer +require daslib/rtti // this_context().last_exception: a refusal read as text +require strings require math // The TTS block home's two layouts against each other: every rows-form kernel (token-major @@ -21,6 +27,7 @@ let REL = 1.0e-4 let FLOOR = 1.0e-6 let T_ROWS = 2048l let STYLE = 32l +let KQ_REL = 2.0e-4 //! the K-quant lane against its own leaf: the repacked tile sums the superblocks' scale products in its own order (the disk-order arm is bit-exact) struct BlockCase { name : string @@ -527,6 +534,133 @@ def private linear_case_q8(nin, nout : int64) : BlockCase { return res } +// ===== the K-quant lane against the leaf per row ===== + +def private put_f16(var b : array; off : int64; v : float) { + let bits = f32_to_f16(v) + b[off] = uint8(bits & 0xFFu) + b[off + 1l] = uint8(bits >> 8u) +} + +//! one Q4_K superblock in the GGUF's disk layout: two f16 scales, 12 bytes of 6-bit sub-scales, 128 bytes of nibbles +def private q4k_disk_superblock() : array { + var b : array + b |> resize(144l) + put_f16(b, 0l, 0.375) + put_f16(b, 2l, 0.125) + for (i in range64(12l)) { + b[4l + i] = uint8((i * 37l + 13l) % 256l) + } + for (i in range64(128l)) { + b[16l + i] = uint8((i * 29l + 7l) % 256l) + } + return <- b +} + +//! [rows][n] of Q4_K planes as the GGUF transcoder writes them: the one superblock everywhere, its nibbles salted per row +def private build_q4k_planes(var kq : array; var ks : array; rows, n : int64; seed : uint) { + let nsb = n / 256l + let qsb = kq_qsb(4) + let ssb = kq_ssb(4) + var inscope blk <- q4k_disk_superblock() + kq |> resize(rows * nsb * qsb) + ks |> resize(rows * nsb * ssb) + for (r in range64(rows)) { + for (s in range64(nsb)) { + transcode_q4k_superblock(blk, 0l, kq, (r * nsb + s) * qsb, ks, (r * nsb + s) * ssb) + } + var st = seed + uint(r) * 0x85EBCA6Bu + for (i in range64(nsb * qsb)) { + st = st * 1664525u + 1013904223u + kq[r * nsb * qsb + i] = uint8(uint(kq[r * nsb * qsb + i]) ^ (st >> 16u)) + } + } +} + +//! the leaf the lane applies per row: the row requantized to the Q8_K form, dot_k4q8 over the disk-order planes, then the bias +def private kq_leaf_rows(kq, ks : array; x : array; t, nin, nout : int64; b : array; var want : array) { + let nsb = nin / 256l + let qsb = kq_qsb(4) + let ssb = kq_ssb(4) + var xq : array + var xs : array + var xbs : array + xq |> resize(nin) + xs |> resize(nsb) + xbs |> resize(nin / 16l) + want |> resize(t * nout) + unsafe { + for (p in range64(t)) { + quantize_q8_k_into_ptr(addr(x[p * nin]), nin, addr(xq[0]), addr(xs[0]), addr(xbs[0]), 0l, 0l, 0l) + for (o in range64(nout)) { + let d = dot_k4q8(addr(kq[o * nsb * qsb]), addr(ks[o * nsb * ssb]), + addr(xq[0]), addr(xs[0]), addr(xbs[0]), nin) + want[p * nout + o] = d + b[o] + } + } + } +} + +// linear_rows (the batched kq GEMM where the backend carries the tile, the GEMV per row otherwise) +// and linear_rows_decode (the GEMV) on the K-quant lane against the leaf per row; `repack` false +// holds the planes in disk order - the portable arm every tier without kq kernels runs; `poison` +// adds a fixed value to one expected element, which the bar must see +def private linear_case_kq(nin, nout : int64; decode, repack : bool; poison : bool = false) : BlockCase { + var r = TtsRng() + rng_seed(r, 0xebcul + uint64(nin)) + let t = decode ? 1l : T_ROWS + var l = TtsLinear(nin = nin, nout = nout) + rand_fill(r, l.b, nout, 0.5) + var kq : array + var ks : array + build_q4k_planes(kq, ks, nout, nin, 0x51ED270Bu) + var inscope x : array + rand_fill(r, x, t * nin, 2.0) + var inscope want : array + kq_leaf_rows(kq, ks, x, t, nin, nout, l.b, want) + if (poison) { + want[length(want) / 2] += 4.0 // a fixed value: the outputs run to a few thousand, so a quarter would hide inside the relative bar + } + linear_take_kq(l, 4, kq, ks, repack) // the planes move into the layer + var inscope got : array + if (decode) { + linear_rows_decode(l, x, got) + } else { + linear_rows(l, x, t, got) + } + var inscope env : array + env |> resize(t * nout) + for (i in range64(t * nout)) { + env[i] = abs(want[i]) + 1.0 + } + let name = "linear kq {nin}x{nout} {decode ? "decode" : "rows"}{repack ? "" : " disk-order"}{poison ? " poisoned" : ""}" + var res = score_at(name, got, want, env, 0.0, KQ_REL) + if (l.kq_fmt != 4 || !empty(l.w) || (repack && kernel_backend_has_kq() != l.kq_repacked)) { + res.bad += 1000000 // the kq lane never engaged, or the repack did not follow the backend + } + return res +} + +// a width the 256-superblock does not divide is refused by name: a case with one bad element +// when the panic text is not the layer's, none when it is +def private kq_width_refusal() : BlockCase { + var res = BlockCase(name = "linear kq width refusal") + var why = "no panic" + try { + var l = TtsLinear(nin = 100l, nout = 8l) + var kq : array + var ks : array + linear_take_kq(l, 4, kq, ks, false) + } recover { + why = this_context().last_exception |> rtrim + } + if (why |> find("needs nin on 256") < 0) { + res.bad = 1 + res.name = "linear kq width refusal - got '{why}'" + } + return res +} + // 66 input channels padded to 96 quantize per 32, so the padded conv reaches the q8 lane: the // padded rows form against the unpadded channel-major reference at the q8 bar. def private pad_case_q8() : BlockCase { @@ -926,6 +1060,15 @@ def test_rows_convs(t : T?) { report(t, conv_case_q8("conv q8 k11 d5", 256l, 256l, 11l, 25l, 5l)) report(t, linear_case_q8(768l, 768l)) } + t |> run("the K-quant lane against the leaf per row - both arms, and a width off the float4 bias") @(t : T?) { + report(t, linear_case_kq(512l, 96l, false, true)) + report(t, linear_case_kq(768l, 64l, true, true)) + report(t, linear_case_kq(512l, 96l, false, false)) + report(t, linear_case_kq(768l, 64l, true, false)) + report(t, linear_case_kq(256l, 6l, true, true)) // nout off 4: the bias tail past the last float4 + report_poison(t, linear_case_kq(512l, 96l, false, true, true)) + report(t, kq_width_refusal()) + } t |> run("snake, adain, pad, polar") @(t : T?) { report(t, snake_case()) report(t, adain_case(64l)) diff --git a/modules/dasLLAMA/tests/test_tts_pocket.das b/modules/dasLLAMA/tests/test_tts_pocket.das index be5e652a7c..98dcfa73de 100644 --- a/modules/dasLLAMA/tests/test_tts_pocket.das +++ b/modules/dasLLAMA/tests/test_tts_pocket.das @@ -623,6 +623,8 @@ def test_pocket_q8_file(t : T?) { } let Q8_FILE_BAR = 5.0e-2lf +let KQ_EXACT_BAR = 5.0e-2lf //! the kq lane against the exact lane of the same file (measured 3.4e-2): the head's Q8_0 route and the activation quants +let KQ_HEAD_BAR = 2.0e-2lf //! the flow head on its Q8_0 route against the f32 oracle's frames (measured 9.8e-3), the oracle's own conditioning and noise in def private kq_gguf_path() : string { return path_join(models_dir(), "pocket-tts-en-kq.gguf") @@ -659,41 +661,96 @@ def test_pocket_kq_file(t : T?) { } t |> equal(kq_layers, length(mk.backbone.layers), "every backbone layer's four GEMMs came in as Q4_K planes") t |> success(mk.mimi.dec_tf.layers[0].ffn1.kq_fmt == 4 && mk.mimi.enc_tf.layers[0].in_proj.kq_fmt == 4, "the codec transformers' matrices too") - t |> success(mk.head.blocks[0].mlp1.q8 && mk.head.final_linear.q8 && mk.head.cond_embed.q8, "the flow head's matrices came in as Q8_0 blocks") + t |> success(mk.head.blocks[0].mlp1.q8 && mk.head.final_linear.q8 && mk.head.cond_embed.q8 && mk.head.input_proj.q8, "the flow head's matrices came in as Q8_0 blocks, its 32-wide input projection included") t |> success(!mk.speaker_proj.q8 && mk.speaker_proj.kq_fmt == 0, "the speaker projection stays f32") t |> equal(long_length(mk.text_emb.a), 4001l * mk.backbone.d, "the Q4_K embedding table dequantized into its f32 form") - // the same file on the q8 lane: every Q4_K tensor dequantized and requantized to Q8_0 - - // the same weights, so the two lanes differ by their activation quants alone + // the same file on the q8 lane: every Q4_K tensor dequantized and requantized to Q8_0 (a + // re-encode of the same values), so the two lanes differ by that re-encode and their + // activation quants; the head is the same Q8_0 blocks on both set_pocket_q8(true) var inscope mq <- load_pocket(kqpath) t |> success(mq.backbone.layers[0].in_proj.q8 && mq.backbone.layers[0].in_proj.kq_fmt == 0, "pinned q8, the K-quant tensor is Q8_0 blocks") - reset_pocket_q8() + // the exact lane over the K-quant file: dequantized f32 planes, the head dequantized too - + // the one load whose head is not the Q8_0 route, so it is what pins that route + set_pocket_q8(false) + var inscope m32 <- load_pocket(kqpath) + t |> success(!m32.q8 && m32.backbone.layers[0].in_proj.kq_fmt == 0 && !m32.backbone.layers[0].in_proj.q8, "pinned f32, the K-quant tensor is an f32 plane") + t |> success(!m32.head.blocks[0].mlp1.q8, "pinned f32, the head is f32 too") + // the f16 file on its f32 lane: the reference the whole small form is measured against var inscope mf <- load_pocket(path) + t |> success(!mf.q8 && !mf.backbone.layers[0].in_proj.q8, "the f16 reference serves its f32 lane") + reset_pocket_q8() + var compared = 0 for (c in man.cases) { continue if (!c.stages || c.voice != "alba") - var inscope a <- forced_latents(mk, c, man, sc) - var inscope b <- forced_latents(mq, c, man, sc) - let rel = rms_relative(a, b) - to_log(LOG_INFO, "pocket {c.id} kq lane vs the q8 lane of the same file: rms relative {rel}\n") - t |> success(rel < Q8_FILE_BAR, "{c.id}: the kq lane and the q8 lane of one file agree (rms relative {rel})") - var inscope poisoned := b - for (v in poisoned) { - v += 2.0 - } - t |> success(rms_relative(a, poisoned) >= Q8_FILE_BAR, "{c.id}: the bar discriminates a poisoned expectation") - var inscope f <- forced_latents(mf, c, man, sc) - to_log(LOG_INFO, "pocket {c.id} kq lane vs the f16 reference: rms relative {rms_relative(a, f)} (the format's loss; the rig is its gate)\n") + compared++ + kq_lanes_case(t, c, man, mk, mq, m32, mf, sc) break } - // the exact lane over the K-quant file: dequantized f32 planes - set_pocket_q8(false) - var inscope m32 <- load_pocket(kqpath) - t |> success(!m32.q8 && m32.backbone.layers[0].in_proj.kq_fmt == 0 && !m32.backbone.layers[0].in_proj.q8, "pinned f32, the K-quant tensor is an f32 plane") - var inscope f32 <- load_tts_model(kqpath) - var inscope spoken <- synthesize(f32, "The quick brown fox jumps over the lazy dog.", "alba") - let st = speech_stats(spoken.pcm) - t |> success(st.finite && st.rms > 0.01, "the exact lane of the K-quant file speaks (rms {st.rms})") + t |> success(compared > 0, "the oracle manifest carries a stages case in alba - the lanes were compared") + kq_clone_over_roster(t, kqpath) + } +} + +// the three lanes of the small file on one oracle case: kq against q8 at the q8 bar (with the +// poisoned control), kq against the exact lane (the head's Q8_0 route), the f16 reference logged; +// then the stored roster against the reference encoder (the f16 file on its f32 lane) +def private kq_lanes_case(t : T?; c : PocketCase; man : PocketManifest; var mk, mq, m32, mf : PocketModel; var sc : PocketScratch) { + var inscope a <- forced_latents(mk, c, man, sc) + var inscope b <- forced_latents(mq, c, man, sc) + let rel = rms_relative(a, b) + to_log(LOG_INFO, "pocket {c.id} kq lane vs the q8 lane of the same file: rms relative {rel}\n") + t |> success(rel < Q8_FILE_BAR, "{c.id}: the kq lane and the q8 lane of one file agree (rms relative {rel})") + var inscope poisoned := b + for (v in poisoned) { + v += 2.0 + } + t |> success(rms_relative(a, poisoned) >= Q8_FILE_BAR, "{c.id}: the bar discriminates a poisoned expectation") + var inscope e <- forced_latents(m32, c, man, sc) + let rel32 = rms_relative(a, e) + to_log(LOG_INFO, "pocket {c.id} kq lane vs the exact lane of the same file: rms relative {rel32} (the head's Q8_0 route and the activation quants)\n") + t |> success(rel32 < KQ_EXACT_BAR, "{c.id}: the kq lane and the exact lane of one file agree (rms relative {rel32})") + var inscope f <- forced_latents(mf, c, man, sc) + to_log(LOG_INFO, "pocket {c.id} kq lane vs the f16 reference: rms relative {rms_relative(a, f)} (the format's loss; the rig is its gate)\n") + var inscope dump <- load_tts_dump(path_join(oracle_dir(), c.file)) + // the head alone on its Q8_0 route - Q8_0 blocks and a Q8_0-quantized activation vector per + // GEMV - fed the oracle's own conditioning and noise, against the oracle's frames + var inscope x0 := dump["x0"].f + var inscope hk <- head_over_frames(mk, sc, dump["cond"].f, x0, int64(c.n_frames)) + let rel_head = rms_relative(hk, dump["x1"].f) + to_log(LOG_INFO, "pocket {c.id} the flow head on Q8_0 blocks vs the oracle: rms relative {rel_head}\n") + t |> success(rel_head < KQ_HEAD_BAR, "{c.id}: the head's Q8_0 route holds the oracle's frames (rms relative {rel_head})") + var inscope head_poisoned := dump["x1"].f + for (v in head_poisoned) { + v += 2.0 + } + t |> success(rms_relative(hk, head_poisoned) >= KQ_HEAD_BAR, "{c.id}: the head bar discriminates a poisoned expectation") + var inscope lat : array + let frames = pocket_encode_latents(mf, dump["voice_pcm"].f, sc, lat) // the f16 file's encoder, f32 weights: the parity rail's own; the kq file's carries Q4_K and Q8_0 + var inscope stored : array + mk.voice_latents |> get("alba") $(s) { + stored := s + } + t |> equal(long_length(stored), frames * mk.latent_dim, "alba's stored frames are the clip's frame count") + rel_diff(t, "{c.id} stored roster vs the reference encoder", stored, lat, ENCODER_BAR) +} + +// a clone over a roster name: alba re-registered from a clip keeps the roster at 19 and speaks +def private kq_clone_over_roster(t : T?; kqpath : string) { + var inscope mkf <- load_tts_model(kqpath) + var inscope clip : array + clip |> resize(24000 * 3) + var r = TtsRng() + rng_seed(r, 7ul) + for (i in range(length(clip))) { + clip[i] = 0.3 * sin(float(i) * 0.05) * (0.5 + 0.5 * rng_normal(r)) } + tts_register_voice(mkf, "alba", clip, 24000) + var inscope ck <- caps(mkf) + t |> equal(length(ck.voices), 19, "a clone under a roster name replaces the voice, the roster count stays") + var inscope spoken <- synthesize(mkf, "The quick brown fox jumps over the lazy dog.", "alba") + let st = speech_stats(spoken.pcm) + t |> success(st.finite && st.rms > 0.01, "the replaced roster voice speaks (rms {st.rms})") } // ===== the other five languages: one Q8_0 file each, its own tokenizer, one default voice ===== @@ -853,6 +910,7 @@ def test_pocket_facade(t : T?) { m.pocket.voices |> get("caro_davy") $(pcm) { clip := pcm } + t |> success(!empty(clip), "the parity file stores its roster as clips (the older form) - caro_davy's is the clone's source") tts_register_voice(m, "cloned", clip, 24000) var inscope c2 <- caps(m) t |> equal(length(c2.voices), 20, "the cloned voice joined the roster") diff --git a/site-dasllama/REVIEW.md b/site-dasllama/REVIEW.md index 9897a7e160..739dd0e1e1 100644 --- a/site-dasllama/REVIEW.md +++ b/site-dasllama/REVIEW.md @@ -46,7 +46,8 @@ publish time is a defect.** A dated `_news` or `_stories` entry is read as a cla date; standing page copy - a masthead, section prose, a meta tag - is read as a claim about now. `README.md`'s copy rules say how a claim is checked. -**A diff that falsifies standing page copy - a masthead, section prose, a meta tag - fixes it +**A diff that falsifies standing page copy - a masthead, section prose, a meta tag - or a +`_news/*.md` / `_stories/*.md` entry dated on or after the publish day of this change, fixes it in the same change.** **A PR whose copy describes what a linked download contains refreshes that artifact at its diff --git a/site-dasllama/_news/2026-09-10-parrot.md b/site-dasllama/_news/2026-09-10-parrot.md new file mode 100644 index 0000000000..ec29f5afc5 --- /dev/null +++ b/site-dasllama/_news/2026-09-10-parrot.md @@ -0,0 +1,14 @@ +--- +date: 2026-09-10 +tag: examples +title: Parrot - talk for a few seconds, and the browser reads the poem in your voice. +--- + +A third dasLLAMA example on the examples page. Press record and talk; Silero VAD ends the take +when you go quiet, Pocket TTS clones the voice from it, and the text in the box - Frost's +"Stopping by Woods on a Snowy Evening" to begin with, or whatever you type - is read in that +voice. The recording stays in the tab. Behind it the English Pocket file shrank from 152 MB to +75: the backbone and the codec transformers as Q4_K, the flow head Q8_0, the codec encoder +still inside; on our 200-sentence rig it reads 3.86 WER / 4.295 UTMOS against the q8 file's +3.91 / 4.328, and we could not hear the difference. Storywish now reads through the same form +with one voice and no encoder, 65 MB. [Try it](examples.html). diff --git a/site-dasllama/_news/2026-09-10-storywish.md b/site-dasllama/_news/2026-09-10-storywish.md index 57b884e054..4d1872ea2d 100644 --- a/site-dasllama/_news/2026-09-10-storywish.md +++ b/site-dasllama/_news/2026-09-10-storywish.md @@ -9,8 +9,8 @@ GPT-Neo, which no GGUF engine runs, so we trained our own: `tinystories-instruct 27M-parameter llama on the TinyStoriesInstruct corpus with a 4K vocabulary, 15 minutes on one H100. Asked for three words over five word triples, 24 sampled stories each, it puts all three in 61 of the 120 stories; the official 33M puts them in 60. It -ships as a 32 MB `.dlim` beside KittenTTS nano; you type the words, Enter tells the story, Tab -asks for dialogue. The model, the training recipe and the hit-rate numbers are on +ships as a 32 MB `.dlim` beside a 65 MB Pocket TTS file with one voice inside and no phoneme +packs; you type the words, Enter tells the story, Tab asks for dialogue. The model, the training recipe and the hit-rate numbers are on [Hugging Face](https://huggingface.co/borisbat/dasllama-stories). The model sets of both examples are now minted by the deploy for the build it ships, so a format bump can no longer leave a page silently declining its images. [Try it](examples.html). diff --git a/site-dasllama/feed.xml b/site-dasllama/feed.xml index 44cfdf7c38..3bf5f86a6c 100644 --- a/site-dasllama/feed.xml +++ b/site-dasllama/feed.xml @@ -16,13 +16,27 @@ GPT-Neo, which no GGUF engine runs, so we trained our own: <code>tinystori 27M-parameter llama on the TinyStoriesInstruct corpus with a 4K vocabulary, 15 minutes on one H100. Asked for three words over five word triples, 24 sampled stories each, it puts all three in 61 of the 120 stories; the official 33M puts them in 60. It -ships as a 32 MB <code>.dlim</code> beside KittenTTS nano; you type the words, Enter tells the story, Tab -asks for dialogue. The model, the training recipe and the hit-rate numbers are on +ships as a 32 MB <code>.dlim</code> beside a 65 MB Pocket TTS file with one voice inside and no phoneme +packs; you type the words, Enter tells the story, Tab asks for dialogue. The model, the training recipe and the hit-rate numbers are on <a href="https://huggingface.co/borisbat/dasllama-stories">Hugging Face</a>. The model sets of both examples are now minted by the deploy for the build it ships, so a format bump can no longer leave a page silently declining its images. <a href="examples.html">Try it</a>.</p> +Parrot - talk for a few seconds, and the browser reads the poem in your voice. + +https://dasllama.io/#n-2026-09-10-parrot +2026-09-10T00:00:00Z +<p>A third dasLLAMA example on the examples page. Press record and talk; Silero VAD ends the take +when you go quiet, Pocket TTS clones the voice from it, and the text in the box - Frost's +"Stopping by Woods on a Snowy Evening" to begin with, or whatever you type - is read in that +voice. The recording stays in the tab. Behind it the English Pocket file shrank from 152 MB to +75: the backbone and the codec transformers as Q4_K, the flow head Q8_0, the codec encoder +still inside; on our 200-sentence rig it reads 3.86 WER / 4.295 UTMOS against the q8 file's +3.91 / 4.328, and we could not hear the difference. Storywish now reads through the same form +with one voice and no encoder, 65 MB. <a href="examples.html">Try it</a>.</p> + + dasllama-server is a download now - one archive per platform. https://dasllama.io/#n-2026-09-07-download-dasllama-server diff --git a/site-dasllama/index.html b/site-dasllama/index.html index 16ba6a68fe..798efd4097 100644 --- a/site-dasllama/index.html +++ b/site-dasllama/index.html @@ -70,12 +70,23 @@

Local inference, written in daslang.

27M-parameter llama on the TinyStoriesInstruct corpus with a 4K vocabulary, 15 minutes on one H100. Asked for three words over five word triples, 24 sampled stories each, it puts all three in 61 of the 120 stories; the official 33M puts them in 60. It -ships as a 32 MB .dlim beside KittenTTS nano; you type the words, Enter tells the story, Tab -asks for dialogue. The model, the training recipe and the hit-rate numbers are on +ships as a 32 MB .dlim beside a 65 MB Pocket TTS file with one voice inside and no phoneme +packs; you type the words, Enter tells the story, Tab asks for dialogue. The model, the training recipe and the hit-rate numbers are on
Hugging Face. The model sets of both examples are now minted by the deploy for the build it ships, so a format bump can no longer leave a page silently declining its images. Try it.

+
+
2026-09-10examples

Parrot - talk for a few seconds, and the browser reads the poem in your voice.

+

A third dasLLAMA example on the examples page. Press record and talk; Silero VAD ends the take +when you go quiet, Pocket TTS clones the voice from it, and the text in the box - Frost's +"Stopping by Woods on a Snowy Evening" to begin with, or whatever you type - is read in that +voice. The recording stays in the tab. Behind it the English Pocket file shrank from 152 MB to +75: the backbone and the codec transformers as Q4_K, the flow head Q8_0, the codec encoder +still inside; on our 200-sentence rig it reads 3.86 WER / 4.295 UTMOS against the q8 file's +3.91 / 4.328, and we could not hear the difference. Storywish now reads through the same form +with one voice and no encoder, 65 MB. Try it.

+
2026-09-07site

dasllama-server is a download now - one archive per platform.

The OpenAI-compatible server ships as a standalone bundle for macOS (Apple silicon), Windows diff --git a/utils/dasllama-server/README.md b/utils/dasllama-server/README.md index 8af526e6ad..3913449d04 100644 --- a/utils/dasllama-server/README.md +++ b/utils/dasllama-server/README.md @@ -184,10 +184,11 @@ Catalog entries carry their **towers**: a vision-capable row offers its pinned m under the table offers the ASR tower (parakeet v3; wires the `asr` key the same way) - `POST /catalog/download` takes `{"name", "tower": "vision"}` or `{"tower": "asr"}` on the same one-at-a-time rail. A **speech** strip sits beside it for the text-to-speech set the -`/catalog` document's `tts` list carries: the two front-end packs first (every speech model -loads them), then one model, then **enable speech** wires the `tts` key - -`{"tower": "tts", "file": }` pulls one file of that set on the same rail; a Pocket file is -its whole set. Setup-mode +`/catalog` document's `tts` list carries: a model on disk that can speak goes straight to +**enable speech**, which wires the `tts` key (a Pocket file stands alone; a phoneme family +needs the two front-end packs beside it - `needs_packs` on the row); otherwise the packs first, +then one model - `{"tower": "tts", "file": }` pulls one file of that set on the same rail. +Setup-mode **serve this model** wires any tower already on disk automatically. Each row also wears a **fit badge** (fits gpu / fits / tight / too big) from the box facts the `/catalog` document carries (`box.ram_gb`, and the armed tier's weight @@ -309,7 +310,7 @@ server first; Windows locks the DLLs. | `POST` | `/v1/audio/speech` | Text->speech (needs `--tts`): `{"input", "voice"?, "speed"?, "response_format"?: "wav" \| "pcm"}` - the OpenAI shape; `wav` (default) is 16-bit PCM at the model's rate, `pcm` the raw samples; the compressed formats answer `400` (no encoder here). One synthesis at a time on the TTS worker (its kernels run inline under `hybrid`, like the ASR workers'), 16 queued | | `POST` | `/v1/audio/phonemes` | The front end alone (needs `--tts`): `{"model"?, "input", "voice"?}` -> `{"normalized", "lang", "chunks": [{"text", "phonemes"}]}` - the normalizer's spoken form of the text, the dialect the voice speaks (`lang`), then one row per chunk a synthesis of it would take, each carrying that chunk beside its phoneme string in that dialect. A model whose front end phonemizes ONE language reads every voice name in it - an alias, or a name it does not carry, since there is no other answer to give; a model that phonemizes several requires a voice from its `caps` and refuses an unservable one with the speech route's own 400. `model` is read the way the speech route reads it (`404` on an id that is not the served one). Answered by the TTS worker on the same queue as a synthesis (the same 4096-CHARACTER cap - codepoints, not bytes - and the same 503 when no speech model is served), so the speech studio can show what the model will actually say | | `POST` | `/vad` | Silero speech spans over an uploaded clip (the control page's waveform overlay; in-handler, <=120 s, needs the in-repo `silero_vad.bin`) | -| `GET` | `/catalog` | The curated model list with local presence, the `asr` tower row, the `tts` list (the three speech GGUFs and the two front-end packs, each `file`/`bytes`/`pack`/`present`/`path`), the `box` memory facts + the download state machine (`idle | downloading | verifying | done | failed`, byte progress) | +| `GET` | `/catalog` | The curated model list with local presence, the `asr` tower row, the `tts` list (the two front-end packs the speech route loads, then every served speech GGUF, each `file`/`bytes`/`pack`/`present`/`path`/`needs_packs` - on a model, whether the file on disk reads the packs, true until it is here; false on a pack), the `box` memory facts + the download state machine (`idle | downloading | verifying | done | failed`, byte progress) | | `POST` | `/catalog/download` | `{"name": }` - start one catalog download; `{"name", "tower": "vision"}` / `{"tower": "asr"}` pull a tower, `{"tower": "tts", "file": }` one file of the speech set (409 while one runs or the file exists; sha-verified, never waived) | | `POST` | `/bench` | Loopback-only: start the benchmark, quiesced. In process by default: pp512 and tg128 on the served model, an untimed warmup then three timed reps each, one pp prefill or one tg token per tick, the text, audio and speech routes (503) and the model-switching, bake and catalog-download routes (409) holding until it finishes - in either mode; with `lcpp_bin` in the config on a source-tree daslang, the A/B child instead - our lcpp_bench then llama-bench on the same GGUF. 400 in setup mode or when the served context is shorter than pp512, 409 while a bench, a bake or a catalog download runs or streams are active, 503 while draining | | `GET` | `/bench` | Bench state (`idle | running | done | failed`), `mode` (`inprocess` | `ab`: what a `POST` runs), live log lines, the result JSON - `ours_pp`, `ours_tg`, `threads`, `elapsed_s`, `ts`; in process also `mode`, `pp_sd`, `tg_sd`, `reps`, `model`, `gguf`, `backend` (`metal`, `gpu:resident`, or the slot's word), `kv` (the codec the rows ran), `exec` (`exe-native` | `jit` | `interpreted`), `tune` (`fat` | `tuned` | `untuned (N of M on fallback)` | `none`) and `ref_cmd` (the llama-bench line for the comparison); the A/B also `theirs_pp`, `theirs_tg`, `pp_ratio`, `tg_ratio` and `record` - and the hardware line | diff --git a/utils/dasllama-server/control.html b/utils/dasllama-server/control.html index aeb6da1c3a..6abd3059af 100644 --- a/utils/dasllama-server/control.html +++ b/utils/dasllama-server/control.html @@ -3238,10 +3238,12 @@ strip.append(b, label); } -// the download ladder over /catalog's `tts` list: the two front-end packs first (every speech -// model loads them), then the models, then the enable. The rail runs one download at a time, -// so an absent pack pair is one button and the card re-offers what is still missing. -// False back = the catalog has no tts list to offer from, and the caller says so instead. +// the download ladder over /catalog's `tts` list: a model on disk that can speak - a Pocket file +// stands alone, a phoneme family reads the two front-end packs beside it - goes straight to the +// enable; otherwise the packs first (every phoneme family loads them), then the models. The rail +// runs one download at a time, so an absent pack pair is one button and the card re-offers what +// is still missing. False back = the catalog has no tts list to offer from, and the caller says +// so instead. function ttsOfferArm(strip, dl, kept) { const items = (catState && catState.tts) || []; if (!items.length) return false; @@ -3253,30 +3255,31 @@ return true; } const missingPacks = items.filter(i => i.pack && !i.present); + const models = items.filter(i => !i.pack); + const present = models.filter(m => m.present && (!m.needs_packs || !missingPacks.length)) + .sort((a, b) => a.bytes - b.bytes); + if (present.length) { + ttsEnableArm(strip, label, present, kept); + return true; + } if (missingPacks.length) { const bytes = missingPacks.reduce((a, p) => a + p.bytes, 0); const b = miniButton("download the front-end packs (" + fmtSize(bytes) + ")", () => startTtsDownload(missingPacks[0].file)); - b.title = "the text normalizer and grapheme-to-phoneme tables every speech model loads"; + b.title = "the text normalizer and grapheme-to-phoneme tables every phoneme family loads"; b.disabled = dlBusy(dl); label.textContent = "add speech synthesis — /v1/audio/speech and the speech studio above"; strip.append(b, label); return true; } - const models = items.filter(i => !i.pack); - const present = models.filter(m => m.present).sort((a, b) => a.bytes - b.bytes); - if (!present.length) { - label.textContent = "the front-end packs are here — pick a speech model"; - for (const m of models) { - const b = miniButton("download " + ttsName(m.file) + " (" + fmtSize(m.bytes) + ")", - () => startTtsDownload(m.file)); - b.disabled = dlBusy(dl); - strip.append(b); - } - strip.append(label); - return true; + label.textContent = "the front-end packs are here — pick a speech model"; + for (const m of models) { + const b = miniButton("download " + ttsName(m.file) + " (" + fmtSize(m.bytes) + ")", + () => startTtsDownload(m.file)); + b.disabled = dlBusy(dl); + strip.append(b); } - ttsEnableArm(strip, label, present, kept); + strip.append(label); return true; } diff --git a/utils/dasllama-server/model_catalog.das b/utils/dasllama-server/model_catalog.das index d4a5fcbebf..70164d94f3 100644 --- a/utils/dasllama-server/model_catalog.das +++ b/utils/dasllama-server/model_catalog.das @@ -16,6 +16,12 @@ require daslib/jobque_boost require daslib/strings_boost require strings +//! The front-end packs the speech route loads beside a phoneme family's GGUF - the pack rows the +//! /catalog document's `tts` list carries. A companion of the served set outside this list (the +//! browser's American-only phoneme pack) is fetchable through `catalog_download_start_tts` and +//! rides no ladder. +let public TTS_FRONT_END_PACKS : array <- ["tts_g2p.bin", "tts_postag.bin"] + //! One curated model: a commit-pinned, ungated HF file this engine serves today. //! `vram_hint_gb` is the advertised working-set hint, not a fit check. The `vision_*` //! fields carry the row's pinned vision tower (mmproj) when it has one — "" = text only. @@ -341,9 +347,11 @@ def public catalog_download_tick() { } //! The GET /catalog document: entries with local presence (model AND vision tower), the ASR -//! tower, the box's memory facts (`ram_gb` in GiB, `vram_mb` in MiB, 0 = unknown — the page's fit badges), -//! plus the download state machine. -def public catalog_state_json(dir : string; ram_gb : int = 0; vram_mb : int64 = 0l) : string { +//! tower, the `tts` list (the two front-end packs, then every served speech model; a model row's +//! `needs_packs` is `needs_packs(path)` on the file once it is here - the server passes the +//! engine's family test - and true until then), the box's memory facts (`ram_gb` in GiB, +//! `vram_mb` in MiB, 0 = unknown — the page's fit badges), plus the download state machine. +def public catalog_state_json(dir : string; ram_gb : int; vram_mb : int64; needs_packs : function<(path : string) : bool>) : string { var cat <- model_catalog() var entries : array entries |> reserve(length(cat)) @@ -367,10 +375,12 @@ def public catalog_state_json(dir : string; ram_gb : int = 0; vram_mb : int64 = var tts_items : array tts_items |> reserve(length(tts_set)) for (item in tts_set) { + continue if (item.pack && !has_value(TTS_FRONT_END_PACKS, item.entry.name)) let p = path_join(dir, item.entry.name) let present = stat(p).is_valid + let packs = !item.pack && (!present || invoke(needs_packs, p)) tts_items |> push(JV((file = item.entry.name, bytes = item.entry.bytes, pack = item.pack, - present = present, path = present ? p : ""))) + present = present, path = present ? p : "", needs_packs = packs))) } var got = 0l if (g_dl_state == "downloading") { diff --git a/utils/dasllama-server/openai_server.das b/utils/dasllama-server/openai_server.das index e36d99725e..413dacad7e 100644 --- a/utils/dasllama-server/openai_server.das +++ b/utils/dasllama-server/openai_server.das @@ -179,7 +179,6 @@ let TTS_LANE_DEFAULT = "q8" var g_tts_lane = TTS_LANE_DEFAULT // the weight lane the worker is ASKED for; its ready event echoes the pin it took var g_tts_voices_dir = "" // clips a cloning model adds to its voices at boot, each under its file's stem let TTS_VOICE_CLIP_EXTENSIONS : array <- [".wav", ".flac", ".mp3", ".ogg"] // what the decode rail reads -let TTS_FRONT_END_PACKS : array <- ["tts_g2p.bin", "tts_postag.bin"] // load_tts_model reads both from the GGUF's directory var g_shutdown_requested = false var g_gc_requested = false var g_req_counter = 0l @@ -5285,7 +5284,7 @@ class OpenAiServer : HvWebServer { return handle_bake_start(req, resp) } GET("/catalog") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { - return resp |> JSON(catalog_state_json(g_catalog_dir, g_box_ram_gb, gpu_weight_budget_bytes() / (1024l * 1024l))) + return resp |> JSON(catalog_state_json(g_catalog_dir, g_box_ram_gb, gpu_weight_budget_bytes() / (1024l * 1024l), @@tts_needs_packs)) } POST("/catalog/download") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { if (!is_loopback_req(req)) return deny_operator(resp) diff --git a/utils/dasllama-server/test_model_catalog.das b/utils/dasllama-server/test_model_catalog.das index 38621a1648..ffdbb25012 100644 --- a/utils/dasllama-server/test_model_catalog.das +++ b/utils/dasllama-server/test_model_catalog.das @@ -183,12 +183,18 @@ def test_download_refusals(t : T?) { } } +// the family test the document is handed in place of the engine's: a file named as a Pocket +// form reads text and needs no pack, everything else is a phoneme family +def private needs_packs_by_name(path : string) : bool { + return find(base_name(path), "pocket-tts-") < 0 +} + [test] def test_state_json(t : T?) { t |> run("the /catalog document carries every entry with presence and an idle machine") @(t : T?) { let dir = tmp_models_dir("doc") var jerr = "" - var doc = read_json(catalog_state_json(dir, 64, 8192l), jerr) + var doc = read_json(catalog_state_json(dir, 64, 8192l, @@needs_packs_by_name), jerr) t |> success(doc != null, "state json parses: {jerr}") if (doc != null) { t |> equal(doc?["models_dir"] ?? "", dir) @@ -228,7 +234,7 @@ def test_state_json(t : T?) { } } var jerr = "" - var doc = read_json(catalog_state_json(dir), jerr) + var doc = read_json(catalog_state_json(dir, 0, 0l, @@needs_packs_by_name), jerr) t |> success(doc != null, "state json parses: {jerr}") let entries = doc?["entries"] if (entries != null && (entries.value is _array)) { @@ -250,6 +256,51 @@ def test_state_json(t : T?) { } } +[test] +def test_state_json_tts(t : T?) { + t |> run("the tts list: the front-end packs the route loads, then the models, each saying whether it reads them") @(t : T?) { + let dir = tmp_models_dir("ttsdoc") + let planted = path_join(dir, "pocket-tts-en-stuart-kq.gguf") + fopen(planted, "wb") $(f) { + if (f != null) { + f |> fwrite("GGUF") + } + } + var jerr = "" + var doc = read_json(catalog_state_json(dir, 0, 0l, @@needs_packs_by_name), jerr) + t |> success(doc != null, "state json parses: {jerr}") + let items = doc?["tts"] + t |> success(items != null && (items.value is _array), "tts is an array") + if (items != null && (items.value is _array)) { + var packs : array + var models = 0 + for (item in items.value as _array) { + let file = "{item?["file"] ?? ""}" + if (item?["pack"] ?? false) { + packs |> push(file) + t |> equal(item?["needs_packs"] ?? true, false, "{file}: a pack reads no pack") + } else { + models++ + let present = item?["present"] ?? false + t |> equal(present, file == "pocket-tts-en-stuart-kq.gguf", "{file}: presence follows the dir") + t |> equal(item?["needs_packs"] ?? false, !present, "{file}: an absent model reads as needing the packs, the planted Pocket file as not") + } + } + t |> equal(packs, TTS_FRONT_END_PACKS, "the pack rows are the route's own list, in its order - the browser's American twin rides no ladder") + var served = 0 + for (item in serve_tts_set()) { + served += item.pack ? 0 : 1 + } + t |> equal(models, served, "every served model is a row") + } + unsafe { + delete doc + } + remove(planted) + rmdir(dir) + } +} + [test] def test_fixture_path_normalization(t : T?) { t |> run("the captured catalog fixtures carry only normalized paths") @(t : T?) { diff --git a/utils/dasllama-server/tests/fixtures/catalog_done.json b/utils/dasllama-server/tests/fixtures/catalog_done.json index 5378d8b2e6..ac9fc3317e 100644 --- a/utils/dasllama-server/tests/fixtures/catalog_done.json +++ b/utils/dasllama-server/tests/fixtures/catalog_done.json @@ -1 +1 @@ -{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "C:\\Users\\user\\.dasllama\\models\\gemma-4-E2B-it-Q4_K_M.gguf", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": true, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": ""}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": ""}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": ""}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": ""}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": ""}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "done", "name": "gemma-4-e2b", "got": 0, "total": 3106738272, "error": ""}} \ No newline at end of file +{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "C:\\Users\\user\\.dasllama\\models\\gemma-4-E2B-it-Q4_K_M.gguf", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": true, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-q8.gguf", "bytes": 152613664, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-de-q8.gguf", "bytes": 134667200, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-es-q8.gguf", "bytes": 134624480, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-it-q8.gguf", "bytes": 134415072, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-pt-q8.gguf", "bytes": 134667488, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-fr-q8.gguf", "bytes": 375793696, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-kq.gguf", "bytes": 74970016, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-stuart-kq.gguf", "bytes": 65107520, "pack": false, "present": false, "path": "", "needs_packs": true}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "done", "name": "gemma-4-e2b", "got": 0, "total": 3106738272, "error": ""}} \ No newline at end of file diff --git a/utils/dasllama-server/tests/fixtures/catalog_downloading.json b/utils/dasllama-server/tests/fixtures/catalog_downloading.json index e34da76038..e820e54050 100644 --- a/utils/dasllama-server/tests/fixtures/catalog_downloading.json +++ b/utils/dasllama-server/tests/fixtures/catalog_downloading.json @@ -1 +1 @@ -{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": false, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": ""}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": ""}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": ""}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": ""}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": ""}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "downloading", "name": "gemma-4-e2b", "got": 157736960, "total": 3106738272, "error": ""}} \ No newline at end of file +{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": false, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-q8.gguf", "bytes": 152613664, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-de-q8.gguf", "bytes": 134667200, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-es-q8.gguf", "bytes": 134624480, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-it-q8.gguf", "bytes": 134415072, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-pt-q8.gguf", "bytes": 134667488, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-fr-q8.gguf", "bytes": 375793696, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-kq.gguf", "bytes": 74970016, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-stuart-kq.gguf", "bytes": 65107520, "pack": false, "present": false, "path": "", "needs_packs": true}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "downloading", "name": "gemma-4-e2b", "got": 160342016, "total": 3106738272, "error": ""}} \ No newline at end of file diff --git a/utils/dasllama-server/tests/fixtures/catalog_empty.json b/utils/dasllama-server/tests/fixtures/catalog_empty.json index 10f183648c..4b06409c8e 100644 --- a/utils/dasllama-server/tests/fixtures/catalog_empty.json +++ b/utils/dasllama-server/tests/fixtures/catalog_empty.json @@ -1 +1 @@ -{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": false, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": ""}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": ""}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": ""}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": ""}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": ""}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "idle", "name": "", "got": 0, "total": 0, "error": ""}} \ No newline at end of file +{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": false, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": false, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": false, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": false, "vision_path": "", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": false, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": false, "path": "", "needs_packs": false}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-q8.gguf", "bytes": 152613664, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-de-q8.gguf", "bytes": 134667200, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-es-q8.gguf", "bytes": 134624480, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-it-q8.gguf", "bytes": 134415072, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-pt-q8.gguf", "bytes": 134667488, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-fr-q8.gguf", "bytes": 375793696, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-kq.gguf", "bytes": 74970016, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-stuart-kq.gguf", "bytes": 65107520, "pack": false, "present": false, "path": "", "needs_packs": true}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "idle", "name": "", "got": 0, "total": 0, "error": ""}} \ No newline at end of file diff --git a/utils/dasllama-server/tests/fixtures/catalog_idle.json b/utils/dasllama-server/tests/fixtures/catalog_idle.json index 7af982ab03..d0e11c62a6 100644 --- a/utils/dasllama-server/tests/fixtures/catalog_idle.json +++ b/utils/dasllama-server/tests/fixtures/catalog_idle.json @@ -1 +1 @@ -{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "C:\\Users\\user\\.dasllama\\models\\gemma-4-E2B-it-Q4_K_M.gguf", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": true, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-gemma-4-E2B-it-bf16.gguf", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-gemma-4-E4B-it-BF16.gguf", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen3VL-4B-Instruct-Q8_0.gguf", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": true, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-Qwen3VL-4B-Instruct-F16.gguf", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen2.5-Omni-3B-Q8_0.gguf", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": true, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-Qwen2.5-Omni-3B-f16.gguf", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": true, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\kitten-nano.gguf"}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\tts_g2p.bin"}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\tts_postag.bin"}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": ""}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": ""}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "idle", "name": "", "got": 0, "total": 0, "error": ""}} \ No newline at end of file +{"models_dir": "C:\\Users\\user\\.dasllama\\models", "entries": [{"is_default": true, "path": "C:\\Users\\user\\.dasllama\\models\\gemma-4-E2B-it-Q4_K_M.gguf", "sha256": "740185b21d22ceb83a11c3aa62ad5842ef32c70f6096d756bbee85a1e4ec34b8", "vision_bytes": 986833664, "display": "Gemma 4 E2B", "file": "gemma-4-E2B-it-Q4_K_M.gguf", "bytes": 3106738272, "present": true, "name": "gemma-4-e2b", "vram_hint_gb": 4, "vision_file": "mmproj-gemma-4-E2B-it-bf16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-gemma-4-E2B-it-bf16.gguf", "ctx": 32768, "note": "the recommended default - fast, capable, runs everywhere"}, {"is_default": false, "path": "", "sha256": "85a896a047553e842f25297ee5b031d64ff30147d9c4af17b1e4b394cd1fab87", "vision_bytes": 991552256, "display": "Gemma 4 E4B", "file": "gemma-4-E4B-it-Q4_K_M.gguf", "bytes": 4977171584, "present": false, "name": "gemma-4-e4b", "vram_hint_gb": 6.5, "vision_file": "mmproj-gemma-4-E4B-it-BF16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-gemma-4-E4B-it-BF16.gguf", "ctx": 32768, "note": "the bigger E-series - better answers, still laptop-class"}, {"is_default": false, "path": "", "sha256": "f2c28b3dc4776931ac6f879e11f203dec637ea0f14267a86ec8f6165f63f293f", "vision_bytes": 0, "display": "Gemma 4 26B-A4B", "file": "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf", "bytes": 16947541728, "present": false, "name": "gemma-4-26b-a4b", "vram_hint_gb": 19, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 131072, "note": "the MoE - 26B quality at 4B active weights per token"}, {"is_default": false, "path": "", "sha256": "322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482", "vision_bytes": 0, "display": "Qwen 3.8 27B", "file": "Qwen3.8-27B-UD-Q4_K_M.gguf", "bytes": 16464440224, "present": false, "name": "qwen3.8-27b", "vram_hint_gb": 18.5, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the newest dense Qwen - thinking model, strong at code"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen3VL-4B-Instruct-Q8_0.gguf", "sha256": "054721f478bc5fa6beffb7f38eae575d45298f88cbb8d2f83ef675a727863eb1", "vision_bytes": 836180256, "display": "Qwen3 VL 4B", "file": "Qwen3VL-4B-Instruct-Q8_0.gguf", "bytes": 4280406144, "present": true, "name": "qwen3-vl-4b", "vram_hint_gb": 6, "vision_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-Qwen3VL-4B-Instruct-F16.gguf", "ctx": 262144, "note": "the deepstack vision Qwen - reads images through wide multi-tap rows"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen2.5-Omni-3B-Q8_0.gguf", "sha256": "c7d8b07c8d8d7a9ed1de1b8df7ac821eb4d259a224bd44310baacfaa5a473d4c", "vision_bytes": 2623983328, "display": "Qwen2.5 Omni 3B", "file": "Qwen2.5-Omni-3B-Q8_0.gguf", "bytes": 3616087360, "present": true, "name": "qwen2.5-omni-3b", "vram_hint_gb": 5.5, "vision_file": "mmproj-Qwen2.5-Omni-3B-f16.gguf", "vision_present": true, "vision_path": "C:\\Users\\user\\.dasllama\\models\\mmproj-Qwen2.5-Omni-3B-f16.gguf", "ctx": 32768, "note": "the window-ViT omni - compact vision chat on the Metal tower"}, {"is_default": false, "path": "C:\\Users\\user\\.dasllama\\models\\Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "sha256": "0b21525e972670ed59e1812e170b27c26355381f0656ecc4e25617ece7dac58b", "vision_bytes": 0, "display": "Qwen 3.6 35B-A3B", "file": "Qwen3.6-35B-A3B-MTP-UD-Q4_K_M.gguf", "bytes": 22663387424, "present": true, "name": "qwen3.6-35b-a3b", "vram_hint_gb": 24, "vision_file": "", "vision_present": false, "vision_path": "", "ctx": 262144, "note": "the Qwen MoE - 3B active weights, MTP-ready"}], "asr": {"file": "ggml-parakeet-tdt-0.6b-v3-f32.bin", "bytes": 2508463079, "present": false, "path": ""}, "tts": [{"file": "kitten-nano.gguf", "bytes": 59331456, "pack": false, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\kitten-nano.gguf", "needs_packs": true}, {"file": "tts_g2p.bin", "bytes": 14011554, "pack": true, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\tts_g2p.bin", "needs_packs": false}, {"file": "tts_postag.bin", "bytes": 12566510, "pack": true, "present": true, "path": "C:\\Users\\user\\.dasllama\\models\\tts_postag.bin", "needs_packs": false}, {"file": "kitten-mini.gguf", "bytes": 295975008, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "kokoro-82m.gguf", "bytes": 352965024, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-q8.gguf", "bytes": 152613664, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-de-q8.gguf", "bytes": 134667200, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-es-q8.gguf", "bytes": 134624480, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-it-q8.gguf", "bytes": 134415072, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-pt-q8.gguf", "bytes": 134667488, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-fr-q8.gguf", "bytes": 375793696, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-kq.gguf", "bytes": 74970016, "pack": false, "present": false, "path": "", "needs_packs": true}, {"file": "pocket-tts-en-stuart-kq.gguf", "bytes": 65107520, "pack": false, "present": false, "path": "", "needs_packs": true}], "box": {"ram_gb": 64, "vram_mb": 0}, "download": {"state": "idle", "name": "", "got": 0, "total": 0, "error": ""}} \ No newline at end of file diff --git a/utils/dasllama-server/tests/tts.spec.js b/utils/dasllama-server/tests/tts.spec.js index 01f2b33233..6652eecd89 100644 --- a/utils/dasllama-server/tests/tts.spec.js +++ b/utils/dasllama-server/tests/tts.spec.js @@ -323,6 +323,35 @@ test('a downloaded model beside the packs offers enable-speech, which wires and expect(posts.some(p => p.path === '/restart')).toBe(true); }); +// the packs taken away again - the state a box is in with one file on disk +function withPacksAbsent(doc) { + const d = JSON.parse(JSON.stringify(doc)); + for (const i of d.tts) { + if (i.pack) { i.present = false; i.path = ''; } + } + return d; +} + +test('a model that reads no packs enables speech with the packs absent; one that reads them waits for them', async ({ page }) => { + const idle = fx('catalog_idle'); + const standsAlone = speechModels(idle).find(m => !m.present); + const readsPacks = speechModels(idle).find(m => m.present); + expect(readsPacks.needs_packs).toBe(true); // the capture stocks one phoneme family + // the phoneme family alone, packs gone: the ladder re-offers the packs, no enable + const waiting = withPacksAbsent(idle); + await openControl(page, { catalog: waiting }); + await expect(page.locator('#tts-offer button', { hasText: 'enable speech' })).toHaveCount(0); + await expect(page.locator('#tts-offer button', { hasText: 'download the front-end packs' })).toHaveCount(1); + // a Pocket file beside it: it is the one enable wires, the packs still absent + const doc = withModelPresent(waiting, standsAlone.file); + doc.tts.find(i => i.file === standsAlone.file).needs_packs = false; + const { posts } = await openControl(page, { catalog: doc }); + await expect(page.locator('#tts-pick')).toHaveCount(0); // the phoneme family is not a choice without its packs + await page.locator('#tts-offer button', { hasText: 'enable speech' }).click(); + await expect(page.locator('#cat-note')).toContainText('speech wired (' + modelName(standsAlone.file) + ')'); + expect(lastJson(posts.filter(p => p.path === '/config')).tts).toBe(doc.models_dir + '\\' + standsAlone.file); +}); + test('several downloaded models become a picker that defaults to the smallest', async ({ page }) => { const idle = fx('catalog_idle'); const absent = bySize(speechModels(idle).filter(m => !m.present))[0]; From 8148c0e3a70032f1c3426228d5a978f2b57e0cd2 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 06:25:14 -0700 Subject: [PATCH 13/14] the speech ladder spec names its second model for what the page reads: any absent row of the capture with needs_packs overridden, not a Pocket file - the page reads only the key, so the arm holds whatever the capture lists first Co-Authored-By: Claude Fable 5.1 --- utils/dasllama-server/tests/tts.spec.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/utils/dasllama-server/tests/tts.spec.js b/utils/dasllama-server/tests/tts.spec.js index 6652eecd89..314033d124 100644 --- a/utils/dasllama-server/tests/tts.spec.js +++ b/utils/dasllama-server/tests/tts.spec.js @@ -334,7 +334,6 @@ function withPacksAbsent(doc) { test('a model that reads no packs enables speech with the packs absent; one that reads them waits for them', async ({ page }) => { const idle = fx('catalog_idle'); - const standsAlone = speechModels(idle).find(m => !m.present); const readsPacks = speechModels(idle).find(m => m.present); expect(readsPacks.needs_packs).toBe(true); // the capture stocks one phoneme family // the phoneme family alone, packs gone: the ladder re-offers the packs, no enable @@ -342,14 +341,17 @@ test('a model that reads no packs enables speech with the packs absent; one that await openControl(page, { catalog: waiting }); await expect(page.locator('#tts-offer button', { hasText: 'enable speech' })).toHaveCount(0); await expect(page.locator('#tts-offer button', { hasText: 'download the front-end packs' })).toHaveCount(1); - // a Pocket file beside it: it is the one enable wires, the packs still absent - const doc = withModelPresent(waiting, standsAlone.file); - doc.tts.find(i => i.file === standsAlone.file).needs_packs = false; + // a second model on disk whose row says it reads no packs - the page reads only that key, + // so any absent row of the capture serves, its needs_packs overridden: it is the one enable + // wires, the packs still absent + const alone = speechModels(idle).find(m => !m.present); + const doc = withModelPresent(waiting, alone.file); + doc.tts.find(i => i.file === alone.file).needs_packs = false; const { posts } = await openControl(page, { catalog: doc }); await expect(page.locator('#tts-pick')).toHaveCount(0); // the phoneme family is not a choice without its packs await page.locator('#tts-offer button', { hasText: 'enable speech' }).click(); - await expect(page.locator('#cat-note')).toContainText('speech wired (' + modelName(standsAlone.file) + ')'); - expect(lastJson(posts.filter(p => p.path === '/config')).tts).toBe(doc.models_dir + '\\' + standsAlone.file); + await expect(page.locator('#cat-note')).toContainText('speech wired (' + modelName(alone.file) + ')'); + expect(lastJson(posts.filter(p => p.path === '/config')).tts).toBe(doc.models_dir + '\\' + alone.file); }); test('several downloaded models become a picker that defaults to the smallest', async ({ page }) => { From 65edd4647b59bf5dccb04a47fbc0cb62629d9962 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Thu, 10 Sep 2026 06:51:37 -0700 Subject: [PATCH 14/14] the extended checks install numpy before the Pocket converter test the way the news step installs markdown - the darwin runner's python carries neither Co-Authored-By: Claude Fable 5.1 --- .github/workflows/extended_checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/extended_checks.yml b/.github/workflows/extended_checks.yml index b93927bba3..0c4b729fce 100644 --- a/.github/workflows/extended_checks.yml +++ b/.github/workflows/extended_checks.yml @@ -642,7 +642,9 @@ jobs: run: | set -eux PYTHONDONTWRITEBYTECODE=1 python3 examples/dasLLAMA/wasm/test_mint_models.py - # the Pocket converter's pure predicates (which tensor lands in which form) - numpy only, no torch + # the Pocket converter's pure predicates (which tensor lands in which form) - numpy only, no + # torch; the runner's python carries no numpy, installed the way the news step installs markdown + python3 -m pip install numpy || python3 -m pip install --user numpy || python3 -m pip install --break-system-packages numpy PYTHONDONTWRITEBYTECODE=1 python3 modules/dasLLAMA/harness/test_convert_pocket.py - name: "Test pr-babysit verdict core"