Реализация голосового AI-ассистента с диалоговым режимом в мобильном приложении
Голосовой ассистент в диалоговом режиме — это не просто STT + GPT + TTS последовательно. Это управление состоянием разговора, контекстным окном, прерыванием, фоновым режимом и аудиосессией, которая конкурирует с системными приложениями. Именно на этих стыках обычно ломается «почти готовая» интеграция.
Из чего состоит диалоговый ассистент
Минимальный стек:
- Wake word / Push-to-Talk — триггер начала фразы
- STT — транскрипция (Whisper, Deepgram, Google STT)
- LLM — ответ в контексте диалога (GPT-4o, Claude, Gemini)
- TTS — озвучивание ответа (ElevenLabs, OpenAI TTS, системный)
- State machine — управление состояниями: idle → listening → processing → speaking → idle
Без явного конечного автомата (state machine) код превращается в флаги типа isListening, isProcessing, isSpeaking, которые рассинхронизируются при ошибках сети. Это классический источник bagов с «ассистент завис и не реагирует».
State machine: единственный правильный подход
enum AssistantState {
case idle
case listening
case transcribing
case thinking(history: [Message])
case speaking(text: String)
case error(Error)
}
class AssistantViewModel: ObservableObject {
@Published private(set) var state: AssistantState = .idle
func startListening() {
guard case .idle = state else { return }
state = .listening
audioCapture.start { [weak self] audioData in
self?.handleAudioChunk(audioData)
}
}
func onSilenceDetected() {
guard case .listening = state else { return }
state = .transcribing
audioCapture.stop()
Task { await transcribeAndRespond() }
}
private func transcribeAndRespond() async {
do {
let text = try await stt.transcribe(audioCapture.buffer)
state = .thinking(history: conversationHistory)
let response = try await llm.chat(messages: conversationHistory + [.user(text)])
conversationHistory.append(.user(text))
conversationHistory.append(.assistant(response))
state = .speaking(text: response)
await tts.speak(response)
state = .idle
} catch {
state = .error(error)
}
}
}
Ключевое — переход в следующее состояние только из ожидаемого предыдущего (guard case). Это исключает гонки при параллельных событиях.
Прерывание (barge-in)
Пользователь говорит поверх ответа ассистента. Нужно: остановить TTS, остановить текущий LLM-запрос, начать слушать заново.
На iOS:
func handleBargeIn() {
tts.stopSpeaking(at: .immediate)
currentLLMTask?.cancel()
audioCapture.reset()
state = .listening
audioCapture.start { ... }
}
VAD должен работать параллельно во время воспроизведения. Если AVAudioSession в режиме .playAndRecord, микрофон доступен одновременно с динамиком. Порог VAD во время speech нужно повысить, иначе эхо из динамика будет триггерить barge-in.
Управление контекстным окном
GPT-4o поддерживает 128K токенов, но слать всю историю разговора в каждом запросе — это деньги и задержка. Стратегия:
- Rolling window: оставляем последние N сообщений (обычно 10–20)
- Summarization: после N сообщений запрашиваем суммари предыдущей части через отдельный вызов, добавляем как системное сообщение
- Relevance filtering: для узкоспециализированных ассистентов — embedding similarity для выбора релевантных фрагментов из истории
Для большинства мобильных ассистентов достаточно rolling window в 15–20 сообщений.
TTS: выбор голоса и кэширование
Стриминг TTS — ключ к низкой задержке. OpenAI TTS поддерживает стриминг: ответ приходит чанками audio/mpeg, клиент начинает воспроизводить до получения полного аудио.
// Стриминговый TTS с OpenAI
func streamSpeak(text: String) async throws {
let request = TTSRequest(model: "tts-1", input: text, voice: "nova", responseFormat: "mp3")
let (bytes, _) = try await urlSession.bytes(for: ttsURLRequest(request))
var audioData = Data()
for try await byte in bytes {
audioData.append(byte)
if audioData.count > 8192 { // Начинаем воспроизведение после первых 8 KB
try audioPlayer.enqueueChunk(audioData)
audioData = Data()
}
}
}
Для часто повторяющихся фраз («Я слушаю», «Подождите», «Не понял») — кэшируем заранее синтезированное аудио локально. Это убирает задержку на типовые реплики.
Push-to-Talk vs Wake Word
Push-to-Talk — проще в реализации, нет ложных срабатываний, меньше расхода батареи. Подходит для профессиональных инструментов.
Wake word через Picovoice Porcupine — всегда активен, работает on-device (< 1% CPU), поддерживает кастомные слова. Интеграция через PorcupineManager на iOS/Android.
// Android: Porcupine wake word
val porcupine = Porcupine.Builder()
.setAccessKey(accessKey)
.setKeyword(Porcupine.BuiltInKeyword.HEY_GOOGLE) // или кастомный .ppn файл
.build(context)
porcupineManager = PorcupineManager.Builder()
.setAccessKey(accessKey)
.setKeyword(Porcupine.BuiltInKeyword.HEY_GOOGLE)
.build(context) { keywordIndex ->
runOnUiThread { viewModel.onWakeWordDetected() }
}
porcupineManager.start()
Wake word в фоновом режиме на Android требует ForegroundService с уведомлением. Без него система убьёт процесс.
Фоновый режим на iOS
Голосовой ассистент во фреймворке Apple подпадает под voip background mode или audio background mode. Для активного прослушивания нужен audio capability в Entitlements + AVAudioSession активная сессия. Apple может отклонить приложение при ревью, если background audio не обосновано — пиши в metadata review notes.
Сроки
MVP с Push-to-Talk, Whisper STT, GPT-4o, OpenAI TTS — 2–3 недели на одну платформу. Полноценный ассистент с wake word, barge-in, стриминговым TTS, управлением контекстом, фоновым режимом — 6–10 недель.







