// Package jtalk implements driven.TextNormalizer by shelling out to the // open_jtalk CLI, reproducing uma-tts-api's japanese_cleaners pipeline // (text/cleaners.py) exactly - verified rune-for-rune against the real // Python implementation during TTS_GATEWAY_PLAN.md's Phase 0 spike. See // tmp/reference/uma-tts-api/spike/FINDINGS.md for the verification detail. package jtalk import ( "bytes" "context" "fmt" "io/fs" "os" "os/exec" "path/filepath" "regexp" "strings" "gitea.nik4nao.com/nik/home-services/tts-gateway/internal/config" "gitea.nik4nao.com/nik/home-services/tts-gateway/internal/core/domain" ) type Client struct { bin string dictDir string voice string } // NewClient constructs the open_jtalk CLI adapter, auto-discovering the // dictionary directory and voice file when the config leaves them empty // (matches how the Phase 0 spike located them on nik-gpu's open-jtalk + // open-jtalk-mecab-naist-jdic + hts-voice-* apt packages). func NewClient(cfg *config.Config) (*Client, error) { dictDir := cfg.OpenJTalkDictDir if dictDir == "" { var err error dictDir, err = findDictDir() if err != nil { return nil, err } } voice := cfg.OpenJTalkVoice if voice == "" { var err error voice, err = findVoice() if err != nil { return nil, err } } return &Client{bin: cfg.OpenJTalkBin, dictDir: dictDir, voice: voice}, nil } var dictDirCandidates = []string{ "/usr/lib/*/open_jtalk/open_jtalk_dic_utf_8-*", "/usr/share/open_jtalk/dic/*", "/var/lib/mecab/dic/open-jtalk/naist-jdic", } func findDictDir() (string, error) { for _, pattern := range dictDirCandidates { matches, err := filepath.Glob(pattern) if err != nil { continue } for _, m := range matches { if info, err := os.Stat(m); err == nil && info.IsDir() { return m, nil } } } return "", fmt.Errorf("no open_jtalk dictionary dir found under %v", dictDirCandidates) } func findVoice() (string, error) { const root = "/usr/share/hts-voice" var found string err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil || found != "" { return nil } if !d.IsDir() && strings.HasSuffix(path, ".htsvoice") { found = path } return nil }) if err != nil || found == "" { return "", fmt.Errorf("no .htsvoice file found under %s", root) } return found, nil } // Normalize reproduces japanese_cleaners(text) then maps the result // character-by-character to symbol IDs (text_to_sequence's actual // behavior - see FINDINGS.md on why this is intentional-if-buggy fidelity, // not a mistake), and finally intersperses blank tokens (add_blank=true). func (c *Client) Normalize(ctx context.Context, text string) ([]int64, error) { cleaned, err := c.japaneseCleaners(ctx, text) if err != nil { return nil, err } seq := make([]int64, 0, len(cleaned)) for _, r := range cleaned { id, ok := domain.SymbolToID[r] if !ok { return nil, fmt.Errorf("jtalk: no symbol for rune %q in cleaned text %q", r, cleaned) } seq = append(seq, int64(id)) } return domain.Intersperse(seq, 0), nil } // japaneseCleaners ports text/cleaners.py::japanese_cleaners rune-for-rune. func (c *Client) japaneseCleaners(ctx context.Context, text string) (string, error) { spans, marks := splitByMarks(text) var b strings.Builder for i, mark := range marks { if spans[i] != "" { phonemes, err := c.g2p(ctx, spans[i]) if err != nil { return "", err } b.WriteString(phonemes) } b.WriteString(markToASCII(mark)) } if last := spans[len(spans)-1]; last != "" { phonemes, err := c.g2p(ctx, last) if err != nil { return "", err } b.WriteString(phonemes) } out := b.String() if out != "" { r := []rune(out) last := r[len(r)-1] if (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') { out += "." } } return out, nil } // splitByMarks reproduces re.split(_japanese_marks, text) / re.findall(...): // every individual non-Japanese-classified rune is its own split point, so // consecutive marks yield an empty span between them. len(spans) is always // len(marks)+1. func splitByMarks(text string) (spans []string, marks []rune) { var current strings.Builder for _, r := range text { if isJapaneseChar(r) { current.WriteRune(r) continue } spans = append(spans, current.String()) marks = append(marks, r) current.Reset() } spans = append(spans, current.String()) return spans, marks } // isJapaneseChar mirrors pyopenjtalk's cleaner regex // `[A-Za-z\d々぀-ヿ一-鿿1-9A-Za-zヲ-ン]` exactly, including its quirks (e.g. // fullwidth "0" (U+FF10) is NOT included, only "1"-"9"). func isJapaneseChar(r rune) bool { switch { case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9': return true case r == '々': // U+3005 return true case r >= '぀' && r <= 'ヿ': // ぀-ヿ: Hiragana + Katakana return true case r >= '一' && r <= '鿿': // 一-鿿: CJK Unified Ideographs return true case r >= '1' && r <= '9': // 1-9 (fullwidth 1-9, NOT 0) return true case r >= 'A' && r <= 'Z': // A-Z return true case r >= 'a' && r <= 'z': // a-z return true case r >= 'ヲ' && r <= 'ン': // ヲ-ン (halfwidth katakana) return true default: return false } } // markToASCII reproduces unidecode(mark).replace(' ', ”) for the small set // of punctuation marks _japanese_marks actually matches in practice (verified // against real unidecode output during the Phase 0 spike). ASCII runes are // unidecode no-ops so pass through unchanged; anything else not in this // table is dropped (empty string) rather than guessing a transliteration. func markToASCII(r rune) string { if r < 128 { return string(r) } if s, ok := markTable[r]; ok { return s } return "" } var markTable = map[rune]string{ '、': ",", '。': ".", '「': "[", '」': "]", '『': "{", '』': "}", '・': "*", '〜': "~", '!': "!", '?': "?", ',': ",", '.': ".", ' ': " ", // fullwidth space '…': "...", '―': "--", } var labelPhonemeRe = regexp.MustCompile(`-([^+]+)\+`) // g2p runs the open_jtalk CLI on one Japanese-only span and returns the // concatenated phoneme string with 'pau' tokens and spaces stripped, matching // pyopenjtalk.g2p(span, kana=False).replace('pau',”).replace(' ',”). func (c *Client) g2p(ctx context.Context, span string) (string, error) { cmd := exec.CommandContext(ctx, c.bin, "-x", c.dictDir, "-m", c.voice, "-ot", "/dev/stdout", "-ow", "/dev/null") cmd.Stdin = strings.NewReader(span) var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { return "", fmt.Errorf("jtalk: open_jtalk run: %w", err) } phonemes, err := parseOutputLabelPhonemes(stdout.String()) if err != nil { return "", err } var b strings.Builder for _, p := range phonemes { if p == "pau" { continue } b.WriteString(p) } return b.String(), nil } // parseOutputLabelPhonemes extracts the current-phoneme field from each HTS // full-context label line within open_jtalk's verbose "-ot /dev/stdout" // output, scoped to only the [Output label] section - the rest of the // verbose dump ([Global parameter] etc.) can contain lines that spuriously // match the same "-X+" pattern, which silently corrupted an earlier version // of this parser (see FINDINGS.md). Boundary sil tokens are stripped. func parseOutputLabelPhonemes(rawStdout string) ([]string, error) { const startMarker = "[Output label]" _, section, ok := strings.Cut(rawStdout, startMarker) if !ok { return nil, fmt.Errorf("jtalk: %q not found in open_jtalk output", startMarker) } if end := strings.IndexByte(section, '['); end != -1 { section = section[:end] } var phonemes []string for line := range strings.SplitSeq(strings.TrimSpace(section), "\n") { m := labelPhonemeRe.FindStringSubmatch(line) if m != nil { phonemes = append(phonemes, m[1]) } } if len(phonemes) > 0 && phonemes[0] == "sil" { phonemes = phonemes[1:] } if len(phonemes) > 0 && phonemes[len(phonemes)-1] == "sil" { phonemes = phonemes[:len(phonemes)-1] } return phonemes, nil }