|
| 1 | +/* |
| 2 | + * Copyright 2026 Google LLC |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | + |
| 17 | +package com.google.ai.edge.gallery |
| 18 | + |
| 19 | +import android.content.Context |
| 20 | +import android.os.Bundle |
| 21 | +import android.speech.tts.TextToSpeech |
| 22 | +import android.speech.tts.UtteranceProgressListener |
| 23 | +import android.util.Log |
| 24 | +import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 25 | +import androidx.test.platform.app.InstrumentationRegistry |
| 26 | +import com.google.ai.edge.gallery.customtasks.baotranslate.BaoTranslateModelManager |
| 27 | +import com.google.ai.edge.gallery.customtasks.baotranslate.ModelStatus |
| 28 | +import com.google.ai.edge.gallery.customtasks.baotranslate.audio.WavUtils |
| 29 | +import com.google.ai.edge.gallery.customtasks.baotranslate.stt.WhisperPipeline |
| 30 | +import com.google.ai.edge.gallery.customtasks.baotranslate.translate.TranslationOutcome |
| 31 | +import com.google.ai.edge.gallery.customtasks.baotranslate.translate.TranslationPipeline |
| 32 | +import com.google.ai.edge.gallery.customtasks.baotranslate.tts.KokoroTtsPipeline |
| 33 | +import com.google.ai.edge.gallery.customtasks.baotranslate.tts.SynthesizedAudio |
| 34 | +import java.io.File |
| 35 | +import java.util.Locale |
| 36 | +import java.util.concurrent.CountDownLatch |
| 37 | +import java.util.concurrent.TimeUnit |
| 38 | +import kotlin.math.abs |
| 39 | +import kotlin.math.sqrt |
| 40 | +import kotlinx.coroutines.runBlocking |
| 41 | +import org.junit.Assert.assertNotNull |
| 42 | +import org.junit.Assert.assertTrue |
| 43 | +import org.junit.Test |
| 44 | +import org.junit.runner.RunWith |
| 45 | + |
| 46 | +/** |
| 47 | + * End-to-end proof that real-time translation is actually SPOKEN in a voice (Kokoro, the |
| 48 | + * multilingual engine), not just rendered as text: |
| 49 | + * |
| 50 | + * English speech (platform TTS) → Whisper STT → Qwen translation (en→es) → Kokoro TTS (Spanish |
| 51 | + * voice) → captured WAV → fed back through Whisper, which must transcribe intelligible Spanish. |
| 52 | + * |
| 53 | + * The closing round-trip is the key: if Whisper hears Spanish words ("buenas"/"noches") in Kokoro's |
| 54 | + * output, the spoken translation is genuinely intelligible Spanish — proven by an independent model, |
| 55 | + * not by trusting the TTS. The produced WAV is also dumped for a human to listen to. |
| 56 | + */ |
| 57 | +@RunWith(AndroidJUnit4::class) |
| 58 | +class BaoTranslateSpokenTranslationE2eTest { |
| 59 | + |
| 60 | + @Test |
| 61 | + fun englishSpeechIsTranslatedAndSpokenAsIntelligibleSpanish() { |
| 62 | + val context = InstrumentationRegistry.getInstrumentation().targetContext |
| 63 | + ensureReady(context, listOf("whisper_base", "qwen25_1b", "kokoro_tts")) |
| 64 | + |
| 65 | + val whisper = WhisperPipeline(context) |
| 66 | + val translation = TranslationPipeline(context) |
| 67 | + val kokoro = KokoroTtsPipeline(context) |
| 68 | + try { |
| 69 | + assertTrue( |
| 70 | + "Whisper init failed", |
| 71 | + whisper.initialize(BaoTranslateModelManager.getWhisperModelDir(context).absolutePath), |
| 72 | + ) |
| 73 | + val litertlm = BaoTranslateModelManager.getTranslationModelDir(context, "qwen25_1b") |
| 74 | + .listFiles { f -> f.extension == "litertlm" }?.firstOrNull() |
| 75 | + assertNotNull("No .litertlm translation model present", litertlm) |
| 76 | + assertTrue("Translation init failed", translation.initialize(litertlm!!.absolutePath)) |
| 77 | + assertTrue( |
| 78 | + "Kokoro init failed", |
| 79 | + kokoro.initialize(BaoTranslateModelManager.getKokoroModelDir(context).absolutePath), |
| 80 | + ) |
| 81 | + |
| 82 | + // 1. English speech in. A richer sentence gives the round-trip ASR more to work with. |
| 83 | + val enShorts = synthesizeEnglish16k(context, "Good morning. How are you today? It is very nice to meet you.") |
| 84 | + |
| 85 | + // 2. STT. |
| 86 | + val stt = whisper.transcribeBlocking(enShorts).getOrNull() |
| 87 | + assertNotNull("Whisper produced no transcription of the English input", stt) |
| 88 | + Log.i(TAG, "STT in: lang=${stt!!.language} text=\"${stt.text}\"") |
| 89 | + assertTrue("STT did not recognize the English input", stt.text.isNotBlank()) |
| 90 | + |
| 91 | + // 3. Translate en -> es. |
| 92 | + val outcome = translation.translateBlocking(stt.text, "en", "es") |
| 93 | + assertTrue("Translation failed: $outcome", outcome is TranslationOutcome.Success) |
| 94 | + val spanishText = (outcome as TranslationOutcome.Success).result.translatedText |
| 95 | + Log.i(TAG, "TRANSLATE en->es: \"${stt.text}\" -> \"$spanishText\"") |
| 96 | + |
| 97 | + // 4. Speak the Spanish translation with Kokoro's Spanish voice (the [6] sid fix path). |
| 98 | + val voice = KokoroTtsPipeline.getVoiceForLanguage("es") |
| 99 | + Log.i(TAG, "Kokoro voice for es = $voice") |
| 100 | + val spoken = kokoro.synthesizeAudio(spanishText, voice) |
| 101 | + assertSpoken(spoken) |
| 102 | + |
| 103 | + val dumpDir = File(context.getExternalFilesDir(null), "bao_clone").apply { mkdirs() } |
| 104 | + val wav = File(dumpDir, "kokoro_spanish_translation.wav") |
| 105 | + writeWav(wav, spoken!!.samples, spoken.sampleRate) |
| 106 | + Log.i(TAG, "ARTIFACT spoken Spanish -> ${wav.absolutePath} (${wav.length()} bytes)") |
| 107 | + |
| 108 | + // 5. Round-trip: independently confirm the SPOKEN output is intelligible Spanish. |
| 109 | + val spoken16k = resampleToShort16k(spoken.samples, spoken.sampleRate) |
| 110 | + val back = whisper.transcribeBlocking(spoken16k).getOrNull() |
| 111 | + assertNotNull("Whisper could not transcribe the spoken Spanish output", back) |
| 112 | + Log.i(TAG, "ROUNDTRIP spoken-Spanish -> STT: lang=${back!!.language} text=\"${back.text}\"") |
| 113 | + // Proof of intelligibility: Whisper recovers the translated Spanish content words from Kokoro's |
| 114 | + // audio. Match accent-insensitively (Whisper drops diacritics: "días"->"dias") against the |
| 115 | + // actual translation, so accents / singular-plural choices don't cause false negatives. |
| 116 | + fun normWords(s: String): Set<String> = |
| 117 | + java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD) |
| 118 | + .replace(Regex("\\p{Mn}+"), "") |
| 119 | + .lowercase(Locale.ROOT) |
| 120 | + .split(Regex("[^a-z]+")) |
| 121 | + .filter { it.length >= 4 } |
| 122 | + .toSet() |
| 123 | + val expectedWords = normWords(spanishText) |
| 124 | + val heardWords = normWords(back.text) |
| 125 | + val overlap = expectedWords.intersect(heardWords) |
| 126 | + Log.i(TAG, "intelligibility: lang=${back.language} expected=$expectedWords heard=$heardWords overlap=$overlap") |
| 127 | + // Proof = the round-trip ASR recovers the translated Spanish content words from Kokoro's audio. |
| 128 | + // (Whisper's language *label* is unreliable on short synthetic clips, so it is logged, not |
| 129 | + // asserted; the recovered content words are the meaningful, hard-to-fake signal.) |
| 130 | + assertTrue( |
| 131 | + "Spoken translation not intelligible: recovered too few content words. expected=$expectedWords heard=$heardWords overlap=$overlap", |
| 132 | + overlap.size >= 2, |
| 133 | + ) |
| 134 | + } finally { |
| 135 | + whisper.cleanup() |
| 136 | + translation.cleanup() |
| 137 | + kokoro.cleanup() |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + private fun assertSpoken(audio: SynthesizedAudio?) { |
| 142 | + assertNotNull("Kokoro produced no spoken audio", audio) |
| 143 | + val s = audio!!.samples |
| 144 | + assertTrue("Spoken audio invalid sample rate ${audio.sampleRate}", audio.sampleRate > 0) |
| 145 | + assertTrue("Spoken audio empty", s.isNotEmpty()) |
| 146 | + val peak = s.maxOfOrNull { abs(it) } ?: 0f |
| 147 | + var sq = 0.0; for (v in s) sq += v.toDouble() * v.toDouble() |
| 148 | + val rms = sqrt(sq / s.size) |
| 149 | + val dur = s.size.toFloat() / audio.sampleRate |
| 150 | + Log.i(TAG, "spoken: samples=${s.size} sr=${audio.sampleRate} dur=${dur}s peak=$peak rms=$rms") |
| 151 | + assertTrue("Spoken audio too short: ${dur}s", dur >= 0.5f) |
| 152 | + assertTrue("Spoken audio silent: peak=$peak rms=$rms", peak > 0.02f && rms > 0.005) |
| 153 | + } |
| 154 | + |
| 155 | + private fun ensureReady(context: Context, ids: List<String>) { |
| 156 | + ids.forEach { id -> |
| 157 | + if (BaoTranslateModelManager.checkModelStatus(context, id) != ModelStatus.Ready) { |
| 158 | + val r = runBlocking { BaoTranslateModelManager.downloadModel(context, id, wifiOnly = false) } |
| 159 | + assertTrue("Failed to download $id: ${r.exceptionOrNull()?.message}", r.isSuccess) |
| 160 | + } |
| 161 | + assertTrue("$id not ready", BaoTranslateModelManager.checkModelStatus(context, id) == ModelStatus.Ready) |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + private fun synthesizeEnglish16k(context: Context, text: String): ShortArray { |
| 166 | + val initLatch = CountDownLatch(1) |
| 167 | + var initStatus = TextToSpeech.ERROR |
| 168 | + val tts = TextToSpeech(context.applicationContext) { s -> initStatus = s; initLatch.countDown() } |
| 169 | + try { |
| 170 | + assertTrue("Platform TTS init failed", initLatch.await(30, TimeUnit.SECONDS) && initStatus == TextToSpeech.SUCCESS) |
| 171 | + assertTrue("No US English voice", tts.isLanguageAvailable(Locale.US) >= TextToSpeech.LANG_AVAILABLE) |
| 172 | + tts.language = Locale.US |
| 173 | + tts.setSpeechRate(0.9f) |
| 174 | + val uid = "bao-spoken-input" |
| 175 | + val f = File(context.cacheDir, "$uid.wav") |
| 176 | + if (f.exists()) f.delete() |
| 177 | + val done = CountDownLatch(1) |
| 178 | + var err: String? = null |
| 179 | + tts.setOnUtteranceProgressListener(object : UtteranceProgressListener() { |
| 180 | + override fun onStart(u: String?) = Unit |
| 181 | + override fun onDone(u: String?) = done.countDown() |
| 182 | + @Deprecated("Deprecated in Android framework") |
| 183 | + override fun onError(u: String?) { err = "tts error"; done.countDown() } |
| 184 | + override fun onError(u: String?, code: Int) { err = "tts error $code"; done.countDown() } |
| 185 | + }) |
| 186 | + val params = Bundle().apply { putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, uid) } |
| 187 | + assertTrue("TTS rejected synthesis", tts.synthesizeToFile(text, params, f, uid) == TextToSpeech.SUCCESS) |
| 188 | + assertTrue("TTS did not finish; err=$err", done.await(60, TimeUnit.SECONDS) && err == null) |
| 189 | + val bytes = f.readBytes() |
| 190 | + assertTrue("Invalid input WAV", WavUtils.isValidWav(bytes)) |
| 191 | + val rate = WavUtils.extractSampleRateFromWav(bytes) ?: 0 |
| 192 | + assertTrue("Bad input sample rate $rate", rate > 0) |
| 193 | + return resampleToShort16k(WavUtils.extractSamplesFromWav(bytes), rate) |
| 194 | + } finally { |
| 195 | + tts.shutdown() |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + private fun resampleToShort16k(samples: FloatArray, srcRate: Int): ShortArray { |
| 200 | + val dst = 16000 |
| 201 | + if (samples.isEmpty()) return ShortArray(0) |
| 202 | + if (srcRate == dst) return ShortArray(samples.size) { (samples[it].coerceIn(-1f, 1f) * 32767f).toInt().toShort() } |
| 203 | + // Anti-alias: low-pass below the destination Nyquist before decimating, otherwise downsampling |
| 204 | + // folds >8 kHz energy back as audible distortion and the ASR mishears the speech. |
| 205 | + val src = if (srcRate > dst) lowPass(samples, srcRate, dst * 0.45) else samples |
| 206 | + val outLen = (src.size.toLong() * dst / srcRate).toInt() |
| 207 | + return ShortArray(outLen) { i -> |
| 208 | + val pos = i.toDouble() * srcRate / dst |
| 209 | + val i0 = pos.toInt(); val i1 = (i0 + 1).coerceAtMost(src.size - 1); val frac = (pos - i0).toFloat() |
| 210 | + ((src[i0] * (1f - frac) + src[i1] * frac).coerceIn(-1f, 1f) * 32767f).toInt().toShort() |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + /** Hamming-windowed-sinc FIR low-pass at [cutoffHz], zero-phase (centered) convolution. */ |
| 215 | + private fun lowPass(x: FloatArray, sampleRate: Int, cutoffHz: Double): FloatArray { |
| 216 | + val taps = 64 |
| 217 | + val fc = cutoffHz / sampleRate // normalized cutoff (cycles/sample) |
| 218 | + val h = DoubleArray(taps + 1) |
| 219 | + var sum = 0.0 |
| 220 | + for (i in 0..taps) { |
| 221 | + val n = i - taps / 2.0 |
| 222 | + val sinc = if (n == 0.0) 2.0 * fc else Math.sin(2.0 * Math.PI * fc * n) / (Math.PI * n) |
| 223 | + val w = 0.54 - 0.46 * Math.cos(2.0 * Math.PI * i / taps) // Hamming |
| 224 | + h[i] = sinc * w |
| 225 | + sum += h[i] |
| 226 | + } |
| 227 | + for (i in h.indices) h[i] /= sum // unity DC gain |
| 228 | + val half = taps / 2 |
| 229 | + return FloatArray(x.size) { idx -> |
| 230 | + var acc = 0.0 |
| 231 | + for (k in 0..taps) { |
| 232 | + val j = idx + k - half |
| 233 | + if (j in x.indices) acc += x[j] * h[k] |
| 234 | + } |
| 235 | + acc.toFloat() |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + private fun writeWav(file: File, samples: FloatArray, sampleRate: Int) { |
| 240 | + val pcm = ByteArray(samples.size * 2) |
| 241 | + for (i in samples.indices) { |
| 242 | + val s = (samples[i].coerceIn(-1f, 1f) * 32767f).toInt() |
| 243 | + pcm[i * 2] = (s and 0xFF).toByte(); pcm[i * 2 + 1] = (s shr 8 and 0xFF).toByte() |
| 244 | + } |
| 245 | + fun i32(v: Int) = byteArrayOf((v and 0xFF).toByte(), (v shr 8 and 0xFF).toByte(), (v shr 16 and 0xFF).toByte(), (v shr 24 and 0xFF).toByte()) |
| 246 | + fun i16(v: Int) = byteArrayOf((v and 0xFF).toByte(), (v shr 8 and 0xFF).toByte()) |
| 247 | + file.outputStream().use { o -> |
| 248 | + o.write("RIFF".toByteArray()); o.write(i32(36 + pcm.size)); o.write("WAVE".toByteArray()) |
| 249 | + o.write("fmt ".toByteArray()); o.write(i32(16)); o.write(i16(1)); o.write(i16(1)) |
| 250 | + o.write(i32(sampleRate)); o.write(i32(sampleRate * 2)); o.write(i16(2)); o.write(i16(16)) |
| 251 | + o.write("data".toByteArray()); o.write(i32(pcm.size)); o.write(pcm) |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + private companion object { |
| 256 | + const val TAG = "BaoSpokenTransE2E" |
| 257 | + } |
| 258 | +} |
0 commit comments