Recording and playing audio with MediaRecorder and MediaPlayer

Creating the project and UI

  1. Create a new Android App targetting API 21 and later named SoundBoard
  2. Configure a view binding named binding for the MainActivity, in the usual way
  3. Change the Layout in activity_main.xml to use a LinearLayout with vertical orientation
  4. Add a ToggleButton control to the layout, with the id of toggleButton
  5. Set the textOff property to a string resource with the word "Playback" and a corresponding resource of the textOn property of "Record"
  6. As the soundboard will allow us to record a number of different sounds, its simpler to progrmatically generate the grid of buttons. Add the following method to the MainActivity class:
    fun drawBoard(width: Int, height: Int){
        val gridLayout = GridLayout(this)
        gridLayout.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        gridLayout.columnCount = width
        for (i in 0 until (width * height)){
            val button = Button(this)
            button.tag = i
            val buttonNumber = i + 1
            button.setText("${buttonNumber}")
            button.gravity = Gravity.CENTER
            //todo: add touch listener
            gridLayout.addView(button)
            
            val layoutParams = GridLayout.LayoutParams()
            layoutParams.columnSpec = GridLayout.spec(i % width,1f)
            layoutParams.rowSpec = GridLayout.spec(i / width,1f)
            button.layoutParams = layoutParams
        }
        binding.layout.addView(gridLayout)
    }
    The GridLayout.spec object defines the weight for each element. Assigning the same weight ensures they are all the same size
  7. Add a call to this method from the onCreate method with values of 3 and 4 for the width and height respectively.
  8. Run the application and verify that you have a toggle button at the top of the UI followed by a grid of 12 buttons
  9. Also, if you are using the emulator, ensure that in the extended controls for the emulator that under 'Microphone', 'Virtual microphone uses host audio input' is enabled, and your Computer's microphone is enabled

Permissions

Starting with Android Marshmallow (6.0), certain types of permissions require explicit permission from the user, from within the app, in addition to being specified in the AndroidManifest.xml file. This includes accessing the microphone and writing to external storage - both of which this app will need to do

  1. Open the AndroidManifest.xml file and add the following permissions inside the <mainfest> element:
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
  2. Next return to the MainActivity class and add the following method to check for permission, and where permission is not granted, request it:
    private fun requestPermissions(){
        val hasRecordPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
        if (!hasRecordPermission){
            val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()){
                    permitted: Boolean ->
                if (!permitted){
                    Toast.makeText(this,"You need to give permissions for the app to work",Toast.LENGTH_LONG).show()
                }
            }
            requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
        }
    }
  3. Call this method from the within onCreate method

Playback and recording audio

