Initial commit
This commit is contained in:
parent
6c804ce481
commit
512606ead1
45
CMakeLists.txt
Normal file
45
CMakeLists.txt
Normal file
@ -0,0 +1,45 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
project(sherpa_server LANGUAGES CXX)
|
||||
|
||||
# 64-bit helper. Must match sherpa-onnx DLL bitness (shipped x64). Runs as a
|
||||
# separate process from the 32-bit FNV plugin, so mismatched bitness is fine.
|
||||
if(MSVC AND NOT DEFINED CMAKE_GENERATOR_PLATFORM)
|
||||
message(FATAL_ERROR
|
||||
"Configure with -A x64 — sherpa_server must be 64-bit "
|
||||
"to match the shipped sherpa-onnx DLLs.")
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
get_filename_component(_default_sherpa_dir
|
||||
"${CMAKE_CURRENT_LIST_DIR}/../../third-party/sherpa-onnx" ABSOLUTE)
|
||||
set(SHERPA_DIR
|
||||
"${_default_sherpa_dir}"
|
||||
CACHE PATH "Path to the sherpa-onnx SDK (must have include/ and lib/)")
|
||||
|
||||
if(NOT EXISTS "${SHERPA_DIR}/include/sherpa-onnx/c-api/c-api.h")
|
||||
message(FATAL_ERROR
|
||||
"sherpa-onnx headers not found under ${SHERPA_DIR}. "
|
||||
"Run 'bash mod/install-sherpa-onnx.sh' to stage the SDK, or "
|
||||
"point -DSHERPA_DIR at the folder containing include/ and lib/.")
|
||||
endif()
|
||||
|
||||
add_executable(sherpa_server main.cpp tray_icon_win32.cpp)
|
||||
|
||||
target_include_directories(sherpa_server PRIVATE "${SHERPA_DIR}/include")
|
||||
target_link_directories(sherpa_server PRIVATE "${SHERPA_DIR}/lib")
|
||||
target_link_libraries(sherpa_server PRIVATE sherpa-onnx-c-api)
|
||||
|
||||
if(MSVC)
|
||||
target_compile_options(sherpa_server PRIVATE /O2 /W3 /D_CRT_SECURE_NO_WARNINGS)
|
||||
set_property(TARGET sherpa_server PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded")
|
||||
set_target_properties(sherpa_server PROPERTIES
|
||||
LINK_FLAGS "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup")
|
||||
endif()
|
||||
|
||||
set_target_properties(sherpa_server PROPERTIES
|
||||
OUTPUT_NAME "sherpa_server"
|
||||
SUFFIX ".exe"
|
||||
)
|
||||
578
main.cpp
Normal file
578
main.cpp
Normal file
@ -0,0 +1,578 @@
|
||||
#include <sherpa-onnx/c-api/c-api.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <io.h>
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include "tray_icon_win32.hpp"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI parsing — tiny, just enough for the flags we need
|
||||
// ---------------------------------------------------------------------------
|
||||
static const char* FindFlag(int argc, char** argv, const char* name) {
|
||||
// Matches --name=value or --name value
|
||||
size_t nameLen = strlen(name);
|
||||
for (int i = 1; i < argc; i++) {
|
||||
const char* a = argv[i];
|
||||
if (strncmp(a, name, nameLen) != 0) continue;
|
||||
if (a[nameLen] == '=') return a + nameLen + 1;
|
||||
if (a[nameLen] == '\0' && i + 1 < argc) return argv[i + 1];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static const char* FlagOrEmpty(int argc, char** argv, const char* name) {
|
||||
const char* v = FindFlag(argc, argv, name);
|
||||
return v ? v : "";
|
||||
}
|
||||
|
||||
static int FlagInt(int argc, char** argv, const char* name, int def) {
|
||||
const char* v = FindFlag(argc, argv, name);
|
||||
return v ? atoi(v) : def;
|
||||
}
|
||||
|
||||
static float FlagFloat(int argc, char** argv, const char* name, float def) {
|
||||
const char* v = FindFlag(argc, argv, name);
|
||||
return v ? (float)atof(v) : def;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// voice_presets.ini [Model.<key>] loader
|
||||
// ---------------------------------------------------------------------------
|
||||
struct ModelDef {
|
||||
std::string key; // section-name suffix
|
||||
std::string type; // "kokoro" / "kitten" / "vits" / "matcha" / ...
|
||||
// Field bag — name -> value. Owned strings stay alive for the
|
||||
// server's lifetime so SherpaOnnx* configs can keep raw pointers.
|
||||
std::unordered_map<std::string, std::string> fields;
|
||||
|
||||
const char* GetCStr(const char* name) const {
|
||||
auto it = fields.find(name);
|
||||
return (it != fields.end() && !it->second.empty()) ? it->second.c_str() : "";
|
||||
}
|
||||
float GetFloat(const char* name, float def) const {
|
||||
auto it = fields.find(name);
|
||||
if (it == fields.end() || it->second.empty()) return def;
|
||||
return (float)atof(it->second.c_str());
|
||||
}
|
||||
};
|
||||
|
||||
// Trim leading/trailing ASCII whitespace + an inline " ;" comment tail
|
||||
// from a string in-place.
|
||||
static void TrimAndStripComment(std::string& s) {
|
||||
// Strip "<sp>;" tail
|
||||
for (size_t i = 0; i + 1 < s.size(); i++) {
|
||||
if ((s[i] == ' ' || s[i] == '\t') && s[i + 1] == ';') {
|
||||
s.resize(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' ||
|
||||
s.back() == '\r' || s.back() == '\n')) {
|
||||
s.pop_back();
|
||||
}
|
||||
size_t lead = 0;
|
||||
while (lead < s.size() && (s[lead] == ' ' || s[lead] == '\t')) lead++;
|
||||
if (lead > 0) s.erase(0, lead);
|
||||
}
|
||||
|
||||
// Walk a GetPrivateProfileSectionA payload ("k=v\0k=v\0...\0\0") and call
|
||||
// visit(key, value) for each entry.
|
||||
template<typename Fn>
|
||||
static void ForEachIniEntry(const char* buf, Fn&& visit) {
|
||||
for (const char* p = buf; *p; p += strlen(p) + 1) {
|
||||
const char* eq = strchr(p, '=');
|
||||
if (!eq || eq == p) continue;
|
||||
std::string key(p, eq - p);
|
||||
std::string val(eq + 1);
|
||||
TrimAndStripComment(key);
|
||||
TrimAndStripComment(val);
|
||||
if (key.empty()) continue;
|
||||
visit(key, val);
|
||||
}
|
||||
}
|
||||
|
||||
static ModelDef* FindModelDefCi(std::vector<ModelDef>& defs,
|
||||
const std::string& key) {
|
||||
for (auto& d : defs) {
|
||||
if (_stricmp(d.key.c_str(), key.c_str()) == 0) return &d;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
static const ModelDef* FindModelDefCi(const std::vector<ModelDef>& defs,
|
||||
const std::string& key) {
|
||||
for (const auto& d : defs) {
|
||||
if (_stricmp(d.key.c_str(), key.c_str()) == 0) return &d;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Discover every [Model.<key>] section in one INI and merge into `out`.
|
||||
// Last-wins on duplicate keys (case-insensitive) — a later file's
|
||||
// redefinition replaces the existing entry in place, mirroring the
|
||||
// plugin-side loader.
|
||||
static void LoadModelDefsFromFile(const char* iniPath,
|
||||
std::vector<ModelDef>& out) {
|
||||
static char nameBuf[8192];
|
||||
DWORD n = GetPrivateProfileSectionNamesA(nameBuf, sizeof(nameBuf), iniPath);
|
||||
if (n == 0) return;
|
||||
if (n >= sizeof(nameBuf) - 2) {
|
||||
fprintf(stderr, "sherpa_server: warning — section-name list in %s "
|
||||
"exceeded %zu bytes, some sections may be missed\n",
|
||||
iniPath, sizeof(nameBuf));
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
static char sectionBuf[16384];
|
||||
for (const char* sect = nameBuf; *sect; sect += strlen(sect) + 1) {
|
||||
if (_strnicmp(sect, "Model.", 6) != 0) continue;
|
||||
const char* keyPart = sect + 6;
|
||||
if (!*keyPart) {
|
||||
fprintf(stderr, "sherpa_server: [%s] in %s missing model-key suffix — skipping\n",
|
||||
sect, iniPath);
|
||||
fflush(stderr);
|
||||
continue;
|
||||
}
|
||||
DWORD len = GetPrivateProfileSectionA(sect, sectionBuf, sizeof(sectionBuf), iniPath);
|
||||
if (len == 0) {
|
||||
fprintf(stderr, "sherpa_server: [%s] in %s is empty — skipping\n",
|
||||
sect, iniPath);
|
||||
fflush(stderr);
|
||||
continue;
|
||||
}
|
||||
ModelDef def;
|
||||
def.key = keyPart;
|
||||
ForEachIniEntry(sectionBuf, [&](const std::string& k, const std::string& v) {
|
||||
if (_stricmp(k.c_str(), "type") == 0) def.type = v;
|
||||
else def.fields[k] = v;
|
||||
});
|
||||
if (def.type.empty()) {
|
||||
fprintf(stderr, "sherpa_server: [%s] in %s missing type= — skipping\n",
|
||||
sect, iniPath);
|
||||
fflush(stderr);
|
||||
continue;
|
||||
}
|
||||
if (ModelDef* existing = FindModelDefCi(out, def.key)) {
|
||||
fprintf(stderr, "sherpa_server: [%s] in %s — overriding previous definition\n",
|
||||
sect, iniPath);
|
||||
fflush(stderr);
|
||||
*existing = std::move(def);
|
||||
} else {
|
||||
fprintf(stderr, "sherpa_server: registered model '%s' (type=%s, %zu fields) from %s\n",
|
||||
def.key.c_str(), def.type.c_str(), def.fields.size(), iniPath);
|
||||
fflush(stderr);
|
||||
out.push_back(std::move(def));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate every *.ini under `dirPath` (alphabetical, case-insensitive)
|
||||
// and merge their [Model.*] sections into `out`. Last-wins on duplicates.
|
||||
static int LoadModelDefsFromDir(const char* dirPath,
|
||||
std::vector<ModelDef>& out) {
|
||||
char pattern[MAX_PATH];
|
||||
int wrote = snprintf(pattern, sizeof(pattern), "%s\\*.ini", dirPath);
|
||||
if (wrote < 0 || wrote >= (int)sizeof(pattern)) {
|
||||
fprintf(stderr, "sherpa_server: voice_presets path too long: %s\n", dirPath);
|
||||
fflush(stderr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::vector<std::string> names;
|
||||
WIN32_FIND_DATAA fd;
|
||||
HANDLE h = FindFirstFileA(pattern, &fd);
|
||||
if (h == INVALID_HANDLE_VALUE) {
|
||||
fprintf(stderr, "sherpa_server: no *.ini files in %s\n", dirPath);
|
||||
fflush(stderr);
|
||||
return 0;
|
||||
}
|
||||
do {
|
||||
if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
|
||||
names.push_back(fd.cFileName);
|
||||
} while (FindNextFileA(h, &fd));
|
||||
FindClose(h);
|
||||
|
||||
std::sort(names.begin(), names.end(),
|
||||
[](const std::string& a, const std::string& b) {
|
||||
return _stricmp(a.c_str(), b.c_str()) < 0;
|
||||
});
|
||||
|
||||
fprintf(stderr, "sherpa_server: loading %zu voice_presets file(s) from %s\n",
|
||||
names.size(), dirPath);
|
||||
fflush(stderr);
|
||||
for (const auto& nm : names) {
|
||||
char full[MAX_PATH];
|
||||
snprintf(full, sizeof(full), "%s\\%s", dirPath, nm.c_str());
|
||||
LoadModelDefsFromFile(full, out);
|
||||
}
|
||||
return (int)out.size();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine creation per family
|
||||
// ---------------------------------------------------------------------------
|
||||
struct EngineGlobals {
|
||||
const char* provider;
|
||||
int numThreads;
|
||||
int debug;
|
||||
int maxNumSentences;
|
||||
float silenceScale;
|
||||
};
|
||||
|
||||
// Preflight the non-CPU provider DLL so we can fall back to CPU without
|
||||
// onnxruntime's CUDA init aborting the whole process. Returns the
|
||||
// effective provider string the caller should use.
|
||||
static const char* PreflightProvider(const char* provider) {
|
||||
const char* providerDll = nullptr;
|
||||
if (_stricmp(provider, "cuda") == 0) providerDll = "onnxruntime_providers_cuda.dll";
|
||||
else if (_stricmp(provider, "tensorrt") == 0) providerDll = "onnxruntime_providers_tensorrt.dll";
|
||||
if (!providerDll) return provider;
|
||||
|
||||
HMODULE h = LoadLibraryA(providerDll);
|
||||
if (!h) {
|
||||
DWORD err = GetLastError();
|
||||
fprintf(stderr,
|
||||
"sherpa_server: cannot load %s (err=%lu) — provider=%s "
|
||||
"unavailable on this machine. Falling back to CPU.\n",
|
||||
providerDll, err, provider);
|
||||
fflush(stderr);
|
||||
return "cpu";
|
||||
}
|
||||
FreeLibrary(h);
|
||||
return provider;
|
||||
}
|
||||
|
||||
// Build a SherpaOnnxOfflineTtsConfig populated for one ModelDef and try
|
||||
// to create the engine. Falls back from non-CPU providers to CPU on
|
||||
// failure. Returns nullptr on hard failure; writes the loaded engine's
|
||||
// sample rate / speaker count to *outSampleRate / *outNumSpeakers on
|
||||
// success.
|
||||
static const SherpaOnnxOfflineTts*
|
||||
CreateEngineForDef(const ModelDef& def, const EngineGlobals& g,
|
||||
int* outSampleRate, int* outNumSpeakers) {
|
||||
SherpaOnnxOfflineTtsConfig cfg = {};
|
||||
cfg.model.num_threads = g.numThreads;
|
||||
cfg.model.debug = g.debug;
|
||||
cfg.model.provider = PreflightProvider(g.provider);
|
||||
cfg.max_num_sentences = g.maxNumSentences;
|
||||
cfg.silence_scale = g.silenceScale;
|
||||
|
||||
const char* t = def.type.c_str();
|
||||
if (_stricmp(t, "kokoro") == 0) {
|
||||
cfg.model.kokoro.model = def.GetCStr("model");
|
||||
cfg.model.kokoro.voices = def.GetCStr("voices");
|
||||
cfg.model.kokoro.tokens = def.GetCStr("tokens");
|
||||
cfg.model.kokoro.data_dir = def.GetCStr("data_dir");
|
||||
cfg.model.kokoro.lexicon = def.GetCStr("lexicon");
|
||||
cfg.model.kokoro.lang = def.GetCStr("lang");
|
||||
cfg.model.kokoro.length_scale = def.GetFloat("length_scale", 1.0f);
|
||||
} else if (_stricmp(t, "kitten") == 0) {
|
||||
cfg.model.kitten.model = def.GetCStr("model");
|
||||
cfg.model.kitten.voices = def.GetCStr("voices");
|
||||
cfg.model.kitten.tokens = def.GetCStr("tokens");
|
||||
cfg.model.kitten.data_dir = def.GetCStr("data_dir");
|
||||
cfg.model.kitten.length_scale = def.GetFloat("length_scale", 1.0f);
|
||||
} else if (_stricmp(t, "vits") == 0) {
|
||||
cfg.model.vits.model = def.GetCStr("model");
|
||||
cfg.model.vits.lexicon = def.GetCStr("lexicon");
|
||||
cfg.model.vits.tokens = def.GetCStr("tokens");
|
||||
cfg.model.vits.data_dir = def.GetCStr("data_dir");
|
||||
cfg.model.vits.noise_scale = def.GetFloat("noise_scale", 0.667f);
|
||||
cfg.model.vits.noise_scale_w = def.GetFloat("noise_scale_w", 0.8f);
|
||||
cfg.model.vits.length_scale = def.GetFloat("length_scale", 1.0f);
|
||||
} else if (_stricmp(t, "matcha") == 0) {
|
||||
cfg.model.matcha.acoustic_model = def.GetCStr("acoustic_model");
|
||||
cfg.model.matcha.vocoder = def.GetCStr("vocoder");
|
||||
cfg.model.matcha.lexicon = def.GetCStr("lexicon");
|
||||
cfg.model.matcha.tokens = def.GetCStr("tokens");
|
||||
cfg.model.matcha.data_dir = def.GetCStr("data_dir");
|
||||
cfg.model.matcha.noise_scale = def.GetFloat("noise_scale", 1.0f);
|
||||
cfg.model.matcha.length_scale = def.GetFloat("length_scale", 1.0f);
|
||||
} else {
|
||||
fprintf(stderr, "sherpa_server: model '%s' has unsupported type='%s' — "
|
||||
"supported: kokoro, kitten, vits, matcha\n",
|
||||
def.key.c_str(), t);
|
||||
fflush(stderr);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
fprintf(stderr, "sherpa_server: loading '%s' (type=%s, provider=%s, threads=%d)\n",
|
||||
def.key.c_str(), t, cfg.model.provider, cfg.model.num_threads);
|
||||
fflush(stderr);
|
||||
|
||||
const SherpaOnnxOfflineTts* tts = SherpaOnnxCreateOfflineTts(&cfg);
|
||||
if (!tts && strcmp(cfg.model.provider, "cpu") != 0) {
|
||||
fprintf(stderr, "sherpa_server: provider=%s failed for '%s' — "
|
||||
"falling back to CPU\n",
|
||||
cfg.model.provider, def.key.c_str());
|
||||
fflush(stderr);
|
||||
cfg.model.provider = "cpu";
|
||||
tts = SherpaOnnxCreateOfflineTts(&cfg);
|
||||
}
|
||||
if (!tts) {
|
||||
fprintf(stderr, "sherpa_server: SherpaOnnxCreateOfflineTts failed for '%s'\n",
|
||||
def.key.c_str());
|
||||
fflush(stderr);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
*outSampleRate = SherpaOnnxOfflineTtsSampleRate(tts);
|
||||
*outNumSpeakers = SherpaOnnxOfflineTtsNumSpeakers(tts);
|
||||
fprintf(stderr, "sherpa_server: '%s' ready sr=%d speakers=%d\n",
|
||||
def.key.c_str(), *outSampleRate, *outNumSpeakers);
|
||||
fflush(stderr);
|
||||
return tts;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WAV building — sherpa returns float [-1, 1] samples; we serialise to a
|
||||
// mono 16-bit PCM RIFF file and hand raw bytes to the plugin.
|
||||
// ---------------------------------------------------------------------------
|
||||
static std::string BuildWav(const float* samples, int32_t n, int32_t sampleRate) {
|
||||
const uint16_t channels = 1;
|
||||
const uint16_t bitsPerSample = 16;
|
||||
const uint16_t blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint32_t byteRate = (uint32_t)sampleRate * blockAlign;
|
||||
const uint32_t dataSize = (uint32_t)n * blockAlign;
|
||||
const uint32_t riffSize = 36 + dataSize;
|
||||
const uint32_t fmtSize = 16;
|
||||
const uint16_t audioFormat = 1;
|
||||
|
||||
std::string wav;
|
||||
wav.reserve(44 + dataSize);
|
||||
|
||||
auto putBytes = [&](const void* p, size_t len) {
|
||||
wav.append((const char*)p, len);
|
||||
};
|
||||
auto putU32 = [&](uint32_t v) { putBytes(&v, 4); };
|
||||
auto putU16 = [&](uint16_t v) { putBytes(&v, 2); };
|
||||
|
||||
putBytes("RIFF", 4); putU32(riffSize);
|
||||
putBytes("WAVE", 4);
|
||||
putBytes("fmt ", 4); putU32(fmtSize);
|
||||
putU16(audioFormat); putU16(channels);
|
||||
putU32((uint32_t)sampleRate); putU32(byteRate);
|
||||
putU16(blockAlign); putU16(bitsPerSample);
|
||||
putBytes("data", 4); putU32(dataSize);
|
||||
|
||||
wav.resize(wav.size() + dataSize);
|
||||
int16_t* pcm = (int16_t*)(wav.data() + wav.size() - dataSize);
|
||||
for (int32_t i = 0; i < n; i++) {
|
||||
float v = samples[i] * 32767.0f;
|
||||
if (v > 32767.0f) pcm[i] = 32767;
|
||||
else if (v < -32768.0f) pcm[i] = -32768;
|
||||
else pcm[i] = (int16_t)v;
|
||||
}
|
||||
return wav;
|
||||
}
|
||||
|
||||
static void WriteResponse(const char* wavBytes, int32_t wavLen) {
|
||||
fwrite(&wavLen, 4, 1, stdout);
|
||||
if (wavLen > 0 && wavBytes) fwrite(wavBytes, 1, (size_t)wavLen, stdout);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
static void WriteFailure() {
|
||||
int32_t zero = 0;
|
||||
fwrite(&zero, 4, 1, stdout);
|
||||
fflush(stdout);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-engine state
|
||||
// ---------------------------------------------------------------------------
|
||||
struct EngineState {
|
||||
const SherpaOnnxOfflineTts* tts;
|
||||
int sampleRate;
|
||||
int numSpeakers;
|
||||
bool loadFailed; // sticky: once a model fails to load, don't retry
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
int main(int argc, char** argv) {
|
||||
// Binary mode on stdout so Windows doesn't mangle \n -> \r\n in PCM.
|
||||
_setmode(_fileno(stdout), _O_BINARY);
|
||||
|
||||
const char* presetsPath = FindFlag(argc, argv, "--voice-presets");
|
||||
if (!presetsPath || !*presetsPath) {
|
||||
fprintf(stderr, "sherpa_server: --voice-presets <dir> is required\n");
|
||||
return 2;
|
||||
}
|
||||
DWORD presetsAttrs = GetFileAttributesA(presetsPath);
|
||||
if (presetsAttrs == INVALID_FILE_ATTRIBUTES ||
|
||||
!(presetsAttrs & FILE_ATTRIBUTE_DIRECTORY)) {
|
||||
fprintf(stderr, "sherpa_server: --voice-presets must name an existing "
|
||||
"directory (got %s)\n", presetsPath);
|
||||
return 2;
|
||||
}
|
||||
|
||||
EngineGlobals globals = {};
|
||||
{
|
||||
const char* provider = FindFlag(argc, argv, "--provider");
|
||||
globals.provider = (provider && provider[0]) ? provider : "cpu";
|
||||
globals.numThreads = FlagInt(argc, argv, "--num-threads", 2);
|
||||
globals.debug = FlagInt(argc, argv, "--debug", 0);
|
||||
// Kokoro ignores max_num_sentences != 1 (it streams the full text
|
||||
// through a single forward pass). Default 1 avoids a spurious warning.
|
||||
globals.maxNumSentences = FlagInt(argc, argv, "--max-num-sentences", 1);
|
||||
globals.silenceScale = FlagFloat(argc, argv, "--silence-scale", 0.2f);
|
||||
}
|
||||
float speed = FlagFloat(argc, argv, "--speed", 1.0f);
|
||||
|
||||
fprintf(stderr, "sherpa_server: voice_presets=%s provider=%s threads=%d speed=%.2f\n",
|
||||
presetsPath, globals.provider, globals.numThreads, speed);
|
||||
fflush(stderr);
|
||||
|
||||
std::vector<ModelDef> modelDefs;
|
||||
int modelCount = LoadModelDefsFromDir(presetsPath, modelDefs);
|
||||
if (modelCount == 0) {
|
||||
fprintf(stderr, "sherpa_server: voice_presets/ has no [Model.*] sections — "
|
||||
"every request will fail until at least one model is declared\n");
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, EngineState> engines;
|
||||
|
||||
// Lazy-load an engine on first reference. Subsequent requests for the
|
||||
// same key reuse the loaded handle. Failed loads are sticky — we
|
||||
// don't retry on every line, just log once and reply failure.
|
||||
auto GetEngine = [&](const std::string& key) -> EngineState* {
|
||||
std::string lower = key;
|
||||
for (auto& c : lower) c = (char)tolower((unsigned char)c);
|
||||
|
||||
auto it = engines.find(lower);
|
||||
if (it != engines.end()) {
|
||||
return it->second.loadFailed ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
EngineState& st = engines[lower];
|
||||
st.tts = nullptr;
|
||||
st.sampleRate = 0;
|
||||
st.numSpeakers = 0;
|
||||
st.loadFailed = false;
|
||||
|
||||
const ModelDef* def = FindModelDefCi(modelDefs, key);
|
||||
if (!def) {
|
||||
fprintf(stderr, "sherpa_server: model '%s' not declared in any voice_presets/*.ini\n",
|
||||
key.c_str());
|
||||
fflush(stderr);
|
||||
st.loadFailed = true;
|
||||
return nullptr;
|
||||
}
|
||||
st.tts = CreateEngineForDef(*def, globals, &st.sampleRate, &st.numSpeakers);
|
||||
if (!st.tts) {
|
||||
st.loadFailed = true;
|
||||
return nullptr;
|
||||
}
|
||||
return &st;
|
||||
};
|
||||
|
||||
// Tray-icon indicator: lets the user see that sherpa_server.exe is
|
||||
// alive in the background. The exe is WIN32 subsystem (no console
|
||||
// window), so without this the only sign of life is the process in
|
||||
// Task Manager.
|
||||
{
|
||||
char tip[160];
|
||||
snprintf(tip, sizeof(tip),
|
||||
"Sherpa-onnx TTS (Numen) - %d model(s) registered",
|
||||
modelCount);
|
||||
sherpa::StartTrayIcon(tip);
|
||||
}
|
||||
|
||||
// Utterance loop. Read one request line, synthesise, emit WAV.
|
||||
std::string line;
|
||||
line.reserve(4096);
|
||||
|
||||
while (true) {
|
||||
int ch = fgetc(stdin);
|
||||
if (ch == EOF) break;
|
||||
if (ch == '\r') continue;
|
||||
if (ch != '\n') {
|
||||
line.push_back((char)ch);
|
||||
// Guard runaway input; drop the rest of the line on overflow.
|
||||
if (line.size() > 16 * 1024) {
|
||||
while ((ch = fgetc(stdin)) != EOF && ch != '\n') {}
|
||||
fprintf(stderr, "sherpa_server: dropped oversized request line\n");
|
||||
fflush(stderr);
|
||||
line.clear();
|
||||
WriteFailure();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse "<modelKey>\t<sid>\t<text>". Two tabs minimum; the text
|
||||
// is everything past the second tab and may itself contain
|
||||
// anything (we don't strip).
|
||||
size_t tab1 = line.find('\t');
|
||||
size_t tab2 = (tab1 == std::string::npos) ? std::string::npos
|
||||
: line.find('\t', tab1 + 1);
|
||||
if (tab1 == std::string::npos || tab2 == std::string::npos) {
|
||||
fprintf(stderr, "sherpa_server: request missing tab separator(s): \"%.80s\"\n",
|
||||
line.c_str());
|
||||
fflush(stderr);
|
||||
WriteFailure();
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string modelKey(line.data(), tab1);
|
||||
std::string sidStr(line.data() + tab1 + 1, tab2 - tab1 - 1);
|
||||
const char* text = line.c_str() + tab2 + 1;
|
||||
|
||||
int32_t sid = atoi(sidStr.c_str());
|
||||
|
||||
EngineState* eng = GetEngine(modelKey);
|
||||
if (!eng) {
|
||||
WriteFailure();
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
SherpaOnnxGenerationConfig gcfg = {};
|
||||
gcfg.sid = sid;
|
||||
gcfg.speed = speed;
|
||||
|
||||
const SherpaOnnxGeneratedAudio* audio =
|
||||
SherpaOnnxOfflineTtsGenerateWithConfig(eng->tts, text, &gcfg, nullptr, nullptr);
|
||||
if (!audio || !audio->samples || audio->n <= 0) {
|
||||
fprintf(stderr, "sherpa_server: synthesis failed (model=%s sid=%d, text=\"%.80s\")\n",
|
||||
modelKey.c_str(), sid, text);
|
||||
fflush(stderr);
|
||||
if (audio) SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio);
|
||||
WriteFailure();
|
||||
line.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string wav = BuildWav(audio->samples, audio->n, audio->sample_rate);
|
||||
fprintf(stderr, "sherpa_server: model=%s sid=%d samples=%d sr=%d bytes=%zu\n",
|
||||
modelKey.c_str(), sid, audio->n, audio->sample_rate, wav.size());
|
||||
fflush(stderr);
|
||||
SherpaOnnxDestroyOfflineTtsGeneratedAudio(audio);
|
||||
|
||||
WriteResponse(wav.data(), (int32_t)wav.size());
|
||||
line.clear();
|
||||
}
|
||||
|
||||
fprintf(stderr, "sherpa_server: stdin EOF, shutting down\n");
|
||||
fflush(stderr);
|
||||
sherpa::StopTrayIcon();
|
||||
for (auto& kv : engines) {
|
||||
if (kv.second.tts) SherpaOnnxDestroyOfflineTts(kv.second.tts);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
195
tray_icon_win32.cpp
Normal file
195
tray_icon_win32.cpp
Normal file
@ -0,0 +1,195 @@
|
||||
#ifndef _CRT_SECURE_NO_WARNINGS
|
||||
#define _CRT_SECURE_NO_WARNINGS
|
||||
#endif
|
||||
|
||||
#include "tray_icon_win32.hpp"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
|
||||
#pragma comment(lib, "user32.lib")
|
||||
#pragma comment(lib, "shell32.lib")
|
||||
|
||||
namespace sherpa {
|
||||
|
||||
namespace {
|
||||
|
||||
// WM_APP-based callback ID for our Shell_NotifyIcon. Anything in [WM_APP,
|
||||
// 0xBFFF] is safe to use for app-defined messages.
|
||||
constexpr UINT WM_TRAYICON = WM_APP + 1;
|
||||
|
||||
// Unique ID for our notification icon within the HWND. Arbitrary value.
|
||||
constexpr UINT TRAY_ICON_UID = 0xC1A2;
|
||||
|
||||
// Right-click menu command ids.
|
||||
constexpr UINT IDM_TOOLTIP = 1; // disabled label echoing the tooltip
|
||||
constexpr UINT IDM_EXIT = 2; // shut the helper down
|
||||
|
||||
HANDLE g_thread = nullptr;
|
||||
DWORD g_threadId = 0;
|
||||
char g_tooltip[128] = {};
|
||||
|
||||
// Set inside the tray thread once NIM_ADD succeeds, so StopTrayIcon can
|
||||
// tell the difference between "still starting" and "icon is live".
|
||||
std::atomic<bool> g_iconLive{false};
|
||||
|
||||
// Optional graceful-shutdown hook, invoked on the tray thread when the user
|
||||
// picks "Exit". Empty -> Exit terminates the process via ExitProcess.
|
||||
std::function<void()> g_onExit;
|
||||
|
||||
// Handle the "Exit" menu pick (tray thread). Remove the icon immediately so
|
||||
// no ghost lingers, then either hand back to the caller's shutdown hook or
|
||||
// terminate outright.
|
||||
void RequestExit(HWND hwnd) {
|
||||
NOTIFYICONDATAA nidDel = {};
|
||||
nidDel.cbSize = sizeof(nidDel);
|
||||
nidDel.hWnd = hwnd;
|
||||
nidDel.uID = TRAY_ICON_UID;
|
||||
Shell_NotifyIconA(NIM_DELETE, &nidDel);
|
||||
g_iconLive.store(false, std::memory_order_release);
|
||||
|
||||
if (g_onExit) {
|
||||
// Graceful: e.g. stop the listen loop so main() returns and calls
|
||||
// StopTrayIcon(), which unwinds this message thread.
|
||||
g_onExit();
|
||||
} else {
|
||||
// No graceful hook (blocking stdin loop): terminate. The OS reaps
|
||||
// the already-removed icon and this thread.
|
||||
ExitProcess(0);
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT CALLBACK TrayWndProc(HWND hwnd, UINT msg,
|
||||
WPARAM wParam, LPARAM lParam) {
|
||||
if (msg == WM_TRAYICON) {
|
||||
UINT event = LOWORD(lParam);
|
||||
if (event == WM_RBUTTONUP || event == WM_CONTEXTMENU) {
|
||||
// Right-click menu: a disabled label mirroring the tooltip, a
|
||||
// separator, then an "Exit" item that shuts the helper down.
|
||||
POINT pt;
|
||||
GetCursorPos(&pt);
|
||||
HMENU menu = CreatePopupMenu();
|
||||
if (menu) {
|
||||
AppendMenuA(menu, MF_STRING | MF_GRAYED, IDM_TOOLTIP, g_tooltip);
|
||||
AppendMenuA(menu, MF_SEPARATOR, 0, nullptr);
|
||||
AppendMenuA(menu, MF_STRING, IDM_EXIT, "Exit");
|
||||
// SetForegroundWindow is required so the menu dismisses
|
||||
// correctly when the user clicks outside it.
|
||||
SetForegroundWindow(hwnd);
|
||||
// TPM_RETURNCMD returns the picked id inline instead of
|
||||
// posting WM_COMMAND, so we can act on it right here.
|
||||
UINT cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD,
|
||||
pt.x, pt.y, 0, hwnd, NULL);
|
||||
DestroyMenu(menu);
|
||||
if (cmd == IDM_EXIT) RequestExit(hwnd);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (msg == WM_DESTROY) {
|
||||
PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return DefWindowProcA(hwnd, msg, wParam, lParam);
|
||||
}
|
||||
|
||||
DWORD WINAPI TrayThreadProc(LPVOID /*param*/) {
|
||||
HINSTANCE hInst = GetModuleHandleA(NULL);
|
||||
|
||||
WNDCLASSEXA wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.lpfnWndProc = TrayWndProc;
|
||||
wc.hInstance = hInst;
|
||||
wc.lpszClassName = "SherpaTrayClass";
|
||||
if (!RegisterClassExA(&wc) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
HWND hwnd = CreateWindowExA(0, "SherpaTrayClass", "Sherpa TTS",
|
||||
0, 0, 0, 0, 0,
|
||||
HWND_MESSAGE, // message-only window
|
||||
NULL, hInst, NULL);
|
||||
if (!hwnd) return 1;
|
||||
|
||||
NOTIFYICONDATAA nid = {};
|
||||
nid.cbSize = sizeof(nid);
|
||||
nid.hWnd = hwnd;
|
||||
nid.uID = TRAY_ICON_UID;
|
||||
nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP;
|
||||
nid.uCallbackMessage = WM_TRAYICON;
|
||||
nid.hIcon = LoadIconA(NULL, IDI_APPLICATION);
|
||||
// szTip is 128 chars on Win2000+; Shell_NotifyIcon truncates safely
|
||||
// if the source is shorter (we keep g_tooltip null-terminated).
|
||||
strncpy(nid.szTip, g_tooltip, sizeof(nid.szTip) - 1);
|
||||
|
||||
if (!Shell_NotifyIconA(NIM_ADD, &nid)) {
|
||||
DestroyWindow(hwnd);
|
||||
return 1;
|
||||
}
|
||||
g_iconLive.store(true, std::memory_order_release);
|
||||
|
||||
MSG msg;
|
||||
while (GetMessageA(&msg, NULL, 0, 0) > 0) {
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessageA(&msg);
|
||||
}
|
||||
|
||||
NOTIFYICONDATAA nidDel = {};
|
||||
nidDel.cbSize = sizeof(nidDel);
|
||||
nidDel.hWnd = hwnd;
|
||||
nidDel.uID = TRAY_ICON_UID;
|
||||
Shell_NotifyIconA(NIM_DELETE, &nidDel);
|
||||
g_iconLive.store(false, std::memory_order_release);
|
||||
|
||||
DestroyWindow(hwnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void StartTrayIcon(const std::string& tooltip, std::function<void()> onExit) {
|
||||
if (g_thread) return; // already started — silently ignore
|
||||
|
||||
g_onExit = std::move(onExit);
|
||||
|
||||
// Snapshot the tooltip into our static buffer so the worker thread
|
||||
// doesn't read the caller's std::string.
|
||||
strncpy(g_tooltip, tooltip.c_str(), sizeof(g_tooltip) - 1);
|
||||
g_tooltip[sizeof(g_tooltip) - 1] = '\0';
|
||||
|
||||
g_thread = CreateThread(NULL, 0, TrayThreadProc, NULL, 0, &g_threadId);
|
||||
}
|
||||
|
||||
void StopTrayIcon() {
|
||||
if (!g_thread) return;
|
||||
|
||||
if (g_threadId != 0) {
|
||||
// PostThreadMessage delivers a WM_QUIT directly to the tray
|
||||
// thread's queue; GetMessage returns 0 and the loop exits.
|
||||
PostThreadMessageA(g_threadId, WM_QUIT, 0, 0);
|
||||
}
|
||||
// 2 s is plenty for the icon-removal + window-destroy path.
|
||||
WaitForSingleObject(g_thread, 2000);
|
||||
CloseHandle(g_thread);
|
||||
g_thread = nullptr;
|
||||
g_threadId = 0;
|
||||
g_onExit = nullptr; // drop any captured references
|
||||
}
|
||||
|
||||
} // namespace sherpa
|
||||
|
||||
#else // !_WIN32
|
||||
|
||||
namespace sherpa {
|
||||
void StartTrayIcon(const std::string&, std::function<void()>) {}
|
||||
void StopTrayIcon() {}
|
||||
} // namespace sherpa
|
||||
|
||||
#endif
|
||||
37
tray_icon_win32.hpp
Normal file
37
tray_icon_win32.hpp
Normal file
@ -0,0 +1,37 @@
|
||||
// Optional tray-icon indicator for the long-running sherpa_server process.
|
||||
// Windows-only; on other platforms the StartTrayIcon / StopTrayIcon calls
|
||||
// are no-ops via the #ifdef _WIN32 stub at the bottom of the .cpp.
|
||||
//
|
||||
// The icon tells the user a background TTS helper is running and offers a
|
||||
// right-click "Exit" to shut it down. The process is normally reaped by the
|
||||
// parent (the Numen NVSE plugin spawns sherpa_server.exe into a kill-on-close
|
||||
// Job Object); Exit is the manual path for when the helper is left running
|
||||
// standalone. The stdin request loop can't be unblocked from the tray thread,
|
||||
// so sherpa_server passes no onExit hook and Exit calls ExitProcess.
|
||||
//
|
||||
// Mirrors third-party/piper/src/cpp/tray_icon_win32.{hpp,cpp}; duplicated
|
||||
// rather than shared because the two helpers ship out of independent CMake
|
||||
// trees (piper is a submodule, sherpa_server is in this repo). Keep the
|
||||
// two copies in sync when touching either.
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace sherpa {
|
||||
|
||||
// Add a tray icon with the given tooltip text. The optional onExit hook is
|
||||
// invoked on the tray thread when the user picks "Exit"; pass it to shut the
|
||||
// host down gracefully. When empty, "Exit" terminates the process via
|
||||
// ExitProcess. Idempotent; subsequent calls are ignored while an icon is
|
||||
// already active. The icon and its hidden message-only window live on a
|
||||
// dedicated thread so the request loop on the main thread is unaffected.
|
||||
void StartTrayIcon(const std::string& tooltip,
|
||||
std::function<void()> onExit = {});
|
||||
|
||||
// Remove the tray icon and wait briefly for the message-pump thread to
|
||||
// exit. Safe to call from a normal exit path; on TerminateProcess the
|
||||
// OS reaps the icon anyway.
|
||||
void StopTrayIcon();
|
||||
|
||||
} // namespace sherpa
|
||||
Loading…
x
Reference in New Issue
Block a user