Реализация AI Text-to-Speech с выбором голоса в мобильном приложении
Системный TTS на iOS и Android — AVSpeechSynthesizer и TextToSpeech — решает базовую задачу, но звучит роботизированно. AI TTS от ElevenLabs, OpenAI или Yandex SpeechKit — это голоса, которые трудно отличить от живых. Интеграция требует продуманного кэширования и стримингового воспроизведения, иначе задержка 2–4 секунды перед первым словом убивает UX.
Провайдеры и их особенности
OpenAI TTS — 6 голосов (alloy, echo, fable, onyx, nova, shimmer), модели tts-1 (быстрая) и tts-1-hd (качественная). Поддерживает стриминг. Русский — хорошо. Стоимость: $15/млн символов для tts-1-hd.
ElevenLabs — большая библиотека голосов, voice cloning, multilingual v2. Стриминг через WebSocket. Лучшее качество среди всех провайдеров.
Yandex SpeechKit — лучшие русскоязычные голоса, включая alena, filipp. REST или gRPC. Есть SSML для управления интонацией, паузами, ударением.
Системный TTS — бесплатно, офлайн, нулевая задержка, но роботизированный. Годится как fallback.
Стриминговое воспроизведение
Самое важное в реализации TTS — не ждать полного ответа. 500 символов текста на tts-1-hd синтезируется ~2 секунды. Со стримингом пользователь слышит первые слова через 300–500 мс.
iOS: стриминг через AVPlayer
class StreamingTTSPlayer {
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
func speak(text: String, voice: String = "nova") async throws {
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/audio/speech")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["model": "tts-1", "input": text, "voice": voice, "response_format": "mp3"]
request.httpBody = try JSONEncoder().encode(body)
// AVPlayer умеет стримить с HTTP-ответа через resourceLoader
// Используем кастомный AVAssetResourceLoaderDelegate
let asset = StreamingAudioAsset(request: request)
playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
player?.play()
}
}
Для полноценного стримингового воспроизведения нужен AVAssetResourceLoaderDelegate, который подаёт чанки аудиоданных по мере их получения. Это ~100 строк кода, но это единственный способ начать воспроизведение до получения полного файла на iOS.
Альтернатива — использовать AudioStreamer библиотеки или AVPlayer с data URI через pipe. На практике проще всего — AVAudioPlayerNode + AVAudioEngine с ручной подачей декодированных PCM-буферов.
Android: ExoPlayer со стримингом
class StreamingTTSPlayer(private val context: Context) {
private val exoPlayer = ExoPlayer.Builder(context).build()
fun speak(text: String, voice: String = "nova") {
val url = "https://api.openai.com/v1/audio/speech"
// ExoPlayer поддерживает стриминг нативно через MediaSource
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
setDefaultRequestProperties(mapOf(
"Authorization" to "Bearer $apiKey",
"Content-Type" to "application/json"
))
}
// Для POST-запросов используем кастомный DataSource
val mediaItem = MediaItem.fromUri(buildCachedUri(text, voice))
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.play()
}
}
ExoPlayer нативно поддерживает прогрессивный стриминг MP3/AAC. Для POST-запросов нужен кастомный DataSource, который делает POST и отдаёт InputStream — ExoPlayer сам буферизует и начинает воспроизведение после первых нескольких секунд аудио.
Кэширование синтезированного аудио
TTS — дорогой. Одна и та же фраза не должна синтезироваться дважды.
// Android: кэш на диске с ключом sha256
class TTSCache(private val cacheDir: File) {
fun getKey(text: String, voice: String): String =
MessageDigest.getInstance("SHA-256")
.digest("$text|$voice".toByteArray())
.joinToString("") { "%02x".format(it) }
fun get(key: String): File? {
val file = File(cacheDir, "$key.mp3")
return if (file.exists()) file else null
}
fun put(key: String, data: ByteArray) {
File(cacheDir, "$key.mp3").writeBytes(data)
}
}
TTL кэша — 30 дней для статичного контента (UI-фразы, обучающий текст), без TTL для пользовательского. Ограничение размера кэша — 50–100 МБ, LRU eviction.
UI выбора голоса
Пользователь должен услышать голос перед выбором. Паттерн:
- Список голосов с именем и коротким описанием
- Кнопка «Прослушать» — воспроизводит 5-секундный пример (кэшируем предзаписанные семплы, не синтезируем на лету)
- Выбранный голос сохраняется в UserDefaults / SharedPreferences
Для ElevenLabs — /v1/voices возвращает список доступных голосов с метаданными: preview_url для предпрослушивания. Не нужно синтезировать — просто воспроизводи готовый preview.
SSML для тонкой настройки
Yandex SpeechKit и Google TTS поддерживают SSML:
<speak>
Добро пожаловать в <emphasis level="strong">наш сервис</emphasis>.
<break time="500ms"/>
Ваш заказ <say-as interpret-as="cardinal">12345</say-as> готов к выдаче.
</speak>
<break>, <prosody rate="slow">, <say-as> для чисел и дат — это то, что отличает естественное звучание от роботизированного. OpenAI TTS SSML не поддерживает — там управление через <pause> в самом тексте или промпт-инструкции.
Сроки
Базовая интеграция одного провайдера с UI выбора голоса — 4–6 дней. Стриминговое воспроизведение + кэш на диске + fallback на системный TTS — ещё 5–7 дней.







