godot/platform/android/java/lib/src/org/godotengine/godot/tts/GodotTTS.java

/**************************************************************************/
/*  GodotTTS.java                                                         */
/**************************************************************************/
/*                         This file is part of:                          */
/*                             GODOT ENGINE                               */
/*                        https://godotengine.org                         */
/**************************************************************************/
/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */
/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.                  */
/*                                                                        */
/* Permission is hereby granted, free of charge, to any person obtaining  */
/* a copy of this software and associated documentation files (the        */
/* "Software"), to deal in the Software without restriction, including    */
/* without limitation the rights to use, copy, modify, merge, publish,    */
/* distribute, sublicense, and/or sell copies of the Software, and to     */
/* permit persons to whom the Software is furnished to do so, subject to  */
/* the following conditions:                                              */
/*                                                                        */
/* The above copyright notice and this permission notice shall be         */
/* included in all copies or substantial portions of the Software.        */
/*                                                                        */
/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,        */
/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF     */
/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */
/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY   */
/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,   */
/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE      */
/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.                 */
/**************************************************************************/

package org.godotengine.godot.tts;

import org.godotengine.godot.GodotLib;

import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.speech.tts.TextToSpeech;
import android.speech.tts.UtteranceProgressListener;
import android.speech.tts.Voice;

import androidx.annotation.Keep;

import java.util.Iterator;
import java.util.LinkedList;
import java.util.Set;

/**
 * Wrapper for Android Text to Speech API and custom utterance query implementation.
 * <p>
 * A [GodotTTS] provides the following features:
 * <p>
 * <ul>
 * <li>Access to the Android Text to Speech API.
 * <li>Utterance pause / resume functions, unsupported by Android TTS API.
 * </ul>
 */
@Keep
public class GodotTTS extends UtteranceProgressListener {
	// Note: These constants must be in sync with DisplayServer::TTSUtteranceEvent enum from "servers/display_server.h".
	final private static int EVENT_START = 0;
	final private static int EVENT_END = 1;
	final private static int EVENT_CANCEL = 2;
	final private static int EVENT_BOUNDARY = 3;

	private final Context context;
	private TextToSpeech synth;
	private LinkedList<GodotUtterance> queue;
	final private Object lock = new Object();
	private GodotUtterance lastUtterance;

	private boolean speaking;
	private boolean paused;

	public GodotTTS(Context context) {
		this.context = context;
	}

