How to Encode Audio Samples to MP3 Using Mp3Encoder and WavParser in Muse

The Muse app converts raw PCM data from WAV files into MP3 streams by using WavParser to extract sample metadata and PCM buffers from an okio.BufferedSource, then feeding those buffers to platform-specific Mp3Encoder implementations that wrap the LAME library to produce MP3 bytes written to an okio.BufferedSink.

In the Kotlin Multiplatform project kkoshin/muse, the audio export pipeline relies on two core components to transform uncompressed audio into compressed MP3 format. This guide explains how WavParser handles PCM sample extraction and how Mp3Encoder processes those samples on both Android and iOS platforms.

Understanding the WAV Parsing Pipeline

The WavParser class, located in [muse/src/commonMain/kotlin/io/github/kkoshin/muse/audio/WavParser.kt](https://github.com/kkoshin/muse/blob/main/muse/src/commonMain/kotlin/io/github/kkoshin/muse/audio/WavParser.kt), is responsible for parsing the WAV file structure and exposing raw audio samples for encoding.

Header Validation and Metadata Extraction

The parser begins by reading the RIFF header and verifying the file is a valid PCM WAV. It parses the fmt chunk to extract critical metadata required for encoding:

  • Sample rate: The frequency in Hz (e.g., 44100)
  • Channels: Mono (1) or Stereo (2)
  • Sample bits: Bit depth (8 or 16)

After parsing the header, WavParser locates the data chunk and records its size, positioning the read pointer at the start of the raw PCM stream.

Reading PCM Samples

WavParser provides two primary methods for consuming audio data:

  • readMono(dst: ShortArray, numSamples: Int): Reads mono PCM samples into a ShortArray
  • readStereo(left: ShortArray, right: ShortArray, numSamples: Int): De-interleaves stereo PCM data into separate left and right channel arrays

Both methods delegate to an internal readSamples function that processes the BufferedSource in chunks of up to 8192 bytes. The parser automatically handles 8-bit and 16-bit PCM formats through decodeSamples8bit and decodeSamples16bit, converting them to signed 16-bit integers required by the LAME encoder.

Encoding Audio to MP3 with Mp3Encoder

The Mp3Encoder interface has platform-specific implementations that wrap the LAME MP3 encoder. Both implementations follow the same pattern: initialize the encoder with metadata from WavParser, then stream PCM data through LAME's encoding functions.

Android Implementation

The Android encoder in [muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/Mp3Encoder.android.kt](https://github.com/kkoshin/muse/blob/main/muse/src/androidMain/kotlin/io/github/kkoshin/muse/audio/Mp3Encoder.android.kt) uses the com.naman14.androidlame library.

The implementation initializes AndroidLame with ID3 tags (artist defaults to "μ's" with the current year), then selects the appropriate encoding path based on channel count:

  • Mono: Calls wavParser.readMono(buffer, CHUNK_SIZE) to fill a ShortArray, then passes it to lame.encode(buffer, buffer, samplesRead, mp3Buffer)
  • Stereo: Calls wavParser.readStereo(leftBuffer, rightBuffer, CHUNK_SIZE) to fill separate arrays, then passes both to lame.encode(leftBuffer, rightBuffer, samplesRead, mp3Buffer)

After processing all samples, lame.flush(mp3Buffer) writes any remaining MP3 frames to the output sink.

iOS Implementation

The iOS encoder in [muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/Mp3Encoder.ios.kt](https://github.com/kkoshin/muse/blob/main/muse/src/iosMain/kotlin/io/github/kkoshin/muse/audio/Mp3Encoder.ios.kt) uses the cocoapods.lame C library directly through Kotlin/Native interop.

The encoder initializes LAME using lame_init(), configures the sample rate and channels with lame_set_in_samplerate() and lame_set_num_channels(), then finalizes parameters with lame_init_params(). The encoding loop mirrors Android's logic but calls lame_encode_buffer() for mono streams or lame_encode_buffer() with interleaved false for stereo streams, passing the ShortArray pointers from WavParser.

End-to-End Audio Export Implementation

The AudioExportPipeline orchestrates the conversion by instantiating WavParser from an input source, selecting the appropriate Mp3Encoder implementation, and managing the BufferedSink for the output file.

Here is a complete example showing how to encode a WAV file to MP3 using these components:

import okio.buffer
import okio.source
import okio.sink
import io.github.kkoshin.muse.audio.WavParser
import io.github.kkoshin.muse.audio.Mp3Encoder
import io.github.kkoshin.muse.audio.Mp3Metadata
import kotlinx.coroutines.runBlocking
import java.io.File

fun encodeWavToMp3(inputWav: File, outputMp3: File) {
    // Initialize the WAV parser with the source file
    val bufferedSource = inputWav.source().buffer()
    val parser = WavParser(bufferedSource)
    
    // Prepare the output sink
    val bufferedSink = outputMp3.sink().buffer()
    
    // Create encoder with ID3 metadata
    val encoder = Mp3Encoder()
    val metadata = Mp3Metadata(
        id3TagArtist = "MuseApp",
        id3TagYear = java.time.Year.now().value.toString()
    )
    
    runBlocking {
        // Stream PCM data from parser to MP3 sink
        encoder.encode(parser, bufferedSink, metadata)
    }
    
    // Ensure all resources are closed
    bufferedSink.close()
    parser.close()
}

For streaming scenarios where you process audio bytes in memory without intermediate files:

fun encodeWavBytesToMp3(wavBytes: ByteArray): ByteArray {
    val source = wavBytes.inputStream().source().buffer()
    val parser = WavParser(source)
    
    // Use a Pipe for streaming output
    val pipe = okio.Pipe()
    val encoder = Mp3Encoder()
    
    runBlocking {
        // Encode in background
        launch {
            encoder.encode(parser, pipe.sink.buffer(), Mp3Metadata("Stream", "2024"))
        }
        
        // Read MP3 bytes as they become available
        pipe.source.readByteArray()
    }
}

Summary

Frequently Asked Questions

What audio formats does WavParser support?

WavParser specifically supports uncompressed PCM WAV files (audio format code 1) with 8-bit or 16-bit sample depths. It validates the RIFF/WAVE header structure and parses the fmt chunk to ensure the data is linear PCM before allowing sample extraction through readMono or readStereo.

How does Mp3Encoder handle different channel configurations?

The encoder detects the channel count from the WavParser metadata and selects the appropriate LAME function. For mono files, it uses writeMonoAudio (Android) or lame_encode_buffer with a single channel pointer (iOS). For stereo, it uses writeStereoAudio (Android) or lame_encode_buffer with separate left/right arrays (iOS), ensuring proper interleaving or de-interleaving as required by the LAME specification.

Can I use Mp3Encoder with sources other than WavParser?

While Mp3Encoder.encode() accepts any WavParser instance, the encoder expects PCM data in 16-bit signed integer format at a consistent sample rate. If you have raw PCM data from another source, you could construct a custom parser that implements the same sample reading interface, though the current implementation is tightly coupled to the WAV parsing logic for metadata extraction.

Where is the LAME library configured in the Muse project?

On Android, the LAME library is included via the com.naman14.androidlame dependency configured in the Android-specific source set. On iOS, it is integrated through the cocoapods.lame CocoaPod, allowing the Kotlin/Native code to import C functions directly via import cocoapods.lame.* in the iOS implementation file.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →