USF00 commited on
Commit
493b3af
ยท
0 Parent(s):

Initial commit: FastAPI TTS Project ready for Vast.ai

Browse files
Files changed (9) hide show
  1. .dockerignore +16 -0
  2. .gitignore +23 -0
  3. Dockerfile +30 -0
  4. README.md +104 -0
  5. app.py +128 -0
  6. requirements.txt +8 -0
  7. utils/audio_utils.py +71 -0
  8. utils/config.py +41 -0
  9. utils/text_utils.py +98 -0
.dockerignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ env/
7
+ venv/
8
+ .env
9
+ .venv
10
+ outputs/
11
+ temp/
12
+ models/
13
+ .ipynb_checkpoints/
14
+ *.ipynb
15
+ .git/
16
+ .gitignore
.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # Virtual environments
7
+ venv/
8
+ env/
9
+ .venv/
10
+ .env/
11
+
12
+ # Project specific
13
+ outputs/
14
+ temp/
15
+ models/
16
+
17
+ # Jupyter Notebook
18
+ .ipynb_checkpoints
19
+ *.ipynb
20
+
21
+ # OS generated files
22
+ .DS_Store
23
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official NVIDIA CUDA base image with Ubuntu 22.04 to ensure Vast.ai compatibility and GPU support
2
+ FROM nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04
3
+
4
+ # Set non-interactive to avoid prompts during apt-get
5
+ ENV DEBIAN_FRONTEND=noninteractive
6
+
7
+ # Install Python 3.10, pip, and ffmpeg
8
+ RUN apt-get update && apt-get install -y \
9
+ python3 \
10
+ python3-pip \
11
+ ffmpeg \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Set the working directory
15
+ WORKDIR /app
16
+
17
+ # Copy the requirements file into the container
18
+ COPY requirements.txt .
19
+
20
+ # Install dependencies (including torch for potential GPU inference)
21
+ RUN pip3 install --no-cache-dir -r requirements.txt
22
+
23
+ # Copy the rest of the application code
24
+ COPY . .
25
+
26
+ # Expose the FastAPI port
27
+ EXPOSE 8000
28
+
29
+ # Run the FastAPI application using uvicorn
30
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Text-to-Speech (TTS) FastAPI Service
2
+
3
+ This is a production-ready Text-to-Speech API built with FastAPI and `edge-tts`. It is optimized for asynchronous generation, handles long texts by chunking them, and is structured for deployment on GPU-enabled platforms like Vast.ai.
4
+
5
+ ## Project Structure
6
+
7
+ ```
8
+ project/
9
+ โ”œโ”€โ”€ app.py # FastAPI application
10
+ โ”œโ”€โ”€ requirements.txt # Dependencies
11
+ โ”œโ”€โ”€ Dockerfile # Docker configuration
12
+ โ”œโ”€โ”€ .dockerignore # Docker ignore file
13
+ โ”œโ”€โ”€ models/ # Directory for local models (if added later)
14
+ โ”œโ”€โ”€ outputs/ # Temporary output directory for generated audio
15
+ โ”œโ”€โ”€ temp/ # Temporary chunk processing directory
16
+ โ”œโ”€โ”€ utils/ # Utility modules
17
+ โ”‚ โ”œโ”€โ”€ config.py # Configuration and settings
18
+ โ”‚ โ”œโ”€โ”€ text_utils.py # Text cleaning and chunking logic
19
+ โ”‚ โ””โ”€โ”€ audio_utils.py # TTS generation and audio concatenation logic
20
+ โ””โ”€โ”€ README.md # Documentation
21
+ ```
22
+
23
+ ## Features
24
+ - **FastAPI / Uvicorn**: High-performance asynchronous API.
25
+ - **Robust Chunking**: Automatically chunks long text inputs without breaking sentences.
26
+ - **Edge-TTS Integration**: Uses Microsoft Edge's neural TTS service.
27
+ - **GPU Readiness**: Includes `torch` and memory cleanup (`torch.cuda.empty_cache()`) for compatibility with Vast.ai templates, allowing easy drop-in of local GPU models later.
28
+ - **Temp File Cleanup**: Automatically cleans up temporary chunks and output files after they are served.
29
+
30
+ ## How to Run Locally
31
+
32
+ ### Prerequisites
33
+ - Python 3.10+
34
+ - `ffmpeg` installed on your system.
35
+
36
+ ### Steps
37
+ 1. Install dependencies:
38
+ ```bash
39
+ pip install -r requirements.txt
40
+ ```
41
+ 2. Run the application:
42
+ ```bash
43
+ uvicorn app:app --host 0.0.0.0 --port 8000 --reload
44
+ ```
45
+ 3. Access the API documentation at `http://localhost:8000/docs`.
46
+
47
+ ## How to Build and Run the Docker Image
48
+
49
+ 1. Build the Docker image:
50
+ ```bash
51
+ docker build -t tts-fastapi .
52
+ ```
53
+ 2. Run the Docker container:
54
+ ```bash
55
+ docker run -p 8000:8000 --gpus all tts-fastapi
56
+ ```
57
+ *(Note: `--gpus all` is optional if you are strictly using `edge-tts` but recommended if you are deploying to a GPU-enabled instance and plan to use local PyTorch models).*
58
+
59
+ ## How to Deploy on Vast.ai
60
+
61
+ 1. Spin up an instance on Vast.ai.
62
+ 2. Under "Template Configuration", select a base image such as `nvidia/cuda:11.8.0-cudnn8-runtime-ubuntu22.04` or simply check the box for "Use Custom Image" and enter `python:3.10` or a custom Docker Hub image if you have pushed yours.
63
+ 3. If using an unconfigured instance, SSH in and run:
64
+ ```bash
65
+ git clone <your-repo-url>
66
+ cd tts-repo
67
+ docker build -t tts-fastapi .
68
+ docker run -d -p 8000:8000 --gpus all tts-fastapi
69
+ ```
70
+ 4. Ensure port `8000` is mapped and exposed in your Vast.ai instance settings so you can reach the API externally.
71
+
72
+ ## API Usage
73
+
74
+ ### `POST /generate`
75
+
76
+ **Endpoint**: `/generate`
77
+ **Content-Type**: `application/json`
78
+
79
+ **Request Body**:
80
+ ```json
81
+ {
82
+ "text": "Hello! This is a test of the Text to Speech API. It can handle very long text by splitting it appropriately.",
83
+ "lang": "en",
84
+ "voice_id": "preset_1"
85
+ }
86
+ ```
87
+
88
+ **Parameters**:
89
+ - `text` (string, required): The text to synthesize.
90
+ - `lang` (string, optional): Language code (e.g., `"en"`, `"ar"`). Defaults to `"en"`.
91
+ - `voice_id` (string, optional): A preset voice from the registry (`preset_1` to `preset_5`). Defaults to `"preset_1"`.
92
+ - `custom_voice_name` (string, optional): A specific Edge-TTS voice name (e.g., `"en-US-AriaNeural"`).
93
+
94
+ **Response**:
95
+ Returns the generated audio file (`audio/wav`).
96
+
97
+ ### Example using `curl`
98
+
99
+ ```bash
100
+ curl -X POST "http://localhost:8000/generate" \
101
+ -H "Content-Type: application/json" \
102
+ -d '{"text":"Welcome to the text to speech service.","voice_id":"preset_2"}' \
103
+ --output generated_speech.wav
104
+ ```
app.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
2
+ from fastapi.responses import FileResponse
3
+ from pydantic import BaseModel, Field
4
+ import uuid
5
+ import os
6
+ import shutil
7
+ import asyncio
8
+ from typing import Optional
9
+ from pathlib import Path
10
+
11
+ # GPU Optimization Imports (as per user request)
12
+ import torch
13
+
14
+ from utils.config import (
15
+ DIR_TEMP, DIR_OUTPUT, DEFAULT_LANG, DEFAULT_VOICE_ID,
16
+ resolve_voice_name, MAX_CHARS, MIN_CHARS
17
+ )
18
+ from utils.text_utils import chunk_text
19
+ from utils.audio_utils import tts_to_wav, concat_wavs_by_timeline
20
+
21
+ app = FastAPI(
22
+ title="TTS FastAPI Service",
23
+ description="Production-ready Text-to-Speech API using Edge-TTS",
24
+ version="1.0.0"
25
+ )
26
+
27
+ # Startup event to clear temp and output directories and initialize GPU
28
+ @app.on_event("startup")
29
+ async def startup_event():
30
+ # Cleanup previous runs
31
+ for directory in [DIR_TEMP, DIR_OUTPUT]:
32
+ for item in directory.glob("*"):
33
+ if item.is_file():
34
+ item.unlink()
35
+ elif item.is_dir():
36
+ shutil.rmtree(item)
37
+
38
+ # Optional GPU Warmup/Initialization
39
+ if torch.cuda.is_available():
40
+ print(f"CUDA is available: {torch.cuda.get_device_name(0)}")
41
+ torch.cuda.empty_cache()
42
+ else:
43
+ print("CUDA is not available. Running on CPU (edge-tts is API based).")
44
+
45
+ class TTSRequest(BaseModel):
46
+ text: str = Field(..., description="Text to synthesize into speech")
47
+ lang: str = Field(DEFAULT_LANG, description="Language code (e.g., 'en', 'ar')")
48
+ voice_id: str = Field(DEFAULT_VOICE_ID, description="Voice ID from registry")
49
+ custom_voice_name: Optional[str] = Field(None, description="Direct Voice name, e.g. 'en-US-GuyNeural'")
50
+
51
+ def remove_file(path: Path):
52
+ """Background task to remove a file after returning it."""
53
+ try:
54
+ if path.exists():
55
+ path.unlink()
56
+ except Exception as e:
57
+ print(f"Failed to delete {path}: {e}")
58
+
59
+ @app.post("/generate", summary="Generate TTS Audio from Text")
60
+ async def generate_audio(request: TTSRequest, background_tasks: BackgroundTasks):
61
+ if not request.text.strip():
62
+ raise HTTPException(status_code=400, detail="Text cannot be empty.")
63
+
64
+ try:
65
+ voice_name = resolve_voice_name(request.voice_id, request.custom_voice_name)
66
+ except ValueError as e:
67
+ raise HTTPException(status_code=400, detail=str(e))
68
+
69
+ req_id = uuid.uuid4().hex[:8]
70
+ req_temp_dir = DIR_TEMP / req_id
71
+ req_temp_dir.mkdir(parents=True, exist_ok=True)
72
+
73
+ output_wav = DIR_OUTPUT / f"output_{req_id}.wav"
74
+
75
+ try:
76
+ # Wrap inference simulation in no_grad for GPU memory optimization requirements
77
+ with torch.no_grad():
78
+ # 1. Chunk Text
79
+ chunks = chunk_text(request.text, lang=request.lang, max_chars=MAX_CHARS, min_chars=MIN_CHARS)
80
+
81
+ if not chunks:
82
+ raise HTTPException(status_code=400, detail="No valid text found after cleaning.")
83
+
84
+ # 2. Generate Audio for each chunk asynchronously
85
+ tasks = []
86
+ chunk_wav_paths = []
87
+
88
+ for i, chunk in enumerate(chunks):
89
+ chunk_wav = req_temp_dir / f"chunk_{i:04d}.wav"
90
+ chunk_wav_paths.append(chunk_wav)
91
+ tasks.append(tts_to_wav(chunk, chunk_wav, voice_name))
92
+
93
+ # Run all edge-tts requests concurrently
94
+ await asyncio.gather(*tasks)
95
+
96
+ # 3. Concatenate
97
+ if len(chunk_wav_paths) == 1:
98
+ # If only one chunk, just move it to output
99
+ shutil.move(str(chunk_wav_paths[0]), str(output_wav))
100
+ else:
101
+ await concat_wavs_by_timeline(chunk_wav_paths, output_wav)
102
+
103
+ # Ensure CUDA cache is cleared after generation to prevent memory leaks
104
+ if torch.cuda.is_available():
105
+ torch.cuda.empty_cache()
106
+
107
+ # Schedule cleanup of the output file after it's returned
108
+ background_tasks.add_task(remove_file, output_wav)
109
+
110
+ # Cleanup the temp directory for this request
111
+ shutil.rmtree(req_temp_dir, ignore_errors=True)
112
+
113
+ return FileResponse(
114
+ path=output_wav,
115
+ media_type="audio/wav",
116
+ filename="generated_speech.wav"
117
+ )
118
+
119
+ except Exception as e:
120
+ # Cleanup on failure
121
+ shutil.rmtree(req_temp_dir, ignore_errors=True)
122
+ if output_wav.exists():
123
+ output_wav.unlink()
124
+
125
+ if torch.cuda.is_available():
126
+ torch.cuda.empty_cache()
127
+
128
+ raise HTTPException(status_code=500, detail=str(e))
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0
3
+ edge-tts==6.1.9
4
+ nest_asyncio==1.5.8
5
+ tqdm==4.66.1
6
+ unidecode==1.3.7
7
+ torch==2.1.0 # Added for GPU support and optimization readiness
8
+ python-multipart==0.0.6
utils/audio_utils.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import edge_tts
3
+ import subprocess
4
+ import wave
5
+ import contextlib
6
+ from pathlib import Path
7
+ from utils.config import EDGE_RATE, EDGE_PITCH, SAMPLE_RATE
8
+
9
+ def wav_duration_seconds(path: Path) -> float:
10
+ with contextlib.closing(wave.open(str(path), "rb")) as wf:
11
+ return wf.getnframes() / float(wf.getframerate())
12
+
13
+ async def _edge_save_mp3(text: str, voice_name: str, out_mp3: Path, rate: str, pitch: str):
14
+ comm = edge_tts.Communicate(text=text, voice=voice_name, rate=rate, pitch=pitch)
15
+ await comm.save(str(out_mp3))
16
+
17
+ async def tts_to_wav(text: str, out_wav: Path, voice_name: str) -> None:
18
+ """
19
+ Generates consistent PCM WAV (mono, SAMPLE_RATE) for easy concatenation.
20
+ """
21
+ out_wav.parent.mkdir(parents=True, exist_ok=True)
22
+ tmp_mp3 = out_wav.with_suffix(".tmp.mp3")
23
+
24
+ await _edge_save_mp3(text, voice_name, tmp_mp3, EDGE_RATE, EDGE_PITCH)
25
+
26
+ # Convert mp3 to wav using ffmpeg
27
+ process = await asyncio.create_subprocess_exec(
28
+ "ffmpeg", "-y", "-i", str(tmp_mp3),
29
+ "-ac", "1", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s16le",
30
+ str(out_wav),
31
+ stdout=subprocess.PIPE,
32
+ stderr=subprocess.PIPE
33
+ )
34
+ stdout, stderr = await process.communicate()
35
+
36
+ if process.returncode != 0:
37
+ raise RuntimeError(f"FFmpeg failed with error:\\n{stderr.decode()}")
38
+
39
+ try:
40
+ tmp_mp3.unlink()
41
+ except Exception:
42
+ pass
43
+
44
+ async def concat_wavs_by_timeline(wav_paths: list[Path], out_wav: Path):
45
+ """
46
+ Concatenates multiple WAV files into a single WAV file.
47
+ """
48
+ if not wav_paths:
49
+ raise ValueError("No WAV files to concatenate.")
50
+
51
+ concat_file = out_wav.with_suffix(".concat.txt")
52
+ with concat_file.open("w", encoding="utf-8") as f:
53
+ for p in wav_paths:
54
+ # ffmpeg concat demuxer requires absolute paths or relative to the list file
55
+ f.write(f"file '{str(p.resolve())}'\\n")
56
+
57
+ process = await asyncio.create_subprocess_exec(
58
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file),
59
+ "-ac", "1", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s16le", str(out_wav),
60
+ stdout=subprocess.PIPE,
61
+ stderr=subprocess.PIPE
62
+ )
63
+ stdout, stderr = await process.communicate()
64
+
65
+ if process.returncode != 0:
66
+ raise RuntimeError(f"FFmpeg concatenation failed:\\n{stderr.decode()}")
67
+
68
+ try:
69
+ concat_file.unlink()
70
+ except Exception:
71
+ pass
utils/config.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ # ---------- Base Directories ----------
5
+ BASE_DIR = Path(__file__).parent.parent
6
+ DIR_TEMP = BASE_DIR / "temp"
7
+ DIR_OUTPUT = BASE_DIR / "outputs"
8
+ DIR_MODELS = BASE_DIR / "models"
9
+
10
+ # Ensure directories exist
11
+ for d in [DIR_TEMP, DIR_OUTPUT, DIR_MODELS]:
12
+ d.mkdir(parents=True, exist_ok=True)
13
+
14
+ # ---------- TTS (Edge-TTS) Configuration ----------
15
+ DEFAULT_VOICE_ID = "preset_1"
16
+ CUSTOM_VOICE_NAME = None # e.g. "en-US-GuyNeural"
17
+ EDGE_RATE = "+0%"
18
+ EDGE_PITCH = "+0Hz"
19
+
20
+ VOICE_REGISTRY = {
21
+ "preset_1": "en-US-GuyNeural",
22
+ "preset_2": "en-US-JennyNeural",
23
+ "preset_3": "en-GB-RyanNeural",
24
+ "preset_4": "en-GB-SoniaNeural",
25
+ "preset_5": "ar-EG-SalmaNeural",
26
+ }
27
+
28
+ # ---------- Text + Chunking ----------
29
+ DEFAULT_LANG = "en"
30
+ MAX_CHARS = 220
31
+ MIN_CHARS = 20
32
+
33
+ # ---------- Audio Formats ----------
34
+ SAMPLE_RATE = 22050 # Output sample rate for consistent mono PCM wav
35
+
36
+ def resolve_voice_name(voice_id: str, custom_name: str = None) -> str:
37
+ if custom_name and str(custom_name).strip():
38
+ return str(custom_name).strip()
39
+ if voice_id not in VOICE_REGISTRY:
40
+ raise ValueError(f"Unknown VOICE_ID={voice_id}. Use {list(VOICE_REGISTRY.keys())} or set CUSTOM_VOICE_NAME.")
41
+ return VOICE_REGISTRY[voice_id]
utils/text_utils.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import List
3
+
4
+ _AR_DIACRITICS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]")
5
+ _SENT_SPLIT = re.compile(r"(?<=[\\.\\!\\?])\s+|(?<=[\u061F\u06D4])\s+|(?<=\n)\s*")
6
+ _SOFT_SPLIT = re.compile(r"(?<=[,;:ุŒุ›])\s+")
7
+
8
+ def normalize_whitespace(s: str) -> str:
9
+ s = s.replace("\u00A0", " ")
10
+ s = re.sub(r"[ \t]+", " ", s)
11
+ s = re.sub(r"\n{3,}", "\n\n", s)
12
+ return s.strip()
13
+
14
+ def normalize_punctuation(s: str) -> str:
15
+ s = s.replace("โ€ฆ", "...")
16
+ s = s.replace("โ€œ", '"').replace("โ€", '"').replace("โ€™", "'").replace("โ€˜", "'")
17
+ s = s.replace("โ€”", "-").replace("โ€“", "-")
18
+ s = re.sub(r"\s+([,.;:!?])", r"\1", s)
19
+ s = re.sub(r"([,.;:!?])([^\s])", r"\1 \2", s)
20
+ return s
21
+
22
+ def normalize_arabic(s: str) -> str:
23
+ s = s.replace("\u0640", "") # tatweel
24
+ s = _AR_DIACRITICS.sub("", s) # diacritics
25
+ s = s.replace("ุŒ", "ุŒ ").replace("ุŸ", "ุŸ ").replace("ุ›", "ุ› ")
26
+ s = re.sub(r"\s+", " ", s)
27
+ return s.strip()
28
+
29
+ def clean_text(s: str, lang: str = "en") -> str:
30
+ s = normalize_whitespace(s)
31
+ s = normalize_punctuation(s)
32
+ if lang.lower().startswith("ar"):
33
+ s = normalize_arabic(s)
34
+ s = re.sub(r"[\u200B-\u200F\u202A-\u202E]", "", s) # bidi junk
35
+ return normalize_whitespace(s)
36
+
37
+ def split_into_sentences(text: str) -> List[str]:
38
+ text = normalize_whitespace(text)
39
+ return [p.strip() for p in _SENT_SPLIT.split(text) if p.strip()]
40
+
41
+ def hard_wrap_by_words(s: str, max_chars: int) -> List[str]:
42
+ words = s.split()
43
+ out, cur = [], []
44
+ for w in words:
45
+ cand = (" ".join(cur + [w])).strip()
46
+ if len(cand) <= max_chars:
47
+ cur.append(w)
48
+ else:
49
+ if cur:
50
+ out.append(" ".join(cur))
51
+ cur = [w]
52
+ else:
53
+ out.append(w[:max_chars])
54
+ rest = w[max_chars:]
55
+ if rest:
56
+ cur = [rest]
57
+ if cur:
58
+ out.append(" ".join(cur))
59
+ return [x.strip() for x in out if x.strip()]
60
+
61
+ def chunk_text(text: str, lang: str = "en", max_chars: int = 220, min_chars: int = 20) -> List[str]:
62
+ text = clean_text(text, lang=lang)
63
+ sents = split_into_sentences(text)
64
+
65
+ chunks, cur = [], ""
66
+
67
+ def flush():
68
+ nonlocal cur
69
+ if cur.strip():
70
+ chunks.append(cur.strip())
71
+ cur = ""
72
+
73
+ for sent in sents:
74
+ if len(sent) > max_chars:
75
+ subs = [x.strip() for x in _SOFT_SPLIT.split(sent) if x.strip()]
76
+ if len(subs) == 1:
77
+ subs = hard_wrap_by_words(sent, max_chars=max_chars)
78
+ else:
79
+ subs = [sent]
80
+
81
+ for part in subs:
82
+ if not cur:
83
+ cur = part
84
+ elif len(cur) + 1 + len(part) <= max_chars:
85
+ cur = cur + " " + part
86
+ else:
87
+ flush()
88
+ cur = part
89
+
90
+ flush()
91
+
92
+ merged = []
93
+ for ch in chunks:
94
+ if merged and len(ch) < min_chars:
95
+ merged[-1] = (merged[-1] + " " + ch).strip()
96
+ else:
97
+ merged.append(ch)
98
+ return merged