From 2aa5e790a1ec4d74204d4ae3643508b0ac6b0aa4 Mon Sep 17 00:00:00 2001 From: Tom Cooks Date: Thu, 24 Sep 2026 12:43:47 -0400 Subject: Stereo recording with L/R level meters; optional Do Not Disturb while recording (1.2.0-alpha) - WavRecorder: stereo capture with mono fallback, per-channel peak levels - LevelMeterView: two-channel dBFS meter with peak hold and CLIP warning - MainActivity: silence notifications/vibrations via Do Not Disturb - Untrack large sherpa-onnx AAR and built APKs; note in TODO.md --- .gitignore | 5 + README.md | 24 +++- TODO.md | 25 ++-- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + .../java/com/wuhei/reccoon/LevelMeterView.java | 160 +++++++++++++++++++++ .../main/java/com/wuhei/reccoon/MainActivity.java | 88 ++++++++++++ .../main/java/com/wuhei/reccoon/WavRecorder.java | 148 +++++++++++++++---- app/src/main/res/layout/activity_main.xml | 44 ++++-- app/src/main/res/values/strings.xml | 9 +- 10 files changed, 452 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/wuhei/reccoon/LevelMeterView.java diff --git a/.gitignore b/.gitignore index f8edc3e..26a741d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ /captures .externalNativeBuild .cxx + +# Large prebuilt binaries — see TODO.md ("Build / distribution"). +# The sherpa-onnx AAR (~50 MB) and built APKs are not tracked in git yet. +/app/libs/*.aar +/dist/ diff --git a/README.md b/README.md index 8b0e8ce..5aba434 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,10 @@ A minimal Android app that records uncompressed WAV audio and transcribes it on device. -- **Format:** 44.1 kHz, 16-bit, mono, PCM (no compression) +- **Format:** 44.1 kHz, 16-bit PCM (stereo when the device supports it), no compression - **Controls:** one big round button — tap to start recording (timer starts), tap again to stop and save +- **Meters:** live left/right peak level meters with a clip indicator while recording +- **Silence:** optional Do Not Disturb total silence (no notifications, no vibration) while recording - **Saving:** the WAV is written to the public **Downloads** folder as `reccoon-YYYY-MM-DD-HHMMSS-XXXXX.wav`, where `XXXXX` is a persistent 5-digit progressive counter @@ -13,7 +15,7 @@ on device. - **Transcription:** on-device, offline speech recognition with [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) using multilingual Whisper — works with **English and Italian** -- **Name:** RECCoon · **by** wuhei · **version** 1.1.0-alpha +- **Name:** RECCoon · **by** wuhei · **version** 1.2.0-alpha - **Icon:** raccoon with a microphone ## Requirements @@ -35,18 +37,30 @@ Outputs: - `app/build/outputs/apk/debug/app-debug.apk` - `app/build/outputs/apk/release/app-release.apk` -A prebuilt copy is available at `dist/RECCoon-1.1.0-alpha.apk`. +A prebuilt copy is available at `dist/RECCoon-1.2.0-alpha.apk`. Note that the +sherpa-onnx AAR in `app/libs/` and the APK in `dist/` are **not** tracked in +git because they are large; see `TODO.md`. ## Recording -- `WavRecorder` uses `AudioRecord` (MIC source) to capture 16-bit PCM mono at +- `WavRecorder` uses `AudioRecord` (MIC source) to capture 16-bit PCM at 44100 Hz into a temporary file, then rewrites a correct 44-byte RIFF/WAVE - header when recording stops. + header when recording stops. It uses stereo when available and falls back to + mono, and reports per-channel peak levels to the UI. - `MainActivity` handles the runtime permissions, the timer, and copying the finished file into Downloads. On Android 10+ this uses `MediaStore`; on older versions it writes directly to the public Downloads directory and notifies the media scanner. +## Level meters and silencing + +- `LevelMeterView` draws a two-channel peak meter (dBFS scale) with a decaying + peak hold and a red `CLIP` warning when a sample reaches full scale. +- The **Silence notifications & vibrations while recording** checkbox switches + the phone to Do Not Disturb total silence while recording and restores the + previous setting on stop. It needs the `ACCESS_NOTIFICATION_POLICY` + permission, which the user grants in system settings when first enabled. + ## Library and player - `RecordingsActivity` lists the `reccoon-*.wav` files in Downloads. diff --git a/TODO.md b/TODO.md index c6af6c6..8863a0e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,19 +1,25 @@ # RECCoon TODO +## Build / distribution + +- [ ] **The sherpa-onnx binaries are too large to keep in git.** The Android + AAR is ~50 MB and the onnxruntime `.so` files push the APK to ~72 MB. The + AAR and the built APK are currently gitignored. Decide how to distribute + them later: Git LFS, a small Maven repository, a Gradle task that + downloads the AAR on first build, or Play Asset/Feature Delivery for the + Whisper models. + ## Requested -- [ ] **Separate left/right volume meters** — requires stereo capture; the - recorder is mono today, so this also means adding a stereo record mode - (44100 Hz / 16-bit / 2ch) and per-channel level computation. -- [ ] **Option to disable notifications and vibrations while recording** — - e.g. enable Do Not Disturb (with the app's notification policy access) - and/or mute the notification stream for the duration of the recording, - exposed as a user setting. +- [x] **Separate left/right volume meters** — done: stereo capture with a + mono fallback, plus a two-channel peak meter (`LevelMeterView`). +- [x] **Option to disable notifications and vibrations while recording** — done + via Do Not Disturb total silence (`ACCESS_NOTIFICATION_POLICY`), restored + on stop. Persisted as a checkbox. ## Backlog (from earlier discussion) -- [ ] Live waveform + clipping meter while recording (dBFS level bar, CLIP - badge, sample-peak/RMS from the captured PCM). +- [ ] Live scrolling waveform in addition to the level meters. - [ ] Markers embedded in the WAV via `cue ` + `LIST`/`adtl` (`labl`) chunks, plus a sidecar JSON / Room record for the app. - [ ] Mark button during recording (note / issue / chapter) with sample offsets. @@ -23,3 +29,4 @@ background. - [ ] Library actions: delete, rename, share, "open with". - [ ] Word-level timestamps and tap-to-seek inside a transcript segment. +- [ ] Let the user choose mono vs stereo recording (stereo doubles file size). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a786c44..400e10e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.wuhei.reccoon" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "1.1.0-alpha" + versionCode = 3 + versionName = "1.2.0-alpha" ndk { // Prebuilt sherpa-onnx / onnxruntime native libraries. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e247045..111b679 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + diff --git a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java b/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java new file mode 100644 index 0000000..d4b13e3 --- /dev/null +++ b/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java @@ -0,0 +1,160 @@ +package com.wuhei.reccoon; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * Two-channel (left/right) peak level meter with a decaying peak hold and a + * clip indicator. Levels are supplied as 0..1 linear amplitudes and mapped to + * a dBFS scale. + */ +public class LevelMeterView extends View { + + private static final float MIN_DB = -60f; + private static final long CLIP_HOLD_MS = 900L; + + private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint barPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint peakPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private final RectF barRect = new RectF(); + + private Shader gradient; + + private float leftTarget; + private float rightTarget; + private float leftLevel; + private float rightLevel; + private float leftPeak; + private float rightPeak; + private long clipUntil; + + public LevelMeterView(Context context) { + this(context, null); + } + + public LevelMeterView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + float density = getResources().getDisplayMetrics().density; + backgroundPaint.setColor(0x22FFFFFF); + peakPaint.setColor(0xFFFFFFFF); + labelPaint.setColor(0xFF93A0B0); + labelPaint.setTextSize(11f * density); + clipPaint.setColor(0xFFE53935); + clipPaint.setTextSize(11f * density); + clipPaint.setFakeBoldText(true); + clipPaint.setTextAlign(Paint.Align.RIGHT); + } + + /** Feed one measurement window. Amplitudes are 0..1. */ + public void setLevels(float leftPeak, float rightPeak, boolean clipped) { + this.leftTarget = clamp(leftPeak); + this.rightTarget = clamp(rightPeak); + if (clipped) { + clipUntil = System.currentTimeMillis() + CLIP_HOLD_MS; + } + invalidate(); + } + + public void reset() { + leftTarget = rightTarget = leftLevel = rightLevel = 0f; + leftPeak = rightPeak = 0f; + clipUntil = 0L; + invalidate(); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + gradient = new LinearGradient(0, 0, w, 0, + new int[]{0xFF3DDC84, 0xFF9CCC65, 0xFFFFD54F, 0xFFFF5252}, + new float[]{0f, 0.55f, 0.8f, 1f}, + Shader.TileMode.CLAMP); + } + + @Override + protected void onDraw(Canvas canvas) { + leftLevel = approach(leftLevel, leftTarget); + rightLevel = approach(rightLevel, rightTarget); + leftPeak = Math.max(leftTarget, leftPeak * 0.92f); + rightPeak = Math.max(rightTarget, rightPeak * 0.92f); + + float density = getResources().getDisplayMetrics().density; + float gap = 6f * density; + float barHeight = (getHeight() - gap) / 2f; + float labelWidth = labelPaint.measureText("L") + 10f * density; + float rightEdge = getWidth() - clipPaint.measureText("CLIP") - 10f * density; + + drawBar(canvas, 0f, labelWidth, rightEdge - labelWidth, barHeight, + leftLevel, leftPeak, "L"); + drawBar(canvas, barHeight + gap, labelWidth, rightEdge - labelWidth, barHeight, + rightLevel, rightPeak, "R"); + + if (System.currentTimeMillis() < clipUntil) { + float baseline = getHeight() / 2f - (clipPaint.descent() + clipPaint.ascent()) / 2f; + canvas.drawText("CLIP", getWidth(), baseline, clipPaint); + } + + // Keep animating while there is something to decay. + if (leftLevel > 0.002f || rightLevel > 0.002f + || leftPeak > 0.002f || rightPeak > 0.002f) { + postInvalidateOnAnimation(); + } + } + + private void drawBar(Canvas canvas, float top, float left, float width, float height, + float level, float peak, String label) { + float density = getResources().getDisplayMetrics().density; + float labelBaseline = top + height / 2f - (labelPaint.descent() + labelPaint.ascent()) / 2f; + canvas.drawText(label, 0f, labelBaseline, labelPaint); + + barRect.set(left, top, left + width, top + height); + canvas.drawRoundRect(barRect, height / 2f, height / 2f, backgroundPaint); + + float filled = width * toPosition(level); + if (filled > 0f) { + barPaint.setShader(gradient); + barRect.set(left, top, left + filled, top + height); + canvas.drawRoundRect(barRect, height / 2f, height / 2f, barPaint); + barPaint.setShader(null); + } + + float peakX = left + width * toPosition(peak); + if (peakX > left + 1f) { + canvas.drawRect(peakX - 1.5f * density, top, peakX, top + height, peakPaint); + } + } + + /** Maps a linear amplitude to a 0..1 bar position on a dBFS scale. */ + private static float toPosition(float amplitude) { + if (amplitude <= 0f) { + return 0f; + } + double db = 20.0 * Math.log10(amplitude); + if (db < MIN_DB) { + return 0f; + } + return clamp((float) ((db - MIN_DB) / -MIN_DB)); + } + + private static float approach(float current, float target) { + if (target >= current) { + return target; + } + return current + (target - current) * 0.25f; + } + + private static float clamp(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java index 435aeb5..c687fcb 100644 --- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java +++ b/app/src/main/java/com/wuhei/reccoon/MainActivity.java @@ -2,11 +2,14 @@ package com.wuhei.reccoon; import android.Manifest; import android.animation.ObjectAnimator; +import android.app.NotificationManager; import android.content.ContentResolver; import android.content.ContentValues; +import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.provider.Settings; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; @@ -24,7 +27,9 @@ 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; @@ -44,11 +49,15 @@ 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 ImageButton recordButton; private ImageView recIndicator; private TextView timerText; private TextView statusText; + private LevelMeterView levelMeter; + private AppCompatCheckBox silenceCheckbox; + private int savedInterruptionFilter = -1; private WavRecorder recorder; private ObjectAnimator blinkAnimator; @@ -68,8 +77,18 @@ public class MainActivity extends AppCompatActivity { recIndicator = findViewById(R.id.recIndicator); timerText = findViewById(R.id.timerText); statusText = findViewById(R.id.statusText); + levelMeter = findViewById(R.id.levelMeter); + silenceCheckbox = findViewById(R.id.silenceCheckbox); recorder = new WavRecorder(this); + recorder.setLevelListener((leftPeak, rightPeak, clipped) -> + runOnUiThread(() -> { + if (recording) { + levelMeter.setLevels(leftPeak, rightPeak, clipped); + } + })); + + setUpSilence(); blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f); blinkAnimator.setDuration(600); @@ -87,6 +106,7 @@ public class MainActivity extends AppCompatActivity { if (blinkAnimator != null) { blinkAnimator.cancel(); } + applySilence(false); super.onDestroy(); } @@ -136,6 +156,70 @@ public class MainActivity extends AppCompatActivity { } } + 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 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 startRecording() { try { recorder.start(); @@ -147,6 +231,8 @@ public class MainActivity extends AppCompatActivity { recording = true; startedAtMs = SystemClock.elapsedRealtime(); + levelMeter.reset(); + applySilence(true); recordButton.setBackgroundResource(R.drawable.bg_record_active); recordButton.setContentDescription(getString(R.string.cd_stop)); recIndicator.setVisibility(View.VISIBLE); @@ -170,6 +256,8 @@ public class MainActivity extends AppCompatActivity { recording = false; busy = true; + levelMeter.reset(); + applySilence(false); long elapsed = SystemClock.elapsedRealtime() - startedAtMs; handler.removeCallbacks(timerRunnable); blinkAnimator.cancel(); diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java index cb6cf93..3e82837 100644 --- a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java +++ b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java @@ -4,6 +4,7 @@ import android.content.Context; import android.media.AudioFormat; import android.media.AudioRecord; import android.media.MediaRecorder; +import android.os.SystemClock; import java.io.File; import java.io.FileOutputStream; @@ -14,29 +15,53 @@ import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; /** - * Records uncompressed 16-bit PCM mono audio at 44.1 kHz and writes a standard + * Records uncompressed 16-bit PCM audio at 44.1 kHz and writes a standard * RIFF/WAVE file (PCM, format code 1). + * + *

