Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2e08d2a
Phase 1.1: feat(whisper): add whisper.cpp as git submodule
jkkj Feb 10, 2026
02c319f
Phase 1.2: build: add gradle toolchain plugin and include whisper module
jkkj Feb 10, 2026
caefb3c
Phase 1.3: build(whisper): add whisper module build configuration
jkkj Feb 10, 2026
b296991
Phase 1.4: build(whisper): add CMake build configuration
jkkj Feb 10, 2026
c5d4db8
Phase 1.5: feat(whisper): add JNI bindings for whisper.cpp
jkkj Feb 10, 2026
8ea788a
Phase 1.6: feat(whisper): add WhisperCallback interface and CPU config
jkkj Feb 10, 2026
40d4915
Phase 1.7: feat(whisper): add LibWhisper Kotlin wrapper
jkkj Feb 10, 2026
9905e7f
Phase 1.8: build: add JVM toolchain configuration
jkkj Feb 10, 2026
815099a
Phase 1.9: build: add whisper and wave-recorder dependencies
jkkj Feb 10, 2026
cd2cd64
Phase 2.1: feat(tts): add TTS strings and PreferencesManager
jkkj Feb 10, 2026
ac3efe4
Phase 2.2: feat(tts): add TextToSpeechManager
jkkj Feb 10, 2026
cf056bd
Phase 3.1: feat(stt): add STT strings, preferences, and language support
jkkj Feb 10, 2026
ff6c34c
Phase 3.2: feat(stt): add mic and stop notification icons
jkkj Feb 10, 2026
4235eda
Phase 3.3: feat(stt): add SpeechToTextManager
jkkj Feb 10, 2026
2159dc4
Phase 3.4: feat(whisper): add PopularWhisperModelsList
jkkj Feb 10, 2026
7e79d00
Phase 3.5: feat(whisper): add DownloadWhisperModelActivity
jkkj Feb 10, 2026
bb270ed
Phase 4.1: feat(voice): add voice chat strings and service manager
jkkj Feb 10, 2026
cf520d2
Phase 4.2: feat(voice): add VoiceChatService
jkkj Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
local.properties
.kotlin
ktlint
ktlint.bat
ktlint.bat
whisper/build/
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[submodule "llama.cpp"]
path = llama.cpp
url = https://github.com/ggerganov/llama.cpp
[submodule "whisper/src/main/jni/whisper.cpp"]
path = whisper/src/main/jni/whisper.cpp
url = https://github.com/ggml-org/whisper.cpp.git
7 changes: 6 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.devtools.ksp")
kotlin("plugin.serialization") version "2.1.0"
kotlin("plugin.serialization") version "2.0.0"
}

