GRAndWalkthroughCP
Building a GNU Radio for Android App with ControlPort
We use ControlPort to communicate commands and control information between the Java interface of the application and the running GNU Radio flowgraph. This walk-through goes over how to use the GrControlPort Android library to allow us to set up the ControlPort connection.
We will use a multiply_const_ff block to change the amplitude of a sine wave and output this to the opensl_sink to hear the tone. This flowgraph simply looks like:
sig_source_f -> multiply_const_ff -> opensl_sink.
This walk-through builds the project GrTemplateCP that can be clone from the linked github repo.
Get GrControlPort
We first need to get the ControlPort Android library, GrControlPort:
git clone https://github.com/trondeau/GrControlPort.git
Create a New Project in Android Studio
In Android Studio, got to File > New> New Project
I named this GrTemplateCP and made it a Phone and Tablet app at API 21; starting with an Empty Activity that I named MainActivity.
We need to perform most of the same setup tasks as we did in the first GRAndWalkthrough tutorial. I'll highlight the big parts here.
Oh, and if your MainActivity class extends "AppCompatActivity", change this to "Activity" or other things seem to screw up. I really don't understand Android and Android Studio.
Edit AndroidManifest.xml
Add permission flags for reading and writing to the external storage and Internet permissions. We aren't dealing with USB hardware here, so we can skip the intent filter and USB access.
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.INTERNET"/>
JNI and Flowgraph Creation
Create the jni directory and populate it with the GrAndroid.mk, Android.mk, Application.mk, fg.cpp, and settmp.cpp files from GrTemplate, the same as in GRAndWalkthrough. We will use the fg.cpp file name here, too, so nothing should need to be changed in the makefiles.
We're copying over the same fg.cpp and settmp.cpp files we had before because it lays out the basic code setup we need. However, we'll need to change the project namespace from "grtemplate" to "grtemplatecp". We're using the same MainActivity name of the main class, so we don't have to change that. The main changes required here are to set up the flowgraph. So in the section where we're including the header files, we now need:
#include <gnuradio/top_block.h> #include <gnuradio/analog/sig_source_f.h> #include <gnuradio/blocks/multiply_const_ff.h> #include <grand/opensl_sink.h>
Then, in the FgInit function, we need to change our block creation and top block connections. It should look like this:
JNIEXPORT void JNICALL
Java_org_gnuradio_grtemplatecp_MainActivity_FgInit(JNIEnv* env,
jobject thiz)
{
GR_INFO("fg", "FgInit Called");
float samp_rate = 48e3; // 48 kHz
// Declare our GNU Radio blocks
gr::analog::sig_source_f::sptr src;
gr::blocks::multiply_const_ff::sptr mult;
gr::grand::opensl_sink::sptr snk;
// Construct the objects for every block in the flowgraph
tb = gr::make_top_block("fg");
src = gr::analog::sig_source_f::make(samp_rate, gr::analog::GR_SIN_WAVE,
400, 1.0, 0.0);
mult = gr::blocks::multiply_const_ff::make(0.0);
snk = gr::grand::opensl_sink::make(int(samp_rate));
// Connect up the flowgraph
tb->connect(src, 0, mult, 0);
tb->connect(mult, 0, snk, 0);
}
Configure Project
First, go to the command line and do the project update step:
cd GrTemplateCP/app/src/main android update project -p . -s -t android-21
Now let's bring in the GrControlPort as a library. Go to New -> Import Module. Point the source directory to where ever you checked out GrControlPort. Make sure you only have the "Import" checked for the "library" source location. Rename the "Module name" to ":controlport" to help us keep it properly identified. When this is imported, you will not see a "controlport" directory added to the root of your GrTemplateCP project.
Then, right click on the "app" in the Android project view and go the "Dependencies" tab. Click the + sign and click "Module Dependency" and then OK on ":controlport" to properly link the app against the library.
Now we need to update the local.properties to point the NDK:
sdk.dir=/opt/android ndk.dir=/opt/ndk
And the gradle.build script for the app to run the NDK steps. IMPORTANT: This is different than what we did in GRAndWalkthrough. Because we'll be pulling in GrControlPort library, that library pulls in some of it's own built binaries and causes a local conflict. It's very likely that I'm doing something wrong with handling the Android Library concept or it could be a basic flaw in the NDK support between multiple projects, but until that issue is worked out, we handle this problem ourselves by adding the ndkCleanup task. We also add a packagingOptions section to allow gradle to ignore certain duplicated text files. Your gradle.build should now look like the following.
apply plugin: 'com.android.application' android { compileSdkVersion 21 buildToolsVersion "23.0.2" // SETUP TO USE OUR OWN Android.mk FILE sourceSets.main { jniLibs.srcDirs = ['src/main/libs'] jni.srcDirs = [] //disable automatic ndk-build call with auto-generated Android.mk } // call regular ndk-build against our Android.mk task ndkBuild(type: Exec) { commandLine 'ndk-build', '-C', file('src/main/jni').absolutePath } task ndkCleanup(type: Exec) { mustRunAfter 'ndkBuild' commandLine 'rm', '-f', file('src/main/libs/armeabi-v7a/libgnustl_shared.so').absolutePath } tasks.withType(JavaCompile) { compileTask -> compileTask.dependsOn ndkBuild } tasks.withType(JavaCompile) { compileTask -> compileTask.dependsOn ndkCleanup } defaultConfig { applicationId "org.gnuradio.grtemplatecp" minSdkVersion 21 targetSdkVersion 23 versionCode 1 versionName "1.0" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } packagingOptions { exclude 'META-INF/LICENSE.txt' exclude 'META-INF/NOTICE.txt' exclude 'META-INF/LICENSE' exclude 'META-INF/NOTICE' exclude 'lib/armeabi-v7a/*' } } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) testCompile 'junit:junit:4.12' compile 'com.android.support:appcompat-v7:23.2.1' compile 'com.android.support:design:23.2.1' compile project(':controlport') }
As an aside, you might need to open up the project-level build.gradle file and adjust the classpath to "com.android.tools.build:gradle:1.5.0".
At this point, the project should compile, though we haven't done anything about the project's main activity, yet.
Construct the Activity
We'll build the activity first to just make sure we can build and launch the activity, but we won't add any controls just yet, so we won't be able to change the coefficient in our multiply_const_ff block, yet. To do this, we'll just add the SetTMP, FgInit, and FgStart functions to MainActivty.java and call them, in this order, in OnCreate, like this:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SetTMP(getCacheDir().getAbsolutePath());
FgInit();
FgStart();
}
public native void SetTMP(String tmpname);
public native void FgInit();
public native void FgStart();
static {
System.loadLibrary("fg");
}
}
When we run this application, logcat will print out something like:
04-21 14:01:50.657 23535-23535/org.gnuradio.grtemplatecp I/fg: FgInit Called 04-21 14:01:50.658 23535-23535/org.gnuradio.grtemplatecp V/VOLK: Using Volk machine: neon_softfp 04-21 14:01:50.830 23535-23535/org.gnuradio.grtemplatecp I/fg: FgStart Called 04-21 14:01:51.034 23535-23535/org.gnuradio.grtemplatecp I/thrift_application_base: Apache Thrift: -h localhost -p 9090 04-21 14:01:51.035 23535-23535/org.gnuradio.grtemplatecp I/gr_log: gr::mvcircbuf_mmap_tmpfile: opened: 31 04-21 14:01:51.035 23535-23535/org.gnuradio.grtemplatecp I/gr_log: gr::mvcircbuf_mmap_tmpfile: opened: 31 04-21 14:01:51.045 23535-23584/org.gnuradio.grtemplatecp V/VOLK: volk_32f_s32f_multiply_32f_a --> u_neon 04-21 14:01:51.045 23535-23584/org.gnuradio.grtemplatecp V/VOLK: volk_32f_s32f_multiply_32f_u --> u_neon 04-21 14:01:51.048 23535-23585/org.gnuradio.grtemplatecp V/VOLK: volk_32f_s32f_convert_16i_a --> a_generic 04-21 14:01:51.048 23535-23585/org.gnuradio.grtemplatecp V/VOLK: volk_32f_s32f_convert_16i_u --> generic
Notice that the app prints out information about the ControlPort connection point, which is localhost on port 9090. We set up the thrift.conf file in GRAndWalkthrough with the port number used here. We can remember that or even query the /sdcard/.gnuradio/thrift.conf file for this information from our Java app. However we get that information, we need it for the next stage, which will create a network runner thread to send commands to the flowgraph over ControlPort.
Create a Runnable Network Class
ControlPort operates over TCP/IP (in the current use of Thrift, at least), and Android Activities cannot make network calls from their main threads. Instead, we'll create a Runnable subclass as a private class of our activity and use this to actually send messages. The data is actually passed to ControlPort through a class that extends the Handler class.
It is quite likely that there is a much easier way to manage the network connections over ControlPort.
Below, I show what the handler class looks like. This handler is specifically created to call the setKnobs ControlPort interface to pass a new set of knobs to the flowgraph.
private static class SetKnobsHandler extends Handler {
private RPCConnection mConnection;
public SetKnobsHandler(RPCConnection conn) {
super();
mConnection = conn;
}
public void handleMessage(Message m) {
Bundle b = m.getData();
if(b != null) {
HashMap<String, RPCConnection.KnobInfo> k =
(HashMap<String, RPCConnection.KnobInfo>) b.getSerializable("Knobs");
Log.d("MainActivity", "Set Knobs: " + k);
if((k != null) && (!k.isEmpty())) {
mConnection.setKnobs(k);
}
}
}
}
The Runnable class that manages the life cycle of the network thread is next:
public class RunNetworkThread implements Runnable {
private RPCConnection mConnection;
private String mHost;
private Integer mPort;
private Boolean mConnected;
private Handler mHandler;
RunNetworkThread(String host, Integer port) {
this.mHost = host;
this.mPort = port;
this.mConnected = false;
}
public void run() {
if(!mConnected) {
mConnection = new RPCConnectionThrift(mHost, mPort);
mConnected = true;
}
Looper.prepare();
mHandler = new SetKnobsHandler(mConnection);
Looper.loop();
}
public RPCConnection getConnection() {
if(mConnection == null) {
throw new IllegalStateException("connection not established");
}
return mConnection;
}
public Handler getHandler() {
return mHandler;
}
}
I then create a function designed to pass the knob information to the handler of the runnable thread:
private void postSetKnobMessage(HashMap<String, RPCConnection.KnobInfo> knobs) {
Handler h = mControlPortThread.getHandler();
Bundle b = new Bundle();
b.putSerializable("Knobs", knobs);
Message m = h.obtainMessage();
m.setData(b);
h.sendMessage(m);
}
Here, I am taking a String:KnobInfo map that we'll create in our activity, bundles it up into a serialized object called "Knobs" and then passes this as a message to the handler class to actually send this message on to ControlPort. Let's then set up the thread and ControlPort connection management in our OnCreate function of MainActivity. We'll need a public object for the RunNetworkThread class, since we directly refer to it in our postSetKnobMessage function above. Below is the first part of our new OnCreate function, just before we actually try to use the network thread. It's important that the RunNetworkThread is created after FgStart is called to make sure that the ControlPort endpoint already exists and is waiting for a connection.
public RunNetworkThread mControlPortThread;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
SetTMP(getCacheDir().getAbsolutePath());
FgInit();
FgStart();
// Make the ControlPort connection in the network thread
mControlPortThread = new RunNetworkThread("localhost", 9090);
Executor executor = Executors.newSingleThreadExecutor();
executor.execute(mControlPortThread);
// Wait here until we have confirmation that the connection succeeded.
while(true) {
try {
mControlPortThread.getConnection();
break;
} catch (IllegalStateException e0) {
try {
Thread.sleep(250);
} catch (InterruptedException e1) {
Thread.currentThread().interrupt();
}
}
}
....
Now, we have to format the KnobInfo to send the right knob information through ControlPort. We have to create the KnobInfo object, which is a class that we get from the GrControlPort library. We need to know the interface name of the knob we are trying to set. In a real application, we would use ControlPort to get the information from the flowgraph. But for our purposes, we just know that this is "multiply_const_ff0::coefficient". We then need a new value to pass as well as the data type of the data. Let's just initialize the value of the multiplier to 0.5, so that's easy to plug in here. The data type must be one of the BaseType's (a class exported from GrControlPort). In this case, it's BaseType.DOUBLE.
final String mult_knob_name = "multiply_const_ff0::coefficient";
RPCConnection.KnobInfo k =
new RPCConnection.KnobInfo(mult_knob_name, 0.5,
BaseTypes.DOUBLE);
We've packaged up the knob as a KnobInfo object, but our ControlPort endpoint takes a HashMap of these KnobInfo objects because it allows us to bundle together many ControlPort knobs. Here's how we do that and pass this to our post function:
HashMap<String, RPCConnection.KnobInfo> map = new HashMap<>();
map.put(mult_knob_name, k);
postSetKnobMessage(map);
Now, when we run this application on our device, the flowgraph creates the multiply_const_ff block with a coefficient of 0, but through ControlPort, we immediately change this value to 0.5, so we should now here our 400 Hz tone coming through the speaker.
NOTE: Notice that the signal clicks pretty frequently. This is because the call to post data to an OpenSLES device is blocking, so during work, we pass a buffer of samples to the device, wait for it to process, then exit work, get more samples, and re-enter work to pass the new buffer on. The clicks correspond to the time between exiting and re-entering work. We need to redo this work function to properly double-buffer the samples so we can keep a continuous stream of samples going to the audio device.
Adding a Slider
The final step in this example is to add a slider to allow us to control the amplitude easily when the application is running. This is as easy as adding a SeekBar into the main activity. Go to layouts and open activity_main.xml. Find the SeekBar and place it on the app under the text that reads "Hello World" (which I have changed to "Change Amplitude"). The SeekBar has an ID of "seekBar" that we'll use in the app to get access to it.
The following code in OnCreate (make sure this is after the "setContentView(R.layout.activity_main);" line so the seekBar has been created) will set up the seekBar to have 100 steps between 0 and 1. Whenever the seek bar is changed, the listener code is fired, which gets the current state of the bar ("progress") that we convert to the new coefficient for the ControlPort knob for the multiplier block.
// Create the seekBar to move update the amplitude
SeekBar ampSeekBar = (SeekBar) findViewById(R.id.seekBar);
ampSeekBar.setMax(100); // max value -> 1.0
ampSeekBar.setProgress(50); // match 0.5 starting value
ampSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
Double amp = progress / 100.0; // rescale by 100
RPCConnection.KnobInfo _k =
new RPCConnection.KnobInfo(mult_knob_name, amp,
BaseTypes.DOUBLE);
HashMap<String, RPCConnection.KnobInfo> _map = new HashMap<>();
_map.put(mult_knob_name, _k);
postSetKnobMessage(_map);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
}
});