How UniAudio generates music + image
UniAudio is ERC-20 + Uniswap v4 hook + on-chain generative-music NFT. Every swap mutates a rolling random seed in the hook. When a buyer's uAUD balance crosses a whole-unit boundary, the token mints a fresh uAUD NFT carrying a derived 256-bit seed. That seed feeds two non-overlapping decoders — a music engine off-chain and an SVG generator on-chain — to produce a piece that is unique, deterministic, and tied to the swap that minted it.
Every assertion below about on-chain behavior carries a ⟨src⟩ path:lines :: symbol chip pointing into the Solidity. Clone the repo and grep the path — no trust in this page required.
contracts/src/AudioToken.sol— ERC-20 + uAUD NFT accounting + tokenURIcontracts/src/AudioHook.sol— V4 hook callbacks + rolling random seedcontracts/src/svg/AudioMetadata.sol— bit decoder for the visual half of the seedcontracts/src/svg/SvgGenerator.sol— palette, layer storage, render pipelinecontracts/src/svg/SvgRects.sol— pixel-art Rect type +toSvg
1. Token + hook (ERC-404 lite)
Two contracts: AudioToken (ERC-20 with per-whole-unituAUD NFTs) and AudioHook (V4 hook + on-chain SVG generator). Modeled on the Unipeg / UpegHook reference deployed on Ethereum mainnet — uAUD is our renamed branding of that pattern.⟨src⟩ contracts/src/AudioToken.sol :: contract AudioToken⟨src⟩ contracts/src/AudioHook.sol :: contract AudioHook
swap on AUDIO/X pool ─► PoolManager ─► AudioHook.afterSwap
│
└─ randomSeed = keccak(seedTicks, prev,
blockhash, prevrandao,
block.number, timestamp,
sender, amountSpecified,
delta0, delta1,
tick, sqrtPriceX96)
router → buyer ERC-20 transfer
│
└─► AudioToken._update
│ if balanceAfter / 1e18 > balanceBefore / 1e18:
│ hookSeed = AudioHook.randomSeed()
│ seed = keccak(hookSeed, upegId, buyer, block.number)
│ mint uAUD #N to buyer
│ symmetric burn on outflow
▼
AudioToken.tokenURI(N) → JSON { image: SVG, attributes: [seed] }⟨src⟩ contracts/src/AudioHook.sol:108-143 :: _afterSwap⟨src⟩ contracts/src/AudioToken.sol:171-197 :: _update⟨src⟩ contracts/src/AudioToken.sol:201-217 :: _mintUAUD⟨src⟩ contracts/src/AudioToken.sol:124-143 :: tokenURI- 1 whole
uAUDtoken ↔ 1uAUDNFT. Crossing a1 etherboundary on transfer mints/burns.⟨src⟩ contracts/src/AudioToken.sol:171-197 :: _update⟨src⟩ contracts/src/AudioToken.sol:30 :: UNITS = 1 ether - Excluded addresses (PoolManager, hook, routers, treasury) skip the auto mint loop — gas-bomb otherwise.
⟨src⟩ contracts/src/AudioToken.sol:65-70 :: constructor exclusions⟨src⟩ contracts/src/AudioToken.sol:98-106 :: setUAUDExcluded - Hook permission flags:
AFTER_ADD_LIQUIDITY | AFTER_SWAP = 0x440. Address mined via CREATE2.⟨src⟩ contracts/src/AudioHook.sol:72-89 :: getHookPermissions transferUAUD(to, id)moves1e18 + a specific uAUD NFT idatomically.⟨src⟩ contracts/src/AudioToken.sol:152-165 :: transferUAUD- The hook IS the SVG generator (10 layer slots, 36-color palette, 24×24 canvas) — single contract, no separate image module.
⟨src⟩ contracts/src/AudioHook.sol:32 :: contract AudioHook is BaseHook, SvgGenerator, ...
2. One seed, two consumers
The 256-bit seed feeds two non-overlapping decoders — they share entropy but never alias.
| Bits | Consumer | Decoder |
|---|---|---|
| 0..81 | Music engine (off-chain, Tone.js) | parseGenome⟨src⟩ web/src/lib/engine/genome.ts:51-102 :: parseGenome |
| 82..95 | Reserved (no consumer) | — |
| 96..247 | SVG generator (on-chain) | AudioMetadataLibrary.decode⟨src⟩ contracts/src/svg/AudioMetadata.sol:35-56 :: decode |
| 248..255 | Reserved (no consumer) | — |
Do not change either decoder post-launch — existing seeds would render different art / play different music. The on-chain decoder is frozen by deployment; the off-chain decoder is frozen by audit (TS port mirrors Solidity byte-for-byte).⟨src⟩ web/src/lib/engine/audioMetadata.ts :: decodeAudioMetadata (TS port)
3. Constrained random — music engine
Pure note-by-note randomness sounds like noise. The trick is that each decision layer can only pick from a table the music theory has already vouched for. The deeper you go (down to individual notes and drum hits), the tighter the constraints get.
Genre → Key+Mode → Tempo+Form → Progression → Bass → Melody → Drums → Timbre → FX (broad) (narrow)
Every layer eats a few bits of the 256-bit seed. Genre is decided first, then it gates everything below — e.g. Lo-fi cannot use the Lydian mode and cannot land on 130 BPM.
4. Music engine bitfield (bits 0..81) — off-chain
grep -rn 'genre\|bpm\|reverb\|delay\|melody\|drumPattern' contracts/src returns zero hits. The contract stores the seed as an opaque uint256 and the FE music engine (parseGenome + Tone.js, in the browser) is the only consumer of bits 0..81.⟨src⟩ contracts/src/AudioToken.sol:211 :: seedOfUAUD[id] = seed⟨src⟩ web/src/lib/engine/genome.ts:51-102 :: parseGenomeThat is why marketplaces like OpenSea show no Genre / BPM / Reverb attributes — only the SVG
image and the raw seed. The bit map below is purely a frontend convention; the on-chain decoder lives in section 7.parseGenome(hex) slices the low 82 bits of the seed into named fields:
| Bits | Field | Notes |
|---|---|---|
| 0–3 | genreIdx | 0..3 → 4 genres |
| 4–7 | rootNote | 0..11 → C..B |
| 8–10 | modeIdx | filtered through genre.modeFilter |
| 11–13 | bpmIdx | index into genre.bpms |
| 20–27 | progressionIdx | filtered through genre.progressionFilter |
| 28–30 | bassStyle | 0..3 → pedal/octave/fifth/arp |
| 31–38 | motifIdx | index into MOTIF_RHYTHMS |
| 39–62 | melodyVar | seed for mulberry32 PRNG |
| 55–58 | drumPatternIdx | -1 = no drums (Ambient) |
| 59–63 | leadPatchIdx | filtered through genre.leadPatches |
| 64–68 | padPatchIdx | filtered through genre.padPatches |
| 69–73 | bassPatchIdx | filtered through genre.bassPatches |
| 74–77 | reverbAmount | 0..15 → 0..1 |
| 78–81 | delayAmount | 0..15 → 0..1 |
5. The 8 inviolable laws
This rule set is what keeps the output from collapsing into noise. Each law has a concrete music-theory reason behind it.
- Genre gates every layer below — genre is decided first; mode, BPM, drums, patches, and progression all filter through it
- Every note lives in the scale — chromatic passing tones are allowed only on the weak 16th-note slots
- Downbeat = chord tone — slot 0 (beat 1) and slot 8 (beat 3) MUST be the root/3rd/5th of the chord — anti-atonal anchor
- Progressions come from a hardcoded palette — I-V-vi-IV, ii-V-I, Andalusian… chords are never picked at random
- Voice leading ≤ 2 semitones — pad voicing in close root position; common tones held between chords
- Cadence rule — last note of bar 8 → chord root; last note of bar 16 → tonic. Phrases must close
- Motif loop + variation — one rhythmic pattern persists through the section; melody is never built from scratch every bar
- Patches & drum patterns are tuned presets — 32 lead/pad/bass patches, 5 drum patterns — synth params are never randomized
6. Per-layer details
Genre (4 bit)
Four genres ship today: Lo-fi Hip Hop · House · Ambient · Synthwave. Each genre is a schema that gates everything below it — BPM range, allowed modes, drum kit, synth patches, progression palette.
Mode (3 bit)
Eight 7-note modes: Ionian, Dorian, Phrygian, Lydian, Mixolydian, Aeolian, Harmonic Minor, Melodic Minor. Pentatonic / blues are excluded because stacking thirds for chords behaves inconsistently on a 5-note scale.
Progression (8 bit)
15 progression palette in three groups. Chords are never random:
- Major-flavor: I–V–vi–IV (axis), I–IV–V–I, I–vi–IV–V (50s), ii–V–I–vi (jazz), Pachelbel 8-chord, …
- Minor-flavor: i–VI–III–VII (pop minor), i–VII–VI–V (Andalusian), i–iv–V–i, …
- Modal vamps: Dorian i–IV, Mixolydian I–VII, …
Melody — the heart of the engine
Each 4/4 bar = 16 sixteenth-note slots. Each slot has its own pitch rule:
| Slot | Role | Pitch pool |
|---|---|---|
| 0 | Downbeat (beat 1) | chord tone (root/3rd/5th) |
| 8 | Beat 3 (medium-strong) | chord tone |
| 4, 12 | Beats 2 / 4 | 65% chord tone, 35% scale tone |
| 2, 6, 10, 14 | Off-8 | scale tone, step from prev |
| odd | 16th | passing/neighbor (step only) |
Cadence rule: the last note of bar 8 → root of the chord currently sounding; the last note of bar 16 → tonic of the scale. Without cadence the music feels "unresolved" — tiring to listen to on loop.
7. On-chain SVG (bits 96..247)
The cover image is generated fully on-chain inside the hook. Three deterministic steps: bit-slice the high half of the seed into 19 fields, pick a layer variant + color for each, render in a fixed z-order.⟨src⟩ contracts/src/svg/AudioMetadata.sol:35-56 :: decode⟨src⟩ contracts/src/svg/SvgGenerator.sol:144-192 :: generateSvg
| Bits | Field | Notes |
|---|---|---|
| 96–103 | backGroundColor | % 6 → background palette |
| 104–111 | frame | % frameCount → variant id (also feeds spectrum) |
| 112–119 | frameColor | % 36 → primary palette (also feeds spectrum) |
| 120–127 | wave | % waveCount |
| 128–135 | waveColor | % 36 |
| 136–143 | motif | % motifCount |
| 144–151 | motifColor | % 36 |
| 152–159 | lead | % leadCount |
| 160–167 | leadColor | % 36 |
| 168–175 | pad | % padCount |
| 176–183 | padColor | % 36 |
| 184–191 | bass | % bassCount |
| 192–199 | bassColor | % 36 |
| 200–207 | drum | % drumCount |
| 208–215 | drumColor | % 36 |
| 216–223 | fx | % fxCount |
| 224–231 | fxColor | % 36 |
| 232–239 | grid | % gridCount |
| 240–247 | gridColor | % 36 |
seed (256 bit)
│
│ (1) AudioMetadataLibrary.decode(seed) — slice bits 96..247
▼
{ backGroundColor, frame, frameColor, wave, waveColor,
motif, motifColor, lead, leadColor, pad, padColor,
bass, bassColor, drum, drumColor, fx, fxColor,
grid, gridColor } — 19 × uint8
│ (2) per layer:
│ id = (field % count) + 1
│ color = colors[colorField % 36]
▼
Rect[] storage → toSvg(color) — concat <rect> elements
│ (3) z-order:
│ bg → grid → spectrum → frame → pad → bass → drum
│ → motif → lead → wave → fx
▼
<svg viewBox='0 0 24 24'> ... </svg>⟨src⟩ contracts/src/svg/SvgGenerator.sol:144-192 :: generateSvg⟨src⟩ contracts/src/svg/SvgGenerator.sol:160-189 :: layer z-order⟨src⟩ contracts/src/svg/SvgGenerator.sol:196-198 :: _color (idx % 36)⟨src⟩ contracts/src/svg/SvgRects.sol:14-43 :: toSvgPalette + canvas
- 24×24 shape-rendering="crispEdges" canvas — pixel-art friendly.
⟨src⟩ contracts/src/svg/SvgGenerator.sol:59 :: SVG_SIZE = 24 - 36-color primary palette + 6-color background palette, frozen at deploy.
⟨src⟩ contracts/src/svg/SvgGenerator.sol:63-70 :: _colors[36]⟨src⟩ contracts/src/svg/SvgGenerator.sol:72-73 :: _backgroundColors[6] - 10 layer slots: frame · wave · motif · lead · pad · bass · drum · fx · grid · spectrum. Each slot is a
mapping(id ⇒ Rect[])populated by the owner viasetFrame(...)/setWave(...)/ etc.⟨src⟩ contracts/src/svg/SvgGenerator.sol:76-85 :: layer mappings⟨src⟩ contracts/src/svg/SvgGenerator.sol:102-113 :: setFrame / setWave / ... - If a layer has zero uploaded variants, it's silently skipped — the SVG is still valid, just simpler.
⟨src⟩ contracts/src/svg/SvgGenerator.sol:160-189 :: if (...Count > 0) - The hook IS the generator: there is no separate image contract.
⟨src⟩ contracts/src/AudioHook.sol:32 :: contract AudioHook is BaseHook, SvgGenerator, ...
Combinatorics
With 4 variants per layer and the full palette, the artwork space is6 × 4¹⁰ × 36⁹ ≈ 6.4 × 10²⁰ distinct pieces (10 layers × 4 variants, 9 layer colors × 36 palette, 6 backgrounds; spectrum reuses the frame field and color so it isn't counted separately). Real-world uniqueness is bounded by mint count (10,000), not combination space — no two minted uAUD will visually collide in practice.
8. Determinism contract
Same seed + same on-chain storage → same SVG, byte-for-byte. To preserve this contract:
- Don't change the decoder — bit layout must freeze post-launch.
⟨src⟩ contracts/src/svg/AudioMetadata.sol:35-56 :: decode⟨src⟩ web/src/lib/engine/audioMetadata.ts :: decodeAudioMetadata (TS mirror) - Don't change the palette — color indices would map to different hex strings.
⟨src⟩ contracts/src/svg/SvgGenerator.sol:63-73 :: _colors / _backgroundColors⟨src⟩ web/src/lib/engine/audioPalette.ts :: COLORS / BACKGROUND_COLORS (TS mirror) - Don't change the z-order — render order is part of the contract.
⟨src⟩ contracts/src/svg/SvgGenerator.sol:144-192 :: generateSvg⟨src⟩ web/src/lib/engine/audioSvg.ts :: renderAudioSvgFromMetadata (TS mirror) - Lock variant counts before first mint. Adding a variant changes
(field % count)retroactively for old uAUD.⟨src⟩ contracts/src/svg/SvgGenerator.sol:160-189 :: (m.field % count) + 1 - Bits 248..255 are reserved. The decoder stops at bit 247 — leave room for future fields.
⟨src⟩ contracts/src/svg/AudioMetadata.sol:36-56 :: seed >> 96; 19 × uint8
Off-chain music engine determinism is independent: the PRNG is mulberry32(melodyVar) and the only side-channel is AudioContext sample-rate (timing). There is no Solidity counterpart to cross-check against — bits 0..81 are FE-only. Visual cross-engine parity (TS port ↔ Solidity) can be eyeballed by running forge test --match-path test/Render.t.sol and comparing svg-out/sample-*.svg against the same seeds rendered by renderAudioSvg.⟨src⟩ contracts/test/Render.t.sol :: RenderTest.test_render_sample_svgs
UNIAUDIO