fix(music): synthetic waveform now actually varies per track

The previous version drew envelope and carrier as pure functions of the
sample position — so 60% of every bar was identical across all tracks and
only the per-bar jitter (40% weight, narrow range) wiggled. Every track
ended up with the same slow-fade-sine silhouette and only the noise
pattern differed, which looked like every waveform was the same shape.

Now every shape parameter — phase offset, carrier frequency, baseline,
envelope strength, noise envelope — is drawn from the seeded RNG before
the per-bar loop. Two different track ids produce visibly different
waveforms: some have many tight bars, some few wide ones, some fade in
and out, others stay flat. The seed is also bit-shuffled with a Knuth
multiplicative constant XOR'd with the id length so two ids whose Java
hashCode happens to collide still diverge.

ExoPlayer still owns playback progress; the bars are purely decorative.
This commit is contained in:
Claude
2026-05-27 14:12:01 +00:00
parent 5f2f70b0bf
commit e510fcceca
@@ -374,27 +374,41 @@ private fun formatDuration(seconds: Int): String {
}
private const val SYNTHETIC_WAVEFORM_SAMPLES = 96
private const val TWO_PI = (Math.PI * 2).toFloat()
/**
* Builds a stable, decorative waveform from the track id. We just need something more
* interesting to look at than a flat line; ExoPlayer continues to drive actual playback
* progress. Seeded so each track's bars stay constant across recompositions and screen
* re-entries, and shaped with two overlaid sines so the result reads as "music waveform"
* rather than pure noise.
* Builds a stable, decorative waveform from the track id. ExoPlayer drives actual playback
* progress — these bars only exist so the audio player has the familiar "music silhouette"
* shape rather than a flat strip when the spec has no `waveform` tag.
*
* Every shape parameter (phase offset, carrier frequency, baseline, envelope skew, jitter
* envelope) is drawn from the seeded RNG before the per-bar loop, so two different track ids
* produce visibly different waveforms — earlier versions used a fixed envelope + carrier that
* only varied by per-bar noise, which made every track look like the same slow-fade sine with
* minor wiggle.
*/
private fun syntheticWaveformFor(seed: String): com.vitorpamplona.amethyst.service.playback.composable.WaveformData {
val rng = kotlin.random.Random(seed.hashCode())
// Fold the seed's 32-bit hash into a Long so two ids whose hashCode happens to collide
// (rare for hex addresses but cheap to defend against) still differ via the bit shuffle.
val rng = kotlin.random.Random((seed.hashCode().toLong() * 0x9E3779B97F4A7C15uL.toLong()) xor seed.length.toLong())
// Shape parameters drawn once per track — these are what makes track A look different
// from track B at a glance.
val phaseOffset = rng.nextFloat() * TWO_PI
val carrierCycles = 2.5f + rng.nextFloat() * 5.5f // 2.58 full cycles across the strip
val carrierWeight = 0.18f + rng.nextFloat() * 0.18f // 0.180.36
val baseline = 0.28f + rng.nextFloat() * 0.22f // 0.280.50
val envelopeStrength = rng.nextFloat() * 0.45f // 0.00.45; some tracks fade in/out, others don't
val noiseStrength = 0.18f + rng.nextFloat() * 0.22f // 0.180.40
val bars =
List(SYNTHETIC_WAVEFORM_SAMPLES) { index ->
val phase = index.toFloat() / SYNTHETIC_WAVEFORM_SAMPLES
val envelope =
kotlin.math
.sin(phase * Math.PI)
.toFloat()
.coerceAtLeast(0.25f)
val carrier = (kotlin.math.sin(phase * Math.PI * 8) * 0.25f + 0.5f).toFloat()
val jitter = rng.nextFloat() * 0.35f + 0.15f
(envelope * (carrier * 0.6f + jitter * 0.4f)).coerceIn(0.05f, 1.0f)
// Optional gentle fade so some tracks taper at the ends, others stay even.
val envelope = 1f - envelopeStrength * (1f - kotlin.math.sin(phase * Math.PI).toFloat())
val carrier = kotlin.math.sin(phase * TWO_PI * carrierCycles + phaseOffset) * carrierWeight
val noise = (rng.nextFloat() - 0.5f) * 2f * noiseStrength
((baseline + carrier + noise) * envelope).coerceIn(0.05f, 1.0f)
}
return com.vitorpamplona.amethyst.service.playback.composable
.WaveformData(bars)