Skip to content

Commit d0c2408

Browse files
committed
baotranslate: add E2E tests & robustness fixes
Add two instrumented E2E tests (spoken-translation roundtrip and voice-clone verification) and harden the BaoTranslate feature surface. Key changes: - Tests: add BaoTranslateSpokenTranslationE2eTest and BaoTranslateVoiceCloneE2eTest covering TTS→STT→translation→TTS round-trips and voice cloning, including WAV artifacts and resampling/low-pass helpers. - Model manager: preserve transient Downloading/Extracting statuses during refresh and wrap network/disk downloads with runCatching to convert throws into Result failures. - Model download coordinator: ensure active-download guards are always released (endDownload) in finally blocks and surface errors to the UI. - ViewModel: persist and restore BaoTranslate settings (translation model, TTS engine, source/target languages, wifi-only) via DataStore; added persistBaoTranslateSettings and call it from setters. - Participant/ BLE handling: avoid mutating BLE state inside CAS loops; broadcast committed localParticipant once after update. - RecordingController: track and cancel in-flight live-segment jobs on stop, ensure AudioRecord is stopped/released in finally, fix final-segment flush logic, and skip duplicate consecutive live translations to avoid double-speaking. - Conversation UI: filter out already-connected peers from the discovered list and rename scanning calls to start/stopConversationDiscovery. - Transcript accessibility: mark only the newest transcript bubble as a live region so TalkBack announces only the latest update. - Voice enrollment: add idempotency guard to stopAndEnroll to avoid duplicate enroll coroutines; add small animation import. These changes improve stability under partial downloads/failures, preserve user preferences across restarts, prevent race conditions that caused duplicate or late audio, and add coverage for critical audio round-trips.
1 parent dda2c52 commit d0c2408

27 files changed

Lines changed: 1170 additions & 298 deletions

.claude/scheduled_tasks.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"sessionId":"f8215f1e-eef6-40b0-ab49-793325754953","pid":74571,"procStart":"Sat Jun 6 10:29:44 2026","acquiredAt":1780750916347}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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

Comments
 (0)