Play any sound in an Android or iOS app with JUCE (3/3)

Matthieu Regnauld
6 min readMar 13, 2023

--

This article is a follow up of the previous article, that you can find here.

In the first part, we managed to create, code and export our C++ JUCE library for both Android and iOS. In the second part, we managed to implement that library in an Android app and in an iOS app.

Now it’s finally time to play a sound! So let’s get into it.

Music illustration

C++ code

There are some slight differences between Android and iOS, when it comes to includes or header files extensions, for example. But aside from that, the code by itself is exactly the same (aside from the NativeLib file / class).

The audio player

The AudioPlayer class directly interacts with our JUCE library. We will put it in app/src/main/cpp/ for Android, and in Native/Cpp/ for iOS.

I’ll show you here the code for Android and share a link to the iOS equivalent.

Here is what the header looks like:

  • for Android:
#ifndef ANDROID_AUDIOPLAYER_H
#define ANDROID_AUDIOPLAYER_H
#define M_PI 3.14159265358979323846

#include <AudioSamplePlayerListener.h>
#include <AudioSamplePlayer.h>

class AudioPlayer : jad::AudioSamplePlayerListener
{
public:

AudioPlayer();
~AudioPlayer() = default;

void prepareToPlay(int samplesPerBlock, double sampleRate) override;
void releaseResources() override;
float* getNextAudioSamples(int nombreSamples, int nombreCanaux) override;

void play(double frequency);
void stop();

private:

jad::AudioSamplePlayer audioSamplePlayer;
double deviceSampleRate = 0;
bool isPlayerActive = false;
double soundFrequencyHz = 0;
double lastAngle = 0;

};

#endif //ANDROID_AUDIOPLAYER_H

Here is what the class looks like:

  • for Android:
#include "AudioPlayer.h"

AudioPlayer::AudioPlayer()
{
audioSamplePlayer.setListener(this);
}

void AudioPlayer::prepareToPlay(int samplesPerBlock, double sampleRate)
{
// sampleRate : number of frames per second
deviceSampleRate = sampleRate;
}

void AudioPlayer::releaseResources()
{
// release resources here if needed
}

float *AudioPlayer::getNextAudioSamples(int numberOfFrames, int numberOfOutputChannels)
{
auto samples = new float[numberOfFrames * numberOfOutputChannels];
int a;
int b;
double value;
for (a = 0; a < numberOfFrames; a++)
{
for (b = 0; b < numberOfOutputChannels; b++)
{
value = sin(lastAngle + 2 * M_PI * a * soundFrequencyHz / deviceSampleRate);
samples[a * numberOfOutputChannels + b] = (float) value;
}
}
lastAngle = (lastAngle + 2 * M_PI * numberOfFrames * soundFrequencyHz / deviceSampleRate);

// modulo, but with double:
lastAngle = ((lastAngle / (2 * M_PI)) - floor(lastAngle / (2 * M_PI))) * 2 * M_PI;

return samples;
}

void AudioPlayer::play(double frequency)
{
if (!isPlayerActive)
{
isPlayerActive = true;
audioSamplePlayer.setupPlayer();
}
soundFrequencyHz = frequency;
}

void AudioPlayer::stop()
{
isPlayerActive = false;
audioSamplePlayer.closePlayer();
}

Let’s get into more details:

  • The prepareToPlay() function is called automatically as soon as we call setupPlayer() in the play() function. It gives us one useful piece of information: the sample rate, that we store in the deviceSampleRate variable. As a reminder, the sample rate is the number of samples played by the device each second on each channel and is provided by the device itself.
  • The getNextAudioSamples() function is called multiple times right after the prepareToPlay() function. It’s called each time the device needs more samples to play. Remember that the device reads the samples by blocks (and not one by one), so that’s why we return an array of floats.
  • Again, in that getNextAudioSamples() function, we generate a simple sin wave, at the frequency specified as an argument of the play() function and saved in soundFrequencyHz. It gets a bit tricky as you can see here because we generate that wave not only for each channel (that’s why we make use of numberOfOutputChannels), but also by block, so we need to backup where we at at each call of getNextAudioSamples().
  • Even though your app is a mobile app only, never assume that you’ll be in stereo (in other words, numberOfOutputChannels is not always equal to 2). For example, if you play a sound on an iPhone 6S speaker, you’ll be in mono. So be sure to always test this use case.

The native-lib.cpp file (Android)

Now we need to instantiate and call the AudioPlayer class, so we can call it from our Kotlin or Swift code.

Here is what we need to add in the app/src/main/cpp/native-lib.cpp file:

#include <android/log.h>
#include "AudioPlayer.h"

extern "C" JNIEXPORT jlong JNICALL Java_com_juceaudio_juceaudiodemo_NativeManager_createPlayer(
JNIEnv __unused *env, jclass)
{
auto *audioPlayer = new(std::nothrow) AudioPlayer();
auto audioPlayerHandle = reinterpret_cast<jlong>(audioPlayer);
return audioPlayerHandle;
}

extern "C" JNIEXPORT void JNICALL Java_com_juceaudio_juceaudiodemo_NativeManager_deletePlayer(
JNIEnv __unused *env, jclass, jlong audioPlayerHandle)
{
delete reinterpret_cast<AudioPlayer *>(audioPlayerHandle);
}

