Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
de1ef6c
kitten tts module for speechd
jsett Aug 5, 2026
8d7a4db
Remove the buffering code. Everything runs fine without it. Fixed an …
jsett Aug 8, 2026
01cb7d1
Added instructions into the readme on building/install
jsett Aug 10, 2026
2b2a6de
small bug
jsett Aug 10, 2026
a5ab64f
change stuff in model_change_voice to use Gstring's instead of char*'s.
jsett Aug 11, 2026
3a611e6
mark need to be a char*
jsett Aug 11, 2026
9f5bf7e
updated readme.
jsett Aug 11, 2026
a284a5c
updated readme
jsett Aug 12, 2026
d188bf5
removed some old dependencies.
jsett Aug 12, 2026
5a8baa9
Updated the make and configure file for optional support. Added depen…
jsett Aug 14, 2026
4a082cc
make sure that if any of our init's fail it get reported as a failure.
jsett Aug 14, 2026
0249b33
placed in license text
jsett Aug 15, 2026
b41356a
added checks for a distro path.
jsett Aug 15, 2026
acebf8e
forgot to change this comment
jsett Aug 15, 2026
b1ce2a2
allow model/voice files to be configured throught the models dot conf…
jsett Aug 26, 2026
9d015f4
Merge branch 'brailcom:master' into kitten
jsett Aug 31, 2026
5198ca7
placed kitten.h into EXTRA_DIST
jsett Aug 31, 2026
a8c493d
- Removed the downloader from the speechd module.
jsett Sep 1, 2026
10bf356
libcurl var should have been removed
jsett Sep 1, 2026
356abac
added example configuration file
jsett Sep 1, 2026
4272cc6
moved the example in the readme to be inline.
jsett Sep 1, 2026
a794550
removed curl from the ci
jsett Sep 1, 2026
b6db9f5
- Added a debug conf option that limits the amount of output printed …
jsett Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions configure.ac
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,18 @@ AC_SUBST([SNDFILE_CFLAGS])
AC_SUBST([SNDFILE_LIBS])
fi

PKG_CHECK_MODULES([LIBONNXRUNTIME], [libonnxruntime])
AC_SUBST([LIBONNXRUNTIME_CFLAGS])
AC_SUBST([LIBONNXRUNTIME_LIBS])

PKG_CHECK_MODULES([LIBXML], [libxml-2.0])
AC_SUBST([LIBXML_CFLAGS])
AC_SUBST([LIBXML_LIBS])

PKG_CHECK_MODULES([LIBCURL], [libcurl])
AC_SUBST([LIBCURL_CFLAGS])
AC_SUBST([LIBCURL_LIBS])

Comment thread
jsett marked this conversation as resolved.
Outdated
PKG_CHECK_MODULES([LIBSYSTEMD], [libsystemd], [have_libsystemd=yes], [:])

# Checks for header files.
Expand Down
8 changes: 8 additions & 0 deletions src/modules/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,14 @@ sd_dummy_LDADD = $(top_builddir)/src/common/libcommon.la \
$(common_LDADD)
dist_snddata_DATA = dummy-message.wav

#
# kitten tts module
#
modulebin_PROGRAMS += sd_kittentts
sd_kittentts_SOURCES = kitten_server.c kitten_downloader.c kitten_model.c kitten_worker.c
sd_kittentts_CFLAGS = $(ESPEAK_NG_CFLAGS) $(GLIB_CFLAGS) $(LIBONNXRUNTIME_CFLAGS) $(LIBXML_CFLAGS) $(LIBCURL_CFLAGS)
sd_kittentts_LDADD = libspeechd_module.la $(ESPEAK_NG_LIBS) $(EXTRA_ESPEAK_LIBS) $(GLIB_LIBS) $(LIBONNXRUNTIME_LIBS) $(LIBXML_LIBS) $(LIBCURL_LIBS)

#
# festival module
#
Expand Down
133 changes: 133 additions & 0 deletions src/modules/kitten.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#include <glib/gstdio.h>
Comment thread
jsett marked this conversation as resolved.
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
#include <curl/curl.h>
#include <stdbool.h>
#include <glib.h>
#include <onnxruntime_c_api.h>
#include <inttypes.h>
#include <sndfile.h>
#include <libxml/parser.h>
#include <libxml/tree.h>
#include <espeak-ng/espeak_ng.h>
#include <espeak-ng/speak_lib.h>
#include "module_utils.h"

#include <speechd_types.h>
#include "spd_module_main.h"

#define CHECK_STATUS(expr) \
do { \
OrtStatus* status = (expr); \
if (status != NULL) { \
const char* msg = g_ort->GetErrorMessage(status); \
fprintf(stderr, "ONNX Runtime Error: %s\n", msg); \
g_ort->ReleaseStatus(status); \
exit(1); \
} \
} while (0)

