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/__init__.py +57 -0
- wfloat/__main__.py +5 -0
- wfloat/_assets.py +421 -0
- wfloat/_cache.py +208 -0
- wfloat/_cli.py +74 -0
- wfloat/_constants.py +147 -0
- wfloat/_core.py +1795 -0
- wfloat/_download.py +130 -0
- wfloat/_generated_model_urls.py +46 -0
- wfloat/_llm.py +84 -0
- wfloat/_llm_assets.py +142 -0
- wfloat/_llm_load.py +61 -0
- wfloat/_model.py +394 -0
- wfloat/_native.py +17 -0
- wfloat/_results.py +163 -0
- wfloat/_stt.py +122 -0
- wfloat/_stt_assets.py +144 -0
- wfloat/_stt_load.py +75 -0
- wfloat/_vad.py +119 -0
- wfloat/_vad_assets.py +130 -0
- wfloat/_vad_load.py +97 -0
- wfloat/_version.py +1 -0
- wfloat/native/wfloat-core.dll +0 -0
- wfloat-2.0.0.dist-info/METADATA +251 -0
- wfloat-2.0.0.dist-info/RECORD +28 -0
- wfloat-2.0.0.dist-info/WHEEL +5 -0
- wfloat-2.0.0.dist-info/entry_points.txt +2 -0
- wfloat-2.0.0.dist-info/top_level.txt +1 -0
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
|
+
)
|