Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .github/workflows/build-test-apk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
name: Build and publish test APK

on:
push:
branches:
- Fix-whisper-initial-download-issue
workflow_dispatch:

permissions:
contents: write

concurrency:
group: test-apk-${{ github.ref }}
cancel-in-progress: true

env:
APK_NAME: ToolNeuron-gguf-audio-mic-debug.apk
APK_ARTIFACT_NAME: toolneuron-gguf-audio-mic-debug-apk
RELEASE_TAG: toolneuron-fix-whisper-test-apk

jobs:
build-test-apk:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
cache: gradle

- name: Set up Android SDK
uses: android-actions/setup-android@v3

- name: Install Android packages
run: |
yes | sdkmanager --licenses > /dev/null
sdkmanager \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0" \
"cmake;3.22.1" \
"ndk;28.2.13676358"

- name: Create local.properties
run: |
SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
cat > local.properties <<EOF
sdk.dir=$SDK_ROOT
cmake.dir=$SDK_ROOT/cmake/3.22.1
ALIAS="sample_val"
EOF

- name: Relocate heavy native build directories
run: |
BUILD_ROOT="${RUNNER_TEMP}/toolneuron-build"
mkdir -p \
"$BUILD_ROOT/app/build" \
"$BUILD_ROOT/file_ops/.cxx" \
"$BUILD_ROOT/file_ops/build" \
"$BUILD_ROOT/neuron-packet/.cxx" \
"$BUILD_ROOT/neuron-packet/build" \
"$BUILD_ROOT/system_encryptor/.cxx" \
"$BUILD_ROOT/system_encryptor/build" \
"$BUILD_ROOT/ums/.cxx" \
"$BUILD_ROOT/ums/build"

rm -rf \
app/build \
file_ops/.cxx \
file_ops/build \
neuron-packet/.cxx \
neuron-packet/build \
system_encryptor/.cxx \
system_encryptor/build \
ums/.cxx \
ums/build

ln -sfn "$BUILD_ROOT/app/build" app/build
ln -sfn "$BUILD_ROOT/file_ops/.cxx" file_ops/.cxx
ln -sfn "$BUILD_ROOT/file_ops/build" file_ops/build
ln -sfn "$BUILD_ROOT/neuron-packet/.cxx" neuron-packet/.cxx
ln -sfn "$BUILD_ROOT/neuron-packet/build" neuron-packet/build
ln -sfn "$BUILD_ROOT/system_encryptor/.cxx" system_encryptor/.cxx
ln -sfn "$BUILD_ROOT/system_encryptor/build" system_encryptor/build
ln -sfn "$BUILD_ROOT/ums/.cxx" ums/.cxx
ln -sfn "$BUILD_ROOT/ums/build" ums/build

- name: Validate and build debug APK
run: |
chmod +x ./gradlew
./gradlew \
--no-daemon \
--no-configuration-cache \
--max-workers=1 \
-Dorg.gradle.jvmargs='-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8' \
-Pksp.incremental=false \
:app:testDebugUnitTest \
--tests com.dark.tool_neuron.repo.ModelStoreRepositoryTest
./gradlew \
--no-daemon \
--no-configuration-cache \
--max-workers=1 \
-Dorg.gradle.jvmargs='-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8' \
-Pksp.incremental=false \
:app:assembleDebug

- name: Prepare APK asset
run: |
mkdir -p dist
cp app/build/outputs/apk/debug/app-debug.apk "dist/${APK_NAME}"

- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.APK_ARTIFACT_NAME }}
path: dist/${{ env.APK_NAME }}
if-no-files-found: error

- name: Publish public release asset
id: release
uses: ncipollo/release-action@v1
with:
allowUpdates: true
artifactErrorsFailBuild: true
artifacts: dist/${{ env.APK_NAME }}
commit: ${{ github.sha }}
name: ToolNeuron test APK
prerelease: true
replacesArtifacts: true
tag: ${{ env.RELEASE_TAG }}
body: |
Public test APK for the `Fix-whisper-initial-download-issue` branch.

Includes:
- GGUF audio model support
- Projector-backed audio transcription flow
- In-app microphone transcription staging UX

Built from commit `${{ github.sha }}`.

- name: Write download links
run: |
ASSET_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${APK_NAME}"
{
echo "## Download links"
echo
echo "- Release: ${{ steps.release.outputs.html_url }}"
echo "- APK: ${ASSET_URL}"
} >> "$GITHUB_STEP_SUMMARY"
3 changes: 2 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
Expand Down Expand Up @@ -122,4 +123,4 @@

</application>

</manifest>
</manifest>
152 changes: 152 additions & 0 deletions app/src/main/java/com/dark/tool_neuron/audio/ChatAudioRecorder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.dark.tool_neuron.audio

