diff options
21 files changed, 1137 insertions, 526 deletions
@@ -9,15 +9,12 @@ on device. - **Mono switch:** optionally record a single channel (half the file size) - **Markers:** place labelled cue points while recording; they are embedded in the WAV (`cue ` + `LIST/adtl`) and shown in the player - **Silence:** optional Do Not Disturb total silence (no notifications, no vibration) while recording -- **Saving:** the WAV is written to the public **Downloads** folder as - `reccoon-YYYY-MM-DD-HHMMSS-XXXXX.wav`, where `XXXXX` is a persistent - 5-digit progressive counter -- **Library:** a list of saved recordings, with a player and a scrubber to jump - around long recordings +- **Recordings list:** scrollable list at the bottom of the main screen, newest first +- **Player:** scrubber, ±10 s jumps, speed control, delete / rename / share / export, no autoplay - **Transcription:** on-device, offline speech recognition with [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) using multilingual Whisper — works with **English and Italian** -- **Name:** RECCoon · **by** wuhei · **version** 1.3.1-alpha +- **Name:** RECCoon · **by** wuhei · **version** 1.4.0-alpha - **Icon:** raccoon with a microphone ## Requirements @@ -32,6 +29,7 @@ on device. ```sh ./gradlew :app:assembleDebug # debug APK ./gradlew :app:assembleRelease # release APK (signed with the debug key) +./gradlew :app:testDebugUnitTest # JVM unit tests ``` Outputs: @@ -39,7 +37,7 @@ Outputs: - `app/build/outputs/apk/debug/app-debug.apk` - `app/build/outputs/apk/release/app-release.apk` -A prebuilt copy is available at `dist/RECCoon-1.3.1-alpha.apk`. Note that the +A prebuilt copy is available at `dist/RECCoon-1.4.0-alpha.apk`. Note that the sherpa-onnx AAR in `app/libs/` and the APK in `dist/` are **not** tracked in git because they are large; see `TODO.md`. @@ -49,10 +47,14 @@ git because they are large; see `TODO.md`. 44100 Hz into a temporary file, then rewrites a correct 44-byte RIFF/WAVE header when recording stops. It uses stereo when available and falls back to mono, and reports per-channel peak levels to the UI. -- `MainActivity` handles the runtime permissions, the timer, and copying the - finished file into Downloads. On Android 10+ this uses `MediaStore`; on - older versions it writes directly to the public Downloads directory and - notifies the media scanner. +- `RecordingService` is a microphone-typed foreground service started while + recording, so capture continues when the app is in the background. +- `MainActivity` handles the runtime permissions, the timer, the level meters + and copying the finished file into Downloads. On Android 10+ this uses + `MediaStore`; on older versions it writes directly to the public Downloads + directory and notifies the media scanner. +- Files are named `reccoon-YYYY-MM-DD-HHMMSS-XXXXX.wav`, where `XXXXX` is a + persistent 5-digit progressive counter. ## Waveform, meters and markers @@ -63,20 +65,22 @@ git because they are large; see `TODO.md`. - The **Mark** button (enabled while recording) adds a labelled cue point at the current position. Markers are written into the WAV as standard `cue ` and `LIST/adtl` chunks (readable by Audacity/Reaper) by `WavRecorder`, and also - stored as a JSON sidecar by `MarkerStore`. `PlayerActivity` shows them as - chips that seek the player when tapped. -- The **Mono** checkbox records a single channel instead of stereo. -- The **Silence alerts** checkbox switches the phone to Do Not Disturb total - silence while recording and restores the previous setting on stop. It needs - the `ACCESS_NOTIFICATION_POLICY` permission, granted in system settings when - first enabled. + stored as a JSON sidecar by `MarkerStore`. The player reads the sidecar and + falls back to `WavMarkers`, which parses the embedded chunks. -## Library and player +## Recordings list and player -- `RecordingsActivity` lists the `reccoon-*.wav` files in Downloads. +- The main screen shows a scrollable list of recordings, newest first. Tapping a + row opens `PlayerActivity`. - `PlayerActivity` plays a file with the platform `MediaPlayer`, a scrubber - (`SeekBar`), ±10 s jumps and a playback speed control (0.5×–2×). Tapping a - transcript line seeks the player to that moment. + (`SeekBar`), ±10 s jumps and a playback speed control (0.5×–2×). Playback does + **not** start automatically. +- Library actions: **Share** (FileProvider on Android 9 and older), **Rename** + (MediaStore or file), **Export** (writes `.txt` and `.srt` to Downloads) and + **Delete** (removes the WAV and its sidecars). +- Transcript words are clickable: tapping a word seeks the player to it. Word + timings come from Whisper token timestamps when available, otherwise the + segment is divided between its words. ## On-device transcription @@ -97,11 +101,10 @@ git because they are large; see `TODO.md`. - Language can be forced to English (`en`) or Italian (`it`), or left on **Auto detect**. - Transcripts are stored as JSON sidecars in app-private storage - (`filesDir/transcripts/<recording>.json`) and are shown in the player. A - `TXT` badge marks recordings that already have a transcript. + (`filesDir/transcripts/<recording>.json`). ## Native libraries and APK size The APK bundles the sherpa-onnx/onnxruntime native libraries for `arm64-v8a` -and `x86_64`, so it is around 70 MB. Removing `x86_64` from the `abiFilters` +and `x86_64`, so it is around 72 MB. Removing `x86_64` from the `abiFilters` in `app/build.gradle.kts` roughly halves the APK size for phone-only builds. @@ -21,15 +21,17 @@ columns. - [x] **WAV markers** — done: the **Mark** button embeds `cue `/`adtl` chunks in the WAV and stores a JSON sidecar; the player shows marker chips. +- [x] **Recordings list on the main screen** — done: the top-right *Recordings* + link is replaced by a scrollable list at the bottom of the main screen, + newest first. Tapping a row opens the player. The player has a **Delete** + button and no longer autoplays. -## Backlog (from earlier discussion) +## Backlog -- [ ] Export transcripts as `.txt` / `.srt` and/or embed them in WAV metadata - (`LIST`/`INFO`, `bext`). -- [ ] Foreground service so recording continues when the app is in the +- [x] Export transcripts as `.txt` / `.srt` (saved to Downloads, plus share). +- [x] Foreground service so recording continues when the app is in the background. -- [ ] Library actions: delete, rename, share, "open with". -- [ ] Word-level timestamps and tap-to-seek inside a transcript segment. -- [ ] Read embedded markers back from the WAV when there is no JSON sidecar. -- [ ] Add a JUnit regression test for `StreamingResampler` (it could throw - `IllegalArgumentException: <n> > <n-1>` when the trim overshot the buffer). +- [x] Library actions: delete, rename, share, "open with". +- [x] Word-level tap-to-seek inside a transcript segment. +- [x] Read embedded markers back from the WAV when there is no JSON sidecar. +- [x] Add a JUnit regression test for `StreamingResampler`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d57a7e6..1e4768a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.wuhei.reccoon" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "1.3.1-alpha" + versionCode = 6 + versionName = "1.4.0-alpha" ndk { // Prebuilt sherpa-onnx / onnxruntime native libraries. @@ -60,4 +60,6 @@ dependencies { // On-device speech recognition (Whisper multilingual: English + Italian). implementation(files("libs/sherpa-onnx-1.13.8.aar")) + + testImplementation("junit:junit:4.13.2") } 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); + } +} |
