summaryrefslogtreecommitdiff
path: root/app/src/main/java/com/tomcooks
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/com/tomcooks')
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java139
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/Formats.java67
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java33
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java160
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java189
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/MainActivity.java933
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/Marker.java19
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/MarkerStore.java110
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java213
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/ModelRepository.java367
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java239
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java647
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/RecordingActions.java154
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/RecordingService.java68
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/RecordingStore.java128
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java57
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java129
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java413
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/WavMarkers.java121
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/WavRecorder.java542
-rw-r--r--app/src/main/java/com/tomcooks/reccoon/WaveformView.java94
21 files changed, 4822 insertions, 0 deletions
diff --git a/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java b/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java
new file mode 100644
index 0000000..168cf53
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java
@@ -0,0 +1,139 @@
+package com.tomcooks.reccoon;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.MediaMuxer;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * Transcodes a PCM WAV file into a small AAC-LC MP4/M4A file.
+ *
+ * <p>AAC-LC is used because every Android device ships an encoder for it,
+ * unlike Opus. At speech bitrates the result is roughly 10-20Ă— smaller than the
+ * 44.1 kHz 16-bit WAV (for example ~22 MB per hour at 48 kbps mono instead of
+ * ~317 MB). The lossy step keeps enough bandwidth (16 kHz) for speech
+ * transcription; see {@link PcmAudioSource} for decoding it again.
+ */
+public final class AudioCompressor {
+
+ private static final long TIMEOUT_US = 10_000;
+ private static final int CHUNK_FRAMES = 4096;
+
+ private AudioCompressor() {
+ }
+
+ /** @return the number of bytes written to {@code m4a}. */
+ public static long compressToM4a(File wav, File m4a) throws IOException {
+ if (m4a.exists() && !m4a.delete()) {
+ throw new IOException("Cannot replace " + m4a);
+ }
+ try (BufferedInputStream in =
+ new BufferedInputStream(new FileInputStream(wav), 256 * 1024)) {
+ TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(in);
+ if (info.bitsPerSample != 16 || info.audioFormat != 1) {
+ throw new IOException("Only 16-bit PCM WAV files can be compressed");
+ }
+ encode(in, info, m4a);
+ }
+ return m4a.length();
+ }
+
+ private static void encode(InputStream pcm, TranscriptionEngine.WavInfo info, File m4a)
+ throws IOException {
+ int sampleRate = Math.max(1, info.sampleRate);
+ int channels = Math.max(1, info.channels);
+
+ MediaFormat format = MediaFormat.createAudioFormat(
+ MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels);
+ format.setInteger(MediaFormat.KEY_AAC_PROFILE,
+ MediaCodecInfo.CodecProfileLevel.AACObjectLC);
+ // Small but still clear for speech: 48 kbps per channel.
+ format.setInteger(MediaFormat.KEY_BIT_RATE, 48_000 * channels);
+ format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, CHUNK_FRAMES * channels * 2);
+
+ MediaCodec codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC);
+ MediaMuxer muxer = new MediaMuxer(m4a.getAbsolutePath(),
+ MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
+ boolean muxerStarted = false;
+ boolean inputDone = false;
+ boolean outputDone = false;
+ int trackIndex = -1;
+ long presentationTimeUs = 0L;
+ long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE;
+
+ try {
+ codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ codec.start();
+
+ byte[] buffer = new byte[CHUNK_FRAMES * channels * 2];
+ MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+
+ while (!outputDone) {
+ if (!inputDone) {
+ int inIndex = codec.dequeueInputBuffer(TIMEOUT_US);
+ if (inIndex >= 0) {
+ ByteBuffer input = codec.getInputBuffer(inIndex);
+ int capacity = input == null ? buffer.length : input.capacity();
+ int toRead = (int) Math.min(Math.min(buffer.length, remaining), capacity);
+ int read = toRead <= 0 ? -1 : pcm.read(buffer, 0, toRead);
+ if (read < 0) {
+ codec.queueInputBuffer(inIndex, 0, 0, presentationTimeUs,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ inputDone = true;
+ } else {
+ remaining -= read;
+ input.clear();
+ input.put(buffer, 0, read);
+ codec.queueInputBuffer(inIndex, 0, read, presentationTimeUs, 0);
+ long frames = read / (channels * 2L);
+ presentationTimeUs += frames * 1_000_000L / sampleRate;
+ }
+ }
+ }
+
+ int outIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US);
+ if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ trackIndex = muxer.addTrack(codec.getOutputFormat());
+ muxer.start();
+ muxerStarted = true;
+ } else if (outIndex >= 0) {
+ ByteBuffer output = codec.getOutputBuffer(outIndex);
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
+ bufferInfo.size = 0;
+ }
+ if (bufferInfo.size > 0 && muxerStarted && output != null) {
+ output.position(bufferInfo.offset);
+ output.limit(bufferInfo.offset + bufferInfo.size);
+ muxer.writeSampleData(trackIndex, output, bufferInfo);
+ }
+ codec.releaseOutputBuffer(outIndex, false);
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ outputDone = true;
+ }
+ }
+ }
+ } finally {
+ try {
+ codec.stop();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ codec.release();
+ if (muxerStarted) {
+ try {
+ muxer.stop();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+ muxer.release();
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/Formats.java b/app/src/main/java/com/tomcooks/reccoon/Formats.java
new file mode 100644
index 0000000..338fd58
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/Formats.java
@@ -0,0 +1,67 @@
+package com.tomcooks.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.endsWith(".m4a")) {
+ 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/tomcooks/reccoon/InsetsHelper.java b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java
new file mode 100644
index 0000000..27939be
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java
@@ -0,0 +1,33 @@
+package com.tomcooks.reccoon;
+
+import android.view.View;
+
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+/**
+ * Adds the system bar insets to a root view's padding so content is not hidden
+ * behind the status bar / notch or the navigation bar (edge-to-edge safe).
+ */
+public final class InsetsHelper {
+
+ private InsetsHelper() {
+ }
+
+ public static void apply(View root) {
+ if (root == null) {
+ return;
+ }
+ final int left = root.getPaddingLeft();
+ final int top = root.getPaddingTop();
+ final int right = root.getPaddingRight();
+ final int bottom = root.getPaddingBottom();
+ ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> {
+ Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
+ view.setPadding(left + insets.left, top + insets.top,
+ right + insets.right, bottom + insets.bottom);
+ return windowInsets;
+ });
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java
new file mode 100644
index 0000000..362ee6a
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java
@@ -0,0 +1,160 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.LinearGradient;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Two-channel (left/right) peak level meter with a decaying peak hold and a
+ * clip indicator. Levels are supplied as 0..1 linear amplitudes and mapped to
+ * a dBFS scale.
+ */
+public class LevelMeterView extends View {
+
+ private static final float MIN_DB = -60f;
+ private static final long CLIP_HOLD_MS = 900L;
+
+ private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint peakPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private final RectF barRect = new RectF();
+
+ private Shader gradient;
+
+ private float leftTarget;
+ private float rightTarget;
+ private float leftLevel;
+ private float rightLevel;
+ private float leftPeak;
+ private float rightPeak;
+ private long clipUntil;
+
+ public LevelMeterView(Context context) {
+ this(context, null);
+ }
+
+ public LevelMeterView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ float density = getResources().getDisplayMetrics().density;
+ backgroundPaint.setColor(0x22FFFFFF);
+ peakPaint.setColor(0xFFFFFFFF);
+ labelPaint.setColor(0xFF93A0B0);
+ labelPaint.setTextSize(11f * density);
+ clipPaint.setColor(0xFFE53935);
+ clipPaint.setTextSize(11f * density);
+ clipPaint.setFakeBoldText(true);
+ clipPaint.setTextAlign(Paint.Align.RIGHT);
+ }
+
+ /** Feed one measurement window. Amplitudes are 0..1. */
+ public void setLevels(float leftPeak, float rightPeak, boolean clipped) {
+ this.leftTarget = clamp(leftPeak);
+ this.rightTarget = clamp(rightPeak);
+ if (clipped) {
+ clipUntil = System.currentTimeMillis() + CLIP_HOLD_MS;
+ }
+ invalidate();
+ }
+
+ public void reset() {
+ leftTarget = rightTarget = leftLevel = rightLevel = 0f;
+ leftPeak = rightPeak = 0f;
+ clipUntil = 0L;
+ invalidate();
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ gradient = new LinearGradient(0, 0, w, 0,
+ new int[]{0xFF3DDC84, 0xFF9CCC65, 0xFFFFD54F, 0xFFFF5252},
+ new float[]{0f, 0.55f, 0.8f, 1f},
+ Shader.TileMode.CLAMP);
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ leftLevel = approach(leftLevel, leftTarget);
+ rightLevel = approach(rightLevel, rightTarget);
+ leftPeak = Math.max(leftTarget, leftPeak * 0.92f);
+ rightPeak = Math.max(rightTarget, rightPeak * 0.92f);
+
+ float density = getResources().getDisplayMetrics().density;
+ float gap = 6f * density;
+ float barHeight = (getHeight() - gap) / 2f;
+ float labelWidth = labelPaint.measureText("L") + 10f * density;
+ float rightEdge = getWidth() - clipPaint.measureText("CLIP") - 10f * density;
+
+ drawBar(canvas, 0f, labelWidth, rightEdge - labelWidth, barHeight,
+ leftLevel, leftPeak, "L");
+ drawBar(canvas, barHeight + gap, labelWidth, rightEdge - labelWidth, barHeight,
+ rightLevel, rightPeak, "R");
+
+ if (System.currentTimeMillis() < clipUntil) {
+ float baseline = getHeight() / 2f - (clipPaint.descent() + clipPaint.ascent()) / 2f;
+ canvas.drawText("CLIP", getWidth(), baseline, clipPaint);
+ }
+
+ // Keep animating while there is something to decay.
+ if (leftLevel > 0.002f || rightLevel > 0.002f
+ || leftPeak > 0.002f || rightPeak > 0.002f) {
+ postInvalidateOnAnimation();
+ }
+ }
+
+ private void drawBar(Canvas canvas, float top, float left, float width, float height,
+ float level, float peak, String label) {
+ float density = getResources().getDisplayMetrics().density;
+ float labelBaseline = top + height / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f;
+ canvas.drawText(label, 0f, labelBaseline, labelPaint);
+
+ barRect.set(left, top, left + width, top + height);
+ canvas.drawRoundRect(barRect, height / 2f, height / 2f, backgroundPaint);
+
+ float filled = width * toPosition(level);
+ if (filled > 0f) {
+ barPaint.setShader(gradient);
+ barRect.set(left, top, left + filled, top + height);
+ canvas.drawRoundRect(barRect, height / 2f, height / 2f, barPaint);
+ barPaint.setShader(null);
+ }
+
+ float peakX = left + width * toPosition(peak);
+ if (peakX > left + 1f) {
+ canvas.drawRect(peakX - 1.5f * density, top, peakX, top + height, peakPaint);
+ }
+ }
+
+ /** Maps a linear amplitude to a 0..1 bar position on a dBFS scale. */
+ private static float toPosition(float amplitude) {
+ if (amplitude <= 0f) {
+ return 0f;
+ }
+ double db = 20.0 * Math.log10(amplitude);
+ if (db < MIN_DB) {
+ return 0f;
+ }
+ return clamp((float) ((db - MIN_DB) / -MIN_DB));
+ }
+
+ private static float approach(float current, float target) {
+ if (target >= current) {
+ return target;
+ }
+ return current + (target - current) * 0.25f;
+ }
+
+ private static float clamp(float value) {
+ return Math.max(0f, Math.min(1f, value));
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java
new file mode 100644
index 0000000..6366d84
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java
@@ -0,0 +1,189 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioDeviceInfo;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioTrack;
+
+import java.util.ArrayDeque;
+
+/**
+ * Plays captured PCM back to a chosen output device so the user can hear what is
+ * being recorded (live monitoring / sidetone).
+ *
+ * <p>The monitor is decoupled from the capture loop by a small bounded queue and
+ * a dedicated thread: if the output device is slow (for example Bluetooth A2DP
+ * with its inherent latency) chunks are dropped instead of stalling the
+ * recorder and corrupting the recording.
+ *
+ * <p>With the phone speaker as the output and the phone microphone as the input
+ * there will be feedback, so headphones are recommended.
+ */
+public final class LiveMonitor {
+
+ private static final int MAX_QUEUED_CHUNKS = 8;
+
+ private final AudioManager audioManager;
+ private final ArrayDeque<byte[]> queue = new ArrayDeque<>();
+
+ private AudioTrack track;
+ private Thread thread;
+ private volatile boolean running;
+ private int preferredDeviceId = -1;
+
+ public LiveMonitor(Context context) {
+ audioManager = (AudioManager) context.getApplicationContext()
+ .getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ public void setPreferredDeviceId(int deviceId) {
+ this.preferredDeviceId = deviceId;
+ }
+
+ public boolean isRunning() {
+ return running;
+ }
+
+ /** @return true when the output could be opened and playback started. */
+ public boolean start(int sampleRate, int channels) {
+ stop();
+ int channelMask = channels > 1
+ ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO;
+ int minBuffer = AudioTrack.getMinBufferSize(
+ sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT);
+ int target = sampleRate * channels * 2 / 5; // ~200 ms
+ int bufferSize = Math.max(Math.max(minBuffer, target), 1024);
+
+ try {
+ track = new AudioTrack.Builder()
+ .setAudioAttributes(new AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
+ .build())
+ .setAudioFormat(new AudioFormat.Builder()
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
+ .setSampleRate(sampleRate)
+ .setChannelMask(channelMask)
+ .build())
+ .setBufferSizeInBytes(bufferSize)
+ .setTransferMode(AudioTrack.MODE_STREAM)
+ .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
+ .build();
+ } catch (Exception e) {
+ track = null;
+ return false;
+ }
+
+ if (track.getState() != AudioTrack.STATE_INITIALIZED) {
+ track.release();
+ track = null;
+ return false;
+ }
+ applyPreferredDevice();
+ try {
+ track.play();
+ } catch (Exception e) {
+ track.release();
+ track = null;
+ return false;
+ }
+
+ running = true;
+ thread = new Thread(this::loop, "reccoon-monitor");
+ thread.start();
+ return true;
+ }
+
+ /** Queues a chunk of PCM to play. Safe to call from the capture thread. */
+ public void write(byte[] data, int length) {
+ if (!running || data == null || length <= 0) {
+ return;
+ }
+ byte[] copy = new byte[length];
+ System.arraycopy(data, 0, copy, 0, length);
+ synchronized (queue) {
+ while (queue.size() >= MAX_QUEUED_CHUNKS) {
+ queue.pollFirst();
+ }
+ queue.addLast(copy);
+ queue.notifyAll();
+ }
+ }
+
+ private void loop() {
+ while (running) {
+ byte[] chunk;
+ synchronized (queue) {
+ while (running && queue.isEmpty()) {
+ try {
+ queue.wait(100);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ break;
+ }
+ }
+ chunk = queue.pollFirst();
+ }
+ if (chunk == null) {
+ continue;
+ }
+ AudioTrack target = track;
+ if (target != null) {
+ try {
+ target.write(chunk, 0, chunk.length);
+ } catch (Exception ignored) {
+ // Drop this chunk and keep going.
+ }
+ }
+ }
+ }
+
+ public void stop() {
+ running = false;
+ synchronized (queue) {
+ queue.clear();
+ queue.notifyAll();
+ }
+ Thread current = thread;
+ thread = null;
+ if (current != null) {
+ try {
+ current.join(500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ AudioTrack target = track;
+ track = null;
+ if (target != null) {
+ try {
+ target.stop();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ try {
+ target.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+ }
+
+ private void applyPreferredDevice() {
+ if (preferredDeviceId < 0 || audioManager == null || track == null) {
+ return;
+ }
+ try {
+ for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ if (device.getId() == preferredDeviceId) {
+ track.setPreferredDevice(device);
+ return;
+ }
+ }
+ } catch (Exception ignored) {
+ // Keep the default output.
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/MainActivity.java b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java
new file mode 100644
index 0000000..b350965
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java
@@ -0,0 +1,933 @@
+package com.tomcooks.reccoon;
+
+import android.Manifest;
+import android.animation.ObjectAnimator;
+import android.app.NotificationManager;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.media.MediaScannerConnection;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.provider.MediaStore;
+import android.provider.Settings;
+import android.text.InputType;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.WindowManager;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.AppCompatCheckBox;
+import androidx.core.content.ContextCompat;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+
+public class MainActivity extends AppCompatActivity {
+
+ private static final int REQUEST_PERMISSIONS = 42;
+ private static final String PREFS = "reccoon";
+ private static final String PREF_COUNTER = "counter";
+ private static final String PREF_SILENCE = "silence_notifications";
+ private static final String PREF_MONO = "mono_recording";
+ private static final String PREF_COMPRESS = "compress_recordings";
+ private static final String PREF_INPUT = "input_device";
+ private static final String PREF_SWAP = "swap_channels";
+ private static final String PREF_MONITOR = "live_monitor";
+ private static final String PREF_OUTPUT = "output_device";
+
+ /** Spinner positions that are not a concrete {@link AudioDeviceInfo}. */
+ private static final int INPUT_AUTO = -1;
+ private static final int INPUT_PHONE = -2;
+ private static final int OUTPUT_AUTO = -1;
+ private static final int OUTPUT_SPEAKER = -2;
+
+ private ImageButton recordButton;
+ private ImageView recIndicator;
+ private TextView timerText;
+ private TextView statusText;
+ private LevelMeterView levelMeter;
+ private WaveformView waveformView;
+ private AppCompatCheckBox silenceCheckbox;
+ private AppCompatCheckBox monoCheckbox;
+ private AppCompatCheckBox compressCheckbox;
+ private AppCompatCheckBox swapCheckbox;
+ private AppCompatCheckBox monitorCheckbox;
+ private Spinner inputSpinner;
+ private Spinner outputSpinner;
+ private final List<AudioDeviceInfo> inputDevices = new ArrayList<>();
+ private final List<AudioDeviceInfo> outputDevices = new ArrayList<>();
+ private Button markButton;
+ private Button discardButton;
+ private LinearLayout recordingsContainer;
+ private TextView emptyText;
+ private int savedInterruptionFilter = -1;
+
+ private WavRecorder recorder;
+ private RecordingStore recordingStore;
+ private TranscriptStore transcriptStore;
+ private ObjectAnimator blinkAnimator;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+
+ private boolean recording;
+ private boolean busy;
+ private long startedAtMs;
+ private Runnable timerRunnable;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ recordButton = findViewById(R.id.recordButton);
+ recIndicator = findViewById(R.id.recIndicator);
+ timerText = findViewById(R.id.timerText);
+ statusText = findViewById(R.id.statusText);
+ levelMeter = findViewById(R.id.levelMeter);
+ waveformView = findViewById(R.id.waveformView);
+ silenceCheckbox = findViewById(R.id.silenceCheckbox);
+ monoCheckbox = findViewById(R.id.monoCheckbox);
+ compressCheckbox = findViewById(R.id.compressCheckbox);
+ swapCheckbox = findViewById(R.id.swapCheckbox);
+ monitorCheckbox = findViewById(R.id.monitorCheckbox);
+ inputSpinner = findViewById(R.id.inputSpinner);
+ outputSpinner = findViewById(R.id.outputSpinner);
+ markButton = findViewById(R.id.markButton);
+ discardButton = findViewById(R.id.discardButton);
+ recordingsContainer = findViewById(R.id.recordingsContainer);
+ emptyText = findViewById(R.id.emptyText);
+
+ recordingStore = new RecordingStore(this);
+ transcriptStore = new TranscriptStore(this);
+
+ recorder = new WavRecorder(this);
+ recorder.setLevelListener((leftPeak, rightPeak, clipped, waveform) ->
+ runOnUiThread(() -> {
+ if (recording) {
+ levelMeter.setLevels(leftPeak, rightPeak, clipped);
+ waveformView.append(waveform);
+ }
+ }));
+
+ setUpSilence();
+ setUpMono();
+ setUpCompress();
+ setUpSwap();
+ setUpMonitor();
+ populateInputDevices();
+ populateOutputDevices();
+ markButton.setOnClickListener(v -> addMarker());
+ discardButton.setOnClickListener(v -> {
+ if (recording && !busy) {
+ stopRecording(true);
+ }
+ });
+ InsetsHelper.apply(findViewById(R.id.root));
+
+ blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f);
+ blinkAnimator.setDuration(600);
+ blinkAnimator.setRepeatCount(ObjectAnimator.INFINITE);
+ blinkAnimator.setRepeatMode(ObjectAnimator.REVERSE);
+
+ recordButton.setOnClickListener(v -> onRecordButtonClicked());
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (inputDevices.isEmpty()) {
+ populateInputDevices();
+ }
+ if (outputDevices.isEmpty()) {
+ populateOutputDevices();
+ }
+ renderRecordings();
+ }
+
+ private void renderRecordings() {
+ recordingsContainer.removeAllViews();
+ List<RecordingStore.Recording> recordings = recordingStore.list();
+ emptyText.setVisibility(recordings.isEmpty() ? View.VISIBLE : View.GONE);
+
+ LayoutInflater inflater = LayoutInflater.from(this);
+ for (RecordingStore.Recording recording : recordings) {
+ View row = inflater.inflate(R.layout.item_recording, recordingsContainer, false);
+ TextView name = row.findViewById(R.id.nameText);
+ TextView meta = row.findViewById(R.id.metaText);
+ TextView badge = row.findViewById(R.id.transcriptBadge);
+
+ name.setText(Formats.prettyName(recording.name));
+ meta.setText(Formats.duration(recording.durationMs)
+ + " · " + Formats.size(recording.sizeBytes));
+ badge.setVisibility(transcriptStore.has(recording.name) ? View.VISIBLE : View.GONE);
+ row.setOnClickListener(v -> openRecording(recording));
+ recordingsContainer.addView(row);
+ }
+ }
+
+ private void openRecording(RecordingStore.Recording recording) {
+ Intent intent = new Intent(this, PlayerActivity.class);
+ intent.putExtra(PlayerActivity.EXTRA_NAME, recording.name);
+ intent.putExtra(PlayerActivity.EXTRA_URI, recording.uri.toString());
+ startActivity(intent);
+ }
+
+ @Override
+ protected void onDestroy() {
+ handler.removeCallbacksAndMessages(null);
+ if (blinkAnimator != null) {
+ blinkAnimator.cancel();
+ }
+ applySilence(false);
+ super.onDestroy();
+ }
+
+ private void onRecordButtonClicked() {
+ if (busy) {
+ return;
+ }
+ if (recording) {
+ stopRecording(false);
+ return;
+ }
+ List<String> missing = missingPermissions();
+ if (!missing.isEmpty()) {
+ requestPermissions(missing.toArray(new String[0]), REQUEST_PERMISSIONS);
+ } else {
+ startRecording();
+ }
+ }
+
+ private List<String> missingPermissions() {
+ List<String> missing = new ArrayList<>();
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.RECORD_AUDIO);
+ }
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
+ && ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
+ && (selectedInputIsBluetooth() || selectedOutputIsBluetooth())
+ && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
+ != PackageManager.PERMISSION_GRANTED) {
+ missing.add(Manifest.permission.BLUETOOTH_CONNECT);
+ }
+ return missing;
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode,
+ @NonNull String[] permissions,
+ @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode != REQUEST_PERMISSIONS) {
+ return;
+ }
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED) {
+ populateInputDevices();
+ startRecording();
+ } else {
+ Toast.makeText(this, R.string.err_permission, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private void setUpSilence() {
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ silenceCheckbox.setChecked(prefs.getBoolean(PREF_SILENCE, false));
+ silenceCheckbox.setOnCheckedChangeListener((button, checked) -> {
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putBoolean(PREF_SILENCE, checked).apply();
+ if (checked) {
+ ensureDndAccess();
+ }
+ });
+ }
+
+ private void setUpMono() {
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ boolean mono = prefs.getBoolean(PREF_MONO, false);
+ monoCheckbox.setChecked(mono);
+ recorder.setForceMono(mono);
+ monoCheckbox.setOnCheckedChangeListener((button, checked) -> {
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putBoolean(PREF_MONO, checked).apply();
+ recorder.setForceMono(checked);
+ });
+ }
+
+ private void setUpCompress() {
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ compressCheckbox.setChecked(prefs.getBoolean(PREF_COMPRESS, false));
+ compressCheckbox.setOnCheckedChangeListener((button, checked) ->
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putBoolean(PREF_COMPRESS, checked).apply());
+ }
+
+ private void setUpSwap() {
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ swapCheckbox.setChecked(prefs.getBoolean(PREF_SWAP, false));
+ swapCheckbox.setOnCheckedChangeListener((button, checked) ->
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putBoolean(PREF_SWAP, checked).apply());
+ }
+
+ private void setUpMonitor() {
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ monitorCheckbox.setChecked(prefs.getBoolean(PREF_MONITOR, false));
+ monitorCheckbox.setOnCheckedChangeListener((button, checked) -> {
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putBoolean(PREF_MONITOR, checked).apply();
+ if (checked) {
+ Toast.makeText(this, R.string.monitor_warning, Toast.LENGTH_LONG).show();
+ }
+ });
+ }
+
+ // --- Output source for live monitoring ---------------------------------
+
+ private void populateOutputDevices() {
+ int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_OUTPUT, OUTPUT_AUTO);
+ outputDevices.clear();
+ List<String> labels = new ArrayList<>();
+ labels.add(getString(R.string.output_auto));
+ labels.add(getString(R.string.output_speaker));
+
+ AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ if (manager != null) {
+ try {
+ for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ // The built-in speaker is already covered by the entry above.
+ if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
+ continue;
+ }
+ outputDevices.add(device);
+ labels.add(describeOutput(device));
+ }
+ } catch (Exception ignored) {
+ // No device list available.
+ }
+ }
+
+ ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item, labels);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ outputSpinner.setAdapter(adapter);
+
+ if (selected == OUTPUT_SPEAKER) {
+ outputSpinner.setSelection(1);
+ return;
+ }
+ for (int i = 0; i < outputDevices.size(); i++) {
+ if (outputDevices.get(i).getId() == selected) {
+ outputSpinner.setSelection(i + 2);
+ return;
+ }
+ }
+ outputSpinner.setSelection(0);
+ }
+
+ /** Resolves the spinner to a concrete device id, or -1 for the default. */
+ private int resolveOutputDeviceId() {
+ int position = outputSpinner.getSelectedItemPosition();
+ if (position == 1) {
+ return findBuiltinSpeakerId();
+ }
+ if (position >= 2 && position - 2 < outputDevices.size()) {
+ return outputDevices.get(position - 2).getId();
+ }
+ return OUTPUT_AUTO;
+ }
+
+ private int selectedOutputId() {
+ int position = outputSpinner.getSelectedItemPosition();
+ if (position == 1) {
+ return OUTPUT_SPEAKER;
+ }
+ if (position >= 2 && position - 2 < outputDevices.size()) {
+ return outputDevices.get(position - 2).getId();
+ }
+ return OUTPUT_AUTO;
+ }
+
+ private boolean selectedOutputIsBluetooth() {
+ int position = outputSpinner.getSelectedItemPosition();
+ return position >= 2 && position - 2 < outputDevices.size()
+ && isBluetoothOutput(outputDevices.get(position - 2));
+ }
+
+ private static boolean isBluetoothOutput(AudioDeviceInfo device) {
+ int type = device.getType();
+ return type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP
+ || type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
+ || type == AudioDeviceInfo.TYPE_BLE_HEADSET
+ || type == AudioDeviceInfo.TYPE_BLE_SPEAKER;
+ }
+
+ private int findBuiltinSpeakerId() {
+ AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ if (manager == null) {
+ return OUTPUT_AUTO;
+ }
+ try {
+ for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
+ return device.getId();
+ }
+ }
+ } catch (Exception ignored) {
+ // Fall through to the default output.
+ }
+ return OUTPUT_AUTO;
+ }
+
+ private static String describeOutput(AudioDeviceInfo device) {
+ String type;
+ switch (device.getType()) {
+ case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP:
+ type = "Bluetooth";
+ break;
+ case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+ type = "Bluetooth (SCO)";
+ break;
+ case AudioDeviceInfo.TYPE_BLE_HEADSET:
+ type = "Bluetooth LE headset";
+ break;
+ case AudioDeviceInfo.TYPE_BLE_SPEAKER:
+ type = "Bluetooth LE speaker";
+ break;
+ case AudioDeviceInfo.TYPE_WIRED_HEADPHONES:
+ type = "Wired headphones";
+ break;
+ case AudioDeviceInfo.TYPE_WIRED_HEADSET:
+ type = "Wired headset";
+ break;
+ case AudioDeviceInfo.TYPE_USB_DEVICE:
+ type = "USB device";
+ break;
+ case AudioDeviceInfo.TYPE_USB_HEADSET:
+ type = "USB headset";
+ break;
+ case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE:
+ type = "Earpiece";
+ break;
+ default:
+ type = "Output " + device.getType();
+ break;
+ }
+ CharSequence name = device.getProductName();
+ if (name != null && name.length() > 0) {
+ type += " \u2014 " + name;
+ }
+ return type;
+ }
+
+ // --- Microphone source (BT-Mic-Force integration) ---------------------
+
+ /** A resolved microphone choice to hand to {@link WavRecorder}. */
+ private static final class InputChoice {
+ final int deviceId;
+ final boolean bluetoothSco;
+
+ InputChoice(int deviceId, boolean bluetoothSco) {
+ this.deviceId = deviceId;
+ this.bluetoothSco = bluetoothSco;
+ }
+ }
+
+ private void populateInputDevices() {
+ int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_INPUT, INPUT_AUTO);
+ inputDevices.clear();
+ List<String> labels = new ArrayList<>();
+ labels.add(getString(R.string.input_auto));
+ labels.add(getString(R.string.input_phone));
+
+ AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ if (manager != null
+ && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
+ == PackageManager.PERMISSION_GRANTED) {
+ try {
+ for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
+ // The built-in mic is already covered by the "Phone mic" entry.
+ if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) {
+ continue;
+ }
+ inputDevices.add(device);
+ labels.add(describeInput(device));
+ }
+ } catch (Exception ignored) {
+ // No device list available.
+ }
+ }
+
+ ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item, labels);
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ inputSpinner.setAdapter(adapter);
+
+ if (selected == INPUT_PHONE) {
+ inputSpinner.setSelection(1);
+ return;
+ }
+ for (int i = 0; i < inputDevices.size(); i++) {
+ if (inputDevices.get(i).getId() == selected) {
+ inputSpinner.setSelection(i + 2);
+ return;
+ }
+ }
+ inputSpinner.setSelection(0);
+ }
+
+ private InputChoice resolveInput() {
+ int position = inputSpinner.getSelectedItemPosition();
+ if (position == 1) {
+ return new InputChoice(findBuiltinMicId(), false);
+ }
+ if (position >= 2 && position - 2 < inputDevices.size()) {
+ AudioDeviceInfo device = inputDevices.get(position - 2);
+ return new InputChoice(device.getId(), isBluetoothInput(device));
+ }
+ return new InputChoice(INPUT_AUTO, false);
+ }
+
+ private int selectedInputId() {
+ int position = inputSpinner.getSelectedItemPosition();
+ if (position == 1) {
+ return INPUT_PHONE;
+ }
+ if (position >= 2 && position - 2 < inputDevices.size()) {
+ return inputDevices.get(position - 2).getId();
+ }
+ return INPUT_AUTO;
+ }
+
+ private boolean selectedInputIsBluetooth() {
+ int position = inputSpinner.getSelectedItemPosition();
+ return position >= 2 && position - 2 < inputDevices.size()
+ && isBluetoothInput(inputDevices.get(position - 2));
+ }
+
+ private int findBuiltinMicId() {
+ AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE);
+ if (manager == null) {
+ return INPUT_AUTO;
+ }
+ try {
+ for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
+ if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) {
+ return device.getId();
+ }
+ }
+ } catch (Exception ignored) {
+ // Fall through to auto.
+ }
+ return INPUT_AUTO;
+ }
+
+ private static boolean isBluetoothInput(AudioDeviceInfo device) {
+ int type = device.getType();
+ return type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
+ || type == AudioDeviceInfo.TYPE_BLE_HEADSET
+ || type == AudioDeviceInfo.TYPE_BLE_SPEAKER;
+ }
+
+ private static String describeInput(AudioDeviceInfo device) {
+ String type;
+ switch (device.getType()) {
+ case AudioDeviceInfo.TYPE_BLUETOOTH_SCO:
+ type = "Bluetooth (SCO)";
+ break;
+ case AudioDeviceInfo.TYPE_BLE_HEADSET:
+ type = "Bluetooth LE headset";
+ break;
+ case AudioDeviceInfo.TYPE_BLE_SPEAKER:
+ type = "Bluetooth LE speaker";
+ break;
+ case AudioDeviceInfo.TYPE_HEARING_AID:
+ type = "Hearing aid";
+ break;
+ case AudioDeviceInfo.TYPE_USB_DEVICE:
+ type = "USB device";
+ break;
+ case AudioDeviceInfo.TYPE_USB_HEADSET:
+ type = "USB headset";
+ break;
+ case AudioDeviceInfo.TYPE_WIRED_HEADSET:
+ type = "Wired headset";
+ break;
+ default:
+ type = "Input " + device.getType();
+ break;
+ }
+ CharSequence name = device.getProductName();
+ if (name != null && name.length() > 0) {
+ type += " \u2014 " + name;
+ }
+ return type;
+ }
+
+ private void addMarker() {
+ if (!recording) {
+ return;
+ }
+ final long offset = recorder.currentSampleOffset();
+ final int next = recorder.getMarkers().size() + 1;
+
+ EditText input = new EditText(this);
+ input.setInputType(InputType.TYPE_CLASS_TEXT);
+ input.setHint(R.string.marker_hint);
+ input.setText(getString(R.string.marker_fmt, next));
+ input.selectAll();
+
+ int padding = (int) (20 * getResources().getDisplayMetrics().density);
+ FrameLayout container = new FrameLayout(this);
+ container.setPadding(padding, padding / 2, padding, 0);
+ container.addView(input);
+
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.marker_title)
+ .setView(container)
+ .setPositiveButton(R.string.marker_add, (dialog, which) -> {
+ String label = input.getText().toString().trim();
+ if (label.isEmpty()) {
+ label = getString(R.string.marker_fmt, next);
+ }
+ recorder.addMarkerAt(offset, label);
+ Toast.makeText(this, getString(R.string.marker_added_fmt, next),
+ Toast.LENGTH_SHORT).show();
+ })
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+
+ private void ensureDndAccess() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return;
+ }
+ NotificationManager manager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager == null || manager.isNotificationPolicyAccessGranted()) {
+ return;
+ }
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.dnd_title)
+ .setMessage(R.string.dnd_message)
+ .setPositiveButton(R.string.dnd_open_settings, (dialog, which) -> {
+ try {
+ startActivity(new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS));
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ })
+ .setNegativeButton(R.string.cancel,
+ (dialog, which) -> silenceCheckbox.setChecked(false))
+ .show();
+ }
+
+ /** Enables total-silence Do Not Disturb while recording, then restores it. */
+ private void applySilence(boolean enable) {
+ if (silenceCheckbox == null || !silenceCheckbox.isChecked()) {
+ return;
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ return;
+ }
+ NotificationManager manager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager == null || !manager.isNotificationPolicyAccessGranted()) {
+ return;
+ }
+ try {
+ if (enable) {
+ if (savedInterruptionFilter == -1) {
+ savedInterruptionFilter = manager.getCurrentInterruptionFilter();
+ }
+ manager.setInterruptionFilter(NotificationManager.INTERRUPTION_FILTER_NONE);
+ } else if (savedInterruptionFilter != -1) {
+ manager.setInterruptionFilter(savedInterruptionFilter);
+ savedInterruptionFilter = -1;
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+
+ private void startRecordingService() {
+ try {
+ ContextCompat.startForegroundService(this,
+ new Intent(this, RecordingService.class)
+ .setAction(RecordingService.ACTION_START));
+ } catch (Exception ignored) {
+ // Recording still works while the app is in the foreground.
+ }
+ }
+
+ private void stopRecordingService() {
+ try {
+ stopService(new Intent(this, RecordingService.class));
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+
+ private void startRecording() {
+ InputChoice input = resolveInput();
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putInt(PREF_INPUT, selectedInputId()).apply();
+ recorder.setPreferredDeviceId(input.deviceId);
+ recorder.setBluetoothSco(input.bluetoothSco);
+ recorder.setSwapChannels(swapCheckbox.isChecked());
+ recorder.setForceMono(monoCheckbox.isChecked());
+ recorder.setMonitoring(monitorCheckbox.isChecked());
+ recorder.setMonitorDeviceId(resolveOutputDeviceId());
+ getSharedPreferences(PREFS, MODE_PRIVATE)
+ .edit().putInt(PREF_OUTPUT, selectedOutputId()).apply();
+ try {
+ recorder.start();
+ } catch (Exception e) {
+ Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show();
+ return;
+ }
+ if (monitorCheckbox.isChecked() && !recorder.isMonitoring()) {
+ Toast.makeText(this, R.string.err_monitor, Toast.LENGTH_LONG).show();
+ }
+
+ recording = true;
+ startedAtMs = SystemClock.elapsedRealtime();
+
+ startRecordingService();
+ levelMeter.reset();
+ waveformView.reset();
+ markButton.setEnabled(true);
+ discardButton.setEnabled(true);
+ applySilence(true);
+ recordButton.setBackgroundResource(R.drawable.bg_record_active);
+ recordButton.setContentDescription(getString(R.string.cd_stop));
+ recIndicator.setVisibility(View.VISIBLE);
+ blinkAnimator.start();
+ statusText.setText(R.string.status_recording);
+ timerText.setText(R.string.timer_zero);
+ getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+
+ timerRunnable = new Runnable() {
+ @Override
+ public void run() {
+ long elapsed = SystemClock.elapsedRealtime() - startedAtMs;
+ timerText.setText(formatDuration(elapsed));
+ handler.postDelayed(this, 200);
+ }
+ };
+ handler.post(timerRunnable);
+ }
+
+ private void stopRecording(boolean discard) {
+ recording = false;
+ busy = true;
+
+ stopRecordingService();
+ levelMeter.reset();
+ waveformView.reset();
+ markButton.setEnabled(false);
+ discardButton.setEnabled(false);
+ applySilence(false);
+ long elapsed = SystemClock.elapsedRealtime() - startedAtMs;
+ handler.removeCallbacks(timerRunnable);
+ blinkAnimator.cancel();
+ recIndicator.setVisibility(View.INVISIBLE);
+ getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ timerText.setText(formatDuration(elapsed));
+ recordButton.setBackgroundResource(R.drawable.bg_record_idle);
+ recordButton.setContentDescription(getString(R.string.cd_record));
+ recordButton.setEnabled(false);
+ statusText.setText(discard ? R.string.status_discarding : R.string.status_saving);
+ final boolean compress = compressCheckbox.isChecked();
+
+ new Thread(() -> {
+ File wav;
+ List<Marker> markers;
+ try {
+ wav = recorder.stop();
+ markers = recorder.getMarkers();
+ } catch (Exception e) {
+ wav = null;
+ markers = Collections.emptyList();
+ }
+
+ String savedName = null;
+ boolean discarded = false;
+ if (wav != null && discard) {
+ //noinspection ResultOfMethodCallIgnored
+ wav.delete();
+ discarded = true;
+ } else if (wav != null) {
+ try {
+ if (compress) {
+ File m4a = new File(getCacheDir(), "reccoon_recording.m4a");
+ try {
+ AudioCompressor.compressToM4a(wav, m4a);
+ savedName = saveToDownloads(
+ m4a, buildFileName(".m4a"), "audio/mp4");
+ } catch (Exception compressionFailed) {
+ savedName = null;
+ } finally {
+ //noinspection ResultOfMethodCallIgnored
+ m4a.delete();
+ }
+ if (savedName == null) {
+ // Never lose a recording just because compression failed.
+ savedName = saveToDownloads(
+ wav, buildFileName(".wav"), "audio/wav");
+ }
+ //noinspection ResultOfMethodCallIgnored
+ wav.delete();
+ } else {
+ savedName = saveToDownloads(
+ wav, buildFileName(".wav"), "audio/wav");
+ }
+ if (savedName != null && !markers.isEmpty()) {
+ new MarkerStore(this).save(savedName, markers);
+ }
+ } catch (Exception ignored) {
+ savedName = null;
+ }
+ }
+
+ final String result = savedName;
+ final boolean wasDiscarded = discarded;
+ runOnUiThread(() -> {
+ busy = false;
+ recordButton.setEnabled(true);
+ if (wasDiscarded) {
+ statusText.setText(R.string.discarded);
+ Toast.makeText(this, R.string.discarded, Toast.LENGTH_SHORT).show();
+ } else if (result != null) {
+ statusText.setText(getString(R.string.saved_fmt, result));
+ Toast.makeText(this, getString(R.string.saved_fmt, result), Toast.LENGTH_LONG).show();
+ } else {
+ statusText.setText(R.string.err_save);
+ Toast.makeText(this, R.string.err_save, Toast.LENGTH_LONG).show();
+ }
+ renderRecordings();
+ });
+ }, "reccoon-save").start();
+ }
+
+ private String saveToDownloads(File file, String fileName, String mimeType)
+ throws IOException {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveViaMediaStore(file, fileName, mimeType);
+ } else {
+ saveToPublicDownloads(file, fileName, mimeType);
+ }
+ return fileName;
+ }
+
+ private void saveViaMediaStore(File source, String fileName, String mimeType)
+ throws IOException {
+ ContentResolver resolver = getContentResolver();
+ ContentValues values = new ContentValues();
+ values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
+ values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
+ values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
+ values.put(MediaStore.MediaColumns.IS_PENDING, 1);
+
+ Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
+ if (uri == null) {
+ throw new IOException("MediaStore rejected the new file");
+ }
+
+ try (OutputStream out = resolver.openOutputStream(uri)) {
+ if (out == null) {
+ throw new IOException("MediaStore returned no output stream");
+ }
+ try (InputStream in = new FileInputStream(source)) {
+ copy(in, out);
+ }
+ }
+
+ ContentValues done = new ContentValues();
+ done.put(MediaStore.MediaColumns.IS_PENDING, 0);
+ resolver.update(uri, done, null, null);
+ }
+
+ @SuppressWarnings("deprecation")
+ private void saveToPublicDownloads(File source, String fileName, String mimeType)
+ throws IOException {
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Could not create the Downloads directory");
+ }
+ File outFile = new File(dir, fileName);
+ try (InputStream in = new FileInputStream(source);
+ OutputStream out = new FileOutputStream(outFile)) {
+ copy(in, out);
+ }
+ MediaScannerConnection.scanFile(
+ this,
+ new String[]{outFile.getAbsolutePath()},
+ new String[]{mimeType},
+ null);
+ }
+
+ private String buildFileName(String extension) {
+ String stamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date());
+ SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE);
+ int next = prefs.getInt(PREF_COUNTER, 0) + 1;
+ prefs.edit().putInt(PREF_COUNTER, next).apply();
+ return String.format(Locale.US, "reccoon-%s-%05d%s", stamp, next, extension);
+ }
+
+ private static void copy(InputStream in, OutputStream out) throws IOException {
+ byte[] buffer = new byte[64 * 1024];
+ int read;
+ while ((read = in.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ }
+
+ private static String formatDuration(long elapsedMs) {
+ long totalSeconds = elapsedMs / 1000;
+ long hours = totalSeconds / 3600;
+ long minutes = (totalSeconds % 3600) / 60;
+ long seconds = totalSeconds % 60;
+ if (hours > 0) {
+ return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds);
+ }
+ return String.format(Locale.US, "%02d:%02d", minutes, seconds);
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/Marker.java b/app/src/main/java/com/tomcooks/reccoon/Marker.java
new file mode 100644
index 0000000..a132cee
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/Marker.java
@@ -0,0 +1,19 @@
+package com.tomcooks.reccoon;
+
+/** A user-placed marker inside a recording, stored as a WAV cue point. */
+public final class Marker {
+
+ public final int index;
+ public final long sampleOffset;
+ public final String label;
+
+ public Marker(int index, long sampleOffset, String label) {
+ this.index = index;
+ this.sampleOffset = sampleOffset;
+ this.label = label == null ? "" : label;
+ }
+
+ public long timeMillis() {
+ return sampleOffset * 1000L / WavRecorder.SAMPLE_RATE;
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java
new file mode 100644
index 0000000..a84ff87
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java
@@ -0,0 +1,110 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Stores recording markers as JSON sidecars (the same markers are also
+ * embedded in the WAV itself as {@code cue } / {@code adtl} chunks).
+ */
+public final class MarkerStore {
+
+ private final File dir;
+
+ public MarkerStore(Context context) {
+ dir = new File(context.getApplicationContext().getFilesDir(), "markers");
+ }
+
+ public boolean has(String recordingName) {
+ return fileFor(recordingName).isFile();
+ }
+
+ public void save(String recordingName, List<Marker> markers) throws IOException {
+ if (markers.isEmpty()) {
+ return;
+ }
+ JSONArray array = new JSONArray();
+ try {
+ for (Marker marker : markers) {
+ JSONObject item = new JSONObject();
+ item.put("index", marker.index);
+ item.put("offset", marker.sampleOffset);
+ item.put("label", marker.label);
+ array.put(item);
+ }
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Cannot create marker directory");
+ }
+ String json;
+ try {
+ json = array.toString(2);
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+ try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) {
+ out.write(json.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ public List<Marker> load(String recordingName) {
+ List<Marker> markers = new ArrayList<>();
+ File file = fileFor(recordingName);
+ if (!file.isFile()) {
+ return markers;
+ }
+ try (InputStream in = new java.io.FileInputStream(file)) {
+ byte[] buffer = new byte[(int) file.length()];
+ int read = 0;
+ while (read < buffer.length) {
+ int n = in.read(buffer, read, buffer.length - read);
+ if (n < 0) {
+ break;
+ }
+ read += n;
+ }
+ JSONArray array = new JSONArray(new String(buffer, 0, read, StandardCharsets.UTF_8));
+ for (int i = 0; i < array.length(); i++) {
+ JSONObject item = array.getJSONObject(i);
+ markers.add(new Marker(
+ item.optInt("index", i + 1),
+ item.optLong("offset", 0),
+ item.optString("label", "")));
+ }
+ } catch (Exception e) {
+ // Return whatever we parsed.
+ }
+ return markers;
+ }
+
+ public void delete(String recordingName) {
+ //noinspection ResultOfMethodCallIgnored
+ fileFor(recordingName).delete();
+ }
+
+ public void rename(String oldName, String newName) {
+ File from = fileFor(oldName);
+ if (from.isFile()) {
+ //noinspection ResultOfMethodCallIgnored
+ from.renameTo(fileFor(newName));
+ }
+ }
+
+ private File fileFor(String recordingName) {
+ return new File(dir, recordingName + ".json");
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java
new file mode 100644
index 0000000..1cea837
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java
@@ -0,0 +1,213 @@
+package com.tomcooks.reccoon;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.PowerManager;
+
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.ContextCompat;
+
+/**
+ * Downloads a transcription model in a foreground service so the transfer keeps
+ * going when the screen turns off or the user switches to another app. A partial
+ * wake lock keeps the CPU up for the network I/O.
+ */
+public class ModelDownloadService extends Service {
+
+ public static final String ACTION_START = "com.tomcooks.reccoon.action.DOWNLOAD_MODEL";
+ public static final String EXTRA_MODEL = "model";
+
+ private static final String CHANNEL_ID = "reccoon_model_download";
+ private static final int NOTIFICATION_ID = 2;
+ private static final long WAKE_LOCK_TIMEOUT_MS = 2 * 60 * 60 * 1000L;
+
+ public interface Listener {
+ void onProgress(long downloaded, long total, String currentFile);
+
+ void onComplete();
+
+ void onError(String message);
+ }
+
+ private static volatile Listener sListener;
+ private static volatile boolean sRunning;
+ private static volatile long sDownloaded;
+ private static volatile long sTotal;
+ private static volatile String sCurrentFile = "";
+
+ public static void setListener(Listener listener) {
+ sListener = listener;
+ }
+
+ public static boolean isRunning() {
+ return sRunning;
+ }
+
+ public static long downloaded() {
+ return sDownloaded;
+ }
+
+ public static long total() {
+ return sTotal;
+ }
+
+ public static String currentFile() {
+ return sCurrentFile;
+ }
+
+ public static void start(Context context, ModelRepository.Model model) {
+ Intent intent = new Intent(context, ModelDownloadService.class)
+ .setAction(ACTION_START)
+ .putExtra(EXTRA_MODEL, model.name());
+ ContextCompat.startForegroundService(context, intent);
+ }
+
+ private PowerManager.WakeLock wakeLock;
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent == null || !ACTION_START.equals(intent.getAction())) {
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+ ModelRepository.Model model;
+ try {
+ model = ModelRepository.Model.valueOf(intent.getStringExtra(EXTRA_MODEL));
+ } catch (Exception e) {
+ stopSelf();
+ return START_NOT_STICKY;
+ }
+
+ startForegroundCompat(getString(R.string.status_downloading, "", 0));
+
+ if (sRunning) {
+ // A download is already in flight; keep the existing one.
+ return START_NOT_STICKY;
+ }
+ sRunning = true;
+ sDownloaded = 0L;
+ sTotal = model.totalBytes();
+ sCurrentFile = "";
+ acquireWakeLock();
+
+ new Thread(() -> {
+ Listener listener = sListener;
+ try {
+ ModelRepository repository = new ModelRepository(this);
+ repository.download(model, (downloaded, total, file) -> {
+ sDownloaded = downloaded;
+ sTotal = total;
+ sCurrentFile = file;
+ int permille = (int) (downloaded * 1000 / Math.max(1, total));
+ updateNotification(getString(
+ R.string.status_downloading, file, permille / 10), permille);
+ Listener current = sListener;
+ if (current != null) {
+ current.onProgress(downloaded, total, file);
+ }
+ });
+ if (listener != null) {
+ listener.onComplete();
+ }
+ } catch (Exception e) {
+ if (listener != null) {
+ listener.onError(String.valueOf(e.getMessage()));
+ }
+ } finally {
+ sRunning = false;
+ releaseWakeLock();
+ stopForeground(true);
+ stopSelf();
+ }
+ }, "reccoon-model-download").start();
+
+ return START_NOT_STICKY;
+ }
+
+ private void startForegroundCompat(String text) {
+ Notification notification = buildNotification(text, 0);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
+ } else {
+ startForeground(NOTIFICATION_ID, notification);
+ }
+ }
+
+ private void updateNotification(String text, int permille) {
+ NotificationManager manager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager != null) {
+ manager.notify(NOTIFICATION_ID, buildNotification(text, permille));
+ }
+ }
+
+ private Notification buildNotification(String text, int permille) {
+ createChannel();
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.model_download_title))
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setProgress(1000, permille, false)
+ .build();
+ }
+
+ 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.model_download_channel),
+ NotificationManager.IMPORTANCE_LOW);
+ channel.setShowBadge(false);
+ manager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ private void acquireWakeLock() {
+ try {
+ PowerManager power = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ if (power != null) {
+ wakeLock = power.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK, "reccoon:model-download");
+ wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS);
+ }
+ } catch (Exception ignored) {
+ // Without the wake lock the download may pause when the screen is off.
+ }
+ }
+
+ private void releaseWakeLock() {
+ try {
+ if (wakeLock != null && wakeLock.isHeld()) {
+ wakeLock.release();
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ wakeLock = null;
+ }
+
+ @Override
+ public void onDestroy() {
+ releaseWakeLock();
+ sRunning = false;
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java
new file mode 100644
index 0000000..1d0e0be
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java
@@ -0,0 +1,367 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Downloads and stores the on-device speech recognition models.
+ *
+ * <p>Currently only NVIDIA Parakeet TDT 0.6B v3 (int8) is offered: a
+ * multilingual transducer (~670 MB) that was clearly the most accurate model in
+ * the evaluation harness. The enum leaves room for more models later.
+ *
+ * <p>A small Silero VAD model splits long recordings into transcribable chunks.
+ */
+public final class ModelRepository {
+
+ private static final String HF_PARAKEET =
+ "https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main/";
+ private static final String VAD_URL =
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx";
+
+ private static final long VAD_BYTES = 643_854L;
+
+ /** A model the player can transcribe with. */
+ public enum Model {
+ PARAKEET_V3("parakeet-v3", "Parakeet v3",
+ 652_184_281L, 11_845_275L, 6_355_277L, 93_939L);
+
+ /** Folder under {@code filesDir/models} (or the external equivalent). */
+ public final String dir;
+ public final String label;
+ final long encoderBytes;
+ final long decoderBytes;
+ final long joinerBytes;
+ final long tokensBytes;
+
+ Model(String dir, String label,
+ long encoderBytes, long decoderBytes, long joinerBytes, long tokensBytes) {
+ this.dir = dir;
+ this.label = label;
+ this.encoderBytes = encoderBytes;
+ this.decoderBytes = decoderBytes;
+ this.joinerBytes = joinerBytes;
+ this.tokensBytes = tokensBytes;
+ }
+
+ public long totalBytes() {
+ return encoderBytes + decoderBytes + joinerBytes + tokensBytes + VAD_BYTES;
+ }
+ }
+
+ public interface Listener {
+ void onProgress(long downloaded, long total, String currentFile);
+
+ default void onComplete() {
+ }
+ }
+
+ private static final class RemoteFile {
+ final String url;
+ final File target;
+ final long expected;
+
+ RemoteFile(String url, File target, long expected) {
+ this.url = url;
+ this.target = target;
+ this.expected = expected;
+ }
+ }
+
+ private final File root;
+
+ public ModelRepository(Context context) {
+ Context app = context.getApplicationContext();
+ // Prefer the app-specific external folder (/sdcard/Android/data/<pkg>/files)
+ // so large models can be side-loaded with adb/scp or copied over USB. Fall
+ // back to internal storage when external storage is not mounted.
+ File external = app.getExternalFilesDir(null);
+ File base = external != null ? external : app.getFilesDir();
+ root = new File(base, "models");
+ migrateLegacyModels(base);
+ }
+
+ /**
+ * Best-effort one-time migration from the old {@code com.tomcooks.reccoon} app
+ * id, so a re-branded install can reuse an already downloaded model instead
+ * of fetching it again. Android 11+ usually blocks access to another app's
+ * external directory; in that case this quietly does nothing and the model
+ * is downloaded again.
+ */
+ private void migrateLegacyModels(File base) {
+ try {
+ File packageDir = base.getParentFile();
+ File androidData = packageDir == null ? null : packageDir.getParentFile();
+ if (androidData == null) {
+ return;
+ }
+ File legacy = new File(
+ new File(new File(androidData, "com.tomcooks.reccoon"), "files"), "models");
+ if (legacy.isDirectory()) {
+ copyMissing(legacy, root);
+ }
+ } catch (Exception ignored) {
+ // Not readable (scoped storage); the model will simply be downloaded.
+ }
+ }
+
+ private static void copyMissing(File from, File to) {
+ File[] children = from.listFiles();
+ if (children == null) {
+ return;
+ }
+ if (!to.isDirectory() && !to.mkdirs()) {
+ return;
+ }
+ for (File child : children) {
+ File destination = new File(to, child.getName());
+ if (child.isDirectory()) {
+ copyMissing(child, destination);
+ } else if (!destination.exists()) {
+ copyFile(child, destination);
+ }
+ }
+ }
+
+ private static void copyFile(File from, File to) {
+ File temp = new File(to.getParentFile(), to.getName() + ".migrate");
+ try (InputStream in = new FileInputStream(from);
+ FileOutputStream out = new FileOutputStream(temp)) {
+ byte[] buffer = new byte[256 * 1024];
+ int read;
+ while ((read = in.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ }
+ out.getFD().sync();
+ //noinspection ResultOfMethodCallIgnored
+ temp.renameTo(to);
+ } catch (Exception ignored) {
+ //noinspection ResultOfMethodCallIgnored
+ temp.delete();
+ }
+ }
+
+ /** Folder that holds every model, for troubleshooting / manual side-loading. */
+ public File modelsRoot() {
+ return root;
+ }
+
+ public File vadModel() {
+ return new File(root, "silero_vad.onnx");
+ }
+
+ public File modelDir(Model model) {
+ return new File(root, model.dir);
+ }
+
+ public File encoder(Model model) {
+ return new File(modelDir(model), "encoder.int8.onnx");
+ }
+
+ public File decoder(Model model) {
+ return new File(modelDir(model), "decoder.int8.onnx");
+ }
+
+ public File joiner(Model model) {
+ return new File(modelDir(model), "joiner.int8.onnx");
+ }
+
+ public File tokens(Model model) {
+ return new File(modelDir(model), "tokens.txt");
+ }
+
+ public boolean isReady(Model model) {
+ return isVadReady() && allComplete(files(model));
+ }
+
+ public boolean isVadReady() {
+ return isComplete(vadModel(), VAD_BYTES);
+ }
+
+ private static boolean isComplete(File file, long expected) {
+ return file.isFile() && (expected <= 0 || file.length() == expected);
+ }
+
+ private List<RemoteFile> files(Model model) {
+ List<RemoteFile> files = new ArrayList<>();
+ files.add(new RemoteFile(VAD_URL, vadModel(), VAD_BYTES));
+ files.add(new RemoteFile(HF_PARAKEET + "encoder.int8.onnx",
+ encoder(model), model.encoderBytes));
+ files.add(new RemoteFile(HF_PARAKEET + "decoder.int8.onnx",
+ decoder(model), model.decoderBytes));
+ files.add(new RemoteFile(HF_PARAKEET + "joiner.int8.onnx",
+ joiner(model), model.joinerBytes));
+ files.add(new RemoteFile(HF_PARAKEET + "tokens.txt",
+ tokens(model), model.tokensBytes));
+ return files;
+ }
+
+ private boolean allComplete(List<RemoteFile> files) {
+ for (RemoteFile file : files) {
+ if (!isComplete(file.target, file.expected)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Blocking. Call from a background thread. Progress is reported through
+ * {@code listener}. Throws on failure.
+ */
+ public void download(Model model, Listener listener) throws IOException {
+ List<RemoteFile> files = files(model);
+ long total = 0;
+ long already = 0;
+ for (RemoteFile file : files) {
+ total += file.expected;
+ if (isComplete(file.target, file.expected)) {
+ already += file.expected;
+ }
+ }
+
+ Counter counter = new Counter(already, total, listener);
+ counter.report("");
+ for (RemoteFile file : files) {
+ fetch(file.url, file.target, file.expected, counter);
+ }
+ listener.onComplete();
+ }
+
+ private static final class Counter {
+ private final long already;
+ private final long total;
+ private final Listener listener;
+ private long doneInCurrent;
+ private String currentName = "";
+
+ Counter(long already, long total, Listener listener) {
+ this.already = already;
+ this.total = total;
+ this.listener = listener;
+ }
+
+ void setCurrent(String name, long done) {
+ this.currentName = name;
+ this.doneInCurrent = done;
+ listener.onProgress(Math.min(already + done, total), total, currentName);
+ }
+
+ void report(String name) {
+ listener.onProgress(Math.min(already + doneInCurrent, total), total, name);
+ }
+ }
+
+ private static final int MAX_ATTEMPTS = 6;
+
+ private static void fetch(String url, File target, long expected, Counter counter)
+ throws IOException {
+ if (isComplete(target, expected)) {
+ return;
+ }
+ File parent = target.getParentFile();
+ if (parent != null && !parent.exists() && !parent.mkdirs()) {
+ throw new IOException("Cannot create " + parent);
+ }
+ File part = new File(parent, target.getName() + ".part");
+
+ IOException lastError = null;
+ for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ try {
+ downloadOnce(url, target, part, expected, counter);
+ return;
+ } catch (IOException e) {
+ lastError = e;
+ // Keep the .part file: the next attempt resumes with a Range request.
+ sleepQuietly(attempt);
+ }
+ }
+ throw lastError != null ? lastError : new IOException("Download failed: " + url);
+ }
+
+ private static void downloadOnce(String url, File target, File part, long expected,
+ Counter counter) throws IOException {
+ long already = part.isFile() ? part.length() : 0L;
+ if (expected > 0 && already >= expected) {
+ if (already == expected) {
+ finish(part, target, expected);
+ return;
+ }
+ //noinspection ResultOfMethodCallIgnored
+ part.delete();
+ already = 0L;
+ }
+
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ connection.setInstanceFollowRedirects(true);
+ connection.setConnectTimeout(30_000);
+ connection.setReadTimeout(120_000);
+ connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)");
+ connection.setRequestProperty("Accept-Encoding", "identity");
+ if (already > 0) {
+ connection.setRequestProperty("Range", "bytes=" + already + "-");
+ }
+ try {
+ int code = connection.getResponseCode();
+ if (code == HttpURLConnection.HTTP_OK) {
+ // The server ignored the Range request: start the file over.
+ already = 0L;
+ } else if (code != HttpURLConnection.HTTP_PARTIAL) {
+ throw new IOException("HTTP " + code + " for " + url);
+ }
+
+ try (InputStream in = connection.getInputStream();
+ FileOutputStream out = new FileOutputStream(part, already > 0)) {
+ byte[] buffer = new byte[256 * 1024];
+ long done = already;
+ int read;
+ long lastReport = 0;
+ while ((read = in.read(buffer)) > 0) {
+ out.write(buffer, 0, read);
+ done += read;
+ long now = System.currentTimeMillis();
+ if (now - lastReport > 200) {
+ lastReport = now;
+ counter.setCurrent(target.getName(), done);
+ }
+ }
+ out.getFD().sync();
+ counter.setCurrent(target.getName(), done);
+ }
+
+ finish(part, target, expected);
+ } finally {
+ connection.disconnect();
+ }
+ }
+
+ private static void finish(File part, File target, long expected) throws IOException {
+ if (expected > 0 && part.length() != expected) {
+ throw new IOException("Incomplete download for " + target.getName()
+ + " (have " + part.length() + " of " + expected + " bytes)");
+ }
+ if (target.exists() && !target.delete()) {
+ throw new IOException("Cannot replace " + target);
+ }
+ if (!part.renameTo(target)) {
+ throw new IOException("Cannot move download into place: " + target);
+ }
+ }
+
+ private static void sleepQuietly(int attempt) {
+ try {
+ Thread.sleep(Math.min(8_000L, 500L * (1L << Math.min(attempt, 4))));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java
new file mode 100644
index 0000000..9987d09
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java
@@ -0,0 +1,239 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.media.MediaCodec;
+import android.media.MediaExtractor;
+import android.media.MediaFormat;
+import android.net.Uri;
+
+import java.io.BufferedInputStream;
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * A streaming source of 16-bit PCM samples.
+ *
+ * <p>Handles both the uncompressed WAV files that RECCoon records and the
+ * compressed files (AAC/M4A, Opus, ...) that the compact mode produces. WAV is
+ * parsed directly; everything else is decoded with {@link MediaCodec}. This is
+ * what lets transcription keep working on compressed recordings.
+ */
+public final class PcmAudioSource implements Closeable {
+
+ private static final long CODEC_TIMEOUT_US = 10_000;
+
+ /** Sample rate of the returned PCM. */
+ public volatile int sampleRate;
+ /** Number of interleaved channels in the returned PCM. */
+ public volatile int channels;
+ /** PCM size in bytes at {@link #sampleRate}, or 0 when unknown. */
+ public final long totalBytes;
+
+ private final InputStream wav;
+ private long wavRemaining;
+
+ private final MediaExtractor extractor;
+ private final MediaCodec codec;
+ private boolean inputDone;
+ private boolean outputDone;
+ private byte[] pending;
+ private int pendingPos;
+ private int pendingLen;
+
+ private PcmAudioSource(int sampleRate, int channels, long totalBytes,
+ InputStream wav, long wavRemaining,
+ MediaExtractor extractor, MediaCodec codec) {
+ this.sampleRate = sampleRate;
+ this.channels = channels;
+ this.totalBytes = totalBytes;
+ this.wav = wav;
+ this.wavRemaining = wavRemaining;
+ this.extractor = extractor;
+ this.codec = codec;
+ }
+
+ /**
+ * Opens a WAV file when possible, otherwise falls back to a compressed
+ * decoder. Throws when neither path can read the URI.
+ */
+ public static PcmAudioSource open(Context context, Uri uri) throws IOException {
+ InputStream raw = context.getContentResolver().openInputStream(uri);
+ if (raw == null) {
+ throw new IOException("Cannot open " + uri);
+ }
+ BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024);
+ try {
+ TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(buffered);
+ if (info.bitsPerSample != 16 || info.audioFormat != 1) {
+ throw new IOException("Only 16-bit PCM WAV files are supported");
+ }
+ long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE;
+ return new PcmAudioSource(Math.max(1, info.sampleRate), Math.max(1, info.channels),
+ info.dataSize, buffered, remaining, null, null);
+ } catch (IOException notWav) {
+ closeQuietly(buffered);
+ }
+ return openCodec(context, uri);
+ }
+
+ private static PcmAudioSource openCodec(Context context, Uri uri) throws IOException {
+ MediaExtractor extractor = new MediaExtractor();
+ try {
+ extractor.setDataSource(context, uri, null);
+ } catch (IOException e) {
+ extractor.release();
+ throw new IOException("Unsupported audio file: " + uri, e);
+ }
+
+ int track = -1;
+ MediaFormat format = null;
+ for (int i = 0; i < extractor.getTrackCount(); i++) {
+ MediaFormat candidate = extractor.getTrackFormat(i);
+ String mime = candidate.getString(MediaFormat.KEY_MIME);
+ if (mime != null && mime.startsWith("audio/")) {
+ track = i;
+ format = candidate;
+ break;
+ }
+ }
+ if (track < 0 || format == null) {
+ extractor.release();
+ throw new IOException("No audio track in " + uri);
+ }
+ extractor.selectTrack(track);
+
+ String mime = format.getString(MediaFormat.KEY_MIME);
+ int sampleRate = format.containsKey(MediaFormat.KEY_SAMPLE_RATE)
+ ? format.getInteger(MediaFormat.KEY_SAMPLE_RATE) : 0;
+ int channels = format.containsKey(MediaFormat.KEY_CHANNEL_COUNT)
+ ? format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) : 1;
+ long durationUs = format.containsKey(MediaFormat.KEY_DURATION)
+ ? format.getLong(MediaFormat.KEY_DURATION) : 0L;
+ long totalBytes = sampleRate > 0
+ ? durationUs * sampleRate * Math.max(1, channels) * 2 / 1_000_000L : 0L;
+
+ MediaCodec codec;
+ try {
+ codec = MediaCodec.createDecoderByType(mime);
+ codec.configure(format, null, null, 0);
+ codec.start();
+ } catch (Exception e) {
+ extractor.release();
+ throw new IOException("Cannot decode " + mime, e);
+ }
+ return new PcmAudioSource(sampleRate, Math.max(1, channels), totalBytes,
+ null, 0L, extractor, codec);
+ }
+
+ /**
+ * Reads up to {@code length} bytes of interleaved 16-bit PCM. Returns the
+ * number of bytes read, or -1 at end of stream.
+ */
+ public int read(byte[] out, int offset, int length) throws IOException {
+ if (wav != null) {
+ if (wavRemaining <= 0) {
+ return -1;
+ }
+ int toRead = (int) Math.min(length, wavRemaining);
+ int total = 0;
+ while (total < toRead) {
+ int n = wav.read(out, offset + total, toRead - total);
+ if (n < 0) {
+ break;
+ }
+ total += n;
+ }
+ wavRemaining -= total;
+ return total == 0 ? -1 : total;
+ }
+
+ int written = 0;
+ while (written < length) {
+ if (pending != null && pendingPos < pendingLen) {
+ int n = Math.min(length - written, pendingLen - pendingPos);
+ System.arraycopy(pending, pendingPos, out, offset + written, n);
+ pendingPos += n;
+ written += n;
+ continue;
+ }
+ if (outputDone) {
+ break;
+ }
+ decodeStep();
+ }
+ return written == 0 ? -1 : written;
+ }
+
+ private void decodeStep() throws IOException {
+ if (!inputDone) {
+ int inIndex = codec.dequeueInputBuffer(CODEC_TIMEOUT_US);
+ if (inIndex >= 0) {
+ ByteBuffer input = codec.getInputBuffer(inIndex);
+ int size = input == null ? -1 : extractor.readSampleData(input, 0);
+ if (size < 0) {
+ codec.queueInputBuffer(inIndex, 0, 0, 0,
+ MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ inputDone = true;
+ } else {
+ codec.queueInputBuffer(inIndex, 0, size, extractor.getSampleTime(), 0);
+ extractor.advance();
+ }
+ }
+ }
+
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ int outIndex = codec.dequeueOutputBuffer(info, CODEC_TIMEOUT_US);
+ if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ MediaFormat output = codec.getOutputFormat();
+ if (output.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ sampleRate = output.getInteger(MediaFormat.KEY_SAMPLE_RATE);
+ }
+ if (output.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ channels = Math.max(1, output.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ } else if (outIndex >= 0) {
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ outputDone = true;
+ }
+ ByteBuffer output = codec.getOutputBuffer(outIndex);
+ if (info.size > 0 && output != null) {
+ byte[] chunk = new byte[info.size];
+ output.position(info.offset);
+ output.limit(info.offset + info.size);
+ output.get(chunk);
+ pending = chunk;
+ pendingPos = 0;
+ pendingLen = info.size;
+ }
+ codec.releaseOutputBuffer(outIndex, false);
+ }
+ }
+
+ @Override
+ public void close() {
+ closeQuietly(wav);
+ if (extractor != null) {
+ extractor.release();
+ }
+ if (codec != null) {
+ try {
+ codec.stop();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ codec.release();
+ }
+ }
+
+ private static void closeQuietly(InputStream stream) {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException ignored) {
+ // Ignore.
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java
new file mode 100644
index 0000000..6d7d3f4
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java
@@ -0,0 +1,647 @@
+package com.tomcooks.reccoon;
+
+import android.content.Intent;
+import android.graphics.Color;
+import android.media.MediaPlayer;
+import android.media.PlaybackParams;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.method.LinkMovementMethod;
+import android.text.style.ClickableSpan;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.SeekBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.io.File;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Plays a saved recording with a scrubber and transcribes it on device with
+ * sherpa-onnx (multilingual Whisper: English and Italian).
+ */
+public class PlayerActivity extends AppCompatActivity {
+
+ public static final String EXTRA_NAME = "name";
+ public static final String EXTRA_URI = "uri";
+
+ private static final float[] SPEEDS = {1.0f, 1.5f, 2.0f, 0.5f};
+
+ private String recordingName;
+ private Uri recordingUri;
+
+ private MediaPlayer player;
+ private boolean prepared;
+
+ private TextView titleText;
+ private TextView timeText;
+ private TextView statusText;
+ private SeekBar seekBar;
+ private Button playButton;
+ private Button speedButton;
+ private Button transcribeButton;
+ private Spinner modelSpinner;
+ private ProgressBar progressBar;
+ private LinearLayout transcriptContainer;
+ private LinearLayout markersSection;
+ private LinearLayout markersContainer;
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private boolean userSeeking;
+ private float speed = SPEEDS[0];
+ private int speedIndex;
+ private volatile boolean cancelled;
+
+ private ModelRepository models;
+ private TranscriptStore transcripts;
+ private MarkerStore markerStore;
+ private ModelRepository.Model downloadingModel;
+
+ private final ModelDownloadService.Listener downloadListener =
+ new ModelDownloadService.Listener() {
+ @Override
+ public void onProgress(long downloaded, long total, String file) {
+ runOnUiThread(() -> {
+ int permille = (int) (downloaded * 1000 / Math.max(1, total));
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(permille);
+ statusText.setText(getString(
+ R.string.status_downloading, file, permille / 10));
+ });
+ }
+
+ @Override
+ public void onComplete() {
+ runOnUiThread(() -> {
+ statusText.setText(R.string.status_model_ready);
+ if (downloadingModel != null) {
+ runTranscription(downloadingModel);
+ }
+ });
+ }
+
+ @Override
+ public void onError(String message) {
+ runOnUiThread(() -> {
+ transcribeButton.setEnabled(true);
+ progressBar.setVisibility(View.GONE);
+ statusText.setText(getString(R.string.err_download, message));
+ });
+ }
+ };
+
+ private final Runnable ticker = new Runnable() {
+ @Override
+ public void run() {
+ updateProgress();
+ handler.postDelayed(this, 250);
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_player);
+
+ Intent intent = getIntent();
+ recordingName = intent.getStringExtra(EXTRA_NAME);
+ String uriString = intent.getStringExtra(EXTRA_URI);
+ if (recordingName == null || uriString == null) {
+ finish();
+ return;
+ }
+ recordingUri = Uri.parse(uriString);
+
+ models = new ModelRepository(this);
+ transcripts = new TranscriptStore(this);
+ markerStore = new MarkerStore(this);
+
+ titleText = findViewById(R.id.titleText);
+ timeText = findViewById(R.id.timeText);
+ statusText = findViewById(R.id.statusText);
+ seekBar = findViewById(R.id.seekBar);
+ playButton = findViewById(R.id.playButton);
+ speedButton = findViewById(R.id.speedButton);
+ transcribeButton = findViewById(R.id.transcribeButton);
+ modelSpinner = findViewById(R.id.modelSpinner);
+ progressBar = findViewById(R.id.progressBar);
+ transcriptContainer = findViewById(R.id.transcriptContainer);
+ markersSection = findViewById(R.id.markersSection);
+ markersContainer = findViewById(R.id.markersContainer);
+
+ titleText.setText(Formats.prettyName(recordingName));
+
+ setUpSpinners();
+ setUpButtons();
+ setUpPlayer();
+
+ InsetsHelper.apply(findViewById(R.id.root));
+ showMarkers(loadMarkers());
+
+ TranscriptionEngine.Result existing = transcripts.load(recordingName);
+ if (existing != null) {
+ showTranscript(existing);
+ } else {
+ statusText.setText(R.string.transcribe_hint);
+ }
+
+ handler.post(ticker);
+ }
+
+ private void setUpSpinners() {
+ ModelRepository.Model[] allModels = ModelRepository.Model.values();
+ String[] modelLabels = new String[allModels.length];
+ for (int i = 0; i < allModels.length; i++) {
+ modelLabels[i] = allModels[i].label;
+ }
+ ArrayAdapter<String> modelAdapter = new ArrayAdapter<>(this,
+ android.R.layout.simple_spinner_item, modelLabels);
+ modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ modelSpinner.setAdapter(modelAdapter);
+ }
+
+ private void setUpButtons() {
+ playButton.setOnClickListener(v -> togglePlay());
+ speedButton.setOnClickListener(v -> cycleSpeed());
+ findViewById(R.id.rewindButton).setOnClickListener(v -> seekBy(-10_000));
+ findViewById(R.id.forwardButton).setOnClickListener(v -> seekBy(10_000));
+ findViewById(R.id.shareButton).setOnClickListener(v -> shareRecording());
+ findViewById(R.id.renameButton).setOnClickListener(v -> showRenameDialog());
+ findViewById(R.id.exportButton).setOnClickListener(v -> exportTranscript());
+ findViewById(R.id.deleteButton).setOnClickListener(v -> confirmDelete());
+ transcribeButton.setOnClickListener(v -> onTranscribeClicked());
+
+ seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ @Override
+ public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) {
+ if (fromUser) {
+ timeText.setText(Formats.clock(progress) + " / " + Formats.clock(duration()));
+ }
+ }
+
+ @Override
+ public void onStartTrackingTouch(SeekBar bar) {
+ userSeeking = true;
+ }
+
+ @Override
+ public void onStopTrackingTouch(SeekBar bar) {
+ userSeeking = false;
+ if (player != null && prepared) {
+ player.seekTo(bar.getProgress());
+ }
+ }
+ });
+ }
+
+ private void setUpPlayer() {
+ player = new MediaPlayer();
+ prepared = false;
+ try {
+ player.setDataSource(this, recordingUri);
+ player.setOnPreparedListener(mp -> {
+ prepared = true;
+ seekBar.setMax(mp.getDuration());
+ updateTimeLabel();
+ updatePlayButton(); // no autoplay
+ });
+ player.setOnCompletionListener(mp -> updatePlayButton());
+ player.prepareAsync();
+ } catch (Exception e) {
+ Toast.makeText(this, R.string.err_playback, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private int duration() {
+ if (player == null || !prepared) {
+ return 0;
+ }
+ try {
+ return player.getDuration();
+ } catch (Exception e) {
+ return 0;
+ }
+ }
+
+ private void togglePlay() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ if (player.isPlaying()) {
+ player.pause();
+ } else {
+ if (player.getCurrentPosition() >= player.getDuration() - 150) {
+ player.seekTo(0);
+ }
+ player.start();
+ applySpeed();
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updatePlayButton();
+ }
+
+ private void seekBy(int deltaMs) {
+ if (player == null || !prepared) {
+ return;
+ }
+ int target = Math.max(0, Math.min(player.getCurrentPosition() + deltaMs, player.getDuration()));
+ player.seekTo(target);
+ seekBar.setProgress(target);
+ updateTimeLabel();
+ }
+
+ private void cycleSpeed() {
+ speedIndex = (speedIndex + 1) % SPEEDS.length;
+ speed = SPEEDS[speedIndex];
+ speedButton.setText(String.format(java.util.Locale.US, "%.1fĂ—", speed));
+ applySpeed();
+ }
+
+ private void applySpeed() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ PlaybackParams params = player.getPlaybackParams();
+ params.setSpeed(speed);
+ player.setPlaybackParams(params);
+ } catch (Exception ignored) {
+ // Some devices reject speed changes while paused.
+ }
+ }
+
+ private void updateProgress() {
+ if (player == null || !prepared) {
+ return;
+ }
+ try {
+ if (!userSeeking) {
+ seekBar.setProgress(player.getCurrentPosition());
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updateTimeLabel();
+ updatePlayButton();
+ }
+
+ private void updateTimeLabel() {
+ timeText.setText(Formats.clock(player != null && prepared ? player.getCurrentPosition() : 0)
+ + " / " + Formats.clock(duration()));
+ }
+
+ private void updatePlayButton() {
+ boolean playing = false;
+ if (player != null && prepared) {
+ try {
+ playing = player.isPlaying();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+ playButton.setText(playing ? R.string.player_pause : R.string.player_play);
+ }
+
+ // --- Library actions --------------------------------------------------
+
+ private List<Marker> loadMarkers() {
+ List<Marker> markers = markerStore.load(recordingName);
+ if (markers.isEmpty()) {
+ markers = WavMarkers.read(this, recordingUri);
+ }
+ return markers;
+ }
+
+ private void shareRecording() {
+ try {
+ Uri shareUri = RecordingActions.shareableUri(this, recordingUri);
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ intent.setType(recordingName.toLowerCase(Locale.US).endsWith(".m4a")
+ ? "audio/mp4" : "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();
+ String lower = requested.toLowerCase(Locale.US);
+ if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) {
+ requested += recordingName.toLowerCase(Locale.US).endsWith(".m4a")
+ ? ".m4a" : ".wav";
+ }
+ String renamed = RecordingActions.rename(
+ this, recordingUri, recordingName, requested);
+ if (renamed == null) {
+ Toast.makeText(this, R.string.rename_failed, Toast.LENGTH_LONG).show();
+ return;
+ }
+ if ("file".equals(recordingUri.getScheme())) {
+ File parent = new File(recordingUri.getPath()).getParentFile();
+ recordingUri = Uri.fromFile(new File(parent, renamed));
+ }
+ recordingName = renamed;
+ titleText.setText(Formats.prettyName(recordingName));
+ showMarkers(loadMarkers());
+ Toast.makeText(this, R.string.renamed, Toast.LENGTH_SHORT).show();
+ })
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+
+ private void exportTranscript() {
+ TranscriptionEngine.Result result = transcripts.load(recordingName);
+ if (result == null || result.segments.isEmpty()) {
+ Toast.makeText(this, R.string.export_none, Toast.LENGTH_LONG).show();
+ return;
+ }
+ try {
+ RecordingActions.exportTranscript(this, recordingName, result);
+ Toast.makeText(this, R.string.exported, Toast.LENGTH_LONG).show();
+ } catch (Exception e) {
+ Toast.makeText(this, getString(R.string.export_failed, String.valueOf(e.getMessage())),
+ Toast.LENGTH_LONG).show();
+ }
+ }
+
+ // --- Transcription ----------------------------------------------------
+
+ private void onTranscribeClicked() {
+ ModelRepository.Model[] allModels = ModelRepository.Model.values();
+ int position = modelSpinner.getSelectedItemPosition();
+ if (position < 0 || position >= allModels.length) {
+ position = 0;
+ }
+ ModelRepository.Model model = allModels[position];
+
+ if (!models.isReady(model)) {
+ promptDownload(model);
+ } else {
+ runTranscription(model);
+ }
+ }
+
+ private void promptDownload(ModelRepository.Model model) {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.model_download_title)
+ .setMessage(getString(R.string.model_download_message,
+ model.label, Formats.humanBytes(model.totalBytes())))
+ .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model))
+ .setNegativeButton(R.string.cancel, null)
+ .show();
+ }
+
+ private void runDownload(ModelRepository.Model model) {
+ downloadingModel = model;
+ transcribeButton.setEnabled(false);
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(0);
+ statusText.setText(R.string.status_preparing);
+ // The download runs in a foreground service so it survives the screen
+ // turning off or the app losing focus.
+ ModelDownloadService.setListener(downloadListener);
+ ModelDownloadService.start(this, model);
+ }
+
+ private void runTranscription(ModelRepository.Model model) {
+ cancelled = false;
+ transcribeButton.setEnabled(false);
+ transcriptContainer.removeAllViews();
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(true);
+ statusText.setText(R.string.status_preparing);
+
+ new Thread(() -> {
+ TranscriptionEngine.Result result;
+ try {
+ TranscriptionEngine engine = new TranscriptionEngine(PlayerActivity.this);
+ result = engine.transcribe(recordingUri, model,
+ new TranscriptionEngine.Callback() {
+ @Override
+ public void onProgress(int percent, String message) {
+ runOnUiThread(() -> {
+ progressBar.setIndeterminate(false);
+ progressBar.setProgress(percent * 10);
+ statusText.setText(message);
+ });
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+ });
+ } catch (Exception e) {
+ runOnUiThread(() -> statusText.setText(
+ getString(R.string.err_transcribe, String.valueOf(e.getMessage()))));
+ return;
+ } finally {
+ runOnUiThread(() -> {
+ transcribeButton.setEnabled(true);
+ progressBar.setVisibility(View.GONE);
+ });
+ }
+
+ if (result == null) {
+ return;
+ }
+ try {
+ transcripts.save(recordingName, result);
+ } catch (Exception ignored) {
+ // The transcript is still shown below.
+ }
+ runOnUiThread(() -> showTranscript(result));
+ }, "transcribe").start();
+ }
+
+ private void showTranscript(TranscriptionEngine.Result result) {
+ transcriptContainer.removeAllViews();
+ if (result.segments.isEmpty()) {
+ statusText.setText(R.string.status_no_speech);
+ return;
+ }
+ for (TranscriptionEngine.Segment segment : result.segments) {
+ addTranscriptLine(segment);
+ }
+ statusText.setText(R.string.status_transcript_saved);
+ }
+
+ private void addTranscriptLine(TranscriptionEngine.Segment segment) {
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ builder.append("[").append(Formats.clock((int) (segment.startSeconds * 1000))).append("] ");
+
+ if (segment.words.isEmpty()) {
+ builder.append(segment.text);
+ } else {
+ for (TranscriptionEngine.Word word : segment.words) {
+ int start = builder.length();
+ builder.append(word.text);
+ int end = builder.length();
+ if (end > start) {
+ final int seekMs = (int) (word.startSeconds * 1000);
+ builder.setSpan(new ClickableSpan() {
+ @Override
+ public void onClick(@NonNull View widget) {
+ seekTo(seekMs);
+ }
+
+ @Override
+ public void updateDrawState(@NonNull TextPaint ds) {
+ ds.setColor(getResources().getColor(R.color.text_primary, getTheme()));
+ ds.setUnderlineText(false);
+ }
+ }, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ }
+
+ TextView view = new TextView(this);
+ view.setText(builder);
+ view.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
+ view.setTextSize(15f);
+ view.setPadding(0, 12, 0, 12);
+ view.setMovementMethod(LinkMovementMethod.getInstance());
+ view.setHighlightColor(Color.TRANSPARENT);
+ view.setOnClickListener(v -> seekTo((int) (segment.startSeconds * 1000)));
+ transcriptContainer.addView(view);
+ }
+
+ private void showMarkers(List<Marker> markers) {
+ markersContainer.removeAllViews();
+ markersSection.setVisibility(markers.isEmpty() ? View.GONE : View.VISIBLE);
+ float density = getResources().getDisplayMetrics().density;
+ for (Marker marker : markers) {
+ TextView chip = new TextView(this);
+ StringBuilder text = new StringBuilder();
+ text.append('M').append(marker.index)
+ .append(" ").append(Formats.clock((int) marker.timeMillis()));
+ if (!marker.label.isEmpty()) {
+ text.append(" ").append(marker.label);
+ }
+ chip.setText(text.toString());
+ chip.setTextColor(getResources().getColor(R.color.text_primary, getTheme()));
+ chip.setTextSize(13f);
+ chip.setPadding((int) (12 * density), (int) (6 * density),
+ (int) (12 * density), (int) (6 * density));
+ chip.setBackgroundResource(R.drawable.badge_marker);
+ chip.setOnClickListener(v -> seekTo((int) marker.timeMillis()));
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ params.rightMargin = (int) (8 * density);
+ chip.setLayoutParams(params);
+ markersContainer.addView(chip);
+ }
+ }
+
+ private void seekTo(int millis) {
+ if (player == null || !prepared) {
+ return;
+ }
+ int target = Math.max(0, Math.min(millis, player.getDuration()));
+ player.seekTo(target);
+ seekBar.setProgress(target);
+ if (!player.isPlaying()) {
+ player.start();
+ applySpeed();
+ }
+ updateTimeLabel();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (ModelDownloadService.isRunning()) {
+ ModelDownloadService.setListener(downloadListener);
+ progressBar.setVisibility(View.VISIBLE);
+ progressBar.setIndeterminate(false);
+ long done = ModelDownloadService.downloaded();
+ long total = ModelDownloadService.total();
+ int permille = (int) (done * 1000 / Math.max(1, total));
+ progressBar.setProgress(permille);
+ statusText.setText(getString(R.string.status_downloading,
+ ModelDownloadService.currentFile(), permille / 10));
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ if (player != null && prepared) {
+ try {
+ if (player.isPlaying()) {
+ player.pause();
+ }
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ updatePlayButton();
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ cancelled = true;
+ ModelDownloadService.setListener(null);
+ handler.removeCallbacksAndMessages(null);
+ if (player != null) {
+ try {
+ player.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ player = null;
+ }
+ super.onDestroy();
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java
new file mode 100644
index 0000000..3a110eb
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java
@@ -0,0 +1,154 @@
+package com.tomcooks.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()) {
+ return null;
+ }
+ newName = newName.trim();
+ String lower = newName.toLowerCase(Locale.US);
+ if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) {
+ return null;
+ }
+ String mime = lower.endsWith(".m4a") ? "audio/mp4" : "audio/wav";
+ 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[]{mime}, 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.endsWith(".m4a"))
+ ? 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/tomcooks/reccoon/RecordingService.java b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java
new file mode 100644
index 0000000..28bf803
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java
@@ -0,0 +1,68 @@
+package com.tomcooks.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.tomcooks.reccoon.action.START";
+ public static final String ACTION_STOP = "com.tomcooks.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/tomcooks/reccoon/RecordingStore.java b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java
new file mode 100644
index 0000000..b7a1c84
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java
@@ -0,0 +1,128 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.content.ContentUris;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** Lists the WAV files saved by RECCoon in the public Downloads folder. */
+public final class RecordingStore {
+
+ public static final class Recording {
+ public final String name;
+ public final Uri uri;
+ public final long sizeBytes;
+ public final long modifiedSeconds;
+ public long durationMs;
+
+ Recording(String name, Uri uri, long sizeBytes, long modifiedSeconds) {
+ this.name = name;
+ this.uri = uri;
+ this.sizeBytes = sizeBytes;
+ this.modifiedSeconds = modifiedSeconds;
+ }
+ }
+
+ private static final String PREFIX = "reccoon-";
+ private static final String[] SUFFIXES = {".wav", ".m4a"};
+
+ private final Context context;
+
+ public RecordingStore(Context context) {
+ this.context = context.getApplicationContext();
+ }
+
+ private static boolean hasKnownSuffix(String name) {
+ for (String suffix : SUFFIXES) {
+ if (name.endsWith(suffix)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public List<Recording> list() {
+ List<Recording> recordings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? listFromMediaStore()
+ : listFromDirectory();
+ for (Recording recording : recordings) {
+ recording.durationMs = TranscriptionEngine.durationMillis(context, recording.uri);
+ }
+ Collections.sort(recordings, (a, b) -> Long.compare(b.modifiedSeconds, a.modifiedSeconds));
+ return recordings;
+ }
+
+ private List<Recording> listFromMediaStore() {
+ List<Recording> result = new ArrayList<>();
+ String[] projection = {
+ MediaStore.MediaColumns._ID,
+ MediaStore.MediaColumns.DISPLAY_NAME,
+ MediaStore.MediaColumns.SIZE,
+ MediaStore.MediaColumns.DATE_MODIFIED,
+ };
+ StringBuilder selection = new StringBuilder(MediaStore.MediaColumns.DISPLAY_NAME)
+ .append(" LIKE ? AND (");
+ for (int i = 0; i < SUFFIXES.length; i++) {
+ if (i > 0) {
+ selection.append(" OR ");
+ }
+ selection.append(MediaStore.MediaColumns.DISPLAY_NAME).append(" LIKE ?");
+ }
+ selection.append(')');
+ String[] args = new String[SUFFIXES.length + 1];
+ args[0] = PREFIX + "%";
+ for (int i = 0; i < SUFFIXES.length; i++) {
+ args[i + 1] = "%" + SUFFIXES[i];
+ }
+ try (Cursor cursor = context.getContentResolver().query(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI,
+ projection, selection.toString(), args, null)) {
+ if (cursor == null) {
+ return result;
+ }
+ int idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID);
+ int nameColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME);
+ int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE);
+ int dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED);
+ while (cursor.moveToNext()) {
+ long id = cursor.getLong(idColumn);
+ Uri uri = ContentUris.withAppendedId(
+ MediaStore.Downloads.EXTERNAL_CONTENT_URI, id);
+ result.add(new Recording(
+ cursor.getString(nameColumn),
+ uri,
+ cursor.getLong(sizeColumn),
+ cursor.getLong(dateColumn)));
+ }
+ } catch (Exception e) {
+ // Ignore and return what we have.
+ }
+ return result;
+ }
+
+ @SuppressWarnings("deprecation")
+ private List<Recording> listFromDirectory() {
+ List<Recording> result = new ArrayList<>();
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && hasKnownSuffix(name));
+ if (files == null) {
+ return result;
+ }
+ for (File file : files) {
+ result.add(new Recording(
+ file.getName(),
+ Uri.fromFile(file),
+ file.length(),
+ file.lastModified() / 1000L));
+ }
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java
new file mode 100644
index 0000000..558f5ec
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java
@@ -0,0 +1,57 @@
+package com.tomcooks.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/tomcooks/reccoon/TranscriptStore.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java
new file mode 100644
index 0000000..69d9439
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java
@@ -0,0 +1,129 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+/** Stores and loads transcript sidecar files in app-private storage. */
+public final class TranscriptStore {
+
+ private final File dir;
+
+ public TranscriptStore(Context context) {
+ dir = new File(context.getApplicationContext().getFilesDir(), "transcripts");
+ }
+
+ public boolean has(String recordingName) {
+ return fileFor(recordingName).isFile();
+ }
+
+ public void save(String recordingName, TranscriptionEngine.Result result) throws IOException {
+ JSONObject root = new JSONObject();
+ JSONArray segments = new JSONArray();
+ try {
+ root.put("language", result.language == null ? "" : result.language);
+ for (TranscriptionEngine.Segment segment : result.segments) {
+ JSONObject item = new JSONObject();
+ item.put("start", segment.startSeconds);
+ item.put("end", segment.endSeconds);
+ item.put("text", segment.text);
+ JSONArray words = new JSONArray();
+ for (TranscriptionEngine.Word word : segment.words) {
+ JSONObject w = new JSONObject();
+ w.put("text", word.text);
+ w.put("start", word.startSeconds);
+ w.put("end", word.endSeconds);
+ words.put(w);
+ }
+ item.put("words", words);
+ segments.put(item);
+ }
+ root.put("segments", segments);
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw new IOException("Cannot create transcript directory");
+ }
+ String json;
+ try {
+ json = root.toString(2);
+ } catch (JSONException e) {
+ throw new IOException(e);
+ }
+ try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) {
+ out.write(json.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ public TranscriptionEngine.Result load(String recordingName) {
+ File file = fileFor(recordingName);
+ if (!file.isFile()) {
+ return null;
+ }
+ try (InputStream in = new java.io.FileInputStream(file)) {
+ byte[] buffer = new byte[(int) file.length()];
+ int read = 0;
+ while (read < buffer.length) {
+ int n = in.read(buffer, read, buffer.length - read);
+ if (n < 0) {
+ break;
+ }
+ read += n;
+ }
+ JSONObject root = new JSONObject(new String(buffer, 0, read, StandardCharsets.UTF_8));
+ TranscriptionEngine.Result result = new TranscriptionEngine.Result();
+ result.language = root.optString("language", "");
+ JSONArray segments = root.optJSONArray("segments");
+ if (segments != null) {
+ for (int i = 0; i < segments.length(); i++) {
+ JSONObject item = segments.getJSONObject(i);
+ TranscriptionEngine.Segment segment = new TranscriptionEngine.Segment(
+ (float) item.optDouble("start", 0),
+ (float) item.optDouble("end", 0),
+ item.optString("text", ""));
+ JSONArray words = item.optJSONArray("words");
+ if (words != null) {
+ for (int j = 0; j < words.length(); j++) {
+ JSONObject w = words.getJSONObject(j);
+ segment.words.add(new TranscriptionEngine.Word(
+ w.optString("text", ""),
+ (float) w.optDouble("start", 0),
+ (float) w.optDouble("end", 0)));
+ }
+ }
+ result.segments.add(segment);
+ }
+ }
+ return result;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ public void delete(String recordingName) {
+ //noinspection ResultOfMethodCallIgnored
+ fileFor(recordingName).delete();
+ }
+
+ public void rename(String oldName, String newName) {
+ File from = fileFor(oldName);
+ if (from.isFile()) {
+ //noinspection ResultOfMethodCallIgnored
+ from.renameTo(fileFor(newName));
+ }
+ }
+
+ private File fileFor(String recordingName) {
+ return new File(dir, recordingName + ".json");
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java
new file mode 100644
index 0000000..5483cbf
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java
@@ -0,0 +1,413 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+
+import com.k2fsa.sherpa.onnx.FeatureConfig;
+import com.k2fsa.sherpa.onnx.OfflineModelConfig;
+import com.k2fsa.sherpa.onnx.OfflineRecognizer;
+import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig;
+import com.k2fsa.sherpa.onnx.OfflineRecognizerResult;
+import com.k2fsa.sherpa.onnx.OfflineStream;
+import com.k2fsa.sherpa.onnx.OfflineTransducerModelConfig;
+import com.k2fsa.sherpa.onnx.SileroVadModelConfig;
+import com.k2fsa.sherpa.onnx.SpeechSegment;
+import com.k2fsa.sherpa.onnx.Vad;
+import com.k2fsa.sherpa.onnx.VadModelConfig;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * On-device transcription using sherpa-onnx with the NVIDIA Parakeet TDT 0.6B
+ * v3 transducer (multilingual, auto language handling).
+ *
+ * <p>Long recordings are streamed, resampled to 16 kHz and segmented with
+ * Silero VAD, then each detected speech segment is decoded. This keeps memory
+ * bounded regardless of the recording length.
+ */
+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;
+ this.endSeconds = endSeconds;
+ this.text = text;
+ }
+ }
+
+ public static final class Result {
+ public final List<Segment> segments = new ArrayList<>();
+ public String language = "";
+
+ /** Plain transcript with one segment per paragraph. */
+ public String asText() {
+ StringBuilder sb = new StringBuilder();
+ for (Segment segment : segments) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(segment.text);
+ }
+ return sb.toString();
+ }
+ }
+
+ public interface Callback {
+ /** @param percent 0..100 */
+ void onProgress(int percent, String message);
+
+ boolean isCancelled();
+ }
+
+ private final Context context;
+ private final ModelRepository models;
+
+ public TranscriptionEngine(Context context) {
+ this.context = context.getApplicationContext();
+ this.models = new ModelRepository(this.context);
+ }
+
+ public Result transcribe(Uri wavUri,
+ ModelRepository.Model model,
+ Callback callback) throws IOException {
+ if (!models.isReady(model)) {
+ throw new IOException("Model is not downloaded");
+ }
+
+ OfflineRecognizer recognizer = buildRecognizer(model);
+ Vad vad = buildVad();
+ Result result = new Result();
+ result.language = "";
+
+ // PcmAudioSource reads uncompressed WAV directly and decodes compressed
+ // recordings (AAC/M4A, ...) through MediaCodec, so compact mode keeps
+ // working here.
+ try (PcmAudioSource source = PcmAudioSource.open(context, wavUri)) {
+ int channelCount = source.channels;
+ StreamingResampler resampler =
+ new StreamingResampler(source.sampleRate, TARGET_RATE);
+ long totalPcm16k = estimatePcm16kSamples(source);
+ long processed = 0;
+
+ int frameBytes = channelCount * 2;
+ byte[] readBuffer = new byte[frameBytes * 8192];
+
+ while (true) {
+ if (callback != null && callback.isCancelled()) {
+ return null;
+ }
+ int read = source.read(readBuffer, 0, readBuffer.length);
+ if (read <= 0) {
+ break;
+ }
+
+ int frames = read / frameBytes;
+ float[] mono = new float[frames];
+ for (int i = 0; i < frames; i++) {
+ int offset = i * frameBytes;
+ if (channelCount == 1) {
+ mono[i] = pcm16(readBuffer, offset) / 32768f;
+ } else {
+ int sum = 0;
+ for (int c = 0; c < channelCount; c++) {
+ sum += pcm16(readBuffer, offset + c * 2);
+ }
+ mono[i] = sum / (float) channelCount / 32768f;
+ }
+ }
+
+ float[] resampled = resampler.process(mono);
+ processed += resampled.length;
+ if (resampled.length > 0) {
+ vad.acceptWaveform(resampled);
+ drainSegments(vad, recognizer, result);
+ }
+ if (callback != null && totalPcm16k > 0) {
+ int percent = (int) Math.min(99, processed * 100 / totalPcm16k);
+ callback.onProgress(percent, "Transcribing…");
+ }
+ }
+
+ vad.flush();
+ drainSegments(vad, recognizer, result);
+ } finally {
+ vad.release();
+ recognizer.release();
+ }
+
+ if (callback != null) {
+ callback.onProgress(100, "Done");
+ }
+ return result;
+ }
+
+ private void drainSegments(Vad vad, OfflineRecognizer recognizer, Result result) {
+ while (!vad.empty()) {
+ SpeechSegment speech = vad.front();
+ vad.pop();
+ float[] samples = speech.getSamples();
+ if (samples == null || samples.length < TARGET_RATE / 10) {
+ continue; // ignore blips shorter than 100 ms
+ }
+ OfflineStream stream = recognizer.createStream();
+ try {
+ stream.acceptWaveform(samples, TARGET_RATE);
+ recognizer.decode(stream);
+ OfflineRecognizerResult decoded = recognizer.getResult(stream);
+ String text = decoded.getText().trim();
+ if (!text.isEmpty()) {
+ float start = speech.getStart() / (float) TARGET_RATE;
+ float end = start + samples.length / (float) TARGET_RATE;
+ Segment segment = new Segment(start, end, text);
+ buildWords(segment, decoded.getTokens(), decoded.getTimestamps(),
+ decoded.getDurations());
+ result.segments.add(segment);
+ }
+ } finally {
+ stream.release();
+ }
+ }
+ }
+
+ /**
+ * Fills in word timings. Uses token timestamps when the model provides
+ * them, otherwise distributes the words across the segment by length.
+ */
+ private static void buildWords(Segment segment, String[] tokens,
+ float[] timestamps, float[] durations) {
+ if (tokens == null || tokens.length == 0) {
+ return;
+ }
+ boolean haveTimestamps = timestamps != null && timestamps.length >= tokens.length;
+ if (haveTimestamps) {
+ for (int i = 0; i < tokens.length; i++) {
+ if (tokens[i] == null || tokens[i].trim().isEmpty()) {
+ continue;
+ }
+ float start = segment.startSeconds + timestamps[i];
+ float end = durations != null && i < durations.length && durations[i] > 0f
+ ? start + durations[i] : start + 0.2f;
+ segment.words.add(new Word(tokens[i], start, end));
+ }
+ return;
+ }
+ int total = 0;
+ for (String token : tokens) {
+ if (token != null && !token.trim().isEmpty()) {
+ total += token.trim().length();
+ }
+ }
+ if (total <= 0) {
+ return;
+ }
+ float span = Math.max(0.001f, segment.endSeconds - segment.startSeconds);
+ int consumed = 0;
+ for (String token : tokens) {
+ if (token == null || token.trim().isEmpty()) {
+ continue;
+ }
+ int weight = token.trim().length();
+ float start = segment.startSeconds + span * consumed / total;
+ consumed += weight;
+ float end = segment.startSeconds + span * consumed / total;
+ segment.words.add(new Word(token, start, end));
+ }
+ }
+
+ private OfflineRecognizer buildRecognizer(ModelRepository.Model model) {
+ FeatureConfig feat = new FeatureConfig();
+ feat.setSampleRate(TARGET_RATE);
+ feat.setFeatureDim(80);
+ feat.setDither(0f);
+
+ OfflineTransducerModelConfig transducer = new OfflineTransducerModelConfig();
+ transducer.setEncoder(models.encoder(model).getAbsolutePath());
+ transducer.setDecoder(models.decoder(model).getAbsolutePath());
+ transducer.setJoiner(models.joiner(model).getAbsolutePath());
+
+ OfflineModelConfig modelConfig = new OfflineModelConfig();
+ modelConfig.setTransducer(transducer);
+ modelConfig.setModelType("nemo_transducer");
+ modelConfig.setTokens(models.tokens(model).getAbsolutePath());
+ modelConfig.setNumThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2));
+ modelConfig.setDebug(false);
+ modelConfig.setProvider("cpu");
+
+ OfflineRecognizerConfig config = new OfflineRecognizerConfig();
+ config.setFeatConfig(feat);
+ config.setModelConfig(modelConfig);
+ config.setDecodingMethod("greedy_search");
+
+ // Passing a null AssetManager makes sherpa-onnx load the model from
+ // the absolute file paths configured above.
+ return new OfflineRecognizer(null, config);
+ }
+
+ private Vad buildVad() {
+ SileroVadModelConfig silero = new SileroVadModelConfig();
+ silero.setModel(models.vadModel().getAbsolutePath());
+ silero.setThreshold(0.5f);
+ silero.setMinSilenceDuration(0.5f);
+ silero.setMinSpeechDuration(0.25f);
+ silero.setWindowSize(512);
+ silero.setMaxSpeechDuration(20.0f);
+
+ VadModelConfig config = new VadModelConfig();
+ config.setSileroVadModelConfig(silero);
+ config.setSampleRate(TARGET_RATE);
+ config.setNumThreads(1);
+ config.setProvider("cpu");
+ config.setDebug(false);
+
+ return new Vad(null, config);
+ }
+
+ // --- WAV handling -----------------------------------------------------
+
+ public static final class WavInfo {
+ public int audioFormat;
+ public int channels;
+ public int sampleRate;
+ public int bitsPerSample;
+ public long dataSize;
+
+ public long durationMillis() {
+ int byteRate = sampleRate * channels * (bitsPerSample / 8);
+ if (byteRate <= 0 || dataSize <= 0) {
+ return 0;
+ }
+ return dataSize * 1000L / byteRate;
+ }
+ }
+
+ public static WavInfo readWavHeader(InputStream stream) throws IOException {
+ DataInputStream in = stream instanceof DataInputStream
+ ? (DataInputStream) stream
+ : new DataInputStream(stream);
+
+ byte[] header = new byte[12];
+ in.readFully(header);
+ if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F'
+ || header[8] != 'W' || header[9] != 'A' || header[10] != 'V' || header[11] != 'E') {
+ throw new IOException("Not a RIFF/WAVE file");
+ }
+
+ WavInfo info = new WavInfo();
+ while (true) {
+ byte[] chunkHeader = new byte[8];
+ in.readFully(chunkHeader);
+ String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII);
+ long size = le32(chunkHeader, 4) & 0xffffffffL;
+
+ if ("fmt ".equals(id)) {
+ if (size < 16 || size > 1024) {
+ throw new IOException("Unsupported fmt chunk");
+ }
+ byte[] fmt = new byte[(int) size];
+ in.readFully(fmt);
+ info.audioFormat = (int) le16(fmt, 0);
+ info.channels = (int) le16(fmt, 2);
+ info.sampleRate = (int) le32(fmt, 4);
+ info.bitsPerSample = (int) le16(fmt, 14);
+ if ((size & 1) == 1) {
+ in.readFully(new byte[1]);
+ }
+ } else if ("data".equals(id)) {
+ info.dataSize = size;
+ return info;
+ } else {
+ skipFully(in, size + (size & 1));
+ }
+ }
+ }
+
+ public static long durationMillis(Context context, Uri uri) {
+ try (InputStream in = context.getContentResolver().openInputStream(uri)) {
+ if (in != null) {
+ WavInfo info = readWavHeader(new BufferedInputStream(in));
+ return info.durationMillis();
+ }
+ } catch (Exception ignored) {
+ // Not a WAV file; try the platform decoder below.
+ }
+ MediaMetadataRetriever retriever = new MediaMetadataRetriever();
+ try {
+ retriever.setDataSource(context, uri);
+ String value = retriever.extractMetadata(
+ MediaMetadataRetriever.METADATA_KEY_DURATION);
+ return value == null ? 0 : Long.parseLong(value);
+ } catch (Exception e) {
+ return 0;
+ } finally {
+ try {
+ retriever.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+ }
+
+ private static long estimatePcm16kSamples(PcmAudioSource source) {
+ if (source.totalBytes <= 0 || source.sampleRate <= 0 || source.channels <= 0) {
+ return 0;
+ }
+ long frames = source.totalBytes / (source.channels * 2L);
+ return frames * TARGET_RATE / source.sampleRate;
+ }
+
+ private static int pcm16(byte[] data, int offset) {
+ return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8));
+ }
+
+ private static long le32(byte[] data, int offset) {
+ return (data[offset] & 0xffL)
+ | ((data[offset + 1] & 0xffL) << 8)
+ | ((data[offset + 2] & 0xffL) << 16)
+ | ((data[offset + 3] & 0xffL) << 24);
+ }
+
+ private static long le16(byte[] data, int offset) {
+ return (data[offset] & 0xffL) | ((data[offset + 1] & 0xffL) << 8);
+ }
+
+ private static void skipFully(DataInputStream in, long count) throws IOException {
+ long remaining = count;
+ while (remaining > 0) {
+ long skipped = in.skip(remaining);
+ if (skipped <= 0) {
+ if (in.read() < 0) {
+ throw new IOException("Unexpected end of WAV file");
+ }
+ skipped = 1;
+ }
+ remaining -= skipped;
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java
new file mode 100644
index 0000000..7ef8425
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java
@@ -0,0 +1,121 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.net.Uri;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.EOFException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Reads {@code cue } + {@code LIST/adtl} marker chunks out of a WAV file. */
+public final class WavMarkers {
+
+ private WavMarkers() {
+ }
+
+ public static List<Marker> read(Context context, Uri uri) {
+ List<Marker> result = new ArrayList<>();
+ try (InputStream raw = context.getContentResolver().openInputStream(uri)) {
+ if (raw == null) {
+ return result;
+ }
+ DataInputStream in = new DataInputStream(new BufferedInputStream(raw, 64 * 1024));
+ byte[] header = new byte[12];
+ in.readFully(header);
+ if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F'
+ || header[8] != 'W' || header[9] != 'A' || header[10] != 'V'
+ || header[11] != 'E') {
+ return result;
+ }
+
+ Map<Integer, Long> offsets = new LinkedHashMap<>();
+ Map<Integer, String> labels = new LinkedHashMap<>();
+
+ while (true) {
+ byte[] chunkHeader = new byte[8];
+ try {
+ in.readFully(chunkHeader);
+ } catch (EOFException eof) {
+ break;
+ }
+ String id = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII);
+ long size = le32(chunkHeader, 4) & 0xffffffffL;
+
+ if ("cue ".equals(id)) {
+ byte[] body = new byte[(int) size];
+ in.readFully(body);
+ int count = (int) le32(body, 0);
+ for (int i = 0; i < count; i++) {
+ int offset = 4 + i * 24;
+ int name = (int) le32(body, offset);
+ long sample = le32(body, offset + 20) & 0xffffffffL;
+ offsets.put(name, sample);
+ }
+ } else if ("LIST".equals(id)) {
+ byte[] body = new byte[(int) size];
+ in.readFully(body);
+ if (body.length >= 4
+ && "adtl".equals(new String(body, 0, 4, StandardCharsets.US_ASCII))) {
+ int p = 4;
+ while (p + 8 <= body.length) {
+ String subId = new String(body, p, 4, StandardCharsets.US_ASCII);
+ long subSize = le32(body, p + 4) & 0xffffffffL;
+ if ("labl".equals(subId) && p + 8 + subSize <= body.length) {
+ int name = (int) le32(body, p + 8);
+ int textLength = (int) subSize - 4;
+ if (textLength >= 0) {
+ String text = new String(body, p + 12, textLength,
+ StandardCharsets.UTF_8);
+ int zero = text.indexOf('\0');
+ if (zero >= 0) {
+ text = text.substring(0, zero);
+ }
+ labels.put(name, text);
+ }
+ }
+ p += 8 + subSize + (subSize & 1);
+ }
+ }
+ } else {
+ skipFully(in, size + (size & 1));
+ }
+ }
+
+ int index = 1;
+ for (Map.Entry<Integer, Long> entry : offsets.entrySet()) {
+ String label = labels.get(entry.getKey());
+ result.add(new Marker(index++, entry.getValue(), label == null ? "" : label));
+ }
+ } catch (Exception e) {
+ // Markers are best effort.
+ }
+ return result;
+ }
+
+ private static long le32(byte[] data, int offset) {
+ return (data[offset] & 0xffL)
+ | ((data[offset + 1] & 0xffL) << 8)
+ | ((data[offset + 2] & 0xffL) << 16)
+ | ((data[offset + 3] & 0xffL) << 24);
+ }
+
+ private static void skipFully(DataInputStream in, long count) throws Exception {
+ long remaining = count;
+ while (remaining > 0) {
+ long skipped = in.skip(remaining);
+ if (skipped <= 0) {
+ if (in.read() < 0) {
+ throw new EOFException();
+ }
+ skipped = 1;
+ }
+ remaining -= skipped;
+ }
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java
new file mode 100644
index 0000000..0171a74
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java
@@ -0,0 +1,542 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.media.AudioDeviceInfo;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.media.MediaRecorder;
+import android.os.SystemClock;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Records uncompressed 16-bit PCM audio at 44.1 kHz and writes a standard
+ * RIFF/WAVE file (PCM, format code 1).
+ *
+ * <p>Stereo is used when the device supports it (so the left/right level
+ * meters are meaningful); it falls back to mono otherwise. Peak levels and a
+ * small waveform envelope for both channels are reported through
+ * {@link LevelListener}.
+ *
+ * <p>Markers added while recording are embedded in the file as standard WAV
+ * {@code cue } + {@code LIST/adtl} chunks (readable by Audacity, Reaper, ...).
+ */
+public class WavRecorder {
+
+ public static final int SAMPLE_RATE = 44100;
+ public static final int BITS_PER_SAMPLE = 16;
+
+ private static final int HEADER_SIZE = 44;
+ private static final long LEVEL_INTERVAL_MS = 40;
+ private static final int WAVEFORM_BUCKETS = 16;
+
+ public interface LevelListener {
+ /**
+ * @param leftPeak 0..1 peak of the left channel in this window
+ * @param rightPeak 0..1 peak of the right channel (same as left when mono)
+ * @param clipped true if any sample reached full scale in this window
+ * @param waveform min/max pairs (length {@code 2 * WAVEFORM_BUCKETS}) of
+ * the left/mono channel for a scrolling waveform
+ */
+ void onLevels(float leftPeak, float rightPeak, boolean clipped, float[] waveform);
+ }
+
+ private final Context context;
+ private final AudioManager audioManager;
+ private final List<Marker> markers = new ArrayList<>();
+
+ private LevelListener levelListener;
+ private AudioRecord audioRecord;
+ private Thread writerThread;
+ private volatile boolean recording;
+ private File tempFile;
+ private volatile long pcmBytes;
+ private int channels = 2;
+ private boolean forceMono;
+ private int preferredDeviceId = -1;
+ private boolean bluetoothSco;
+ private boolean scoActive;
+ private boolean swapChannels;
+ private boolean monitoring;
+ private int monitorDeviceId = -1;
+ private final LiveMonitor monitor;
+
+ public WavRecorder(Context context) {
+ this.context = context.getApplicationContext();
+ this.audioManager = (AudioManager) this.context.getSystemService(Context.AUDIO_SERVICE);
+ this.monitor = new LiveMonitor(this.context);
+ }
+
+ public void setLevelListener(LevelListener listener) {
+ this.levelListener = listener;
+ }
+
+ /** When true, always record a single channel (half the file size). */
+ public void setForceMono(boolean forceMono) {
+ this.forceMono = forceMono;
+ }
+
+ /**
+ * Forces a specific input device, or -1 to let Android pick. Mirrors the
+ * BT-Mic-Force behaviour: the id comes from
+ * {@link AudioManager#getDevices(int)}.
+ */
+ public void setPreferredDeviceId(int deviceId) {
+ this.preferredDeviceId = deviceId;
+ }
+
+ /**
+ * When true, route audio through Bluetooth SCO (headset microphone) before
+ * opening the recorder. Call {@link #setPreferredDeviceId(int)} as well to
+ * pick the exact headset when several are connected.
+ */
+ public void setBluetoothSco(boolean bluetoothSco) {
+ this.bluetoothSco = bluetoothSco;
+ }
+
+ /**
+ * When true, exchange the two stereo channels before writing. Some devices
+ * report the microphones in the opposite order once the phone is turned to
+ * landscape; swapping makes both the meters and the saved file agree with
+ * what the user hears on their left/right.
+ */
+ public void setSwapChannels(boolean swapChannels) {
+ this.swapChannels = swapChannels;
+ }
+
+ /** When true, play the captured audio back through {@link LiveMonitor}. */
+ public void setMonitoring(boolean monitoring) {
+ this.monitoring = monitoring;
+ }
+
+ /** Output device for live monitoring, or -1 for the system default. */
+ public void setMonitorDeviceId(int deviceId) {
+ this.monitorDeviceId = deviceId;
+ }
+
+ /** True while live monitoring is actually running. */
+ public boolean isMonitoring() {
+ return monitoring && monitor.isRunning();
+ }
+
+ /** Effective number of channels used by the last/current recording. */
+ public int getChannels() {
+ return channels;
+ }
+
+ /**
+ * Starts capturing audio. Returns the file that will hold the WAV data.
+ */
+ public File start() throws IOException {
+ if (recording) {
+ throw new IllegalStateException("A recording is already in progress");
+ }
+ markers.clear();
+
+ audioRecord = createAudioRecord();
+ if (audioRecord == null) {
+ throw new IOException("No usable microphone / audio configuration");
+ }
+
+ tempFile = new File(context.getCacheDir(), "reccoon_recording.wav");
+ writePlaceholderHeader();
+
+ pcmBytes = 0L;
+ recording = true;
+ audioRecord.startRecording();
+
+ if (monitoring) {
+ monitor.setPreferredDeviceId(monitorDeviceId);
+ monitor.start(SAMPLE_RATE, channels);
+ }
+
+ final AudioRecord record = audioRecord;
+ final int channelCount = channels;
+ final int frameBytes = channelCount * 2;
+ final int minBuffer = AudioRecord.getMinBufferSize(
+ SAMPLE_RATE, channelConfigFor(channelCount), AudioFormat.ENCODING_PCM_16BIT);
+ final int chunkSize = Math.max(minBuffer, frameBytes * 4096);
+
+ writerThread = new Thread(() -> {
+ byte[] buffer = new byte[chunkSize];
+ long lastLevelTime = 0;
+ try (FileOutputStream out = new FileOutputStream(tempFile, true)) {
+ while (recording) {
+ int read = record.read(buffer, 0, buffer.length);
+ if (read > 0) {
+ maybeSwapChannels(buffer, read, channelCount);
+ out.write(buffer, 0, read);
+ if (monitoring) {
+ monitor.write(buffer, read);
+ }
+ pcmBytes += read;
+
+ long now = SystemClock.uptimeMillis();
+ if (levelListener != null && now - lastLevelTime >= LEVEL_INTERVAL_MS) {
+ lastLevelTime = now;
+ reportLevels(buffer, read, channelCount);
+ }
+ }
+ }
+ out.flush();
+ } catch (Exception ignored) {
+ // Whatever was captured before the failure stays in the file.
+ }
+ }, "reccoon-writer");
+ writerThread.start();
+
+ return tempFile;
+ }
+
+ /** Exchanges the two 16-bit channels in-place when the swap option is on. */
+ private void maybeSwapChannels(byte[] buffer, int length, int channelCount) {
+ if (!swapChannels || channelCount != 2) {
+ return;
+ }
+ int frames = length / 4;
+ for (int i = 0; i < frames; i++) {
+ int offset = i * 4;
+ byte leftLow = buffer[offset];
+ byte leftHigh = buffer[offset + 1];
+ byte rightLow = buffer[offset + 2];
+ byte rightHigh = buffer[offset + 3];
+ buffer[offset] = rightLow;
+ buffer[offset + 1] = rightHigh;
+ buffer[offset + 2] = leftLow;
+ buffer[offset + 3] = leftHigh;
+ }
+ }
+
+ private void reportLevels(byte[] buffer, int length, int channelCount) {
+ int frameBytes = channelCount * 2;
+ int frames = length / frameBytes;
+ if (frames <= 0) {
+ return;
+ }
+ float leftPeak = 0f;
+ float rightPeak = 0f;
+ boolean clipped = false;
+ for (int i = 0; i < frames; i++) {
+ int offset = i * frameBytes;
+ short left = pcm16(buffer, offset);
+ float l = Math.abs((int) left) / 32768f;
+ if (l > leftPeak) {
+ leftPeak = l;
+ }
+ if (Math.abs((int) left) >= 32767) {
+ clipped = true;
+ }
+ if (channelCount > 1) {
+ short right = pcm16(buffer, offset + 2);
+ float r = Math.abs((int) right) / 32768f;
+ if (r > rightPeak) {
+ rightPeak = r;
+ }
+ if (Math.abs((int) right) >= 32767) {
+ clipped = true;
+ }
+ }
+ }
+ if (channelCount == 1) {
+ rightPeak = leftPeak;
+ }
+
+ float[] waveform = buildWaveform(buffer, frames, frameBytes);
+ LevelListener listener = levelListener;
+ if (listener != null) {
+ listener.onLevels(leftPeak, rightPeak, clipped, waveform);
+ }
+ }
+
+ private static float[] buildWaveform(byte[] buffer, int frames, int frameBytes) {
+ float[] waveform = new float[WAVEFORM_BUCKETS * 2];
+ int framesPerBucket = Math.max(1, frames / WAVEFORM_BUCKETS);
+ for (int bucket = 0; bucket < WAVEFORM_BUCKETS; bucket++) {
+ int start = bucket * framesPerBucket;
+ int end = bucket == WAVEFORM_BUCKETS - 1 ? frames : start + framesPerBucket;
+ float min = 0f;
+ float max = 0f;
+ for (int i = start; i < end && i < frames; i++) {
+ float sample = pcm16(buffer, i * frameBytes) / 32768f;
+ if (sample < min) {
+ min = sample;
+ }
+ if (sample > max) {
+ max = sample;
+ }
+ }
+ waveform[bucket * 2] = min;
+ waveform[bucket * 2 + 1] = max;
+ }
+ return waveform;
+ }
+
+ /** Current position in sample frames (usable while recording). */
+ public long currentSampleOffset() {
+ int blockAlign = Math.max(1, channels * 2);
+ return pcmBytes / blockAlign;
+ }
+
+ /** Adds a marker at the current position. */
+ public void addMarker(String label) {
+ addMarkerAt(currentSampleOffset(), label);
+ }
+
+ public void addMarkerAt(long sampleOffset, String label) {
+ markers.add(new Marker(markers.size() + 1, sampleOffset, label));
+ }
+
+ public List<Marker> getMarkers() {
+ return Collections.unmodifiableList(new ArrayList<>(markers));
+ }
+
+ /**
+ * Stops capturing and finalizes the WAV header. Returns the finished file.
+ */
+ public File stop() {
+ recording = false;
+
+ AudioRecord record = audioRecord;
+ if (record != null) {
+ try {
+ if (record.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
+ record.stop();
+ }
+ } catch (Exception ignored) {
+ // Ignore and continue tearing down.
+ }
+ }
+
+ Thread thread = writerThread;
+ if (thread != null) {
+ try {
+ thread.join(3000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ if (record != null) {
+ try {
+ record.release();
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ audioRecord = null;
+ }
+ writerThread = null;
+ monitor.stop();
+ stopSco();
+
+ writeHeader();
+ writeMarkers();
+ return tempFile;
+ }
+
+ public boolean isRecording() {
+ return recording;
+ }
+
+ private AudioRecord createAudioRecord() {
+ startScoIfNeeded();
+ int source = bluetoothSco
+ ? MediaRecorder.AudioSource.VOICE_COMMUNICATION
+ : MediaRecorder.AudioSource.MIC;
+ int[] candidates = forceMono
+ ? new int[]{AudioFormat.CHANNEL_IN_MONO}
+ : new int[]{AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO};
+ for (int channelConfig : candidates) {
+ int minBuffer = AudioRecord.getMinBufferSize(
+ SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT);
+ if (minBuffer <= 0) {
+ continue;
+ }
+ int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1;
+ int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count);
+ AudioRecord candidate = new AudioRecord(
+ source,
+ SAMPLE_RATE,
+ channelConfig,
+ AudioFormat.ENCODING_PCM_16BIT,
+ bufferSize);
+ if (candidate.getState() == AudioRecord.STATE_INITIALIZED) {
+ applyPreferredDevice(candidate);
+ channels = count;
+ return candidate;
+ }
+ candidate.release();
+ }
+ stopSco();
+ return null;
+ }
+
+ private void startScoIfNeeded() {
+ if (!bluetoothSco || audioManager == null) {
+ return;
+ }
+ try {
+ audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
+ audioManager.startBluetoothSco();
+ audioManager.setBluetoothScoOn(true);
+ scoActive = true;
+ } catch (Exception ignored) {
+ // Fall back to the default route below.
+ }
+ }
+
+ private void applyPreferredDevice(AudioRecord record) {
+ if (preferredDeviceId < 0 || audioManager == null) {
+ return;
+ }
+ try {
+ for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
+ if (device.getId() == preferredDeviceId) {
+ record.setPreferredDevice(device);
+ return;
+ }
+ }
+ } catch (Exception ignored) {
+ // Keep the default device.
+ }
+ }
+
+ private void stopSco() {
+ if (!scoActive || audioManager == null) {
+ return;
+ }
+ scoActive = false;
+ try {
+ audioManager.stopBluetoothSco();
+ audioManager.setBluetoothScoOn(false);
+ audioManager.setMode(AudioManager.MODE_NORMAL);
+ } catch (Exception ignored) {
+ // Ignore.
+ }
+ }
+
+ private static int channelConfigFor(int channelCount) {
+ return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO;
+ }
+
+ private static short pcm16(byte[] data, int offset) {
+ return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8));
+ }
+
+ private void writePlaceholderHeader() throws IOException {
+ try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
+ raf.setLength(0);
+ raf.write(new byte[HEADER_SIZE]);
+ }
+ }
+
+ private void writeHeader() {
+ try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
+ raf.seek(0);
+ raf.write(buildHeader(pcmBytes));
+ } catch (IOException ignored) {
+ // The file is still usable except for the size fields.
+ }
+ }
+
+ /** Appends {@code cue } and {@code LIST/adtl} chunks for the markers. */
+ private void writeMarkers() {
+ if (markers.isEmpty() || tempFile == null || !tempFile.isFile()) {
+ return;
+ }
+ try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) {
+ raf.seek(raf.length());
+ raf.write(buildCueChunk(markers));
+ raf.write(buildAdtlChunk(markers));
+
+ long newLength = raf.length();
+ raf.seek(4);
+ raf.write(intLE((int) (newLength - 8)));
+ } catch (IOException ignored) {
+ // Markers are a bonus; the audio itself stays valid.
+ }
+ }
+
+ private static byte[] buildCueChunk(List<Marker> markers) {
+ int count = markers.size();
+ ByteBuffer buffer = ByteBuffer.allocate(8 + 4 + 24 * count).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(ascii("cue "));
+ buffer.putInt(4 + 24 * count);
+ buffer.putInt(count);
+ for (Marker marker : markers) {
+ buffer.putInt(marker.index); // dwName
+ buffer.putInt(0); // dwPosition
+ buffer.put(ascii("data")); // fccChunk
+ buffer.putInt(0); // dwChunkStart
+ buffer.putInt(0); // dwBlockStart
+ buffer.putInt((int) marker.sampleOffset); // dwSampleOffset
+ }
+ return buffer.array();
+ }
+
+ private static byte[] buildAdtlChunk(List<Marker> markers) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (Marker marker : markers) {
+ byte[] text = marker.label.getBytes(StandardCharsets.UTF_8);
+ int size = 4 + text.length + 1; // cue id + text + NUL
+ int padded = size + (size & 1);
+ ByteBuffer buffer = ByteBuffer.allocate(8 + padded).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(ascii("labl"));
+ buffer.putInt(size);
+ buffer.putInt(marker.index);
+ buffer.put(text);
+ buffer.put((byte) 0);
+ while (buffer.position() < buffer.capacity()) {
+ buffer.put((byte) 0);
+ }
+ out.write(buffer.array(), 0, buffer.array().length);
+ }
+ byte[] adtl = out.toByteArray();
+
+ ByteBuffer list = ByteBuffer.allocate(12 + adtl.length).order(ByteOrder.LITTLE_ENDIAN);
+ list.put(ascii("LIST"));
+ list.putInt(4 + adtl.length);
+ list.put(ascii("adtl"));
+ list.put(adtl);
+ return list.array();
+ }
+
+ private byte[] buildHeader(long dataSize) {
+ int byteRate = SAMPLE_RATE * channels * BITS_PER_SAMPLE / 8;
+ int blockAlign = channels * BITS_PER_SAMPLE / 8;
+
+ ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(ascii("RIFF"));
+ buffer.putInt((int) (36 + dataSize));
+ buffer.put(ascii("WAVE"));
+ buffer.put(ascii("fmt "));
+ buffer.putInt(16); // PCM chunk size
+ buffer.putShort((short) 1); // PCM = 1
+ buffer.putShort((short) channels);
+ buffer.putInt(SAMPLE_RATE);
+ buffer.putInt(byteRate);
+ buffer.putShort((short) blockAlign);
+ buffer.putShort((short) BITS_PER_SAMPLE);
+ buffer.put(ascii("data"));
+ buffer.putInt((int) dataSize);
+ return buffer.array();
+ }
+
+ private static byte[] intLE(int value) {
+ return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array();
+ }
+
+ private static byte[] ascii(String value) {
+ return value.getBytes(StandardCharsets.US_ASCII);
+ }
+}
diff --git a/app/src/main/java/com/tomcooks/reccoon/WaveformView.java b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java
new file mode 100644
index 0000000..413a825
--- /dev/null
+++ b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java
@@ -0,0 +1,94 @@
+package com.tomcooks.reccoon;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.util.AttributeSet;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+/**
+ * A scrolling waveform of the left/mono channel. Columns whose peak reaches
+ * full scale are drawn in red so clipping is easy to spot.
+ */
+public class WaveformView extends View {
+
+ private static final int CAPACITY = 600;
+
+ private final float[] mins = new float[CAPACITY];
+ private final float[] maxs = new float[CAPACITY];
+ private final boolean[] clipped = new boolean[CAPACITY];
+
+ private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+
+ private int count;
+ private int writeIndex;
+
+ public WaveformView(Context context) {
+ this(context, null);
+ }
+
+ public WaveformView(Context context, @Nullable AttributeSet attrs) {
+ super(context, attrs);
+ float density = getResources().getDisplayMetrics().density;
+ wavePaint.setColor(0xFF3DDC84);
+ wavePaint.setStrokeWidth(Math.max(1f, density));
+ clipPaint.setColor(0xFFE53935);
+ clipPaint.setStrokeWidth(Math.max(1f, density));
+ centerPaint.setColor(0x33FFFFFF);
+ centerPaint.setStrokeWidth(Math.max(1f, density * 0.6f));
+ backgroundPaint.setColor(0x14000000);
+ }
+
+ /** Appends min/max pairs ({@code [min0,max0,min1,max1,...]}) to the ring. */
+ public void append(float[] waveform) {
+ if (waveform == null || waveform.length < 2) {
+ return;
+ }
+ for (int i = 0; i + 1 < waveform.length; i += 2) {
+ mins[writeIndex] = waveform[i];
+ maxs[writeIndex] = waveform[i + 1];
+ clipped[writeIndex] = Math.abs(waveform[i]) >= 0.999f
+ || Math.abs(waveform[i + 1]) >= 0.999f;
+ writeIndex = (writeIndex + 1) % CAPACITY;
+ if (count < CAPACITY) {
+ count++;
+ }
+ }
+ invalidate();
+ }
+
+ public void reset() {
+ count = 0;
+ writeIndex = 0;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas canvas) {
+ super.onDraw(canvas);
+ int width = getWidth();
+ int height = getHeight();
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ canvas.drawRect(0, 0, width, height, backgroundPaint);
+
+ float centerY = height / 2f;
+ canvas.drawLine(0, centerY, width, centerY, centerPaint);
+
+ float amplitude = centerY * 0.92f;
+ float step = (float) width / CAPACITY;
+ for (int i = 0; i < count; i++) {
+ int index = (writeIndex - count + i + CAPACITY) % CAPACITY;
+ float x = i * step + step / 2f;
+ float top = centerY - maxs[index] * amplitude;
+ float bottom = centerY - mins[index] * amplitude;
+ canvas.drawLine(x, top, x, bottom, clipped[index] ? clipPaint : wavePaint);
+ }
+ }
+}