Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- Fixed [#477](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/477) - Serialize extractor teardown with a lock and clear codec references to prevent stopping an already-released codec on Android
- Partially fixed [#478](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/478) - Guard decode callbacks against released resources to prevent crash while extracting waveform from ID3v2.4 files on Android
- Fixed [#488](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/488) - Auto-retry playback on transient network loss and run extraction setup off the main thread to prevent crashes and ANR on Android
- Feature [#442](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/442), [#381](https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues/381) - Add configurable Android audio source and opt-in noise suppression, echo cancellation and automatic gain control to reduce echo and background noise

## 2.0.2

Expand Down
118 changes: 116 additions & 2 deletions android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.AudioEffect
import android.media.audiofx.AutomaticGainControl
import android.media.audiofx.NoiseSuppressor
import android.os.Build
import android.os.Handler
import android.os.Looper
Expand All @@ -26,6 +29,9 @@ import kotlin.math.sqrt
class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
private var permissions = arrayOf(Manifest.permission.RECORD_AUDIO)
private var audioRecord: AudioRecord? = null
private var noiseSuppressor: NoiseSuppressor? = null
private var echoCanceler: AcousticEchoCanceler? = null
private var gainControl: AutomaticGainControl? = null
private var channelConfig: Int = AudioFormat.CHANNEL_IN_MONO
private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
private var bufferSize: Int? = null
Expand Down Expand Up @@ -94,10 +100,12 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
"Invalid buffer size: $bufferSize",
null
)
return
}
val resolvedSource = resolveAudioSource(recorderSettings.audioSource)
try {
audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
resolvedSource,
recorderSettings.sampleRate,
channelConfig,
audioFormat,
Expand All @@ -111,12 +119,111 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
)
return
}
// A successfully constructed AudioRecord can still be unusable when the
// requested (source, sampleRate, channel) combo is unsupported — e.g.
// VOICE_COMMUNICATION at 44100Hz on some devices. Reading from an
// uninitialised recorder yields garbage/wrong-rate PCM (loud noise),
// so fail loudly instead of recording unusable audio.
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) {
audioRecord?.release()
audioRecord = null
result.error(
LOG_TAG,
"AudioRecord failed to initialise for source=$resolvedSource, " +
"sampleRate=${recorderSettings.sampleRate}. The audio source may not support this configuration.",
null
)
return
}
attachAudioEffects(audioRecord!!.audioSessionId, recorderSettings)
this.recorderSettings = recorderSettings
encoder = recorderSettings.encoder
recorderState = RecorderState.Initialised
result.success(true)
}

/**
* Resolves the requested audio source against the running API level.
*
* UNPROCESSED (9) requires API 24 and VOICE_PERFORMANCE (10) requires API
* 29. On older devices these constants are unknown, so we fall back to
* DEFAULT (0) instead of passing an int the platform cannot honour.
*/
private fun resolveAudioSource(audioSource: Int): Int {
return when {
audioSource == UNPROCESSED_SOURCE && Build.VERSION.SDK_INT < Build.VERSION_CODES.N ->
DEFAULT_SOURCE
audioSource == VOICE_PERFORMANCE_SOURCE && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
DEFAULT_SOURCE
else -> audioSource
}
}

/**
* Attaches the audio effects the caller opted into to the recording session.
*
* Each effect is opt-in (off by default) and device-dependent, so we guard
* with the caller's flag and isAvailable(), and swallow failures
* individually — a missing or unsupported effect must not abort recording.
*/
private fun attachAudioEffects(sessionId: Int, recorderSettings: RecorderSettings) {
if (recorderSettings.useNoiseSuppressor) {
try {
if (NoiseSuppressor.isAvailable()) {
noiseSuppressor = NoiseSuppressor.create(sessionId)?.also { enableEffect(it, "NoiseSuppressor") }
}
} catch (e: Exception) {
Log.e(LOG_TAG, "Error enabling NoiseSuppressor: ${e.message}")
}
}
if (recorderSettings.useEchoCanceler) {
try {
if (AcousticEchoCanceler.isAvailable()) {
echoCanceler = AcousticEchoCanceler.create(sessionId)?.also { enableEffect(it, "AcousticEchoCanceler") }
}
} catch (e: Exception) {
Log.e(LOG_TAG, "Error enabling AcousticEchoCanceler: ${e.message}")
}
}
if (recorderSettings.useAutoGainControl) {
try {
if (AutomaticGainControl.isAvailable()) {
gainControl = AutomaticGainControl.create(sessionId)?.also { enableEffect(it, "AutomaticGainControl") }
}
} catch (e: Exception) {
Log.e(LOG_TAG, "Error enabling AutomaticGainControl: ${e.message}")
}
}
}

