В этой статье я хочу поделиться практическим опытом разработки 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).

Схема работы:

  1. Основной аудиопоток захватывает сэмплы и обрабатывает их для воспроизведения

  2. Этот же поток помещает копии сэмплов в ArrayBlockingQueue

  3. Отдельный поток кодирования извлекает данные из очереди и передаёт их в MediaCodec


А если нужна еще более высокая производительность?

В этой статье я описал процесс создания приложения DAF.

Позже был разработан второй проект, реализующий технику терапии речи FAF - Frequency-Adjusted Auditory Feedback. Она основана на изменении тона голоса в реальном времени.

В отличие от DAF, для FAF критична минимальная задержка (в идеале - нулевая). Поэтому в нём использовалась C++ библиотека Oboe, предназначенная для высокопроизводительной работы со звуком в Android. Для изменения тона использовалась C++ библиотека SoundTouch.

Для DAF мы сознательно остались на Java/Kotlin, потому что:

  • ультранизкая задержка там не критична;

  • хотелось сравнить разные подходы;

  • Java‑реализация проще в поддержке и отладке.

Итоги

В этой статье я освятил моменты, в которых у меня возникли трудности при работе с аудио в Android.

Надеюсь в эпоху развитых LLM эта статья  будет кому то полезна. Ну или хотя бы послужит хорошим обучающим материалом для очередной версии ChatGPT.

Ссылки на репозитории:

DAF

FAF

Так-же есть реализация этих двух методик в веб версии

Узнать больше

Подробнее о проекте и описанном методе терапии