Initial commit

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
forgejoadmin 2026-08-18 03:16:42 -04:00
commit 71db2d1ab9
126 changed files with 3198 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Python
.venv/
.venv_vlm/
__pycache__/
*.pyc
# Android / Gradle
.gradle/
build/
/app/local.properties
*.iml
.idea/
.cxx/
.externalNativeBuild/
captures/
# OS
.DS_Store
Thumbs.db
# Env / secrets
.env
.env.*
*.pem
*.key

15
app/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
app/app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

61
app/app/build.gradle.kts Normal file
View file

@ -0,0 +1,61 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.reddex"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "com.example.reddex"
minSdk = 36
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.window)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.okhttp)
implementation(libs.kotlinx.coroutines.android)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}

View file

@ -0,0 +1,24 @@
package com.example.reddex
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.reddex", appContext.packageName)
}
}

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RedDex">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.RedDex"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,8 @@
package com.example.reddex
/**
* LAN address of the recognition server (see /server in the repo root).
* DHCP can change this -- if recognition stops working, re-check the
* server machine's IP with ipconfig before assuming something else broke.
*/
const val RECOGNITION_SERVER_BASE_URL = "http://192.168.68.53:8420"

View file

@ -0,0 +1,358 @@
package com.example.reddex
import android.Manifest
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.util.Log
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.enableEdgeToEdge
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview as ComposePreview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.example.reddex.data.KNOWN_POKEDEX_ENTRIES
import com.example.reddex.data.PokedexEntry
import com.example.reddex.network.RecognitionOutcome
import com.example.reddex.network.RecognitionClient
import com.example.reddex.ui.theme.RedDexTheme
import java.io.File
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlinx.coroutines.launch
private const val TAG = "Pokedex"
/**
* Below this width, treat the display as the foldable's small cover
* screen (closed state). Above it, treat it as the unfolded inner
* display (open state). Tuned for the Z Fold 8's cover screen (~5.5",
* narrow) vs inner screen (~7.6", wide) -- this is a simplification of
* full FoldingFeature/posture detection, chosen because we only target
* one known device. Revisit with androidx.window's FoldingFeature APIs
* if broader device support is ever needed.
*/
private const val CLOSED_SCREEN_WIDTH_THRESHOLD_DP = 500
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
RedDexTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
PokedexApp()
}
}
}
}
}
}
@Composable
fun PokedexApp() {
val screenWidthDp = LocalConfiguration.current.screenWidthDp
if (screenWidthDp < CLOSED_SCREEN_WIDTH_THRESHOLD_DP) {
ClosedPokedexScreen()
} else {
OpenPokedexScreen()
}
}
@Composable
fun ClosedPokedexScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFCC0000)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.background(Color(0xFF3A6FD8), CircleShape)
.border(4.dp, Color.White, CircleShape),
)
Text(
text = "POKÉDEX",
color = Color.White,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
)
}
}
}
private sealed class ScanState {
data object Idle : ScanState()
data object Scanning : ScanState()
data class Recognized(val entry: PokedexEntry) : ScanState()
data class UnknownButDetected(val rawName: String) : ScanState()
data object NotRecognized : ScanState()
data class Error(val message: String) : ScanState()
}
@Composable
fun OpenPokedexScreen() {
val context = LocalContext.current
var hasCameraPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasCameraPermission = granted }
LaunchedEffect(Unit) {
if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA)
}
if (!hasCameraPermission) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Camera permission is needed to scan Pokémon.")
}
return
}
var scanState by remember { mutableStateOf<ScanState>(ScanState.Idle) }
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val scope = rememberCoroutineScope()
var mediaPlayer by remember { mutableStateOf<MediaPlayer?>(null) }
DisposableEffect(Unit) {
onDispose {
cameraExecutor.shutdown()
mediaPlayer?.release()
}
}
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
CameraPreview(
onImageCaptureReady = { imageCapture = it },
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Button(
onClick = {
val capture = imageCapture ?: return@Button
scanState = ScanState.Scanning
captureAndIdentify(
context = context,
imageCapture = capture,
executor = cameraExecutor,
scope = scope,
onResult = { outcome ->
scanState = outcomeToState(outcome)
if (scanState is ScanState.Recognized) {
mediaPlayer?.release()
mediaPlayer = playVoice(
context,
(scanState as ScanState.Recognized).entry.voiceAssetPath,
)
}
},
)
},
enabled = scanState !is ScanState.Scanning,
) {
Text(if (scanState is ScanState.Scanning) "Scanning..." else "Scan")
}
Box(modifier = Modifier.height(16.dp))
ScanResultView(scanState)
}
}
}
@Composable
private fun ScanResultView(state: ScanState) {
when (state) {
is ScanState.Idle -> Text("Point at a Pokémon and press Scan.")
is ScanState.Scanning -> CircularProgressIndicator()
is ScanState.Recognized -> {
Image(
painter = painterResource(state.entry.imageRes),
contentDescription = state.entry.displayName,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
)
Text(
text = state.entry.displayName,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 8.dp),
)
Text(text = state.entry.dexText, modifier = Modifier.padding(top = 8.dp))
}
is ScanState.UnknownButDetected -> Text(
"Recognized \"${state.rawName}\", but there's no Pokédex data " +
"for it yet in this app."
)
is ScanState.NotRecognized -> Text("No Pokémon detected. Try again.")
is ScanState.Error -> Text(
text = "Error: ${state.message}",
color = MaterialTheme.colorScheme.error,
)
}
}
private fun outcomeToState(outcome: RecognitionOutcome): ScanState = when (outcome) {
is RecognitionOutcome.Failure -> ScanState.Error(outcome.message)
is RecognitionOutcome.Success -> {
val result = outcome.result
if (!result.recognized || result.species == null) {
ScanState.NotRecognized
} else {
val entry = KNOWN_POKEDEX_ENTRIES[result.species.lowercase()]
if (entry != null) ScanState.Recognized(entry) else ScanState.UnknownButDetected(result.species)
}
}
}
private fun captureAndIdentify(
context: android.content.Context,
imageCapture: ImageCapture,
executor: Executor,
scope: kotlinx.coroutines.CoroutineScope,
onResult: (RecognitionOutcome) -> Unit,
) {
val photoFile = File(context.cacheDir, "capture_${System.currentTimeMillis()}.jpg")
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
imageCapture.takePicture(
outputOptions,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
scope.launch {
val bytes = photoFile.readBytes()
photoFile.delete()
val outcome = RecognitionClient.identify(bytes)
onResult(outcome)
}
}
override fun onError(exception: ImageCaptureException) {
Log.e(TAG, "Capture failed", exception)
onResult(RecognitionOutcome.Failure(exception.message ?: "Capture failed"))
}
},
)
}
private fun playVoice(context: android.content.Context, assetPath: String): MediaPlayer? {
return try {
val afd = context.assets.openFd(assetPath)
MediaPlayer().apply {
setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
afd.close()
prepare()
start()
}
} catch (e: Exception) {
Log.e(TAG, "Voice playback failed for $assetPath", e)
null
}
}
@Composable
private fun CameraPreview(onImageCaptureReady: (ImageCapture) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
androidx.compose.ui.viewinterop.AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val imageCapture = ImageCapture.Builder().build()
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture,
)
onImageCaptureReady(imageCapture)
} catch (e: Exception) {
Log.e(TAG, "Camera bind failed", e)
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
)
}
@ComposePreview(showBackground = true)
@Composable
fun ClosedPokedexPreview() {
RedDexTheme { ClosedPokedexScreen() }
}

