Skip to main content

SonixRecorder

Capture microphone input and save recordings as M4A, MP3, or WAV files with real-time audio level monitoring.

Quick Start

Kotlin

val recorder = SonixRecorder.create("/path/to/output.m4a")
recorder.start()
// ... user sings ...
recorder.stop()
recorder.release()

Swift

let recorder = SonixRecorder.create(outputPath: "/path/to/output.m4a")
recorder.start()
// ... user sings ...
recorder.stop()
recorder.release()

Configuration

Presets

PresetKotlinSwiftSample RateChannelsBitrate
VoiceSonixRecorderConfig.VOICE.voice16 kHzMono64 kbps
StandardSonixRecorderConfig.STANDARD.standard44.1 kHzMono128 kbps
HighSonixRecorderConfig.HIGH.high44.1 kHzStereo192 kbps

Builder

Kotlin

val config = SonixRecorderConfig.Builder()
.preset(SonixRecorderConfig.STANDARD)
.format(AudioFormat.MP3)
.echoCancellation(true)
.onRecordingStopped { path -> println("Saved to: $path") }
.build()

val recorder = SonixRecorder.create("/path/to/output.mp3", config)

Swift

let config = SonixRecorderConfig.Builder()
.preset(.standard)
.format(.mp3)
.echoCancellation(true)
.onRecordingStopped { path in print("Saved to: \(path)") }
.build()

let recorder = SonixRecorder.create(outputPath: "/path/to/output.mp3", config: config)

Config Properties

PropertyTypeDefaultDescription
formatAudioFormatM4AOutput format (M4A, MP3, WAV)
sampleRateInt16000Sample rate in Hz
channelsInt11 = mono, 2 = stereo
bitrateInt64000Encoder bitrate (bps)
echoCancellationBooleanfalseEnable acoustic echo cancellation
audioBufferSizeMsInt40Audio capture buffer size (ms)

Callbacks

Builder MethodSignatureDescription
onRecordingStarted() -> UnitRecording started
onRecordingStopped(outputPath: String) -> UnitRecording saved
onError(message: String) -> UnitError occurred
onLevelUpdate(level: Float) -> UnitAudio level changed (0.0–1.0)
onStateChange(RecordingState) -> UnitState machine transition

Creating a Recorder

With Output Path

val recorder = SonixRecorder.create(
outputPath = "/path/to/output.m4a",
config = SonixRecorderConfig.VOICE,
audioSession = AudioMode.RECORDING
)
let recorder = SonixRecorder.create(
outputPath: "/path/to/output.m4a",
config: .voice,
audioSession: .recording
)

With Temporary Path

For cases where you don't need a specific output location (e.g., real-time analysis):

val recorder = SonixRecorder.createTemporary()
let recorder = SonixRecorder.createTemporary()

Recording Controls

recorder.start()    // Begin recording
recorder.stop() // Stop and save to file
recorder.release() // Release all resources

Recording State

The recorder follows a state machine:

Idle → Starting → Recording → Stopping → Encoding → Finished
↘ Error
sealed class RecordingState {
object Idle
object Starting
data class Recording(val durationMs: Long)
object Stopping
object Encoding
data class Finished(val outputPath: String?)
data class Error(val message: String)
}

Observing State

Kotlin (StateFlow)

// Recording status
recorder.isRecording.collect { recording ->
recordButton.isEnabled = !recording
stopButton.isEnabled = recording
}

// Duration
recorder.duration.collect { durationMs ->
timerLabel.text = formatTime(durationMs)
}

// Audio level (for VU meter)
recorder.level.collect { level ->
vuMeter.setLevel(level) // 0.0 to 1.0
}

// Full state machine
recorder.state.collect { state ->
when (state) {
is RecordingState.Recording -> showRecording(state.durationMs)
is RecordingState.Encoding -> showEncoding()
is RecordingState.Finished -> showDone(state.outputPath)
is RecordingState.Error -> showError(state.message)
else -> {}
}
}

Swift (Observers)

let recordingTask = recorder.observeIsRecording { isRecording in
self.isRecording = isRecording
}

let durationTask = recorder.observeDuration { durationMs in
self.durationMs = durationMs
}

let levelTask = recorder.observeLevel { level in
self.audioLevel = level
}

// Cancel when done
recordingTask.cancel()
durationTask.cancel()
levelTask.cancel()

Swift (Combine)

recorder.isRecordingPublisher
.receive(on: DispatchQueue.main)
.sink { self.isRecording = $0 }
.store(in: &cancellables)

recorder.levelPublisher
.receive(on: DispatchQueue.main)
.sink { self.audioLevel = $0 }
.store(in: &cancellables)

StateFlows

StateFlowTypeDescription
isRecordingStateFlow<Boolean>Whether currently recording
durationStateFlow<Long>Recording duration in milliseconds
levelStateFlow<Float>Audio level (0.0 = silence, 1.0 = loud)
errorStateFlow<SonixError?>Error state
stateStateFlow<RecordingState>Full state machine
audioBuffersSharedFlow<AudioBuffer>Raw audio buffers for real-time processing

Properties

