From 0f72c94f3692f747654a769374fd77bf620a1f49 Mon Sep 17 00:00:00 2001 From: PageScribe Date: Thu, 15 Oct 2026 11:34:42 -0700 Subject: [PATCH] Play a shutter click when a photo is taken A camera you are not looking at has to say it heard the tap. A synthesised shutter_click.wav (110 ms, two filtered noise transients for the shutter open and close) is bundled and played through SoundPool on each accepted shot. The click sits behind the capture guards, so a tap the camera refuses - still busy, or not ready - stays silent and the click always means "taken". SoundPool loads asynchronously, so a tap that beats the sample into memory plays a short system tone instead of nothing. The sample is released in onDestroy. Verified on device: with system touch sounds disabled, dumpsys audio shows player piid:1263 (package:com.bm.pagescribe, type:android.media.SoundPool) emitting event:started on the tap, while AudioFlinger's mixer leaves standby at the same moment. --- README.md | 5 +- .../java/com/bm/pagescribe/CaptureActivity.kt | 12 +++ .../java/com/bm/pagescribe/util/ClickSound.kt | 72 ++++++++++++++++++ app/src/main/res/raw/shutter_click.wav | Bin 0 -> 9746 bytes 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/bm/pagescribe/util/ClickSound.kt create mode 100644 app/src/main/res/raw/shutter_click.wav diff --git a/README.md b/README.md index 3e086fb..e967f93 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ it can go to Drive, email, or the Files app. - **Shelf** (`MainActivity`) — one row per book, plus a `+` to create one. - **Capture** (`CaptureActivity`) — CameraX preview, flash toggle, shutter, and - a jump to the book. + a jump to the book. Every accepted shot is confirmed with a short shutter + click (a bundled sample played through `SoundPool`), so a batch of spreads can + be shot by feel. A tap that the camera refuses — still busy, not ready — + stays silent, so the click always means "taken". - **Book** (`BookActivity`) — the pages read so far, the log, **Export text**, **Capture**, and **Delete book**. diff --git a/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt index a9ea695..08e6aaf 100644 --- a/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt +++ b/app/src/main/java/com/bm/pagescribe/CaptureActivity.kt @@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat import com.bm.pagescribe.book.Book import com.bm.pagescribe.book.BookRepository import com.bm.pagescribe.ocr.WorkScheduler +import com.bm.pagescribe.util.ClickSound import com.bm.pagescribe.util.EdgeToEdge import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.button.MaterialButton @@ -44,6 +45,7 @@ class CaptureActivity : AppCompatActivity() { private lateinit var shutter: ImageButton private lateinit var flashButton: MaterialButton private lateinit var permissionPanel: View + private var clickSound: ClickSound? = null private var imageCapture: ImageCapture? = null private var book: Book? = null @@ -95,6 +97,7 @@ class CaptureActivity : AppCompatActivity() { return } supportActionBar?.title = book?.title + clickSound = ClickSound(this) shutter.setOnClickListener { shoot() } flashButton.setOnClickListener { toggleFlash() } @@ -126,6 +129,12 @@ class CaptureActivity : AppCompatActivity() { handler.removeCallbacks(poll) } + override fun onDestroy() { + clickSound?.release() + clickSound = null + super.onDestroy() + } + private fun hasCameraPermission(): Boolean = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED @@ -174,6 +183,9 @@ class CaptureActivity : AppCompatActivity() { val target = File(current.queueDir, "page-${System.currentTimeMillis()}.jpg") val options = ImageCapture.OutputFileOptions.Builder(target).build() + // Confirmation for the tap: only once the shot is really under way, so a + // click never plays for a tap that was refused. + clickSound?.play() capturing = true shutter.isEnabled = false diff --git a/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt b/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt new file mode 100644 index 0000000..530b63e --- /dev/null +++ b/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt @@ -0,0 +1,72 @@ +package com.bm.pagescribe.util + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.media.ToneGenerator +import com.bm.pagescribe.R + +/** + * The short click the Camera button makes when a shot is taken. + * + * The sound is a bundled shutter sample played through [SoundPool], which is + * built for short UI sounds and keeps the latency low enough that the click + * lands with the tap. [SoundPool.load] is asynchronous, so a tap that beats + * the sample into memory falls back to a system tone rather than going silent. + * + * Call [release] when the owning activity is destroyed. + */ +class ClickSound(context: Context) { + + private val appContext = context.applicationContext + private val pool: SoundPool = SoundPool.Builder() + .setMaxStreams(1) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + .build() + + private var sampleId = 0 + private var sampleReady = false + private var tone: ToneGenerator? = null + private var released = false + + init { + pool.setOnLoadCompleteListener { _, _, status -> sampleReady = status == 0 } + sampleId = pool.load(appContext, R.raw.shutter_click, 1) + } + + /** Play the click. Safe to call on every tap. */ + fun play() { + if (released) return + if (sampleReady && sampleId != 0) { + pool.play(sampleId, VOLUME, VOLUME, 1, 0, 1f) + return + } + // The sample has not finished loading yet; a system tone still confirms + // the tap. (ToneGenerator can fail on some devices, hence runCatching.) + runCatching { + val generator = tone ?: ToneGenerator(AudioManager.STREAM_SYSTEM, TONE_VOLUME) + .also { tone = it } + generator.startTone(ToneGenerator.TONE_PROP_ACK, TONE_MS) + } + } + + fun release() { + if (released) return + released = true + pool.release() + runCatching { tone?.release() } + tone = null + } + + private companion object { + const val VOLUME = 1f + const val TONE_VOLUME = 60 + const val TONE_MS = 80 + } +} diff --git a/app/src/main/res/raw/shutter_click.wav b/app/src/main/res/raw/shutter_click.wav new file mode 100644 index 0000000000000000000000000000000000000000..36c6137b655b5daffe142289a06a0e5cda5a5c30 GIT binary patch literal 9746 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