	private void updateTTS() {
		if (!speaking && queue.size() > 0) {
			int mode = TextToSpeech.QUEUE_FLUSH;
			GodotUtterance message = queue.pollFirst();

			Set<Voice> voices = synth.getVoices();
			for (Voice v : voices) {
				if (v.getName().equals(message.voice)) {
					synth.setVoice(v);
					break;
				}
			}
			synth.setPitch(message.pitch);
			synth.setSpeechRate(message.rate);

			Bundle params = new Bundle();
			params.putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, message.volume / 100.f);

			lastUtterance = message;
			lastUtterance.start = 0;
			lastUtterance.offset = 0;
			paused = false;

			synth.speak(message.text, mode, params, String.valueOf(message.id));
			speaking = true;
		}
	}

	/**
	 * Called by TTS engine when the TTS service is about to speak the specified range.
	 */
	@Override
	public void onRangeStart(String utteranceId, int start, int end, int frame) {
		synchronized (lock) {
			if (lastUtterance != null && Integer.parseInt(utteranceId) == lastUtterance.id) {
				lastUtterance.offset = start;
				GodotLib.ttsCallback(EVENT_BOUNDARY, lastUtterance.id, start + lastUtterance.start);
			}
		}
	}

	/**
	 * Called by TTS engine when an utterance was canceled in progress.
	 */
	@Override
	public void onStop(String utteranceId, boolean interrupted) {
		synchronized (lock) {
			if (lastUtterance != null && !paused && Integer.parseInt(utteranceId) == lastUtterance.id) {
				GodotLib.ttsCallback(EVENT_CANCEL, lastUtterance.id, 0);
				speaking = false;
				updateTTS();
			}
		}
	}

	/**
	 * Called by TTS engine when an utterance has begun to be spoken..
	 */
	@Override
	public void onStart(String utteranceId) {
		synchronized (lock) {
			if (lastUtterance != null && lastUtterance.start == 0 && Integer.parseInt(utteranceId) == lastUtterance.id) {
				GodotLib.ttsCallback(EVENT_START, lastUtterance.id, 0);
			}
		}
	}

	/**
	 * Called by TTS engine when an utterance was successfully finished.
	 */
	@Override
	public void onDone(String utteranceId) {
		synchronized (lock) {
			if (lastUtterance != null && !paused && Integer.parseInt(utteranceId) == lastUtterance.id) {
				GodotLib.ttsCallback(EVENT_END, lastUtterance.id, 0);
				speaking = false;
				updateTTS();
			}
		}
	}

	/**
	 * Called by TTS engine when an error has occurred during processing.
	 */
	@Override
	public void onError(String utteranceId, int errorCode) {
		synchronized (lock) {
			if (lastUtterance != null && !paused && Integer.parseInt(utteranceId) == lastUtterance.id) {
				GodotLib.ttsCallback(EVENT_CANCEL, lastUtterance.id, 0);
				speaking = false;
				updateTTS();
			}
		}
	}

	/**
	 * Called by TTS engine when an error has occurred during processing (pre API level 21 version).
	 */
	@Override
	public void onError(String utteranceId) {
		synchronized (lock) {
			if (lastUtterance != null && !paused && Integer.parseInt(utteranceId) == lastUtterance.id) {
				GodotLib.ttsCallback(EVENT_CANCEL, lastUtterance.id, 0);
				speaking = false;
				updateTTS();
			}
		}
	}

	/**
	 * Initialize synth and query.
	 */
	public void init() {
		synth = new TextToSpeech(context, null);
		queue = new LinkedList<GodotUtterance>();

		synth.setOnUtteranceProgressListener(this);
	}

	/**
	 * Adds an utterance to the queue.
	 */
	public void speak(String text, String voice, int volume, float pitch, float rate, int utterance_id, boolean interrupt) {
		synchronized (lock) {
			GodotUtterance message = new GodotUtterance(text, voice, volume, pitch, rate, utterance_id);
			queue.addLast(message);

			if (isPaused()) {
				resumeSpeaking();
			} else {
				updateTTS();
			}
		}
	}

	/**
	 * Puts the synthesizer into a paused state.
	 */
	public void pauseSpeaking() {
		synchronized (lock) {
			if (!paused) {
				paused = true;
				synth.stop();
			}
		}
	}

	/**
	 * Resumes the synthesizer if it was paused.
	 */
	public void resumeSpeaking() {
		synchronized (lock) {
			if (lastUtterance != null && paused) {
				int mode = TextToSpeech.QUEUE_FLUSH;

				Set<Voice> voices = synth.getVoices();
				for (Voice v : voices) {
					if (v.getName().equals(lastUtterance.voice)) {
						synth.setVoice(v);
						break;
					}
				}
				synth.setPitch(lastUtterance.pitch);
				synth.setSpeechRate(lastUtterance.rate);

				Bundle params = new Bundle();
				params.putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, lastUtterance.volume / 100.f);

				lastUtterance.start = lastUtterance.offset;
				lastUtterance.offset = 0;
				paused = false;

				synth.speak(lastUtterance.text.substring(lastUtterance.start), mode, params, String.valueOf(lastUtterance.id));
				speaking = true;
			} else {
				paused = false;
			}
		}
	}

	/**
	 * Stops synthesis in progress and removes all utterances from the queue.
	 */
	public void stopSpeaking() {
		synchronized (lock) {
			for (GodotUtterance u : queue) {
				GodotLib.ttsCallback(EVENT_CANCEL, u.id, 0);
			}
			queue.clear();

			if (lastUtterance != null) {
				GodotLib.ttsCallback(EVENT_CANCEL, lastUtterance.id, 0);
			}
			lastUtterance = null;

			paused = false;
			speaking = false;

			synth.stop();
		}
	}

	/**
	 * Returns voice information.
	 */
	public String[] getVoices() {
		Set<Voice> voices = synth.getVoices();
		String[] list = new String[voices.size()];
		int i = 0;
		for (Voice v : voices) {
			list[i++] = v.getLocale().toString() + ";" + v.getName();
		}
		return list;
	}

	/**
	 * Returns true if the synthesizer is generating speech, or have utterance waiting in the queue.
	 */
	public boolean isSpeaking() {
		return speaking;
	}

	/**
	 * Returns true if the synthesizer is in a paused state.
	 */
	public boolean isPaused() {
		return paused;
	}
}