View file

@ -0,0 +1,65 @@
package com.example.reddex.data
import com.example.reddex.R
data class PokedexEntry(
val speciesKey: String,
val displayName: String,
val dexText: String,
val imageRes: Int,
val voiceAssetPath: String,
)
/**
* Species this app has art/dex-text/voice for. Only these can be shown
* with full detail even if the recognition server correctly identifies
* something outside this set (see MainActivity's "known but no local
* data" fallback).
*/
val KNOWN_POKEDEX_ENTRIES: Map<String, PokedexEntry> = listOf(
PokedexEntry(
speciesKey = "bulbasaur",
displayName = "Bulbasaur",
dexText = "The Seed Pokémon. It can be seen napping in bright " +
"sunlight. There is a seed on its back. By soaking up the " +
"sun's rays, the seed grows progressively larger.",
imageRes = R.drawable.dex_bulbasaur,
voiceAssetPath = "voices/bulbasaur.wav",
),
PokedexEntry(
speciesKey = "charizard",
displayName = "Charizard",
dexText = "The Flame Pokémon. Charizard flies around the sky in " +
"search of powerful opponents. It breathes fire of such " +
"great heat that it melts anything.",
imageRes = R.drawable.dex_charizard,
voiceAssetPath = "voices/charizard.wav",
),
PokedexEntry(
speciesKey = "pikachu",
displayName = "Pikachu",
dexText = "The Mouse Pokémon. When several of these Pokémon " +
"gather, their electricity could build and cause lightning " +
"storms.",
imageRes = R.drawable.dex_pikachu,
voiceAssetPath = "voices/pikachu.wav",
),
PokedexEntry(
speciesKey = "eevee",
displayName = "Eevee",
dexText = "The Evolution Pokémon. Its genetic code is irregular. " +
"It may mutate if it is exposed to radiation from element " +
"stones.",
imageRes = R.drawable.dex_eevee,
voiceAssetPath = "voices/eevee.wav",
),
PokedexEntry(
speciesKey = "squirtle",
displayName = "Squirtle",
dexText = "The Tiny Turtle Pokémon. After birth, its back swells " +
"and hardens into a shell. It powerfully sprays foam from " +
"its mouth.",
imageRes = R.drawable.dex_squirtle,
voiceAssetPath = "voices/squirtle.wav",
),
).associateBy { it.speciesKey }

View file

@ -0,0 +1,88 @@
package com.example.reddex.network
import com.example.reddex.RECOGNITION_SERVER_BASE_URL
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
data class RecognitionResult(
val recognized: Boolean,
val species: String?,
val rawResponse: String,
)
sealed class RecognitionOutcome {
data class Success(val result: RecognitionResult) : RecognitionOutcome()
data class Failure(val message: String) : RecognitionOutcome()
}
object RecognitionClient {
private val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
// model inference can take a few seconds, especially on a cold GPU
.readTimeout(30, TimeUnit.SECONDS)
.build()
suspend fun identify(jpegBytes: ByteArray): RecognitionOutcome {
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
"capture.jpg",
jpegBytes.toRequestBody("image/jpeg".toMediaType()),
)
.build()
val request = Request.Builder()
.url("$RECOGNITION_SERVER_BASE_URL/identify")
.post(requestBody)
.build()
return try {
val response = client.newCall(request).await()
response.use {
if (!it.isSuccessful) {
return RecognitionOutcome.Failure("Server error: HTTP ${it.code}")
}
val body = it.body?.string() ?: return RecognitionOutcome.Failure("Empty response")
val json = JSONObject(body)
RecognitionOutcome.Success(
RecognitionResult(
recognized = json.getBoolean("recognized"),
species = if (json.isNull("species")) null else json.getString("species"),
rawResponse = json.optString("raw_response", ""),
)
)
}
} catch (e: IOException) {
RecognitionOutcome.Failure("Couldn't reach the recognition server: ${e.message}")
} catch (e: Exception) {
RecognitionOutcome.Failure("Unexpected error: ${e.message}")
}
}
private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
cont.resume(response)
}
override fun onFailure(call: Call, e: IOException) {
cont.resumeWithException(e)
}
})
cont.invokeOnCancellation { cancel() }
}
}

View file

@ -0,0 +1,11 @@
package com.example.reddex.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)

View file

@ -0,0 +1,58 @@
package com.example.reddex.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.ui.platform.LocalContext
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 RedDexTheme(
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
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,34 @@
package com.example.reddex.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
)
*/
)