extern int model_type;
extern int ROWS;
#define COLS 256

#define PAD "$"
#define PUNCTUATION ";:,.!?¡¿—…\"«»\"\" "
#define LETTERS "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
Comment thread
jsett marked this conversation as resolved.
#define LETTERS_IPA "ɑɐɒæɓʙβɔɕçɗɖðʤəɘɚɛɜɝɞɟʄɡɠɢʛɦɧħɥʜɨɪʝɭɬɫɮʟɱɯɰŋɳɲɴøɵɸθœɶʘɹɺɾɻʀʁɽʂʃʈʧʉʊʋⱱʌɣɤʍχʎʏʑʐʒʔʡʕʢǀǁǂǃˈˌːˑʼʴʰʱʲʷˠˤ˞↓↑→↗↘'̩'ᵻ"

#define SYMBOLS PAD PUNCTUATION LETTERS LETTERS_IPA

// setting var's
extern float speed;
//['Leo','Kiki','Hugo','Rosie','Bruno','Luna','Jasper','Bella']
extern GString *voice;
extern GString *voice_setting;
// paths var's
extern GString *model_path;
extern GString *voices_path;
extern const char *home_dir;
extern GString *model_dir;

extern bool stop_generation;

// holds and array of values that must be passed to the model based off the requested voice and length of the text.
extern float *voice_styles;

extern const OrtApi* g_ort;
extern OrtEnv* env;
extern OrtSessionOptions* session_options;
extern OrtSession* session;

#define TARGET_SUBDIR ".config/speech-dispatcher/extra"

typedef struct {
const char *url;
const char *filename;
curl_off_t expected_size;
const char *expected_sha256;
} FileInfo;

extern const FileInfo FILES[];

#define NUM_FILES (sizeof(FILES) / sizeof(FILES[0]))

#define VOICE_LIST(X) \
X(Leo) \
X(Kiki) \
X(Hugo) \
X(Rosie) \
X(Bruno) \
X(Luna) \
X(Jasper) \
X(Bella) \
X(Leo_Low) \
X(Kiki_Low) \
X(Hugo_Low) \
X(Rosie_Low) \
X(Bruno_Low) \
X(Luna_Low) \
X(Jasper_Low) \
X(Bella_Low) \
X(Leo_High) \
X(Kiki_High) \
X(Hugo_High) \
X(Rosie_High) \
X(Bruno_High) \
X(Luna_High) \
X(Jasper_High) \
X(Bella_High)

#define DEFINE_VOICE(name_token) static SPDVoice voice_##name_token = { .name = #name_token, .language = "en" };
#define VOICE_PTR_ITEM(name_token) &voice_##name_token,

// kitten_downloader.c
int download_models(void);

// kitten_model.c
int init_voice_style(const char* voices_path);
void cleanup_voice_style();
GArray *get_style(const char *text, const char *voice);
GArray *get_char_indices(const gchar *locate, const gchar *index_str);
GString *get_phonemes(const char *text);
int init_model(const char* model_path);
void cleanup_model();
GArray* run_model(GArray *inputs_array, GArray *styles_array, float speed);
void convert_float_to_short(const float* in_buffer, GArray* out_buffer, size_t num_samples);
GArray* kitten_speak(const char* data);
int reload_models_and_voices(const char *model_filename, const char* voice_filename);

// kitten_worker.c
int init_model_thread_pool();
int cleanup_threads();
int model_change_voice(const char *var, const char *val);
int model_change_speed(const char *var, const char *val);
int add_generate_speech_task(const char* data, size_t bytes);
int model_stop_generation();
196 changes: 196 additions & 0 deletions src/modules/kitten_downloader.c

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is best to be handled by package managers instead?

As models update, we don't want to frequently update and recompile this as downstream packages might lag behind for years.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also all the download links are outside of this project's control. Which means that we won't be able to fix a broken download until a new release is packaged by distributions and shipped to the end user (which as commented, might be months/years after).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is best to be handled by package managers

It already checks if the models/voices are in the path /usr/share/speech-dispatcher/models/kitten and uses that path first. If the package manger placed them there, then they will get used.

we won't be able to fix a broken download until a new release

I have added the ability to set the download configuration through the models dot conf file.

Here is an example dot conf on how to do that https://gist.github.com/jsett/6146eb2803f8780a1830ed816bdc94ef

@unlimitedsola unlimitedsola Aug 26, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is best to be handled by package managers

It already checks if the models/voices are in the path /usr/share/speech-dispatcher/models/kitten and uses that path first. If the package manger placed them there, then they will get used.

I'm afraid distribution would not organize their packages this way.

