- Implemented core model components in `modules.py` including various convolutional layers and normalization techniques. - Added transformation functions in `transforms.py` for piecewise rational quadratic transformations. - Created utility functions in `utils.py` for checkpoint management, logging, and hyperparameter handling. - Introduced monotonic alignment functionality with Cython optimization in `monotonic_align`. - Developed a minimal inference server in `server.py` to handle synthesis requests. - Updated requirements to include necessary dependencies for Cython and scipy.
288 lines
8.0 KiB
Go
288 lines
8.0 KiB
Go
// 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
|
||
}
|