Build Your Own Voice Notes App in Android with Jetpack Compose
We all know that great ideas strike at the most unexpected times. You could be walking, cooking, or lying in bed, and suddenly a thought pops into your head. If you don’t capture it quickly, it disappears. The usual solution is to type it down, but typing on a small phone keyboard often slows us down. Wouldn’t it be easier if your phone simply listened and turned your voice into text?
That’s exactly what we’ll do in this tutorial. We’ll build a Voice Notes app in Android using Jetpack Compose. It will listen to your speech, transcribe it, and let you save those notes with one tap. The best part? It’s simple, practical, and doesn’t require any fancy paid APIs.
What will you learn?
🎙️ Capture speech using the phone’s microphone.
📝 Show live transcripts of what you say.
💾 Save your words into a local file as notes.
📂 Display all saved notes on screen.
🗑️ Delete all notes whenever you want.
Let’s dive in step by step.
🛠️ Step 1: Set Up Permissions
Since we’ll be recording audio, the app needs permission to access the microphone. Open your AndroidManifest.xml and add:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" />
On Android 6.0 and above, permissions also need to be requested at runtime. Compose itself doesn’t handle permissions, so you can use rememberLauncherForActivityResult or libraries like Accompanist Permissions to request it smoothly.
How to build👇
with Jetpack Compose?
🎨 Step 2: Designing the UI with Compose
One of the reasons I love Jetpack Compose is how easy it makes UI design. For our app, we don’t need multiple screens—just one main page with three sections:
- A Live Transcript section to show the recognized speech.
- A Saved Notes section that lists everything we’ve stored.
- A row of buttons at the bottom: Start Listening, Stop, Save, and Delete All.
Compose’s declarative style makes this layout clean and minimal.
🎤 Step 3: Adding Speech Recognition
Android has a built-in SpeechRecognizer API, which means we don’t need any third-party services or internet connection for basic speech-to-text. Here’s how it works:
- You create a SpeechRecognizer instance.
- You configure an Intent with RecognizerIntent.ACTION_RECOGNIZE_SPEECH.
- You set a RecognitionListener to receive results (final and partial).
- Start listening, and the recognizer will return text once you stop speaking.
- This gives us the live transcript we’ll display in the app.
💾 Step 4: Saving Notes
What’s the point of dictating if the text disappears once the app closes? We’ll make sure the notes are stored permanently. For simplicity, we’ll use a plain text file (voice_notes.txt) in the app’s private storage. Each new note is appended with a newline.
Later, you could switch to Room database if you want advanced features like note search, sorting, or syncing. But for now, the file approach keeps things light and easy.
📝 Full Code Example
Here’s a complete working Composable screen:
package com.codingbihar.voicenotesapp
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Environment
import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import java.io.File
import java.util.Locale
@Composable
fun VoiceNoteApp() {
val context = LocalContext.current
// Launcher for microphone permission
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { /* no-op */ }
// Ask permission when screen appears
LaunchedEffect(Unit) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
Box(Modifier.fillMaxSize()) {
VoiceNotesScreen()
}
}
@Composable
fun VoiceNotesScreen() {
val context = LocalContext.current
var notesList by remember { mutableStateOf(readFromFile().lines().filter { it.isNotBlank() }) }
var isRecording by remember { mutableStateOf(false) }
var transcript by remember { mutableStateOf("") }
// recognizer
val speechRecognizer = remember {
SpeechRecognizer.createSpeechRecognizer(context)
}
val recognizerIntent = remember {
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
}
}
DisposableEffect(Unit) {
val listener = object : RecognitionListener {
override fun onReadyForSpeech(params: Bundle?) {
isRecording = true
}
override fun onEndOfSpeech() {
isRecording = false
// restart for continuous listening
speechRecognizer.startListening(recognizerIntent)
}
override fun onError(error: Int) {
isRecording = false
// restart after error
speechRecognizer.startListening(recognizerIntent)
}
override fun onResults(results: Bundle?) {
val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
// instead of replacing, append
transcript += " " + matches[0]
}
// restart listening
speechRecognizer.startListening(recognizerIntent)
}
override fun onPartialResults(partialResults: Bundle?) {
val matches =
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
if (!matches.isNullOrEmpty()) {
// show live words while speaking (but don’t save)
// temporary live text can be displayed separately if needed
transcript = transcript.trimEnd() + " " + matches[0]
}
}
override fun onBeginningOfSpeech() {}
override fun onBufferReceived(buffer: ByteArray?) {}
override fun onEvent(eventType: Int, params: Bundle?) {}
override fun onRmsChanged(rmsdB: Float) {}
}
speechRecognizer.setRecognitionListener(listener)
// start immediately
speechRecognizer.startListening(recognizerIntent)
onDispose {
speechRecognizer.destroy()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(16.dp)
) {
Text(
text = "🎤 Listening...",
color = Color.Red,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(8.dp)
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
) {
OutlinedTextField(
value = transcript,
onValueChange = { transcript = it },
label = { Text("Transcript") },
textStyle = LocalTextStyle.current.copy(fontSize = 18.sp),
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
)
}
Spacer(Modifier.height(12.dp))
Row {
Button(onClick = {
if (transcript.isNotBlank()) {
appendToFile(transcript)
notesList = readFromFile().lines().filter { it.isNotBlank() }
transcript = ""
Toast.makeText(context, "Note saved!", Toast.LENGTH_SHORT).show()
}
}) {
Text("Save Note")
}
Spacer(Modifier.width(8.dp))
Button(onClick = { transcript = "" }) {
Text("Clear")
}
}
Spacer(Modifier.height(16.dp))
Text("Saved Notes:", fontWeight = FontWeight.Bold)
LazyColumn(
modifier = Modifier
.height(200.dp)
.padding(top = 8.dp)
) {
items(notesList) { note ->
Text("• $note")
}
}
Spacer(Modifier.height(16.dp))
Button(onClick = {
val folder = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VoiceNotes"
)
val file = File(folder, "voice_notes.txt")
if (file.exists()) file.delete()
notesList = emptyList()
}) {
Text("Delete All")
}
}
}
}
File helpers for saving notes:
// save to custom folder (Downloads/VoiceNotes)
fun appendToFile(text: String) {
val folder = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VoiceNotes"
)
if (!folder.exists()) folder.mkdirs()
val file = File(folder, "voice_notes.txt")
file.appendText("$text\n")
}
// read from custom folder
fun readFromFile(): String {
val file = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
"VoiceNotes/voice_notes.txt"
)
return if (file.exists()) file.readText() else ""
}
🌟 Enhancements You Can Try
This simple app already feels useful, but here are a few ideas to take it further:
- List UI: Instead of showing notes as plain text, use LazyColumn so each note appears in its own card.
- Timestamps: Add a date and time to every note when saving.
- Share Option: Let users share notes via WhatsApp, Gmail, or Google Drive.
- Multi-language Support: By setting RecognizerIntent.EXTRA_LANGUAGE, you can capture speech in different languages.
- AI Integration: Later, you could connect this with a free AI API (like Hugging Face or OpenAI trial) to automatically summarize notes.
✅ Final Thoughts
We just built a Voice Notes app from scratch using Jetpack Compose. It’s small, but it solves a real problem—capturing thoughts quickly without typing. Along the way, we learned how to:
- Work with SpeechRecognizer for speech-to-text.
- Design a Compose UI with buttons and text sections.
- Save, display, and delete notes locally.
This is a great starter project for beginners exploring Android and Compose. And who knows—you might even end up using your own app daily as a voice journal.
So go ahead, run it on your device, and let your phone be your personal note-taker.