package shadow import ( "bufio" "embed" "fmt" "strings" "sync" "unicode" ) //go:embed cmudict.txt var dictFS embed.FS // ARPAbet → Shadow mapping. // // CMU uses stress digits on vowels: 0=no stress, 1=primary, 2=secondary. // The digits are stripped before lookup here; stress is handled separately. // // Vowel mapping rationale (American English → 7-vowel Shadow system): // // AA /ɑ/ → A (father, hot) // AE /æ/ → A (cat — no /æ/ in Shadow, collapses to open A) // AH /ʌ/ → A (but, cup — schwa/strut vowel → A) // AO /ɔ/ → O (thought, law) // AW /aʊ/ → AU (diphthong: cow) // AY /aɪ/ → AY (diphthong: eye — Y as glide) // EH /ɛ/ → E (bed) // ER /ɝ/ → R (syllabic r: bird, butter) // EY /eɪ/ → EY (diphthong: day) // IH /ɪ/ → I (bit — near-close, collapses to I) // IY /i/ → I (see) // OW /oʊ/ → O (go — diphthong simplified) // OY /ɔɪ/ → OY (diphthong: boy) // UH /ʊ/ → U (book — near-close, collapses to U) // UW /u/ → U (moon) var arpabetToShadow = map[string]string{ // Vowels (monophthongs) "AA": A, "AE": A, "AH": A, "AO": O, "EH": E, "ER": R, "IH": I, "IY": I, "UH": U, "UW": U, // Diphthongs (decomposed into Shadow vowel sequences) "AW": A + U, "AY": A + Y, "EY": E + Y, "OW": O, "OY": O + Y, // Consonants "B": B, "CH": Ch, "D": D, "DH": Dh, "F": F, "G": G, "HH": H, "JH": Dzh, "K": K, "L": L, "M": M, "N": N, "NG": Ng, "P": P, "R": R, "S": S, "SH": Sh, "T": T, "TH": Th, "V": V, "W": W, "Y": Y, "Z": Z, "ZH": Zh, } // EnglishDict holds the parsed CMU dictionary. type EnglishDict struct { // words maps lowercase English words to their Shadow transliterations. // Multiple pronunciations are stored with the first (most common) only. words map[string]string } var ( defaultDict *EnglishDict defaultDictOnce sync.Once defaultDictErr error ) // LoadEnglishDict loads the embedded CMU dictionary and builds the // word → Shadow mapping. func LoadEnglishDict() (*EnglishDict, error) { defaultDictOnce.Do(func() { defaultDict, defaultDictErr = loadDict() }) return defaultDict, defaultDictErr } func loadDict() (*EnglishDict, error) { f, err := dictFS.Open("cmudict.txt") if err != nil { return nil, fmt.Errorf("shadow: open embedded dict: %w", err) } defer f.Close() d := &EnglishDict{ words: make(map[string]string, 140000), } sc := bufio.NewScanner(f) for sc.Scan() { line := sc.Text() if len(line) == 0 || line[0] == ';' { continue } // Format: "WORD PH1 PH2 PH3" (two-space separator) // Alternate pronunciations: "WORD(2) PH1 PH2" parts := strings.SplitN(line, " ", 2) if len(parts) != 2 { continue } word := strings.TrimSpace(parts[0]) phonemes := strings.TrimSpace(parts[1]) // Skip alternate pronunciations — keep only the first if strings.Contains(word, "(") { continue } // Normalize word to lowercase word = strings.ToLower(word) // Convert phoneme sequence to Shadow shadow := phonemesToShadow(phonemes) d.words[word] = shadow } if err := sc.Err(); err != nil { return nil, fmt.Errorf("shadow: scan dict: %w", err) } return d, nil } // phonemesToShadow converts a CMU ARPAbet phoneme string to Shadow letters. // Stress digits on vowels are used to place the accent mark. func phonemesToShadow(phonemes string) string { parts := strings.Fields(phonemes) var out strings.Builder for _, ph := range parts { // Strip stress digit (0, 1, 2) from end of vowel phonemes base := ph stress := byte(0) if len(ph) > 1 { last := ph[len(ph)-1] if last >= '0' && last <= '2' { stress = last base = ph[:len(ph)-1] } } s, ok := arpabetToShadow[base] if !ok { // Unknown phoneme — skip continue } out.WriteString(s) // Place accent mark after primary-stressed vowels if stress == '1' { out.WriteString(Accent) } } return out.String() } // FromEnglish transliterates English text into the Shadow Alphabet. // Words are looked up in the CMU dictionary. Words not found are // passed through unchanged (preserving case). Punctuation and // whitespace pass through. func (d *EnglishDict) FromEnglish(text string) string { var out strings.Builder out.Grow(len(text) * 2) i := 0 for i < len(text) { // Skip and copy whitespace if text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r' { out.WriteByte(text[i]) i++ continue } // Collect a word (letters and apostrophes) start := i for i < len(text) && (isWordChar(rune(text[i]))) { i++ } if i > start { word := text[start:i] lower := strings.ToLower(word) if shadow, ok := d.words[lower]; ok { out.WriteString(shadow) } else { // Not in dictionary — pass through unchanged out.WriteString(word) } continue } // Non-word character (punctuation, digits) — pass through out.WriteByte(text[i]) i++ } return out.String() } func isWordChar(r rune) bool { return unicode.IsLetter(r) || r == '\'' } // Lookup returns the Shadow transliteration for a single English word, // or empty string and false if not found. func (d *EnglishDict) Lookup(word string) (string, bool) { s, ok := d.words[strings.ToLower(word)] return s, ok } // Size returns the number of words in the dictionary. func (d *EnglishDict) Size() int { return len(d.words) }