In questa sessione, esploreremo diversi casi d'uso dell'AI generativa utilizzando le API ML Kit per capire quando l'AI generativa on-device è ideale per un'app e anche per capire come valutare i casi d'uso dell'AI generativa esistenti per vedere se i modelli on-device possono soddisfare i loro standard di qualità.
Trascrizione
David Chou
tu,
Buon pomeriggio.
Mi chiamo David Chou.
Sono un product lead di Android AICore.
Sono molto felice di parlarti delle nostre funzionalità di AI on-device.
Oggi parleremo di alcune aree principali.
Il primo è semplicemente pensare al motivo per cui dovresti utilizzare l'AI on-device, soprattutto considerando l'accelerazione dei progressi dell'AI nel cloud.
In secondo luogo, pensiamo ad alcuni dei miglioramenti che abbiamo apportato nell'ultimo anno per rendere questi modelli di AI on-device estremamente potenti.
Infine, parleremo di un esempio end-to-end che mostra come sfruttare tutti questi vantaggi utilizzando l'AI on-device.
Innanzitutto, perché utilizzare l'AI on-device?
I motivi principali sono quattro.
Il primo è la velocità, il secondo è il costo, il terzo è la disponibilità e il quarto è la privacy.
Quindi, per quanto riguarda la velocità, l'inferenza AI on-device sta raggiungendo un punto in cui può essere più veloce dell'esecuzione nel cloud, semplicemente perché non è necessario trasferire le informazioni avanti e indietro tra il dispositivo dell'utente e il cloud.
Quindi, in molti dei casi d'uso che abbiamo testato, stiamo assistendo a un'accelerazione di 2 volte rispetto all'AI on-device rispetto al cloud.
La seconda riguarda i costi.
Immaginiamo di eseguire un milione di inferenze al giorno.
Potresti risparmiare centinaia di migliaia di dollari se esegui l'AI on-device, in particolare dal dispositivo dell'utente, rispetto all'esecuzione nel cloud.
La terza è la disponibilità offline.
Per questo motivo, all'inizio di quest'anno abbiamo inviato un sondaggio e il 62% degli sviluppatori con cui abbiamo parlato ha dichiarato che la disponibilità offline era uno dei tre motivi principali per utilizzare l'AI on-device.
Quindi,che l'utente si trovi a 10.000 metri di altezza o nel seminterrato della stazione di Tokyo e non abbia accesso a internet, può comunque sfruttare tutte queste funzionalità.
Infine, la privacy è uno dei motivi principali che abbiamo riscontrato quando abbiamo intervistato gli sviluppatori.
Quindi, il 72% lo ha classificato tra i primi tre motivi.
Il 40% lo ha classificato al primo posto.
Quindi, quando si tratta di messaggi, note vocali, foto o persino informazioni finanziarie o mediche, gli utenti vogliono mantenerle private, perciò l'esecuzione dell'inferenza on-device sarà davvero preziosa per loro.
Considerati tutti questi vantaggi, abbiamo creato ML Kit per consentirti di mantenere e sfruttare tutte queste funzionalità.
Stiamo cercando di rendere questa operazione estremamente semplice.
Quindi, se hai dei reclami, non esitare a contattarmi.
Lascerò i miei dati di contatto alla fine di questo intervento.
Ci piacerebbe capire come possiamo abilitare i tuoi casi d'uso.
Per iniziare, abbiamo abilitato una serie di funzionalità per casi d'uso come il parlato e la creatività.
Ad esempio, correzione di bozze, riscrittura e prompting multimodale flessibile.
Il nostro obiettivo è quindi cercare di renderlo il più utilizzabile possibile.
Ecco perché molti dei nostri utenti e sviluppatori hanno già iniziato ad adottarlo.
Oggi abbiamo già 9 app di terze parti con 10 milioni di download che utilizzano queste funzionalità di AI generativa in produzione.
Inoltre, stiamo assistendo a un aumento significativo delle app sia proprietarie che di terze parti, con una crescita del 110% su base trimestrale dei lanci di app per le funzionalità di produzione dell'AI.
Pertanto, per quanto riguarda la messaggistica, la creatività e la finanza, ti invitiamo a provare le API che abbiamo a disposizione.
Riteniamo che le possibilità siano infinite e probabilmente hai più immaginazione di noi in molti di questi casi d'uso, quindi ci piacerebbe collaborare con te e capire come possiamo lavorare insieme.
Ora che hai compreso i vantaggi generali,
Quindi potresti chiederti: "Ok, qual è la copertura effettiva?"
Qual è il rendimento sul dispositivo?
Quando parliamo di velocità, quanto è buona in realtà?
O come la qualità del modello stesso, giusto?
Esaminiamoli uno per uno.
In termini di copertura, siamo già disponibili su 175 milioni di dispositivi AICore.
Prevediamo che questa cifra aumenterà in modo significativo nel corso del prossimo anno, con l'aggiunta di centinaia di milioni di altri dispositivi.
Ciò è possibile in parte perché abbiamo creato un'imbracatura molto semplice.
Pertanto, l'API ML Kit è l'unica con cui devi eseguire l'integrazione.
AICore è molto complesso per gestire i segnali di sicurezza di input e output, gestire il download del modello insieme ai pesi e pensare a come configurarlo nel runtime.
Quindi, l'obiettivo è rendere ML Kit il più semplice possibile da usare, in modo che tu possa concentrarti solo su come offrire un'esperienza fantastica e noi possiamo occuparci del resto.
David Chou
Siamo inoltre felici di annunciare che abbiamo ridotto in modo significativo la RAM necessaria per eseguire Gemini Nano, il modello principale che supporta molte di queste API di AI generativa.
In questo modo, abbiamo ridotto l'utilizzo della RAM da 3,6 GB a 1,1 GB.
Per rendere possibile tutto questo, abbiamo apportato quattro miglioramenti principali.
Il primo è l'utilizzo della quantizzazione a 2 bit anziché a 4 bit.
Pertanto, la metà dei bit significa solo la metà dei byte che devi trasmettere attraverso il modello.
La seconda è l'utilizzo di una cache KV condivisa.
Quindi, abbiamo ridotto significativamente questo valore consentendo ai primi 20 livelli del modello di condividere questo valore nel pacchetto da 35 MB, il che significa che puoi comunque accedere alla dimensione del contesto di 16.000 token con questa dimensione del pacchetto molto, molto ridotta.
La terza è l'attivazione di encoder audio e video lazy.
Quindi, per molti dei casi d'uso del testo che stiamo vedendo, non è necessario caricarli.
In questo modo risparmierai un altro mezzo gigabyte di RAM.
Infine, per migliorare la durata della batteria, abbiamo anche attivato la scalabilità del clock della NPU.
Ciò significa che la NPU può funzionare alla velocità necessaria per risparmiare energia, garantendo al contempo un'alta qualità nei tuoi casi d'uso.
Abbiamo apportato alcuni altri miglioramenti per quanto riguarda l'accelerazione.
La prima è l'attivazione della memorizzazione nella cache dei prefissi.
Come puoi vedere in questo esempio, il prompt di sistema viene riutilizzato per più turni, giusto?
Di conseguenza, l'elaborazione diventa molto costosa.
Ma con il pre-caching, in sostanza, non è necessario rielaborare ripetutamente il prompt di sistema, giusto?
Puoi salvarlo nella cache dei prefissi, il che significa che, per questo esempio, risparmi il 60% dei token che devi passare, il che significa anche un aumento della velocità del 60% su qualsiasi dispositivo tu stia utilizzando.
Il secondo miglioramento che abbiamo apportato riguarda la decodifica speculativa.
In questo esempio, puoi vedere che Gemini Nano genera un token per passaggio.
Questo perché gli LLM sono autoregressivi, il che significa che devono continuare a esaminare i token generati in precedenza per capire quali saranno i token successivi.
Per risolvere questo problema, abbiamo creato un modello di bozza che genera un gruppo di token a una velocità molto maggiore.
Questo modello di bozza è sostanzialmente più piccolo di Gemini Nano, quindi può generare un gruppo di token alla volta e passarli a Gemini per vedere quali sono effettivamente in linea con le ipotesi di Gemini Nano.
In questo esempio, puoi vedere che nel primo passaggio vengono generati sei token, nel successivo cinque e, al terzo turno, l'operazione è completata.
Abbiamo notato che, per molti dei casi d'uso interni che stiamo esaminando, questo approccio porta a un'accelerazione media di 2 volte.
Potresti ottenere miglioramenti più rapidi a seconda dell'hardware.
Ok.
Ecco perché, tornando alla velocità, è possibile notare questa notevole accelerazione tra on-device e cloud, anche su una rete 5G.
Come puoi vedere, il semplice caricamento dei dati nel cloud richiede anche solo un secondo, giusto, per 1000-1300 token di input, quindi anche se il pre-riempimento e la decodifica in un percorso cloud potrebbero essere più veloci, il percorso end-to-end è comunque sostanzialmente più veloce quando l'esecuzione avviene on-device.
Inoltre, per quanto riguarda la qualità, siamo felici di annunciare che questo modello da 2 miliardi di parametri ha prestazioni paragonabili a quelle dei modelli da 27 miliardi di parametri dello scorso anno.
Sebbene non sia così performante come i modelli pionieristici che abbiamo oggi, ciò che mostriamo qui è che ci sono progressi sostanziali per quanto riguarda l'AI su questi modelli on-device, giusto?
Come puoi vedere, per MMLU-Pro, che è un benchmark di conoscenza standard, il modello 2B è quasi altrettanto valido del modello con 27 miliardi di parametri dell'anno scorso.
Inoltre, per quanto riguarda i benchmark di scienze avanzate, ragionamento e utilizzo di strumenti agentici, questo modello da 2 miliardi di parametri è effettivamente migliore, anche se è meno di un decimo delle dimensioni del modello dell'anno scorso.
Pertanto, prevediamo che queste tendenze continueranno.
Continuiamo a lavorare a stretto contatto con il team di GDM per assicurarci di poter continuare ad apportare questi miglioramenti al modello in modo che tu possa trarne il massimo valore.
David Chou
Ok.
Quindi, ho appena elencato molti vantaggi e nuovi miglioramenti e forse ti starai chiedendo: "Cosa possiamo fare effettivamente con questi modelli?"
Inoltre, abbiamo apportato tutti questi miglioramenti alle funzionalità anche nell'API.
Quindi, con le istruzioni di sistema, puoi impostare un personaggio.
Con l'input di più immagini, puoi caricare un gruppo di immagini contemporaneamente.
All'inizio di quest'anno abbiamo anche attivato la modalità di pensiero, in modo che tu possa dedicare più tempo ai problemi di ragionamento e alle chiamate agli strumenti.
Infine, con l'output strutturato, abbiamo anche reso possibile annotare una classe Kotlin e ottenere un oggetto che corrisponde allo schema specifico.
Inoltre, cerchiamo anche di chiedere agli sviluppatori quali nuovi miglioramenti vorrebbero apportare.
Il 91% degli sviluppatori che abbiamo intervistato ha dichiarato che lancerebbe un'esperienza se una di queste funzionalità fosse disponibile.
Per questo, all'inizio di questa settimana, abbiamo lanciato queste quattro funzionalità: traduzione vocale, embedding, sintesi vocale e chiamata di funzione.
Con la traduzione vocale, puoi trascrivere l'audio da oltre 20 lingue.
Con gli embedding, puoi cercare i contenuti della tua app in base al significato semantico, anziché utilizzare solo la ricerca per parole chiave.
Con la sintesi vocale, ora puoi fornire voci simili a quelle umane per le tue esperienze, così non dovrai più avere voci robotiche.
Infine, con la chiamata di funzione, puoi consentire alla tua app di eseguire azioni per conto degli utenti.
Abbiamo già collaborato con alcuni sviluppatori per vedere come potrebbero creare agenti in-app nelle loro esperienze e speriamo di lanciare questa funzionalità entro la fine del prossimo anno.
David Chou
Considerate tutte queste funzionalità, esaminiamo un esempio end-to-end in cui creiamo un'applicazione con tutte queste funzionalità.
Immaginiamo che un fotografo americano stia viaggiando in Giappone.
E, come puoi vedere dalla foto, non hanno abbastanza fotocamere o obiettivi.
Perciò, ordina un nuovo obiettivo e lo fa spedire al suo hotel.
Ma c'è un problema: mentre svolgono le loro attività quotidiane, ricevono un messaggio vocale che li informa che devono pagare una commissione di sdoganamento di 6800 yen per ricevere il pacco entro la fine della giornata.
Ha anche ricevuto un messaggio con una foto del pacco trattenuto.
Quindi, in questo caso, l'utente è interessato ad avere una sorta di protezione antifrode, giusto?
Vogliono capire come identificare i messaggi vocali come legittimi o fraudolenti.
Per questo motivo, vogliamo mantenere private tutte le informazioni, assicurarci che siano fondate, ma anche che siano spiegabili.
Quindi, ci assicureremo che possano comprendere il testo in giapponese e che possano utilizzare qualsiasi funzionalità linguistica disponibile in caso di esigenze di accessibilità.
Quindi, i passaggi sono quattro, giusto?
Il primo passaggio consiste nell'elaborare il messaggio vocale nel testo, quindi nel tradurlo.
Per farlo, utilizzeremo l'API Speech Translation.
Poi, se vogliono, potranno fare una domanda, quindi utilizzeremo l'API Speech Recognition.
Dopodiché, vogliamo definire funzioni in grado di recuperare le informazioni necessarie all'utente per ottenere ciò di cui ha bisogno.
Quindi, utilizzeremo l'API Prompt e la chiamata di funzione.
Questa operazione verrà eseguita in background senza che l'utente veda nulla.
Infine, scopriremo come archiviare e recuperare queste informazioni utilizzando l'API di embedding e AppSearch.
Anche questa operazione verrà eseguita in background.
Infine, emetteremo un verdetto in base alle informazioni che raccogliamo, utilizzando l'API Prompt, l'output strutturato e la sintesi vocale.
Ok.
Iniziamo.
Utilizzeremo la traduzione vocale e il riconoscimento vocale.
Quindi, il primo passaggio consiste nel prendere il messaggio vocale e il testo e trasformarli in informazioni utili, giusto?
L'utente ha ricevuto questo messaggio: "Devi pagare questa commissione di 6800 yen.
I dazi potrebbero non essere stati pagati in anticipo, quindi devi pagarli."
Quindi, vogliono poter avere questa traduzione, giusto?
Quindi, una delle cose che vorremmo sottolineare qui è che sia la traduzione sia la domanda dell'utente sulle informazioni vengono inviate a Gemini Nano 4 utilizzando lo stesso codificatore audio, perché il codificatore audio è stato addestrato su oltre 100 lingue.
In questo modo, puoi ottenere la traduzione e l'ASR per il testo della domanda dell'utente.
Quindi, per la traduzione, non dobbiamo fare altro che impostare una lingua di origine e una di destinazione.
val translator = SpeechTranslation.getClient(
SpeechTranslatorOptions.Builder(
Locale.JAPANESE, Locale.US
).build()
)In questo caso, utilizzeremo il giapponese e l'inglese americano.
Configureremo un flusso Kotlin che passa i byte direttamente al traduttore vocale.
val translator = SpeechTranslation.getClient(
SpeechTranslatorOptions.Builder(
Locale.JAPANESE, Locale.US
).build()
)
val audioFlow = flow {
emit(TranslationAudio(Locale.JAPANESE, courierVoicemailBytes))
}
val request = SpeechTranslatorRequest.Builder(audioFlow).build()
translator.startTranslation(request).collect { response ->
if (response is SpeechTranslatorResponse.Text) {
translatedCourierClaim = response.translatedText
}
}Poi, possiamo raccogliere la risposta utilizzando il testo tradotto.
Ok.
Ottimo.
Ora capiamo cosa dice effettivamente il messaggio vocale, giusto?
C'è un pacco con obiettivo della fotocamera.
È bloccato alla dogana.
Deve pagare la commissione di 6800 yen.
Ora l'utente vuole poter fare una domanda in merito.
Ok.
Quindi, questo blocco doganale è reale?
In questo caso, utilizzeremo l'API ASR.
val recognizer = SpeechRecognition.getClient(
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
)In questo modo, l'inglese americano viene comunque impostato come lingua.
Poi, possiamo impostarla sulla modalità avanzata, che sfrutta l'encoder audio.
Poi, possiamo passare anche queste informazioni, raccogliere la risposta finale e trasformarla in informazioni che passeremo a Gemini Nano.
val recognizer = SpeechRecognition.getClient(
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
)
val request = speechRecognizerRequest {
audioSource = AudioSource.fromMic()
recognitionOptions { enableWordTiming = true } // optional
}
recognizer.startRecognition(request).collect { response ->
when (response) {
is SpeechRecognizerResponse.PartialTextResponse ->
liveCaption = response.text
is SpeechRecognizerResponse.FinalTextResponse ->
planAndCallVaultTool(translatedCourierClaim, response.text)
}
}Ok.
Ora abbiamo terminato la prima parte della raccolta di informazioni.
Come puoi vedere, non sono state necessarie molte righe di codice.
David Chou
La seconda parte è il triage, ovvero pensare a come sfruttare l'API Prompt e la chiamata di funzione per capire che tipo di informazioni sono necessarie.
Ok.
Quindi, per creare l'architettura, per prima cosa configureremo alcune istruzioni di sistema.
Inoltre, il messaggio vocale, il testo e la foto, nonché la domanda dell'utente, verranno trasmessi come input a Gemini Nano.
Poi definiremo le funzioni che recuperano effettivamente le informazioni di cui abbiamo bisogno.
retrieveOrderAndDeliveryPhotos(
searchQuery = "camera lens customs",
claimedFeeUsd = 45,
comparePackagePhotos = true
)Per iniziare, configureremo le istruzioni di sistema.
// Keep SystemInstruction static across Turn 1 & Turn 2:
val securitySystemInstruction = SystemInstruction(
"""
You are an on-device parcel & phishing security auditor.
1. Cross-check packaging, carrier labels and tracking across all images.
2. Verify customs fee claims against local DDP (Delivered Duty Paid) order records.
3. Flag any payment demand when the order record shows duties prepaid.
""".trimIndent()
)Qui puoi vedere un esempio molto semplice di come sarebbe configurare un revisore della sicurezza, che dice che effettueremo un controllo incrociato di imballaggi ed etichette, esamineremo le tariffe personalizzate e segnaleremo eventuali richieste di pagamento necessarie.
Potresti scoprire che l'ottimizzazione dei prompt sarà davvero utile per ottenere una qualità significativamente migliore.
Una volta impostato il set di istruzioni di sistema, possiamo prendere tutti i contenuti del passaggio precedente, la traduzione del messaggio vocale e del testo, nonché la domanda dell'utente, e passarli tutti in questo campo TextPart, insieme alle istruzioni di sistema, per ottenere le informazioni.
// Pull in the Speech Translation (AST) & Speech Recognition (ASR) outputs:
val turn1Prompt = """
Translated voicemail: $translatedCourierClaim
Incoming text message: $callerText
User's spoken question: $userQuestion
""".trimIndent()
val turn1Request = generateContentRequest(
securitySystemInstruction,
TextPart(turn1Prompt)
) { /* enableThinking & setTools */ }Poi vogliamo definire una funzione che possiamo utilizzare.
Per questo esempio specifico, siamo felici di annunciare che abbiamo integrato la funzionalità dell'app.
Quindi, se hai familiarità con questo concetto, probabilmente capirai già a grandi linee come funziona.
Qui configuriamo un entry point AppFunctionService e poi definiamo una funzione dell'app.
// Registered in AndroidManifest.xml
@AppFunctionServiceEntryPoint("ScamInvestigationService", "scam_tools")
abstract class ScamInvestigationTools : AppFunctionService() {
}Come puoi vedere, è esattamente lo stesso processo che utilizzeresti per configurare le funzioni dell'app per un agente di sistema.
Nei parametri di searchQuery, imposti la commissione per il reclamo e se dobbiamo o meno confrontare le foto dei pacchi, solo per fare un esempio di parametri, giusto?
// Registered in AndroidManifest.xml
@AppFunctionServiceEntryPoint("ScamInvestigationService", "scam_tools")
abstract class ScamInvestigationTools : AppFunctionService() {
/**
* Searches local orders and order images in AppSearch.
* @param searchQuery Semantic query to match the order
* @param claimedFeeUsd Customs fee claimed in USD
* @param comparePackagePhotos Whether to fetch order images to compare
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun retrieveOrderAndDeliveryPhotos(
searchQuery: String,
claimedFeeUsd: Int,
comparePackagePhotos: Boolean
): VaultMatch = withContext(Dispatchers.IO) { executeVaultSearch(searchQuery) }
}Potresti definirne di diversi.
Infine, per questa funzione abbiamo una funzione executeVaultSearch che definiremo in un secondo momento.
Una delle cose che voglio sottolineare è che è disponibile la modalità di ragionamento.
val vaultTools = AppFunctionTools.getClient().getAppFunctions()
val turn1Request = generateContentRequest(securitySystemInstruction, TextPart(turn1Prompt)) {
enableThinking = true
setTools(vaultTools)
}
val call = generativeModel.generateContent(turn1Request)
.candidates.first().functionCalls.first()Ti consiglio vivamente di utilizzarlo quando esegui la chiamata allo strumento.
Il motivo è che abbiamo eseguito una serie di test che mostrano un miglioramento di 13 punti su 100 nell'accuratezza quando si esegue la chiamata allo strumento su Gemini Nano 4.
In alcuni casi, è addirittura molto più alto, quindi potrebbe darti risultati molto migliori.
Se vuoi saperne di più sulla chiamata di funzione, partecipa alla sessione delle mie colleghe Jolanda e Victoria, che si terrà più tardi.
Si tratta di funzionalità agentiche nella tua app.
Penso che Victoria sia seduta da qualche parte tra il pubblico, quindi non esitate a salutarla.
David Chou
Ok, ottimo.
Ora abbiamo completato i primi due passaggi.
Parliamo di come funziona il recupero.
Quindi, utilizzeremo gli embedding e AppSearch.
Quindi, forse facendo qualche passo indietro, giusto, nel percorso dell'utente, nel momento in cui ha ordinato l'obiettivo, ciò che vorremmo fare è indicizzare queste informazioni in modo che siano accessibili sia all'utente che allo sviluppatore, giusto?
In questo caso, abbiamo informazioni dettagliate sul numero del pacco dell'ordine.
Inoltre, ha un numero di tracciamento.
Contiene alcuni dettagli sulla destinazione.
Come puoi vedere, qui è già indicato che i dazi sono stati prepagati.
Quindi, genereremo un embedding multimodale per indicizzare queste informazioni insieme a qualsiasi altra informazione che potrebbe essere importante per noi.
Ecco come si presenta.
val embedModel = EmbeddingGeneration.getEmbeddingModels().first()
val docVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(orderEmailLensBmp), // the lens photo in the email
TextPart(orderEmailBody) // the order details
))
)
).embedding.vectorPrendiamo l'email dell'ordine.
Anche in questo caso, è molto semplice: passiamo solo un'immagine e un testo, ma potresti avere più contenuti da indicizzare qui.
Poi genereremo il vettore di embedding.
Quindi, dopo averlo fatto, utilizzeremo AppSearch per creare un documento e poi imposteremo le proprietà che riteniamo necessarie per generare quel documento specifico.
val embedModel = EmbeddingGeneration.getEmbeddingModels().first()
val docVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(orderEmailLensBmp), // the lens photo in the email
TextPart(orderEmailBody) // the order details
))
)
).embedding.vector
val doc = GenericDocument.Builder<GenericDocument.Builder<*>>(
"orders", "order_jp_904", "OrderRecord"
)
.setPropertyString("summary", "Shipped DDP, duties prepaid ($0 owed). In transit.")
.setPropertyString("photoUris", "order_email_lens.png")
.setPropertyEmbedding(
"embedding",
EmbeddingVector(docVector, embedModel.getModelSignature())
).build()Un aspetto da notare è che il motivo per cui vogliamo utilizzare gli embedding multimodali è che, anche se non ci sono parole condivise, le immagini e il testo riguardano contenuti simili, il che ci aiuta a ottenere la somiglianza semantica.
Possiamo quindi utilizzare la somiglianza del coseno per trovare elementi che non hanno parole condivise in comune.
Ok, questa è la funzione executeVaultSearch di cui abbiamo parlato in precedenza.
suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
}Questo è ciò che viene chiamato dalla funzione dell'app che abbiamo definito.
Quindi, in questo esempio, passiamo la query di ricerca generata da Gemini Nano.
Poi utilizzeremo una strategia di ranking, la somiglianza del coseno.
suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
val spec = SearchSpec.Builder()
.addEmbeddingParameters(EmbeddingVector(qVec, embedModel.getModelSignature()))
.setDefaultEmbeddingSearchMetricType(SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE)
.setRankingStrategy("sum(this.matchedSemanticScores(getEmbeddingParameter(0)))")
.setListFilterQueryLanguageEnabled(true).build()
}E poi, this.matchedSemanticScores ci aiuta semplicemente a ottenere queste informazioni.
Infine, specificheremo una soglia di 0, 68 per determinare la somiglianza del coseno minima per recuperare le informazioni.
suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
val spec = SearchSpec.Builder()
.addEmbeddingParameters(EmbeddingVector(qVec, embedModel.getModelSignature()))
.setDefaultEmbeddingSearchMetricType(SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE)
.setRankingStrategy("sum(this.matchedSemanticScores(getEmbeddingParameter(0)))")
.setListFilterQueryLanguageEnabled(true).build()
val results = appSearchSession.search(
"semanticSearch(getEmbeddingParameter(0), 0.68)", spec
)
// Cleanly close the cursor.
return results.use { it.nextPageAsync.await().first().genericDocument.toVaultMatch() }
}Quindi, questo è un aspetto con cui potresti voler sperimentare mentre esamini i tuoi casi d'uso, solo per vedere cosa funziona effettivamente per te.
Inoltre, un aspetto che vogliamo sottolineare è che, quando si pensa alla sicurezza di questo modello, Gemini Nano non è l'unico che esegue il codice, giusto?
Quindi, Gemini Nano si limita a fare una domanda, ma è la tua app che definisce il codice, lo esegue, recupera le informazioni e determina cosa passare a Gemini Nano.
In questo modo, hai un maggiore controllo su ciò che vuoi esporre a Gemini Nano e contribuisci a garantire una maggiore privacy e sicurezza per i tuoi utenti.
David Chou
Ok, ora siamo all'ultimo passaggio.
Quindi, abbiamo tutte le informazioni di cui abbiamo bisogno.
Utilizzeremo l'API Prompt, l'output strutturato e la sintesi vocale per effettuare i confronti e poi restituire queste informazioni all'utente in modo comprensibile.
Ok, quindi fondamentalmente, giusto, quello che vogliamo sapere è se questo pacco è effettivamente lo stesso delle informazioni dell'utente, che è l'ordine reale.
Per farlo, genereremo anche l'embedding, giusto, su queste informazioni dal messaggio vocale e dal testo, trasformandolo nello stesso vettore di embedding che abbiamo creato in precedenza.
val callerVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(callerParcelPhotoBmp), // brown box w/ shipping label
TextPart(callerText) // "Clearance fee ¥6,800 due today."
))
)
).embedding.vector
val matchScore = cosineSimilarity(callerVector, vaultMatch.embedding)Poi, utilizzeremo matchScore, utilizzando la somiglianza del coseno, possiamo fare un confronto tra la corrispondenza che avevamo recuperato dal vault e le informazioni del chiamante.
Quindi, come puoi vedere qui, anche se le immagini potrebbero non corrispondere, il testo è comunque molto utile.
È qui che gli embedding multimodali sono davvero utili.
Puoi inserire informazioni diverse e poi ottenere un significato semantico condiviso, giusto?
Quindi, l'embedding multimodale consente di mappare tutto sullo stesso spazio vettoriale.
Anche in questo caso, utilizzeremo la modalità Ragionamento per migliorare il nostro ragionamento.
val forensicRequest = generateContentRequest(
securitySystemInstruction,
content {
image(callerParcelPhotoBmp)
image(orderEmailLensBmp)
text(forensicAuditPrompt)
}
) {
// Enable Gemini Nano 4 reasoning before it answers
enableThinking = true
}Poi definiremo anche una classe di output, giusto?
@Generable(description = "Details of a visual contradiction found during an audit")
data class VisualContradiction(
@Guide(
description = "The type of contradiction",
enumValues = ["PACKAGING_MISMATCH", "STATUS_CONFLICT", "DUTY_FRAUD"],
)
val contradictionType: String,
@Guide(description = "Detailed explanation of the visual contradiction")
val explanation: String,
)Quindi, tutto ciò che facciamo qui è configurare un VisualContradiction e alcuni enum diversi dei diversi tipi di possibili contraddizioni che potremmo avere, giusto?
Che si tratti di DUTY_FRAUD, PACKAGING_MISMATCH o STATUS_CONFLICT.
Poi qui avremo una spiegazione.
Quindi, passeremo tutte queste informazioni a un verdetto di controllo, giusto?
@Generable(description = "Verdict and details of a phishing audit")
data class PhishingAuditVerdict(
@Guide(
description = "The audit verdict",
enumValues = ["CONFIRMED_PHISHING_SCAM", "LEGITIMATE_HOLD"],
)
val verdict: String,
@Guide(description = "The fraud risk score from 0 to 100", minimum = 0.0, maximum = 100.0)
val fraudRiskScore: Int,
@Guide(description = "Estimated amount of money saved in USD", minimum = 0.0)
val moneySavedUsd: Int,
@Guide(description = "List of visual contradictions identified during audit", minItems = 0)
val contradictions: List<VisualContradiction>,
// @Guide(description = "Summary of the warning to be spoken to the user")
// `@Guide` annotations are optional.
val spokenWarningSummary: String,
)Come puoi vedere, è molto semplice.
Vogliamo solo sapere se si tratta di una truffa o meno.
Potremmo avere un fraudRiskScore in cui puoi impostare un valore minimo e uno massimo da 0 a 100.
Puoi anche far sapere all'utente quanto ha risparmiato se vuoi tenere traccia di questo dato nel tempo.
Inoltre, puoi elencare le contraddizioni visive che abbiamo appena definito.
Un'altra cosa da notare è che l'annotazione @Guide è in realtà facoltativa.
Non è necessario impostarlo e fornire questo tipo di descrizione.
È utile perché potresti non essere in grado di spiegare cosa sta succedendo solo con la definizione della variabile.
Quindi, avere questa descrizione può essere utile per Gemini Nano per capire quali funzioni specifiche chiamare e come configurarle in modo da inserire le informazioni corrette nei campi giusti.
Quindi, una volta configurato, è molto semplice.
Basta eseguire tre righe di codice.
val typedRequest = generateTypedContentRequest(forensicRequest, PhishingAuditVerdict::class)
val response = generativeModel.generateContent(typedRequest)
val verdict: PhishingAuditVerdict? = response.candidates.first().responsegenerateTypedContentRequest, generi i contenuti e poi puoi recuperare la risposta.
Ecco un esempio di come potrebbe apparire.
{
"verdict": "CONFIRMED_PHISHING_SCAM",
"fraudRiskScore": 98,
"moneySavedUsd": 45,
"contradictions": [
{ "contradictionType": "DUTY_FRAUD", "explanation": "Fee demanded, but duties were prepaid." },
{ "contradictionType": "PACKAGING_MISMATCH", "explanation": "Carton has no carrier label or tracking." },
],
"spokenWarningSummary": "Scam blocked. Do not pay the 45 dollar fee. Your lens shipped duties prepaid."
}Quindi confermiamo che si tratta di una truffa, otteniamo un punteggio di rischio di frode molto elevato, che spiega queste contraddizioni evidenti nei dati che hai recuperato confrontandoli con le informazioni del chiamante.
Abbiamo anche fatto risparmiare a questo utente una certa somma di denaro, quindi possiamo tenere un tracker anche lì.
Sì.
In questo modo, abbiamo tutte le informazioni di cui abbiamo bisogno.
Il passaggio finale consiste nel mostrare queste informazioni all'utente in modo accessibile.
Ad esempio, se sono in viaggio o se hanno una disabilità visiva, potresti voler leggere le informazioni ad alta voce anziché far leggere il testo manualmente.
Per questo motivo, implementeremo la funzionalità di sintesi vocale per mostrare come funziona.
val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
/* more code here */
}
} finally {
synthesizer.close() // safe here: the flow completes after DONE, STOP, or Error
}Anche in questo caso, è semplicissimo: specifichiamo le impostazioni internazionali e una lingua, quindi passiamo il verdetto che avevamo appena generato utilizzando @Generable e @Guide.
Una volta ottenuto, è quasi istantaneo.
val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
synthesizer.synthesize(ttsRequest).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate ->
/* playback finished */
if (response.status == SpeechSynthesizerResponse.Status.DONE) { }
/* response.errorCode is a TextToSpeech.ERROR_* code */
is SpeechSynthesizerResponse.Error -> { }
else -> {}
}
}
}
} finally {
synthesizer.close() // safe here: the flow completes after DONE, STOP, or Error
}Sulla maggior parte dei dispositivi, la generazione della risposta richiederà meno di 120 ms.
Libereremo tutta la memoria necessaria.
Se necessario, possiamo anche impostare un fallback, giusto, utilizzando le API della piattaforma.
val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
synthesizer.synthesize(ttsRequest).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate ->
/* playback finished */
if (response.status == SpeechSynthesizerResponse.Status.DONE) { }
/* response.errorCode is a TextToSpeech.ERROR_* code */
is SpeechSynthesizerResponse.Error -> {
/* handle fallback to platform API */
}
else -> {}
}
}
}
// more code here ...La voce sarebbe comunque robotica, ma almeno l'utente avrebbe qualcosa con cui interagire nel caso in cui non sappia leggere.
David Chou
Ok.
È stato un percorso lungo, ma per riepilogare ciò che abbiamo fatto, abbiamo sfruttato tutte queste diverse API nei processi di acquisizione, triage, recupero e audit.
Ad esempio, utilizzando le API di riconoscimento vocale e traduzione, l'API Prompt e la chiamata di funzione, gli embedding e AppSearch, nonché l'output strutturato e la sintesi vocale.
Questo è solo un esempio di come potresti sfruttare tutte queste funzionalità in modo coerente ed eseguirle completamente on-device.
E poi, tornando a ciò di cui abbiamo parlato all'inizio, ci sono vantaggi sostanziali nell'eseguire questo, giusto?
Se dovessi eseguire questa operazione sul tuo dispositivo oggi, vedresti che è sostanzialmente più veloce on-device sulla maggior parte dell'hardware.
Non costa nulla, giusto? E potenzialmente risparmi centinaia di migliaia di dollari se lo esegui sempre.
E questo è disponibile, giusto, indipendentemente dal fatto che l'utente si trovi in montagna o in un seminterrato e non abbia accesso a internet.
Inoltre, è completamente privato perché non devi inviare informazioni al cloud.
Considerate tutte queste funzionalità, non esitare a consultare la documentazione, dare un'occhiata al codice QR o visitare il link.
Queste sono tutte le funzionalità che abbiamo lanciato negli ultimi sei mesi circa.
Grazie mille per aver dedicato del tempo a partecipare a questo intervento.
Ti ringraziamo per il tempo che ci hai dedicato.
Quindi, grazie mille.
val translator = SpeechTranslation.getClient(
SpeechTranslatorOptions.Builder(
Locale.JAPANESE, Locale.US
).build()
)val translator = SpeechTranslation.getClient(
SpeechTranslatorOptions.Builder(
Locale.JAPANESE, Locale.US
).build()
)
val audioFlow = flow {
emit(TranslationAudio(Locale.JAPANESE, courierVoicemailBytes))
}
val request = SpeechTranslatorRequest.Builder(audioFlow).build()
translator.startTranslation(request).collect { response ->
if (response is SpeechTranslatorResponse.Text) {
translatedCourierClaim = response.translatedText
}
}val recognizer = SpeechRecognition.getClient(
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
)val recognizer = SpeechRecognition.getClient(
speechRecognizerOptions {
locale = Locale.US
preferredMode = SpeechRecognizerOptions.Mode.MODE_ADVANCED
}
)
val request = speechRecognizerRequest {
audioSource = AudioSource.fromMic()
recognitionOptions { enableWordTiming = true } // optional
}
recognizer.startRecognition(request).collect { response ->
when (response) {
is SpeechRecognizerResponse.PartialTextResponse ->
liveCaption = response.text
is SpeechRecognizerResponse.FinalTextResponse ->
planAndCallVaultTool(translatedCourierClaim, response.text)
}
}retrieveOrderAndDeliveryPhotos(
searchQuery = "camera lens customs",
claimedFeeUsd = 45,
comparePackagePhotos = true
)// Keep SystemInstruction static across Turn 1 & Turn 2:
val securitySystemInstruction = SystemInstruction(
"""
You are an on-device parcel & phishing security auditor.
1. Cross-check packaging, carrier labels and tracking across all images.
2. Verify customs fee claims against local DDP (Delivered Duty Paid) order records.
3. Flag any payment demand when the order record shows duties prepaid.
""".trimIndent()
)// Pull in the Speech Translation (AST) & Speech Recognition (ASR) outputs:
val turn1Prompt = """
Translated voicemail: $translatedCourierClaim
Incoming text message: $callerText
User's spoken question: $userQuestion
""".trimIndent()
val turn1Request = generateContentRequest(
securitySystemInstruction,
TextPart(turn1Prompt)
) { /* enableThinking & setTools */ }// Registered in AndroidManifest.xml
@AppFunctionServiceEntryPoint("ScamInvestigationService", "scam_tools")
abstract class ScamInvestigationTools : AppFunctionService() {
}// Registered in AndroidManifest.xml
@AppFunctionServiceEntryPoint("ScamInvestigationService", "scam_tools")
abstract class ScamInvestigationTools : AppFunctionService() {
/**
* Searches local orders and order images in AppSearch.
* @param searchQuery Semantic query to match the order
* @param claimedFeeUsd Customs fee claimed in USD
* @param comparePackagePhotos Whether to fetch order images to compare
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun retrieveOrderAndDeliveryPhotos(
searchQuery: String,
claimedFeeUsd: Int,
comparePackagePhotos: Boolean
): VaultMatch = withContext(Dispatchers.IO) { executeVaultSearch(searchQuery) }
}val vaultTools = AppFunctionTools.getClient().getAppFunctions()
val turn1Request = generateContentRequest(securitySystemInstruction, TextPart(turn1Prompt)) {
enableThinking = true
setTools(vaultTools)
}
val call = generativeModel.generateContent(turn1Request)
.candidates.first().functionCalls.first()val embedModel = EmbeddingGeneration.getEmbeddingModels().first()
val docVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(orderEmailLensBmp), // the lens photo in the email
TextPart(orderEmailBody) // the order details
))
)
).embedding.vectorval embedModel = EmbeddingGeneration.getEmbeddingModels().first()
val docVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(orderEmailLensBmp), // the lens photo in the email
TextPart(orderEmailBody) // the order details
))
)
).embedding.vector
val doc = GenericDocument.Builder<GenericDocument.Builder<*>>(
"orders", "order_jp_904", "OrderRecord"
)
.setPropertyString("summary", "Shipped DDP, duties prepaid ($0 owed). In transit.")
.setPropertyString("photoUris", "order_email_lens.png")
.setPropertyEmbedding(
"embedding",
EmbeddingVector(docVector, embedModel.getModelSignature())
).build()suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
}suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
val spec = SearchSpec.Builder()
.addEmbeddingParameters(EmbeddingVector(qVec, embedModel.getModelSignature()))
.setDefaultEmbeddingSearchMetricType(SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE)
.setRankingStrategy("sum(this.matchedSemanticScores(getEmbeddingParameter(0)))")
.setListFilterQueryLanguageEnabled(true).build()
}suspend fun executeVaultSearch(searchQuery: String): VaultMatch {
val qVec = embedModel.generateEmbedding(
EmbeddingRequest(
content = Content(listOf(TextPart(searchQuery)))
)
).embedding.vector
val spec = SearchSpec.Builder()
.addEmbeddingParameters(EmbeddingVector(qVec, embedModel.getModelSignature()))
.setDefaultEmbeddingSearchMetricType(SearchSpec.EMBEDDING_SEARCH_METRIC_TYPE_COSINE)
.setRankingStrategy("sum(this.matchedSemanticScores(getEmbeddingParameter(0)))")
.setListFilterQueryLanguageEnabled(true).build()
val results = appSearchSession.search(
"semanticSearch(getEmbeddingParameter(0), 0.68)", spec
)
// Cleanly close the cursor.
return results.use { it.nextPageAsync.await().first().genericDocument.toVaultMatch() }
}val callerVector = embedModel.generateEmbedding(
EmbeddingRequest(
Content(listOf(
ImagePart(callerParcelPhotoBmp), // brown box w/ shipping label
TextPart(callerText) // "Clearance fee ¥6,800 due today."
))
)
).embedding.vector
val matchScore = cosineSimilarity(callerVector, vaultMatch.embedding)val forensicRequest = generateContentRequest(
securitySystemInstruction,
content {
image(callerParcelPhotoBmp)
image(orderEmailLensBmp)
text(forensicAuditPrompt)
}
) {
// Enable Gemini Nano 4 reasoning before it answers
enableThinking = true
}@Generable(description = "Details of a visual contradiction found during an audit")
data class VisualContradiction(
@Guide(
description = "The type of contradiction",
enumValues = ["PACKAGING_MISMATCH", "STATUS_CONFLICT", "DUTY_FRAUD"],
)
val contradictionType: String,
@Guide(description = "Detailed explanation of the visual contradiction")
val explanation: String,
)@Generable(description = "Verdict and details of a phishing audit")
data class PhishingAuditVerdict(
@Guide(
description = "The audit verdict",
enumValues = ["CONFIRMED_PHISHING_SCAM", "LEGITIMATE_HOLD"],
)
val verdict: String,
@Guide(description = "The fraud risk score from 0 to 100", minimum = 0.0, maximum = 100.0)
val fraudRiskScore: Int,
@Guide(description = "Estimated amount of money saved in USD", minimum = 0.0)
val moneySavedUsd: Int,
@Guide(description = "List of visual contradictions identified during audit", minItems = 0)
val contradictions: List<VisualContradiction>,
// @Guide(description = "Summary of the warning to be spoken to the user")
// `@Guide` annotations are optional.
val spokenWarningSummary: String,
)val typedRequest = generateTypedContentRequest(forensicRequest, PhishingAuditVerdict::class)
val response = generativeModel.generateContent(typedRequest)
val verdict: PhishingAuditVerdict? = response.candidates.first().response{
"verdict": "CONFIRMED_PHISHING_SCAM",
"fraudRiskScore": 98,
"moneySavedUsd": 45,
"contradictions": [
{ "contradictionType": "DUTY_FRAUD", "explanation": "Fee demanded, but duties were prepaid." },
{ "contradictionType": "PACKAGING_MISMATCH", "explanation": "Carton has no carrier label or tracking." },
],
"spokenWarningSummary": "Scam blocked. Do not pay the 45 dollar fee. Your lens shipped duties prepaid."
}val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
/* more code here */
}
} finally {
synthesizer.close() // safe here: the flow completes after DONE, STOP, or Error
}val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
synthesizer.synthesize(ttsRequest).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate ->
/* playback finished */
if (response.status == SpeechSynthesizerResponse.Status.DONE) { }
/* response.errorCode is a TextToSpeech.ERROR_* code */
is SpeechSynthesizerResponse.Error -> { }
else -> {}
}
}
}
} finally {
synthesizer.close() // safe here: the flow completes after DONE, STOP, or Error
}val synthesizer = SpeechSynthesis.getClient(SpeechSynthesizerOptions.builder(Locale.US).build())
try {
val voice = synthesizer.getSupportedVoices(Locale.US).firstOrNull()
if (voice != null) {
val ttsRequest = SpeechSynthesizerRequest(
input = SpeechSynthesizerRequest.Input.Text(verdict.spokenWarningSummary, voice),
audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANT)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
synthesizer.synthesize(ttsRequest).collect { response ->
when (response) {
is SpeechSynthesizerResponse.StatusUpdate ->
/* playback finished */
if (response.status == SpeechSynthesizerResponse.Status.DONE) { }
/* response.errorCode is a TextToSpeech.ERROR_* code */
is SpeechSynthesizerResponse.Error -> {
/* handle fallback to platform API */
}
else -> {}
}
}
}
// more code here ...