Skip to content

Commit 755ca3b

Browse files
criticalAYdavid-allison
authored andcommitted
feat: new audio recorder class
- Consolidation of duplicate AudioRecorder classes into a single one - Added support for AAC (High Quality) with a fallback to AMR_NB (Standard Quality).
1 parent 35a613c commit 755ca3b

1 file changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
* Copyright (c) 2013 Zaur Molotnikov <qutorial@gmail.com>
3+
* Copyright (c) 2013 Nicolas Raoul <nicolas.raoul@gmail.com>
4+
* Copyright (c) 2013 Flavio Lerda <flerda@gmail.com>
5+
* Copyright (c) 2021 David Allison <davidallisongithub@gmail.com>
6+
* Copyright (c) 2025 Brayan Oliveira <69634269+brayandso@users.noreply.github.com>
7+
* Copyright (c) 2025 Ashish Yadav <mailtoashish693@gmail.com>
8+
*
9+
* This program is free software; you can redistribute it and/or modify it under
10+
* the terms of the GNU General Public License as published by the Free Software
11+
* Foundation; either version 3 of the License, or (at your option) any later
12+
* version.
13+
*
14+
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
15+
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16+
* FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17+
* details.
18+
*
19+
* You should have received a copy of the GNU General Public License along with
20+
* this program. If not, see <http://www.gnu.org/licenses/>.
21+
*/
22+
23+
package com.ichi2.anki.recorder
24+
25+
import android.content.Context
26+
import android.media.MediaRecorder
27+
import com.ichi2.compat.CompatHelper
28+
import timber.log.Timber
29+
import java.io.Closeable
30+
import java.io.File
31+
32+
/**
33+
* A robust wrapper for [MediaRecorder] designed for usage in both Activities and Services.
34+
*
35+
* This class handles hardware fallbacks (AAC to AMR), state management for pausing/resuming,
36+
* and automatic resource cleanup via [Closeable].
37+
*
38+
* @property context The context used to initialize the MediaRecorder.
39+
*/
40+
class AudioRecorder(
41+
private val context: Context,
42+
) : Closeable {
43+
private var recorder: MediaRecorder? = null
44+
45+
/**
46+
* Indicates whether the recorder is currently capturing audio.
47+
*/
48+
var isRecording = false
49+
private set
50+
51+
/**
52+
* The file currently being used for recording. Null if not recording.
53+
*/
54+
var currentFile: File? = null
55+
private set
56+
57+
/**
58+
* Starts audio capture.
59+
*
60+
* If [file] is provided, it will be used as the destination. Otherwise, a temporary
61+
* file is created in the app's cache directory.
62+
*
63+
* @param file The destination file for the recording.
64+
* @throws IllegalStateException if called while already recording.
65+
*/
66+
fun start(file: File? = null) {
67+
Timber.i("AudioRecorder::startRecording (isRecording %b)", isRecording)
68+
if (isRecording) return
69+
70+
val target = file ?: createTempFile() ?: return
71+
currentFile = target
72+
73+
val mediaRecorder = recorder ?: CompatHelper.compat.getMediaRecorder(context).also { recorder = it }
74+
75+
// Attempt High Quality (AAC), fallback to Standard (AMR) if it fails
76+
if (!configure(mediaRecorder, target, useHighQuality = true)) {
77+
Timber.i("HQ configuration failed, falling back to AMR_NB")
78+
mediaRecorder.reset()
79+
configure(mediaRecorder, target, useHighQuality = false)
80+
}
81+
82+
try {
83+
mediaRecorder.start()
84+
isRecording = true
85+
} catch (e: IllegalStateException) {
86+
Timber.w(e, "MediaRecorder started in wrong state")
87+
} catch (e: Exception) {
88+
Timber.w(e, "MediaRecorder start failed")
89+
}
90+
}
91+
92+
/**
93+
* Configures the [MediaRecorder] parameters.
94+
*
95+
* @param mediaRecorder The instance to configure.
96+
* @param file The output file.
97+
* @param useHighQuality If true, uses AAC @ 44.1kHz. If false, uses AMR_NB.
98+
* @return True if configuration and [MediaRecorder.prepare] succeeded.
99+
*/
100+
private fun configure(
101+
mediaRecorder: MediaRecorder,
102+
file: File,
103+
useHighQuality: Boolean,
104+
): Boolean =
105+
try {
106+
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC)
107+
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
108+
mediaRecorder.setOutputFile(file.absolutePath)
109+
110+
if (useHighQuality) {
111+
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
112+
mediaRecorder.setAudioSamplingRate(44100)
113+
mediaRecorder.setAudioEncodingBitRate(192000)
114+
mediaRecorder.setAudioChannels(2)
115+
} else {
116+
mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
117+
}
118+
119+
mediaRecorder.prepare()
120+
true
121+
} catch (e: Exception) {
122+
Timber.w("Configuration failed: ${e.message}")
123+
false
124+
}
125+
126+
/**
127+
* Stops the recording and updates state.
128+
*/
129+
fun stop() {
130+
if (!isRecording) return
131+
132+
try {
133+
recorder?.stop()
134+
} catch (e: RuntimeException) {
135+
Timber.w(e, "Failed to stop recorder: likely called too soon after start")
136+
currentFile?.delete()
137+
currentFile = null
138+
} finally {
139+
isRecording = false
140+
}
141+
}
142+
143+
/**
144+
* Pauses the current recording session.
145+
*/
146+
fun pause() {
147+
if (isRecording) {
148+
recorder?.pause()
149+
}
150+
}
151+
152+
/**
153+
* Resumes a previously paused recording session.
154+
*/
155+
fun resume() {
156+
if (isRecording) {
157+
recorder?.resume()
158+
}
159+
}
160+
161+
/**
162+
* Retrieves the maximum absolute amplitude sampled since the last call.
163+
*/
164+
fun getMaxAmplitude(): Int = recorder?.maxAmplitude ?: 0
165+
166+
/**
167+
* Generates a temporary file in the application's cache directory.
168+
* Returns null if the file cannot be created due to storage issues or
169+
* restricted permissions.
170+
*/
171+
private fun createTempFile(): File? =
172+
try {
173+
File.createTempFile("rec_", ".3gp", context.cacheDir)
174+
} catch (e: Exception) {
175+
Timber.w(e, "Could not create temporary recording file")
176+
null
177+
}
178+
179+
/**
180+
* Stops recording and releases the [MediaRecorder] resources.
181+
* Should be called in `onDestroy()` or when the class is no longer needed.
182+
*/
183+
override fun close() {
184+
stop()
185+
recorder?.release()
186+
recorder = null
187+
}
188+
}

0 commit comments

Comments
 (0)