androidaudioremote-controlmodulation

android PPM encoder audio library


I need to implement audio PPM (Pulse Position Modulation) on android

Reference: http://en.wikipedia.org/wiki/Pulse-position_modulation

I want to output PPM from the audio output of the smartphone. The final scope is to create a joystick for radiocontrol. but this library may have many future purposes (follow me, lightbridge, etc.etc.). The radios commonly have a PPM output. Transmitters (and pc flight simulators) commonly have PPM input. My scope is to replace the radio with an android device. I wish to know if there is some piece of code ready to use or should i start from scratch?

EDIT: I found some points where to start

1) smartpropplus is a windows software that receives PPM audio and decodes it http://sourceforge.net/p/smartpropoplus/code/HEAD/tree/SPP4/

2) this is how PPM is structured: http://www.aerodesign.de/peter/2000/PCM/PCM_PPM_eng.html#Anker144123

3) this is a easy image that explains how the signal is structured: http://www.aerodesign.de/peter/2000/PCM/frame_ppm.gif

I calculated that sampling the audio signal at 22000Hz will be sufficient to achieve a good resolution for each channel (22 steps for each channel)

Note: if you are interested in receiving ppm audio signal, you need the android ppm decoder class that you can find here: android PPM decoder audio library


Solution

  • I made a working example for ppm encoder class.

    This is how i tested it:

    1)recording the generated sound with a PC i can see on "wavepad editor" the waveform and it corresponds to what we need.

    2) recording the audio output of the smartphone with a pc and analyzing the audio signal with the software "smartpropoplus" and its debug utilities, i can correctly control the PPM channels with my android app.

    3) I connected the phone to a PPM receiver (DJI Lightbridge) but the signal is not correctly received. I suspect that the signal level is not the one expected by the dji device. I will wait your feedback comments, but until that moment, i suspect that i did the best that we can do with android.


    NOTE: if you want to use my complete example, you need to use the file joystickView.jar in order to control the channels with a graphical joypad. this is how to use it:

    1)download the jar file from this link: https://github.com/downloads/zerokol/JoystickView/joystickview.jar

    2) Create a folder called "libs" on the root of your project and place the JAR file into this folder.


    Now you can test my app.

    These are the files of my test app:

    file AndroidManifest.xml

     <?xml version="1.0" encoding="utf-8"?>
     <manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.tr3ma.PPMtestProject"
    android:versionCode="1"
    android:versionName="1.0" >
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    
    <uses-sdk
        android:minSdkVersion="8"
        android:targetSdkVersion="17" />
    
    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        <activity
            android:name="com.tr3ma.PPMtestProject.Test"
            android:label="@string/app_name" 
            android:screenOrientation="landscape" 
         >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
    
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
    
     </manifest>
    

    file activity_test.xml

      <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:baselineAligned="true"
    android:orientation="vertical" >
    <LinearLayout     
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
    >
    <TextView
        android:id="@+id/stick1VerticalLabel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:ems="10"
        android:text="stick1Vertical" />
    
    <TextView
        android:id="@+id/stick1HorizontalLabel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:textColor="#ff0000"
        android:ems="10"
        android:text="stick1Horizontal" />
    
    <TextView
        android:id="@+id/stick2VerticalLabel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:ems="10"
        android:text="stick2Vertical"  />
    <TextView
        android:id="@+id/stick2HorizontalLabel"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:textColor="#ff0000"
        android:layout_weight="1"
        android:ems="10"
        android:text="stick2Horizontal"  />
    </LinearLayout>
    
    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
    >
    
        <com.zerokol.views.JoystickView
            android:id="@+id/joystickViewLeft"
            android:layout_width="wrap_content"
            android:layout_height="fill_parent" />
    
        <com.zerokol.views.JoystickView
            android:id="@+id/joystickViewRight"
            android:layout_width="wrap_content"
            android:layout_height="fill_parent"
            android:layout_gravity="end" />
    
    </LinearLayout>
    
    
    
    
     </LinearLayout>
    

    file Test.java

     package com.tr3ma.PPMtestProject;
    
     import android.os.Bundle;
     import android.widget.TextView;
     import android.app.Activity;
     import android.app.AlertDialog;
     import android.content.DialogInterface;
    
     import com.tr3ma.PPMtestProject.R;
     import com.zerokol.views.JoystickView;
     import com.zerokol.views.JoystickView.OnJoystickMoveListener;
    
     public class Test extends Activity {
    
     PPMEncoder ppmencoder;
    
     private TextView stick1VerticalLabel;
     private TextView stick1HorizontalLabel;
     private TextView stick2VerticalLabel;
     private TextView stick2HorizontalLabel;
     // Importing as others views
     private JoystickView joystickLeft;
     private JoystickView joystickRight;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_test);
    
    
    
        ppmencoder=new PPMEncoder(this);
    
        //start the generation of the signal through the speakers
        int result=ppmencoder.startGeneration();
        if (result!=0){
            //error occoured, something went wrong
            AlertDialog.Builder alert = new AlertDialog.Builder(this);
            alert.setTitle("Error");
            alert.setMessage("Error during audio signal generation. Error Number " + result);
            alert.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int whichButton) {
                }
            });
            alert.show();
    
        }
    
        stick1VerticalLabel = (TextView) findViewById(R.id.stick1VerticalLabel);
        stick1HorizontalLabel = (TextView) findViewById(R.id.stick1HorizontalLabel);
        stick2VerticalLabel = (TextView) findViewById(R.id.stick2VerticalLabel);
        stick2HorizontalLabel = (TextView) findViewById(R.id.stick2HorizontalLabel);
        // referring as others views
        joystickLeft = (JoystickView) findViewById(R.id.joystickViewLeft);
        joystickRight = (JoystickView) findViewById(R.id.joystickViewRight);
    
        // Listener of events, it'll return the angle in graus and power in percents
        // return to the direction of the moviment
        joystickLeft.setOnJoystickMoveListener(new OnJoystickMoveListener() {
             @Override
             public void onValueChanged(int angle, int power, int direction) {
    
                 //scompose the vector
                 float stickVertical=(float) Math.sin((Math.PI/180) * angle)*power; //values between +100 and -100
                 stickVertical=stickVertical+(float)100; //values between 0 and 200
                 stickVertical=(float)stickVertical*(float)((float)255/(float)200); //values between 0 and 255
    
    
                 float stickHorizontal=(float) Math.cos((Math.PI/180) * angle)*power; //values between +100 and -100
                 stickHorizontal=stickHorizontal+(float)100; //values between 0 and 200
                 stickHorizontal=stickHorizontal*(float)((float)255/(float)200); //values between 0 and 255
    
                 stick1VerticalLabel.setText("channel1:" + String.valueOf(stickVertical));
                 stick1HorizontalLabel.setText("channel2:" + String.valueOf(stickHorizontal));
    
                 ppmencoder.setChannelValue(1, stickVertical);
                 ppmencoder.setChannelValue(2, stickHorizontal);
    
    
             }
        }, JoystickView.DEFAULT_LOOP_INTERVAL);
    
        joystickRight.setOnJoystickMoveListener(new OnJoystickMoveListener() {
            @Override
            public void onValueChanged(int angle, int power, int direction) {
    
             //scompose the vector
                //scompose the vector
             float stickVertical=(float) Math.sin((Math.PI/180) * angle)*power; //values between +100 and -100
             stickVertical=stickVertical+(float)100; //values between 0 and 200
             stickVertical=(float)stickVertical*(float)((float)255/(float)200); //values between 0 and 255
    
    
             float stickHorizontal=(float) Math.cos((Math.PI/180) * angle)*power; //values between +100 and -100
             stickHorizontal=stickHorizontal+(float)100; //values between 0 and 200
             stickHorizontal=stickHorizontal*(float)((float)255/(float)200); //values between 0 and 255
    
             stick2VerticalLabel.setText("channel3:" + String.valueOf(stickVertical));
             stick2HorizontalLabel.setText("channel4:" + String.valueOf(stickHorizontal));
    
             ppmencoder.setChannelValue(3, stickVertical);
             ppmencoder.setChannelValue(4, stickHorizontal);
    
    
            }
       }, JoystickView.DEFAULT_LOOP_INTERVAL);
    
    
    }
    
    @Override
    protected void onDestroy() {
        super.onDestroy();
        int result=ppmencoder.stopGeneration();
        if (result!=0){
            AlertDialog.Builder alert = new AlertDialog.Builder(this);
            alert.setTitle("Error");
            alert.setMessage("Error while stopping the audio generation. Error number " + result);
            alert.setPositiveButton("Ok", new DialogInterface.OnClickListener() {
                public void onClick(DialogInterface dialog, int whichButton) {
                }
            });
            alert.show();
        }
    }
     }
    

    file PPMEncoder.java (this is the class requested on the original question)

     package com.tr3ma.PPMtestProject;
    
     import java.util.ArrayList;
    
     import android.content.Context;
     import android.media.AudioFormat;
     import android.media.AudioManager;
     import android.media.AudioTrack;
     import android.os.AsyncTask;
    
     public class PPMEncoder
     {
    public int SAMPLE_RATE = 44100;
    public int ppmFrameBufferSize = (int)(SAMPLE_RATE * 0.0225); // 22KHz * 22,5ms that it is the duration of a frame ppm
    public int audioBufferSize;
    
    private ArrayList<Float> channelValues;
    
    AudioManager audioManager;
    StreamPPMSignalTask streamPPMSignalTask;
    
    private boolean started;
    
    public PPMEncoder(Context context)
    {
        audioManager = (AudioManager)context.getSystemService(Context.AUDIO_SERVICE);
    
        //set volume to max
        //audioManager=(AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
        int tmpVol = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC);    
        audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, tmpVol, 0);
    
    
    
        channelValues = new ArrayList<Float>(8);
        for (int i = 0; i < 8; i++) {
            channelValues.add((float)0.68181818);
        }
    }
    
    public int startGeneration()
    {
        try {
    
    
            audioBufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE,
                    AudioFormat.CHANNEL_OUT_MONO,
                    AudioFormat.ENCODING_PCM_16BIT)*2;
    
            if (audioBufferSize<=0 ) return -2;
    
            started = true;
    
            streamPPMSignalTask = new StreamPPMSignalTask();
            streamPPMSignalTask.execute();
            return 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }
    
    public int stopGeneration()
    {
        try {
            started = false;
    
            streamPPMSignalTask.cancel(true);
            streamPPMSignalTask = null;
            return 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }
    
    
    private int timeToSamples(float time)
    {
        //time is expressed in milliseconds
        return (int)Math.round(time * 0.001 * SAMPLE_RATE);
    }
    
    public void setChannelValue(int channel, float value)
    {
        channelValues.set(channel - 1, (float)0.68181818+(float)1.0 * ((float)value/(float)255));
    }
    
    public int setSamplingRate(int freq) {
        //we can change the sampling frequency in case the default one is not supported
        try {
            SAMPLE_RATE=freq;
    
            ppmFrameBufferSize = (int)(SAMPLE_RATE* 0.0225); // 22KHz * 22,5ms
    
            audioBufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE,
                    AudioFormat.CHANNEL_OUT_MONO,
                    AudioFormat.ENCODING_PCM_16BIT) * 2;
    
            if (audioBufferSize<=0 ) return -2;
    
            started=false;
            stopGeneration();
            startGeneration();
    
            //frame=new byte[streamBufferSize];
            return 0;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return -1;
    }
    
    
    public class StreamPPMSignalTask extends AsyncTask<Void, Double, Void>
    {
        @Override
        protected Void doInBackground(Void... arg0) {
            AudioTrack ppmTrack = new AudioTrack(AudioManager.STREAM_MUSIC, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO,
                    AudioFormat.ENCODING_PCM_16BIT, audioBufferSize, AudioTrack.MODE_STREAM);
    
            //set volume of audioplayer to max
            ppmTrack.setStereoVolume((float) 1.0, (float) 1.0);
    
            if (ppmTrack.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) {
                ppmTrack.play();
            }
    
            //feed the speakers with our audio, by continuously send the PPM frame
            int tempBound;
            while (started) {
                try {
                    short[] frame = new short[ppmFrameBufferSize];
    
                    int i = 0;
                    tempBound = i + timeToSamples((float)0.3);
                    for (;i < tempBound; i += 1) {
                        frame[i] = Short.MIN_VALUE;
                    }
    
                    for (int channel = 0; channel < 8; channel++) {
                        tempBound = i + timeToSamples(channelValues.get(channel));
                        for (;i < tempBound; i += 1) {
                            frame[i] = Short.MAX_VALUE;
                        }
    
                        tempBound= i + timeToSamples((float)0.3);
                        for (;i < tempBound; i += 1) {
                            frame[i] = Short.MIN_VALUE;
                        }
                    }
    
                    for (;i < frame.length; i += 1) {
                        frame[i] = Short.MAX_VALUE;
                    }
    
                    //send the frame
                    ppmTrack.write(frame, 0, frame.length);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            return null;
        }
    }
     }
    

    End notes:

    1) as you can see the 2 joysticks moves only 4 channels, but it is clear that you can add another 2 joystick controls on the activity in order to move all 8 channels.

    2) credit goes to this site where you can see how it is made the joystickControl http://www.zerokol.com/2012/03/joystickview-custom-android-view-to.html in case you want to customize it. I wish to do it but i had no time. today i spent the entire day to write this post.