├── .gitignore ├── .idea ├── .name ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── copyright │ ├── MIT_License.xml │ └── profiles_settings.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jarRepositories.xml ├── kotlinc.xml ├── misc.xml └── vcs.xml ├── LICENSE ├── README.md ├── android-wave-recorder ├── .gitignore ├── build.gradle ├── consumer-rules.pro ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── github │ │ └── squti │ │ └── androidwaverecorder │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── squti │ │ │ └── androidwaverecorder │ │ │ ├── Calculate.kt │ │ │ ├── FileWriter.kt │ │ │ ├── RecorderState.kt │ │ │ ├── SilenceDetectionConfig.kt │ │ │ ├── WaveConfig.kt │ │ │ ├── WaveHeaderWriter.kt │ │ │ └── WaveRecorder.kt │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ └── java │ └── com │ └── github │ └── squti │ └── androidwaverecorder │ └── ExampleUnitTest.kt ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── sample ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── github │ │ └── squti │ │ └── androidwaverecordersample │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── github │ │ │ └── squti │ │ │ └── androidwaverecordersample │ │ │ └── MainActivity.kt │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── github │ └── squti │ └── androidwaverecordersample │ └── ExampleUnitTest.kt ├── settings.gradle └── static └── android-wave-recorder-logo.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle/ 3 | build/ 4 | captures/ 5 | .externalNativeBuild/ 6 | cxx/ 7 | 8 | # IntelliJ IDEA 9 | .idea/ 10 | *.iml 11 | *.ipr 12 | *.iws 13 | .idea_modules/ 14 | 15 | # Android 16 | *.apk 17 | *.ap_ 18 | *.aab 19 | *.dex 20 | bin/ 21 | gen/ 22 | out/ 23 | sample/release/ 24 | sample/develop/ 25 | proguard/ 26 | *.log 27 | .navigation/ 28 | 29 | # macOS 30 | .DS_Store 31 | 32 | # Android Studio 33 | local.properties 34 | 35 | # Keystore files 36 | *.jks 37 | *.keystore 38 | google-services.json -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Android Wave Recorder Sample -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | xmlns:android 20 | 21 | ^$ 22 | 23 | 24 | 25 |
26 |
27 | 28 | 29 | 30 | xmlns:.* 31 | 32 | ^$ 33 | 34 | 35 | BY_NAME 36 | 37 |
38 |
39 | 40 | 41 | 42 | .*:id 43 | 44 | http://schemas.android.com/apk/res/android 45 | 46 | 47 | 48 |
49 |
50 | 51 | 52 | 53 | .*:name 54 | 55 | http://schemas.android.com/apk/res/android 56 | 57 | 58 | 59 |
60 |
61 | 62 | 63 | 64 | name 65 | 66 | ^$ 67 | 68 | 69 | 70 |
71 |
72 | 73 | 74 | 75 | style 76 | 77 | ^$ 78 | 79 | 80 | 81 |
82 |
83 | 84 | 85 | 86 | .* 87 | 88 | ^$ 89 | 90 | 91 | BY_NAME 92 | 93 |
94 |
95 | 96 | 97 | 98 | .* 99 | 100 | http://schemas.android.com/apk/res/android 101 | 102 | 103 | ANDROID_ATTRIBUTE_ORDER 104 | 105 |
106 |
107 | 108 | 109 | 110 | .* 111 | 112 | .* 113 | 114 | 115 | BY_NAME 116 | 117 |
118 |
119 |
120 |
121 | 122 | 124 |
125 |
-------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/MIT_License.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/copyright/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 squti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Android Wave Recorder 2 | [![](https://jitpack.io/v/squti/Android-Wave-Recorder.svg)](https://jitpack.io/#squti/Android-Wave-Recorder) 3 | 4 | A powerful and efficient library to record WAVE form audio files (WAV) in Android with Float and 32-bit encoding support. 5 |

6 | 7 |

8 | 9 | Android Wave Recorder is a lightweight library written in Kotlin to record audio files in WAVE (WAV) format on Android. It’s memory efficient and easy to use, with customizable recording options like Silence Detection and high-quality audio encoding (Float and 32-bit) 10 | 11 | ### Download 12 | Step 1. Add this to your root (Project) `build.gradle` at the end of repositories: 13 | ```gradle 14 | allprojects { 15 | repositories { 16 | ... 17 | maven { url "https://jitpack.io" } 18 | } 19 | } 20 | ``` 21 | Step 2. Add the following dependency to your module `build.gradle`: 22 | ```gradle 23 | dependencies{ 24 | implementation 'com.github.squti:Android-Wave-Recorder:2.1.0' 25 | } 26 | ``` 27 | ### Permission 28 | Add these permissions to your `AndroidManifest.xml` and [request them at runtime for Android 6.0+](https://developer.android.com/training/permissions/requesting.html) 29 | ```xml 30 | 31 | 32 | ``` 33 | If you use [Scoped Storage](https://source.android.com/docs/core/storage/scoped) there is an example in the [Sample](https://github.com/squti/Android-Wave-Recorder/tree/master/sample) project. 34 | ### Usage 35 | Pass the path of the output file to the `WaveRecorder` class and call `startRecording()`: 36 | ```kotlin 37 | /** 38 | * This path points to the file directory in the application's internal storage. 39 | * you can change it based on your usage 40 | */ 41 | val filePath:String = filesDir.absolutePath + "/audioFile.wav" 42 | 43 | val waveRecorder = WaveRecorder(filePath) 44 | waveRecorder.startRecording() 45 | 46 | ``` 47 | You can also pass a `URI` for the file path if you are dealing with [Scoped Storage](https://source.android.com/docs/core/storage/scoped): 48 | ```kotlin 49 | val audioUri = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) 50 | val contentValues = ContentValues().apply { 51 | put(MediaStore.Audio.Media.DISPLAY_NAME, "audioFile.wav") 52 | put(MediaStore.Audio.Media.MIME_TYPE, "audio/x-wav") 53 | put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/Android-Wave-Recorder") 54 | } 55 | /** 56 | * This URI points to audioFile.wav in the Android-Wave-Recorder directory in Android's default Music folder. 57 | * you can change it based on your usage 58 | */ 59 | val uri = contentResolver.insert(audioUri, contentValues) 60 | 61 | val waveRecorder = WaveRecorder(uri, context = this) 62 | waveRecorder.startRecording() 63 | 64 | ``` 65 | _[Here](https://github.com/squti/Android-Wave-Recorder/blob/master/sample/src/main/java/com/github/squti/androidwaverecordersample/MainActivity.kt) is an example of how to get URI before and after Android 10._ 66 | 67 | To stop recording, call the `stopRecording()` function: 68 | ```kotlin 69 | waveRecorder.stopRecording() 70 | 71 | ``` 72 | 73 | To pause and resume recording, use the `pauseRecording()` and `resumeRecording()` functions: 74 | ```kotlin 75 | //Pause 76 | waveRecorder.pauseRecording() 77 | 78 | //Resume 79 | waveRecorder.resumeRecording() 80 | 81 | ``` 82 | 83 | To listen to audio amplitude during recording, register a listener to `onAmplitudeListener`: 84 | ```kotlin 85 | waveRecorder.onAmplitudeListener = { 86 | Log.i(TAG, "Amplitude : $it") 87 | } 88 | ``` 89 | 90 | To capture audio data chunks during recording, register a listener to `onAudioChunkCaptured`: 91 | ```kotlin 92 | waveRecorder.onAudioChunkCaptured = { dataChunk -> 93 | //doProcess(dataChunk) 94 | } 95 | ``` 96 | ### Silence Detection 97 | To activate the *Silence Detection*, set `silenceDetection` to `true`: 98 | ```kotlin 99 | waveRecorder.silenceDetection = true 100 | 101 | ``` 102 | You can adjust the silence amplitude level based on your needs. The default threshold is 1500, meaning amplitudes below 1500 are considered silence. The recorder will pause until the amplitude goes above 1500 again. By default, the recorder buffers the last 2 seconds of silence and adds it to the file when recording resumes. Silence detection waits for 2 seconds after detecting silence; if silence continues, recording pauses. The recorder will resume when it detects sound again. You can adjust the **buffer time, silence waiting time, and amplitude threshold** using `configureSilenceDetection`: 103 | ```kotlin 104 | waveRecorder.configureSilenceDetection { 105 | minAmplitudeThreshold = 2000 106 | bufferDurationInMillis = 1500 107 | preSilenceDurationInMillis = 1500 108 | } 109 | 110 | ``` 111 | _Note 1: Buffer and Silence Waiting Time may have slight inaccuracies due to the conversion from milliseconds to bytes in the background. Adjust these settings to achieve more accurate results._ 112 | 113 | _Note 2: Big buffer size can reduce the performance_ 114 | 115 | ### Noise Suppression 116 | To activate the `Noise Suppressor`, set `noiseSuppressorActive` to `true`: 117 | ```kotlin 118 | waveRecorder.noiseSuppressorActive = true 119 | 120 | ``` 121 | _Note: If the device does not support Noise Suppressor it will not affect the output_ 122 | 123 | ### Recording States 124 | To listen to recording state changes **(RECORDING, STOP, PAUSE and SKIPPING SILENCE)**, register a listener to `onStateChangeListener`: 125 | ```kotlin 126 | waveRecorder.onStateChangeListener = { 127 | when (it) { 128 | RecorderState.RECORDING -> TODO() 129 | RecorderState.STOP -> TODO() 130 | RecorderState.PAUSE -> TODO() 131 | RecorderState.SKIPPING_SILENCE -> TODO() 132 | } 133 | } 134 | ``` 135 | ### Configuration 136 | Android Wave Recorder supports **Float, 32-bit, 16-bit and 8-bit** encoding. 137 | 138 | The default configuration for recording audio is: 139 | 140 | | Property | Value | 141 | | :---: | :---: | 142 | | sampleRate | 16000 | 143 | | channels | AudioFormat.CHANNEL_IN_MONO | 144 | | audioEncoding | AudioFormat.ENCODING_PCM_16BIT | 145 | 146 | You can change the configuration using `configureWaveSettings`: 147 | ```kotlin 148 | waveRecorder.configureWaveSettings { 149 | sampleRate = 44100 150 | channels = AudioFormat.CHANNEL_IN_STEREO 151 | audioEncoding = AudioFormat.ENCODING_PCM_FLOAT 152 | } 153 | ``` 154 | 155 | ### License 156 | ``` 157 | MIT License 158 | 159 | Copyright (c) 2019 squti 160 | 161 | Permission is hereby granted, free of charge, to any person obtaining a copy 162 | of this software and associated documentation files (the "Software"), to deal 163 | in the Software without restriction, including without limitation the rights 164 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 165 | copies of the Software, and to permit persons to whom the Software is 166 | furnished to do so, subject to the following conditions: 167 | 168 | The above copyright notice and this permission notice shall be included in all 169 | copies or substantial portions of the Software. 170 | 171 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 172 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 173 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 174 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 175 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 176 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 177 | SOFTWARE. 178 | ``` 179 | 180 | -------------------------------------------------------------------------------- /android-wave-recorder/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /android-wave-recorder/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'maven-publish' 5 | } 6 | android { 7 | 8 | 9 | defaultConfig { 10 | compileSdk 33 11 | minSdkVersion 16 12 | targetSdkVersion 33 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles 'consumer-rules.pro' 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | namespace 'com.github.squti.androidwaverecorder' 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_17 27 | targetCompatibility JavaVersion.VERSION_17 28 | } 29 | kotlinOptions { 30 | jvmTarget = '17' 31 | } 32 | } 33 | afterEvaluate { 34 | publishing { 35 | publications { 36 | release(MavenPublication) { 37 | from components.release 38 | } 39 | } 40 | } 41 | } 42 | dependencies { 43 | implementation fileTree(dir: 'libs', include: ['*.jar']) 44 | implementation 'androidx.core:core-ktx:1.10.1' 45 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2' 46 | testImplementation 'junit:junit:4.13.2' 47 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 48 | } 49 | -------------------------------------------------------------------------------- /android-wave-recorder/consumer-rules.pro: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squti/Android-Wave-Recorder/9ddcb0cafd0004425eb726fc32d07b30e2f73d03/android-wave-recorder/consumer-rules.pro -------------------------------------------------------------------------------- /android-wave-recorder/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /android-wave-recorder/src/androidTest/java/com/github/squti/androidwaverecorder/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.squti.androidwaverecorder 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.github.squti.androidwaverecorder.test", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /android-wave-recorder/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/Calculate.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | import android.media.AudioFormat 28 | import android.media.AudioRecord 29 | import java.io.File 30 | import java.math.BigDecimal 31 | import java.math.MathContext 32 | import java.nio.ByteBuffer 33 | import java.nio.ByteOrder 34 | 35 | 36 | internal fun calculateMinBufferSize(waveConfig: WaveConfig): Int { 37 | return AudioRecord.getMinBufferSize( 38 | waveConfig.sampleRate, 39 | waveConfig.channels, 40 | waveConfig.audioEncoding 41 | ) 42 | } 43 | 44 | internal fun calculateAmplitude(data: ByteArray, audioFormat: Int): Int { 45 | return when (audioFormat) { 46 | AudioFormat.ENCODING_PCM_8BIT -> { 47 | val scaleFactor = 32767.0 / 255.0 48 | (data.average().plus(128) * scaleFactor).toInt() 49 | } 50 | 51 | AudioFormat.ENCODING_PCM_16BIT -> { 52 | val shortData = ShortArray(data.size / 2) 53 | ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).asShortBuffer().get(shortData) 54 | shortData.maxOrNull()?.toInt() ?: 0 55 | } 56 | 57 | AudioFormat.ENCODING_PCM_32BIT -> { 58 | val intData = IntArray(data.size / 4) 59 | ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).asIntBuffer().get(intData) 60 | val maxAmplitude = intData.maxOrNull() ?: 0 61 | val scaledAmplitude = ((maxAmplitude / Int.MAX_VALUE.toFloat()) * 32768).toInt() 62 | scaledAmplitude 63 | } 64 | 65 | else -> throw IllegalArgumentException("Unsupported audio format for encoding $audioFormat") 66 | } 67 | } 68 | 69 | internal fun calculateAmplitude(data: FloatArray): Int { 70 | val maxFloatAmplitude = data.maxOrNull() ?: 0f 71 | return (maxFloatAmplitude * 32768).toInt() 72 | } 73 | 74 | internal fun calculateDurationInMillis(data: ByteArray, waveConfig: WaveConfig): BigDecimal { 75 | return when (waveConfig.audioEncoding) { 76 | AudioFormat.ENCODING_PCM_8BIT -> { 77 | BigDecimal(data.size).divide( 78 | BigDecimal(1 * channelCount(waveConfig.channels) * waveConfig.sampleRate), 79 | MathContext.DECIMAL64 80 | ) * BigDecimal(1000) 81 | } 82 | 83 | AudioFormat.ENCODING_PCM_16BIT -> { 84 | BigDecimal(data.size).divide( 85 | BigDecimal(2 * channelCount(waveConfig.channels) * waveConfig.sampleRate), 86 | MathContext.DECIMAL64 87 | ) * BigDecimal(1000) 88 | } 89 | 90 | AudioFormat.ENCODING_PCM_32BIT -> { 91 | BigDecimal(data.size).divide( 92 | BigDecimal(4 * channelCount(waveConfig.channels) * waveConfig.sampleRate), 93 | MathContext.DECIMAL64 94 | ) * BigDecimal(1000) 95 | } 96 | 97 | else -> throw IllegalArgumentException("Unsupported audio format for encoding ${waveConfig.audioEncoding}") 98 | } 99 | } 100 | 101 | internal fun calculateDurationInMillis(data: FloatArray, waveConfig: WaveConfig): BigDecimal { 102 | return BigDecimal(data.size).divide( 103 | BigDecimal(channelCount(waveConfig.channels) * waveConfig.sampleRate), 104 | MathContext.DECIMAL64 105 | ) * BigDecimal(1000) 106 | } 107 | 108 | internal fun calculateDurationInMillis(audioFile: File, waveConfig: WaveConfig): Long { 109 | val bytesPerSample = bitPerSample(waveConfig.audioEncoding) / 8 110 | val totalSamplesRead = 111 | (audioFile.length() / bytesPerSample) / channelCount(waveConfig.channels) 112 | return (totalSamplesRead * 1000 / waveConfig.sampleRate) 113 | } -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/FileWriter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | import java.io.DataOutputStream 28 | import java.nio.ByteBuffer 29 | import java.nio.ByteOrder 30 | import java.util.LinkedList 31 | 32 | internal class FileWriter( 33 | private val outputStream: DataOutputStream, 34 | private val onAudioChunkCaptured: ((ByteArray) -> Unit)? 35 | ) { 36 | fun writeDataToStream( 37 | lastSkippedData: LinkedList, data: ByteArray 38 | ) { 39 | val totalSize = lastSkippedData.sumOf { it.size } + data.size 40 | val byteBuffer = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) 41 | 42 | lastSkippedData.forEach { byteArray -> 43 | byteBuffer.put(byteArray) 44 | } 45 | byteBuffer.put(data) 46 | lastSkippedData.clear() 47 | 48 | outputStream.write(byteBuffer.array()) 49 | onAudioChunkCaptured?.let { 50 | it(byteBuffer.array()) 51 | } 52 | } 53 | 54 | fun writeDataToStream( 55 | lastSkippedData: LinkedList, data: FloatArray 56 | ) { 57 | val totalFloats = lastSkippedData.sumOf { it.size } + data.size 58 | val totalSize = totalFloats * 4 59 | val byteBuffer = ByteBuffer.allocate(totalSize).order(ByteOrder.LITTLE_ENDIAN) 60 | 61 | lastSkippedData.forEach { floatArray -> 62 | floatArray.forEach { floatValue -> 63 | byteBuffer.putFloat(floatValue) 64 | } 65 | } 66 | data.forEach { floatValue -> 67 | byteBuffer.putFloat(floatValue) 68 | } 69 | lastSkippedData.clear() 70 | 71 | outputStream.write(byteBuffer.array()) 72 | 73 | onAudioChunkCaptured?.let { 74 | it(byteBuffer.array()) 75 | } 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/RecorderState.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2020 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | enum class RecorderState { 28 | RECORDING, PAUSE, STOP, SKIPPING_SILENCE 29 | } -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/SilenceDetectionConfig.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2024 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | /** 28 | * Configuration for silence detection and handling during audio recording. 29 | * 30 | * @property [minAmplitudeThreshold] The minimum amplitude level (1 to 32767) considered as non-silent. 31 | * @property [bufferDurationInMillis] The duration (in milliseconds) of audio data buffered when silence is detected. It captures the last seconds of silence. 32 | * @property [preSilenceDurationInMillis] The duration (in milliseconds) of audio data recorded before silence is detected. It captures the last seconds leading up to silence. 33 | */ 34 | data class SilenceDetectionConfig( 35 | var minAmplitudeThreshold: Int, 36 | var bufferDurationInMillis: Long = 2000, 37 | var preSilenceDurationInMillis: Long = 2000, 38 | ) -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/WaveConfig.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2019 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | import android.media.AudioFormat 28 | 29 | /** 30 | * Configuration for recording file. 31 | * @property [sampleRate] the number of samples that audio carried per second. 32 | * @property [channels] number and position of sound source when the sound is recording. 33 | * @property [audioEncoding] size of data per sample. 34 | */ 35 | data class WaveConfig( 36 | var sampleRate: Int = 16000, 37 | var channels: Int = AudioFormat.CHANNEL_IN_MONO, 38 | var audioEncoding: Int = AudioFormat.ENCODING_PCM_16BIT 39 | ) 40 | 41 | internal fun bitPerSample(audioEncoding: Int) = when (audioEncoding) { 42 | AudioFormat.ENCODING_PCM_8BIT -> 8 43 | AudioFormat.ENCODING_PCM_16BIT -> 16 44 | AudioFormat.ENCODING_PCM_32BIT -> 32 45 | AudioFormat.ENCODING_PCM_FLOAT -> 32 46 | else -> throw IllegalArgumentException("Unsupported audio format for encoding $audioEncoding") 47 | } 48 | 49 | internal fun channelCount(channels: Int) = when (channels) { 50 | AudioFormat.CHANNEL_IN_MONO -> 1 51 | AudioFormat.CHANNEL_IN_STEREO -> 2 52 | else -> throw IllegalArgumentException("Unsupported audio channel") 53 | } -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/WaveHeaderWriter.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2019 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | import android.content.Context 28 | import android.media.AudioFormat 29 | import android.net.Uri 30 | import java.io.File 31 | import java.io.FileInputStream 32 | import java.io.RandomAccessFile 33 | 34 | class WaveHeaderWriter { 35 | private var fileUri: Uri? = null 36 | private var filePath: String? = null 37 | private lateinit var context: Context 38 | private var waveConfig: WaveConfig 39 | 40 | constructor(fileUri: Uri, context: Context, waveConfig: WaveConfig) { 41 | this.fileUri = fileUri 42 | this.context = context 43 | this.waveConfig = waveConfig 44 | } 45 | 46 | constructor(filePath: String, waveConfig: WaveConfig) { 47 | this.filePath = filePath 48 | this.waveConfig = waveConfig 49 | } 50 | 51 | fun writeHeader() { 52 | val inputStream: FileInputStream = if (fileUri != null) { 53 | val fileDescriptor = 54 | context.contentResolver.openFileDescriptor(fileUri!!, "rw")?.fileDescriptor 55 | FileInputStream(fileDescriptor) 56 | } else { 57 | File(filePath!!).inputStream() 58 | } 59 | val totalAudioLen = inputStream.channel.size() - 44 60 | val totalDataLen = totalAudioLen + 36 61 | val channels = if (waveConfig.channels == AudioFormat.CHANNEL_IN_MONO) 62 | 1 63 | else 64 | 2 65 | 66 | val sampleRate = waveConfig.sampleRate.toLong() 67 | val byteRate = 68 | (bitPerSample(waveConfig.audioEncoding) * waveConfig.sampleRate * channels / 8).toLong() 69 | val header = getWavFileHeaderByteArray( 70 | totalAudioLen, 71 | totalDataLen, 72 | sampleRate, 73 | channels, 74 | byteRate, 75 | bitPerSample(waveConfig.audioEncoding), 76 | waveConfig.audioEncoding == AudioFormat.ENCODING_PCM_FLOAT 77 | ) 78 | 79 | if (fileUri != null) { 80 | val outputStream = context.contentResolver.openOutputStream(fileUri!!, "rw") 81 | outputStream?.write(header) 82 | outputStream?.close() 83 | } else { 84 | val randomAccessFile = RandomAccessFile(File(filePath!!), "rw") 85 | randomAccessFile.seek(0) 86 | randomAccessFile.write(header) 87 | randomAccessFile.close() 88 | } 89 | } 90 | 91 | private fun getWavFileHeaderByteArray( 92 | totalAudioLen: Long, totalDataLen: Long, longSampleRate: Long, 93 | channels: Int, byteRate: Long, bitsPerSample: Int, isFloat: Boolean 94 | ): ByteArray { 95 | val header = ByteArray(44) 96 | header[0] = 'R'.code.toByte() 97 | header[1] = 'I'.code.toByte() 98 | header[2] = 'F'.code.toByte() 99 | header[3] = 'F'.code.toByte() 100 | header[4] = (totalDataLen and 0xff).toByte() 101 | header[5] = (totalDataLen shr 8 and 0xff).toByte() 102 | header[6] = (totalDataLen shr 16 and 0xff).toByte() 103 | header[7] = (totalDataLen shr 24 and 0xff).toByte() 104 | header[8] = 'W'.code.toByte() 105 | header[9] = 'A'.code.toByte() 106 | header[10] = 'V'.code.toByte() 107 | header[11] = 'E'.code.toByte() 108 | header[12] = 'f'.code.toByte() 109 | header[13] = 'm'.code.toByte() 110 | header[14] = 't'.code.toByte() 111 | header[15] = ' '.code.toByte() 112 | header[16] = 16 113 | header[17] = 0 114 | header[18] = 0 115 | header[19] = 0 116 | header[20] = if (isFloat) 3 else 1 117 | header[21] = 0 118 | header[22] = channels.toByte() 119 | header[23] = 0 120 | header[24] = (longSampleRate and 0xff).toByte() 121 | header[25] = (longSampleRate shr 8 and 0xff).toByte() 122 | header[26] = (longSampleRate shr 16 and 0xff).toByte() 123 | header[27] = (longSampleRate shr 24 and 0xff).toByte() 124 | header[28] = (byteRate and 0xff).toByte() 125 | header[29] = (byteRate shr 8 and 0xff).toByte() 126 | header[30] = (byteRate shr 16 and 0xff).toByte() 127 | header[31] = (byteRate shr 24 and 0xff).toByte() 128 | header[32] = (channels * bitsPerSample / 8).toByte() 129 | header[33] = 0 130 | header[34] = bitsPerSample.toByte() 131 | header[35] = 0 132 | header[36] = 'd'.code.toByte() 133 | header[37] = 'a'.code.toByte() 134 | header[38] = 't'.code.toByte() 135 | header[39] = 'a'.code.toByte() 136 | header[40] = (totalAudioLen and 0xff).toByte() 137 | header[41] = (totalAudioLen shr 8 and 0xff).toByte() 138 | header[42] = (totalAudioLen shr 16 and 0xff).toByte() 139 | header[43] = (totalAudioLen shr 24 and 0xff).toByte() 140 | return header 141 | } 142 | } -------------------------------------------------------------------------------- /android-wave-recorder/src/main/java/com/github/squti/androidwaverecorder/WaveRecorder.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2019 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecorder 26 | 27 | import android.annotation.SuppressLint 28 | import android.content.Context 29 | import android.media.AudioFormat 30 | import android.media.AudioRecord 31 | import android.media.MediaRecorder 32 | import android.media.audiofx.NoiseSuppressor 33 | import android.net.Uri 34 | import android.os.Build 35 | import androidx.annotation.RequiresApi 36 | import kotlinx.coroutines.DelicateCoroutinesApi 37 | import kotlinx.coroutines.Dispatchers 38 | import kotlinx.coroutines.GlobalScope 39 | import kotlinx.coroutines.launch 40 | import kotlinx.coroutines.withContext 41 | import java.io.DataOutputStream 42 | import java.io.File 43 | import java.io.FileOutputStream 44 | import java.io.OutputStream 45 | import java.math.BigDecimal 46 | import java.util.LinkedList 47 | import java.util.concurrent.TimeUnit 48 | import java.util.concurrent.atomic.AtomicBoolean 49 | 50 | /** 51 | * The WaveRecorder class used to record Waveform audio file using AudioRecord class to get the audio stream in PCM encoding 52 | * and then convert it to WAVE file (WAV due to its filename extension) by adding appropriate headers. This class uses 53 | * Kotlin Coroutine with IO dispatcher to writing input data on storage asynchronously. 54 | * @property filePath the path of the file to be saved. 55 | */ 56 | class WaveRecorder { 57 | private var fileUri: Uri? = null 58 | private var filePath: String? = null 59 | private lateinit var context: Context 60 | 61 | constructor(fileUri: Uri, context: Context) { 62 | this.fileUri = fileUri 63 | this.context = context 64 | } 65 | 66 | constructor(filePath: String) { 67 | this.filePath = filePath 68 | } 69 | 70 | /** 71 | * Configuration for recording audio file. 72 | */ 73 | @Deprecated( 74 | "Use configureWaveSettings to set recording configuration. Access to this property will not be available in the future." 75 | ) 76 | var waveConfig: WaveConfig = WaveConfig() 77 | 78 | private var silenceDetectionConfig: SilenceDetectionConfig = SilenceDetectionConfig(1500) 79 | 80 | /** 81 | * Register a callback to be invoked in every recorded chunk of audio data 82 | * to get max amplitude of that chunk. 83 | */ 84 | var onAmplitudeListener: ((Int) -> Unit)? = null 85 | 86 | /** 87 | * Register a callback to be invoked for each recorded chunk of audio data. 88 | * Provides the captured chunk as a ByteArray. 89 | */ 90 | var onAudioChunkCaptured: ((ByteArray) -> Unit)? = null 91 | 92 | /** 93 | * Register a callback to be invoked in recording state changes 94 | */ 95 | var onStateChangeListener: ((RecorderState) -> Unit)? = null 96 | 97 | /** 98 | * Register a callback to get elapsed recording time in seconds 99 | */ 100 | var onTimeElapsed: ((Long) -> Unit)? = null 101 | 102 | /** 103 | * Register a callback to get elapsed recording time in milliseconds 104 | */ 105 | var onTimeElapsedInMillis: ((Long) -> Unit)? = null 106 | 107 | /** 108 | * Activates Noise Suppressor during recording if the device implements noise 109 | * suppression. 110 | */ 111 | var noiseSuppressorActive: Boolean = false 112 | 113 | /** 114 | * The ID of the audio session this WaveRecorder belongs to. 115 | * The default value is -1 which means no audio session exist. 116 | */ 117 | var audioSessionId: Int = -1 118 | private set 119 | 120 | /** 121 | * Activates Silence Detection during recording 122 | */ 123 | var silenceDetection: Boolean = false 124 | 125 | private var isPaused = AtomicBoolean(false) 126 | private var isSkipping = AtomicBoolean(false) 127 | private lateinit var audioRecorder: AudioRecord 128 | private var noiseSuppressor: NoiseSuppressor? = null 129 | private var silenceDuration = BigDecimal.ZERO 130 | private var currentState: RecorderState = RecorderState.STOP 131 | 132 | /** 133 | * Set configuration for recording audio file. 134 | */ 135 | fun configureWaveSettings(block: WaveConfig.() -> Unit): WaveRecorder { 136 | waveConfig.apply(block) 137 | return this 138 | } 139 | 140 | /** 141 | * Set configuration for Silence Detection. 142 | */ 143 | fun configureSilenceDetection(block: SilenceDetectionConfig.() -> Unit): WaveRecorder { 144 | silenceDetectionConfig.apply(block) 145 | return this 146 | } 147 | 148 | /** 149 | * Starts audio recording asynchronously and writes recorded data chunks on storage. 150 | */ 151 | @OptIn(DelicateCoroutinesApi::class) 152 | fun startRecording() { 153 | if (!isAudioRecorderInitialized()) { 154 | initializeAudioRecorder() 155 | GlobalScope.launch(Dispatchers.IO) { 156 | if (waveConfig.audioEncoding == AudioFormat.ENCODING_PCM_FLOAT) { 157 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 158 | writeFloatAudioDataToStorage() 159 | } else { 160 | throw UnsupportedOperationException("Float audio is not supported on this version of Android. You need Android Android 6.0 or above") 161 | } 162 | } else 163 | writeByteAudioDataToStorage() 164 | } 165 | } 166 | } 167 | 168 | @SuppressLint("MissingPermission") 169 | private fun initializeAudioRecorder() { 170 | audioRecorder = AudioRecord( 171 | MediaRecorder.AudioSource.MIC, 172 | waveConfig.sampleRate, 173 | waveConfig.channels, 174 | waveConfig.audioEncoding, 175 | AudioRecord.getMinBufferSize( 176 | waveConfig.sampleRate, 177 | waveConfig.channels, 178 | waveConfig.audioEncoding 179 | ) 180 | ) 181 | 182 | audioSessionId = audioRecorder.audioSessionId 183 | audioRecorder.startRecording() 184 | 185 | if (noiseSuppressorActive) { 186 | noiseSuppressor = NoiseSuppressor.create(audioRecorder.audioSessionId) 187 | } 188 | } 189 | 190 | private suspend fun writeByteAudioDataToStorage() { 191 | val bufferSize = calculateMinBufferSize(waveConfig) 192 | val data = ByteArray(bufferSize) 193 | val outputStream: OutputStream = if (fileUri != null) { 194 | context.contentResolver.openOutputStream(fileUri ?: return) ?: return 195 | } else { 196 | val file = File(filePath!!) 197 | FileOutputStream(file) 198 | } 199 | val dataOutputStream = DataOutputStream(outputStream) 200 | val fileWriter = FileWriter(dataOutputStream, onAudioChunkCaptured) 201 | 202 | val bufferSizeToKeep = 203 | (waveConfig.sampleRate * channelCount(waveConfig.channels) * (bitPerSample(waveConfig.audioEncoding) / 8) * silenceDetectionConfig.bufferDurationInMillis / 1000).toInt() 204 | 205 | val lastSkippedData = LinkedList() 206 | 207 | var fileDurationInMillis = BigDecimal.ZERO 208 | 209 | while (audioRecorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) { 210 | val operationStatus = audioRecorder.read(data, 0, bufferSize) 211 | 212 | if (operationStatus != AudioRecord.ERROR_INVALID_OPERATION) { 213 | val amplitude = getByteArrayAmplitude(data) 214 | 215 | if (isPaused.get()) 216 | updateState(RecorderState.PAUSE) 217 | else { 218 | 219 | if (silenceDetection) { 220 | handleByteSilenceState( 221 | amplitude, 222 | lastSkippedData, 223 | data, 224 | bufferSizeToKeep 225 | ) 226 | } 227 | 228 | if (!isSkipping.get()) { 229 | updateState(RecorderState.RECORDING) 230 | lastSkippedData.forEach { 231 | fileDurationInMillis += calculateDurationInMillis(it, waveConfig) 232 | } 233 | fileWriter.writeDataToStream(lastSkippedData, data) 234 | fileDurationInMillis += calculateDurationInMillis(data, waveConfig) 235 | } 236 | } 237 | 238 | updateListeners(amplitude, fileDurationInMillis.toLong()) 239 | } 240 | } 241 | updateState(RecorderState.STOP) 242 | cleanup(dataOutputStream) 243 | } 244 | 245 | @RequiresApi(Build.VERSION_CODES.M) 246 | private suspend fun writeFloatAudioDataToStorage() { 247 | val bufferSize = calculateMinBufferSize(waveConfig) 248 | val data = FloatArray(bufferSize) 249 | val outputStream: OutputStream = if (fileUri != null) { 250 | context.contentResolver.openOutputStream(fileUri ?: return) ?: return 251 | } else { 252 | val file = File(filePath!!) 253 | FileOutputStream(file) 254 | } 255 | val dataOutputStream = DataOutputStream(outputStream) 256 | val fileWriter = FileWriter(dataOutputStream, onAudioChunkCaptured) 257 | 258 | val bufferSizeToKeep = 259 | (waveConfig.sampleRate * channelCount(waveConfig.channels) * silenceDetectionConfig.bufferDurationInMillis / 1000).toInt() 260 | 261 | val lastSkippedData = LinkedList() 262 | 263 | var fileDurationInMillis = BigDecimal.ZERO 264 | 265 | while (audioRecorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) { 266 | val operationStatus = audioRecorder.read(data, 0, bufferSize, AudioRecord.READ_BLOCKING) 267 | 268 | if (AudioRecord.ERROR_INVALID_OPERATION != operationStatus) { 269 | val amplitude = getFloatArrayAmplitude(data) 270 | 271 | if (isPaused.get()) 272 | updateState(RecorderState.PAUSE) 273 | else { 274 | 275 | if (silenceDetection) { 276 | handleFloatSilenceState( 277 | amplitude, 278 | lastSkippedData, 279 | data, 280 | bufferSizeToKeep 281 | ) 282 | } 283 | 284 | if (!isSkipping.get()) { 285 | updateState(RecorderState.RECORDING) 286 | lastSkippedData.forEach { 287 | fileDurationInMillis += calculateDurationInMillis(it, waveConfig) 288 | } 289 | fileWriter.writeDataToStream(lastSkippedData, data) 290 | fileDurationInMillis += calculateDurationInMillis(data, waveConfig) 291 | } 292 | } 293 | updateListeners(amplitude, fileDurationInMillis.toLong()) 294 | } 295 | } 296 | updateState(RecorderState.STOP) 297 | cleanup(outputStream) 298 | } 299 | 300 | private fun getByteArrayAmplitude(data: ByteArray): Int { 301 | return if (onAmplitudeListener != null || silenceDetection) calculateAmplitude( 302 | data = data, 303 | audioFormat = waveConfig.audioEncoding 304 | ) else 0 305 | } 306 | 307 | private fun getFloatArrayAmplitude(data: FloatArray): Int { 308 | return if (onAmplitudeListener != null || silenceDetection) calculateAmplitude( 309 | data = data, 310 | ) else 0 311 | } 312 | 313 | private suspend fun handleByteSilenceState( 314 | amplitude: Int, 315 | lastSkippedData: LinkedList, 316 | data: ByteArray, 317 | bufferSizeToKeep: Int 318 | ) { 319 | if (amplitude < silenceDetectionConfig.minAmplitudeThreshold) { 320 | silenceDuration += calculateDurationInMillis(data, waveConfig) 321 | if (silenceDuration.toLong() >= silenceDetectionConfig.preSilenceDurationInMillis) { 322 | if (!isSkipping.get()) { 323 | isSkipping.set(true) 324 | updateState(RecorderState.SKIPPING_SILENCE) 325 | } 326 | lastSkippedData.addLast(data.copyOf()) 327 | if (lastSkippedData.sumOf { it.size } > bufferSizeToKeep) { 328 | lastSkippedData.removeFirst() 329 | } 330 | } 331 | } else { 332 | silenceDuration = BigDecimal.ZERO 333 | isSkipping.set(false) 334 | } 335 | } 336 | 337 | private suspend fun handleFloatSilenceState( 338 | amplitude: Int, 339 | lastSkippedData: LinkedList, 340 | data: FloatArray, 341 | bufferSizeToKeep: Int 342 | ) { 343 | if (amplitude < silenceDetectionConfig.minAmplitudeThreshold) { 344 | silenceDuration += calculateDurationInMillis(data, waveConfig) 345 | if (silenceDuration.toLong() >= silenceDetectionConfig.preSilenceDurationInMillis) { 346 | 347 | if (!isSkipping.get()) { 348 | isSkipping.set(true) 349 | updateState(RecorderState.SKIPPING_SILENCE) 350 | } 351 | lastSkippedData.addLast(data.copyOf()) 352 | if (lastSkippedData.sumOf { it.size } > bufferSizeToKeep) { 353 | lastSkippedData.removeFirst() 354 | } 355 | } 356 | } else { 357 | silenceDuration = BigDecimal.ZERO 358 | isSkipping.set(false) 359 | } 360 | } 361 | 362 | private suspend fun updateListeners(amplitude: Int, fileDurationInMillis: Long) { 363 | withContext(Dispatchers.Main) { 364 | onAmplitudeListener?.let { 365 | it(amplitude) 366 | } 367 | onTimeElapsed?.let { 368 | it(TimeUnit.MILLISECONDS.toSeconds(fileDurationInMillis)) 369 | } 370 | onTimeElapsedInMillis?.let { 371 | it(fileDurationInMillis) 372 | } 373 | } 374 | } 375 | 376 | 377 | private suspend fun updateState(state: RecorderState) { 378 | 379 | if (currentState != state) { 380 | currentState = state 381 | withContext(Dispatchers.Main) { 382 | onStateChangeListener?.let { 383 | it(state) 384 | } 385 | } 386 | } 387 | } 388 | 389 | private fun cleanup(outputStream: OutputStream) { 390 | outputStream.close() 391 | noiseSuppressor?.release() 392 | } 393 | 394 | /** Changes @property filePath to @param newFilePath 395 | * Calling this method while still recording throws an IllegalStateException 396 | */ 397 | fun changeFilePath(newFilePath: String) { 398 | if (isAudioRecorderInitialized() && audioRecorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) 399 | throw IllegalStateException("Cannot change filePath when still recording.") 400 | else 401 | filePath = newFilePath 402 | } 403 | 404 | /** Changes @property fileUri to @param newFilePath 405 | * Calling this method while still recording throws an IllegalStateException 406 | */ 407 | fun changeFilePath(newFileUri: Uri) { 408 | if (isAudioRecorderInitialized() && audioRecorder.recordingState == AudioRecord.RECORDSTATE_RECORDING) 409 | throw IllegalStateException("Cannot change filePath when still recording.") 410 | else 411 | fileUri = newFileUri 412 | } 413 | 414 | /** 415 | * Stops audio recorder and release resources then writes recorded file headers. 416 | */ 417 | fun stopRecording() { 418 | if (isAudioRecorderInitialized()) { 419 | audioRecorder.stop() 420 | audioRecorder.release() 421 | isPaused.set(false) 422 | isSkipping.set(false) 423 | silenceDuration = BigDecimal.ZERO 424 | audioSessionId = -1 425 | if (fileUri != null) { 426 | WaveHeaderWriter(fileUri!!, context, waveConfig).writeHeader() 427 | } else { 428 | WaveHeaderWriter(filePath!!, waveConfig).writeHeader() 429 | } 430 | 431 | } 432 | 433 | } 434 | 435 | private fun isAudioRecorderInitialized(): Boolean = 436 | this::audioRecorder.isInitialized && audioRecorder.state == AudioRecord.STATE_INITIALIZED 437 | 438 | /** 439 | * Pauses audio recorder 440 | */ 441 | fun pauseRecording() { 442 | isPaused.set(true) 443 | } 444 | 445 | /** 446 | * Resumes audio recorder 447 | */ 448 | fun resumeRecording() { 449 | silenceDuration = BigDecimal.ZERO 450 | isSkipping.set(false) 451 | isPaused.set(false) 452 | } 453 | 454 | } 455 | -------------------------------------------------------------------------------- /android-wave-recorder/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Android Wave Recorder 3 | 4 | -------------------------------------------------------------------------------- /android-wave-recorder/src/test/java/com/github/squti/androidwaverecorder/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.squti.androidwaverecorder 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | 8 | } 9 | dependencies { 10 | classpath 'com.android.tools.build:gradle:8.3.2' 11 | classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' 12 | // NOTE: Do not place your application dependencies here; they belong 13 | // in the individual module build.gradle files 14 | } 15 | } 16 | 17 | allprojects { 18 | repositories { 19 | google() 20 | mavenCentral() 21 | } 22 | } 23 | 24 | task clean(type: Delete) { 25 | delete rootProject.buildDir 26 | } 27 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official 22 | android.nonTransitiveRClass=false 23 | android.nonFinalResIds=false 24 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squti/Android-Wave-Recorder/9ddcb0cafd0004425eb726fc32d07b30e2f73d03/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Oct 30 18:23:54 IRST 2019 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /sample/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | apply plugin: 'kotlin-android' 4 | 5 | android { 6 | defaultConfig { 7 | compileSdk 33 8 | applicationId "com.github.squti.androidwaverecordersample" 9 | minSdkVersion 16 10 | targetSdkVersion 33 11 | versionCode 1 12 | versionName "1.0" 13 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 14 | } 15 | viewBinding { 16 | enabled = true 17 | } 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | namespace 'com.github.squti.androidwaverecordersample' 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_17 27 | targetCompatibility JavaVersion.VERSION_17 28 | } 29 | kotlinOptions { 30 | jvmTarget = '17' 31 | } 32 | } 33 | 34 | dependencies { 35 | implementation fileTree(dir: 'libs', include: ['*.jar']) 36 | implementation 'androidx.appcompat:appcompat:1.6.1' 37 | implementation 'androidx.core:core-ktx:1.10.1' 38 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 39 | testImplementation 'junit:junit:4.13.2' 40 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 41 | implementation project(':android-wave-recorder') 42 | } 43 | -------------------------------------------------------------------------------- /sample/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /sample/src/androidTest/java/com/github/squti/androidwaverecordersample/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.squti.androidwaverecordersample 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.github.squti.androidwaverecordersample", appContext.packageName) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /sample/src/main/java/com/github/squti/androidwaverecordersample/MainActivity.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * MIT License 3 | * 4 | * Copyright (c) 2019 squti 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | package com.github.squti.androidwaverecordersample 26 | 27 | import android.Manifest 28 | import android.R 29 | import android.content.ContentUris 30 | import android.content.ContentValues 31 | import android.content.Intent 32 | import android.content.pm.PackageManager 33 | import android.media.AudioFormat 34 | import android.net.Uri 35 | import android.os.Build 36 | import android.os.Bundle 37 | import android.os.Environment 38 | import android.provider.MediaStore 39 | import android.provider.Settings 40 | import android.util.Log 41 | import android.view.View 42 | import android.widget.AdapterView 43 | import android.widget.ArrayAdapter 44 | import android.widget.Toast 45 | import androidx.appcompat.app.AlertDialog 46 | import androidx.appcompat.app.AppCompatActivity 47 | import androidx.core.app.ActivityCompat 48 | import androidx.core.content.ContextCompat 49 | import com.github.squti.androidwaverecorder.RecorderState 50 | import com.github.squti.androidwaverecorder.WaveRecorder 51 | import com.github.squti.androidwaverecordersample.databinding.ActivityMainBinding 52 | import java.io.File 53 | import java.util.Locale 54 | import java.util.concurrent.TimeUnit 55 | 56 | class MainActivity : AppCompatActivity() { 57 | private val PERMISSIONS_REQUEST_RECORD_AUDIO = 77 58 | private val PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE = 88 59 | 60 | private lateinit var waveRecorder: WaveRecorder 61 | private lateinit var filePath: String 62 | private var isRecording = false 63 | private var isPaused = false 64 | private lateinit var binding: ActivityMainBinding 65 | private var selectedEncoding = AudioFormat.ENCODING_PCM_FLOAT 66 | private val encodingOptions = listOf( 67 | "8-bit" to AudioFormat.ENCODING_PCM_8BIT, 68 | "16-bit" to AudioFormat.ENCODING_PCM_16BIT, 69 | "32-bit" to AudioFormat.ENCODING_PCM_32BIT, 70 | "Float 32-bit" to AudioFormat.ENCODING_PCM_FLOAT 71 | ) 72 | private var isSaveToExternalStorageFlag = false 73 | override fun onCreate(savedInstanceState: Bundle?) { 74 | super.onCreate(savedInstanceState) 75 | binding = ActivityMainBinding.inflate(layoutInflater) 76 | setContentView(binding.root) 77 | val encodingSpinner = binding.encodingSpinner 78 | val adapter = ArrayAdapter( 79 | this, 80 | R.layout.simple_spinner_item, 81 | encodingOptions.map { it.first } // Display names only 82 | ) 83 | adapter.setDropDownViewResource(R.layout.simple_spinner_dropdown_item) 84 | 85 | encodingSpinner.adapter = adapter 86 | encodingSpinner.setSelection(encodingOptions.indexOfFirst { it.second == AudioFormat.ENCODING_PCM_FLOAT }) 87 | encodingSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { 88 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { 89 | selectedEncoding = encodingOptions[position].second 90 | initRecorder(isSaveToExternalStorageFlag) 91 | } 92 | 93 | override fun onNothingSelected(parent: AdapterView<*>?) {} 94 | } 95 | initRecorder(isSaveToExternalStorage = false) 96 | 97 | binding.saveToExternalStorageSwitch.setOnCheckedChangeListener { _, isChecked -> 98 | if (!isChecked) { 99 | resetSwitches() 100 | initRecorder(isSaveToExternalStorage = false) 101 | return@setOnCheckedChangeListener 102 | } 103 | 104 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 105 | resetSwitches() 106 | initRecorder(isSaveToExternalStorage = true) 107 | return@setOnCheckedChangeListener 108 | } 109 | 110 | if (ContextCompat.checkSelfPermission( 111 | this, 112 | Manifest.permission.WRITE_EXTERNAL_STORAGE 113 | ) == PackageManager.PERMISSION_GRANTED 114 | ) { 115 | resetSwitches() 116 | initRecorder(isSaveToExternalStorage = true) 117 | return@setOnCheckedChangeListener 118 | } 119 | 120 | if (ActivityCompat.shouldShowRequestPermissionRationale( 121 | this, 122 | Manifest.permission.WRITE_EXTERNAL_STORAGE 123 | ) 124 | ) { 125 | showPermissionSettingsDialog() 126 | } else { 127 | ActivityCompat.requestPermissions( 128 | this, 129 | arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), 130 | PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE 131 | ) 132 | } 133 | } 134 | 135 | binding.startStopRecordingButton.setOnClickListener { 136 | if (isRecording) { 137 | waveRecorder.stopRecording() 138 | return@setOnClickListener 139 | } 140 | 141 | if (ContextCompat.checkSelfPermission( 142 | this, 143 | Manifest.permission.RECORD_AUDIO 144 | ) == PackageManager.PERMISSION_GRANTED 145 | ) { 146 | waveRecorder.startRecording() 147 | return@setOnClickListener 148 | } 149 | 150 | if (ActivityCompat.shouldShowRequestPermissionRationale( 151 | this, 152 | Manifest.permission.RECORD_AUDIO 153 | ) 154 | ) { 155 | showPermissionSettingsDialog() 156 | } else { 157 | ActivityCompat.requestPermissions( 158 | this, 159 | arrayOf(Manifest.permission.RECORD_AUDIO), 160 | PERMISSIONS_REQUEST_RECORD_AUDIO 161 | ) 162 | } 163 | } 164 | 165 | binding.pauseResumeRecordingButton.setOnClickListener { 166 | if (!isPaused) { 167 | waveRecorder.pauseRecording() 168 | } else { 169 | waveRecorder.resumeRecording() 170 | } 171 | } 172 | binding.showAmplitudeSwitch.setOnCheckedChangeListener { _, isChecked -> 173 | if (this::waveRecorder.isInitialized) { 174 | if (isChecked) { 175 | binding.amplitudeTextView.text = "Amplitude : 0" 176 | binding.amplitudeTextView.visibility = View.VISIBLE 177 | waveRecorder.onAmplitudeListener = { 178 | binding.amplitudeTextView.text = "Amplitude : $it" 179 | } 180 | 181 | } else { 182 | waveRecorder.onAmplitudeListener = null 183 | binding.amplitudeTextView.visibility = View.GONE 184 | } 185 | } 186 | } 187 | 188 | binding.silenceDetectionSwitch.setOnCheckedChangeListener { _, isChecked -> 189 | if (this::waveRecorder.isInitialized) { 190 | waveRecorder.silenceDetection = isChecked 191 | if (isChecked) 192 | Toast.makeText(this, "Silence Detection Activated", Toast.LENGTH_SHORT).show() 193 | } 194 | } 195 | 196 | binding.noiseSuppressorSwitch.setOnCheckedChangeListener { _, isChecked -> 197 | if (this::waveRecorder.isInitialized) { 198 | waveRecorder.noiseSuppressorActive = isChecked 199 | 200 | if (isChecked) { 201 | Toast.makeText(this, "Noise Suppressor Activated", Toast.LENGTH_SHORT).show() 202 | } 203 | } 204 | 205 | } 206 | } 207 | 208 | private fun resetSwitches() { 209 | binding.showAmplitudeSwitch.isChecked = false 210 | binding.silenceDetectionSwitch.isChecked = false 211 | binding.noiseSuppressorSwitch.isChecked = false 212 | } 213 | 214 | private fun initRecorder(isSaveToExternalStorage: Boolean) { 215 | isSaveToExternalStorageFlag = isSaveToExternalStorage 216 | if (isSaveToExternalStorage) 217 | initWithExternalStorage("audioFile") 218 | else 219 | initWithInternalStorage("audioFile") 220 | 221 | waveRecorder.onStateChangeListener = { 222 | Log.d("RecorderState : ", it.name) 223 | 224 | when (it) { 225 | RecorderState.RECORDING -> startRecording() 226 | RecorderState.STOP -> stopRecording() 227 | RecorderState.PAUSE -> pauseRecording() 228 | RecorderState.SKIPPING_SILENCE -> skipRecording() 229 | } 230 | } 231 | waveRecorder.onTimeElapsedInMillis = { 232 | binding.timeTextView.text = formatTimeUnit(it) 233 | } 234 | } 235 | 236 | 237 | private fun startRecording() { 238 | Log.d(TAG, "Recording Started") 239 | isRecording = true 240 | isPaused = false 241 | binding.messageTextView.visibility = View.GONE 242 | binding.recordingTextView.text = "Recording..." 243 | binding.recordingTextView.visibility = View.VISIBLE 244 | binding.startStopRecordingButton.text = "STOP" 245 | binding.pauseResumeRecordingButton.text = "PAUSE" 246 | binding.pauseResumeRecordingButton.visibility = View.VISIBLE 247 | binding.noiseSuppressorSwitch.isEnabled = false 248 | binding.encodingSpinner.isEnabled = false 249 | } 250 | 251 | private fun skipRecording() { 252 | Log.d(TAG, "Recording Skipped") 253 | isRecording = true 254 | isPaused = false 255 | binding.messageTextView.visibility = View.GONE 256 | binding.recordingTextView.text = "Skipping..." 257 | binding.recordingTextView.visibility = View.VISIBLE 258 | binding.startStopRecordingButton.text = "STOP" 259 | binding.pauseResumeRecordingButton.visibility = View.INVISIBLE 260 | binding.noiseSuppressorSwitch.isEnabled = false 261 | binding.encodingSpinner.isEnabled = false 262 | } 263 | 264 | private fun stopRecording() { 265 | Log.d(TAG, "Recording Stopped") 266 | isRecording = false 267 | isPaused = false 268 | binding.recordingTextView.visibility = View.GONE 269 | binding.messageTextView.visibility = View.VISIBLE 270 | binding.pauseResumeRecordingButton.visibility = View.GONE 271 | binding.startStopRecordingButton.text = "START" 272 | binding.noiseSuppressorSwitch.isEnabled = true 273 | binding.encodingSpinner.isEnabled = true 274 | resetSwitches() 275 | Toast.makeText(this, "File saved at : $filePath", Toast.LENGTH_LONG).show() 276 | } 277 | 278 | private fun pauseRecording() { 279 | Log.d(TAG, "Recording Paused") 280 | binding.recordingTextView.text = "PAUSE" 281 | binding.pauseResumeRecordingButton.text = "RESUME" 282 | isPaused = true 283 | } 284 | 285 | override fun onRequestPermissionsResult( 286 | requestCode: Int, 287 | permissions: Array, 288 | grantResults: IntArray 289 | ) { 290 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 291 | if (requestCode == PERMISSIONS_REQUEST_RECORD_AUDIO && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { 292 | waveRecorder.startRecording() 293 | } 294 | if (requestCode == PERMISSIONS_REQUEST_WRITE_EXTERNAL_STORAGE && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { 295 | resetSwitches() 296 | initRecorder(isSaveToExternalStorage = true) 297 | } 298 | } 299 | 300 | companion object { 301 | private const val TAG = "MainActivity" 302 | } 303 | 304 | private fun formatTimeUnit(timeInMilliseconds: Long): String { 305 | return try { 306 | String.format( 307 | Locale.getDefault(), 308 | "%02d:%02d:%03d", 309 | TimeUnit.MILLISECONDS.toMinutes(timeInMilliseconds), 310 | TimeUnit.MILLISECONDS.toSeconds(timeInMilliseconds) - TimeUnit.MINUTES.toSeconds( 311 | TimeUnit.MILLISECONDS.toMinutes(timeInMilliseconds) 312 | ), 313 | timeInMilliseconds % 1000 314 | ) 315 | } catch (e: Exception) { 316 | "00:00:000" 317 | } 318 | } 319 | 320 | private fun initWithExternalStorage(fileName: String) { 321 | val folderName = "Android-Wave-Recorder" 322 | val audioUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 323 | MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) 324 | } else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 325 | val path = 326 | Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC).absolutePath + "/$folderName/$fileName.wav" 327 | val contentValues = ContentValues().apply { 328 | put(MediaStore.Audio.Media.DISPLAY_NAME, "$fileName.wav") 329 | put(MediaStore.Audio.Media.MIME_TYPE, "audio/x-wav") 330 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 331 | put(MediaStore.Audio.Media.RELATIVE_PATH, "Music/$folderName") 332 | } else { 333 | val file = File(path) 334 | val parentFile = file.parentFile 335 | if (parentFile != null && !parentFile.exists()) { 336 | parentFile.mkdirs() 337 | } 338 | put(MediaStore.Audio.AudioColumns.DATA, path) 339 | } 340 | } 341 | 342 | try { 343 | val existingUri = contentResolver.query( 344 | audioUri, 345 | arrayOf(MediaStore.Audio.Media._ID), 346 | "${MediaStore.Audio.AudioColumns.DATA}=?", 347 | arrayOf(path), 348 | null 349 | )?.use { cursor -> 350 | if (cursor.moveToFirst()) { 351 | ContentUris.withAppendedId( 352 | audioUri, 353 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)) 354 | ) 355 | } else { 356 | null 357 | } 358 | } 359 | 360 | val uri = existingUri ?: contentResolver.insert(audioUri, contentValues) 361 | uri?.let { 362 | waveRecorder = WaveRecorder(it, context = this) 363 | .configureWaveSettings { 364 | sampleRate = 44100 365 | channels = AudioFormat.CHANNEL_IN_MONO 366 | audioEncoding = selectedEncoding 367 | }.configureSilenceDetection { 368 | minAmplitudeThreshold = 2000 369 | bufferDurationInMillis = 1500 370 | preSilenceDurationInMillis = 1500 371 | } 372 | filePath = "/Music/$folderName/$fileName.wav" 373 | } 374 | } catch (e: Exception) { 375 | e.printStackTrace() 376 | } 377 | } 378 | 379 | private fun initWithInternalStorage(fileName: String) { 380 | 381 | filePath = filesDir.absolutePath + "/$fileName.wav" 382 | 383 | waveRecorder = WaveRecorder(filePath) 384 | .configureWaveSettings { 385 | sampleRate = 44100 386 | channels = AudioFormat.CHANNEL_IN_MONO 387 | audioEncoding = selectedEncoding 388 | }.configureSilenceDetection { 389 | minAmplitudeThreshold = 2000 390 | bufferDurationInMillis = 1500 391 | preSilenceDurationInMillis = 1500 392 | } 393 | } 394 | 395 | private fun showPermissionSettingsDialog() { 396 | AlertDialog.Builder(this) 397 | .setTitle("Permission Required") 398 | .setMessage("This app needs audio recording permission to function. Please grant the permission in the app settings.") 399 | .setPositiveButton("Go to Settings") { _, _ -> 400 | val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { 401 | data = Uri.fromParts("package", packageName, null) 402 | } 403 | startActivity(intent) 404 | } 405 | .setNegativeButton("Cancel", null) 406 | .show() 407 | } 408 | 409 | } -------------------------------------------------------------------------------- /sample/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /sample/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /sample/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 25 | 26 | 32 | 33 | 43 | 44 | 45 |