Skip to content

Sandhi

Tone sandhi changes a syllable’s tone according to its context. PinyinJS applies it to parsed syllables, including changes across word boundaries.

import { applySandhi, readWord } from "@kensio/pinyinjs";
const buShi = readWord("bùshì") ?? [];
applySandhi(buShi); // bú shì, 不 flattens before a fourth tone
applySandhi(buShi, { yiBu: false }); // unchanged
const niHao = readWord("nǐhǎo") ?? [];
applySandhi(niHao); // unchanged by default
applySandhi(niHao, { thirdTone: true }); // ní hǎo

The dictionary stores underlying tones. This lets you choose whether to apply sandhi when formatting a reading.

On by default.

不 changes from bù to bú before a fourth tone:

convert(dictionary, "不是"); // "bú shì"
convert(dictionary, "不对"); // "bú duì"
convert(dictionary, "不行"); // "bùxíng", 行 is second tone here, so no change

一 changes from yī to yì before tones 1, 2 and 3, and to yí before tone 4. It keeps yī in ordinals and final position:

convert(dictionary, "一天"); // "yì tiān", before first tone
convert(dictionary, "一起"); // "yìqǐ", before third tone
convert(dictionary, "一个"); // "yí gè", before fourth tone
convert(dictionary, "一样"); // "yíyàng"
convert(dictionary, "第一"); // "dìyī", ordinal, unchanged

Both rules apply to a character rather than to a sound. 医, 依, 衣 and 伊 are all yī, and 部, 布, 步 and 埠 are all bù. None of them changes tone for what follows it:

convert(dictionary, "医生"); // "yīshēng", not yìshēng
convert(dictionary, "依然"); // "yīrán"
convert(dictionary, "部队"); // "bùduì", not búduì
convert(dictionary, "全部是"); // "quánbù shì", across a word boundary too

convert knows which Han characters it read and applies both rules only to 一 and 不. applySandhi given pinyin alone has only the spelling, so pinyinjs sandhi yīshēng still changes a 医 it cannot see.

一 assimilates to the syllable it counts, and that syllable is inside its own word. A 一 at the end of a longer word counts nothing, so it keeps yī whatever starts the next word:

convert(dictionary, "唯一办法"); // "wéiyī bànfǎ", not wéiyí
convert(dictionary, "统一中国"); // "tǒngyī Zhōngguó"
convert(dictionary, "星期一去"); // "xīngqīyī qù"
convert(dictionary, "一个"); // "yí gè", here 一 is a word on its own

The last line is the distinction. A 一 counting a 个 is a one-syllable word that joins the following word’s group, while the 一 of 唯一 is already inside a word. 不 behaves differently and still changes at the end of a word, since what it negates is often in the next one.

This comes from the word boundaries the decoder found. Supply a SandhiGrouping to applySandhi where you know them.

Counting uses can change the tone of 一. Digit sequences and ordinals retain its underlying tone:

convert(dictionary, "十一月"); // "shíyīyuè", a last digit, not a quantity
convert(dictionary, "十一点"); // "shíyīdiǎn"
convert(dictionary, "第一次"); // "dìyīcì", ordinal
convert(dictionary, "万一你来"); // "wànyī nǐ lái"
convert(dictionary, "31日"); // "sānshíyī rì", the same through the digits
convert(dictionary, "一个"); // "yí gè", still counting, so still assimilates
convert(dictionary, "当时一个人"); // "dāngshí yí gè rén", 时 is not 十
convert(dictionary, "那是一条狗"); // "nà shì yìtiáo gǒu", nor is 是

The rule identifies a number-final 一 by a preceding numeral and the absence of a following numeral. The middle 一 in 一百一十 still counts 十 and remains eligible for sandhi.

convert uses the source Chinese characters to distinguish numeral uses. applySandhi can also work from pinyin alone, but identical spellings can be ambiguous. For example, shí may represent 十 or 时, dì may represent 第 or 地, and yī may represent 一 or 医.

In 88,866 corpus lines, providing the characters corrected 1,575 一 tone decisions compared with using spellings alone. The count was measured for this rule alone, before the two rules above were added:

一 after 是 (是一个, 那是一样) 1,338
after 前, 试, 晚, 释, 始, 时, 弟 231
a real 十 shedding a wrong assimilation 6
broken 0

The spelling-based numeral set excludes 亿 because its reading overlaps with non-numeral characters such as 意 and 议. Character-aware conversion can distinguish these cases. Its numeral set includes 十, 百, 千 and 万, with traditional and financial forms.

Turn both off with sandhi: { yiBu: false }, or --no-sandhi at the command line.

Off by default.

A third tone can become a second tone before another third tone. For example, 你好 is spoken ní hǎo. Standard pinyin spelling retains nǐ hǎo:

convert(dictionary, "好好"); // "hǎohǎo"
convert(dictionary, "好好", { sandhi: { thirdTone: true } }); // "háohǎo"

Enable third-tone sandhi for pronunciation guides or speech exercises. Leave it disabled when you want ordinary written pinyin.

const henHao = readWord("hěnhǎo") ?? [];
applySandhi(henHao); // hěn hǎo
applySandhi(henHao, { thirdTone: true }); // hén hǎo

Third-tone sandhi depends on prosodic feet, groups of syllables pronounced together. It is strongest within a foot and more optional across larger boundaries. The reference used here is Shih 1986.

PinyinJS approximates these groups with three rules:

const said = { sandhi: { thirdTone: true } };
convert(dictionary, "展览馆", said); // "zhánlánguǎn", 展览 + 馆
convert(dictionary, "纸老虎", said); // "zhǐláohǔ", 纸 + 老虎
convert(dictionary, "老板很好", said); // "láobǎn hén hǎo"
  1. Within a word, dictionary boundaries determine the order of changes. 展覽館 splits as 展覽 + 館, while 紙老虎 splits as 紙 + 老虎. A split requires both parts to be dictionary words, and the most even split is preferred.
  2. A one-syllable word joins the following word’s group. This changes 很 in 很喜歡 and 我 and 也 in 我也很好 (wó yé hén hǎo).
  3. Two multi-syllable words remain separate groups. For example, 行長 and 很喜歡 keep the boundary in hángzhǎng hén xǐhuan.

The approximation misses one-syllable words that attach to the preceding word. For example, it writes 保管好 as báoguǎn hǎo, while the intended pronunciation is báoguán hǎo. Resolving this requires grammatical information beyond word boundaries.

The pass processes the complete syllable array. 一 and 不 can change tone in response to a syllable in the next word.

Third-tone sandhi and 一 also need word grouping. convert supplies the decoder’s groups. A direct applySandhi call treats the reading as one word unless you supply SandhiGrouping:

const reading = readWord("hángzhǎnghěnxǐhuan") ?? [];
applySandhi(reading, { thirdTone: true }); // háng zháng hén xǐ huan
applySandhi(reading, { thirdTone: true }, [2, 1, 2]); // háng zhǎng hén xǐ huan

Each grouping entry describes one word, using either its syllable count or the counts of its parts. For example, [[1, 2]] describes 紙老虎. Groupings with the wrong total syllable count are ignored. The sandhi CLI command derives groups from spaces in its input.

applySandhi(syllables, options?, grouping?, characters?) accepts the same options as ConvertOptions.sandhi. The optional characters array associates each syllable with its source Han character, and is what identifies 一 and 不. Use undefined when one character cannot represent the syllable:

Field Default Does
yiBu true 一 and 不 tone changes
thirdTone false third tone before third tone

Options are merged with the defaults. { thirdTone: true } keeps yiBu enabled.

Erhua is stored in the dictionary and handled by orthography. The sandhi pass does not change it. The half-third tone before a non-third tone has no separate pinyin spelling and is not marked.

Terminal window
$ pinyinjs sandhi bùshì
bùshì bú shì
$ pinyinjs sandhi --third-tone nǐhǎo
nǐhǎo ní hǎo

sandhi accepts written pinyin and runs without a dictionary. The --no-sandhi and --third-tone flags also work with convert, html and explain.