Junki
Junki
发布于 2026-09-04 / 2 阅读
0
0

Sing-song:一种适合口述的长数字与密钥编码

51ac0759fc4d 这样的十六进制数转换成 kalo-tadu-komu-tigi 这样可以吟诵、容易记忆的字符串。

摘要

Sing-song 是一种可逆编码,可将任意字节串编码为可发音的“辅音—元音”(CV)音节。它使用由 64 个音节组成的字母表,每个 6 位值都直接映射到一个音节。完整编码会保留字节长度及开头的零字节,不需要外部长度元数据,并且具有规范化的唯一形式。

这种编码具有前缀稳定性:相同的输入前缀会产生相同的音节前缀。还可以使用可选的变体后缀,为同一个字节串提供其他可逆表示。

动机

十六进制和 Base58 等面向机器的编码很紧凑,但不便于口述、抄录和记忆。Sing-song 牺牲了一部分书写密度,换取一套小巧、规则的发音语法,同时仍然保持确定性、可逆性和计算上的简洁性。

设计目标

这种编码应当具备以下特性:确定性可逆性前缀稳定性;无需训练即可口述和抄录;对于完整字节串能够自行确定长度;并且不依赖承担关键语义的标点,也能自行确定边界

编码方式

字母表

位置符号数量
辅音(奇数位)b d f g j k l m n p r s t v w z16
元音(偶数位)a i o u4

辅音和元音严格交替,构成 64 个开放式 CV 音节,不含辅音簇,也没有韵尾。字符所在位置的奇偶性决定应使用哪张符号表。hye 被排除,因为它们的发音相对不稳定。

音节与分组

每个音节恰好编码 6 位。显示时,每组包含两个音节(例如 zilasibo),各组之间用仅具视觉作用的连字符分隔。

解析器必须忽略连字符:zilasibotivajuzuzila-sibo-tiva-juzu 完全相同。分组可以在口述时提供自然的停顿和核对点。

发音

Sing-song 为每个字母规定了固定发音。其拼写遵循音位原则:每个字母代表一个音,并且同一个字母始终以相同方式发音。

以下国际音标(IPA)值为规范定义:

字母aioubdfgjk
IPA/a//i//o//u//b//d//f//ɡ//dz//k/
字母lmnprstvz
IPA/l//m//n//p//r//s//t//v//z/

元音都是单元音,不得按照英语拼写习惯理解。尤其是,i 读作 /i/,o 读作 /o/,u 读作 /u/;不能读成 /aɪ/、/oʊ/ 或 /juː/。

每个字母都独立发音。不存在不发音字母、二合字母或随上下文变化的发音。

每一对“辅音—元音”构成一个音节,单词重音落在第一个音节上。

例如:

单词IPA
sibo/ˈsi.bo/
katu/ˈka.tu/
pova/ˈpo.va/

只要编码中的各个字母仍能清楚区分,就允许因口音产生细微发音差异。

算法

把输入视为比特流,按从最高有效位到最低有效位的顺序切分为 6 位一组。每组直接映射到一个音节:

第 5..2 位 → 辅音索引 0..15
第 1..0 位 → 元音索引 0..3

对于长度为 L 字节的输入,输出 n = ceil(8·L / 6) 个音节。如果最后一组不足 6 个输入位,就在低位补零。这些零是规范填充,不携带信息。

完整编码能够自行确定大小:L = floor(6·n / 8)。解码器重建各个 6 位分组,推断 L,返回前 8·L 位,并且必须拒绝非规范的音节数量或非零填充。开头的零字节会被保留。

上述规则适用于完整编码。截断的前缀无法表明后面是否还有更多音节。

k 个音节组成的前缀确定了编码值的前 6·k 位;验证该前缀时应重新计算编码,而不是对其解码。

变体

变体是同一个字节串的另一种可逆表示。变体标识符包含在表示形式中,因此解码不需要外部元数据。

对于输入 X 和变体 v = 0…15