Like piper, kitten models are general purpose in the sense of the Text-to-Speech task. If we take example from piper, the AUR packages place the models into /usr/share/piper-voices/, because their use are not limited to speech-dispatcher, and the distributions would not want to package multiple copies of large model files just for different applications to use.

It is also likely that different distributions wouldn't come to agreement on where these models should be placed. So the distribution packagers would adjust the conf default to accommodate their packaging schemes.

I believe this is best to be handled by package managers instead?

Nevertheless, I personally wouldn't expect program like speech-dispatcher (as a daemon process) to make network requests (and I strongly believe that we should learn from the log4shell disaster where a logging library contains the code path to make network requests) . So adding this to me feels like a scope creep that I would like to push against, as indicated by the first time linking with libcurl.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/usr/share/speech-dispatcher was recommended in the comments above, but I really don't have any real issue changing it if that what is decided.

code path to make network requests

I'm not opposed to removing the downloader. But there is a trade off here. If you remove it you increase the complexity for users to get things working, if there distro did not include the models with the install. I would be interested in hearing what other people think.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turning the downloader into a one-off script might not be a bad idea

That could be an idea yes. People installing speech-dispatcher by hand would use it, and distribution people would package the voices. People could still use the script to update the voices.

Again, if only upstream voices providers could manage these questions instead of just throwing .onnx files at people...

There could simply be a voice downloading manager, even independent from speech-dispatcher, that manages updates and whatnot, and speech-dispatcher would just happen to see the voice files show up, and use them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upstream of these models could simply document where these are supposed to be shipped

Unfortunately I don't think hugging face models have a way of doing this currently.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based off the above comments, I think this is what I am going to do.

With regards to the downloader.

  1. Remove the downloader from the speechd module.

  2. Create a separate script outside speechd code that has the downloader (this will likely be in python).

  3. Add documentation on how to use the downloader.

With regards to the search path for the distro's.

  1. Search if the models are in ~/.cache/speech-dispatcher/kitten (for user provided models).

  2. Check if a path is provided in the module's .conf file. Something like a line for modelsPath "<path>" distro's then should be able to place the models where ever they desire and add a line in the conf file.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

upstream of these models could simply document where these are supposed to be shipped

Unfortunately I don't think hugging face models have a way of doing this currently.

It would really just be a matter of documenting it in README or website or whatever, nothing fancy...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made all those changes in the latest commit.

Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
#include "kitten.h"

const FileInfo FILES[] = {
{
"https://huggingface.co/KittenML/kitten-tts-micro-0.8/resolve/main/kitten_tts_micro_v0_8.onnx",
"kitten_tts_micro_v0_8.onnx",
41384970,
"95481626fee1ba70ce683e69c534fc7cb38433c46ce42d3abbeafb4b9f1a4123"
},
{
"https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/kitten_tts_mini_v0_8.onnx",
"kitten_tts_mini_v0_8.onnx",
78268016,
"0f5bbae4fc4800c98dbc544a87ecfa79510de2fb8222db30d12e5bfe9177df91"
},
{
"https://huggingface.co/KittenML/kitten-tts-nano-0.2/resolve/main/kitten_tts_nano_v0_2.onnx",
"kitten_tts_nano_v0_2.onnx",
23804156,
"42fa8809db319cd7c4c83b3c501e2313bf90edf610235291cad605e4adcb242d"
},
{
"https://github.com/jsett/kittenvoices/raw/refs/heads/main/voices_micro.bin",
"voices_micro.bin",
3276800,
"12ad10f1fcce8a458b5cf79769b8edd4ba0e11e9fb6532fd192c3500b2b37a5d"
},
{
"https://github.com/jsett/kittenvoices/raw/refs/heads/main/voices_mini.bin",
"voices_mini.bin",
3276800,
"0e4965b46333db53ce09c73842623bf7055ea62c67f78803cb7ea9c16da6ac2b"
},
{
"https://github.com/jsett/kittenvoices/raw/refs/heads/main/voices_nano.bin",
"voices_nano.bin",
8192,
"42a40a24a352a38657d6cb86ceee51bbc2b7780b29e04fb60bcdf959adccea01"
}
};

// Helper function to recursively create directories
static int ensure_directory_exists(const char *path) {
if (g_mkdir_with_parents(path, 0755) != 0) {
fprintf(stderr, "Error: Failed to create directory '%s': %s\n", path, g_strerror(errno));
return -1;
}
return 0;
}

// Helper function to check if file exists
static int file_exists(const char *path) {
struct stat buffer;
return (stat(path, &buffer) == 0);
}

// Callback for curl to write HTTP data to disk
static size_t write_data(void *ptr, size_t size, size_t nmemb, FILE *stream) {
return fwrite(ptr, size, nmemb, stream);
}