PropertyTypeDescription
actualSampleRateIntHardware sample rate (may differ from configured)
bufferPoolAvailableIntBuffers available in pool
bufferPoolWasExhaustedBooleanWhether pool was ever exhausted
latestBufferAudioBuffer?Most recent audio buffer
synchronizedTimeMsLongPlayback-synchronized time (falls back to recording duration when no sync provider is set)
inputLatencyMsLongInput latency diagnostic (ms). 1.0.0+. Already factored into AudioBuffer.timestamp — do not subtract again.

Real-time Audio Access

Access raw audio buffers for visualization, analysis, or hardware-clock timing alignment.

Kotlin

recorder.audioBuffers.collect { buffer ->
val samples = buffer.toFloatArray()
pitchDetector.detect(samples, buffer.sampleRate)
// Pass buffer.timestamp to CalibraLiveEval.feedAudioSamples for player alignment
}

// Or get a fixed-rate stream — resampling handled internally
recorder.audioBuffersResampled(targetRate = 16000).collect { samples ->
pitchDetector.detect(samples, 16000)
}

Swift (Resampled Stream)

audioBuffersResampled is exposed on iOS as an AsyncStream via audioBuffers(resampledTo:):

for await samples in recorder.audioBuffers(resampledTo: 16000) {
pitchDetector.detect(samples: samples, sampleRate: 16000)
}

AudioBuffer

The element type emitted by audioBuffers (and stored in latestBuffer).

PropertyTypeDescription
dataByteArrayRaw 16-bit signed little-endian PCM bytes
timestampLongAbsolute monotonic nanoseconds at the moment the last sample in the buffer was captured at the mic, with input latency already subtracted (1.0.1+). Same domain as System.nanoTime() on Android and AVAudioTime.hostTime (converted to ns) on iOS.
durationMsLongDuration of the buffer in milliseconds
sampleRateIntSample rate of data (matches actualSampleRate)
samplesFloatArraySame audio as data decoded to floats in [-1, 1]
sampleCountIntNumber of audio frames
sizeIntSize of data in bytes

Methods: toFloatArray() and fillFloatSamples(out: FloatArray).

AudioBuffer.timestamp is the canonical input timestamp for hardware-clock-aligned mic↔player correlation. Pass it to CalibraLiveEval.feedAudioSamples(captureTimestampNanos = …) or SonixPlayer.audibleTimeMsAtWallNanos(...). Do not subtract inputLatencyMs from the timestamp — input latency is already accounted for. (See audio latency concepts.)

Segment Recording

Record specific portions within a session (for practice apps):

// Enable in config
val config = SonixRecorderConfig.Builder()
.enableSegmentRecording(outputDirectory = "/path/to/segments/")
.build()

val recorder = SonixRecorder.create("/path/to/full.m4a", config)
recorder.start()

// Record a segment
recorder.startRecordingSegment(segmentIndex = 0)
// ... user performs section ...
val segmentPath = recorder.stopRecordingSegment(segmentIndex = 0)

// Query segments
val segments: List<SegmentInfo> = recorder.getRecordedSegments()
recorder.clearSegments() // Clear for retry

Playback Sync

Synchronize recording with a backing track for karaoke:

val player = SonixPlayer.create("backing-track.mp3")

val config = SonixRecorderConfig.Builder()
.playbackSyncProvider(player.asPlaybackInfoProvider)
.build()

val recorder = SonixRecorder.create("recording.m4a", config)

player.play()
recorder.start()

// Recording timestamps align with playback position
val syncedTime = recorder.synchronizedTimeMs

Listener Interface

recorder.setRecordingListener(object : SonixRecorder.RecordingListener {
override fun onRecordingStarted() { println("Started") }
override fun onRecordingStopped(outputPath: String) { println("Saved: $outputPath") }
override fun onStateChanged(state: RecordingState) { updateUI(state) }
override fun onLevelUpdate(level: Float) { vuMeter.setLevel(level) }
override fun onDurationUpdate(durationMs: Long) { updateTimer(durationMs) }
override fun onError(message: String) { showError(message) }
override fun onSegmentSaved(segmentIndex: Int, filePath: String) {
println("Segment $segmentIndex saved to $filePath")
}
})

Common Patterns

Recording ViewModel

class RecordingViewModel : ViewModel() {
private var recorder: SonixRecorder? = null

val isRecording = MutableStateFlow(false)
val duration = MutableStateFlow(0L)
val audioLevel = MutableStateFlow(0f)

fun startRecording(outputPath: String) {
val config = SonixRecorderConfig.Builder()
.preset(SonixRecorderConfig.VOICE)
.format(AudioFormat.MP3)
.build()

recorder = SonixRecorder.create(outputPath, config)

viewModelScope.launch { recorder!!.isRecording.collect { isRecording.value = it } }
viewModelScope.launch { recorder!!.duration.collect { duration.value = it } }
viewModelScope.launch { recorder!!.level.collect { audioLevel.value = it } }

recorder!!.start()
}

fun stopRecording() {
recorder?.stop()
}

override fun onCleared() {
recorder?.release()
}
}

Platform Notes

  • iOS: Requires microphone permission in Info.plist. Hardware sample rate may differ from requested — check actualSampleRate.
  • Android: Requires RECORD_AUDIO permission. Audio focus handled automatically.

Next Steps