Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ IF(RESET_INSTALL_PREFIX)
ENDIF(NOT $ENV{FS2PATH} STREQUAL "")
ENDIF(RESET_INSTALL_PREFIX)

IF(WIN32 OR APPLE OR CMAKE_SYSTEM_NAME STREQUAL "Linux")
IF(WIN32 OR APPLE OR ANDROID OR CMAKE_SYSTEM_NAME STREQUAL "Linux")
OPTION(FSO_USE_SPEECH "Use text-to-speach libraries" ON)
ELSE()
OPTION(FSO_USE_SPEECH "Use text-to-speach libraries" OFF)
Expand Down
2 changes: 2 additions & 0 deletions cmake/finder/FindSpeech.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ if (WIN32)
endif()
elseif(APPLE)
# it should just work
elseif(ANDROID)
# connects to a java TTS manager via SDL activity class
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
# uses speech-dispatcher with dlopen
else()
Expand Down
12 changes: 10 additions & 2 deletions code/sound/fsspeech.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ static bool ttstechroom_change(bool new_val, bool initial)
return true;
}

#ifndef __ANDROID__
Comment thread
notimaginative marked this conversation as resolved.
Outdated
static bool ttsvolume_change(float new_val, bool initial)
{
if (initial) {
Expand All @@ -83,6 +84,7 @@ static bool ttsvolume_change(float new_val, bool initial)
speech_set_volume((unsigned short) new_val);
return true;
}
#endif

static std::pair<int, SCP_string> ttsvoice_deserializer(const json_t* el)
{
Expand Down Expand Up @@ -125,6 +127,7 @@ static SCP_string ttsvoice_display(const std::pair<int, SCP_string>& vi)
return vi.second;
}

#ifndef __ANDROID__ //Note: No independient TTS volume control in Android, remove the option.
static auto SpeechVolumeOption = options::OptionBuilder<float>("Speech.Volume",
std::pair<const char*, int>{"TTS Volume", 1920},
std::pair<const char*, int>{"Volume used for playing TTS speech", 1921})
Expand All @@ -134,6 +137,7 @@ static auto SpeechVolumeOption = options::OptionBuilder<float>("Speech.Volume",
.change_listener(ttsvolume_change)
.importance(2)
.finish();
#endif

static auto SpeechRateOption = options::OptionBuilder<float>("Speech.Rate",
std::pair<const char*, int>{"TTS Rate", 1922},
Expand All @@ -152,7 +156,9 @@ static bool ttsvoice_change(const std::pair<int, SCP_string>& new_voice, bool in
}
speech_set_voice(new_voice.first);
// Re-apply volume and rate, it is needed on Mac and maybe on other OS as well
#ifndef __ANDROID__
speech_set_volume((unsigned short)SpeechVolumeOption->getValue());
#endif
speech_set_rate(SpeechRateOption->getValue());
return true;
}
Expand Down Expand Up @@ -251,7 +257,9 @@ bool fsspeech_init()
FSSpeech_play_from[FSSPEECH_FROM_MULTI] = SpeechMultiOption->getValue();
// The apply order must be Voice->Volume/Rate to avoid issues on Mac.
speech_set_voice(SpeechVoiceOption->getValue().first);
#ifndef __ANDROID__
speech_set_volume((unsigned short)SpeechVolumeOption->getValue());
#endif
speech_set_rate(SpeechRateOption->getValue());
}
else
Expand All @@ -264,10 +272,10 @@ bool fsspeech_init()

int voice = os_config_read_uint(nullptr, "SpeechVoice", 0);
speech_set_voice(voice);

#ifndef __ANDROID__
int volume = os_config_read_uint(nullptr, "SpeechVolume", 100);
speech_set_volume((unsigned short)volume);

#endif
int rate = os_config_read_uint(nullptr, "SpeechRate", 100);
speech_set_rate(static_cast<float>(rate));
}
Expand Down
298 changes: 298 additions & 0 deletions code/sound/speech_android.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
#ifdef FS2_SPEECH
#ifdef __ANDROID__
#include "globalincs/pstypes.h"
#include "utils/unicode.h"
#include "speech.h"
#include <jni.h>
#include "SDL.h"
#include "SDL_system.h"