The application needs to be able to start and stop recording, as well as start and stop playback. Adding all this functionality within the Activity would make it less reuable, so we will create a class with the responsibility for recording and playback

  1. Add a new Kotlin class called AudioManager
  2. Set the primary constructor for the audio manager so that it has declares a val named context of type Context
  3. Add two properties for a MediaRecorder and MediaPlayer as follows:
    var mediaRecorder : MediaRecorder? = null
    var mediaPlayer : MediaPlayer? = null
  4. When files are saved, we will used an id (corresponding to their position on the soundboard). Add the following method to the AudioManager class to retrieve a file path for a given id as follows:
    
        private fun filePathForId(id: Int) : String {
        return context.getExternalFilesDir(null)!!.absolutePath + "/$id.aac"
        }
    
  5. Next add a method to setup the MediaRecorder object and start recording. Pay attention to the comments in the code which explain what is happening
    fun startRecording(id: Int): Boolean {
        //check the device has a microphone
        if (context.packageManager.hasSystemFeature(PackageManager.FEATURE_MICROPHONE)) {
            
            mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                    MediaRecorder(context)
                } else {
                    @Suppress("DEPRECATION")
                    MediaRecorder()
                }
    
            //specify source of audio (Microphone)
            mediaRecorder?.setAudioSource(MediaRecorder.AudioSource.MIC)
    
            //specify file type and compression format
            mediaRecorder?.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS)
            mediaRecorder?.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
    
            //specify audio sampling rate and encoding bit rate (48kHz and 128kHz respectively)
            mediaRecorder?.setAudioSamplingRate(48000)
            mediaRecorder?.setAudioEncodingBitRate(128000)
    
            //specify where to save
            val fileLocation = filePathForId(id)
            mediaRecorder?.setOutputFile(fileLocation)
    
            //record
            mediaRecorder?.prepare()
            mediaRecorder?.start()
            
            return true
        } else {
            return false
        }
    }
    The MediaRecorder class can use a range for formats and file types, detailed here
  6. Stopping the recording is a little easier, add the following method which will do that:
    fun stopRecording() {
        mediaRecorder?.stop()
        mediaRecorder?.release()
        mediaRecorder = null
    }
  7. To playback the audio, we use the MediaPlayer object, but less configuration is required, add the following methods to start and stop playback:
    fun startPlayback(id: Int): Boolean {
        val path = filePathForId(id)
        if (File(path).exists()) {
            mediaPlayer = MediaPlayer()
            mediaPlayer?.setDataSource(path)
            mediaPlayer?.prepare()
            mediaPlayer?.start()
            return true
        }
        return false
    }
    fun stopPlayback() {
        mediaPlayer?.stop()
        mediaPlayer?.release()
        mediaPlayer = null
    }
  8. Return to the MainActivity and declare an instance of the AudioManager class as follows:
    lateinit var audioManager: AudioManager
  9. Next in the onCreate method, instantiate the audioManager object as follows:
    audioManager = AudioManager(this)

Handling touch events to start and stop recording and playback

The app is designed to work in such a way that holding down on a button will either play or record audio associated with that button. Whether audio is played or recorded will depend on the state of the ToggleButton.

  1. The app will also change the colour of the buttons when they are pressed, so add a method which can colour a view as follows:
    private fun setBackgroundColour(colour: Int, v: View) {
        val filter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            BlendModeColorFilter(colour, BlendMode.DARKEN)
        } else {
            @Suppress("DEPRECATION")
            PorterDuffColorFilter(colour, PorterDuff.Mode.DARKEN)
            //PorterDuff is a class with list of blending + compositing modes, named after the authors of a paper on the subject
        }
        v.background.colorFilter = filter
    }
  2. Add an implementation of the View.OnTouchListener interface as follows:
    val touchListener = View.OnTouchListener { v: View?, event: MotionEvent? -> Boolean
        val id = v?.tag as Int
            if (event?.action == MotionEvent.ACTION_DOWN) {
                if (binding.toggleButton.isChecked){ //recording
                    val isRecording = audioManager.startRecording(id)
                    if (isRecording) {
                        setBackgroundColour(Color.RED, v)
                    } else {
                        Toast.makeText(this,"Unable to start recording",Toast.LENGTH_LONG).show()
                    }
                } else {
                    if (audioManager.startPlayback(id)){
                        setBackgroundColour(Color.GREEN, v)
                    }
                }
                binding.toggleButton.isEnabled = false
                return@OnTouchListener true
            }
            if (event?.action == MotionEvent.ACTION_UP){
                if (toggleButton.isChecked){
                    audioManager.stopRecording()
                } else {
                    audioManager.stopPlayback()
                }
                v.background.clearColorFilter()
                binding.toggleButton.isEnabled = true
                return@OnTouchListener true
            }
         false
    }
  3. Locate the //todo: comment in the drawBoard method, and replace it with the following:
    button.setOnTouchListener(touchListener)
  4. Run the application and make sure it works as expected. Be aware that if you use an emulator the recorded audio may be severely distorted (at the time of writing the documentation states that it does not work). If you are able, test on an Android phone or tablet

A completed version of the project can be found on GitHub

Tasks