voice dans une app Expo.
Pas à pas : enregistrer au micro, obtenir le texte, faire lire une réponse. Le code se copie tel quel. Écrit pour Expo SDK 58.
Avant de commencer
- Un projet Expo SDK 58 (
npx create-expo-app@latest). - Une clé okto.it avec le service
voicecoché. Voir Démarrer en 3 étapes. - Un vrai téléphone de préférence : le micro d'un émulateur marche mal.
Ce que fait l'exemple : on maintient un bouton pour parler, le texte s'affiche, et un bouton « Lire » le fait dire par voice.
1. Installer
Deux modules Expo : expo-audio pour le micro et la lecture, expo-file-system pour ranger le son reçu.
npx expo install expo-audio expo-file-system
2. Autoriser le micro
Le module expo-audio ajoute les autorisations pour vous. Dans app.json :
{
"expo": {
"plugins": [
[
"expo-audio",
{
"microphonePermission": "L'app utilise le micro pour transcrire ce que vous dites."
}
]
]
}
}
Il écrit NSMicrophoneUsageDescription pour iOS (avec votre phrase) et RECORD_AUDIO pour Android. Si vous préférez les écrire vous-même, c'est l'équivalent de :
{
"expo": {
"ios": {
"infoPlist": {
"NSMicrophoneUsageDescription": "L'app utilise le micro pour transcrire ce que vous dites."
}
},
"android": {
"permissions": ["android.permission.RECORD_AUDIO"]
}
}
}
Ces réglages sont natifs : ils ne comptent qu'après un nouveau build (development build ou EAS Build). Dans Expo Go, l'essai marche aussi, avec le texte d'autorisation d'Expo Go.
# build local (Android Studio ou Xcode installés)
npx expo prebuild --clean
npx expo run:android # ou : npx expo run:ios
# ou build dans le nuage
eas build --profile development --platform android
3. La clé
Pour un prototype, la clé passe par une variable d'environnement. Créez .env à la racine du projet :
# .env : à la racine du projet, jamais commité
EXPO_PUBLIC_VOICE_KEY=okto_key_0123456789ab_…
# .gitignore
.env
.env*.local
EXPO_PUBLIC_ veut dire publique
Une variable EXPO_PUBLIC_… est recopiée en clair dans le code de l'app. Quiconque a l'APK peut la lire. Cela évite de la commiter, pas de la divulguer. Pour une app publiée, lisez la section sécurité.
4. Le module voice.ts
Deux fonctions, une par route : transcribe() envoie le fichier enregistré, say() rend un fichier WAV prêt à jouer.
// voice.ts : les deux appels à voice.okto.it (transcrire, faire parler).
import { fetch as expoFetch } from "expo/fetch";
import { File, Paths } from "expo-file-system";
const VOICE_URL = "https://voice.okto.it";
// Prototype seulement : une variable EXPO_PUBLIC_ finit en clair dans l'app.
const VOICE_KEY = process.env.EXPO_PUBLIC_VOICE_KEY ?? "";
/** Erreur rendue par voice : code HTTP, message en français, adresse où recharger (402). */
export class VoiceError extends Error {
status: number;
action?: string;
constructor(status: number, message: string, action?: string) {
super(message);
this.status = status;
this.action = action;
}
}
async function fail(res: { status: number; json(): Promise<unknown> }): Promise<never> {
let body: { error?: string; action?: string } = {};
try {
body = (await res.json()) as typeof body;
} catch {
// Pas de JSON (panne réseau, proxy) : on garde le code HTTP.
}
throw new VoiceError(res.status, body.error ?? `Erreur ${res.status}`, body.action);
}
/** Voix → texte. `uri` : le fichier enregistré (m4a). */
export async function transcribe(
uri: string,
options: { language?: string; vocabulary?: string[] } = {},
): Promise<{ text: string; seconds: number }> {
const form = new FormData();
// Forme propre à React Native : le fichier est lu depuis son adresse locale.
form.append("file", { uri, name: "audio.m4a", type: "audio/mp4" } as unknown as Blob);
const query = new URLSearchParams();
if (options.language) query.set("language", options.language);
if (options.vocabulary?.length) query.set("vocabulary", options.vocabulary.join(","));
const qs = query.toString();
// fetch de React Native : il sait envoyer { uri, name, type }. Ne fixez pas Content-Type, il ajoute la frontière.
const res = await fetch(`${VOICE_URL}/v1/transcribe${qs ? `?${qs}` : ""}`, {
method: "POST",
headers: { Authorization: `Bearer ${VOICE_KEY}` },
body: form,
});
if (!res.ok) await fail(res);
return (await res.json()) as { text: string; seconds: number };
}
// Cache des phrases déjà dites : voice ne garde rien, et chaque synthèse est payée.
const said = new Map<string, File>();
/** Texte → voix. Rend l'adresse d'un fichier WAV du cache, prêt à jouer. */
export async function say(text: string, voice: "Kore" | "Aoede" | "Charon" | "Puck" = "Kore"): Promise<string> {
const cacheKey = `${voice}:${text}`;
const cached = said.get(cacheKey);
if (cached?.exists) return cached.uri;
// fetch d'expo/fetch : arrayBuffer() fiable pour une réponse binaire.
const res = await expoFetch(`${VOICE_URL}/v1/say`, {
method: "POST",
headers: { Authorization: `Bearer ${VOICE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ text, voice, format: "web" }),
});
if (!res.ok) await fail(res);
const file = new File(Paths.cache, `voice-${Date.now()}.wav`);
file.create({ overwrite: true });
await file.write(new Uint8Array(await res.arrayBuffer()));
said.set(cacheKey, file);
return file.uri;
}
Trois points à connaître :
- L'envoi du fichier :
{ uri, name: "audio.m4a", type: "audio/mp4" }dans unFormDataest la forme propre à React Native. Elle passe par lefetchglobal, qui lit le fichier sur le téléphone. Ne fixez pasContent-Type:fetchle fait, avec la frontière du formulaire. - La réception du son :
say()utilise lefetchd'expo/fetch, dontarrayBuffer()lit bien une réponse binaire. Ce mêmefetchne sait pas envoyer{ uri }: d'où deuxfetchdans le module. - Le fichier WAV : écrit dans le cache avec la nouvelle API d'
expo-file-system(File,Paths), celle du SDK 58. L'ancienne (writeAsStringAsyncen base64) n'existe plus que dansexpo-file-system/legacy; ce guide ne l'utilise pas.
5. Le composant
Placez voice.ts et VoiceDemo.tsx côte à côte, à la racine du projet ou dans components/.
// VoiceDemo.tsx : maintenir pour parler → texte affiché → « Lire » pour l'entendre.
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from "react-native";
import {
RecordingPresets,
requestRecordingPermissionsAsync,
setAudioModeAsync,
useAudioPlayer,
useAudioRecorder,
useAudioRecorderState,
} from "expo-audio";
import { say, transcribe, VoiceError } from "./voice";
type Problem = { message: string; action?: string };
function explain(e: unknown): Problem {
if (e instanceof VoiceError) {
switch (e.status) {
case 401:
return { message: "Clé refusée : vérifiez la clé et que le service voice y est coché." };
case 402:
return { message: "Le crédit de l'espace est épuisé.", action: e.action };
case 413:
return { message: "Enregistrement trop long : 10 minutes au plus." };
case 429:
return { message: "Trop de demandes, ou plafond du jour atteint. Réessayez plus tard." };
case 502:
case 503:
return { message: "Le moteur de voix est indisponible. Réessayez dans un instant." };
default:
return { message: e.message };
}
}
return { message: "Pas de réponse du serveur. Vérifiez la connexion." };
}
export default function VoiceDemo() {
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
const recorderState = useAudioRecorderState(recorder);
const player = useAudioPlayer(null);
const starting = useRef<Promise<void> | null>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<Problem | null>(null);
useEffect(() => {
(async () => {
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) setProblem({ message: "Autorisez le micro dans les réglages du téléphone." });
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
})();
}, []);
function startRecording() {
setProblem(null);
starting.current = (async () => {
try {
await recorder.prepareToRecordAsync();
recorder.record();
} catch {
setProblem({ message: "Impossible d'ouvrir le micro." });
}
})();
}
async function stopRecording() {
await starting.current; // le doigt a pu se lever avant que l'enregistrement démarre
if (!recorder.isRecording) return;
const duration = recorder.getStatus().durationMillis;
await recorder.stop();
if (!recorder.uri || duration < 500) return; // appui trop court : rien à envoyer
setBusy(true);
try {
const result = await transcribe(recorder.uri, { language: "fr-FR" });
setText(result.text);
} catch (e) {
setProblem(explain(e));
} finally {
setBusy(false);
}
}
async function read() {
setBusy(true);
try {
const uri = await say(text, "Kore");
player.replace({ uri });
player.play();
} catch (e) {
setProblem(explain(e));
} finally {
setBusy(false);
}
}
return (
<View style={styles.box}>
<Pressable
onPressIn={startRecording}
onPressOut={stopRecording}
disabled={busy}
style={[styles.talk, recorderState.isRecording && styles.talkOn]}
>
<Text style={styles.talkText}>{recorderState.isRecording ? "Je vous écoute…" : "Maintenir pour parler"}</Text>
</Pressable>
{busy && <ActivityIndicator />}
{text !== "" && <Text style={styles.text}>{text}</Text>}
<Pressable onPress={read} disabled={busy || text === ""} style={styles.read}>
<Text style={styles.readText}>Lire</Text>
</Pressable>
{problem && (
<Text style={styles.problem}>
{problem.message}
{problem.action && (
<Text style={styles.link} onPress={() => Linking.openURL(problem.action!)}>
{" "}Recharger le crédit
</Text>
)}
</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
box: { gap: 16, padding: 24 },
talk: { padding: 24, borderRadius: 999, backgroundColor: "#17707F", alignItems: "center" },
talkOn: { backgroundColor: "#FF8A1F" },
talkText: { color: "#FFFFFF", fontSize: 18, fontWeight: "700" },
text: { fontSize: 18, lineHeight: 26 },
read: { padding: 14, borderRadius: 999, borderWidth: 1.5, borderColor: "#17707F", alignItems: "center" },
readText: { color: "#17707F", fontSize: 16, fontWeight: "700" },
problem: { color: "#B42318" },
link: { textDecorationLine: "underline", fontWeight: "700" },
});
Puis affichez-le sur un écran :
// app/index.tsx (Expo Router) ou App.tsx
import { SafeAreaView } from "react-native-safe-area-context";
import VoiceDemo from "../VoiceDemo";
export default function Home() {
return (
<SafeAreaView>
<VoiceDemo />
</SafeAreaView>
);
}
Ce qu'il fait, dans l'ordre :
- Au montage, il demande le micro (
requestRecordingPermissionsAsync) et règle le son (allowsRecording, et lecture même en mode silencieux sur iPhone). - Doigt posé :
prepareToRecordAsync()puisrecord(), au format m4a du préréglageRecordingPresets.HIGH_QUALITY. - Doigt levé :
stop(), puis le fichierrecorder.uripart à/v1/transcribe. Un appui de moins d'une demi-seconde n'est pas envoyé. - « Lire » :
say()rend un fichier du cache, que le lecteur charge (replace) et joue (play). Une phrase déjà dite est rejouée depuis le cache, sans nouvel appel.
6. Les erreurs
Le module lève une VoiceError avec le code HTTP, le message de voice et, pour 402, l'adresse action. Le composant les traduit pour la personne :
| Code | Dans l'app |
|---|---|
401 | Clé refusée : mauvaise clé, clé révoquée, ou voice pas coché. À corriger dans .env, puis relancer npx expo start. |
402 | Crédit épuisé. Le lien « Recharger le crédit » ouvre action. |
413 | Enregistrement trop long : 10 minutes ou 20 Mo au plus. |
415 | Format audio non reconnu : vérifiez le type du fichier (audio/mp4 pour un m4a). |
429 | Trop de demandes, ou plafond du jour de la clé (30 minutes) atteint. |
502, 503 | Moteur indisponible : réessayer dans un instant. |
La liste complète est dans la référence.
7. Sécurité
Tout ce qui est dans l'app, EXPO_PUBLIC_… compris, se lit en ouvrant l'APK.
- Pour un prototype, c'est acceptable : le plafond de 30 minutes par clé et par jour et le crédit de l'espace limitent la dépense. Révoquez la clé dès qu'elle a trop circulé.
- Pour une app publiée, appelez voice depuis votre serveur. L'app parle à votre serveur, qui vérifie la personne et ajoute la clé.
- Dans tous les cas, jamais de clé dans le dépôt git.
Un relais minimal, en Node et Express. La clé reste dans une variable d'environnement du serveur :
// server.mjs : relais entre votre app et voice. La clé reste ici, jamais dans l'app.
// Node 20 ou plus : npm install express, puis VOICE_KEY=okto_key_… node server.mjs
import express from "express";
const VOICE_URL = "https://voice.okto.it";
const VOICE_KEY = process.env.VOICE_KEY;
const app = express();
// Renvoie la réponse de voice telle quelle : code, type, corps (texte JSON ou WAV).
async function relay(res, upstream) {
res.status(upstream.status);
res.type(upstream.headers.get("content-type") ?? "application/json");
res.send(Buffer.from(await upstream.arrayBuffer()));
}
// À vous de vérifier ici que la personne est connectée à VOTRE appli, et de limiter ses appels.
function checkUser(req, res, next) {
next();
}
// Le formulaire de l'app (champ file) est transmis sans être décodé.
app.post("/api/transcribe", checkUser, express.raw({ type: "multipart/form-data", limit: "21mb" }), async (req, res) => {
const upstream = await fetch(`${VOICE_URL}/v1/transcribe?language=fr-FR`, {
method: "POST",
headers: { Authorization: `Bearer ${VOICE_KEY}`, "Content-Type": req.get("content-type") },
body: req.body,
});
await relay(res, upstream);
});
app.post("/api/say", checkUser, express.json(), async (req, res) => {
const upstream = await fetch(`${VOICE_URL}/v1/say`, {
method: "POST",
headers: { Authorization: `Bearer ${VOICE_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ text: req.body.text, voice: req.body.voice }),
});
await relay(res, upstream);
});
app.listen(3000, () => console.log("Relais voice sur http://localhost:3000"));
Côté app, seules l'adresse et l'en-tête changent :
// voice.ts, version app publiée : l'app parle à votre serveur, plus à voice.
const API_URL = "https://api.votre-app.fr";
// Dans transcribe() : même formulaire, autre adresse, et votre propre jeton de session.
fetch(`${API_URL}/api/transcribe`, {
method: "POST",
headers: { Authorization: `Bearer ${sessionToken}` },
body: form,
});
// Dans say() : même corps JSON, autre adresse.
expoFetch(`${API_URL}/api/say`, {
method: "POST",
headers: { Authorization: `Bearer ${sessionToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ text, voice }),
});
Une route d'API d'un autre cadre (Next.js, Hono, Expo Router côté serveur…) fait la même chose : recevoir, ajouter la clé, transmettre, renvoyer la réponse.
Et ensuite
vocabularypour les noms propres de votre domaine ({ language: "fr-FR", vocabulary: ["UCA", "Kapsuls"] }).- Les voix
Aoede,CharonetPuck, à essayer avecsay(text, "Puck"). - Toutes les routes, tous les paramètres : la référence de l'API voice.