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. Decode now runs on a dedicated `HandlerThread` so network reads during decoding no longer block the UI, and a waveform that ends well short of the expected points now completes with an error instead of a truncated success
- 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
132 changes: 131 additions & 1 deletion android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ 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 +30,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 +101,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 +120,132 @@ 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)
Comment thread
vasu-nageshri-simform marked this conversation as resolved.
Resolved
this.recorderSettings = recorderSettings
encoder = recorderSettings.encoder
recorderState = RecorderState.Initialised
result.success(true)
}

/**
* Resolves the requested audio source against the running API level.
*
* UNPROCESSED requires API 24 and VOICE_PERFORMANCE requires API 29. On older
* devices these constants are unknown, so we fall back to MIC (the package
* default, and the closest to the raw signal these sources ask for) instead of
* passing an int the platform cannot honour. The constants are inlined at
* compile time, so referencing them is safe on older APIs.
*/
private fun resolveAudioSource(audioSource: Int): Int {
return when {
audioSource == MediaRecorder.AudioSource.UNPROCESSED && Build.VERSION.SDK_INT < Build.VERSION_CODES.N ->
MediaRecorder.AudioSource.MIC
audioSource == MediaRecorder.AudioSource.VOICE_PERFORMANCE && Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
MediaRecorder.AudioSource.MIC
else -> audioSource
}
}
Comment thread
vasu-nageshri-simform marked this conversation as resolved.
Resolved

/**
* 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.
* Idempotent: effects from a previous init are released first.
*/
private fun attachAudioEffects(sessionId: Int, recorderSettings: RecorderSettings) {
releaseAudioEffects()
if (recorderSettings.useNoiseSuppressor) {
attachEffect(
"NoiseSuppressor",
isAvailable = { NoiseSuppressor.isAvailable() },
create = { NoiseSuppressor.create(sessionId) },
store = { noiseSuppressor = it },
)
}
if (recorderSettings.useEchoCanceler) {
attachEffect(
"AcousticEchoCanceler",
isAvailable = { AcousticEchoCanceler.isAvailable() },
create = { AcousticEchoCanceler.create(sessionId) },
store = { echoCanceler = it },
)
}
if (recorderSettings.useAutoGainControl) {
attachEffect(
"AutomaticGainControl",
isAvailable = { AutomaticGainControl.isAvailable() },
create = { AutomaticGainControl.create(sessionId) },
store = { gainControl = it },
)
}
}

/**
* Creates, stores and enables one effect. The effect is stored before it is enabled so that
* [releaseAudioEffects] still releases it if enabling throws.
*/
private fun <T : AudioEffect> attachEffect(
name: String,
isAvailable: () -> Boolean,
create: () -> T?,
store: (T) -> Unit,
) {
try {
if (!isAvailable()) return
val effect = create() ?: return
store(effect)
enableEffect(effect, name)
} catch (e: Exception) {
Log.e(LOG_TAG, "Error enabling $name: ${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 +366,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
}

fun release() {
releaseAudioEffects()
try {
audioRecord?.release()
} catch (e: Exception) {
Expand Down
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
96 changes: 94 additions & 2 deletions doc/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,86 @@ 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
Comment thread
vasu-nageshri-simform marked this conversation as resolved.
Resolved

`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 `mic` on older devices. |
| `voicePerformance` | Tuned for live performance. Requires API 29+; falls back to `mic` on older devices. |

### Unsupported Source / Sample Rate Combinations

Some devices cannot initialise certain combinations, for example `voiceCommunication` at the default
44100 Hz. Instead of silently recording unusable audio, `record()` throws a `PlatformException`
when the chosen audio source and sample rate cannot be initialised. Catch it and retry with another
sample rate (16000 Hz is the most widely supported for the voice sources) or another source:

```dart
try {
await recorderController.record(
recorderSettings: const RecorderSettings(
sampleRate: 16000,
androidEncoderSettings: AndroidEncoderSettings(
audioSource: AndroidAudioSource.voiceCommunication,
),
),
);
} on PlatformException {
// Fall back to another sample rate or audio source.
}
```

### 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 +1842,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
13 changes: 12 additions & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,18 @@ 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(
// voiceCommunication can fail to initialise at 44100 Hz on some
// devices, 16000 Hz is the most widely supported rate.
sampleRate: 16000,
androidEncoderSettings: AndroidEncoderSettings(
// voiceCommunication + echo canceller + noise suppressor routes
// capture through the device voice pipeline to cut echo/noise.
audioSource: AndroidAudioSource.voiceCommunication,
Comment thread
vasu-nageshri-simform marked this conversation as resolved.
Resolved
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