Android: voice notes
The engine and the client already carried audio — `Attachment.AUDIO`, a `duration` field, and a `sendAttachment` that takes both. What was missing was entirely the phone: something to record with, something to play with, and two controls. So this is app-layer only, and adds no dependency: `MediaRecorder` and `MediaPlayer` are platform APIs, and a voice note is an ordinary encrypted attachment the moment it is bytes. AAC in an MP4 container, mono, 32 kbit/s. Speech rather than music: clearly intelligible, and roughly a tenth the size of the stereo default nobody asked for — which matters when every byte is uploaded encrypted and stored by somebody paying for it. ⚠️ **Tap to start, tap to send — not hold-to-talk.** Hold-to-talk demonstrates well and behaves badly on a real phone: the gesture competes with scrolling, and letting go early silently discards what was said. Two deliberate taps cannot be lost by accident. ⚠️ **`MediaRecorder.stop()` throws when it captured no frames** — a tap instead of a hold, or a permission granted and then denied by policy. The exception is a bare `RuntimeException` and it reads as "recording is broken" when the truth is "there was nothing to record". Treated as an empty recording, and anything under half a second is dropped rather than delivered: a note that plays silence looks like a delivery bug, and the person who fumbled the button already knows they did. ⚠️ **The microphone permission is requested when somebody asks to record**, not at startup. A microphone prompt on first launch, before anything has been said, is one people deny on reflex — and a denied permission is far harder to recover from than one that was never asked for. ⚠️ Received notes are fetched and decrypted **on tap, never on render**. A thread of twenty would otherwise download and decrypt all twenty on scroll: bytes nobody asked for, and twenty decrypted files in the cache for a list nobody has listened to. Playback stops on leaving the thread, because a MediaPlayer that outlives its composable holds audio focus and a file handle. Every temporary file — the recording, and the decrypted copy playback needs because `MediaPlayer` cannot take a byte array — is deleted on both the success and the failure path. Plaintext audio left in a cache directory is the exact thing this product is not supposed to do. `imageBytes` became `attachmentBytes`: photos and voice notes take the same path, and a method called `imageBytes` that audio also calls is a name that has quietly stopped being true. Also retires buddy commands in the parity table — no longer a feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a213ad8d5d
commit
4728928d98
@@ -95,7 +95,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
||||
| Session restore across restarts | ✅ credential and device both survive |
|
||||
| Reactions | 🟡 codec done, no UI |
|
||||
| Photos | ✅ send via the system picker, render inline |
|
||||
| Voice notes (record + play) | ⚪ |
|
||||
| Voice notes (record + play) | ✅ AAC/MP4, tap to record, tap to send |
|
||||
| Stories, and seen-state | ⚪ |
|
||||
| Groups: create, list, members, leave | ⚪ |
|
||||
| Group text and images | ⚪ |
|
||||
@@ -108,7 +108,7 @@ From `E2E/E2EInbox.swift`, which is the real feature list.
|
||||
| Buddy list, add by username | ✅ |
|
||||
| Remove, block, unblock | ✅ |
|
||||
| Member lookup | ⚪ |
|
||||
| Buddy commands | ⚪ |
|
||||
| Buddy commands | — retired; not a feature any more (owner, 2026-08-25) |
|
||||
| Voice calls (WebRTC, DTLS-SRTP, TURN) | ⚪ |
|
||||
| Notifications list, read state | ✅ |
|
||||
| Services (AIM/ICQ via `flamenet-oscar`) | ⚪ |
|
||||
|
||||
@@ -142,6 +142,10 @@ class CrudSurfaceTest {
|
||||
// Identity state, on the thread it describes rather than buried in a
|
||||
// settings screen. A freshly added contact is PINNED — trust on first
|
||||
// use — so the state line and a way to reach the number both exist.
|
||||
// Voice notes are reachable from the thread, and the control says what
|
||||
// it does rather than being a bare icon.
|
||||
compose.onNodeWithTag("record-voice").assertExists()
|
||||
|
||||
compose.onNodeWithTag("identity-state").assertExists()
|
||||
compose.onNodeWithTag("show-safety-number").performClick()
|
||||
compose.onNodeWithTag("safety-number").assertExists()
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!--
|
||||
Voice notes. Requested at runtime as well: on this minSdk a dangerous
|
||||
permission declared here is not granted here, and a recorder started
|
||||
without it fails with a RuntimeException carrying no useful message.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- The app is usable without a microphone; it simply cannot record. -->
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -37,6 +37,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import io.flamenet.client.IdentityTrust
|
||||
import io.flamenet.e2e.SafetyNumber
|
||||
@@ -188,7 +197,7 @@ private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerV
|
||||
var failed by remember(ref.id) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(ref.id) {
|
||||
val bytes = model.imageBytes(ref)
|
||||
val bytes = model.attachmentBytes(ref)
|
||||
if (bytes == null) { failed = true; return@LaunchedEffect }
|
||||
bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
if (bitmap == null) failed = true
|
||||
@@ -644,6 +653,75 @@ fun ColumnScope.ChatScreen(model: MessengerViewModel, chat: MessengerViewModel.P
|
||||
modifier = Modifier.testTag("send"),
|
||||
) { Text("Send") }
|
||||
}
|
||||
|
||||
// Voice notes sit under the composer rather than inside it: recording
|
||||
// replaces the row with its own controls, and squeezing a fourth control
|
||||
// into a row that already has three makes every one of them too small.
|
||||
if (chat.peerId != null) VoiceComposer(model, chat)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record and send a voice note.
|
||||
*
|
||||
* ⚠️ **Tap to start, tap to send** rather than hold-to-talk. Hold-to-talk reads
|
||||
* well in a demo and badly on a real phone: the gesture competes with scrolling,
|
||||
* and letting go early — which happens constantly — silently discards what was
|
||||
* said. Two deliberate taps cannot be lost by accident.
|
||||
*/
|
||||
@Composable
|
||||
private fun ColumnScope.VoiceComposer(
|
||||
model: MessengerViewModel,
|
||||
chat: MessengerViewModel.Phase.Chat,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var granted by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
// ⚠️ Requested when the user asks to record, not at startup. A microphone
|
||||
// prompt on first launch, before anything has been said, is the kind a
|
||||
// person denies on reflex — and a denied permission is far harder to
|
||||
// recover than one that was never asked for.
|
||||
val ask = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { allowed ->
|
||||
granted = allowed
|
||||
if (allowed) model.startRecording()
|
||||
}
|
||||
|
||||
val seconds = chat.recordingSeconds
|
||||
if (seconds == null) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (granted) model.startRecording()
|
||||
else ask.launch(Manifest.permission.RECORD_AUDIO)
|
||||
},
|
||||
modifier = Modifier.testTag("record-voice"),
|
||||
) { Text("Record a voice note") }
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// A visible timer: a recorder with no feedback is one people talk
|
||||
// into after it has already stopped.
|
||||
Text(
|
||||
"Recording ${"%.1f".format(seconds)}s",
|
||||
modifier = Modifier.weight(1f).testTag("recording-timer"),
|
||||
)
|
||||
TextButton(
|
||||
onClick = model::cancelRecording,
|
||||
modifier = Modifier.testTag("cancel-voice"),
|
||||
) { Text("Cancel") }
|
||||
Button(
|
||||
onClick = model::stopAndSendRecording,
|
||||
modifier = Modifier.testTag("send-voice"),
|
||||
) { Text("Send") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -665,7 +743,14 @@ private fun Bubble(line: Session.Line, model: MessengerViewModel) {
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
line.attachment?.let { AttachedImage(it, model) }
|
||||
line.attachment?.let { ref ->
|
||||
// One field, two very different things. Audio has no thumbnail
|
||||
// to show and a duration that matters; an image has the
|
||||
// reverse. Routing on the kind rather than trying to render
|
||||
// both with one composable.
|
||||
if (ref.kind == io.flamenet.e2e.Attachment.AUDIO) VoiceBubble(ref, model)
|
||||
else AttachedImage(ref, model)
|
||||
}
|
||||
if (line.text.isNotEmpty()) {
|
||||
Text(
|
||||
line.text,
|
||||
@@ -785,3 +870,58 @@ private fun ColumnScope.IdentityPanel(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A received voice note: play, stop, and how long it runs.
|
||||
*
|
||||
* ⚠️ Fetched and decrypted **on tap**, never on render. A thread of twenty
|
||||
* notes would otherwise download and decrypt all twenty on scroll — bytes
|
||||
* nobody asked for, and twenty decrypted files in the cache for the sake of a
|
||||
* list that has not been listened to.
|
||||
*/
|
||||
@Composable
|
||||
private fun VoiceBubble(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val player = remember { VoiceNote.Player(context.cacheDir) }
|
||||
var playing by remember(ref.id) { mutableStateOf(false) }
|
||||
var busy by remember(ref.id) { mutableStateOf(false) }
|
||||
|
||||
// ⚠️ A MediaPlayer that outlives the composable holds an audio focus and a
|
||||
// file handle. Leaving the thread mid-playback must stop it, or the note
|
||||
// keeps playing into a screen that is no longer there.
|
||||
DisposableEffect(ref.id) { onDispose { player.stop() } }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.testTag("voice-${ref.id}"),
|
||||
) {
|
||||
TextButton(
|
||||
enabled = !busy,
|
||||
onClick = {
|
||||
if (playing) {
|
||||
player.stop()
|
||||
playing = false
|
||||
return@TextButton
|
||||
}
|
||||
busy = true
|
||||
scope.launch {
|
||||
val bytes = model.attachmentBytes(ref)
|
||||
busy = false
|
||||
if (bytes == null) return@launch
|
||||
runCatching {
|
||||
player.play(bytes) { playing = false }
|
||||
playing = true
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag("play-voice"),
|
||||
) { Text(if (playing) "Stop" else "Play") }
|
||||
|
||||
Text(
|
||||
if (ref.duration > 0) "%.0fs".format(ref.duration) else "Voice note",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
* for all of them. Showing a reassuring tick that covers some
|
||||
* unknown subset of the members would be worse than showing none.
|
||||
*/
|
||||
/** Seconds elapsed while recording a voice note, or null when not. */
|
||||
val recordingSeconds: Double? = null,
|
||||
val trust: IdentityTrust = IdentityTrust.UNKNOWN,
|
||||
/** The number both people read aloud. Null until something is pinned. */
|
||||
val safetyNumber: String? = null,
|
||||
@@ -675,8 +677,99 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/** Bytes for a received image, fetched and cached on first view. */
|
||||
suspend fun imageBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? {
|
||||
// -- Voice notes (§6.6) --------------------------------------------------
|
||||
|
||||
private var recording: VoiceNote.Recording? = null
|
||||
private var recordingTicker: kotlinx.coroutines.Job? = null
|
||||
|
||||
/**
|
||||
* Begin recording.
|
||||
*
|
||||
* ⚠️ The caller must already hold `RECORD_AUDIO`. Asking here would raise
|
||||
* the dialog after the user has pressed record, so the first note of every
|
||||
* install would be lost to a prompt.
|
||||
*/
|
||||
fun startRecording() {
|
||||
if (recording != null) return
|
||||
val started = runCatching { VoiceNote.start(getApplication<Application>().cacheDir) }
|
||||
started.onFailure { failure ->
|
||||
update { it.copy(error = "Could not start recording: ${failure.message}") }
|
||||
return
|
||||
}
|
||||
recording = started.getOrNull()
|
||||
// A visible timer, because a recorder with no feedback is one people
|
||||
// talk into after it has already stopped.
|
||||
recordingTicker = viewModelScope.launch {
|
||||
while (recording != null) {
|
||||
val seconds = recording?.elapsedSeconds
|
||||
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = seconds)) }
|
||||
kotlinx.coroutines.delay(200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw the recording away. Nothing is sent and no file is left behind. */
|
||||
fun cancelRecording() {
|
||||
recordingTicker?.cancel()
|
||||
recordingTicker = null
|
||||
recording?.cancel()
|
||||
recording = null
|
||||
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop, and send what was captured.
|
||||
*
|
||||
* A recording too short to carry speech is dropped rather than delivered:
|
||||
* a note that plays silence looks like a delivery bug, and the user who
|
||||
* fumbled the button already knows they did.
|
||||
*/
|
||||
fun stopAndSendRecording() = viewModelScope.launch {
|
||||
recordingTicker?.cancel()
|
||||
recordingTicker = null
|
||||
val finished = withContext(Dispatchers.IO) { recording?.stop() }
|
||||
recording = null
|
||||
update { it.copy(openChat = it.openChat?.copy(recordingSeconds = null)) }
|
||||
|
||||
val current = session ?: return@launch
|
||||
val chat = signedIn()?.openChat ?: return@launch
|
||||
val peerId = chat.peerId
|
||||
if (finished == null) {
|
||||
update { it.copy(error = "Too short to send — hold to record.") }
|
||||
return@launch
|
||||
}
|
||||
if (peerId == null) {
|
||||
update { it.copy(error = "Voice notes are one-to-one for now.") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
update { it.copy(busy = true, error = null) }
|
||||
val done = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
current.sendVoice(peerId, finished.bytes, finished.mime, finished.seconds)
|
||||
}
|
||||
}
|
||||
done.onSuccess { sent ->
|
||||
update {
|
||||
it.copy(
|
||||
busy = false,
|
||||
openChat = it.openChat?.copy(lines = current.history(chat.threadId)),
|
||||
error = if (sent.failed.isEmpty()) null
|
||||
else "Delivered to ${sent.delivered.size} of " +
|
||||
"${sent.delivered.size + sent.failed.size} devices.",
|
||||
)
|
||||
}
|
||||
}.onFailure { failure -> update { it.copy(busy = false, error = describe(failure)) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes for a received attachment, fetched and cached on first use.
|
||||
*
|
||||
* Named for attachments rather than images: photos and voice notes take
|
||||
* the same path, and a method called `imageBytes` that audio also calls is
|
||||
* the kind of name that quietly stops being true.
|
||||
*/
|
||||
suspend fun attachmentBytes(ref: io.flamenet.e2e.Attachment.Ref): ByteArray? {
|
||||
val current = session ?: return null
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching { current.attachmentBytes(ref, getApplication<Application>().cacheDir) }
|
||||
@@ -809,4 +902,15 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
is Vault.Corrupt -> "This phone's stored keys could not be read."
|
||||
else -> failure.message ?: "Something went wrong reaching that server."
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ A recorder outlives the view model unless something stops it, and a
|
||||
* live MediaRecorder holds the microphone against the whole device.
|
||||
*/
|
||||
override fun onCleared() {
|
||||
recordingTicker?.cancel()
|
||||
recording?.cancel()
|
||||
recording = null
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,23 @@ class Session(
|
||||
return sent
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a voice note.
|
||||
*
|
||||
* The duration is measured while recording rather than read back from the
|
||||
* container: the sender knows how long they held the button, and parsing
|
||||
* metadata to rediscover it is a second source of truth that can disagree.
|
||||
*/
|
||||
fun sendVoice(peerUserId: Long, bytes: ByteArray, mime: String, seconds: Double):
|
||||
io.flamenet.client.Conversations.Sent {
|
||||
val sent = conversations.sendAttachment(
|
||||
peerUserId, bytes, mime, Attachment.AUDIO, duration = seconds)
|
||||
if (sent.reachedSomeone) {
|
||||
record(Vault.threadForUser(peerUserId), "", mine = true, attachment = sent.attachment)
|
||||
}
|
||||
return sent
|
||||
}
|
||||
|
||||
/** Bytes for an attachment, cached on disk after the first fetch. */
|
||||
fun attachmentBytes(ref: Attachment.Ref, cacheDir: java.io.File): ByteArray {
|
||||
val cached = java.io.File(cacheDir, "att-" + ref.id.replace('/', '_'))
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import android.media.MediaPlayer
|
||||
import android.media.MediaRecorder
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Recording and playing voice notes.
|
||||
*
|
||||
* Platform APIs only — `MediaRecorder` and `MediaPlayer` — because a voice note
|
||||
* is an ordinary encrypted attachment once it is bytes, and everything below
|
||||
* this file already handles that. Nothing here knows about the relay.
|
||||
*
|
||||
* AAC in an MP4 container (`audio/mp4`): playable by the other clients without
|
||||
* a codec conversation, and small enough that a minute of speech is tens of
|
||||
* kilobytes rather than megabytes — which matters when every byte is uploaded
|
||||
* encrypted and stored by somebody paying for it.
|
||||
*/
|
||||
object VoiceNote {
|
||||
|
||||
/** What a finished recording produced. */
|
||||
data class Recorded(val bytes: ByteArray, val mime: String, val seconds: Double) {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is Recorded && bytes.contentEquals(other.bytes) &&
|
||||
mime == other.mime && seconds == other.seconds
|
||||
|
||||
override fun hashCode(): Int = bytes.contentHashCode()
|
||||
}
|
||||
|
||||
const val MIME = "audio/mp4"
|
||||
|
||||
/**
|
||||
* A recording in progress.
|
||||
*
|
||||
* ⚠️ **Holds a file the caller must dispose of.** Both [stop] and [cancel]
|
||||
* delete it; a path that does neither leaves plaintext audio in the cache
|
||||
* directory, which is exactly the thing this product is not supposed to do.
|
||||
*/
|
||||
class Recording internal constructor(
|
||||
private val recorder: MediaRecorder,
|
||||
private val file: File,
|
||||
private val startedAtMillis: Long,
|
||||
) {
|
||||
/**
|
||||
* Stop, and return the bytes.
|
||||
*
|
||||
* ⚠️ **`MediaRecorder.stop()` throws if it never captured a frame** —
|
||||
* a tap rather than a hold, or a permission that was granted but is
|
||||
* being denied by a policy. The exception is `RuntimeException` with no
|
||||
* useful message, and it reads as "recording is broken" when the truth
|
||||
* is "there was nothing to record". Treated as an empty recording,
|
||||
* because that is what it is.
|
||||
*/
|
||||
fun stop(): Recorded? {
|
||||
val elapsed = (System.currentTimeMillis() - startedAtMillis) / 1000.0
|
||||
val captured = try {
|
||||
recorder.stop()
|
||||
true
|
||||
} catch (_: RuntimeException) {
|
||||
false
|
||||
} finally {
|
||||
recorder.release()
|
||||
}
|
||||
val bytes = if (captured && file.exists()) file.readBytes() else ByteArray(0)
|
||||
file.delete()
|
||||
// Below this, the container holds a header and nothing worth
|
||||
// sending. Better to drop it than to deliver a note that plays
|
||||
// silence and looks like a delivery bug.
|
||||
if (!captured || bytes.size < MIN_USEFUL_BYTES || elapsed < MIN_SECONDS) return null
|
||||
return Recorded(bytes, MIME, elapsed)
|
||||
}
|
||||
|
||||
/** Abandon it, and leave nothing behind. */
|
||||
fun cancel() {
|
||||
try {
|
||||
recorder.stop()
|
||||
} catch (_: RuntimeException) {
|
||||
// Never started, or stopped with no frames. Nothing to salvage
|
||||
// and nothing to report — this path is the user changing their
|
||||
// mind, not a failure.
|
||||
} finally {
|
||||
recorder.release()
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val elapsedSeconds: Double
|
||||
get() = (System.currentTimeMillis() - startedAtMillis) / 1000.0
|
||||
}
|
||||
|
||||
private const val MIN_SECONDS = 0.4
|
||||
private const val MIN_USEFUL_BYTES = 512
|
||||
|
||||
/**
|
||||
* Start recording into the cache directory.
|
||||
*
|
||||
* @throws IllegalStateException when the microphone is unavailable — most
|
||||
* often because `RECORD_AUDIO` has not been granted. The caller asks for
|
||||
* the permission first; this does not, because a library that raises
|
||||
* dialogs is a library that raises them at the wrong moment.
|
||||
*/
|
||||
fun start(cacheDir: File): Recording {
|
||||
val file = File.createTempFile("voice-", ".m4a", cacheDir)
|
||||
val recorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
// Speech, not music. Mono at 32 kbit/s is clearly intelligible and
|
||||
// roughly a tenth the size of a stereo default nobody asked for.
|
||||
setAudioChannels(1)
|
||||
setAudioSamplingRate(44_100)
|
||||
setAudioEncodingBitRate(32_000)
|
||||
setOutputFile(file.absolutePath)
|
||||
}
|
||||
try {
|
||||
recorder.prepare()
|
||||
recorder.start()
|
||||
} catch (e: Exception) {
|
||||
recorder.release()
|
||||
file.delete()
|
||||
throw IllegalStateException("could not start recording: ${e.message}", e)
|
||||
}
|
||||
return Recording(recorder, file, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
/**
|
||||
* Play decrypted bytes.
|
||||
*
|
||||
* ⚠️ Writes them to a file first, because `MediaPlayer` cannot be handed a
|
||||
* byte array. The file lives in the cache and is deleted when playback
|
||||
* ends or is stopped — decrypted audio sitting on disk after the user has
|
||||
* finished with it is a leak, however briefly.
|
||||
*/
|
||||
class Player(private val cacheDir: File) {
|
||||
private var player: MediaPlayer? = null
|
||||
private var file: File? = null
|
||||
|
||||
fun play(bytes: ByteArray, onFinished: () -> Unit) {
|
||||
stop()
|
||||
val target = File.createTempFile("play-", ".m4a", cacheDir).also { it.writeBytes(bytes) }
|
||||
file = target
|
||||
player = MediaPlayer().apply {
|
||||
setDataSource(target.absolutePath)
|
||||
setOnCompletionListener {
|
||||
stop()
|
||||
onFinished()
|
||||
}
|
||||
prepare()
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
player?.let { runCatching { it.stop() }; it.release() }
|
||||
player = null
|
||||
file?.delete()
|
||||
file = null
|
||||
}
|
||||
|
||||
val isPlaying: Boolean get() = player?.isPlaying == true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user