summaryrefslogtreecommitdiff
path: root/app/src/main/java/com/wuhei
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/com/wuhei')
-rw-r--r--app/src/main/java/com/wuhei/reccoon/Formats.java67
-rw-r--r--app/src/main/java/com/wuhei/reccoon/InsetsHelper.java33
-rw-r--r--app/src/main/java/com/wuhei/reccoon/LevelMeterView.java160
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MainActivity.java522
-rw-r--r--app/src/main/java/com/wuhei/reccoon/Marker.java19
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MarkerStore.java110
-rw-r--r--app/src/main/java/com/wuhei/reccoon/ModelRepository.java206
-rw-r--r--app/src/main/java/com/wuhei/reccoon/PlayerActivity.java628
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingActions.java149
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingService.java68
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingStore.java108
-rw-r--r--app/src/main/java/com/wuhei/reccoon/StreamingResampler.java57
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptStore.java129
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java418
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WavMarkers.java121
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WavRecorder.java407
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WaveformView.java94
17 files changed, 0 insertions, 3296 deletions
diff --git a/app/src/main/java/com/wuhei/reccoon/Formats.java b/app/src/main/java/com/wuhei/reccoon/Formats.java
deleted file mode 100644
index 37e0909..0000000
--- a/app/src/main/java/com/wuhei/reccoon/Formats.java
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.wuhei.reccoon;
-
-import java.util.Locale;
-
-/** Small formatting helpers shared by the activities. */
-public final class Formats {
-
- private Formats() {
- }
-
- /** Turns {@code reccoon-2026-09-24-153000-00001.wav} into a readable label. */
- public static String prettyName(String fileName) {
- if (fileName == null) {
- return "";
- }
- 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;
- }
-
- public static String duration(long millis) {
- long totalSeconds = Math.max(0, 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);
- }
-
- public static String size(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);
- }
-
- public 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);
- }
-
- /** mm:ss or h:mm:ss from milliseconds. */
- public static String clock(int millis) {
- return duration(millis);
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java b/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java
deleted file mode 100644
index 660fa64..0000000
--- a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.view.View;
-
-import androidx.core.graphics.Insets;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
-
-/**
- * Adds the system bar insets to a root view's padding so content is not hidden
- * behind the status bar / notch or the navigation bar (edge-to-edge safe).
- */
-public final class InsetsHelper {
-
- private InsetsHelper() {
- }
-
- public static void apply(View root) {
- if (root == null) {
- return;
- }
- final int left = root.getPaddingLeft();
- final int top = root.getPaddingTop();
- final int right = root.getPaddingRight();
- final int bottom = root.getPaddingBottom();
- ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> {
- Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
- view.setPadding(left + insets.left, top + insets.top,
- right + insets.right, bottom + insets.bottom);
- return windowInsets;
- });
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java b/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java
deleted file mode 100644
index d4b13e3..0000000
--- a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java
+++ /dev/null
@@ -1,160 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.LinearGradient;
-import android.graphics.Paint;
-import android.graphics.RectF;
-import android.graphics.Shader;
-import android.util.AttributeSet;
-import android.view.View;
-
-import androidx.annotation.Nullable;
-
-/**
- * Two-channel (left/right) peak level meter with a decaying peak hold and a
- * clip indicator. Levels are supplied as 0..1 linear amplitudes and mapped to
- * a dBFS scale.
- */
-public class LevelMeterView extends View {
-
- private static final float MIN_DB = -60f;
- private static final long CLIP_HOLD_MS = 900L;
-
- private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint peakPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-
- private final RectF barRect = new RectF();
-
- private Shader gradient;
-
- private float leftTarget;
- private float rightTarget;
- private float leftLevel;
- private float rightLevel;
- private float leftPeak;
- private float rightPeak;
- private long clipUntil;
-
- public LevelMeterView(Context context) {
- this(context, null);
- }
-
- public LevelMeterView(Context context, @Nullable AttributeSet attrs) {
- super(context, attrs);
- float density = getResources().getDisplayMetrics().density;
- backgroundPaint.setColor(0x22FFFFFF);
- peakPaint.setColor(0xFFFFFFFF);
- labelPaint.setColor(0xFF93A0B0);
- labelPaint.setTextSize(11f * density);
- clipPaint.setColor(0xFFE53935);
- clipPaint.setTextSize(11f * density);
- clipPaint.setFakeBoldText(true);
- clipPaint.setTextAlign(Paint.Align.RIGHT);
- }
-
- /** Feed one measurement window. Amplitudes are 0..1. */
- public void setLevels(float leftPeak, float rightPeak, boolean clipped) {
- this.leftTarget = clamp(leftPeak);
- this.rightTarget = clamp(rightPeak);
- if (clipped) {
- clipUntil = System.currentTimeMillis() + CLIP_HOLD_MS;
- }
- invalidate();
- }
-
- public void reset() {
- leftTarget = rightTarget = leftLevel = rightLevel = 0f;
- leftPeak = rightPeak = 0f;
- clipUntil = 0L;
- invalidate();
- }
-
- @Override
- protected void onSizeChanged(int w, int h, int oldw, int oldh) {
- super.onSizeChanged(w, h, oldw, oldh);
- gradient = new LinearGradient(0, 0, w, 0,
- new int[]{0xFF3DDC84, 0xFF9CCC65, 0xFFFFD54F, 0xFFFF5252},
- new float[]{0f, 0.55f, 0.8f, 1f},
- Shader.TileMode.CLAMP);
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- leftLevel = approach(leftLevel, leftTarget);
- rightLevel = approach(rightLevel, rightTarget);
- leftPeak = Math.max(leftTarget, leftPeak * 0.92f);
- rightPeak = Math.max(rightTarget, rightPeak * 0.92f);
-
- float density = getResources().getDisplayMetrics().density;
- float gap = 6f * density;
- float barHeight = (getHeight() - gap) / 2f;
- float labelWidth = labelPaint.measureText("L") + 10f * density;
- float rightEdge = getWidth() - clipPaint.measureText("CLIP") - 10f * density;
-
- drawBar(canvas, 0f, labelWidth, rightEdge - labelWidth, barHeight,
- leftLevel, leftPeak, "L");
- drawBar(canvas, barHeight + gap, labelWidth, rightEdge - labelWidth, barHeight,
- rightLevel, rightPeak, "R");
-
- if (System.currentTimeMillis() < clipUntil) {
- float baseline = getHeight() / 2f - (clipPaint.descent() + clipPaint.ascent()) / 2f;
- canvas.drawText("CLIP", getWidth(), baseline, clipPaint);
- }
-
- // Keep animating while there is something to decay.
- if (leftLevel > 0.002f || rightLevel > 0.002f
- || leftPeak > 0.002f || rightPeak > 0.002f) {
- postInvalidateOnAnimation();
- }
- }
-
- private void drawBar(Canvas canvas, float top, float left, float width, float height,
- float level, float peak, String label) {
- float density = getResources().getDisplayMetrics().density;
- float labelBaseline = top + height / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f;
- canvas.drawText(label, 0f, labelBaseline, labelPaint);
-
- barRect.set(left, top, left + width, top + height);
- canvas.drawRoundRect(barRect, height / 2f, height / 2f, backgroundPaint);
-
- float filled = width * toPosition(level);
- if (filled > 0f) {
- barPaint.setShader(gradient);
- barRect.set(left, top, left + filled, top + height);
- canvas.drawRoundRect(barRect, height / 2f, height / 2f, barPaint);
- barPaint.setShader(null);
- }
-
- float peakX = left + width * toPosition(peak);
- if (peakX > left + 1f) {
- canvas.drawRect(peakX - 1.5f * density, top, peakX, top + height, peakPaint);
- }
- }
-
- /** Maps a linear amplitude to a 0..1 bar position on a dBFS scale. */
- private static float toPosition(float amplitude) {
- if (amplitude <= 0f) {
- return 0f;
- }
- double db = 20.0 * Math.log10(amplitude);
- if (db < MIN_DB) {
- return 0f;
- }
- return clamp((float) ((db - MIN_DB) / -MIN_DB));
- }
-
- private static float approach(float current, float target) {
- if (target >= current) {
- return target;
- }
- return current + (target - current) * 0.25f;
- }
-
- private static float clamp(float value) {
- return Math.max(0f, Math.min(1f, value));
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java
deleted file mode 100644
index d11726f..0000000
--- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java
+++ /dev/null
@@ -1,522 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.Manifest;
-import android.animation.ObjectAnimator;
-import android.app.NotificationManager;
-import android.content.ContentResolver;
-import android.content.ContentValues;
-import android.content.Context;
-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.provider.Settings;
-import android.text.InputType;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.WindowManager;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-import android.widget.ImageButton;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.TextView;
-import android.widget.Toast;
-
-import androidx.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.widget.AppCompatCheckBox;
-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.Collections;
-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 static final String PREF_SILENCE = "silence_notifications";
- private static final String PREF_MONO = "mono_recording";
-
- private ImageButton recordButton;
- private ImageView recIndicator;
- private TextView timerText;
- private TextView statusText;
- private LevelMeterView levelMeter;
- private WaveformView waveformView;
- private AppCompatCheckBox silenceCheckbox;
- private AppCompatCheckBox monoCheckbox;
- private Button markButton;
- private LinearLayout recordingsContainer;
- private TextView emptyText;
- private int savedInterruptionFilter = -1;
-
- private WavRecorder recorder;
- private RecordingStore recordingStore;
- private TranscriptStore transcriptStore;
- 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);
- levelMeter = findViewById(R.id.levelMeter);
- waveformView = findViewById(R.id.waveformView);
- silenceCheckbox = findViewById(R.id.silenceCheckbox);
- monoCheckbox = findViewById(R.id.monoCheckbox);
- markButton = findViewById(R.id.markButton);
- recordingsContainer = findViewById(R.id.recordingsContainer);
- emptyText = findViewById(R.id.emptyText);
-
- recordingStore = new RecordingStore(this);
- transcriptStore = new TranscriptStore(this);
-
- recorder = new WavRecorder(this);
- recorder.setLevelListener((leftPeak, rightPeak, clipped, waveform) ->
- runOnUiThread(() -> {
- if (recording) {
- levelMeter.setLevels(leftPeak, rightPeak, clipped);
- waveformView.append(waveform);
- }
- }));
-
- setUpSilence();
- setUpMono();
- markButton.setOnClickListener(v -> addMarker());
- InsetsHelper.apply(findViewById(R.id.root));
-
- blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f);
- blinkAnimator.setDuration(600);
- blinkAnimator.setRepeatCount(ObjectAnimator.INFINITE);
- blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE);
-
- recordButton.setOnClickListener(v -> onRecordButtonClicked());
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- renderRecordings();
- }
-
- private void renderRecordings() {
- recordingsContainer.removeAllViews();
- List<RecordingStore.Recording> recordings = recordingStore.list();
- emptyText.setVisibility(recordings.isEmpty() ? View.VISIBLE : View.GONE);
-
- LayoutInflater inflater = LayoutInflater.from(this);
- for (RecordingStore.Recording recording : recordings) {
- View row = inflater.inflate(R.layout.item_recording, recordingsContainer, false);
- TextView name = row.findViewById(R.id.nameText);
- TextView meta = row.findViewById(R.id.metaText);
- TextView badge = row.findViewById(R.id.transcriptBadge);
-
- name.setText(Formats.prettyName(recording.name));
- meta.setText(Formats.duration(recording.durationMs)
- + " · " + Formats.size(recording.sizeBytes));
- badge.setVisibility(transcriptStore.has(recording.name) ? View.VISIBLE : View.GONE);
- row.setOnClickListener(v -> openRecording(recording));
- recordingsContainer.addView(row);
- }
- }
-
- private void openRecording(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);
- }
-
- @Override
- protected void onDestroy() {
- handler.removeCallbacksAndMessages(null);
- if (blinkAnimator != null) {
- blinkAnimator.cancel();
- }
- applySilence(false);
- 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 setUpSilence() {
- SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
- silenceCheckbox.setChecked(prefs.getBoolean(PREF_SILENCE, false));
- silenceCheckbox.setOnCheckedChangeListener((button, checked) -> {
- getSharedPreferences(PREFS, MODE_PRIVATE)
- .edit().putBoolean(PREF_SILENCE, checked).apply();
- if (checked) {
- ensureDndAccess();
- }
- });
- }
-
- private void setUpMono() {
- SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
- boolean mono = prefs.getBoolean(PREF_MONO, false);
- monoCheckbox.setChecked(mono);
- recorder.setForceMono(mono);
- monoCheckbox.setOnCheckedChangeListener((button, checked) -> {
- getSharedPreferences(PREFS, MODE_PRIVATE)
- .edit().putBoolean(PREF_MONO, checked).apply();
- recorder.setForceMono(checked);
- });
- }
-
- private void addMarker() {
- if (!recording) {
- return;
- }
- final long offset = recorder.currentSampleOffset();
- final int next = recorder.getMarkers().size() + 1;
-
- EditText input = new EditText(this);
- input.setInputType(InputType.TYPE_CLASS_TEXT);
- input.setHint(R.string.marker_hint);
- input.setText(getString(R.string.marker_fmt, next));
- input.selectAll();
-
- int padding = (int) (20 * getResources().getDisplayMetrics().density);
- FrameLayout container = new FrameLayout(this);
- container.setPadding(padding, padding / 2, padding, 0);
- container.addView(input);
-
- new AlertDialog.Builder(this)
- .setTitle(R.string.marker_title)
- .setView(container)
- .setPositiveButton(R.string.marker_add, (dialog, which) -> {
- String label = input.getText().toString().trim();
- if (label.isEmpty()) {
- label = getString(R.string.marker_fmt, next);
- }
- recorder.addMarkerAt(offset, label);
- Toast.makeText(this, getString(R.string.marker_added_fmt, next),
- Toast.LENGTH_SHORT).show();
- })
- .setNegativeButton(R.string.cancel, null)
- .show();
- }
-
- private void ensureDndAccess() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- return;
- }
- NotificationManager manager =
- (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (manager == null || manager.isNotificationPolicyAccessGranted()) {
- return;
- }
- new AlertDialog.Builder(this)
- .setTitle(R.string.dnd_title)
- .setMessage(R.string.dnd_message)
- .setPositiveButton(R.string.dnd_open_settings, (dialog, which) -> {
- try {
- startActivity(new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS));
- } catch (Exception ignored) {
- // Ignore.
- }
- })
- .setNegativeButton(R.string.cancel,
- (dialog, which) -> silenceCheckbox.setChecked(false))
- .show();
- }
-
- /** Enables total-silence Do Not Disturb while recording, then restores it. */
- private void applySilence(boolean enable) {
- if (silenceCheckbox == null || !silenceCheckbox.isChecked()) {
- return;
- }
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
- return;
- }
- NotificationManager manager =
- (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (manager == null || !manager.isNotificationPolicyAccessGranted()) {
- return;
- }
- try {
- if (enable) {
- if (savedInterruptionFilter == -1) {
- savedInterruptionFilter = manager.getCurrentInterruptionFilter();
- }
- manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE);
- } else if (savedInterruptionFilter != -1) {
- manager.setInterruptionFilter(savedInterruptionFilter);
- savedInterruptionFilter = -1;
- }
- } catch (Exception ignored) {
- // Ignore.
- }
- }
-
- private void startRecordingService() {
- try {
- ContextCompat.startForegroundService(this,
- new Intent(this, RecordingService.class)
- .setAction(RecordingService.ACTION_START));
- } catch (Exception ignored) {
- // Recording still works while the app is in the foreground.
- }
- }
-
- private void stopRecordingService() {
- try {
- stopService(new Intent(this, RecordingService.class));
- } catch (Exception ignored) {
- // Ignore.
- }
- }
-
- private void startRecording() {
- recorder.setForceMono(monoCheckbox.isChecked());
- try {
- recorder.start();
- } catch (Exception e) {
- Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show();
- return;
- }
-
- recording = true;
- startedAtMs = SystemClock.elapsedRealtime();
-
- startRecordingService();
- levelMeter.reset();
- waveformView.reset();
- markButton.setEnabled(true);
- applySilence(true);
- 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;
-
- stopRecordingService();
- levelMeter.reset();
- waveformView.reset();
- markButton.setEnabled(false);
- applySilence(false);
- 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;
- List<Marker> markers;
- try {
- wav = recorder.stop();
- markers = recorder.getMarkers();
- } catch (Exception e) {
- wav = null;
- markers = Collections.emptyList();
- }
-
- String savedName = null;
- if (wav != null) {
- try {
- savedName = saveToDownloads(wav);
- if (savedName != null && !markers.isEmpty()) {
- new MarkerStore(this).save(savedName, markers);
- }
- } 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();
- }
- renderRecordings();
- });
- }, "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/Marker.java b/app/src/main/java/com/wuhei/reccoon/Marker.java
deleted file mode 100644
index d4a7ef5..0000000
--- a/app/src/main/java/com/wuhei/reccoon/Marker.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.wuhei.reccoon;
-
-/** A user-placed marker inside a recording, stored as a WAV cue point. */
-public final class Marker {
-
- public final int index;
- public final long sampleOffset;
- public final String label;
-
- public Marker(int index, long sampleOffset, String label) {
- this.index = index;
- this.sampleOffset = sampleOffset;
- this.label = label == null ? "" : label;
- }
-
- public long timeMillis() {
- return sampleOffset * 1000L / WavRecorder.SAMPLE_RATE;
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java
deleted file mode 100644
index bde7a24..0000000
--- a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java
+++ /dev/null
@@ -1,110 +0,0 @@
-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.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Stores recording markers as JSON sidecars (the same markers are also
- * embedded in the WAV itself as {@code cue } / {@code adtl} chunks).
- */
-public final class MarkerStore {
-
- private final File dir;
-
- public MarkerStore(Context context) {
- dir = new File(context.getApplicationContext().getFilesDir(), "markers");
- }
-
- public boolean has(String recordingName) {
- return fileFor(recordingName).isFile();
- }
-
- public void save(String recordingName, List<Marker> markers) throws IOException {
- if (markers.isEmpty()) {
- return;
- }
- JSONArray array = new JSONArray();
- try {
- for (Marker marker : markers) {
- JSONObject item = new JSONObject();
- item.put("index", marker.index);
- item.put("offset", marker.sampleOffset);
- item.put("label", marker.label);
- array.put(item);
- }
- } catch (JSONException e) {
- throw new IOException(e);
- }
-
- if (!dir.exists() && !dir.mkdirs()) {
- throw new IOException("Cannot create marker directory");
- }
- String json;
- try {
- json = array.toString(2);
- } catch (JSONException e) {
- throw new IOException(e);
- }
- try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) {
- out.write(json.getBytes(StandardCharsets.UTF_8));
- }
- }
-
- public List<Marker> load(String recordingName) {
- List<Marker> markers = new ArrayList<>();
- File file = fileFor(recordingName);
- if (!file.isFile()) {
- return markers;
- }
- try (InputStream in = new java.io.FileInputStream(file)) {
- byte[] buffer = new byte[(int) file.length()];
- int read = 0;
- while (read < buffer.length) {
- int n = in.read(buffer, read, buffer.length - read);
- if (n < 0) {
- break;
- }
- read += n;
- }
- JSONArray array = new JSONArray(new String(buffer, 0, read, StandardCharsets.UTF_8));
- for (int i = 0; i < array.length(); i++) {
- JSONObject item = array.getJSONObject(i);
- markers.add(new Marker(
- item.optInt("index", i + 1),
- item.optLong("offset", 0),
- item.optString("label", "")));
- }
- } catch (Exception e) {
- // Return whatever we parsed.
- }
- return markers;
- }
-
- public void delete(String recordingName) {
- //noinspection ResultOfMethodCallIgnored
- fileFor(recordingName).delete();
- }
-
- public void rename(String oldName, String newName) {
- File from = fileFor(oldName);
- if (from.isFile()) {
- //noinspection ResultOfMethodCallIgnored
- from.renameTo(fileFor(newName));
- }
- }
-
- private File fileFor(String recordingName) {
- return new File(dir, recordingName + ".json");
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java b/app/src/main/java/com/wuhei/reccoon/ModelRepository.java
deleted file mode 100644
index cbf25c2..0000000
--- a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java
+++ /dev/null
@@ -1,206 +0,0 @@
-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
deleted file mode 100644
index eb5b8ca..0000000
--- a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
+++ /dev/null
@@ -1,628 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Intent;
-import android.graphics.Color;
-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.text.SpannableStringBuilder;
-import android.text.Spanned;
-import android.text.TextPaint;
-import android.text.method.LinkMovementMethod;
-import android.text.style.ClickableSpan;
-import android.view.View;
-import android.widget.ArrayAdapter;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-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.annotation.NonNull;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.app.AppCompatActivity;
-
-import java.io.File;
-import java.util.List;
-
-/**
- * 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 LinearLayout markersSection;
- private LinearLayout markersContainer;
-
- private final Handler handler = new Handler(Looper.getMainLooper());
- private boolean userSeeking;
- private float speed = SPEEDS[0];
- private int speedIndex;
- private volatile boolean cancelled;
-
- private ModelRepository models;
- private TranscriptStore transcripts;
- private MarkerStore markerStore;
-
- 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);
- markerStore = new MarkerStore(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);
- markersSection = findViewById(R.id.markersSection);
- markersContainer = findViewById(R.id.markersContainer);
-
- titleText.setText(Formats.prettyName(recordingName));
-
- setUpSpinners();
- setUpButtons();
- setUpPlayer();
-
- InsetsHelper.apply(findViewById(R.id.root));
- showMarkers(loadMarkers());
-
- 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));
- findViewById(R.id.shareButton).setOnClickListener(v -> shareRecording());
- findViewById(R.id.renameButton).setOnClickListener(v -> showRenameDialog());
- findViewById(R.id.exportButton).setOnClickListener(v -> exportTranscript());
- findViewById(R.id.deleteButton).setOnClickListener(v -> confirmDelete());
- transcribeButton.setOnClickListener(v -> onTranscribeClicked());
-
- seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) {
- if (fromUser) {
- timeText.setText(Formats.clock(progress) + " / " + Formats.clock(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();
- prepared = false;
- try {
- player.setDataSource(this, recordingUri);
- player.setOnPreparedListener(mp -> {
- prepared = true;
- seekBar.setMax(mp.getDuration());
- updateTimeLabel();
- updatePlayButton(); // no autoplay
- });
- 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 = Math.max(0, Math.min(player.getCurrentPosition() + deltaMs, player.getDuration()));
- player.seekTo(target);
- seekBar.setProgress(target);
- updateTimeLabel();
- }
-
- private void cycleSpeed() {
- speedIndex = (speedIndex + 1) % SPEEDS.length;
- speed = SPEEDS[speedIndex];
- speedButton.setText(String.format(java.util.Locale.US, "%.1fĂ—", speed));
- applySpeed();
- }
-
- private void applySpeed() {
- if (player == null || !prepared) {
- return;
- }
- try {
- PlaybackParams params = player.getPlaybackParams();
- params.setSpeed(speed);
- 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(Formats.clock(player != null && prepared ? player.getCurrentPosition() : 0)
- + " / " + Formats.clock(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);
- }
-
- // --- Library actions --------------------------------------------------
-
- private List<Marker> loadMarkers() {
- List<Marker> markers = markerStore.load(recordingName);
- if (markers.isEmpty()) {
- markers = WavMarkers.read(this, recordingUri);
- }
- return markers;
- }
-
- private void shareRecording() {
- try {
- Uri shareUri = RecordingActions.shareableUri(this, recordingUri);
- Intent intent = new Intent(Intent.ACTION_SEND);
- intent.setType("audio/wav");
- intent.putExtra(Intent.EXTRA_STREAM, shareUri);
- intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
- startActivity(Intent.createChooser(intent, getString(R.string.action_share)));
- } catch (Exception e) {
- Toast.makeText(this, R.string.err_share, Toast.LENGTH_LONG).show();
- }
- }
-
- private void confirmDelete() {
- new AlertDialog.Builder(this)
- .setTitle(R.string.delete_title)
- .setMessage(getString(R.string.delete_message, Formats.prettyName(recordingName)))
- .setPositiveButton(R.string.action_delete, (dialog, which) -> {
- RecordingActions.delete(this, recordingUri, recordingName);
- Toast.makeText(this, R.string.deleted, Toast.LENGTH_SHORT).show();
- finish();
- })
- .setNegativeButton(R.string.cancel, null)
- .show();
- }
-
- private void showRenameDialog() {
- EditText input = new EditText(this);
- input.setText(recordingName);
- input.selectAll();
-
- int padding = (int) (20 * getResources().getDisplayMetrics().density);
- FrameLayout container = new FrameLayout(this);
- container.setPadding(padding, padding / 2, padding, 0);
- container.addView(input);
-
- new AlertDialog.Builder(this)
- .setTitle(R.string.rename_title)
- .setView(container)
- .setPositiveButton(R.string.action_rename, (dialog, which) -> {
- String requested = input.getText().toString().trim();
- if (!requested.endsWith(".wav")) {
- requested = requested + ".wav";
- }
- String renamed = RecordingActions.rename(
- this, recordingUri, recordingName, requested);
- if (renamed == null) {
- Toast.makeText(this, R.string.rename_failed, Toast.LENGTH_LONG).show();
- return;
- }
- if ("file".equals(recordingUri.getScheme())) {
- File parent = new File(recordingUri.getPath()).getParentFile();
- recordingUri = Uri.fromFile(new File(parent, renamed));
- }
- recordingName = renamed;
- titleText.setText(Formats.prettyName(recordingName));
- showMarkers(loadMarkers());
- Toast.makeText(this, R.string.renamed, Toast.LENGTH_SHORT).show();
- })
- .setNegativeButton(R.string.cancel, null)
- .show();
- }
-
- private void exportTranscript() {
- TranscriptionEngine.Result result = transcripts.load(recordingName);
- if (result == null || result.segments.isEmpty()) {
- Toast.makeText(this, R.string.export_none, Toast.LENGTH_LONG).show();
- return;
- }
- try {
- RecordingActions.exportTranscript(this, recordingName, result);
- Toast.makeText(this, R.string.exported, Toast.LENGTH_LONG).show();
- } catch (Exception e) {
- Toast.makeText(this, getString(R.string.export_failed, String.valueOf(e.getMessage())),
- Toast.LENGTH_LONG).show();
- }
- }
-
- // --- 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, Formats.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(segment);
- }
- statusText.setText(R.string.status_transcript_saved);
- }
-
- private void addTranscriptLine(TranscriptionEngine.Segment segment) {
- SpannableStringBuilder builder = new SpannableStringBuilder();
- builder.append("[").append(Formats.clock((int) (segment.startSeconds * 1000))).append("] ");
-
- if (segment.words.isEmpty()) {
- builder.append(segment.text);
- } else {
- for (TranscriptionEngine.Word word : segment.words) {
- int start = builder.length();
- builder.append(word.text);
- int end = builder.length();
- if (end > start) {
- final int seekMs = (int) (word.startSeconds * 1000);
- builder.setSpan(new ClickableSpan() {
- @Override
- public void onClick(@NonNull View widget) {
- seekTo(seekMs);
- }
-
- @Override
- public void updateDrawState(@NonNull TextPaint ds) {
- ds.setColor(getResources().getColor(R.color.text_primary, getTheme()));
- ds.setUnderlineText(false);
- }
- }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
- }
-
- TextView view = new TextView(this);
- view.setText(builder);
- view.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
- view.setTextSize(15f);
- view.setPadding(0, 12, 0, 12);
- view.setMovementMethod(LinkMovementMethod.getInstance());
- view.setHighlightColor(Color.TRANSPARENT);
- view.setOnClickListener(v -> seekTo((int) (segment.startSeconds * 1000)));
- transcriptContainer.addView(view);
- }
-
- private void showMarkers(List<Marker> markers) {
- markersContainer.removeAllViews();
- markersSection.setVisibility(markers.isEmpty() ? View.GONE : View.VISIBLE);
- float density = getResources().getDisplayMetrics().density;
- for (Marker marker : markers) {
- TextView chip = new TextView(this);
- StringBuilder text = new StringBuilder();
- text.append('M').append(marker.index)
- .append(" ").append(Formats.clock((int) marker.timeMillis()));
- if (!marker.label.isEmpty()) {
- text.append(" ").append(marker.label);
- }
- chip.setText(text.toString());
- chip.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
- chip.setTextSize(13f);
- chip.setPadding((int) (12 * density), (int) (6 * density),
- (int) (12 * density), (int) (6 * density));
- chip.setBackgroundResource(R.drawable.badge_marker);
- chip.setOnClickListener(v -> seekTo((int) marker.timeMillis()));
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT);
- params.rightMargin = (int) (8 * density);
- chip.setLayoutParams(params);
- markersContainer.addView(chip);
- }
- }
-
- private void seekTo(int millis) {
- if (player == null || !prepared) {
- return;
- }
- int target = Math.max(0, Math.min(millis, player.getDuration()));
- player.seekTo(target);
- seekBar.setProgress(target);
- if (!player.isPlaying()) {
- player.start();
- applySpeed();
- }
- updateTimeLabel();
- }
-
- 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;
- }
- }
-
- @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/RecordingActions.java b/app/src/main/java/com/wuhei/reccoon/RecordingActions.java
deleted file mode 100644
index 4131181..0000000
--- a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.ContentResolver;
-import android.content.ContentValues;
-import android.content.Context;
-import android.media.MediaScannerConnection;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Environment;
-import android.provider.MediaStore;
-
-import androidx.core.content.FileProvider;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.Locale;
-
-/** Delete / rename / share helpers for saved recordings. */
-public final class RecordingActions {
-
- private RecordingActions() {
- }
-
- public static void delete(Context context, Uri uri, String name) {
- try {
- if ("file".equals(uri.getScheme())) {
- //noinspection ResultOfMethodCallIgnored
- new File(uri.getPath()).delete();
- } else {
- context.getContentResolver().delete(uri, null, null);
- }
- } catch (Exception ignored) {
- // Keep going: also remove the sidecars.
- }
- new TranscriptStore(context).delete(name);
- new MarkerStore(context).delete(name);
- }
-
- /** @return the new file name, or null if renaming failed. */
- public static String rename(Context context, Uri uri, String oldName, String newName) {
- if (newName == null || newName.trim().isEmpty() || !newName.endsWith(".wav")) {
- return null;
- }
- newName = newName.trim();
- try {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !"file".equals(uri.getScheme())) {
- ContentValues values = new ContentValues();
- values.put(MediaStore.MediaColumns.DISPLAY_NAME, newName);
- context.getContentResolver().update(uri, values, null, null);
- } else {
- File oldFile = new File(uri.getPath());
- File newFile = new File(oldFile.getParentFile(), newName);
- if (!oldFile.renameTo(newFile)) {
- return null;
- }
- MediaScannerConnection.scanFile(context,
- new String[]{newFile.getAbsolutePath()},
- new String[]{"audio/wav"}, null);
- }
- } catch (Exception e) {
- return null;
- }
- new TranscriptStore(context).rename(oldName, newName);
- new MarkerStore(context).rename(oldName, newName);
- return newName;
- }
-
- /** A URI that can be handed to other apps (FileProvider for file:// paths). */
- public static Uri shareableUri(Context context, Uri uri) {
- if ("file".equals(uri.getScheme())) {
- File file = new File(uri.getPath());
- return FileProvider.getUriForFile(
- context, context.getPackageName() + ".fileprovider", file);
- }
- return uri;
- }
-
- public static void exportTranscript(Context context, String recordingName,
- TranscriptionEngine.Result result) throws IOException {
- if (result == null || result.segments.isEmpty()) {
- throw new IOException("Nothing to export");
- }
- String stem = recordingName.endsWith(".wav")
- ? recordingName.substring(0, recordingName.length() - 4)
- : recordingName;
- writeToDownloads(context, stem + ".txt", "text/plain", result.asText());
- writeToDownloads(context, stem + ".srt", "application/x-subrip", toSrt(result));
- }
-
- public static String toSrt(TranscriptionEngine.Result result) {
- StringBuilder sb = new StringBuilder();
- int index = 1;
- for (TranscriptionEngine.Segment segment : result.segments) {
- sb.append(index++).append('\n');
- sb.append(srtTime(segment.startSeconds)).append(" --> ")
- .append(srtTime(segment.endSeconds)).append('\n');
- sb.append(segment.text.trim()).append("\n\n");
- }
- return sb.toString();
- }
-
- private static String srtTime(float seconds) {
- int total = Math.round(Math.max(0f, seconds) * 1000f);
- int millis = total % 1000;
- int secs = (total / 1000) % 60;
- int mins = (total / 60000) % 60;
- int hours = total / 3600000;
- return String.format(Locale.US, "%02d:%02d:%02d,%03d", hours, mins, secs, millis);
- }
-
- private static void writeToDownloads(Context context, String name, String mime, String content)
- throws IOException {
- byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- ContentResolver resolver = context.getContentResolver();
- ContentValues values = new ContentValues();
- values.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
- values.put(MediaStore.MediaColumns.MIME_TYPE, mime);
- 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 " + name);
- }
- try (OutputStream out = resolver.openOutputStream(uri)) {
- if (out == null) {
- throw new IOException("No output stream for " + name);
- }
- out.write(bytes);
- }
- ContentValues done = new ContentValues();
- done.put(MediaStore.MediaColumns.IS_PENDING, 0);
- resolver.update(uri, done, null, null);
- } else {
- File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
- if (!dir.exists() && !dir.mkdirs()) {
- throw new IOException("Could not create Downloads");
- }
- File out = new File(dir, name);
- try (FileOutputStream fos = new FileOutputStream(out)) {
- fos.write(bytes);
- }
- MediaScannerConnection.scanFile(context, new String[]{out.getAbsolutePath()}, null, null);
- }
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingService.java b/app/src/main/java/com/wuhei/reccoon/RecordingService.java
deleted file mode 100644
index 830c3bb..0000000
--- a/app/src/main/java/com/wuhei/reccoon/RecordingService.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.app.Notification;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.app.Service;
-import android.content.Intent;
-import android.content.pm.ServiceInfo;
-import android.os.Build;
-import android.os.IBinder;
-
-import androidx.core.app.NotificationCompat;
-
-/**
- * Keeps the process alive (and the microphone accessible) while a recording is
- * in progress, so recording continues when the app goes to the background.
- */
-public class RecordingService extends Service {
-
- private static final String CHANNEL_ID = "reccoon_recording";
- private static final int NOTIFICATION_ID = 1;
-
- public static final String ACTION_START = "com.wuhei.reccoon.action.START";
- public static final String ACTION_STOP = "com.wuhei.reccoon.action.STOP";
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- if (intent != null && ACTION_STOP.equals(intent.getAction())) {
- stopForeground(true);
- stopSelf();
- return START_NOT_STICKY;
- }
- createChannel();
- Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.app_name))
- .setContentText(getString(R.string.notification_recording))
- .setSmallIcon(R.drawable.ic_rec_dot)
- .setOngoing(true)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build();
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- startForeground(NOTIFICATION_ID, notification,
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
- } else {
- startForeground(NOTIFICATION_ID, notification);
- }
- return START_NOT_STICKY;
- }
-
- private void createChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- NotificationManager manager = getSystemService(NotificationManager.class);
- if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) {
- NotificationChannel channel = new NotificationChannel(
- CHANNEL_ID,
- getString(R.string.notification_channel),
- NotificationManager.IMPORTANCE_LOW);
- channel.setShowBadge(false);
- manager.createNotificationChannel(channel);
- }
- }
- }
-
- @Override
- public IBinder onBind(Intent intent) {
- return null;
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java b/app/src/main/java/com/wuhei/reccoon/RecordingStore.java
deleted file mode 100644
index 12abb06..0000000
--- a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java
+++ /dev/null
@@ -1,108 +0,0 @@
-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/StreamingResampler.java b/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java
deleted file mode 100644
index beb372a..0000000
--- a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.wuhei.reccoon;
-
-import java.util.Arrays;
-
-/**
- * Linear-interpolation streaming resampler.
- *
- * <p>Keeps the fractional read position between calls so chunks can be fed
- * continuously without glitches at the boundaries.
- */
-final class StreamingResampler {
-
- private static final float[] EMPTY = new float[0];
-
- private final double ratio;
- private float[] buffer = new float[0];
- private double position;
-
- StreamingResampler(int inputRate, int outputRate) {
- if (inputRate <= 0 || outputRate <= 0) {
- throw new IllegalArgumentException("Sample rates must be positive");
- }
- this.ratio = inputRate / (double) outputRate;
- }
-
- float[] process(float[] input) {
- if (input == null || input.length == 0) {
- return EMPTY;
- }
- 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;
- }
-
- // Drop the samples we have fully passed. `position` can overshoot the
- // buffer end by up to one step, so clamp before trimming: otherwise
- // Arrays.copyOfRange throws "from > to".
- int drop = (int) position;
- if (drop > buffer.length) {
- drop = buffer.length;
- }
- 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/TranscriptStore.java b/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
deleted file mode 100644
index 7ae336e..0000000
--- a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
+++ /dev/null
@@ -1,129 +0,0 @@
-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.io.InputStream;
-import java.nio.charset.StandardCharsets;
-
-/** 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);
- JSONArray words = new JSONArray();
- for (TranscriptionEngine.Word word : segment.words) {
- JSONObject w = new JSONObject();
- w.put("text", word.text);
- w.put("start", word.startSeconds);
- w.put("end", word.endSeconds);
- words.put(w);
- }
- item.put("words", words);
- 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);
- }
- try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) {
- out.write(json.getBytes(StandardCharsets.UTF_8));
- }
- }
-
- public TranscriptionEngine.Result load(String recordingName) {
- File file = fileFor(recordingName);
- if (!file.isFile()) {
- return null;
- }
- try (InputStream in = new java.io.FileInputStream(file)) {
- byte[] buffer = new byte[(int) file.length()];
- int read = 0;
- while (read < buffer.length) {
- int n = in.read(buffer, read, buffer.length - read);
- if (n < 0) {
- break;
- }
- read += n;
- }
- JSONObject root = new JSONObject(new String(buffer, 0, read, StandardCharsets.UTF_8));
- 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);
- TranscriptionEngine.Segment segment = new TranscriptionEngine.Segment(
- (float) item.optDouble("start", 0),
- (float) item.optDouble("end", 0),
- item.optString("text", ""));
- JSONArray words = item.optJSONArray("words");
- if (words != null) {
- for (int j = 0; j < words.length(); j++) {
- JSONObject w = words.getJSONObject(j);
- segment.words.add(new TranscriptionEngine.Word(
- w.optString("text", ""),
- (float) w.optDouble("start", 0),
- (float) w.optDouble("end", 0)));
- }
- }
- result.segments.add(segment);
- }
- }
- return result;
- } catch (Exception e) {
- return null;
- }
- }
-
- public void delete(String recordingName) {
- //noinspection ResultOfMethodCallIgnored
- fileFor(recordingName).delete();
- }
-
- public void rename(String oldName, String newName) {
- File from = fileFor(oldName);
- if (from.isFile()) {
- //noinspection ResultOfMethodCallIgnored
- from.renameTo(fileFor(newName));
- }
- }
-
- private File fileFor(String recordingName) {
- return new File(dir, recordingName + ".json");
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java b/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
deleted file mode 100644
index 3579ab5..0000000
--- a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
+++ /dev/null
@@ -1,418 +0,0 @@
-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.OfflineRecognizerResult;
-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.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 Word {
- public final String text;
- public final float startSeconds;
- public final float endSeconds;
-
- public Word(String text, float startSeconds, float endSeconds) {
- this.text = text;
- this.startSeconds = startSeconds;
- this.endSeconds = endSeconds;
- }
- }
-
- public static final class Segment {
- public final float startSeconds;
- public final float endSeconds;
- public final String text;
- public final List<Word> words = new ArrayList<>();
-
- 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);
- OfflineRecognizerResult decoded = recognizer.getResult(stream);
- String text = decoded.getText().trim();
- if (!text.isEmpty()) {
- float start = speech.getStart() / (float) TARGET_RATE;
- float end = start + samples.length / (float) TARGET_RATE;
- Segment segment = new Segment(start, end, text);
- buildWords(segment, decoded.getTokens(), decoded.getTimestamps(),
- decoded.getDurations());
- result.segments.add(segment);
- }
- } finally {
- stream.release();
- }
- }
- }
-
- /**
- * Fills in word timings. Uses token timestamps when the model provides
- * them, otherwise distributes the words across the segment by length.
- */
- private static void buildWords(Segment segment, String[] tokens,
- float[] timestamps, float[] durations) {
- if (tokens == null || tokens.length == 0) {
- return;
- }
- boolean haveTimestamps = timestamps != null && timestamps.length >= tokens.length;
- if (haveTimestamps) {
- for (int i = 0; i < tokens.length; i++) {
- if (tokens[i] == null || tokens[i].trim().isEmpty()) {
- continue;
- }
- float start = segment.startSeconds + timestamps[i];
- float end = durations != null && i < durations.length && durations[i] > 0f
- ? start + durations[i] : start + 0.2f;
- segment.words.add(new Word(tokens[i], start, end));
- }
- return;
- }
- int total = 0;
- for (String token : tokens) {
- if (token != null && !token.trim().isEmpty()) {
- total += token.trim().length();
- }
- }
- if (total <= 0) {
- return;
- }
- float span = Math.max(0.001f, segment.endSeconds - segment.startSeconds);
- int consumed = 0;
- for (String token : tokens) {
- if (token == null || token.trim().isEmpty()) {
- continue;
- }
- int weight = token.trim().length();
- float start = segment.startSeconds + span * consumed / total;
- consumed += weight;
- float end = segment.startSeconds + span * consumed / total;
- segment.words.add(new Word(token, start, end));
- }
- }
-
- 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(true);
- 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;
- }
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java b/app/src/main/java/com/wuhei/reccoon/WavMarkers.java
deleted file mode 100644
index 1f55e96..0000000
--- a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java
+++ /dev/null
@@ -1,121 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Context;
-import android.net.Uri;
-
-import java.io.BufferedInputStream;
-import java.io.DataInputStream;
-import java.io.EOFException;
-import java.io.InputStream;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Reads {@code cue } + {@code LIST/adtl} marker chunks out of a WAV file. */
-public final class WavMarkers {
-
- private WavMarkers() {
- }
-
- public static List<Marker> read(Context context, Uri uri) {
- List<Marker> result = new ArrayList<>();
- try (InputStream raw = context.getContentResolver().openInputStream(uri)) {
- if (raw == null) {
- return result;
- }
- DataInputStream in = new DataInputStream(new BufferedInputStream(raw, 64 * 1024));
- 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') {
- return result;
- }
-
- Map<Integer, Long> offsets = new LinkedHashMap<>();
- Map<Integer, String> labels = new LinkedHashMap<>();
-
- while (true) {
- byte[] chunkHeader = new byte[8];
- try {
- in.readFully(chunkHeader);
- } catch (EOFException eof) {
- break;
- }
- String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII);
- long size = le32(chunkHeader, 4) & 0xffffffffL;
-
- if ("cue ".equals(id)) {
- byte[] body = new byte[(int) size];
- in.readFully(body);
- int count = (int) le32(body, 0);
- for (int i = 0; i < count; i++) {
- int offset = 4 + i * 24;
- int name = (int) le32(body, offset);
- long sample = le32(body, offset + 20) & 0xffffffffL;
- offsets.put(name, sample);
- }
- } else if ("LIST".equals(id)) {
- byte[] body = new byte[(int) size];
- in.readFully(body);
- if (body.length >= 4
- && "adtl".equals(new String(body, 0, 4, StandardCharsets.US_ASCII))) {
- int p = 4;
- while (p + 8 <= body.length) {
- String subId = new String(body, p, 4, StandardCharsets.US_ASCII);
- long subSize = le32(body, p + 4) & 0xffffffffL;
- if ("labl".equals(subId) && p + 8 + subSize <= body.length) {
- int name = (int) le32(body, p + 8);
- int textLength = (int) subSize - 4;
- if (textLength >= 0) {
- String text = new String(body, p + 12, textLength,
- StandardCharsets.UTF_8);
- int zero = text.indexOf('\0');
- if (zero >= 0) {
- text = text.substring(0, zero);
- }
- labels.put(name, text);
- }
- }
- p += 8 + subSize + (subSize & 1);
- }
- }
- } else {
- skipFully(in, size + (size & 1));
- }
- }
-
- int index = 1;
- for (Map.Entry<Integer, Long> entry : offsets.entrySet()) {
- String label = labels.get(entry.getKey());
- result.add(new Marker(index++, entry.getValue(), label == null ? "" : label));
- }
- } catch (Exception e) {
- // Markers are best effort.
- }
- return result;
- }
-
- 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 void skipFully(DataInputStream in, long count) throws Exception {
- long remaining = count;
- while (remaining > 0) {
- long skipped = in.skip(remaining);
- if (skipped <= 0) {
- if (in.read() < 0) {
- throw new EOFException();
- }
- skipped = 1;
- }
- remaining -= skipped;
- }
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java
deleted file mode 100644
index 8279847..0000000
--- a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java
+++ /dev/null
@@ -1,407 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Context;
-import android.media.AudioFormat;
-import android.media.AudioRecord;
-import android.media.MediaRecorder;
-import android.os.SystemClock;
-
-import java.io.ByteArrayOutputStream;
-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;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Records uncompressed 16-bit PCM audio at 44.1 kHz and writes a standard
- * RIFF/WAVE file (PCM, format code 1).
- *
- * <p>Stereo is used when the device supports it (so the left/right level
- * meters are meaningful); it falls back to mono otherwise. Peak levels and a
- * small waveform envelope for both channels are reported through
- * {@link LevelListener}.
- *
- * <p>Markers added while recording are embedded in the file as standard WAV
- * {@code cue } + {@code LIST/adtl} chunks (readable by Audacity, Reaper, ...).
- */
-public class WavRecorder {
-
- public static final int SAMPLE_RATE = 44100;
- public static final int BITS_PER_SAMPLE = 16;
-
- private static final int HEADER_SIZE = 44;
- private static final long LEVEL_INTERVAL_MS = 40;
- private static final int WAVEFORM_BUCKETS = 16;
-
- public interface LevelListener {
- /**
- * @param leftPeak 0..1 peak of the left channel in this window
- * @param rightPeak 0..1 peak of the right channel (same as left when mono)
- * @param clipped true if any sample reached full scale in this window
- * @param waveform min/max pairs (length {@code 2 * WAVEFORM_BUCKETS}) of
- * the left/mono channel for a scrolling waveform
- */
- void onLevels(float leftPeak, float rightPeak, boolean clipped, float[] waveform);
- }
-
- private final Context context;
- private final List<Marker> markers = new ArrayList<>();
-
- private LevelListener levelListener;
- private AudioRecord audioRecord;
- private Thread writerThread;
- private volatile boolean recording;
- private File tempFile;
- private volatile long pcmBytes;
- private int channels = 2;
- private boolean forceMono;
-
- public WavRecorder(Context context) {
- this.context = context.getApplicationContext();
- }
-
- public void setLevelListener(LevelListener listener) {
- this.levelListener = listener;
- }
-
- /** When true, always record a single channel (half the file size). */
- public void setForceMono(boolean forceMono) {
- this.forceMono = forceMono;
- }
-
- /** Effective number of channels used by the last/current recording. */
- public int getChannels() {
- return channels;
- }
-
- /**
- * 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");
- }
- markers.clear();
-
- audioRecord = createAudioRecord();
- if (audioRecord == null) {
- throw new IOException("No usable microphone / audio configuration");
- }
-
- tempFile = new File(context.getCacheDir(), "reccoon_recording.wav");
- writePlaceholderHeader();
-
- pcmBytes = 0L;
- recording = true;
- audioRecord.startRecording();
-
- final AudioRecord record = audioRecord;
- final int channelCount = channels;
- final int frameBytes = channelCount * 2;
- final int minBuffer = AudioRecord.getMinBufferSize(
- SAMPLE_RATE, channelConfigFor(channelCount), AudioFormat.ENCODING_PCM_16BIT);
- final int chunkSize = Math.max(minBuffer, frameBytes * 4096);
-
- writerThread = new Thread(() -> {
- byte[] buffer = new byte[chunkSize];
- long lastLevelTime = 0;
- try (FileOutputStream out = new FileOutputStream(tempFile, true)) {
- while (recording) {
- int read = record.read(buffer, 0, buffer.length);
- if (read > 0) {
- out.write(buffer, 0, read);
- pcmBytes += read;
-
- long now = SystemClock.uptimeMillis();
- if (levelListener != null && now - lastLevelTime >= LEVEL_INTERVAL_MS) {
- lastLevelTime = now;
- reportLevels(buffer, read, channelCount);
- }
- }
- }
- out.flush();
- } catch (Exception ignored) {
- // Whatever was captured before the failure stays in the file.
- }
- }, "reccoon-writer");
- writerThread.start();
-
- return tempFile;
- }
-
- private void reportLevels(byte[] buffer, int length, int channelCount) {
- int frameBytes = channelCount * 2;
- int frames = length / frameBytes;
- if (frames <= 0) {
- return;
- }
- float leftPeak = 0f;
- float rightPeak = 0f;
- boolean clipped = false;
- for (int i = 0; i < frames; i++) {
- int offset = i * frameBytes;
- short left = pcm16(buffer, offset);
- float l = Math.abs((int) left) / 32768f;
- if (l > leftPeak) {
- leftPeak = l;
- }
- if (Math.abs((int) left) >= 32767) {
- clipped = true;
- }
- if (channelCount > 1) {
- short right = pcm16(buffer, offset + 2);
- float r = Math.abs((int) right) / 32768f;
- if (r > rightPeak) {
- rightPeak = r;
- }
- if (Math.abs((int) right) >= 32767) {
- clipped = true;
- }
- }
- }
- if (channelCount == 1) {
- rightPeak = leftPeak;
- }
-
- float[] waveform = buildWaveform(buffer, frames, frameBytes);
- LevelListener listener = levelListener;
- if (listener != null) {
- listener.onLevels(leftPeak, rightPeak, clipped, waveform);
- }
- }
-
- private static float[] buildWaveform(byte[] buffer, int frames, int frameBytes) {
- float[] waveform = new float[WAVEFORM_BUCKETS * 2];
- int framesPerBucket = Math.max(1, frames / WAVEFORM_BUCKETS);
- for (int bucket = 0; bucket < WAVEFORM_BUCKETS; bucket++) {
- int start = bucket * framesPerBucket;
- int end = bucket == WAVEFORM_BUCKETS - 1 ? frames : start + framesPerBucket;
- float min = 0f;
- float max = 0f;
- for (int i = start; i < end && i < frames; i++) {
- float sample = pcm16(buffer, i * frameBytes) / 32768f;
- if (sample < min) {
- min = sample;
- }
- if (sample > max) {
- max = sample;
- }
- }
- waveform[bucket * 2] = min;
- waveform[bucket * 2 + 1] = max;
- }
- return waveform;
- }
-
- /** Current position in sample frames (usable while recording). */
- public long currentSampleOffset() {
- int blockAlign = Math.max(1, channels * 2);
- return pcmBytes / blockAlign;
- }
-
- /** Adds a marker at the current position. */
- public void addMarker(String label) {
- addMarkerAt(currentSampleOffset(), label);
- }
-
- public void addMarkerAt(long sampleOffset, String label) {
- markers.add(new Marker(markers.size() + 1, sampleOffset, label));
- }
-
- public List<Marker> getMarkers() {
- return Collections.unmodifiableList(new ArrayList<>(markers));
- }
-
- /**
- * 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();
- writeMarkers();
- return tempFile;
- }
-
- public boolean isRecording() {
- return recording;
- }
-
- private AudioRecord createAudioRecord() {
- int[] candidates = forceMono
- ? new int[]{AudioFormat.CHANNEL_IN_MONO}
- : new int[]{AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO};
- for (int channelConfig : candidates) {
- int minBuffer = AudioRecord.getMinBufferSize(
- SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT);
- if (minBuffer <= 0) {
- continue;
- }
- int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1;
- int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count);
- AudioRecord candidate = new AudioRecord(
- MediaRecorder.AudioSource.MIC,
- SAMPLE_RATE,
- channelConfig,
- AudioFormat.ENCODING_PCM_16BIT,
- bufferSize);
- if (candidate.getState() == AudioRecord.STATE_INITIALIZED) {
- channels = count;
- return candidate;
- }
- candidate.release();
- }
- return null;
- }
-
- private static int channelConfigFor(int channelCount) {
- return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO;
- }
-
- private static short pcm16(byte[] data, int offset) {
- return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8));
- }
-
- 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.
- }
- }
-
- /** Appends {@code cue } and {@code LIST/adtl} chunks for the markers. */
- private void writeMarkers() {
- if (markers.isEmpty() || tempFile == null || !tempFile.isFile()) {
- return;
- }
- try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
- raf.seek(raf.length());
- raf.write(buildCueChunk(markers));
- raf.write(buildAdtlChunk(markers));
-
- long newLength = raf.length();
- raf.seek(4);
- raf.write(intLE((int) (newLength - 8)));
- } catch (IOException ignored) {
- // Markers are a bonus; the audio itself stays valid.
- }
- }
-
- private static byte[] buildCueChunk(List<Marker> markers) {
- int count = markers.size();
- ByteBuffer buffer = ByteBuffer.allocate(8 + 4 + 24 * count).order(ByteOrder.LITTLE_ENDIAN);
- buffer.put(ascii("cue "));
- buffer.putInt(4 + 24 * count);
- buffer.putInt(count);
- for (Marker marker : markers) {
- buffer.putInt(marker.index); // dwName
- buffer.putInt(0); // dwPosition
- buffer.put(ascii("data")); // fccChunk
- buffer.putInt(0); // dwChunkStart
- buffer.putInt(0); // dwBlockStart
- buffer.putInt((int) marker.sampleOffset); // dwSampleOffset
- }
- return buffer.array();
- }
-
- private static byte[] buildAdtlChunk(List<Marker> markers) {
- ByteArrayOutputStream out = new ByteArrayOutputStream();
- for (Marker marker : markers) {
- byte[] text = marker.label.getBytes(StandardCharsets.UTF_8);
- int size = 4 + text.length + 1; // cue id + text + NUL
- int padded = size + (size & 1);
- ByteBuffer buffer = ByteBuffer.allocate(8 + padded).order(ByteOrder.LITTLE_ENDIAN);
- buffer.put(ascii("labl"));
- buffer.putInt(size);
- buffer.putInt(marker.index);
- buffer.put(text);
- buffer.put((byte) 0);
- while (buffer.position() < buffer.capacity()) {
- buffer.put((byte) 0);
- }
- out.write(buffer.array(), 0, buffer.array().length);
- }
- byte[] adtl = out.toByteArray();
-
- ByteBuffer list = ByteBuffer.allocate(12 + adtl.length).order(ByteOrder.LITTLE_ENDIAN);
- list.put(ascii("LIST"));
- list.putInt(4 + adtl.length);
- list.put(ascii("adtl"));
- list.put(adtl);
- return list.array();
- }
-
- private 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[] intLE(int value) {
- return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array();
- }
-
- private static byte[] ascii(String value) {
- return value.getBytes(StandardCharsets.US_ASCII);
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/WaveformView.java b/app/src/main/java/com/wuhei/reccoon/WaveformView.java
deleted file mode 100644
index de62639..0000000
--- a/app/src/main/java/com/wuhei/reccoon/WaveformView.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.util.AttributeSet;
-import android.view.View;
-
-import androidx.annotation.Nullable;
-
-/**
- * A scrolling waveform of the left/mono channel. Columns whose peak reaches
- * full scale are drawn in red so clipping is easy to spot.
- */
-public class WaveformView extends View {
-
- private static final int CAPACITY = 600;
-
- private final float[] mins = new float[CAPACITY];
- private final float[] maxs = new float[CAPACITY];
- private final boolean[] clipped = new boolean[CAPACITY];
-
- private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
-
- private int count;
- private int writeIndex;
-
- public WaveformView(Context context) {
- this(context, null);
- }
-
- public WaveformView(Context context, @Nullable AttributeSet attrs) {
- super(context, attrs);
- float density = getResources().getDisplayMetrics().density;
- wavePaint.setColor(0xFF3DDC84);
- wavePaint.setStrokeWidth(Math.max(1f, density));
- clipPaint.setColor(0xFFE53935);
- clipPaint.setStrokeWidth(Math.max(1f, density));
- centerPaint.setColor(0x33FFFFFF);
- centerPaint.setStrokeWidth(Math.max(1f, density * 0.6f));
- backgroundPaint.setColor(0x14000000);
- }
-
- /** Appends min/max pairs ({@code [min0,max0,min1,max1,...]}) to the ring. */
- public void append(float[] waveform) {
- if (waveform == null || waveform.length < 2) {
- return;
- }
- for (int i = 0; i + 1 < waveform.length; i += 2) {
- mins[writeIndex] = waveform[i];
- maxs[writeIndex] = waveform[i + 1];
- clipped[writeIndex] = Math.abs(waveform[i]) >= 0.999f
- || Math.abs(waveform[i + 1]) >= 0.999f;
- writeIndex = (writeIndex + 1) % CAPACITY;
- if (count < CAPACITY) {
- count++;
- }
- }
- invalidate();
- }
-
- public void reset() {
- count = 0;
- writeIndex = 0;
- invalidate();
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
- int width = getWidth();
- int height = getHeight();
- if (width <= 0 || height <= 0) {
- return;
- }
- canvas.drawRect(0, 0, width, height, backgroundPaint);
-
- float centerY = height / 2f;
- canvas.drawLine(0, centerY, width, centerY, centerPaint);
-
- float amplitude = centerY * 0.92f;
- float step = (float) width / CAPACITY;
- for (int i = 0; i < count; i++) {
- int index = (writeIndex - count + i + CAPACITY) % CAPACITY;
- float x = i * step + step / 2f;
- float top = centerY - maxs[index] * amplitude;
- float bottom = centerY - mins[index] * amplitude;
- canvas.drawLine(x, top, x, bottom, clipped[index] ? clipPaint : wavePaint);
- }
- }
-}