0.95.2: reopen the sound at the same point when the box's audio clock goes wrong (spurious audio timestamps), instead of drifting
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@ android {
|
||||
applicationId = "tv.orbit.classic"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 120
|
||||
versionName = "0.95.1-early-end-ident"
|
||||
versionCode = 121
|
||||
versionName = "0.95.2-audio-clock-reset"
|
||||
}
|
||||
buildFeatures { compose = true }
|
||||
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||
|
||||
@@ -66,7 +66,7 @@ internal class HttpsEmbyTransport(internal val deviceId: String = UUID.randomUUI
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = request.readTimeoutMs ?: if (request.path == "/Sessions/Logout") 5_000 else 15_000
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.95.1\"")
|
||||
connection.setRequestProperty("X-Emby-Authorization", "Emby Client=\"Orbit TV\", Device=\"Orbit TV\", DeviceId=\"$deviceId\", Version=\"0.95.2\"")
|
||||
request.token?.let { connection.setRequestProperty("X-Emby-Token", it) }
|
||||
if (request.method == "POST") {
|
||||
val bytes = request.body.orEmpty().toByteArray(Charsets.UTF_8)
|
||||
|
||||
@@ -38,6 +38,25 @@ import kotlinx.coroutines.withContext
|
||||
/** A decoder fault normally repeats on the same file, so one more try is enough. */
|
||||
private const val MAX_PLAYER_RETRIES = 1
|
||||
|
||||
/**
|
||||
* The box's audio clock can go wrong deep into a long film (seen 3 November on Godzilla's TrueHD passthrough, about
|
||||
* 1 hour 40 in): every position report the sound hardware gives back is nonsense, ExoPlayer rejects them twice a second
|
||||
* ("Spurious audio timestamp"), and the sound drifts out of sync with the picture until the track is reopened.
|
||||
* While a scheduled programme has been playing steadily, Orbit asks ExoPlayer to stop on that instead, and reopens the
|
||||
* sound at the same point: a one-second blip rather than a drifting film. Not during the first moments of a programme,
|
||||
* where a single stray report is normal, and only a few times a programme, after which the old tolerant behaviour returns.
|
||||
*/
|
||||
internal const val AUDIO_CLOCK_SETTLE_MS = 30_000L
|
||||
internal const val MAX_AUDIO_CLOCK_RESETS = 3
|
||||
|
||||
/** True when a playback error is ExoPlayer giving up on the sound hardware's position reports. */
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
internal fun isAudioClockFault(error: Throwable): Boolean =
|
||||
generateSequence(error) { it.cause }.any { it is androidx.media3.exoplayer.audio.DefaultAudioSink.InvalidAudioTrackTimestampException }
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
internal fun watchAudioClock(on: Boolean) { androidx.media3.exoplayer.audio.DefaultAudioSink.failOnSpuriousAudioTimestamp = on }
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@Composable
|
||||
internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPlan, onClose: () -> Unit, scheduled: ScheduledPlayback? = null,
|
||||
@@ -64,6 +83,7 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
var handedBack by remember { mutableStateOf(false) }
|
||||
var playerRetries by remember { mutableIntStateOf(0) }
|
||||
var audioRetries by remember { mutableIntStateOf(0) }
|
||||
var audioClockResets by remember { mutableIntStateOf(0) }
|
||||
var audioChosen by remember { mutableStateOf(false) }
|
||||
var trackProblem by remember { mutableStateOf<String?>(null) }
|
||||
val notes = remember { java.io.File(context.noBackupFilesDir, "schedule") }
|
||||
@@ -118,9 +138,9 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
else numberProblem = "Channel ${entry.number} isn't available"
|
||||
}
|
||||
LaunchedEffect(trackProblem) { if (trackProblem != null) { delay(6_000); trackProblem = null } }
|
||||
LaunchedEffect(playerRetries, audioRetries) {
|
||||
if ((playerRetries > 0 || audioRetries > 0) && scheduled != null) {
|
||||
delay(800)
|
||||
LaunchedEffect(playerRetries, audioRetries, audioClockResets) {
|
||||
if ((playerRetries > 0 || audioRetries > 0 || audioClockResets > 0) && scheduled != null) {
|
||||
delay(if (audioClockResets > 0 && playerRetries == 0 && audioRetries == 0) 200 else 800)
|
||||
player.seekTo(scheduled.programme.offsetAt(scheduled.nowMs()))
|
||||
player.prepare()
|
||||
}
|
||||
@@ -154,6 +174,15 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
playing = player.isPlaying
|
||||
val listener = object : Player.Listener {
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
// The sound hardware's clock went wrong: reopen at the same point, not a retry and never a failure.
|
||||
if (scheduled != null && isAudioClockFault(error) && audioClockResets < MAX_AUDIO_CLOCK_RESETS) {
|
||||
android.util.Log.w("OrbitSchedule", "channel=${scheduled.channel.number} audioClockReset=${audioClockResets + 1} positionMs=${player.currentPosition}")
|
||||
PlaybackNotes.add(notes, "channel=${scheduled.channel.number} item=${plan.item.id} audioClockReset=${audioClockResets + 1} positionMs=${player.currentPosition}")
|
||||
watchAudioClock(false)
|
||||
ready = false
|
||||
audioClockResets++
|
||||
return
|
||||
}
|
||||
android.util.Log.w("OrbitSchedule", "channel=${scheduled?.channel?.number} playerError=${error.errorCodeName} retry=$playerRetries")
|
||||
PlaybackNotes.add(notes, "channel=${scheduled?.channel?.number} item=${plan.item.id} error=${error.errorCodeName} audioChosen=$audioChosen")
|
||||
// The viewer's choice of sound failed, not the programme. Back to the sound that was working, and the file keeps its good name.
|
||||
@@ -230,6 +259,15 @@ internal fun EmbyPlaybackDialog(session: EmbySampleSession, plan: EmbyPlaybackPl
|
||||
LaunchedEffect(player, ready) {
|
||||
if (ready) { delay(2_000); tuningNumberVisible = false }
|
||||
}
|
||||
// Watch the audio clock only once the programme has played steadily for a while, and never under its ident.
|
||||
LaunchedEffect(player, ready, ident != null, audioClockResets) {
|
||||
watchAudioClock(false)
|
||||
if (scheduled != null && ready && ident == null && audioClockResets < MAX_AUDIO_CLOCK_RESETS) {
|
||||
delay(AUDIO_CLOCK_SETTLE_MS)
|
||||
watchAudioClock(true)
|
||||
}
|
||||
}
|
||||
DisposableEffect(player) { onDispose { watchAudioClock(false) } }
|
||||
val covered = ident != null
|
||||
LaunchedEffect(player, intro?.showing) { if (intro != null) player.volume = if (intro.showing) 0f else 1f }
|
||||
LaunchedEffect(player, covered) {
|
||||
|
||||
@@ -33,7 +33,7 @@ internal class SecureMediaDataSource(private val session: EmbySampleSession) : B
|
||||
next.instanceFollowRedirects = false
|
||||
next.connectTimeout = 10_000
|
||||
next.readTimeout = 20_000
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.95.1")
|
||||
next.setRequestProperty("User-Agent", "OrbitTV/0.95.2")
|
||||
next.setRequestProperty("Accept-Encoding", "identity")
|
||||
session.mediaHeaders(address).forEach { (key, value) -> next.setRequestProperty(key, value) }
|
||||
if (dataSpec.position != 0L || dataSpec.length != C.LENGTH_UNSET.toLong()) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package tv.orbit.classic
|
||||
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class AudioClockTest {
|
||||
@Test fun anAudioClockFaultIsRecognisedThroughTheErrorChain() {
|
||||
// ExoPlayer makes this one itself; its constructor is private, so the test reaches it the same way.
|
||||
val fault = DefaultAudioSink.InvalidAudioTrackTimestampException::class.java.getDeclaredConstructor(String::class.java)
|
||||
.apply { isAccessible = true }.newInstance("Spurious audio timestamp (frame position mismatch)")
|
||||
// ExoPlayer wraps it in its own playback error; the check walks the chain of causes.
|
||||
assertTrue(isAudioClockFault(RuntimeException("playback error", fault)))
|
||||
assertTrue(isAudioClockFault(fault))
|
||||
}
|
||||
|
||||
@Test fun otherPlaybackErrorsAreNotTreatedAsAudioClockFaults() {
|
||||
assertFalse(isAudioClockFault(RuntimeException("playback error", IllegalStateException("decoder"))))
|
||||
}
|
||||
|
||||
@Test fun watchingCanBeSwitchedOnAndOff() {
|
||||
watchAudioClock(true); assertTrue(DefaultAudioSink.failOnSpuriousAudioTimestamp)
|
||||
watchAudioClock(false); assertFalse(DefaultAudioSink.failOnSpuriousAudioTimestamp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Orbit TV 0.95.2: reopen the sound when the box's audio clock goes wrong
|
||||
|
||||
A small fix of its own, approved by Paul on 3 November 2026. No scheduler, artwork or plan changes.
|
||||
|
||||
**What happened:** on the Drive-In (300) on 3 November, Godzilla (HEVC 4K with Dolby TrueHD 8-channel sent straight to the amplifier) started on time at 20:00. By about 21:37 the sound had drifted behind the picture. The box's log showed ExoPlayer warning twice a second: "Spurious audio timestamp (frame position mismatch)", with the hardware's playback position reading about 89,478 seconds (2^32 frames at 48 kHz, a wrapped counter). The sound hardware's position reports had become nonsense, so the player could no longer keep the picture in step. Retuning (a fresh audio track) fixed it, which Paul did at 21:48.
|
||||
|
||||
**The change:** once a scheduled programme has played steadily for 30 seconds (`AUDIO_CLOCK_SETTLE_MS`), Orbit sets ExoPlayer's `DefaultAudioSink.failOnSpuriousAudioTimestamp`, so a bad position report stops the player instead of being tolerated. That error (`isAudioClockFault`) is handled on its own: the player reopens at the programme's live position after 200 ms, with no failure message, and not counted as a retry. Logged as `audioClockReset=N positionMs=…` in logcat (OrbitSchedule) and the playback notes. At most 3 resets a programme (`MAX_AUDIO_CLOCK_RESETS`), then the old tolerant behaviour returns. Watching is off during the first 30 seconds of a programme (a single stray report is normal there), off under the ident, and switched off when the player closes.
|
||||
|
||||
**Unchanged:** schedules, plans, the guide, idents, preloading, and every other error path (the one retry, then "won't play").
|
||||
|
||||
Files: EmbyPlaybackDialog.kt (`watchAudioClock`, `isAudioClockFault`, the reset path and the settle timer); AudioClockTest (3 tests). 363 unit tests pass; lint has no errors. Not installed.
|
||||
|
||||
**To check on the TV:** a long film with TrueHD or DTS passthrough. If the fault returns, there should be a one-second blip and the sound back in sync, with `audioClockReset` in logcat, rather than a drift. If resets show up often early in films, raise the settle time.
|
||||
Reference in New Issue
Block a user