diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/build.gradle.kts | 19 | ||||
| -rw-r--r-- | app/src/main/AndroidManifest.xml | 30 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java | 139 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/Formats.java (renamed from app/src/main/java/com/wuhei/reccoon/Formats.java) | 4 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java (renamed from app/src/main/java/com/wuhei/reccoon/InsetsHelper.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java (renamed from app/src/main/java/com/wuhei/reccoon/LevelMeterView.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java | 189 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/MainActivity.java (renamed from app/src/main/java/com/wuhei/reccoon/MainActivity.java) | 449 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/Marker.java (renamed from app/src/main/java/com/wuhei/reccoon/Marker.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/MarkerStore.java (renamed from app/src/main/java/com/wuhei/reccoon/MarkerStore.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java | 213 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/ModelRepository.java | 367 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java | 239 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java (renamed from app/src/main/java/com/wuhei/reccoon/PlayerActivity.java) | 141 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/RecordingActions.java (renamed from app/src/main/java/com/wuhei/reccoon/RecordingActions.java) | 13 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/RecordingService.java (renamed from app/src/main/java/com/wuhei/reccoon/RecordingService.java) | 6 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/RecordingStore.java (renamed from app/src/main/java/com/wuhei/reccoon/RecordingStore.java) | 34 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java (renamed from app/src/main/java/com/wuhei/reccoon/StreamingResampler.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java (renamed from app/src/main/java/com/wuhei/reccoon/TranscriptStore.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java (renamed from app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java) | 111 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/WavMarkers.java (renamed from app/src/main/java/com/wuhei/reccoon/WavMarkers.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/WavRecorder.java (renamed from app/src/main/java/com/wuhei/reccoon/WavRecorder.java) | 139 | ||||
| -rw-r--r-- | app/src/main/java/com/tomcooks/reccoon/WaveformView.java (renamed from app/src/main/java/com/wuhei/reccoon/WaveformView.java) | 2 | ||||
| -rw-r--r-- | app/src/main/java/com/wuhei/reccoon/ModelRepository.java | 206 | ||||
| -rw-r--r-- | app/src/main/res/layout/activity_main.xml | 111 | ||||
| -rw-r--r-- | app/src/main/res/layout/activity_player.xml | 20 | ||||
| -rw-r--r-- | app/src/main/res/values/strings.xml | 32 | ||||
| -rw-r--r-- | app/src/test/java/com/tomcooks/reccoon/FormatsTest.java | 20 | ||||
| -rw-r--r-- | app/src/test/java/com/tomcooks/reccoon/StreamingResamplerTest.java (renamed from app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java) | 2 |
29 files changed, 2084 insertions, 416 deletions
diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1e4768a..1f5fb20 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } android { - namespace = "com.wuhei.reccoon" + namespace = "com.tomcooks.reccoon" compileSdk { version = release(37) { @@ -12,15 +12,16 @@ android { } defaultConfig { - applicationId = "com.wuhei.reccoon" + applicationId = "com.tomcooks.reccoon" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.4.0-alpha" + versionCode = 1 + versionName = "1.0.0" ndk { - // Prebuilt sherpa-onnx / onnxruntime native libraries. - abiFilters += listOf("arm64-v8a", "x86_64") + // Prebuilt sherpa-onnx / onnxruntime native libraries. Only arm64 is + // shipped; x86_64 was only useful for emulators and doubled the APK. + abiFilters += listOf("arm64-v8a") } } @@ -58,8 +59,10 @@ dependencies { // Kotlin runtime required by the sherpa-onnx Kotlin API. implementation("org.jetbrains.kotlin:kotlin-stdlib:2.4.20") - // On-device speech recognition (Whisper multilingual: English + Italian). - implementation(files("libs/sherpa-onnx-1.13.8.aar")) + // On-device speech recognition runtime (loads the Parakeet ONNX graphs). + // Pulled from JitPack (an F-Droid-trusted Maven repository) so no prebuilt + // binary has to live in this repo. Byte-identical to the official AAR. + implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:1.13.8@aar") testImplementation("junit:junit:4.13.2") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 791d668..eecbcc1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,11 +2,22 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> + <uses-permission + android:name="android.permission.BLUETOOTH_CONNECT" /> + <uses-permission + android:name="android.permission.BLUETOOTH" + android:maxSdkVersion="30" /> + <uses-permission + android:name="android.permission.BLUETOOTH_ADMIN" + android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> @@ -24,10 +35,16 @@ android:supportsRtl="true" android:theme="@style/Theme.Reccoon"> + <!-- + No screenOrientation lock: the UI rotates with the device so the level + meters and waveform stay upright. configChanges keeps the activity (and + the in-progress WavRecorder) alive across a rotation instead of recreating + it. + --> <activity android:name=".MainActivity" - android:exported="true" - android:screenOrientation="portrait"> + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode" + android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> @@ -36,14 +53,19 @@ <activity android:name=".PlayerActivity" - android:exported="false" - android:screenOrientation="portrait" /> + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode" + android:exported="false" /> <service android:name=".RecordingService" android:exported="false" android:foregroundServiceType="microphone" /> + <service + android:name=".ModelDownloadService" + android:exported="false" + android:foregroundServiceType="dataSync" /> + <provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" diff --git a/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java b/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java new file mode 100644 index 0000000..168cf53 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/AudioCompressor.java @@ -0,0 +1,139 @@ +package com.tomcooks.reccoon; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaFormat; +import android.media.MediaMuxer; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * Transcodes a PCM WAV file into a small AAC-LC MP4/M4A file. + * + * <p>AAC-LC is used because every Android device ships an encoder for it, + * unlike Opus. At speech bitrates the result is roughly 10-20× smaller than the + * 44.1 kHz 16-bit WAV (for example ~22 MB per hour at 48 kbps mono instead of + * ~317 MB). The lossy step keeps enough bandwidth (16 kHz) for speech + * transcription; see {@link PcmAudioSource} for decoding it again. + */ +public final class AudioCompressor { + + private static final long TIMEOUT_US = 10_000; + private static final int CHUNK_FRAMES = 4096; + + private AudioCompressor() { + } + + /** @return the number of bytes written to {@code m4a}. */ + public static long compressToM4a(File wav, File m4a) throws IOException { + if (m4a.exists() && !m4a.delete()) { + throw new IOException("Cannot replace " + m4a); + } + try (BufferedInputStream in = + new BufferedInputStream(new FileInputStream(wav), 256 * 1024)) { + TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(in); + if (info.bitsPerSample != 16 || info.audioFormat != 1) { + throw new IOException("Only 16-bit PCM WAV files can be compressed"); + } + encode(in, info, m4a); + } + return m4a.length(); + } + + private static void encode(InputStream pcm, TranscriptionEngine.WavInfo info, File m4a) + throws IOException { + int sampleRate = Math.max(1, info.sampleRate); + int channels = Math.max(1, info.channels); + + MediaFormat format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels); + format.setInteger(MediaFormat.KEY_AAC_PROFILE, + MediaCodecInfo.CodecProfileLevel.AACObjectLC); + // Small but still clear for speech: 48 kbps per channel. + format.setInteger(MediaFormat.KEY_BIT_RATE, 48_000 * channels); + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, CHUNK_FRAMES * channels * 2); + + MediaCodec codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC); + MediaMuxer muxer = new MediaMuxer(m4a.getAbsolutePath(), + MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + boolean muxerStarted = false; + boolean inputDone = false; + boolean outputDone = false; + int trackIndex = -1; + long presentationTimeUs = 0L; + long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; + + try { + codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); + codec.start(); + + byte[] buffer = new byte[CHUNK_FRAMES * channels * 2]; + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + + while (!outputDone) { + if (!inputDone) { + int inIndex = codec.dequeueInputBuffer(TIMEOUT_US); + if (inIndex >= 0) { + ByteBuffer input = codec.getInputBuffer(inIndex); + int capacity = input == null ? buffer.length : input.capacity(); + int toRead = (int) Math.min(Math.min(buffer.length, remaining), capacity); + int read = toRead <= 0 ? -1 : pcm.read(buffer, 0, toRead); + if (read < 0) { + codec.queueInputBuffer(inIndex, 0, 0, presentationTimeUs, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + remaining -= read; + input.clear(); + input.put(buffer, 0, read); + codec.queueInputBuffer(inIndex, 0, read, presentationTimeUs, 0); + long frames = read / (channels * 2L); + presentationTimeUs += frames * 1_000_000L / sampleRate; + } + } + } + + int outIndex = codec.dequeueOutputBuffer(bufferInfo, TIMEOUT_US); + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + trackIndex = muxer.addTrack(codec.getOutputFormat()); + muxer.start(); + muxerStarted = true; + } else if (outIndex >= 0) { + ByteBuffer output = codec.getOutputBuffer(outIndex); + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + bufferInfo.size = 0; + } + if (bufferInfo.size > 0 && muxerStarted && output != null) { + output.position(bufferInfo.offset); + output.limit(bufferInfo.offset + bufferInfo.size); + muxer.writeSampleData(trackIndex, output, bufferInfo); + } + codec.releaseOutputBuffer(outIndex, false); + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + outputDone = true; + } + } + } + } finally { + try { + codec.stop(); + } catch (Exception ignored) { + // Ignore. + } + codec.release(); + if (muxerStarted) { + try { + muxer.stop(); + } catch (Exception ignored) { + // Ignore. + } + } + muxer.release(); + } + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/Formats.java b/app/src/main/java/com/tomcooks/reccoon/Formats.java index 37e0909..338fd58 100644 --- a/app/src/main/java/com/wuhei/reccoon/Formats.java +++ b/app/src/main/java/com/tomcooks/reccoon/Formats.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import java.util.Locale; @@ -17,7 +17,7 @@ public final class Formats { if (core.startsWith("reccoon-")) { core = core.substring("reccoon-".length()); } - if (core.endsWith(".wav")) { + if (core.endsWith(".wav") || core.endsWith(".m4a")) { core = core.substring(0, core.length() - 4); } String[] parts = core.split("-"); diff --git a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java index 660fa64..27939be 100644 --- a/app/src/main/java/com/wuhei/reccoon/InsetsHelper.java +++ b/app/src/main/java/com/tomcooks/reccoon/InsetsHelper.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.view.View; diff --git a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java index d4b13e3..362ee6a 100644 --- a/app/src/main/java/com/wuhei/reccoon/LevelMeterView.java +++ b/app/src/main/java/com/tomcooks/reccoon/LevelMeterView.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; import android.graphics.Canvas; diff --git a/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java new file mode 100644 index 0000000..6366d84 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/LiveMonitor.java @@ -0,0 +1,189 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioDeviceInfo; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; + +import java.util.ArrayDeque; + +/** + * Plays captured PCM back to a chosen output device so the user can hear what is + * being recorded (live monitoring / sidetone). + * + * <p>The monitor is decoupled from the capture loop by a small bounded queue and + * a dedicated thread: if the output device is slow (for example Bluetooth A2DP + * with its inherent latency) chunks are dropped instead of stalling the + * recorder and corrupting the recording. + * + * <p>With the phone speaker as the output and the phone microphone as the input + * there will be feedback, so headphones are recommended. + */ +public final class LiveMonitor { + + private static final int MAX_QUEUED_CHUNKS = 8; + + private final AudioManager audioManager; + private final ArrayDeque<byte[]> queue = new ArrayDeque<>(); + + private AudioTrack track; + private Thread thread; + private volatile boolean running; + private int preferredDeviceId = -1; + + public LiveMonitor(Context context) { + audioManager = (AudioManager) context.getApplicationContext() + .getSystemService(Context.AUDIO_SERVICE); + } + + public void setPreferredDeviceId(int deviceId) { + this.preferredDeviceId = deviceId; + } + + public boolean isRunning() { + return running; + } + + /** @return true when the output could be opened and playback started. */ + public boolean start(int sampleRate, int channels) { + stop(); + int channelMask = channels > 1 + ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; + int minBuffer = AudioTrack.getMinBufferSize( + sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); + int target = sampleRate * channels * 2 / 5; // ~200 ms + int bufferSize = Math.max(Math.max(minBuffer, target), 1024); + + try { + track = new AudioTrack.Builder() + .setAudioAttributes(new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .build()) + .setBufferSizeInBytes(bufferSize) + .setTransferMode(AudioTrack.MODE_STREAM) + .setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) + .build(); + } catch (Exception e) { + track = null; + return false; + } + + if (track.getState() != AudioTrack.STATE_INITIALIZED) { + track.release(); + track = null; + return false; + } + applyPreferredDevice(); + try { + track.play(); + } catch (Exception e) { + track.release(); + track = null; + return false; + } + + running = true; + thread = new Thread(this::loop, "reccoon-monitor"); + thread.start(); + return true; + } + + /** Queues a chunk of PCM to play. Safe to call from the capture thread. */ + public void write(byte[] data, int length) { + if (!running || data == null || length <= 0) { + return; + } + byte[] copy = new byte[length]; + System.arraycopy(data, 0, copy, 0, length); + synchronized (queue) { + while (queue.size() >= MAX_QUEUED_CHUNKS) { + queue.pollFirst(); + } + queue.addLast(copy); + queue.notifyAll(); + } + } + + private void loop() { + while (running) { + byte[] chunk; + synchronized (queue) { + while (running && queue.isEmpty()) { + try { + queue.wait(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + chunk = queue.pollFirst(); + } + if (chunk == null) { + continue; + } + AudioTrack target = track; + if (target != null) { + try { + target.write(chunk, 0, chunk.length); + } catch (Exception ignored) { + // Drop this chunk and keep going. + } + } + } + } + + public void stop() { + running = false; + synchronized (queue) { + queue.clear(); + queue.notifyAll(); + } + Thread current = thread; + thread = null; + if (current != null) { + try { + current.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + AudioTrack target = track; + track = null; + if (target != null) { + try { + target.stop(); + } catch (Exception ignored) { + // Ignore. + } + try { + target.release(); + } catch (Exception ignored) { + // Ignore. + } + } + } + + private void applyPreferredDevice() { + if (preferredDeviceId < 0 || audioManager == null || track == null) { + return; + } + try { + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getId() == preferredDeviceId) { + track.setPreferredDevice(device); + return; + } + } + } catch (Exception ignored) { + // Keep the default output. + } + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/MainActivity.java b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java index d11726f..b350965 100644 --- a/app/src/main/java/com/wuhei/reccoon/MainActivity.java +++ b/app/src/main/java/com/tomcooks/reccoon/MainActivity.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.Manifest; import android.animation.ObjectAnimator; @@ -9,6 +9,8 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; @@ -23,12 +25,14 @@ import android.text.InputType; import android.view.LayoutInflater; import android.view.View; import android.view.WindowManager; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; +import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -58,6 +62,17 @@ public class MainActivity extends AppCompatActivity { private static final String PREF_COUNTER = "counter"; private static final String PREF_SILENCE = "silence_notifications"; private static final String PREF_MONO = "mono_recording"; + private static final String PREF_COMPRESS = "compress_recordings"; + private static final String PREF_INPUT = "input_device"; + private static final String PREF_SWAP = "swap_channels"; + private static final String PREF_MONITOR = "live_monitor"; + private static final String PREF_OUTPUT = "output_device"; + + /** Spinner positions that are not a concrete {@link AudioDeviceInfo}. */ + private static final int INPUT_AUTO = -1; + private static final int INPUT_PHONE = -2; + private static final int OUTPUT_AUTO = -1; + private static final int OUTPUT_SPEAKER = -2; private ImageButton recordButton; private ImageView recIndicator; @@ -67,7 +82,15 @@ public class MainActivity extends AppCompatActivity { private WaveformView waveformView; private AppCompatCheckBox silenceCheckbox; private AppCompatCheckBox monoCheckbox; + private AppCompatCheckBox compressCheckbox; + private AppCompatCheckBox swapCheckbox; + private AppCompatCheckBox monitorCheckbox; + private Spinner inputSpinner; + private Spinner outputSpinner; + private final List<AudioDeviceInfo> inputDevices = new ArrayList<>(); + private final List<AudioDeviceInfo> outputDevices = new ArrayList<>(); private Button markButton; + private Button discardButton; private LinearLayout recordingsContainer; private TextView emptyText; private int savedInterruptionFilter = -1; @@ -96,7 +119,13 @@ public class MainActivity extends AppCompatActivity { waveformView = findViewById(R.id.waveformView); silenceCheckbox = findViewById(R.id.silenceCheckbox); monoCheckbox = findViewById(R.id.monoCheckbox); + compressCheckbox = findViewById(R.id.compressCheckbox); + swapCheckbox = findViewById(R.id.swapCheckbox); + monitorCheckbox = findViewById(R.id.monitorCheckbox); + inputSpinner = findViewById(R.id.inputSpinner); + outputSpinner = findViewById(R.id.outputSpinner); markButton = findViewById(R.id.markButton); + discardButton = findViewById(R.id.discardButton); recordingsContainer = findViewById(R.id.recordingsContainer); emptyText = findViewById(R.id.emptyText); @@ -114,7 +143,17 @@ public class MainActivity extends AppCompatActivity { setUpSilence(); setUpMono(); + setUpCompress(); + setUpSwap(); + setUpMonitor(); + populateInputDevices(); + populateOutputDevices(); markButton.setOnClickListener(v -> addMarker()); + discardButton.setOnClickListener(v -> { + if (recording && !busy) { + stopRecording(true); + } + }); InsetsHelper.apply(findViewById(R.id.root)); blinkAnimator = ObjectAnimator.ofFloat(recIndicator, View.ALPHA, 1f, 0.15f); @@ -128,6 +167,12 @@ public class MainActivity extends AppCompatActivity { @Override protected void onResume() { super.onResume(); + if (inputDevices.isEmpty()) { + populateInputDevices(); + } + if (outputDevices.isEmpty()) { + populateOutputDevices(); + } renderRecordings(); } @@ -174,7 +219,7 @@ public class MainActivity extends AppCompatActivity { return; } if (recording) { - stopRecordingAndSave(); + stopRecording(false); return; } List<String> missing = missingPermissions(); @@ -196,6 +241,12 @@ public class MainActivity extends AppCompatActivity { != PackageManager.PERMISSION_GRANTED) { missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE); } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && (selectedInputIsBluetooth() || selectedOutputIsBluetooth()) + && ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) + != PackageManager.PERMISSION_GRANTED) { + missing.add(Manifest.permission.BLUETOOTH_CONNECT); + } return missing; } @@ -209,6 +260,7 @@ public class MainActivity extends AppCompatActivity { } if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + populateInputDevices(); startRecording(); } else { Toast.makeText(this, R.string.err_permission, Toast.LENGTH_LONG).show(); @@ -239,6 +291,316 @@ public class MainActivity extends AppCompatActivity { }); } + private void setUpCompress() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + compressCheckbox.setChecked(prefs.getBoolean(PREF_COMPRESS, false)); + compressCheckbox.setOnCheckedChangeListener((button, checked) -> + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_COMPRESS, checked).apply()); + } + + private void setUpSwap() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + swapCheckbox.setChecked(prefs.getBoolean(PREF_SWAP, false)); + swapCheckbox.setOnCheckedChangeListener((button, checked) -> + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_SWAP, checked).apply()); + } + + private void setUpMonitor() { + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + monitorCheckbox.setChecked(prefs.getBoolean(PREF_MONITOR, false)); + monitorCheckbox.setOnCheckedChangeListener((button, checked) -> { + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putBoolean(PREF_MONITOR, checked).apply(); + if (checked) { + Toast.makeText(this, R.string.monitor_warning, Toast.LENGTH_LONG).show(); + } + }); + } + + // --- Output source for live monitoring --------------------------------- + + private void populateOutputDevices() { + int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_OUTPUT, OUTPUT_AUTO); + outputDevices.clear(); + List<String> labels = new ArrayList<>(); + labels.add(getString(R.string.output_auto)); + labels.add(getString(R.string.output_speaker)); + + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager != null) { + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + // The built-in speaker is already covered by the entry above. + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { + continue; + } + outputDevices.add(device); + labels.add(describeOutput(device)); + } + } catch (Exception ignored) { + // No device list available. + } + } + + ArrayAdapter<String> adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + outputSpinner.setAdapter(adapter); + + if (selected == OUTPUT_SPEAKER) { + outputSpinner.setSelection(1); + return; + } + for (int i = 0; i < outputDevices.size(); i++) { + if (outputDevices.get(i).getId() == selected) { + outputSpinner.setSelection(i + 2); + return; + } + } + outputSpinner.setSelection(0); + } + + /** Resolves the spinner to a concrete device id, or -1 for the default. */ + private int resolveOutputDeviceId() { + int position = outputSpinner.getSelectedItemPosition(); + if (position == 1) { + return findBuiltinSpeakerId(); + } + if (position >= 2 && position - 2 < outputDevices.size()) { + return outputDevices.get(position - 2).getId(); + } + return OUTPUT_AUTO; + } + + private int selectedOutputId() { + int position = outputSpinner.getSelectedItemPosition(); + if (position == 1) { + return OUTPUT_SPEAKER; + } + if (position >= 2 && position - 2 < outputDevices.size()) { + return outputDevices.get(position - 2).getId(); + } + return OUTPUT_AUTO; + } + + private boolean selectedOutputIsBluetooth() { + int position = outputSpinner.getSelectedItemPosition(); + return position >= 2 && position - 2 < outputDevices.size() + && isBluetoothOutput(outputDevices.get(position - 2)); + } + + private static boolean isBluetoothOutput(AudioDeviceInfo device) { + int type = device.getType(); + return type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP + || type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + || type == AudioDeviceInfo.TYPE_BLE_HEADSET + || type == AudioDeviceInfo.TYPE_BLE_SPEAKER; + } + + private int findBuiltinSpeakerId() { + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) { + return OUTPUT_AUTO; + } + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) { + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) { + return device.getId(); + } + } + } catch (Exception ignored) { + // Fall through to the default output. + } + return OUTPUT_AUTO; + } + + private static String describeOutput(AudioDeviceInfo device) { + String type; + switch (device.getType()) { + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + type = "Bluetooth"; + break; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + type = "Bluetooth (SCO)"; + break; + case AudioDeviceInfo.TYPE_BLE_HEADSET: + type = "Bluetooth LE headset"; + break; + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + type = "Bluetooth LE speaker"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + type = "Wired headphones"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + type = "Wired headset"; + break; + case AudioDeviceInfo.TYPE_USB_DEVICE: + type = "USB device"; + break; + case AudioDeviceInfo.TYPE_USB_HEADSET: + type = "USB headset"; + break; + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + type = "Earpiece"; + break; + default: + type = "Output " + device.getType(); + break; + } + CharSequence name = device.getProductName(); + if (name != null && name.length() > 0) { + type += " \u2014 " + name; + } + return type; + } + + // --- Microphone source (BT-Mic-Force integration) --------------------- + + /** A resolved microphone choice to hand to {@link WavRecorder}. */ + private static final class InputChoice { + final int deviceId; + final boolean bluetoothSco; + + InputChoice(int deviceId, boolean bluetoothSco) { + this.deviceId = deviceId; + this.bluetoothSco = bluetoothSco; + } + } + + private void populateInputDevices() { + int selected = getSharedPreferences(PREFS, MODE_PRIVATE).getInt(PREF_INPUT, INPUT_AUTO); + inputDevices.clear(); + List<String> labels = new ArrayList<>(); + labels.add(getString(R.string.input_auto)); + labels.add(getString(R.string.input_phone)); + + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager != null + && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + // The built-in mic is already covered by the "Phone mic" entry. + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) { + continue; + } + inputDevices.add(device); + labels.add(describeInput(device)); + } + } catch (Exception ignored) { + // No device list available. + } + } + + ArrayAdapter<String> adapter = new ArrayAdapter<>(this, + android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + inputSpinner.setAdapter(adapter); + + if (selected == INPUT_PHONE) { + inputSpinner.setSelection(1); + return; + } + for (int i = 0; i < inputDevices.size(); i++) { + if (inputDevices.get(i).getId() == selected) { + inputSpinner.setSelection(i + 2); + return; + } + } + inputSpinner.setSelection(0); + } + + private InputChoice resolveInput() { + int position = inputSpinner.getSelectedItemPosition(); + if (position == 1) { + return new InputChoice(findBuiltinMicId(), false); + } + if (position >= 2 && position - 2 < inputDevices.size()) { + AudioDeviceInfo device = inputDevices.get(position - 2); + return new InputChoice(device.getId(), isBluetoothInput(device)); + } + return new InputChoice(INPUT_AUTO, false); + } + + private int selectedInputId() { + int position = inputSpinner.getSelectedItemPosition(); + if (position == 1) { + return INPUT_PHONE; + } + if (position >= 2 && position - 2 < inputDevices.size()) { + return inputDevices.get(position - 2).getId(); + } + return INPUT_AUTO; + } + + private boolean selectedInputIsBluetooth() { + int position = inputSpinner.getSelectedItemPosition(); + return position >= 2 && position - 2 < inputDevices.size() + && isBluetoothInput(inputDevices.get(position - 2)); + } + + private int findBuiltinMicId() { + AudioManager manager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + if (manager == null) { + return INPUT_AUTO; + } + try { + for (AudioDeviceInfo device : manager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (device.getType() == AudioDeviceInfo.TYPE_BUILTIN_MIC) { + return device.getId(); + } + } + } catch (Exception ignored) { + // Fall through to auto. + } + return INPUT_AUTO; + } + + private static boolean isBluetoothInput(AudioDeviceInfo device) { + int type = device.getType(); + return type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + || type == AudioDeviceInfo.TYPE_BLE_HEADSET + || type == AudioDeviceInfo.TYPE_BLE_SPEAKER; + } + + private static String describeInput(AudioDeviceInfo device) { + String type; + switch (device.getType()) { + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + type = "Bluetooth (SCO)"; + break; + case AudioDeviceInfo.TYPE_BLE_HEADSET: + type = "Bluetooth LE headset"; + break; + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + type = "Bluetooth LE speaker"; + break; + case AudioDeviceInfo.TYPE_HEARING_AID: + type = "Hearing aid"; + break; + case AudioDeviceInfo.TYPE_USB_DEVICE: + type = "USB device"; + break; + case AudioDeviceInfo.TYPE_USB_HEADSET: + type = "USB headset"; + break; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + type = "Wired headset"; + break; + default: + type = "Input " + device.getType(); + break; + } + CharSequence name = device.getProductName(); + if (name != null && name.length() > 0) { + type += " \u2014 " + name; + } + return type; + } + private void addMarker() { if (!recording) { return; @@ -344,13 +706,26 @@ public class MainActivity extends AppCompatActivity { } private void startRecording() { + InputChoice input = resolveInput(); + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putInt(PREF_INPUT, selectedInputId()).apply(); + recorder.setPreferredDeviceId(input.deviceId); + recorder.setBluetoothSco(input.bluetoothSco); + recorder.setSwapChannels(swapCheckbox.isChecked()); recorder.setForceMono(monoCheckbox.isChecked()); + recorder.setMonitoring(monitorCheckbox.isChecked()); + recorder.setMonitorDeviceId(resolveOutputDeviceId()); + getSharedPreferences(PREFS, MODE_PRIVATE) + .edit().putInt(PREF_OUTPUT, selectedOutputId()).apply(); try { recorder.start(); } catch (Exception e) { Toast.makeText(this, R.string.err_record, Toast.LENGTH_LONG).show(); return; } + if (monitorCheckbox.isChecked() && !recorder.isMonitoring()) { + Toast.makeText(this, R.string.err_monitor, Toast.LENGTH_LONG).show(); + } recording = true; startedAtMs = SystemClock.elapsedRealtime(); @@ -359,6 +734,7 @@ public class MainActivity extends AppCompatActivity { levelMeter.reset(); waveformView.reset(); markButton.setEnabled(true); + discardButton.setEnabled(true); applySilence(true); recordButton.setBackgroundResource(R.drawable.bg_record_active); recordButton.setContentDescription(getString(R.string.cd_stop)); @@ -379,7 +755,7 @@ public class MainActivity extends AppCompatActivity { handler.post(timerRunnable); } - private void stopRecordingAndSave() { + private void stopRecording(boolean discard) { recording = false; busy = true; @@ -387,6 +763,7 @@ public class MainActivity extends AppCompatActivity { levelMeter.reset(); waveformView.reset(); markButton.setEnabled(false); + discardButton.setEnabled(false); applySilence(false); long elapsed = SystemClock.elapsedRealtime() - startedAtMs; handler.removeCallbacks(timerRunnable); @@ -397,7 +774,8 @@ public class MainActivity extends AppCompatActivity { recordButton.setBackgroundResource(R.drawable.bg_record_idle); recordButton.setContentDescription(getString(R.string.cd_record)); recordButton.setEnabled(false); - statusText.setText(R.string.status_saving); + statusText.setText(discard ? R.string.status_discarding : R.string.status_saving); + final boolean compress = compressCheckbox.isChecked(); new Thread(() -> { File wav; @@ -411,9 +789,36 @@ public class MainActivity extends AppCompatActivity { } String savedName = null; - if (wav != null) { + boolean discarded = false; + if (wav != null && discard) { + //noinspection ResultOfMethodCallIgnored + wav.delete(); + discarded = true; + } else if (wav != null) { try { - savedName = saveToDownloads(wav); + if (compress) { + File m4a = new File(getCacheDir(), "reccoon_recording.m4a"); + try { + AudioCompressor.compressToM4a(wav, m4a); + savedName = saveToDownloads( + m4a, buildFileName(".m4a"), "audio/mp4"); + } catch (Exception compressionFailed) { + savedName = null; + } finally { + //noinspection ResultOfMethodCallIgnored + m4a.delete(); + } + if (savedName == null) { + // Never lose a recording just because compression failed. + savedName = saveToDownloads( + wav, buildFileName(".wav"), "audio/wav"); + } + //noinspection ResultOfMethodCallIgnored + wav.delete(); + } else { + savedName = saveToDownloads( + wav, buildFileName(".wav"), "audio/wav"); + } if (savedName != null && !markers.isEmpty()) { new MarkerStore(this).save(savedName, markers); } @@ -423,10 +828,14 @@ public class MainActivity extends AppCompatActivity { } final String result = savedName; + final boolean wasDiscarded = discarded; runOnUiThread(() -> { busy = false; recordButton.setEnabled(true); - if (result != null) { + if (wasDiscarded) { + statusText.setText(R.string.discarded); + Toast.makeText(this, R.string.discarded, Toast.LENGTH_SHORT).show(); + } else if (result != null) { statusText.setText(getString(R.string.saved_fmt, result)); Toast.makeText(this, getString(R.string.saved_fmt, result), Toast.LENGTH_LONG).show(); } else { @@ -438,21 +847,22 @@ public class MainActivity extends AppCompatActivity { }, "reccoon-save").start(); } - private String saveToDownloads(File wav) throws IOException { - String fileName = buildFileName(); + private String saveToDownloads(File file, String fileName, String mimeType) + throws IOException { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - saveViaMediaStore(wav, fileName); + saveViaMediaStore(file, fileName, mimeType); } else { - saveToPublicDownloads(wav, fileName); + saveToPublicDownloads(file, fileName, mimeType); } return fileName; } - private void saveViaMediaStore(File wav, String fileName) throws IOException { + private void saveViaMediaStore(File source, String fileName, String mimeType) + throws IOException { ContentResolver resolver = getContentResolver(); ContentValues values = new ContentValues(); values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); - values.put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav"); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); values.put(MediaStore.MediaColumns.IS_PENDING, 1); @@ -465,7 +875,7 @@ public class MainActivity extends AppCompatActivity { if (out == null) { throw new IOException("MediaStore returned no output stream"); } - try (InputStream in = new FileInputStream(wav)) { + try (InputStream in = new FileInputStream(source)) { copy(in, out); } } @@ -476,29 +886,30 @@ public class MainActivity extends AppCompatActivity { } @SuppressWarnings("deprecation") - private void saveToPublicDownloads(File wav, String fileName) throws IOException { + private void saveToPublicDownloads(File source, String fileName, String mimeType) + throws IOException { File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); if (!dir.exists() && !dir.mkdirs()) { throw new IOException("Could not create the Downloads directory"); } File outFile = new File(dir, fileName); - try (InputStream in = new FileInputStream(wav); + try (InputStream in = new FileInputStream(source); OutputStream out = new FileOutputStream(outFile)) { copy(in, out); } MediaScannerConnection.scanFile( this, new String[]{outFile.getAbsolutePath()}, - new String[]{"audio/wav"}, + new String[]{mimeType}, null); } - private String buildFileName() { + private String buildFileName(String extension) { String stamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date()); SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); int next = prefs.getInt(PREF_COUNTER, 0) + 1; prefs.edit().putInt(PREF_COUNTER, next).apply(); - return String.format(Locale.US, "reccoon-%s-%05d.wav", stamp, next); + return String.format(Locale.US, "reccoon-%s-%05d%s", stamp, next, extension); } private static void copy(InputStream in, OutputStream out) throws IOException { diff --git a/app/src/main/java/com/wuhei/reccoon/Marker.java b/app/src/main/java/com/tomcooks/reccoon/Marker.java index d4a7ef5..a132cee 100644 --- a/app/src/main/java/com/wuhei/reccoon/Marker.java +++ b/app/src/main/java/com/tomcooks/reccoon/Marker.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; /** A user-placed marker inside a recording, stored as a WAV cue point. */ public final class Marker { diff --git a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java index bde7a24..a84ff87 100644 --- a/app/src/main/java/com/wuhei/reccoon/MarkerStore.java +++ b/app/src/main/java/com/tomcooks/reccoon/MarkerStore.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java new file mode 100644 index 0000000..1cea837 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/ModelDownloadService.java @@ -0,0 +1,213 @@ +package com.tomcooks.reccoon; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; + +/** + * Downloads a transcription model in a foreground service so the transfer keeps + * going when the screen turns off or the user switches to another app. A partial + * wake lock keeps the CPU up for the network I/O. + */ +public class ModelDownloadService extends Service { + + public static final String ACTION_START = "com.tomcooks.reccoon.action.DOWNLOAD_MODEL"; + public static final String EXTRA_MODEL = "model"; + + private static final String CHANNEL_ID = "reccoon_model_download"; + private static final int NOTIFICATION_ID = 2; + private static final long WAKE_LOCK_TIMEOUT_MS = 2 * 60 * 60 * 1000L; + + public interface Listener { + void onProgress(long downloaded, long total, String currentFile); + + void onComplete(); + + void onError(String message); + } + + private static volatile Listener sListener; + private static volatile boolean sRunning; + private static volatile long sDownloaded; + private static volatile long sTotal; + private static volatile String sCurrentFile = ""; + + public static void setListener(Listener listener) { + sListener = listener; + } + + public static boolean isRunning() { + return sRunning; + } + + public static long downloaded() { + return sDownloaded; + } + + public static long total() { + return sTotal; + } + + public static String currentFile() { + return sCurrentFile; + } + + public static void start(Context context, ModelRepository.Model model) { + Intent intent = new Intent(context, ModelDownloadService.class) + .setAction(ACTION_START) + .putExtra(EXTRA_MODEL, model.name()); + ContextCompat.startForegroundService(context, intent); + } + + private PowerManager.WakeLock wakeLock; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || !ACTION_START.equals(intent.getAction())) { + stopSelf(); + return START_NOT_STICKY; + } + ModelRepository.Model model; + try { + model = ModelRepository.Model.valueOf(intent.getStringExtra(EXTRA_MODEL)); + } catch (Exception e) { + stopSelf(); + return START_NOT_STICKY; + } + + startForegroundCompat(getString(R.string.status_downloading, "", 0)); + + if (sRunning) { + // A download is already in flight; keep the existing one. + return START_NOT_STICKY; + } + sRunning = true; + sDownloaded = 0L; + sTotal = model.totalBytes(); + sCurrentFile = ""; + acquireWakeLock(); + + new Thread(() -> { + Listener listener = sListener; + try { + ModelRepository repository = new ModelRepository(this); + repository.download(model, (downloaded, total, file) -> { + sDownloaded = downloaded; + sTotal = total; + sCurrentFile = file; + int permille = (int) (downloaded * 1000 / Math.max(1, total)); + updateNotification(getString( + R.string.status_downloading, file, permille / 10), permille); + Listener current = sListener; + if (current != null) { + current.onProgress(downloaded, total, file); + } + }); + if (listener != null) { + listener.onComplete(); + } + } catch (Exception e) { + if (listener != null) { + listener.onError(String.valueOf(e.getMessage())); + } + } finally { + sRunning = false; + releaseWakeLock(); + stopForeground(true); + stopSelf(); + } + }, "reccoon-model-download").start(); + + return START_NOT_STICKY; + } + + private void startForegroundCompat(String text) { + Notification notification = buildNotification(text, 0); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } + + private void updateNotification(String text, int permille) { + NotificationManager manager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (manager != null) { + manager.notify(NOTIFICATION_ID, buildNotification(text, permille)); + } + } + + private Notification buildNotification(String text, int permille) { + createChannel(); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.model_download_title)) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(1000, permille, false) + .build(); + } + + private void createChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null && manager.getNotificationChannel(CHANNEL_ID) == null) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.model_download_channel), + NotificationManager.IMPORTANCE_LOW); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + } + } + + private void acquireWakeLock() { + try { + PowerManager power = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (power != null) { + wakeLock = power.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, "reccoon:model-download"); + wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS); + } + } catch (Exception ignored) { + // Without the wake lock the download may pause when the screen is off. + } + } + + private void releaseWakeLock() { + try { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + } catch (Exception ignored) { + // Ignore. + } + wakeLock = null; + } + + @Override + public void onDestroy() { + releaseWakeLock(); + sRunning = false; + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java new file mode 100644 index 0000000..1d0e0be --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/ModelRepository.java @@ -0,0 +1,367 @@ +package com.tomcooks.reccoon; + +import android.content.Context; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +/** + * Downloads and stores the on-device speech recognition models. + * + * <p>Currently only NVIDIA Parakeet TDT 0.6B v3 (int8) is offered: a + * multilingual transducer (~670 MB) that was clearly the most accurate model in + * the evaluation harness. The enum leaves room for more models later. + * + * <p>A small Silero VAD model splits long recordings into transcribable chunks. + */ +public final class ModelRepository { + + private static final String HF_PARAKEET = + "https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/resolve/main/"; + private static final String VAD_URL = + "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"; + + private static final long VAD_BYTES = 643_854L; + + /** A model the player can transcribe with. */ + public enum Model { + PARAKEET_V3("parakeet-v3", "Parakeet v3", + 652_184_281L, 11_845_275L, 6_355_277L, 93_939L); + + /** Folder under {@code filesDir/models} (or the external equivalent). */ + public final String dir; + public final String label; + final long encoderBytes; + final long decoderBytes; + final long joinerBytes; + final long tokensBytes; + + Model(String dir, String label, + long encoderBytes, long decoderBytes, long joinerBytes, long tokensBytes) { + this.dir = dir; + this.label = label; + this.encoderBytes = encoderBytes; + this.decoderBytes = decoderBytes; + this.joinerBytes = joinerBytes; + this.tokensBytes = tokensBytes; + } + + public long totalBytes() { + return encoderBytes + decoderBytes + joinerBytes + tokensBytes + VAD_BYTES; + } + } + + public interface Listener { + void onProgress(long downloaded, long total, String currentFile); + + default void onComplete() { + } + } + + private static final class RemoteFile { + final String url; + final File target; + final long expected; + + RemoteFile(String url, File target, long expected) { + this.url = url; + this.target = target; + this.expected = expected; + } + } + + private final File root; + + public ModelRepository(Context context) { + Context app = context.getApplicationContext(); + // Prefer the app-specific external folder (/sdcard/Android/data/<pkg>/files) + // so large models can be side-loaded with adb/scp or copied over USB. Fall + // back to internal storage when external storage is not mounted. + File external = app.getExternalFilesDir(null); + File base = external != null ? external : app.getFilesDir(); + root = new File(base, "models"); + migrateLegacyModels(base); + } + + /** + * Best-effort one-time migration from the old {@code com.tomcooks.reccoon} app + * id, so a re-branded install can reuse an already downloaded model instead + * of fetching it again. Android 11+ usually blocks access to another app's + * external directory; in that case this quietly does nothing and the model + * is downloaded again. + */ + private void migrateLegacyModels(File base) { + try { + File packageDir = base.getParentFile(); + File androidData = packageDir == null ? null : packageDir.getParentFile(); + if (androidData == null) { + return; + } + File legacy = new File( + new File(new File(androidData, "com.tomcooks.reccoon"), "files"), "models"); + if (legacy.isDirectory()) { + copyMissing(legacy, root); + } + } catch (Exception ignored) { + // Not readable (scoped storage); the model will simply be downloaded. + } + } + + private static void copyMissing(File from, File to) { + File[] children = from.listFiles(); + if (children == null) { + return; + } + if (!to.isDirectory() && !to.mkdirs()) { + return; + } + for (File child : children) { + File destination = new File(to, child.getName()); + if (child.isDirectory()) { + copyMissing(child, destination); + } else if (!destination.exists()) { + copyFile(child, destination); + } + } + } + + private static void copyFile(File from, File to) { + File temp = new File(to.getParentFile(), to.getName() + ".migrate"); + try (InputStream in = new FileInputStream(from); + FileOutputStream out = new FileOutputStream(temp)) { + byte[] buffer = new byte[256 * 1024]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + out.getFD().sync(); + //noinspection ResultOfMethodCallIgnored + temp.renameTo(to); + } catch (Exception ignored) { + //noinspection ResultOfMethodCallIgnored + temp.delete(); + } + } + + /** Folder that holds every model, for troubleshooting / manual side-loading. */ + public File modelsRoot() { + return root; + } + + public File vadModel() { + return new File(root, "silero_vad.onnx"); + } + + public File modelDir(Model model) { + return new File(root, model.dir); + } + + public File encoder(Model model) { + return new File(modelDir(model), "encoder.int8.onnx"); + } + + public File decoder(Model model) { + return new File(modelDir(model), "decoder.int8.onnx"); + } + + public File joiner(Model model) { + return new File(modelDir(model), "joiner.int8.onnx"); + } + + public File tokens(Model model) { + return new File(modelDir(model), "tokens.txt"); + } + + public boolean isReady(Model model) { + return isVadReady() && allComplete(files(model)); + } + + public boolean isVadReady() { + return isComplete(vadModel(), VAD_BYTES); + } + + private static boolean isComplete(File file, long expected) { + return file.isFile() && (expected <= 0 || file.length() == expected); + } + + private List<RemoteFile> files(Model model) { + List<RemoteFile> files = new ArrayList<>(); + files.add(new RemoteFile(VAD_URL, vadModel(), VAD_BYTES)); + files.add(new RemoteFile(HF_PARAKEET + "encoder.int8.onnx", + encoder(model), model.encoderBytes)); + files.add(new RemoteFile(HF_PARAKEET + "decoder.int8.onnx", + decoder(model), model.decoderBytes)); + files.add(new RemoteFile(HF_PARAKEET + "joiner.int8.onnx", + joiner(model), model.joinerBytes)); + files.add(new RemoteFile(HF_PARAKEET + "tokens.txt", + tokens(model), model.tokensBytes)); + return files; + } + + private boolean allComplete(List<RemoteFile> files) { + for (RemoteFile file : files) { + if (!isComplete(file.target, file.expected)) { + return false; + } + } + return true; + } + + /** + * Blocking. Call from a background thread. Progress is reported through + * {@code listener}. Throws on failure. + */ + public void download(Model model, Listener listener) throws IOException { + List<RemoteFile> files = files(model); + long total = 0; + long already = 0; + for (RemoteFile file : files) { + total += file.expected; + if (isComplete(file.target, file.expected)) { + already += file.expected; + } + } + + Counter counter = new Counter(already, total, listener); + counter.report(""); + for (RemoteFile file : files) { + fetch(file.url, file.target, file.expected, counter); + } + listener.onComplete(); + } + + private static final class Counter { + private final long already; + private final long total; + private final Listener listener; + private long doneInCurrent; + private String currentName = ""; + + Counter(long already, long total, Listener listener) { + this.already = already; + this.total = total; + this.listener = listener; + } + + void setCurrent(String name, long done) { + this.currentName = name; + this.doneInCurrent = done; + listener.onProgress(Math.min(already + done, total), total, currentName); + } + + void report(String name) { + listener.onProgress(Math.min(already + doneInCurrent, total), total, name); + } + } + + private static final int MAX_ATTEMPTS = 6; + + private static void fetch(String url, File target, long expected, Counter counter) + throws IOException { + if (isComplete(target, expected)) { + return; + } + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Cannot create " + parent); + } + File part = new File(parent, target.getName() + ".part"); + + IOException lastError = null; + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + downloadOnce(url, target, part, expected, counter); + return; + } catch (IOException e) { + lastError = e; + // Keep the .part file: the next attempt resumes with a Range request. + sleepQuietly(attempt); + } + } + throw lastError != null ? lastError : new IOException("Download failed: " + url); + } + + private static void downloadOnce(String url, File target, File part, long expected, + Counter counter) throws IOException { + long already = part.isFile() ? part.length() : 0L; + if (expected > 0 && already >= expected) { + if (already == expected) { + finish(part, target, expected); + return; + } + //noinspection ResultOfMethodCallIgnored + part.delete(); + already = 0L; + } + + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setInstanceFollowRedirects(true); + connection.setConnectTimeout(30_000); + connection.setReadTimeout(120_000); + connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)"); + connection.setRequestProperty("Accept-Encoding", "identity"); + if (already > 0) { + connection.setRequestProperty("Range", "bytes=" + already + "-"); + } + try { + int code = connection.getResponseCode(); + if (code == HttpURLConnection.HTTP_OK) { + // The server ignored the Range request: start the file over. + already = 0L; + } else if (code != HttpURLConnection.HTTP_PARTIAL) { + throw new IOException("HTTP " + code + " for " + url); + } + + try (InputStream in = connection.getInputStream(); + FileOutputStream out = new FileOutputStream(part, already > 0)) { + byte[] buffer = new byte[256 * 1024]; + long done = already; + int read; + long lastReport = 0; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + done += read; + long now = System.currentTimeMillis(); + if (now - lastReport > 200) { + lastReport = now; + counter.setCurrent(target.getName(), done); + } + } + out.getFD().sync(); + counter.setCurrent(target.getName(), done); + } + + finish(part, target, expected); + } finally { + connection.disconnect(); + } + } + + private static void finish(File part, File target, long expected) throws IOException { + if (expected > 0 && part.length() != expected) { + throw new IOException("Incomplete download for " + target.getName() + + " (have " + part.length() + " of " + expected + " bytes)"); + } + if (target.exists() && !target.delete()) { + throw new IOException("Cannot replace " + target); + } + if (!part.renameTo(target)) { + throw new IOException("Cannot move download into place: " + target); + } + } + + private static void sleepQuietly(int attempt) { + try { + Thread.sleep(Math.min(8_000L, 500L * (1L << Math.min(attempt, 4)))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java new file mode 100644 index 0000000..9987d09 --- /dev/null +++ b/app/src/main/java/com/tomcooks/reccoon/PcmAudioSource.java @@ -0,0 +1,239 @@ +package com.tomcooks.reccoon; + +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.net.Uri; + +import java.io.BufferedInputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * A streaming source of 16-bit PCM samples. + * + * <p>Handles both the uncompressed WAV files that RECCoon records and the + * compressed files (AAC/M4A, Opus, ...) that the compact mode produces. WAV is + * parsed directly; everything else is decoded with {@link MediaCodec}. This is + * what lets transcription keep working on compressed recordings. + */ +public final class PcmAudioSource implements Closeable { + + private static final long CODEC_TIMEOUT_US = 10_000; + + /** Sample rate of the returned PCM. */ + public volatile int sampleRate; + /** Number of interleaved channels in the returned PCM. */ + public volatile int channels; + /** PCM size in bytes at {@link #sampleRate}, or 0 when unknown. */ + public final long totalBytes; + + private final InputStream wav; + private long wavRemaining; + + private final MediaExtractor extractor; + private final MediaCodec codec; + private boolean inputDone; + private boolean outputDone; + private byte[] pending; + private int pendingPos; + private int pendingLen; + + private PcmAudioSource(int sampleRate, int channels, long totalBytes, + InputStream wav, long wavRemaining, + MediaExtractor extractor, MediaCodec codec) { + this.sampleRate = sampleRate; + this.channels = channels; + this.totalBytes = totalBytes; + this.wav = wav; + this.wavRemaining = wavRemaining; + this.extractor = extractor; + this.codec = codec; + } + + /** + * Opens a WAV file when possible, otherwise falls back to a compressed + * decoder. Throws when neither path can read the URI. + */ + public static PcmAudioSource open(Context context, Uri uri) throws IOException { + InputStream raw = context.getContentResolver().openInputStream(uri); + if (raw == null) { + throw new IOException("Cannot open " + uri); + } + BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024); + try { + TranscriptionEngine.WavInfo info = TranscriptionEngine.readWavHeader(buffered); + if (info.bitsPerSample != 16 || info.audioFormat != 1) { + throw new IOException("Only 16-bit PCM WAV files are supported"); + } + long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; + return new PcmAudioSource(Math.max(1, info.sampleRate), Math.max(1, info.channels), + info.dataSize, buffered, remaining, null, null); + } catch (IOException notWav) { + closeQuietly(buffered); + } + return openCodec(context, uri); + } + + private static PcmAudioSource openCodec(Context context, Uri uri) throws IOException { + MediaExtractor extractor = new MediaExtractor(); + try { + extractor.setDataSource(context, uri, null); + } catch (IOException e) { + extractor.release(); + throw new IOException("Unsupported audio file: " + uri, e); + } + + int track = -1; + MediaFormat format = null; + for (int i = 0; i < extractor.getTrackCount(); i++) { + MediaFormat candidate = extractor.getTrackFormat(i); + String mime = candidate.getString(MediaFormat.KEY_MIME); + if (mime != null && mime.startsWith("audio/")) { + track = i; + format = candidate; + break; + } + } + if (track < 0 || format == null) { + extractor.release(); + throw new IOException("No audio track in " + uri); + } + extractor.selectTrack(track); + + String mime = format.getString(MediaFormat.KEY_MIME); + int sampleRate = format.containsKey(MediaFormat.KEY_SAMPLE_RATE) + ? format.getInteger(MediaFormat.KEY_SAMPLE_RATE) : 0; + int channels = format.containsKey(MediaFormat.KEY_CHANNEL_COUNT) + ? format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) : 1; + long durationUs = format.containsKey(MediaFormat.KEY_DURATION) + ? format.getLong(MediaFormat.KEY_DURATION) : 0L; + long totalBytes = sampleRate > 0 + ? durationUs * sampleRate * Math.max(1, channels) * 2 / 1_000_000L : 0L; + + MediaCodec codec; + try { + codec = MediaCodec.createDecoderByType(mime); + codec.configure(format, null, null, 0); + codec.start(); + } catch (Exception e) { + extractor.release(); + throw new IOException("Cannot decode " + mime, e); + } + return new PcmAudioSource(sampleRate, Math.max(1, channels), totalBytes, + null, 0L, extractor, codec); + } + + /** + * Reads up to {@code length} bytes of interleaved 16-bit PCM. Returns the + * number of bytes read, or -1 at end of stream. + */ + public int read(byte[] out, int offset, int length) throws IOException { + if (wav != null) { + if (wavRemaining <= 0) { + return -1; + } + int toRead = (int) Math.min(length, wavRemaining); + int total = 0; + while (total < toRead) { + int n = wav.read(out, offset + total, toRead - total); + if (n < 0) { + break; + } + total += n; + } + wavRemaining -= total; + return total == 0 ? -1 : total; + } + + int written = 0; + while (written < length) { + if (pending != null && pendingPos < pendingLen) { + int n = Math.min(length - written, pendingLen - pendingPos); + System.arraycopy(pending, pendingPos, out, offset + written, n); + pendingPos += n; + written += n; + continue; + } + if (outputDone) { + break; + } + decodeStep(); + } + return written == 0 ? -1 : written; + } + + private void decodeStep() throws IOException { + if (!inputDone) { + int inIndex = codec.dequeueInputBuffer(CODEC_TIMEOUT_US); + if (inIndex >= 0) { + ByteBuffer input = codec.getInputBuffer(inIndex); + int size = input == null ? -1 : extractor.readSampleData(input, 0); + if (size < 0) { + codec.queueInputBuffer(inIndex, 0, 0, 0, + MediaCodec.BUFFER_FLAG_END_OF_STREAM); + inputDone = true; + } else { + codec.queueInputBuffer(inIndex, 0, size, extractor.getSampleTime(), 0); + extractor.advance(); + } + } + } + + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + int outIndex = codec.dequeueOutputBuffer(info, CODEC_TIMEOUT_US); + if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + MediaFormat output = codec.getOutputFormat(); + if (output.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + sampleRate = output.getInteger(MediaFormat.KEY_SAMPLE_RATE); + } + if (output.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + channels = Math.max(1, output.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + } + } else if (outIndex >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + outputDone = true; + } + ByteBuffer output = codec.getOutputBuffer(outIndex); + if (info.size > 0 && output != null) { + byte[] chunk = new byte[info.size]; + output.position(info.offset); + output.limit(info.offset + info.size); + output.get(chunk); + pending = chunk; + pendingPos = 0; + pendingLen = info.size; + } + codec.releaseOutputBuffer(outIndex, false); + } + } + + @Override + public void close() { + closeQuietly(wav); + if (extractor != null) { + extractor.release(); + } + if (codec != null) { + try { + codec.stop(); + } catch (Exception ignored) { + // Ignore. + } + codec.release(); + } + } + + private static void closeQuietly(InputStream stream) { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + // Ignore. + } + } + } +} diff --git a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java index eb5b8ca..6d7d3f4 100644 --- a/app/src/main/java/com/wuhei/reccoon/PlayerActivity.java +++ b/app/src/main/java/com/tomcooks/reccoon/PlayerActivity.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Intent; import android.graphics.Color; @@ -31,6 +31,7 @@ import androidx.appcompat.app.AppCompatActivity; import java.io.File; import java.util.List; +import java.util.Locale; /** * Plays a saved recording with a scrubber and transcribes it on device with @@ -56,7 +57,6 @@ public class PlayerActivity extends AppCompatActivity { private Button playButton; private Button speedButton; private Button transcribeButton; - private Spinner languageSpinner; private Spinner modelSpinner; private ProgressBar progressBar; private LinearLayout transcriptContainer; @@ -72,6 +72,40 @@ public class PlayerActivity extends AppCompatActivity { private ModelRepository models; private TranscriptStore transcripts; private MarkerStore markerStore; + private ModelRepository.Model downloadingModel; + + private final ModelDownloadService.Listener downloadListener = + new ModelDownloadService.Listener() { + @Override + public void onProgress(long downloaded, long total, String file) { + runOnUiThread(() -> { + int permille = (int) (downloaded * 1000 / Math.max(1, total)); + progressBar.setIndeterminate(false); + progressBar.setProgress(permille); + statusText.setText(getString( + R.string.status_downloading, file, permille / 10)); + }); + } + + @Override + public void onComplete() { + runOnUiThread(() -> { + statusText.setText(R.string.status_model_ready); + if (downloadingModel != null) { + runTranscription(downloadingModel); + } + }); + } + + @Override + public void onError(String message) { + runOnUiThread(() -> { + transcribeButton.setEnabled(true); + progressBar.setVisibility(View.GONE); + statusText.setText(getString(R.string.err_download, message)); + }); + } + }; private final Runnable ticker = new Runnable() { @Override @@ -106,7 +140,6 @@ public class PlayerActivity extends AppCompatActivity { playButton = findViewById(R.id.playButton); speedButton = findViewById(R.id.speedButton); transcribeButton = findViewById(R.id.transcribeButton); - languageSpinner = findViewById(R.id.languageSpinner); modelSpinner = findViewById(R.id.modelSpinner); progressBar = findViewById(R.id.progressBar); transcriptContainer = findViewById(R.id.transcriptContainer); @@ -133,22 +166,13 @@ public class PlayerActivity extends AppCompatActivity { } private void setUpSpinners() { - ArrayAdapter<String> languages = new ArrayAdapter<>(this, - android.R.layout.simple_spinner_item, - new String[]{ - getString(R.string.lang_auto), - getString(R.string.lang_en), - getString(R.string.lang_it), - }); - languages.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - languageSpinner.setAdapter(languages); - + ModelRepository.Model[] allModels = ModelRepository.Model.values(); + String[] modelLabels = new String[allModels.length]; + for (int i = 0; i < allModels.length; i++) { + modelLabels[i] = allModels[i].label; + } ArrayAdapter<String> modelAdapter = new ArrayAdapter<>(this, - android.R.layout.simple_spinner_item, - new String[]{ - getString(R.string.model_base), - getString(R.string.model_tiny), - }); + android.R.layout.simple_spinner_item, modelLabels); modelAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); modelSpinner.setAdapter(modelAdapter); } @@ -312,7 +336,8 @@ public class PlayerActivity extends AppCompatActivity { try { Uri shareUri = RecordingActions.shareableUri(this, recordingUri); Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("audio/wav"); + intent.setType(recordingName.toLowerCase(Locale.US).endsWith(".m4a") + ? "audio/mp4" : "audio/wav"); intent.putExtra(Intent.EXTRA_STREAM, shareUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(intent, getString(R.string.action_share))); @@ -349,8 +374,10 @@ public class PlayerActivity extends AppCompatActivity { .setView(container) .setPositiveButton(R.string.action_rename, (dialog, which) -> { String requested = input.getText().toString().trim(); - if (!requested.endsWith(".wav")) { - requested = requested + ".wav"; + String lower = requested.toLowerCase(Locale.US); + if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) { + requested += recordingName.toLowerCase(Locale.US).endsWith(".m4a") + ? ".m4a" : ".wav"; } String renamed = RecordingActions.rename( this, recordingUri, recordingName, requested); @@ -389,58 +416,44 @@ public class PlayerActivity extends AppCompatActivity { // --- Transcription ---------------------------------------------------- private void onTranscribeClicked() { - ModelRepository.WhisperModel model = modelSpinner.getSelectedItemPosition() == 1 - ? ModelRepository.WhisperModel.TINY - : ModelRepository.WhisperModel.BASE; - String language = languageFor(languageSpinner.getSelectedItemPosition()); + ModelRepository.Model[] allModels = ModelRepository.Model.values(); + int position = modelSpinner.getSelectedItemPosition(); + if (position < 0 || position >= allModels.length) { + position = 0; + } + ModelRepository.Model model = allModels[position]; if (!models.isReady(model)) { - promptDownload(model, language); + promptDownload(model); } else { - runTranscription(model, language); + runTranscription(model); } } - private void promptDownload(ModelRepository.WhisperModel model, String language) { + private void promptDownload(ModelRepository.Model model) { new AlertDialog.Builder(this) .setTitle(R.string.model_download_title) .setMessage(getString(R.string.model_download_message, model.label, Formats.humanBytes(model.totalBytes()))) - .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model, language)) + .setPositiveButton(R.string.download, (dialog, which) -> runDownload(model)) .setNegativeButton(R.string.cancel, null) .show(); } - private void runDownload(ModelRepository.WhisperModel model, String language) { + private void runDownload(ModelRepository.Model model) { + downloadingModel = model; transcribeButton.setEnabled(false); progressBar.setVisibility(View.VISIBLE); progressBar.setIndeterminate(false); progressBar.setProgress(0); statusText.setText(R.string.status_preparing); - - new Thread(() -> { - try { - models.download(model, (downloaded, total, file) -> runOnUiThread(() -> { - int permille = (int) (downloaded * 1000 / Math.max(1, total)); - progressBar.setProgress(permille); - statusText.setText(getString( - R.string.status_downloading, file, permille / 10)); - })); - runOnUiThread(() -> { - statusText.setText(R.string.status_model_ready); - runTranscription(model, language); - }); - } catch (Exception e) { - runOnUiThread(() -> { - transcribeButton.setEnabled(true); - progressBar.setVisibility(View.GONE); - statusText.setText(getString(R.string.err_download, String.valueOf(e.getMessage()))); - }); - } - }, "model-download").start(); + // The download runs in a foreground service so it survives the screen + // turning off or the app losing focus. + ModelDownloadService.setListener(downloadListener); + ModelDownloadService.start(this, model); } - private void runTranscription(ModelRepository.WhisperModel model, String language) { + private void runTranscription(ModelRepository.Model model) { cancelled = false; transcribeButton.setEnabled(false); transcriptContainer.removeAllViews(); @@ -452,7 +465,7 @@ public class PlayerActivity extends AppCompatActivity { TranscriptionEngine.Result result; try { TranscriptionEngine engine = new TranscriptionEngine(PlayerActivity.this); - result = engine.transcribe(recordingUri, model, language, + result = engine.transcribe(recordingUri, model, new TranscriptionEngine.Callback() { @Override public void onProgress(int percent, String message) { @@ -585,14 +598,19 @@ public class PlayerActivity extends AppCompatActivity { updateTimeLabel(); } - private static String languageFor(int position) { - switch (position) { - case 1: - return TranscriptionEngine.LANG_EN; - case 2: - return TranscriptionEngine.LANG_IT; - default: - return TranscriptionEngine.LANG_AUTO; + @Override + protected void onResume() { + super.onResume(); + if (ModelDownloadService.isRunning()) { + ModelDownloadService.setListener(downloadListener); + progressBar.setVisibility(View.VISIBLE); + progressBar.setIndeterminate(false); + long done = ModelDownloadService.downloaded(); + long total = ModelDownloadService.total(); + int permille = (int) (done * 1000 / Math.max(1, total)); + progressBar.setProgress(permille); + statusText.setText(getString(R.string.status_downloading, + ModelDownloadService.currentFile(), permille / 10)); } } @@ -614,6 +632,7 @@ public class PlayerActivity extends AppCompatActivity { @Override protected void onDestroy() { cancelled = true; + ModelDownloadService.setListener(null); handler.removeCallbacksAndMessages(null); if (player != null) { try { diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java index 4131181..3a110eb 100644 --- a/app/src/main/java/com/wuhei/reccoon/RecordingActions.java +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingActions.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.ContentResolver; import android.content.ContentValues; @@ -41,10 +41,15 @@ public final class RecordingActions { /** @return the new file name, or null if renaming failed. */ public static String rename(Context context, Uri uri, String oldName, String newName) { - if (newName == null || newName.trim().isEmpty() || !newName.endsWith(".wav")) { + if (newName == null || newName.trim().isEmpty()) { return null; } newName = newName.trim(); + String lower = newName.toLowerCase(Locale.US); + if (!lower.endsWith(".wav") && !lower.endsWith(".m4a")) { + return null; + } + String mime = lower.endsWith(".m4a") ? "audio/mp4" : "audio/wav"; try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !"file".equals(uri.getScheme())) { ContentValues values = new ContentValues(); @@ -58,7 +63,7 @@ public final class RecordingActions { } MediaScannerConnection.scanFile(context, new String[]{newFile.getAbsolutePath()}, - new String[]{"audio/wav"}, null); + new String[]{mime}, null); } } catch (Exception e) { return null; @@ -83,7 +88,7 @@ public final class RecordingActions { if (result == null || result.segments.isEmpty()) { throw new IOException("Nothing to export"); } - String stem = recordingName.endsWith(".wav") + String stem = (recordingName.endsWith(".wav") || recordingName.endsWith(".m4a")) ? recordingName.substring(0, recordingName.length() - 4) : recordingName; writeToDownloads(context, stem + ".txt", "text/plain", result.asText()); diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingService.java b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java index 830c3bb..28bf803 100644 --- a/app/src/main/java/com/wuhei/reccoon/RecordingService.java +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingService.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.app.Notification; import android.app.NotificationChannel; @@ -20,8 +20,8 @@ public class RecordingService extends Service { private static final String CHANNEL_ID = "reccoon_recording"; private static final int NOTIFICATION_ID = 1; - public static final String ACTION_START = "com.wuhei.reccoon.action.START"; - public static final String ACTION_STOP = "com.wuhei.reccoon.action.STOP"; + public static final String ACTION_START = "com.tomcooks.reccoon.action.START"; + public static final String ACTION_STOP = "com.tomcooks.reccoon.action.STOP"; @Override public int onStartCommand(Intent intent, int flags, int startId) { diff --git a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java index 12abb06..b7a1c84 100644 --- a/app/src/main/java/com/wuhei/reccoon/RecordingStore.java +++ b/app/src/main/java/com/tomcooks/reccoon/RecordingStore.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; import android.content.ContentUris; @@ -32,7 +32,7 @@ public final class RecordingStore { } private static final String PREFIX = "reccoon-"; - private static final String SUFFIX = ".wav"; + private static final String[] SUFFIXES = {".wav", ".m4a"}; private final Context context; @@ -40,6 +40,15 @@ public final class RecordingStore { this.context = context.getApplicationContext(); } + private static boolean hasKnownSuffix(String name) { + for (String suffix : SUFFIXES) { + if (name.endsWith(suffix)) { + return true; + } + } + return false; + } + public List<Recording> list() { List<Recording> recordings = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? listFromMediaStore() @@ -59,12 +68,23 @@ public final class RecordingStore { MediaStore.MediaColumns.SIZE, MediaStore.MediaColumns.DATE_MODIFIED, }; - String selection = MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ? AND " - + MediaStore.MediaColumns.DISPLAY_NAME + " LIKE ?"; - String[] args = {PREFIX + "%", "%" + SUFFIX}; + StringBuilder selection = new StringBuilder(MediaStore.MediaColumns.DISPLAY_NAME) + .append(" LIKE ? AND ("); + for (int i = 0; i < SUFFIXES.length; i++) { + if (i > 0) { + selection.append(" OR "); + } + selection.append(MediaStore.MediaColumns.DISPLAY_NAME).append(" LIKE ?"); + } + selection.append(')'); + String[] args = new String[SUFFIXES.length + 1]; + args[0] = PREFIX + "%"; + for (int i = 0; i < SUFFIXES.length; i++) { + args[i + 1] = "%" + SUFFIXES[i]; + } try (Cursor cursor = context.getContentResolver().query( MediaStore.Downloads.EXTERNAL_CONTENT_URI, - projection, selection, args, null)) { + projection, selection.toString(), args, null)) { if (cursor == null) { return result; } @@ -92,7 +112,7 @@ public final class RecordingStore { private List<Recording> listFromDirectory() { List<Recording> result = new ArrayList<>(); File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); - File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); + File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && hasKnownSuffix(name)); if (files == null) { return result; } diff --git a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java index beb372a..558f5ec 100644 --- a/app/src/main/java/com/wuhei/reccoon/StreamingResampler.java +++ b/app/src/main/java/com/tomcooks/reccoon/StreamingResampler.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import java.util.Arrays; diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java index 7ae336e..69d9439 100644 --- a/app/src/main/java/com/wuhei/reccoon/TranscriptStore.java +++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptStore.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; diff --git a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java index 3579ab5..5483cbf 100644 --- a/app/src/main/java/com/wuhei/reccoon/TranscriptionEngine.java +++ b/app/src/main/java/com/tomcooks/reccoon/TranscriptionEngine.java @@ -1,6 +1,7 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; +import android.media.MediaMetadataRetriever; import android.net.Uri; import com.k2fsa.sherpa.onnx.FeatureConfig; @@ -9,7 +10,7 @@ import com.k2fsa.sherpa.onnx.OfflineRecognizer; import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig; import com.k2fsa.sherpa.onnx.OfflineRecognizerResult; import com.k2fsa.sherpa.onnx.OfflineStream; -import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig; +import com.k2fsa.sherpa.onnx.OfflineTransducerModelConfig; import com.k2fsa.sherpa.onnx.SileroVadModelConfig; import com.k2fsa.sherpa.onnx.SpeechSegment; import com.k2fsa.sherpa.onnx.Vad; @@ -25,19 +26,15 @@ import java.util.ArrayList; import java.util.List; /** - * On-device transcription using sherpa-onnx with a multilingual Whisper model. + * On-device transcription using sherpa-onnx with the NVIDIA Parakeet TDT 0.6B + * v3 transducer (multilingual, auto language handling). * * <p>Long recordings are streamed, resampled to 16 kHz and segmented with * Silero VAD, then each detected speech segment is decoded. This keeps memory - * bounded regardless of the recording length (offline Whisper itself only - * handles up to 30 seconds at a time). + * bounded regardless of the recording length. */ public final class TranscriptionEngine { - public static final String LANG_AUTO = ""; - public static final String LANG_EN = "en"; - public static final String LANG_IT = "it"; - private static final int TARGET_RATE = 16_000; public static final class Word { @@ -98,59 +95,51 @@ public final class TranscriptionEngine { } public Result transcribe(Uri wavUri, - ModelRepository.WhisperModel model, - String language, + ModelRepository.Model model, Callback callback) throws IOException { if (!models.isReady(model)) { throw new IOException("Model is not downloaded"); } - OfflineRecognizer recognizer = buildRecognizer(model, language); + OfflineRecognizer recognizer = buildRecognizer(model); Vad vad = buildVad(); Result result = new Result(); - result.language = language; - - try (InputStream raw = context.getContentResolver().openInputStream(wavUri)) { - if (raw == null) { - throw new IOException("Cannot open " + wavUri); - } - BufferedInputStream buffered = new BufferedInputStream(raw, 256 * 1024); - WavInfo info = readWavHeader(buffered); - if (info.bitsPerSample != 16 || info.audioFormat != 1) { - throw new IOException("Only 16-bit PCM WAV files are supported"); - } - - StreamingResampler resampler = new StreamingResampler(info.sampleRate, TARGET_RATE); - long totalPcm16k = estimatePcm16kSamples(info); + result.language = ""; + + // PcmAudioSource reads uncompressed WAV directly and decodes compressed + // recordings (AAC/M4A, ...) through MediaCodec, so compact mode keeps + // working here. + try (PcmAudioSource source = PcmAudioSource.open(context, wavUri)) { + int channelCount = source.channels; + StreamingResampler resampler = + new StreamingResampler(source.sampleRate, TARGET_RATE); + long totalPcm16k = estimatePcm16kSamples(source); long processed = 0; - int frameBytes = info.channels * 2; + int frameBytes = channelCount * 2; byte[] readBuffer = new byte[frameBytes * 8192]; - long remaining = info.dataSize > 0 ? info.dataSize : Long.MAX_VALUE; - while (remaining > 0) { + while (true) { if (callback != null && callback.isCancelled()) { return null; } - int toRead = (int) Math.min(readBuffer.length, remaining); - int read = buffered.read(readBuffer, 0, toRead); + int read = source.read(readBuffer, 0, readBuffer.length); if (read <= 0) { break; } - remaining -= read; int frames = read / frameBytes; float[] mono = new float[frames]; for (int i = 0; i < frames; i++) { int offset = i * frameBytes; - if (info.channels == 1) { + if (channelCount == 1) { mono[i] = pcm16(readBuffer, offset) / 32768f; } else { int sum = 0; - for (int c = 0; c < info.channels; c++) { + for (int c = 0; c < channelCount; c++) { sum += pcm16(readBuffer, offset + c * 2); } - mono[i] = sum / (float) info.channels / 32768f; + mono[i] = sum / (float) channelCount / 32768f; } } @@ -252,28 +241,24 @@ public final class TranscriptionEngine { } } - private OfflineRecognizer buildRecognizer(ModelRepository.WhisperModel model, String language) { + private OfflineRecognizer buildRecognizer(ModelRepository.Model model) { FeatureConfig feat = new FeatureConfig(); feat.setSampleRate(TARGET_RATE); feat.setFeatureDim(80); feat.setDither(0f); - OfflineWhisperModelConfig whisper = new OfflineWhisperModelConfig(); - whisper.setEncoder(models.encoder(model).getAbsolutePath()); - whisper.setDecoder(models.decoder(model).getAbsolutePath()); - whisper.setLanguage(language == null ? "" : language); - whisper.setTask("transcribe"); - whisper.setTailPaddings(1000); - whisper.setEnableTokenTimestamps(true); - whisper.setEnableSegmentTimestamps(false); + OfflineTransducerModelConfig transducer = new OfflineTransducerModelConfig(); + transducer.setEncoder(models.encoder(model).getAbsolutePath()); + transducer.setDecoder(models.decoder(model).getAbsolutePath()); + transducer.setJoiner(models.joiner(model).getAbsolutePath()); OfflineModelConfig modelConfig = new OfflineModelConfig(); - modelConfig.setWhisper(whisper); + modelConfig.setTransducer(transducer); + modelConfig.setModelType("nemo_transducer"); modelConfig.setTokens(models.tokens(model).getAbsolutePath()); modelConfig.setNumThreads(Math.max(2, Runtime.getRuntime().availableProcessors() / 2)); modelConfig.setDebug(false); modelConfig.setProvider("cpu"); - modelConfig.setModelType("whisper"); OfflineRecognizerConfig config = new OfflineRecognizerConfig(); config.setFeatConfig(feat); @@ -365,26 +350,36 @@ public final class TranscriptionEngine { public static long durationMillis(Context context, Uri uri) { try (InputStream in = context.getContentResolver().openInputStream(uri)) { - if (in == null) { - return 0; + if (in != null) { + WavInfo info = readWavHeader(new BufferedInputStream(in)); + return info.durationMillis(); } - WavInfo info = readWavHeader(new BufferedInputStream(in)); - return info.durationMillis(); + } catch (Exception ignored) { + // Not a WAV file; try the platform decoder below. + } + MediaMetadataRetriever retriever = new MediaMetadataRetriever(); + try { + retriever.setDataSource(context, uri); + String value = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_DURATION); + return value == null ? 0 : Long.parseLong(value); } catch (Exception e) { return 0; + } finally { + try { + retriever.release(); + } catch (Exception ignored) { + // Ignore. + } } } - private static long estimatePcm16kSamples(WavInfo info) { - if (info.dataSize <= 0 || info.sampleRate <= 0) { - return 0; - } - int frameBytes = info.channels * (info.bitsPerSample / 8); - if (frameBytes <= 0) { + private static long estimatePcm16kSamples(PcmAudioSource source) { + if (source.totalBytes <= 0 || source.sampleRate <= 0 || source.channels <= 0) { return 0; } - long frames = info.dataSize / frameBytes; - return frames * TARGET_RATE / info.sampleRate; + long frames = source.totalBytes / (source.channels * 2L); + return frames * TARGET_RATE / source.sampleRate; } private static int pcm16(byte[] data, int offset) { diff --git a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java index 1f55e96..7ef8425 100644 --- a/app/src/main/java/com/wuhei/reccoon/WavMarkers.java +++ b/app/src/main/java/com/tomcooks/reccoon/WavMarkers.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; import android.net.Uri; diff --git a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java index 8279847..0171a74 100644 --- a/app/src/main/java/com/wuhei/reccoon/WavRecorder.java +++ b/app/src/main/java/com/tomcooks/reccoon/WavRecorder.java @@ -1,7 +1,9 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; +import android.media.AudioDeviceInfo; import android.media.AudioFormat; +import android.media.AudioManager; import android.media.AudioRecord; import android.media.MediaRecorder; import android.os.SystemClock; @@ -51,6 +53,7 @@ public class WavRecorder { } private final Context context; + private final AudioManager audioManager; private final List<Marker> markers = new ArrayList<>(); private LevelListener levelListener; @@ -61,9 +64,18 @@ public class WavRecorder { private volatile long pcmBytes; private int channels = 2; private boolean forceMono; + private int preferredDeviceId = -1; + private boolean bluetoothSco; + private boolean scoActive; + private boolean swapChannels; + private boolean monitoring; + private int monitorDeviceId = -1; + private final LiveMonitor monitor; public WavRecorder(Context context) { this.context = context.getApplicationContext(); + this.audioManager = (AudioManager) this.context.getSystemService(Context.AUDIO_SERVICE); + this.monitor = new LiveMonitor(this.context); } public void setLevelListener(LevelListener listener) { @@ -75,6 +87,49 @@ public class WavRecorder { this.forceMono = forceMono; } + /** + * Forces a specific input device, or -1 to let Android pick. Mirrors the + * BT-Mic-Force behaviour: the id comes from + * {@link AudioManager#getDevices(int)}. + */ + public void setPreferredDeviceId(int deviceId) { + this.preferredDeviceId = deviceId; + } + + /** + * When true, route audio through Bluetooth SCO (headset microphone) before + * opening the recorder. Call {@link #setPreferredDeviceId(int)} as well to + * pick the exact headset when several are connected. + */ + public void setBluetoothSco(boolean bluetoothSco) { + this.bluetoothSco = bluetoothSco; + } + + /** + * When true, exchange the two stereo channels before writing. Some devices + * report the microphones in the opposite order once the phone is turned to + * landscape; swapping makes both the meters and the saved file agree with + * what the user hears on their left/right. + */ + public void setSwapChannels(boolean swapChannels) { + this.swapChannels = swapChannels; + } + + /** When true, play the captured audio back through {@link LiveMonitor}. */ + public void setMonitoring(boolean monitoring) { + this.monitoring = monitoring; + } + + /** Output device for live monitoring, or -1 for the system default. */ + public void setMonitorDeviceId(int deviceId) { + this.monitorDeviceId = deviceId; + } + + /** True while live monitoring is actually running. */ + public boolean isMonitoring() { + return monitoring && monitor.isRunning(); + } + /** Effective number of channels used by the last/current recording. */ public int getChannels() { return channels; @@ -101,6 +156,11 @@ public class WavRecorder { recording = true; audioRecord.startRecording(); + if (monitoring) { + monitor.setPreferredDeviceId(monitorDeviceId); + monitor.start(SAMPLE_RATE, channels); + } + final AudioRecord record = audioRecord; final int channelCount = channels; final int frameBytes = channelCount * 2; @@ -115,7 +175,11 @@ public class WavRecorder { while (recording) { int read = record.read(buffer, 0, buffer.length); if (read > 0) { + maybeSwapChannels(buffer, read, channelCount); out.write(buffer, 0, read); + if (monitoring) { + monitor.write(buffer, read); + } pcmBytes += read; long now = SystemClock.uptimeMillis(); @@ -135,6 +199,25 @@ public class WavRecorder { return tempFile; } + /** Exchanges the two 16-bit channels in-place when the swap option is on. */ + private void maybeSwapChannels(byte[] buffer, int length, int channelCount) { + if (!swapChannels || channelCount != 2) { + return; + } + int frames = length / 4; + for (int i = 0; i < frames; i++) { + int offset = i * 4; + byte leftLow = buffer[offset]; + byte leftHigh = buffer[offset + 1]; + byte rightLow = buffer[offset + 2]; + byte rightHigh = buffer[offset + 3]; + buffer[offset] = rightLow; + buffer[offset + 1] = rightHigh; + buffer[offset + 2] = leftLow; + buffer[offset + 3] = leftHigh; + } + } + private void reportLevels(byte[] buffer, int length, int channelCount) { int frameBytes = channelCount * 2; int frames = length / frameBytes; @@ -253,6 +336,8 @@ public class WavRecorder { audioRecord = null; } writerThread = null; + monitor.stop(); + stopSco(); writeHeader(); writeMarkers(); @@ -264,6 +349,10 @@ public class WavRecorder { } private AudioRecord createAudioRecord() { + startScoIfNeeded(); + int source = bluetoothSco + ? MediaRecorder.AudioSource.VOICE_COMMUNICATION + : MediaRecorder.AudioSource.MIC; int[] candidates = forceMono ? new int[]{AudioFormat.CHANNEL_IN_MONO} : new int[]{AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO}; @@ -276,20 +365,66 @@ public class WavRecorder { int count = channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1; int bufferSize = Math.max(minBuffer, SAMPLE_RATE * count); AudioRecord candidate = new AudioRecord( - MediaRecorder.AudioSource.MIC, + source, SAMPLE_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT, bufferSize); if (candidate.getState() == AudioRecord.STATE_INITIALIZED) { + applyPreferredDevice(candidate); channels = count; return candidate; } candidate.release(); } + stopSco(); return null; } + private void startScoIfNeeded() { + if (!bluetoothSco || audioManager == null) { + return; + } + try { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.startBluetoothSco(); + audioManager.setBluetoothScoOn(true); + scoActive = true; + } catch (Exception ignored) { + // Fall back to the default route below. + } + } + + private void applyPreferredDevice(AudioRecord record) { + if (preferredDeviceId < 0 || audioManager == null) { + return; + } + try { + for (AudioDeviceInfo device : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + if (device.getId() == preferredDeviceId) { + record.setPreferredDevice(device); + return; + } + } + } catch (Exception ignored) { + // Keep the default device. + } + } + + private void stopSco() { + if (!scoActive || audioManager == null) { + return; + } + scoActive = false; + try { + audioManager.stopBluetoothSco(); + audioManager.setBluetoothScoOn(false); + audioManager.setMode(AudioManager.MODE_NORMAL); + } catch (Exception ignored) { + // Ignore. + } + } + private static int channelConfigFor(int channelCount) { return channelCount > 1 ? AudioFormat.CHANNEL_IN_STEREO : AudioFormat.CHANNEL_IN_MONO; } diff --git a/app/src/main/java/com/wuhei/reccoon/WaveformView.java b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java index de62639..413a825 100644 --- a/app/src/main/java/com/wuhei/reccoon/WaveformView.java +++ b/app/src/main/java/com/tomcooks/reccoon/WaveformView.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import android.content.Context; import android.graphics.Canvas; diff --git a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java b/app/src/main/java/com/wuhei/reccoon/ModelRepository.java deleted file mode 100644 index cbf25c2..0000000 --- a/app/src/main/java/com/wuhei/reccoon/ModelRepository.java +++ /dev/null @@ -1,206 +0,0 @@ -package com.wuhei.reccoon; - -import android.content.Context; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; - -/** - * Downloads and stores the on-device speech recognition models. - * - * <p>Uses multilingual Whisper (tiny or base, int8) from the sherpa-onnx model - * zoo. The multilingual variants support both English and Italian. A small - * Silero VAD model is used to split long recordings into transcribable chunks. - */ -public final class ModelRepository { - - private static final String HF_BASE = - "https://huggingface.co/csukuangfj/sherpa-onnx-whisper-"; - private static final String VAD_URL = - "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"; - - public enum WhisperModel { - TINY("tiny", "Tiny", 12_937_772L, 89_855_401L), - BASE("base", "Base", 29_120_534L, 130_672_026L); - - public final String id; - public final String label; - public final long encoderBytes; - public final long decoderBytes; - - WhisperModel(String id, String label, long encoderBytes, long decoderBytes) { - this.id = id; - this.label = label; - this.encoderBytes = encoderBytes; - this.decoderBytes = decoderBytes; - } - - public long totalBytes() { - return encoderBytes + decoderBytes + TOKENS_BYTES + VAD_BYTES; - } - } - - private static final long TOKENS_BYTES = 816_730L; - private static final long VAD_BYTES = 643_854L; - - public interface Listener { - void onProgress(long downloaded, long total, String currentFile); - - default void onComplete() { - } - } - - private final File root; - - public ModelRepository(Context context) { - root = new File(context.getApplicationContext().getFilesDir(), "models"); - } - - public File vadModel() { - return new File(root, "silero_vad.onnx"); - } - - public File encoder(WhisperModel model) { - return new File(modelDir(model), "encoder.int8.onnx"); - } - - public File decoder(WhisperModel model) { - return new File(modelDir(model), "decoder.int8.onnx"); - } - - public File tokens(WhisperModel model) { - return new File(modelDir(model), "tokens.txt"); - } - - private File modelDir(WhisperModel model) { - return new File(root, "whisper-" + model.id); - } - - public boolean isReady(WhisperModel model) { - return isComplete(vadModel(), VAD_BYTES) - && isComplete(encoder(model), model.encoderBytes) - && isComplete(decoder(model), model.decoderBytes) - && isComplete(tokens(model), TOKENS_BYTES); - } - - public boolean isVadReady() { - return isComplete(vadModel(), VAD_BYTES); - } - - private static boolean isComplete(File file, long expected) { - return file.isFile() && (expected <= 0 || file.length() == expected); - } - - /** - * Blocking. Call from a background thread. Progress is reported through - * {@code listener}. Throws on failure. - */ - public void download(WhisperModel model, Listener listener) throws IOException { - long total = model.totalBytes(); - long already = 0; - if (isComplete(vadModel(), VAD_BYTES)) already += VAD_BYTES; - if (isComplete(encoder(model), model.encoderBytes)) already += model.encoderBytes; - if (isComplete(decoder(model), model.decoderBytes)) already += model.decoderBytes; - if (isComplete(tokens(model), TOKENS_BYTES)) already += TOKENS_BYTES; - - Counter counter = new Counter(already, total, listener); - counter.report(""); - - fetch(VAD_URL, vadModel(), VAD_BYTES, counter); - String base = HF_BASE + model.id + "/resolve/main/" + model.id; - fetch(base + "-encoder.int8.onnx", encoder(model), model.encoderBytes, counter); - fetch(base + "-decoder.int8.onnx", decoder(model), model.decoderBytes, counter); - fetch(base + "-tokens.txt", tokens(model), TOKENS_BYTES, counter); - - listener.onComplete(); - } - - private static final class Counter { - private final long already; - private final long total; - private final Listener listener; - private long doneInCurrent; - private String currentName = ""; - - Counter(long already, long total, Listener listener) { - this.already = already; - this.total = total; - this.listener = listener; - } - - void setCurrent(String name, long done) { - this.currentName = name; - this.doneInCurrent = done; - listener.onProgress(Math.min(already + done, total), total, currentName); - } - - void report(String name) { - listener.onProgress(Math.min(already + doneInCurrent, total), total, name); - } - } - - private static void fetch(String url, File target, long expected, Counter counter) - throws IOException { - if (isComplete(target, expected)) { - return; - } - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) { - throw new IOException("Cannot create " + parent); - } - File part = new File(parent, target.getName() + ".part"); - - HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); - connection.setInstanceFollowRedirects(true); - connection.setConnectTimeout(30_000); - connection.setReadTimeout(120_000); - connection.setRequestProperty("User-Agent", "RECCoon/1.0 (Android)"); - try { - int code = connection.getResponseCode(); - if (code != HttpURLConnection.HTTP_OK) { - throw new IOException("HTTP " + code + " for " + url); - } - long length = connection.getContentLengthLong(); - long thisTotal = length > 0 ? length : expected; - - try (InputStream in = connection.getInputStream(); - FileOutputStream out = new FileOutputStream(part)) { - byte[] buffer = new byte[256 * 1024]; - long done = 0; - int read; - long lastReport = 0; - while ((read = in.read(buffer)) > 0) { - out.write(buffer, 0, read); - done += read; - long now = System.currentTimeMillis(); - if (now - lastReport > 200) { - lastReport = now; - counter.setCurrent(target.getName(), done); - } - } - out.getFD().sync(); - counter.setCurrent(target.getName(), done); - } - - if (expected > 0 && part.length() != expected) { - //noinspection ResultOfMethodCallIgnored - part.delete(); - throw new IOException( - "Corrupt download for " + target.getName() - + " (expected " + expected + " bytes, got " + part.length() + ")"); - } - if (target.exists() && !target.delete()) { - throw new IOException("Cannot replace " + target); - } - if (!part.renameTo(target)) { - throw new IOException("Cannot move download into place: " + target); - } - } finally { - connection.disconnect(); - } - } -} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index e919117..2f3a1ac 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -65,27 +65,113 @@ android:scaleType="centerInside" android:src="@android:color/transparent" /> - <com.wuhei.reccoon.WaveformView + <androidx.appcompat.widget.AppCompatCheckBox + android:id="@+id/compressCheckbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="4dp" + android:contentDescription="@string/compress_recording" + android:text="@string/compress_short" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + + <LinearLayout + android:id="@+id/inputRow" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="4dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/input_label" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + + <Spinner + android:id="@+id/inputSpinner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:minWidth="180dp" /> + </LinearLayout> + + <LinearLayout + android:id="@+id/outputRow" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="4dp" + android:gravity="center_vertical" + android:orientation="horizontal"> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/output_label" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + + <Spinner + android:id="@+id/outputSpinner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:minWidth="180dp" /> + </LinearLayout> + + <androidx.appcompat.widget.AppCompatCheckBox + android:id="@+id/monitorCheckbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="2dp" + android:contentDescription="@string/monitor_recording" + android:text="@string/monitor_short" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + + <com.tomcooks.reccoon.WaveformView android:id="@+id/waveformView" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginTop="16dp" /> - <com.wuhei.reccoon.LevelMeterView + <com.tomcooks.reccoon.LevelMeterView android:id="@+id/levelMeter" android:layout_width="match_parent" android:layout_height="38dp" android:layout_marginTop="10dp" /> - <Button - android:id="@+id/markButton" + <LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="6dp" - android:enabled="false" - android:minWidth="140dp" - android:text="@string/mark_action" /> + android:gravity="center" + android:orientation="horizontal"> + + <Button + android:id="@+id/markButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:enabled="false" + android:minWidth="130dp" + android:text="@string/mark_action" /> + + <Button + android:id="@+id/discardButton" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="10dp" + android:enabled="false" + android:minWidth="130dp" + android:text="@string/discard_action" /> + </LinearLayout> <LinearLayout android:id="@+id/optionsRow" @@ -116,6 +202,17 @@ android:textSize="13sp" /> </LinearLayout> + <androidx.appcompat.widget.AppCompatCheckBox + android:id="@+id/swapCheckbox" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:layout_marginTop="2dp" + android:contentDescription="@string/swap_channels" + android:text="@string/swap_short" + android:textColor="@color/text_secondary" + android:textSize="13sp" /> + <TextView android:id="@+id/statusText" android:layout_width="match_parent" diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index 6ba1190..2bb2f54 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -159,25 +159,11 @@ android:textSize="18sp" android:textStyle="bold" /> - <LinearLayout + <Spinner + android:id="@+id/modelSpinner" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:orientation="horizontal"> - - <Spinner - android:id="@+id/languageSpinner" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" /> - - <Spinner - android:id="@+id/modelSpinner" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="10dp" - android:layout_weight="1" /> - </LinearLayout> + android:layout_marginTop="8dp" /> <Button android:id="@+id/transcribeButton" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 416ce5e..6bab640 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,13 +1,16 @@ <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">RECCoon</string> - <string name="subtitle">by wuhei · v1.4.0-alpha</string> + <string name="subtitle">by Tom Cooks · v1.0.0</string> <string name="timer_zero">00:00</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> <string name="status_saving">Saving to Downloads…</string> + <string name="status_discarding">Discarding…</string> + <string name="discarded">Recording discarded</string> + <string name="discard_action">Discard</string> <string name="cd_record">Start recording</string> <string name="cd_stop">Stop and save</string> @@ -22,8 +25,22 @@ <string name="mono_short">Mono</string> <string name="mono_recording">Record in mono (half file size)</string> + <string name="compress_short">Compressed (small M4A)</string> + <string name="compress_recording">Save a compressed M4A instead of a WAV (much smaller, lossy)</string> + <string name="input_label">Mic</string> + <string name="input_auto">Auto (Android picks)</string> + <string name="input_phone">Phone mic (built-in)</string> <string name="silence_short">Silence alerts</string> <string name="silence_recording">Silence notifications & vibrations while recording</string> + <string name="swap_short">Swap L/R</string> + <string name="swap_channels">Swap the left and right channels (use this if the meters are inverted)</string> + <string name="monitor_short">Monitor</string> + <string name="monitor_recording">Play the microphone through the selected output while recording</string> + <string name="monitor_warning">Monitoring live audio can cause feedback — use headphones.</string> + <string name="err_monitor">Could not start live monitoring</string> + <string name="output_label">Out</string> + <string name="output_auto">Auto (system default)</string> + <string name="output_speaker">Phone speaker</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> <string name="dnd_open_settings">Open settings</string> @@ -49,9 +66,10 @@ <string name="export_failed">Export failed: %1$s</string> <string name="err_share">Could not share this file</string> <string name="notification_channel">Recording</string> + <string name="model_download_channel">Model download</string> <string name="notification_recording">Recording audio…</string> <string name="recordings_title">Recordings</string> - <string name="recordings_subtitle">Saved in Downloads · reccoon-*.wav</string> + <string name="recordings_subtitle">Saved in Downloads · reccoon-*.wav / *.m4a</string> <string name="recordings_empty">No recordings yet.\nTap the record button to make one.</string> <string name="badge_transcript">TXT</string> @@ -63,15 +81,11 @@ <string name="transcribe_title">Transcription (on-device)</string> <string name="transcribe_action">Transcribe</string> - <string name="transcribe_hint">Choose a language and model, then tap Transcribe.</string> - <string name="lang_auto">Auto detect</string> - <string name="lang_en">English</string> - <string name="lang_it">Italiano</string> - <string name="model_tiny">Tiny (fast)</string> - <string name="model_base">Base (better)</string> + <string name="transcribe_hint">Tap Transcribe to run Parakeet v3 on this recording.</string> + <string name="model_download_title">Download model</string> - <string name="model_download_message">The %1$s multilingual Whisper model is required (about %2$s). Download it now over Wi-Fi?</string> + <string name="model_download_message">The %1$s model is required (about %2$s). Download it now over Wi-Fi?</string> <string name="download">Download</string> <string name="cancel">Cancel</string> <string name="status_model_ready">Model ready</string> diff --git a/app/src/test/java/com/tomcooks/reccoon/FormatsTest.java b/app/src/test/java/com/tomcooks/reccoon/FormatsTest.java new file mode 100644 index 0000000..96427a1 --- /dev/null +++ b/app/src/test/java/com/tomcooks/reccoon/FormatsTest.java @@ -0,0 +1,20 @@ +package com.tomcooks.reccoon; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class FormatsTest { + + @Test + public void prettyNameStripsWavExtension() { + assertEquals("2026-10-05 09:30:00 #00012", + Formats.prettyName("reccoon-2026-10-05-093000-00012.wav")); + } + + @Test + public void prettyNameStripsM4aExtension() { + assertEquals("2026-10-05 09:30:00 #00012", + Formats.prettyName("reccoon-2026-10-05-093000-00012.m4a")); + } +} diff --git a/app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java b/app/src/test/java/com/tomcooks/reccoon/StreamingResamplerTest.java index 6e4cdcd..4c44a44 100644 --- a/app/src/test/java/com/wuhei/reccoon/StreamingResamplerTest.java +++ b/app/src/test/java/com/tomcooks/reccoon/StreamingResamplerTest.java @@ -1,4 +1,4 @@ -package com.wuhei.reccoon; +package com.tomcooks.reccoon; import static org.junit.Assert.assertTrue; |
