|
@@ -0,0 +1,102 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * Normalize narration text so a TTS engine reads counts aloud as natural
|
|
|
|
|
+ * spoken Chinese instead of a literal ASCII transcription.
|
|
|
|
|
+ *
|
|
|
|
|
+ * GitHub star/fork counts are written with a lowercase "k" suffix everywhere
|
|
|
|
|
+ * (e.g. "11.3k", "123k") to match the on-screen cards (see formatCount). But a
|
|
|
|
|
+ * TTS engine fed "11.3k" reads it literally — "十一点三k" or "十一点三千" — which
|
|
|
|
|
+ * sounds wrong. This expands every "Nk" token to its full integer value in
|
|
|
|
|
+ * spoken Chinese ("11.3k" → 一万一千三百, "2k" → 两千, "20k" → 两万), leaving
|
|
|
|
|
+ * everything else untouched: plain numbers stay (TTS reads "312" as 三百一十二
|
|
|
|
|
+ * fine), version strings ("React 18"), repo names, and the visual "k" formatting
|
|
|
|
|
+ * on cards are all unaffected. Applied to narration only.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Follows the colloquial 两/二 rule: 2 reads 两 before 千/百/万/亿 (两千, 两百,
|
|
|
|
|
+ * 两万, 两亿) but 二 in the tens/ones place (二十, 十二, 二十二). */
|
|
|
|
|
+
|
|
|
|
|
+const ZH_DIGITS = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
|
|
|
|
|
+
|
|
|
|
|
+/** Convert a 1..9999 group to Chinese place-value form, collapsing internal
|
|
|
|
|
+ * zeros to a single 零 (e.g. 1001 → 一千零一, 1300 → 一千三百). "一十" is
|
|
|
|
|
+ * shortened to "十" only when the tens place is the group's leading position. */
|
|
|
|
|
+function fourDigitsToZh(n: number): string {
|
|
|
|
|
+ const places = ["千", "百", "十", ""];
|
|
|
|
|
+ const digits = [
|
|
|
|
|
+ Math.floor(n / 1000) % 10,
|
|
|
|
|
+ Math.floor(n / 100) % 10,
|
|
|
|
|
+ Math.floor(n / 10) % 10,
|
|
|
|
|
+ n % 10,
|
|
|
|
|
+ ];
|
|
|
|
|
+ const parts: string[] = [];
|
|
|
|
|
+ let started = false;
|
|
|
|
|
+ let pendingZero = false;
|
|
|
|
|
+ for (let i = 0; i < 4; i++) {
|
|
|
|
|
+ const d = digits[i];
|
|
|
|
|
+ if (d === 0) {
|
|
|
|
|
+ if (started) pendingZero = true;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (pendingZero) {
|
|
|
|
|
+ parts.push("零");
|
|
|
|
|
+ pendingZero = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (i === 2 && d === 1 && !started) {
|
|
|
|
|
+ parts.push("十");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Colloquial 两/二 rule: 千位/百位的 2 reads 两 (两千/两百); 十位/个位
|
|
|
|
|
+ // read 二 (二十/十二). So 2000→两千, 2200→两千两百, but 20→二十, 22→二十二.
|
|
|
|
|
+ const word = d === 2 && (i === 0 || i === 1) ? "两" : ZH_DIGITS[d];
|
|
|
|
|
+ parts.push(word + places[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+ started = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return parts.join("");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Convert a non-negative integer to spoken Chinese (supports up to 亿 scale,
|
|
|
|
|
+ * which covers any k-suffixed count: even 9999k ≈ 一亿). */
|
|
|
|
|
+function integerToZh(n: number): string {
|
|
|
|
|
+ n = Math.trunc(n);
|
|
|
|
|
+ if (n === 0) return "零";
|
|
|
|
|
+ const units = ["", "万", "亿"];
|
|
|
|
|
+ const groups: number[] = [];
|
|
|
|
|
+ while (n > 0) {
|
|
|
|
|
+ groups.push(n % 10000);
|
|
|
|
|
+ n = Math.floor(n / 10000);
|
|
|
|
|
+ }
|
|
|
|
|
+ let result = "";
|
|
|
|
|
+ let seenNonZero = false;
|
|
|
|
|
+ let zeroGap = false; // a higher group was emitted; a lower group is empty
|
|
|
|
|
+ for (let i = groups.length - 1; i >= 0; i--) {
|
|
|
|
|
+ const g = groups[i];
|
|
|
|
|
+ if (g === 0) {
|
|
|
|
|
+ if (seenNonZero) zeroGap = true;
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ // Insert 零 when a non-zero group doesn't fill its 千 place, or when an
|
|
|
|
|
+ // interior all-zero group separates two non-zero groups (e.g. 1亿零3千).
|
|
|
|
|
+ if (zeroGap || (seenNonZero && g < 1000)) result += "零";
|
|
|
|
|
+ const unit = units[i];
|
|
|
|
|
+ // A lone 2 multiplying 万/亿 reads 两 (两万/两亿), not 二万/二亿. Larger group
|
|
|
|
|
+ // values are handled by fourDigitsToZh (which already uses 两 in 百/千 place).
|
|
|
|
|
+ if (g === 2 && unit) result += "两" + unit;
|
|
|
|
|
+ else result += fourDigitsToZh(g) + unit;
|
|
|
|
|
+ seenNonZero = true;
|
|
|
|
|
+ zeroGap = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Match "11.3k", "12.3K", "123k", "1 k" — the k must NOT be part of a larger
|
|
|
|
|
+// word (reject when followed by a letter/digit, so "k8s" / "key" are untouched).
|
|
|
|
|
+const K_COUNT_PATTERN = /(\d+(?:\.\d+)?)\s*[kK](?![a-zA-Z0-9])/g;
|
|
|
|
|
+
|
|
|
|
|
+/** Expand every "Nk" count token in `text` to spoken Chinese. */
|
|
|
|
|
+export function normalizeCountsForTTS(text: string): string {
|
|
|
|
|
+ if (typeof text !== "string" || text.length === 0) return text;
|
|
|
|
|
+ return text.replace(K_COUNT_PATTERN, (match, num) => {
|
|
|
|
|
+ const value = Math.round(Number(num) * 1000);
|
|
|
|
|
+ if (!Number.isFinite(value) || value <= 0) return match;
|
|
|
|
|
+ return integerToZh(value);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|