Skip to content

WavFloatArrayLoader.kt

src/jvmMain/kotlin/afield/audio/engine/WavFloatArrayLoader.kt · 198 lines

package afield.audio.engine

Reads a WAV file into a mono FloatArray: WavFloatArrayLoader.loadWavToFloatArray accepts PCM (8, 16, 24 and 32 bit) and IEEE float (32 and 64 bit) files and keeps the left channel of a stereo file.

The result, WavFloatData, carries the sample rate and can report RMS at a time and apply a filter.

import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.sqrt

class WavFloatData

class WavFloatData(
    val samples: FloatArray,
    val sampleRate: Int
) {

fun getRMSAtTime(timeSeconds: Float, windowSeconds: Float = 0.02f): Float

Calculates the Root Mean Square (RMS) of a chunk of audio at the given time.

@param timeSeconds The start time of the chunk in seconds. @param windowSeconds The duration of the chunk in seconds (default 20ms). @return The RMS value of the chunk, or 0.0f if the time is out of bounds.

    fun getRMSAtTime(timeSeconds: Float, windowSeconds: Float = 0.02f): Float {
        val startIndex = (timeSeconds * sampleRate).toInt()
        val windowSamples = (windowSeconds * sampleRate).toInt()

        if (startIndex < 0 || startIndex >= samples.size) return 0.0f

        val endIndex = minOf(startIndex + windowSamples, samples.size)
        val actualWindowSamples = endIndex - startIndex

        if (actualWindowSamples <= 0) return 0.0f

        var sumSquares = 0.0
        for (i in startIndex until endIndex) {
            val sample = samples[i]
            sumSquares += sample * sample
        }

        return sqrt(sumSquares / actualWindowSamples).toFloat()
    }

fun applyFilter(filter: afield.math.statistics.FloatFilter): WavFloatData

Creates a new WavFloatData by applying a FloatFilter to all samples. This is useful for pre-filtering the entire audio file before playback.

    fun applyFilter(filter: afield.math.statistics.FloatFilter): WavFloatData {
        val newSamples = FloatArray(samples.size)
        for (i in samples.indices) {
            newSamples[i] = filter(samples[i])
        }
        return WavFloatData(newSamples, sampleRate)
    }
}

object WavFloatArrayLoader

object WavFloatArrayLoader {

fun loadWavToFloatArray(path: String): WavFloatData

Loads a WAV file into a FloatArray with values from -1.0 to 1.0. If the WAV file is stereo, it takes only the left channel.

body — 135 lines, from line 63
    fun loadWavToFloatArray(path: String): WavFloatData {
        val file = File(path)
        require(file.exists()) { "WAV file not found: $path" }
        val bytes = file.readBytes()
        val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)

        fun readFourCC(): String {
            val id = ByteArray(4)
            buf.get(id)
            return String(id, Charsets.US_ASCII)
        }

        fun skipChunkPadding(size: Int) {
            if (size % 2 == 1 && buf.remaining() > 0) {
                buf.position(buf.position() + 1)
            }
        }

        val riff = readFourCC()
        require(riff == "RIFF") { "Invalid WAV (missing RIFF header): $path" }
        buf.int // file size
        val wave = readFourCC()
        require(wave == "WAVE") { "Invalid WAV (missing WAVE header): $path" }

        var fmtFound = false
        var dataFound = false

        var audioFormat = 0
        var channels = 0
        var sampleRate = 0
        var bitsPerSample = 0

        var dataOffset = 0
        var dataSize = 0

        while (buf.remaining() >= 8) {
            val chunkId = readFourCC()
            val chunkSize = buf.int
            val chunkStart = buf.position()

            when (chunkId) {
                "fmt " -> {
                    audioFormat = buf.short.toInt() and 0xFFFF
                    channels = buf.short.toInt() and 0xFFFF
                    sampleRate = buf.int
                    buf.int // byteRate
                    buf.short // blockAlign
                    bitsPerSample = buf.short.toInt() and 0xFFFF
                    fmtFound = true
                }
                "data" -> {
                    dataOffset = chunkStart
                    dataSize = chunkSize
                    dataFound = true
                }
            }

            buf.position(chunkStart + chunkSize)
            skipChunkPadding(chunkSize)
            if (fmtFound && dataFound) break
        }

        require(fmtFound) { "Invalid WAV (missing fmt chunk): $path" }
        require(dataFound) { "Invalid WAV (missing data chunk): $path" }
        require(audioFormat == 1 || audioFormat == 3) { "Unsupported WAV format (only PCM and IEEE Float supported): $path" }
        require(bitsPerSample == 8 || bitsPerSample == 16 || bitsPerSample == 24 || bitsPerSample == 32 || bitsPerSample == 64) { "Unsupported WAV bit depth: $bitsPerSample" }
        require(channels == 1 || channels == 2) { "Unsupported WAV channels: $channels" }

        require(dataOffset >= 0 && dataSize >= 0 && dataOffset + dataSize <= bytes.size) {
            "Invalid WAV data chunk bounds: offset=$dataOffset size=$dataSize total=${bytes.size}"
        }

        val raw = bytes.copyOfRange(dataOffset, dataOffset + dataSize)
        val bytesPerSample = bitsPerSample / 8
        val frameSize = channels * bytesPerSample
        val frameCount = raw.size / frameSize

        val floatSamples = FloatArray(frameCount)
        val rawBuf = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)

        fun readPcm24(): Int {
            val b0 = rawBuf.get().toInt() and 0xFF
            val b1 = rawBuf.get().toInt() and 0xFF
            val b2 = rawBuf.get().toInt() and 0xFF
            val value = b0 or (b1 shl 8) or (b2 shl 16)
            return if ((value and 0x800000) != 0) value or -0x1000000 else value
        }

        if (audioFormat == 3 && bitsPerSample == 32) {
            for (i in 0 until frameCount) {
                val left = rawBuf.float
                if (channels == 2) {
                    rawBuf.float // skip right channel
                }
                floatSamples[i] = left
            }
        } else if (audioFormat == 3 && bitsPerSample == 64) {
            for (i in 0 until frameCount) {
                val left = rawBuf.double.toFloat()
                if (channels == 2) {
                    rawBuf.double // skip right channel
                }
                floatSamples[i] = left
            }
        } else if (audioFormat == 1 && bitsPerSample == 16) {
            for (i in 0 until frameCount) {
                val left = rawBuf.short.toInt()
                if (channels == 2) {
                    rawBuf.short // skip right channel
                }
                floatSamples[i] = left / 32768.0f
            }
        } else if (audioFormat == 1 && bitsPerSample == 24) {
            for (i in 0 until frameCount) {
                val left = readPcm24()
                if (channels == 2) {
                    readPcm24() // skip right channel
                }
                floatSamples[i] = left / 8388608.0f
            }
        } else if (audioFormat == 1 && bitsPerSample == 8) {
            for (i in 0 until frameCount) {
                val left = (rawBuf.get().toInt() and 0xFF) - 128
                if (channels == 2) {
                    rawBuf.get() // skip right channel
                }
                floatSamples[i] = left / 128.0f
            }
        } else {
            throw IllegalArgumentException("Unsupported combination of format=$audioFormat and bitsPerSample=$bitsPerSample")
        }

        return WavFloatData(floatSamples, sampleRate)
    }
}