| 1 | package codewhale.pet |
| 2 | |
| 3 | import android.content.Context |
| 4 | import android.media.AudioAttributes |
| 5 | import android.media.AudioFocusRequest |
| 6 | import android.media.AudioFormat |
| 7 | import android.media.AudioManager |
| 8 | import android.media.AudioTrack |
| 9 | import android.os.Handler |
| 10 | import android.os.Looper |
| 11 | import android.os.SystemClock |
| 12 | import java.util.concurrent.ArrayBlockingQueue |
| 13 | import java.util.concurrent.TimeUnit |
| 14 | import java.util.concurrent.atomic.AtomicBoolean |
| 15 | import java.util.concurrent.atomic.AtomicReference |
| 16 | import kotlin.concurrent.thread |
| 17 | import kotlin.math.max |
| 18 | |
| 19 | /** One optional output device. Slow audio drops a bounded packet, never blocks |
| 20 | * the world worker, and never builds a backlog to play after a pause. */ |
| 21 | class PetAudioOutput(context: Context, onFocusLost: () -> Unit) : AutoCloseable { |
| 22 | private data class Packet(val samples: FloatArray, val createdMs: Long = SystemClock.elapsedRealtime()) |
| 23 | private val pending = ArrayBlockingQueue<Packet>(4) |
| 24 | private val running = AtomicBoolean(true) |
| 25 | private val closed = AtomicBoolean(false) |
| 26 | private val failure = AtomicReference<String?>(null) |
| 27 | private val manager = context.getSystemService(AudioManager::class.java) |
| 28 | private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA) |
| 29 | .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build() |
| 30 | private val focus = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) |
| 31 | .setAudioAttributes(attributes).setOnAudioFocusChangeListener({ change -> |
| 32 | if (change != AudioManager.AUDIOFOCUS_GAIN) { close(); onFocusLost() } |
| 33 | }, Handler(Looper.getMainLooper())).build() |
| 34 | private lateinit var writer: Thread |
| 35 | |
| 36 | init { |
| 37 | check(manager.requestAudioFocus(focus) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { "Another app is using audio. Try Sound again." } |
| 38 | writer = thread(name = "codewhale-pet-audio", isDaemon = true) { |
| 39 | var track: AudioTrack? = null |
| 40 | try { |
| 41 | val minimum = AudioTrack.getMinBufferSize(48_000, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_FLOAT) |
| 42 | check(minimum > 0) { "Stereo float audio is unavailable." } |
| 43 | track = AudioTrack.Builder().setAudioAttributes(attributes).setAudioFormat(AudioFormat.Builder() |
| 44 | .setSampleRate(48_000).setEncoding(AudioFormat.ENCODING_PCM_FLOAT) |
| 45 | .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build()) |
| 46 | .setTransferMode(AudioTrack.MODE_STREAM).setBufferSizeInBytes(max(minimum, 4_800 * 8)).build() |
| 47 | check(track.state == AudioTrack.STATE_INITIALIZED) { "Audio output could not start." } |
| 48 | track.play() |
| 49 | while (running.get()) { |
| 50 | val packet = pending.poll(100, TimeUnit.MILLISECONDS) ?: continue |
| 51 | var offset = 0 |
| 52 | while (running.get() && offset < packet.samples.size && SystemClock.elapsedRealtime() - packet.createdMs <= 500) { |
| 53 | val n = track.write(packet.samples, offset, packet.samples.size - offset, AudioTrack.WRITE_NON_BLOCKING) |
| 54 | check(n >= 0) { "Audio output stopped ($n)." } |
| 55 | offset += n |
| 56 | if (n == 0) Thread.sleep(4) |
| 57 | } |
| 58 | } |
| 59 | } catch (_: InterruptedException) { |
| 60 | // Closing wakes a waiting writer; the device is always released below. |
| 61 | } catch (e: Exception) { |
| 62 | failure.set(e.message ?: "Audio output stopped.") |
| 63 | } finally { |
| 64 | running.set(false); pending.clear() |
| 65 | track?.let { runCatching { it.pause(); it.flush() }; it.release() } |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | fun offer(samples: FloatArray) { |
| 71 | failure.get()?.let { error(it) } |
| 72 | check(running.get()) { "Audio output stopped." } |
| 73 | require(samples.size in 2..48_000 && samples.size % 2 == 0 && samples.all { it.isFinite() && it in -1f..1f }) |
| 74 | pending.offer(Packet(samples)) |
| 75 | } |
| 76 | override fun close() { |
| 77 | if (closed.getAndSet(true)) return |
| 78 | running.set(false); pending.clear() |
| 79 | // This may also be called by the focus listener during construction. |
| 80 | if (this::writer.isInitialized) writer.interrupt() |
| 81 | manager.abandonAudioFocusRequest(focus) |
| 82 | } |
| 83 | } |
| 84 |