WavFloatArrayWriter.kt
src/jvmMain/kotlin/afield/audio/engine/WavFloatArrayWriter.kt · 152 lines
package afield.audio.engine
Writes float signals as 16-bit PCM WAV: WavFloatArrayWriter writes mono or stereo files, optionally normalizing to a peak, and encodeMono16 returns the WAV bytes for display(...) to play in a box.
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.roundToInt
object WavFloatArrayWriter
object WavFloatArrayWriter {
fun writeMono16(path: String, samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null)
Writes mono 16-bit PCM WAV from normalized floats.
fun writeMono16(path: String, samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null) {
writeMono16(File(path), samples, sampleRate, normalizePeak)
}
fun writeMono16(file: File, samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null)
fun writeMono16(file: File, samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null) {
require(sampleRate > 0) { "sampleRate must be > 0" }
file.parentFile?.mkdirs()
file.writeBytes(encodeMono16(samples, sampleRate, normalizePeak))
}
fun encodeMono16(samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null): ByteArray
Encodes mono 16-bit PCM WAV from normalized floats and returns the complete .wav bytes.
Handy for display(...): the value visualizer renders a WAV byte array as an inline audio
player, so display(WavFloatArrayWriter.encodeMono16(signal, 8000)) plays a signal in a box.
fun encodeMono16(samples: FloatArray, sampleRate: Int, normalizePeak: Float? = null): ByteArray {
require(sampleRate > 0) { "sampleRate must be > 0" }
val working = samples.copyOf()
if (normalizePeak != null) normalizeInPlace(working, normalizePeak)
val dataSize = working.size * 2
val riffSize = 36 + dataSize
val buffer = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putAscii("RIFF")
buffer.putInt(riffSize)
buffer.putAscii("WAVE")
buffer.putAscii("fmt ")
buffer.putInt(16)
buffer.putShort(1) // PCM
buffer.putShort(1) // mono
buffer.putInt(sampleRate)
buffer.putInt(sampleRate * 2)
buffer.putShort(2)
buffer.putShort(16)
buffer.putAscii("data")
buffer.putInt(dataSize)
for (sample in working) {
val clipped = sample.coerceIn(-1.0f, 1.0f)
val intSample = (clipped * 32767.0f).roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
buffer.putShort(intSample.toShort())
}
return buffer.array()
}
fun writeStereo16(file: File, left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null)
Writes interleaved stereo 16-bit PCM WAV from two equal-length channels.
Needed by anything whose whole point is the stereo image — a jux-panned pattern, a plate
reverb's decorrelated tail — where folding to mono destroys the thing being demonstrated.
fun writeStereo16(file: File, left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null) {
file.parentFile?.mkdirs()
file.writeBytes(encodeStereo16(left, right, sampleRate, normalizePeak))
}
fun writeStereo16(path: String, left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null)
writeStereo16 to a path.
fun writeStereo16(path: String, left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null) {
writeStereo16(File(path), left, right, sampleRate, normalizePeak)
}
fun encodeStereo16(left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null): ByteArray
Encodes interleaved stereo 16-bit PCM WAV and returns the complete .wav bytes.
When normalizePeak is set, both channels are scaled by the same factor — the peak of the
louder one — so normalisation cannot move the stereo image.
body — 64 lines, from line 88
fun encodeStereo16(left: FloatArray, right: FloatArray, sampleRate: Int, normalizePeak: Float? = null): ByteArray {
require(sampleRate > 0) { "sampleRate must be > 0" }
require(left.size == right.size) { "channels must be the same length: ${left.size} vs ${right.size}" }
val l = left.copyOf()
val r = right.copyOf()
if (normalizePeak != null) {
var peak = 0.0f
for (v in l) { val a = kotlin.math.abs(v); if (a > peak) peak = a }
for (v in r) { val a = kotlin.math.abs(v); if (a > peak) peak = a }
if (peak > 0.0f) {
val scale = normalizePeak / peak
for (i in l.indices) { l[i] *= scale; r[i] *= scale }
}
}
val frames = l.size
val dataSize = frames * 4
val buffer = ByteBuffer.allocate(44 + dataSize).order(ByteOrder.LITTLE_ENDIAN)
buffer.putAscii("RIFF")
buffer.putInt(36 + dataSize)
buffer.putAscii("WAVE")
buffer.putAscii("fmt ")
buffer.putInt(16)
buffer.putShort(1) // PCM
buffer.putShort(2) // stereo
buffer.putInt(sampleRate)
buffer.putInt(sampleRate * 4)
buffer.putShort(4)
buffer.putShort(16)
buffer.putAscii("data")
buffer.putInt(dataSize)
for (i in 0 until frames) {
buffer.putShort(pcm16(l[i]))
buffer.putShort(pcm16(r[i]))
}
return buffer.array()
}
private fun pcm16(sample: Float): Short {
val clipped = sample.coerceIn(-1.0f, 1.0f)
return (clipped * 32767.0f).roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt()).toShort()
}
private fun ByteBuffer.putAscii(value: String) {
put(value.toByteArray(Charsets.US_ASCII))
}
private fun normalizeInPlace(values: FloatArray, targetPeak: Float) {
require(targetPeak >= 0.0f) { "targetPeak must be >= 0" }
var peak = 0.0f
for (value in values) {
val a = kotlin.math.abs(value)
if (a > peak) peak = a
}
if (peak <= 0.0f) return
val scale = targetPeak / peak
for (i in values.indices) values[i] *= scale
}
}