diff options
| author | Tom Cooks <tommasogagliardi+github@gmail.com> | 2026-09-24 14:16:46 -0400 |
|---|---|---|
| committer | Tom Cooks <tommasogagliardi+github@gmail.com> | 2026-09-24 14:16:46 -0400 |
| commit | 68402ebbdacfc95e40bd856caa8c4cb83bef8342 (patch) | |
| tree | f08147f73013d79ad9eae09515f7f18e06f25134 /app/src | |
| parent | 2aa5e790a1ec4d74204d4ae3643508b0ac6b0aa4 (diff) | |
| download | reccoon-68402ebbdacfc95e40bd856caa8c4cb83bef8342.tar.gz | |
Add mono switch, live waveform, and WAV cue markers (1.3.0-alpha)
- WavRecorder: optional mono, waveform envelope, cue/adtl marker chunks
- WaveformView: scrolling waveform with red clipped columns
- Marker + MarkerStore; Mark button with label dialog
- PlayerActivity: marker chips that seek; shared seekTo
- InsetsHelper: keep UI clear of status bar/notch and nav bar
- Bump to 1.3.0-alpha (versionCode 4)
Diffstat (limited to 'app/src')
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/InsetsHelper.java | 33 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/MainActivity.java | 83 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/Marker.java | 19 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/MarkerStore.java | 97 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/PlayerActivity.java | 62 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/WavRecorder.java | 142 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/WaveformView.java | 94 | ||||
| -rw-r--r-- | app/src/main/res/drawable/badge_marker.xml | 9 | ||||
| -rw-r--r-- | app/src/main/res/drawable/bg_record_active.xml | 8 | ||||
| -rw-r--r-- | app/src/main/res/drawable/bg_record_idle.xml | 8 | ||||
| -rw-r--r-- | app/src/main/res/layout/activity_main.xml | 98 | ||||
| -rw-r--r-- | app/src/main/res/layout/activity_player.xml | 31 | ||||
| -rw-r--r-- | app/src/main/res/layout/activity_recordings.xml | 1 | ||||
| -rw-r--r-- | app/src/main/res/values/strings.xml | 15 |
15 files changed, 648 insertions, 54 deletions
diff --git a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java b/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java new file mode 100644 index 0000000..660fa64 --- /dev/null +++ b/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java @@ -0,0 +1,33 @@ +package com.wuhei.reccoon; + +import android.view.View; + +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +/** + * Adds the system bar insets to a root view's padding so content is not hidden + * behind the status bar / notch or the navigation bar (edge-to-edge safe). + */ +public final class InsetsHelper { + + private InsetsHelper() { + } + + public static void apply(View root) { + if (root == null) { + return; + } + final int left = root.getPaddingLeft(); + final int top = root.getPaddingTop(); + final int right = root.getPaddingRight(); + final int bottom = root.getPaddingBottom(); + ViewCompat.setOnApplyWindowInsetsListener(root, (view, windowInsets) -> { + Insets insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()); + view.setPadding(left + insets.left, top + insets.top, + right + insets.right, bottom + insets.bottom); + return windowInsets; + }); + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/wuhei/reccoon/MainActivity.java index c687fcb..c8daf44 100644 --- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java +++ b/app/src/main/java/com/wuhei/reccoon/MainActivity.java @@ -10,6 +10,7 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.provider.Settings; +import android.text.InputType; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; @@ -21,6 +22,9 @@ import android.os.SystemClock; import android.provider.MediaStore; import android.view.View; import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; @@ -31,6 +35,9 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.AppCompatCheckBox; import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import java.io.File; import java.io.FileInputStream; @@ -40,6 +47,7 @@ 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; @@ -50,13 +58,17 @@ public class MainActivity extends AppCompatActivity { private static final String PREFS = "reccoon"; private static final String PREF_COUNTER = "counter"; private static final String PREF_SILENCE = "silence_notifications"; + private static final String PREF_MONO = "mono_recording"; private ImageButton recordButton; private ImageView recIndicator; private TextView timerText; private TextView statusText; private LevelMeterView levelMeter; + private WaveformView waveformView; private AppCompatCheckBox silenceCheckbox; + private AppCompatCheckBox monoCheckbox; + private Button markButton; private int savedInterruptionFilter = -1; private WavRecorder recorder; @@ -78,17 +90,24 @@ public class MainActivity extends AppCompatActivity { timerText = findViewById(R.id.timerText); statusText = findViewById(R.id.statusText); levelMeter = findViewById(R.id.levelMeter); + waveformView = findViewById(R.id.waveformView); silenceCheckbox = findViewById(R.id.silenceCheckbox); + monoCheckbox = findViewById(R.id.monoCheckbox); + markButton = findViewById(R.id.markButton); recorder = new WavRecorder(this); - recorder.setLevelListener((leftPeak, rightPeak, clipped) -> + recorder.setLevelListener((leftPeak, rightPeak, clipped, waveform) -> runOnUiThread(() -> { if (recording) { levelMeter.setLevels(leftPeak, rightPeak, clipped); + waveformView.append(waveform); } })); setUpSilence(); + setUpMono(); + markButton.setOnClickListener(v -> addMarker()); + applyWindowInsets(); blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f); blinkAnimator.setDuration(600); @@ -168,6 +187,57 @@ public class MainActivity extends AppCompatActivity { }); } + private void setUpMono() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + boolean mono = prefs.getBoolean(PREF_MONO, false); + monoCheckbox.setChecked(mono); + recorder.setForceMono(mono); + monoCheckbox.setOnCheckedChangeListener((button, checked) -> { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_MONO, checked).apply(); + recorder.setForceMono(checked); + }); + } + + private void addMarker() { + if (!recording) { + return; + } + final long offset = recorder.currentSampleOffset(); + final int next = recorder.getMarkers().size() + 1; + + EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setHint(R.string.marker_hint); + input.setText(getString(R.string.marker_fmt, next)); + input.selectAll(); + + int padding = (int) (20 * getResources().getDisplayMetrics().density); + FrameLayout container = new FrameLayout(this); + container.setPadding(padding, padding / 2, padding, 0); + container.addView(input); + + new AlertDialog.Builder(this) + .setTitle(R.string.marker_title) + .setView(container) + .setPositiveButton(R.string.marker_add, (dialog, which) -> { + String label = input.getText().toString().trim(); + if (label.isEmpty()) { + label = getString(R.string.marker_fmt, next); + } + recorder.addMarkerAt(offset, label); + Toast.makeText(this, getString(R.string.marker_added_fmt, next), + Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + /** Keeps the UI clear of the status bar/notch and the navigation bar. */ + private void applyWindowInsets() { + InsetsHelper.apply(findViewById(R.id.root)); + } + private void ensureDndAccess() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { return; @@ -221,6 +291,7 @@ public class MainActivity extends AppCompatActivity { } private void startRecording() { + recorder.setForceMono(monoCheckbox.isChecked()); try { recorder.start(); } catch (Exception e) { @@ -232,6 +303,8 @@ public class MainActivity extends AppCompatActivity { startedAtMs = SystemClock.elapsedRealtime(); levelMeter.reset(); + waveformView.reset(); + markButton.setEnabled(true); applySilence(true); recordButton.setBackgroundResource(R.drawable.bg_record_active); recordButton.setContentDescription(getString(R.string.cd_stop)); @@ -257,6 +330,8 @@ public class MainActivity extends AppCompatActivity { busy = true; levelMeter.reset(); + waveformView.reset(); + markButton.setEnabled(false); applySilence(false); long elapsed = SystemClock.elapsedRealtime() - startedAtMs; handler.removeCallbacks(timerRunnable); @@ -271,16 +346,22 @@ public class MainActivity extends AppCompatActivity { new Thread(() -> { File wav; + List<Marker> markers; try { wav = recorder.stop(); + markers = recorder.getMarkers(); } catch (Exception e) { wav = null; + markers = Collections.emptyList(); } String savedName = null; if (wav != null) { try { savedName = saveToDownloads(wav); + if (savedName != null && !markers.isEmpty()) { + new MarkerStore(this).save(savedName, markers); + } } catch (Exception ignored) { savedName = null; } diff --git a/app/src/main/java/com/wuhei/reccoon/Marker.java b/app/src/main/java/com/wuhei/reccoon/Marker.java new file mode 100644 index 0000000..d4a7ef5 --- /dev/null +++ b/app/src/main/java/com/wuhei/reccoon/Marker.java @@ -0,0 +1,19 @@ +package com.wuhei.reccoon; + +/** A user-placed marker inside a recording, stored as a WAV cue point. */ +public final class Marker { + + public final int index; + public final long sampleOffset; + public final String label; + + public Marker(int index, long sampleOffset, String label) { + this.index = index; + this.sampleOffset = sampleOffset; + this.label = label == null ? "" : label; + } + + public long timeMillis() { + return sampleOffset * 1000L / WavRecorder.SAMPLE_RATE; + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java new file mode 100644 index 0000000..9002264 --- /dev/null +++ b/app/src/main/java/com/wuhei/reccoon/MarkerStore.java @@ -0,0 +1,97 @@ +package com.wuhei.reccoon; + +import android.content.Context; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Stores recording markers as JSON sidecars (the same markers are also + * embedded in the WAV itself as {@code cue } / {@code adtl} chunks). + */ +public final class MarkerStore { + + private final File dir; + + public MarkerStore(Context context) { + dir = new File(context.getApplicationContext().getFilesDir(), "markers"); + } + + public boolean has(String recordingName) { + return fileFor(recordingName).isFile(); + } + + public void save(String recordingName, List<Marker> markers) throws IOException { + if (markers.isEmpty()) { + return; + } + JSONArray array = new JSONArray(); + try { + for (Marker marker : markers) { + JSONObject item = new JSONObject(); + item.put("index", marker.index); + item.put("offset", marker.sampleOffset); + item.put("label", marker.label); + array.put(item); + } + } catch (JSONException e) { + throw new IOException(e); + } + + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Cannot create marker directory"); + } + String json; + try { + json = array.toString(2); + } catch (JSONException e) { + throw new IOException(e); + } + try (FileOutputStream out = new FileOutputStream(fileFor(recordingName))) { + out.write(json.getBytes(StandardCharsets.UTF_8)); + } + } + + public List<Marker> load(String recordingName) { + List<Marker> markers = new ArrayList<>(); + File file = fileFor(recordingName); + if (!file.isFile()) { + return markers; + } + try (InputStream in = new java.io.FileInputStream(file)) { + byte[] buffer = new byte[(int) file.length()]; + int read = 0; + while (read < buffer.length) { + int n = in.read(buffer, read, buffer.length - read); + if (n < 0) { + break; + } + read += n; + } + JSONArray array = new JSONArray(new String(buffer, 0, read, StandardCharsets.UTF_8)); + for (int i = 0; i < array.length(); i++) { + JSONObject item = array.getJSONObject(i); + markers.add(new Marker( + item.optInt("index", i + 1), + item.optLong("offset", 0), + item.optString("label", ""))); + } + } catch (Exception e) { + // Return whatever we parsed. + } + return markers; + } + + private File fileFor(String recordingName) { + return new File(dir, recordingName + ".json"); + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java index e882fbc..6ebff5b 100644 --- a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java +++ b/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java @@ -20,6 +20,7 @@ import android.widget.Toast; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import java.util.List; import java.util.Locale; /** @@ -50,6 +51,8 @@ public class PlayerActivity extends AppCompatActivity { 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; @@ -59,6 +62,7 @@ public class PlayerActivity extends AppCompatActivity { private ModelRepository models; private TranscriptStore transcripts; + private MarkerStore markerStore; private final Runnable ticker = new Runnable() { @Override @@ -84,6 +88,7 @@ public class PlayerActivity extends AppCompatActivity { models = new ModelRepository(this); transcripts = new TranscriptStore(this); + markerStore = new MarkerStore(this); titleText = findViewById(R.id.titleText); timeText = findViewById(R.id.timeText); @@ -96,6 +101,8 @@ public class PlayerActivity extends AppCompatActivity { 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(RecordingsActivity.prettyName(recordingName)); @@ -103,6 +110,9 @@ public class PlayerActivity extends AppCompatActivity { setUpButtons(); setUpPlayer(); + InsetsHelper.apply(findViewById(R.id.root)); + showMarkers(markerStore.load(recordingName)); + TranscriptionEngine.Result existing = transcripts.load(recordingName); if (existing != null) { showTranscript(existing); @@ -407,18 +417,52 @@ public class PlayerActivity extends AppCompatActivity { view.setTextSize(15f); view.setPadding(0, 12, 0, 12); view.setBackgroundResource(android.R.drawable.list_selector_background); - view.setOnClickListener(v -> { - if (player != null && prepared) { - player.seekTo(Math.max(0, Math.min(seekMs, player.getDuration()))); - if (!player.isPlaying()) { - player.start(); - applySpeed(); - } - } - }); + view.setOnClickListener(v -> seekTo(seekMs)); 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(formatClock((int) marker.timeMillis())); + if (!marker.label.isEmpty()) { + text.append(" ").append(marker.label); + } + chip.setText(text.toString()); + chip.setTextColor(getResources().getColor(R.color.text_primary, getTheme())); + chip.setTextSize(13f); + chip.setPadding((int) (12 * density), (int) (6 * density), + (int) (12 * density), (int) (6 * density)); + chip.setBackgroundResource(R.drawable.badge_marker); + chip.setOnClickListener(v -> seekTo((int) marker.timeMillis())); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + params.rightMargin = (int) (8 * density); + chip.setLayoutParams(params); + markersContainer.addView(chip); + } + } + + private void seekTo(int millis) { + if (player == null || !prepared) { + return; + } + int target = Math.max(0, Math.min(millis, player.getDuration())); + player.seekTo(target); + seekBar.setProgress(target); + if (!player.isPlaying()) { + player.start(); + applySpeed(); + } + updateTimeLabel(); + } + private static String languageFor(int position) { switch (position) { case 1: diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java b/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java index 9551ef5..7424e10 100644 --- a/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java +++ b/app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java @@ -34,6 +34,8 @@ public class RecordingsActivity extends AppCompatActivity { transcripts = new TranscriptStore(this); emptyText = findViewById(R.id.emptyText); + InsetsHelper.apply(findViewById(R.id.root)); + ListView listView = findViewById(R.id.recordingsList); adapter = new Adapter(); listView.setAdapter(adapter); diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java index 3e82837..8279847 100644 --- a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java +++ b/app/src/main/java/com/wuhei/reccoon/WavRecorder.java @@ -6,6 +6,7 @@ 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; @@ -13,14 +14,21 @@ 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 for both - * channels are reported through {@link LevelListener}. + * 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 { @@ -29,17 +37,21 @@ public class WavRecorder { 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); + void onLevels(float leftPeak, float rightPeak, boolean clipped, float[] waveform); } private final Context context; + private final List<Marker> markers = new ArrayList<>(); private LevelListener levelListener; private AudioRecord audioRecord; @@ -48,6 +60,7 @@ public class WavRecorder { private File tempFile; private volatile long pcmBytes; private int channels = 2; + private boolean forceMono; public WavRecorder(Context context) { this.context = context.getApplicationContext(); @@ -57,6 +70,11 @@ public class WavRecorder { this.levelListener = listener; } + /** When true, always record a single channel (half the file size). */ + public void setForceMono(boolean forceMono) { + this.forceMono = forceMono; + } + /** Effective number of channels used by the last/current recording. */ public int getChannels() { return channels; @@ -69,6 +87,7 @@ public class WavRecorder { if (recording) { throw new IllegalStateException("A recording is already in progress"); } + markers.clear(); audioRecord = createAudioRecord(); if (audioRecord == null) { @@ -149,10 +168,54 @@ public class WavRecorder { if (channelCount == 1) { rightPeak = leftPeak; } + + float[] waveform = buildWaveform(buffer, frames, frameBytes); LevelListener listener = levelListener; if (listener != null) { - listener.onLevels(leftPeak, rightPeak, clipped); + 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)); } /** @@ -192,6 +255,7 @@ public class WavRecorder { writerThread = null; writeHeader(); + writeMarkers(); return tempFile; } @@ -200,7 +264,9 @@ public class WavRecorder { } private AudioRecord createAudioRecord() { - int[] candidates = {AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO}; + 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); @@ -248,6 +314,68 @@ public class WavRecorder { } } + /** 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; @@ -269,6 +397,10 @@ public class WavRecorder { return buffer.array(); } + private static byte[] intLE(int value) { + return ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(value).array(); + } + private static byte[] ascii(String value) { return value.getBytes(StandardCharsets.US_ASCII); } diff --git a/app/src/main/java/com/wuhei/reccoon/WaveformView.java b/app/src/main/java/com/wuhei/reccoon/WaveformView.java new file mode 100644 index 0000000..de62639 --- /dev/null +++ b/app/src/main/java/com/wuhei/reccoon/WaveformView.java @@ -0,0 +1,94 @@ +package com.wuhei.reccoon; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +/** + * A scrolling waveform of the left/mono channel. Columns whose peak reaches + * full scale are drawn in red so clipping is easy to spot. + */ +public class WaveformView extends View { + + private static final int CAPACITY = 600; + + private final float[] mins = new float[CAPACITY]; + private final float[] maxs = new float[CAPACITY]; + private final boolean[] clipped = new boolean[CAPACITY]; + + private final Paint wavePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint clipPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint centerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + + private int count; + private int writeIndex; + + public WaveformView(Context context) { + this(context, null); + } + + public WaveformView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + float density = getResources().getDisplayMetrics().density; + wavePaint.setColor(0xFF3DDC84); + wavePaint.setStrokeWidth(Math.max(1f, density)); + clipPaint.setColor(0xFFE53935); + clipPaint.setStrokeWidth(Math.max(1f, density)); + centerPaint.setColor(0x33FFFFFF); + centerPaint.setStrokeWidth(Math.max(1f, density * 0.6f)); + backgroundPaint.setColor(0x14000000); + } + + /** Appends min/max pairs ({@code [min0,max0,min1,max1,...]}) to the ring. */ + public void append(float[] waveform) { + if (waveform == null || waveform.length < 2) { + return; + } + for (int i = 0; i + 1 < waveform.length; i += 2) { + mins[writeIndex] = waveform[i]; + maxs[writeIndex] = waveform[i + 1]; + clipped[writeIndex] = Math.abs(waveform[i]) >= 0.999f + || Math.abs(waveform[i + 1]) >= 0.999f; + writeIndex = (writeIndex + 1) % CAPACITY; + if (count < CAPACITY) { + count++; + } + } + invalidate(); + } + + public void reset() { + count = 0; + writeIndex = 0; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + int width = getWidth(); + int height = getHeight(); + if (width <= 0 || height <= 0) { + return; + } + canvas.drawRect(0, 0, width, height, backgroundPaint); + + float centerY = height / 2f; + canvas.drawLine(0, centerY, width, centerY, centerPaint); + + float amplitude = centerY * 0.92f; + float step = (float) width / CAPACITY; + for (int i = 0; i < count; i++) { + int index = (writeIndex - count + i + CAPACITY) % CAPACITY; + float x = i * step + step / 2f; + float top = centerY - maxs[index] * amplitude; + float bottom = centerY - mins[index] * amplitude; + canvas.drawLine(x, top, x, bottom, clipped[index] ? clipPaint : wavePaint); + } + } +} diff --git a/app/src/main/res/drawable/badge_marker.xml b/app/src/main/res/drawable/badge_marker.xml new file mode 100644 index 0000000..267ee7e --- /dev/null +++ b/app/src/main/res/drawable/badge_marker.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <corners android:radius="14dp" /> + <solid android:color="#2A3442" /> + <stroke + android:width="1dp" + android:color="#3A4A5E" /> +</shape> diff --git a/app/src/main/res/drawable/bg_record_active.xml b/app/src/main/res/drawable/bg_record_active.xml index e218a37..4034b8c 100644 --- a/app/src/main/res/drawable/bg_record_active.xml +++ b/app/src/main/res/drawable/bg_record_active.xml @@ -9,10 +9,10 @@ </shape> </item> <item - android:bottom="62dp" - android:left="62dp" - android:right="62dp" - android:top="62dp"> + android:bottom="46dp" + android:left="46dp" + android:right="46dp" + android:top="46dp"> <shape android:shape="rectangle"> <corners android:radius="14dp" /> <solid android:color="@color/accent" /> diff --git a/app/src/main/res/drawable/bg_record_idle.xml b/app/src/main/res/drawable/bg_record_idle.xml index c9f776a..0a6be67 100644 --- a/app/src/main/res/drawable/bg_record_idle.xml +++ b/app/src/main/res/drawable/bg_record_idle.xml @@ -9,10 +9,10 @@ </shape> </item> <item - android:bottom="56dp" - android:left="56dp" - android:right="56dp" - android:top="56dp"> + android:bottom="42dp" + android:left="42dp" + android:right="42dp" + android:top="42dp"> <shape android:shape="oval"> <solid android:color="@color/accent" /> </shape> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index f7f2ac6..8bda7d3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,21 +2,22 @@ <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/background" - android:paddingLeft="24dp" - android:paddingRight="24dp"> + android:paddingLeft="20dp" + android:paddingRight="20dp"> <TextView android:id="@+id/titleText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="24dp" + android:layout_marginTop="16dp" android:letterSpacing="0.04" android:text="@string/app_name" android:textColor="@color/text_primary" - android:textSize="32sp" + android:textSize="30sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -26,7 +27,7 @@ android:id="@+id/libraryButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="16dp" + android:layout_marginTop="8dp" android:layout_marginEnd="4dp" android:background="?android:attr/selectableItemBackground" android:padding="8dp" @@ -40,19 +41,18 @@ android:id="@+id/subtitleText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="2dp" android:text="@string/subtitle" android:textColor="@color/text_secondary" - android:textSize="13sp" + android:textSize="12sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/titleText" /> <ImageView android:id="@+id/recIndicator" - android:layout_width="14dp" - android:layout_height="14dp" - android:layout_marginBottom="8dp" + android:layout_width="12dp" + android:layout_height="12dp" + android:layout_marginBottom="6dp" 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="14dp" + android:layout_marginBottom="10dp" android:fontFamily="monospace" android:text="@string/timer_zero" android:textColor="@color/text_primary" - android:textSize="52sp" + android:textSize="46sp" android:textStyle="bold" app:layout_constraintBottom_toTopOf="@id/recordButton" app:layout_constraintEnd_toEndOf="parent" @@ -76,8 +76,8 @@ <ImageButton android:id="@+id/recordButton" - android:layout_width="180dp" - android:layout_height="180dp" + android:layout_width="148dp" + android:layout_height="148dp" android:background="@drawable/bg_record_idle" android:contentDescription="@string/cd_record" android:scaleType="centerInside" @@ -86,51 +86,91 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintVertical_bias="0.42" /> + app:layout_constraintVertical_bias="0.34" /> + + <com.wuhei.reccoon.WaveformView + android:id="@+id/waveformView" + android:layout_width="0dp" + android:layout_height="64dp" + android:layout_marginTop="16dp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/recordButton" /> <com.wuhei.reccoon.LevelMeterView android:id="@+id/levelMeter" android:layout_width="0dp" - android:layout_height="46dp" - android:layout_marginTop="22dp" + android:layout_height="38dp" + android:layout_marginTop="10dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/recordButton" /> + app:layout_constraintTop_toBottomOf="@id/waveformView" /> - <androidx.appcompat.widget.AppCompatCheckBox - android:id="@+id/silenceCheckbox" + <Button + android:id="@+id/markButton" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="10dp" - android:text="@string/silence_recording" - android:textColor="@color/text_secondary" - android:textSize="13sp" + android:layout_marginTop="6dp" + android:enabled="false" + android:minWidth="140dp" + android:text="@string/mark_action" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/levelMeter" /> + <LinearLayout + android:id="@+id/optionsRow" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="2dp" + android:gravity="center" + android:orientation="horizontal" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/markButton"> + + <androidx.appcompat.widget.AppCompatCheckBox + android:id="@+id/monoCheckbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:contentDescription="@string/mono_recording" + android:text="@string/mono_short" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + + <androidx.appcompat.widget.AppCompatCheckBox + android:id="@+id/silenceCheckbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:contentDescription="@string/silence_recording" + android:text="@string/silence_short" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + </LinearLayout> + <TextView android:id="@+id/statusText" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginTop="14dp" + android:layout_marginTop="8dp" android:gravity="center" android:text="@string/status_idle" android:textColor="@color/text_secondary" - android:textSize="15sp" + android:textSize="14sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/silenceCheckbox" /> + app:layout_constraintTop_toBottomOf="@id/optionsRow" /> <TextView android:id="@+id/footerText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginBottom="12dp" + android:layout_marginBottom="10dp" android:alpha="0.6" android:text="@string/footer" android:textColor="@color/text_secondary" - android:textSize="12sp" + android:textSize="11sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 479efa9..67f5284 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <ScrollView xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/background" @@ -74,6 +75,36 @@ android:text="@string/player_speed_1x" /> </LinearLayout> + <LinearLayout + android:id="@+id/markersSection" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="14dp" + android:orientation="vertical" + android:visibility="gone"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/markers_title" + android:textColor="@color/text_primary" + android:textSize="15sp" + android:textStyle="bold" /> + + <HorizontalScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="6dp" + android:scrollbars="none"> + + <LinearLayout + android:id="@+id/markersContainer" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="horizontal" /> + </HorizontalScrollView> + </LinearLayout> + <View android:layout_width="match_parent" android:layout_height="1dp" diff --git a/app/src/main/res/layout/activity_recordings.xml b/app/src/main/res/layout/activity_recordings.xml index c0ad20a..aff2088 100644 --- a/app/src/main/res/layout/activity_recordings.xml +++ b/app/src/main/res/layout/activity_recordings.xml @@ -1,5 +1,6 @@ <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/root" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/background" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b08c3f7..54a69d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">RECCoon</string> - <string name="subtitle">by wuhei · v1.2.0-alpha</string> + <string name="subtitle">by wuhei · v1.3.0-alpha</string> <string name="timer_zero">00:00</string> - <string name="footer">44.1 kHz · 16-bit · stereo PCM (WAV)</string> + <string name="footer">44.1 kHz · 16-bit · PCM (WAV) · cue markers</string> <string name="status_idle">Tap the button to start recording</string> <string name="status_recording">Recording… tap again to stop and save</string> @@ -12,6 +12,17 @@ <string name="cd_record">Start recording</string> <string name="cd_stop">Stop and save</string> + <string name="mark_action">Mark</string> + <string name="marker_title">Add marker</string> + <string name="marker_hint">Marker label</string> + <string name="marker_add">Add</string> + <string name="marker_fmt">Marker %1$d</string> + <string name="marker_added_fmt">Marker %1$d added</string> + <string name="markers_title">Markers</string> + + <string name="mono_short">Mono</string> + <string name="mono_recording">Record in mono (half file size)</string> + <string name="silence_short">Silence alerts</string> <string name="silence_recording">Silence notifications & vibrations while recording</string> <string name="dnd_title">Allow Do Not Disturb</string> <string name="dnd_message">To silence notifications and vibrations while recording, allow RECCoon access to Do Not Disturb.</string> |
