diff options
| author | Tom Cooks <tommasogagliardi+github@gmail.com> | 2026-10-10 15:42:04 -0400 |
|---|---|---|
| committer | Tom Cooks <tommasogagliardi+github@gmail.com> | 2026-10-10 15:42:04 -0400 |
| commit | 901bf58c5cf9151942f8c4cd4e1427a02dec4b52 (patch) | |
| tree | e9e312fb9893881f142a6f79e30417cd0c3f2be8 /app/src/main/java/com/wuhei | |
| parent | 6db1714d0354d1e1105e330ce84c46a54487c3bb (diff) | |
| download | reccoon-901bf58c5cf9151942f8c4cd4e1427a02dec4b52.tar.gz | |
RECCoon 1.0.0: rebrand to Tom Cooks, Parakeet-only, GPLv3, F-Droid-ready
Diffstat (limited to 'app/src/main/java/com/wuhei')
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); - } - } -} |
