diff options
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. @@ -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 Binary files differnew file mode 100644 index 0000000..eddabd2 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar 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 @@ -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") |
