Skip to content

WavWriter.kt

src/jvmMain/kotlin/afield/examples/steamaudio/WavWriter.kt · 88 lines

package afield.examples.steamaudio

import java.io.DataOutputStream
import java.io.OutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path

internal object WavWriter

Minimal 16-bit PCM WAV writer, so the demos produce something you can just open and listen to without dragging in an audio library.

internal object WavWriter {

fun write(path: Path, samples: FloatArray, channelCount: Int, samplingRate: Int)

Writes interleaved float samples in -1..1 to a 16-bit PCM WAV file.

@param samples interleaved samples, frames * channels long.

body — 43 lines, from line 21
    fun write(path: Path, samples: FloatArray, channelCount: Int, samplingRate: Int) {
        require(channelCount > 0) { "channelCount must be positive" }
        require(samples.size % channelCount == 0) {
            "sample count ${samples.size} is not a multiple of $channelCount channels"
        }

        val bitsPerSample = 16
        val bytesPerFrame = channelCount * bitsPerSample / 8
        val dataBytes = samples.size * bitsPerSample / 8

        Files.newOutputStream(path).use { raw ->
            DataOutputStream(raw.buffered()).use { out ->
                out.writeAscii("RIFF")
                out.writeIntLE(36 + dataBytes)
                out.writeAscii("WAVE")

                out.writeAscii("fmt ")
                out.writeIntLE(16)
                out.writeShortLE(1)                                  // PCM, uncompressed
                out.writeShortLE(channelCount)
                out.writeIntLE(samplingRate)
                out.writeIntLE(samplingRate * bytesPerFrame)         // byte rate
                out.writeShortLE(bytesPerFrame)                      // block align
                out.writeShortLE(bitsPerSample)

                out.writeAscii("data")
                out.writeIntLE(dataBytes)

                // Convert in chunks so a long render does not need a second full-size buffer.
                val chunk = ByteBuffer.allocate(8192 * 2).order(ByteOrder.LITTLE_ENDIAN)
                for (sample in samples) {
                    if (chunk.remaining() < 2) {
                        out.write(chunk.array(), 0, chunk.position())
                        chunk.clear()
                    }
                    chunk.putShort(toPcm16(sample))
                }
                if (chunk.position() > 0) {
                    out.write(chunk.array(), 0, chunk.position())
                }
            }
        }
    }

Clamps to -1..1 and scales to a signed 16-bit value.

    private fun toPcm16(sample: Float): Short {
        val clamped = sample.coerceIn(-1f, 1f)
        // 32767 rather than 32768 so that +1.0 does not wrap to negative.
        return (clamped * 32767f).toInt().toShort()
    }

    private fun DataOutputStream.writeAscii(text: String) {
        write(text.toByteArray(Charsets.US_ASCII))
    }

    private fun OutputStream.writeIntLE(value: Int) {
        write(value and 0xFF)
        write((value ushr 8) and 0xFF)
        write((value ushr 16) and 0xFF)
        write((value ushr 24) and 0xFF)
    }

    private fun OutputStream.writeShortLE(value: Int) {
        write(value and 0xFF)
        write((value ushr 8) and 0xFF)
    }
}