Sing-song: a speakable encoding for long numbers and keys

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 51ac0759fc4d and searches for known users whose npub start with this hex.
  • The clinet 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.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论