Передача цифровых данных по рации с помощью мобильного приложения. Часть 1

Аннотация

В статье дано подробное описание приложения, позволяющего обмениваться текстовыми сообщениями между Android-устройствами с помощью встроенных динамика и микрофона. Дана ссылка на полный исходный код, а для ключевых моментов приведены поясняющие блок-схемы. Приложение представляет практический интерес и готово к применению, работает достаточно стабильно и имеет большой потенциал для дальнейших экспериментов и улучшений. В ходе работы затронуты вопросы формирования звука, фильтрации, реализации скользящей средней, сохранения и оцифровки аналогового сигнала. Материал может быть рекомендован в первую очередь начинающим разработчикам для повторения и закрепления указанных тем.

Введение. Проблема. Цель

Во-первых, большое количество людей регулярно используют рации, потому что это, с одной стороны, удобно, а с другой — иногда и вовсе не имеет альтернатив. Область применения очень широкая: от активного отдыха до чрезвычайных ситуаций. И, как правило, функционал раций покрывает все нужды в своей нише, но, учитывая, что мобильный телефон всегда под рукой, хотелось бы попробовать сочетать утилитарность раций и удобство смартфона. Даже возможность передать просто координаты по радиоканалу в рамках своей туристической группы — это уже интересно, но что, если передавать зашифрованные сообщения или даже графические данные? Для меня, как для туриста со стажем, это представляется по меньшей мере любопытным.

На Хабре широко рассмотрен вопрос передачи данных звуком:

Кроме детальных обзоров на проекты wave-share и ozzilate здесь есть впечатляющие материалы на такие темы, как, например, передача видео под водой звуком или скрытое общение с помощью ультразвука. Однако, несмотря на проработанность вопроса, попытка реализовать идею в готовом программном решении с учетом специфики и ресурсов смартфона, работающего на ОС Android, является новой, а предлагаемый проект закрывает этот пробел.

Передатчик

Послать сигнал, как правило, намного проще, чем распознать этот сигнал и верно его интерпретировать. В данном случае, это именно так. В текущей версии приложения для формирования сигнала выбрана OOK модуляция несущей, как самый простой вариант. Главные недостатки — это низкая помехоустойчивость и сильная зависимость от расстояния, ведь практически всё сводится к отношению амплитуды сигнала к амплитуде шумов. Главный плюс — наглядность и простота реализации.

В приложении два активити, из которых одно реализует передатчик (слева), а другое — приемник (справа):

Внешний вид передатчика (слева) и приемника (справа)Внешний вид передатчика (слева) и приемника (справа)

Передатчик на вход принимает три параметра: частота несущей freq (Гц), продолжительность одного бита duration (мс), текст сообщения одной строкой StringBuffer textbuffer.

Формирование сигнала происходит следующим образом. Сначала текст сообщения преобразуется в набор бит:

текст → байты → BitSetArrayList 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

Таким образом блок-схема передатчика:

Алгоритм формирования сигнала из текстового сообщенияАлгоритм формирования сигнала из текстового сообщения

Для проигрывания полученного сигнала использован класс 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

В первую очередь необходимо зафиксировать сигнал, для чего используется класс 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;

Большое количество проверок связано с тем, что данные подаются сравнительно маленькими массивами и требуется постоянно ждать следующую порцию и ориентироваться, в каком моменте времени находится интересующий замер. В приемнике сообщение будет выводиться в режиме реального времени, что немного компенсирует невысокую скорость передачи.

Дополнительно, нужно отметить, что передатчик работает без каких-либо предварительных настроек, но приемник для запуска и работы требует разрешений на использование микрофона и работы с хранилищем данных:



Можно без потери функционала убрать часть, где сохраняются данные. Без неё пропадет возможность удобной разработки и отладки, но сократится одно разрешение и будет значительно сэкономлена память устройства.

Демонстрация

На видео представлена работа текущей версии приложения:

Выводы и планы

Во-первых, достигнута поставленная цель: приложение действительно позволяет расширять функционал рации до передачи текстовых сообщений. Однако нет принципиальных ограничений для передачи, например, картинки, и это планируется сделать в первую очередь. Во-вторых, вопросы вызывает скорость и стабильность распознавания. Требуется провести ряд экспериментов, чтобы набрать статистику для оценки качества функционирования. В-третьих, выбранная для пробы пера модуляция годится лишь при демонстрации. Поэтому в ходе дальнейшей работы планируется попробовать частотную модуляцию и линейно-частотную модуляцию, чтобы добиться лучшей помехоустойчивости и скорости передачи. В-четвертых, примененные алгоритмы детектирования и распознавания не привязаны к каким-либо особенностям именно встроенных динамиков и микрофонов, то есть их можно использовать и с сигналами другой природы.

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

Полный исходный код и весь проект лежит здесь.

© Habrahabr.ru