summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore10
-rw-r--r--README.md83
-rw-r--r--TODO.md25
-rw-r--r--app/build.gradle.kts63
-rw-r--r--app/proguard-rules.pro2
-rw-r--r--app/src/main/AndroidManifest.xml44
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MainActivity.java297
-rw-r--r--app/src/main/java/com/wuhei/reccoon/ModelRepository.java206
-rw-r--r--app/src/main/java/com/wuhei/reccoon/PlayerActivity.java481
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingStore.java108
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java135
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptStore.java110
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java393
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WavRecorder.java181
-rw-r--r--app/src/main/res/drawable/badge_transcript.xml6
-rw-r--r--app/src/main/res/drawable/bg_record_active.xml21
-rw-r--r--app/src/main/res/drawable/bg_record_idle.xml20
-rw-r--r--app/src/main/res/drawable/ic_launcher_background.xml25
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml102
-rw-r--r--app/src/main/res/drawable/ic_rec_dot.xml10
-rw-r--r--app/src/main/res/layout/activity_main.xml116
-rw-r--r--app/src/main/res/layout/activity_player.xml144
-rw-r--r--app/src/main/res/layout/activity_recordings.xml53
-rw-r--r--app/src/main/res/layout/item_recording.xml47
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--app/src/main/res/values/colors.xml10
-rw-r--r--app/src/main/res/values/strings.xml54
-rw-r--r--app/src/main/res/values/themes.xml13
-rw-r--r--build.gradle.kts3
-rw-r--r--gradle.properties6
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 47505 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties9
-rwxr-xr-xgradlew248
-rw-r--r--gradlew.bat82
-rw-r--r--settings.gradle.kts30
36 files changed, 3147 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f8edc3e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/app/build
+/captures
+.externalNativeBuild
+.cxx
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..8b0e8ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,83 @@
+# RECCoon
+
+A minimal Android app that records uncompressed WAV audio and transcribes it
+on device.
+
+- **Format:** 44.1 kHz, 16-bit, mono, PCM (no compression)
+- **Controls:** one big round button — tap to start recording (timer starts), tap again to stop and save
+- **Saving:** the WAV is written to the public **Downloads** folder as
+ `reccoon-YYYY-MM-DD-HHMMSS-XXXXX.wav`, where `XXXXX` is a persistent
+ 5-digit progressive counter
+- **Library:** a list of saved recordings, with a player and a scrubber to jump
+ around long recordings
+- **Transcription:** on-device, offline speech recognition with
+ [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) using multilingual
+ Whisper — works with **English and Italian**
+- **Name:** RECCoon · **by** wuhei · **version** 1.1.0-alpha
+- **Icon:** raccoon with a microphone
+
+## Requirements
+
+- Android 8.0 (API 26) or newer, arm64-v8a or x86_64
+- `RECORD_AUDIO` permission
+- On Android 9 and older, `WRITE_EXTERNAL_STORAGE` is also requested
+- Internet access the first time a transcription model is downloaded
+
+## Build
+
+```sh
+./gradlew :app:assembleDebug # debug APK
+./gradlew :app:assembleRelease # release APK (signed with the debug key)
+```
+
+Outputs:
+
+- `app/build/outputs/apk/debug/app-debug.apk`
+- `app/build/outputs/apk/release/app-release.apk`
+
+A prebuilt copy is available at `dist/RECCoon-1.1.0-alpha.apk`.
+
+## Recording
+
+- `WavRecorder` uses `AudioRecord` (MIC source) to capture 16-bit PCM mono at
+ 44100 Hz into a temporary file, then rewrites a correct 44-byte RIFF/WAVE
+ header when recording stops.
+- `MainActivity` handles the runtime permissions, the timer, and copying the
+ finished file into Downloads. On Android 10+ this uses `MediaStore`; on
+ older versions it writes directly to the public Downloads directory and
+ notifies the media scanner.
+
+## Library and player
+
+- `RecordingsActivity` lists the `reccoon-*.wav` files in Downloads.
+- `PlayerActivity` plays a file with the platform `MediaPlayer`, a scrubber
+ (`SeekBar`), ±10 s jumps and a playback speed control (0.5×–2×). Tapping a
+ transcript line seeks the player to that moment.
+
+## On-device transcription
+
+- `TranscriptionEngine` streams the WAV, resamples it to 16 kHz and segments it
+ with Silero VAD, then decodes each speech segment with a multilingual Whisper
+ model through sherpa-onnx. Streaming keeps memory bounded for long
+ recordings (offline Whisper only handles 30 s at a time).
+- `ModelRepository` downloads the models on first use into app-private storage:
+
+ | Model | Size | Notes |
+ |---|---|---|
+ | Whisper tiny (int8, multilingual) | ~99 MB | faster, lower accuracy |
+ | Whisper base (int8, multilingual) | ~154 MB | default, better Italian |
+
+ Both include the small Silero VAD model (~0.6 MB). Files come from the
+ sherpa-onnx Whisper model repos on Hugging Face and the sherpa-onnx GitHub
+ release.
+- Language can be forced to English (`en`) or Italian (`it`), or left on
+ **Auto detect**.
+- Transcripts are stored as JSON sidecars in app-private storage
+ (`filesDir/transcripts/<recording>.json`) and are shown in the player. A
+ `TXT` badge marks recordings that already have a transcript.
+
+## Native libraries and APK size
+
+The APK bundles the sherpa-onnx/onnxruntime native libraries for `arm64-v8a`
+and `x86_64`, so it is around 70 MB. Removing `x86_64` from the `abiFilters`
+in `app/build.gradle.kts` roughly halves the APK size for phone-only builds.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..c6af6c6
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,25 @@
+# RECCoon TODO
+
+## Requested
+
+- [ ] **Separate left/right volume meters** — requires stereo capture; the
+ recorder is mono today, so this also means adding a stereo record mode
+ (44100 Hz / 16-bit / 2ch) and per-channel level computation.
+- [ ] **Option to disable notifications and vibrations while recording** —
+ e.g. enable Do Not Disturb (with the app's notification policy access)
+ and/or mute the notification stream for the duration of the recording,
+ exposed as a user setting.
+
+## Backlog (from earlier discussion)
+
+- [ ] Live waveform + clipping meter while recording (dBFS level bar, CLIP
+ badge, sample-peak/RMS from the captured PCM).
+- [ ] Markers embedded in the WAV via `cue ` + `LIST`/`adtl` (`labl`) chunks,
+ plus a sidecar JSON / Room record for the app.
+- [ ] Mark button during recording (note / issue / chapter) with sample offsets.
+- [ ] Export transcripts as `.txt` / `.srt` and/or embed them in WAV metadata
+ (`LIST`/`INFO`, `bext`).
+- [ ] Foreground service so recording continues when the app is in the
+ background.
+- [ ] Library actions: delete, rename, share, "open with".
+- [ ] Word-level timestamps and tap-to-seek inside a transcript segment.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..a786c44
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,63 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "com.wuhei.reccoon"
+
+ compileSdk {
+ version = release(37) {
+ minorApiLevel = 0
+ }
+ }
+
+ defaultConfig {
+ applicationId = "com.wuhei.reccoon"
+ minSdk = 26
+ targetSdk = 35
+ versionCode = 2
+ versionName = "1.1.0-alpha"
+
+ ndk {
+ // Prebuilt sherpa-onnx / onnxruntime native libraries.
+ abiFilters += listOf("arm64-v8a", "x86_64")
+ }
+ }
+
+ buildTypes {
+ debug {
+ isMinifyEnabled = false
+ }
+ release {
+ isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("debug")
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ packaging {
+ jniLibs {
+ useLegacyPackaging = false
+ }
+ }
+}
+
+dependencies {
+ implementation("androidx.appcompat:appcompat:1.8.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.2")
+ implementation("androidx.recyclerview:recyclerview:1.4.0")
+
+ // Kotlin runtime required by the sherpa-onnx Kotlin API.
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.4.20")
+
+ // On-device speech recognition (Whisper multilingual: English + Italian).
+ implementation(files("libs/sherpa-onnx-1.13.8.aar"))
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..d11f58e
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,2 @@
+# Add project specific ProGuard rules here.
+-keepattributes SourceFile,LineNumberTable
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e247045
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission
+ android:name="android.permission.WRITE_EXTERNAL_STORAGE"
+ android:maxSdkVersion="28" />
+
+ <uses-feature
+ android:name="android.hardware.microphone"
+ android:required="true" />
+
+ <application
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:largeHeap="true"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.Reccoon">
+
+ <activity
+ android:name=".MainActivity"
+ android:exported="true"
+ android:screenOrientation="portrait">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ <activity
+ android:name=".RecordingsActivity"
+ android:exported="false"
+ android:screenOrientation="portrait" />
+
+ <activity
+ android:name=".PlayerActivity"
+ android:exported="false"
+ android:screenOrientation="portrait" />
+ </application>
+
+</manifest>
diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java
new file mode 100644
index 0000000..435aeb5
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/MainActivity.java
@@ -0,0 +1,297 @@
+package com.wuhei.reccoon;
+
+import android.Manifest;
+import android.animation.ObjectAnimator;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.provider.MediaStore;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.ContextCompat;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+public class MainActivity extends AppCompatActivity {
+
+ private static final int REQUEST_PERMISSIONS = 42;
+ private static final String PREFS = "reccoon";
+ private static final String PREF_COUNTER = "counter";
+
+ private ImageButton recordButton;
+ private ImageView recIndicator;
+ private TextView timerText;
+ private TextView statusText;
+
+ private WavRecorder recorder;
+ private ObjectAnimator blinkAnimator;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ private boolean recording;
+ private boolean busy;
+ private long startedAtMs;
+ private Runnable timerRunnable;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ recordButton = findViewById(R.id.recordButton);
+ recIndicator = findViewById(R.id.recIndicator);
+ timerText = findViewById(R.id.timerText);
+ statusText = findViewById(R.id.statusText);
+
+ recorder = new WavRecorder(this);
+
+ blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f);
+ blinkAnimator.setDuration(600);
+ blinkAnimator.setRepeatCount(ObjectAnimator.INFINITE);
+ blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE);
+
+ recordButton.setOnClickListener(v -> onRecordButtonClicked());
+ findViewById(R.id.libraryButton).setOnClickListener(v ->
+ startActivity(new Intent(this, RecordingsActivity.class)));
+ }
+
+ @Override
+ protected void onDestroy() {
+ handler.removeCallbacksAndMessages(null);
+ if (blinkAnimator != null) {
+ blinkAnimator.cancel();
+ }
+ super.onDestroy();
+ }
+
+ private void onRecordButtonClicked() {
+ if (busy) {
+ return;
+ }
+ if (recording) {
+ stopRecordingAndSave();
+ return;
+ }
+ List<String> missing = missingPermissions();
+ if (!missing.isEmpty()) {
+ requestPermissions(missing.toArray(new String[0]), REQUEST_PERMISSIONS);
+ } else {
+ startRecording();
+ }
+ }
+
+ private List<String> missingPermissions() {
+ List<String> missing = new ArrayList<>();
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.RECORD_AUDIO);
+ }
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
+ && ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ return missing;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode != REQUEST_PERMISSIONS) {
+ return;
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED) {
+ startRecording();
+ } else {
+ Toast.makeText(this, R.string.err_permission, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void startRecording() {
+ try {
+ recorder.start();
+ } catch (Exception e) {
+ Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ recording = true;
+ startedAtMs = SystemClock.elapsedRealtime();
+
+ recordButton.setBackgroundResource(R.drawable.bg_record_active);
+ recordButton.setContentDescription(getString(R.string.cd_stop));
+ recIndicator.setVisibility(View.VISIBLE);
+ blinkAnimator.start();
+ statusText.setText(R.string.status_recording);
+ timerText.setText(R.string.timer_zero);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ timerRunnable = new Runnable() {
+ @Override
+ public void run() {
+ long elapsed = SystemClock.elapsedRealtime() - startedAtMs;
+ timerText.setText(formatDuration(elapsed));
+ handler.postDelayed(this, 200);
+ }
+ };
+ handler.post(timerRunnable);
+ }
+
+ private void stopRecordingAndSave() {
+ recording = false;
+ busy = true;
+
+ long elapsed = SystemClock.elapsedRealtime() - startedAtMs;
+ handler.removeCallbacks(timerRunnable);
+ blinkAnimator.cancel();
+ recIndicator.setVisibility(View.INVISIBLE);
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ timerText.setText(formatDuration(elapsed));
+ recordButton.setBackgroundResource(R.drawable.bg_record_idle);
+ recordButton.setContentDescription(getString(R.string.cd_record));
+ recordButton.setEnabled(false);
+ statusText.setText(R.string.status_saving);
+
+ new Thread(() -> {
+ File wav;
+ try {
+ wav = recorder.stop();
+ } catch (Exception e) {
+ wav = null;
+ }
+
+ String savedName = null;
+ if (wav != null) {
+ try {
+ savedName = saveToDownloads(wav);
+ } catch (Exception ignored) {
+ savedName = null;
+ }
+ }
+
+ final String result = savedName;
+ runOnUiThread(() -> {
+ busy = false;
+ recordButton.setEnabled(true);
+ if (result != null) {
+ statusText.setText(getString(R.string.saved_fmt, result));
+ Toast.makeText(this, getString(R.string.saved_fmt, result), Toast.LENGTH_LONG).show();
+ } else {
+ statusText.setText(R.string.err_save);
+ Toast.makeText(this, R.string.err_save, Toast.LENGTH_LONG).show();
+ }
+ });
+ }, "reccoon-save").start();
+ }
+
+ private String saveToDownloads(File wav) throws IOException {
+ String fileName = buildFileName();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveViaMediaStore(wav, fileName);
+ } else {
+ saveToPublicDownloads(wav, fileName);
+ }
+ return fileName;
+ }
+
+ private void saveViaMediaStore(File wav, String fileName) throws IOException {
+ ContentResolver resolver = getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
+ values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav");
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
+ values.put(MediaStore.MediaColumns.IS_PENDING, 1);
+
+ Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
+ if (uri == null) {
+ throw new IOException("MediaStore rejected the new file");
+ }
+
+ try (OutputStream out = resolver.openOutputStream(uri)) {
+ if (out == null) {
+ throw new IOException("MediaStore returned no output stream");
+ }
+ try (InputStream in = new FileInputStream(wav)) {
+ copy(in, out);
+ }
+ }
+
+ ContentValues done = new ContentValues();
+ done.put(MediaStore.MediaColumns.IS_PENDING, 0);
+ resolver.update(uri, done, null, null);
+ }
+
+ @SuppressWarnings("deprecation")
+ private void saveToPublicDownloads(File wav, String fileName) throws IOException {
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Could not create the Downloads directory");
+ }
+ File outFile = new File(dir, fileName);
+ try (InputStream in = new FileInputStream(wav);
+ OutputStream out = new FileOutputStream(outFile)) {
+ copy(in, out);
+ }
+ MediaScannerConnection.scanFile(
+ this,
+ new String[]{outFile.getAbsolutePath()},
+ new String[]{"audio/wav"},
+ null);
+ }
+
+ private String buildFileName() {
+ String stamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date());
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ int next = prefs.getInt(PREF_COUNTER, 0) + 1;
+ prefs.edit().putInt(PREF_COUNTER, next).apply();
+ return String.format(Locale.US, "reccoon-%s-%05d.wav", stamp, next);
+ }
+
+ private static void copy(InputStream in, OutputStream out) throws IOException {
+ byte[] buffer = new byte[64 * 1024];
+ int read;
+ while ((read = in.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ }
+
+ private static String formatDuration(long elapsedMs) {
+ long totalSeconds = elapsedMs / 1000;
+ long hours = totalSeconds / 3600;
+ long minutes = (totalSeconds % 3600) / 60;
+ long seconds = totalSeconds % 60;
+ if (hours > 0) {
+ return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds);
+ }
+ return String.format(Locale.US, "%02d:%02d", minutes, seconds);
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java b/app/src/main/java/com/wuhei/reccoon/ModelRepository.java
new file mode 100644
index 0000000..cbf25c2
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/ModelRepository.java
@@ -0,0 +1,206 @@
+package com.wuhei.reccoon;
+
+import android.content.Context;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+/**
+ * Downloads and stores the on-device speech recognition models.
+ *
+ * <p>Uses multilingual Whisper (tiny or base, int8) from the sherpa-onnx model
+ * zoo. The multilingual variants support both English and Italian. A small
+ * Silero VAD model is used to split long recordings into transcribable chunks.
+ */
+public final class ModelRepository {
+
+ private static final String HF_BASE =
+ "https://huggingface.co/csukuangfj/sherpa-onnx-whisper-";
+ private static final String VAD_URL =
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx";
+
+ public enum WhisperModel {
+ TINY("tiny", "Tiny", 12_937_772L, 89_855_401L),
+ BASE("base", "Base", 29_120_534L, 130_672_026L);
+
+ public final String id;
+ public final String label;
+ public final long encoderBytes;
+ public final long decoderBytes;
+
+ WhisperModel(String id, String label, long encoderBytes, long decoderBytes) {
+ this.id = id;
+ this.label = label;
+ this.encoderBytes = encoderBytes;
+ this.decoderBytes = decoderBytes;
+ }
+
+ public long totalBytes() {
+ return encoderBytes + decoderBytes + TOKENS_BYTES + VAD_BYTES;
+ }
+ }
+
+ private static final long TOKENS_BYTES = 816_730L;
+ private static final long VAD_BYTES = 643_854L;
+
+ public interface Listener {
+ void onProgress(long downloaded, long total, String currentFile);
+
+ default void onComplete() {
+ }
+ }
+
+ private final File root;
+
+ public ModelRepository(Context context) {
+ root = new File(context.getApplicationContext().getFilesDir(), "models");
+ }
+
+ public File vadModel() {
+ return new File(root, "silero_vad.onnx");
+ }
+
+ public File encoder(WhisperModel model) {
+ return new File(modelDir(model), "encoder.int8.onnx");
+ }
+
+ public File decoder(WhisperModel model) {
+ return new File(modelDir(model), "decoder.int8.onnx");
+ }
+
+ public File tokens(WhisperModel model) {
+ return new File(modelDir(model), "tokens.txt");
+ }
+
+ private File modelDir(WhisperModel model) {
+ return new File(root, "whisper-" + model.id);
+ }
+
+ public boolean isReady(WhisperModel model) {
+ return isComplete(vadModel(), VAD_BYTES)
+ && isComplete(encoder(model), model.encoderBytes)
+ && isComplete(decoder(model), model.decoderBytes)
+ && isComplete(tokens(model), TOKENS_BYTES);
+ }
+
+ public boolean isVadReady() {
+ return isComplete(vadModel(), VAD_BYTES);
+ }
+
+ private static boolean isComplete(File file, long expected) {
+ return file.isFile() && (expected <= 0 || file.length() == expected);
+ }
+
+ /**
+ * Blocking. Call from a background thread. Progress is reported through
+ * {@code listener}. Throws on failure.
+ */
+ public void download(WhisperModel model, Listener listener) throws IOException {
+ long total = model.totalBytes();
+ long already = 0;
+ if (isComplete(vadModel(), VAD_BYTES)) already += VAD_BYTES;
+ if (isComplete(encoder(model), model.encoderBytes)) already += model.encoderBytes;
+ if (isComplete(decoder(model), model.decoderBytes)) already += model.decoderBytes;
+ if (isComplete(tokens(model), TOKENS_BYTES)) already += TOKENS_BYTES;
+
+ Counter counter = new Counter(already, total, listener);
+ counter.report("");
+
+ fetch(VAD_URL, vadModel(), VAD_BYTES, counter);
+ String base = HF_BASE + model.id + "/resolve/main/" + model.id;
+ fetch(base + "-encoder.int8.onnx", encoder(model), model.encoderBytes, counter);
+ fetch(base + "-decoder.int8.onnx", decoder(model), model.decoderBytes, counter);
+ fetch(base + "-tokens.txt", tokens(model), TOKENS_BYTES, counter);
+
+ listener.onComplete();
+ }
+
+ private static final class Counter {
+ private final long already;
+ private final long total;
+ private final Listener listener;
+ private long doneInCurrent;
+ private String currentName = "";
+
+ Counter(long already, long total, Listener listener) {
+ this.already = already;
+ this.total = total;
+ this.listener = listener;
+ }
+
+ void setCurrent(String name, long done) {
+ this.currentName = name;
+ this.doneInCurrent = done;
+ listener.onProgress(Math.min(already + done, total), total, currentName);
+ }
+
+ void report(String name) {
+ listener.onProgress(Math.min(already + doneInCurrent, total), total, name);
+ }
+ }
+
+ private static void fetch(String url, File target, long expected, Counter counter)
+ throws IOException {
+ if (isComplete(target, expected)) {
+ return;
+ }
+ File parent = target.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("Cannot create " + parent);
+ }
+ File part = new File(parent, target.getName() + ".part");
+
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ connection.setInstanceFollowRedirects(true);
+ connection.setConnectTimeout(30_000);
+ connection.setReadTimeout(120_000);
+ connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)");
+ try {
+ int code = connection.getResponseCode();
+ if (code != HttpURLConnection.HTTP_OK) {
+ throw new IOException("HTTP " + code + " for " + url);
+ }
+ long length = connection.getContentLengthLong();
+ long thisTotal = length > 0 ? length : expected;
+
+ try (InputStream in = connection.getInputStream();
+ FileOutputStream out = new FileOutputStream(part)) {
+ byte[] buffer = new byte[256 * 1024];
+ long done = 0;
+ int read;
+ long lastReport = 0;
+ while ((read = in.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ done += read;
+ long now = System.currentTimeMillis();
+ if (now - lastReport > 200) {
+ lastReport = now;
+ counter.setCurrent(target.getName(), done);
+ }
+ }
+ out.getFD().sync();
+ counter.setCurrent(target.getName(), done);
+ }
+
+ if (expected > 0 && part.length() != expected) {
+ //noinspection ResultOfMethodCallIgnored
+ part.delete();
+ throw new IOException(
+ "Corrupt download for " + target.getName()
+ + " (expected " + expected + " bytes, got " + part.length() + ")");
+ }
+ if (target.exists() && !target.delete()) {
+ throw new IOException("Cannot replace " + target);
+ }
+ if (!part.renameTo(target)) {
+ throw new IOException("Cannot move download into place: " + target);
+ }
+ } finally {
+ connection.disconnect();
+ }
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
new file mode 100644
index 0000000..e882fbc
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
@@ -0,0 +1,481 @@
+package com.wuhei.reccoon;
+
+import android.content.Intent;
+import android.media.MediaPlayer;
+import android.media.PlaybackParams;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.Locale;
+
+/**
+ * Plays a saved recording with a scrubber and transcribes it on device with
+ * sherpa-onnx (multilingual Whisper: English and Italian).
+ */
+public class PlayerActivity extends AppCompatActivity {
+
+ public static final String EXTRA_NAME = "name";
+ public static final String EXTRA_URI = "uri";
+
+ private static final float[] SPEEDS = {1.0f, 1.5f, 2.0f, 0.5f};
+
+ private String recordingName;
+ private Uri recordingUri;
+
+ private MediaPlayer player;
+ private boolean prepared;
+
+ private TextView titleText;
+ private TextView timeText;
+ private TextView statusText;
+ private SeekBar seekBar;
+ private Button playButton;
+ private Button speedButton;
+ private Button transcribeButton;
+ private Spinner languageSpinner;
+ private Spinner modelSpinner;
+ private ProgressBar progressBar;
+ private LinearLayout transcriptContainer;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private boolean userSeeking;
+ private float speedIndex0 = SPEEDS[0];
+ private int speedIndex;
+ private volatile boolean cancelled;
+
+ private ModelRepository models;
+ private TranscriptStore transcripts;
+
+ private final Runnable ticker = new Runnable() {
+ @Override
+ public void run() {
+ updateProgress();
+ handler.postDelayed(this, 250);
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_player);
+
+ Intent intent = getIntent();
+ recordingName = intent.getStringExtra(EXTRA_NAME);
+ String uriString = intent.getStringExtra(EXTRA_URI);
+ if (recordingName == null || uriString == null) {
+ finish();
+ return;
+ }
+ recordingUri = Uri.parse(uriString);
+
+ models = new ModelRepository(this);
+ transcripts = new TranscriptStore(this);
+
+ titleText = findViewById(R.id.titleText);
+ timeText = findViewById(R.id.timeText);
+ statusText = findViewById(R.id.statusText);
+ seekBar = findViewById(R.id.seekBar);
+ playButton = findViewById(R.id.playButton);
+ speedButton = findViewById(R.id.speedButton);
+ transcribeButton = findViewById(R.id.transcribeButton);
+ languageSpinner = findViewById(R.id.languageSpinner);
+ modelSpinner = findViewById(R.id.modelSpinner);
+ progressBar = findViewById(R.id.progressBar);
+ transcriptContainer = findViewById(R.id.transcriptContainer);
+
+ titleText.setText(RecordingsActivity.prettyName(recordingName));
+
+ setUpSpinners();
+ setUpButtons();
+ setUpPlayer();
+
+ TranscriptionEngine.Result existing = transcripts.load(recordingName);
+ if (existing != null) {
+ showTranscript(existing);
+ } else {
+ statusText.setText(R.string.transcribe_hint);
+ }
+
+ handler.post(ticker);
+ }
+
+ private void setUpSpinners() {
+ ArrayAdapter<String> languages = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item,
+ new String[]{
+ getString(R.string.lang_auto),
+ getString(R.string.lang_en),
+ getString(R.string.lang_it),
+ });
+ languages.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ languageSpinner.setAdapter(languages);
+
+ ArrayAdapter<String> modelAdapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item,
+ new String[]{
+ getString(R.string.model_base),
+ getString(R.string.model_tiny),
+ });
+ modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ modelSpinner.setAdapter(modelAdapter);
+ }
+
+ private void setUpButtons() {
+ playButton.setOnClickListener(v -> togglePlay());
+ speedButton.setOnClickListener(v -> cycleSpeed());
+ findViewById(R.id.rewindButton).setOnClickListener(v -> seekBy(-10_000));
+ findViewById(R.id.forwardButton).setOnClickListener(v -> seekBy(10_000));
+ transcribeButton.setOnClickListener(v -> onTranscribeClicked());
+
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) {
+ if (fromUser) {
+ timeText.setText(formatClock(progress) + " / " + formatClock(duration()));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar bar) {
+ userSeeking = true;
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar bar) {
+ userSeeking = false;
+ if (player != null && prepared) {
+ player.seekTo(bar.getProgress());
+ }
+ }
+ });
+ }
+
+ private void setUpPlayer() {
+ player = new MediaPlayer();
+ try {
+ player.setDataSource(this, recordingUri);
+ player.setOnPreparedListener(mp -> {
+ prepared = true;
+ seekBar.setMax(mp.getDuration());
+ updateTimeLabel();
+ try {
+ mp.start();
+ applySpeed();
+ } catch (Exception ignored) {
+ // Ignore, playback can be started manually.
+ }
+ updatePlayButton();
+ });
+ player.setOnCompletionListener(mp -> updatePlayButton());
+ player.prepareAsync();
+ } catch (Exception e) {
+ Toast.makeText(this, R.string.err_playback, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private int duration() {
+ if (player == null || !prepared) {
+ return 0;
+ }
+ try {
+ return player.getDuration();
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private void togglePlay() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ if (player.isPlaying()) {
+ player.pause();
+ } else {
+ if (player.getCurrentPosition() >= player.getDuration() - 150) {
+ player.seekTo(0);
+ }
+ player.start();
+ applySpeed();
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updatePlayButton();
+ }
+
+ private void seekBy(int deltaMs) {
+ if (player == null || !prepared) {
+ return;
+ }
+ int target = player.getCurrentPosition() + deltaMs;
+ target = Math.max(0, Math.min(target, player.getDuration()));
+ player.seekTo(target);
+ seekBar.setProgress(target);
+ updateTimeLabel();
+ }
+
+ private void cycleSpeed() {
+ speedIndex = (speedIndex + 1) % SPEEDS.length;
+ speedIndex0 = SPEEDS[speedIndex];
+ speedButton.setText(String.format(Locale.US, "%.1f×", speedIndex0));
+ applySpeed();
+ }
+
+ private void applySpeed() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ PlaybackParams params = player.getPlaybackParams();
+ params.setSpeed(speedIndex0);
+ player.setPlaybackParams(params);
+ } catch (Exception ignored) {
+ // Some devices reject speed changes while paused.
+ }
+ }
+
+ private void updateProgress() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ if (!userSeeking) {
+ seekBar.setProgress(player.getCurrentPosition());
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updateTimeLabel();
+ updatePlayButton();
+ }
+
+ private void updateTimeLabel() {
+ timeText.setText(formatClock(player != null && prepared ? player.getCurrentPosition() : 0)
+ + " / " + formatClock(duration()));
+ }
+
+ private void updatePlayButton() {
+ boolean playing = false;
+ if (player != null && prepared) {
+ try {
+ playing = player.isPlaying();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+ playButton.setText(playing ? R.string.player_pause : R.string.player_play);
+ }
+
+ // --- Transcription ----------------------------------------------------
+
+ private void onTranscribeClicked() {
+ ModelRepository.WhisperModel model = modelSpinner.getSelectedItemPosition() == 1
+ ? ModelRepository.WhisperModel.TINY
+ : ModelRepository.WhisperModel.BASE;
+ String language = languageFor(languageSpinner.getSelectedItemPosition());
+
+ if (!models.isReady(model)) {
+ promptDownload(model, language);
+ } else {
+ runTranscription(model, language);
+ }
+ }
+
+ private void promptDownload(ModelRepository.WhisperModel model, String language) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.model_download_title)
+ .setMessage(getString(R.string.model_download_message,
+ model.label, humanBytes(model.totalBytes())))
+ .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model, language))
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+
+ private void runDownload(ModelRepository.WhisperModel model, String language) {
+ transcribeButton.setEnabled(false);
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(0);
+ statusText.setText(R.string.status_preparing);
+
+ new Thread(() -> {
+ try {
+ models.download(model, (downloaded, total, file) -> runOnUiThread(() -> {
+ int permille = (int) (downloaded * 1000 / Math.max(1, total));
+ progressBar.setProgress(permille);
+ statusText.setText(getString(
+ R.string.status_downloading, file, permille / 10));
+ }));
+ runOnUiThread(() -> {
+ statusText.setText(R.string.status_model_ready);
+ runTranscription(model, language);
+ });
+ } catch (Exception e) {
+ runOnUiThread(() -> {
+ transcribeButton.setEnabled(true);
+ progressBar.setVisibility(View.GONE);
+ statusText.setText(getString(R.string.err_download, String.valueOf(e.getMessage())));
+ });
+ }
+ }, "model-download").start();
+ }
+
+ private void runTranscription(ModelRepository.WhisperModel model, String language) {
+ cancelled = false;
+ transcribeButton.setEnabled(false);
+ transcriptContainer.removeAllViews();
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(true);
+ statusText.setText(R.string.status_preparing);
+
+ new Thread(() -> {
+ TranscriptionEngine.Result result;
+ try {
+ TranscriptionEngine engine = new TranscriptionEngine(PlayerActivity.this);
+ result = engine.transcribe(recordingUri, model, language,
+ new TranscriptionEngine.Callback() {
+ @Override
+ public void onProgress(int percent, String message) {
+ runOnUiThread(() -> {
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(percent * 10);
+ statusText.setText(message);
+ });
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+ });
+ } catch (Exception e) {
+ runOnUiThread(() -> statusText.setText(
+ getString(R.string.err_transcribe, String.valueOf(e.getMessage()))));
+ return;
+ } finally {
+ runOnUiThread(() -> {
+ transcribeButton.setEnabled(true);
+ progressBar.setVisibility(View.GONE);
+ });
+ }
+
+ if (result == null) {
+ return;
+ }
+ try {
+ transcripts.save(recordingName, result);
+ } catch (Exception ignored) {
+ // The transcript is still shown below.
+ }
+ runOnUiThread(() -> showTranscript(result));
+ }, "transcribe").start();
+ }
+
+ private void showTranscript(TranscriptionEngine.Result result) {
+ transcriptContainer.removeAllViews();
+ if (result.segments.isEmpty()) {
+ statusText.setText(R.string.status_no_speech);
+ return;
+ }
+ for (TranscriptionEngine.Segment segment : result.segments) {
+ addTranscriptLine(
+ "[" + formatClock((int) (segment.startSeconds * 1000)) + "] " + segment.text,
+ (int) (segment.startSeconds * 1000));
+ }
+ statusText.setText(R.string.status_transcript_saved);
+ }
+
+ private void addTranscriptLine(String text, int seekMs) {
+ TextView view = new TextView(this);
+ view.setText(text);
+ view.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
+ view.setTextSize(15f);
+ view.setPadding(0, 12, 0, 12);
+ view.setBackgroundResource(android.R.drawable.list_selector_background);
+ view.setOnClickListener(v -> {
+ if (player != null && prepared) {
+ player.seekTo(Math.max(0, Math.min(seekMs, player.getDuration())));
+ if (!player.isPlaying()) {
+ player.start();
+ applySpeed();
+ }
+ }
+ });
+ transcriptContainer.addView(view);
+ }
+
+ private static String languageFor(int position) {
+ switch (position) {
+ case 1:
+ return TranscriptionEngine.LANG_EN;
+ case 2:
+ return TranscriptionEngine.LANG_IT;
+ default:
+ return TranscriptionEngine.LANG_AUTO;
+ }
+ }
+
+ static String humanBytes(long bytes) {
+ double mb = bytes / 1048576.0;
+ if (mb >= 1) {
+ return String.format(Locale.US, "%.0f MB", mb);
+ }
+ return String.format(Locale.US, "%.0f KB", bytes / 1024.0);
+ }
+
+ static String formatClock(int millis) {
+ int totalSeconds = Math.max(0, millis) / 1000;
+ int minutes = totalSeconds / 60;
+ int seconds = totalSeconds % 60;
+ int hours = minutes / 60;
+ if (hours > 0) {
+ return String.format(Locale.US, "%d:%02d:%02d", hours, minutes % 60, seconds);
+ }
+ return String.format(Locale.US, "%d:%02d", minutes, seconds);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (player != null && prepared) {
+ try {
+ if (player.isPlaying()) {
+ player.pause();
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updatePlayButton();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ cancelled = true;
+ handler.removeCallbacksAndMessages(null);
+ if (player != null) {
+ try {
+ player.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ player = null;
+ }
+ super.onDestroy();
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java b/app/src/main/java/com/wuhei/reccoon/RecordingStore.java
new file mode 100644
index 0000000..12abb06
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/RecordingStore.java
@@ -0,0 +1,108 @@
+package com.wuhei.reccoon;
+
+import android.content.Context;
+import android.content.ContentUris;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Lists the WAV files saved by RECCoon in the public Downloads folder. */
+public final class RecordingStore {
+
+ public static final class Recording {
+ public final String name;
+ public final Uri uri;
+ public final long sizeBytes;
+ public final long modifiedSeconds;
+ public long durationMs;
+
+ Recording(String name, Uri uri, long sizeBytes, long modifiedSeconds) {
+ this.name = name;
+ this.uri = uri;
+ this.sizeBytes = sizeBytes;
+ this.modifiedSeconds = modifiedSeconds;
+ }
+ }
+
+ private static final String PREFIX = "reccoon-";
+ private static final String SUFFIX = ".wav";
+
+ private final Context context;
+
+ public RecordingStore(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ public List<Recording> list() {
+ List<Recording> recordings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? listFromMediaStore()
+ : listFromDirectory();
+ for (Recording recording : recordings) {
+ recording.durationMs = TranscriptionEngine.durationMillis(context, recording.uri);
+ }
+ Collections.sort(recordings, (a, b) -> Long.compare(b.modifiedSeconds, a.modifiedSeconds));
+ return recordings;
+ }
+
+ private List<Recording> listFromMediaStore() {
+ List<Recording> result = new ArrayList<>();
+ String[] projection = {
+ MediaStore.MediaColumns._ID,
+ MediaStore.MediaColumns.DISPLAY_NAME,
+ MediaStore.MediaColumns.SIZE,
+ MediaStore.MediaColumns.DATE_MODIFIED,
+ };
+ String selection = MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ? AND "
+ + MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ?";
+ String[] args = {PREFIX + "%", "%" + SUFFIX};
+ try (Cursor cursor = context.getContentResolver().query(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ projection, selection, args, null)) {
+ if (cursor == null) {
+ return result;
+ }
+ int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
+ int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
+ int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE);
+ int dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(idColumn);
+ Uri uri = ContentUris.withAppendedId(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI, id);
+ result.add(new Recording(
+ cursor.getString(nameColumn),
+ uri,
+ cursor.getLong(sizeColumn),
+ cursor.getLong(dateColumn)));
+ }
+ } catch (Exception e) {
+ // Ignore and return what we have.
+ }
+ return result;
+ }
+
+ @SuppressWarnings("deprecation")
+ private List<Recording> listFromDirectory() {
+ List<Recording> result = new ArrayList<>();
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX));
+ if (files == null) {
+ return result;
+ }
+ for (File file : files) {
+ result.add(new Recording(
+ file.getName(),
+ Uri.fromFile(file),
+ file.length(),
+ file.lastModified() / 1000L));
+ }
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java b/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java
new file mode 100644
index 0000000..9551ef5
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java
@@ -0,0 +1,135 @@
+package com.wuhei.reccoon;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ListView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+/** Lists the recordings saved in Downloads and opens them in the player. */
+public class RecordingsActivity extends AppCompatActivity {
+
+ private RecordingStore store;
+ private TranscriptStore transcripts;
+ private TextView emptyText;
+ private Adapter adapter;
+
+ private final List<RecordingStore.Recording> items = new ArrayList<>();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_recordings);
+
+ store = new RecordingStore(this);
+ transcripts = new TranscriptStore(this);
+ emptyText = findViewById(R.id.emptyText);
+
+ ListView listView = findViewById(R.id.recordingsList);
+ adapter = new Adapter();
+ listView.setAdapter(adapter);
+ listView.setOnItemClickListener((parent, view, position, id) -> open(items.get(position)));
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ items.clear();
+ items.addAll(store.list());
+ adapter.notifyDataSetChanged();
+ emptyText.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE);
+ }
+
+ private void open(RecordingStore.Recording recording) {
+ Intent intent = new Intent(this, PlayerActivity.class);
+ intent.putExtra(PlayerActivity.EXTRA_NAME, recording.name);
+ intent.putExtra(PlayerActivity.EXTRA_URI, recording.uri.toString());
+ startActivity(intent);
+ }
+
+ /** Turns {@code reccoon-2026-09-24-153000-00001.wav} into a readable label. */
+ static String prettyName(String fileName) {
+ String core = fileName;
+ if (core.startsWith("reccoon-")) {
+ core = core.substring("reccoon-".length());
+ }
+ if (core.endsWith(".wav")) {
+ core = core.substring(0, core.length() - 4);
+ }
+ String[] parts = core.split("-");
+ if (parts.length >= 4) {
+ String date = parts[0] + "-" + parts[1] + "-" + parts[2];
+ String time = parts[3];
+ String sequence = parts.length >= 5 ? parts[4] : "";
+ if (time.length() == 6) {
+ time = time.substring(0, 2) + ":" + time.substring(2, 4) + ":" + time.substring(4);
+ }
+ return date + " " + time + (sequence.isEmpty() ? "" : " #" + sequence);
+ }
+ return core;
+ }
+
+ static String formatDuration(long millis) {
+ long totalSeconds = millis / 1000;
+ long hours = totalSeconds / 3600;
+ long minutes = (totalSeconds % 3600) / 60;
+ long seconds = totalSeconds % 60;
+ if (hours > 0) {
+ return String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds);
+ }
+ return String.format(Locale.US, "%d:%02d", minutes, seconds);
+ }
+
+ static String formatSize(long bytes) {
+ double mb = bytes / 1048576.0;
+ if (mb >= 1) {
+ return String.format(Locale.US, "%.1f MB", mb);
+ }
+ return String.format(Locale.US, "%d KB", bytes / 1024);
+ }
+
+ private class Adapter extends BaseAdapter {
+ @Override
+ public int getCount() {
+ return items.size();
+ }
+
+ @Override
+ public Object getItem(int position) {
+ return items.get(position);
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View view = convertView != null
+ ? convertView
+ : LayoutInflater.from(RecordingsActivity.this)
+ .inflate(R.layout.item_recording, parent, false);
+ RecordingStore.Recording recording = items.get(position);
+
+ TextView name = view.findViewById(R.id.nameText);
+ TextView meta = view.findViewById(R.id.metaText);
+ TextView badge = view.findViewById(R.id.transcriptBadge);
+
+ name.setText(prettyName(recording.name));
+ meta.setText(formatDuration(recording.durationMs)
+ + " · " + formatSize(recording.sizeBytes));
+ badge.setVisibility(transcripts.has(recording.name) ? View.VISIBLE : View.GONE);
+ return view;
+ }
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java b/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
new file mode 100644
index 0000000..a30608e
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
@@ -0,0 +1,110 @@
+package com.wuhei.reccoon;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Stores and loads transcript sidecar files in app-private storage. */
+public final class TranscriptStore {
+
+ private final File dir;
+
+ public TranscriptStore(Context context) {
+ dir = new File(context.getApplicationContext().getFilesDir(), "transcripts");
+ }
+
+ public boolean has(String recordingName) {
+ return fileFor(recordingName).isFile();
+ }
+
+ public void save(String recordingName, TranscriptionEngine.Result result) throws IOException {
+ JSONObject root = new JSONObject();
+ JSONArray segments = new JSONArray();
+ try {
+ root.put("language", result.language == null ? "" : result.language);
+ for (TranscriptionEngine.Segment segment : result.segments) {
+ JSONObject item = new JSONObject();
+ item.put("start", segment.startSeconds);
+ item.put("end", segment.endSeconds);
+ item.put("text", segment.text);
+ segments.put(item);
+ }
+ root.put("segments", segments);
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Cannot create transcript directory");
+ }
+ String json;
+ try {
+ json = root.toString(2);
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+ File target = fileFor(recordingName);
+ try (FileOutputStream out = new FileOutputStream(target)) {
+ out.write(json.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ public TranscriptionEngine.Result load(String recordingName) {
+ File file = fileFor(recordingName);
+ if (!file.isFile()) {
+ return null;
+ }
+ try {
+ String json = new String(readAll(file), StandardCharsets.UTF_8);
+ JSONObject root = new JSONObject(json);
+ TranscriptionEngine.Result result = new TranscriptionEngine.Result();
+ result.language = root.optString("language", "");
+ JSONArray segments = root.optJSONArray("segments");
+ if (segments != null) {
+ for (int i = 0; i < segments.length(); i++) {
+ JSONObject item = segments.getJSONObject(i);
+ result.segments.add(new TranscriptionEngine.Segment(
+ (float) item.optDouble("start", 0),
+ (float) item.optDouble("end", 0),
+ item.optString("text", "")));
+ }
+ }
+ return result;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private File fileFor(String recordingName) {
+ return new File(dir, recordingName + ".json");
+ }
+
+ private static byte[] readAll(File file) throws IOException {
+ try (java.io.InputStream in = new java.io.FileInputStream(file)) {
+ List<byte[]> chunks = new ArrayList<>();
+ byte[] buffer = new byte[16384];
+ int total = 0;
+ int read;
+ while ((read = in.read(buffer)) > 0) {
+ chunks.add(java.util.Arrays.copyOf(buffer, read));
+ total += read;
+ }
+ byte[] all = new byte[total];
+ int offset = 0;
+ for (byte[] chunk : chunks) {
+ System.arraycopy(chunk, 0, all, offset, chunk.length);
+ offset += chunk.length;
+ }
+ return all;
+ }
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java b/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
new file mode 100644
index 0000000..f4306ff
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
@@ -0,0 +1,393 @@
+package com.wuhei.reccoon;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.k2fsa.sherpa.onnx.FeatureConfig;
+import com.k2fsa.sherpa.onnx.OfflineModelConfig;
+import com.k2fsa.sherpa.onnx.OfflineRecognizer;
+import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig;
+import com.k2fsa.sherpa.onnx.OfflineStream;
+import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig;
+import com.k2fsa.sherpa.onnx.SileroVadModelConfig;
+import com.k2fsa.sherpa.onnx.SpeechSegment;
+import com.k2fsa.sherpa.onnx.Vad;
+import com.k2fsa.sherpa.onnx.VadModelConfig;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * On-device transcription using sherpa-onnx with a multilingual Whisper model.
+ *
+ * <p>Long recordings are streamed, resampled to 16 kHz and segmented with
+ * Silero VAD, then each detected speech segment is decoded. This keeps memory
+ * bounded regardless of the recording length (offline Whisper itself only
+ * handles up to 30 seconds at a time).
+ */
+public final class TranscriptionEngine {
+
+ public static final String LANG_AUTO = "";
+ public static final String LANG_EN = "en";
+ public static final String LANG_IT = "it";
+
+ private static final int TARGET_RATE = 16_000;
+
+ public static final class Segment {
+ public final float startSeconds;
+ public final float endSeconds;
+ public final String text;
+
+ public Segment(float startSeconds, float endSeconds, String text) {
+ this.startSeconds = startSeconds;
+ this.endSeconds = endSeconds;
+ this.text = text;
+ }
+ }
+
+ public static final class Result {
+ public final List<Segment> segments = new ArrayList<>();
+ public String language = "";
+
+ /** Plain transcript with one segment per paragraph. */
+ public String asText() {
+ StringBuilder sb = new StringBuilder();
+ for (Segment segment : segments) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(segment.text);
+ }
+ return sb.toString();
+ }
+ }
+
+ public interface Callback {
+ /** @param percent 0..100 */
+ void onProgress(int percent, String message);
+
+ boolean isCancelled();
+ }
+
+ private final Context context;
+ private final ModelRepository models;
+
+ public TranscriptionEngine(Context context) {
+ this.context = context.getApplicationContext();
+ this.models = new ModelRepository(this.context);
+ }
+
+ public Result transcribe(Uri wavUri,
+ ModelRepository.WhisperModel model,
+ String language,
+ Callback callback) throws IOException {
+ if (!models.isReady(model)) {
+ throw new IOException("Model is not downloaded");
+ }
+
+ OfflineRecognizer recognizer = buildRecognizer(model, language);
+ Vad vad = buildVad();
+ Result result = new Result();
+ result.language = language;
+
+ try (InputStream raw = context.getContentResolver().openInputStream(wavUri)) {
+ if (raw == null) {
+ throw new IOException("Cannot open " + wavUri);
+ }
+ BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024);
+ WavInfo info = readWavHeader(buffered);
+ if (info.bitsPerSample != 16 || info.audioFormat != 1) {
+ throw new IOException("Only 16-bit PCM WAV files are supported");
+ }
+
+ StreamingResampler resampler = new StreamingResampler(info.sampleRate, TARGET_RATE);
+ long totalPcm16k = estimatePcm16kSamples(info);
+ long processed = 0;
+
+ int frameBytes = info.channels * 2;
+ byte[] readBuffer = new byte[frameBytes * 8192];
+ long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE;
+
+ while (remaining > 0) {
+ if (callback != null && callback.isCancelled()) {
+ return null;
+ }
+ int toRead = (int) Math.min(readBuffer.length, remaining);
+ int read = buffered.read(readBuffer, 0, toRead);
+ if (read <= 0) {
+ break;
+ }
+ remaining -= read;
+
+ int frames = read / frameBytes;
+ float[] mono = new float[frames];
+ for (int i = 0; i < frames; i++) {
+ int offset = i * frameBytes;
+ if (info.channels == 1) {
+ mono[i] = pcm16(readBuffer, offset) / 32768f;
+ } else {
+ int sum = 0;
+ for (int c = 0; c < info.channels; c++) {
+ sum += pcm16(readBuffer, offset + c * 2);
+ }
+ mono[i] = sum / (float) info.channels / 32768f;
+ }
+ }
+
+ float[] resampled = resampler.process(mono);
+ processed += resampled.length;
+ if (resampled.length > 0) {
+ vad.acceptWaveform(resampled);
+ drainSegments(vad, recognizer, result);
+ }
+ if (callback != null && totalPcm16k > 0) {
+ int percent = (int) Math.min(99, processed * 100 / totalPcm16k);
+ callback.onProgress(percent, "Transcribing…");
+ }
+ }
+
+ vad.flush();
+ drainSegments(vad, recognizer, result);
+ } finally {
+ vad.release();
+ recognizer.release();
+ }
+
+ if (callback != null) {
+ callback.onProgress(100, "Done");
+ }
+ return result;
+ }
+
+ private void drainSegments(Vad vad, OfflineRecognizer recognizer, Result result) {
+ while (!vad.empty()) {
+ SpeechSegment speech = vad.front();
+ vad.pop();
+ float[] samples = speech.getSamples();
+ if (samples == null || samples.length < TARGET_RATE / 10) {
+ continue; // ignore blips shorter than 100 ms
+ }
+ OfflineStream stream = recognizer.createStream();
+ try {
+ stream.acceptWaveform(samples, TARGET_RATE);
+ recognizer.decode(stream);
+ String text = recognizer.getResult(stream).getText().trim();
+ if (!text.isEmpty()) {
+ float start = speech.getStart() / (float) TARGET_RATE;
+ float end = start + samples.length / (float) TARGET_RATE;
+ result.segments.add(new Segment(start, end, text));
+ }
+ } finally {
+ stream.release();
+ }
+ }
+ }
+
+ private OfflineRecognizer buildRecognizer(ModelRepository.WhisperModel model, String language) {
+ FeatureConfig feat = new FeatureConfig();
+ feat.setSampleRate(TARGET_RATE);
+ feat.setFeatureDim(80);
+ feat.setDither(0f);
+
+ OfflineWhisperModelConfig whisper = new OfflineWhisperModelConfig();
+ whisper.setEncoder(models.encoder(model).getAbsolutePath());
+ whisper.setDecoder(models.decoder(model).getAbsolutePath());
+ whisper.setLanguage(language == null ? "" : language);
+ whisper.setTask("transcribe");
+ whisper.setTailPaddings(1000);
+ whisper.setEnableTokenTimestamps(false);
+ whisper.setEnableSegmentTimestamps(false);
+
+ OfflineModelConfig modelConfig = new OfflineModelConfig();
+ modelConfig.setWhisper(whisper);
+ modelConfig.setTokens(models.tokens(model).getAbsolutePath());
+ modelConfig.setNumThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
+ modelConfig.setDebug(false);
+ modelConfig.setProvider("cpu");
+ modelConfig.setModelType("whisper");
+
+ OfflineRecognizerConfig config = new OfflineRecognizerConfig();
+ config.setFeatConfig(feat);
+ config.setModelConfig(modelConfig);
+ config.setDecodingMethod("greedy_search");
+
+ // Passing a null AssetManager makes sherpa-onnx load the model from
+ // the absolute file paths configured above.
+ return new OfflineRecognizer(null, config);
+ }
+
+ private Vad buildVad() {
+ SileroVadModelConfig silero = new SileroVadModelConfig();
+ silero.setModel(models.vadModel().getAbsolutePath());
+ silero.setThreshold(0.5f);
+ silero.setMinSilenceDuration(0.5f);
+ silero.setMinSpeechDuration(0.25f);
+ silero.setWindowSize(512);
+ silero.setMaxSpeechDuration(20.0f);
+
+ VadModelConfig config = new VadModelConfig();
+ config.setSileroVadModelConfig(silero);
+ config.setSampleRate(TARGET_RATE);
+ config.setNumThreads(1);
+ config.setProvider("cpu");
+ config.setDebug(false);
+
+ return new Vad(null, config);
+ }
+
+ // --- WAV handling -----------------------------------------------------
+
+ public static final class WavInfo {
+ public int audioFormat;
+ public int channels;
+ public int sampleRate;
+ public int bitsPerSample;
+ public long dataSize;
+
+ public long durationMillis() {
+ int byteRate = sampleRate * channels * (bitsPerSample / 8);
+ if (byteRate <= 0 || dataSize <= 0) {
+ return 0;
+ }
+ return dataSize * 1000L / byteRate;
+ }
+ }
+
+ public static WavInfo readWavHeader(InputStream stream) throws IOException {
+ DataInputStream in = stream instanceof DataInputStream
+ ? (DataInputStream) stream
+ : new DataInputStream(stream);
+
+ byte[] header = new byte[12];
+ in.readFully(header);
+ if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F'
+ || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' || header[11] != 'E') {
+ throw new IOException("Not a RIFF/WAVE file");
+ }
+
+ WavInfo info = new WavInfo();
+ while (true) {
+ byte[] chunkHeader = new byte[8];
+ in.readFully(chunkHeader);
+ String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII);
+ long size = le32(chunkHeader, 4) & 0xffffffffL;
+
+ if ("fmt ".equals(id)) {
+ if (size < 16 || size > 1024) {
+ throw new IOException("Unsupported fmt chunk");
+ }
+ byte[] fmt = new byte[(int) size];
+ in.readFully(fmt);
+ info.audioFormat = (int) le16(fmt, 0);
+ info.channels = (int) le16(fmt, 2);
+ info.sampleRate = (int) le32(fmt, 4);
+ info.bitsPerSample = (int) le16(fmt, 14);
+ if ((size & 1) == 1) {
+ in.readFully(new byte[1]);
+ }
+ } else if ("data".equals(id)) {
+ info.dataSize = size;
+ return info;
+ } else {
+ skipFully(in, size + (size & 1));
+ }
+ }
+ }
+
+ public static long durationMillis(Context context, Uri uri) {
+ try (InputStream in = context.getContentResolver().openInputStream(uri)) {
+ if (in == null) {
+ return 0;
+ }
+ WavInfo info = readWavHeader(new BufferedInputStream(in));
+ return info.durationMillis();
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private static long estimatePcm16kSamples(WavInfo info) {
+ if (info.dataSize <= 0 || info.sampleRate <= 0) {
+ return 0;
+ }
+ int frameBytes = info.channels * (info.bitsPerSample / 8);
+ if (frameBytes <= 0) {
+ return 0;
+ }
+ long frames = info.dataSize / frameBytes;
+ return frames * TARGET_RATE / info.sampleRate;
+ }
+
+ private static int pcm16(byte[] data, int offset) {
+ return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8));
+ }
+
+ private static long le32(byte[] data, int offset) {
+ return (data[offset] & 0xffL)
+ | ((data[offset + 1] & 0xffL) << 8)
+ | ((data[offset + 2] & 0xffL) << 16)
+ | ((data[offset + 3] & 0xffL) << 24);
+ }
+
+ private static long le16(byte[] data, int offset) {
+ return (data[offset] & 0xffL) | ((data[offset + 1] & 0xffL) << 8);
+ }
+
+ private static void skipFully(DataInputStream in, long count) throws IOException {
+ long remaining = count;
+ while (remaining > 0) {
+ long skipped = in.skip(remaining);
+ if (skipped <= 0) {
+ if (in.read() < 0) {
+ throw new IOException("Unexpected end of WAV file");
+ }
+ skipped = 1;
+ }
+ remaining -= skipped;
+ }
+ }
+
+ /** Linear-interpolation streaming resampler. */
+ static final class StreamingResampler {
+ private final double ratio;
+ private float[] buffer = new float[0];
+ private double position;
+
+ StreamingResampler(int inputRate, int outputRate) {
+ this.ratio = inputRate / (double) outputRate;
+ }
+
+ float[] process(float[] input) {
+ if (input.length == 0) {
+ return new float[0];
+ }
+ float[] combined = Arrays.copyOf(buffer, buffer.length + input.length);
+ System.arraycopy(input, 0, combined, buffer.length, input.length);
+ buffer = combined;
+
+ int capacity = (int) Math.max(1, (buffer.length - position) / ratio + 2);
+ float[] output = new float[capacity];
+ int count = 0;
+ while (position + 1 < buffer.length && count < output.length) {
+ int index = (int) position;
+ float fraction = (float) (position - index);
+ output[count++] = buffer[index] * (1f - fraction) + buffer[index + 1] * fraction;
+ position += ratio;
+ }
+
+ int drop = (int) position;
+ if (drop > 0) {
+ buffer = Arrays.copyOfRange(buffer, drop, buffer.length);
+ position -= drop;
+ }
+ return count == output.length ? output : Arrays.copyOf(output, count);
+ }
+ }
+}
diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java
new file mode 100644
index 0000000..cb6cf93
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java
@@ -0,0 +1,181 @@
+package com.wuhei.reccoon;
+
+import android.content.Context;
+import android.media.AudioFormat;
+import android.media.AudioRecord;
+import android.media.MediaRecorder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Records uncompressed 16-bit PCM mono audio at 44.1 kHz and writes a standard
+ * RIFF/WAVE file (PCM, format code 1).
+ */
+public class WavRecorder {
+
+ public static final int SAMPLE_RATE = 44100;
+ public static final int CHANNELS = 1;
+ public static final int BITS_PER_SAMPLE = 16;
+
+ private static final int HEADER_SIZE = 44;
+
+ private final Context context;
+
+ private AudioRecord audioRecord;
+ private Thread writerThread;
+ private volatile boolean recording;
+ private File tempFile;
+ private volatile long pcmBytes;
+
+ public WavRecorder(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ /**
+ * Starts capturing audio. Returns the file that will hold the WAV data.
+ */
+ public File start() throws IOException {
+ if (recording) {
+ throw new IllegalStateException("A recording is already in progress");
+ }
+
+ int minBuffer = AudioRecord.getMinBufferSize(
+ SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT);
+ if (minBuffer <= 0) {
+ throw new IOException("Unsupported audio configuration (minBuffer=" + minBuffer + ")");
+ }
+ int bufferSize = Math.max(minBuffer, SAMPLE_RATE);
+
+ audioRecord = new AudioRecord(
+ MediaRecorder.AudioSource.MIC,
+ SAMPLE_RATE,
+ AudioFormat.CHANNEL_IN_MONO,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize);
+ if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+ audioRecord.release();
+ audioRecord = null;
+ throw new IOException("AudioRecord failed to initialize");
+ }
+
+ tempFile = new File(context.getCacheDir(), "reccoon_recording.wav");
+ writePlaceholderHeader();
+
+ pcmBytes = 0L;
+ recording = true;
+ audioRecord.startRecording();
+
+ final int chunkSize = Math.max(minBuffer, 8192);
+ writerThread = new Thread(() -> {
+ byte[] buffer = new byte[chunkSize];
+ try (FileOutputStream out = new FileOutputStream(tempFile, true)) {
+ while (recording) {
+ int read = audioRecord.read(buffer, 0, buffer.length);
+ if (read > 0) {
+ out.write(buffer, 0, read);
+ pcmBytes += read;
+ }
+ }
+ out.flush();
+ } catch (Exception ignored) {
+ // Whatever was captured before the failure stays in the file.
+ }
+ }, "reccoon-writer");
+ writerThread.start();
+
+ return tempFile;
+ }
+
+ /**
+ * Stops capturing and finalizes the WAV header. Returns the finished file.
+ */
+ public File stop() {
+ recording = false;
+
+ AudioRecord record = audioRecord;
+ if (record != null) {
+ try {
+ if (record.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
+ record.stop();
+ }
+ } catch (Exception ignored) {
+ // Ignore and continue tearing down.
+ }
+ }
+
+ Thread thread = writerThread;
+ if (thread != null) {
+ try {
+ thread.join(3000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ if (record != null) {
+ try {
+ record.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ audioRecord = null;
+ }
+ writerThread = null;
+
+ writeHeader();
+ return tempFile;
+ }
+
+ public boolean isRecording() {
+ return recording;
+ }
+
+ private void writePlaceholderHeader() throws IOException {
+ try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
+ raf.setLength(0);
+ raf.write(new byte[HEADER_SIZE]);
+ }
+ }
+
+ private void writeHeader() {
+ try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
+ raf.seek(0);
+ raf.write(buildHeader(pcmBytes));
+ } catch (IOException ignored) {
+ // The file is still usable except for the size fields.
+ }
+ }
+
+ private static byte[] buildHeader(long dataSize) {
+ int byteRate = SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8;
+ int blockAlign = CHANNELS * BITS_PER_SAMPLE / 8;
+
+ ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(ascii("RIFF"));
+ buffer.putInt((int) (36 + dataSize));
+ buffer.put(ascii("WAVE"));
+ buffer.put(ascii("fmt "));
+ buffer.putInt(16); // PCM chunk size
+ buffer.putShort((short) 1); // PCM = 1
+ buffer.putShort((short) CHANNELS);
+ buffer.putInt(SAMPLE_RATE);
+ buffer.putInt(byteRate);
+ buffer.putShort((short) blockAlign);
+ buffer.putShort((short) BITS_PER_SAMPLE);
+ buffer.put(ascii("data"));
+ buffer.putInt((int) dataSize);
+ return buffer.array();
+ }
+
+ private static byte[] ascii(String value) {
+ return value.getBytes(StandardCharsets.US_ASCII);
+ }
+}
diff --git a/app/src/main/res/drawable/badge_transcript.xml b/app/src/main/res/drawable/badge_transcript.xml
new file mode 100644
index 0000000..06848bf
--- /dev/null
+++ b/app/src/main/res/drawable/badge_transcript.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+ android:shape="rectangle">
+ <corners android:radius="10dp" />
+ <solid android:color="@color/accent" />
+</shape>
diff --git a/app/src/main/res/drawable/bg_record_active.xml b/app/src/main/res/drawable/bg_record_active.xml
new file mode 100644
index 0000000..e218a37
--- /dev/null
+++ b/app/src/main/res/drawable/bg_record_active.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item>
+ <shape android:shape="oval">
+ <solid android:color="#1FE53935" />
+ <stroke
+ android:width="3dp"
+ android:color="@color/accent" />
+ </shape>
+ </item>
+ <item
+ android:bottom="62dp"
+ android:left="62dp"
+ android:right="62dp"
+ android:top="62dp">
+ <shape android:shape="rectangle">
+ <corners android:radius="14dp" />
+ <solid android:color="@color/accent" />
+ </shape>
+ </item>
+</layer-list>
diff --git a/app/src/main/res/drawable/bg_record_idle.xml b/app/src/main/res/drawable/bg_record_idle.xml
new file mode 100644
index 0000000..c9f776a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_record_idle.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+ <item>
+ <shape android:shape="oval">
+ <solid android:color="#14FFFFFF" />
+ <stroke
+ android:width="3dp"
+ android:color="#40FFFFFF" />
+ </shape>
+ </item>
+ <item
+ android:bottom="56dp"
+ android:left="56dp"
+ android:right="56dp"
+ android:top="56dp">
+ <shape android:shape="oval">
+ <solid android:color="@color/accent" />
+ </shape>
+ </item>
+</layer-list>
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..0c2447b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<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="M0,0 h108 v108 h-108 z">
+ <aapt:attr name="android:fillColor">
+ <gradient
+ android:type="linear"
+ android:startX="0"
+ android:startY="0"
+ android:endX="108"
+ android:endY="108">
+ <item
+ android:offset="0"
+ android:color="#FF2B3A4C" />
+ <item
+ android:offset="1"
+ android:color="#FF0E1116" />
+ </gradient>
+ </aapt:attr>
+ </path>
+</vector>
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..61b5198
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,102 @@
+<?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">
+
+ <!-- Ears -->
+ <path
+ android:fillColor="#7C8794"
+ android:pathData="M34,33 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
+ <path
+ android:fillColor="#7C8794"
+ android:pathData="M74,33 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
+ <path
+ android:fillColor="#3A414B"
+ android:pathData="M34,33 m-5.5,0 a5.5,5.5 0 1,0 11,0 a5.5,5.5 0 1,0 -11,0" />
+ <path
+ android:fillColor="#3A414B"
+ android:pathData="M74,33 m-5.5,0 a5.5,5.5 0 1,0 11,0 a5.5,5.5 0 1,0 -11,0" />
+
+ <!-- Head -->
+ <path
+ android:fillColor="#9AA3AD"
+ android:strokeColor="#6B7280"
+ android:strokeWidth="1.5"
+ android:pathData="M54,50 m-28,0 a28,26 0 1,0 56,0 a28,26 0 1,0 -56,0" />
+
+ <!-- Eye mask -->
+ <path
+ android:fillColor="#333A45"
+ android:pathData="M41,46 m-15,0 a15,10.5 0 1,0 30,0 a15,10.5 0 1,0 -30,0" />
+ <path
+ android:fillColor="#333A45"
+ android:pathData="M67,46 m-15,0 a15,10.5 0 1,0 30,0 a15,10.5 0 1,0 -30,0" />
+ <path
+ android:fillColor="#333A45"
+ android:pathData="M41,40 h26 v12 h-26 z" />
+
+ <!-- Eyes -->
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M41,46 m-6.5,0 a6.5,7 0 1,0 13,0 a6.5,7 0 1,0 -13,0" />
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M67,46 m-6.5,0 a6.5,7 0 1,0 13,0 a6.5,7 0 1,0 -13,0" />
+ <path
+ android:fillColor="#1B1F27"
+ android:pathData="M41,47 m-3.2,0 a3.2,3.2 0 1,0 6.4,0 a3.2,3.2 0 1,0 -6.4,0" />
+ <path
+ android:fillColor="#1B1F27"
+ android:pathData="M67,47 m-3.2,0 a3.2,3.2 0 1,0 6.4,0 a3.2,3.2 0 1,0 -6.4,0" />
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M42.4,45.2 m-1.1,0 a1.1,1.1 0 1,0 2.2,0 a1.1,1.1 0 1,0 -2.2,0" />
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M68.4,45.2 m-1.1,0 a1.1,1.1 0 1,0 2.2,0 a1.1,1.1 0 1,0 -2.2,0" />
+
+ <!-- Muzzle -->
+ <path
+ android:fillColor="#E9EDF1"
+ android:pathData="M54,64 m-14,0 a14,10.5 0 1,0 28,0 a14,10.5 0 1,0 -28,0" />
+ <path
+ android:fillColor="#1B1F27"
+ android:pathData="M54,59 m-5,0 a5,3.8 0 1,0 10,0 a5,3.8 0 1,0 -10,0" />
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="#1B1F27"
+ android:strokeLineCap="round"
+ android:strokeWidth="1.6"
+ android:pathData="M54,62.5 q0,3.5 -4.5,3.5 M54,62.5 q0,3.5 4.5,3.5" />
+
+ <!-- Microphone badge -->
+ <path
+ android:fillColor="#0E1116"
+ android:pathData="M68,68 m-13,0 a13,13 0 1,0 26,0 a13,13 0 1,0 -26,0" />
+ <path
+ android:fillColor="#E53935"
+ android:pathData="M68,68 m-11.5,0 a11.5,11.5 0 1,0 23,0 a11.5,11.5 0 1,0 -23,0" />
+ <path
+ android:fillColor="#FFFFFF"
+ android:pathData="M68,60 a3.5,3.5 0 0 1 3.5,3.5 v5 a3.5,3.5 0 0 1 -7,0 v-5 a3.5,3.5 0 0 1 3.5,-3.5 z" />
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="#FFFFFF"
+ android:strokeLineCap="round"
+ android:strokeWidth="1.8"
+ android:pathData="M62.5,66 v2.5 a5.5,5.5 0 0 0 11,0 v-2.5" />
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="#FFFFFF"
+ android:strokeLineCap="round"
+ android:strokeWidth="1.8"
+ android:pathData="M68,73.5 v1.5" />
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="#FFFFFF"
+ android:strokeLineCap="round"
+ android:strokeWidth="1.8"
+ android:pathData="M65.5,75 h5" />
+</vector>
diff --git a/app/src/main/res/drawable/ic_rec_dot.xml b/app/src/main/res/drawable/ic_rec_dot.xml
new file mode 100644
index 0000000..dd16b26
--- /dev/null
+++ b/app/src/main/res/drawable/ic_rec_dot.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="14dp"
+ android:height="14dp"
+ android:viewportWidth="14"
+ android:viewportHeight="14">
+ <path
+ android:fillColor="@color/accent"
+ android:pathData="M7,7 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0" />
+</vector>
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..a281759
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,116 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/background"
+ android:paddingLeft="24dp"
+ android:paddingRight="24dp">
+
+ <TextView
+ android:id="@+id/titleText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="28dp"
+ android:letterSpacing="0.04"
+ android:text="@string/app_name"
+ android:textColor="@color/text_primary"
+ android:textSize="34sp"
+ android:textStyle="bold"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <TextView
+ android:id="@+id/libraryButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="18dp"
+ android:layout_marginEnd="4dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:padding="8dp"
+ android:text="@string/library"
+ android:textColor="@color/text_secondary"
+ android:textSize="14sp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <TextView
+ android:id="@+id/subtitleText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:text="@string/subtitle"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/titleText" />
+
+ <ImageView
+ android:id="@+id/recIndicator"
+ android:layout_width="14dp"
+ android:layout_height="14dp"
+ android:layout_marginBottom="10dp"
+ android:contentDescription="@null"
+ android:src="@drawable/ic_rec_dot"
+ android:visibility="invisible"
+ app:layout_constraintBottom_toTopOf="@id/timerText"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <TextView
+ android:id="@+id/timerText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="20dp"
+ android:fontFamily="monospace"
+ android:text="@string/timer_zero"
+ android:textColor="@color/text_primary"
+ android:textSize="56sp"
+ android:textStyle="bold"
+ app:layout_constraintBottom_toTopOf="@id/recordButton"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+ <ImageButton
+ android:id="@+id/recordButton"
+ android:layout_width="200dp"
+ android:layout_height="200dp"
+ android:background="@drawable/bg_record_idle"
+ android:contentDescription="@string/cd_record"
+ android:scaleType="centerInside"
+ android:src="@android:color/transparent"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent" />
+
+ <TextView
+ android:id="@+id/statusText"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="28dp"
+ android:gravity="center"
+ android:text="@string/status_idle"
+ android:textColor="@color/text_secondary"
+ android:textSize="15sp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/recordButton" />
+
+ <TextView
+ android:id="@+id/footerText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginBottom="12dp"
+ android:alpha="0.6"
+ android:text="@string/footer"
+ android:textColor="@color/text_secondary"
+ android:textSize="12sp"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml
new file mode 100644
index 0000000..479efa9
--- /dev/null
+++ b/app/src/main/res/layout/activity_player.xml
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/background"
+ android:fillViewport="true">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:paddingLeft="20dp"
+ android:paddingTop="20dp"
+ android:paddingRight="20dp"
+ android:paddingBottom="28dp">
+
+ <TextView
+ android:id="@+id/titleText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textColor="@color/text_primary"
+ android:textSize="20sp"
+ android:textStyle="bold" />
+
+ <TextView
+ android:id="@+id/timeText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="4dp"
+ android:fontFamily="monospace"
+ android:textColor="@color/text_secondary"
+ android:textSize="14sp" />
+
+ <SeekBar
+ android:id="@+id/seekBar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:gravity="center"
+ android:orientation="horizontal">
+
+ <Button
+ android:id="@+id/rewindButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/player_rewind" />
+
+ <Button
+ android:id="@+id/playButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="10dp"
+ android:layout_marginRight="10dp"
+ android:minWidth="72dp"
+ android:text="@string/player_play"
+ android:textSize="18sp" />
+
+ <Button
+ android:id="@+id/forwardButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/player_forward" />
+
+ <Button
+ android:id="@+id/speedButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="10dp"
+ android:text="@string/player_speed_1x" />
+ </LinearLayout>
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginTop="18dp"
+ android:layout_marginBottom="16dp"
+ android:background="@color/divider" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/transcribe_title"
+ android:textColor="@color/text_primary"
+ android:textSize="18sp"
+ android:textStyle="bold" />
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:orientation="horizontal">
+
+ <Spinner
+ android:id="@+id/languageSpinner"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1" />
+
+ <Spinner
+ android:id="@+id/modelSpinner"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="10dp"
+ android:layout_weight="1" />
+ </LinearLayout>
+
+ <Button
+ android:id="@+id/transcribeButton"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="10dp"
+ android:text="@string/transcribe_action" />
+
+ <ProgressBar
+ android:id="@+id/progressBar"
+ style="?android:attr/progressBarStyleHorizontal"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="10dp"
+ android:max="1000"
+ android:visibility="gone" />
+
+ <TextView
+ android:id="@+id/statusText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp" />
+
+ <LinearLayout
+ android:id="@+id/transcriptContainer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:orientation="vertical" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/app/src/main/res/layout/activity_recordings.xml b/app/src/main/res/layout/activity_recordings.xml
new file mode 100644
index 0000000..c0ad20a
--- /dev/null
+++ b/app/src/main/res/layout/activity_recordings.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:background="@color/background"
+ android:orientation="vertical"
+ android:paddingLeft="20dp"
+ android:paddingRight="20dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="24dp"
+ android:text="@string/recordings_title"
+ android:textColor="@color/text_primary"
+ android:textSize="26sp"
+ android:textStyle="bold" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:text="@string/recordings_subtitle"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp" />
+
+ <FrameLayout
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_marginTop="12dp"
+ android:layout_weight="1">
+
+ <ListView
+ android:id="@+id/recordingsList"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:divider="@color/divider"
+ android:dividerHeight="1dp"
+ android:scrollbars="none" />
+
+ <TextView
+ android:id="@+id/emptyText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:gravity="center"
+ android:text="@string/recordings_empty"
+ android:textColor="@color/text_secondary"
+ android:textSize="15sp"
+ android:visibility="gone" />
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml
new file mode 100644
index 0000000..0065268
--- /dev/null
+++ b/app/src/main/res/layout/item_recording.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ android:paddingTop="14dp"
+ android:paddingBottom="14dp">
+
+ <LinearLayout
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:orientation="vertical">
+
+ <TextView
+ android:id="@+id/nameText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:textColor="@color/text_primary"
+ android:textSize="16sp" />
+
+ <TextView
+ android:id="@+id/metaText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp" />
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/transcriptBadge"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="12dp"
+ android:background="@drawable/badge_transcript"
+ android:paddingLeft="8dp"
+ android:paddingTop="3dp"
+ android:paddingRight="8dp"
+ android:paddingBottom="3dp"
+ android:text="@string/badge_transcript"
+ android:textColor="@color/badge_text"
+ android:textSize="11sp"
+ android:visibility="gone" />
+
+</LinearLayout>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?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" />
+</adaptive-icon>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..6b78462
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?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" />
+</adaptive-icon>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..2eb3eeb
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="background">#0E1116</color>
+ <color name="text_primary">#F5F7FA</color>
+ <color name="text_secondary">#93A0B0</color>
+ <color name="accent">#E53935</color>
+ <color name="divider">#22FFFFFF</color>
+ <color name="badge_text">#FFFFFF</color>
+ <color name="ic_launcher_background">#1B2430</color>
+</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..3776f4c
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,54 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">RECCoon</string>
+ <string name="subtitle">by wuhei · v1.1.0-alpha</string>
+ <string name="timer_zero">00:00</string>
+ <string name="footer">44.1 kHz · 16-bit · mono PCM (WAV)</string>
+
+ <string name="status_idle">Tap the button to start recording</string>
+ <string name="status_recording">Recording… tap again to stop and save</string>
+ <string name="status_saving">Saving to Downloads…</string>
+
+ <string name="cd_record">Start recording</string>
+ <string name="cd_stop">Stop and save</string>
+
+ <string name="err_permission">Microphone permission is required to record</string>
+ <string name="err_record">Could not start recording</string>
+ <string name="err_save">Could not save the file</string>
+ <string name="saved_fmt">Saved %1$s</string>
+
+ <string name="library">Recordings</string>
+ <string name="recordings_title">Recordings</string>
+ <string name="recordings_subtitle">Saved in Downloads · reccoon-*.wav</string>
+ <string name="recordings_empty">No recordings yet.\nTap the record button to make one.</string>
+ <string name="badge_transcript">TXT</string>
+
+ <string name="player_play">▶</string>
+ <string name="player_pause">❚❚</string>
+ <string name="player_rewind">-10s</string>
+ <string name="player_forward">+10s</string>
+ <string name="player_speed_1x">1.0×</string>
+
+ <string name="transcribe_title">Transcription (on-device)</string>
+ <string name="transcribe_action">Transcribe</string>
+ <string name="transcribe_hint">Choose a language and model, then tap Transcribe.</string>
+ <string name="lang_auto">Auto detect</string>
+ <string name="lang_en">English</string>
+ <string name="lang_it">Italiano</string>
+ <string name="model_tiny">Tiny (fast)</string>
+ <string name="model_base">Base (better)</string>
+
+ <string name="model_download_title">Download model</string>
+ <string name="model_download_message">The %1$s multilingual Whisper model is required (about %2$s). Download it now over Wi-Fi?</string>
+ <string name="download">Download</string>
+ <string name="cancel">Cancel</string>
+ <string name="status_model_ready">Model ready</string>
+ <string name="status_transcribing">Transcribing…</string>
+ <string name="status_no_speech">No speech was recognized</string>
+ <string name="status_transcript_saved">Transcript saved</string>
+ <string name="err_download">Download failed: %1$s</string>
+ <string name="err_transcribe">Transcription failed: %1$s</string>
+ <string name="err_playback">Could not play this file</string>
+ <string name="status_downloading">Downloading %1$s… %2$d%%</string>
+ <string name="status_preparing">Preparing model…</string>
+</resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..1cc7d6d
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.Reccoon" parent="Theme.AppCompat.NoActionBar">
+ <item name="android:windowBackground">@color/background</item>
+ <item name="android:statusBarColor">@color/background</item>
+ <item name="android:navigationBarColor">@color/background</item>
+ <item name="android:windowLightStatusBar">false</item>
+ <item name="android:windowLightNavigationBar">false</item>
+ <item name="colorPrimary">@color/accent</item>
+ <item name="colorPrimaryDark">@color/background</item>
+ <item name="colorAccent">@color/accent</item>
+ </style>
+</resources>
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..1216438
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "9.3.2" apply false
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..ad2ea60
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.caching=true
+
+android.useAndroidX=true
+android.nonTransitiveRClass=true
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..eddabd2
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ad7845b
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/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
+#
+
+##############################################################################
+#
+# gradlew 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 gradlew
+#
+# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/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
+
+
+
+# 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" )
+
+ 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" \
+ -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" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..8508ef6
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@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 gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+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
+
+"%COMSPEC%" /c exit 1
+
+: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
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..8720732
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,30 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx\\..*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx\\..*")
+ }
+ }
+ mavenCentral()
+ }
+}
+
+rootProject.name = "RECCoon"
+include(":app")