Files
PageScribe/app/src/main/java/com/bm/pagescribe/util/ClickSound.kt
PageScribe 0f72c94f36 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.
2026-10-15 11:34:42 -07:00

73 lines
2.3 KiB
Kotlin

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