bool Speech_init = false;
static jclass j_game_class = nullptr;
static jmethodID tts_speak = nullptr;
static jmethodID tts_stop = nullptr;
static jmethodID tts_pause = nullptr;
static jmethodID tts_resume = nullptr;
static jmethodID tts_isSpeaking = nullptr;
static jmethodID tts_shutdown = nullptr;
static jmethodID tts_setRate = nullptr;
static jmethodID tts_setVoice = nullptr;
static jmethodID tts_getVoices = nullptr;

// Helper to get a static method from a java class and clear the exception
// if the method is not found. This is needed to avoid crashing on the next JNI request.
static jmethodID get_static_method(JNIEnv* e, jclass cls, const char* name, const char* sig)
{
jmethodID m = e->GetStaticMethodID(cls, name, sig);
if (e->ExceptionCheck()) {
e->ExceptionClear();
m = nullptr;
mprintf(("Speech : Method: %s. Not found on GameActivity! Signature: %s. \n", name, sig));
}
return m;
}

// Ask SDL for the JNI Environment and hook to
// the external TTSManager on the android side of things.
// Then assign all method IDs for later use.
bool speech_init()
{
Speech_init = false;
mprintf(("Speech : Try to init TTSManager on GameActivity...\n"));

// Get the JNI Environment pointer and current Activity instance via SDL
JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
jobject activity = (jobject)SDL_AndroidGetActivity();

if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

if (activity == nullptr) {
mprintf(("Speech : Unable to get SDL Android activity!\n"));
return false;
}

// GetObjectClass returns a local ref — promote to global so it survives
// across JNI calls made from different scopes/threads.
jclass local_class = env->GetObjectClass(activity);
env->DeleteLocalRef(activity);

if (local_class == nullptr) {
mprintf(("Speech : Unable to find the GameActivity class!\n"));
return false;
}

j_game_class = (jclass)env->NewGlobalRef(local_class);
env->DeleteLocalRef(local_class);

// Map all static methods from TTSManager
tts_speak = get_static_method (env, j_game_class, "tts_speak", "(Ljava/lang/String;)Z");
tts_stop = get_static_method (env, j_game_class, "tts_stop", "()Z");
tts_pause = get_static_method (env, j_game_class, "tts_pause", "()Z");
tts_resume = get_static_method (env, j_game_class, "tts_resume", "()Z");
tts_isSpeaking = get_static_method (env, j_game_class, "tts_isSpeaking", "()Z");
tts_shutdown = get_static_method (env, j_game_class, "tts_shutdown", "()V");
tts_setRate = get_static_method (env, j_game_class, "tts_setRate", "(F)V");
tts_setVoice = get_static_method (env, j_game_class, "tts_setLanguageTag", "(Ljava/lang/String;)V");
tts_getVoices = get_static_method (env, j_game_class, "tts_getAvailableLanguageTags", "()[Ljava/lang/String;");

if (!tts_speak || !tts_stop || !tts_pause || !tts_resume || !tts_isSpeaking || !tts_shutdown || !tts_setRate || !tts_setVoice || !tts_getVoices) {
mprintf(("Speech : Unable to map at least one core TTS method to GameActivity!\n"));
env->DeleteGlobalRef(j_game_class);
j_game_class = nullptr;
return false;
}

mprintf(("Speech : Init Completed!\n"));
Speech_init = true;
return true;
}


bool speech_play(const SCP_string& text)
{
if (!Speech_init)
return false;

if (text.empty()) {
nprintf(("Speech", "Not playing speech because passed text is empty.\n"));
return false;
}

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

jstring j_txt = env->NewStringUTF(text.c_str());
nprintf(("Speech : Playing TTS string: %s!\n", text.c_str()));
jboolean ok = env->CallStaticBooleanMethod(j_game_class, tts_speak, j_txt);
env->DeleteLocalRef(j_txt);

if (ok != JNI_TRUE) {
mprintf(("Speech : Error playing TTS string!\n"));
return false;
}
return true;
}

