В этой статье я хочу поделиться практическим опытом разработки Android-приложения для терапии заикания, основанного на технике Delayed Auditory Feedback (DAF).
DAF - это метод, при котором человек слышит собственную речь с небольшой задержкой (обычно 50-200 мс) через наушники. Такая обратная связь замедляет речь, снижает автоматизм, при котором возникают судорожные повторы, усиливает контроль над артикуляцией и помогает синхронизировать дыхание с речью.
На первый взгляд идея кажется простой: считать звук с микрофона, добавить задержку и воспроизвести его обратно. Однако на практике корректная реализация DAF на Android оказалась значительно сложнее - в первую очередь из-за особенностей аудиостека и системных задержек.
Техническая основа
Для низкоуровневой работы со звуком используются два стандартных Android-класса:
AudioRecord - захват аудиоданных с микрофона
AudioTrack - воспроизведение аудиопотока
Эта связка позволяет работать напрямую с сырыми PCM-данными (в нашем случае - 16-битные семплы). Манипулируя массивами семплов, мы можем точно контролировать задержку между входом и выходом сигнала.
val inBuf = ShortArray(1024)
// с помошью класса AudioRecord читаем аудио данные микрофона в массив inBuf
val readCount = audioRecord.read(inBuf, 0, inBuf.size)
// inBuf = [12, 32, 43, 42, 96, ...] - массив семплов за небольшой промежуток времени. Можем производить с ними какие-лиюо манипуляции
// проигрываем семплы через динамик используя класс AudioTrack.
audioTrack.(inBuf, 0, readCount)Почему задержка получается слишком большой
Уже на базовой реализации я столкнулся с основной проблемой Android‑аудио - высокой суммарной задержкой обработки.
Звук в Android проходит через несколько слоёв, каждый из которых занимает какое-то время:
драйверы аудиоустройства;
HAL (Hardware Abstraction Layer);
DSP‑обработку;
ресемплинг и преобразование форматов.
Даже с учётом того, что DAF сам по себе предполагает задержку, реальный лаг между речью пользователя и звуком в наушниках был слишком большим для комфортного использования.
Основная цель при оптимизации обработки аудио - избежать как можно большего количества слоёв обработки.
Ниже приведены конфигурации AudioTrack и AudioRecord, которые показали наилучший баланс между задержкой и качеством звука.
AudioTrack:
// получаем оптимальный sample rate для данного устройства
val sampleRateStr = audioHelper.audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)
val sampleRate = sampleRateStr?.let { str -> Integer.parseInt(str).takeUnless { it == 0 }} ?: 44100
audioProcessor.setSampleRate(sampleRate)
// получаем минимальный размер буфера, при котором гарантируется стабильная обработка аудио
val bufferSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val player = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setBufferSizeInBytes(bufferSize)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()Почему именно такие параметры?
AudioTrack.PERFORMANCE_MODE_LOW_LATENCY- устанавливает предпочитаемый режим работы. Это значение оптимизирует внутреннюю обработку под низкую задержку. Так-же есть вариант под экономию батареи, но он нам не подходит.AudioFormat.CHANNEL_OUT_MONOиAudioFormat.ENCODING_PCM_16BIT- формат аудио. Моно-звук и 16-битные семплы обеспечивают лучшую производительность и минимальные накладные расходы.AudioAttributes.CONTENT_TYPE_SPEECH- значение параметра contentType. Он указывает аудиопроцессору, что будет воспроизводится речь. Таким образом система сможет настроить оптимальное усиление звука и отбросить ненужные обработки, что снизит задержку.AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY - значение параметра usage. Определяет сценарий использования аудио и управляет маршрутизацией, фокусировкой и громкостью. Он так-же влияет на задержку и качество звука. В наших экспериментах лучше всего себя показало именно это значение. Оно обеспечило минимальные задержки, отключённое шумоподавление и адекватный уровень усиления сигнала.
AudioRecord:
// по аналогии с AudioTrack получаем размер буффера
val bufferSize = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val recorder = AudioRecord.Builder()
.setAudioSource(MediaRecorder.AudioSource.MIC)
// по аналогии с AudioTrack устанавливаем формат аудио
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
.build()
)
.setBufferSizeInBytes(bufferSize)
.build()Параметр AudioSource определяет источник звука и набор применяемых обработок. Его выбор напрямую влияет на прохождение данных через дополнительные обработки и усиление. Для достижения минимальной задержки документация Андроид рекомендует использовать MediaRecorder.AudioSource.VOICE_RECOGNITION. При этой конфигурации аудио проходит минимальное количество слоев обработки. Но в наших тестах она показывала очень тихий звук на выходе, это нам не подходит. Лучше всего себя показал MediaRecorder.AudioSource.MIC. У него небольшая задержка и неплохое усиление сигнала.
⚠️ Поведение может отличаться в зависимости от устройства и производителя — это важно учитывать. Небольшие изменения конфигурации обязательно нужно тестировать на реальных пользователях.
Размер буфера и HAL
В некоторых источниках видел, что значение bufferSize, используемое при создании AudioTrack/AudioRecord должно быть кратен размеру буфера HAL. Это позволяет избежать ситуации jitter buffer underrun, когда в HAL поступают неполные данные.
Получить размер буфера HAL можно так:
val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val framesPerBuffer: String? = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)
var framesPerBufferInt: Int = framesPerBuffer?.let { str ->
Integer.parseInt(str).takeUnless { it == 0 }
} ?: 256 // Use defaultЗатем увеличиваем bufferSize до значения, кратного framesPerBufferInt
В моем случае это не давало никаких изменений но для полноты картины добавляю это уточнение.
Bluetooth‑наушники и скрытая ловушка
Даже после всех оптимизаций задержка оставалась слишком высокой. Причина оказалась в Bluetooth‑наушниках.
Bluetooth работает в двух режимах:
A2DP (Advanced Audio Distribution Profile) - высокое качество, большая задержка;
SCO (Synchronous Connection-Oriented) - низкое качество, но минимальная задержка.
По умолчанию используется A2DP, что для DAF неприемлемо.
Режимом работы Bluetooth наушником можно управлять с помощью класса AudioManager. Эта настройка работает глобально, поэтому лучше всего включать этот режим только на время работы приложения.
val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager
fun startBluetoothSco(device: AudioDeviceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.setCommunicationDevice(device)
} else {
audioManager.isBluetoothScoOn = true
audioManager.startBluetoothSco()
}
}
fun stopBluetoothSco() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
audioManager.clearCommunicationDevice()
} else {
audioManager.stopBluetoothSco()
audioManager.isBluetoothScoOn = false
}
}В setCommunicationDevice передаем Bluetooth аудио устройство. Его можно получить так-же с помощью класса AudioManager:
val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager
fun AudioManager.getOutputDevice(): AudioDeviceInfo? {
return getDevices(AudioManager.GET_DEVICES_OUTPUTS)
.toList()
.filter { it.type === AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
.firstOrNull()
}Нестабильность при смене устройств
С помощью свойства preferredDevice у объектов AudioTrack и AudioRecord можно указывать предпочитаемые устройства для ввода и вывода аудио.
Я столкнулся с проблемой: при динамическом изменении этого параметра (во время активной работы) иногда увеличивалась задержка. После полного пересоздания AudioTrack и AudioRecord задержка возвращалась к норме.
Поэтому от динамической смены устройств пришлось отказаться. При изменении параметра создаются новые экземпляры, а старые корректно освобождаются.
Дополнительное усиление сигнала
Даже с подобранными настройками некоторые наушники звучали слишком тихо. Было добавлено дополнительное усиление аудио. Значение усиления выведено на UI.
Простейшая реализация - это умножение сэмплов с последующим обрезанием значений, превысивших максимальное или минимальное.
val readCount = recorder.read(inBuf, 0, inBuf.size)
for (i in 0 until readCount) {
val sample = (inBuf[i] * gain)
.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
.toShort()
outBuf[i] = sample
}
player.write(outBuf, 0, readCount)Этот способ прост, но усиливает и шум. В дальнейшем было реализовано усиление с шумоподавлением и акцентом на речевой диапазон (реализация доступна в репозитории, ссылки - в конце статьи).
Обеспечение стабильной работы
Вся аудиообработка выполняется в отдельном потоке с повышенным приоритетом:
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)Для корректной работы в фоне используется foreground‑сервис.
С особенностями конфигурации на этом все. Далее расскажу о приемах, которые использовались для реализации основного функционала.
Реализация задержки
Для получения задержки между входом и выходом использовался кольцевой буфер (Ring Buffer).
Принцип работы:
входящие PCM‑семплы записываются в буфер;
чтение происходит с фиксированным смещением.
Смещение рассчитывается по частоте дискретизации. Например, при SampleRate = 48000 и требуемой задержке delay=0.1сек смещение в буфере составит:
48000 * 0.1 = 4 800 семплов
Запись аудио в файл
Помимо основной функциональности была реализована запись звука в M4A (AAC) с использованием MediaCodec и MediaMuxer. Энкодер принимает PCM-данные сохраняет их в сжатом виде в файле.
Кодирование достаточно ресурсоёмкое, поэтому вынесено в отдельный поток. Передача данных между потоками реализована через потокобезопасный ArrayBlockingQueue (паттерн producer-consumer).
Схема работы:
Основной аудиопоток захватывает сэмплы и обрабатывает их для воспроизведения
Этот же поток помещает копии сэмплов в ArrayBlockingQueue
Отдельный поток кодирования извлекает данные из очереди и передаёт их в MediaCodec
А если нужна еще более высокая производительность?
В этой статье я описал процесс создания приложения DAF.
Позже был разработан второй проект, реализующий технику терапии речи FAF - Frequency-Adjusted Auditory Feedback. Она основана на изменении тона голоса в реальном времени.
В отличие от DAF, для FAF критична минимальная задержка (в идеале - нулевая). Поэтому в нём использовалась C++ библиотека Oboe, предназначенная для высокопроизводительной работы со звуком в Android. Для изменения тона использовалась C++ библиотека SoundTouch.
Для DAF мы сознательно остались на Java/Kotlin, потому что:
ультранизкая задержка там не критична;
хотелось сравнить разные подходы;
Java‑реализация проще в поддержке и отладке.
Итоги
В этой статье я освятил моменты, в которых у меня возникли трудности при работе с аудио в Android.
Надеюсь в эпоху развитых LLM эта статья будет кому то полезна. Ну или хотя бы послужит хорошим обучающим материалом для очередной версии ChatGPT.
Ссылки на репозитории:
Так-же есть реализация этих двух методик в веб версии