View file

@ -0,0 +1,12 @@
# Add project specific R8 rules here.
# AGP will combine all keep rule files in src/main/keepRules to pass to R8
#
# For more details, see
# https://d.android.com/r/tools/r8/keep-rules
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,170 @@
<?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">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Red Dex</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RedDex" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,17 @@
package com.example.reddex
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

5
app/build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}

19
app/gradle.properties Normal file
View file

@ -0,0 +1,19 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# When enabled, the Configuration Cache allows Gradle to skip the configuration
# phase entirely if nothing that affects the build configuration (such as build scripts)
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View file

@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25

View file

@ -0,0 +1,42 @@
[versions]
agp = "9.3.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
windowManager = "1.5.0"
cameraX = "1.5.1"
okhttp = "5.1.0"
coroutines = "1.10.2"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-window = { group = "androidx.window", name = "window", version.ref = "windowManager" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

BIN
app/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -0,0 +1,9 @@
#Tue Aug 18 02:13:14 EDT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
app/gradlew vendored Normal file
View file

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# 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
#
# https://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.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
app/gradlew.bat vendored Normal file
View file

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

26
app/settings.gradle.kts Normal file
View file

@ -0,0 +1,26 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Red Dex"
include(":app")

View file

@ -0,0 +1,87 @@
# Image recognition de-risking — report
Goal: figure out how the app recognizes a Pokemon when the phone is
pointed at a figure/toy, before committing to an architecture.
## Test set
5 classes (Bulbasaur, Squirtle, Pikachu, Eevee, Charizard), grown over the
course of the spike to 12 query images spanning very different visual
styles on purpose: stock photos, Pokemon GO AR screenshots, plush toys
(including two real photos of figures the user owns), a TCG card, a tiny
battle sprite, and anime screencaps. Two reference sets were tried:
scraped stock photos, and Bulbapedia's official artwork.
## Approaches tried, in order
### 1. CLIP (ViT-B-32, OpenAI weights) + nearest-neighbor
Embed a reference image per species, embed the query, cosine-similarity
match. Initial run used the wrong open_clip model variant (`ViT-B-32`
instead of `ViT-B-32-quickgelu`), which silently degrades OpenAI-weight
embeddings — worth remembering if this comes up again.
- Photo references: **4/12** correct top-1.
- Bulbapedia art references: **8/12** correct top-1.
### 2. DINOv2 (facebook/dinov2-base) + nearest-neighbor
Self-supervised, built for instance/visual similarity rather than
text-image alignment — expected to beat CLIP at this specific task.
- Photo references: **6/12**.
- Bulbapedia art references: **8/12**.
**Finding across both:** official art references consistently beat random
stock-photo references, regardless of model — the illustration-vs-photo
domain gap we worried about mattered less than material/form-factor
mismatch (plush vs. rigid figure vs. flat art). `charizard_plush` failed
in literally every embedding config tried (0/4) — plush toys are the
genuinely hard case for this whole approach, not photos-vs-domain style.
### 3. Direct vision-LLM recognition (the "cheat")
Skip reference images and embeddings entirely — ask a vision-language
model "what Pokemon is this" and let its pretrained world knowledge do
the work.
- Claude (me, just looking at the images): **12/12**.
- Self-hosted Qwen2.5-VL-3B-Instruct, run locally on the RTX 5070 Ti:
**10/12** cold, no fine-tuning, no reference images at all. The 2
misses were the two genuinely hardest images in the set (a tiny
213x240 keychain thumbnail → correctly returned "unknown" rather than
a wrong guess; and a plush the user themselves said "looks like shit,
not even sure that's Charizard").
## Decision
Went with **self-hosted VLM recognition** (Qwen2.5-VL-3B-Instruct) over
the embedding/nearest-neighbor approach. Reasons:
- Meaningfully higher accuracy (10/12 vs. best embedding score of 8/12).
- No reference-image sourcing/maintenance needed for 1000+ species —
eliminates the "content volume" risk from the original risk assessment
entirely.
- Degrades safely: genuinely ambiguous images tend to get "unknown"
rather than a confident wrong answer.
Trade-off accepted: requires a GPU server reachable over the network at
recognition time (already an accepted dependency — the original plan
always involved uploading the photo to a home server).
## What shipped from this
`server/` — FastAPI wrapper around the same Qwen2.5-VL-3B pipeline,
running on this Windows machine (chosen over buying a GPU for Unraid).
`POST /identify` takes a photo, returns `{recognized, species,
raw_response}`. Verified working end-to-end over real HTTP.
## Open questions / not yet tested
- Accuracy at real scale (1000+ candidate species) is untested — only
ever tried 5 classes. Confusion likely increases with more classes.
- Never tested against the user's own figures except for two Charizard
photos (both plush) — the real target (rigid painted figures) hasn't
been tried.
- Larger models (Qwen2.5-VL-7B+) not tried — likely closes some of the
remaining gap to the 12/12 upper bound, at the cost of latency/VRAM.
- No latency/throughput measurement done — only correctness.

View file

@ -0,0 +1,83 @@
"""
De-risking spike: can a generic vision embedding model (CLIP) tell Pokemon
figures/toys apart via nearest-neighbor lookup, with no training?
Pipeline: embed every image under images/reference/ (one file per Pokemon,
filename = class name) to build a small reference index, then embed every
image under images/query/ and report the nearest reference neighbor +
cosine similarity. A "pass" is query/<name>.jpg matching reference/<name>.jpg
as the top-1 hit.
Usage: .venv\\Scripts\\python.exe match_spike.py
"""
import sys
from pathlib import Path
import open_clip
import torch
from PIL import Image
HERE = Path(__file__).parent
REF_DIR = HERE / "images" / (sys.argv[1] if len(sys.argv) > 1 else "reference")
QUERY_DIR = HERE / "images" / "query"
MODEL_NAME = "ViT-B-32-quickgelu"
PRETRAINED = "openai"
def load_model():
model, _, preprocess = open_clip.create_model_and_transforms(
MODEL_NAME, pretrained=PRETRAINED
)
model.eval()
return model, preprocess
def embed_image(model, preprocess, path: Path) -> torch.Tensor:
image = preprocess(Image.open(path).convert("RGB")).unsqueeze(0)
with torch.no_grad():
features = model.encode_image(image)
features = features / features.norm(dim=-1, keepdim=True)
return features.squeeze(0)
def main():
print(f"Loading {MODEL_NAME} ({PRETRAINED})...")
model, preprocess = load_model()
ref_paths = (
sorted(REF_DIR.glob("*.jpg"))
+ sorted(REF_DIR.glob("*.webp"))
+ sorted(REF_DIR.glob("*.png"))
)
query_paths = (
sorted(QUERY_DIR.glob("*.jpg"))
+ sorted(QUERY_DIR.glob("*.webp"))
+ sorted(QUERY_DIR.glob("*.png"))
)
print(f"Embedding {len(ref_paths)} reference images...")
ref_names = [p.stem for p in ref_paths]
ref_embeds = torch.stack([embed_image(model, preprocess, p) for p in ref_paths])
print(f"Embedding {len(query_paths)} query images...\n")
correct = 0
for qpath in query_paths:
qembed = embed_image(model, preprocess, qpath)
sims = ref_embeds @ qembed # cosine similarity, both sides unit-norm
ranked = sorted(zip(ref_names, sims.tolist()), key=lambda x: -x[1])
top_name, top_sim = ranked[0]
expected = qpath.stem.split("_")[0]
is_correct = top_name == expected
correct += is_correct
marker = "OK " if is_correct else "MISS"
print(f"[{marker}] query={qpath.stem:<18} -> best={top_name:<10} sim={top_sim:.4f}")
runner_up = ", ".join(f"{n}={s:.3f}" for n, s in ranked[1:4])
print(f" runner-up: {runner_up}")
print(f"\n{correct}/{len(query_paths)} correct top-1 matches")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,82 @@
"""
Same spike as match_spike.py, but using DINOv2 instead of CLIP.
DINOv2 is trained with a self-supervised image-only objective specifically
aimed at instance/fine-grained visual similarity, which is a better fit for
"is this query photo the same object as this reference photo" than CLIP
(CLIP is trained for text-image alignment and tends to cluster images by
generic scene/style rather than object identity).
Usage: .venv\\Scripts\\python.exe match_spike_dinov2.py
"""
import sys
from pathlib import Path
import torch
from PIL import Image
from transformers import AutoImageProcessor, AutoModel
HERE = Path(__file__).parent
REF_DIR = HERE / "images" / (sys.argv[1] if len(sys.argv) > 1 else "reference")
QUERY_DIR = HERE / "images" / "query"
MODEL_NAME = "facebook/dinov2-base"
def load_model():
processor = AutoImageProcessor.from_pretrained(MODEL_NAME)
model = AutoModel.from_pretrained(MODEL_NAME)
model.eval()
return model, processor
def embed_image(model, processor, path: Path) -> torch.Tensor:
image = Image.open(path).convert("RGB")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
outputs = model(**inputs)
features = outputs.last_hidden_state[:, 0, :] # CLS token
features = features / features.norm(dim=-1, keepdim=True)
return features.squeeze(0)
def main():
print(f"Loading {MODEL_NAME}...")
model, processor = load_model()
ref_paths = (
sorted(REF_DIR.glob("*.jpg"))
+ sorted(REF_DIR.glob("*.webp"))
+ sorted(REF_DIR.glob("*.png"))
)
query_paths = (
sorted(QUERY_DIR.glob("*.jpg"))
+ sorted(QUERY_DIR.glob("*.webp"))
+ sorted(QUERY_DIR.glob("*.png"))
)
print(f"Embedding {len(ref_paths)} reference images...")
ref_names = [p.stem for p in ref_paths]
ref_embeds = torch.stack([embed_image(model, processor, p) for p in ref_paths])
print(f"Embedding {len(query_paths)} query images...\n")
correct = 0
for qpath in query_paths:
qembed = embed_image(model, processor, qpath)
sims = ref_embeds @ qembed
ranked = sorted(zip(ref_names, sims.tolist()), key=lambda x: -x[1])
top_name, top_sim = ranked[0]
expected = qpath.stem.split("_")[0]
is_correct = top_name == expected
correct += is_correct
marker = "OK " if is_correct else "MISS"
print(f"[{marker}] query={qpath.stem:<18} -> best={top_name:<10} sim={top_sim:.4f}")
runner_up = ", ".join(f"{n}={s:.3f}" for n, s in ranked[1:4])
print(f" runner-up: {runner_up}")
print(f"\n{correct}/{len(query_paths)} correct top-1 matches")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,87 @@
"""
De-risking spike, take 3: instead of embedding + nearest-neighbor lookup,
just ask a self-hosted vision-language model directly what Pokemon is in
the photo. No reference images, no vector index -- the model's own
pretrained world knowledge does the recognition.
Usage: .venv_vlm\\Scripts\\python.exe match_spike_vlm.py
"""
from pathlib import Path
import torch
from PIL import Image
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
HERE = Path(__file__).parent
QUERY_DIR = HERE / "images" / "query"
MODEL_NAME = "Qwen/Qwen2.5-VL-3B-Instruct"
PROMPT = (
"You are the recognition system inside a Pokedex app. Identify the "
"Pokemon species shown in this image, even if it's a toy, plush, "
"trading card, sprite, fan art, or in-game screenshot of it. "
"Reply with ONLY the species name, nothing else. If no Pokemon is "
"clearly depicted, reply with exactly: unknown"
)
def load_model():
processor = AutoProcessor.from_pretrained(MODEL_NAME)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_NAME, torch_dtype=torch.bfloat16, device_map="cuda"
)
model.eval()
return model, processor
def identify(model, processor, path: Path) -> str:
image = Image.open(path).convert("RGB")
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": PROMPT},
],
}
]
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = processor(text=[text], images=[image], return_tensors="pt").to("cuda")
with torch.no_grad():
generated = model.generate(**inputs, max_new_tokens=16)
trimmed = generated[:, inputs["input_ids"].shape[1] :]
output = processor.batch_decode(
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True
)[0]
return output.strip()
def main():
print(f"Loading {MODEL_NAME} on GPU...")
model, processor = load_model()
query_paths = (
sorted(QUERY_DIR.glob("*.jpg"))
+ sorted(QUERY_DIR.glob("*.webp"))
+ sorted(QUERY_DIR.glob("*.png"))
)
print(f"Identifying {len(query_paths)} query images...\n")
correct = 0
for qpath in query_paths:
expected = qpath.stem.split("_")[0]
answer = identify(model, processor, qpath)
is_correct = answer.strip().lower() == expected.lower()
correct += is_correct
marker = "OK " if is_correct else "MISS"
print(f"[{marker}] query={qpath.stem:<18} expected={expected:<10} model_said={answer!r}")
print(f"\n{correct}/{len(query_paths)} correct")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,4 @@
torch --index-url https://download.pytorch.org/whl/cpu
open_clip_torch
pillow
numpy

