diff --git a/CMakeLists.txt b/CMakeLists.txt index be1013e..c63d3ff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,8 +20,9 @@ add_custom_target(version ALL find_package(Threads REQUIRED) # Suppress MSVC fopen/sprintf deprecation warnings, force UTF-8 source and -# execution charsets so non-ASCII string literals (CJK in text-chunker.h, -# prompt-builder.cpp) survive the compile without a BOM. +# execution charsets so non-ASCII string literals (CJK language names, +# punctuation tables in BPE and prompt builder) survive the compile +# without a BOM. if(MSVC) add_compile_definitions(_CRT_SECURE_NO_WARNINGS) add_compile_options(/utf-8) diff --git a/src/audio-io.h b/src/audio-io.h index 8e7d2fd..6785877 100644 --- a/src/audio-io.h +++ b/src/audio-io.h @@ -27,29 +27,6 @@ // utf8.h: utf8_fopen, the path-UTF-8-aware fopen used below. #include "utf8.h" -// case-insensitive extension check -static bool audio_io_ends_with(const char * str, const char * suffix) { - int slen = (int) strlen(str); - int xlen = (int) strlen(suffix); - if (slen < xlen) { - return false; - } - for (int i = 0; i < xlen; i++) { - char a = str[slen - xlen + i]; - char b = suffix[i]; - if (a >= 'A' && a <= 'Z') { - a += 32; - } - if (b >= 'A' && b <= 'Z') { - b += 32; - } - if (a != b) { - return false; - } - } - return true; -} - // Load entire file into memory. Caller frees the returned pointer. static uint8_t * audio_io_load_file(const char * path, size_t * size_out) { *size_out = 0; diff --git a/src/audio-postproc.h b/src/audio-postproc.h deleted file mode 100644 index 0bb8fbc..0000000 --- a/src/audio-postproc.h +++ /dev/null @@ -1,387 +0,0 @@ -#pragma once -// audio-postproc.h: TTS waveform post-processing -// -// Generic post-processing for neural TTS output: silence trimming -// (pydub-strict bit-for-bit), fade-in/out, padding. Public functions take -// and return float32 mono PCM in [-1, 1] at the pipeline sample rate. -// Internal silence detection runs on int16 samples to match pydub. -// Math reference: omnivoice/utils/audio.py (1:1 port). - -#include -#include -#include -#include -#include - -// RMS of an int16 slice [start, start + n) clamped to s16.size(). A slice that -// extends past the end shrinks accordingly. Empty slices return 0.0, matching -// pydub's AudioSegment.rms on empty segments. -static double postproc_slice_rms_s16(const std::vector & s16, size_t start, size_t n) { - size_t end = start + n; - if (end > s16.size()) { - end = s16.size(); - } - - if (start >= end) { - return 0.0; - } - - int64_t ssq = 0; - for (size_t i = start; i < end; i++) { - int32_t s = s16[i]; - ssq += (int64_t) s * (int64_t) s; - } - - size_t cnt = end - start; - return std::sqrt((double) ssq / (double) cnt); -} - -// Converts float32 [-1, 1] to int16 with the exact pydub recipe: -// (audio * 32768.0).clip(-32768, 32767).astype(int16). Truncation toward 0, -// matching numpy's astype(int16). -static std::vector postproc_f32_to_s16(const std::vector & a) { - std::vector out(a.size()); - for (size_t i = 0; i < a.size(); i++) { - double v = (double) a[i] * 32768.0; - if (v > 32767.0) { - v = 32767.0; - } - - if (v < -32768.0) { - v = -32768.0; - } - - out[i] = (int16_t) v; - } - - return out; -} - -// Inverse of postproc_f32_to_s16: int16 -> float32 via division by 32768.0. -static std::vector postproc_s16_to_f32(const std::vector & s16) { - std::vector out(s16.size()); - for (size_t i = 0; i < s16.size(); i++) { - out[i] = (float) ((double) s16[i] / 32768.0); - } - - return out; -} - -// pydub.silence.detect_silence ported to int16 samples. seek_step and -// min_silence_len are in samples. Returns inclusive ranges [start, end] in -// samples where end = start + min_silence_len of the last hit, exactly as -// pydub builds them. -static std::vector> postproc_detect_silence(const std::vector & s16, - int min_silence_len, - double thresh_lin, - int seek_step) { - std::vector> ranges; - int seg_len = (int) s16.size(); - - if (seg_len < min_silence_len) { - return ranges; - } - - int last_slice_start = seg_len - min_silence_len; - - std::vector starts; - for (int i = 0; i <= last_slice_start; i += seek_step) { - starts.push_back(i); - } - - if ((last_slice_start % seek_step) != 0) { - starts.push_back(last_slice_start); - } - - std::vector silence_starts; - for (int i : starts) { - double r = postproc_slice_rms_s16(s16, (size_t) i, (size_t) min_silence_len); - if (r <= thresh_lin) { - silence_starts.push_back(i); - } - } - - if (silence_starts.empty()) { - return ranges; - } - - int prev_i = silence_starts[0]; - int range_start = prev_i; - - for (size_t k = 1; k < silence_starts.size(); k++) { - int si = silence_starts[k]; - bool continuous = (si == prev_i + seek_step); - bool has_gap = (si > prev_i + min_silence_len); - - if (!continuous && has_gap) { - ranges.push_back({ range_start, prev_i + min_silence_len }); - range_start = si; - } - - prev_i = si; - } - - ranges.push_back({ range_start, prev_i + min_silence_len }); - return ranges; -} - -// pydub.silence.detect_nonsilent: invert detect_silence over [0, seg_len]. -static std::vector> postproc_detect_nonsilent(const std::vector & s16, - int min_silence_len, - double thresh_lin, - int seek_step) { - std::vector> nonsilent; - int seg_len = (int) s16.size(); - auto silent = postproc_detect_silence(s16, min_silence_len, thresh_lin, seek_step); - - if (silent.empty()) { - nonsilent.push_back({ 0, seg_len }); - return nonsilent; - } - - if (silent.front().first == 0 && silent.front().second == seg_len) { - return nonsilent; - } - - int prev_end = 0; - int last_end = 0; - - for (const auto & r : silent) { - nonsilent.push_back({ prev_end, r.first }); - prev_end = r.second; - last_end = r.second; - } - - if (last_end != seg_len) { - nonsilent.push_back({ prev_end, seg_len }); - } - - if (!nonsilent.empty() && nonsilent.front().first == 0 && nonsilent.front().second == 0) { - nonsilent.erase(nonsilent.begin()); - } - - return nonsilent; -} - -// pydub.silence.detect_leading_silence ported to int16. chunk_n is in samples. -// Returns the sample index where the leading silence ends (clamped to len). -static int postproc_detect_leading_silence(const std::vector & s16, double thresh_lin, int chunk_n) { - int trim = 0; - int seg_len = (int) s16.size(); - - while (trim < seg_len) { - int slice_end = std::min(trim + chunk_n, seg_len); - int n = slice_end - trim; - double r = postproc_slice_rms_s16(s16, (size_t) trim, (size_t) n); - - // pydub compares dBFS < threshold; in linear amplitude that is - // r < thresh_lin (strict), since dBFS is monotonic in r and r=0 - // gives -inf which is always below any finite threshold. - if (r >= thresh_lin) { - break; - } - - trim += chunk_n; - } - - if (trim > seg_len) { - trim = seg_len; - } - - return trim; -} - -// remove_silence: strict 1:1 port of omnivoice/utils/audio.py:remove_silence. -// Removes mid silences longer than mid_sil_ms (kept down to mid_sil_ms via -// pydub split_on_silence with keep_silence == mid_sil_ms), then trims the -// leading and trailing silences leaving lead_sil_ms / trail_sil_ms intact. -// thresh_db is the dBFS threshold (default -50 dBFS in upstream). -static void remove_silence(std::vector & a, - int sr, - int mid_sil_ms, - int lead_sil_ms, - int trail_sil_ms, - double thresh_db) { - if (a.empty()) { - return; - } - - std::vector s16 = postproc_f32_to_s16(a); - double thresh_lin = 32768.0 * std::pow(10.0, thresh_db / 20.0); - int seek_step = sr / 100; // 10 ms - - // Mid silence removal via split_on_silence + concat. - if (mid_sil_ms > 0) { - int min_sil_n = sr * mid_sil_ms / 1000; - int keep_n = min_sil_n; - - auto nonsilent = postproc_detect_nonsilent(s16, min_sil_n, thresh_lin, seek_step); - - std::vector> output_ranges; - output_ranges.reserve(nonsilent.size()); - for (const auto & r : nonsilent) { - output_ranges.push_back({ r.first - keep_n, r.second + keep_n }); - } - - // pydub pairwise overlap dedup: split overlap at the midpoint. - for (size_t i = 0; i + 1 < output_ranges.size(); i++) { - int last_end = output_ranges[i].second; - int next_start = output_ranges[i + 1].first; - if (next_start < last_end) { - int mid = (last_end + next_start) / 2; - output_ranges[i].second = mid; - output_ranges[i + 1].first = mid; - } - } - - // Concat clipped slices. Empty slices contribute nothing, matching - // AudioSegment.silent(0) += seg semantics. - std::vector out; - out.reserve(s16.size()); - int seg_len = (int) s16.size(); - - for (const auto & r : output_ranges) { - int cs = std::max(0, r.first); - int ce = std::min(seg_len, r.second); - if (cs < ce) { - out.insert(out.end(), s16.begin() + cs, s16.begin() + ce); - } - } - - s16 = std::move(out); - } - - // Edge trimming: leading then trailing via reverse trick. - int chunk_n = sr / 100; // 10 ms - - int trim_lead = postproc_detect_leading_silence(s16, thresh_lin, chunk_n); - trim_lead = std::max(0, trim_lead - sr * lead_sil_ms / 1000); - if (trim_lead > 0) { - s16.erase(s16.begin(), s16.begin() + std::min(trim_lead, (int) s16.size())); - } - - std::reverse(s16.begin(), s16.end()); - - int trim_trail = postproc_detect_leading_silence(s16, thresh_lin, chunk_n); - trim_trail = std::max(0, trim_trail - sr * trail_sil_ms / 1000); - if (trim_trail > 0) { - s16.erase(s16.begin(), s16.begin() + std::min(trim_trail, (int) s16.size())); - } - - std::reverse(s16.begin(), s16.end()); - - a = postproc_s16_to_f32(s16); -} - -// peak_normalize_half: rescale so peak amplitude becomes 0.5 (-6 dBFS). -// Mirrors the no-ref branch of _post_process_audio in omnivoice.py. -static void peak_normalize_half(std::vector & a) { - if (a.empty()) { - return; - } - - float peak = 0.0f; - for (float s : a) { - float v = std::fabs(s); - if (v > peak) { - peak = v; - } - } - - if (peak > 1e-6f) { - float k = 0.5f / peak; - for (float & s : a) { - s *= k; - } - } -} - -// fade_and_pad: linear fade-in / fade-out on the first and last fade_dur -// seconds, then pad pad_dur seconds of silence on each side. 1:1 port of -// fade_and_pad_audio in omnivoice/utils/audio.py. -static void fade_and_pad(std::vector & a, int sr, double fade_dur, double pad_dur) { - if (a.empty()) { - return; - } - - int fade_n = (int) (fade_dur * (double) sr); - int pad_n = (int) (pad_dur * (double) sr); - - if (fade_n > 0) { - int k = std::min(fade_n, (int) a.size() / 2); - if (k > 0) { - int denom = std::max(k - 1, 1); - - for (int i = 0; i < k; i++) { - float w = (float) i / (float) denom; - a[(size_t) i] *= w; - } - - for (int i = 0; i < k; i++) { - float w = 1.0f - (float) i / (float) denom; - a[a.size() - (size_t) k + (size_t) i] *= w; - } - } - } - - if (pad_n > 0) { - std::vector padded((size_t) pad_n + a.size() + (size_t) pad_n, 0.0f); - std::copy(a.begin(), a.end(), padded.begin() + pad_n); - a = std::move(padded); - } -} - -// cross_fade_chunks: concatenate audio chunks with a silence_dur gap split -// into fade_out, pure silence, fade_in. 1:1 port of cross_fade_chunks in -// omnivoice/utils/audio.py. -static std::vector cross_fade_chunks(const std::vector> & chunks, - int sr, - double silence_dur) { - if (chunks.empty()) { - return std::vector(); - } - - if (chunks.size() == 1) { - return chunks[0]; - } - - int total_n = (int) (silence_dur * (double) sr); - int fade_n = total_n / 3; - int silence_n = fade_n; - - std::vector merged = chunks[0]; - - for (size_t i = 1; i < chunks.size(); i++) { - const auto & chunk = chunks[i]; - - // Fade-out tail of merged. - int fout_n = std::min(fade_n, (int) merged.size()); - if (fout_n > 0) { - int denom = std::max(fout_n - 1, 1); - for (int j = 0; j < fout_n; j++) { - float w = 1.0f - (float) j / (float) denom; - merged[merged.size() - (size_t) fout_n + (size_t) j] *= w; - } - } - - // Silence gap. - if (silence_n > 0) { - merged.insert(merged.end(), (size_t) silence_n, 0.0f); - } - - // Fade-in head of next chunk (worked on a copy to keep input const). - std::vector head = chunk; - int fin_n = std::min(fade_n, (int) head.size()); - if (fin_n > 0) { - int denom = std::max(fin_n - 1, 1); - for (int j = 0; j < fin_n; j++) { - float w = (float) j / (float) denom; - head[(size_t) j] *= w; - } - } - - merged.insert(merged.end(), head.begin(), head.end()); - } - - return merged; -} diff --git a/src/debug.h b/src/debug.h index 916aff5..3aa5fa2 100644 --- a/src/debug.h +++ b/src/debug.h @@ -6,7 +6,6 @@ #include "utf8.h" -#include #include #include #include @@ -69,19 +68,6 @@ static void debug_dump_2d(const DebugDumper * d, const char * name, const float debug_dump(d, name, data, shape, 2); } -// Convenience: dump 3D tensor [d0, d1, d2]. -static void debug_dump_3d(const DebugDumper * d, const char * name, const float * data, int d0, int d1, int d2) { - int shape[3] = { d0, d1, d2 }; - debug_dump(d, name, data, shape, 3); -} - -// Convenience: dump 4D tensor [d0, d1, d2, d3]. -static void -debug_dump_4d(const DebugDumper * d, const char * name, const float * data, int d0, int d1, int d2, int d3) { - int shape[4] = { d0, d1, d2, d3 }; - debug_dump(d, name, data, shape, 4); -} - // Cast a stream of int32 values to f32 in place into a temporary buffer and // dump under the given name. Token comparisons are then expressed as cossim // over float values, with exact match recoverable via integer compare on the @@ -104,17 +90,3 @@ static void debug_dump_i32_as_f32(const DebugDumper * d, } debug_dump(d, name, buf.data(), shape, ndims); } - -// Cosine similarity between two f32 arrays. -static double debug_cosine_sim(const float * a, const float * b, int n) { - double dot = 0, na = 0, nb = 0; - for (int i = 0; i < n; i++) { - dot += (double) a[i] * (double) b[i]; - na += (double) a[i] * (double) a[i]; - nb += (double) b[i] * (double) b[i]; - } - if (na < 1e-30 || nb < 1e-30) { - return 0.0; - } - return dot / (sqrt(na) * sqrt(nb)); -} diff --git a/src/gguf-weights.h b/src/gguf-weights.h index 86e7b8a..af820f4 100644 --- a/src/gguf-weights.h +++ b/src/gguf-weights.h @@ -359,93 +359,6 @@ static const void * gf_get_data(const GGUFModel & gf, const char * name) { return gf.mapping + gf.data_offset + offset; } -// Look up the native ggml type of a tensor stored in the GGUF, so callers -// can mirror it on the backend allocation and let dtype-agnostic memcpy -// handle the bytes. Aborts if the tensor is missing. -static enum ggml_type gf_get_type(const GGUFModel & gf, const std::string & name) { - struct ggml_tensor * src = ggml_get_tensor(gf.meta, name.c_str()); - if (!src) { - qt_throw("[GGUF] tensor '%s' not in meta context", name.c_str()); - } - return src->type; -} - -// Fuse Q, K, V projection weights into a single tensor [ne0, q_ne1 + k_ne1 + v_ne1]. -// Works for any quantized type since quantization is per-row (along ne[0]). -// The fused tensor data is q rows || k rows || v rows (contiguous). -static struct ggml_tensor * gf_load_qkv_fused(WeightCtx * wctx, - const GGUFModel & gf, - const std::string & q_name, - const std::string & k_name, - const std::string & v_name) { - struct ggml_tensor * q_src = ggml_get_tensor(gf.meta, q_name.c_str()); - struct ggml_tensor * k_src = ggml_get_tensor(gf.meta, k_name.c_str()); - struct ggml_tensor * v_src = ggml_get_tensor(gf.meta, v_name.c_str()); - if (!q_src || !k_src || !v_src) { - qt_throw("[GGUF] QKV tensor not found: %s / %s / %s", q_name.c_str(), k_name.c_str(), v_name.c_str()); - } - // All must share ne[0] (input dim) and type - otherwise can't fuse - GGML_ASSERT(q_src->ne[0] == k_src->ne[0] && k_src->ne[0] == v_src->ne[0]); - if (q_src->type != k_src->type || k_src->type != v_src->type) { - return NULL; // caller should fall back to separate loads - } - - int64_t ne0 = q_src->ne[0]; - int64_t fused_ne1 = q_src->ne[1] + k_src->ne[1] + v_src->ne[1]; - int64_t ne[2] = { ne0, fused_ne1 }; - struct ggml_tensor * fused = ggml_new_tensor(wctx->ctx, q_src->type, 2, ne); - - size_t row_size = ggml_row_size(q_src->type, ne0); - size_t q_bytes = q_src->ne[1] * row_size; - size_t k_bytes = k_src->ne[1] * row_size; - size_t v_bytes = v_src->ne[1] * row_size; - - auto get_data = [&](const std::string & name) -> const void * { - int64_t idx = gguf_find_tensor(gf.gguf, name.c_str()); - size_t off = gguf_get_tensor_offset(gf.gguf, idx); - return gf.mapping + gf.data_offset + off; - }; - - wctx->pending.push_back({ fused, get_data(q_name), q_bytes, 0 }); - wctx->pending.push_back({ fused, get_data(k_name), k_bytes, q_bytes }); - wctx->pending.push_back({ fused, get_data(v_name), v_bytes, q_bytes + k_bytes }); - return fused; -} - -// Fuse two projection weights [ne0, a_ne1 + b_ne1] when types match. -// Returns NULL if types differ. -static struct ggml_tensor * gf_load_pair_fused(WeightCtx * wctx, - const GGUFModel & gf, - const std::string & a_name, - const std::string & b_name) { - struct ggml_tensor * a_src = ggml_get_tensor(gf.meta, a_name.c_str()); - struct ggml_tensor * b_src = ggml_get_tensor(gf.meta, b_name.c_str()); - if (!a_src || !b_src) { - return NULL; - } - if (a_src->ne[0] != b_src->ne[0] || a_src->type != b_src->type) { - return NULL; - } - - int64_t ne0 = a_src->ne[0]; - int64_t ne[2] = { ne0, a_src->ne[1] + b_src->ne[1] }; - struct ggml_tensor * fused = ggml_new_tensor(wctx->ctx, a_src->type, 2, ne); - - size_t row_size = ggml_row_size(a_src->type, ne0); - size_t a_bytes = a_src->ne[1] * row_size; - size_t b_bytes = b_src->ne[1] * row_size; - - auto get_data = [&](const std::string & name) -> const void * { - int64_t idx = gguf_find_tensor(gf.gguf, name.c_str()); - size_t off = gguf_get_tensor_offset(gf.gguf, idx); - return gf.mapping + gf.data_offset + off; - }; - - wctx->pending.push_back({ fused, get_data(a_name), a_bytes, 0 }); - wctx->pending.push_back({ fused, get_data(b_name), b_bytes, a_bytes }); - return fused; -} - // Read a uint32 KV value (returns 0 if not found) static uint32_t gf_get_u32(const GGUFModel & gf, const char * key) { int64_t idx = gguf_find_key(gf.gguf, key); diff --git a/src/philox.h b/src/philox.h index fef66f7..a511130 100644 --- a/src/philox.h +++ b/src/philox.h @@ -1,16 +1,11 @@ #pragma once -// philox.h Philox4x32-10 PRNG + Box-Muller normal distribution +// philox.h Philox4x32-10 PRNG, uniform distribution // -// Matches PyTorch CUDA torch.randn() output (cuRAND Philox4_32_10). -// Zero dependencies beyond , , . -// -// CUDA kernel mapping (normal distribution): -// element[k] = philox_normal4(seed, subsequence=k, offset=0)[0] -// vals[1..3] discarded (one thread per element, one normal per thread). +// Matches PyTorch CUDA torch.rand() output (cuRAND Philox4_32_10). +// Used by the multinomial sampler in sampling.h to stay byte-exact +// with the upstream Python pipeline. Zero dependencies beyond . -#include #include -#include // Philox constants (same as cuRAND / Random123) static constexpr uint32_t PHILOX_M0 = 0xD2511F53u; @@ -19,8 +14,7 @@ static constexpr uint32_t PHILOX_W0 = 0x9E3779B9u; static constexpr uint32_t PHILOX_W1 = 0xBB67AE85u; // cuRAND uniform conversion -static constexpr float CURAND_2POW32_INV = 2.3283064365386963e-10f; // 1 / 2^32 -static constexpr float CURAND_2POW32_INV_2PI = 1.4629180792671596e-09f; // 2*PI / 2^32 +static constexpr float CURAND_2POW32_INV = 2.3283064365386963e-10f; // 1 / 2^32 struct Philox4 { uint32_t x, y, z, w; @@ -81,60 +75,6 @@ static inline Philox4 philox4x32_10(Philox4 ctr, uint32_t seed_lo, uint32_t seed return ctr; } -// cuRAND Box-Muller: 2 uint32 -> 2 N(0,1) -static inline void box_muller(uint32_t u0, uint32_t u1, float * n0, float * n1) { - float u = (float) u0 * CURAND_2POW32_INV + (CURAND_2POW32_INV * 0.5f); - float v = (float) u1 * CURAND_2POW32_INV_2PI + (CURAND_2POW32_INV_2PI * 0.5f); - float s = sqrtf(-2.0f * logf(u)); - *n0 = s * sinf(v); - *n1 = s * cosf(v); -} - -// Generate 4 N(0,1) for (seed, subsequence, offset) -// counter = [offset_lo, offset_hi, subseq_lo, subseq_hi] -static inline void philox_normal4(int64_t seed, int64_t subsequence, int64_t offset, float out[4]) { - Philox4 ctr = { - (uint32_t) (offset), - (uint32_t) (offset >> 32), - (uint32_t) (subsequence), - (uint32_t) (subsequence >> 32), - }; - uint32_t slo = (uint32_t) (seed); - uint32_t shi = (uint32_t) ((uint64_t) seed >> 32); - Philox4 r = philox4x32_10(ctr, slo, shi); - box_muller(r.x, r.y, &out[0], &out[1]); - box_muller(r.z, r.w, &out[2], &out[3]); -} - -// bf16 round-trip (match torch.bfloat16 precision) -static inline float f32_to_bf16_to_f32(float x) { - uint32_t bits; - memcpy(&bits, &x, 4); - bits += 0x7FFF + ((bits >> 16) & 1); // round-to-nearest-even - bits &= 0xFFFF0000u; - float y; - memcpy(&y, &bits, 4); - return y; -} - -// Fill array with N(0,1) matching torch.randn() on CUDA with bf16. -// -// Reproduces: -// gen = torch.Generator(device="cuda").manual_seed(seed) -// torch.randn([...], generator=gen, device="cuda", dtype=torch.bfloat16) -// -// PyTorch CUDA normal distribution: each element k gets its own Philox -// subsequence and uses only the first Box-Muller output (val[0]). -// vals[1..3] are discarded. This matches the CUDA kernel behavior where -// grid = ceil(n / block_size), one element per thread. -static inline void philox_randn(int64_t seed, float * out, int n, bool bf16_round = true) { - for (int k = 0; k < n; k++) { - float vals[4]; - philox_normal4(seed, k, 0, vals); - out[k] = bf16_round ? f32_to_bf16_to_f32(vals[0]) : vals[0]; - } -} - // Fill array with uniform [0, 1) drawn from Philox4x32-10. Matches // PyTorch CUDA torch.rand kernels. // diff --git a/src/pipeline-codec.cpp b/src/pipeline-codec.cpp index 7606bcb..59ff080 100644 --- a/src/pipeline-codec.cpp +++ b/src/pipeline-codec.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include bool pipeline_codec_load(PipelineCodec * pc, const char * gguf_path, BackendPair bp) { @@ -69,13 +70,11 @@ bool pipeline_codec_load(PipelineCodec * pc, const char * gguf_path, BackendPair gf_close(&pc->gguf); return false; } - pc->pre_conv_ctx = wctx.ctx; - pc->pre_conv_buf = wctx.buffer; + pc->pre_conv_wctx = std::move(wctx); } if (!seanet_encoder_load(&pc->seanet, pc->gguf, pc->backend)) { - ggml_backend_buffer_free(pc->pre_conv_buf); - ggml_free(pc->pre_conv_ctx); + wctx_free(&pc->pre_conv_wctx); dac_decoder_free(&pc->dac); upsample_stage_free(&pc->upsample); tok_trans_free(&pc->transformer); @@ -86,8 +85,7 @@ bool pipeline_codec_load(PipelineCodec * pc, const char * gguf_path, BackendPair if (!enc_trans_load(&pc->enc_transformer, pc->gguf, pc->backend)) { seanet_encoder_free(&pc->seanet); - ggml_backend_buffer_free(pc->pre_conv_buf); - ggml_free(pc->pre_conv_ctx); + wctx_free(&pc->pre_conv_wctx); dac_decoder_free(&pc->dac); upsample_stage_free(&pc->upsample); tok_trans_free(&pc->transformer); @@ -99,8 +97,7 @@ bool pipeline_codec_load(PipelineCodec * pc, const char * gguf_path, BackendPair if (!enc_down_load(&pc->enc_downsample, pc->gguf, pc->backend)) { enc_trans_free(&pc->enc_transformer); seanet_encoder_free(&pc->seanet); - ggml_backend_buffer_free(pc->pre_conv_buf); - ggml_free(pc->pre_conv_ctx); + wctx_free(&pc->pre_conv_wctx); dac_decoder_free(&pc->dac); upsample_stage_free(&pc->upsample); tok_trans_free(&pc->transformer); @@ -113,8 +110,7 @@ bool pipeline_codec_load(PipelineCodec * pc, const char * gguf_path, BackendPair enc_down_free(&pc->enc_downsample); enc_trans_free(&pc->enc_transformer); seanet_encoder_free(&pc->seanet); - ggml_backend_buffer_free(pc->pre_conv_buf); - ggml_free(pc->pre_conv_ctx); + wctx_free(&pc->pre_conv_wctx); dac_decoder_free(&pc->dac); upsample_stage_free(&pc->upsample); tok_trans_free(&pc->transformer); @@ -442,14 +438,7 @@ void pipeline_codec_free(PipelineCodec * pc) { enc_down_free(&pc->enc_downsample); enc_trans_free(&pc->enc_transformer); seanet_encoder_free(&pc->seanet); - if (pc->pre_conv_buf) { - ggml_backend_buffer_free(pc->pre_conv_buf); - pc->pre_conv_buf = NULL; - } - if (pc->pre_conv_ctx) { - ggml_free(pc->pre_conv_ctx); - pc->pre_conv_ctx = NULL; - } + wctx_free(&pc->pre_conv_wctx); dac_decoder_free(&pc->dac); upsample_stage_free(&pc->upsample); tok_trans_free(&pc->transformer); diff --git a/src/pipeline-codec.h b/src/pipeline-codec.h index b00dbfa..aaa7388 100644 --- a/src/pipeline-codec.h +++ b/src/pipeline-codec.h @@ -53,12 +53,11 @@ struct PipelineCodec { QwenUpsampleStage upsample; QwenDACDecoder dac; - // pre_conv: causal Conv1d k=3, 512 -> 1024. Loaded into a dedicated - // weight ctx because it is the only module that does not own one. - struct ggml_tensor * pre_conv_w; // [3, 512, 1024] f32 - struct ggml_tensor * pre_conv_b; // [1024] f32 - struct ggml_context * pre_conv_ctx; - ggml_backend_buffer_t pre_conv_buf; + // pre_conv: causal Conv1d k=3, 512 -> 1024. Owns a dedicated + // WeightCtx because it is the only module without one. + struct ggml_tensor * pre_conv_w; // [3, 512, 1024] f32 + struct ggml_tensor * pre_conv_b; // [1024] f32 + WeightCtx pre_conv_wctx; // Encode side modules QwenSEANetEncoder seanet; diff --git a/src/prompt-builder.h b/src/prompt-builder.h index 9261e03..d74634d 100644 --- a/src/prompt-builder.h +++ b/src/prompt-builder.h @@ -210,13 +210,6 @@ static void embed_text_range(const GGUFModel & gf, } } -// Append codec_embedding(id) to dst (one hidden-dim vector). -static void embed_codec(const GGUFModel & gf, int id, int hidden_size, std::vector & dst) { - size_t old = dst.size(); - dst.resize(old + (size_t) hidden_size); - embed_row_to_f32(gf, "talker.codec_embd.weight", id, hidden_size, dst.data() + old); -} - // Vector add: a += b, length n. static void vec_add(float * a, const float * b, int n) { for (int i = 0; i < n; i++) { diff --git a/src/text-chunker.h b/src/text-chunker.h deleted file mode 100644 index 23c7599..0000000 --- a/src/text-chunker.h +++ /dev/null @@ -1,382 +0,0 @@ -#pragma once -// text-chunker.h: punctuation-aware long-form text splitter for TTS -// -// chunk_text_punctuation: splits text on sentence-ending punctuation -// (skipping abbreviation periods), then merges sentences into chunks of -// at most chunk_len UTF-8 codepoints. Optional min_chunk_len merges -// undersized chunks into a neighbour. Strings are UTF-8 in, UTF-8 out. -// Comparison and length are codepoint-based, matching Python str -// semantics. -// Math reference: omnivoice/utils/text.py (1:1 port). - -#include -#include -#include - -// Returns the byte length of the UTF-8 codepoint starting at b (1, 2, 3 or 4). -// Falls back to 1 on invalid first bytes so iteration always advances. -static inline int chunker_utf8_len(unsigned char b) { - if ((b & 0x80) == 0x00) { - return 1; - } - - if ((b & 0xE0) == 0xC0) { - return 2; - } - - if ((b & 0xF0) == 0xE0) { - return 3; - } - - if ((b & 0xF8) == 0xF0) { - return 4; - } - - return 1; -} - -// Sentence-ending punctuation. Mirrors SPLIT_PUNCTUATION in text.py. -static const std::set & chunker_split_punctuation() { - static const std::set s = { - ".", - ",", - ";", - ":", - "!", - "?", - "\xe3\x80\x82", // U+3002 ideographic full stop - "\xef\xbc\x8c", // U+FF0C fullwidth comma - "\xef\xbc\x9b", // U+FF1B fullwidth semicolon - "\xef\xbc\x9a", // U+FF1A fullwidth colon - "\xef\xbc\x81", // U+FF01 fullwidth exclamation mark - "\xef\xbc\x9f", // U+FF1F fullwidth question mark - }; - return s; -} - -// Closing marks attach to the preceding sentence. Mirrors CLOSING_MARKS. -static const std::set & chunker_closing_marks() { - static const std::set s = { - "\"", "'", "]", ">", - "\xe2\x80\x9c", // U+201C left double quotation mark - "\xe2\x80\x9d", // U+201D right double quotation mark - "\xe2\x80\x98", // U+2018 left single quotation mark - "\xe2\x80\x99", // U+2019 right single quotation mark - "\xef\xbc\x89", // U+FF09 fullwidth right parenthesis - "\xe3\x80\x8b", // U+300B right double angle bracket - "\xe3\x80\x8d", // U+300D right corner bracket - "\xe3\x80\x91", // U+3011 right black lenticular bracket - }; - return s; -} - -// Abbreviations that suppress the period as a sentence break. ASCII only, -// matched on the last whitespace-delimited word ending with the period. -// Mirrors ABBREVIATIONS in text.py. -static const std::set & chunker_abbreviations() { - static const std::set s = { - "Mr.", "Mrs.", "Ms.", "Dr.", "Prof.", "Sr.", "Jr.", "Rev.", "Fr.", "Hon.", "Pres.", - "Gov.", "Capt.", "Gen.", "Sen.", "Rep.", "Col.", "Maj.", "Lt.", "Cmdr.", "Sgt.", "Cpl.", - "Co.", "Corp.", "Inc.", "Ltd.", "Est.", "Dept.", "St.", "Ave.", "Blvd.", "Rd.", "Mt.", - "Ft.", "No.", "Jan.", "Feb.", "Mar.", "Apr.", "Aug.", "Sep.", "Sept.", "Oct.", "Nov.", - "Dec.", "i.e.", "e.g.", "vs.", "Vs.", "Etc.", "approx.", "fig.", "def.", - }; - return s; -} - -// Returns the last whitespace-delimited word of s, or s itself if no space. -// Used to detect abbreviation periods. -static std::string chunker_last_word(const std::string & s) { - size_t e = s.find_last_not_of(" \t\n\r"); - if (e == std::string::npos) { - return std::string(); - } - - std::string trimmed = s.substr(0, e + 1); - size_t sp = trimmed.find_last_of(" \t\n\r"); - if (sp == std::string::npos) { - return trimmed; - } - - return trimmed.substr(sp + 1); -} - -// Strips leading and trailing ASCII whitespace from s. -static std::string chunker_strip(const std::string & s) { - size_t a = s.find_first_not_of(" \t\n\r"); - if (a == std::string::npos) { - return std::string(); - } - - size_t b = s.find_last_not_of(" \t\n\r"); - return s.substr(a, b - a + 1); -} - -// Splits text on sentence-ending punctuation (skipping abbreviations) and -// merges sentences into chunks of at most chunk_len codepoints. If -// min_chunk_len > 0, undersized chunks are merged with a neighbour. -// Returns a list of stripped chunk strings (UTF-8). Empty chunks are dropped. -// -// Strict 1:1 port of chunk_text_punctuation in omnivoice/utils/text.py. -static std::vector chunk_text_punctuation(const std::string & text, int chunk_len, int min_chunk_len) { - // Step 1: tokenise into UTF-8 codepoints, then split on punctuation. - // sentences holds vectors of codepoints (each codepoint is a std::string). - std::vector> sentences; - std::vector current; - - const std::set & split_set = chunker_split_punctuation(); - const std::set & closing_set = chunker_closing_marks(); - const std::set & abbrev_set = chunker_abbreviations(); - - const unsigned char * p = (const unsigned char *) text.data(); - const unsigned char * end = p + text.size(); - - while (p < end) { - int n = chunker_utf8_len(*p); - if (p + n > end) { - n = (int) (end - p); - } - - std::string cp((const char *) p, (size_t) n); - p += n; - - bool is_split = split_set.count(cp) > 0; - bool is_closing = closing_set.count(cp) > 0; - - // Leading punctuation glues onto the previous sentence. - if (current.empty() && !sentences.empty() && (is_split || is_closing)) { - sentences.back().push_back(cp); - continue; - } - - current.push_back(cp); - - if (!is_split) { - continue; - } - - // Period after an abbreviation does not break the sentence. - bool is_abbreviation = false; - if (cp == ".") { - std::string joined; - for (const auto & c : current) { - joined += c; - } - - std::string last = chunker_last_word(joined); - if (!last.empty() && abbrev_set.count(last) > 0) { - is_abbreviation = true; - } - } - - if (!is_abbreviation) { - sentences.push_back(current); - current.clear(); - } - } - - if (!current.empty()) { - sentences.push_back(current); - } - - // Step 2: greedy merge of sentences into chunks of at most chunk_len - // codepoints. A sentence that does not fit starts a new chunk by itself, - // even if it is longer than chunk_len. - std::vector> merged; - std::vector cur_chunk; - - for (const auto & sent : sentences) { - if ((int) (cur_chunk.size() + sent.size()) <= chunk_len) { - for (const auto & c : sent) { - cur_chunk.push_back(c); - } - } else { - if (!cur_chunk.empty()) { - merged.push_back(cur_chunk); - } - - cur_chunk = sent; - } - } - - if (!cur_chunk.empty()) { - merged.push_back(cur_chunk); - } - - // Step 3: merge undersized chunks. The first chunk, if short, is folded - // into the second. Subsequent short chunks fold into the previous one. - std::vector> finals; - if (min_chunk_len > 0) { - bool first_short = !merged.empty() && (int) merged[0].size() < min_chunk_len; - - for (size_t i = 0; i < merged.size(); i++) { - const auto & chunk = merged[i]; - - if (i == 1 && first_short) { - for (const auto & c : chunk) { - finals.back().push_back(c); - } - continue; - } - - if ((int) chunk.size() >= min_chunk_len) { - finals.push_back(chunk); - continue; - } - - if (finals.empty()) { - finals.push_back(chunk); - } else { - for (const auto & c : chunk) { - finals.back().push_back(c); - } - } - } - } else { - finals = merged; - } - - // Step 4: join codepoints, strip whitespace, drop empty. - std::vector result; - result.reserve(finals.size()); - - for (const auto & chunk : finals) { - std::string joined; - for (const auto & c : chunk) { - joined += c; - } - - std::string stripped = chunker_strip(joined); - if (!stripped.empty()) { - result.push_back(stripped); - } - } - - return result; -} - -// Counts UTF-8 codepoints in s. Used to derive the per-chunk character budget -// from the average tokens-per-character of the full text, matching Python's -// len(text) which counts codepoints. -static int chunker_utf8_count(const std::string & text) { - const unsigned char * p = (const unsigned char *) text.data(); - const unsigned char * end = p + text.size(); - int n = 0; - - while (p < end) { - int s = chunker_utf8_len(*p); - if (p + s > end) { - s = (int) (end - p); - } - - p += s; - n += 1; - } - - return n; -} - -// Punctuation considered "terminal" by add_punctuation. Mirrors END_PUNCTUATION -// in text.py. ASCII first, then UTF-8 byte sequences for fancy quotes, ellipsis -// and Chinese variants. -static const std::set & chunker_end_punctuation() { - static const std::set s = { - ";", - ":", - ",", - ".", - "!", - "?", - ")", - "]", - "}", - "\"", - "'", - "\xe2\x80\xa6", // U+2026 horizontal ellipsis - "\xe2\x80\x9c", // U+201C left double quotation mark - "\xe2\x80\x9d", // U+201D right double quotation mark - "\xe2\x80\x98", // U+2018 left single quotation mark - "\xe2\x80\x99", // U+2019 right single quotation mark - "\xef\xbc\x9b", // U+FF1B fullwidth semicolon - "\xef\xbc\x9a", // U+FF1A fullwidth colon - "\xef\xbc\x8c", // U+FF0C fullwidth comma - "\xe3\x80\x82", // U+3002 ideographic full stop - "\xef\xbc\x81", // U+FF01 fullwidth exclamation mark - "\xef\xbc\x9f", // U+FF1F fullwidth question mark - "\xe3\x80\x81", // U+3001 ideographic comma - "\xef\xbc\x89", // U+FF09 fullwidth right parenthesis - "\xe3\x80\x91", // U+3011 right black lenticular bracket - }; - return s; -} - -// Returns the last UTF-8 codepoint of s as a std::string, or empty if s is -// empty. Walks the byte sequence to find the start of the last codepoint. -static std::string chunker_last_codepoint(const std::string & s) { - if (s.empty()) { - return std::string(); - } - - size_t i = s.size(); - while (i > 0) { - unsigned char b = (unsigned char) s[i - 1]; - if ((b & 0xC0) != 0x80) { - return s.substr(i - 1); - } - - i--; - } - - return s; -} - -// Returns true if any codepoint of s falls inside the CJK Unified Ideographs -// block (U+4E00..U+9FFF). Mirrors the Chinese-detection heuristic in -// add_punctuation upstream. -static bool chunker_contains_chinese(const std::string & s) { - const unsigned char * p = (const unsigned char *) s.data(); - const unsigned char * end = p + s.size(); - - while (p < end) { - int n = chunker_utf8_len(*p); - if (p + n > end) { - return false; - } - - if (n == 3) { - uint32_t cp = - ((uint32_t) (p[0] & 0x0F) << 12) | ((uint32_t) (p[1] & 0x3F) << 6) | ((uint32_t) (p[2] & 0x3F)); - if (cp >= 0x4E00 && cp <= 0x9FFF) { - return true; - } - } - - p += n; - } - - return false; -} - -// Strips text and appends a terminal punctuation if missing. Mirrors -// add_punctuation in omnivoice/utils/text.py: appends "." for non-Chinese -// text, and the ideographic full stop "。" for text containing CJK. -static std::string add_punctuation(const std::string & text) { - std::string s = chunker_strip(text); - if (s.empty()) { - return s; - } - - std::string last = chunker_last_codepoint(s); - const auto & end_set = chunker_end_punctuation(); - if (end_set.count(last) > 0) { - return s; - } - - if (chunker_contains_chinese(s)) { - s += "\xe3\x80\x82"; // U+3002 - } else { - s += "."; - } - - return s; -}