Compare commits

...

3 Commits

Author SHA1 Message Date
Joshua Perry d5ff6a1a0a Added build tasks 2024-10-28 23:23:43 +00:00
Joshua Perry 335ccae93d ui updates 2024-10-28 23:23:32 +00:00
Joshua Perry 10f17f07ab profile screens. ui bugs. styling. 2024-10-28 00:46:18 +00:00
15 changed files with 739 additions and 252 deletions

1
.gitignore vendored
View File

@ -50,4 +50,5 @@ bin/
### User Generated Files ###
/logs/
/profiles/
/user.settings

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
<option name="version" value="2.0.21" />
</component>
</project>

View File

@ -1,7 +1,11 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.internal.de.undercouch.gradle.tasks.download.Download
plugins {
kotlin("jvm")
val kotlinVersion = "2.0.21"
kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
}
@ -16,9 +20,6 @@ repositories {
}
dependencies {
val djlVersion = "0.30.0"
val jnaVersion = "5.15.0"
val voyagerVersion = "1.1.0-beta02"
// Core
implementation(compose.desktop.currentOs)
@ -29,10 +30,8 @@ dependencies {
// Material Design
implementation(compose.materialIconsExtended)
// Speech to Text
implementation("com.alphacephei:vosk:0.3.45")
//Java Native Access var capturing = false
//Java Native Access
val jnaVersion = "5.15.0"
implementation("net.java.dev.jna:jna:$jnaVersion")
implementation("net.java.dev.jna:jna-platform:$jnaVersion")
@ -40,13 +39,18 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
// Voyager
val voyagerVersion = "1.1.0-beta02"
implementation("cafe.adriel.voyager:voyager-navigator:$voyagerVersion")
implementation("cafe.adriel.voyager:voyager-screenmodel:$voyagerVersion")
implementation("cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyagerVersion")
implementation("cafe.adriel.voyager:voyager-tab-navigator:$voyagerVersion")
implementation("cafe.adriel.voyager:voyager-transitions:$voyagerVersion")
// Speech to Text
implementation("com.alphacephei:vosk:0.3.45")
// Deep Java Library
val djlVersion = "0.30.0"
implementation("ai.djl:api:$djlVersion")
implementation("ai.djl.huggingface:tokenizers:$djlVersion")
implementation("ai.djl.pytorch:pytorch-engine:$djlVersion")
@ -59,9 +63,10 @@ dependencies {
implementation("io.github.oshai:kotlin-logging-jvm:7.0.0")
implementation("ch.qos.logback:logback-classic:1.5.11")
//Json
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.5.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
// Json
val serializationVersion = "1.5.1"
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
}
compose.desktop {
@ -75,3 +80,86 @@ compose.desktop {
}
}
}
tasks.register("buildLite") {
description = "Build program with the small vosk STT model"
dependsOn("prepareLiteResources", "build")
}
tasks.register("buildFull") {
description = "Build program with the large vosk STT model"
dependsOn("prepareFullResources", "build")
}
tasks.register("generateSemanticModel") {
description = "Run DJL to generate Semantic model from HuggingFace (all-roberta-large-v1)"
val exeDir = ""
val modelName = "sentence-transformers/all-roberta-large-v1"
val djlOutputDir = project.file("src/main/resources/Semantic-model")
doFirst {
djlOutputDir.deleteRecursively()
}
doLast {
val os = System.getProperty("os.name")
val executable = when {
os.contains("win") -> project.file("$exeDir/djl-windows.exe")
os.contains("mac") -> project.file("$exeDir/djl-mac")
os.contains("linux") || os.contains("nix") || os.contains("nux") -> project.file("$exeDir/djl-linux")
else -> throw GradleException("Unsupported OS: $os")
}
exec {
commandLine = listOf(executable.absolutePath, modelName, djlOutputDir.absolutePath)
}
}
}
val sttOutputDir = project.file("src/main/resources/STT-model")
val modelUrl = "https://alphacephei.com/vosk/models"
val zipFile = layout.buildDirectory.file("STT-model.zip")
tasks.register<Download>("prepareLiteResources") {
description = "Download small vosk model to resources"
val modelName = "vosk-model-small-en-us-0.15"
doFirst {
sttOutputDir.deleteRecursively()
dependsOn("generateSemanticModel")
}
src("$modelUrl/$modelName.zip")
dest(zipFile)
doLast {
project.copy {
from(project.zipTree(zipFile))
into(sttOutputDir)
}
}
}
tasks.register<Download>("prepareFullResources") {
description = "Download large vosk model to resources"
val modelName = "vosk-model-en-us-0.22"
doFirst {
sttOutputDir.deleteRecursively()
dependsOn("generateSemanticModel")
}
src("$modelUrl/$modelName.zip")
dest(zipFile)
doLast {
project.copy {
from(project.zipTree(zipFile))
into(sttOutputDir)
}
}
}

View File

@ -1,20 +1,28 @@
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.Navigator
import io.github.oshai.kotlinlogging.KotlinLogging
import processing.SemanticSimilarity
import ui.screens.Home
import ui.Bars
import ui.Profiles.Companion.activeProfile
import ui.Profiles.Companion.getCurrentProfile
import java.io.File
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
@ -23,8 +31,12 @@ val logger = KotlinLogging.logger {}
fun main() = application {
logger.info { "\n================== NEW APPLICATION SESSION ==================" }
checkLogsDir()
Window(onCloseRequest = ::exitApplication) {
checkDirExists("logs/")
checkDirExists("profiles/")
Window(
title = "M.I.A",
onCloseRequest = ::exitApplication) {
MaterialTheme(
colors = if (isSystemInDarkTheme()) darkColors() else lightColors()
) {
@ -41,14 +53,33 @@ fun main() = application {
logger.info { "Application started" }
}
fun checkLogsDir() {
val dir = File(Paths.get("logs/").absolutePathString())
fun checkDirExists(path: String) {
val dir = File(Paths.get(path).absolutePathString())
if (!dir.exists()) {
dir.createNewFile()
logger.info { "Folder Created: logs/" }
dir.mkdir()
logger.info { "Folder Created: $path" }
}
}
@Composable
fun transparentButton(icon: ImageVector, contentDescription: String, onClick: () -> Unit, modifier: Modifier) {
Button(
onClick = onClick,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colors.onPrimary,
backgroundColor = Color.Transparent
),
elevation = ButtonDefaults.elevation(0.dp),
modifier = modifier
) {
Icon(
imageVector = icon,
contentDescription = contentDescription
)
}
}
//TODO: If folders (Semantic-model/, STT-model/) don't exist on start-up install
// Install Semantic-model by downloading, using djl-converter (look into using pip module from Java), move to resources
// Install STT-model by downloading, move to resources

View File

@ -1,7 +1,10 @@
package processing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.serialization.json.Json
import javax.sound.sampled.AudioFormat
@ -28,22 +31,26 @@ class Microphone {
*/
@OptIn(DelicateCoroutinesApi::class)
fun startCapture(captureDuration: Int) {
captureLogger.info { "{NEW CAPTURE SESSION}" }
capturing = true
source.open(audioFormat, source.bufferSize)
source.start()
CoroutineScope(Dispatchers.IO + SupervisorJob()).async {
capturing = true
val buffer = ByteArray(32000 * captureDuration) // sampleRate * sampleSizeInBits * channels * duration
GlobalScope.async {
val buffer = ByteArray(32000 * captureDuration) // sampleRate * sampleSizeInBits * channels * duration
val stt = STT()
captureLogger.info { "{NEW CAPTURE SESSION}" }
source.open(audioFormat, source.bufferSize)
source.start()
while (capturing) {
val audioBytes = source.read(buffer, 0, buffer.size)
val result = stt.parseBuffer(buffer, audioBytes)
val result = stt.parseBuffer(
buffer,
source.read(buffer, 0, buffer.size)
)
if (result.isNotEmpty()) {
captureLogger.info { result }
//TODO: Pass onto processing
}
//TODO: Pass onto processing
}
stt.closeModel()
}
@ -52,9 +59,9 @@ class Microphone {
*
*/
fun stopCapture() {
capturing = false
source.stop()
source.close()
capturing = false
}
/**
*
@ -64,7 +71,6 @@ class Microphone {
TargetDataLine::class.java,
audioFormat
)
return AudioSystem.getLine(info) as TargetDataLine
}
}

View File

@ -15,7 +15,7 @@ class STT {
/**
*
*/
fun parseBuffer(buffer: ByteArray, audioBytes: Int): String = if (recognizer.acceptWaveForm(buffer, audioBytes)) recognizer.result.split("\"")[3] else recognizer.partialResult.split("\"")[3]
fun parseBuffer(buffer: ByteArray, audioBytes: Int): String = if (recognizer.acceptWaveForm(buffer, audioBytes)) recognizer.finalResult.split("\"")[3] else recognizer.result.split("\"")[3]
fun closeModel() {
recognizer.close()

View File

@ -25,7 +25,7 @@ class SemanticSimilarity {
fun initTokenizer(): HuggingFaceTokenizer {
logger.info { "Semantic similarity tokenizer initialized" }
return HuggingFaceTokenizer.newInstance("sentence-transformers/all-mpnet-base-v2")
return HuggingFaceTokenizer.newInstance("sentence-transformers/all-roberta-large-v1")
}
fun getCriteria(): Criteria<NDList, NDList> = Criteria.builder()
@ -50,19 +50,30 @@ class SemanticSimilarity {
fun generateEmbedding(input: String): NDArray {
val inputList = tokenizer.encode(input).toNDList(manager, false)
val inputList = tokenizer.encode(input).toNDList(manager, true)
val inputIds = inputList[0].expandDims(0)
val attentionMask = inputList[1].expandDims(0)
return model.predict(
val tokenEmbeddings = model.predict(
NDList(inputIds, attentionMask)
)[0].mean(intArrayOf(1)).normalize(2.0, 1)
)[0]
return meanPooling(tokenEmbeddings, attentionMask)
}
fun meanPooling(tokenEmbeddings: NDArray, attentionMask: NDArray): NDArray {
val inputMask = attentionMask.expandDims(-1).toType(tokenEmbeddings.dataType, false) // Shape: [1, seq_len, 1]
val sumEmbeddings = tokenEmbeddings.mul(inputMask).sum(intArrayOf(1)) // Sum over seq_len
val sumMask = inputMask.sum(intArrayOf(1)).clip(1e-9f, Float.MAX_VALUE) // Prevent division by zero
return sumEmbeddings.div(sumMask)
}
fun cosineSimilarity(input: NDArray, command: NDArray): Float {
val inputVec = input.squeeze(0)
val commandVec = command.squeeze(0)
val dotProduct = input.matMul(commandVec).getFloat()
val inputVec = input.squeeze()
val commandVec = command.squeeze()
val dotProduct = inputVec.matMul(commandVec).getFloat()
val normInput = inputVec.norm().getFloat()
val normCommand = commandVec.norm().getFloat()

View File

@ -2,30 +2,23 @@ package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBackIosNew
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import transparentButton
import ui.Profiles.Companion.Buttons.Companion.createProfile
import ui.Profiles.Companion.Buttons.Companion.currentProfile
import ui.screens.ProfilesManager
import ui.screens.Settings
class Bars {
@ -33,9 +26,9 @@ class Bars {
@Composable
fun topBar(screenCount: Int) {
ConstraintLayout(
Modifier
.fillMaxWidth()
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
) {
val (back, capture) = createRefs()
@ -46,51 +39,68 @@ class Bars {
})
}
Capture.captureContext(Modifier.constrainAs(capture) {
linkTo(parent.top, parent.bottom)
linkTo(parent.start, parent.end)
centerVerticallyTo(parent)
})
}
}
@Composable
fun bottomBar() {
ConstraintLayout(
Modifier
modifier = Modifier
.padding(5.dp)
.fillMaxWidth()
) {
val currentProfile = createRef()
val (currentProfile, createProfile) = createRefs()
Profiles.currentProfile(Modifier.constrainAs(currentProfile) {
bottom.linkTo(parent.bottom)
currentProfile(Modifier.constrainAs(currentProfile) {
linkTo(parent.top, parent.bottom)
start.linkTo(parent.start)
})
if (LocalNavigator.currentOrThrow.lastItem is ProfilesManager) {
createProfile(Modifier.constrainAs(createProfile) {
linkTo(parent.top, parent.bottom)
end.linkTo(parent.end)
})
}
}
}
@Composable
fun backButton(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
Box(
modifier = modifier
.background(MaterialTheme.colors.primary, CircleShape)
) {
Button(
shape = CircleShape,
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent, contentColor = MaterialTheme.colors.onPrimary),
elevation = ButtonDefaults.elevation(0.dp),
onClick = {
navigator.pop()
}) {
Icon(
imageVector = Icons.Filled.ArrowBackIosNew,
contentDescription = "Go Back"
)
}
transparentButton(
modifier = Modifier,
icon = Icons.Filled.ArrowBackIosNew,
contentDescription = "Go Back",
onClick = { navigator.pop() },
)
}
}
@Composable
fun settingsButton(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
transparentButton(
modifier = modifier,
icon = Icons.Filled.Settings,
contentDescription = "Settings",
onClick = {
if (navigator.lastItem !is Settings) {
navigator.push(Settings())
}
},
)
}
}
}

View File

@ -1,21 +1,16 @@
package ui
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import processing.Microphone
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Slider
import androidx.compose.material.Text
@ -24,59 +19,63 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.delay
import logger
import transparentButton
import ui.Bars.Companion.settingsButton
import ui.screens.Settings
import ui.screens.Settings.Companion.userSettings
import java.io.File
import java.nio.file.Paths
import kotlin.io.path.absolutePathString
import kotlin.math.max
import kotlin.math.roundToInt
class Capture {
companion object {
val mic = Microphone()
val captureLogger = KotlinLogging.logger("capture_logger")
val logUpdateDelay = (getCaptureDuration().toLong() * 1000) + 100 // captureDuration + 0.1s in ms
fun getCaptureDuration() = userSettings.getProperty("capture_duration").toInt()
fun getCaptureOnClick(mic: Microphone) = if (!mic.capturing) mic.startCapture(getCaptureDuration()) else mic.stopCapture()
fun getCaptureIcon(capturing: Boolean) = if (!capturing) Icons.Filled.PlayArrow else Icons.Filled.Stop
fun getCaptureDescription(capturing: Boolean) = if (!capturing) "Start" else "Stop"
fun getCaptureLog() = File(Paths.get("logs/capture.log").absolutePathString()).readLines()
fun getCaptureOnClick(mic: Microphone) = if (mic.capturing) mic.stopCapture() else mic.startCapture(getCaptureDuration())
fun getCaptureIcon(capturing: Boolean) = if (capturing) Icons.Filled.Stop else Icons.Filled.PlayArrow
fun getCaptureDescription(capturing: Boolean) = if (capturing) "Stop" else "Start"
fun getCaptureLog() = File(Paths.get("logs/capture.log").absolutePathString()).readLines().toMutableList()
@Composable
fun captureContext(modifier: Modifier) {
ConstraintLayout(
modifier = modifier
.background(MaterialTheme.colors.primary, CircleShape)
) {
val (button, info, settings) = createRefs()
captureButton(Modifier.constrainAs(button) {
linkTo(parent.top, parent.bottom)
linkTo(parent.start, info.start, startMargin = 5.dp)
})
lastCapture(Modifier.constrainAs(info) {
linkTo(parent.top, parent.bottom)
linkTo(button.end, settings.start, startMargin = 5.dp)
})
Settings.settingsButton(Modifier.constrainAs(settings) {
settingsButton(Modifier.constrainAs(settings) {
linkTo(parent.top, parent.bottom)
linkTo(info.end, parent.end, startMargin = 5.dp, endMargin = 5.dp)
})
@ -86,117 +85,114 @@ class Capture {
@OptIn(DelicateCoroutinesApi::class)
@Composable
fun captureButton(modifier: Modifier) {
val mic = Microphone()
var isCapturing by rememberSaveable { mutableStateOf(mic.capturing) }
Button(
shape = CircleShape,
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colors.onPrimary, backgroundColor = Color.Transparent),
elevation = ButtonDefaults.elevation(0.dp),
var isCapturing by remember { mutableStateOf(mic.capturing) }
transparentButton(
modifier = modifier,
onClick = {
icon = getCaptureIcon(isCapturing),
contentDescription = getCaptureDescription(isCapturing) + " audio capture",
onClick = {
getCaptureOnClick(mic)
isCapturing = !isCapturing
}
) {
Icon(
imageVector = getCaptureIcon(isCapturing),
contentDescription = getCaptureDescription(isCapturing) + " audio capture"
)
}
},
)
}
@Composable
fun captureDurationSlider() {
var sliderPosition by remember { mutableStateOf(getCaptureDuration().toFloat()) }
Text("Capture Duration")
val textModifier = Modifier.padding(vertical = 5.dp)
Text(
modifier = textModifier,
text = "Capture Duration",
)
Slider(
valueRange = 1.0f..20.0f,
value = sliderPosition,
onValueChange = {
if (sliderPosition != it) {
sliderPosition = it.roundToInt().toFloat()
userSettings.setProperty("capture_duration", "${it.roundToInt()}")
onValueChange = { newValue ->
val newValue = newValue.roundToInt()
val newValueFloat = newValue.toFloat()
if (sliderPosition != newValueFloat) {
sliderPosition = newValueFloat
userSettings.setProperty("capture_duration", "$newValue")
userSettings.save()
logger.info { "User Setting Changed: capture_duration = ${it.roundToInt()}" }
logger.info { "User Setting Changed: capture_duration = $newValue" }
}
}
},
)
Text(
modifier = textModifier,
text = "$sliderPosition seconds",
)
Text("$sliderPosition seconds")
}
@Composable
fun captureLog() {
var lines by rememberSaveable { mutableStateOf(getCaptureLog())}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 25.dp, end = 25.dp, top = 10.dp, bottom = 50.dp)
.verticalScroll(rememberScrollState())
) {
var count = 1
Text(
color = MaterialTheme.colors.onBackground,
text = "Capture Log:",
var lines: MutableState<MutableList<String>> = rememberSaveable { mutableStateOf(getCaptureLog())}
SelectionContainer {
Column(
modifier = Modifier
.background(MaterialTheme.colors.onBackground.copy(alpha = 0.05f), CircleShape)
.padding(start = 5.dp)
.fillMaxWidth()
)
lines.forEach { line ->
val backgroundColor = if (count % 2 == 0) MaterialTheme.colors.onBackground.copy(alpha = 0.05f) else MaterialTheme.colors.background
Text(
color = MaterialTheme.colors.onBackground,
text = line,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.background(backgroundColor, CircleShape)
.padding(start = 5.dp)
.fillMaxWidth()
)
count += 1
}
}
.padding(horizontal = 25.dp)
.padding(top = 10.dp, bottom = 50.dp)
.verticalScroll(rememberScrollState())
) {
lines.value.add(0, "Capture Log:")
LaunchedEffect(Unit) {
while(true) {
val newLines = getCaptureLog()
if (newLines != lines) {
lines = newLines
var count = 0
lines.value.forEach { line ->
val backgroundColor =
if (count % 2 == 0) MaterialTheme.colors.onBackground.copy(alpha = 0.05f) else MaterialTheme.colors.background
Text(
modifier = Modifier
.background(backgroundColor, CircleShape)
.padding(start = 5.dp)
.fillMaxWidth(),
color = MaterialTheme.colors.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = line
)
count++
}
delay(getCaptureDuration().toLong() * 1100)
}
}
logUpdateCheck(lines)
}
@Composable
fun lastCapture(modifier: Modifier) { //TODO: Constrain size so buttons don't move everywhere
var lines by rememberSaveable { mutableStateOf(getCaptureLog()) }
fun lastCapture(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
var lines: MutableState<MutableList<String>> = rememberSaveable { mutableStateOf(getCaptureLog()) }
Text(
color = MaterialTheme.colors.onPrimary,
modifier = modifier
.width(300.dp),
text = if (lines.isNotEmpty()) lines.last() else "No captures this session",
.width(300.dp)
.clickable { navigator.popUntilRoot() },
color = MaterialTheme.colors.onPrimary,
text = lines.value.lastOrNull()?: "No captures this session",
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
maxLines = 1
)
logUpdateCheck(lines)
}
@Composable
fun logUpdateCheck(lines: MutableState<MutableList<String>>) {
LaunchedEffect(Unit) {
while(true) {
val newLines = getCaptureLog()
if (newLines != lines) {
lines = newLines
}
delay((getCaptureDuration().toLong() + 1) * 1000)
lines.value = getCaptureLog()
delay(logUpdateDelay)
}
}
}
}
}

View File

@ -1,19 +1,35 @@
package ui
import ai.djl.ndarray.NDArray
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Save
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.MutableState
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -21,73 +37,68 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.encodeToStream
import ui.Capture.Companion.getCaptureDuration
import transparentButton
import ui.Profiles.Companion.Dialogs.Companion.activateDialog
import ui.Profiles.Companion.Dialogs.Companion.deleteDialog
import ui.Profiles.Companion.Entities.Profile
import ui.screens.ProfileDetails
import ui.screens.ProfilesManager
import ui.screens.Settings
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.nio.file.Path
import java.nio.file.Paths
import java.util.UUID
import kotlin.io.path.absolutePathString
class Profiles {
companion object {
fun getCurrentProfile() = load(Settings.userSettings.getProperty("current_profile", null))
val activeProfile = mutableStateOf(getCurrentProfile())
val profiles = mutableStateOf(getProfiles())
val showActivateDialog = mutableStateOf(false)
val showDeleteDialog = mutableStateOf(false)
val selectedProfile = mutableStateOf(Profile())
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun currentProfile(modifier: Modifier) { //TODO: store profile in mutableState so component updates when currentProfile
var profile by remember { mutableStateOf(getCurrentProfile()) }
fun getCurrentProfile() = load(Settings.userSettings.getProperty("current_profile"))
fun getProfileLocation(profile: String?) = Paths.get("profiles/$profile.profile").absolutePathString()
fun getProfiles(): MutableList<Profile> {
val files = File(Paths.get("profiles/").absolutePathString()).listFiles()
val navigator = LocalNavigator.currentOrThrow
Text(
color = MaterialTheme.colors.onPrimary,
text = if (profile != null) profile!!.name else "Not Selected",
textAlign = TextAlign.Center,
modifier = modifier
.background(MaterialTheme.colors.primary, CircleShape)
.padding(5.dp)
.width(150.dp)
.combinedClickable(
onClick = { if (profile != null) navigator.push(ProfileDetails(profile!!)) },
onDoubleClick = { navigator.push(ProfilesManager()) }
)
)
files.ifEmpty {
Settings.userSettings.remove("current_profile")
}
return files.map { file ->
load(file)
} as MutableList<Profile>
}
data class Profile(
val name: String,
@SerialName("ai_name")
val aiName: String,
@SerialName("program_name")
val programName: Path,
val commands: List<Command>
)
data class Command(
@SerialName("wording_Variants")
val wordingVariants: List<String>,
val triggers: List<String>
)
@OptIn(ExperimentalSerializationApi::class)
fun load(profile: String?): Profile? {
if (profile != null) {
val stream = FileInputStream(getProfileLocation(profile))
val file = Json.decodeFromStream<Profile>(stream)
fun load(profile: File?): Profile {
var file: Profile
try {
val stream = FileInputStream(profile!!)
file = Json.decodeFromStream<Profile>(stream).copy(name = profile.nameWithoutExtension)
stream.close()
return file
}
else {
return null
} catch (ne: FileNotFoundException) {
file = Profile(name = "Not Selected")
} catch (je: SerializationException) {
file = Profile(name = profile!!.nameWithoutExtension)
}
return file
//TODO: Add logging messages
}
fun load(profile: String?): Profile = load(File(getProfileLocation(profile)))
@OptIn(ExperimentalSerializationApi::class)
fun save(profile: Profile) {
val stream = FileOutputStream(getProfileLocation(profile.name))
@ -95,7 +106,324 @@ class Profiles {
stream.flush()
stream.close()
}
/**
*
*/
class Entities {
@Serializable
data class Profile(
var name: String = "{NO NAME}",
@SerialName("ai_name")
var aiName: String = "{NO NAME}",
@SerialName("program_name")
var programName: String = "{NO NAME}",
@SerialName("program_path")
var programPath: String = "{NO NAME}",
@SerialName("commands")
var commands: List<Command>? = null
)
fun getProfileLocation(profile: String) = Paths.get("profiles/$profile.profile").absolutePathString()
@Serializable
data class Command(
@SerialName("wording_Variants")
val wordingVariants: List<NDArray>? = null,
val triggers: List<String>? = null
)
@Serializable
data class WordingVariant(
val wording: String,
val embedding: NDArray? = null
)
}
/**
*
*/
class Dialogs {
companion object {
@Composable
fun editDialog(flag: MutableState<Boolean>, title: String, text: String, confirmClick: () -> Unit) {
if (flag.value) {
profileDialog(
title = title,
text = text,
confirmClick = confirmClick,
cancelClick = {
flag.value = false
}
)
}
}
@Composable
fun activateDialog(profile: Profile) {
editDialog(
title = "Confirm Activation",
text = "Activate ${profile.name}?",
flag = showActivateDialog,
confirmClick = {
activeProfile.value = profile
Settings.userSettings.setProperty("current_profile", profile.name)
Settings.userSettings.save()
showActivateDialog.value = false
}
)
}
@Composable
fun deleteDialog(profile: Profile) {
val navigator = LocalNavigator.currentOrThrow
editDialog(
title = "Confirm Deletion",
text = "Delete ${profile.name}?",
flag = showDeleteDialog,
confirmClick = {
File(getProfileLocation(profile.name)).delete()
profiles.value = getProfiles()
if (activeProfile.value == profile) {
Settings.userSettings.remove("current_profile")
Settings.userSettings.save()
activeProfile.value = getCurrentProfile()
}
showDeleteDialog.value = false
if (navigator.lastItem is ProfileDetails) {
navigator.pop()
}
}
)
}
@Composable
fun profileDialog(title: String, text: String, confirmClick: () -> Unit, cancelClick: () -> Unit) {
AlertDialog(
title = { Text(text = title) },
text = { Text(text = text) },
onDismissRequest = {
cancelClick
},
confirmButton = {
Button(
onClick = confirmClick
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Accept"
)
}
},
dismissButton = {
Button(
onClick = cancelClick
) {
Icon(
imageVector = Icons.Filled.Cancel,
contentDescription = "Cancel"
)
}
}
)
}
}
}
/**
*
*/
class Lists {
companion object {
@Composable
fun profileList() {
profiles.value = getProfiles()
Column(
modifier = Modifier
.padding(horizontal = 25.dp)
.padding(top = 10.dp, bottom = 50.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.End,
) {
profileListHeader()
profileListEntries()
activateDialog(selectedProfile.value)
deleteDialog(selectedProfile.value)
}
}
@Composable
fun profileListItem(profileName: String, programName: String, aiName: String, modifier: Modifier) {
Row(
modifier = modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
listOf(profileName, programName, aiName).forEach { name ->
Text(
modifier = Modifier
.weight(1f),
text = name,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun profileListHeader() {
profileListItem(
modifier = Modifier
.background(MaterialTheme.colors.onBackground.copy(alpha = 0.05f), CircleShape),
profileName = "Profile Name",
programName = "Program Name",
aiName = "AI Name"
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun profileListEntries() {
val navigator = LocalNavigator.currentOrThrow
var count = 1
profiles.value.forEach { profile ->
val backgroundColor =
if (count % 2 == 0) MaterialTheme.colors.onBackground.copy(alpha = 0.05f) else MaterialTheme.colors.background
profileListItem(
modifier = Modifier
.background(backgroundColor, CircleShape)
.combinedClickable(
onClick = {
navigator.push(ProfileDetails(profile))
},
onDoubleClick = {
selectedProfile.value = profile
showActivateDialog.value = true
},
onLongClick = {
selectedProfile.value = profile
showDeleteDialog.value = true
},
),
profileName = profile.name,
programName = profile.programName,
aiName = profile.aiName,
)
count++
}
}
}
}
/**
*
*/
class Buttons {
companion object {
@Composable
fun createProfile(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
Box(
modifier = modifier
.background(MaterialTheme.colors.primary, CircleShape)
) {
transparentButton(
modifier = Modifier,
icon = Icons.Filled.Create,
contentDescription = "Create new profile",
onClick = {
navigator.push(ProfileDetails())
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun currentProfile(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
Text(
modifier = modifier
.background(MaterialTheme.colors.primary, CircleShape)
.padding(5.dp)
.width(150.dp)
.combinedClickable(
onClick = { navigator.push(ProfileDetails(activeProfile.value)) },
onDoubleClick = {
if (navigator.lastItem !is ProfilesManager) {
navigator.push(ProfilesManager())
}
}
),
color = MaterialTheme.colors.onPrimary,
textAlign = TextAlign.Center,
text = activeProfile.value.name
)
}
@Composable
fun saveButton(profile: Profile) {
val navigator = LocalNavigator.currentOrThrow
Button(
onClick = {
if (profile.name == "{NO NAME}") {
profile.name = UUID.randomUUID().toString()
}
save(profile)
navigator.pop()
}
) {
Icon(
imageVector = Icons.Filled.Save,
contentDescription = "Save Profile"
)
}
}
@Composable
fun deleteButton(profile: Profile) {
Button(
onClick = {
val file = File(getProfileLocation(profile.name))
if (file.exists()) {
showDeleteDialog.value = true
}
}
) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = "Delete Profile"
)
}
}
@Composable
fun activateButton(profile: Profile) {
Button(
onClick = {
showActivateDialog.value = true
}
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Activate Profile"
)
}
}
}
}
}
}

View File

@ -12,5 +12,4 @@ class Home: Screen {
override fun Content() {
Capture.captureLog()
}
}

View File

@ -1,15 +1,44 @@
package ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.core.screen.Screen
import ui.Profiles.Companion.Profile
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import ui.Profiles.Companion.Dialogs.Companion.activateDialog
import ui.Profiles.Companion.Buttons.Companion.activateButton
import ui.Profiles.Companion.Buttons.Companion.saveButton
import ui.Profiles.Companion.Buttons.Companion.deleteButton
import ui.Profiles.Companion.Dialogs.Companion.deleteDialog
import ui.Profiles.Companion.Entities.Profile
class ProfileDetails(profile: Profile): Screen {
class ProfileDetails(val profile: Profile): Screen {
/**
*
*/
constructor(): this(Profile())
/**
*
*/
@Composable
override fun Content() {
//TODO: Display all fields for profile
// Allow editing
// Save Profile Button
// Delete Profile Button
override fun Content() { //TODO: Description of what each attribute represents
Column {
Text("Name: ${profile.name}") //TODO: Text Field
Text("AI name: ${profile.aiName}") //TODO: Text Field
Text("Program name: ${profile.programName}") //TODO: Text Field
Text("Program path: ${profile.programPath}") //TODO: FileDialog
Row {
activateButton(profile)
saveButton(profile)
deleteButton(profile)
activateDialog(profile)
deleteDialog(profile)
}
}
//TODO: Display commands
// Allow editing. on unFocus, update profile entry
}
}

View File

@ -2,13 +2,11 @@ package ui.screens
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.core.screen.Screen
import ui.Profiles.Companion.Lists.Companion.profileList
class ProfilesManager: Screen {
class ProfilesManager(): Screen {
@Composable
override fun Content() {
//TODO: List of profiles in profiles folder onDoubleClick = ProfileDetails for that profile, longClick = open confirmation dialog to activate profile. Show name, programName, and aiName
// Create Profile button
// Delete Profile button
profileList()
}
}

View File

@ -1,12 +1,12 @@
package ui.screens
import androidx.compose.foundation.background
import ui.UserSettings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
@ -16,17 +16,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import transparentButton
import ui.Capture
class Settings: Screen {
@Composable
override fun Content() {
Column {
Column(
modifier = Modifier
.padding(10.dp)
) {
userSettings.forEach { setting ->
Spacer(Modifier.padding(10.dp))
setting(setting)
Spacer(
modifier = Modifier
.height(1.dp)
.fillMaxWidth()
.background(MaterialTheme.colors.onBackground)
)
}
}
}
@ -34,25 +45,6 @@ class Settings: Screen {
companion object {
val userSettings = UserSettings()
@Composable
fun settingsButton(modifier: Modifier) {
val navigator = LocalNavigator.currentOrThrow
Button(
shape = CircleShape,
colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colors.onPrimary ,backgroundColor = Color.Transparent),
elevation = ButtonDefaults.elevation(0.dp),
modifier = modifier,
onClick = {
navigator.push(Settings())
}
) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings"
)
}
}
@Composable
fun setting(setting: Map.Entry<Any?, Any?>) {
when(setting.key) {
@ -61,6 +53,5 @@ class Settings: Screen {
else -> null
}
}
}
}

View File

@ -1,12 +1,11 @@
<configuration>
<logger name="capture_logger" level="DEBUG">
<appender-ref ref="CAPTURE_LOGS_FILE" />
<appender-ref ref="STDOUT" />
</logger>
<root level="INFO">
<appender-ref ref="APPLICATION_LOGS_FILE" />
<appender-ref ref="STDOUT" />
<appender-ref ref="APPLICATION_LOGS_FILE" />
</root>
<!-- Capture appender -->