Nik Afiq 5238298b55
Some checks failed
CI / test (push) Successful in 6s
CI / build-ai-gateway (push) Failing after 28s
CI / build-ha-gateway (push) Failing after 24s
CI / build-discord-bot (push) Failing after 29s
Add TTS model components and inference server
- 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.
2026-07-24 22:38:36 +09:00

288 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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---zヲ-ン]` exactly, including its quirks (e.g.
// fullwidth "" (U+FF10) is NOT included, only ""-"").
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 >= '' && r <= '': // - (fullwidth 1-9, NOT 0)
return true
case r >= '' && r <= '': // -
return true
case r >= '' && r <= '': // -
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
}