将
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 z | 16 |
| 元音(偶数位) | a i o u | 4 |
辅音和元音严格交替,构成 64 个开放式 CV 音节,不含辅音簇,也没有韵尾。字符所在位置的奇偶性决定应使用哪张符号表。h、y 和 e 被排除,因为它们的发音相对不稳定。
音节与分组
每个音节恰好编码 6 位。显示时,每组包含两个音节(例如 zila、sibo),各组之间用仅具视觉作用的连字符分隔。
解析器必须忽略连字符:zilasibotivajuzu 与 zila-sibo-tiva-juzu 完全相同。分组可以在口述时提供自然的停顿和核对点。
发音
Sing-song 为每个字母规定了固定发音。其拼写遵循音位原则:每个字母代表一个音,并且同一个字母始终以相同方式发音。
以下国际音标(IPA)值为规范定义:
| 字母 | a | i | o | u | b | d | f | g | j | k |
|---|---|---|---|---|---|---|---|---|---|---|
| IPA | /a/ | /i/ | /o/ | /u/ | /b/ | /d/ | /f/ | /ɡ/ | /dz/ | /k/ |
| 字母 | l | m | n | p | r | s | t | v | z |
|---|---|---|---|---|---|---|---|---|---|
| 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 语法能够提供基本的语法检查。奇数位置必须是辅音,偶数位置必须是元音。不属于相应字母表的字符必须被拒绝。
解析器可以在字符位置足以明确预期符号时,对以下无歧义的抄录替换进行规范化:
- 元音位置的
0→o - 辅音位置的
1→l - 元音位置的
e→i
这些替换只能纠正表示层面的错误。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 位。但在禁止双元音字母、限制元音连续出现,并只允许清晰的复合元音(ai、au、oi、ou、ui)之后,实际书写长度仅能缩短约 3%,口述密度则几乎没有提升。
示例:
zilai-sibo-tauva-juzu
这点微小收益不足以证明以下代价合理:用自动机替代位置奇偶规则、削弱错误修复能力,以及让变体解析变得更复杂。
选定的辅音簇声母
保持开放音节,但允许选定的英语 CC 声母,就会得到 (C | selected CC)V 形式,例如 ba、gro、pli、tru。在当前 16 个简单声母之外,再加入 12 个辅音簇(br、bl、dr、fr、fl、gr、gl、kr、kl、pr、pl、tr),字母表便有 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 字母数 | 分组数 |
|---|---|---|---|---|
| 48 | 12 | 9 | 16 | 4 |
| 64 | 16 | 11 | 22 | 5.5 |
| 128 | 32 | 22 | 44 | 11 |
| 256 | 64 | 44 | 86 | 21.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