Голосовий 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, які рассинхронізуються при ошибках мережі. Це класичний источник багів з «ассистент завис та не реагує».
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 під час мови потрібно підняти, інакше эхо з динаміка буде тригерити 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 проти 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
Голосовой ассистент підпадає під voip або audio background mode у фреймворку Apple. Для активного прослухування потрібна 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 тижнів.