M(0, n) = 0^n
M(v, n) = SHAKE-256("sing-song/variant" ‖ byte(v), n)    当 v > 0
Y       = X XOR M(v, len(X))

使用普通 Sing-song 编解码器编码 Y。由于异或运算具有自逆性:

X = Y XOR M(v, len(Y))

掩码是公开的,不提供任何保密性。SHAKE-256 会生成确定性的输出流,因此保留了下文所述的前缀稳定性。变体 0 是直接编码。

变体标识符呈现为末尾的双字母后缀:一个元音,后接 l m n r 中的一个辅音:

v = 4·i + j,其中 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

变体 0 应该不带后缀,但解析器必须接受显式的 al,并将其视为等价形式。

位置奇偶性可以消除后缀歧义:内容中的辅音位于奇数位置,因此奇数位置出现元音时,只可能是变体后缀的起始字符。解析器必须要求末尾恰好有两个字符(先是元音,再是 l/m/n/r),并拒绝其他违反位置奇偶规则的形式。

前缀稳定性

每个完整音节恰好表示输入中连续的 6 位。因此,如果两个字节串的前 6k 位相同,那么它们的直接 Sing-song 编码也会有相同的前 k 个音节。

对于按字节对齐的前缀,每隔 24 位,字节边界和音节边界会同时对齐:

3 字节 = 24 位 = 4 音节

在这些边界处,截断编码与编码截断后的字节串完全等价:

SingSong(X)[0:4k 个音节] = SingSong(X[0:3k 个字节])

变体也具有相同的性质。SHAKE-256 掩码以输出流方式生成,因此较短的掩码是较长掩码的前缀:

M(v, 3k) = M(v, len(X))[0:3k]

所以:

body(SingSong(X, v))[0:4k 个音节] + suffix(v)
    = SingSong(X[0:3k 个字节], v)

对于前缀存在的任意 k,上式都成立。

如果前缀的结尾没有同时落在字节边界和音节边界上,那么共同的开头音节仍表示相同的起始比特,但截断后的文本本身并不是某个字节串的完整、规范编码。

转录与错误处理

CV 语法能够提供基本的语法检查。奇数位置必须是辅音,偶数位置必须是元音。不属于相应字母表的字符必须被拒绝。

解析器可以在字符位置足以明确预期符号时,对以下无歧义的抄录替换进行规范化:

  • 元音位置的 0o
  • 辅音位置的 1l
  • 元音位置的 ei

这些替换只能纠正表示层面的错误。Sing-song 不包含纠错码或校验和:如果把一个有效音节替换成另一个有效音节,仅凭编码本身无法检测出来。

考虑过的替代方案

基础语法在与三个值得记录的替代方案比较后得以保留。

精选音节词典

手工挑选的码表可以把 b/p、d/t、g/k、f/v、s/z、m/n 和 l/r 等容易混淆的声音合并为等价类,从而排除易混淆的最小对立词。大约 10 个声母类别 × 4 个元音 × 3 个韵尾类别,可以得到约 120 个稳健音节,即每个音节约 6.9 位:音节数比 Sing-song 少约 17%,容错能力也更好。代价是需要大型查找表,书写形式更长,而且闭音节更显沉重。

示例:

ban-fok-rim-tus-gal-nom-pik-sur

为了这点收益而失去简单的生成式语法和轻盈、开放的声音,并不值得。

将严格交替放宽为“无辅音簇”约束

允许 CV、VC 和 CVC,同时仅禁止相邻辅音,理论容量会从每个字母 2.95 位提高到 3.32 位。但在禁止双元音字母、限制元音连续出现,并只允许清晰的复合元音(aiauoiouui)之后,实际书写长度仅能缩短约 3%,口述密度则几乎没有提升。

示例:

zilai-sibo-tauva-juzu

这点微小收益不足以证明以下代价合理:用自动机替代位置奇偶规则、削弱错误修复能力,以及让变体解析变得更复杂。

选定的辅音簇声母

