server: add OpenAI compatible TTS server (chunked PCM streaming, WAV one-shot)
This commit is contained in:
@@ -60,6 +60,19 @@ endif()
|
|||||||
# ggml as subdirectory, inherits GGML_CUDA, GGML_METAL, etc. from cmake flags
|
# ggml as subdirectory, inherits GGML_CUDA, GGML_METAL, etc. from cmake flags
|
||||||
add_subdirectory(ggml)
|
add_subdirectory(ggml)
|
||||||
|
|
||||||
|
# cpp-httplib (HTTP server library, no SSL, behind reverse proxy in prod).
|
||||||
|
# Used by tts-server.
|
||||||
|
add_subdirectory(vendor/cpp-httplib)
|
||||||
|
|
||||||
|
# yyjson (MIT, fast JSON parser/writer). Used by tts-server.
|
||||||
|
add_library(yyjson STATIC vendor/yyjson/yyjson.c)
|
||||||
|
target_include_directories(yyjson PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/vendor/yyjson)
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(yyjson PRIVATE /W0)
|
||||||
|
else()
|
||||||
|
target_compile_options(yyjson PRIVATE -w)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Shared compile options and ggml linkage
|
# Shared compile options and ggml linkage
|
||||||
macro(link_ggml_backends target)
|
macro(link_ggml_backends target)
|
||||||
target_include_directories(${target} PRIVATE
|
target_include_directories(${target} PRIVATE
|
||||||
@@ -154,6 +167,12 @@ add_executable(qwen-tts tools/qwen-tts.cpp)
|
|||||||
target_link_libraries(qwen-tts PRIVATE qwen-core)
|
target_link_libraries(qwen-tts PRIVATE qwen-core)
|
||||||
link_ggml_backends(qwen-tts)
|
link_ggml_backends(qwen-tts)
|
||||||
|
|
||||||
|
# tts-server : OpenAI-compatible HTTP server over the TTS pipeline.
|
||||||
|
# Always built : it is part of the project, not an optional add-on.
|
||||||
|
add_executable(tts-server tools/tts-server.cpp)
|
||||||
|
target_link_libraries(tts-server PRIVATE qwen-core httplib yyjson)
|
||||||
|
link_ggml_backends(tts-server)
|
||||||
|
|
||||||
# test-abi-c : pure C99 smoke test that locks in the public ABI contract.
|
# test-abi-c : pure C99 smoke test that locks in the public ABI contract.
|
||||||
# Compiles qwen.h with a C compiler under -Wall -Werror -pedantic and
|
# Compiles qwen.h with a C compiler under -Wall -Werror -pedantic and
|
||||||
# links against the static lib. The test never loads a model ; failure
|
# links against the static lib. The test never loads a model ; failure
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ Sampling:
|
|||||||
|
|
||||||
Debug:
|
Debug:
|
||||||
--no-fa Disable flash attention
|
--no-fa Disable flash attention
|
||||||
--clamp-fp16 Clamp hidden states + V to FP16 range
|
--clamp-fp16 Clamp hidden states to FP16 range
|
||||||
--dump <dir> Dump intermediate tensors (f32) to <dir>
|
--dump <dir> Dump intermediate tensors (f32) to <dir>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Call the qwentts OpenAI-compatible TTS server.
|
||||||
|
# Default response_format is pcm : audio streams chunked as it is generated.
|
||||||
|
|
||||||
|
host="${1:-127.0.0.1}"
|
||||||
|
port="${2:-8080}"
|
||||||
|
|
||||||
|
# Streaming pcm, piped straight into a player as it arrives (real time).
|
||||||
|
# s16le mono 24 kHz. ffplay reads the raw stream from stdin.
|
||||||
|
curl -s -X POST "http://${host}:${port}/v1/audio/speech" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"input":"The quick brown fox jumps over the lazy dog."}' \
|
||||||
|
| ffplay -f s16le -ar 24000 -ch_layout mono -nodisp -autoexit -i -
|
||||||
|
|
||||||
|
# One-shot wav written to a file.
|
||||||
|
curl -s -X POST "http://${host}:${port}/v1/audio/speech" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"input":"This one is written to a file.","response_format":"wav"}' \
|
||||||
|
--output out.wav
|
||||||
|
echo "wrote out.wav"
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Start the qwentts OpenAI-compatible TTS server.
|
||||||
|
|
||||||
|
./build/tts-server \
|
||||||
|
--model models/qwen-talker-1.7b-base-Q8_0.gguf \
|
||||||
|
--codec models/qwen-tokenizer-12hz-Q8_0.gguf \
|
||||||
|
--host 127.0.0.1 --port 8080 --lang English
|
||||||
@@ -397,4 +397,19 @@ int qt_duration_sec_to_tokens(const struct qt_context * q, float duration_sec) {
|
|||||||
return pipeline_tts_duration_sec_to_tokens(&q->pt, duration_sec);
|
return pipeline_tts_duration_sec_to_tokens(&q->pt, duration_sec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int qt_n_speakers(const struct qt_context * q) {
|
||||||
|
if (!q) {
|
||||||
|
qt_set_error("qt_n_speakers: q is NULL");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (int) q->pt.speakers.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
const char * qt_speaker_name(const struct qt_context * q, int i) {
|
||||||
|
if (!q || i < 0 || i >= (int) q->pt.speakers.size()) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
return q->pt.speakers[(size_t) i].name.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
} // extern "C"
|
} // extern "C"
|
||||||
|
|||||||
@@ -291,6 +291,14 @@ QT_API enum qt_status qt_synthesize(struct qt_context * q, const struct qt_tts_p
|
|||||||
// Clamps to a minimum of one frame.
|
// Clamps to a minimum of one frame.
|
||||||
QT_API int qt_duration_sec_to_tokens(const struct qt_context * q, float duration_sec);
|
QT_API int qt_duration_sec_to_tokens(const struct qt_context * q, float duration_sec);
|
||||||
|
|
||||||
|
// Number of named speakers in the loaded model. custom_voice carries a
|
||||||
|
// speaker table ; base and voice_design return 0.
|
||||||
|
QT_API int qt_n_speakers(const struct qt_context * q);
|
||||||
|
|
||||||
|
// Name of speaker i, valid for i in [0, qt_n_speakers). Returns NULL when
|
||||||
|
// i is out of range. The pointer stays valid until qt_free. UTF-8.
|
||||||
|
QT_API const char * qt_speaker_name(const struct qt_context * q, int i);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
#pragma once
|
||||||
|
// tts-server.h: shared OpenAI-compatible TTS HTTP core for the *.cpp ports.
|
||||||
|
//
|
||||||
|
// One synthesis context lives GPU resident for the process lifetime. The
|
||||||
|
// project tool fills a tts_backend adapter that wires its own ABI
|
||||||
|
// (qt_synthesize / ov_synthesize) into the generic sink, then calls
|
||||||
|
// tts_server_run. The HTTP layer, tuning, OAI parsing and audio framing
|
||||||
|
// are identical across projects ; only the adapter differs.
|
||||||
|
//
|
||||||
|
// Endpoints:
|
||||||
|
// POST /v1/audio/speech OAI text-to-speech
|
||||||
|
// GET /v1/models single loaded model
|
||||||
|
// GET /v1/voices named speakers (empty when the model has none)
|
||||||
|
// GET /health liveness probe
|
||||||
|
//
|
||||||
|
// Audio out: response_format "pcm" streams s16le 24 kHz mono chunked as it
|
||||||
|
// is generated (real time), "wav" returns a one-shot RIFF file. pcm is the
|
||||||
|
// default so streaming is on unless the client asks for a file.
|
||||||
|
|
||||||
|
#include "../vendor/cpp-httplib/httplib.h"
|
||||||
|
#include "audio-io.h"
|
||||||
|
#include "yyjson.h"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <functional>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// One synthesis request parsed from the OAI JSON body.
|
||||||
|
struct tts_request {
|
||||||
|
std::string input; // text to speak
|
||||||
|
std::string voice; // OAI voice, mapped to a speaker by the adapter
|
||||||
|
std::string instructions; // OAI instructions, mapped to the ABI instruct field
|
||||||
|
std::string format; // "pcm" (stream) or "wav" (one-shot)
|
||||||
|
float speed; // OAI speed, parsed then ignored (no time stretch in the ABI)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The adapter pushes mono f32 24 kHz audio here. Returns false to abort the
|
||||||
|
// synthesis (client gone or cancellation), which propagates into the ABI
|
||||||
|
// on_chunk and stops generation.
|
||||||
|
using tts_sink = std::function<bool(const float * samples, int n_samples)>;
|
||||||
|
|
||||||
|
// Adapter implemented by each project tool.
|
||||||
|
struct tts_backend {
|
||||||
|
std::string model_id; // reported by GET /v1/models
|
||||||
|
std::vector<std::string> voices; // reported by GET /v1/voices, may be empty
|
||||||
|
// Run synthesis. When the request streams, the adapter routes the ABI
|
||||||
|
// on_chunk to sink ; otherwise it pushes the whole buffer once. Returns
|
||||||
|
// the ABI status (0 on success), and fills err with the ABI message on
|
||||||
|
// failure. The shared layer maps the status to an HTTP code.
|
||||||
|
std::function<int(const tts_request & req, const tts_sink & sink, std::string & err)> synthesize;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct server_config {
|
||||||
|
std::string host = "127.0.0.1";
|
||||||
|
int port = 8080;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single GPU context : synthesis is serialised FIFO across connections.
|
||||||
|
static std::mutex g_synth_mutex;
|
||||||
|
static httplib::Server * g_svr = nullptr;
|
||||||
|
|
||||||
|
static void tts_on_signal(int) {
|
||||||
|
if (g_svr) {
|
||||||
|
g_svr->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to [-1, 1] and scale to s16. lrintf rounds to nearest, ties to even.
|
||||||
|
static inline int16_t tts_f32_to_s16(float x) {
|
||||||
|
float v = x < -1.0f ? -1.0f : (x > 1.0f ? 1.0f : x);
|
||||||
|
return (int16_t) lrintf(v * 32767.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a mono f32 block as s16le bytes onto out.
|
||||||
|
static void tts_append_s16le(std::string & out, const float * samples, int n_samples) {
|
||||||
|
size_t base = out.size();
|
||||||
|
out.resize(base + (size_t) n_samples * 2);
|
||||||
|
char * p = &out[base];
|
||||||
|
for (int i = 0; i < n_samples; i++) {
|
||||||
|
int16_t s = tts_f32_to_s16(samples[i]);
|
||||||
|
*p++ = (char) ((uint16_t) s & 0xff);
|
||||||
|
*p++ = (char) (((uint16_t) s >> 8) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write a JSON error body in the OAI error envelope and set the status.
|
||||||
|
static void tts_json_error(httplib::Response & res, int status, const char * type, const char * message) {
|
||||||
|
yyjson_mut_doc * doc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val * root = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_doc_set_root(doc, root);
|
||||||
|
yyjson_mut_val * err = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_obj_add_str(doc, err, "message", message);
|
||||||
|
yyjson_mut_obj_add_str(doc, err, "type", type);
|
||||||
|
yyjson_mut_obj_add_val(doc, root, "error", err);
|
||||||
|
char * json = yyjson_mut_write(doc, 0, NULL);
|
||||||
|
res.status = status;
|
||||||
|
res.set_content(json ? json : "{}", "application/json");
|
||||||
|
if (json) {
|
||||||
|
free(json);
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_free(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the OAI body into req. Returns false and fills err on bad input.
|
||||||
|
static bool tts_parse_request(const std::string & body, tts_request & req, std::string & err) {
|
||||||
|
yyjson_doc * doc = yyjson_read(body.c_str(), body.size(), 0);
|
||||||
|
if (!doc) {
|
||||||
|
err = "request body is not valid JSON";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
yyjson_val * root = yyjson_doc_get_root(doc);
|
||||||
|
if (!yyjson_is_obj(root)) {
|
||||||
|
err = "request body must be a JSON object";
|
||||||
|
yyjson_doc_free(doc);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
yyjson_val * input = yyjson_obj_get(root, "input");
|
||||||
|
if (!yyjson_is_str(input) || yyjson_get_len(input) == 0) {
|
||||||
|
err = "'input' must be a non-empty string";
|
||||||
|
yyjson_doc_free(doc);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
req.input = yyjson_get_str(input);
|
||||||
|
|
||||||
|
yyjson_val * voice = yyjson_obj_get(root, "voice");
|
||||||
|
req.voice = yyjson_is_str(voice) ? yyjson_get_str(voice) : "";
|
||||||
|
|
||||||
|
yyjson_val * instructions = yyjson_obj_get(root, "instructions");
|
||||||
|
req.instructions = yyjson_is_str(instructions) ? yyjson_get_str(instructions) : "";
|
||||||
|
|
||||||
|
yyjson_val * fmt = yyjson_obj_get(root, "response_format");
|
||||||
|
req.format = yyjson_is_str(fmt) ? yyjson_get_str(fmt) : "pcm";
|
||||||
|
|
||||||
|
yyjson_val * speed = yyjson_obj_get(root, "speed");
|
||||||
|
req.speed = yyjson_is_num(speed) ? (float) yyjson_get_num(speed) : 1.0f;
|
||||||
|
|
||||||
|
yyjson_doc_free(doc);
|
||||||
|
|
||||||
|
if (req.format != "pcm" && req.format != "wav") {
|
||||||
|
err = "response_format must be 'pcm' or 'wav'";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map an ABI status to an HTTP code. The two ABIs share numeric values:
|
||||||
|
// -1 invalid params, -2 mode/instruct invalid -> client error ; the rest
|
||||||
|
// are server side failures.
|
||||||
|
static int tts_status_to_http(int rc) {
|
||||||
|
if (rc == 0) {
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
|
if (rc == -1 || rc == -2) {
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
return 502;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tts_handle_speech(const tts_backend & be, const httplib::Request & http_req, httplib::Response & res) {
|
||||||
|
tts_request req;
|
||||||
|
std::string err;
|
||||||
|
if (!tts_parse_request(http_req.body, req, err)) {
|
||||||
|
tts_json_error(res, 400, "invalid_request_error", err.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.format == "wav") {
|
||||||
|
// One-shot : collect the whole utterance, then emit a RIFF file.
|
||||||
|
std::vector<float> buf;
|
||||||
|
tts_sink sink = [&buf](const float * s, int n) {
|
||||||
|
buf.insert(buf.end(), s, s + n);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
std::string synth_err;
|
||||||
|
int rc;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_synth_mutex);
|
||||||
|
rc = be.synthesize(req, sink, synth_err);
|
||||||
|
}
|
||||||
|
if (rc != 0) {
|
||||||
|
tts_json_error(res, tts_status_to_http(rc), "server_error",
|
||||||
|
synth_err.empty() ? "synthesis failed" : synth_err.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string wav = audio_encode_wav(buf.data(), (int) buf.size(), 24000, WAV_S16);
|
||||||
|
res.set_content(std::move(wav), "audio/wav");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming : run synthesis inside the chunked provider on the connection
|
||||||
|
// thread, pushing s16le frames as the codec produces them. A failed
|
||||||
|
// sink.write means the client disconnected, which aborts generation and
|
||||||
|
// frees the GPU instead of finishing a stream nobody reads.
|
||||||
|
res.set_header("Cache-Control", "no-cache");
|
||||||
|
res.set_header("X-Accel-Buffering", "no");
|
||||||
|
res.set_chunked_content_provider("audio/pcm", [&be, req](size_t, httplib::DataSink & sink) mutable -> bool {
|
||||||
|
tts_sink push = [&sink](const float * s, int n) {
|
||||||
|
std::string bytes;
|
||||||
|
tts_append_s16le(bytes, s, n);
|
||||||
|
return sink.write(bytes.data(), bytes.size());
|
||||||
|
};
|
||||||
|
std::string synth_err;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(g_synth_mutex);
|
||||||
|
be.synthesize(req, push, synth_err);
|
||||||
|
}
|
||||||
|
sink.done();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tts_handle_models(const tts_backend & be, const httplib::Request &, httplib::Response & res) {
|
||||||
|
yyjson_mut_doc * doc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val * root = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_doc_set_root(doc, root);
|
||||||
|
yyjson_mut_obj_add_str(doc, root, "object", "list");
|
||||||
|
yyjson_mut_val * data = yyjson_mut_arr(doc);
|
||||||
|
yyjson_mut_val * one = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_obj_add_str(doc, one, "id", be.model_id.c_str());
|
||||||
|
yyjson_mut_obj_add_str(doc, one, "object", "model");
|
||||||
|
yyjson_mut_obj_add_str(doc, one, "owned_by", "local");
|
||||||
|
yyjson_mut_arr_add_val(data, one);
|
||||||
|
yyjson_mut_obj_add_val(doc, root, "data", data);
|
||||||
|
char * json = yyjson_mut_write(doc, 0, NULL);
|
||||||
|
res.set_content(json ? json : "{}", "application/json");
|
||||||
|
if (json) {
|
||||||
|
free(json);
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_free(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tts_handle_voices(const tts_backend & be, const httplib::Request &, httplib::Response & res) {
|
||||||
|
yyjson_mut_doc * doc = yyjson_mut_doc_new(NULL);
|
||||||
|
yyjson_mut_val * root = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_doc_set_root(doc, root);
|
||||||
|
yyjson_mut_val * arr = yyjson_mut_arr(doc);
|
||||||
|
for (const std::string & v : be.voices) {
|
||||||
|
yyjson_mut_val * one = yyjson_mut_obj(doc);
|
||||||
|
yyjson_mut_obj_add_str(doc, one, "name", v.c_str());
|
||||||
|
yyjson_mut_arr_add_val(arr, one);
|
||||||
|
}
|
||||||
|
yyjson_mut_obj_add_val(doc, root, "voices", arr);
|
||||||
|
char * json = yyjson_mut_write(doc, 0, NULL);
|
||||||
|
res.set_content(json ? json : "{}", "application/json");
|
||||||
|
if (json) {
|
||||||
|
free(json);
|
||||||
|
}
|
||||||
|
yyjson_mut_doc_free(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void tts_handle_health(const httplib::Request &, httplib::Response & res) {
|
||||||
|
res.set_content("{\"status\":\"ok\"}", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int tts_server_run(const tts_backend & be, const server_config & cfg) {
|
||||||
|
httplib::Server svr;
|
||||||
|
g_svr = &svr;
|
||||||
|
|
||||||
|
// per-operation socket idle timeouts. read is small (text in), write is
|
||||||
|
// generous to cover a long streamed utterance without tripping on a slow
|
||||||
|
// client.
|
||||||
|
svr.set_read_timeout(60);
|
||||||
|
svr.set_write_timeout(120);
|
||||||
|
|
||||||
|
// reject oversized bodies. text plus an optional reference clip stays
|
||||||
|
// well under this.
|
||||||
|
svr.set_payload_max_length(32 * 1024 * 1024);
|
||||||
|
|
||||||
|
// Nagle coalescing holds small packets back for tens of ms ; streamed
|
||||||
|
// PCM chunks must leave the socket the moment they are written.
|
||||||
|
svr.set_tcp_nodelay(true);
|
||||||
|
|
||||||
|
// SO_REUSEADDR lets us rebind a port still in TIME_WAIT after a restart.
|
||||||
|
// SO_REUSEPORT is deliberately not set : a second instance on the same
|
||||||
|
// port then fails with EADDRINUSE instead of silently sharing the socket
|
||||||
|
// and splitting traffic between two daemons.
|
||||||
|
svr.set_socket_options([](socket_t sock) {
|
||||||
|
int one = 1;
|
||||||
|
#ifdef _WIN32
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char *) &one, sizeof(one));
|
||||||
|
#else
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
|
||||||
|
#endif
|
||||||
|
});
|
||||||
|
|
||||||
|
// permissive CORS so a browser client can call the API directly.
|
||||||
|
svr.set_default_headers({
|
||||||
|
{ "Access-Control-Allow-Origin", "*" }
|
||||||
|
});
|
||||||
|
svr.Options("/.*", [](const httplib::Request &, httplib::Response & res) {
|
||||||
|
res.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
|
res.set_header("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/v1/audio/speech",
|
||||||
|
[&be](const httplib::Request & req, httplib::Response & res) { tts_handle_speech(be, req, res); });
|
||||||
|
svr.Get("/v1/models",
|
||||||
|
[&be](const httplib::Request & req, httplib::Response & res) { tts_handle_models(be, req, res); });
|
||||||
|
svr.Get("/v1/voices",
|
||||||
|
[&be](const httplib::Request & req, httplib::Response & res) { tts_handle_voices(be, req, res); });
|
||||||
|
svr.Get("/health", tts_handle_health);
|
||||||
|
|
||||||
|
signal(SIGINT, tts_on_signal);
|
||||||
|
signal(SIGTERM, tts_on_signal);
|
||||||
|
|
||||||
|
fprintf(stderr, "[Server] model %s\n", be.model_id.c_str());
|
||||||
|
fprintf(stderr, "[Server] listening on %s:%d\n", cfg.host.c_str(), cfg.port);
|
||||||
|
if (!svr.listen(cfg.host.c_str(), cfg.port)) {
|
||||||
|
fprintf(stderr, "[Server] FATAL: cannot bind %s:%d\n", cfg.host.c_str(), cfg.port);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -159,6 +159,17 @@ int main(void) {
|
|||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Speaker getters resolve and behave on a NULL handle : count is 0 and
|
||||||
|
* any index returns NULL. No model is needed to lock in the contract. */
|
||||||
|
if (qt_n_speakers(NULL) != 0) {
|
||||||
|
fprintf(stderr, "[Probe] qt_n_speakers(NULL) must be 0\n");
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
if (qt_speaker_name(NULL, 0) != NULL) {
|
||||||
|
fprintf(stderr, "[Probe] qt_speaker_name(NULL, 0) must be NULL\n");
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
/* Restore the default stderr fallback before exit so the trailing
|
/* Restore the default stderr fallback before exit so the trailing
|
||||||
* [Qwen] log lines from the cleanup paths land where the user
|
* [Qwen] log lines from the cleanup paths land where the user
|
||||||
* expects them. */
|
* expects them. */
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ static void print_usage(const char * prog) {
|
|||||||
" --sub-top-p <f> Sub-talker top-p (default: 1.0)\n\n"
|
" --sub-top-p <f> Sub-talker top-p (default: 1.0)\n\n"
|
||||||
"Debug:\n"
|
"Debug:\n"
|
||||||
" --no-fa Disable flash attention\n"
|
" --no-fa Disable flash attention\n"
|
||||||
" --clamp-fp16 Clamp hidden states + V to FP16 range\n"
|
" --clamp-fp16 Clamp hidden states to FP16 range\n"
|
||||||
" --dump <dir> Dump intermediate tensors (f32) to <dir>\n",
|
" --dump <dir> Dump intermediate tensors (f32) to <dir>\n",
|
||||||
prog);
|
prog);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// tts-server.cpp: OpenAI-compatible HTTP server backed by the qwentts
|
||||||
|
// ABI. Loads a talker + codec once, GPU resident, and serves synthesis over
|
||||||
|
// POST /v1/audio/speech. The shared core lives in src/tts-server.h ; this
|
||||||
|
// file only wires the qt_* ABI into the generic adapter.
|
||||||
|
|
||||||
|
#include "qwen.h"
|
||||||
|
#include "tts-server.h"
|
||||||
|
#include "version.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
static void print_usage(const char * prog) {
|
||||||
|
fprintf(stderr, "qwentts.cpp %s\n\n", QWEN_VERSION);
|
||||||
|
fprintf(stderr,
|
||||||
|
"Usage: %s --model <gguf> --codec <gguf> [options]\n\n"
|
||||||
|
"Required:\n"
|
||||||
|
" --model <gguf> Talker LM GGUF (qwen-talker-*.gguf)\n"
|
||||||
|
" --codec <gguf> Codec GGUF (qwen-tokenizer-*.gguf)\n\n"
|
||||||
|
"Optional:\n"
|
||||||
|
" --host <ip> Listen address (default: 127.0.0.1)\n"
|
||||||
|
" --port <n> Listen port (default: 8080)\n"
|
||||||
|
" --lang <name> Language label (default: english)\n"
|
||||||
|
" --no-fa Disable flash attention\n"
|
||||||
|
" --clamp-fp16 Clamp hidden states to FP16 range\n",
|
||||||
|
prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim a path down to its file name for the reported model id.
|
||||||
|
static std::string basename_of(const char * path) {
|
||||||
|
std::string s = path;
|
||||||
|
size_t p = s.find_last_of("/\\");
|
||||||
|
return p == std::string::npos ? s : s.substr(p + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(int argc, char ** argv) {
|
||||||
|
const char * talker_path = NULL;
|
||||||
|
const char * codec_path = NULL;
|
||||||
|
std::string lang = "english";
|
||||||
|
server_config cfg;
|
||||||
|
bool use_fa = true;
|
||||||
|
bool clamp_fp16 = false;
|
||||||
|
|
||||||
|
for (int i = 1; i < argc; i++) {
|
||||||
|
const char * arg = argv[i];
|
||||||
|
if (!std::strcmp(arg, "--model") && i + 1 < argc) {
|
||||||
|
talker_path = argv[++i];
|
||||||
|
} else if (!std::strcmp(arg, "--codec") && i + 1 < argc) {
|
||||||
|
codec_path = argv[++i];
|
||||||
|
} else if (!std::strcmp(arg, "--host") && i + 1 < argc) {
|
||||||
|
cfg.host = argv[++i];
|
||||||
|
} else if (!std::strcmp(arg, "--port") && i + 1 < argc) {
|
||||||
|
cfg.port = std::atoi(argv[++i]);
|
||||||
|
} else if (!std::strcmp(arg, "--lang") && i + 1 < argc) {
|
||||||
|
lang = argv[++i];
|
||||||
|
} else if (!std::strcmp(arg, "--no-fa")) {
|
||||||
|
use_fa = false;
|
||||||
|
} else if (!std::strcmp(arg, "--clamp-fp16")) {
|
||||||
|
clamp_fp16 = true;
|
||||||
|
} else if (!std::strcmp(arg, "--help") || !std::strcmp(arg, "-h")) {
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
fprintf(stderr, "[CLI] ERROR: unknown arg: %s\n", arg);
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!talker_path || !codec_path) {
|
||||||
|
print_usage(argv[0]);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct qt_init_params iparams;
|
||||||
|
qt_init_default_params(&iparams);
|
||||||
|
iparams.talker_path = talker_path;
|
||||||
|
iparams.codec_path = codec_path;
|
||||||
|
iparams.use_fa = use_fa;
|
||||||
|
iparams.clamp_fp16 = clamp_fp16;
|
||||||
|
|
||||||
|
struct qt_context * q = qt_init(&iparams);
|
||||||
|
if (!q) {
|
||||||
|
fprintf(stderr, "[Server] FATAL: %s\n", qt_last_error());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tts_backend be;
|
||||||
|
be.model_id = basename_of(talker_path);
|
||||||
|
int n = qt_n_speakers(q);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
be.voices.push_back(qt_speaker_name(q, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The adapter always drives the streaming pipeline : on_chunk routes to
|
||||||
|
// the shared sink, which either streams to the socket (pcm) or fills a
|
||||||
|
// one-shot buffer (wav). Either way the audio path is identical.
|
||||||
|
be.synthesize = [q, &lang](const tts_request & req, const tts_sink & sink, std::string & err) -> int {
|
||||||
|
struct qt_tts_params p;
|
||||||
|
qt_tts_default_params(&p);
|
||||||
|
p.text = req.input.c_str();
|
||||||
|
p.lang = lang.c_str();
|
||||||
|
if (!req.voice.empty() && qt_n_speakers(q) > 0) {
|
||||||
|
p.speaker = req.voice.c_str();
|
||||||
|
}
|
||||||
|
if (!req.instructions.empty()) {
|
||||||
|
p.instruct = req.instructions.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trampoline : the C ABI on_chunk forwards to the C++ sink.
|
||||||
|
const tts_sink * sink_ptr = &sink;
|
||||||
|
p.on_chunk = [](const float * s, int ns, void * u) -> bool {
|
||||||
|
return (*static_cast<const tts_sink *>(u))(s, ns);
|
||||||
|
};
|
||||||
|
p.on_chunk_user_data = (void *) sink_ptr;
|
||||||
|
|
||||||
|
struct qt_audio out = {};
|
||||||
|
enum qt_status rc = qt_synthesize(q, &p, &out);
|
||||||
|
qt_audio_free(&out);
|
||||||
|
if (rc != QT_STATUS_OK) {
|
||||||
|
err = qt_last_error();
|
||||||
|
return (int) rc;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
int rc = tts_server_run(be, cfg);
|
||||||
|
qt_free(q);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
Vendored
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# cpp-httplib: HTTP server library (no SSL, behind reverse proxy in prod)
|
||||||
|
|
||||||
|
add_library(httplib STATIC httplib.cpp)
|
||||||
|
target_include_directories(httplib PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
# suppress warnings in third-party code
|
||||||
|
if(MSVC)
|
||||||
|
target_compile_options(httplib PRIVATE /w)
|
||||||
|
else()
|
||||||
|
target_compile_options(httplib PRIVATE -w)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(httplib PRIVATE ws2_32)
|
||||||
|
endif()
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2017 yhirose
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
Vendored
+16526
File diff suppressed because it is too large
Load Diff
Vendored
+3903
File diff suppressed because it is too large
Load Diff
Vendored
+11224
File diff suppressed because it is too large
Load Diff
Vendored
+8349
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user