/**
* Enables an audio effect and logs when the platform refuses to enable it.
*
* [AudioEffect.setEnabled] returns a status code rather than throwing; a
* non-SUCCESS result means the effect is attached but inactive, so the
* recording keeps the unwanted noise/echo. Surface that instead of silently
* pretending the effect is on.
*/
private fun enableEffect(effect: AudioEffect, name: String) {
val status = effect.setEnabled(true)
if (status != AudioEffect.SUCCESS) {
Log.e(LOG_TAG, "Failed to enable $name (status=$status); recording continues without it")
}
}

private fun releaseAudioEffects() {
try {
noiseSuppressor?.release()
echoCanceler?.release()
gainControl?.release()
} catch (e: Exception) {
Log.e(LOG_TAG, "Error releasing audio effects: ${e.message}")
}
noiseSuppressor = null
echoCanceler = null
gainControl = null
}

fun start(result: Result) {
if (recorderSettings == null || bufferSize == null) {
result.error(
Expand Down Expand Up @@ -237,6 +344,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
}

fun release() {
releaseAudioEffects()
try {
audioRecord?.release()
} catch (e: Exception) {
Expand All @@ -260,4 +368,10 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
}
return -1
}

companion object {
private const val DEFAULT_SOURCE = 0
private const val UNPROCESSED_SOURCE = 9
private const val VOICE_PERFORMANCE_SOURCE = 10
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,28 @@ data class RecorderSettings(
* Bit rate in bits per second
* Defaults to 128kbps (good quality for most audio)
*/
val bitRate: Int = 128000
val bitRate: Int = 128000,

/**
* MediaRecorder.AudioSource constant used for capture.
* Defaults to MIC (1).
*/
val audioSource: Int = 1,

/**
* Whether to attach a NoiseSuppressor to the recording session when available.
*/
val useNoiseSuppressor: Boolean = false,

/**
* Whether to attach an AcousticEchoCanceler to the recording session when available.
*/
val useEchoCanceler: Boolean = false,

/**
* Whether to attach an AutomaticGainControl to the recording session when available.
*/
val useAutoGainControl: Boolean = false
) {
companion object {
/**
Expand All @@ -54,7 +75,11 @@ data class RecorderSettings(
path = json[Constants.path] as String?,
encoder = Encoder.fromString(json[Constants.encoder] as String?),
sampleRate = (json[Constants.sampleRate] as Int?) ?: 44100,
bitRate = json[Constants.bitRate] as Int
bitRate = json[Constants.bitRate] as Int,
audioSource = (json[Constants.audioSource] as Int?) ?: 1,
useNoiseSuppressor = (json[Constants.useNoiseSuppressor] as Boolean?) ?: false,
useEchoCanceler = (json[Constants.useEchoCanceler] as Boolean?) ?: false,
useAutoGainControl = (json[Constants.useAutoGainControl] as Boolean?) ?: false
)
}
}
Expand Down
4 changes: 4 additions & 0 deletions android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ object Constants {
const val encoder = "encoder"
const val sampleRate = "sampleRate"
const val bitRate = "bitRate"
const val audioSource = "audioSource"
const val useNoiseSuppressor = "useNoiseSuppressor"
const val useEchoCanceler = "useEchoCanceler"
const val useAutoGainControl = "useAutoGainControl"
const val fileNameFormat = "dd-MM-yy-hh-mm-ss"

const val preparePlayer = "preparePlayer"
Expand Down
74 changes: 72 additions & 2 deletions doc/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,64 @@ await recorderController.record(
**Important**: Ensure your file extension, sample rate, and bit rate are compatible with chosen
encoder.

## Android Audio Source and Noise Reduction

> **Android only.** These options live on `AndroidEncoderSettings` and have no effect on iOS/macOS.

By default Android captures from the raw microphone (`AndroidAudioSource.mic`), which can include
noticeable echo and background noise. You can route capture through the device's voice processing
pipeline and attach hardware audio effects to clean up the signal. All options are opt-in, so the
default recording behaviour is unchanged.

```dart
await recorderController.record(
recorderSettings: const RecorderSettings(
androidEncoderSettings: AndroidEncoderSettings(
audioSource: AndroidAudioSource.voiceCommunication,
useNoiseSuppressor: true,
useEchoCanceler: true,
useAutoGainControl: false,
),
),
);
```

### Audio Source

`audioSource` maps to Android's [`MediaRecorder.AudioSource`](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource).
For voice/chat recordings, `AndroidAudioSource.voiceCommunication` is usually the best choice — it
runs capture through the device voice pipeline, which already applies echo cancellation and noise
suppression where the hardware supports it.

| `AndroidAudioSource` | Description |
|----------------------|-------------|
| `defaultSource` | Device default source. |
| `mic` | Raw microphone. **Default for this package.** |
| `camcorder` | Tuned for video recording. |
| `voiceRecognition` | Tuned for speech recognition. |
| `voiceCommunication` | Tuned for VoIP; applies device echo cancellation and noise suppression. |
| `unprocessed` | Raw, unprocessed signal (e.g. music). Requires API 24+; falls back to `defaultSource` on older devices. |
| `voicePerformance` | Tuned for live performance. Requires API 29+; falls back to `defaultSource` on older devices. |

### Audio Effects

Each effect attaches to the recording session only when you enable it **and** the device reports it
as available; an unsupported effect is skipped without aborting the recording, and all effects are
released when the recorder is released.

| Setting | Effect | Notes |
|---------|--------|-------|
| `useNoiseSuppressor` | `NoiseSuppressor` | Suppresses steady background noise. |
| `useEchoCanceler` | `AcousticEchoCanceler` | Most effective with `AndroidAudioSource.voiceCommunication`. |
| `useAutoGainControl` | `AutomaticGainControl` | Normalises captured level — see caveat below. |

**Note**: `useAutoGainControl` alters the captured amplitude and therefore the rendered waveform, so
avoid it for music or level-sensitive recording.

**Tip**: `voiceCommunication` already applies echo/noise processing in the device pipeline. Enabling
`useEchoCanceler`/`useNoiseSuppressor` on top is redundant on some devices and helpful on others, so
validate the combination on your target hardware.

## Override Audio Session (iOS)

Control whether the plugin should override iOS audio session settings:
Expand Down Expand Up @@ -1762,14 +1820,26 @@ IosEncoderSetting({

## AndroidEncoderSettings

Android encoder configuration:
Android encoder and capture configuration:

```dart
AndroidEncoderSettings({
required AndroidEncoder androidEncoder,
AndroidEncoder androidEncoder = AndroidEncoder.aacLc,
AndroidAudioSource audioSource = AndroidAudioSource.mic,
bool useNoiseSuppressor = false,
bool useEchoCanceler = false,
bool useAutoGainControl = false,
})
```

See [Android Audio Source and Noise Reduction](#android-audio-source-and-noise-reduction) for
details on `audioSource` and the audio-effect flags.

## AndroidAudioSource

Android-only audio input source. Values: `defaultSource`, `mic` (default), `camcorder`,
`voiceRecognition`, `voiceCommunication`, `unprocessed` (API 24+), `voicePerformance` (API 29+).

# Migration Guides

This document provides guidance for migrating between different versions of the Audio Waveforms package.
Expand Down
10 changes: 9 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,15 @@ class _HomeState extends State<Home> {
final path = "${appDirectory.path}/recording.m4a";
await recorderController.record(
path: path, // Path is optional
recorderSettings: const RecorderSettings(),
recorderSettings: const RecorderSettings(
androidEncoderSettings: AndroidEncoderSettings(
// voiceCommunication + echo canceller + noise suppressor routes
// capture through the device voice pipeline to cut echo/noise.
audioSource: AndroidAudioSource.voiceCommunication,
useEchoCanceler: true,
useNoiseSuppressor: true,
),
),
);
}
} catch (e) {
Expand Down
4 changes: 4 additions & 0 deletions lib/src/base/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class Constants {
static const String outputFormat = 'outputFormat';
static const String sampleRate = 'sampleRate';
static const String bitRate = 'bitRate';
static const String audioSource = 'audioSource';
static const String useNoiseSuppressor = 'useNoiseSuppressor';
static const String useEchoCanceler = 'useEchoCanceler';
static const String useAutoGainControl = 'useAutoGainControl';
static const String readAudioFile = 'readAudioFile';
static const String convertToBytes = 'convertToBytes';
static const String preparePlayer = "preparePlayer";
Expand Down
Loading