Trim sentence edges; sentence_pause_ms (default 350 ms)
Browse files- README.md +1 -0
- swar/inference.py +36 -13
README.md
CHANGED
|
@@ -69,6 +69,7 @@ python -m swar.inference "नमस्ते, यहाँलाई स्वा
|
|
| 69 |
| `voice` | a name from `voices/` (default `bijaya`), a `.pt` of style vectors, or an audio clip (wav / flac / mp3 / ogg) |
|
| 70 |
| `speed` | 0.5-2.0, scales the predicted durations (1.0 = as predicted) |
|
| 71 |
| `pause_floor_ms` | every comma / full stop / question mark *inside* a sentence lasts at least this long (0 = as predicted) |
|
|
|
|
| 72 |
| `return_info` | also return timing and the exact letters the model read |
|
| 73 |
|
| 74 |
## What is inside
|
|
|
|
| 69 |
| `voice` | a name from `voices/` (default `bijaya`), a `.pt` of style vectors, or an audio clip (wav / flac / mp3 / ogg) |
|
| 70 |
| `speed` | 0.5-2.0, scales the predicted durations (1.0 = as predicted) |
|
| 71 |
| `pause_floor_ms` | every comma / full stop / question mark *inside* a sentence lasts at least this long (0 = as predicted) |
|
| 72 |
+
| `sentence_pause_ms` | silence between two sentences (default 350); the start and end of each sentence are trimmed, so the first sound comes within about 0.1 s |
|
| 73 |
| `return_info` | also return timing and the exact letters the model read |
|
| 74 |
|
| 75 |
## What is inside
|
swar/inference.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
tts = Swar.from_pretrained("elitexp/Swar") # or a local folder
|
| 5 |
wav, sr = tts.synthesize("नमस्ते, यहाँलाई स्वागत छ।") # default voice: bijaya
|
| 6 |
wav, sr = tts.synthesize("Your balance is Rs. 250.", voice="path/to/reference.wav",
|
| 7 |
-
speed=1.0, pause_floor_ms=300)
|
| 8 |
|
| 9 |
python -m swar.inference "नमस्ते, यहाँलाई स्वागत छ।" -o hello.wav
|
| 10 |
|
|
@@ -13,6 +13,12 @@ prosodic (manner of speaking, read by the duration / pitch / energy predictor).
|
|
| 13 |
voices/*.pt hold them ready-made; any clean 3-10 s clip of one speaker gives a new
|
| 14 |
voice through style_from_audio(). pause_floor_ms makes every comma / full stop /
|
| 15 |
question mark inside a sentence last at least that long.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
|
@@ -29,7 +35,9 @@ from .model import build, conf
|
|
| 29 |
from .text import STOI, normalize, to_ids
|
| 30 |
|
| 31 |
PAUSE_CHARS = ",.?!;:…—"
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
MAX_CHARS = 220
|
| 34 |
|
| 35 |
|
|
@@ -65,6 +73,7 @@ class Swar:
|
|
| 65 |
self.m = m.to(self.device).eval()
|
| 66 |
self.mel = Mel().to(self.device)
|
| 67 |
self._pause_ids = torch.tensor([STOI[c] for c in PAUSE_CHARS], device=self.device)
|
|
|
|
| 68 |
self._voices: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
|
| 69 |
|
| 70 |
@classmethod
|
|
@@ -106,7 +115,10 @@ class Swar:
|
|
| 106 |
|
| 107 |
# ---- synthesis
|
| 108 |
@torch.no_grad()
|
| 109 |
-
def _tts(self, ids: list[int], s_ac, s_pr, speed: float, pause_floor: int
|
|
|
|
|
|
|
|
|
|
| 110 |
m, dev = self.m, self.device
|
| 111 |
x_ids = torch.tensor([ids], device=dev)
|
| 112 |
n = torch.tensor([len(ids)], device=dev)
|
|
@@ -121,30 +133,39 @@ class Swar:
|
|
| 121 |
pos = torch.arange(L, device=dev)
|
| 122 |
p = torch.isin(x_ids[0], self._pause_ids) & (pos > 0) & (pos < L - 2) # inside the sentence
|
| 123 |
dur[0, p] = dur[0, p].clamp(min=pause_floor)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
aln = _alignment(dur, int(dur.sum()))
|
| 125 |
F0, N = m["predictor"].F0Ntrain(d.transpose(-1, -2) @ aln, s_pr)
|
| 126 |
asr = m["text_encoder"](x_ids, n.cpu(), mask) @ aln
|
| 127 |
return m["decoder"](asr, F0, N, s_ac).squeeze().clamp(-1, 1)
|
| 128 |
|
| 129 |
def synthesize(self, text: str, voice: str | Path | None = None, speed: float = 1.0,
|
| 130 |
-
pause_floor_ms: int = 0, return_info: bool = False):
|
| 131 |
"""-> (float32 waveform, 16000), plus a timing dict if return_info."""
|
| 132 |
t0 = time.time()
|
| 133 |
s_ac, s_pr = self.voice(voice)
|
| 134 |
floor = int(round(pause_floor_ms / 25))
|
| 135 |
speed = float(min(2.0, max(0.5, speed)))
|
| 136 |
-
|
| 137 |
# split the RAW text first, so each sentence's numbers are read in its own
|
| 138 |
# language ("Rs. 250" stays together: no split before a digit)
|
| 139 |
raws = re.split(r"(?<=[।?!])\s+|(?<=\.)\s+(?![0-9०-९])", text)
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
y = self._tts(to_ids(sent), s_ac, s_pr, speed, floor).float().cpu().numpy()
|
| 143 |
-
first = first if first is not None else time.time() - t0
|
| 144 |
-
pieces += [y, np.zeros(int(GAP_S * SR), np.float32)]
|
| 145 |
-
if not pieces:
|
| 146 |
raise ValueError("nothing speakable left after normalising the text")
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
if not return_info:
|
| 149 |
return wav, SR
|
| 150 |
return wav, SR, {"first_audio_sec": round(first, 3), "total_sec": round(time.time() - t0, 3),
|
|
@@ -159,12 +180,14 @@ def main() -> int:
|
|
| 159 |
ap.add_argument("--voice", default=None, help="voice name, .pt, or a reference audio clip")
|
| 160 |
ap.add_argument("--speed", type=float, default=1.0)
|
| 161 |
ap.add_argument("--pause-floor-ms", type=int, default=0)
|
|
|
|
| 162 |
ap.add_argument("--device", default=None)
|
| 163 |
a = ap.parse_args()
|
| 164 |
import soundfile as sf
|
| 165 |
tts = Swar.from_pretrained(a.model, device=a.device)
|
| 166 |
wav, sr, info = tts.synthesize(a.text, voice=a.voice, speed=a.speed,
|
| 167 |
-
pause_floor_ms=a.pause_floor_ms,
|
|
|
|
| 168 |
sf.write(a.out, wav, sr)
|
| 169 |
print(f"{a.out}: {info['duration_sec']} s audio in {info['total_sec']} s -- read as: "
|
| 170 |
f"{' | '.join(info['sentences'])}")
|
|
|
|
| 4 |
tts = Swar.from_pretrained("elitexp/Swar") # or a local folder
|
| 5 |
wav, sr = tts.synthesize("नमस्ते, यहाँलाई स्वागत छ।") # default voice: bijaya
|
| 6 |
wav, sr = tts.synthesize("Your balance is Rs. 250.", voice="path/to/reference.wav",
|
| 7 |
+
speed=1.0, pause_floor_ms=300, sentence_pause_ms=350)
|
| 8 |
|
| 9 |
python -m swar.inference "नमस्ते, यहाँलाई स्वागत छ।" -o hello.wav
|
| 10 |
|
|
|
|
| 13 |
voices/*.pt hold them ready-made; any clean 3-10 s clip of one speaker gives a new
|
| 14 |
voice through style_from_audio(). pause_floor_ms makes every comma / full stop /
|
| 15 |
question mark inside a sentence last at least that long.
|
| 16 |
+
|
| 17 |
+
Sentences are generated one at a time. Training clips began and ended with room
|
| 18 |
+
silence, so the model gives the start and end tokens 0.3-0.5 s each; joined
|
| 19 |
+
sentence by sentence that piled up to ~1 s at every full stop and delayed the first
|
| 20 |
+
sound. So the edges are cut to EDGE_UNITS, and the gap between two sentences is
|
| 21 |
+
exactly sentence_pause_ms, carried by the first sentence's final punctuation.
|
| 22 |
"""
|
| 23 |
from __future__ import annotations
|
| 24 |
|
|
|
|
| 35 |
from .text import STOI, normalize, to_ids
|
| 36 |
|
| 37 |
PAUSE_CHARS = ",.?!;:…—"
|
| 38 |
+
UNIT_S = 0.025 # one duration unit
|
| 39 |
+
EDGE_UNITS = 2 # start / end token cap (50 ms)
|
| 40 |
+
TAIL_UNITS = 4 # after the last sentence (100 ms)
|
| 41 |
MAX_CHARS = 220
|
| 42 |
|
| 43 |
|
|
|
|
| 73 |
self.m = m.to(self.device).eval()
|
| 74 |
self.mel = Mel().to(self.device)
|
| 75 |
self._pause_ids = torch.tensor([STOI[c] for c in PAUSE_CHARS], device=self.device)
|
| 76 |
+
self._pause_set = {STOI[c] for c in PAUSE_CHARS}
|
| 77 |
self._voices: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
|
| 78 |
|
| 79 |
@classmethod
|
|
|
|
| 115 |
|
| 116 |
# ---- synthesis
|
| 117 |
@torch.no_grad()
|
| 118 |
+
def _tts(self, ids: list[int], s_ac, s_pr, speed: float, pause_floor: int,
|
| 119 |
+
end_pause: int | None = None, tail: int = EDGE_UNITS) -> torch.Tensor:
|
| 120 |
+
"""end_pause: units for the sentence-final punctuation (None = as predicted); the
|
| 121 |
+
last sentence gets a short one, so the clip does not end in half a second of nothing."""
|
| 122 |
m, dev = self.m, self.device
|
| 123 |
x_ids = torch.tensor([ids], device=dev)
|
| 124 |
n = torch.tensor([len(ids)], device=dev)
|
|
|
|
| 133 |
pos = torch.arange(L, device=dev)
|
| 134 |
p = torch.isin(x_ids[0], self._pause_ids) & (pos > 0) & (pos < L - 2) # inside the sentence
|
| 135 |
dur[0, p] = dur[0, p].clamp(min=pause_floor)
|
| 136 |
+
dur[0, 0] = dur[0, 0].clamp(max=EDGE_UNITS) # room silence before the sentence
|
| 137 |
+
dur[0, -1] = dur[0, -1].clamp(max=tail) # ... and after it
|
| 138 |
+
if end_pause is not None and ids[-2] in self._pause_set:
|
| 139 |
+
dur[0, -2] = max(1, end_pause)
|
| 140 |
aln = _alignment(dur, int(dur.sum()))
|
| 141 |
F0, N = m["predictor"].F0Ntrain(d.transpose(-1, -2) @ aln, s_pr)
|
| 142 |
asr = m["text_encoder"](x_ids, n.cpu(), mask) @ aln
|
| 143 |
return m["decoder"](asr, F0, N, s_ac).squeeze().clamp(-1, 1)
|
| 144 |
|
| 145 |
def synthesize(self, text: str, voice: str | Path | None = None, speed: float = 1.0,
|
| 146 |
+
pause_floor_ms: int = 0, sentence_pause_ms: int = 350, return_info: bool = False):
|
| 147 |
"""-> (float32 waveform, 16000), plus a timing dict if return_info."""
|
| 148 |
t0 = time.time()
|
| 149 |
s_ac, s_pr = self.voice(voice)
|
| 150 |
floor = int(round(pause_floor_ms / 25))
|
| 151 |
speed = float(min(2.0, max(0.5, speed)))
|
| 152 |
+
gap = int(round(max(0, sentence_pause_ms) / 1000 / UNIT_S)) - 2 * EDGE_UNITS # edges are silent too
|
| 153 |
# split the RAW text first, so each sentence's numbers are read in its own
|
| 154 |
# language ("Rs. 250" stays together: no split before a digit)
|
| 155 |
raws = re.split(r"(?<=[।?!])\s+|(?<=\.)\s+(?![0-9०-९])", text)
|
| 156 |
+
sentences = [s for raw in raws for s in split_sentences(normalize(raw))]
|
| 157 |
+
if not sentences:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
raise ValueError("nothing speakable left after normalising the text")
|
| 159 |
+
pieces, first = [], None
|
| 160 |
+
for k, sent in enumerate(sentences):
|
| 161 |
+
last = k == len(sentences) - 1
|
| 162 |
+
y = self._tts(to_ids(sent), s_ac, s_pr, speed, floor, end_pause=TAIL_UNITS if last else gap,
|
| 163 |
+
tail=TAIL_UNITS if last else EDGE_UNITS).float().cpu().numpy()
|
| 164 |
+
first = first if first is not None else time.time() - t0
|
| 165 |
+
pieces.append(y)
|
| 166 |
+
if not last and sent[-1] not in PAUSE_CHARS: # cut mid run-on: pause as silence
|
| 167 |
+
pieces.append(np.zeros(int(max(0, gap) * UNIT_S * SR), np.float32))
|
| 168 |
+
wav = np.concatenate(pieces)
|
| 169 |
if not return_info:
|
| 170 |
return wav, SR
|
| 171 |
return wav, SR, {"first_audio_sec": round(first, 3), "total_sec": round(time.time() - t0, 3),
|
|
|
|
| 180 |
ap.add_argument("--voice", default=None, help="voice name, .pt, or a reference audio clip")
|
| 181 |
ap.add_argument("--speed", type=float, default=1.0)
|
| 182 |
ap.add_argument("--pause-floor-ms", type=int, default=0)
|
| 183 |
+
ap.add_argument("--sentence-pause-ms", type=int, default=350)
|
| 184 |
ap.add_argument("--device", default=None)
|
| 185 |
a = ap.parse_args()
|
| 186 |
import soundfile as sf
|
| 187 |
tts = Swar.from_pretrained(a.model, device=a.device)
|
| 188 |
wav, sr, info = tts.synthesize(a.text, voice=a.voice, speed=a.speed,
|
| 189 |
+
pause_floor_ms=a.pause_floor_ms, sentence_pause_ms=a.sentence_pause_ms,
|
| 190 |
+
return_info=True)
|
| 191 |
sf.write(a.out, wav, sr)
|
| 192 |
print(f"{a.out}: {info['duration_sec']} s audio in {info['total_sec']} s -- read as: "
|
| 193 |
f"{' | '.join(info['sentences'])}")
|