server: add OpenAI compatible TTS server (chunked PCM streaming, WAV one-shot)

This commit is contained in:
Pascal
2026-06-06 12:47:10 +02:00
parent eda8b59092
commit f3cfa5cf47
16 changed files with 40571 additions and 2 deletions
+19
View File
@@ -60,6 +60,19 @@ endif()
# ggml as subdirectory, inherits GGML_CUDA, GGML_METAL, etc. from cmake flags
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
macro(link_ggml_backends target)
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)
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.
# Compiles qwen.h with a C compiler under -Wall -Werror -pedantic and
# links against the static lib. The test never loads a model ; failure
+1 -1
View File
@@ -521,7 +521,7 @@ Sampling:
Debug:
--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>
```
+20
View File
@@ -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"
+7
View File
@@ -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
+15
View File
@@ -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);
}
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"
+8
View File
@@ -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.
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
}
#endif
+319
View File
@@ -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;
}
+11
View File
@@ -159,6 +159,17 @@ int main(void) {
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
* [Qwen] log lines from the cleanup paths land where the user
* expects them. */
+1 -1
View File
@@ -55,7 +55,7 @@ static void print_usage(const char * prog) {
" --sub-top-p <f> Sub-talker top-p (default: 1.0)\n\n"
"Debug:\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",
prog);
}
+131
View File
@@ -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;
}
+15
View File
@@ -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()
+22
View File
@@ -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.
+16526
View File
File diff suppressed because it is too large Load Diff
+3903
View File
File diff suppressed because it is too large Load Diff
+11224
View File
File diff suppressed because it is too large Load Diff
+8349
View File
File diff suppressed because it is too large Load Diff