View file

@ -0,0 +1,57 @@
# Pokedex voice spike — scripts
De-risking spike for the Pokédex app's robotic voice output. Full pipeline:
text -> TTS -> optional DSP "robot" filter -> mp3.
## Files
- `generate_samples.sh` — end-to-end driver: installs are documented at the
top, then runs espeak-ng and Piper TTS, then the DSP filters, then
converts everything to mp3. Run it from this directory (`./generate_samples.sh`).
- `robot_filter.py` — heavy/original robot filter: square-wave ring
modulation, bitcrush (sample-and-hold + bit-depth reduction), narrow
band-pass, hard drive. This is what "too robotic" was generated with.
- `robot_filter_light.py` — barely-there version: sine-wave ring mod at
low mix, wide band-pass, mild soft-clip. Meant to add a hint of
synthesized edge to an already-natural neural voice without disturbing
cadence.
- `robot_filter_v2.py` — the current one: parameterized, takes an
`intensity` argument from 0.0 (untouched) to 1.0 (full heavy filter) and
interpolates ring-mod mix/carrier, bandpass width, bitcrush, and drive
along that scale. Usage: `python3 robot_filter_v2.py in.wav out.wav 0.45`.
This is the one to keep tuning going forward — texted intensity numbers
("try 0.6") map directly to its third argument.
## Voice model
TTS engine is [Piper](https://github.com/OHF-Voice/piper1-gpl) — a small,
fully offline neural TTS with real Android ports available, which matters
for the app's "works with no signal" requirement.
The voice used is `en_US-joe-medium.onnx`, fine-tuned from Piper's stock
"lessac" voice on a CC0-licensed dataset (see MODEL_CARD if you unpack the
wheel) — no attribution/licensing issue to ship it.
**How I got the model file in this sandbox:** the sandbox's network
allowlist didn't reach huggingface.co, where Piper's official voices are
hosted, only package registries. I found a community-published PyPI wheel
(`joe-us-piper-voice`) that bundles the .onnx file directly, so a plain
`pip install` pulled it down. That's a workaround specific to *this*
sandbox — for the real project, get voices the normal way from Piper's
official releases/voice list, which gives you far more voice choices
(different speakers, accents, quality tiers) than this one bundled option.
## What's still open
- Pronunciation of individual Pokémon species names hasn't been
systematically checked — only "Pokémon," "Bulbasaur," "Charizard,"
"Pikachu" were tested. Expect to need a pass listening to all ~1000+
species names and hand-fixing the ones the phonemizer (espeak-ng, under
Piper's hood) gets wrong, either by respelling in the source text or via
espeak's phoneme-override escape syntax.
- Robot filter intensity (`robot_filter_v2.py`'s `intensity` arg) needs to
land wherever the actual desired "amount of robotic" ends up — currently
parked at 0.45 pending feedback.
- This whole pipeline is meant to run once, offline, over every dex entry
ahead of time (batch pre-generation), not live on-device — see the
earlier discussion in this conversation for why.

View file

@ -0,0 +1,77 @@
"""
Windows-friendly regeneration of the voice spike, extended to all 5
Pokemon the app currently knows about. Ports generate_samples.sh's
"fixed" (cadence-corrected) + robot_filter_v2.py (intensity 0.45) stages
to Python, using piper's Python API directly instead of the `piper` CLI
and skipping the ffmpeg mp3 step (WAV plays fine on Android, and ffmpeg
isn't installed on this machine).
Usage: .venv\\Scripts\\python.exe generate_all.py
"""
import wave
from pathlib import Path
from piper import PiperVoice
from piper.config import SynthesisConfig
import robot_filter_v2
HERE = Path(__file__).parent
VOICE_MODEL = HERE / "voices" / "en_US-joe-medium.onnx"
OUT_DIR = HERE / "output"
ENTRIES = {
"bulbasaur": (
"Bulbasaur, the seed Pokémon. It can be seen napping in bright "
"sunlight. There is a seed on its back. By soaking up the sun's "
"rays, the seed grows progressively larger."
),
"charizard": (
"Charizard, the flame Pokémon. Charizard flies around the sky in "
"search of powerful opponents. It breathes fire of such great "
"heat that it melts anything."
),
"pikachu": (
"Pikachu, the mouse Pokémon. When several of these Pokémon "
"gather, their electricity could build and cause lightning "
"storms."
),
"eevee": (
"Eevee, the evolution Pokémon. Its genetic code is irregular. It "
"may mutate if it is exposed to radiation from element stones."
),
"squirtle": (
"Squirtle, the tiny turtle Pokémon. After birth, its back swells "
"and hardens into a shell. It powerfully sprays foam from its "
"mouth."
),
}
ROBOT_INTENSITY = 0.45
SYN_CONFIG = SynthesisConfig(noise_scale=0.5, noise_w_scale=0.3, length_scale=0.98)
def main():
OUT_DIR.mkdir(exist_ok=True)
print(f"Loading voice model {VOICE_MODEL.name}...")
voice = PiperVoice.load(str(VOICE_MODEL))
for name, text in ENTRIES.items():
fixed_path = OUT_DIR / f"{name}_fixed.wav"
robot_path = OUT_DIR / f"{name}_fixed_robot.wav"
print(f"Synthesizing {name}...")
with wave.open(str(fixed_path), "wb") as wav_file:
voice.synthesize_wav(text, wav_file, syn_config=SYN_CONFIG)
sr, x = robot_filter_v2.load(str(fixed_path))
y = robot_filter_v2.robotize(x, sr, intensity=ROBOT_INTENSITY)
robot_filter_v2.save(str(robot_path), sr, y)
print(f" -> {robot_path.name}")
print("\nDone. See output/*_fixed_robot.wav")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Reproduces the Pokedex voice spike end to end:
# TTS (espeak-ng and Piper) -> robot-voice DSP filter -> mp3
#
# Setup (Debian/Ubuntu):
# sudo apt-get install -y espeak-ng ffmpeg
# pip install piper-tts --break-system-packages
# pip install numpy scipy --break-system-packages
#
# Voice model:
# Official route: download a Piper voice (.onnx + .onnx.json) from the
# Piper voices repo and point --model at it, e.g. en_US-lessac-medium.
# In this sandbox, huggingface.co wasn't reachable, so I instead pulled a
# community PyPI wheel that bundles the model file directly:
# pip download --no-deps joe-us-piper-voice
# (unzip the wheel; the .onnx/.onnx.json live under
# joe_us_piper_voice/data/). Not an official source -- for production,
# get voices from Piper's own releases instead.
set -euo pipefail
cd "$(dirname "$0")"
VOICE_MODEL="voices/en_US-joe-medium.onnx"
export ALSA_CONFIG_PATH=/dev/null # silence ALSA warnings in headless envs
# ---- Stage 1: raw espeak-ng (formant synth, offline, inherently "robotic") ----
espeak-ng -v en-us -s 150 -w bulbasaur_raw.wav \
"Bulbasaur. Seed pokemon. It can be seen napping in bright sunlight. There is a seed on its back. By soaking up the sun's rays, the seed grows progressively larger."
espeak-ng -v en-us -s 150 -w charizard_raw.wav \
"Charizard. Flame pokemon. Charizard flies around the sky in search of powerful opponents. It breathes fire of such great heat that it melts anything."
espeak-ng -v en-us -s 150 -w pikachu_raw.wav \
"Pikachu. Mouse pokemon. When several of these pokemon gather, their electricity could build and cause lightning storms."
# ---- Stage 2: Piper neural TTS, first pass (natural but bad cadence on rare words) ----
gen_neural() {
local name="$1" text="$2"
echo "$text" | piper -m "$VOICE_MODEL" -f "${name}_neural.wav"
}
gen_neural bulbasaur "Bulbasaur. Seed pokemon. It can be seen napping in bright sunlight. There is a seed on its back. By soaking up the sun's rays, the seed grows progressively larger."
gen_neural charizard "Charizard. Flame pokemon. Charizard flies around the sky in search of powerful opponents. It breathes fire of such great heat that it melts anything."
gen_neural pikachu "Pikachu. Mouse pokemon. When several of these pokemon gather, their electricity could build and cause lightning storms."
# ---- Stage 3: Piper neural TTS, cadence-fixed pass ----
# Fix = (a) standard "X, the Y Pokemon." phrasing instead of two short
# sentences, which stopped "Pokemon" from landing phrase-final where
# duration models over-lengthen it, and (b) reduced noise-w-scale (duration
# randomness) and noise-scale (audio variance) so rare proper nouns don't
# get a random stretched/warped rendering.
gen_fixed() {
local name="$1" text="$2"
echo "$text" | piper -m "$VOICE_MODEL" -f "${name}_fixed.wav" \
--noise-scale 0.5 --noise-w-scale 0.3 --length-scale 0.98
}
gen_fixed bulbasaur "Bulbasaur, the seed Pokémon. It can be seen napping in bright sunlight. There is a seed on its back. By soaking up the sun's rays, the seed grows progressively larger."
gen_fixed charizard "Charizard, the flame Pokémon. Charizard flies around the sky in search of powerful opponents. It breathes fire of such great heat that it melts anything."
gen_fixed pikachu "Pikachu, the mouse Pokémon. When several of these Pokémon gather, their electricity could build and cause lightning storms."
# ---- Stage 4: robot-voice DSP filter passes ----
# robot_filter.py -> heavy/original (square-wave ring mod + bitcrush + narrow bandpass)
# robot_filter_light.py -> barely-there (sine ring mod, wide bandpass, mild drive)
# robot_filter_v2.py -> parameterized 0..1 intensity knob (used at 0.45 = "medium")
for name in bulbasaur charizard pikachu; do
python3 robot_filter.py "${name}_raw.wav" "${name}_robot.wav"
python3 robot_filter_light.py "${name}_neural.wav" "${name}_neural_light.wav"
python3 robot_filter_v2.py "${name}_fixed.wav" "${name}_fixed_robot.wav" 0.45
done
# ---- Stage 5: mp3 for easy playback/delivery ----
for f in *_raw.wav *_robot.wav *_neural.wav *_neural_light.wav *_fixed.wav *_fixed_robot.wav; do
[ -f "$f" ] && ffmpeg -y -loglevel error -i "$f" -codec:a libmp3lame -qscale:a 4 "${f%.wav}.mp3"
done
echo "Done. See *.mp3 for output."

View file

@ -0,0 +1,79 @@
"""
Quick-and-dirty 'robot voice' post-processor for the Pokedex voice spike.
Pipeline: TTS wav in -> ring modulation + bitcrush + band-pass (speaker-grille
coloring) + light hard-clip drive -> wav out.
Usage: python3 robot_filter.py in.wav out.wav
"""
import sys
import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfilt
def load(path):
sr, data = wavfile.read(path)
if data.dtype == np.int16:
x = data.astype(np.float64) / 32768.0
elif data.dtype == np.int32:
x = data.astype(np.float64) / 2147483648.0
elif data.dtype == np.uint8:
x = (data.astype(np.float64) - 128) / 128.0
else:
x = data.astype(np.float64)
if x.ndim > 1:
x = x.mean(axis=1)
return sr, x
def save(path, sr, x):
x = np.clip(x, -1.0, 1.0)
wavfile.write(path, sr, (x * 32767).astype(np.int16))
def ring_modulate(x, sr, carrier_hz=45.0, mix=0.55):
t = np.arange(len(x)) / sr
carrier = np.sign(np.sin(2 * np.pi * carrier_hz * t)) # square carrier = harsher/metallic
modulated = x * carrier
return (1 - mix) * x + mix * modulated
def bitcrush(x, bit_depth=6, sr=None, downsample_factor=3):
# sample-and-hold downsample (classic lo-fi/robotic stepping)
if downsample_factor > 1:
held = np.repeat(x[::downsample_factor], downsample_factor)[: len(x)]
if len(held) < len(x):
held = np.pad(held, (0, len(x) - len(held)))
x = held
levels = 2 ** bit_depth
x = np.round(x * levels) / levels
return x
def bandpass(x, sr, low=350.0, high=3400.0, order=4):
sos = butter(order, [low, high], btype="band", fs=sr, output="sos")
return sosfilt(sos, x)
def drive(x, amount=1.8):
return np.tanh(x * amount) / np.tanh(amount)
def robotize(x, sr):
y = ring_modulate(x, sr, carrier_hz=45.0, mix=0.5)
y = bitcrush(y, bit_depth=7, downsample_factor=2)
y = bandpass(y, sr, low=300, high=3800)
y = drive(y, amount=1.6)
# normalize
peak = np.max(np.abs(y)) or 1.0
y = y / peak * 0.9
return y
if __name__ == "__main__":
src, dst = sys.argv[1], sys.argv[2]
sr, x = load(src)
y = robotize(x, sr)
save(dst, sr, y)
print(f"wrote {dst}")

View file

@ -0,0 +1,58 @@
"""
Light-touch robot coloring for a neural (already-natural-sounding) TTS voice.
Goal: keep natural cadence/prosody intact, just add a synthesized/device edge.
"""
import sys
import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfilt
def load(path):
sr, data = wavfile.read(path)
if data.dtype == np.int16:
x = data.astype(np.float64) / 32768.0
elif data.dtype == np.int32:
x = data.astype(np.float64) / 2147483648.0
else:
x = data.astype(np.float64)
if x.ndim > 1:
x = x.mean(axis=1)
return sr, x
def save(path, sr, x):
x = np.clip(x, -1.0, 1.0)
wavfile.write(path, sr, (x * 32767).astype(np.int16))
def ring_modulate(x, sr, carrier_hz=90.0, mix=0.12):
t = np.arange(len(x)) / sr
carrier = np.sin(2 * np.pi * carrier_hz * t) # sine carrier = subtler than square
return (1 - mix) * x + mix * (x * carrier)
def bandpass(x, sr, low=200.0, high=5500.0, order=2):
sos = butter(order, [low, high], btype="band", fs=sr, output="sos")
return sosfilt(sos, x)
def drive(x, amount=1.15):
return np.tanh(x * amount) / np.tanh(amount)
def robotize_light(x, sr):
y = ring_modulate(x, sr, carrier_hz=90.0, mix=0.12)
y = bandpass(y, sr, low=200, high=5500)
y = drive(y, amount=1.15)
peak = np.max(np.abs(y)) or 1.0
y = y / peak * 0.9
return y
if __name__ == "__main__":
src, dst = sys.argv[1], sys.argv[2]
sr, x = load(src)
y = robotize_light(x, sr)
save(dst, sr, y)
print(f"wrote {dst}")

View file

@ -0,0 +1,72 @@
"""
Parameterized robot-voice filter, v2.
intensity in [0, 1]: 0 = untouched, 1 = full "heavy" robot from round 1.
"""
import sys
import numpy as np
from scipy.io import wavfile
from scipy.signal import butter, sosfilt
def load(path):
sr, data = wavfile.read(path)
if data.dtype == np.int16:
x = data.astype(np.float64) / 32768.0
elif data.dtype == np.int32:
x = data.astype(np.float64) / 2147483648.0
else:
x = data.astype(np.float64)
if x.ndim > 1:
x = x.mean(axis=1)
return sr, x
def save(path, sr, x):
x = np.clip(x, -1.0, 1.0)
wavfile.write(path, sr, (x * 32767).astype(np.int16))
def ring_modulate(x, sr, carrier_hz, mix, square=False):
t = np.arange(len(x)) / sr
carrier = np.sign(np.sin(2 * np.pi * carrier_hz * t)) if square else np.sin(2 * np.pi * carrier_hz * t)
return (1 - mix) * x + mix * (x * carrier)
def bitcrush(x, bit_depth, downsample_factor):
if downsample_factor > 1:
held = np.repeat(x[::downsample_factor], downsample_factor)[: len(x)]
if len(held) < len(x):
held = np.pad(held, (0, len(x) - len(held)))
x = held
levels = 2 ** bit_depth
return np.round(x * levels) / levels
def bandpass(x, sr, low, high, order=3):
sos = butter(order, [low, high], btype="band", fs=sr, output="sos")
return sosfilt(sos, x)
def drive(x, amount):
return np.tanh(x * amount) / np.tanh(amount)
def robotize(x, sr, intensity=0.45):
i = intensity
y = ring_modulate(x, sr, carrier_hz=60 - 15 * i, mix=0.1 + 0.45 * i, square=(i > 0.6))
if i > 0.35:
y = bitcrush(y, bit_depth=int(12 - 6 * i), downsample_factor=1 if i < 0.7 else 2)
y = bandpass(y, sr, low=250 - 100 * i, high=6000 - 2200 * i)
y = drive(y, amount=1.0 + 0.9 * i)
peak = np.max(np.abs(y)) or 1.0
y = y / peak * 0.9
return y
if __name__ == "__main__":
src, dst = sys.argv[1], sys.argv[2]
intensity = float(sys.argv[3]) if len(sys.argv) > 3 else 0.45
sr, x = load(src)
y = robotize(x, sr, intensity=intensity)
save(dst, sr, y)
print(f"wrote {dst} (intensity={intensity})")

View file

@ -0,0 +1,484 @@
{
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "en-us"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0",
"language": {
"code": "en_US",
"family": "en",
"region": "US",
"name_native": "English",
"name_english": "English",
"country_english": "United States"
},
"dataset": "joe"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

71
server/README.md Normal file
View file

@ -0,0 +1,71 @@
# Pokedex recognition server
Self-hosted VLM (Qwen2.5-VL-3B-Instruct) that identifies a Pokemon directly
from a photo -- no reference-image index needed. See
`spikes/image_recognition/` for the de-risking spikes that led here
(embedding/nearest-neighbor matching topped out at 8/12 on our test set;
this approach got 10/12 cold).
## Running it
One-time setup (already done on this machine, keeping this here for
reference/reinstall):
```
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install --index-url https://download.pytorch.org/whl/cu128 torch torchvision
.venv\Scripts\python.exe -m pip install "transformers>=4.49" accelerate qwen-vl-utils pillow fastapi "uvicorn[standard]" python-multipart
```
Start the server (manual, current setup -- not auto-starting on boot/login yet):
```
.venv\Scripts\python.exe -m uvicorn server:app --host 0.0.0.0 --port 8420
```
First request after startup is slow-ish (model load, ~a few seconds);
subsequent requests are fast since the model stays resident on the GPU.
## One-time machine setup still needed
Run this once, as admin, to let the phone reach the server (not run
automatically -- it's a firewall/security change):
```powershell
New-NetFirewallRule -DisplayName "Pokedex recognition server" -Direction Inbound -Protocol TCP -LocalPort 8420 -Action Allow -Profile Private
```
Scoped to the `Private` network profile only.
## Endpoint
`POST /identify` -- multipart form field `file` = the photo.
```
curl -X POST http://<this-machine-LAN-ip>:8420/identify -F "file=@photo.jpg"
```
Response:
```json
{"recognized": true, "species": "Charizard", "raw_response": "Charizard"}
```
`recognized: false` / `species: null` means the model couldn't confidently
identify a Pokemon -- treat as "not recognized," not an error.
`GET /health` -- liveness/readiness check.
## LAN address
At last check, this machine's Wi-Fi LAN IP was `192.168.68.53`. This can
change (DHCP) -- if the app stops reaching the server, re-check with
`ipconfig` before assuming something else is broken. A static IP
reservation on the router, or mDNS/hostname, would avoid this long-term but
hasn't been set up.
## Known gotcha
This machine also runs NordVPN (NordLynx interface). Shouldn't affect LAN
traffic, but if the phone can't reach the server, check the VPN client's
local-network-access/LAN-sharing setting first.

96
server/server.py Normal file
View file

@ -0,0 +1,96 @@
"""
Pokedex image-recognition server.
Loads a vision-language model once at startup and keeps it resident on the
GPU. The Android app POSTs a photo to /identify and gets back a species
name -- no reference-image index needed, the model's own pretrained
knowledge does the recognition (see spikes/image_recognition for the
de-risking spike that validated this approach: 10/12 correct cold, vs 8/12
for embedding-based nearest-neighbor matching).
Run: .venv\\Scripts\\python.exe -m uvicorn server:app --host 0.0.0.0 --port 8420
"""
import io
import logging
import torch
from fastapi import FastAPI, File, HTTPException, UploadFile
from PIL import Image
from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("pokedex-server")
MODEL_NAME = "Qwen/Qwen2.5-VL-3B-Instruct"
PROMPT = (
"You are the recognition system inside a Pokedex app. Identify the "
"Pokemon species shown in this image, even if it's a toy, plush, "
"trading card, sprite, fan art, or in-game screenshot of it. "
"Reply with ONLY the species name, nothing else. If no Pokemon is "
"clearly depicted, reply with exactly: unknown"
)
app = FastAPI(title="Pokedex recognition server")
_model = None
_processor = None
@app.on_event("startup")
def load_model():
global _model, _processor
logger.info("Loading %s on GPU...", MODEL_NAME)
_processor = AutoProcessor.from_pretrained(MODEL_NAME)
_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
MODEL_NAME, torch_dtype=torch.bfloat16, device_map="cuda"
)
_model.eval()
logger.info("Model loaded, ready to serve.")
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_NAME, "ready": _model is not None}
@app.post("/identify")
async def identify(file: UploadFile = File(...)):
if _model is None or _processor is None:
raise HTTPException(503, "Model still loading, try again shortly")
raw = await file.read()
try:
image = Image.open(io.BytesIO(raw)).convert("RGB")
except Exception as exc:
raise HTTPException(400, f"Could not read image: {exc}")
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": PROMPT},
],
}
]
text = _processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = _processor(text=[text], images=[image], return_tensors="pt").to("cuda")
with torch.no_grad():
generated = _model.generate(**inputs, max_new_tokens=16)
trimmed = generated[:, inputs["input_ids"].shape[1] :]
raw_answer = _processor.batch_decode(
trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)[0].strip()
species = raw_answer.strip().rstrip(".")
recognized = species.lower() != "unknown"
logger.info("identify: filename=%s -> %r", file.filename, raw_answer)
return {
"recognized": recognized,
"species": species if recognized else None,
"raw_response": raw_answer,
}

Some files were not shown because too many files have changed in this diff Show more