import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import android.os.SystemClock
import android.util.Log
import java.io.File
import java.io.IOException

data class RecordedAudioClip(
val file: File,
val durationMillis: Long
)

class ChatAudioRecorder(
private val appContext: Context
) {
private var mediaRecorder: MediaRecorder? = null
private var activeOutputFile: File? = null
private var recordingStartedAtMs: Long? = null

@Throws(IOException::class, IllegalStateException::class)
fun startRecording() {
check(mediaRecorder == null) { "A microphone recording is already in progress" }

val outputDirectory = File(appContext.cacheDir, CACHE_DIRECTORY_NAME)
if (!outputDirectory.exists() && !outputDirectory.mkdirs()) {
throw IOException("Failed to prepare microphone cache directory")
}

val outputFile = File.createTempFile("chat-mic-", OUTPUT_EXTENSION, outputDirectory)
val recorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(DEFAULT_AUDIO_BITRATE)
setAudioSamplingRate(DEFAULT_AUDIO_SAMPLE_RATE)
setOutputFile(outputFile.absolutePath)
}

try {
recorder.prepare()
recorder.start()
} catch (e: IOException) {
recorder.release()
deleteClip(outputFile)
throw IOException("Unable to prepare microphone recording", e)
} catch (e: RuntimeException) {
recorder.release()
deleteClip(outputFile)
throw IllegalStateException("Unable to start microphone recording", e)
}

mediaRecorder = recorder
activeOutputFile = outputFile
recordingStartedAtMs = SystemClock.elapsedRealtime()
}

@Throws(IllegalStateException::class)
fun stopRecording(): RecordedAudioClip {
val recorder = mediaRecorder ?: throw IllegalStateException("No microphone recording is in progress")
val outputFile = activeOutputFile
?: throw IllegalStateException("No microphone recording file is available")
val startedAtMs = recordingStartedAtMs
?: throw IllegalStateException("Microphone recording start time is unavailable")

try {
recorder.stop()
} catch (e: RuntimeException) {
releaseRecorder(resetFirst = false)
deleteClip(outputFile)
throw IllegalStateException(
"Microphone recording could not be finalized. Try recording a little longer.",
e
)
}

releaseRecorder(resetFirst = true)

return RecordedAudioClip(
file = outputFile,
durationMillis = (SystemClock.elapsedRealtime() - startedAtMs).coerceAtLeast(0L)
)
}

fun cancelRecording() {
val recorder = mediaRecorder ?: return
val outputFile = activeOutputFile

try {
recorder.stop()
} catch (e: RuntimeException) {
Log.w(TAG, "Discarding incomplete microphone recording", e)
} finally {
releaseRecorder(resetFirst = true)
}

outputFile?.let(::deleteClip)
}

fun deleteClip(file: File) {
if (file.exists() && !file.delete()) {
Log.w(TAG, "Failed to delete temporary audio clip: ${file.absolutePath}")
}
}

fun release() {
if (mediaRecorder != null) {
cancelRecording()
}
}

@Suppress("DEPRECATION")
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(appContext)
} else {
MediaRecorder()
}
}

private fun releaseRecorder(resetFirst: Boolean) {
val recorder = mediaRecorder
if (recorder != null) {
if (resetFirst) {
try {
recorder.reset()
} catch (e: RuntimeException) {
Log.w(TAG, "Failed to reset MediaRecorder before release", e)
}
}
try {
recorder.release()
} catch (e: RuntimeException) {
Log.w(TAG, "Failed to release MediaRecorder", e)
}
}

mediaRecorder = null
activeOutputFile = null
recordingStartedAtMs = null
}

companion object {
private const val TAG = "ChatAudioRecorder"
private const val CACHE_DIRECTORY_NAME = "audio-recordings"
private const val OUTPUT_EXTENSION = ".m4a"
private const val DEFAULT_AUDIO_BITRATE = 128_000
private const val DEFAULT_AUDIO_SAMPLE_RATE = 44_100
}
}
8 changes: 8 additions & 0 deletions app/src/main/java/com/dark/tool_neuron/global/AppPaths.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ object AppPaths {
fun modelFile(context: Context, modelId: String): File =
File(models(context), "$modelId.gguf")

/** Hidden multimodal/audio projector sidecars paired to GGUF models */
fun modelProjectors(context: Context): File =
File(models(context), "projectors")

/** Specific GGUF projector sidecar */
fun modelProjectorFile(context: Context, modelId: String): File =
File(modelProjectors(context), "$modelId.gguf")

/** TTS model directory */
fun ttsModel(context: Context): File =
File(models(context), "supertonic-2")
Expand Down
Loading