保持开放音节,但允许选定的英语 CC 声母,就会得到 (C | selected CC)V 形式,例如 bagroplitru。在当前 16 个简单声母之外,再加入 12 个辅音簇(brbldrfrflgrglkrklprpltr),字母表便有 112 个音节,即每个音节约 6.81 位。一个 256 位的值大约需要 38 个音节。

示例:

zila-grovi-pluma-triso-fraku-silo-bruna-koti

这种方式保留了 Sing-song 大部分开放、富有旋律感的特征,但为了适度缩短口述长度,牺牲了统一的 CV 语法和基于位置奇偶性的解析。

16 × 4 的 CV 字母表是一个实用的平衡点:每个音节恰好编码 6 位,编解码器极其简单,同时保留了小型语法、开放的声音和按位置解析的能力。

与其他编码的比较

Sing-song 以较低的书写密度换取更高的口述密度。它每个字母承载 3 位,每个音节恰好承载 6 位。十六进制每个字符承载 4 位,Base58 则约为 5.9 位。

位数十六进制字符数Base58 字符数Sing-song 字母数分组数
48129164
641611225.5
12832224411
25664448621.5

当这些值需要口述时,取舍会反转:十六进制字符的名称更长,并且存在大量强烈押韵的音组,而 Sing-song 的每个简短 CV 音节都能承载 6 位。一个完整的 256 位值需要 43 个音节。

如果不存在人工传递渠道,十六进制或 Base58 更短,也更合适。Sing-song 面向的是必须由人阅读、说出、输入或记忆的值。

测试向量

该编解码器只处理字节,不为字节赋予任何语义。

输入 = 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

输入 = 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

另一个 256 位输入:

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

输入 = 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

解码 Sing-song 主体会得到 derived;再与同一个掩码进行异或,即可恢复 input

参考实现

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()

示例:

$ 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 是自逆函数:连续两次应用同一个变体,就能恢复原始字节。

应用:Nostr 用户名

Nostr 的 npub 是 32 字节公钥的 Bech32 表示。应用程序可以先解码 npub,再取直接 Sing-song 编码的前八个音节(四个显示分组),由此生成固定长度的 Sing-song 用户名:

P = bech32_decode(npub)          # 32 字节公钥
username = SingSong(P) 的前 8 个音节

八个音节恰好表示 48 位,因此这等价于编码公钥的前六个字节:

username = SingSong(P[0:6])

这直接源自 Sing-song 的前缀稳定性规则:6 字节 = 48 位 = 8 音节

因此,得到的用户名是公钥前缀的一种易读表示,而不是由哈希生成的指纹。用户可以把用户名解码回六个十六进制字节,并直接与公钥开头进行比较。应用程序同样可以在不使用哈希的情况下推导用户名,并通过比较解码后的前缀来查找候选匹配项。

该用户名并非全局唯一:许多 32 字节公钥可能拥有相同的前六个字节。需要更强身份识别能力的应用程序可以使用更多音节,直至采用完整的 Sing-song 编码;完整编码能够精确、可逆地还原整个 32 字节公钥。

不过,实际流程可以是:

  • Anna 告诉 Bob,她的用户名是 kalo-tadu-komu-tigi
  • Bob 在自己的 Nostr 客户端中输入该字符串。
  • 客户端将其转换为 51ac0759fc4d,并搜索 npub 以这段十六进制数开头的已知用户。
  • 客户端向 Bob 展示匹配结果,Bob 从中选出 Anna 的账户。

既有成果

Sing-song 建立在多种可发音编码成果之上,包括 S/Key 单词编码(RFC 1751)、PGP 单词表、Bubble Babble、Oren Tirosh 的助记编码、proquints、BIP39,以及 Urbit 的 @p

它的独特之处在于同时具备以下特性:可逆、前缀稳定的字节串编码;由 64 个音节构成的 CV 语法;直接的 6 位映射;能够自行确定长度的完整形式;以及编码在表示形式中的可逆变体。

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


评论