summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Cooks <tommasogagliardi+github@gmail.com>2026-09-24 14:16:46 -0400
committerTom Cooks <tommasogagliardi+github@gmail.com>2026-09-24 14:16:46 -0400
commit68402ebbdacfc95e40bd856caa8c4cb83bef8342 (patch)
treef08147f73013d79ad9eae09515f7f18e06f25134
parent2aa5e790a1ec4d74204d4ae3643508b0ac6b0aa4 (diff)
downloadreccoon-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)
-rw-r--r--README.md26
-rw-r--r--TODO.md11
-rw-r--r--app/build.gradle.kts4
-rw-r--r--app/src/main/java/com/wuhei/reccoon/InsetsHelper.java33
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MainActivity.java83
-rw-r--r--app/src/main/java/com/wuhei/reccoon/Marker.java19
-rw-r--r--app/src/main/java/com/wuhei/reccoon/MarkerStore.java97
-rw-r--r--app/src/main/java/com/wuhei/reccoon/PlayerActivity.java62
-rw-r--r--app/src/main/java/com/wuhei/reccoon/RecordingsActivity.java2
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WavRecorder.java142
-rw-r--r--app/src/main/java/com/wuhei/reccoon/WaveformView.java94
-rw-r--r--app/src/main/res/drawable/badge_marker.xml9
-rw-r--r--app/src/main/res/drawable/bg_record_active.xml8
-rw-r--r--app/src/main/res/drawable/bg_record_idle.xml8
-rw-r--r--app/src/main/res/layout/activity_main.xml98
-rw-r--r--app/src/main/res/layout/activity_player.xml31
-rw-r--r--app/src/main/res/layout/activity_recordings.xml1
-rw-r--r--app/src/main/res/values/strings.xml15
18 files changed, 674 insertions, 69 deletions
diff --git a/README.md b/README.md
index 5aba434..17bf0a0 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,9 @@ on device.
- **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
+- **Meters:** live scrolling waveform plus left/right peak level meters with a clip indicator while recording
+- **Mono switch:** optionally record a single channel (half the file size)
+- **Markers:** place labelled cue points while recording; they are embedded in the WAV (`cue ` + `LIST/adtl`) and shown in the player
- **Silence:** optional Do Not Disturb total silence (no notifications, no vibration) while recording
- **Saving:** the WAV is written to the public **Downloads** folder as
`reccoon-YYYY-MM-DD-HHMMSS-XXXXX.wav`, where `XXXXX` is a persistent
@@ -15,7 +17,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.2.0-alpha
+- **Name:** RECCoon · **by** wuhei · **version** 1.3.0-alpha
- **Icon:** raccoon with a microphone
## Requirements
@@ -37,7 +39,7 @@ Outputs:
- `app/build/outputs/apk/debug/app-debug.apk`
- `app/build/outputs/apk/release/app-release.apk`
-A prebuilt copy is available at `dist/RECCoon-1.2.0-alpha.apk`. Note that the
+A prebuilt copy is available at `dist/RECCoon-1.3.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`.
@@ -52,14 +54,22 @@ git because they are large; see `TODO.md`.
older versions it writes directly to the public Downloads directory and
notifies the media scanner.
-## Level meters and silencing
+## Waveform, meters and markers
- `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.
+- `WaveformView` draws a scrolling min/max waveform of the left/mono channel;
+ clipped columns are drawn in red.
+- The **Mark** button (enabled while recording) adds a labelled cue point at the
+ current position. Markers are written into the WAV as standard `cue ` and
+ `LIST/adtl` chunks (readable by Audacity/Reaper) by `WavRecorder`, and also
+ stored as a JSON sidecar by `MarkerStore`. `PlayerActivity` shows them as
+ chips that seek the player when tapped.
+- The **Mono** checkbox records a single channel instead of stereo.
+- The **Silence alerts** checkbox switches the phone to Do Not Disturb total
+ silence while recording and restores the previous setting on stop. It needs
+ the `ACCESS_NOTIFICATION_POLICY` permission, granted in system settings when
+ first enabled.
## Library and player
diff --git a/TODO.md b/TODO.md
index 8863a0e..c8d1f54 100644
--- a/TODO.md
+++ b/TODO.md
@@ -13,20 +13,21 @@
- [x] **Separate left/right volume meters** — done: stereo capture with a
mono fallback, plus a two-channel peak meter (`LevelMeterView`).
+- [x] **Mono switch** — done: the **Mono** checkbox records a single channel.
- [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.
+- [x] **Live waveform** — done: scrolling `WaveformView` with red clipped
+ columns.
+- [x] **WAV markers** — done: the **Mark** button embeds `cue `/`adtl` chunks in
+ the WAV and stores a JSON sidecar; the player shows marker chips.
## Backlog (from earlier discussion)
-- [ ] 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.
- [ ] Export transcripts as `.txt` / `.srt` and/or embed them in WAV metadata
(`LIST`/`INFO`, `bext`).
- [ ] Foreground service so recording continues when the app is in the
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).
+- [ ] Read embedded markers back from the WAV when there is no JSON sidecar.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 400e10e..b1a0836 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 = 3
- versionName = "1.2.0-alpha"
+ versionCode = 4
+ versionName = "1.3.0-alpha"
ndk {
// Prebuilt sherpa-onnx / onnxruntime native libraries.
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 &amp; 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>