diff --git a/CMakeLists.txt b/CMakeLists.txt index 27279b799f8..36c9ab9bdc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/cmake/finder/FindSpeech.cmake b/cmake/finder/FindSpeech.cmake index c7cc6b50b4c..d30ac68dbe2 100644 --- a/cmake/finder/FindSpeech.cmake +++ b/cmake/finder/FindSpeech.cmake @@ -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() diff --git a/code/sound/speech_android.cpp b/code/sound/speech_android.cpp new file mode 100644 index 00000000000..6a5a2b5e0c9 --- /dev/null +++ b/code/sound/speech_android.cpp @@ -0,0 +1,296 @@ +#ifdef FS2_SPEECH +#ifdef __ANDROID__ +#include "globalincs/pstypes.h" +#include "utils/unicode.h" +#include "speech.h" +#include +#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()); + 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> speech_enumerate_voices() +{ + SCP_vector> 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); + + 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 diff --git a/code/source_groups.cmake b/code/source_groups.cmake index fdf03d2b942..8ec88999eb5 100644 --- a/code/source_groups.cmake +++ b/code/source_groups.cmake @@ -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}