diff --git a/README.md b/README.md
index a3375a8..b7fecb7 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,8 @@ It does not deal with radio management, modulation, etc, it is up to your modem
- Keep screen ON
- **Codec2**
- Set Codec2 mode/speed from 450 up to 3200 bps
- - Enable/disable loopback test mode
+ - Enable/disable echo/loopback test mode
+ - Enable/disable RX/TX recorder
- **TNC parameters**
- Change default baud rate for USB port
- Set default Bluetooth device for automatic connectivity on startup
@@ -61,6 +62,7 @@ It does not deal with radio management, modulation, etc, it is up to your modem
- Enable/Disable KISS non-real time buffered playback mode
- Enable/Disable KISS extensions for radio module control and signal levels (modem must support them to work correctly!)
- Set radio parameters (frequency, bandwidth, spreading factor, coding rate, power, sync word, crc checksum enable/disable)
+- **Recording player**, simple player, which allows TX/RX recording playback and removal
# Suitable radios and modems
- Tested, works:
diff --git a/codec2talkie/build.gradle b/codec2talkie/build.gradle
index 3b44ab1..68668a3 100644
--- a/codec2talkie/build.gradle
+++ b/codec2talkie/build.gradle
@@ -11,7 +11,7 @@ android {
minSdkVersion 23
targetSdkVersion 30
versionCode 1
- versionName "1.3"
+ versionName "1.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/codec2talkie/src/main/AndroidManifest.xml b/codec2talkie/src/main/AndroidManifest.xml
index e2cbc63..50e679e 100644
--- a/codec2talkie/src/main/AndroidManifest.xml
+++ b/codec2talkie/src/main/AndroidManifest.xml
@@ -21,6 +21,9 @@
+
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java b/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java
index 5223dad..04eaf88 100644
--- a/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/MainActivity.java
@@ -1,8 +1,10 @@
package com.radio.codec2talkie;
+import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
+import androidx.core.view.MenuCompat;
import androidx.preference.PreferenceManager;
import android.Manifest;
@@ -23,7 +25,6 @@ import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
-import android.util.Log;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
@@ -36,9 +37,11 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import android.widget.Toast;
+import com.radio.codec2talkie.audio.AudioProcessor;
import com.radio.codec2talkie.connect.BluetoothConnectActivity;
import com.radio.codec2talkie.connect.SocketHandler;
import com.radio.codec2talkie.protocol.ProtocolFactory;
+import com.radio.codec2talkie.recorder.RecorderActivity;
import com.radio.codec2talkie.settings.PreferenceKeys;
import com.radio.codec2talkie.settings.SettingsActivity;
import com.radio.codec2talkie.tools.RadioTools;
@@ -53,12 +56,11 @@ import java.util.Locale;
public class MainActivity extends AppCompatActivity {
- private static final String TAG = MainActivity.class.getSimpleName();
-
private final static int REQUEST_CONNECT_BT = 1;
private final static int REQUEST_CONNECT_USB = 2;
private final static int REQUEST_PERMISSIONS = 3;
private final static int REQUEST_SETTINGS = 4;
+ private final static int REQUEST_VOICEMAIL = 5;
// S9 level at -93 dBm as per VHF Managers Handbook
private final static int S_METER_S0_VALUE_DB = -153;
@@ -90,6 +92,7 @@ public class MainActivity extends AppCompatActivity {
@SuppressLint("ClickableViewAccessibility")
@Override
protected void onCreate(Bundle savedInstanceState) {
+
super.onCreate(savedInstanceState);
String appName = getResources().getString(R.string.app_name);
@@ -166,8 +169,13 @@ public class MainActivity extends AppCompatActivity {
startActivityForResult(bluetoothConnectIntent, REQUEST_CONNECT_BT);
}
+ protected void startVoicemailActivity() {
+ Intent voicemailIntent = new Intent(this, RecorderActivity.class);
+ startActivityForResult(voicemailIntent, REQUEST_VOICEMAIL);
+ }
+
protected boolean requestPermissions() {
- List permissionsToRequest = new LinkedList();
+ List permissionsToRequest = new LinkedList<>();
for (String permission : _requiredPermissions) {
if (ContextCompat.checkSelfPermission(MainActivity.this, permission) == PackageManager.PERMISSION_DENIED) {
@@ -215,6 +223,7 @@ public class MainActivity extends AppCompatActivity {
@Override
public boolean onCreateOptionsMenu(Menu menu) {
+ MenuCompat.setGroupDividerEnabled(menu, true);
getMenuInflater().inflate(R.menu.main_menu, menu);
return true;
}
@@ -229,6 +238,10 @@ public class MainActivity extends AppCompatActivity {
startActivityForResult(settingsIntent, REQUEST_SETTINGS);
return true;
}
+ if (itemId == R.id.voicemail) {
+ startVoicemailActivity();
+ return true;
+ }
else if (itemId == R.id.exit) {
stopRunning();
return true;
@@ -303,7 +316,7 @@ public class MainActivity extends AppCompatActivity {
};
@Override
- public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_PERMISSIONS) {
boolean allGranted = true;
@@ -427,9 +440,15 @@ public class MainActivity extends AppCompatActivity {
}
speedModeInfo += ", " + protocolType.toString();
+
+ boolean voicemailEnabled = _sharedPreferences.getBoolean(PreferenceKeys.CODEC2_VOICEMAIL, false);
+
+ if (voicemailEnabled) {
+ speedModeInfo += ", " + getString(R.string.recorder_status_label);
+ }
_textCodecMode.setText(speedModeInfo);
- _audioProcessor = new AudioProcessor(transportType, protocolType, codec2ModeId, onAudioProcessorStateChanged, getApplicationContext());
+ _audioProcessor = new AudioProcessor(transportType, protocolType, voicemailEnabled, codec2ModeId, onAudioProcessorStateChanged, getApplicationContext());
_audioProcessor.start();
} catch (IOException e) {
e.printStackTrace();
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java
new file mode 100644
index 0000000..b54d8c1
--- /dev/null
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioPlayer.java
@@ -0,0 +1,172 @@
+package com.radio.codec2talkie.audio;
+
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioTrack;
+import android.os.Handler;
+import android.os.Message;
+
+import com.radio.codec2talkie.MainActivity;
+import com.ustadmobile.codec2.Codec2;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Arrays;
+
+public class AudioPlayer extends Thread {
+ private static final String TAG = MainActivity.class.getSimpleName();
+
+ public static final int PLAYER_STARTED = 1;
+ public static final int PLAYER_PLAYING_FILE = 2;
+ public static final int PLAYER_PLAYED_FILE= 3;
+ public static final int PLAYER_ERROR = 4;
+ public static final int PLAYER_STOPPED = 5;
+
+ private final Handler _onPlayerStateChanged;
+ private final Context _context;
+ private final File[] _files;
+
+ private final int AUDIO_SAMPLE_SIZE = 8000;
+
+ private AudioTrack _systemAudioPlayer;
+ private short[] _playbackAudioBuffer;
+
+ private long _codec2Con;
+ private int _codec2Mode;
+
+ private int _audioBufferSize;
+ private int _codec2FrameSize;
+
+ private int _currentStatus = PLAYER_STOPPED;
+
+ private boolean _stopPlayback = false;
+
+ public AudioPlayer(File[] files, Handler onPlayerStateChanged, Context context) {
+
+ _onPlayerStateChanged = onPlayerStateChanged;
+ _context = context;
+ _files = files;
+ _codec2Con = 0;
+
+ Arrays.sort(_files);
+
+ constructSystemAudioDevices();
+ }
+
+ private void constructSystemAudioDevices() {
+ int _audioPlayerMinBufferSize = AudioTrack.getMinBufferSize(
+ AUDIO_SAMPLE_SIZE,
+ AudioFormat.CHANNEL_OUT_MONO,
+ AudioFormat.ENCODING_PCM_16BIT);
+ _systemAudioPlayer = new AudioTrack.Builder()
+ .setAudioAttributes(new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build())
+ .setAudioFormat(new AudioFormat.Builder()
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setSampleRate(AUDIO_SAMPLE_SIZE)
+ .setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
+ .build())
+ .setTransferMode(AudioTrack.MODE_STREAM)
+ .setBufferSizeInBytes(10 * _audioPlayerMinBufferSize)
+ .build();
+ }
+
+ private void constructCodec2(int codecMode) {
+ if (_codec2Con != 0) {
+ Codec2.destroy(_codec2Con);
+ }
+ _codec2Con = Codec2.create(codecMode);
+
+ _audioBufferSize = Codec2.getSamplesPerFrame(_codec2Con);
+ _codec2FrameSize = Codec2.getBitsSize(_codec2Con); // returns number of bytes
+
+ _playbackAudioBuffer = new short[_audioBufferSize];
+ }
+
+ private void sendStatusUpdate(int newStatus, String fileName) {
+ if (newStatus != _currentStatus) {
+ _currentStatus = newStatus;
+ Message msg = Message.obtain();
+ msg.what = newStatus;
+ msg.obj = fileName;
+
+ _onPlayerStateChanged.sendMessage(msg);
+ }
+ }
+
+ private boolean playFile(File file) {
+ String codec2ModeStr = file.getName().substring(0, 2);
+ int codec2Mode = Integer.parseInt(codec2ModeStr);
+
+ // reconstruct codec2 on mode change
+ if (_codec2Con == 0 || _codec2Mode != codec2Mode) {
+ _codec2Mode = codec2Mode;
+ constructCodec2(codec2Mode);
+ }
+
+ FileInputStream inputStream;
+ try {
+ inputStream = new FileInputStream(file);
+ } catch (FileNotFoundException e) {
+ sendStatusUpdate(PLAYER_ERROR, file.getName());
+ e.printStackTrace();
+ return false;
+ }
+
+ byte[] codec2Buffer = new byte[_codec2FrameSize];
+
+ try {
+ while (inputStream.read(codec2Buffer) == _codec2FrameSize) {
+ if (_stopPlayback) {
+ return false;
+ }
+ Codec2.decode(_codec2Con, _playbackAudioBuffer, codec2Buffer);
+ _systemAudioPlayer.write(_playbackAudioBuffer, 0, _audioBufferSize);
+ }
+ } catch (IOException e) {
+ sendStatusUpdate(PLAYER_ERROR, file.getName());
+ e.printStackTrace();
+ return false;
+ }
+
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ return true;
+ }
+
+ private void play() {
+ _systemAudioPlayer.play();
+ for (File file : _files) {
+ if (file.isDirectory() || !file.getName().endsWith(".c2")) continue;
+ sendStatusUpdate(PLAYER_PLAYING_FILE, file.getName());
+ if (!playFile(file)) {
+ break;
+ }
+ sendStatusUpdate(PLAYER_PLAYED_FILE, file.getName());
+ }
+ _systemAudioPlayer.stop();
+ _systemAudioPlayer.release();
+ if (_codec2Con != 0) {
+ Codec2.destroy(_codec2Con);
+ }
+ }
+
+ @Override
+ public void run() {
+ sendStatusUpdate(PLAYER_STARTED, null);
+ play();
+ sendStatusUpdate(PLAYER_STOPPED, null);
+ }
+
+ public void stopPlayback() {
+ _stopPlayback = true;
+ }
+}
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java
similarity index 96%
rename from codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java
rename to codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java
index 6fa43c2..2b783ef 100644
--- a/codec2talkie/src/main/java/com/radio/codec2talkie/AudioProcessor.java
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/audio/AudioProcessor.java
@@ -1,4 +1,4 @@
-package com.radio.codec2talkie;
+package com.radio.codec2talkie.audio;
import android.content.Context;
import android.media.AudioAttributes;
@@ -11,12 +11,8 @@ import android.os.Looper;
import android.os.Message;
import android.util.Log;
-import java.io.ByteArrayInputStream;
import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.Serializable;
import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
import java.util.Timer;
import java.util.TimerTask;
@@ -46,8 +42,8 @@ public class AudioProcessor extends Thread {
public static final int PROCESSOR_PROCESS = 11;
public static final int PROCESSOR_QUIT = 12;
- private static int AUDIO_MIN_LEVEL = -70;
- private static int AUDIO_MAX_LEVEL = 0;
+ private static final int AUDIO_MIN_LEVEL = -70;
+ private static final int AUDIO_MAX_LEVEL = 0;
private final int AUDIO_SAMPLE_SIZE = 8000;
private final int PROCESS_INTERVAL_MS = 20;
@@ -86,13 +82,14 @@ public class AudioProcessor extends Thread {
private final Context _context;
public AudioProcessor(TransportFactory.TransportType transportType, ProtocolFactory.ProtocolType protocolType,
- int codec2Mode, Handler onPlayerStateChanged, Context context) throws IOException {
+ boolean voicemailEnabled, int codec2Mode,
+ Handler onPlayerStateChanged, Context context) throws IOException {
_onPlayerStateChanged = onPlayerStateChanged;
_context = context;
_transport = TransportFactory.create(transportType);
- _protocol = ProtocolFactory.create(protocolType);
+ _protocol = ProtocolFactory.create(protocolType, codec2Mode, voicemailEnabled);
_processPeriodicTimer = new Timer();
@@ -340,8 +337,6 @@ public class AudioProcessor extends Thread {
// playback
if (_protocol.receive(_protocolReceiveCallback)) {
sendStatusUpdate(PROCESSOR_RECEIVING);
- } else {
- // idling
}
}
}
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java
index 1e8c7e4..b618263 100644
--- a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/Callback.java
@@ -1,7 +1,5 @@
package com.radio.codec2talkie.protocol;
-import java.io.IOException;
-
public abstract class Callback {
abstract protected void onReceiveAudioFrames(byte [] frame);
abstract protected void onReceiveSignalLevel(byte [] rawData);
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java
index 2b85950..e768384 100644
--- a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/ProtocolFactory.java
@@ -8,7 +8,7 @@ public class ProtocolFactory {
KISS_BUFFERED("KISS BUFFERED"),
KISS_PARROT("KISS PARROT");
- private String _name;
+ private final String _name;
ProtocolType(String name) {
_name = name;
@@ -20,17 +20,28 @@ public class ProtocolFactory {
}
};
- public static Protocol create(ProtocolType protocolType) {
+ public static Protocol create(ProtocolType protocolType, int codec2ModeId, boolean voicemailEnabled) {
+ Protocol proto;
switch (protocolType) {
case KISS:
- return new Kiss();
+ proto = new Kiss();
+ break;
case KISS_BUFFERED:
- return new KissBuffered();
+ proto = new KissBuffered();
+ break;
case KISS_PARROT:
- return new KissParrot();
+ proto = new KissParrot();
+ break;
case RAW:
default:
- return new Raw();
+ proto = new Raw();
+ break;
}
+
+ if (voicemailEnabled) {
+ proto = new VoicemailProxy(proto, codec2ModeId);
+ }
+
+ return proto;
}
}
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java
new file mode 100644
index 0000000..70cbe8a
--- /dev/null
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/protocol/VoicemailProxy.java
@@ -0,0 +1,142 @@
+package com.radio.codec2talkie.protocol;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.radio.codec2talkie.MainActivity;
+import com.radio.codec2talkie.tools.StorageTools;
+import com.radio.codec2talkie.transport.Transport;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Timer;
+import java.util.TimerTask;
+
+public class VoicemailProxy implements Protocol {
+
+ private static final String TAG = MainActivity.class.getSimpleName();
+
+ private final int ROTATION_DELAY_MS = 10000;
+
+ Context _context;
+ File _storage;
+ FileOutputStream _activeStream;
+ Timer _fileRotationTimer;
+
+ final Protocol _protocol;
+ final int _codec2ModeId;
+
+ public VoicemailProxy(Protocol protocol, int codec2ModeId) {
+ _protocol = protocol;
+ _codec2ModeId = codec2ModeId;
+ }
+
+ @Override
+ public void initialize(Transport transport, Context context) throws IOException {
+ _context = context;
+ _storage = StorageTools.getStorage(context);
+ _protocol.initialize(transport, context);
+ }
+
+ @Override
+ public void send(byte[] frame) throws IOException {
+ _protocol.send(frame);
+ writeToFile(frame);
+ }
+
+ @Override
+ public boolean receive(Callback callback) throws IOException {
+ return _protocol.receive(new Callback() {
+ @Override
+ protected void onReceiveAudioFrames(byte[] audioFrames) {
+ callback.onReceiveAudioFrames(audioFrames);
+ writeToFile(audioFrames);
+ }
+
+ @Override
+ protected void onReceiveSignalLevel(byte[] rawData) {
+ callback.onReceiveSignalLevel(rawData);
+ }
+ });
+ }
+
+ @Override
+ public void flush() throws IOException {
+ _protocol.flush();
+ }
+
+ private void writeToFile(byte[] rawData) {
+ stopRotationTimer();
+ createStreamIfNotExists();
+ writeToStream(rawData);
+ startRotationTimer();
+ }
+
+ private void writeToStream(byte[] rawData) {
+ try {
+ if (_activeStream != null) {
+ _activeStream.write(rawData);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void createStreamIfNotExists() {
+ if (_activeStream == null) {
+ try {
+ Date date = new Date();
+ File newDirectory = new File(_storage, getNewDirectoryName(date));
+ if (!newDirectory.exists() && !newDirectory.mkdirs()) {
+ Log.e(TAG, "Failed to create directory for voicemails");
+ }
+ File newAudioFile = new File(newDirectory, getNewFileName(date));
+ _activeStream = new FileOutputStream(newAudioFile);
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ private String getNewDirectoryName(Date date) {
+ SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd", Locale.US);
+ return df.format(date);
+ }
+
+ private String getNewFileName(Date date) {
+ SimpleDateFormat tf = new SimpleDateFormat("HHmmss", Locale.ENGLISH);
+ String codec2mode = String.format(Locale.ENGLISH, "%02d", _codec2ModeId);
+ return codec2mode + "_" + tf.format(date) + ".c2";
+ }
+
+ private void startRotationTimer() {
+ _fileRotationTimer = new Timer();
+ _fileRotationTimer.schedule(new TimerTask() {
+ @Override
+ public void run() {
+ if (_activeStream != null) {
+ try {
+ _activeStream.close();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ _activeStream = null;
+ }
+ }, ROTATION_DELAY_MS);
+ }
+
+ private void stopRotationTimer() {
+ try {
+ if (_fileRotationTimer != null) {
+ _fileRotationTimer.cancel();
+ _fileRotationTimer.purge();
+ }
+ } catch (IllegalStateException ignored) {}
+ }
+}
diff --git a/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java b/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java
new file mode 100644
index 0000000..43b667d
--- /dev/null
+++ b/codec2talkie/src/main/java/com/radio/codec2talkie/recorder/RecorderActivity.java
@@ -0,0 +1,221 @@
+package com.radio.codec2talkie.recorder;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.AdapterView;
+import android.widget.ArrayAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.radio.codec2talkie.MainActivity;
+import com.radio.codec2talkie.R;
+import com.radio.codec2talkie.audio.AudioPlayer;
+import com.radio.codec2talkie.tools.StorageTools;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class RecorderActivity extends AppCompatActivity {
+
+ private static final String TAG = MainActivity.class.getSimpleName();
+
+ private File _root;
+ private File _currentDirectory;
+ private ArrayAdapter