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.
This commit is contained in:
2026-10-15 11:34:42 -07:00
parent 684c65b025
commit 0f72c94f36
4 changed files with 88 additions and 1 deletions

View File

@@ -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**.

View File

@@ -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

View File

@@ -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
}
}

Binary file not shown.