diff --git a/CMakeLists.txt b/CMakeLists.txt index 42e0356..01d0d13 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,10 @@ elseif(APPLE) target_sources(${PROJECT_NAME}_obj PRIVATE "SRC/NSSpeech.h" "SRC/NSSpeech.mm") endif() +elseif(ANDROID) + target_sources(${PROJECT_NAME}_obj PRIVATE + "SRC/AndroidTextToSpeech.h" "SRC/AndroidTextToSpeech.cpp" + "Dep/AndroidContext.h" "Dep/AndroidContext.cpp") else() target_sources(${PROJECT_NAME}_obj PRIVATE "Dep/utf-8.h" "Dep/utf-8.c" "SRC/SpeechDispatcher.h" "SRC/SpeechDispatcher.cpp") @@ -135,6 +139,11 @@ if (BUILD_SRAL_TEST) "-framework AVFoundation" ) endif() +elseif(ANDROID) + if(BUILD_SHARED_LIBS) + target_link_libraries(${PROJECT_NAME} log) + endif() + target_link_libraries(${PROJECT_NAME}_static log) else() find_package(PkgConfig REQUIRED) pkg_check_modules(SpeechD REQUIRED speech-dispatcher) diff --git a/Dep/AndroidContext.cpp b/Dep/AndroidContext.cpp new file mode 100644 index 0000000..a897977 --- /dev/null +++ b/Dep/AndroidContext.cpp @@ -0,0 +1,55 @@ +#include "AndroidContext.h" + +namespace Sral { + +namespace { +JavaVM* g_vm = nullptr; +jobject g_activity = nullptr; +} + +bool SetAndroidJNIEnv(JNIEnv* env) { + if (!env) return false; + return env->GetJavaVM(&g_vm) == JNI_OK; +} + +bool SetAndroidActivity(jobject activity) { + if (!activity) return false; + JNIEnv* env = GetAndroidJNIEnv(); + if (!env) return false; + if (g_activity) { + env->DeleteGlobalRef(g_activity); + g_activity = nullptr; + } + g_activity = env->NewGlobalRef(activity); + return g_activity != nullptr; +} + +void ClearAndroidContext() { + JNIEnv* env = GetAndroidJNIEnv(); + if (env && g_activity) { + env->DeleteGlobalRef(g_activity); + } + g_activity = nullptr; + g_vm = nullptr; +} + +// A JNIEnv* is thread-local — the one passed to SetAndroidJNIEnv is only +// valid on the thread that called into SRAL. We cache the JavaVM* +// (process-global) instead and use GetEnv/AttachCurrentThread to obtain a +// valid JNIEnv* for whichever thread is currently calling into SRAL. +JNIEnv* GetAndroidJNIEnv() { + if (!g_vm) return nullptr; + JNIEnv* env = nullptr; + jint status = g_vm->GetEnv((void**)&env, JNI_VERSION_1_6); + if (status == JNI_OK) return env; + if (status == JNI_EDETACHED) { + if (g_vm->AttachCurrentThread(&env, nullptr) == JNI_OK) return env; + } + return nullptr; +} + +jobject GetAndroidActivity() { + return g_activity; +} + +} // namespace Sral diff --git a/Dep/AndroidContext.h b/Dep/AndroidContext.h new file mode 100644 index 0000000..2c0a0de --- /dev/null +++ b/Dep/AndroidContext.h @@ -0,0 +1,42 @@ +#ifndef SRAL_ANDROID_CONTEXT_H_ +#define SRAL_ANDROID_CONTEXT_H_ +#pragma once +#include + +// Shared Android JNI context for SRAL engines. +// +// Native code on Android cannot obtain a JNIEnv* or the app's Activity on its +// own — the host application must provide them. The host sets them via +// SRAL_SetEngineParameter using SRAL_PARAM_ANDROID_JNI_ENV and +// SRAL_PARAM_ANDROID_ACTIVITY (see SRAL.h), which forward into the setters +// below. Engine implementations (e.g. AndroidTextToSpeech, future +// AndroidTalkBack) retrieve the values through the accessor functions. +// +// SetAndroidJNIEnv must be called before SetAndroidActivity, since creating +// a global ref for the activity requires a valid JNIEnv*. + +namespace Sral { + +// Captures the JavaVM* from the provided env. Returns false if env is null. +bool SetAndroidJNIEnv(JNIEnv* env); + +// Stores a global ref to activity. Requires SetAndroidJNIEnv to have been +// called first. Returns false if the JavaVM is not yet set, activity is null, +// or the global ref could not be created. +bool SetAndroidActivity(jobject activity); + +// Called by SRAL_Uninitialize. Releases the global ref to activity. +void ClearAndroidContext(); + +// Returns a JNIEnv* valid on the calling thread, attaching the thread to +// the JavaVM if necessary. Returns nullptr if SetAndroidJNIEnv has not +// been called or if attach fails. +JNIEnv* GetAndroidJNIEnv(); + +// Returns the Activity global ref, or nullptr if SetAndroidActivity has +// not been called. +jobject GetAndroidActivity(); + +} // namespace Sral + +#endif diff --git a/Dep/AndroidTTSHelper.java b/Dep/AndroidTTSHelper.java new file mode 100644 index 0000000..08846cb --- /dev/null +++ b/Dep/AndroidTTSHelper.java @@ -0,0 +1,66 @@ +package org.sral; + +import android.content.Context; +import android.os.Bundle; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import java.util.Locale; + +public class AndroidTTSHelper { + private TextToSpeech tts; + private boolean ready = false; + private boolean speaking = false; + private float currentRate = 1.0f; + private float currentVolume = 1.0f; + + public AndroidTTSHelper(Context context) { + tts = new TextToSpeech(context, status -> { + if (status == TextToSpeech.SUCCESS) { + tts.setLanguage(Locale.getDefault()); + tts.setOnUtteranceProgressListener(new UtteranceProgressListener() { + @Override public void onStart(String utteranceId) { speaking = true; } + @Override public void onDone(String utteranceId) { speaking = false; } + @Override public void onError(String utteranceId) { speaking = false; } + }); + ready = true; + } + }); + } + + public boolean isActive() { return ready; } + + public boolean isSpeaking() { return speaking; } + + public void speak(String text, boolean interrupt) { + if (!ready) return; + int queueMode = interrupt ? TextToSpeech.QUEUE_FLUSH : TextToSpeech.QUEUE_ADD; + Bundle params = new Bundle(); + tts.speak(text, queueMode, params, "sral_utterance"); + } + + public void stop() { + if (tts != null) tts.stop(); + speaking = false; + } + + public void setSpeechRate(float rate) { + if (tts != null) tts.setSpeechRate(rate); + currentRate = rate; + } + + public void setVolume(float volume) { + currentVolume = volume; + } + + public float getRate() { return currentRate; } + public float getVolume() { return currentVolume; } + + public void shutdown() { + if (tts != null) { + tts.stop(); + tts.shutdown(); + tts = null; + } + ready = false; + } +} diff --git a/Include/SRAL.h b/Include/SRAL.h index 928ce8d..d6d828f 100644 --- a/Include/SRAL.h +++ b/Include/SRAL.h @@ -85,7 +85,10 @@ SRAL_ENGINE_VOICE_OVER = 1 << 8, SRAL_ENGINE_NS_SPEECH = 1 << 9, /** @brief AVFoundation Speech Synthesizer (AVSpeechSynthesizer), for text-to-speech on Apple platforms. */ -SRAL_ENGINE_AV_SPEECH = 1 << 10 +SRAL_ENGINE_AV_SPEECH = 1 << 10, + +// --- Android Speech Synthesis Engines --- +SRAL_ENGINE_ANDROID_TEXT_TO_SPEECH = 1 << 11 }; /** @@ -122,7 +125,21 @@ SRAL_ENGINE_AV_SPEECH = 1 << 10 SRAL_PARAM_SAPI_TRIM_THRESHOLD, SRAL_PARAM_ENABLE_SPELLING, SRAL_PARAM_USE_CHARACTER_DESCRIPTIONS, - SRAL_PARAM_NVDA_IS_CONTROL_EX + SRAL_PARAM_NVDA_IS_CONTROL_EX, + + /** + * @brief (Android only) Set the JNIEnv* used by Android engines. + * Must be set via SRAL_SetEngineParameter before SRAL_Initialize. + * Value is a JNIEnv* cast to void*. + */ + SRAL_PARAM_ANDROID_JNI_ENV, + + /** + * @brief (Android only) Set the Activity (jobject) used by Android engines. + * Must be set via SRAL_SetEngineParameter before SRAL_Initialize. + * Value is a jobject cast to void*. + */ + SRAL_PARAM_ANDROID_ACTIVITY }; diff --git a/README.md b/README.md index 1bb792e..54678de 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,15 @@ SRAL is a cross-platform library designed to provide a unified interface for out ## 🛠 Supported Engines & Platforms -SRAL supports Windows, macOS, and Linux. +SRAL supports Windows, macOS, iOS, Android, and Linux. | Category | Supported Engines | | --- | --- | | **Windows Screen Readers** | NVDA, JAWS, ZDSR, Microsoft Narrator | | **Windows Frameworks** | Microsoft UI Automation (UIA) | -| **Apple (macOS)** | VoiceOver, AVFoundation (AVSpeech) | +| **macOS** | VoiceOver, NSSpeech, AVFoundation (AVSpeech) | +| **iOS** | VoiceOver, AVFoundation (AVSpeech) | +| **Android** | Android TextToSpeech | | **Linux** | Speech Dispatcher | | **General APIs** | Microsoft SAPI (Windows), BRLTTY (Braille) | @@ -121,5 +123,3 @@ bool SRAL_Braille(const char* text); bool SRAL_IsSpeaking(void); ``` - - diff --git a/SRC/AndroidTextToSpeech.cpp b/SRC/AndroidTextToSpeech.cpp new file mode 100644 index 0000000..9d82cf8 --- /dev/null +++ b/SRC/AndroidTextToSpeech.cpp @@ -0,0 +1,120 @@ + #include "AndroidTextToSpeech.h" + #include "../Dep/AndroidContext.h" + + namespace Sral { + + bool AndroidTextToSpeech::Initialize() { + env = GetAndroidJNIEnv(); + if (!env) return false; + + jobject activity = GetAndroidActivity(); + if (!activity) return false; + + speechClass = env->FindClass("org/sral/AndroidTTSHelper"); + if (!speechClass || env->ExceptionCheck()) { + env->ExceptionClear(); + return false; + } + speechClass = (jclass)env->NewGlobalRef(speechClass); + + constructor = env->GetMethodID(speechClass, "", "(Landroid/content/Context;)V"); + midSpeak = env->GetMethodID(speechClass, "speak", "(Ljava/lang/String;Z)V"); + midSilence = env->GetMethodID(speechClass, "stop", "()V"); + midIsActive = env->GetMethodID(speechClass, "isActive", "()Z"); + midIsSpeaking = env->GetMethodID(speechClass, "isSpeaking", "()Z"); + midSetRate = env->GetMethodID(speechClass, "setSpeechRate", "(F)V"); + midSetVolume = env->GetMethodID(speechClass, "setVolume", "(F)V"); + midGetRate = env->GetMethodID(speechClass, "getRate", "()F"); + midGetVolume = env->GetMethodID(speechClass, "getVolume", "()F"); + + if (!constructor || !midSpeak || !midSilence || !midIsActive || !midIsSpeaking) return false; + + jobject localObj = env->NewObject(speechClass, constructor, activity); + if (!localObj) return false; + speechObj = env->NewGlobalRef(localObj); + env->DeleteLocalRef(localObj); + + return true; + } + + bool AndroidTextToSpeech::Uninitialize() { + ReleaseAllStrings(); + if (env && speechObj) { + jmethodID midShutdown = env->GetMethodID(speechClass, "shutdown", "()V"); + if (midShutdown) env->CallVoidMethod(speechObj, midShutdown); + env->DeleteGlobalRef(speechObj); + speechObj = nullptr; + } + if (env && speechClass) { + env->DeleteGlobalRef(speechClass); + speechClass = nullptr; + } + return true; + } + + bool AndroidTextToSpeech::GetActive() { + if (!env || !speechObj || !midIsActive) return false; + return env->CallBooleanMethod(speechObj, midIsActive); + } + + bool AndroidTextToSpeech::Speak(const char* text, bool interrupt) { + if (!env || !speechObj || !midSpeak) return false; + jstring jtext = env->NewStringUTF(text); + if (!jtext) return false; + env->CallVoidMethod(speechObj, midSpeak, jtext, (jboolean)interrupt); + env->DeleteLocalRef(jtext); + return true; + } + + bool AndroidTextToSpeech::StopSpeech() { + if (!env || !speechObj || !midSilence) return false; + env->CallVoidMethod(speechObj, midSilence); + return true; + } + + bool AndroidTextToSpeech::IsSpeaking() { + if (!env || !speechObj || !midIsSpeaking) return false; + return env->CallBooleanMethod(speechObj, midIsSpeaking); + } + + bool AndroidTextToSpeech::SetParameter(int param, const void* value) { + if (!env || !speechObj) return false; + switch (param) { + case SRAL_PARAM_SPEECH_RATE: + if (midSetRate) { + env->CallVoidMethod(speechObj, midSetRate, (jfloat)*reinterpret_cast(value)); + return true; + } + return false; + case SRAL_PARAM_SPEECH_VOLUME: + if (midSetVolume) { + env->CallVoidMethod(speechObj, midSetVolume, (jfloat)*reinterpret_cast(value)); + return true; + } + return false; + default: + return false; + } + } + + bool AndroidTextToSpeech::GetParameter(int param, void* value) { + if (!env || !speechObj) return false; + switch (param) { + case SRAL_PARAM_SPEECH_RATE: + if (midGetRate) { + *(int*)value = (int)env->CallFloatMethod(speechObj, midGetRate); + return true; + } + return false; + case SRAL_PARAM_SPEECH_VOLUME: + if (midGetVolume) { + *(int*)value = (int)env->CallFloatMethod(speechObj, midGetVolume); + return true; + } + return false; + default: + return false; + } + } + + } // namespace Sral diff --git a/SRC/AndroidTextToSpeech.h b/SRC/AndroidTextToSpeech.h index 796547d..5813d1b 100644 --- a/SRC/AndroidTextToSpeech.h +++ b/SRC/AndroidTextToSpeech.h @@ -14,7 +14,7 @@ namespace Sral { bool SpeakSsml(const char* ssml, bool interrupt)override { return false; } - void* SpeakToMemory(const char* text, uint64_t* buffer_size, int* channels, int* sample_rate, int* bits_per_sample)override { return false;} + void* SpeakToMemory(const char* text, uint64_t* buffer_size, int* channels, int* sample_rate, int* bits_per_sample)override { return nullptr;} bool SetParameter(int param, const void* value)override; bool GetParameter(int param, void* value) override; diff --git a/SRC/SRAL.cpp b/SRC/SRAL.cpp index 9e843ce..9732b0a 100644 --- a/SRC/SRAL.cpp +++ b/SRC/SRAL.cpp @@ -18,6 +18,9 @@ #include "NSSpeech.h" #endif #include "VoiceOver.h" +#elif defined(__ANDROID__) +#include "AndroidTextToSpeech.h" +#include "../Dep/AndroidContext.h" #else #include "SpeechDispatcher.h" #endif @@ -234,6 +237,8 @@ extern "C" SRAL_API bool SRAL_Initialize(int engines_exclude) { #ifndef SRAL_NO_NSSPEECH g_engines[SRAL_ENGINE_NS_SPEECH] = std::make_unique(); #endif +#elif defined(__ANDROID__) + g_engines[SRAL_ENGINE_ANDROID_TEXT_TO_SPEECH] = std::make_unique(); #else g_engines[SRAL_ENGINE_SPEECH_DISPATCHER] = std::make_unique(); #endif @@ -262,6 +267,9 @@ extern "C" SRAL_API void SRAL_Uninitialize(void) { } #ifdef _WIN32 CoUninitialize(); +#endif +#ifdef __ANDROID__ + Sral::ClearAndroidContext(); #endif g_currentEngine = nullptr; g_engines.clear(); @@ -326,7 +334,7 @@ static BOOL FindProcess(const wchar_t* name) { #endif static void speech_engine_update() { - if (!g_currentEngine || !g_currentEngine->GetActive() || g_currentEngine->GetNumber() == SRAL_ENGINE_SAPI || g_currentEngine->GetNumber() == SRAL_ENGINE_UIA || g_currentEngine->GetNumber() == SRAL_ENGINE_AV_SPEECH) { + if (!g_currentEngine || !g_currentEngine->GetActive() || g_currentEngine->GetNumber() == SRAL_ENGINE_SAPI || g_currentEngine->GetNumber() == SRAL_ENGINE_UIA || g_currentEngine->GetNumber() == SRAL_ENGINE_AV_SPEECH || g_currentEngine->GetNumber() == SRAL_ENGINE_ANDROID_TEXT_TO_SPEECH) { #if defined(_WIN32) && !defined(SRAL_NO_UIA) if (FindProcess(L"narrator.exe") == TRUE) { g_currentEngine = get_engine(SRAL_ENGINE_UIA); @@ -422,6 +430,16 @@ extern "C" SRAL_API int SRAL_GetEngineFeatures(int engine) { extern "C" SRAL_API bool SRAL_SetEngineParameter(int engine, int param, const void* value) { +#ifdef __ANDROID__ + // Android platform bootstrap params may be set before SRAL_Initialize, + // so they are handled here directly rather than dispatching to an engine. + if (param == SRAL_PARAM_ANDROID_JNI_ENV) { + return Sral::SetAndroidJNIEnv((JNIEnv*)const_cast(value)); + } + if (param == SRAL_PARAM_ANDROID_ACTIVITY) { + return Sral::SetAndroidActivity((jobject)const_cast(value)); + } +#endif if (engine == 0 && g_currentEngine != nullptr) { return g_currentEngine->SetParameter(param, value); } @@ -616,6 +634,7 @@ extern "C" SRAL_API const char* SRAL_GetEngineName(int engine) { case SRAL_ENGINE_NARRATOR: return "Narrator"; case SRAL_ENGINE_VOICE_OVER: return "Voice Over"; case SRAL_ENGINE_ZDSR: return "ZDSR"; + case SRAL_ENGINE_ANDROID_TEXT_TO_SPEECH: return "Android TTS"; default: return "Unknown"; } }