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.
73 lines
2.3 KiB
Kotlin
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
|
|
}
|
|
}
|