Add Android sample (#277)

* Add Android sample

* Use main project C files

* Stop existing playback before starting new playback

* Make text scrollable

* Stop playback when starting to record

* Remove extra var
This commit is contained in:
Digipom
2022-12-16 19:20:13 +02:00
committed by GitHub
parent c6c3ad5a98
commit 675e787171
42 changed files with 1598 additions and 0 deletions
@@ -0,0 +1,22 @@
package com.whispercppdemo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import com.whispercppdemo.ui.main.MainScreen
import com.whispercppdemo.ui.main.MainScreenViewModel
import com.whispercppdemo.ui.theme.WhisperCppDemoTheme
class MainActivity : ComponentActivity() {
private val viewModel: MainScreenViewModel by viewModels { MainScreenViewModel.factory() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WhisperCppDemoTheme {
MainScreen(viewModel)
}
}
}
}
@@ -0,0 +1,76 @@
package com.whispercppdemo.media
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
fun decodeWaveFile(file: File): FloatArray {
val baos = ByteArrayOutputStream()
file.inputStream().use { it.copyTo(baos) }
val buffer = ByteBuffer.wrap(baos.toByteArray())
buffer.order(ByteOrder.LITTLE_ENDIAN)
buffer.position(44)
val shortBuffer = buffer.asShortBuffer()
val shortArray = ShortArray(shortBuffer.limit())
shortBuffer.get(shortArray)
return FloatArray(shortArray.size) { index ->
(shortArray[index] / 32767.0f).coerceIn(-1f..1f)
}
}
fun encodeWaveFile(file: File, data: ShortArray) {
file.outputStream().use {
it.write(headerBytes(data.size * 2))
val buffer = ByteBuffer.allocate(data.size * 2)
buffer.order(ByteOrder.LITTLE_ENDIAN)
buffer.asShortBuffer().put(data)
val bytes = ByteArray(buffer.limit())
buffer.get(bytes)
it.write(bytes)
}
}
private fun headerBytes(totalLength: Int): ByteArray {
require(totalLength >= 44)
ByteBuffer.allocate(44).apply {
order(ByteOrder.LITTLE_ENDIAN)
put('R'.code.toByte())
put('I'.code.toByte())
put('F'.code.toByte())
put('F'.code.toByte())
putInt(totalLength - 8)
put('W'.code.toByte())
put('A'.code.toByte())
put('V'.code.toByte())
put('E'.code.toByte())
put('f'.code.toByte())
put('m'.code.toByte())
put('t'.code.toByte())
put(' '.code.toByte())
putInt(16)
putShort(1.toShort())
putShort(1.toShort())
putInt(16000)
putInt(32000)
putShort(2.toShort())
putShort(16.toShort())
put('d'.code.toByte())
put('a'.code.toByte())
put('t'.code.toByte())
put('a'.code.toByte())
putInt(totalLength - 44)
position(0)
}.also {
val bytes = ByteArray(it.limit())
it.get(bytes)
return bytes
}
}
@@ -0,0 +1,88 @@
package com.whispercppdemo.recorder
import android.annotation.SuppressLint
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import com.whispercppdemo.media.encodeWaveFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class Recorder {
private val scope: CoroutineScope = CoroutineScope(
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
private var recorder: AudioRecordThread? = null
suspend fun startRecording(outputFile: File, onError: (Exception) -> Unit) = withContext(scope.coroutineContext) {
recorder = AudioRecordThread(outputFile, onError)
recorder?.start()
}
suspend fun stopRecording() = withContext(scope.coroutineContext) {
recorder?.stopRecording()
@Suppress("BlockingMethodInNonBlockingContext")
recorder?.join()
recorder = null
}
}
private class AudioRecordThread(
private val outputFile: File,
private val onError: (Exception) -> Unit
) :
Thread("AudioRecorder") {
private var quit = AtomicBoolean(false)
@SuppressLint("MissingPermission")
override fun run() {
try {
val bufferSize = AudioRecord.getMinBufferSize(
16000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
) * 4
val buffer = ShortArray(bufferSize / 2)
val audioRecord = AudioRecord(
MediaRecorder.AudioSource.MIC,
16000,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
)
try {
audioRecord.startRecording()
val allData = mutableListOf<Short>()
while (!quit.get()) {
val read = audioRecord.read(buffer, 0, buffer.size)
if (read > 0) {
for (i in 0 until read) {
allData.add(buffer[i])
}
} else {
throw java.lang.RuntimeException("audioRecord.read returned $read")
}
}
audioRecord.stop()
encodeWaveFile(outputFile, allData.toShortArray())
} finally {
audioRecord.release()
}
} catch (e: Exception) {
onError(e)
}
}
fun stopRecording() {
quit.set(true)
}
}
@@ -0,0 +1,99 @@
package com.whispercppdemo.ui.main
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.whispercppdemo.R
@Composable
fun MainScreen(viewModel: MainScreenViewModel) {
MainScreen(
canTranscribe = viewModel.canTranscribe,
isRecording = viewModel.isRecording,
messageLog = viewModel.dataLog,
onTranscribeSampleTapped = viewModel::transcribeSample,
onRecordTapped = viewModel::toggleRecord
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen(
canTranscribe: Boolean,
isRecording: Boolean,
messageLog: String,
onTranscribeSampleTapped: () -> Unit,
onRecordTapped: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) }
)
},
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
TranscribeSampleButton(enabled = canTranscribe, onClick = onTranscribeSampleTapped)
RecordButton(
enabled = canTranscribe,
isRecording = isRecording,
onClick = onRecordTapped
)
}
MessageLog(messageLog)
}
}
}
@Composable
private fun MessageLog(log: String) {
Text(modifier = Modifier.verticalScroll(rememberScrollState()), text = log)
}
@Composable
private fun TranscribeSampleButton(enabled: Boolean, onClick: () -> Unit) {
Button(onClick = onClick, enabled = enabled) {
Text("Transcribe sample")
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
private fun RecordButton(enabled: Boolean, isRecording: Boolean, onClick: () -> Unit) {
val micPermissionState = rememberPermissionState(
permission = android.Manifest.permission.RECORD_AUDIO,
onPermissionResult = { granted ->
if (granted) {
onClick()
}
}
)
Button(onClick = {
if (micPermissionState.status.isGranted) {
onClick()
} else {
micPermissionState.launchPermissionRequest()
}
}, enabled = enabled) {
Text(
if (isRecording) {
"Stop recording"
} else {
"Start recording"
}
)
}
}
@@ -0,0 +1,193 @@
package com.whispercppdemo.ui.main
import android.app.Application
import android.content.Context
import android.media.MediaPlayer
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.whispercppdemo.media.decodeWaveFile
import com.whispercppdemo.recorder.Recorder
import com.whispercppdemo.whisper.WhisperContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.io.File
private const val LOG_TAG = "MainScreenViewModel"
class MainScreenViewModel(private val application: Application) : ViewModel() {
var canTranscribe by mutableStateOf(false)
private set
var dataLog by mutableStateOf("")
private set
var isRecording by mutableStateOf(false)
private set
private val modelsPath = File(application.filesDir, "models")
private val samplesPath = File(application.filesDir, "samples")
private var recorder: Recorder = Recorder()
private var whisperContext: WhisperContext? = null
private var mediaPlayer: MediaPlayer? = null
private var recordedFile: File? = null
init {
viewModelScope.launch {
loadData()
}
}
private suspend fun loadData() {
printMessage("Loading data...\n")
try {
copyAssets()
loadBaseModel()
canTranscribe = true
} catch (e: Exception) {
Log.w(LOG_TAG, e)
printMessage("${e.localizedMessage}\n")
}
}
private suspend fun printMessage(msg: String) = withContext(Dispatchers.Main) {
dataLog += msg
}
private suspend fun copyAssets() = withContext(Dispatchers.IO) {
modelsPath.mkdirs()
samplesPath.mkdirs()
application.copyData("models", modelsPath, ::printMessage)
application.copyData("samples", samplesPath, ::printMessage)
printMessage("All data copied to working directory.\n")
}
private suspend fun loadBaseModel() = withContext(Dispatchers.IO) {
printMessage("Loading model...\n")
val firstModel = modelsPath.listFiles()!!.first()
whisperContext = WhisperContext.createContext(firstModel.absolutePath)
printMessage("Loaded model ${firstModel.name}.\n")
}
fun transcribeSample() = viewModelScope.launch {
transcribeAudio(getFirstSample())
}
private suspend fun getFirstSample(): File = withContext(Dispatchers.IO) {
samplesPath.listFiles()!!.first()
}
private suspend fun readAudioSamples(file: File): FloatArray = withContext(Dispatchers.IO) {
stopPlayback()
startPlayback(file)
return@withContext decodeWaveFile(file)
}
private suspend fun stopPlayback() = withContext(Dispatchers.Main) {
mediaPlayer?.stop()
mediaPlayer?.release()
mediaPlayer = null
}
private suspend fun startPlayback(file: File) = withContext(Dispatchers.Main) {
mediaPlayer = MediaPlayer.create(application, file.absolutePath.toUri())
mediaPlayer?.start()
}
private suspend fun transcribeAudio(file: File) {
if (!canTranscribe) {
return
}
canTranscribe = false
try {
printMessage("Reading wave samples...\n")
val data = readAudioSamples(file)
printMessage("Transcribing data...\n")
val text = whisperContext?.transcribeData(data)
printMessage("Done: $text\n")
} catch (e: Exception) {
Log.w(LOG_TAG, e)
printMessage("${e.localizedMessage}\n")
}
canTranscribe = true
}
fun toggleRecord() = viewModelScope.launch {
try {
if (isRecording) {
recorder.stopRecording()
isRecording = false
recordedFile?.let { transcribeAudio(it) }
} else {
stopPlayback()
val file = getTempFileForRecording()
recorder.startRecording(file) { e ->
viewModelScope.launch {
withContext(Dispatchers.Main) {
printMessage("${e.localizedMessage}\n")
isRecording = false
}
}
}
isRecording = true
recordedFile = file
}
} catch (e: Exception) {
Log.w(LOG_TAG, e)
printMessage("${e.localizedMessage}\n")
isRecording = false
}
}
private suspend fun getTempFileForRecording() = withContext(Dispatchers.IO) {
File.createTempFile("recording", "wav")
}
override fun onCleared() {
runBlocking {
whisperContext?.release()
whisperContext = null
stopPlayback()
}
}
companion object {
fun factory() = viewModelFactory {
initializer {
val application =
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
MainScreenViewModel(application)
}
}
}
}
private suspend fun Context.copyData(
assetDirName: String,
destDir: File,
printMessage: suspend (String) -> Unit
) = withContext(Dispatchers.IO) {
assets.list(assetDirName)?.forEach { name ->
val assetPath = "$assetDirName/$name"
Log.v(LOG_TAG, "Processing $assetPath...")
val destination = File(destDir, name)
Log.v(LOG_TAG, "Copying $assetPath to $destination...")
printMessage("Copying $name...\n")
assets.open(assetPath).use { input ->
destination.outputStream().use { output ->
input.copyTo(output)
}
}
Log.v(LOG_TAG, "Copied $assetPath to $destination")
}
}
@@ -0,0 +1,11 @@
package com.whispercppdemo.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,68 @@
package com.whispercppdemo.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun WhisperCppDemoTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
(view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.whispercppdemo.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,61 @@
package com.whispercppdemo.whisper
import kotlinx.coroutines.*
import java.util.concurrent.Executors
class WhisperContext private constructor(private var ptr: Long) {
// Meet Whisper C++ constraint: Don't access from more than one thread at a time.
private val scope: CoroutineScope = CoroutineScope(
Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
suspend fun transcribeData(data: FloatArray): String = withContext(scope.coroutineContext) {
require(ptr != 0L)
WhisperLib.fullTranscribe(ptr, data)
val textCount = WhisperLib.getTextSegmentCount(ptr)
return@withContext buildString {
for (i in 0 until textCount) {
append(WhisperLib.getTextSegment(ptr, i))
}
}
}
suspend fun release() = withContext(scope.coroutineContext) {
if (ptr != 0L) {
WhisperLib.freeContext(ptr)
ptr = 0
}
}
protected fun finalize() {
runBlocking {
release()
}
}
companion object {
fun createContext(filePath: String): WhisperContext {
val ptr = WhisperLib.initContext(filePath)
if (ptr == 0L) {
throw java.lang.RuntimeException("Couldn't create context with path $filePath")
}
return WhisperContext(ptr)
}
}
}
private class WhisperLib {
companion object {
init {
System.loadLibrary("whisper")
}
// JNI methods
external fun initContext(modelPath: String): Long
external fun freeContext(contextPtr: Long)
external fun fullTranscribe(contextPtr: Long, audioData: FloatArray)
external fun getTextSegmentCount(contextPtr: Long): Int
external fun getTextSegment(contextPtr: Long, index: Int): String
}
}