extern "C" JNIEXPORT void JNICALL Java_com_juceaudio_juceaudiodemo_NativeManager_nativePlay(
JNIEnv __unused *env, jclass, jlong audioPlayerHandle, jdouble frequency)
{
auto *audioPlayer = reinterpret_cast<AudioPlayer *>(audioPlayerHandle);
if (audioPlayer == nullptr)
{
__android_log_print(ANDROID_LOG_ERROR, "TAG", "Invalid audio player handle");
return;
}
audioPlayer->play(frequency);
}

extern "C" JNIEXPORT void JNICALL Java_com_juceaudio_juceaudiodemo_NativeManager_nativeStop(
JNIEnv __unused *env, jclass, jlong audioPlayerHandle)
{
auto *audioPlayer = reinterpret_cast<AudioPlayer *>(audioPlayerHandle);
if (audioPlayer == nullptr)
{
__android_log_print(ANDROID_LOG_ERROR, "TAG", "Invalid audio player handle");
return;
}
audioPlayer->stop();
}

When creating the AudioPlayer, we are dealing with an audioPlayerHandle, which is a long. We will need that value each time we need to get access to the AudioPlayer (we’ll see that later in the NativeManager class).

The NativeLib class and wrapper (iOS)

For iOS, it’s more straightforward. We just need to add an AudioPlayer attribute and 4 new functions:

  • Here is what we need to add in the NativeLib.hpp header file:
#include "AudioPlayer.hpp"

class NativeLib
{
public:
...
void createPlayer();
void deletePlayer();
void nativePlay(double frequency);
void nativeStop();
private:
AudioPlayer* audioPlayer = nullptr;
};
  • And in the NativeLib.cpp class file:
void NativeLib::createPlayer()
{
audioPlayer = new AudioPlayer();
}

void NativeLib::deletePlayer()
{
if (audioPlayer != nullptr)
{
delete audioPlayer;
}
}

void NativeLib::nativePlay(double frequency)
{
if (audioPlayer != nullptr)
{
audioPlayer->play(frequency);
}
}

void NativeLib::nativeStop()
{
if (audioPlayer != nullptr)
{
audioPlayer->stop();
}
}

That being said, before we can interact with the Swift code, remember that we need to call our C++ function from some ugly Objective-C code.

  • Here is what we need to add in the NativeLibWrapper.h header file:
- (void) createPlayer;

- (void) deletePlayer;

- (void) nativePlay: (double) frequency;

- (void) nativeStop;
  • And in the NativeLibWrapper.mm class file:
- (void) createPlayer {
nativeLib.createPlayer();
}

- (void) deletePlayer {
nativeLib.deletePlayer();
}

- (void) nativePlay: (double) frequency {
nativeLib.nativePlay(frequency);
}

- (void) nativeStop {
nativeLib.nativeStop();
}

The NativeManager class

For our last step, let’s see now how to call the code above from our Kotlin or Swift code.

  • Here is what the NativeManager class looks like now in Android:
class NativeManager
{
companion object
{
init
{
try
{
System.loadLibrary("native-lib")
}
catch (_: UnsatisfiedLinkError) {}
}

@JvmStatic private external fun nativeTest(originalText: String): String
@JvmStatic private external fun nativeObjectTest(exampleEntity: ExampleEntity): ExampleEntity
@JvmStatic private external fun createPlayer(): Long
@JvmStatic private external fun deletePlayer(audioPlayerHandle: Long)
@JvmStatic private external fun nativePlay(audioPlayerHandle: Long, frequency: Double)
@JvmStatic private external fun nativeStop(audioPlayerHandle: Long)
}

private var audioPlayerHandle: Long = 0

// those 2 functions come from my article "How to run C++ code from a native Android or iOS app"
fun test(originalText: String): String = nativeTest(originalText)
fun objectTest(exampleEntity: ExampleEntity): ExampleEntity = nativeObjectTest(exampleEntity)

fun setup()
{
if (audioPlayerHandle == 0L)
{
audioPlayerHandle = createPlayer()
}
}

fun dispose()
{
if (audioPlayerHandle != 0L)
{
deletePlayer(audioPlayerHandle)
}
audioPlayerHandle = 0L
}

fun play(frequency: Double)
{
if (audioPlayerHandle != 0L)
{
nativePlay(audioPlayerHandle, frequency)
}
}

fun stop()
{
if (audioPlayerHandle != 0L)
{
nativeStop(audioPlayerHandle)
}
}
}
  • Here are the functions we need to add in the NativeManager class in iOS:
func setup()
{
nativeLibWrapper.createPlayer()
}

func dispose()
{
nativeLibWrapper.deletePlayer()
}

func play(frequency: Double)
{
nativeLibWrapper.nativePlay(frequency)
}

func stop()
{
nativeLibWrapper.nativeStop()
}

Let’s have fun!

Now it’s finally time to try that out!

First, we need to create the audio player, by calling the following (works for both Kotlin and Swift):

nativeManager = NativeManager()
nativeManager.setup() // <- add this

You can now play a sound, with a random frequency between 100 Hz and 1 KHz (because it’s fun):

  • on Android: nativeManager.play(Random.nextDouble(100.0, 1001.0))
  • on iOS: nativeManager.play(frequency: (drand48() * 901) + 100)

You can stop the sound by calling: nativeManager.stop()

Conclusion

And that concludes the 3-part JUCE series! We first learned how to create a C++ JUCE library for Android and iOS (here), then we learned how to implement that library in an Android app and in an iOS app (here), and now we are able to play any sound on Android and iOS.

Also, don’t forget that you can check the JUCEAudioDemo project in my Github here.

And if you have any questions, feel free to ask in the comments!

--

--