android {
Expand Down Expand Up @@ -92,12 +92,17 @@ dependencies {

implementation(project(":smollm"))
implementation(project(":hf-model-hub-api"))
implementation(project(":whisper"))

// Android Wave Recorder for speech-to-text
implementation("com.github.squti:Android-Wave-Recorder:2.1.0")

// Koin: dependency injection
implementation(libs.koin.android)
implementation(libs.koin.annotations)
implementation(libs.koin.androidx.compose)
implementation(libs.androidx.ui.text.google.fonts)
implementation(libs.androidx.compose.foundation)
ksp(libs.koin.ksp.compiler)

// compose-markdown: Markdown rendering in Compose
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright (C) 2024 Shubham Panchal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.shubham0204.smollmandroid.data

import android.content.Context
import org.koin.core.annotation.Single

@Single
class PreferencesManager(context: Context) {
private val prefs = context.getSharedPreferences("smolchat_prefs", Context.MODE_PRIVATE)

var ttsEnabled: Boolean
get() = prefs.getBoolean("tts_enabled", false)
set(value) = prefs.edit().putBoolean("tts_enabled", value).apply()

var autoSubmitEnabled: Boolean
get() = prefs.getBoolean("auto_submit_enabled", false)
set(value) = prefs.edit().putBoolean("auto_submit_enabled", value).apply()

var autoSubmitDelayMs: Long
get() = prefs.getLong("auto_submit_delay_ms", 2000L)
set(value) = prefs.edit().putLong("auto_submit_delay_ms", value).apply()

var selectedWhisperModel: String
get() = prefs.getString("selected_whisper_model", DEFAULT_WHISPER_MODEL) ?: DEFAULT_WHISPER_MODEL
set(value) = prefs.edit().putString("selected_whisper_model", value).apply()

var sttLanguage: String
get() = prefs.getString("stt_language", DEFAULT_STT_LANGUAGE) ?: DEFAULT_STT_LANGUAGE
set(value) = prefs.edit().putString("stt_language", value).apply()

companion object {
const val DEFAULT_WHISPER_MODEL = "ggml-base.en.bin"
const val DEFAULT_STT_LANGUAGE = "en"

// Whisper supported languages with their display names
val SUPPORTED_LANGUAGES = listOf(
"en" to "English",
"de" to "German",
"fr" to "French",
"es" to "Spanish",
"it" to "Italian",
"pt" to "Portuguese",
"nl" to "Dutch",
"pl" to "Polish",
"ru" to "Russian",
"zh" to "Chinese",
"ja" to "Japanese",
"ko" to "Korean",
"ar" to "Arabic",
"hi" to "Hindi",
"tr" to "Turkish",
"uk" to "Ukrainian",
"cs" to "Czech",
"sv" to "Swedish",
"auto" to "Auto-detect",
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/*
* Copyright (C) 2024 Shubham Panchal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.shubham0204.smollmandroid.service

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationCompat
import io.shubham0204.smollmandroid.R
import io.shubham0204.smollmandroid.ui.screens.chat.ChatActivity
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

private const val LOG_TAG = "VoiceChatService"

/**
* Foreground service that keeps voice chat active when the screen is locked.
* Shows a persistent notification with a "Stop" action.
* Uses a partial wake lock to keep CPU active for transcription.
*/
class VoiceChatService : Service(), KoinComponent {

private val voiceChatServiceManager: VoiceChatServiceManager by inject()
private var wakeLock: PowerManager.WakeLock? = null

companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "voice_chat_channel"
const val ACTION_STOP = "io.shubham0204.smollmandroid.STOP_VOICE_CHAT"

fun start(context: Context) {
Log.d(LOG_TAG, ">>> Starting VoiceChatService")
val intent = Intent(context, VoiceChatService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}

fun stop(context: Context) {
Log.d(LOG_TAG, ">>> Stopping VoiceChatService")
context.stopService(Intent(context, VoiceChatService::class.java))
}

/**
* Check if the app is exempt from battery optimization.
* On Samsung devices, this is required to prevent the app from being frozen.
*/
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
}

/**
* Request the user to disable battery optimization for this app.
* This is required on Samsung and other OEM devices to prevent aggressive app killing.
*/
fun requestBatteryOptimizationExemption(context: Context) {
if (!isIgnoringBatteryOptimizations(context)) {
Log.d(LOG_TAG, ">>> Requesting battery optimization exemption")
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}

override fun onCreate() {
super.onCreate()
Log.d(LOG_TAG, "onCreate")
createNotificationChannel()
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(LOG_TAG, "onStartCommand, action=${intent?.action}")

if (intent?.action == ACTION_STOP) {
Log.d(LOG_TAG, "Stop action received")
voiceChatServiceManager.requestStopService()
stopSelf()
return START_NOT_STICKY
}

val notification = buildNotification()
startForeground(NOTIFICATION_ID, notification)
voiceChatServiceManager.setServiceRunning(true)

// Acquire partial wake lock to keep CPU active for transcription
acquireWakeLock()

Log.d(LOG_TAG, "Service started in foreground")
return START_STICKY
}

override fun onDestroy() {
Log.d(LOG_TAG, "onDestroy")
releaseWakeLock()
voiceChatServiceManager.setServiceRunning(false)
super.onDestroy()
}

override fun onBind(intent: Intent?): IBinder? = null

@Suppress("DEPRECATION")
private fun acquireWakeLock() {
if (wakeLock == null) {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"SmolChat:VoiceChatWakeLock"
).apply {
acquire(60 * 60 * 1000L) // 1 hour max, released when service stops
}
Log.d(LOG_TAG, "Wake lock acquired")
}
}

private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
Log.d(LOG_TAG, "Wake lock released")
}
}
wakeLock = null
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.voice_chat_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.voice_chat_notification_text)
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}

private fun buildNotification(): Notification {
val stopIntent = Intent(this, VoiceChatService::class.java).apply {
action = ACTION_STOP
}
val stopPendingIntent = PendingIntent.getService(
this, 0, stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val openIntent = Intent(this, ChatActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val openPendingIntent = PendingIntent.getActivity(
this, 0, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.voice_chat_notification_title))
.setContentText(getString(R.string.voice_chat_notification_text))
.setSmallIcon(R.drawable.ic_mic_notification)
.setOngoing(true)
.setContentIntent(openPendingIntent)
.addAction(
R.drawable.ic_stop,
getString(R.string.voice_chat_stop),
stopPendingIntent
)
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2024 Shubham Panchal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.shubham0204.smollmandroid.service

import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.Single

/**
* Manages the state of the VoiceChatService and provides a way for the service
* and UI components to communicate.
*/
@Single
class VoiceChatServiceManager {
private val _isServiceRunning = MutableStateFlow(false)
val isServiceRunning: StateFlow<Boolean> = _isServiceRunning

private val _stopServiceRequest = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val stopServiceRequest: SharedFlow<Unit> = _stopServiceRequest

fun setServiceRunning(running: Boolean) {
_isServiceRunning.value = running
}

fun requestStopService() {
_stopServiceRequest.tryEmit(Unit)
}
}
Loading