// Download file with libcurl
static int download_file(CURL *curl, const char *url, const char *dest_path) {
FILE *fp = fopen(dest_path, "wb");
if (!fp) {
fprintf(stderr, "Error: Cannot open destination file '%s' for writing: %s\n", dest_path, g_strerror(errno));
return -1;
}

curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_data);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); // Follow redirects
curl_easy_setopt(curl, CURLOPT_FAILONERROR, 1L); // Fail on HTTP errors (>=400)

CURLcode res = curl_easy_perform(curl);
fclose(fp);

if (res != CURLE_OK) {
fprintf(stderr, "Error: Download failed for '%s': %s\n", url, curl_easy_strerror(res));
remove(dest_path); // Clean up partial download
return -1;
}

return 0;
}

// Verify file SHA256 using GLib GChecksum
static int verify_sha256(const char *filepath, const char *expected_sha256) {
GMappedFile *mfile = g_mapped_file_new(filepath, FALSE, NULL);
if (!mfile) {
fprintf(stderr, "Error: Failed to memory-map file '%s' for SHA256 calculation.\n", filepath);
return -1;
}

gsize length = g_mapped_file_get_length(mfile);
const gchar *contents = g_mapped_file_get_contents(mfile);

GChecksum *checksum = g_checksum_new(G_CHECKSUM_SHA256);
g_checksum_update(checksum, (const guchar *)contents, length);
const gchar *computed_sha256 = g_checksum_get_string(checksum);

int match = (g_ascii_strcasecmp(computed_sha256, expected_sha256) == 0);

if (!match) {
fprintf(stderr, "Error: Checksum mismatch for '%s'!\n Expected: %s\n Computed: %s\n",
filepath, expected_sha256, computed_sha256);
}

g_checksum_free(checksum);
g_mapped_file_unref(mfile);

return match ? 0 : -1;
}

// downloads the models and voices if they do not already exist.
// also verifys using sha256 and checks file size.
int download_models(void) {
// Build absolute destination directory path: ~/.config/speech-dispatcher/extra/
char *target_dir = g_build_filename(home_dir, TARGET_SUBDIR, NULL);
Comment thread
jsett marked this conversation as resolved.
Outdated

if (ensure_directory_exists(target_dir) != 0) {
g_free(target_dir);
return EXIT_FAILURE;
}

if (curl_global_init(CURL_GLOBAL_ALL) != 0) {
fprintf(stderr, "Error: Failed to initialize libcurl.\n");
g_free(target_dir);
return EXIT_FAILURE;
}

CURL *curl = curl_easy_init();
if (!curl) {
fprintf(stderr, "Error: Failed to create libcurl handle.\n");
curl_global_cleanup();
g_free(target_dir);
return EXIT_FAILURE;
}

int overall_status = EXIT_SUCCESS;

for (size_t i = 0; i < NUM_FILES; i++) {
char *full_path = g_build_filename(target_dir, FILES[i].filename, NULL);

// Check if file already exists
if (file_exists(full_path)) {
fprintf(stderr, "Info: File '%s' already exists. Skipping download.\n", FILES[i].filename);
g_free(full_path);
continue;
}

fprintf(stderr, "Info: Downloading '%s'...\n", FILES[i].filename);
if (download_file(curl, FILES[i].url, full_path) != 0) {
fprintf(stderr, "Error: Aborting process due to download error.\n");
g_free(full_path);
overall_status = EXIT_FAILURE;
break;
}

// Verify File Size
struct stat st;
if (stat(full_path, &st) != 0) {
fprintf(stderr, "Error: Could not stat downloaded file '%s'.\n", full_path);
g_free(full_path);
overall_status = EXIT_FAILURE;
break;
}

if ((curl_off_t)st.st_size != FILES[i].expected_size) {
fprintf(stderr, "Error: File size mismatch for '%s'! Expected: %ld bytes, Got: %ld bytes.\n",
FILES[i].filename, (long)FILES[i].expected_size, (long)st.st_size);
g_free(full_path);
overall_status = EXIT_FAILURE;
break;
}

// Verify SHA256 using GLib
if (verify_sha256(full_path, FILES[i].expected_sha256) != 0) {
fprintf(stderr, "Error: Integrity check failed for '%s'.\n", FILES[i].filename);
g_free(full_path);
overall_status = EXIT_FAILURE;
break;
}

fprintf(stderr, "Info: Successfully downloaded and verified '%s'.\n", FILES[i].filename);
g_free(full_path);
}

// Cleanup resources
curl_easy_cleanup(curl);
curl_global_cleanup();
g_free(target_dir);

return overall_status;
}
Loading
Loading