This is a fun experiment that started as a way to generate deterministic "usernames" for
Nostr npub keys (see the last section).
English is really hard to deal with even for simple syllables, so the result is not as good as I'd want it to be (a string you can read to an other person over the phone and make sure they get it right), but it's not bad either —if everyone spoke Itallian sing-song would be much more valuable :-)
Sing-song: speakable encodings for long numbers and keys
Status: draft, v0.1.0 --- comments welcome.
Abstract
Sing-song is a reversible encoding of arbitrary byte strings as pronounceable CV syllables. Its 64-syllable alphabet maps each 6-bit value directly to one syllable. Complete encodings preserve byte length and leading zero bytes, require no external length metadata, and are canonical.
The encoding is prefix-stable: shared input prefixes produce shared syllable prefixes. An optional variant suffix provides alternative reversible representations of the same byte string.
Motivation
Machine-oriented encodings such as hex and Base58 are compact but awkward to speak, transcribe, and remember. Sing-song trades some written density for a small, regular pronunciation grammar while remaining deterministic, reversible, and computationally simple.
Design goals
The encoding should be deterministic, reversible,
prefix-stable, speakable and transcribable without training,
self-sizing for complete byte strings, and self-delimiting
without load-bearing punctuation.
The encoding
Alphabet
| Position | Symbols | Count |
|---|---|---|
| Consonant (odd ) | b d f g j k l m n p r s t v w z | 16 |
| Vowel (even) | a i o u | 4 |
Consonants and vowels strictly alternate, producing 64 open CV syllables
with no clusters or codas. Position parity determines the symbol table.
h, y, and e are omitted because their pronunciation is
comparatively unstable.
Syllables and grouping
Each syllable encodes exactly 6 bits. Display groups contain two
syllables (zila, sibo) separated by cosmetic hyphens.
Parsers MUST
ignore hyphens: zilasibotivajuzu and zila-sibo-tiva-juzu are
identical. Groups provide natural speaking checkpoints.
Algorithm
Treat the input as a bit stream and split it into 6-bit chunks, most significant bit first. Each chunk maps directly to one syllable:
bits 5..2 → consonant index 0..15
bits 1..0 → vowel index 0..3
For L input bytes, emit n = ceil(8·L / 6) syllables. If the final
chunk has fewer than six input bits, zero-fill its low bits. These
zeroes are canonical padding and carry no information.
A complete encoding is self-sizing: L = floor(6·n / 8). The decoder
reconstructs the 6-bit chunks, infers L, returns the first 8·L bits,
and MUST reject non-canonical syllable counts or non-zero padding.
Leading zero bytes are preserved.
This applies to complete encodings. A truncated prefix does not
indicate whether more syllables follow.
A prefix of k syllables commits to the first 6·k bits of the encoded
value and is verified by recomputation, not decoding.
Variants
A variant is an alternative reversible representation of the same byte string. The variant identifier is encoded in the representation, so decoding requires no external metadata.
For input X and variant v = 0…15:
M(0, n) = 0^n
M(v, n) = SHAKE-256("sing-song/variant" ‖ byte(v), n) for v > 0
Y = X XOR M(v, len(X))
Encode Y with the ordinary Sing-song codec. Because XOR is
self-inverse:
X = Y XOR M(v, len(Y))
The mask is public and provides no confidentiality. SHAKE-256 produces a deterministic stream, preserving the prefix-stability property described above. Variant 0 is the direct encoding.
The variant identifier is rendered as a trailing two-letter suffix: one
vowel followed by one of l m n r:
v = 4·i + j where vowel = "aiou"[i], consonant = "lmnr"[j]
0=al 1=am 2=an 3=ar 4=il 5=im 6=in 7=ir
8=ol 9=om 10=on 11=or 12=ul 13=um 14=un 15=ur
Variant 0 SHOULD be rendered without a suffix; parsers MUST accept an
explicit al as equivalent.
Parity disambiguates the suffix: content consonants occupy odd
positions, so a vowel at an odd position can only begin the variant
suffix. Parsers MUST require exactly two trailing letters (vowel then
l/m/n/r) and reject other parity violations.
Prefix stability
Each complete syllable represents exactly six consecutive input bits.
Therefore, if two byte strings share their first 6k bits, their direct
Sing-song encodings share their first k syllables.
For byte-aligned prefixes, a boundary is both byte- and syllable-aligned every 24 bits:
3 bytes = 24 bits = 4 syllables
At those boundaries, truncating the encoding is exactly equivalent to encoding the truncated byte string:
SingSong(X)[0:4k syllables] = SingSong(X[0:3k bytes])
The same property holds for variants. The SHAKE-256 mask is generated as an output stream, so a shorter mask is a prefix of a longer one:
M(v, 3k) = M(v, len(X))[0:3k]
Therefore:
body(SingSong(X, v))[0:4k syllables] + suffix(v)
= SingSong(X[0:3k bytes], v)
for any k such that the prefix exists.
For prefixes that do not end on both a byte and syllable boundary, the shared leading syllables still represent the same leading bits, but the truncated text is not itself a complete canonical encoding of a byte string.
Transcription and error handling
Position parity makes limited corrections safe: 0→o, 1→l, and e→i.
Parsers MAY apply these and MUST reject other out-of-alphabet or parity
errors.
Errors localize by group: when a prefix stops matching, the client can request repetition of that group rather than the whole string.
Alternatives considered
The base grammar survived three alternatives worth recording.
Curated syllable dictionaries
A hand-picked codebook can exclude confusable minimal pairs by merging sounds such as b/p, d/t, g/k, f/v, s/z, m/n, and l/r into equivalence classes. Roughly ten onset classes × four vowels × three coda classes gives about 120 robust syllables, or ~6.9 bits each: about 17% fewer syllables than Sing-song, with better error tolerance. The cost is a large lookup table, longer written forms, and heavier closed syllables.
Example:
ban-fok-rim-tus-gal-nom-pik-sur
The gain was not worth losing the simple generative grammar and light, open sound.
Relaxing alternation to a no-cluster constraint
Allowing CV, VC, and CVC while merely forbidding adjacent consonants
raises the theoretical capacity from 2.95 to 3.32 bits/letter. Once
doubled vowels are banned, vowel runs are limited, and only clear
diphthongs (ai, au, oi, ou, ui) are allowed, the practical
gain falls to about 3% in written length and essentially zero in spoken
density.
Example:
zilai-sibo-tauva-juzu
The small gain does not justify replacing the parity rule with an automaton, weakening error healing, and complicating variant parsing.
Selected consonant-cluster onsets
Keeping syllables open but allowing selected English CC onsets gives
(C | selected CC)V, such as ba, gro, pli, tru. With the
current 16 simple onsets plus twelve clusters (br, bl, dr, fr,
fl, gr, gl, kr, kl, pr, pl, tr), the alphabet has 112
syllables, or about 6.81 bits each. A 256-bit value would need about 38
syllables.
Example:
zila-grovi-pluma-triso-fraku-silo-bruna-koti
This preserves much of Sing-song's open, melodic character, but sacrifices the uniform CV grammar and parity parsing for a modest reduction in spoken length.
The 16 × 4 CV alphabet is the useful boundary: exactly 6 bits per syllable with a trivial codec, while preserving the small grammar, open sound, and positional parsing.
Comparison with other encodings
Sing-song trades written density for spoken density. It carries 3 bits per letter and exactly 6 bits per syllable. Hex carries 4 bits per character and Base58 about 5.9.
| Bits | Hex | Base58 | Sing-song | Groups |
|---|---|---|---|---|
| 48 | 12 | 9 | 16 | 4 |
| 64 | 16 | 11 | 22 | 5.5 |
| 128 | 32 | 22 | 44 | 11 |
| 256 | 64 | 44 | 86 | 21.5 |
The trade reverses when values are spoken: hexadecimal character names are longer and contain strong rhyme classes, while Sing-song carries 6 bits in each short CV syllable. A complete 256-bit value takes 43 syllables.
Where no human channel exists, hex or Base58 is shorter and preferable. Sing-song is intended for values that humans must read, say, type, or remember.
Test vectors
The codec operates on bytes without assigning them semantics.
Input = 32 × 00:
input 0000000000000000000000000000000000000000000000000000000000000000
sing-song baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-baba-ba
Input = SHA-256(sing-song):
input 7910c06577ab67de51fed45ba18f27fc28eb618ebc1b78f9bced0f47fcefec2d
sing-song moji-buba-liku-moru-lizi-wiji-zusi-jilu-rala-zapu-zubo-nuru-lala-woza-dovu-nuwi-sugo-vagu-jizu-tusu-wubo-va
Another 256-bit input:
input d16997955b621dde4e0debc35fbbd3497eeb641008787903fea57437665399fa
sing-song vako-poku-piki-sino-dumi-wigo-bumo-subu-kuwu-suju-joku-wuru-libi-bafa-modu-pabu-zuro-kiva-givo-liju-pomu-ra
Variant derivation for input = 32 × 00:
v 1
mask 607aa3412838d5ebff0ae2b8521c453e0bf24d48d5438217dbee1dcb39991be7
derived 607aa3412838d5ebff0ae2b8521c453e0bf24d48d5438217dbee1dcb39991be7
sing-song ladu-ronu-jajo-nawa-vimo-suzu-boso-fowa-kani-tidi-guna-suto-juka-nuki-jawa-faku-vozo-wami-totu-poli-dozo-ma-am
v 5
mask 6a0dea99f82a4d9776babb55ded1d9824fa22789bbcb0e95da7035a7df307946
derived 6a0dea99f82a4d9776babb55ded1d9824fa22789bbcb0e95da7035a7df307946
sing-song lona-vuro-pomu-naro-juli-mivo-soru-siki-vusi-duli-napa-zono-fiwa-powu-tota-woki-vopu-bavi-rizi-zata-moka-la-im
Decoding the Sing-song body yields derived; XOR with the same mask
recovers input.
Reference implementation
import argparse
import hashlib
CONS = "bdfgjklmnprstvwz"
VOWELS = "aiou"
VARC = "lmnr"
def encode(data: bytes, group: int = 4) -> str:
n_bits = 8 * len(data)
n = (n_bits + 5) // 6
pad = 6 * n - n_bits
bits = int.from_bytes(data, "big") << pad
syllables = []
for i in range(n):
x = (bits >> (6 * (n - i - 1))) & 0x3f
syllables.append(CONS[x >> 2] + VOWELS[x & 3])
s = "".join(syllables)
return "-".join(s[i:i + group] for i in range(0, len(s), group))
def decode(s: str) -> bytes:
s = s.replace("-", "")
if len(s) % 2:
raise ValueError("incomplete syllable")
n = len(s) // 2
n_bytes = (6 * n) // 8
if (8 * n_bytes + 5) // 6 != n:
raise ValueError("not a complete canonical byte-string encoding")
bits = 0
for i in range(0, len(s), 2):
bits = (bits << 6) | (CONS.index(s[i]) << 2) | VOWELS.index(s[i + 1])
pad = 6 * n - 8 * n_bytes
if pad and bits & ((1 << pad) - 1):
raise ValueError("non-zero padding")
return (bits >> pad).to_bytes(n_bytes, "big")
def variant_mask(v: int, n: int) -> bytes:
if not 0 <= v <= 15:
raise ValueError("variant must be 0..15")
if v == 0:
return bytes(n)
return hashlib.shake_256(
b"sing-song/variant" + bytes([v])
).digest(n)
def apply_variant(data: bytes, v: int) -> bytes:
return bytes(
a ^ b
for a, b in zip(data, variant_mask(v, len(data)))
)
def variant_suffix(v: int) -> str:
return VOWELS[v // 4] + VARC[v % 4]
def parse_variant_suffix(s: str) -> tuple[str, int]:
s = s.replace("-", "")
# Content always begins with a consonant and has even length.
# A variant suffix begins with a vowel after the content body.
if len(s) >= 2 and s[-2] in VOWELS and s[-1] in VARC:
v = VOWELS.index(s[-2]) * 4 + VARC.index(s[-1])
return s[:-2], v
return s, 0
def encode_variant(data: bytes, v: int = 0) -> str:
encoded = encode(apply_variant(data, v))
if v == 0:
return encoded
return encoded + "-" + variant_suffix(v)
def decode_variant(s: str) -> tuple[bytes, int]:
body, v = parse_variant_suffix(s)
transformed = decode(body)
return apply_variant(transformed, v), v
def main() -> None:
parser = argparse.ArgumentParser(
description="Encode hex as Sing-song or decode Sing-song to hex."
)
sub = parser.add_subparsers(dest="command", required=True)
p_encode = sub.add_parser("encode", help="encode hex to Sing-song")
p_encode.add_argument("hex", help="hex-encoded byte string")
p_encode.add_argument(
"-v", "--variant",
type=int,
choices=range(16),
default=0,
metavar="0..15",
help="encoding variant (default: 0)",
)
p_decode = sub.add_parser("decode", help="decode Sing-song to hex")
p_decode.add_argument("singsong", help="Sing-song string")
args = parser.parse_args()
if args.command == "encode":
try:
data = bytes.fromhex(args.hex)
print(encode_variant(data, args.variant))
except ValueError as e:
parser.error(str(e))
elif args.command == "decode":
try:
data, v = decode_variant(args.singsong)
print(data.hex())
except (ValueError, IndexError) as e:
parser.error(f"invalid Sing-song: {e}")
if __name__ == "__main__":
main()
Example:
$ python singsong.py encode 7910c06577ab67de51fed45ba18f27fc28eb618ebc1b78f9bced0f47fcefec2d
moji-buba-liku-moru-lizi-wiji-zusi-jilu-rala-zapu-zubo-nuru-lala-woza-dovu-nuwi-sugo-vagu-jizu-tusu-wubo-va
$ python singsong.py decode moji-buba-liku-moru-lizi-wiji-zusi-jilu-rala-zapu-zubo-nuru-lala-woza-dovu-nuwi-sugo-vagu-jizu-tusu-wubo-va
7910c06577ab67de51fed45ba18f27fc28eb618ebc1b78f9bced0f47fcefec2d
apply_variant is its own inverse: applying the same variant twice recovers the original bytes.
Application: Nostr usernames
A Nostr npub is a Bech32 representation of a 32-byte public key. An application can derive a fixed-length Sing-song username by decoding the npub and taking the first eight syllables (four display groups) of the direct Sing-song encoding:
P = bech32_decode(npub) # 32-byte public key
username = first 8 syllables of SingSong(P)
Eight syllables represent exactly 48 bits, so this is equivalent to encoding the first six bytes of the public key:
username = SingSong(P[0:6])
This follows directly from Sing-song's prefix-stability rule: 6 bytes = 48 bits = 8 syllables.
The resulting username is therefore a readable representation of the public-key prefix, not a hash-derived fingerprint. A user can decode the username back to six bytes of hex and compare them directly with the beginning of the public key. Applications can likewise derive usernames without hashing and find candidate matches by comparing the decoded prefix.
The username is not globally unique: many 32-byte public keys can share the same first six bytes. Applications that require stronger identification can use more syllables, up to the complete Sing-song encoding, which reverses exactly to the full 32-byte public key.
However:
- Anna tells Bob that her username is
kalo-tadu-komu-tigi. - Bob enters the string in his Nostr client.
- The client converts it to
51ac0759fc4dand searches for known users whosenpubstart with this hex. - The client presents the matches to Bob, and Bob picks Anna's account.
Prior art
Sing-song builds on pronounceable-encoding work including S/Key word
encoding (RFC 1751), the PGP word list, Bubble Babble, Oren Tirosh's
mnemonic encoding, proquints, BIP39, and Urbit @p.
Its distinguishing combination is a reversible, prefix-stable byte-string encoding with a 64-syllable CV grammar, direct 6-bit mapping, self-sizing complete forms, and encoded reversible variants.