From b5b13780f787437582058feb9af075c7774b6fb8 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Tue, 14 Apr 2026 23:47:30 +0200 Subject: [PATCH] UI: whole-sphere Fourier-mode deformation during speech MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped the internal waveform lines — not what we wanted visually — and replaced them with a spectrum-driven deformation of the sphere outline itself. Each of the 12 log-spaced bands drives one Fourier mode of the perimeter (band b → mode b + 2, so modes 0/1 stay circular and higher bands produce tighter ripples). Low bands pull the shape into wide asymmetric bumps that feel like formants; high bands add quick sibilant-like tremors. Phase advances faster for higher modes so tight ripples visually match high-frequency content. Overall displacement is gated by the RMS envelope so silence is quiet and loud syllables distort strongly. Fill + highlight are clipped to the deformed path so the gradient follows the shape and it reads as a single living object rather than a circle with stuff bolted on. Removed drawSpectrumBars and drawWaveformLine. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/kazeia/ui/AudioVisualizerView.kt | 212 +++++++----------- 1 file changed, 80 insertions(+), 132 deletions(-) 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 e5ffee9..2047462 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 @@ -317,7 +317,7 @@ class AudioVisualizerView @JvmOverloads constructor( private fun drawSpeaking( canvas: Canvas, cx: Float, cy: Float, maxR: Float, now: Long, s: State.Speaking ) { - // Envelope → orb size pulsation, spectrogram → bars inside. + // Envelope → overall size pulsation + halo intensity. val elapsed = now - s.startedAtMs val envIdxF = elapsed.toFloat() * s.envelope.size / s.durationMs val envIdx = envIdxF.toInt().coerceIn(0, s.envelope.size - 1) @@ -328,12 +328,25 @@ class AudioVisualizerView @JvmOverloads constructor( envFrac ) smoothedAmp += (env - smoothedAmp) * 0.30f - val scale = 0.92f + 0.16f * smoothedAmp + + // Update per-band smoothed energies — these drive the Fourier + // modes of the sphere outline in buildSpeakingBlobPath below. + val timeIdxF = elapsed.toFloat() * s.spectrogram.size / s.durationMs + val timeIdx = timeIdxF.toInt().coerceIn(0, s.spectrogram.size - 1) + val timeFrac = (timeIdxF - timeIdx).coerceIn(0f, 1f) + for (b in 0 until SPECTRUM_BANDS) { + val a = s.spectrogram[timeIdx][b] + val c = s.spectrogram[min(timeIdx + 1, s.spectrogram.size - 1)][b] + val target = lerp(a, c, timeFrac) + smoothedBars[b] += (target - smoothedBars[b]) * 0.35f + } + + val scale = 0.92f + 0.14f * smoothedAmp val radius = maxR * scale - // Halo pulses with amp; emit ripples on peaks. - drawHalo(canvas, cx, cy, maxR * 1.25f * scale, - alphaBase = 80, alphaGain = (140 * smoothedAmp).toInt().coerceIn(0, 200)) + // Halo pulses with amp; emit ripples on envelope peaks. + drawHalo(canvas, cx, cy, maxR * 1.30f * scale, + alphaBase = 90, alphaGain = (160 * smoothedAmp).toInt().coerceIn(0, 220)) if (envIdx != lastSpectroIdx && env > 0.45f) { val prev = if (envIdx > 0) s.envelope[envIdx - 1] else 0f @@ -345,153 +358,88 @@ class AudioVisualizerView @JvmOverloads constructor( } drawRipples(canvas, cx, cy, maxR, now, listeningMode = false) - // Sphere body — pure circle here, serves as the container for - // the spectrum bars. - spherePath.rewind() - spherePath.addCircle(cx, cy, radius, Path.Direction.CW) + // The sphere outline IS the spectrometer: each spectrogram band + // drives one Fourier mode of the perimeter (low bands = wide + // low-mode bumps, high bands = tight high-mode ripples), so the + // whole shape distorts in response to the voice content. No + // internal bars or curves — the sphere itself is what speaks. + buildSpeakingBlobPath(spherePath, cx, cy, radius, now) + // Fill the deformed sphere with the voice-tinted gradient. corePaint.shader = RadialGradient( - cx - radius * 0.25f, cy - radius * 0.30f, radius * 1.2f, + cx - radius * 0.25f, cy - radius * 0.30f, radius * 1.25f, currentCore, deriveCoreEdge(currentCore), Shader.TileMode.CLAMP ) canvas.drawPath(spherePath, corePaint) - // Spectrum bars, clipped to the sphere so they appear *inside*. + // Soft top-left highlight clipped to the deformed shape — lends + // a subtle "3D glassy" read without being distracting. canvas.save() canvas.clipPath(spherePath) - drawSpectrumBars(canvas, cx, cy, radius, s, elapsed) + val hl = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + shader = RadialGradient( + cx - radius * 0.28f, cy - radius * 0.30f, radius * 0.9f, + Color.argb(75, 255, 255, 255), + Color.argb(0, 255, 255, 255), + Shader.TileMode.CLAMP + ) + } + canvas.drawCircle(cx, cy, radius * 1.2f, hl) canvas.restore() - // Outline ring on top so the sphere edge stays crisp after bar - // clipping. - blobOutlinePaint.strokeWidth = 2f + 3f * smoothedAmp - blobOutlinePaint.color = withAlpha(currentAccent, 220) - canvas.drawCircle(cx, cy, radius, blobOutlinePaint) - } - - private fun drawSpectrumBars( - canvas: Canvas, cx: Float, cy: Float, radius: Float, - s: State.Speaking, elapsed: Long - ) { - val nBands = SPECTRUM_BANDS - val timeIdxF = elapsed.toFloat() * s.spectrogram.size / s.durationMs - 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 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] - val target = lerp(a, c, timeFrac) - smoothedBars[b] += (target - smoothedBars[b]) * 0.35f - } - - // 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 - val maxDisp = radius * 0.55f - - // 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 + // Outline of the deformed shape on top, thickness tracks amp so + // loud consonants give a stronger line. + blobOutlinePaint.strokeWidth = 2.5f + 3.5f * smoothedAmp + blobOutlinePaint.color = withAlpha(currentAccent, 230) + canvas.drawPath(spherePath, blobOutlinePaint) } /** - * 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. + * Build the speaking-state sphere perimeter: base circle plus a + * sum of Fourier modes, one per spectrogram band. Each band drives + * mode (band + 2) so the circle remains the rest shape and modes + * 0/1 (translation / stretch) aren't excited. Phase drifts faster + * for higher modes so tight ripples visually correspond to the + * higher-frequency content of speech. Deformation amplitude is + * scaled both by per-band energy and by overall envelope so quiet + * passages show small motion and loud syllables show strong + * distortion. Sampled at 96 points — smooth enough for the + * highest mode we render without being expensive. */ - 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 + private fun buildSpeakingBlobPath( + path: Path, cx: Float, cy: Float, radius: Float, now: Long ) { - 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 - } + path.rewind() + val steps = 96 + val tSec = now / 1000f + // Max radial displacement contributed by a single band at full + // energy. 0.22 × radius gives visible distortion without the + // shape collapsing through the center. + val modeGain = radius * 0.22f + // Envelope weight — quiet passages feel less jittery. + val envWeight = (0.5f + 0.5f * smoothedAmp).coerceIn(0f, 1f) - // 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) + for (i in 0..steps) { + val theta = (i % steps).toFloat() / steps * 2f * PI.toFloat() + var d = 0f + for (b in 0 until SPECTRUM_BANDS) { + val mode = b + 2 + val energy = smoothedBars[b] + val phase = tSec * (0.45f + 0.22f * b) + d += modeGain * energy * envWeight * + sin((mode * theta + phase).toDouble()).toFloat() + } + val r = radius + d + val x = cx + r * cos(theta.toDouble()).toFloat() + val y = cy + r * sin(theta.toDouble()).toFloat() + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) } - 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) + path.close() } + // ---------- Helpers: halo / ripples / blob ---------- private fun drawHalo( canvas: Canvas, cx: Float, cy: Float, r: Float,