Stereo is used when the device supports it (so the left/right level + * meters are meaningful); it falls back to mono otherwise. Peak levels for both + * channels are reported through {@link LevelListener}. */ public class WavRecorder { public static final int SAMPLE_RATE = 44100; - public static final int CHANNELS = 1; public static final int BITS_PER_SAMPLE = 16; private static final int HEADER_SIZE = 44; + private static final long LEVEL_INTERVAL_MS = 40; + + 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 + */ + void onLevels(float leftPeak, float rightPeak, boolean clipped); + } private final Context context; + private LevelListener levelListener; private AudioRecord audioRecord; private Thread writerThread; private volatile boolean recording; private File tempFile; private volatile long pcmBytes; + private int channels = 2; public WavRecorder(Context context) { this.context = context.getApplicationContext(); } + public void setLevelListener(LevelListener listener) { + this.levelListener = listener; + } + + /** 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. */ @@ -45,25 +70,9 @@ public class WavRecorder { throw new IllegalStateException("A recording is already in progress"); } - int minBuffer = AudioRecord.getMinBufferSize( - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT); - if (minBuffer <= 0) { - throw new IOException("Unsupported audio configuration (minBuffer=" + minBuffer + ")"); - } - int bufferSize = Math.max(minBuffer, SAMPLE_RATE); - - audioRecord = new AudioRecord( - MediaRecorder.AudioSource.MIC, - SAMPLE_RATE, - AudioFormat.CHANNEL_IN_MONO, - AudioFormat.ENCODING_PCM_16BIT, - bufferSize); - if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { - audioRecord.release(); - audioRecord = null; - throw new IOException("AudioRecord failed to initialize"); + audioRecord = createAudioRecord(); + if (audioRecord == null) { + throw new IOException("No usable microphone / audio configuration"); } tempFile = new File(context.getCacheDir(), "reccoon_recording.wav"); @@ -73,15 +82,28 @@ public class WavRecorder { recording = true; audioRecord.startRecording(); - final int chunkSize = Math.max(minBuffer, 8192); + 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 = audioRecord.read(buffer, 0, buffer.length); + int read = record.read(buffer, 0, buffer.length); if (read > 0) { out.write(buffer, 0, read); pcmBytes += read; + + long now = SystemClock.uptimeMillis(); + if (levelListener != null && now - lastLevelTime >= LEVEL_INTERVAL_MS) { + lastLevelTime = now; + reportLevels(buffer, read, channelCount); + } } } out.flush(); @@ -94,6 +116,45 @@ public class WavRecorder { return tempFile; } + private void reportLevels(byte[] buffer, int length, int channelCount) { + int frameBytes = channelCount * 2; + int frames = length / frameBytes; + if (frames <= 0) { + return; + } + float leftPeak = 0f; + float rightPeak = 0f; + boolean clipped = false; + for (int i = 0; i < frames; i++) { + int offset = i * frameBytes; + short left = pcm16(buffer, offset); + float l = Math.abs((int) left) / 32768f; + if (l > leftPeak) { + leftPeak = l; + } + if (Math.abs((int) left) >= 32767) { + clipped = true; + } + if (channelCount > 1) { + short right = pcm16(buffer, offset + 2); + float r = Math.abs((int) right) / 32768f; + if (r > rightPeak) { + rightPeak = r; + } + if (Math.abs((int) right) >= 32767) { + clipped = true; + } + } + } + if (channelCount == 1) { + rightPeak = leftPeak; + } + LevelListener listener = levelListener; + if (listener != null) { + listener.onLevels(leftPeak, rightPeak, clipped); + } + } + /** * Stops capturing and finalizes the WAV header. Returns the finished file. */ @@ -138,6 +199,39 @@ public class WavRecorder { return recording; } + private AudioRecord createAudioRecord() { + int[] candidates = {AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO}; + for (int channelConfig : candidates) { + int minBuffer = AudioRecord.getMinBufferSize( + SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT); + if (minBuffer <= 0) { + continue; + } + int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1; + int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count); + AudioRecord candidate = new AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize); + if (candidate.getState() == AudioRecord.STATE_INITIALIZED) { + channels = count; + return candidate; + } + candidate.release(); + } + return null; + } + + private static int channelConfigFor(int channelCount) { + return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO; + } + + private static short pcm16(byte[] data, int offset) { + return (short) ((data[offset] & 0xff) | (data[offset + 1] << 8)); + } + private void writePlaceholderHeader() throws IOException { try (RandomAccessFile raf = new RandomAccessFile(tempFile, "rw")) { raf.setLength(0); @@ -154,9 +248,9 @@ public class WavRecorder { } } - private static byte[] buildHeader(long dataSize) { - int byteRate = SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8; - int blockAlign = CHANNELS * BITS_PER_SAMPLE / 8; + 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")); @@ -165,7 +259,7 @@ public class WavRecorder { buffer.put(ascii("fmt ")); buffer.putInt(16); // PCM chunk size buffer.putShort((short) 1); // PCM = 1 - buffer.putShort((short) CHANNELS); + buffer.putShort((short) channels); buffer.putInt(SAMPLE_RATE); buffer.putInt(byteRate); buffer.putShort((short) blockAlign); diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a281759..f7f2ac6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,11 +12,11 @@ android:id="@+id/titleText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="28dp" + android:layout_marginTop="24dp" android:letterSpacing="0.04" android:text="@string/app_name" android:textColor="@color/text_primary" - android:textSize="34sp" + android:textSize="32sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -26,7 +26,7 @@ android:id="@+id/libraryButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="18dp" + android:layout_marginTop="16dp" android:layout_marginEnd="4dp" android:background="?android:attr/selectableItemBackground" android:padding="8dp" @@ -52,7 +52,7 @@ android:id="@+id/recIndicator" android:layout_width="14dp" android:layout_height="14dp" - android:layout_marginBottom="10dp" + android:layout_marginBottom="8dp" android:contentDescription="@null" android:src="@drawable/ic_rec_dot" android:visibility="invisible" @@ -64,11 +64,11 @@ android:id="@+id/timerText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="20dp" + android:layout_marginBottom="14dp" android:fontFamily="monospace" android:text="@string/timer_zero" android:textColor="@color/text_primary" - android:textSize="56sp" + android:textSize="52sp" android:textStyle="bold" app:layout_constraintBottom_toTopOf="@id/recordButton" app:layout_constraintEnd_toEndOf="parent" @@ -76,8 +76,8 @@ + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintVertical_bias="0.42" /> + + + + + app:layout_constraintTop_toBottomOf="@id/silenceCheckbox" /> RECCoon - by wuhei · v1.1.0-alpha + by wuhei · v1.2.0-alpha 00:00 - 44.1 kHz · 16-bit · mono PCM (WAV) + 44.1 kHz · 16-bit · stereo PCM (WAV) Tap the button to start recording Recording… tap again to stop and save @@ -12,6 +12,11 @@ Start recording Stop and save + Silence notifications & vibrations while recording + Allow Do Not Disturb + To silence notifications and vibrations while recording, allow RECCoon access to Do Not Disturb. + Open settings + Microphone permission is required to record Could not start recording Could not save the file -- cgit v1.2.3