diff --git a/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt b/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt index dba958a..a26d6ae 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt @@ -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 + ) + + fun arm(info: SegmentReady, mp: android.media.MediaPlayer): Live { + val done = kotlinx.coroutines.CompletableDeferred() + 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 { 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() diff --git a/kazeia-android/app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt b/kazeia-android/app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt index 3935088..e5ffee9 100644 --- a/kazeia-android/app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt +++ b/kazeia-android/app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt @@ -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 ----------