add text chunking for long-form generation (Fixes #4)

This commit is contained in:
ANLGBOY
2025-11-19 18:08:30 +09:00
parent d31536d9fc
commit c31b6745e4
30 changed files with 1813 additions and 102 deletions

View File

@@ -2,6 +2,10 @@
This guide provides examples for running TTS inference using `example_onnx.go`.
## 📰 Update News
**2025.11.19** - Added automatic text chunking for long-form inference. Long texts are split into chunks and synthesized with natural pauses.
## Installation
This project uses Go modules for dependency management.
@@ -73,6 +77,7 @@ This will use:
Process multiple voice styles and texts at once:
```bash
go run example_onnx.go helper.go \
--batch \
-voice-style "assets/voice_styles/M1.json,assets/voice_styles/F1.json" \
-text "The sun sets behind the mountains, painting the sky in shades of pink and orange.|The weather is beautiful and sunny outside. A gentle breeze makes the air feel fresh and pleasant."
```
@@ -96,6 +101,23 @@ This will:
- Use 10 denoising steps instead of the default 5
- Produce higher quality output at the cost of slower inference
### Example 4: Long-Form Inference
The system automatically chunks long texts into manageable segments, synthesizes each segment separately, and concatenates them with natural pauses (0.3 seconds by default) into a single audio file. This happens by default when you don't use the `--batch` flag:
```bash
go run example_onnx.go helper.go \
-voice-style "assets/voice_styles/M1.json" \
-text "This is a very long text that will be automatically split into multiple chunks. The system will process each chunk separately and then concatenate them together with natural pauses between segments. This ensures that even very long texts can be processed efficiently while maintaining natural speech flow and avoiding memory issues."
```
This will:
- Automatically split the text into chunks based on paragraph and sentence boundaries
- Synthesize each chunk separately
- Add 0.3 seconds of silence between chunks for natural pauses
- Concatenate all chunks into a single audio file
**Note**: Automatic text chunking is disabled when using `--batch` mode. In batch mode, each text is processed as-is without chunking.
## Available Arguments
| Argument | Type | Default | Description |
@@ -107,10 +129,12 @@ This will:
| `-voice-style` | str | `assets/voice_styles/M1.json` | Voice style file path(s), comma-separated |
| `-text` | str | (long default text) | Text(s) to synthesize, pipe-separated |
| `-save-dir` | str | `results` | Output directory |
| `--batch` | flag | false | Enable batch mode (multiple text-style pairs, disables automatic chunking) |
## Notes
- **Batch Processing**: The number of `-voice-style` files must match the number of `-text` entries
- **Batch Processing**: When using `--batch`, the number of `-voice-style` files must match the number of `-text` entries
- **Automatic Chunking**: Without `--batch`, long texts are automatically split and concatenated with 0.3s pauses
- **Quality vs Speed**: Higher `-total-step` values produce better quality but take longer
- **GPU Support**: GPU mode is not supported yet

View File

@@ -19,6 +19,7 @@ type Args struct {
voiceStyle []string
text []string
saveDir string
batch bool
}
func parseArgs() *Args {
@@ -29,6 +30,7 @@ func parseArgs() *Args {
flag.IntVar(&args.totalStep, "total-step", 5, "Number of denoising steps")
flag.IntVar(&args.nTest, "n-test", 4, "Number of times to generate")
flag.StringVar(&args.saveDir, "save-dir", "results", "Output directory")
flag.BoolVar(&args.batch, "batch", false, "Enable batch mode (multiple text-style pairs)")
var voiceStyleStr, textStr string
flag.StringVar(&voiceStyleStr, "voice-style", "assets/voice_styles/M1.json", "Voice style file path(s), comma-separated")
@@ -65,11 +67,14 @@ func main() {
saveDir := args.saveDir
voiceStylePaths := args.voiceStyle
textList := args.text
batch := args.batch
if len(voiceStylePaths) != len(textList) {
fmt.Printf("Error: Number of voice styles (%d) must match number of texts (%d)\n",
len(voiceStylePaths), len(textList))
os.Exit(1)
if batch {
if len(voiceStylePaths) != len(textList) {
fmt.Printf("Error: Number of voice styles (%d) must match number of texts (%d)\n",
len(voiceStylePaths), len(textList))
os.Exit(1)
}
}
bsz := len(voiceStylePaths)
@@ -115,21 +120,46 @@ func main() {
var wav []float32
var duration []float32
Timer("Generating speech from text", func() interface{} {
w, d, err := textToSpeech.Call(textList, style, totalStep)
if err != nil {
fmt.Printf("Error generating speech: %v\n", err)
os.Exit(1)
}
wav = w
duration = d
return nil
})
if batch {
Timer("Generating speech from text", func() interface{} {
w, d, err := textToSpeech.Batch(textList, style, totalStep)
if err != nil {
fmt.Printf("Error generating speech: %v\n", err)
os.Exit(1)
}
wav = w
duration = d
return nil
})
} else {
Timer("Generating speech from text", func() interface{} {
w, d, err := textToSpeech.Call(textList[0], style, totalStep, 0.3)
if err != nil {
fmt.Printf("Error generating speech: %v\n", err)
os.Exit(1)
}
wav = w
duration = []float32{d}
return nil
})
}
// Save outputs
for i := 0; i < bsz; i++ {
fname := fmt.Sprintf("%s_%d.wav", sanitizeFilename(textList[i], 20), n+1)
wavOut := extractWavSegment(wav, duration[i], textToSpeech.SampleRate, i, bsz)
var wavOut []float64
if batch {
wavOut = extractWavSegment(wav, duration[i], textToSpeech.SampleRate, i, bsz)
} else {
// For non-batch mode, wav is a single concatenated audio
wavLen := int(float32(textToSpeech.SampleRate) * duration[0])
wavOut = make([]float64, wavLen)
for j := 0; j < wavLen && j < len(wav); j++ {
wavOut[j] = float64(wav[j])
}
}
outputPath := filepath.Join(saveDir, fname)
if err := writeWavFile(outputPath, wavOut, textToSpeech.SampleRate); err != nil {

View File

@@ -7,6 +7,8 @@ import (
"math/rand"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-audio/audio"
@@ -145,6 +147,184 @@ func (up *UnicodeProcessor) Call(textList []string) ([][]int64, [][][]float64) {
return textIDs, textMask
}
// Text chunking utilities
const maxChunkLength = 300
var abbreviations = []string{
"Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.",
"St.", "Ave.", "Rd.", "Blvd.", "Dept.", "Inc.", "Ltd.",
"Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.",
}
func chunkText(text string, maxLen int) []string {
if maxLen == 0 {
maxLen = maxChunkLength
}
text = strings.TrimSpace(text)
if text == "" {
return []string{""}
}
// Split by paragraphs
paragraphs := regexp.MustCompile(`\n\s*\n`).Split(text, -1)
var chunks []string
for _, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
continue
}
if len(para) <= maxLen {
chunks = append(chunks, para)
continue
}
// Split by sentences
sentences := splitSentences(para)
var current strings.Builder
currentLen := 0
for _, sentence := range sentences {
sentence = strings.TrimSpace(sentence)
if sentence == "" {
continue
}
sentenceLen := len(sentence)
if sentenceLen > maxLen {
// If sentence is longer than maxLen, split by comma or space
if current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
currentLen = 0
}
// Try splitting by comma
parts := strings.Split(sentence, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
partLen := len(part)
if partLen > maxLen {
// Split by space as last resort
words := strings.Fields(part)
var wordChunk strings.Builder
wordChunkLen := 0
for _, word := range words {
wordLen := len(word)
if wordChunkLen+wordLen+1 > maxLen && wordChunk.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(wordChunk.String()))
wordChunk.Reset()
wordChunkLen = 0
}
if wordChunk.Len() > 0 {
wordChunk.WriteString(" ")
wordChunkLen++
}
wordChunk.WriteString(word)
wordChunkLen += wordLen
}
if wordChunk.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(wordChunk.String()))
}
} else {
if currentLen+partLen+1 > maxLen && current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
currentLen = 0
}
if current.Len() > 0 {
current.WriteString(", ")
currentLen += 2
}
current.WriteString(part)
currentLen += partLen
}
}
continue
}
if currentLen+sentenceLen+1 > maxLen && current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
current.Reset()
currentLen = 0
}
if current.Len() > 0 {
current.WriteString(" ")
currentLen++
}
current.WriteString(sentence)
currentLen += sentenceLen
}
if current.Len() > 0 {
chunks = append(chunks, strings.TrimSpace(current.String()))
}
}
if len(chunks) == 0 {
return []string{""}
}
return chunks
}
func splitSentences(text string) []string {
// Go's regexp doesn't support lookbehind, so we use a simpler approach
// Split on sentence boundaries and then check if they're abbreviations
re := regexp.MustCompile(`([.!?])\s+`)
// Find all matches
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
return []string{text}
}
var sentences []string
lastEnd := 0
for _, match := range matches {
// Get the text before the punctuation
beforePunc := text[lastEnd:match[0]]
// Check if this ends with an abbreviation
isAbbrev := false
for _, abbrev := range abbreviations {
if strings.HasSuffix(strings.TrimSpace(beforePunc+text[match[0]:match[0]+1]), abbrev) {
isAbbrev = true
break
}
}
if !isAbbrev {
// This is a real sentence boundary
sentences = append(sentences, text[lastEnd:match[1]])
lastEnd = match[1]
}
}
// Add the remaining text
if lastEnd < len(text) {
sentences = append(sentences, text[lastEnd:])
}
if len(sentences) == 0 {
return []string{text}
}
return sentences
}
// Utility functions
func preprocessText(text string) string {
// Simple normalization (Go doesn't have built-in NFKD normalization)
@@ -392,7 +572,7 @@ func (tts *TextToSpeech) sampleNoisyLatent(durOnnx []float32) ([][][]float64, []
return noisyLatent, latentMask
}
func (tts *TextToSpeech) Call(textList []string, style *Style, totalStep int) ([]float32, []float32, error) {
func (tts *TextToSpeech) _infer(textList []string, style *Style, totalStep int) ([]float32, []float32, error) {
bsz := len(textList)
// Process text
@@ -510,6 +690,44 @@ func (tts *TextToSpeech) Call(textList []string, style *Style, totalStep int) ([
return wav, durOnnx, nil
}
// Call synthesizes speech from a single text with automatic chunking
func (tts *TextToSpeech) Call(text string, style *Style, totalStep int, silenceDuration float32) ([]float32, float32, error) {
chunks := chunkText(text, 0)
var wavCat []float32
var durCat float32
for i, chunk := range chunks {
wav, duration, err := tts._infer([]string{chunk}, style, totalStep)
if err != nil {
return nil, 0, err
}
dur := duration[0]
wavLen := int(float32(tts.SampleRate) * dur)
wavChunk := wav[:wavLen]
if i == 0 {
wavCat = wavChunk
durCat = dur
} else {
silenceLen := int(silenceDuration * float32(tts.SampleRate))
silence := make([]float32, silenceLen)
wavCat = append(wavCat, silence...)
wavCat = append(wavCat, wavChunk...)
durCat += silenceDuration + dur
}
}
return wavCat, durCat, nil
}
// Batch synthesizes speech from multiple texts
func (tts *TextToSpeech) Batch(textList []string, style *Style, totalStep int) ([]float32, []float32, error) {
return tts._infer(textList, style, totalStep)
}
func (tts *TextToSpeech) Destroy() {
if tts.dpOrt != nil {
tts.dpOrt.Destroy()