diff --git a/app/build.gradle b/app/build.gradle index 52c5c5e..24dcf34 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,10 +7,10 @@ android { defaultConfig { applicationId "com.danefinlay.ttsutil" - minSdkVersion 20 + minSdkVersion 21 targetSdkVersion 28 - versionCode 1 - versionName "1.0" + versionCode 2 + versionName "2.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } @@ -38,9 +38,10 @@ dependencies { androidTestImplementation('com.android.support.test.espresso:espresso-core:3.0.2') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation 'com.android.support:appcompat-v7:28.0.0' + implementation 'com.android.support:preference-v7:28.0.0' implementation 'org.jetbrains.anko:anko-sdk15:0.9' implementation 'org.jetbrains.anko:anko-support-v4:0.9.1' - implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta3' + implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta6' implementation 'com.android.support:design:28.0.0' testImplementation 'junit:junit:4.13-beta-3' } diff --git a/app/src/main/java/com/danefinlay/ttsutil/ApplicationEx.kt b/app/src/main/java/com/danefinlay/ttsutil/ApplicationEx.kt index 52abf19..527bcb3 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ApplicationEx.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ApplicationEx.kt @@ -21,18 +21,47 @@ package com.danefinlay.ttsutil import android.app.Application +import android.content.res.Resources import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.media.AudioManager.OnAudioFocusChangeListener import android.os.Build +import android.speech.tts.TextToSpeech +import android.support.v7.preference.PreferenceManager import org.jetbrains.anko.audioManager +import org.jetbrains.anko.longToast +import org.jetbrains.anko.notificationManager +import java.util.* class ApplicationEx : Application() { var speaker: Speaker? = null private set + var errorMessageId: Int? = null + + /** + * Return the system's current locale. + * + * This will be a Locale object representing the user's preferred language as + * set in the system settings. + */ + val currentSystemLocale: Locale + get() { + val systemConfig = Resources.getSystem().configuration + val systemLocale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + systemConfig?.locales?.get(0) + } else { + @Suppress("deprecation") + systemConfig?.locale + } + + // Return the system locale. Fallback on the default JVM locale if + // necessary. + return systemLocale ?: Locale.getDefault() + } + private val audioFocusGain = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT private val audioFocusRequest: AudioFocusRequest by lazy { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) { @@ -89,15 +118,44 @@ class ApplicationEx : Application() { } } - fun startSpeaker() { + fun startSpeaker(initListener: TextToSpeech.OnInitListener, + preferredEngine: String?) { if (speaker == null) { - speaker = Speaker(this, true) + // Try to get the preferred engine package from shared preferences if + // it is null. + val engineName = preferredEngine ?: + PreferenceManager.getDefaultSharedPreferences(this) + .getString("pref_tts_engine", null) + + // Initialise the Speaker object. + speaker = Speaker(this, true, initListener, + engineName) } } + /** + * Show the speaker error message (if set) or the default speaker not ready + * message. + */ + fun showSpeakerNotReadyMessage() { + val defaultMessageId = R.string.speaker_not_ready_message + val errorMessageId = errorMessageId + longToast(errorMessageId ?: defaultMessageId) + } + fun freeSpeaker() { speaker?.free() speaker = null + + // Cancel any TTS notifications present. + notificationManager.cancel(SPEAKING_NOTIFICATION_ID) + notificationManager.cancel(SYNTHESIS_NOTIFICATION_ID) + } + + fun reinitialiseSpeaker(initListener: TextToSpeech.OnInitListener, + preferredEngine: String?) { + freeSpeaker() + startSpeaker(initListener, preferredEngine) } override fun onLowMemory() { diff --git a/app/src/main/java/com/danefinlay/ttsutil/Notifications.kt b/app/src/main/java/com/danefinlay/ttsutil/Notifications.kt index f3b8006..7e3aadb 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/Notifications.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/Notifications.kt @@ -20,7 +20,6 @@ package com.danefinlay.ttsutil -import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent @@ -93,9 +92,10 @@ private fun getNotificationBuilder(ctx: Context, notificationId: Int): } -fun buildSpeakerNotification(ctx: Context, notificationId: Int): Notification { +fun speakerNotificationBuilder(ctx: Context, notificationId: Int): + NotificationCompat.Builder { val builder = getNotificationBuilder(ctx, notificationId) - builder.apply { + return builder.apply { // Set the title and text depending on the notification ID. when (notificationId) { SPEAKING_NOTIFICATION_ID -> { @@ -111,5 +111,4 @@ fun buildSpeakerNotification(ctx: Context, notificationId: Int): Notification { } } } - return builder.build() } diff --git a/app/src/main/java/com/danefinlay/ttsutil/Speaker.kt b/app/src/main/java/com/danefinlay/ttsutil/Speaker.kt index 46206b4..b41f5a3 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/Speaker.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/Speaker.kt @@ -22,31 +22,37 @@ package com.danefinlay.ttsutil import android.content.Context import android.media.AudioManager -import android.os.Build import android.os.Bundle import android.speech.tts.TextToSpeech -import android.speech.tts.TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID +import android.speech.tts.Voice import org.jetbrains.anko.runOnUiThread import org.jetbrains.anko.toast import java.io.File class Speaker(private val context: Context, var speechAllowed: Boolean, - onReady: Speaker.() -> Unit = {}) : TextToSpeech.OnInitListener { + initListener: TextToSpeech.OnInitListener, + preferredEngine: String?) { - private val tts = TextToSpeech(context.applicationContext, this) + val tts = TextToSpeech(context.applicationContext, initListener, + preferredEngine) private val appCtx: ApplicationEx get() = context.applicationContext as ApplicationEx - var onReady: Speaker.() -> Unit = onReady - set(value) { - field = value - if ( ready ) value() - } - + /** + * Whether the object is ready for speech synthesis. + * + * This should be changed by the OnInitListener. + * */ var ready = false + + /** + * Whether TTS synthesis has been or is in the process of being stopped. + * */ + var stoppingSpeech = false private set + private var lastUtteranceWasFileSynthesis: Boolean = false private var currentUtteranceId: Long = 0 @@ -56,14 +62,79 @@ class Speaker(private val context: Context, return "$id" } - override fun onInit(status: Int) { - when ( status ) { - TextToSpeech.SUCCESS -> { - ready = true - onReady() + /** + * Wrapper for the TextToSpeech.getVoice and TextToSpeech.setVoice methods. + * This catches errors sometimes raised by the TextToSpeech class. + * + * @return Voice instance used by the client, or {@code null} if not set or on + * error. + * + * @see TextToSpeech.getVoice + * @see TextToSpeech.setVoice + * @see Voice + */ + var voice: Voice? + get() { + return try { + // Try to retrieve the current voice. + // This can sometimes raise a NullPointerException. + tts.voice + } + catch (error: NullPointerException) { + null + } + } + set(value) { + try { + // Try to retrieve the current voice. + // This can sometimes raise a NullPointerException. + tts.voice = value + } + catch (error: NullPointerException) {} + } + + /** + * Wrapper for the TextToSpeech.getDefaultVoice method. + * This catches errors sometimes raised by the TextToSpeech class. + * + * @return default Voice instance used by the client, or {@code null} if not set + * or on error. + * + * @see TextToSpeech.getDefaultVoice + * @see Voice + */ + val defaultVoice: Voice? + get() { + return try { + // Try to retrieve the current voice. + // This can sometimes raise a NullPointerException. + tts.defaultVoice + } + catch (error: NullPointerException) { + null + } + } + + /** + * Wrapper for the TextToSpeech.getVoices method. + * This catches errors sometimes raised by the TextToSpeech class. + * + * @return set of available Voice instances. + * + * @see TextToSpeech.getVoices + * @see Voice + */ + val voices: MutableSet + get() { + return try { + // Try to retrieve the set of available voices. + // This can sometimes raise a NullPointerException. + tts.voices + } + catch (error: NullPointerException) { + return mutableSetOf() } } - } fun speak(string: String?) { // Split the text on any new lines to get a list. Utterance pauses will be @@ -72,49 +143,86 @@ class Speaker(private val context: Context, speak(lines) } + private fun splitLongLines(lines: List): List { + val maxLength = TextToSpeech.getMaxSpeechInputLength() + val result = mutableListOf() + for (line in lines) { + if (line.length < maxLength) { + result.add(line) + continue + } + + // Split long lines into multiple strings of reasonable length. + val shorterLines = mutableListOf("") + line.forEach { + val lastString = shorterLines.last() + if (lastString.length < maxLength) { + // Separate on whitespace close to the maximum length where + // possible. + if (it.isWhitespace() && lastString.length > maxLength - 50) + shorterLines.add(it.toString()) + + // Add to the last string. + else { + val newLine = lastString + it.toString() + shorterLines[shorterLines.lastIndex] = newLine + } + } + + // Add a new string. + else shorterLines.add(it.toString()) + } + + // Add the shorter lines to the result list. + result.addAll(shorterLines) + } + + return result + } + fun speak(lines: List) { if (!(ready && speechAllowed)) { return } + // Reset the stopping speech flag. + stoppingSpeech = false + // Stop possible file synthesis before speaking. if (lastUtteranceWasFileSynthesis) { - tts.stop() + stopSpeech() lastUtteranceWasFileSynthesis = false } + // Handle lines that are null, blank or too long. + val inputLines = splitLongLines( + lines.mapNotNull { it }.filter { !it.isBlank() } + ) + // Set the listener. val listener = SpeakingEventListener(appCtx) tts.setOnUtteranceProgressListener(listener) - // Get Android's TTS framework to speak each non-null line. + // Get Android's TTS framework to speak each line. // This is, quite typically, different in some versions of Android. - val nonEmptyLines = lines.mapNotNull { it }.filter { !it.isBlank() } val streamKey = TextToSpeech.Engine.KEY_PARAM_STREAM - var utteranceId: String? = null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val firstUtteranceId = "$currentUtteranceId" + inputLines.forEach { + // Get the next utterance ID. + val utteranceId = getUtteranceId() + + // Add this utterance to the queue. val bundle = Bundle() bundle.putInt(streamKey, AudioManager.STREAM_MUSIC) - nonEmptyLines.forEach { - utteranceId = getUtteranceId() - tts.speak(it, TextToSpeech.QUEUE_ADD, bundle, utteranceId) - pause(100) - } - } else { - val streamValue = AudioManager.STREAM_MUSIC.toString() - nonEmptyLines.forEach { - utteranceId = getUtteranceId() - val map = hashMapOf(streamKey to streamValue, - KEY_PARAM_UTTERANCE_ID to utteranceId) - - @Suppress("deprecation") // handled above. - tts.speak(it, TextToSpeech.QUEUE_ADD, map) - pause(100) - } + tts.speak(it, TextToSpeech.QUEUE_ADD, bundle, utteranceId) + + // Add a short pause after each utterance. + pause(100) } - // Set the listener's final utterance ID. - listener.finalUtteranceId = utteranceId + // Set the listener's first and final utterance IDs. + listener.firstUtteranceId = firstUtteranceId + listener.finalUtteranceId = "${currentUtteranceId - 1}" } fun pause(duration: Long, listener: SpeakerEventListener) { @@ -125,49 +233,55 @@ class Speaker(private val context: Context, @Suppress("SameParameterValue") private fun pause(duration: Long) { - val utteranceId = getUtteranceId() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - tts.playSilentUtterance(duration, TextToSpeech.QUEUE_ADD, - utteranceId) - } else { - @Suppress("deprecation") - tts.playSilence(duration, TextToSpeech.QUEUE_ADD, - hashMapOf(KEY_PARAM_UTTERANCE_ID to utteranceId)) - } - + tts.playSilentUtterance(duration, TextToSpeech.QUEUE_ADD, getUtteranceId()) } - fun synthesizeToFile(text: String, outFile: File, - listener: SpeakerEventListener) { + fun synthesizeToFile(text: String, listener: SynthesisEventListener) { // Stop speech before synthesizing. if (tts.isSpeaking) { - tts.stop() + stopSpeech() context.runOnUiThread { toast(getString(R.string.pre_file_synthesis_msg)) } } + // Reset the stopping speech flag. + stoppingSpeech = false + + // Handle lines that are too long. + val inputLines = splitLongLines(listOf(text)) + // Set the listener. tts.setOnUtteranceProgressListener(listener) - // Get an utterance ID. - val utteranceId = getUtteranceId() + // Get Android's TTS framework to synthesise each input line. + val filesDir = appCtx.filesDir + val firstUtteranceId = "$currentUtteranceId" + inputLines.forEach { + // Get the next utterance ID. + val utteranceId = getUtteranceId() + + // Create a wave file for this utterance. + val file = File(filesDir, "$utteranceId.wav") - if (Build.VERSION.SDK_INT >= 21) { - tts.synthesizeToFile(text, null, outFile, utteranceId) - } else { - @Suppress("deprecation") - tts.synthesizeToFile( - text, hashMapOf(KEY_PARAM_UTTERANCE_ID to utteranceId), - outFile.absolutePath) + // Add this utterance to the queue. + tts.synthesizeToFile(it, null, file, utteranceId) } + // Set the listener's first and final utterance IDs. + listener.firstUtteranceId = firstUtteranceId + listener.finalUtteranceId = "${currentUtteranceId - 1}" + // Set an internal variable for keeping track of file synthesis. lastUtteranceWasFileSynthesis = true } fun stopSpeech() { if ( tts.isSpeaking ) { + // Set the stopping speech flag. + stoppingSpeech = true + + // Tell the TTS engine to stop speech synthesis. tts.stop() } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/SpeakerEventListeners.kt b/app/src/main/java/com/danefinlay/ttsutil/SpeakerEventListeners.kt index a34b109..68dfd25 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/SpeakerEventListeners.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/SpeakerEventListeners.kt @@ -20,47 +20,94 @@ package com.danefinlay.ttsutil -import android.app.Notification import android.content.Context +import android.speech.tts.TextToSpeech.* import android.speech.tts.UtteranceProgressListener +import android.support.v4.app.NotificationCompat import org.jetbrains.anko.* +import java.io.File +import java.lang.RuntimeException -abstract class SpeakerEventListener(private val ctx: Context): +abstract class SpeakerEventListener(protected val app: ApplicationEx): UtteranceProgressListener() { protected abstract val notificationId: Int - protected abstract val notification: Notification + protected abstract val notificationBuilder: NotificationCompat.Builder + var firstUtteranceId: String? = null + var finalUtteranceId: String? = null override fun onError(utteranceId: String?) { // deprecated onError(utteranceId, -1) } override fun onError(utteranceId: String?, errorCode: Int) { - // Display a toast message. - ctx.runOnUiThread { - longToast(R.string.text_synthesis_error_msg) + // Return early if synthesis is only stopping. + val speaker = app.speaker + if (speaker?.stoppingSpeech == true) { + return + } + + // Schedule the notification to be cancelled and audio focus released in + // roughly 300 ms. This is being done asynchronously because it is possible + // that neither the notification has been started nor the audio focus has + // been acquired by the time this is run. + app.doAsync { + Thread.sleep(300) + cancelNotification() + app.releaseAudioFocus() + } + + // Get the matching error message string for errorCode. + val errorMsg = when (errorCode) { + ERROR_SYNTHESIS -> R.string.synthesis_error_msg_synthesis + ERROR_SERVICE -> R.string.synthesis_error_msg_service + ERROR_OUTPUT -> R.string.synthesis_error_msg_output + ERROR_NETWORK -> R.string.synthesis_error_msg_network + ERROR_NETWORK_TIMEOUT -> R.string.synthesis_error_msg_net_timeout + ERROR_INVALID_REQUEST -> R.string.synthesis_error_msg_invalid_req + ERROR_NOT_INSTALLED_YET -> R.string.synthesis_error_msg_voice_data + else -> R.string.synthesis_error_msg_generic + } + + // Display the error message. + app.runOnUiThread { + longToast(errorMsg) } } protected fun startNotification() { - ctx.notificationManager.notify(notificationId, notification) + val notification = notificationBuilder + .setProgress(100, 0, false) + .build() + app.notificationManager.notify(notificationId, notification) + } + + protected fun setProgressNotification(utteranceId: String?) { + val iUtteranceId = utteranceId?.toInt() ?: return + val iFirstUtteranceId = firstUtteranceId?.toInt() ?: return + val iFinalUtteranceId = finalUtteranceId?.toInt() ?: return + + val progress = (iUtteranceId - iFirstUtteranceId).toFloat() / + (iFinalUtteranceId - iFirstUtteranceId).toFloat() * 100 + val notification = notificationBuilder + .setProgress(100, progress.toInt(), false) + .build() + app.notificationManager.notify(notificationId, notification) } protected fun cancelNotification() { - ctx.notificationManager.cancel(notificationId) + app.notificationManager.cancel(notificationId) } } -class SpeakingEventListener(private val app: ApplicationEx): - SpeakerEventListener(app) { +class SpeakingEventListener(app: ApplicationEx): SpeakerEventListener(app) { override val notificationId = SPEAKING_NOTIFICATION_ID - override val notification = - buildSpeakerNotification(app, notificationId) + override val notificationBuilder = + speakerNotificationBuilder(app, notificationId) - var finalUtteranceId: String? = null private var audioFocusRequestGranted = false override fun onStart(utteranceId: String?) { @@ -71,11 +118,6 @@ class SpeakingEventListener(private val app: ApplicationEx): } } - override fun onError(utteranceId: String?, errorCode: Int) { - super.onError(utteranceId, errorCode) - cancelNotification() - } - override fun onStop(utteranceId: String?, interrupted: Boolean) { super.onStop(utteranceId, interrupted) if (interrupted) { @@ -88,48 +130,108 @@ class SpeakingEventListener(private val app: ApplicationEx): if (utteranceId == finalUtteranceId || finalUtteranceId == null) { app.releaseAudioFocus() cancelNotification() + } else { + setProgressNotification(utteranceId) } } } -class SynthesisEventListener(private val ctx: Context, - private val filename: String): - SpeakerEventListener(ctx) { +class SynthesisEventListener(app: ApplicationEx, private val filename: String, + private val uiCtx: Context, private val outFile: File): + SpeakerEventListener(app) { override val notificationId = SYNTHESIS_NOTIFICATION_ID - override val notification = - buildSpeakerNotification(ctx, notificationId) + override val notificationBuilder = + speakerNotificationBuilder(app, notificationId) private var notificationStarted = false + private val utteranceIds = mutableSetOf() + private val inWaveFiles: List + get() { + val filesDir = app.filesDir + return utteranceIds.map { File(filesDir, "$it.wav") } + } + + private fun deleteWaveFiles() { + inWaveFiles.forEach { + if (it.isFile && it.canWrite()) { + it.delete() + } + } + } override fun onStart(utteranceId: String?) { if (!notificationStarted) { startNotification() notificationStarted = true } + + // Store the utterance ID. + if (utteranceId != null) utteranceIds.add(utteranceId) } override fun onStop(utteranceId: String?, interrupted: Boolean) { super.onStop(utteranceId, interrupted) if (interrupted) { cancelNotification() - ctx.runOnUiThread { + deleteWaveFiles() + app.runOnUiThread { toast(getString(R.string.file_synthesis_interrupted_msg)) } } } + override fun onError(utteranceId: String?, errorCode: Int) { + super.onError(utteranceId, errorCode) + deleteWaveFiles() + } + override fun onDone(utteranceId: String?) { - ctx.runOnUiThread { - AlertDialogBuilder(ctx).apply { + // Only continue if this is the final utterance. + if (finalUtteranceId != null && utteranceId != finalUtteranceId) { + setProgressNotification(utteranceId) + return + } + + // Join each utterance's wave file into one wave file. Use the output + // file passed to this listener. + val errorMessage = try { + joinWaveFiles(inWaveFiles, outFile) + null + } catch (error: RuntimeException) { + when (error) { + is IncompatibleWaveFileException -> + app.getString(R.string.incompatible_wave_file_error_msg) + else -> { + val errorInfo = error.javaClass.simpleName + + "(message = \"${error.localizedMessage}\")" + app.getString(R.string.generic_wave_file_error_msg, + errorInfo) + } + } + } + + // Delete the temporary wave files afterwards. + deleteWaveFiles() + + // Cancel the notification. + cancelNotification() + + // Build and show an alert dialog once finished. + app.runOnUiThread { + // Use the given UI context. + AlertDialogBuilder(uiCtx).apply { title(R.string.write_files_fragment_label) - val msgPart1 = ctx.getString( - R.string.write_to_file_alert_message_success) - val fullMsg = "$msgPart1 \"$filename\"" + val fullMsg = if (errorMessage == null) { + val msgPart1 = uiCtx.getString( + R.string.write_to_file_alert_message_success) + "$msgPart1 \"$filename\"" + } else { + errorMessage + } message(fullMsg) positiveButton(R.string.alert_positive_message) {} show() } } - cancelNotification() } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/SpeakerIntentService.kt b/app/src/main/java/com/danefinlay/ttsutil/SpeakerIntentService.kt index e13cef9..12a1344 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/SpeakerIntentService.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/SpeakerIntentService.kt @@ -67,6 +67,10 @@ class SpeakerIntentService : IntentService("SpeakerIntentService") { * parameters. */ private fun handleActionReadText(text: String?) { + if (!speaker.isReady()) { + return + } + // Display a message if 'text' is blank/empty. if (text == null || text.isBlank()) { runOnUiThread { diff --git a/app/src/main/java/com/danefinlay/ttsutil/WaveUtil.kt b/app/src/main/java/com/danefinlay/ttsutil/WaveUtil.kt new file mode 100644 index 0000000..43876d0 --- /dev/null +++ b/app/src/main/java/com/danefinlay/ttsutil/WaveUtil.kt @@ -0,0 +1,211 @@ +/* + * TTS Util + * + * Authors: Dane Finlay + * + * Copyright (C) 2019 Dane Finlay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.danefinlay.ttsutil + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.InputStream +import java.lang.RuntimeException +import java.nio.ByteBuffer +import java.nio.ByteOrder.LITTLE_ENDIAN + +class IncompatibleWaveFileException(message: String): RuntimeException(message) + +class WaveFile(stream: InputStream) { + // Define a few convenient byte-related extension functions. + private fun InputStream.read(n: Int): ByteArray { + return (1..n).map { read().toByte() }.toByteArray() + } + + private fun ByteArray.toLEByteBuffer(): ByteBuffer { + return ByteBuffer.wrap(this).order(LITTLE_ENDIAN) + } + + private fun ByteArray.toAsciiString(): String { + return fold("") { acc, i -> acc + i.toChar() } + } + + private fun ByteArray.toInt() = toLEByteBuffer().int + private fun ByteArray.toShort() = toLEByteBuffer().short + + private fun ByteBuffer.putArrays(vararg arrays: ByteArray): ByteBuffer { + arrays.forEach { put(it) } + return this + } + + // Read the RIFF chunk descriptor fields. + val bChunkId = stream.read(4) + val bChunkSize = stream.read(4) + val bFormat = stream.read(4) + val chunkId = bChunkId.toAsciiString() + val chunkSize = bChunkSize.toInt() + val format = bFormat.toAsciiString() + + // Read the "fmt " sub-chunk. + val bSubChunk1Id = stream.read(4) + val bSubChunk1Size = stream.read(4) + val bAudioFormat = stream.read(2) + val bNumChannels = stream.read(2) + val bSampleRate = stream.read(4) + val bByteRate = stream.read(4) + val bBlockAlign = stream.read(2) + val bBitsPerSample = stream.read(2) + val subChunk1Id = bSubChunk1Id.toAsciiString() + val subChunk1Size = bSubChunk1Size.toInt() + val audioFormat = bAudioFormat.toShort() + val numChannels = bNumChannels.toShort() + val sampleRate = bSampleRate.toInt() + val byteRate = bByteRate.toInt() + val blockAlign = bBlockAlign.toShort() + val bitsPerSample = bBitsPerSample.toShort() + + // Read the "data" sub-chunk. + val bSubChunk2Id = stream.read(4) + val subChunk2Id = { + val result = bSubChunk2Id.toAsciiString() + + // We do not support non-PCM wave files with extra parameters. + if (result != "data") { + throw IncompatibleWaveFileException("Non-PCM wave files with extra " + + "parameters are not unsupported") + } + + // PCM wave file. + result + }() + val bSubChunk2Size = stream.read(4) + val subChunk2Size = bSubChunk2Size.toInt() + + // The rest of the file is the actual sound data. + val soundData = stream.readBytes() + + override fun toString(): String { + return "${javaClass.simpleName}(" + + "chunkId=$chunkId, chunkSize=$chunkSize, format=$format, " + + "subChunk1Id=$subChunk1Id, subChunk1Size=$subChunk1Size, " + + "audioFormat=$audioFormat, numChannels=$numChannels, " + + "sampleRate=$sampleRate, byteRate=$byteRate, " + + "blockAlign=$blockAlign, bitsPerSample=$bitsPerSample, " + + "subChunk2Id=$subChunk2Id, subChunk2Size=$subChunk2Size" + + ")" + } + + fun getRiffHeader(chunkSize: Int): ByteArray { + val header = ByteBuffer.allocate(12) + val bChunkSize = ByteBuffer.allocate(4).putInt(chunkSize).array() + return header.putArrays(bChunkId, bChunkSize, bFormat).array() + } + + fun getSubChunk1(): ByteArray { + return ByteBuffer.allocate(24).putArrays(bSubChunk1Id, bSubChunk1Size, + bAudioFormat, bNumChannels, bSampleRate, bByteRate, bBlockAlign, + bBitsPerSample).array() + } + + fun compatibleWith(other: WaveFile): Boolean { + return chunkId == other.chunkId && + // Do not compare the chunkSize fields because they include the size + // of sub-chunk 2, the data chunk. + // chunkSize == other.chunkSize && + format == other.format && subChunk1Id == other.subChunk1Id && + subChunk1Size == other.subChunk1Size && + audioFormat == other.audioFormat && + numChannels == other.numChannels && + sampleRate == other.sampleRate && + byteRate == other.byteRate && + blockAlign == other.blockAlign && + bitsPerSample == other.bitsPerSample + } +} + +/** + * Function for taking wave files and writing a joined wave file. + * + * I note here that although all TTS engines I've tested have used wave files, + * Android's TextToSpeech documentation makes no specific reference to them: + * https://developer.android.com/reference/android/speech/tts/TextToSpeech + * + * This, of course, has no bearing if the reader wishes to use this code on other + * platforms. + * + * @param inFiles List of WAVE files. + * @param outFile Output file where the joined wave file will be written. + * @exception IncompatibleWaveFileException Raised for invalid/incompatible Wave + * files. + */ +fun joinWaveFiles(inFiles: List, outFile: File) { + // Handle special case: empty list. + if (inFiles.isEmpty()) return + // Handle special case: single input file. + // This also handles single non-wave input files. + if (inFiles.size == 1) { + outFile.writeBytes(inFiles.first().readBytes()) + return + } + + val waveFiles = mutableListOf() + for (file in inFiles) { + // Read the wave file. + // Errors will be thrown if the file is NOT a wave sound file as defined by + // the following specification: http://soundfile.sapp.org/doc/WaveFormat/ + val waveFile = FileInputStream(file).use { WaveFile(it) } + if (waveFiles.isNotEmpty()) { + val firstWaveFile = waveFiles.first() + if (!waveFile.compatibleWith(firstWaveFile)) { + throw IncompatibleWaveFileException("wave files with " + + "incompatible headers are not supported: " + + "$waveFile ~ $firstWaveFile") + } + } + + // Add the wave file to the list. + waveFiles.add(waveFile) + } + + // Construct a new wave file using header data. + // Use the first wave file for fields with the same values. + val firstWaveFile = waveFiles.first() + + // Calculate the SubChunk2Size and ChunkSize. + val subChunk2Size = waveFiles.fold(0) { acc, waveFile -> + acc + waveFile.subChunk2Size + } + val chunkSize = 4 + 8 + firstWaveFile.subChunk1Size + 8 + subChunk2Size + + // Open the output file for writing. + FileOutputStream(outFile).use { + // Write the RIFF header. + it.write(firstWaveFile.getRiffHeader(chunkSize)) + + // Write sub-chunk 1 ("fmt "). + it.write(firstWaveFile.getSubChunk1()) + + // Begin writing sub-chunk 2 ("data"). + it.write(firstWaveFile.bSubChunk2Id) + it.write(ByteBuffer.allocate(4).putInt(subChunk2Size).array()) + + // Write sound data from each file. + for (waveFile in waveFiles) { + it.write(waveFile.soundData) + } + } +} diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/EditReadActivity.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/EditReadActivity.kt index 282b4de..8127594 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/EditReadActivity.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/EditReadActivity.kt @@ -25,7 +25,6 @@ import android.os.Bundle import android.view.MenuItem import com.danefinlay.ttsutil.ACTION_EDIT_READ_CLIPBOARD import com.danefinlay.ttsutil.R -import com.danefinlay.ttsutil.isReady class EditReadActivity : SpeakerActivity() { @@ -42,11 +41,6 @@ class EditReadActivity : SpeakerActivity() { supportFragmentManager.beginTransaction() .replace(R.id.fragment_container, fragment) .commit() - - // Set up text-to-speech if necessary. - if (!speaker.isReady()) { - checkTTS(CHECK_TTS) - } } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/FileChooserFragments.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/FileChooserFragments.kt index 8a1871e..6d72cca 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/FileChooserFragments.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/FileChooserFragments.kt @@ -219,10 +219,17 @@ class ReadFilesFragment : FileChooserFragment() { } private fun onClickReadFile() { + // Show the speaker not ready message if appropriate. + if (!speaker.isReady()) { + myActivity.showSpeakerNotReadyMessage() + return + } + val fileToRead = fileToRead when (fileToRead?.validFilePath(ctx)) { - true -> fileToRead.getContent(ctx)?.reader()?.forEachLine { - speaker?.speak(it) + true -> { + val lines = fileToRead.getContent(ctx)?.reader()?.readLines() + speaker?.speak(lines ?: return) } false -> buildInvalidFileAlertDialog().show() else -> activity?.toast(R.string.no_file_chosen2) @@ -278,12 +285,13 @@ class WriteFilesFragment : FileChooserFragment() { } // Save the file in the external storage directory using the filename - // + '.mp4'. + // + '.wav'. val dir = Environment.getExternalStorageDirectory() - val filename = "${uri.getDisplayName(ctx)}.mp4" + val filename = "${uri.getDisplayName(ctx)}.wav" val file = File(dir, filename) - val listener = SynthesisEventListener(ctx, filename) - speaker?.synthesizeToFile(content, file, listener) + val listener = SynthesisEventListener(myActivity.myApplication, filename, + ctx, file) + speaker?.synthesizeToFile(content, listener) } private fun onClickWriteFile() { @@ -299,12 +307,18 @@ class WriteFilesFragment : FileChooserFragment() { } } + // Show the speaker not ready message if appropriate. + if (!speaker.isReady()) { + myActivity.showSpeakerNotReadyMessage() + return + } + // Build and display an appropriate alert dialog. val msgPart1 = getString(R.string.write_to_file_alert_message_p1) val msgPart2 = getString(R.string.write_to_file_alert_message_p2) val filename = "${uri?.getDisplayName(ctx)}" val fullMsg = "$msgPart1 \"$filename\"\n" + - "\n$msgPart2 \"$filename.mp4\"" + "\n$msgPart2 \"$filename.wav\"" AlertDialogBuilder(ctx).apply { title(R.string.write_files_fragment_label) message(fullMsg) diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/MainActivity.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/MainActivity.kt index d6c53ef..aa97799 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/MainActivity.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/MainActivity.kt @@ -66,7 +66,7 @@ class MainActivity : SpeakerActivity(), FileChooser { // menu should be considered as top level destinations. appBarConfiguration = AppBarConfiguration(setOf( R.id.nav_read_text, R.id.nav_read_files, R.id.nav_write_files, - R.id.nav_read_clipboard), drawerLayout) + R.id.nav_read_clipboard, R.id.nav_settings), drawerLayout) setupActionBarWithNavController(navController, appBarConfiguration) navView.setupWithNavController(navController) @@ -93,13 +93,16 @@ class MainActivity : SpeakerActivity(), FileChooser { } R.id.menu_tts_settings -> { - // Got this from: https://stackoverflow.com/a/8688354 - val intent = Intent() - intent.action = "com.android.settings.TTS_SETTINGS" - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - startActivity(intent) + openSystemTTSSettings() true } + + R.id.menu_reinitialise_tts -> { + // Reinitialise the Speaker object. + myApplication.reinitialiseSpeaker(this, null) + true + } + else -> return super.onOptionsItemSelected(item) } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/QuickShareActivities.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/QuickShareActivities.kt index af69121..91c6eb2 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/QuickShareActivities.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/QuickShareActivities.kt @@ -22,7 +22,6 @@ package com.danefinlay.ttsutil.ui import android.content.Intent import android.os.Bundle -import android.speech.tts.TextToSpeech import com.danefinlay.ttsutil.ACTION_READ_CLIPBOARD import com.danefinlay.ttsutil.SpeakerIntentService import com.danefinlay.ttsutil.isReady @@ -35,30 +34,57 @@ abstract class QuickShareActivity : SpeakerActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (speaker.isReady()) { - // Start the appropriate service action if the Speaker is ready. + // Start the appropriate service action and finish the activity. startServiceAction() - - // Finish the activity. finish() } else { - // Check (and eventually setup) text-to-speech. - checkTTS(CHECK_TTS_SPEAK_AFTERWARDS) + // Show the speaker not ready message and finish the activity. + showSpeakerNotReadyMessage() + finish() } } + override fun onInit(status: Int) { + super.onInit(status) + // Start the appropriate service action if the Speaker is ready. + if (speaker.isReady()) { + startServiceAction() + } else { + // Show the speaker not ready message. + showSpeakerNotReadyMessage() + } + + // Finish the activity. + finish() + } + + override fun onDoNotInstallTTSData() { + // Finish the quick share activity if the user doesn't want TTS data + // installed. + finish() + } + + override fun onTTSInstallFailureDialogExit() { + // Finish the quick share activity after informing the user that installing + // TTS data failed. + finish() + } + abstract fun startServiceAction() override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) - // Start the appropriate service action now that the Speaker is ready. - if (requestCode == CHECK_TTS_SPEAK_AFTERWARDS && - resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) { + // Start the appropriate service action if the Speaker is ready. + if (speaker.isReady()) { startServiceAction() - - // Finish the activity. - finish() + } else { + // Show the speaker not ready message. + showSpeakerNotReadyMessage() } + + // Finish the activity. + finish() } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/ReadTextFragments.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/ReadTextFragments.kt index 8c8217f..cec4714 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/ReadTextFragments.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/ReadTextFragments.kt @@ -29,10 +29,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button -import com.danefinlay.ttsutil.R -import com.danefinlay.ttsutil.Speaker -import com.danefinlay.ttsutil.getClipboardText -import com.danefinlay.ttsutil.isReady +import com.danefinlay.ttsutil.* import org.jetbrains.anko.doAsync import org.jetbrains.anko.onClick import org.jetbrains.anko.support.v4.find @@ -61,11 +58,8 @@ abstract class ReadTextFragmentBase : Fragment() { if (speaker.isReady()) { speakFromInputLayout() } else { - // Speaker isn't set up. - myActivity.toast(R.string.speaker_not_ready_message) - - // Check (and eventually setup) text-to-speech. - myActivity.checkTTS(SpeakerActivity.CHECK_TTS_SPEAK_AFTERWARDS) + // Show the speaker not ready message. + myActivity.showSpeakerNotReadyMessage() } } diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/SettingsFragment.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/SettingsFragment.kt new file mode 100644 index 0000000..8d687ad --- /dev/null +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/SettingsFragment.kt @@ -0,0 +1,271 @@ +/* + * TTS Util + * + * Authors: Dane Finlay + * + * Copyright (C) 2019 Dane Finlay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.danefinlay.ttsutil.ui + +import android.os.Bundle +import android.support.v4.app.Fragment +import android.support.v7.app.AlertDialog +import android.support.v7.preference.Preference +import android.support.v7.preference.PreferenceFragmentCompat +import android.support.v7.view.ContextThemeWrapper +import com.danefinlay.ttsutil.ApplicationEx +import com.danefinlay.ttsutil.R +import com.danefinlay.ttsutil.Speaker +import com.danefinlay.ttsutil.isReady +import org.jetbrains.anko.toast + +/** + * A [Fragment] subclass for app and TTS settings. + */ +class SettingsFragment : PreferenceFragmentCompat() { + + private val myActivity: SpeakerActivity + get() = (activity as SpeakerActivity) + + private val myApplication: ApplicationEx + get() = myActivity.myApplication + + private val speaker: Speaker? + get() = myActivity.speaker + + override fun onCreatePreferences(savedInstanceState: Bundle?, + rootKey: String?) { + // Load the preferences from an XML resource. + setPreferencesFromResource(R.xml.prefs, rootKey) + } + + override fun onPreferenceTreeClick(preference: Preference?): Boolean { + // Handle TTS engine preferences. + if (handleTtsEnginePrefs(preference)) return true + + return super.onPreferenceTreeClick(preference) + } + + private fun handleTtsEnginePrefs(preference: Preference?): Boolean { + val key = preference?.key + + // Handle opening the system settings. + if (key == "pref_tts_system_settings") { + myActivity.openSystemTTSSettings() + return true + } + + val speaker = speaker + if (speaker == null || !speaker.isReady()) { + // Show the speaker not ready message. + myActivity.showSpeakerNotReadyMessage() + return true + } + + // Handle preferences using the Speaker. + return when (key) { + "pref_tts_engine" -> handleSetTtsEngine(key, speaker) + "pref_tts_voice" -> handleSetTtsVoice(key, speaker) + "pref_tts_pitch" -> handleSetTtsPitch(key, speaker) + "pref_tts_speech_rate" -> handleSetTtsSpeechRate(key, speaker) + else -> false // not a TTS engine preference. + } + } + + private fun displayAlertDialog(title: Int, items: List, + checkedItem: Int, + onClickPositiveListener: (index: Int) -> Unit, + onClickNeutralListener: () -> Unit) { + val context = ContextThemeWrapper(context, R.style.AlertDialogTheme) + AlertDialog.Builder(context).apply { + setTitle(title) + var selection = checkedItem + setSingleChoiceItems(items.toTypedArray(), checkedItem) { _, index -> + selection = index + } + setPositiveButton(R.string.alert_positive_message) { _, _ -> + if (selection >= 0 && selection < items.size) { + onClickPositiveListener(selection) + } + } + setNeutralButton(R.string.use_default_tts_preference) { _, _ -> + onClickNeutralListener() + } + show() + } + } + + private fun handleSetTtsEngine(preferenceKey: String, + speaker: Speaker): Boolean { + // Get a list of the available TTS engines. + val tts = speaker.tts + val engines = tts.engines?.toList()?.sortedBy { it.label } ?: return true + val engineNames = engines.map { it.label } + val enginePackages = engines.map { it.name } + + // Get the previous or default voice. + val prefs = preferenceManager.sharedPreferences + val currentValue = prefs.getString(preferenceKey, tts.defaultEngine) + val currentIndex = enginePackages.indexOf(currentValue) + + // Show a list alert dialog of the available TTS engines. + val dialogTitle = R.string.pref_tts_engine_summary + val onClickPositiveListener = { index: Int -> + // Get the package name from the index of the selected item and + // use it to set the current engine. + val packageName = engines.map { it.name }[index] + myApplication.reinitialiseSpeaker(myActivity, packageName) + + // Set the engine's name in the preferences. + prefs.edit().putString(preferenceKey, packageName).apply() + } + val onClickNeutralListener = { + // Remove the preferred engine's name from the preferences. + prefs.edit().putString(preferenceKey, null).apply() + + // Set the default engine. + myApplication.reinitialiseSpeaker(myActivity, null) + } + displayAlertDialog(dialogTitle, engineNames, currentIndex, + onClickPositiveListener, onClickNeutralListener) + return true + } + + private fun handleSetTtsVoice(preferenceKey: String, + speaker: Speaker): Boolean { + // Get the set of available TTS voices. + // Return early if the engine returned no voices. + val voices = speaker.voices + if (voices.isEmpty()) { + context?.toast(R.string.no_tts_voices_msg) + return true + } + + // Get a list of voices, voice names and display names. + // TODO: This doesn't work for multiple voices with the same display name. + // This is done because I'm not sure how to implement that nicely. + val voicesList = voices.toList().filterNotNull() + .sortedBy { it.name } + .distinctBy { it.locale.displayName } + val voiceNames = voicesList.map { it.name } + val voiceDisplayNames = voicesList.map { it.locale.displayName } + + // Get the previous or default voice. + val prefs = preferenceManager.sharedPreferences + val currentValue = prefs.getString(preferenceKey, + speaker.voice?.name ?: speaker.defaultVoice?.name + ) + val currentIndex = voiceNames.indexOf(currentValue) + + // Show a list alert dialog of the available TTS voices. + val dialogTitle = R.string.pref_tts_voice_summary + val onClickPositiveListener = { index: Int -> + // Get the Voice from the index of the selected item and + // set it to the current voice of the engine. + speaker.voice = voicesList[index] + + // Set the voice's name in the preferences. + prefs.edit().putString(preferenceKey, voiceNames[index]) + .apply() + } + val onClickNeutralListener = { + // Use the default TTS voice/language. + val defaultVoice = speaker.defaultVoice + if (defaultVoice != null) { + speaker.voice = defaultVoice + } else { + speaker.tts.language = myApplication.currentSystemLocale + } + + // Remove the current voice's name from the preferences. + prefs.edit().putString(preferenceKey, null).apply() + } + displayAlertDialog(dialogTitle, voiceDisplayNames, currentIndex, + onClickPositiveListener, onClickNeutralListener) + return true + } + + private fun handleSetTtsPitch(preferenceKey: String, + speaker: Speaker): Boolean { + // Define a list of pitch values and their string representations. + val pitches = listOf(0.25f, 0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f) + val pitchStrings = pitches.map { it.toString() } + + // Get the previous or default pitch value. + val prefs = preferenceManager.sharedPreferences + val currentValue = prefs.getFloat(preferenceKey, 1.0f) + val currentIndex = pitches.indexOf(currentValue) + + // Show a list alert dialog of pitch choices. + val dialogTitle = R.string.pref_tts_pitch_summary + val onClickPositiveListener = { index: Int -> + // Get the pitch from the index of the selected item and + // use it to set the current voice pitch. + val pitch = pitches[index] + speaker.tts.setPitch(pitch) + + // Set the pitch in the preferences. + prefs.edit().putFloat(preferenceKey, pitch).apply() + } + val onClickNeutralListener = { + // Remove the preferred pitch from the preferences. + prefs.edit().remove(preferenceKey).apply() + + // Reinitialise the TTS engine so it uses the pitch as set in the system + // TTS settings. + myApplication.reinitialiseSpeaker(myActivity, null) + } + displayAlertDialog(dialogTitle, pitchStrings, currentIndex, + onClickPositiveListener, onClickNeutralListener) + return true + } + + private fun handleSetTtsSpeechRate(preferenceKey: String, + speaker: Speaker): Boolean { + // Define a list of speech rate values and their string representations. + val speechRates = listOf(0.25f, 0.5f, 0.75f, 1.0f, 1.5f, 2.0f, 2.5f, 3.0f, + 4.0f, 5.0f) + val speechRateStrings = speechRates.map { it.toString() } + + // Get the previous or default speech rate value. + val prefs = preferenceManager.sharedPreferences + val currentValue = prefs.getFloat(preferenceKey, 1.0f) + val currentIndex = speechRates.indexOf(currentValue) + + // Show a list alert dialog of speech rate choices. + val dialogTitle = R.string.pref_tts_speech_rate_summary + val onClickPositiveListener = { index: Int -> + // Get the speech rate from the index of the selected item and + // use it to set the current speech rate. + val speechRate = speechRates[index] + speaker.tts.setSpeechRate(speechRate) + + // Set the speech rate in the preferences. + prefs.edit().putFloat(preferenceKey, speechRate).apply() + } + val onClickNeutralListener = { + // Remove the preferred speech rate from the preferences. + prefs.edit().remove(preferenceKey).apply() + + // Reinitialise the TTS engine so it uses the speech rate as set in the + // system TTS settings. + myApplication.reinitialiseSpeaker(myActivity, null) + } + displayAlertDialog(dialogTitle, speechRateStrings, currentIndex, + onClickPositiveListener, onClickNeutralListener) + return true + } +} diff --git a/app/src/main/java/com/danefinlay/ttsutil/ui/SpeakerActivity.kt b/app/src/main/java/com/danefinlay/ttsutil/ui/SpeakerActivity.kt index 5e4b347..a49f404 100644 --- a/app/src/main/java/com/danefinlay/ttsutil/ui/SpeakerActivity.kt +++ b/app/src/main/java/com/danefinlay/ttsutil/ui/SpeakerActivity.kt @@ -20,22 +20,22 @@ package com.danefinlay.ttsutil.ui -import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.speech.tts.TextToSpeech import android.support.v7.app.AppCompatActivity +import android.support.v7.preference.PreferenceManager import com.danefinlay.ttsutil.ApplicationEx import com.danefinlay.ttsutil.R import com.danefinlay.ttsutil.Speaker import org.jetbrains.anko.AlertDialogBuilder +import org.jetbrains.anko.longToast /** - * Custom activity class so things like 'myApplication' and 'speaker' don't need to - * be redefined for each activity. + * Abstract activity class inherited from classes that use text-to-speech in some + * way. */ -@SuppressLint("Registered") -open class SpeakerActivity: AppCompatActivity() { +abstract class SpeakerActivity: AppCompatActivity(), TextToSpeech.OnInitListener { val myApplication: ApplicationEx get() = application as ApplicationEx @@ -45,19 +45,128 @@ open class SpeakerActivity: AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if ( savedInstanceState == null ) { - // Check if there is a TTS engine is installed on the device. - // Only necessary if the Speaker is null. - if (speaker == null) { - checkTTS(CHECK_TTS) + if ( savedInstanceState == null && speaker == null ) { + // Start the speaker. + myApplication.startSpeaker(this, null) + } + } + + private fun setSpeakerReady() { + val speaker = speaker ?: return + speaker.ready = true + + // Set the preferred voice if one has been set in the preferences. + val tts = speaker.tts + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val voiceName = prefs.getString("pref_tts_voice", null) + if (voiceName != null) { + val voices = speaker.voices.toList().filterNotNull() + if (voices.isNotEmpty()) { + val voiceNames = voices.map { it.name } + val voiceIndex = voiceNames.indexOf(voiceName) + tts.voice = if (voiceIndex == -1) { + speaker.voice ?: speaker.defaultVoice + } else voices[voiceIndex] } } + + // Set the preferred pitch if one has been set in the preferences. + val preferredPitch = prefs.getFloat("pref_tts_pitch", -1.0f) + if (preferredPitch > 0) { + tts.setPitch(preferredPitch) + } + + // Set the preferred speech rate if one has been set in the preferences. + val preferredSpeechRate = prefs.getFloat("pref_tts_speech_rate", -1.0f) + if (preferredSpeechRate > 0) { + tts.setSpeechRate(preferredSpeechRate) + } } - fun checkTTS(code: Int) { - val check = Intent() - check.action = TextToSpeech.Engine.ACTION_CHECK_TTS_DATA - startActivityForResult(check, code) + override fun onInit(status: Int) { + // Handle errors. + val speaker = speaker + val tts = speaker?.tts + myApplication.errorMessageId = null + if (status == TextToSpeech.ERROR || tts == null) { + // Check the number of available TTS engines and set an appropriate + // error message. + val engines = speaker?.tts?.engines ?: listOf() + val messageId = if (engines.isEmpty()) { + // No usable TTS engines. + R.string.no_engine_available_message + } else { + // General TTS initialisation failure. + R.string.tts_initialisation_failure_msg + } + runOnUiThread { longToast(messageId) } + + // Save the error message ID for later use, free the Speaker and return. + myApplication.errorMessageId = messageId + myApplication.freeSpeaker() + return + } + + // Check if the language is available. + val systemLocale = myApplication.currentSystemLocale + @Suppress("deprecation") + val language = speaker.voice?.locale ?: tts.language ?: systemLocale + when (tts.isLanguageAvailable(language)) { + // Set the language if it is available and there is no current voice. + TextToSpeech.LANG_AVAILABLE, TextToSpeech.LANG_COUNTRY_AVAILABLE, + TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE -> { + if (speaker.voice == null) + tts.language = language + + // The Speaker is now ready to process text into speech. + setSpeakerReady() + } + + // Install missing voice data if required. + TextToSpeech.LANG_MISSING_DATA -> { + runOnUiThread { + showNoTTSDataDialog() + } + } + + // Inform the user that the selected language is not available. + TextToSpeech.LANG_NOT_SUPPORTED -> { + // Attempt to fall back on the system language. + when (tts.isLanguageAvailable(systemLocale)) { + TextToSpeech.LANG_AVAILABLE, + TextToSpeech.LANG_COUNTRY_AVAILABLE, + TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE -> { + runOnUiThread { + longToast(R.string.tts_language_not_available_msg1) + } + tts.language = systemLocale + + // The Speaker is now ready to process text into speech. + setSpeakerReady() + } + + else -> { + // Neither the selected nor the default languages are + // available. + val messageId = R.string.tts_language_not_available_msg2 + myApplication.errorMessageId = messageId + runOnUiThread { longToast(messageId) } + } + } + } + } + } + + fun showSpeakerNotReadyMessage() { + myApplication.showSpeakerNotReadyMessage() + } + + fun openSystemTTSSettings() { + // Got this from: https://stackoverflow.com/a/8688354 + val intent = Intent() + intent.action = "com.android.settings.TTS_SETTINGS" + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) } private fun showNoTTSDataDialog() { @@ -65,40 +174,44 @@ open class SpeakerActivity: AppCompatActivity() { title(R.string.no_tts_data_alert_title) message(R.string.no_tts_data_alert_message) positiveButton(R.string.alert_positive_message) { - val install = Intent() - install.action = TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA - startActivityForResult(install, INSTALL_TTS_DATA) + noTTSDataDialogPositiveButton() + } + negativeButton(R.string.alert_negative_message1) { + onDoNotInstallTTSData() } - negativeButton(R.string.alert_negative_message1) show() } } + open fun noTTSDataDialogPositiveButton() { + val install = Intent() + install.action = TextToSpeech.Engine.ACTION_INSTALL_TTS_DATA + startActivityForResult(install, INSTALL_TTS_DATA) + } + + open fun onDoNotInstallTTSData() { } + private fun showTTSInstallFailureDialog() { // Show a dialog if installing TTS data failed. AlertDialogBuilder(this).apply { title(R.string.failed_to_get_tts_data_title) message(R.string.failed_to_get_tts_data_msg) - positiveButton(R.string.alert_positive_message) {} + positiveButton(R.string.alert_positive_message) { + onTTSInstallFailureDialogExit() + } + onCancel { onTTSInstallFailureDialogExit() } show() } } + open fun onTTSInstallFailureDialogExit() { } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { - CHECK_TTS, CHECK_TTS_SPEAK_AFTERWARDS -> { - if (resultCode == TextToSpeech.Engine.CHECK_VOICE_DATA_PASS) { - // Start the speaker. - myApplication.startSpeaker() - } else { - // Show a dialogue *and then* start an activity to install a - // text to speech engine if the user agrees. - showNoTTSDataDialog() - } - } - INSTALL_TTS_DATA -> { - if (resultCode == TextToSpeech.ERROR) { + if (resultCode == TextToSpeech.SUCCESS) { + speaker?.ready = true + } else { showTTSInstallFailureDialog() } } @@ -107,8 +220,6 @@ open class SpeakerActivity: AppCompatActivity() { } companion object { - const val CHECK_TTS = 1 - const val CHECK_TTS_SPEAK_AFTERWARDS = 2 - const val INSTALL_TTS_DATA = 3 + const val INSTALL_TTS_DATA = 1 } } diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml new file mode 100644 index 0000000..84506aa --- /dev/null +++ b/app/src/main/res/drawable/ic_apps.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_height.xml b/app/src/main/res/drawable/ic_height.xml new file mode 100644 index 0000000..f16092c --- /dev/null +++ b/app/src/main/res/drawable/ic_height.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..0be10c4 --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/drawable/ic_speed.xml b/app/src/main/res/drawable/ic_speed.xml new file mode 100644 index 0000000..7be1b3a --- /dev/null +++ b/app/src/main/res/drawable/ic_speed.xml @@ -0,0 +1,4 @@ + + + diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml index 71739ca..6b1d72c 100644 --- a/app/src/main/res/menu/activity_main_drawer.xml +++ b/app/src/main/res/menu/activity_main_drawer.xml @@ -40,6 +40,10 @@ android:id="@+id/nav_write_files" android:icon="@drawable/ic_save" android:title="@string/write_files_fragment_label" /> + diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml index 4713a68..2f91c08 100644 --- a/app/src/main/res/menu/main.xml +++ b/app/src/main/res/menu/main.xml @@ -29,5 +29,11 @@ + + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index dbf8fc9..df217bd 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -48,4 +48,9 @@ android:name="com.danefinlay.ttsutil.ui.WriteFilesFragment" android:label="@string/write_files_fragment_label" tools:layout="@layout/fragment_write_files" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cf865a..819de13 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,6 +33,7 @@ This part of the app can process text files using text-to-speech.\n\nChoose a text file from storage and press the \'Read from file\' button. Write to files This part of the app can create a speech wave file using text from a chosen file. Most media player applications will be able to play wave files.\n\nChoose a text file from storage and press the \'Write to file\' button.\n\nSuccessfully synthesised wave files will be placed in internal storage. + Settings Edit & read text Edit the text below and have it read with text-to-speech. TTS Util @@ -74,6 +75,7 @@ Failed to get TTS data Your device was unable to download the text-to-speech data. Perhaps your device has no network access? It is also possible that there is no data available for your selected language.\n\nTry looking in your device\'s settings for \"Text-to-speech output\". You should be able to change your selected language or use a different text-to-speech engine. Text-to-speech isn\'t ready. Try again in a moment. + No text-to-speech engine is available. File not found The chosen file could not be found. Maybe it has been deleted or renamed?\n\nChoose another? The current file is: @@ -83,6 +85,9 @@ No write storage permission Permission to write to storage was not granted.\n\nWould you like to grant permission? Give permission + Error: the selected text-to-speech engine provided wave files that couldn\'t be joined together. Because of this, the app cannot produce wave files for selected text files longer than 4000 characters. Please try again using a shorter text file. + Error: failed to join wave files internally.\n\nThe following exception was caught: %1$s + Use default File to read text from: @@ -91,7 +96,8 @@ About - Text-to-speech settings + System text-to-speech settings + Reinitialise text-to-speech Speak text in background Edit & read clipboard @@ -101,6 +107,33 @@ File synthesis in progress Text-to-speech synthesis in progress… + + There was an error during text synthesis. + The text-to-speech engine failed to synthesise the text. + The text-to-speech service failed unexpectedly. + The text-to-speech engine failed to send output audio. + Text-to-speech failed due to network connectivity problems. + Text-to-speech failed due to a network timeout. + Text-to-speech failed due to a network timeout. + Text-to-speech failed because voice data was not installed. + File synthesis was interrupted. + Text-to-speech engine failed to initialise. + Selected speaker language is not available, using default instead. + Neither the selected nor the system speaker languages are available. + + + App Text-to-speech (TTS) Settings + TTS Engine + Select the TTS engine. + TTS Voice + Select the TTS voice. + Speech Pitch + Select the pitch/tone of the synthesised voice. + Speech Rate + Select the speed of the synthesised voice. + System text-to-speech settings + Open the system text-to-speech settings. + Not implemented yet. @@ -108,9 +141,8 @@ Cannot speak empty/blank text! Clipboard is empty/blank! \'Read clipboard\' doesn\'t work on Android 10 yet. - There was an error during text synthesis. + The TTS engine reported no voice selection. Stopping speech prior to file synthesis. - File synthesis was interrupted. Open navigation drawer Close navigation drawer Navigation header diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c27d730..8427073 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -46,4 +46,17 @@ + + + + diff --git a/app/src/main/res/xml/prefs.xml b/app/src/main/res/xml/prefs.xml new file mode 100644 index 0000000..23b656e --- /dev/null +++ b/app/src/main/res/xml/prefs.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/build.gradle b/build.gradle index a41f0ed..b81e859 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,14 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.2.71' + ext.kotlin_version = '1.3.72' repositories { jcenter() google() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.50" + classpath 'com.android.tools.build:gradle:4.0.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.72" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9fb4bd6..4ea8bdc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Sep 18 11:35:38 AEST 2019 +#Wed Jun 03 14:37:17 AEST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip