Android M1: meeting recording via foreground service
- core/recording: MeetingRecorder (AudioRecord at the device's native rate, WAV on disk + 60 s rolling window resampled to 16 kHz), streaming WavWriter, thread-safe RollingWindow, linear resampler; WavReader moved here from the app - app: RecorderService (foreground, type microphone) with ongoing notification and StateFlow state; Record/Stop UI with timer and level meter; runtime permission flow; finished recordings auto-load for transcription - launcher icon (mic + waveform, matching the desktop brand) and notification glyph - fix real M0 bug caught by the new unit tests: WavReader parsed 16-bit fmt fields (audioFormat/channels/bitsPerSample) with 32-bit reads - JVM tests: WAV round trip, native-rate header, resampler, rolling window — 5/5 green; assembleDebug and aapt2 APK checks pass - validated on-device on a Fairphone 6 (Android 16): model download, engine load, recording and transcription all working
This commit is contained in:
+17
-10
@@ -4,10 +4,10 @@ Native Android app with the same functionality as the desktop meetrec:
|
||||
record meetings and transcribe them on-device with Whisper. Nothing leaves
|
||||
the phone — no cloud, no telemetry.
|
||||
|
||||
Status: **M0 (engine proof)** — the app can download a GGML model, load it
|
||||
via JNI (whisper.cpp, CPU/NEON) and transcribe a picked WAV file. Recording,
|
||||
live transcript, and output files arrive in later milestones (see the
|
||||
milestone plan in the project docs).
|
||||
Status: **M1 (recording)** — record a meeting with a foreground service
|
||||
(native sample rate, timer, level meter), then transcribe it on device.
|
||||
Model download + engine load are from M0; the live rolling transcript
|
||||
arrives with M2 (see the milestone plan in the project docs).
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -28,15 +28,15 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
`third_party/whisper.cpp` is gitignored — the fetch script pins the exact
|
||||
release tag so the JNI layer never breaks on upstream churn.
|
||||
|
||||
## Test on device (M0)
|
||||
## Test on device
|
||||
|
||||
1. Launch **MeetRec**, pick a model (`tiny` is fine for a first test) and tap
|
||||
**Download** (model comes from Hugging Face; tiny is ~75 MB).
|
||||
2. Tap **Load engine**.
|
||||
3. Tap **Pick WAV file** and choose a 16 kHz mono WAV for best results
|
||||
(other PCM WAVs are resampled automatically).
|
||||
4. Tap **Transcribe** — segments with timestamps appear; share via the
|
||||
share sheet.
|
||||
3. Tap **Record**, speak, then tap **Stop** — the recording is saved as WAV
|
||||
(native device rate) and auto-loaded for transcription.
|
||||
4. Tap **Transcribe** — segments with timestamps appear; share via the share
|
||||
sheet. You can also pick any PCM WAV file instead of recording.
|
||||
|
||||
Expect roughly realtime transcription with `tiny`/`base` on the
|
||||
Fairphone 6's CPU; `small` is noticeably slower — use it for final passes
|
||||
@@ -51,12 +51,19 @@ only (the live/final split comes with the recorder milestones).
|
||||
## Module layout
|
||||
|
||||
```
|
||||
app/ Compose UI (model download, WAV picker, transcript view)
|
||||
app/ Compose UI + RecorderService (foreground mic recording)
|
||||
core/recording/ MeetingRecorder, WavWriter/WavReader, RollingWindow,
|
||||
linear resampler — pure Kotlin, unit tested on the JVM
|
||||
core/whisper/ whisper.cpp JNI wrapper: CMake build + LibWhisper.kt +
|
||||
WhisperEngine.kt (the on-device transcription API)
|
||||
tools/ fetch-whisper.sh — vendor the pinned whisper.cpp release
|
||||
```
|
||||
|
||||
Recording mirrors the desktop app: audio is captured at the input's native
|
||||
rate (WAV on disk keeps it), while the 60 s rolling window is resampled to
|
||||
Whisper's 16 kHz for the upcoming live pass. Recording continues while the
|
||||
app is backgrounded via the microphone foreground service.
|
||||
|
||||
Models live in the app's private storage (`filesDir/models`), shared files
|
||||
with the desktop app's `~/.cache/meetrec/whisper-cpp` naming
|
||||
(ggml-tiny.bin … ggml-large-v3.bin).
|
||||
@@ -32,6 +32,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:recording"))
|
||||
implementation(project(":core:whisper"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Only used to download Whisper GGML models from Hugging Face. -->
|
||||
<!-- Model downloads from Hugging Face. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Meeting recording (foreground service, type microphone). -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="MeetRec"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||
android:allowBackup="true">
|
||||
@@ -17,5 +23,10 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".RecorderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -16,25 +19,29 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.meetrec.core.recording.WavReader
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -42,9 +49,8 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* M0 screen: proves the whisper.cpp engine on device.
|
||||
* Pick a model (downloads from Hugging Face), load it, pick a WAV file,
|
||||
* transcribe, share the text. Recording arrives in M1.
|
||||
* M1 screen: record a meeting (foreground service), then transcribe the
|
||||
* recording on device. Live transcript arrives with M2.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -72,6 +78,8 @@ fun MeetRecScreen() {
|
||||
val modelsDir = remember { File(context.filesDir, "models") }
|
||||
val modelFile = ModelDownloader.modelFile(model, modelsDir)
|
||||
|
||||
val recState by RecorderService.state.collectAsState()
|
||||
|
||||
fun <T> runBusy(block: suspend () -> T) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
@@ -86,8 +94,61 @@ fun MeetRecScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
// A finished recording is auto-loaded for transcription.
|
||||
LaunchedEffect(recState) {
|
||||
val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect
|
||||
runBusy {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
fin.file.inputStream().use { WavReader.read(it) }
|
||||
}
|
||||
wavName = fin.file.name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "Recorded ${fmtMs(fin.durationMs)} at ${fin.sampleRate} Hz" +
|
||||
" — ready to transcribe"
|
||||
}
|
||||
}
|
||||
(recState as? RecordingState.Failed)?.let {
|
||||
LaunchedEffect(it) { status = "Recording failed: ${it.message}" }
|
||||
}
|
||||
|
||||
fun startRecording() = context.startForegroundService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_START),
|
||||
)
|
||||
|
||||
fun stopRecording() = context.startService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_STOP),
|
||||
)
|
||||
|
||||
val permLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.RECORD_AUDIO] == true) {
|
||||
startRecording()
|
||||
} else {
|
||||
status = "Microphone permission is required to record"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (recState is RecordingState.Recording) {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
val wanted = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val missing = wanted.filter {
|
||||
context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) startRecording() else permLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
|
||||
val pickWav = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runBusy {
|
||||
@@ -101,7 +162,7 @@ fun MeetRecScreen() {
|
||||
wavName = name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "$name: ${samples.size / WavReader.TARGET_RATE} s @ 16 kHz"
|
||||
status = "$name: ${samples.size / 16000} s @ 16 kHz"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,12 +233,41 @@ fun MeetRecScreen() {
|
||||
) { Text("Load engine") }
|
||||
}
|
||||
|
||||
// wav + transcribe actions
|
||||
// recording
|
||||
val recording = recState as? RecordingState.Recording
|
||||
Button(
|
||||
onClick = { toggleRecording() },
|
||||
enabled = !busy || recording != null,
|
||||
colors = if (recording == null) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
if (recording == null) {
|
||||
"\u25CF Record"
|
||||
} else {
|
||||
"\u25A0 Stop (${fmtMs(recording.elapsedMs)})"
|
||||
},
|
||||
)
|
||||
}
|
||||
if (recording != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { recording.level.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// transcribe actions
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
|
||||
enabled = !busy,
|
||||
) { Text(if (wavName == null) "Pick WAV file" else "Pick another WAV") }
|
||||
) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") }
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = wavSamples
|
||||
@@ -194,8 +284,8 @@ fun MeetRecScreen() {
|
||||
}
|
||||
segments = segs
|
||||
val secs = (System.currentTimeMillis() - start) / 1000.0
|
||||
status = "${segs.size} segments in $secs s (audio " +
|
||||
"${samples.size / WavReader.TARGET_RATE} s)"
|
||||
status = "${segs.size} segments in $secs s " +
|
||||
"(audio ${samples.size / 16000} s)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -214,7 +304,7 @@ fun MeetRecScreen() {
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text),
|
||||
"Share transcript",
|
||||
)
|
||||
),
|
||||
)
|
||||
}) { Text("Share transcript") }
|
||||
}
|
||||
@@ -238,5 +328,8 @@ fun MeetRecScreen() {
|
||||
|
||||
private fun fmtMs(ms: Long): String {
|
||||
val total = ms / 1000
|
||||
return "%02d:%02d".format(total / 60, total % 60)
|
||||
val h = total / 3600
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.meetrec.core.recording.MeetingRecorder
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Recording state published to the UI (collect RecorderService.state). */
|
||||
sealed interface RecordingState {
|
||||
data object Idle : RecordingState
|
||||
data class Recording(val file: File, val elapsedMs: Long, val level: Float) :
|
||||
RecordingState
|
||||
|
||||
data class Finished(
|
||||
val file: File,
|
||||
val durationMs: Long,
|
||||
val sampleRate: Int,
|
||||
) : RecordingState
|
||||
|
||||
data class Failed(val message: String) : RecordingState
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground service (type: microphone) that owns the MeetingRecorder,
|
||||
* mirroring the desktop recorder lifecycle. Keeps recording alive while
|
||||
* the app is backgrounded and posts an ongoing notification.
|
||||
*/
|
||||
class RecorderService : Service() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_START = "com.meetrec.android.action.START"
|
||||
const val ACTION_STOP = "com.meetrec.android.action.STOP"
|
||||
private const val CHANNEL_ID = "meetrec_recording"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
val state: StateFlow<RecordingState> = MutableStateFlow(RecordingState.Idle)
|
||||
private val _state = state as MutableStateFlow<RecordingState>
|
||||
}
|
||||
|
||||
private var recorder: MeetingRecorder? = null
|
||||
private var currentFile: File? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> recorder?.stop()
|
||||
else -> startRecording()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun startRecording() {
|
||||
if (recorder != null) return
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_state.value = RecordingState.Failed("Microphone permission is missing")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
createChannel()
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification("Recording meeting…"),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
)
|
||||
|
||||
val dir = File(filesDir, "recordings").apply { mkdirs() }
|
||||
val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
currentFile = File(dir, "meeting-$stamp.wav")
|
||||
|
||||
val rec = MeetingRecorder(currentFile!!, listener)
|
||||
recorder = rec
|
||||
_state.value = RecordingState.Recording(currentFile!!, 0, 0f)
|
||||
rec.start()
|
||||
}
|
||||
|
||||
private val listener = object : MeetingRecorder.Listener {
|
||||
override fun onTick(elapsedMs: Long, level: Float) {
|
||||
currentFile?.let { _state.value = RecordingState.Recording(it, elapsedMs, level) }
|
||||
}
|
||||
|
||||
override fun onFinish(file: File, durationMs: Long, sampleRate: Int) {
|
||||
_state.value = RecordingState.Finished(file, durationMs, sampleRate)
|
||||
cleanup()
|
||||
}
|
||||
|
||||
override fun onError(message: String) {
|
||||
_state.value = RecordingState.Failed(message)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
recorder = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
recorder?.stop()
|
||||
recorder = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Meeting recording",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notification(text: String): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_mic)
|
||||
.setContentTitle("MeetRec")
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(pi)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- MeetRec glyph: mic + waveform (voice becoming transcript). -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M45.6,24.6h16.8a8.4,8.4 0 0 1 8.4,8.4v8.4a8.4,8.4 0 0 1 -8.4,8.4h-16.8a8.4,8.4 0 0 1 -8.4,-8.4v-8.4a8.4,8.4 0 0 1 8.4,-8.4z" />
|
||||
<path
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="5.4"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M37.2,42.6 v4.8 a16.8,16.8 0 0 0 33.6,0 v-4.8" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M34.8,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M43.2,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M51.6,70.8h4.8a2.4,2.4 0 0 1 2.4,2.4v4.8a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-4.8a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M59.9,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M68.4,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_bg" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/launcher_bg" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launcher_bg">#C0392B</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.meetrec.core.recording"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 29
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// WavWriter/WavReader/RollingWindow/resampler are pure Kotlin and unit
|
||||
// tested on the JVM; MeetingRecorder needs a device (AudioRecord).
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.meetrec.core.recording
|
||||
|
||||
/** Small pure-Kotlin audio helpers shared by the recording pipeline. */
|
||||
object Audio {
|
||||
|
||||
/** Whisper's input rate. */
|
||||
const val TARGET_RATE = 16000
|
||||
|
||||
/**
|
||||
* Linearly resample mono float32 audio between sample rates.
|
||||
* Mirrors the desktop meetrec's resample_16k().
|
||||
*/
|
||||
fun resample(x: FloatArray, fromRate: Int, toRate: Int): FloatArray {
|
||||
if (x.isEmpty() || fromRate == toRate) return x
|
||||
val n = (x.size.toDouble() * toRate / fromRate).toInt().coerceAtLeast(1)
|
||||
val out = FloatArray(n)
|
||||
val step = fromRate.toDouble() / toRate
|
||||
for (i in 0 until n) {
|
||||
val t = i * step
|
||||
val i0 = t.toInt().coerceAtMost(x.size - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(x.size - 1)
|
||||
val frac = (t - i0).toFloat()
|
||||
out[i] = x[i0] * (1f - frac) + x[i1] * frac
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.meetrec.core.recording
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Records microphone audio to a WAV file at the input's actual rate,
|
||||
* mirroring the desktop meetrec Recorder: native-rate WAV on disk plus a
|
||||
* 60 s rolling window resampled to 16 kHz, ready for live transcription.
|
||||
*
|
||||
* All [Listener] callbacks are invoked on the recording thread.
|
||||
*/
|
||||
class MeetingRecorder(
|
||||
private val outputFile: File,
|
||||
private val listener: Listener,
|
||||
requestedRate: Int = 48000,
|
||||
private val rollingWindowSec: Int = 60,
|
||||
) {
|
||||
|
||||
interface Listener {
|
||||
/** Progress tick, roughly every 200 ms. */
|
||||
fun onTick(elapsedMs: Long, level: Float)
|
||||
|
||||
fun onFinish(file: File, durationMs: Long, sampleRate: Int)
|
||||
|
||||
fun onError(message: String)
|
||||
}
|
||||
|
||||
/** A live window: 16 kHz audio plus its absolute start time (s). */
|
||||
class Snapshot(val audio: FloatArray, val startSec: Double)
|
||||
|
||||
private val requestedRate = requestedRate
|
||||
private var record: AudioRecord? = null
|
||||
private var wav: WavWriter? = null
|
||||
private var window: RollingWindow? = null
|
||||
private var thread: Thread? = null
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
/** The rate the device actually runs at (may differ from requested). */
|
||||
var sampleRate: Int = requestedRate
|
||||
private set
|
||||
|
||||
val isRunning: Boolean get() = running
|
||||
|
||||
/**
|
||||
* Starts recording. RECORD_AUDIO must already be granted — the
|
||||
* foreground service checks before calling this.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start() {
|
||||
check(!running) { "recorder already running" }
|
||||
val minBuf = AudioRecord.getMinBufferSize(
|
||||
requestedRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBuf <= 0) {
|
||||
listener.onError("no audio input available (min buffer $minBuf)")
|
||||
return
|
||||
}
|
||||
val rec = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
requestedRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
maxOf(minBuf * 4, 16 * 1024),
|
||||
)
|
||||
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
||||
rec.release()
|
||||
listener.onError("could not open the microphone")
|
||||
return
|
||||
}
|
||||
sampleRate = rec.sampleRate
|
||||
outputFile.parentFile?.mkdirs()
|
||||
record = rec
|
||||
wav = WavWriter(outputFile, sampleRate)
|
||||
window = RollingWindow(sampleRate * rollingWindowSec, sampleRate)
|
||||
running = true
|
||||
stopRequested = false
|
||||
rec.startRecording()
|
||||
thread = Thread(::loop, "meetrec-recorder").also { it.start() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopRequested = true
|
||||
}
|
||||
|
||||
/** Current rolling window (16 kHz + start time), or null if too short. */
|
||||
fun snapshot(): Snapshot? = window?.snapshot16k()?.let {
|
||||
Snapshot(it.audio, it.startSec)
|
||||
}
|
||||
|
||||
private fun loop() {
|
||||
val rec = record ?: return
|
||||
val buf = ShortArray(4096)
|
||||
var frames = 0L
|
||||
var lastTick = -1L
|
||||
try {
|
||||
while (!stopRequested) {
|
||||
val n = rec.read(buf, 0, buf.size)
|
||||
if (n <= 0) {
|
||||
listener.onError("audio read failed (code $n)")
|
||||
break
|
||||
}
|
||||
wav?.writePcm16(buf, n)
|
||||
window?.push(buf, n)
|
||||
frames += n
|
||||
|
||||
val now = frames * 1000 / sampleRate
|
||||
if (now - lastTick >= 200) {
|
||||
lastTick = now
|
||||
var peak = 0
|
||||
for (i in 0 until n) {
|
||||
val a = abs(buf[i].toInt())
|
||||
if (a > peak) peak = a
|
||||
}
|
||||
listener.onTick(now, peak / 32768f)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
listener.onError("recording failed: ${e.message}")
|
||||
} finally {
|
||||
running = false
|
||||
try {
|
||||
rec.stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
rec.release()
|
||||
record = null
|
||||
try {
|
||||
wav?.close()
|
||||
} catch (e: Exception) {
|
||||
listener.onError("could not finalize WAV: ${e.message}")
|
||||
}
|
||||
if (frames > 0) {
|
||||
listener.onFinish(outputFile, frames * 1000 / sampleRate, sampleRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.meetrec.core.recording
|
||||
|
||||
/**
|
||||
* Fixed-capacity FIFO of mono float samples keeping the most recent
|
||||
* [capacity] frames — the rolling window the live transcription (M2)
|
||||
* will consume. Thread-safe: pushed from the record thread, snapshotted
|
||||
* from the transcription thread.
|
||||
*/
|
||||
internal class RollingWindow(
|
||||
private val capacity: Int,
|
||||
private val rate: Int,
|
||||
) {
|
||||
|
||||
class Snapshot(val audio: FloatArray, val startSec: Double)
|
||||
|
||||
private val data = FloatArray(capacity)
|
||||
private val lock = Object()
|
||||
private var head = 0 // next write position
|
||||
private var count = 0 // valid samples in the ring
|
||||
private var total = 0L // samples ever pushed
|
||||
|
||||
fun push(pcm: ShortArray, n: Int) {
|
||||
synchronized(lock) {
|
||||
for (i in 0 until n) {
|
||||
data[head] = pcm[i] / 32768f
|
||||
head = (head + 1) % capacity
|
||||
if (count < capacity) count++
|
||||
}
|
||||
total += n
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the window resampled to 16 kHz (oldest first) and the
|
||||
* absolute time (s) where it starts within the recording, or null
|
||||
* when fewer than 2 s have been captured.
|
||||
*/
|
||||
fun snapshot16k(): Snapshot? {
|
||||
val audio: FloatArray
|
||||
val startSec: Double
|
||||
synchronized(lock) {
|
||||
if (count < 2 * rate) return null
|
||||
audio = FloatArray(count)
|
||||
val first = (head - count + capacity) % capacity
|
||||
for (i in 0 until count) {
|
||||
audio[i] = data[(first + i) % capacity]
|
||||
}
|
||||
startSec = (total - count).toDouble() / rate
|
||||
}
|
||||
return Snapshot(Audio.resample(audio, rate, Audio.TARGET_RATE), startSec)
|
||||
}
|
||||
}
|
||||
+12
-22
@@ -1,4 +1,4 @@
|
||||
package com.meetrec.android
|
||||
package com.meetrec.core.recording
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -11,8 +11,6 @@ import java.io.InputStream
|
||||
*/
|
||||
object WavReader {
|
||||
|
||||
const val TARGET_RATE = 16000
|
||||
|
||||
fun read(input: InputStream): FloatArray {
|
||||
val data = input.readBytes()
|
||||
require(data.size > 44) { "file too small to be a WAV" }
|
||||
@@ -33,10 +31,10 @@ object WavReader {
|
||||
val body = pos + 8
|
||||
when (id) {
|
||||
"fmt " -> {
|
||||
audioFormat = leInt(data, body)
|
||||
channels = leInt(data, body + 2)
|
||||
sampleRate = leInt(data, body + 4)
|
||||
bitsPerSample = leInt(data, body + 14)
|
||||
audioFormat = leShort(data, body) // 16-bit field
|
||||
channels = leShort(data, body + 2) // 16-bit field
|
||||
sampleRate = leInt(data, body + 4) // 32-bit field
|
||||
bitsPerSample = leShort(data, body + 14) // 16-bit field
|
||||
}
|
||||
"data" -> {
|
||||
val fmt = audioFormat
|
||||
@@ -55,7 +53,11 @@ object WavReader {
|
||||
}
|
||||
|
||||
val mono = samples ?: error("no data chunk found")
|
||||
return if (sampleRate == TARGET_RATE) mono else resample(mono, sampleRate, TARGET_RATE)
|
||||
return if (sampleRate == Audio.TARGET_RATE) {
|
||||
mono
|
||||
} else {
|
||||
Audio.resample(mono, sampleRate, Audio.TARGET_RATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(
|
||||
@@ -84,20 +86,8 @@ object WavReader {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun resample(x: FloatArray, from: Int, to: Int): FloatArray {
|
||||
if (x.isEmpty()) return x
|
||||
val n = (x.size.toDouble() * to / from).toInt().coerceAtLeast(1)
|
||||
val out = FloatArray(n)
|
||||
val step = from.toDouble() / to
|
||||
for (i in 0 until n) {
|
||||
val t = i * step
|
||||
val i0 = t.toInt().coerceAtMost(x.size - 1)
|
||||
val i1 = (i0 + 1).coerceAtMost(x.size - 1)
|
||||
val frac = (t - i0).toFloat()
|
||||
out[i] = x[i0] * (1f - frac) + x[i1] * frac
|
||||
}
|
||||
return out
|
||||
}
|
||||
private fun leShort(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8)
|
||||
|
||||
private fun leInt(b: ByteArray, off: Int): Int =
|
||||
(b[off].toInt() and 0xFF) or
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.meetrec.core.recording
|
||||
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* Streaming mono 16-bit PCM WAV writer.
|
||||
*
|
||||
* The header is written with zero sizes and patched in [close], so an
|
||||
* interrupted recording leaves a headerless-data file (recoverable, but
|
||||
* the file is only valid WAV after a clean close).
|
||||
*/
|
||||
class WavWriter(
|
||||
private val file: File,
|
||||
private val sampleRate: Int,
|
||||
private val channels: Int = 1,
|
||||
) : Closeable {
|
||||
|
||||
private val out = DataOutputStream(BufferedOutputStream(FileOutputStream(file)))
|
||||
private var frames: Long = 0
|
||||
private var closed = false
|
||||
|
||||
init {
|
||||
out.writeBytes("RIFF")
|
||||
out.writeIntLe(36) // patched in close()
|
||||
out.writeBytes("WAVE")
|
||||
out.writeBytes("fmt ")
|
||||
out.writeIntLe(16) // PCM chunk size
|
||||
out.writeShortLe(1) // PCM
|
||||
out.writeShortLe(channels)
|
||||
out.writeIntLe(sampleRate)
|
||||
out.writeIntLe(sampleRate * channels * 2)
|
||||
out.writeShortLe(channels * 2) // block align
|
||||
out.writeShortLe(16) // bits per sample
|
||||
out.writeBytes("data")
|
||||
out.writeIntLe(0) // patched in close()
|
||||
}
|
||||
|
||||
/** Appends [count] mono PCM16 frames. */
|
||||
fun writePcm16(data: ShortArray, count: Int) {
|
||||
check(!closed)
|
||||
for (i in 0 until count) {
|
||||
out.writeShortLe(data[i].toInt())
|
||||
}
|
||||
frames += count
|
||||
}
|
||||
|
||||
val framesWritten: Long get() = frames
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
out.flush()
|
||||
out.close()
|
||||
RandomAccessFile(file, "rw").use { raf ->
|
||||
raf.seek(4)
|
||||
raf.writeIntLe((36 + frames * channels * 2).toInt())
|
||||
raf.seek(40)
|
||||
raf.writeIntLe((frames * channels * 2).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun DataOutputStream.writeIntLe(v: Int) {
|
||||
write(v and 0xFF)
|
||||
write((v shr 8) and 0xFF)
|
||||
write((v shr 16) and 0xFF)
|
||||
write((v shr 24) and 0xFF)
|
||||
}
|
||||
|
||||
private fun DataOutputStream.writeShortLe(v: Int) {
|
||||
write(v and 0xFF)
|
||||
write((v shr 8) and 0xFF)
|
||||
}
|
||||
|
||||
private fun RandomAccessFile.writeIntLe(v: Int) {
|
||||
write(v and 0xFF)
|
||||
write((v shr 8) and 0xFF)
|
||||
write((v shr 16) and 0xFF)
|
||||
write((v shr 24) and 0xFF)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.meetrec.core.recording
|
||||
|
||||
import java.io.File
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.sin
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WavRoundTripTest {
|
||||
|
||||
private fun tempFile(name: String): File =
|
||||
File.createTempFile(name, ".wav", File(System.getProperty("java.io.tmpdir")))
|
||||
|
||||
private fun sine(seconds: Double, rate: Int, freq: Int, amp: Float): FloatArray {
|
||||
val n = (seconds * rate).toInt()
|
||||
return FloatArray(n) { i ->
|
||||
amp * sin(2 * PI * freq * i / rate).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dominantFreq(x: FloatArray, rate: Int): Double {
|
||||
val spec = FloatArray(x.size / 2 + 1)
|
||||
// naive DFT is too slow for 1 s; use zero crossings instead
|
||||
var crossings = 0
|
||||
for (i in 1 until x.size) {
|
||||
if (x[i - 1] <= 0 && x[i] > 0) crossings++
|
||||
}
|
||||
return crossings.toDouble() * rate / x.size
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav writer then reader round trips at 16k`() {
|
||||
val f = tempFile("rt16k")
|
||||
val pcm = FloatArray(16000) { i ->
|
||||
(16000 * sin(2 * PI * 440 * i / 16000.0) / 32768.0).toFloat()
|
||||
}
|
||||
val shorts = ShortArray(pcm.size) { (pcm[it] * 32767f).toInt().toShort() }
|
||||
WavWriter(f, 16000).use { w -> w.writePcm16(shorts, shorts.size) }
|
||||
|
||||
val back = f.inputStream().use { WavReader.read(it) }
|
||||
assertEquals(pcm.size, back.size)
|
||||
for (i in pcm.indices) {
|
||||
assertEquals(pcm[i], back[i], 1f / 16000f) // int16 quantization
|
||||
}
|
||||
assertEquals(440.0, dominantFreq(back, 16000), 15.0)
|
||||
assertTrue(f.delete())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wav header carries native rate and reader resamples to 16k`() {
|
||||
val rate = 48000
|
||||
val f = tempFile("rt48k")
|
||||
val x = sine(1.0, rate, 440, 0.5f)
|
||||
val shorts = ShortArray(x.size) { (x[it] * 32767f).toInt().toShort() }
|
||||
WavWriter(f, rate).use { w -> w.writePcm16(shorts, shorts.size) }
|
||||
|
||||
// header must record the native rate, not 16 kHz
|
||||
val raw = f.readBytes()
|
||||
assertEquals("RIFF", String(raw, 0, 4))
|
||||
val dataLen = (raw[40].toInt() and 0xFF) or ((raw[41].toInt() and 0xFF) shl 8) or
|
||||
((raw[42].toInt() and 0xFF) shl 16) or ((raw[43].toInt() and 0xFF) shl 24)
|
||||
assertEquals(shorts.size * 2, dataLen)
|
||||
val hdrRate = (raw[24].toInt() and 0xFF) or ((raw[25].toInt() and 0xFF) shl 8) or
|
||||
((raw[26].toInt() and 0xFF) shl 16)
|
||||
assertEquals(rate, hdrRate)
|
||||
|
||||
val back = f.inputStream().use { WavReader.read(it) }
|
||||
assertEquals(16000, back.size) // 1 s at 16 kHz
|
||||
assertEquals(440.0, dominantFreq(back, 16000), 15.0)
|
||||
assertTrue(f.delete())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resampler preserves length ratio and tone`() {
|
||||
for (from in intArrayOf(44100, 48000)) {
|
||||
val x = sine(2.0, from, 440, 0.5f)
|
||||
val y = Audio.resample(x, from, 16000)
|
||||
assertEquals(32000, y.size)
|
||||
assertEquals(440.0, dominantFreq(y, 16000), 10.0)
|
||||
}
|
||||
// identity path
|
||||
val same = FloatArray(100) { 0.5f }
|
||||
assertTrue(Audio.resample(same, 16000, 16000) === same)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rolling window keeps newest samples and reports start time`() {
|
||||
val rate = 16000
|
||||
val capacity = 32000 // 2 s window
|
||||
val w = RollingWindow(capacity, rate)
|
||||
val chunk = ShortArray(rate / 2) // 0.5 s chunks
|
||||
for (c in 1..5) { // 2.5 s total
|
||||
for (i in chunk.indices) chunk[i] = (c * 1000 + i).toShort()
|
||||
w.push(chunk, chunk.size)
|
||||
}
|
||||
val snap = w.snapshot16k()!!
|
||||
assertEquals(capacity, snap.audio.size) // newest 2 s only
|
||||
assertEquals(0.5, snap.startSec, 1e-9) // (40000-32000)/rate
|
||||
// oldest kept sample is chunk 2's first value
|
||||
assertEquals(2000f / 32768f, snap.audio[0], 1e-4f)
|
||||
// newest kept sample is chunk 5's last value
|
||||
assertEquals((5000 + chunk.size - 1).toFloat(), snap.audio.last() * 32768f, 0.5f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rolling window returns null before 2 seconds`() {
|
||||
val w = RollingWindow(32000, 16000)
|
||||
assertNull(w.snapshot16k())
|
||||
w.push(ShortArray(31999), 31999)
|
||||
assertNull(w.snapshot16k()) // one sample short
|
||||
w.push(ShortArray(2), 2)
|
||||
assertNotNull(w.snapshot16k())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ coroutines = "1.11.0"
|
||||
coreKtx = "1.18.0"
|
||||
activityCompose = "1.13.0"
|
||||
composeBom = "2026.06.01"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -14,6 +15,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -16,4 +16,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "meetrec"
|
||||
include(":app")
|
||||
include(":core:recording")
|
||||
include(":core:whisper")
|
||||
Reference in New Issue
Block a user