Fix seg-2 audio dropout + switch spectrum from bars to Bézier lines
**Regression fix**: when synthesis of segment N+1 ran longer than the playback of segment N (e.g. 5 s synth for a 1.5 s "Bonjour !"), the previous MediaPlayer was already in the Completed state by the time we queued the next one. setNextMediaPlayer() on a Completed player is a documented silent no-op — so the second sentence never started and the user only heard the first part of the reply. Rewrote playChainedMediaPlayers with per-player CompletableDeferred tracking: before calling setNext we check whether current's done has fired; after awaiting completion we verify next really auto-started (checking isPlaying / currentPosition) and call start() explicitly if the chain missed. Belt-and-suspenders against the race either way. Removed the now-unused waitForPlaybackCompletion helper. **Visual change**: in-sphere spectrum bars replaced with three superimposed Bézier "deforming lines", mirrored above/below a central baseline, with a soft cosine taper so the curves decay to zero at the sphere's left/right edges (matches the circular mask). Each line has its own slow-moving phase + gain + thickness + alpha so the three overlap to give depth — closer to an oscilloscope trace than an EQ. Low-level sin jitter keeps the lines alive during quiet passages, amplitude-gated so true silence is a flat line. User-facing change: no bars anymore. The sphere now "breathes" with flowing waveforms matching its voice. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
06dcd76dcb
commit
2fe46e0f15
|
|
@ -3483,55 +3483,91 @@ class Qwen3TtsEngine(
|
|||
return mp
|
||||
}
|
||||
|
||||
var current: android.media.MediaPlayer? = null
|
||||
var currentInfo: SegmentReady? = null
|
||||
var next: android.media.MediaPlayer? = null
|
||||
var nextInfo: SegmentReady? = null
|
||||
// Per-player book-keeping. `done` completes the moment the
|
||||
// MediaPlayer's OnCompletionListener fires, so the loop can
|
||||
// tell *before* calling setNextMediaPlayer whether the chain
|
||||
// will actually trigger (setNextMediaPlayer on a player already
|
||||
// in the Completed state is a silent no-op — that was the root
|
||||
// cause of missing audio on seg 1 when synthesis ran longer
|
||||
// than seg 0's playback).
|
||||
class Live(
|
||||
val mp: android.media.MediaPlayer,
|
||||
val info: SegmentReady,
|
||||
val done: kotlinx.coroutines.CompletableDeferred<Unit>
|
||||
)
|
||||
|
||||
fun arm(info: SegmentReady, mp: android.media.MediaPlayer): Live {
|
||||
val done = kotlinx.coroutines.CompletableDeferred<Unit>()
|
||||
mp.setOnCompletionListener {
|
||||
try { it.release() } catch (_: Exception) {}
|
||||
if (!done.isCompleted) done.complete(Unit)
|
||||
}
|
||||
mp.setOnErrorListener { _, what, extra ->
|
||||
nlog("MP seg ${info.segIdx} play error: what=$what extra=$extra")
|
||||
if (!done.isCompleted) done.complete(Unit)
|
||||
true
|
||||
}
|
||||
return Live(mp, info, done)
|
||||
}
|
||||
|
||||
var current: Live? = null
|
||||
|
||||
try {
|
||||
// Bootstrap: wait for first WAV.
|
||||
// Bootstrap with the first segment.
|
||||
val first = wavChan.receiveCatching().getOrNull() ?: return
|
||||
currentInfo = first
|
||||
current = prepareMp(first.wavPath, first.segIdx)
|
||||
current!!.setOnCompletionListener { it.release() }
|
||||
current!!.start()
|
||||
val firstMp = prepareMp(first.wavPath, first.segIdx)
|
||||
firstMp.start()
|
||||
current = arm(first, firstMp)
|
||||
try { onSegmentPlaying?.invoke(first.sentence, first.durationMs, first.rmsEnvelope, first.spectrogram) } catch (_: Exception) {}
|
||||
nlog("MP seg ${first.segIdx} started (chained, ${first.durationMs}ms)")
|
||||
nlog("MP seg ${first.segIdx} started (${first.durationMs}ms)")
|
||||
|
||||
while (true) {
|
||||
// Fetch next WAV (may block). If channel closes, let
|
||||
// current finish playing and exit.
|
||||
val upcoming = wavChan.receiveCatching().getOrNull()
|
||||
if (upcoming == null) break
|
||||
nextInfo = upcoming
|
||||
next = prepareMp(upcoming.wavPath, upcoming.segIdx)
|
||||
current!!.setNextMediaPlayer(next)
|
||||
nlog("MP seg ${upcoming.segIdx} queued as next")
|
||||
val upcoming = wavChan.receiveCatching().getOrNull() ?: break
|
||||
val nextMp = prepareMp(upcoming.wavPath, upcoming.segIdx)
|
||||
|
||||
// Wait for current seg to finish playing before rotating.
|
||||
val prevFile = currentInfo!!.wavPath
|
||||
waitForPlaybackCompletion(current!!, currentInfo!!.segIdx)
|
||||
try { java.io.File(prevFile).delete() } catch (_: Exception) {}
|
||||
current = next
|
||||
currentInfo = nextInfo
|
||||
// `next` player was chained via setNextMediaPlayer and has
|
||||
// auto-started at this point; notify the UI so it can start
|
||||
// revealing the sentence in sync with the audio.
|
||||
try { onSegmentPlaying?.invoke(currentInfo!!.sentence, currentInfo!!.durationMs, currentInfo!!.rmsEnvelope, currentInfo!!.spectrogram) } catch (_: Exception) {}
|
||||
next = null
|
||||
nextInfo = null
|
||||
// Try to chain so Android auto-starts next when current
|
||||
// finishes — gives zero-gap playback without re-arming
|
||||
// the DAC. Skipped if current has already completed
|
||||
// (setNext on Completed is a no-op); we fall back to an
|
||||
// explicit start() below in that case.
|
||||
var chained = false
|
||||
try {
|
||||
if (!current!!.done.isCompleted) {
|
||||
current!!.mp.setNextMediaPlayer(nextMp)
|
||||
chained = true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
nlog("MP seg ${upcoming.segIdx} setNext failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Wait for current playback to finish before rotating.
|
||||
current!!.done.await()
|
||||
try { java.io.File(current!!.info.wavPath).delete() } catch (_: Exception) {}
|
||||
|
||||
// If we never chained (or the chain raced with the
|
||||
// current's completion), start next manually. Safe to
|
||||
// start() again even if Android already auto-started.
|
||||
val autoStarted = try { chained && (nextMp.isPlaying || nextMp.currentPosition > 0) } catch (_: Exception) { false }
|
||||
if (!autoStarted) {
|
||||
try { nextMp.start() } catch (e: Exception) {
|
||||
nlog("MP seg ${upcoming.segIdx} manual start failed: ${e.message}")
|
||||
}
|
||||
nlog("MP seg ${upcoming.segIdx} started manually (chain missed)")
|
||||
} else {
|
||||
nlog("MP seg ${upcoming.segIdx} auto-chained")
|
||||
}
|
||||
|
||||
current = arm(upcoming, nextMp)
|
||||
try { onSegmentPlaying?.invoke(upcoming.sentence, upcoming.durationMs, upcoming.rmsEnvelope, upcoming.spectrogram) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
// Drain: wait for the last prepared player to finish.
|
||||
if (current != null && currentInfo != null) {
|
||||
waitForPlaybackCompletion(current!!, currentInfo!!.segIdx)
|
||||
try { java.io.File(currentInfo!!.wavPath).delete() } catch (_: Exception) {}
|
||||
}
|
||||
// Drain: wait for the last player to finish.
|
||||
current?.done?.await()
|
||||
current?.let { try { java.io.File(it.info.wavPath).delete() } catch (_: Exception) {} }
|
||||
} catch (e: Exception) {
|
||||
nlog("MP playback chain error: ${e.message}")
|
||||
} finally {
|
||||
try { next?.release() } catch (_: Exception) {}
|
||||
try { current?.release() } catch (_: Exception) {}
|
||||
try { current?.mp?.release() } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3673,24 +3709,6 @@ class Qwen3TtsEngine(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForPlaybackCompletion(
|
||||
mp: android.media.MediaPlayer, segIdx: Int
|
||||
) {
|
||||
val t0 = System.currentTimeMillis()
|
||||
kotlinx.coroutines.suspendCancellableCoroutine<Unit> { cont ->
|
||||
mp.setOnCompletionListener {
|
||||
nlog("MP seg $segIdx completed (${System.currentTimeMillis() - t0}ms)")
|
||||
if (cont.isActive) cont.resume(Unit) {}
|
||||
}
|
||||
mp.setOnErrorListener { _, what, extra ->
|
||||
nlog("MP seg $segIdx play error: what=$what extra=$extra")
|
||||
if (cont.isActive) cont.resume(Unit) {}
|
||||
true
|
||||
}
|
||||
cont.invokeOnCancellation { /* player released by caller */ }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun endStreamingSessionMp() {
|
||||
val chan = sessionMpQueue ?: return
|
||||
chan.close()
|
||||
|
|
|
|||
|
|
@ -379,9 +379,9 @@ class AudioVisualizerView @JvmOverloads constructor(
|
|||
val timeIdx = timeIdxF.toInt().coerceIn(0, s.spectrogram.size - 1)
|
||||
val timeFrac = (timeIdxF - timeIdx).coerceIn(0f, 1f)
|
||||
|
||||
// Smoothly interpolate between adjacent spectrogram columns,
|
||||
// and exponentially smooth toward the target to keep bars
|
||||
// fluid even at 60 fps with 20 fps spectrogram data.
|
||||
// Smoothly interpolate between adjacent spectrogram columns and
|
||||
// exponentially smooth toward the target so the curves flow
|
||||
// even at 60 fps with 20 fps spectrogram data.
|
||||
for (b in 0 until nBands) {
|
||||
val a = s.spectrogram[timeIdx][b]
|
||||
val c = s.spectrogram[min(timeIdx + 1, s.spectrogram.size - 1)][b]
|
||||
|
|
@ -389,39 +389,107 @@ class AudioVisualizerView @JvmOverloads constructor(
|
|||
smoothedBars[b] += (target - smoothedBars[b]) * 0.35f
|
||||
}
|
||||
|
||||
// Bars fill the bottom ~75% of the sphere diameter. Each bar is
|
||||
// a rounded rectangle rising from a horizontal baseline at
|
||||
// ~60% of the sphere height (slightly below center — feels more
|
||||
// natural like a real EQ).
|
||||
val spanW = radius * 1.55f
|
||||
val gap = spanW / nBands * 0.25f
|
||||
val barW = (spanW - gap * (nBands - 1)) / nBands
|
||||
// Draw 3 superimposed waveform lines centred on the sphere
|
||||
// horizontal axis. Each line is a smooth Bézier curve whose
|
||||
// vertical displacement is driven by the spectrogram plus a
|
||||
// slow-moving phase offset per line (for depth, so the lines
|
||||
// don't lock-step). Top and bottom mirrors around the center
|
||||
// keep the shape symmetric like a real oscilloscope trace.
|
||||
val spanW = radius * 1.75f
|
||||
val leftX = cx - spanW / 2f
|
||||
val baseline = cy + radius * 0.60f
|
||||
val maxBarH = radius * 1.20f
|
||||
val cornerR = barW * 0.45f
|
||||
val baseline = cy
|
||||
val maxDisp = radius * 0.55f
|
||||
|
||||
for (b in 0 until nBands) {
|
||||
val v = smoothedBars[b].coerceIn(0f, 1f)
|
||||
// Mirror the bands around the center so low bass is in the
|
||||
// middle, highs on the edges — visually centred.
|
||||
val displayIdx = if (b % 2 == 0) nBands / 2 + b / 2 else nBands / 2 - 1 - b / 2
|
||||
val x = leftX + displayIdx * (barW + gap)
|
||||
val barH = maxBarH * v
|
||||
// Color gradient: brighter toward the top.
|
||||
barPaint.color = withAlpha(brighten(currentAccent, 0.3f + 0.4f * v),
|
||||
(180 + 70 * v).toInt().coerceIn(0, 255))
|
||||
canvas.drawRoundRect(
|
||||
x, baseline - barH,
|
||||
x + barW, baseline,
|
||||
cornerR, cornerR, barPaint
|
||||
)
|
||||
// nPoints samples along the horizontal — more than the 12
|
||||
// spectrogram bands so interpolation renders smoothly.
|
||||
val nPoints = 36
|
||||
val tSec = System.currentTimeMillis() / 1000f
|
||||
|
||||
drawWaveformLine(canvas, leftX, baseline, spanW, maxDisp, nPoints,
|
||||
phase = 0f, gain = 1.00f, thickness = 4.0f,
|
||||
color = withAlpha(currentAccent, 230), tSec = tSec)
|
||||
drawWaveformLine(canvas, leftX, baseline, spanW, maxDisp, nPoints,
|
||||
phase = 0.18f, gain = 0.78f, thickness = 2.8f,
|
||||
color = withAlpha(brighten(currentAccent, 0.25f), 170), tSec = tSec)
|
||||
drawWaveformLine(canvas, leftX, baseline, spanW, maxDisp, nPoints,
|
||||
phase = -0.14f, gain = 0.55f, thickness = 1.8f,
|
||||
color = withAlpha(darken(currentAccent, 0.15f), 130), tSec = tSec)
|
||||
|
||||
// Horizontal hairline so silence still hints at a live surface.
|
||||
barPaint.style = Paint.Style.STROKE
|
||||
barPaint.strokeWidth = 1.2f
|
||||
barPaint.color = withAlpha(currentAccent, 60)
|
||||
canvas.drawLine(leftX, baseline, leftX + spanW, baseline, barPaint)
|
||||
barPaint.style = Paint.Style.FILL_AND_STROKE
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample the smoothed spectrogram across [nPoints] positions and
|
||||
* draw a Bézier-smoothed "deforming line" through them, mirrored
|
||||
* above and below the baseline. Displacement = band energy × gain,
|
||||
* with a small moving phase offset so overlaid lines stay visually
|
||||
* distinct. Uses a quadratic midpoint-to-midpoint curve — cheaper
|
||||
* and more organic than Catmull-Rom for this band count.
|
||||
*/
|
||||
private fun drawWaveformLine(
|
||||
canvas: Canvas,
|
||||
leftX: Float, baseline: Float, spanW: Float,
|
||||
maxDisp: Float, nPoints: Int,
|
||||
phase: Float, gain: Float, thickness: Float,
|
||||
color: Int, tSec: Float
|
||||
) {
|
||||
val xs = FloatArray(nPoints)
|
||||
val disps = FloatArray(nPoints)
|
||||
for (i in 0 until nPoints) {
|
||||
val u = i.toFloat() / (nPoints - 1)
|
||||
xs[i] = leftX + u * spanW
|
||||
// Map x position → band index. Windowed by a soft cosine
|
||||
// taper so curve edges decay to zero at the sphere's
|
||||
// left/right endpoints, matching the circular mask.
|
||||
val bandF = u * (SPECTRUM_BANDS - 1) + phase * SPECTRUM_BANDS * 0.1f
|
||||
val bandA = bandF.toInt().coerceIn(0, SPECTRUM_BANDS - 1)
|
||||
val bandB = (bandA + 1).coerceAtMost(SPECTRUM_BANDS - 1)
|
||||
val frac = (bandF - bandA).coerceIn(0f, 1f)
|
||||
val v = lerp(smoothedBars[bandA], smoothedBars[bandB], frac)
|
||||
val taper = 0.5f - 0.5f * cos((u * PI).toFloat()) // 0 at edges, 1 at center
|
||||
// Small sinusoidal jitter over time to keep the line alive
|
||||
// during quieter passages — amplitude scales with the
|
||||
// smoothed RMS so silence stays flat.
|
||||
val jitter = sin((tSec * 2.5f + i * 0.35f + phase * 6f).toDouble()).toFloat() *
|
||||
0.10f * smoothedAmp
|
||||
disps[i] = (v + jitter) * taper * gain
|
||||
}
|
||||
|
||||
// Soft horizontal baseline (thin line) so silent bars still
|
||||
// hint at a spectrometer rather than an empty circle.
|
||||
barPaint.color = withAlpha(currentAccent, 90)
|
||||
canvas.drawRect(leftX, baseline - 1.2f, leftX + spanW, baseline + 1.2f, barPaint)
|
||||
// Build path: top curve baseline - disp, bottom curve baseline + disp.
|
||||
ringPaint.style = Paint.Style.STROKE
|
||||
ringPaint.strokeWidth = thickness
|
||||
ringPaint.color = color
|
||||
ringPaint.strokeCap = Paint.Cap.ROUND
|
||||
ringPaint.strokeJoin = Paint.Join.ROUND
|
||||
|
||||
// Top line (above baseline).
|
||||
blobPath.rewind()
|
||||
blobPath.moveTo(xs[0], baseline - maxDisp * disps[0])
|
||||
for (i in 1 until nPoints - 1) {
|
||||
val xMid = (xs[i] + xs[i + 1]) * 0.5f
|
||||
val yI = baseline - maxDisp * disps[i]
|
||||
val yMid = baseline - maxDisp * (disps[i] + disps[i + 1]) * 0.5f
|
||||
blobPath.quadTo(xs[i], yI, xMid, yMid)
|
||||
}
|
||||
blobPath.lineTo(xs.last(), baseline - maxDisp * disps.last())
|
||||
canvas.drawPath(blobPath, ringPaint)
|
||||
|
||||
// Bottom line (below baseline, mirrored).
|
||||
blobPath.rewind()
|
||||
blobPath.moveTo(xs[0], baseline + maxDisp * disps[0])
|
||||
for (i in 1 until nPoints - 1) {
|
||||
val xMid = (xs[i] + xs[i + 1]) * 0.5f
|
||||
val yI = baseline + maxDisp * disps[i]
|
||||
val yMid = baseline + maxDisp * (disps[i] + disps[i + 1]) * 0.5f
|
||||
blobPath.quadTo(xs[i], yI, xMid, yMid)
|
||||
}
|
||||
blobPath.lineTo(xs.last(), baseline + maxDisp * disps.last())
|
||||
canvas.drawPath(blobPath, ringPaint)
|
||||
}
|
||||
|
||||
// ---------- Helpers: halo / ripples / blob ----------
|
||||
|
|
|
|||
Loading…
Reference in New Issue