Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import com.thelightphone.sdk.audio.LightAudioPlayer
import com.thelightphone.sdk.audio.LightAudioRecorder
import com.thelightphone.sdk.audio.LightAudioUsage
import com.thelightphone.sdk.audio.LightAudioVoice
import com.thelightphone.sdk.audio.LightMediaEnv
import com.thelightphone.sdk.audio.LightPlayerConfigurator
import com.thelightphone.sdk.audio.RecorderConfig
import kotlin.test.Test
import kotlin.test.assertEquals
Expand All @@ -26,7 +28,14 @@ class ToneScreenTest {
val vm = ToneViewModel(object : LightAudio {
override val capabilities: AudioCapabilities = AudioCapabilities(67)

override fun newPlayer(usage: LightAudioUsage): LightAudioPlayer {
override fun newPlayer(
usage: LightAudioUsage,
configure: LightPlayerConfigurator?
): LightAudioPlayer {
TODO("should not be called")
}

override fun mediaEnv(): LightMediaEnv {
TODO("Should not be called")
}

Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref =
androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
androidx-media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource = { module = "androidx.media3:media3-datasource", version.ref = "media3" }
androidx-media3-database = { module = "androidx.media3:media3-database", version.ref = "media3" }
androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }

[plugins]
Expand Down
16 changes: 16 additions & 0 deletions sdk/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ player.play()
- Observe `isPlaying` for the actual state.
- Transient focus loss pauses and later resumes playback, while duckable loss lowers the volume.

#### Advanced audio configuration
The default `newPlayer()` path builds an internal ExoPlayer and is unchanged for simple playback. Tools that have more complex audio needs can pass `LightPlayerConfigurator` to have more fine-grained control.
The SDK owns `Context` and constructs `ExoPlayer.Builder`. Your tool configures source factory, cache, load control, and related builder options via `LightPlayerConfigurator`. `LightMediaEnv` vends sandboxed cache, database, and data-source helpers rooted in the tool's private storage.
```kotlin
val player = audio.newPlayer(configure = LightPlayerConfigurator { builder, env ->
val cache = env.cache("stream", maxBytes = 64L * 1024 * 1024)
// configure builder with the things you need
})
```
Call `LightAudioPlayer.release` when done.
- Caches from `env.cache(...)` are process-scoped.
- Use `LightAudio.mediaEnv` for bankers, downloads, or other cache readers/writers.
- Do not call `SimpleCache.release()` from tool code. Player `release()` only tears down ExoPlayer.
- Use `LightAudioPlayer.media3Player` for direct queue editing (`replaceMediaItem`), per-item keys, and `Player.Listener` access.


#### PCM voice

`LightAudioVoice` plays short mono signed 16-bit PCM buffers.
Expand Down
6 changes: 4 additions & 2 deletions sdk/client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ dependencies {
api(libs.androidx.room.runtime)
api(libs.androidx.room.ktx)
implementation(libs.androidx.work.runtime)
implementation(libs.androidx.media3.common)
implementation(libs.androidx.media3.exoplayer)
api(libs.androidx.media3.common)
api(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.datasource)
implementation(libs.androidx.media3.database)
lintChecks(project(":lint-rules"))

testImplementation(libs.kotlin.test)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,28 @@ package com.thelightphone.sdk.audio

import android.content.Context
import android.media.AudioManager
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import com.thelightphone.sdk.SealedLightActivity

interface LightAudio {
val capabilities: AudioCapabilities
fun newPlayer(usage: LightAudioUsage = LightAudioUsage.Music): LightAudioPlayer

/**
* Creates a player that requests audio focus appropriate for [usage].
* When [configure] is non-null, the SDK builds an [ExoPlayer.Builder] and
* invokes it before constructing the player.
*/
@OptIn(UnstableApi::class)
fun newPlayer(
usage: LightAudioUsage = LightAudioUsage.Music,
configure: LightPlayerConfigurator? = null,
): LightAudioPlayer

/** Sandboxed media3 cache and data-source helpers for this tool process. */
@OptIn(UnstableApi::class)
fun mediaEnv(): LightMediaEnv

fun newRecorder(cfg: RecorderConfig = RecorderConfig()): LightAudioRecorder
fun newCapture(cfg: CaptureConfig = CaptureConfig()): LightAudioCapture
fun newVoice(
Expand All @@ -24,11 +41,18 @@ value class DefaultLightAudio(
override val capabilities: AudioCapabilities
get() = sealedActivity.activity.readAudioCapabilities()

/** Create a player that requests audio focus appropriate for [usage]. */
override fun newPlayer(usage: LightAudioUsage): LightAudioPlayer {
return LightAudioPlayer(sealedActivity.activity, usage)
@OptIn(UnstableApi::class)
override fun newPlayer(
usage: LightAudioUsage,
configure: LightPlayerConfigurator?,
): LightAudioPlayer {
return LightAudioPlayer.create(sealedActivity.activity, usage, configure)
}

@OptIn(UnstableApi::class)
override fun mediaEnv(): LightMediaEnv =
LightMediaEnv.forContext(sealedActivity.activity)

/** Create a recorder using [cfg]. Call [LightAudioRecorder.release] when done. */
override fun newRecorder(cfg: RecorderConfig): LightAudioRecorder =
LightAudioRecorder(sealedActivity.activity, cfg)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.thelightphone.sdk.audio
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
Expand All @@ -29,9 +30,10 @@ import kotlinx.coroutines.flow.StateFlow
* Transient focus loss pauses and later resumes playback; duckable loss lowers
* volume. Call [release] when the owning screen is destroyed.
*/
class LightAudioPlayer internal constructor(
open class LightAudioPlayerCore internal constructor(
context: Context,
usage: LightAudioUsage = LightAudioUsage.Music
private val usage: LightAudioUsage,
private val player: Player,
) {
private val scopeJob = SupervisorJob()
private val scope = CoroutineScope(scopeJob + Dispatchers.Main.immediate)
Expand All @@ -45,52 +47,59 @@ class LightAudioPlayer internal constructor(

/** Current position in milliseconds, updated while playing. */
val positionMs: StateFlow<Long> = _positionMs

/** Resolved duration in milliseconds, or `0` while unknown/unavailable. */
val durationMs: StateFlow<Long> = _durationMs

/** Whether the platform is actively advancing playback. */
val isPlaying: StateFlow<Boolean> = _isPlaying

/** Current queue index, or `-1` when the queue is empty. */
val currentMediaItemIndex: StateFlow<Int> = _currentMediaItemIndex

private val player = ExoPlayer.Builder(context).build().apply player@{
setAudioAttributes(usage.toMedia3AudioAttributes(), false)
addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
// `this@player` is the ExoPlayer (Int index), not the wrapper's StateFlow.
_currentMediaItemIndex.value = if (mediaItem == null) {
NO_MEDIA_ITEM
} else {
this@player.currentMediaItemIndex
}
}
private val focus = AudioFocusHelper(
context = context,
usage = usage,
gainType = AudioManager.AUDIOFOCUS_GAIN,
onFocusChange = ::onAudioFocusChange
)

override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
if (isPlaying) {
startPositionUpdates()
} else {
stopPositionUpdates()
updatePosition()
}
private val stateListener = object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
_currentMediaItemIndex.value = if (mediaItem == null) {
NO_MEDIA_ITEM
} else {
player.currentMediaItemIndex
}
}

override fun onPlaybackStateChanged(playbackState: Int) {
updateDuration()
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
if (isPlaying) {
startPositionUpdates()
} else {
stopPositionUpdates()
updatePosition()
if (playbackState == Player.STATE_ENDED) {
stopPositionUpdates()
abandonFocus()
}
}
})
}

override fun onPlaybackStateChanged(playbackState: Int) {
updateDuration()
updatePosition()
if (playbackState == Player.STATE_ENDED) {
stopPositionUpdates()
abandonFocus()
}
}
}

private val focus = AudioFocusHelper(
context = context,
usage = usage,
gainType = AudioManager.AUDIOFOCUS_GAIN,
onFocusChange = ::onAudioFocusChange
)
init {
reassertSdkOutput(player, usage)
player.addListener(stateListener)
}

/** The underlying media3 [Player] for direct queue and listener access. */
fun media3Player(): Player = player

/** Playback rate, clamped to a minimum positive rate. */
var speed: Float = 1.0f
Expand All @@ -99,22 +108,6 @@ class LightAudioPlayer internal constructor(
player.playbackParameters = PlaybackParameters(field)
}

/** Enables the platform player's silence-skipping behavior. */
var skipSilence: Boolean = false
@androidx.annotation.OptIn(markerClass = [UnstableApi::class])
set(value) {
field = value
player.skipSilenceEnabled = value
}

/** When `true`, playback pauses at the end of each queue item instead of advancing. */
var pauseAtEndOfMediaItems: Boolean = false
@androidx.annotation.OptIn(markerClass = [UnstableApi::class])
set(value) {
field = value
player.pauseAtEndOfMediaItems = value
}

/** Replaces the queue with [file] and prepares it for playback. */
fun setSource(file: File) {
setQueue(listOf(file), metadata = null)
Expand Down Expand Up @@ -208,12 +201,12 @@ class LightAudioPlayer internal constructor(
}

/** Permanently releases playback, focus, and state-update resources. Idempotent. */
fun release() {
open fun release() {
if (released) return
released = true
stopPositionUpdates()
abandonFocus()
player.release()
player.removeListener(stateListener)
scope.cancel()
}

Expand Down Expand Up @@ -270,6 +263,63 @@ class LightAudioPlayer internal constructor(
private fun updateDuration() {
_durationMs.value = player.duration.validDuration()
}

internal companion object {
@OptIn(UnstableApi::class)
internal fun create(
context: Context,
usage: LightAudioUsage,
player: Player
): LightAudioPlayerCore {
return LightAudioPlayerCore(context, usage, player)
}

private fun reassertSdkOutput(player: Player, usage: LightAudioUsage) {
player.setAudioAttributes(usage.toMedia3AudioAttributes(), false)
}
}
}

class LightAudioPlayer internal constructor(
context: Context,
usage: LightAudioUsage,
private val exoPlayer: ExoPlayer
) :
LightAudioPlayerCore(context, usage, exoPlayer) {
/** Enables the platform player's silence-skipping behavior. */
var skipSilence: Boolean = false
@OptIn(UnstableApi::class)
set(value) {
field = value
exoPlayer.skipSilenceEnabled = value
}

/** When `true`, playback pauses at the end of each queue item instead of advancing. */
var pauseAtEndOfMediaItems: Boolean = false
@OptIn(UnstableApi::class)
set(value) {
field = value
exoPlayer.pauseAtEndOfMediaItems = value
}

override fun release() {
super.release()
exoPlayer.release()
}

internal companion object {
@OptIn(UnstableApi::class)
internal fun create(
context: Context,
usage: LightAudioUsage,
configure: LightPlayerConfigurator?,
): LightAudioPlayer {
val builder = ExoPlayer.Builder(context)
configure?.configure(builder, LightMediaEnv.forContext(context))
val exoPlayer = builder.build()
return LightAudioPlayer(context, usage, exoPlayer)
}
}
}

internal fun LightAudioItem.toMediaItem(queueIndex: Int): MediaItem {
Expand Down
Loading
Loading