wfloat 2.0.0__py3-none-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wfloat/_model.py ADDED
@@ -0,0 +1,394 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
4
+
5
+ from ._assets import fetch_model_assets
6
+ from ._cache import (
7
+ CachedModelAssets,
8
+ cache_model_assets,
9
+ )
10
+ from ._constants import (
11
+ DEFAULT_MODEL_NAME,
12
+ DEFAULT_SILENCE_BETWEEN_SEGMENTS_SEC,
13
+ normalize_emotion,
14
+ normalize_intensity,
15
+ normalize_silence_padding_sec,
16
+ normalize_speed,
17
+ normalize_text,
18
+ normalize_voice_id,
19
+ )
20
+ from ._native import create_native_tts
21
+ from ._results import Audio, GenerationResult, Timeline, TimelineChunk
22
+
23
+
24
+ @dataclass
25
+ class _PreparedChunk:
26
+ text: str
27
+ text_clean: str
28
+ highlight_start: int
29
+ highlight_end: int
30
+ sid: int
31
+ voice_id: Optional[object]
32
+ emotion: str
33
+ intensity: float
34
+ speed: float
35
+ silence_padding_sec: float
36
+ segment_index: Optional[int]
37
+
38
+
39
+ class Model:
40
+ def __init__(
41
+ self,
42
+ model_name: str,
43
+ native_tts,
44
+ *,
45
+ cached_assets: Optional[CachedModelAssets] = None,
46
+ ) -> None:
47
+ self.model_name = model_name
48
+ self._native_tts = native_tts
49
+ self._cached_assets = cached_assets
50
+
51
+ @property
52
+ def sample_rate(self) -> int:
53
+ return int(self._native_tts.sample_rate)
54
+
55
+ @property
56
+ def num_speakers(self) -> int:
57
+ return int(self._native_tts.num_speakers)
58
+
59
+ def __repr__(self) -> str:
60
+ return "Model(model_name=%r, sample_rate=%r, num_speakers=%r)" % (
61
+ self.model_name,
62
+ self.sample_rate,
63
+ self.num_speakers,
64
+ )
65
+
66
+ def generate(
67
+ self,
68
+ *,
69
+ text: str,
70
+ voice_id=None,
71
+ emotion: Optional[str] = None,
72
+ intensity: Optional[float] = None,
73
+ speed: Optional[float] = None,
74
+ silence_padding_sec: Optional[float] = None,
75
+ ) -> GenerationResult:
76
+ normalized_text = normalize_text(text)
77
+ normalized_emotion = normalize_emotion(emotion)
78
+ normalized_intensity = normalize_intensity(intensity)
79
+ normalized_speed = normalize_speed(speed)
80
+ normalized_silence_padding_sec = normalize_silence_padding_sec(
81
+ silence_padding_sec
82
+ )
83
+ normalized_voice_id = voice_id
84
+ sid = normalize_voice_id(voice_id)
85
+
86
+ if hasattr(self._native_tts, "synthesize_result"):
87
+ return self._native_tts.synthesize_result(
88
+ model_id=self.model_name,
89
+ text=normalized_text,
90
+ voice=normalized_voice_id,
91
+ sid=sid,
92
+ emotion=normalized_emotion,
93
+ intensity=normalized_intensity,
94
+ speed=normalized_speed,
95
+ silence_padding_sec=normalized_silence_padding_sec,
96
+ )
97
+
98
+ prepared = self._native_tts.prepare_wfloat_text(
99
+ normalized_text,
100
+ normalized_emotion,
101
+ normalized_intensity,
102
+ )
103
+ prepared_chunks = self._build_prepared_chunks(
104
+ prepared.text,
105
+ prepared.text_clean,
106
+ sid=sid,
107
+ voice_id=voice_id,
108
+ emotion=normalized_emotion,
109
+ intensity=normalized_intensity,
110
+ speed=normalized_speed,
111
+ silence_padding_sec=normalized_silence_padding_sec,
112
+ segment_index=None,
113
+ )
114
+
115
+ result = self._synthesize_chunks(
116
+ prepared_chunks,
117
+ result_text=normalized_text,
118
+ )
119
+ return result
120
+
121
+ def synthesize(
122
+ self,
123
+ *,
124
+ text: str,
125
+ voice: Optional[object] = None,
126
+ speed: Optional[float] = None,
127
+ emotion: Optional[str] = None,
128
+ intensity: Optional[float] = None,
129
+ silence_padding_sec: Optional[float] = None,
130
+ ) -> GenerationResult:
131
+ return self.generate(
132
+ text=text,
133
+ voice_id=voice,
134
+ emotion=emotion,
135
+ intensity=intensity,
136
+ speed=speed,
137
+ silence_padding_sec=silence_padding_sec,
138
+ )
139
+
140
+ def generate_dialogue(
141
+ self,
142
+ *,
143
+ segments: Sequence[Mapping[str, Any]],
144
+ speed: Optional[float] = None,
145
+ silence_between_segments_sec: Optional[float] = None,
146
+ ) -> GenerationResult:
147
+ if not segments:
148
+ raise ValueError("segments is required.")
149
+
150
+ default_speed = normalize_speed(speed)
151
+ dialogue_silence_sec = normalize_silence_padding_sec(
152
+ silence_between_segments_sec,
153
+ default=DEFAULT_SILENCE_BETWEEN_SEGMENTS_SEC,
154
+ )
155
+
156
+ if hasattr(self._native_tts, "synthesize_dialogue_result"):
157
+ normalized_segments = []
158
+ for segment in segments:
159
+ if not isinstance(segment, Mapping):
160
+ raise TypeError("Each segment must be a mapping.")
161
+
162
+ normalized_segments.append(
163
+ self._normalize_dialogue_segment(
164
+ segment,
165
+ default_speed=default_speed,
166
+ )
167
+ )
168
+
169
+ return self._native_tts.synthesize_dialogue_result(
170
+ model_id=self.model_name,
171
+ segments=normalized_segments,
172
+ silence_between_segments_sec=dialogue_silence_sec,
173
+ )
174
+
175
+ prepared_chunks = []
176
+ dialogue_texts = []
177
+
178
+ for segment_index, segment in enumerate(segments):
179
+ if not isinstance(segment, Mapping):
180
+ raise TypeError("Each segment must be a mapping.")
181
+
182
+ normalized_segment = self._normalize_dialogue_segment(
183
+ segment,
184
+ default_speed=default_speed,
185
+ )
186
+ dialogue_texts.append(normalized_segment["text"])
187
+
188
+ prepared = self._native_tts.prepare_wfloat_text(
189
+ normalized_segment["text"],
190
+ normalized_segment["emotion"],
191
+ normalized_segment["intensity"],
192
+ )
193
+ segment_chunks = self._build_prepared_chunks(
194
+ prepared.text,
195
+ prepared.text_clean,
196
+ sid=normalized_segment["sid"],
197
+ voice_id=normalized_segment["voice_id"],
198
+ emotion=normalized_segment["emotion"],
199
+ intensity=normalized_segment["intensity"],
200
+ speed=normalized_segment["speed"],
201
+ silence_padding_sec=normalized_segment["sentence_silence_padding_sec"],
202
+ segment_index=segment_index,
203
+ )
204
+ prepared_chunks.extend(segment_chunks)
205
+
206
+ if segment_index < len(segments) - 1 and segment_chunks:
207
+ prepared_chunks[-1].silence_padding_sec = dialogue_silence_sec
208
+
209
+ return self._synthesize_chunks(
210
+ prepared_chunks,
211
+ result_text=" ".join(dialogue_texts),
212
+ )
213
+
214
+ def synthesize_dialogue(
215
+ self,
216
+ *,
217
+ segments: Sequence[Mapping[str, Any]],
218
+ speed: Optional[float] = None,
219
+ silence_between_segments_sec: Optional[float] = None,
220
+ ) -> GenerationResult:
221
+ return self.generate_dialogue(
222
+ segments=segments,
223
+ speed=speed,
224
+ silence_between_segments_sec=silence_between_segments_sec,
225
+ )
226
+
227
+ def _normalize_dialogue_segment(
228
+ self,
229
+ segment: Mapping[str, Any],
230
+ *,
231
+ default_speed: float,
232
+ ) -> Dict[str, Any]:
233
+ text = normalize_text(segment.get("text")) # type: ignore[arg-type]
234
+ voice_id = segment.get("voice", segment.get("voice_id"))
235
+ emotion = normalize_emotion(segment.get("emotion"))
236
+ intensity = normalize_intensity(segment.get("intensity"))
237
+ speed = normalize_speed(segment.get("speed"), default=default_speed)
238
+ sentence_silence_padding_sec = normalize_silence_padding_sec(
239
+ segment.get("sentence_silence_padding_sec")
240
+ )
241
+ sid = normalize_voice_id(voice_id)
242
+
243
+ return {
244
+ "text": text,
245
+ "voice_id": voice_id,
246
+ "emotion": emotion,
247
+ "intensity": intensity,
248
+ "speed": speed,
249
+ "sentence_silence_padding_sec": sentence_silence_padding_sec,
250
+ "sid": sid,
251
+ }
252
+
253
+ def _build_prepared_chunks(
254
+ self,
255
+ raw_text_chunks: Sequence[str],
256
+ clean_text_chunks: Sequence[str],
257
+ *,
258
+ sid: int,
259
+ voice_id,
260
+ emotion: str,
261
+ intensity: float,
262
+ speed: float,
263
+ silence_padding_sec: float,
264
+ segment_index: Optional[int],
265
+ ) -> List[_PreparedChunk]:
266
+ chunks = []
267
+ raw_cursor = 0
268
+ for index, raw_chunk_text in enumerate(raw_text_chunks):
269
+ highlight_start = raw_cursor
270
+ highlight_end = raw_cursor + len(raw_chunk_text)
271
+ raw_cursor = highlight_end
272
+
273
+ chunks.append(
274
+ _PreparedChunk(
275
+ text=raw_chunk_text,
276
+ text_clean=clean_text_chunks[index],
277
+ highlight_start=highlight_start,
278
+ highlight_end=highlight_end,
279
+ sid=sid,
280
+ voice_id=voice_id,
281
+ emotion=emotion,
282
+ intensity=intensity,
283
+ speed=speed,
284
+ silence_padding_sec=silence_padding_sec,
285
+ segment_index=segment_index,
286
+ )
287
+ )
288
+ return chunks
289
+
290
+ def _synthesize_chunks(
291
+ self,
292
+ chunks: Sequence[_PreparedChunk],
293
+ *,
294
+ result_text: str,
295
+ ) -> GenerationResult:
296
+ if not chunks:
297
+ raise RuntimeError("Text preparation produced no synthesizeable chunks.")
298
+
299
+ all_samples = []
300
+ timeline_chunks = []
301
+ total_chunks = len(chunks)
302
+ cumulative_samples = 0
303
+ sample_rate = self.sample_rate
304
+
305
+ for index, chunk in enumerate(chunks):
306
+ generated_audio = self._native_tts.generate(
307
+ chunk.text_clean,
308
+ chunk.sid,
309
+ chunk.speed,
310
+ )
311
+ chunk_samples = [float(sample) for sample in generated_audio.samples]
312
+ chunk_sample_rate = int(generated_audio.sample_rate)
313
+ if chunk_sample_rate <= 0:
314
+ raise RuntimeError("Native generation returned an invalid sample rate.")
315
+
316
+ if index == 0:
317
+ sample_rate = chunk_sample_rate
318
+ elif chunk_sample_rate != sample_rate:
319
+ raise RuntimeError(
320
+ "Native generation returned inconsistent sample rates across chunks."
321
+ )
322
+
323
+ start_sec = cumulative_samples / float(sample_rate)
324
+ all_samples.extend(chunk_samples)
325
+ cumulative_samples += len(chunk_samples)
326
+ end_sec = cumulative_samples / float(sample_rate)
327
+
328
+ timeline_chunks.append(
329
+ TimelineChunk(
330
+ index=index,
331
+ text=chunk.text,
332
+ highlight_start=chunk.highlight_start,
333
+ highlight_end=chunk.highlight_end,
334
+ start_sec=start_sec,
335
+ end_sec=end_sec,
336
+ duration_sec=end_sec - start_sec,
337
+ progress=float(index + 1) / float(total_chunks),
338
+ voice_id=chunk.voice_id,
339
+ sid=chunk.sid,
340
+ emotion=chunk.emotion,
341
+ intensity=chunk.intensity,
342
+ speed=chunk.speed,
343
+ segment_index=chunk.segment_index,
344
+ )
345
+ )
346
+
347
+ if index < total_chunks - 1 and chunk.silence_padding_sec > 0:
348
+ silence_samples = int(round(chunk.silence_padding_sec * sample_rate))
349
+ if silence_samples > 0:
350
+ all_samples.extend([0.0] * silence_samples)
351
+ cumulative_samples += silence_samples
352
+
353
+ audio = Audio(samples=all_samples, sample_rate=sample_rate)
354
+ timeline = Timeline(chunks=timeline_chunks, duration_sec=audio.duration_sec)
355
+ result = GenerationResult(
356
+ audio=audio,
357
+ timeline=timeline,
358
+ text=result_text,
359
+ model_name=self.model_name,
360
+ )
361
+
362
+ return result
363
+
364
+
365
+ def load(
366
+ model_name: str = DEFAULT_MODEL_NAME,
367
+ *,
368
+ cache_dir=None,
369
+ force_download: bool = False,
370
+ ) -> Model:
371
+ normalized_model_name = normalize_text(model_name)
372
+ resolved_cache_dir = Path(cache_dir) if cache_dir is not None else None
373
+ assets = fetch_model_assets(normalized_model_name)
374
+ cached_assets = cache_model_assets(
375
+ normalized_model_name,
376
+ assets,
377
+ cache_dir=resolved_cache_dir,
378
+ force_download=force_download,
379
+ )
380
+ native_tts = create_native_tts(
381
+ normalized_model_name,
382
+ cached_assets.model_path,
383
+ cached_assets.tokens_path,
384
+ cached_assets.espeak_data_dir,
385
+ )
386
+ return Model(
387
+ normalized_model_name,
388
+ native_tts,
389
+ cached_assets=cached_assets,
390
+ )
391
+
392
+
393
+ TtsModel = Model
394
+ load_tts_model = load
wfloat/_native.py ADDED
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+
3
+ from ._core import create_core_tts
4
+
5
+
6
+ def create_native_tts(
7
+ model_name: str,
8
+ model_path: Path,
9
+ tokens_path: Path,
10
+ espeak_data_dir: Path,
11
+ ):
12
+ return create_core_tts(
13
+ model_name=model_name,
14
+ model_path=model_path,
15
+ tokens_path=tokens_path,
16
+ espeak_data_dir=espeak_data_dir,
17
+ )
wfloat/_results.py ADDED
@@ -0,0 +1,163 @@
1
+ import io
2
+ import struct
3
+ import wave
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterable, List, Optional
7
+
8
+
9
+ def _coerce_samples(samples: Iterable[float]) -> List[float]:
10
+ return [float(sample) for sample in samples]
11
+
12
+
13
+ def _sample_to_pcm16(sample: float) -> int:
14
+ clipped = max(-1.0, min(1.0, float(sample)))
15
+ if clipped <= -1.0:
16
+ return -32768
17
+ return int(round(clipped * 32767.0))
18
+
19
+
20
+ def _write_wave_bytes(samples: List[float], sample_rate: int) -> bytes:
21
+ buffer = io.BytesIO()
22
+ with wave.open(buffer, "wb") as wav_file:
23
+ wav_file.setnchannels(1)
24
+ wav_file.setsampwidth(2)
25
+ wav_file.setframerate(sample_rate)
26
+ frame_bytes = bytearray()
27
+ for sample in samples:
28
+ frame_bytes.extend(struct.pack("<h", _sample_to_pcm16(sample)))
29
+ wav_file.writeframes(bytes(frame_bytes))
30
+ return buffer.getvalue()
31
+
32
+
33
+ @dataclass
34
+ class Audio:
35
+ samples: List[float]
36
+ sample_rate: int
37
+
38
+ def __post_init__(self) -> None:
39
+ self.samples = _coerce_samples(self.samples)
40
+ self.sample_rate = int(self.sample_rate)
41
+
42
+ @property
43
+ def duration_sec(self) -> float:
44
+ if self.sample_rate <= 0:
45
+ return 0.0
46
+ return len(self.samples) / float(self.sample_rate)
47
+
48
+ def save(self, path) -> None:
49
+ output_path = Path(path)
50
+ output_path.parent.mkdir(parents=True, exist_ok=True)
51
+ output_path.write_bytes(self.wav_bytes())
52
+
53
+ def wav_bytes(self) -> bytes:
54
+ return _write_wave_bytes(self.samples, self.sample_rate)
55
+
56
+
57
+ AudioResult = Audio
58
+
59
+
60
+ @dataclass
61
+ class TimelineChunk:
62
+ index: int
63
+ text: str
64
+ highlight_start: int
65
+ highlight_end: int
66
+ start_sec: float
67
+ end_sec: float
68
+ duration_sec: float
69
+ progress: float
70
+ voice_id: Optional[object]
71
+ sid: int
72
+ emotion: str
73
+ intensity: float
74
+ speed: float
75
+ segment_index: Optional[int] = None
76
+
77
+
78
+ @dataclass
79
+ class Timeline:
80
+ chunks: List[TimelineChunk]
81
+ duration_sec: float
82
+
83
+
84
+ @dataclass
85
+ class GenerationResult:
86
+ audio: Audio
87
+ timeline: Timeline
88
+ text: str
89
+ model_name: str
90
+
91
+ @property
92
+ def model_id(self) -> str:
93
+ return self.model_name
94
+
95
+ def __iter__(self):
96
+ yield self.audio
97
+ yield self.timeline
98
+
99
+
100
+ TtsSynthesisResult = GenerationResult
101
+
102
+
103
+ @dataclass
104
+ class TranscriptionToken:
105
+ text: str
106
+ start_sec: float
107
+ duration_sec: float
108
+ confidence: float = 0.0
109
+
110
+
111
+ @dataclass
112
+ class TranscriptionSegment:
113
+ text: str
114
+ start_sec: float
115
+ duration_sec: float
116
+
117
+
118
+ @dataclass
119
+ class TranscriptionResult:
120
+ text: str
121
+ model_id: str
122
+ language: str = ""
123
+ emotion: str = ""
124
+ event: str = ""
125
+ json: str = ""
126
+ tokens: Optional[List[TranscriptionToken]] = None
127
+ segments: Optional[List[TranscriptionSegment]] = None
128
+
129
+
130
+ @dataclass
131
+ class StreamingTranscriptionResult:
132
+ text: str
133
+ model_id: str
134
+ is_endpoint: bool = False
135
+ json: str = ""
136
+
137
+
138
+ @dataclass
139
+ class LlmGenerationResult:
140
+ text: str
141
+ model_id: str
142
+ finish_reason: str = ""
143
+ json: str = ""
144
+ prompt_token_count: int = 0
145
+ completion_token_count: int = 0
146
+
147
+
148
+ @dataclass
149
+ class VadSegment:
150
+ start_sec: float
151
+ duration_sec: float
152
+ end_sec: float
153
+ start_sample: int
154
+ sample_count: int
155
+ sample_rate: int
156
+ audio: Audio
157
+
158
+
159
+ @dataclass
160
+ class VadDetectionResult:
161
+ model_id: str
162
+ segments: List[VadSegment]
163
+ speech_ratio: float
wfloat/_stt.py ADDED
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import wave
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterable, Optional, Sequence
7
+
8
+ from ._results import StreamingTranscriptionResult, TranscriptionResult
9
+
10
+
11
+ def _coerce_audio_samples(audio: bytes | Sequence[float]) -> list[float]:
12
+ if isinstance(audio, (bytes, bytearray, memoryview)):
13
+ raise TypeError(
14
+ "Raw PCM bytes are not supported yet. Pass a float sequence or a WAV path."
15
+ )
16
+
17
+ return [float(sample) for sample in audio]
18
+
19
+
20
+ def _load_wav_audio(path: Path) -> tuple[list[float], int]:
21
+ with wave.open(str(path), "rb") as wav_file:
22
+ channels = wav_file.getnchannels()
23
+ sample_width = wav_file.getsampwidth()
24
+ sample_rate = wav_file.getframerate()
25
+ frame_count = wav_file.getnframes()
26
+
27
+ if channels != 1:
28
+ raise ValueError("Only mono WAV files are supported.")
29
+ if sample_width != 2:
30
+ raise ValueError("Only 16-bit PCM WAV files are supported.")
31
+
32
+ frames = wav_file.readframes(frame_count)
33
+
34
+ samples: list[float] = []
35
+ for index in range(0, len(frames), 2):
36
+ sample = int.from_bytes(frames[index : index + 2], "little", signed=True)
37
+ samples.append(float(sample) / 32768.0)
38
+
39
+ return samples, sample_rate
40
+
41
+
42
+ @dataclass
43
+ class SttSession:
44
+ model_id: str
45
+ _native_session: object
46
+
47
+ def push(
48
+ self,
49
+ audio: Sequence[float],
50
+ *,
51
+ sample_rate: Optional[int] = None,
52
+ ) -> None:
53
+ if not hasattr(self._native_session, "push"):
54
+ raise RuntimeError("Native STT backend does not support push().")
55
+
56
+ self._native_session.push(audio, sample_rate=sample_rate)
57
+
58
+ def get_result(self) -> StreamingTranscriptionResult:
59
+ if not hasattr(self._native_session, "get_result"):
60
+ raise RuntimeError("Native STT backend does not support get_result().")
61
+
62
+ return self._native_session.get_result()
63
+
64
+ def finish(self) -> StreamingTranscriptionResult:
65
+ if not hasattr(self._native_session, "finish"):
66
+ raise RuntimeError("Native STT backend does not support finish().")
67
+
68
+ return self._native_session.finish()
69
+
70
+ def reset(self) -> None:
71
+ if not hasattr(self._native_session, "reset"):
72
+ raise RuntimeError("Native STT backend does not support reset().")
73
+
74
+ self._native_session.reset()
75
+
76
+ def close(self) -> None:
77
+ if hasattr(self._native_session, "close"):
78
+ self._native_session.close()
79
+
80
+
81
+ @dataclass
82
+ class SttModel:
83
+ model_id: str
84
+ _native_stt: object
85
+
86
+ def transcribe(
87
+ self,
88
+ *,
89
+ audio: bytes | Sequence[float] | str | Path,
90
+ sample_rate: Optional[int] = None,
91
+ language: Optional[str] = None,
92
+ task: Optional[str] = None,
93
+ hotwords: Optional[str] = None,
94
+ ) -> TranscriptionResult:
95
+ if isinstance(audio, (str, Path)):
96
+ samples, resolved_sample_rate = _load_wav_audio(Path(audio))
97
+ else:
98
+ if sample_rate is None or sample_rate <= 0:
99
+ raise ValueError("sample_rate is required for in-memory audio.")
100
+ samples = _coerce_audio_samples(audio)
101
+ resolved_sample_rate = int(sample_rate)
102
+
103
+ if not hasattr(self._native_stt, "transcribe_result"):
104
+ raise RuntimeError("Native STT backend does not support transcribe_result().")
105
+
106
+ return self._native_stt.transcribe_result(
107
+ model_id=self.model_id,
108
+ samples=samples,
109
+ sample_rate=resolved_sample_rate,
110
+ language=language,
111
+ task=task,
112
+ hotwords=hotwords,
113
+ )
114
+
115
+ def create_session(self) -> SttSession:
116
+ if not hasattr(self._native_stt, "create_session"):
117
+ raise RuntimeError("Native STT backend does not support create_session().")
118
+
119
+ return SttSession(
120
+ model_id=self.model_id,
121
+ _native_session=self._native_stt.create_session(),
122
+ )