Аннотация
В статье дано подробное описание приложения, позволяющего обмениваться текстовыми сообщениями между Android-устройствами с помощью встроенных динамика и микрофона. Дана ссылка на полный исходный код, а для ключевых моментов приведены поясняющие блок-схемы. Приложение представляет практический интерес и готово к применению, работает достаточно стабильно и имеет большой потенциал для дальнейших экспериментов и улучшений. В ходе работы затронуты вопросы формирования звука, фильтрации, реализации скользящей средней, сохранения и оцифровки аналогового сигнала. Материал может быть рекомендован в первую очередь начинающим разработчикам для повторения и закрепления указанных тем.
Введение. Проблема. Цель
Во-первых, большое количество людей регулярно используют рации, потому что это, с одной стороны, удобно, а с другой – иногда и вовсе не имеет альтернатив. Область применения очень широкая: от активного отдыха до чрезвычайных ситуаций. И, как правило, функционал раций покрывает все нужды в своей нише, но, учитывая, что мобильный телефон всегда под рукой, хотелось бы попробовать сочетать утилитарность раций и удобство смартфона. Даже возможность передать просто координаты по радиоканалу в рамках своей туристической группы – это уже интересно, но что, если передавать зашифрованные сообщения или даже графические данные? Для меня, как для туриста со стажем, это представляется по меньшей мере любопытным.
На Хабре широко рассмотрен вопрос передачи данных звуком:
Атака MOSQUITO: протокол скрытной передачи данных между колонками и наушниками в виде ультразвука;
Как уязвимость в Яндекс.Станции вдохновила меня на проект: Музыкальная передача данных;
Quiet.js: библиотека для приёма и передачи данных ультразвуком;
Кто реализовал обмен данными по WebRTC с помощью звука и т. д.
Кроме детальных обзоров на проекты wave-share и ozzilate здесь есть впечатляющие материалы на такие темы, как, например, передача видео под водой звуком или скрытое общение с помощью ультразвука. Однако, несмотря на проработанность вопроса, попытка реализовать идею в готовом программном решении с учетом специфики и ресурсов смартфона, работающего на ОС Android, является новой, а предлагаемый проект закрывает этот пробел.
Передатчик
Послать сигнал, как правило, намного проще, чем распознать этот сигнал и верно его интерпретировать. В данном случае, это именно так. В текущей версии приложения для формирования сигнала выбрана OOK модуляция несущей, как самый простой вариант. Главные недостатки – это низкая помехоустойчивость и сильная зависимость от расстояния, ведь практически всё сводится к отношению амплитуды сигнала к амплитуде шумов. Главный плюс – наглядность и простота реализации.
В приложении два активити, из которых одно реализует передатчик (слева), а другое – приемник (справа):

Передатчик на вход принимает три параметра: частота несущей freq
(Гц), продолжительность одного бита duration
(мс), текст сообщения одной строкой StringBuffer
textbuffer
.
Формирование сигнала происходит следующим образом. Сначала текст сообщения преобразуется в набор бит:
текст → байты → BitSet
→ ArrayList <Boolean> booleanList
Одновременно с этим создается аудио сэмпл для передачи бита, равного единице:
//one bit sine array initialization
for (int i = 1; i < numSamples; ++i)
{
samples[i] = Math.sin(2 * Math.PI * i / (sampleRate/freq)); // Sine wave
buffer[i] = (short) (samples[i] * Short.MAX_VALUE); // Higher amplitude increases volume
}
где
int sampleRate = 44100
– частота дискретизации источника в Гц.
int numSamples = (int) (sampleRate*duration)
– количество значений для передачи одного бита.
Для передачи бита, равного нулю, используется тишина. Поэтому итоговое аудио сигнала формируется так:
//Wave to output. 1-sine, 0-zero
for (int i=0; i<booleanList.size(); i++)
{
if(booleanList.get(i))
{
for (int k=0;k<buffer.length;k++)
{
audioBuffer.add(buffer[k]);
}
}
else
{
for (int k=0;k<buffer.length;k++)
{
audioBuffer.add((short)0);
}
}
}
Таким образом блок-схема передатчика:

Для проигрывания полученного сигнала использован класс AudioTrack
. Сообщение передается слева направо, причем в каждом байте сначала идут младшие биты.
Вот пример уже озвученного и записанного на диктофон сигнала, передающего текстовое сообщение из трех букв латинского алфавита, имеющего следующие параметры: частота несущей – 500 Гц, продолжительность бита – 300 мс:

Приемник
При реализации приемника было необходимо ответить на ряд известных вопросов: как понять, что начался именно сигнал, как отделить сигнал от шума и помех, как обеспечить приемлемый уровень стабильности распознавания.
Известным ответом на часть озвученных вопросов является наличие преамбулы. Для начала была добавлена 10-битная преамбула “1010101010
”. Она дает возможность синхронизировать приемник и понять ему, где сообщение начинается. Размер должен отвечать по меньшей мере двум взаимоисключающим требованиям: быть достаточно долгим, чтобы исключить ложное детектирование и быть исчезающе малым, чтобы не тратить эфирное время. В передатчике преамбула добавляется в начало booleanList
уже ПОСЛЕ того, как текст переведен в биты:
//Add the preambula at the beginning
//1010101010
for (int i=0; i<=9; i++)
{
if(i%2==0)
{
booleanList.add(0,false);
}
else
{
booleanList.add(0,true);
}
}
С учетом преамбулы то же самое сообщение будет выглядеть так:

Но преамбулу, как и все остальное, необходимо еще “разглядеть” среди шума и помех, что было решено с помощью полосового фильтра, реализованного в библиотеке ddf.minim.effects Class BandPass:
floatedValues=new float[file.length];
for (int i=0; i<file.length; i++)
{
floatedValues[i]=file[i];
}
bandpass.process(floatedValues);
В первую очередь необходимо зафиксировать сигнал, для чего используется класс AudioRecord
:
int SAMPPERSEC = 44100;
int channelConfiguration = AudioFormat.CHANNEL_IN_MONO;
int audioEncoding = AudioFormat.ENCODING_PCM_16BIT;
audioRecord = new AudioRecord(
android.media.MediaRecorder.AudioSource.MIC,
SAMPPERSEC,
channelConfiguration,
audioEncoding,
bufflen10
);
где bufflen = AudioRecord.getMinBufferSize(SAMPPERSEC, channelConfiguration, audioEncoding).
Здесь bufflen
вычисляется для каждого смартфона отдельно, потому что есть ограничения на минимальный размер этого буфера, что обусловленно производителем.
Чтобы приложение не зависало во время записи, это делается в отдельном потоке, который выдает только небольшие массивы измерений, обрабатываемых в основном потоке. При этом используется циклический буфер, чтобы данные не стирались, пока идет обработка. Т.е. после получения от AudioRecord'a
массива данных data
, идет их передача в handleMessage Handler'a
основного потока:
// 1. В потоке, записывающем аудио:
private void sendMsg(short[] data)
{
for(Handler handler : handlers)
{
handler.sendMessage(handler.obtainMessage(MSG_DATA, data));
}
}
// 2. В основном потоке:
h = new Handler(Looper.getMainLooper())
{
public void handleMessage(android.os.Message msg)
{
if(msg.what == thread.THREAD_END)
{
thread.interrupt();
}
else
{
saveFile((short[]) msg.obj);
}
}
};
В методе saveFile
происходит основная работа с сигналом. В текущей версии приложения эта работа состоит в фильтрации и усреднении данных, поиске преамбулы и распознавании сигнала. С целью визуализации и отладки происходит также сохранение "сырых" данных в файле obtainedValues.txt
, отфильтрованных данных – в файле filteredValues.txt
и усредненных данных – в processedValues.txt
. Форматирование данных в этих файлах оптимизировано для работы в Matlab, где их можно, например, открыть одновременно:
fileID = fopen('obtainedValues.txt','r');
formatSpec = '%d';
sizeA = [Inf];
A = fscanf(fileID,formatSpec,sizeA);
plot(A);
fclose(fileID);
fileID_2 = fopen('filteredValues.txt','r');
formatSpec = '%f';
sizeA_2 = [Inf];
A_2 = fscanf(fileID_2,formatSpec,sizeA_2);
hold on;
plot(A_2);
fclose(fileID_2);
fileID_3 = fopen('processedValues.txt','r');
formatSpec = '%f';
sizeA_3 = [Inf];
A_3 = fscanf(fileID_3,formatSpec,sizeA_3);
hold on;
plot(A_3);
fclose(fileID_3);
Файлы нужно перетащить из папки Android>data>audiorecorder в папку со скриптом и в результате исполнения данного скрипта получаем наглядное представление записанного сигнала:

Здесь видно, что фильтрация хорошо справляется, и человек теперь без труда увидел бы сигнал невооруженным взглядом, но для дальнейшего распознования нужно формализовать "объяснить" программе, что конкретно является сигналом. По аналогии с детекторным приемником для этого было решено перенести все отрицательные значения наверх заменой знака (или просто заменить отрицательные значения нулями) и усреднить скользящей средней, после чего получается следующее:

После этого можно сравнить скользящую среднюю с некоторым пороговым значением и принять решение о дальнейших действиях, например, предположить, что получена единица или ноль. Если удачно "попасть" в середину меандра, то всё отлично распознается, но есть значительная вероятность выбрать момент, когда высокий уровень сменяется низким. В этом случае никакой синхронизации не получится, и данные будут утеряны. Чтобы избежать этой проблемы, приложение ищет преамбулу одновременно дважды: со сдвигом на половину продолжительности бита и без сдвига. Какой-то из этих двух поисков точно будет синхронизирован с сигналом, после чего уже начнется распознавание:
Hidden text
//1 Looking for preambula. Non-shifted
if(!isPreambula)
{
while( !isBufferEnd1 && !isPreambula )
{
if ((preambulaCounter1 % 2) == 0)
{
if ( sampleIndex1 < file.length)
{
if (movingAverageValues[sampleIndex1] > bitThreshold)
{
preambulaCounter1++;
}
else
{
preambulaCounter1 = 0;
}
}
//если индекс не помещается в этом буфере
else
{
sampleIndex1 = sampleIndex1 - file.length;
isBufferEnd1 = true;
}
}
else
{
if ( sampleIndex1 < file.length)
{
if (movingAverageValues[sampleIndex1] < bitThreshold)
{
preambulaCounter1++;
}
else
{
preambulaCounter1 = 0;
}
}
//если индекс не помещается в этом буфере
else
{
sampleIndex1 = sampleIndex1 - file.length;
isBufferEnd1 = true;
}
}
//IF sampleIndex bigger than buffer size THEN interrupt 'while'
if(isBufferEnd1) break;
//if needed sample in this(current) massive
if ( (sampleIndex1 + numSamples) < file.length)
{
sampleIndex1 = sampleIndex1 + numSamples;
}
//if needed sample is in the next massive
else// ЗДЕСЬ ТОЖЕ КОСЯК
{
numberToEnd = file.length - sampleIndex1;
sampleIndex1 = numSamples - numberToEnd;
isBufferEnd1=true;
}
if (preambulaCounter1 == 9)
{
isPreambula = true;
sampleIndex = sampleIndex1;
isBufferEnd = isBufferEnd1;
//print Preambula
showMessageSB = new StringBuilder();
showMessageSB.append("Preambula has been detected");
messageOutput.setText(showMessageSB.toString());
}
}
isBufferEnd1 = false;
}
//2 Looking for preambula. Shifted numSamples/2 to the right
if( !isPreambula )
{
while( !isBufferEnd2 && !isPreambula )
{
if( (preambulaCounter2 % 2) == 0)
{
if ( sampleIndex2 < file.length)
{
if (movingAverageValues[sampleIndex2] > bitThreshold) {
preambulaCounter2++;
} else {
preambulaCounter2 = 0;
}
}
//если индекс не помещается в этом буфере
else
{
sampleIndex2 = sampleIndex2 - file.length;
isBufferEnd2 = true;
}
}
else
{
if ( sampleIndex2 < file.length)
{
if(movingAverageValues[sampleIndex2] < bitThreshold)
{
preambulaCounter2++;
}
else
{
preambulaCounter2 = 0;
}
}
//если индекс не помещается в этом буфере
else
{
sampleIndex2 = sampleIndex2 - file.length;
isBufferEnd2 = true;
}
}
//IF sampleIndex bigger than buffer size THEN interrupt 'while'
if(isBufferEnd2) break;
//if needed sample in this(current) massive
if((sampleIndex2 + numSamples) < file.length)
{
sampleIndex2 = sampleIndex2 + numSamples;
}
//if needed sample is in the next massive
else
{
numberToEnd = file.length - sampleIndex2;
sampleIndex2 = numSamples - numberToEnd;
isBufferEnd2 = true;
}
if(preambulaCounter2 == 9)
{
isPreambula = true;
sampleIndex = sampleIndex2;
isBufferEnd = isBufferEnd2;
//print Preambula
showMessageSB = new StringBuilder();
showMessageSB.append("Preambula has been detected");
messageOutput.setText(showMessageSB.toString());
}
}
isBufferEnd2 = false;
}
//3 Reading the message
if( isPreambula )
{
while(!isBufferEnd)
{
if ( sampleIndex < file.length )
{
if (movingAverageValues[sampleIndex] > bitThreshold)
{
//showMessageDataSB.append("1");
//messageData.setText(showMessageDataSB.toString());
datum = datum | (1 << (bitIndex1-1));
}
else
{
//showMessageDataSB.append("0");
//messageData.setText(showMessageDataSB.toString());
}
bitIndex1++;
if ( bitIndex1 == 8)
{
showMessageDataSB.append( (char) datum );
messageData.setText(showMessageDataSB.toString());
bitIndex1 = 0;
datum = 0;
}
}
//если индекс не помещается в этом буфере
else
{
sampleIndex = sampleIndex - file.length;
isBufferEnd = true;
}
//IF sampleIndex is bigger than buffer size THEN interrupt 'while'
if (isBufferEnd) break;
//if needed sample in this(current) massive
if( (sampleIndex + numSamples) < file.length)
{
sampleIndex = sampleIndex + numSamples;
}
//if needed sample is in the next massive
else
{
numberToEnd = file.length - sampleIndex;
sampleIndex = numSamples - numberToEnd;
isBufferEnd = true;
}
}
isBufferEnd = false;
}
else
{
showMessageSB = new StringBuilder();
showMessageSB.append(String.valueOf(preambulaCounter1));
showMessageSB.append(String.valueOf(preambulaCounter2));
messageOutput.setText(showMessageSB.toString());
}
isBufferEnd=false;
Большое количество проверок связано с тем, что данные подаются сравнительно маленькими массивами и требуется постоянно ждать следующую порцию и ориентироваться, в каком моменте времени находится интересующий замер. В приемнике сообщение будет выводиться в режиме реального времени, что немного компенсирует невысокую скорость передачи.
Дополнительно, нужно отметить, что передатчик работает без каких-либо предварительных настроек, но приемник для запуска и работы требует разрешений на использование микрофона и работы с хранилищем данных:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
Можно без потери функционала убрать часть, где сохраняются данные. Без неё пропадет возможность удобной разработки и отладки, но сократится одно разрешение и будет значительно сэкономлена память устройства.
Демонстрация
На видео представлена работа текущей версии приложения:
Выводы и планы
Во-первых, достигнута поставленная цель: приложение действительно позволяет расширять функционал рации до передачи текстовых сообщений. Однако нет принципиальных ограничений для передачи, например, картинки, и это планируется сделать в первую очередь. Во-вторых, вопросы вызывает скорость и стабильность распознавания. Требуется провести ряд экспериментов, чтобы набрать статистику для оценки качества функционирования. В-третьих, выбранная для пробы пера модуляция годится лишь при демонстрации. Поэтому в ходе дальнейшей работы планируется попробовать частотную модуляцию и линейно-частотную модуляцию, чтобы добиться лучшей помехоустойчивости и скорости передачи. В-четвертых, примененные алгоритмы детектирования и распознавания не привязаны к каким-либо особенностям именно встроенных динамиков и микрофонов, то есть их можно использовать и с сигналами другой природы.
Ссылка на репозиторий
Полный исходный код и весь проект лежит здесь.