bool speech_stop()
{
if (!Speech_init)
return false;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

return env->CallStaticBooleanMethod(j_game_class, tts_stop) == JNI_TRUE;
}

bool speech_pause()
{
if (!Speech_init)
return false;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

return env->CallStaticBooleanMethod(j_game_class, tts_pause) == JNI_TRUE;
}

bool speech_resume()
{
if (!Speech_init)
return false;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

return env->CallStaticBooleanMethod(j_game_class, tts_resume) == JNI_TRUE;
}

bool speech_is_speaking()
{
if (!Speech_init)
return false;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

return env->CallStaticBooleanMethod(j_game_class, tts_isSpeaking) == JNI_TRUE;
}

void speech_deinit()
{
if (!Speech_init)
return;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env != nullptr) {
env->CallStaticVoidMethod(j_game_class, tts_shutdown);
env->DeleteGlobalRef(j_game_class);
}
j_game_class = nullptr;
tts_speak = nullptr;
tts_stop = nullptr;
tts_pause = nullptr;
tts_resume = nullptr;
tts_isSpeaking = nullptr;
tts_shutdown = nullptr;
tts_setRate = nullptr;
tts_setVoice = nullptr;
tts_getVoices = nullptr;
Speech_init = false;
}


// Android TTS does not expose a direct volume API.
// Volume is controlled by the STREAM_MUSIC channel at the OS level.
bool speech_set_volume(unsigned short /*volume*/)
{
if (!Speech_init)
return false;
return true;
}

bool speech_set_rate(float rate_percent)
{
if (!Speech_init)
return false;

if(rate_percent > 150.0)
rate_percent = 150.0;
else if(rate_percent < 50.0)
rate_percent = 50.0;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

float android_rate = rate_percent / 100.0f; // 0.5 .. 1.0 .. 1.5
env->CallStaticVoidMethod(j_game_class, tts_setRate, (jfloat)android_rate);
return true;
}

bool speech_set_voice(int voice)
{
if (!Speech_init)
return false;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return false;
}

jobjectArray tags = (jobjectArray)env->CallStaticObjectMethod(j_game_class, tts_getVoices);
if (tags == nullptr)
return false;

jsize count = env->GetArrayLength(tags);
if (voice < 0 || voice >= (int)count) {
env->DeleteLocalRef(tags);
return false;
}

jstring tag = (jstring)env->GetObjectArrayElement(tags, (jsize)voice);
env->CallStaticVoidMethod(j_game_class, tts_setVoice, tag);
env->DeleteLocalRef(tag);
env->DeleteLocalRef(tags);
return true;
}

SCP_vector<std::pair<int, SCP_string>> speech_enumerate_voices()
{
SCP_vector<std::pair<int, SCP_string>> voices;

if (!Speech_init)
return voices;

JNIEnv* env = (JNIEnv*)SDL_AndroidGetJNIEnv();
if (env == nullptr) {
mprintf(("Speech : Unable to get JNI environment!\n"));
return voices;
}

jobjectArray tags = (jobjectArray)env->CallStaticObjectMethod(j_game_class, tts_getVoices);
if (tags == nullptr)
return voices;

jsize count = env->GetArrayLength(tags);
voices.reserve((size_t)count);

for (jsize i = 0; i < count; ++i) {
jstring tag = (jstring)env->GetObjectArrayElement(tags, i);
if (tag == nullptr)
continue;

const char* raw = env->GetStringUTFChars(tag, nullptr);
if (raw) {
voices.emplace_back((int)i, SCP_string(raw));
env->ReleaseStringUTFChars(tag, raw);
}
env->DeleteLocalRef(tag);
}

env->DeleteLocalRef(tags);
return voices;
}

#endif // __ANDROID__
#endif // FS2_SPEECH
5 changes: 5 additions & 0 deletions code/source_groups.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -1641,6 +1641,11 @@ elseif (APPLE)
${file_root_sound}
sound/speech_mac.mm
)
elseif (ANDROID)
add_file_folder("Sound"
${file_root_sound}
sound/speech_android.cpp
)
elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_file_folder("Sound"
${file_root_sound}
Expand Down
Loading