// qwen-tts.cpp: thin CLI wrapper around the qwentts.cpp public ABI. // Parses arguments, reads the optional reference WAV plus transcript, // hands off to qt_synthesize and writes the resulting waveform as a // WAV file. All synthesis logic, mode validation and seed resolution // live behind the qwen_* facade declared in qwen.h. // // Talker variants: 0.6B-Base / 0.6B-CustomVoice / 1.7B-Base / // 1.7B-CustomVoice / 1.7B-VoiceDesign. The decoder path is selected // from GGUF metadata at qt_init time. The CLI surface mirrors the // omnivoice.cpp tooling: kebab-case flags, --format wav16/wav24/wav32, // -o '-' streams to stdout, --seed -1 means non deterministic // (resolved inside qt_synthesize), the utterance text is read from stdin. #include "audio-io.h" #include "qwen.h" #include "rvq-file.h" #include "utf8.h" #include #include #include #include #include #include #include #if defined(_WIN32) # include # include #endif static void print_usage(const char * prog) { fprintf(stderr, "qwentts.cpp %s\n\n", qt_version()); fprintf(stderr, "Usage: %s --model --codec [options] -o < text.txt\n\n" "Required:\n" " --model Talker LM GGUF (qwen-talker-*.gguf)\n" " --codec Codec GGUF (qwen-tokenizer-*.gguf)\n" " -o Output WAV (24 kHz mono). '-' streams to stdout (pipe friendly).\n\n" "Input:\n" " stdin Target text to synthesise. Read fully then synthesised in one\n" " shot, or line by line with --stream-by-line.\n\n" "Optional:\n" " --format WAV output format: wav16, wav24, wav32 (default: wav16)\n" " --lang Language label (default: auto)\n" " --instruct Style instruction. Required for VoiceDesign, optional for\n" " CustomVoice, rejected for Base\n" " --speaker Speaker name (CustomVoice only)\n" " --ref-wav Reference WAV for voice cloning (Base only)\n" " --ref-spk Pre-extracted speaker embedding from qwen-codec --talker\n" " (replaces --ref-wav, Base only)\n" " --ref-rvq Pre-encoded reference codes from qwen-codec (requires\n" " --ref-spk and --ref-text, enables ICL clone mode)\n" " --ref-text Transcript file for the reference (enables ICL clone mode)\n" " --max-new Max new audio frames (default: 2048)\n" " --codec-chunk-dur Codec decode chunk duration in seconds (default: 24.0)\n" " --stream-by-line Flush synthesis at each newline, one WAV header per line (-o '-')\n\n" "Sampling:\n" " --seed Sampling seed (default: -1 for random)\n" " --greedy Disable stochastic sampling on both stacks\n" " --temp Talker temperature (default: 0.9)\n" " --top-k Talker top-k (default: 50, 0 disables)\n" " --top-p Talker top-p (default: 1.0)\n" " --rep-pen Talker repetition penalty (default: 1.05)\n" " --sub-temp Sub-talker temperature (default: 0.9)\n" " --sub-top-k Sub-talker top-k (default: 50)\n" " --sub-top-p Sub-talker top-p (default: 1.0)\n\n" "Debug:\n" " --no-fa Disable flash attention\n" " --clamp-fp16 Clamp hidden states to FP16 range\n" " --dump Dump intermediate tensors (f32) to \n", prog); } struct Args { const char * model; const char * codec; const char * lang; const char * instruct; const char * speaker; const char * ref_wav; const char * ref_spk; const char * ref_rvq; const char * ref_text_path; const char * dump_dir; const char * out_wav; const char * format; int max_new_tokens; int64_t seed; bool do_sample; float temperature; int top_k; float top_p; float repetition_penalty; int subtalker_top_k; float subtalker_top_p; float subtalker_temperature; bool subtalker_do_sample; bool use_fa; bool clamp_fp16; bool stream_by_line; float codec_chunk_sec; }; // Read all of stdin into a string. Binary mode on Windows so UTF-16 input // survives CRLF translation, then normalised to UTF-8. Trims trailing // newlines so a piped text file behaves like clean utterance input. static std::string read_stdin_text() { #if defined(_WIN32) _setmode(_fileno(stdin), _O_BINARY); #endif std::ostringstream ss; ss << std::cin.rdbuf(); std::string s = ss.str(); utf8_normalize(s); while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) { s.pop_back(); } return s; } // Read a small text file into a string, normalised to UTF-8. // Trims trailing newlines. // 11 bits per code (V <= 2048), matching qwen-codec. static const int RVQ_CODE_BITS = 11; // Read a .spk file: raw f32 values, the count IS the embedding dimension. static bool read_spk_file(const char * path, std::vector & emb) { FILE * f = utf8_fopen(path, "rb"); if (!f) { fprintf(stderr, "[CLI] ERROR: cannot open --ref-spk '%s'\n", path); return false; } fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET); if (sz <= 0 || (sz % (long) sizeof(float)) != 0) { fprintf(stderr, "[CLI] ERROR: --ref-spk '%s' size %ld is not a positive multiple of 4\n", path, sz); fclose(f); return false; } emb.resize((size_t) sz / sizeof(float)); if (fread(emb.data(), sizeof(float), emb.size(), f) != emb.size()) { fprintf(stderr, "[CLI] ERROR: short read on --ref-spk '%s'\n", path); fclose(f); return false; } fclose(f); return true; } static bool read_text_file(const char * path, std::string & out) { FILE * f = utf8_fopen(path, "rb"); if (!f) { fprintf(stderr, "[CLI] FATAL: cannot open '%s'\n", path); return false; } fseek(f, 0, SEEK_END); long sz = ftell(f); fseek(f, 0, SEEK_SET); if (sz < 0) { fclose(f); fprintf(stderr, "[CLI] FATAL: ftell failed on '%s'\n", path); return false; } out.resize((size_t) sz); if (sz > 0 && fread(&out[0], 1, (size_t) sz, f) != (size_t) sz) { fclose(f); fprintf(stderr, "[CLI] FATAL: fread failed on '%s'\n", path); return false; } fclose(f); utf8_normalize(out); while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) { out.pop_back(); } return true; } static bool parse_args(int argc, char ** argv, Args & a) { a = {}; a.lang = "auto"; a.format = "wav16"; a.max_new_tokens = 2048; a.seed = -1; a.do_sample = true; a.temperature = 0.9f; a.top_k = 50; a.top_p = 1.0f; a.repetition_penalty = 1.05f; a.subtalker_do_sample = true; a.subtalker_top_k = 50; a.subtalker_top_p = 1.0f; a.subtalker_temperature = 0.9f; a.use_fa = true; a.clamp_fp16 = false; a.stream_by_line = false; // Chunk sentinel : qt_init resolves a non positive value to the // library default. a.codec_chunk_sec = 0.0f; for (int i = 1; i < argc; i++) { const char * arg = argv[i]; if (std::strcmp(arg, "-h") == 0 || std::strcmp(arg, "--help") == 0) { return false; } if (std::strcmp(arg, "--model") == 0 && i + 1 < argc) { a.model = argv[++i]; } else if (std::strcmp(arg, "--codec") == 0 && i + 1 < argc) { a.codec = argv[++i]; } else if (std::strcmp(arg, "--lang") == 0 && i + 1 < argc) { a.lang = argv[++i]; } else if (std::strcmp(arg, "--instruct") == 0 && i + 1 < argc) { a.instruct = argv[++i]; } else if (std::strcmp(arg, "--speaker") == 0 && i + 1 < argc) { a.speaker = argv[++i]; } else if (std::strcmp(arg, "--ref-wav") == 0 && i + 1 < argc) { a.ref_wav = argv[++i]; } else if (std::strcmp(arg, "--ref-spk") == 0 && i + 1 < argc) { a.ref_spk = argv[++i]; } else if (std::strcmp(arg, "--ref-rvq") == 0 && i + 1 < argc) { a.ref_rvq = argv[++i]; } else if (std::strcmp(arg, "--ref-text") == 0 && i + 1 < argc) { a.ref_text_path = argv[++i]; } else if (std::strcmp(arg, "--format") == 0 && i + 1 < argc) { a.format = argv[++i]; } else if (std::strcmp(arg, "--dump") == 0 && i + 1 < argc) { a.dump_dir = argv[++i]; } else if (std::strcmp(arg, "--max-new") == 0 && i + 1 < argc) { a.max_new_tokens = std::atoi(argv[++i]); } else if (std::strcmp(arg, "--seed") == 0 && i + 1 < argc) { a.seed = (int64_t) std::atoll(argv[++i]); } else if (std::strcmp(arg, "--greedy") == 0) { // Greedy mode: argmax sampling on both stacks. The sampling // fast path in sampling.h uses temperature <= 0 to short // circuit to argmax, bypassing rep penalty and top-k/p // truncation, which exactly mirrors the Python reference // greedy behaviour used by tests/debug-tts-cossim.py. a.do_sample = false; a.subtalker_do_sample = false; } else if (std::strcmp(arg, "--temp") == 0 && i + 1 < argc) { a.temperature = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "--top-k") == 0 && i + 1 < argc) { a.top_k = std::atoi(argv[++i]); } else if (std::strcmp(arg, "--top-p") == 0 && i + 1 < argc) { a.top_p = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "--rep-pen") == 0 && i + 1 < argc) { a.repetition_penalty = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "--sub-temp") == 0 && i + 1 < argc) { a.subtalker_temperature = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "--sub-top-k") == 0 && i + 1 < argc) { a.subtalker_top_k = std::atoi(argv[++i]); } else if (std::strcmp(arg, "--sub-top-p") == 0 && i + 1 < argc) { a.subtalker_top_p = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "--no-fa") == 0) { a.use_fa = false; } else if (std::strcmp(arg, "--clamp-fp16") == 0) { a.clamp_fp16 = true; } else if (std::strcmp(arg, "--stream-by-line") == 0) { a.stream_by_line = true; } else if (std::strcmp(arg, "--codec-chunk-dur") == 0 && i + 1 < argc) { a.codec_chunk_sec = (float) std::atof(argv[++i]); } else if (std::strcmp(arg, "-o") == 0 && i + 1 < argc) { a.out_wav = argv[++i]; } else { fprintf(stderr, "[CLI] ERROR: unknown or incomplete argument: %s\n", arg); return false; } } return a.model && a.codec; } static int run(const Args & a) { // Init the facade. The seven mode validations // (base / custom_voice / voice_design rules) and the BPE tokenizer // load live inside qt_init / qt_synthesize; the CLI just hands // off the two GGUF paths and reports qt_last_error on failure. qt_init_params iparams; qt_init_default_params(&iparams); iparams.talker_path = a.model; iparams.codec_path = a.codec; iparams.use_fa = a.use_fa; iparams.clamp_fp16 = a.clamp_fp16; iparams.codec_chunk_sec = a.codec_chunk_sec; qt_context * q = qt_init(&iparams); if (!q) { fprintf(stderr, "[CLI] ERROR: %s\n", qt_last_error()); return 1; } // Load the reference transcript from the file passed via --ref-text. // Empty content is rejected since the ICL prompt needs it non empty. std::string ref_text_buf; const char * ref_text = NULL; if (a.ref_text_path) { if (!read_text_file(a.ref_text_path, ref_text_buf)) { qt_free(q); return 1; } if (ref_text_buf.empty()) { fprintf(stderr, "[CLI] ERROR: --ref-text file '%s' is empty\n", a.ref_text_path); qt_free(q); return 1; } ref_text = ref_text_buf.c_str(); } // Decode the reference WAV once, mono at the codec sample rate. The // facade consumes the buffer directly so the WAV is read exactly // once regardless of how many encoders need the audio (speaker // encoder embedding + codec encoder RVQ codes for ICL mode B). std::unique_ptr raw_holder(NULL, std::free); const float * ref_audio_24k = NULL; int ref_n_samples = 0; if (a.ref_wav) { int T_in = 0; float * raw = audio_read_mono(a.ref_wav, 24000, &T_in); if (!raw || T_in <= 0) { fprintf(stderr, "[CLI] ERROR: cannot read --ref-wav '%s'\n", a.ref_wav); if (raw) { std::free(raw); } qt_free(q); return 1; } raw_holder.reset(raw); ref_audio_24k = raw; ref_n_samples = T_in; } // Latent reference files. The .spk holds raw f32 values whose count // IS the embedding dimension; the .rvq holds the packed ICL code // matrix. The facade validates the structural constraints (mutual // exclusions, dim match against the talker hidden size). std::vector ref_spk_emb; std::vector ref_codes; int ref_T = 0; if (a.ref_spk) { if (!read_spk_file(a.ref_spk, ref_spk_emb)) { qt_free(q); return 1; } fprintf(stderr, "[CLI] Reference SPK: %s, %zu f32 values\n", a.ref_spk, ref_spk_emb.size()); } if (a.ref_rvq) { const int K = qt_num_codebooks(q); if (!rvq_read_file(a.ref_rvq, K, RVQ_CODE_BITS, ref_codes, &ref_T)) { qt_free(q); return 1; } fprintf(stderr, "[CLI] Reference RVQ: %s, K=%d T=%d\n", a.ref_rvq, K, ref_T); } // Resolve output WAV format string: wav16 / wav24 / wav32. Default // wav16 mirrors the omnivoice.cpp default. WavFormat wav_fmt; if (!audio_parse_format(a.format, wav_fmt)) { fprintf(stderr, "[CLI] ERROR: invalid --format '%s' (expected wav16, wav24, wav32)\n", a.format); qt_free(q); return 1; } // Streaming detection : -o '-' writes a wide RIFF header to stdout // up front and pipes encoded samples chunk by chunk through the // on_chunk callback as the AR loop produces frames. Any other path // (or no -o) takes the buffered route so the file gets accurate // sizes in its header. const char * out_path = a.out_wav ? a.out_wav : "out.wav"; const bool stream_to_stdout = (out_path[0] == '-' && out_path[1] == '\0'); const bool line_mode = a.stream_by_line && stream_to_stdout; // Resolve utterance text: read stdin fully. Empty stdin triggers a // clean error. Inline text on the command line is intentionally not // supported: shell quoting and special characters belong in a file // or piped input, never in argv. Line mode reads stdin line by line // in the streaming loop below instead. std::string text_buf; if (!line_mode) { text_buf = read_stdin_text(); if (text_buf.empty()) { fprintf(stderr, "[CLI] ERROR: stdin is empty, nothing to synthesise\n"); qt_free(q); return 1; } } const char * text = text_buf.c_str(); // Translate CLI args into the facade params. Seed -1 is forwarded // verbatim and resolved by qt_synthesize via std::random_device. qt_tts_params params; qt_tts_default_params(¶ms); params.text = text; params.lang = a.lang; params.instruct = a.instruct; params.speaker = a.speaker; params.ref_audio_24k = ref_audio_24k; params.ref_n_samples = ref_n_samples; params.ref_text = ref_text; params.ref_spk_emb = ref_spk_emb.empty() ? NULL : ref_spk_emb.data(); params.ref_spk_dim = (int) ref_spk_emb.size(); params.ref_codes = ref_codes.empty() ? NULL : ref_codes.data(); params.ref_T = ref_T; params.seed = a.seed; params.max_new_tokens = a.max_new_tokens; params.do_sample = a.do_sample; params.temperature = a.temperature; params.top_k = a.top_k; params.top_p = a.top_p; params.repetition_penalty = a.repetition_penalty; params.subtalker_do_sample = a.subtalker_do_sample; params.subtalker_temperature = a.subtalker_temperature; params.subtalker_top_k = a.subtalker_top_k; params.subtalker_top_p = a.subtalker_top_p; params.dump_dir = a.dump_dir; if (stream_to_stdout) { wav_stream ws = {}; if (!wav_stream_open_stdout(&ws, 24000, wav_fmt)) { qt_free(q); return 1; } params.on_chunk = [](const float * s, int n, void * ud) -> bool { return wav_stream_write((wav_stream *) ud, s, n); }; params.on_chunk_user_data = &ws; if (line_mode) { // Line oriented streaming: every stdin line synthesises // immediately as its own utterance, model and speaker staying // resident across lines. Each utterance after the first opens // with a fresh RIFF header so a client can split the stream // into one standalone WAV per line on the RIFF magic. The // header is armed when a line completes and consumed lazily at // the next synthesis, so empty lines and a trailing newline // never emit an orphan header. Lines are text, an embedded NUL // truncates the read at strlen. char buf[4096]; std::string line; bool need_header = false; int n_lines = 0; #if defined(_WIN32) _setmode(_fileno(stdin), _O_BINARY); #endif while (true) { const bool eof = (fgets(buf, sizeof(buf), stdin) == nullptr); if (!eof) { line += buf; } const bool complete = !line.empty() && line.back() == '\n'; if (complete || (eof && !line.empty())) { while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) { line.pop_back(); } if (!line.empty()) { if (need_header) { if (!wav_stream_write_header(&ws)) { qt_free(q); return 1; } need_header = false; } params.text = line.c_str(); qt_audio audio = {}; qt_status status = qt_synthesize(q, ¶ms, &audio); qt_audio_free(&audio); if (status != QT_STATUS_OK) { fprintf(stderr, "[CLI] ERROR: %s\n", qt_last_error()); wav_stream_close(&ws); qt_free(q); return 1; } n_lines++; need_header = true; } line.clear(); } if (eof) { break; } } wav_stream_close(&ws); qt_free(q); fprintf(stderr, "[Pipeline] Streamed %d lines to \n", n_lines); return 0; } qt_audio audio = {}; qt_status status = qt_synthesize(q, ¶ms, &audio); wav_stream_close(&ws); if (status != QT_STATUS_OK) { fprintf(stderr, "[CLI] ERROR: %s\n", qt_last_error()); qt_audio_free(&audio); qt_free(q); return 1; } qt_audio_free(&audio); qt_free(q); fprintf(stderr, "[Pipeline] Streamed to \n"); return 0; } qt_audio audio = {}; qt_status status = qt_synthesize(q, ¶ms, &audio); if (status != QT_STATUS_OK) { fprintf(stderr, "[CLI] ERROR: %s\n", qt_last_error()); qt_audio_free(&audio); qt_free(q); return 1; } if (audio.n_samples > 0) { if (!audio_write_wav(out_path, audio.samples, audio.n_samples, audio.sample_rate, wav_fmt)) { fprintf(stderr, "[Pipeline] FATAL: WAV write failed for %s\n", out_path); qt_audio_free(&audio); qt_free(q); return 1; } fprintf(stderr, "[Pipeline] Wrote %d samples (%.2f s) -> %s\n", audio.n_samples, (double) audio.n_samples / (double) audio.sample_rate, out_path); } qt_audio_free(&audio); qt_free(q); return 0; } int main(int argc, char ** argv) { Args a; if (!parse_args(argc, argv, a)) { print_usage(argv[0]); return 1; } // The facade absorbs every std::exception thrown deep in the load // and synthesis chains, converting them into qt_status + a // qt_last_error message. No top-level try / catch needed here : // the CLI just reads the status returned by qt_init / // qt_synthesize and renders qt_last_error to stderr. return run(a); }