UI: whole-sphere Fourier-mode deformation during speech
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) <noreply@anthropic.com>
This commit is contained in:
parent
2fe46e0f15
commit
b5b13780f7
|
|
@ -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,152 +358,87 @@ 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)
|
||||
|
||||
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)
|
||||
}
|
||||
path.close()
|
||||
}
|
||||
|
||||
// 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 ----------
|
||||
private fun drawHalo(
|
||||
|
|
|
|||
Loading…
Reference in New Issue