1. Expo Modules (Kotlin Only)
1.1. Module Structure
modules/expo-audio-converter/ ├── android/ # Android native code │ ├── build.gradle # Android build config │ └── src/main/java/expo/modules/audioconverter/ │ └── ExpoAudioConverterModule.kt # Native implementation ├── ios/ # iOS native code (if needed) │ └── ExpoAudioConverterModule.swift ├── src/ │ └── ExpoAudioConverterModule.ts # TypeScript interface ├── index.ts # Public API └── expo-module.config.json # Module configuration
1.1.1. build.gradle Template
build.gradle TemplateThe following build configuration is a boilerplate for android plugin in react-native:
apply plugin: 'com.android.library' group = 'expo.modules.audioconverter' version = '0.6.3' def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") apply from: expoModulesCorePlugin applyKotlinExpoModulesCorePlugin() useCoreDependencies() useExpoPublishing() // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. // Most of the time, you may like to manage the Android SDK versions yourself. def useManagedAndroidSdkVersions = false if (useManagedAndroidSdkVersions) { useDefaultAndroidSdkVersions() } else { buildscript { // Simple helper that allows the root project to override versions declared by this library. ext.safeExtGet = { prop, fallback -> rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback } } project.android { compileSdkVersion safeExtGet("compileSdkVersion", 34) defaultConfig { minSdkVersion safeExtGet("minSdkVersion", 21) targetSdkVersion safeExtGet("targetSdkVersion", 34) } } } android { namespace "expo.modules.audioconverter" defaultConfig { versionCode 1 versionName "0.6.3" } lintOptions { abortOnError false } }
1.1.2. Define Native Module in Kotlin
1package expo.modules.audioconverter 2 3import android.media.MediaCodec 4import android.media.MediaExtractor 5import android.media.MediaFormat 6import expo.modules.kotlin.modules.Module 7import expo.modules.kotlin.modules.ModuleDefinition 8import java.io.File 9import java.io.FileOutputStream 10import java.io.RandomAccessFile 11 12class ExpoAudioConverterModule : Module() { 13 override fun definition() = ModuleDefinition { 14 Name("ExpoAudioConverter") 15 16 AsyncFunction("convertToWav") { inputPath: String, outputPath: String -> 17 try { 18 // Remove file:// prefix if present 19 val cleanInputPath = inputPath.removePrefix("file://") 20 val cleanOutputPath = outputPath.removePrefix("file://") 21 22 val inputFile = File(cleanInputPath) 23 val outputFile = File(cleanOutputPath) 24 25 if (!inputFile.exists()) { 26 throw Error("Input file does not exist: $cleanInputPath") 27 } 28 29 val converter = AudioConverter() 30 converter.convertToWav(inputFile, outputFile) 31 32 if (!outputFile.exists()) { 33 throw Error("Output file was not created: $cleanOutputPath") 34 } 35 36 mapOf( 37 "outputPath" to outputFile.absolutePath 38 ) 39 } catch (e: Exception) { 40 throw Error("Conversion failed: ${e.message}") 41 } 42 } 43 } 44}
-
Note that in line 14 we register the name of the module (the class), in typescript (only within this plugin project) we will call
const theInstance = requireNativeModule<ExpoAudioConverterModule>( "ExpoAudioConverter" )to instantiate the registered instance.
-
Also in
expo-module.config.json(go 〈1.1. Module Structure〉 to see where it is located) we record the module that we want to export:{ "platforms": ["android"], "android": { "modules": ["expo.modules.audioconverter.ExpoAudioConverterModule"] } }On starting the application, Expo will scan for the
ExpoAudioConverterModuleand bridge our typescript code to this kotlin instance.Remark. Note that we provide the full classpath in the json, not the filename.
-
Here
Module,ModuleDefinitionare all provided by the dependencies injected byuseCoreDependencies(). -
Within the scope (trailing closure) of
ModuleDefinition,AsyncFunctionis one of the method provided by the classModuleDefinitionvia function literal (for detail, refer to my article Function Literals with Receiver). -
For synchrouous operations we can implement
Functionin place ofAsyncFunction.
1.1.3. Create TypeScript Interface and Wrapper for the Module
import { NativeModule, requireNativeModule } from "expo" import { Platform } from "react-native" export interface ConvertToWavResult { outputPath: string } declare class ExpoAudioConverterModule extends NativeModule { convertToWav(inputPath: string, outputPath: string): Promise<ConvertToWavResult> } // This call loads the native module object from the JSI. let AudioConverterModule: ExpoAudioConverterModule | null = null try { // Only attempt to load on Android if (Platform.OS === "android") { AudioConverterModule = requireNativeModule<ExpoAudioConverterModule>("ExpoAudioConverter") } } catch (e) { // Module failed to load console.warn("ExpoAudioConverter native module not available") } export async function convertToWav(inputPath: string, outputPath: string): Promise<ConvertToWavResult> { if (!AudioConverterModule) { throw new Error("ExpoAudioConverter native module not available") } return await AudioConverterModule.convertToWav(inputPath, outputPath) }
So basically we don't export the class ExpoAudioConverter, we simply instantiate it in the typescript file and export the execution convertToWav for use.
1.2. Key Expo Modules Concepts
1.2.1. AsyncFunction
- Purpose: Define async native functions callable from JavaScript
- Syntax:
AsyncFunction("name") { param1: Type, param2: Type -> ... } - Returns: Automatically wrapped in a Promise
- Errors: Thrown errors become rejected Promises
// Native AsyncFunction("myFunction") { input: String -> if (input.isEmpty()) throw Error("Input is empty") return "Success" } // JavaScript try { const result = await myFunction("") // Throws error } catch (e) { console.error(e.message) // "Input is empty" }
1.2.2. Type Conversion
Expo automatically converts between JavaScript and native types:
| JavaScript | Kotlin | Swift |
|---|---|---|
string | String | String |
number | Int, Double | Int, Double |
boolean | Boolean | Bool |
object | Map<String, Any?> | [String: Any?] |
array | List<Any?> | [Any?] |
Promise | suspend fun | async |
1.2.3. Module Configuration
// expo-module.config.json { "platforms": ["android", "ios"], "android": { "modules": ["expo.modules.audioconverter.ExpoAudioConverterModule"] }, "ios": { "modules": ["ExpoAudioConverterModule"] } }
2. Full Implementation of AudioConverer (Not Related to Expo Modules)
2.1. Implementation
class AudioConverter { fun convertToWav(inputFile: File, outputFile: File) { val extractor = MediaExtractor() var format: MediaFormat? = null try { extractor.setDataSource(inputFile.absolutePath) // Find the audio track for (i in 0 until extractor.trackCount) { format = extractor.getTrackFormat(i) if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true) { extractor.selectTrack(i) break } } if (format == null) { throw IllegalArgumentException("No audio track found") } // Get audio properties val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE) println("sampleRate: $sampleRate") val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) println("channelCount: $channelCount") // Create decoder val mime = format.getString(MediaFormat.KEY_MIME) val decoder = MediaCodec.createDecoderByType(mime!!) decoder.configure(format, null, null, 0) decoder.start() // Prepare output stream and write WAV header val outputStream = FileOutputStream(outputFile) writeWavHeader(outputStream, 0, sampleRate, channelCount) // Start decoding val info = MediaCodec.BufferInfo() var totalBytes = 0 while (true) { val inputBufferId = decoder.dequeueInputBuffer(10000) if (inputBufferId >= 0) { val inputBuffer = decoder.getInputBuffer(inputBufferId)!! val sampleSize = extractor.readSampleData(inputBuffer, 0) if (sampleSize < 0) { decoder.queueInputBuffer(inputBufferId, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) } else { decoder.queueInputBuffer(inputBufferId, 0, sampleSize, extractor.sampleTime, 0) extractor.advance() } } val outputBufferId = decoder.dequeueOutputBuffer(info, 10000) if (outputBufferId >= 0) { val outputBuffer = decoder.getOutputBuffer(outputBufferId)!! val chunk = ByteArray(info.size) outputBuffer.get(chunk) outputBuffer.clear() if (chunk.isNotEmpty()) { outputStream.write(chunk) totalBytes += chunk.size } decoder.releaseOutputBuffer(outputBufferId, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { break } } } // Clean up outputStream.close() decoder.stop() decoder.release() extractor.release() // Update WAV header with final size updateWavHeader(outputFile, totalBytes) } catch (e: Exception) { e.printStackTrace() } } private fun writeWavHeader( outputStream: FileOutputStream, totalAudioLen: Int, sampleRate: Int, channels: Int ) { val totalDataLen = totalAudioLen + 36 val byteRate = sampleRate * channels * 2 // 16 bits per sample val header = ByteArray(44).apply { // RIFF header this[0] = 'R'.code.toByte() this[1] = 'I'.code.toByte() this[2] = 'F'.code.toByte() this[3] = 'F'.code.toByte() // Total file size - 8 this[4] = (totalDataLen and 0xff).toByte() this[5] = (totalDataLen shr 8 and 0xff).toByte() this[6] = (totalDataLen shr 16 and 0xff).toByte() this[7] = (totalDataLen shr 24 and 0xff).toByte() // WAVE this[8] = 'W'.code.toByte() this[9] = 'A'.code.toByte() this[10] = 'V'.code.toByte() this[11] = 'E'.code.toByte() // fmt chunk this[12] = 'f'.code.toByte() this[13] = 'm'.code.toByte() this[14] = 't'.code.toByte() this[15] = ' '.code.toByte() // fmt chunk size this[16] = 16 this[17] = 0 this[18] = 0 this[19] = 0 // Audio format (PCM) this[20] = 1 this[21] = 0 // Number of channels this[22] = channels.toByte() this[23] = 0 // Sample rate this[24] = (sampleRate and 0xff).toByte() this[25] = (sampleRate shr 8 and 0xff).toByte() this[26] = (sampleRate shr 16 and 0xff).toByte() this[27] = (sampleRate shr 24 and 0xff).toByte() // Byte rate this[28] = (byteRate and 0xff).toByte() this[29] = (byteRate shr 8 and 0xff).toByte() this[30] = (byteRate shr 16 and 0xff).toByte() this[31] = (byteRate shr 24 and 0xff).toByte() // Block align this[32] = (channels * 2).toByte() this[33] = 0 // Bits per sample this[34] = 16 this[35] = 0 // data chunk this[36] = 'd'.code.toByte() this[37] = 'a'.code.toByte() this[38] = 't'.code.toByte() this[39] = 'a'.code.toByte() // Data size this[40] = (totalAudioLen and 0xff).toByte() this[41] = (totalAudioLen shr 8 and 0xff).toByte() this[42] = (totalAudioLen shr 16 and 0xff).toByte() this[43] = (totalAudioLen shr 24 and 0xff).toByte() } outputStream.write(header, 0, 44) } private fun updateWavHeader(file: File, totalAudioLen: Int) { RandomAccessFile(file, "rw").use { raf -> // Update file size raf.seek(4) val totalDataLen = totalAudioLen + 36 raf.write((totalDataLen and 0xff).toByte().toInt()) raf.write((totalDataLen shr 8 and 0xff).toByte().toInt()) raf.write((totalDataLen shr 16 and 0xff).toByte().toInt()) raf.write((totalDataLen shr 24 and 0xff).toByte().toInt()) // Update data size raf.seek(40) raf.write((totalAudioLen and 0xff).toByte().toInt()) raf.write((totalAudioLen shr 8 and 0xff).toByte().toInt()) raf.write((totalAudioLen shr 16 and 0xff).toByte().toInt()) raf.write((totalAudioLen shr 24 and 0xff).toByte().toInt()) } } }
2.2. Code Breakdown and Learning Resources
2.2.1. STEP 1: Extract audio metadata
Reference: https://developer.android.com/reference/android/media/MediaExtractor
val extractor = MediaExtractor() extractor.setDataSource(inputFile.absolutePath) val format = extractor.getTrackFormat(0) // Get audio format info
2.2.2. STEP 2: Create decoder
Reference: https://developer.android.com/reference/android/media/MediaCodec
val decoder = MediaCodec.createDecoderByType(mime) // AAC, MP3, etc. decoder.configure(format, null, null, 0) decoder.start()
2.2.3. STEP 3: Decode loop (Producer-Consumer pattern)
while (true) { // Producer: Feed compressed data val inputBufferId = decoder.dequeueInputBuffer(10000) extractor.readSampleData(inputBuffer, 0) decoder.queueInputBuffer(...) // Consumer: Get decoded PCM val outputBufferId = decoder.dequeueOutputBuffer(info, 10000) outputStream.write(pcmData) }
2.2.4. STEP 4: WAV header
Reference: http://soundfile.sapp.org/doc/WaveFormat/
writeWavHeader(outputStream, totalBytes, sampleRate, channels)











