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)
inputSourceAudioInputSourceMicrophoneAudio source: the microphone, or a WAV file streamed as if it were the microphone (see File Input)

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

File Input​

Stream a WAV file through the recorder as if it were live microphone input — deterministic tests, replay of captured sessions, offline analysis of existing audio. Everything downstream (level metering, real-time buffers, encoding, playback sync) behaves exactly as with the microphone.

Kotlin​

val config = SonixRecorderConfig.Builder()
.inputFile("/path/to/take.wav") // loop = false, leadInSilenceMs = 0
.build()

val recorder = SonixRecorder.create("output.m4a", config)
recorder.start() // streams the WAV in real time instead of opening the mic

Swift​

let config = SonixRecorderConfig.Builder()
.inputFile(path: "/path/to/take.wav", loop: false, leadInSilenceMs: 0)
.build()

let recorder = SonixRecorder.create(outputPath: "output.m4a", config: config)
recorder.start()

AudioInputSource​

ValueDescription
AudioInputSource.MicrophoneLive microphone capture (default)
AudioInputSource.File(path, loop, leadInSilenceMs)Stream a WAV file in real time
  • Accepts any canonical PCM WAV (any sample rate / channel count); audio is decoded to mono at the configured sampleRate.
  • leadInSilenceMs plays silence once before the file starts; at end-of-file the stream continues with silence (a quiet room, not a dead mic) unless loop = true.
  • A missing or non-WAV file surfaces through the normal onError / RecordingState.Error path when recording starts.
  • No OS audio session or audio focus is touched, so file input also runs in host environments without audio hardware.
  • With playbackSyncProvider set (see Playback Sync), the file stream follows the playback timeline — late starts, pauses, and seeks — instead of free-running.

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​