summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorTom Cooks <tommasogagliardi+github@gmail.com>2026-09-30 08:49:02 -0400
committerTom Cooks <tommasogagliardi+github@gmail.com>2026-09-30 08:49:02 -0400
commit6db1714d0354d1e1105e330ce84c46a54487c3bb (patch)
treee4011a2b512770f271381b712bbe118de71dd8ef /app/src
parente15b89af9889d94eca15380048fc2bf6bbc8e22e (diff)
downloadreccoon-6db1714d0354d1e1105e330ce84c46a54487c3bb.tar.gz
Main-screen recordings list, player actions, background recording, exports (1.4.0-alpha)
- Main screen: recordings list at the bottom (newest first); removed the separate library screen and the top-right link - Player: delete/rename/share/export, no autoplay, embedded-marker fallback - Word-level tap-to-seek from Whisper token timestamps (length fallback) - Export transcripts as .txt + .srt to Downloads - RecordingService: microphone foreground service for background recording - FileProvider for sharing on Android 9 and older - StreamingResampler extracted to its own class + JUnit regression tests - Bump to 1.4.0-alpha (versionCode 6)
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/AndroidManifest.xml21
-rw-r--r--app/src/main/java/com/wuhei/reccoon/Formats.java67
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MainActivity.java82
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MarkerStore.java13
-rw-r--r--app/src/main/java/com/wuhei/reccoon/PlayerActivity.java197
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingActions.java149
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingService.java68
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java137
-rw-r--r--app/src/main/java/com/wuhei/reccoon/StreamingResampler.java57
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptStore.java75
-rw-r--r--app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java110
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WavMarkers.java121
-rw-r--r--app/src/main/res/layout/activity_main.xml328
-rw-r--r--app/src/main/res/layout/activity_player.xml39
-rw-r--r--app/src/main/res/layout/activity_recordings.xml54
-rw-r--r--app/src/main/res/values/strings.xml18
-rw-r--r--app/src/main/res/xml/file_paths.xml7
-rw-r--r--app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java41
18 files changed, 1094 insertions, 490 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 111b679..791d668 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -32,14 +35,24 @@
</activity>
<activity
- android:name=".RecordingsActivity"
+ android:name=".PlayerActivity"
android:exported="false"
android:screenOrientation="portrait" />
- <activity
- android:name=".PlayerActivity"
+ <service
+ android:name=".RecordingService"
android:exported="false"
- android:screenOrientation="portrait" />
+ android:foregroundServiceType="microphone" />
+
+ <provider
+ android:name="androidx.core.content.FileProvider"
+ android:authorities="${applicationId}.fileprovider"
+ android:exported="false"
+ android:grantUriPermissions="true">
+ <meta-data
+ android:name="android.support.FILE_PROVIDER_PATHS"
+ android:resource="@xml/file_paths" />
+ </provider>
</application>
</manifest>
diff --git a/app/src/main/java/com/wuhei/reccoon/Formats.java b/app/src/main/java/com/wuhei/reccoon/Formats.java
new file mode 100644
index 0000000..37e0909
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/Formats.java
@@ -0,0 +1,67 @@
+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/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java
index c8daf44..d11726f 100644
--- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java
+++ b/app/src/main/java/com/wuhei/reccoon/MainActivity.java
@@ -9,8 +9,6 @@ import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
-import android.provider.Settings;
-import android.text.InputType;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Build;
@@ -20,6 +18,9 @@ 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;
@@ -27,6 +28,7 @@ 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;
@@ -35,9 +37,6 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.AppCompatCheckBox;
import androidx.core.content.ContextCompat;
-import androidx.core.graphics.Insets;
-import androidx.core.view.ViewCompat;
-import androidx.core.view.WindowInsetsCompat;
import java.io.File;
import java.io.FileInputStream;
@@ -69,9 +68,13 @@ public class MainActivity extends AppCompatActivity {
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());
@@ -94,6 +97,11 @@ public class MainActivity extends AppCompatActivity {
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) ->
@@ -107,7 +115,7 @@ public class MainActivity extends AppCompatActivity {
setUpSilence();
setUpMono();
markButton.setOnClickListener(v -> addMarker());
- applyWindowInsets();
+ InsetsHelper.apply(findViewById(R.id.root));
blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f);
blinkAnimator.setDuration(600);
@@ -115,8 +123,40 @@ public class MainActivity extends AppCompatActivity {
blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE);
recordButton.setOnClickListener(v -> onRecordButtonClicked());
- findViewById(R.id.libraryButton).setOnClickListener(v ->
- startActivity(new Intent(this, RecordingsActivity.class)));
+ }
+
+ @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
@@ -233,11 +273,6 @@ public class MainActivity extends AppCompatActivity {
.show();
}
- /** Keeps the UI clear of the status bar/notch and the navigation bar. */
- private void applyWindowInsets() {
- InsetsHelper.apply(findViewById(R.id.root));
- }
-
private void ensureDndAccess() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return;
@@ -290,6 +325,24 @@ public class MainActivity extends AppCompatActivity {
}
}
+ 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 {
@@ -302,6 +355,7 @@ public class MainActivity extends AppCompatActivity {
recording = true;
startedAtMs = SystemClock.elapsedRealtime();
+ startRecordingService();
levelMeter.reset();
waveformView.reset();
markButton.setEnabled(true);
@@ -329,6 +383,7 @@ public class MainActivity extends AppCompatActivity {
recording = false;
busy = true;
+ stopRecordingService();
levelMeter.reset();
waveformView.reset();
markButton.setEnabled(false);
@@ -378,6 +433,7 @@ public class MainActivity extends AppCompatActivity {
statusText.setText(R.string.err_save);
Toast.makeText(this, R.string.err_save, Toast.LENGTH_LONG).show();
}
+ renderRecordings();
});
}, "reccoon-save").start();
}
diff --git a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java
index 9002264..bde7a24 100644
--- a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java
+++ b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java
@@ -91,6 +91,19 @@ public final class MarkerStore {
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/PlayerActivity.java b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
index 6ebff5b..eb5b8ca 100644
--- a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
+++ b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java
@@ -1,15 +1,23 @@
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;
@@ -17,11 +25,12 @@ 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;
-import java.util.Locale;
/**
* Plays a saved recording with a scrubber and transcribes it on device with
@@ -56,7 +65,7 @@ public class PlayerActivity extends AppCompatActivity {
private final Handler handler = new Handler(Looper.getMainLooper());
private boolean userSeeking;
- private float speedIndex0 = SPEEDS[0];
+ private float speed = SPEEDS[0];
private int speedIndex;
private volatile boolean cancelled;
@@ -104,14 +113,14 @@ public class PlayerActivity extends AppCompatActivity {
markersSection = findViewById(R.id.markersSection);
markersContainer = findViewById(R.id.markersContainer);
- titleText.setText(RecordingsActivity.prettyName(recordingName));
+ titleText.setText(Formats.prettyName(recordingName));
setUpSpinners();
setUpButtons();
setUpPlayer();
InsetsHelper.apply(findViewById(R.id.root));
- showMarkers(markerStore.load(recordingName));
+ showMarkers(loadMarkers());
TranscriptionEngine.Result existing = transcripts.load(recordingName);
if (existing != null) {
@@ -149,13 +158,17 @@ public class PlayerActivity extends AppCompatActivity {
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(formatClock(progress) + " / " + formatClock(duration()));
+ timeText.setText(Formats.clock(progress) + " / " + Formats.clock(duration()));
}
}
@@ -176,19 +189,14 @@ public class PlayerActivity extends AppCompatActivity {
private void setUpPlayer() {
player = new MediaPlayer();
+ prepared = false;
try {
player.setDataSource(this, recordingUri);
player.setOnPreparedListener(mp -> {
prepared = true;
seekBar.setMax(mp.getDuration());
updateTimeLabel();
- try {
- mp.start();
- applySpeed();
- } catch (Exception ignored) {
- // Ignore, playback can be started manually.
- }
- updatePlayButton();
+ updatePlayButton(); // no autoplay
});
player.setOnCompletionListener(mp -> updatePlayButton());
player.prepareAsync();
@@ -232,8 +240,7 @@ public class PlayerActivity extends AppCompatActivity {
if (player == null || !prepared) {
return;
}
- int target = player.getCurrentPosition() + deltaMs;
- target = Math.max(0, Math.min(target, player.getDuration()));
+ int target = Math.max(0, Math.min(player.getCurrentPosition() + deltaMs, player.getDuration()));
player.seekTo(target);
seekBar.setProgress(target);
updateTimeLabel();
@@ -241,8 +248,8 @@ public class PlayerActivity extends AppCompatActivity {
private void cycleSpeed() {
speedIndex = (speedIndex + 1) % SPEEDS.length;
- speedIndex0 = SPEEDS[speedIndex];
- speedButton.setText(String.format(Locale.US, "%.1f×", speedIndex0));
+ speed = SPEEDS[speedIndex];
+ speedButton.setText(String.format(java.util.Locale.US, "%.1f×", speed));
applySpeed();
}
@@ -252,7 +259,7 @@ public class PlayerActivity extends AppCompatActivity {
}
try {
PlaybackParams params = player.getPlaybackParams();
- params.setSpeed(speedIndex0);
+ params.setSpeed(speed);
player.setPlaybackParams(params);
} catch (Exception ignored) {
// Some devices reject speed changes while paused.
@@ -275,8 +282,8 @@ public class PlayerActivity extends AppCompatActivity {
}
private void updateTimeLabel() {
- timeText.setText(formatClock(player != null && prepared ? player.getCurrentPosition() : 0)
- + " / " + formatClock(duration()));
+ timeText.setText(Formats.clock(player != null && prepared ? player.getCurrentPosition() : 0)
+ + " / " + Formats.clock(duration()));
}
private void updatePlayButton() {
@@ -291,6 +298,94 @@ public class PlayerActivity extends AppCompatActivity {
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() {
@@ -310,7 +405,7 @@ public class PlayerActivity extends AppCompatActivity {
new AlertDialog.Builder(this)
.setTitle(R.string.model_download_title)
.setMessage(getString(R.string.model_download_message,
- model.label, humanBytes(model.totalBytes())))
+ model.label, Formats.humanBytes(model.totalBytes())))
.setPositiveButton(R.string.download, (dialog, which) -> runDownload(model, language))
.setNegativeButton(R.string.cancel, null)
.show();
@@ -403,21 +498,48 @@ public class PlayerActivity extends AppCompatActivity {
return;
}
for (TranscriptionEngine.Segment segment : result.segments) {
- addTranscriptLine(
- "[" + formatClock((int) (segment.startSeconds * 1000)) + "] " + segment.text,
- (int) (segment.startSeconds * 1000));
+ addTranscriptLine(segment);
}
statusText.setText(R.string.status_transcript_saved);
}
- private void addTranscriptLine(String text, int seekMs) {
+ 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(text);
+ view.setText(builder);
view.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
view.setTextSize(15f);
view.setPadding(0, 12, 0, 12);
- view.setBackgroundResource(android.R.drawable.list_selector_background);
- view.setOnClickListener(v -> seekTo(seekMs));
+ view.setMovementMethod(LinkMovementMethod.getInstance());
+ view.setHighlightColor(Color.TRANSPARENT);
+ view.setOnClickListener(v -> seekTo((int) (segment.startSeconds * 1000)));
transcriptContainer.addView(view);
}
@@ -429,7 +551,7 @@ public class PlayerActivity extends AppCompatActivity {
TextView chip = new TextView(this);
StringBuilder text = new StringBuilder();
text.append('M').append(marker.index)
- .append(" ").append(formatClock((int) marker.timeMillis()));
+ .append(" ").append(Formats.clock((int) marker.timeMillis()));
if (!marker.label.isEmpty()) {
text.append(" ").append(marker.label);
}
@@ -474,25 +596,6 @@ public class PlayerActivity extends AppCompatActivity {
}
}
- static String humanBytes(long bytes) {
- double mb = bytes / 1048576.0;
- if (mb >= 1) {
- return String.format(Locale.US, "%.0f MB", mb);
- }
- return String.format(Locale.US, "%.0f KB", bytes / 1024.0);
- }
-
- static String formatClock(int millis) {
- int totalSeconds = Math.max(0, millis) / 1000;
- int minutes = totalSeconds / 60;
- int seconds = totalSeconds % 60;
- int hours = minutes / 60;
- if (hours > 0) {
- return String.format(Locale.US, "%d:%02d:%02d", hours, minutes % 60, seconds);
- }
- return String.format(Locale.US, "%d:%02d", minutes, seconds);
- }
-
@Override
protected void onPause() {
super.onPause();
diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java b/app/src/main/java/com/wuhei/reccoon/RecordingActions.java
new file mode 100644
index 0000000..4131181
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/RecordingActions.java
@@ -0,0 +1,149 @@
+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
new file mode 100644
index 0000000..830c3bb
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/RecordingService.java
@@ -0,0 +1,68 @@
+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/RecordingsActivity.java b/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java
deleted file mode 100644
index 7424e10..0000000
--- a/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java
+++ /dev/null
@@ -1,137 +0,0 @@
-package com.wuhei.reccoon;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.ListView;
-import android.widget.TextView;
-
-import androidx.appcompat.app.AppCompatActivity;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-
-/** Lists the recordings saved in Downloads and opens them in the player. */
-public class RecordingsActivity extends AppCompatActivity {
-
- private RecordingStore store;
- private TranscriptStore transcripts;
- private TextView emptyText;
- private Adapter adapter;
-
- private final List<RecordingStore.Recording> items = new ArrayList<>();
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setContentView(R.layout.activity_recordings);
-
- store = new RecordingStore(this);
- transcripts = new TranscriptStore(this);
- emptyText = findViewById(R.id.emptyText);
-
- InsetsHelper.apply(findViewById(R.id.root));
-
- ListView listView = findViewById(R.id.recordingsList);
- adapter = new Adapter();
- listView.setAdapter(adapter);
- listView.setOnItemClickListener((parent, view, position, id) -> open(items.get(position)));
- }
-
- @Override
- protected void onResume() {
- super.onResume();
- items.clear();
- items.addAll(store.list());
- adapter.notifyDataSetChanged();
- emptyText.setVisibility(items.isEmpty() ? View.VISIBLE : View.GONE);
- }
-
- private void open(RecordingStore.Recording recording) {
- Intent intent = new Intent(this, PlayerActivity.class);
- intent.putExtra(PlayerActivity.EXTRA_NAME, recording.name);
- intent.putExtra(PlayerActivity.EXTRA_URI, recording.uri.toString());
- startActivity(intent);
- }
-
- /** Turns {@code reccoon-2026-09-24-153000-00001.wav} into a readable label. */
- static String prettyName(String fileName) {
- String core = fileName;
- if (core.startsWith("reccoon-")) {
- core = core.substring("reccoon-".length());
- }
- if (core.endsWith(".wav")) {
- core = core.substring(0, core.length() - 4);
- }
- String[] parts = core.split("-");
- if (parts.length >= 4) {
- String date = parts[0] + "-" + parts[1] + "-" + parts[2];
- String time = parts[3];
- String sequence = parts.length >= 5 ? parts[4] : "";
- if (time.length() == 6) {
- time = time.substring(0, 2) + ":" + time.substring(2, 4) + ":" + time.substring(4);
- }
- return date + " " + time + (sequence.isEmpty() ? "" : " #" + sequence);
- }
- return core;
- }
-
- static String formatDuration(long millis) {
- long totalSeconds = millis / 1000;
- long hours = totalSeconds / 3600;
- long minutes = (totalSeconds % 3600) / 60;
- long seconds = totalSeconds % 60;
- if (hours > 0) {
- return String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds);
- }
- return String.format(Locale.US, "%d:%02d", minutes, seconds);
- }
-
- static String formatSize(long bytes) {
- double mb = bytes / 1048576.0;
- if (mb >= 1) {
- return String.format(Locale.US, "%.1f MB", mb);
- }
- return String.format(Locale.US, "%d KB", bytes / 1024);
- }
-
- private class Adapter extends BaseAdapter {
- @Override
- public int getCount() {
- return items.size();
- }
-
- @Override
- public Object getItem(int position) {
- return items.get(position);
- }
-
- @Override
- public long getItemId(int position) {
- return position;
- }
-
- @Override
- public View getView(int position, View convertView, ViewGroup parent) {
- View view = convertView != null
- ? convertView
- : LayoutInflater.from(RecordingsActivity.this)
- .inflate(R.layout.item_recording, parent, false);
- RecordingStore.Recording recording = items.get(position);
-
- TextView name = view.findViewById(R.id.nameText);
- TextView meta = view.findViewById(R.id.metaText);
- TextView badge = view.findViewById(R.id.transcriptBadge);
-
- name.setText(prettyName(recording.name));
- meta.setText(formatDuration(recording.durationMs)
- + " · " + formatSize(recording.sizeBytes));
- badge.setVisibility(transcripts.has(recording.name) ? View.VISIBLE : View.GONE);
- return view;
- }
- }
-}
diff --git a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java b/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java
new file mode 100644
index 0000000..beb372a
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java
@@ -0,0 +1,57 @@
+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
index a30608e..7ae336e 100644
--- a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
+++ b/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java
@@ -9,9 +9,8 @@ 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 and loads transcript sidecar files in app-private storage. */
public final class TranscriptStore {
@@ -36,6 +35,15 @@ public final class TranscriptStore {
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);
@@ -52,8 +60,7 @@ public final class TranscriptStore {
} catch (JSONException e) {
throw new IOException(e);
}
- File target = fileFor(recordingName);
- try (FileOutputStream out = new FileOutputStream(target)) {
+ try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) {
out.write(json.getBytes(StandardCharsets.UTF_8));
}
}
@@ -63,19 +70,38 @@ public final class TranscriptStore {
if (!file.isFile()) {
return null;
}
- try {
- String json = new String(readAll(file), StandardCharsets.UTF_8);
- JSONObject root = new JSONObject(json);
+ 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);
- result.segments.add(new TranscriptionEngine.Segment(
+ TranscriptionEngine.Segment segment = new TranscriptionEngine.Segment(
(float) item.optDouble("start", 0),
(float) item.optDouble("end", 0),
- item.optString("text", "")));
+ 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;
@@ -84,27 +110,20 @@ public final class TranscriptStore {
}
}
- private File fileFor(String recordingName) {
- return new File(dir, recordingName + ".json");
+ public void delete(String recordingName) {
+ //noinspection ResultOfMethodCallIgnored
+ fileFor(recordingName).delete();
}
- private static byte[] readAll(File file) throws IOException {
- try (java.io.InputStream in = new java.io.FileInputStream(file)) {
- List<byte[]> chunks = new ArrayList<>();
- byte[] buffer = new byte[16384];
- int total = 0;
- int read;
- while ((read = in.read(buffer)) > 0) {
- chunks.add(java.util.Arrays.copyOf(buffer, read));
- total += read;
- }
- byte[] all = new byte[total];
- int offset = 0;
- for (byte[] chunk : chunks) {
- System.arraycopy(chunk, 0, all, offset, chunk.length);
- offset += chunk.length;
- }
- return all;
+ 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
index efee8dd..3579ab5 100644
--- a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
+++ b/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java
@@ -7,6 +7,7 @@ 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;
@@ -21,7 +22,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
/**
@@ -40,10 +40,23 @@ public final class TranscriptionEngine {
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;
@@ -178,11 +191,15 @@ public final class TranscriptionEngine {
try {
stream.acceptWaveform(samples, TARGET_RATE);
recognizer.decode(stream);
- String text = recognizer.getResult(stream).getText().trim();
+ 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;
- result.segments.add(new Segment(start, end, text));
+ Segment segment = new Segment(start, end, text);
+ buildWords(segment, decoded.getTokens(), decoded.getTimestamps(),
+ decoded.getDurations());
+ result.segments.add(segment);
}
} finally {
stream.release();
@@ -190,6 +207,51 @@ public final class TranscriptionEngine {
}
}
+ /**
+ * 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);
@@ -202,7 +264,7 @@ public final class TranscriptionEngine {
whisper.setLanguage(language == null ? "" : language);
whisper.setTask("transcribe");
whisper.setTailPaddings(1000);
- whisper.setEnableTokenTimestamps(false);
+ whisper.setEnableTokenTimestamps(true);
whisper.setEnableSegmentTimestamps(false);
OfflineModelConfig modelConfig = new OfflineModelConfig();
@@ -353,44 +415,4 @@ public final class TranscriptionEngine {
remaining -= skipped;
}
}
-
- /** Linear-interpolation streaming resampler. */
- static final class StreamingResampler {
- private final double ratio;
- private float[] buffer = new float[0];
- private double position;
-
- StreamingResampler(int inputRate, int outputRate) {
- this.ratio = inputRate / (double) outputRate;
- }
-
- float[] process(float[] input) {
- if (input.length == 0) {
- return new float[0];
- }
- float[] combined = Arrays.copyOf(buffer, buffer.length + input.length);
- System.arraycopy(input, 0, combined, buffer.length, input.length);
- buffer = combined;
-
- int capacity = (int) Math.max(1, (buffer.length - position) / ratio + 2);
- float[] output = new float[capacity];
- int count = 0;
- while (position + 1 < buffer.length && count < output.length) {
- int index = (int) position;
- float fraction = (float) (position - index);
- output[count++] = buffer[index] * (1f - fraction) + buffer[index + 1] * fraction;
- position += ratio;
- }
-
- int drop = (int) position;
- if (drop > 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/WavMarkers.java b/app/src/main/java/com/wuhei/reccoon/WavMarkers.java
new file mode 100644
index 0000000..1f55e96
--- /dev/null
+++ b/app/src/main/java/com/wuhei/reccoon/WavMarkers.java
@@ -0,0 +1,121 @@
+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/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 8bda7d3..e919117 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,178 +1,182 @@
<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout
- xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
- android:paddingLeft="20dp"
- android:paddingRight="20dp">
-
- <TextView
- android:id="@+id/titleText"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="16dp"
- android:letterSpacing="0.04"
- android:text="@string/app_name"
- android:textColor="@color/text_primary"
- android:textSize="30sp"
- android:textStyle="bold"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <TextView
- android:id="@+id/libraryButton"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:layout_marginEnd="4dp"
- android:background="?android:attr/selectableItemBackground"
- android:padding="8dp"
- android:text="@string/library"
- android:textColor="@color/text_secondary"
- android:textSize="14sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintTop_toTopOf="parent" />
-
- <TextView
- android:id="@+id/subtitleText"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/subtitle"
- android:textColor="@color/text_secondary"
- android:textSize="12sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/titleText" />
-
- <ImageView
- android:id="@+id/recIndicator"
- android:layout_width="12dp"
- android:layout_height="12dp"
- android:layout_marginBottom="6dp"
- android:contentDescription="@null"
- android:src="@drawable/ic_rec_dot"
- android:visibility="invisible"
- app:layout_constraintBottom_toTopOf="@id/timerText"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent" />
-
- <TextView
- android:id="@+id/timerText"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="10dp"
- android:fontFamily="monospace"
- android:text="@string/timer_zero"
- android:textColor="@color/text_primary"
- android:textSize="46sp"
- android:textStyle="bold"
- app:layout_constraintBottom_toTopOf="@id/recordButton"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent" />
-
- <ImageButton
- android:id="@+id/recordButton"
- android:layout_width="148dp"
- android:layout_height="148dp"
- android:background="@drawable/bg_record_idle"
- android:contentDescription="@string/cd_record"
- android:scaleType="centerInside"
- android:src="@android:color/transparent"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintVertical_bias="0.34" />
-
- <com.wuhei.reccoon.WaveformView
- android:id="@+id/waveformView"
- android:layout_width="0dp"
- android:layout_height="64dp"
- android:layout_marginTop="16dp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/recordButton" />
-
- <com.wuhei.reccoon.LevelMeterView
- android:id="@+id/levelMeter"
- android:layout_width="0dp"
- android:layout_height="38dp"
- android:layout_marginTop="10dp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/waveformView" />
-
- <Button
- android:id="@+id/markButton"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="6dp"
- android:enabled="false"
- android:minWidth="140dp"
- android:text="@string/mark_action"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/levelMeter" />
+ android:fillViewport="true">
<LinearLayout
- android:id="@+id/optionsRow"
- android:layout_width="wrap_content"
+ android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:layout_marginTop="2dp"
- android:gravity="center"
- android:orientation="horizontal"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/markButton">
-
- <androidx.appcompat.widget.AppCompatCheckBox
- android:id="@+id/monoCheckbox"
+ android:orientation="vertical"
+ android:paddingLeft="20dp"
+ android:paddingTop="16dp"
+ android:paddingRight="20dp"
+ android:paddingBottom="24dp">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:letterSpacing="0.04"
+ android:text="@string/app_name"
+ android:textColor="@color/text_primary"
+ android:textSize="30sp"
+ android:textStyle="bold" />
+
+ <TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:contentDescription="@string/mono_recording"
- android:text="@string/mono_short"
+ android:layout_gravity="center_horizontal"
+ android:text="@string/subtitle"
android:textColor="@color/text_secondary"
- android:textSize="13sp" />
+ android:textSize="12sp" />
+
+ <ImageView
+ android:id="@+id/recIndicator"
+ android:layout_width="12dp"
+ android:layout_height="12dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="14dp"
+ android:contentDescription="@null"
+ android:src="@drawable/ic_rec_dot"
+ android:visibility="invisible" />
- <androidx.appcompat.widget.AppCompatCheckBox
- android:id="@+id/silenceCheckbox"
+ <TextView
+ android:id="@+id/timerText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_marginStart="10dp"
- android:contentDescription="@string/silence_recording"
- android:text="@string/silence_short"
+ android:layout_gravity="center_horizontal"
+ android:fontFamily="monospace"
+ android:text="@string/timer_zero"
+ android:textColor="@color/text_primary"
+ android:textSize="46sp"
+ android:textStyle="bold" />
+
+ <ImageButton
+ android:id="@+id/recordButton"
+ android:layout_width="148dp"
+ android:layout_height="148dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="6dp"
+ android:background="@drawable/bg_record_idle"
+ android:contentDescription="@string/cd_record"
+ android:scaleType="centerInside"
+ android:src="@android:color/transparent" />
+
+ <com.wuhei.reccoon.WaveformView
+ android:id="@+id/waveformView"
+ android:layout_width="match_parent"
+ android:layout_height="64dp"
+ android:layout_marginTop="16dp" />
+
+ <com.wuhei.reccoon.LevelMeterView
+ android:id="@+id/levelMeter"
+ android:layout_width="match_parent"
+ android:layout_height="38dp"
+ android:layout_marginTop="10dp" />
+
+ <Button
+ android:id="@+id/markButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="6dp"
+ android:enabled="false"
+ android:minWidth="140dp"
+ android:text="@string/mark_action" />
+
+ <LinearLayout
+ android:id="@+id/optionsRow"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="2dp"
+ android:gravity="center"
+ android:orientation="horizontal">
+
+ <androidx.appcompat.widget.AppCompatCheckBox
+ android:id="@+id/monoCheckbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:contentDescription="@string/mono_recording"
+ android:text="@string/mono_short"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp" />
+
+ <androidx.appcompat.widget.AppCompatCheckBox
+ android:id="@+id/silenceCheckbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="10dp"
+ android:contentDescription="@string/silence_recording"
+ android:text="@string/silence_short"
+ android:textColor="@color/text_secondary"
+ android:textSize="13sp" />
+ </LinearLayout>
+
+ <TextView
+ android:id="@+id/statusText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:gravity="center"
+ android:text="@string/status_idle"
android:textColor="@color/text_secondary"
- android:textSize="13sp" />
- </LinearLayout>
+ android:textSize="14sp" />
- <TextView
- android:id="@+id/statusText"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:gravity="center"
- android:text="@string/status_idle"
- android:textColor="@color/text_secondary"
- android:textSize="14sp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/optionsRow" />
-
- <TextView
- android:id="@+id/footerText"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="10dp"
- android:alpha="0.6"
- android:text="@string/footer"
- android:textColor="@color/text_secondary"
- android:textSize="11sp"
- app:layout_constraintBottom_toBottomOf="parent"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:layout_marginTop="20dp"
+ android:layout_marginBottom="14dp"
+ android:background="@color/divider" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/recordings_title"
+ android:textColor="@color/text_primary"
+ android:textSize="18sp"
+ android:textStyle="bold" />
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:text="@string/recordings_subtitle"
+ android:textColor="@color/text_secondary"
+ android:textSize="12sp" />
+
+ <LinearLayout
+ android:id="@+id/recordingsContainer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:orientation="vertical" />
+
+ <TextView
+ android:id="@+id/emptyText"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="12dp"
+ android:gravity="center"
+ android:text="@string/recordings_empty"
+ android:textColor="@color/text_secondary"
+ android:textSize="14sp"
+ android:visibility="gone" />
+
+ <TextView
+ android:id="@+id/footerText"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="20dp"
+ android:alpha="0.6"
+ android:text="@string/footer"
+ android:textColor="@color/text_secondary"
+ android:textSize="11sp" />
+
+ </LinearLayout>
+</ScrollView>
diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml
index 67f5284..6ba1190 100644
--- a/app/src/main/res/layout/activity_player.xml
+++ b/app/src/main/res/layout/activity_player.xml
@@ -76,6 +76,45 @@
</LinearLayout>
<LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="6dp"
+ android:orientation="horizontal">
+
+ <Button
+ android:id="@+id/shareButton"
+ style="?android:attr/buttonBarButtonStyle"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/action_share" />
+
+ <Button
+ android:id="@+id/renameButton"
+ style="?android:attr/buttonBarButtonStyle"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/action_rename" />
+
+ <Button
+ android:id="@+id/exportButton"
+ style="?android:attr/buttonBarButtonStyle"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/action_export" />
+
+ <Button
+ android:id="@+id/deleteButton"
+ style="?android:attr/buttonBarButtonStyle"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_weight="1"
+ android:text="@string/action_delete" />
+ </LinearLayout>
+
+ <LinearLayout
android:id="@+id/markersSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/app/src/main/res/layout/activity_recordings.xml b/app/src/main/res/layout/activity_recordings.xml
deleted file mode 100644
index aff2088..0000000
--- a/app/src/main/res/layout/activity_recordings.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
- android:id="@+id/root"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/background"
- android:orientation="vertical"
- android:paddingLeft="20dp"
- android:paddingRight="20dp">
-
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="24dp"
- android:text="@string/recordings_title"
- android:textColor="@color/text_primary"
- android:textSize="26sp"
- android:textStyle="bold" />
-
- <TextView
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="2dp"
- android:text="@string/recordings_subtitle"
- android:textColor="@color/text_secondary"
- android:textSize="13sp" />
-
- <FrameLayout
- android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_marginTop="12dp"
- android:layout_weight="1">
-
- <ListView
- android:id="@+id/recordingsList"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:divider="@color/divider"
- android:dividerHeight="1dp"
- android:scrollbars="none" />
-
- <TextView
- android:id="@+id/emptyText"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_gravity="center"
- android:gravity="center"
- android:text="@string/recordings_empty"
- android:textColor="@color/text_secondary"
- android:textSize="15sp"
- android:visibility="gone" />
- </FrameLayout>
-
-</LinearLayout>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index dbd3535..416ce5e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">RECCoon</string>
- <string name="subtitle">by wuhei · v1.3.1-alpha</string>
+ <string name="subtitle">by wuhei · v1.4.0-alpha</string>
<string name="timer_zero">00:00</string>
<string name="footer">44.1 kHz · 16-bit · PCM (WAV) · cue markers</string>
@@ -34,6 +34,22 @@
<string name="saved_fmt">Saved %1$s</string>
<string name="library">Recordings</string>
+ <string name="action_share">Share</string>
+ <string name="action_rename">Rename</string>
+ <string name="action_export">Export</string>
+ <string name="action_delete">Delete</string>
+ <string name="delete_title">Delete recording?</string>
+ <string name="delete_message">Delete %1$s? This cannot be undone.</string>
+ <string name="deleted">Recording deleted</string>
+ <string name="rename_title">Rename recording</string>
+ <string name="renamed">Recording renamed</string>
+ <string name="rename_failed">Could not rename the recording</string>
+ <string name="export_none">Transcribe the recording before exporting</string>
+ <string name="exported">Transcript (.txt and .srt) saved to Downloads</string>
+ <string name="export_failed">Export failed: %1$s</string>
+ <string name="err_share">Could not share this file</string>
+ <string name="notification_channel">Recording</string>
+ <string name="notification_recording">Recording audio…</string>
<string name="recordings_title">Recordings</string>
<string name="recordings_subtitle">Saved in Downloads · reccoon-*.wav</string>
<string name="recordings_empty">No recordings yet.\nTap the record button to make one.</string>
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..46d069c
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+ <!-- Public Downloads directory, used on Android 9 and older. -->
+ <external-path
+ name="downloads"
+ path="Download/" />
+</paths>
diff --git a/app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java b/app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java
new file mode 100644
index 0000000..6e4cdcd
--- /dev/null
+++ b/app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java
@@ -0,0 +1,41 @@
+package com.wuhei.reccoon;
+
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Random;
+
+/** Regression tests for the streaming resampler. */
+public class StreamingResamplerTest {
+
+ @Test
+ public void longStreamOfFullChunksDoesNotThrow() {
+ StreamingResampler resampler = new StreamingResampler(44100, 16000);
+ float[] chunk = new float[8192];
+ long total = 0;
+ for (int i = 0; i < 400; i++) {
+ for (int j = 0; j < chunk.length; j++) {
+ chunk[j] = (float) Math.sin((i * 8192 + j) * 0.01);
+ }
+ total += resampler.process(chunk).length;
+ }
+ assertTrue(total > 1_000_000);
+ }
+
+ @Test
+ public void oddChunkSizesDoNotThrow() {
+ StreamingResampler resampler = new StreamingResampler(44100, 16000);
+ Random random = new Random(1);
+ long total = 0;
+ for (int i = 0; i < 3000; i++) {
+ int n = 1 + random.nextInt(3000);
+ float[] chunk = new float[n];
+ for (int j = 0; j < n; j++) {
+ chunk[j] = random.nextFloat() * 2f - 1f;
+ }
+ total += resampler.process(chunk).length;
+ }
+ assertTrue(total > 0);
+ }
+}