diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d332a14..1653068b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt b/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt index 24fd73bd..93384789 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/AudioRecorder.kt @@ -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 @@ -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 @@ -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, @@ -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( @@ -237,6 +344,7 @@ class AudioRecorder : PluginRegistry.RequestPermissionsResultListener { } fun release() { + releaseAudioEffects() try { audioRecord?.release() } catch (e: Exception) { @@ -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 + } } diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt b/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt index b1d58dd9..19900949 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/RecorderSettings.kt @@ -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 { /** @@ -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 ) } } diff --git a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt index 59bbfcf2..622b45d2 100644 --- a/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt +++ b/android/src/main/kotlin/com/simform/audio_waveforms/Utils.kt @@ -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" diff --git a/doc/documentation.md b/doc/documentation.md index a130d796..58b69861 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -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: @@ -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. diff --git a/example/lib/main.dart b/example/lib/main.dart index 78a4df4c..57e5b827 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -219,7 +219,15 @@ class _HomeState extends State { 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) { diff --git a/lib/src/base/constants.dart b/lib/src/base/constants.dart index 9bdd7924..3aedd40c 100644 --- a/lib/src/base/constants.dart +++ b/lib/src/base/constants.dart @@ -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"; diff --git a/lib/src/base/utils.dart b/lib/src/base/utils.dart index 6073b289..ae8966e1 100644 --- a/lib/src/base/utils.dart +++ b/lib/src/base/utils.dart @@ -46,6 +46,52 @@ enum AndroidEncoder { } } +/// Audio input source for Android recordings. +/// +/// Maps to [MediaRecorder.AudioSource](https://developer.android.com/reference/android/media/MediaRecorder.AudioSource). +/// Selecting [voiceCommunication] routes capture through the device's voice +/// pipeline, which applies hardware noise suppression and echo cancellation — +/// this is what most voice-chat apps use. Use [mic] (the default) or +/// [unprocessed] when you want the raw signal (e.g. music). +enum AndroidAudioSource { + /// Default audio source. + defaultSource, + + /// Microphone audio source. This is the default for this package. + mic, + + /// Tuned for video recording, with the same orientation as the camera. + camcorder, + + /// Tuned for voice recognition. + voiceRecognition, + + /// Tuned for voice communications (e.g. VoIP). Applies echo cancellation + /// and noise suppression where the device supports it. + voiceCommunication, + + /// Unprocessed source, with no effects applied by the device. Requires + /// API 24+; falls back to [defaultSource] on older devices. + unprocessed, + + /// Tuned for live performance recording. Requires API 29+; falls back to + /// [defaultSource] on older devices. + voicePerformance; + + /// Native `MediaRecorder.AudioSource` integer constant. + int toNativeValue() { + return switch (this) { + AndroidAudioSource.defaultSource => 0, + AndroidAudioSource.mic => 1, + AndroidAudioSource.camcorder => 5, + AndroidAudioSource.voiceRecognition => 6, + AndroidAudioSource.voiceCommunication => 7, + AndroidAudioSource.unprocessed => 9, + AndroidAudioSource.voicePerformance => 10, + }; + } +} + /// IOS encoders. /// /// Android and IOS are have been separated to better support diff --git a/lib/src/models/android_encoder_settings.dart b/lib/src/models/android_encoder_settings.dart index d95c1622..9bf3327d 100644 --- a/lib/src/models/android_encoder_settings.dart +++ b/lib/src/models/android_encoder_settings.dart @@ -5,12 +5,44 @@ class AndroidEncoderSettings { /// Constructor for AndroidEncoderSettings. /// /// [androidEncoder] - Defines the encoder type for Android (default: AAC). - /// [androidOutputFormat] - Specifies the output format for Android recordings (default: MPEG4). + /// [audioSource] - Input source used for capture (default: [AndroidAudioSource.mic]). + /// [useNoiseSuppressor] - Enable noise suppression when supported (default: false). + /// [useEchoCanceler] - Enable acoustic echo cancellation when supported (default: false). + /// [useAutoGainControl] - Enable automatic gain control when supported (default: false). const AndroidEncoderSettings({ this.androidEncoder = AndroidEncoder.aacLc, + this.audioSource = AndroidAudioSource.mic, + this.useNoiseSuppressor = false, + this.useEchoCanceler = false, + this.useAutoGainControl = false, }); /// Encoder type for Android recordings. /// Default is aacLc. final AndroidEncoder androidEncoder; + + /// Audio input source used for capture. + /// + /// Defaults to [AndroidAudioSource.mic] (raw microphone). For voice apps + /// that want device-level noise suppression and echo cancellation, + /// [AndroidAudioSource.voiceCommunication] is usually the best choice. + final AndroidAudioSource audioSource; + + /// Attaches an `android.media.audiofx.NoiseSuppressor` to the recording + /// session when the device supports it. Default is false. + final bool useNoiseSuppressor; + + /// Attaches an `android.media.audiofx.AcousticEchoCanceler` to the recording + /// session when the device supports it. Default is false. + /// + /// Echo cancellation is most effective with + /// [AndroidAudioSource.voiceCommunication]. + final bool useEchoCanceler; + + /// Attaches an `android.media.audiofx.AutomaticGainControl` to the recording + /// session when the device supports it. Default is false. + /// + /// Note: gain control alters the captured amplitude and therefore the + /// rendered waveform. Avoid for music or level-sensitive recording. + final bool useAutoGainControl; } diff --git a/lib/src/models/recorder_settings.dart b/lib/src/models/recorder_settings.dart index adfc08bc..9005c401 100644 --- a/lib/src/models/recorder_settings.dart +++ b/lib/src/models/recorder_settings.dart @@ -54,5 +54,12 @@ class RecorderSettings { androidEncoderSettings.androidEncoder.toNativeFormat(), Constants.sampleRate: sampleRate, Constants.bitRate: bitRate, + Constants.audioSource: + androidEncoderSettings.audioSource.toNativeValue(), + Constants.useNoiseSuppressor: + androidEncoderSettings.useNoiseSuppressor, + Constants.useEchoCanceler: androidEncoderSettings.useEchoCanceler, + Constants.useAutoGainControl: + androidEncoderSettings.useAutoGainControl, }; }