Lucy-TTS/F5: Skripte + Batches versionieren, schwere Assets ignoriert
- pocket_server.py (Produktions-TTS mit Stimmen-Waechter), text_norm, Bench-/Diag-Skripte - lucy-f5: f5_server/f5_test/bench_dml (DirectML-Experiment, Phase C/D offen) - .gitignore: venvs/Modelle/Audio/Logs der beiden Ordner + box_recon/gemma_swap-Scratch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
import re
|
||||
import site
|
||||
import time
|
||||
import jieba
|
||||
import torch
|
||||
import onnxruntime
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
from pydub import AudioSegment
|
||||
from pypinyin import lazy_pinyin, Style
|
||||
python_package_path = site.getsitepackages()[-1]
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-f5"
|
||||
vocab_path = BASE + r"\vocab_v1.txt"
|
||||
onnx_model_A = BASE + r"\onnx_f32\F5_Preprocess.onnx"
|
||||
onnx_model_B = BASE + r"\onnx_f32\F5_Transformer.onnx"
|
||||
onnx_model_C = BASE + r"\onnx_f32\F5_Decode.onnx"
|
||||
generated_audio = BASE + r"\bench_out.wav"
|
||||
test_in_english = True # eingebaute engl. Referenz -> kein Chinesisch/Pinyin; Tempo ist sprachunabhängig
|
||||
|
||||
if test_in_english:
|
||||
reference_audio = python_package_path + "/f5_tts/infer/examples/basic/basic_ref_en.wav"
|
||||
ref_text = "Some call me nature, others call me mother nature."
|
||||
# realistischer Satz-Längen-Benchmark (wie eine echte Lucy-Antwort):
|
||||
gen_text = "Of course, Commander. I will restart the service, check the logs, and let you know once everything is running again."
|
||||
else:
|
||||
reference_audio = python_package_path + "/f5_tts/infer/examples/basic/basic_ref_zh.wav" # The reference audio path.
|
||||
ref_text = "对,这就是我,万人敬仰的太乙真人。" # The ASR result of reference audio.
|
||||
gen_text = "对,这就是我,万人敬仰的大可奇奇。" # The target TTS.
|
||||
|
||||
|
||||
import os as _os
|
||||
ORT_Accelerate_Providers = [_os.environ.get("BENCH_PROVIDER", "DmlExecutionProvider")] # DML (9070 XT) oder CPUExecutionProvider
|
||||
# else keep empty.
|
||||
RANDOM_SEED = 9527 # Set seed to reproduce the generated audio
|
||||
NFE_STEP = int(_os.environ.get("BENCH_NFE", "32")) if (_os := __import__("os")) else 32 # via env testbar
|
||||
FUSE_NFE = 1 # Maintain the same values as the exported model.
|
||||
SPEED = 1.0 # Set for talking speed. Only works with dynamic_axes=True
|
||||
MAX_THREADS = 8 # Max CPU parallel threads.
|
||||
DEVICE_ID = 0 # The GPU id, default to 0.
|
||||
MODEL_SAMPLE_RATE = 24000 # Do not modify it.
|
||||
HOP_LENGTH = 256 # It affects the generated audio length and speech speed.
|
||||
|
||||
if "OpenVINOExecutionProvider" in ORT_Accelerate_Providers:
|
||||
provider_options = [
|
||||
{
|
||||
'device_type': 'CPU', # [CPU, NPU, GPU, GPU.0, GPU.1]]
|
||||
'precision': 'ACCURACY', # [FP32, FP16, ACCURACY]
|
||||
'num_of_threads': MAX_THREADS,
|
||||
'num_streams': 1,
|
||||
'enable_opencl_throttling': True,
|
||||
'enable_qdq_optimizer': False # Enable it carefully
|
||||
}
|
||||
]
|
||||
elif "CUDAExecutionProvider" in ORT_Accelerate_Providers:
|
||||
provider_options = [
|
||||
{
|
||||
'device_id': DEVICE_ID,
|
||||
'gpu_mem_limit': 8 * 1024 * 1024 * 1024, # 8 GB
|
||||
'arena_extend_strategy': 'kNextPowerOfTwo',
|
||||
'cudnn_conv_algo_search': 'EXHAUSTIVE',
|
||||
'cudnn_conv_use_max_workspace': '1',
|
||||
'do_copy_in_default_stream': '1',
|
||||
'cudnn_conv1d_pad_to_nc1d': '1',
|
||||
'enable_cuda_graph': '0', # Set to '0' to avoid potential errors when enabled.
|
||||
'use_tf32': '0'
|
||||
}
|
||||
]
|
||||
else:
|
||||
# Please config by yourself for others providers.
|
||||
provider_options = None
|
||||
|
||||
|
||||
with open(vocab_path, "r", encoding="utf-8") as f:
|
||||
vocab_char_map = {}
|
||||
for i, char in enumerate(f):
|
||||
vocab_char_map[char[:-1]] = i
|
||||
vocab_size = len(vocab_char_map)
|
||||
|
||||
|
||||
# From the official code
|
||||
def convert_char_to_pinyin(text_list, polyphone=True):
|
||||
if jieba.dt.initialized is False:
|
||||
jieba.default_logger.setLevel(50) # CRITICAL
|
||||
jieba.initialize()
|
||||
|
||||
final_text_list = []
|
||||
custom_trans = str.maketrans(
|
||||
{";": ",", "“": '"', "”": '"', "‘": "'", "’": "'"}
|
||||
) # add custom trans here, to address oov
|
||||
|
||||
def is_chinese(c):
|
||||
return (
|
||||
"\u3100" <= c <= "\u9fff" # common chinese characters
|
||||
)
|
||||
|
||||
for text in text_list:
|
||||
char_list = []
|
||||
text = text.translate(custom_trans)
|
||||
for seg in jieba.cut(text):
|
||||
seg_byte_len = len(bytes(seg, "UTF-8"))
|
||||
if seg_byte_len == len(seg): # if pure alphabets and symbols
|
||||
if char_list and seg_byte_len > 1 and char_list[-1] not in " :'\"":
|
||||
char_list.append(" ")
|
||||
char_list.extend(seg)
|
||||
elif polyphone and seg_byte_len == 3 * len(seg): # if pure east asian characters
|
||||
seg_ = lazy_pinyin(seg, style=Style.TONE3, tone_sandhi=True)
|
||||
for i, c in enumerate(seg):
|
||||
if is_chinese(c):
|
||||
char_list.append(" ")
|
||||
char_list.append(seg_[i])
|
||||
else: # if mixed characters, alphabets and symbols
|
||||
for c in seg:
|
||||
if ord(c) < 256:
|
||||
char_list.extend(c)
|
||||
elif is_chinese(c):
|
||||
char_list.append(" ")
|
||||
char_list.extend(lazy_pinyin(c, style=Style.TONE3, tone_sandhi=True))
|
||||
else:
|
||||
char_list.append(c)
|
||||
final_text_list.append(char_list)
|
||||
return final_text_list
|
||||
|
||||
|
||||
# From the official code
|
||||
def list_str_to_idx(
|
||||
text: list[str] | list[list[str]],
|
||||
vocab_char_map: dict[str, int], # {char: idx}
|
||||
padding_value=-1
|
||||
):
|
||||
get_idx = vocab_char_map.get
|
||||
list_idx_tensors = [torch.tensor([get_idx(c, 0) for c in t], dtype=torch.int32) for t in text]
|
||||
text = torch.nn.utils.rnn.pad_sequence(list_idx_tensors, padding_value=padding_value, batch_first=True)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_to_int16(audio):
|
||||
max_val = np.max(np.abs(audio))
|
||||
scaling_factor = 32767.0 / max_val if max_val > 0 else 1.0
|
||||
return (audio * float(scaling_factor)).astype(np.int16)
|
||||
|
||||
|
||||
# ONNX Runtime settings
|
||||
onnxruntime.set_seed(RANDOM_SEED)
|
||||
session_opts = onnxruntime.SessionOptions()
|
||||
session_opts.log_severity_level = 4 # fatal level = 4, it an adjustable value.
|
||||
session_opts.log_verbosity_level = 4 # fatal level = 4, it an adjustable value.
|
||||
session_opts.inter_op_num_threads = MAX_THREADS # Run different nodes with num_threads. Set 0 for auto.
|
||||
session_opts.intra_op_num_threads = MAX_THREADS # Under the node, execute the operators with num_threads. Set 0 for auto.
|
||||
session_opts.enable_cpu_mem_arena = True # True for execute speed; False for less memory usage.
|
||||
session_opts.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
session_opts.add_session_config_entry("session.intra_op.allow_spinning", "1")
|
||||
session_opts.add_session_config_entry("session.inter_op.allow_spinning", "1")
|
||||
session_opts.add_session_config_entry("session.set_denormal_as_zero", "1")
|
||||
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
ort_session_A = onnxruntime.InferenceSession(onnx_model_A, sess_options=session_opts, providers=['CPUExecutionProvider'], provider_options=None)
|
||||
model_type = ort_session_A._inputs_meta[0].type
|
||||
in_name_A = ort_session_A.get_inputs()
|
||||
out_name_A = ort_session_A.get_outputs()
|
||||
in_name_A0 = in_name_A[0].name
|
||||
in_name_A1 = in_name_A[1].name
|
||||
in_name_A2 = in_name_A[2].name
|
||||
out_name_A0 = out_name_A[0].name
|
||||
out_name_A1 = out_name_A[1].name
|
||||
out_name_A2 = out_name_A[2].name
|
||||
out_name_A3 = out_name_A[3].name
|
||||
out_name_A4 = out_name_A[4].name
|
||||
out_name_A5 = out_name_A[5].name
|
||||
out_name_A6 = out_name_A[6].name
|
||||
out_name_A7 = out_name_A[7].name
|
||||
|
||||
if "CPUExecutionProvider" in ORT_Accelerate_Providers or not ORT_Accelerate_Providers:
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
else:
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_BASIC
|
||||
ort_session_B = onnxruntime.InferenceSession(onnx_model_B, sess_options=session_opts, providers=ORT_Accelerate_Providers, provider_options=provider_options)
|
||||
ORT_Accelerate_Providers = ort_session_B.get_providers()[0]
|
||||
# For Windows DirectML + Intel/AMD/Nvidia GPU,
|
||||
# pip install onnxruntime-directml --upgrade
|
||||
# ort_session_B = onnxruntime.InferenceSession(onnx_model_B, sess_options=session_opts, providers=['DmlExecutionProvider'])
|
||||
print(f"\nUsable Providers: {ORT_Accelerate_Providers}")
|
||||
model_dtype = ort_session_B._inputs_meta[0].type
|
||||
in_name_B = ort_session_B.get_inputs()
|
||||
out_name_B = ort_session_B.get_outputs()
|
||||
in_name_B0 = in_name_B[0].name
|
||||
in_name_B1 = in_name_B[1].name
|
||||
in_name_B2 = in_name_B[2].name
|
||||
in_name_B3 = in_name_B[3].name
|
||||
in_name_B4 = in_name_B[4].name
|
||||
in_name_B5 = in_name_B[5].name
|
||||
in_name_B6 = in_name_B[6].name
|
||||
in_name_B7 = in_name_B[7].name
|
||||
out_name_B0 = out_name_B[0].name
|
||||
out_name_B1 = out_name_B[1].name
|
||||
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
ort_session_C = onnxruntime.InferenceSession(onnx_model_C, sess_options=session_opts, providers=['CPUExecutionProvider'], provider_options=None)
|
||||
in_name_C = ort_session_C.get_inputs()
|
||||
out_name_C = ort_session_C.get_outputs()
|
||||
in_name_C0 = in_name_C[0].name
|
||||
in_name_C1 = in_name_C[1].name
|
||||
out_name_C0 = out_name_C[0].name
|
||||
|
||||
# Load the input audio
|
||||
print(f"\nReference Audio: {reference_audio}")
|
||||
audio = np.array(AudioSegment.from_file(reference_audio).set_channels(1).set_frame_rate(MODEL_SAMPLE_RATE).get_array_of_samples(), dtype=np.float32)
|
||||
audio = normalize_to_int16(audio)
|
||||
audio_len = len(audio)
|
||||
audio = audio.reshape(1, 1, -1)
|
||||
|
||||
zh_pause_punc = r"。,、;:?!"
|
||||
ref_text_len = len(ref_text.encode('utf-8')) + 3 * len(re.findall(zh_pause_punc, ref_text))
|
||||
gen_text_len = len(gen_text.encode('utf-8')) + 3 * len(re.findall(zh_pause_punc, gen_text))
|
||||
ref_audio_len = audio_len // HOP_LENGTH + 1
|
||||
max_duration = np.array([ref_audio_len + int(ref_audio_len / ref_text_len * gen_text_len / SPEED)], dtype=np.int64)
|
||||
gen_text = convert_char_to_pinyin([ref_text + gen_text])
|
||||
text_ids = list_str_to_idx(gen_text, vocab_char_map).numpy()
|
||||
time_step = np.array([0], dtype=np.int32)
|
||||
|
||||
if "CPUExecutionProvider" in ORT_Accelerate_Providers or not ORT_Accelerate_Providers:
|
||||
device_type = 'cpu'
|
||||
elif "CUDAExecutionProvider" in ORT_Accelerate_Providers or "TensorrtExecutionProvider" in ORT_Accelerate_Providers:
|
||||
device_type = 'cuda'
|
||||
elif "DmlExecutionProvider" in ORT_Accelerate_Providers:
|
||||
device_type = 'dml'
|
||||
else:
|
||||
device_type = None
|
||||
|
||||
def run_pipeline():
|
||||
a_out = ort_session_A.run(
|
||||
[out_name_A0, out_name_A1, out_name_A2, out_name_A3, out_name_A4, out_name_A5, out_name_A6, out_name_A7],
|
||||
{in_name_A0: audio, in_name_A1: text_ids, in_name_A2: max_duration})
|
||||
noise, rope_cos_q, rope_sin_q, rope_cos_k, rope_sin_k, cat_mel_text, cat_mel_text_drop, ref_signal_len = a_out
|
||||
ts = np.array([0], dtype=np.int32)
|
||||
if device_type:
|
||||
inputs = [onnxruntime.OrtValue.ortvalue_from_numpy(x, device_type, DEVICE_ID) for x in
|
||||
(noise, rope_cos_q, rope_sin_q, rope_cos_k, rope_sin_k, cat_mel_text, cat_mel_text_drop, ts)]
|
||||
outputs = [inputs[0], inputs[-1]]
|
||||
iob = ort_session_B.io_binding()
|
||||
for i in range(len(inputs)):
|
||||
iob.bind_ortvalue_input(name=in_name_B[i].name, ortvalue=inputs[i])
|
||||
for i in range(len(outputs)):
|
||||
iob.bind_ortvalue_output(name=out_name_B[i].name, ortvalue=outputs[i])
|
||||
for _ in range(0, NFE_STEP, FUSE_NFE):
|
||||
ort_session_B.run_with_iobinding(iob)
|
||||
noise = onnxruntime.OrtValue.numpy(iob.get_outputs()[0])
|
||||
else:
|
||||
for _ in range(0, NFE_STEP - 1, FUSE_NFE):
|
||||
noise, ts = ort_session_B.run(
|
||||
[out_name_B0, out_name_B1],
|
||||
{in_name_B0: noise, in_name_B1: rope_cos_q, in_name_B2: rope_sin_q, in_name_B3: rope_cos_k,
|
||||
in_name_B4: rope_sin_k, in_name_B5: cat_mel_text, in_name_B6: cat_mel_text_drop, in_name_B7: ts})
|
||||
return ort_session_C.run([out_name_C0], {in_name_C0: noise, in_name_C1: ref_signal_len})[0]
|
||||
|
||||
print(f"\nProvider={ORT_Accelerate_Providers} device_type={device_type} NFE={NFE_STEP}")
|
||||
print("Warmup (DML kompiliert beim 1. Lauf die Shader) ...")
|
||||
t0 = time.time(); _ = run_pipeline(); print(f" warmup gen = {time.time()-t0:.2f}s")
|
||||
best = 1e9
|
||||
for k in range(2):
|
||||
t0 = time.time(); gen = run_pipeline(); dt = time.time() - t0; best = min(best, dt)
|
||||
print(f" run {k+1}: gen = {dt:.2f}s")
|
||||
audio_s = gen.reshape(-1).shape[0] / MODEL_SAMPLE_RATE
|
||||
sf.write(generated_audio, gen.reshape(-1), MODEL_SAMPLE_RATE, format='WAVEX')
|
||||
rtf = best / max(audio_s, 0.01)
|
||||
print(f"\n=== ERGEBNIS NFE={NFE_STEP} === audio={audio_s:.2f}s gen(best)={best:.2f}s RTF={rtf:.2f} "
|
||||
f"({'REAL-TIME' if rtf < 1 else 'zu langsam'})")
|
||||
@@ -0,0 +1,298 @@
|
||||
import re
|
||||
import site
|
||||
import time
|
||||
import jieba
|
||||
import torch
|
||||
import onnxruntime
|
||||
import soundfile as sf
|
||||
import numpy as np
|
||||
from pydub import AudioSegment
|
||||
from pypinyin import lazy_pinyin, Style
|
||||
python_package_path = site.getsitepackages()[-1]
|
||||
|
||||
vocab_path = "/home/DakeQQ/Downloads/F5TTS_v1_Base/vocab.txt" # The F5-TTS model vocab download path. URL: https://huggingface.co/SWivid/F5-TTS/tree/main/F5TTS_v1_Base
|
||||
onnx_model_A = "/home/DakeQQ/Downloads/F5_Optimized/F5_Preprocess.onnx" # The exported onnx model path.
|
||||
onnx_model_B = "/home/DakeQQ/Downloads/F5_Optimized/F5_Transformer.onnx" # The exported onnx model path.
|
||||
onnx_model_C = "/home/DakeQQ/Downloads/F5_Optimized/F5_Decode.onnx" # The exported onnx model path.
|
||||
generated_audio = "./generated_audio.wav"
|
||||
test_in_english = False
|
||||
|
||||
if test_in_english:
|
||||
reference_audio = python_package_path + "/f5_tts/infer/examples/basic/basic_ref_en.wav"
|
||||
ref_text = "Some call me nature, others call me mother nature."
|
||||
gen_text = "Some call me Dake, others call me QQ."
|
||||
else:
|
||||
reference_audio = python_package_path + "/f5_tts/infer/examples/basic/basic_ref_zh.wav" # The reference audio path.
|
||||
ref_text = "对,这就是我,万人敬仰的太乙真人。" # The ASR result of reference audio.
|
||||
gen_text = "对,这就是我,万人敬仰的大可奇奇。" # The target TTS.
|
||||
|
||||
|
||||
ORT_Accelerate_Providers = ['CPUExecutionProvider'] # If you have accelerate devices for : ['CUDAExecutionProvider', 'TensorrtExecutionProvider', 'CoreMLExecutionProvider', 'DmlExecutionProvider', 'OpenVINOExecutionProvider', 'ROCMExecutionProvider', 'MIGraphXExecutionProvider', 'AzureExecutionProvider']
|
||||
# else keep empty.
|
||||
RANDOM_SEED = 9527 # Set seed to reproduce the generated audio
|
||||
NFE_STEP = 32 # F5-TTS model setting, 0~31
|
||||
FUSE_NFE = 1 # Maintain the same values as the exported model.
|
||||
SPEED = 1.0 # Set for talking speed. Only works with dynamic_axes=True
|
||||
MAX_THREADS = 8 # Max CPU parallel threads.
|
||||
DEVICE_ID = 0 # The GPU id, default to 0.
|
||||
MODEL_SAMPLE_RATE = 24000 # Do not modify it.
|
||||
HOP_LENGTH = 256 # It affects the generated audio length and speech speed.
|
||||
|
||||
if "OpenVINOExecutionProvider" in ORT_Accelerate_Providers:
|
||||
provider_options = [
|
||||
{
|
||||
'device_type': 'CPU', # [CPU, NPU, GPU, GPU.0, GPU.1]]
|
||||
'precision': 'ACCURACY', # [FP32, FP16, ACCURACY]
|
||||
'num_of_threads': MAX_THREADS,
|
||||
'num_streams': 1,
|
||||
'enable_opencl_throttling': True,
|
||||
'enable_qdq_optimizer': False # Enable it carefully
|
||||
}
|
||||
]
|
||||
elif "CUDAExecutionProvider" in ORT_Accelerate_Providers:
|
||||
provider_options = [
|
||||
{
|
||||
'device_id': DEVICE_ID,
|
||||
'gpu_mem_limit': 8 * 1024 * 1024 * 1024, # 8 GB
|
||||
'arena_extend_strategy': 'kNextPowerOfTwo',
|
||||
'cudnn_conv_algo_search': 'EXHAUSTIVE',
|
||||
'cudnn_conv_use_max_workspace': '1',
|
||||
'do_copy_in_default_stream': '1',
|
||||
'cudnn_conv1d_pad_to_nc1d': '1',
|
||||
'enable_cuda_graph': '0', # Set to '0' to avoid potential errors when enabled.
|
||||
'use_tf32': '0'
|
||||
}
|
||||
]
|
||||
else:
|
||||
# Please config by yourself for others providers.
|
||||
provider_options = None
|
||||
|
||||
|
||||
with open(vocab_path, "r", encoding="utf-8") as f:
|
||||
vocab_char_map = {}
|
||||
for i, char in enumerate(f):
|
||||
vocab_char_map[char[:-1]] = i
|
||||
vocab_size = len(vocab_char_map)
|
||||
|
||||
|
||||
# From the official code
|
||||
def convert_char_to_pinyin(text_list, polyphone=True):
|
||||
if jieba.dt.initialized is False:
|
||||
jieba.default_logger.setLevel(50) # CRITICAL
|
||||
jieba.initialize()
|
||||
|
||||
final_text_list = []
|
||||
custom_trans = str.maketrans(
|
||||
{";": ",", "“": '"', "”": '"', "‘": "'", "’": "'"}
|
||||
) # add custom trans here, to address oov
|
||||
|
||||
def is_chinese(c):
|
||||
return (
|
||||
"\u3100" <= c <= "\u9fff" # common chinese characters
|
||||
)
|
||||
|
||||
for text in text_list:
|
||||
char_list = []
|
||||
text = text.translate(custom_trans)
|
||||
for seg in jieba.cut(text):
|
||||
seg_byte_len = len(bytes(seg, "UTF-8"))
|
||||
if seg_byte_len == len(seg): # if pure alphabets and symbols
|
||||
if char_list and seg_byte_len > 1 and char_list[-1] not in " :'\"":
|
||||
char_list.append(" ")
|
||||
char_list.extend(seg)
|
||||
elif polyphone and seg_byte_len == 3 * len(seg): # if pure east asian characters
|
||||
seg_ = lazy_pinyin(seg, style=Style.TONE3, tone_sandhi=True)
|
||||
for i, c in enumerate(seg):
|
||||
if is_chinese(c):
|
||||
char_list.append(" ")
|
||||
char_list.append(seg_[i])
|
||||
else: # if mixed characters, alphabets and symbols
|
||||
for c in seg:
|
||||
if ord(c) < 256:
|
||||
char_list.extend(c)
|
||||
elif is_chinese(c):
|
||||
char_list.append(" ")
|
||||
char_list.extend(lazy_pinyin(c, style=Style.TONE3, tone_sandhi=True))
|
||||
else:
|
||||
char_list.append(c)
|
||||
final_text_list.append(char_list)
|
||||
return final_text_list
|
||||
|
||||
|
||||
# From the official code
|
||||
def list_str_to_idx(
|
||||
text: list[str] | list[list[str]],
|
||||
vocab_char_map: dict[str, int], # {char: idx}
|
||||
padding_value=-1
|
||||
):
|
||||
get_idx = vocab_char_map.get
|
||||
list_idx_tensors = [torch.tensor([get_idx(c, 0) for c in t], dtype=torch.int32) for t in text]
|
||||
text = torch.nn.utils.rnn.pad_sequence(list_idx_tensors, padding_value=padding_value, batch_first=True)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_to_int16(audio):
|
||||
max_val = np.max(np.abs(audio))
|
||||
scaling_factor = 32767.0 / max_val if max_val > 0 else 1.0
|
||||
return (audio * float(scaling_factor)).astype(np.int16)
|
||||
|
||||
|
||||
# ONNX Runtime settings
|
||||
onnxruntime.set_seed(RANDOM_SEED)
|
||||
session_opts = onnxruntime.SessionOptions()
|
||||
session_opts.log_severity_level = 4 # fatal level = 4, it an adjustable value.
|
||||
session_opts.log_verbosity_level = 4 # fatal level = 4, it an adjustable value.
|
||||
session_opts.inter_op_num_threads = MAX_THREADS # Run different nodes with num_threads. Set 0 for auto.
|
||||
session_opts.intra_op_num_threads = MAX_THREADS # Under the node, execute the operators with num_threads. Set 0 for auto.
|
||||
session_opts.enable_cpu_mem_arena = True # True for execute speed; False for less memory usage.
|
||||
session_opts.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
session_opts.add_session_config_entry("session.intra_op.allow_spinning", "1")
|
||||
session_opts.add_session_config_entry("session.inter_op.allow_spinning", "1")
|
||||
session_opts.add_session_config_entry("session.set_denormal_as_zero", "1")
|
||||
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
ort_session_A = onnxruntime.InferenceSession(onnx_model_A, sess_options=session_opts, providers=['CPUExecutionProvider'], provider_options=None)
|
||||
model_type = ort_session_A._inputs_meta[0].type
|
||||
in_name_A = ort_session_A.get_inputs()
|
||||
out_name_A = ort_session_A.get_outputs()
|
||||
in_name_A0 = in_name_A[0].name
|
||||
in_name_A1 = in_name_A[1].name
|
||||
in_name_A2 = in_name_A[2].name
|
||||
out_name_A0 = out_name_A[0].name
|
||||
out_name_A1 = out_name_A[1].name
|
||||
out_name_A2 = out_name_A[2].name
|
||||
out_name_A3 = out_name_A[3].name
|
||||
out_name_A4 = out_name_A[4].name
|
||||
out_name_A5 = out_name_A[5].name
|
||||
out_name_A6 = out_name_A[6].name
|
||||
out_name_A7 = out_name_A[7].name
|
||||
|
||||
if "CPUExecutionProvider" in ORT_Accelerate_Providers or not ORT_Accelerate_Providers:
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
else:
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_BASIC
|
||||
ort_session_B = onnxruntime.InferenceSession(onnx_model_B, sess_options=session_opts, providers=ORT_Accelerate_Providers, provider_options=provider_options)
|
||||
ORT_Accelerate_Providers = ort_session_B.get_providers()[0]
|
||||
# For Windows DirectML + Intel/AMD/Nvidia GPU,
|
||||
# pip install onnxruntime-directml --upgrade
|
||||
# ort_session_B = onnxruntime.InferenceSession(onnx_model_B, sess_options=session_opts, providers=['DmlExecutionProvider'])
|
||||
print(f"\nUsable Providers: {ORT_Accelerate_Providers}")
|
||||
model_dtype = ort_session_B._inputs_meta[0].type
|
||||
in_name_B = ort_session_B.get_inputs()
|
||||
out_name_B = ort_session_B.get_outputs()
|
||||
in_name_B0 = in_name_B[0].name
|
||||
in_name_B1 = in_name_B[1].name
|
||||
in_name_B2 = in_name_B[2].name
|
||||
in_name_B3 = in_name_B[3].name
|
||||
in_name_B4 = in_name_B[4].name
|
||||
in_name_B5 = in_name_B[5].name
|
||||
in_name_B6 = in_name_B[6].name
|
||||
in_name_B7 = in_name_B[7].name
|
||||
out_name_B0 = out_name_B[0].name
|
||||
out_name_B1 = out_name_B[1].name
|
||||
|
||||
session_opts.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
ort_session_C = onnxruntime.InferenceSession(onnx_model_C, sess_options=session_opts, providers=['CPUExecutionProvider'], provider_options=None)
|
||||
in_name_C = ort_session_C.get_inputs()
|
||||
out_name_C = ort_session_C.get_outputs()
|
||||
in_name_C0 = in_name_C[0].name
|
||||
in_name_C1 = in_name_C[1].name
|
||||
out_name_C0 = out_name_C[0].name
|
||||
|
||||
# Load the input audio
|
||||
print(f"\nReference Audio: {reference_audio}")
|
||||
audio = np.array(AudioSegment.from_file(reference_audio).set_channels(1).set_frame_rate(MODEL_SAMPLE_RATE).get_array_of_samples(), dtype=np.float32)
|
||||
audio = normalize_to_int16(audio)
|
||||
audio_len = len(audio)
|
||||
audio = audio.reshape(1, 1, -1)
|
||||
|
||||
zh_pause_punc = r"。,、;:?!"
|
||||
ref_text_len = len(ref_text.encode('utf-8')) + 3 * len(re.findall(zh_pause_punc, ref_text))
|
||||
gen_text_len = len(gen_text.encode('utf-8')) + 3 * len(re.findall(zh_pause_punc, gen_text))
|
||||
ref_audio_len = audio_len // HOP_LENGTH + 1
|
||||
max_duration = np.array([ref_audio_len + int(ref_audio_len / ref_text_len * gen_text_len / SPEED)], dtype=np.int64)
|
||||
gen_text = convert_char_to_pinyin([ref_text + gen_text])
|
||||
text_ids = list_str_to_idx(gen_text, vocab_char_map).numpy()
|
||||
time_step = np.array([0], dtype=np.int32)
|
||||
|
||||
if "CPUExecutionProvider" in ORT_Accelerate_Providers or not ORT_Accelerate_Providers:
|
||||
device_type = 'cpu'
|
||||
elif "CUDAExecutionProvider" in ORT_Accelerate_Providers or "TensorrtExecutionProvider" in ORT_Accelerate_Providers:
|
||||
device_type = 'cuda'
|
||||
elif "DmlExecutionProvider" in ORT_Accelerate_Providers:
|
||||
device_type = 'dml'
|
||||
else:
|
||||
device_type = None
|
||||
|
||||
print("\n\nRun F5-TTS by ONNX Runtime.")
|
||||
start_count = time.time()
|
||||
noise, rope_cos_q, rope_sin_q, rope_cos_k, rope_sin_k, cat_mel_text, cat_mel_text_drop, ref_signal_len = ort_session_A.run(
|
||||
[out_name_A0, out_name_A1, out_name_A2, out_name_A3, out_name_A4, out_name_A5, out_name_A6, out_name_A7],
|
||||
{
|
||||
in_name_A0: audio,
|
||||
in_name_A1: text_ids,
|
||||
in_name_A2: max_duration
|
||||
})
|
||||
|
||||
if device_type:
|
||||
inputs = [
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(noise, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(rope_cos_q, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(rope_sin_q, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(rope_cos_k, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(rope_sin_k, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(cat_mel_text, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(cat_mel_text_drop, device_type, DEVICE_ID),
|
||||
onnxruntime.OrtValue.ortvalue_from_numpy(time_step, device_type, DEVICE_ID)
|
||||
]
|
||||
outputs = [
|
||||
inputs[0],
|
||||
inputs[-1]
|
||||
]
|
||||
|
||||
io_binding = ort_session_B.io_binding()
|
||||
for i in range(len(inputs)):
|
||||
io_binding.bind_ortvalue_input(
|
||||
name=in_name_B[i].name,
|
||||
ortvalue=inputs[i]
|
||||
)
|
||||
for i in range(len(outputs)):
|
||||
io_binding.bind_ortvalue_output(
|
||||
name=out_name_B[i].name,
|
||||
ortvalue=outputs[i]
|
||||
)
|
||||
|
||||
print("NFE_STEP: 0")
|
||||
for i in range(0, NFE_STEP, FUSE_NFE):
|
||||
ort_session_B.run_with_iobinding(io_binding)
|
||||
print(f"NFE_STEP: {i + FUSE_NFE}")
|
||||
noise = onnxruntime.OrtValue.numpy(io_binding.get_outputs()[0])
|
||||
else:
|
||||
print("NFE_STEP: 0")
|
||||
for i in range(0, NFE_STEP - 1, FUSE_NFE):
|
||||
noise, time_step = ort_session_B.run(
|
||||
[out_name_B0, out_name_B1],
|
||||
{
|
||||
in_name_B0: noise,
|
||||
in_name_B1: rope_cos_q,
|
||||
in_name_B2: rope_sin_q,
|
||||
in_name_B3: rope_cos_k,
|
||||
in_name_B4: rope_sin_k,
|
||||
in_name_B5: cat_mel_text,
|
||||
in_name_B6: cat_mel_text_drop,
|
||||
in_name_B7: time_step
|
||||
})
|
||||
print(f"NFE_STEP: {i + FUSE_NFE}")
|
||||
|
||||
generated_signal = ort_session_C.run(
|
||||
[out_name_C0],
|
||||
{
|
||||
in_name_C0: noise,
|
||||
in_name_C1: ref_signal_len
|
||||
})[0]
|
||||
end_count = time.time()
|
||||
|
||||
# Save to audio
|
||||
sf.write(generated_audio, generated_signal.reshape(-1), MODEL_SAMPLE_RATE, format='WAVEX')
|
||||
print(f"\nAudio generation is complete.\n\nONNXRuntime Time Cost in Seconds:\n{end_count - start_count:.3f}")
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Lucy-Stimme v2: F5-TTS (deutsch) via ONNX Runtime + DirectML (9070 XT, nativ Windows, kein ROCm).
|
||||
Non-autoregressiv -> keine Kollaps-/Wiederhol-/Männerstimmen-Fehler wie pocket. Satzweises Streaming.
|
||||
Modelliert nach dem verifizierten DML-Benchmark (bench_dml.py) + Export_F5-Preprocessing + pocket_server-Struktur."""
|
||||
import os, io, re, time, threading, logging
|
||||
import numpy as np, soundfile as sf, librosa, jieba, torch
|
||||
import onnxruntime as ort
|
||||
from pypinyin import lazy_pinyin, Style
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response, JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
log = logging.getLogger("lucy-f5"); logging.basicConfig(level=logging.INFO)
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
ONNX_DIR = os.environ.get("LUCY_F5_ONNX", os.path.join(BASE, "onnx_de"))
|
||||
VOCAB = os.environ.get("LUCY_F5_VOCAB", os.path.join(BASE, "vocab.txt"))
|
||||
REF_WAV = os.environ.get("LUCY_F5_REF", os.path.join(BASE, "lucy_ref.wav"))
|
||||
REF_TXT = os.environ.get("LUCY_F5_REF_TXT", os.path.join(BASE, "lucy_ref.txt"))
|
||||
PROVIDER = os.environ.get("LUCY_F5_PROVIDER", "DmlExecutionProvider")
|
||||
NFE_STEP = int(os.environ.get("LUCY_F5_NFE", "32")) # MUSS zum Export passen (Zeitplan ist eingebacken)
|
||||
TARGET_RMS = float(os.environ.get("LUCY_TARGET_RMS", "0.09"))
|
||||
SR = 24000; HOP_LENGTH = 256
|
||||
STATE, LOCK = {}, threading.Lock()
|
||||
|
||||
# ---- Text-Preprocessing (aus Export_F5.py; für Deutsch laufen Nicht-CJK-Zeichen einfach durch) ----
|
||||
def _load_vocab(path):
|
||||
m = {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for i, ch in enumerate(f):
|
||||
m[ch[:-1]] = i
|
||||
return m
|
||||
|
||||
def convert_char_to_pinyin(text_list, polyphone=True):
|
||||
if jieba.dt.initialized is False:
|
||||
jieba.default_logger.setLevel(50); jieba.initialize()
|
||||
out, trans = [], str.maketrans({";": ",", "“": '"', "”": '"', "‘": "'", "’": "'"})
|
||||
def is_zh(c): return "" <= c <= "鿿"
|
||||
for text in text_list:
|
||||
cl = []; text = text.translate(trans)
|
||||
for seg in jieba.cut(text):
|
||||
blen = len(bytes(seg, "UTF-8"))
|
||||
if blen == len(seg):
|
||||
if cl and blen > 1 and cl[-1] not in " :'\"": cl.append(" ")
|
||||
cl.extend(seg)
|
||||
elif polyphone and blen == 3 * len(seg):
|
||||
pin = lazy_pinyin(seg, style=Style.TONE3, tone_sandhi=True)
|
||||
for i, c in enumerate(seg):
|
||||
if is_zh(c): cl.append(" ")
|
||||
cl.append(pin[i])
|
||||
else:
|
||||
for c in seg:
|
||||
if ord(c) < 256: cl.extend(c)
|
||||
elif is_zh(c): cl.append(" "); cl.extend(lazy_pinyin(c, style=Style.TONE3, tone_sandhi=True))
|
||||
else: cl.append(c)
|
||||
out.append(cl)
|
||||
return out
|
||||
|
||||
def list_str_to_idx(text, vocab_map, padding_value=-1):
|
||||
get = vocab_map.get
|
||||
tensors = [torch.tensor([get(c, 0) for c in t], dtype=torch.int32) for t in text]
|
||||
return torch.nn.utils.rnn.pad_sequence(tensors, padding_value=padding_value, batch_first=True).numpy()
|
||||
|
||||
_ZH_PUNC = r"。,、;:?!"
|
||||
def _text_len(s): return len(s.encode("utf-8")) + 3 * len(re.findall(_ZH_PUNC, s))
|
||||
|
||||
# ---- Satz-Splitter (wie pocket) ----
|
||||
_SENT_RX = re.compile(r".+?(?:[.!?…]+(?:\s|$)|$)", re.S)
|
||||
def split_sentences(text, min_len=30):
|
||||
parts = [m.group(0).strip() for m in _SENT_RX.finditer(text.strip())]
|
||||
out = []
|
||||
for p in parts:
|
||||
if not p: continue
|
||||
if out and len(out[-1]) < min_len: out[-1] = f"{out[-1]} {p}"
|
||||
else: out.append(p)
|
||||
return out or [text.strip()]
|
||||
|
||||
def cleanup(a, sr):
|
||||
"""F5-Output putzen: Stille-Trim hinten, RMS-Norm auf TARGET_RMS, Peak-Clamp, 80ms-Pads."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
rev, _ = librosa.effects.trim(a[::-1], top_db=40); a = rev[::-1] if rev.size else a
|
||||
yt, _ = librosa.effects.trim(a, top_db=40); a = yt if yt.size else a
|
||||
rms = float(np.sqrt(np.mean(a ** 2))) or 1e-9
|
||||
a = a * (TARGET_RMS / rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0.95: a = a * (0.95 / peak)
|
||||
fi = min(int(0.008 * sr), a.size // 2)
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0., 1., fi, dtype=np.float32); a[-fi:] *= np.linspace(1., 0., fi, dtype=np.float32)
|
||||
pad = np.zeros(int(0.08 * sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
def _to_pcm16(a):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
np.clip(a, -0.95, 0.95, out=a)
|
||||
return (a * 32767.0).astype("<i2").tobytes()
|
||||
|
||||
# ---- ONNX-Inferenz (A=Preprocess CPU, B=Transformer DML+io_binding, C=Decode CPU) ----
|
||||
def _infer(gen_text: str) -> np.ndarray:
|
||||
s = STATE
|
||||
ref_text = s["ref_text"]
|
||||
rt_len = _text_len(ref_text); gt_len = max(_text_len(gen_text), 1)
|
||||
ref_audio_len = s["ref_audio"].shape[-1] // HOP_LENGTH + 1
|
||||
max_duration = np.array([ref_audio_len + int(ref_audio_len / rt_len * gt_len)], dtype=np.int64)
|
||||
text = convert_char_to_pinyin([ref_text + gen_text])
|
||||
text_ids = list_str_to_idx(text, s["vocab"])
|
||||
A = s["A"].run(s["A_out"], {s["A_in"][0]: s["ref_audio"], s["A_in"][1]: text_ids, s["A_in"][2]: max_duration})
|
||||
noise, rcq, rsq, rck, rsk, cmt, cmtd, ref_signal_len = A
|
||||
dev = s["dev"]
|
||||
if dev: # DirectML/CUDA: io_binding, Tensoren GPU-resident über die NFE-Schleife
|
||||
ts = np.array([0], dtype=np.int32)
|
||||
ins = [ort.OrtValue.ortvalue_from_numpy(x, dev, 0) for x in (noise, rcq, rsq, rck, rsk, cmt, cmtd, ts)]
|
||||
outs = [ins[0], ins[-1]]
|
||||
iob = s["B"].io_binding()
|
||||
for i in range(len(ins)): iob.bind_ortvalue_input(name=s["B_in"][i], ortvalue=ins[i])
|
||||
for i in range(len(outs)): iob.bind_ortvalue_output(name=s["B_out"][i], ortvalue=outs[i])
|
||||
for _ in range(0, NFE_STEP, 1): s["B"].run_with_iobinding(iob)
|
||||
noise = ort.OrtValue.numpy(iob.get_outputs()[0])
|
||||
else:
|
||||
ts = np.array([0], dtype=np.int32)
|
||||
for _ in range(0, NFE_STEP - 1, 1):
|
||||
noise, ts = s["B"].run(s["B_out"], {s["B_in"][0]: noise, s["B_in"][1]: rcq, s["B_in"][2]: rsq,
|
||||
s["B_in"][3]: rck, s["B_in"][4]: rsk, s["B_in"][5]: cmt, s["B_in"][6]: cmtd, s["B_in"][7]: ts})
|
||||
out = s["C"].run([s["C_out"]], {s["C_in"][0]: noise, s["C_in"][1]: ref_signal_len})[0]
|
||||
a = np.asarray(out).reshape(-1).astype(np.float32)
|
||||
if a.dtype != np.float32 or np.max(np.abs(a)) > 1.5: # int16-Decoder -> auf float
|
||||
a = a / 32768.0
|
||||
return a
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
t0 = time.time(); log.info("Lade F5 ONNX (%s) ...", PROVIDER)
|
||||
so = ort.SessionOptions(); so.log_severity_level = 4
|
||||
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
A = ort.InferenceSession(os.path.join(ONNX_DIR, "F5_Preprocess.onnx"), so, providers=["CPUExecutionProvider"])
|
||||
B = ort.InferenceSession(os.path.join(ONNX_DIR, "F5_Transformer.onnx"), so, providers=[PROVIDER])
|
||||
C = ort.InferenceSession(os.path.join(ONNX_DIR, "F5_Decode.onnx"), so, providers=["CPUExecutionProvider"])
|
||||
prov = B.get_providers()[0]
|
||||
dev = "dml" if "Dml" in prov else ("cuda" if "CUDA" in prov or "Tensorrt" in prov else None)
|
||||
# Lucy-Referenz als int16 laden (Decoder/Preprocess erwartet int16-Pfad)
|
||||
ref, _sr = sf.read(REF_WAV, dtype="float32", always_2d=False)
|
||||
ref = np.asarray(ref, dtype=np.float32).reshape(-1)
|
||||
if _sr != SR: ref = librosa.resample(ref, orig_sr=_sr, target_sr=SR)
|
||||
mx = np.max(np.abs(ref)) or 1.0
|
||||
ref_i16 = (ref * (32767.0 / mx)).astype(np.int16).reshape(1, 1, -1)
|
||||
STATE.update(
|
||||
A=A, B=B, C=C, dev=dev, prov=prov,
|
||||
A_in=[i.name for i in A.get_inputs()], A_out=[o.name for o in A.get_outputs()],
|
||||
B_in=[i.name for i in B.get_inputs()], B_out=[o.name for o in B.get_outputs()],
|
||||
C_in=[i.name for i in C.get_inputs()], C_out=C.get_outputs()[0].name,
|
||||
vocab=_load_vocab(VOCAB), ref_audio=ref_i16,
|
||||
ref_text=open(REF_TXT, encoding="utf-8").read().strip(),
|
||||
)
|
||||
log.info("Lucy-F5 bereit in %.1fs (Provider=%s, dev=%s, NFE=%d)", time.time() - t0, prov, dev, NFE_STEP)
|
||||
yield
|
||||
STATE.clear()
|
||||
|
||||
app = FastAPI(title="Lucy TTS (F5/DirectML)", lifespan=lifespan)
|
||||
|
||||
class Req(BaseModel):
|
||||
text: str
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok" if "A" in STATE else "loading", "engine": "f5-tts",
|
||||
"provider": STATE.get("prov"), "nfe": NFE_STEP, "sr": SR}
|
||||
|
||||
@app.post("/tts")
|
||||
def tts(req: Req):
|
||||
if "A" not in STATE: return JSONResponse({"error": "loading"}, status_code=503)
|
||||
t0 = time.time()
|
||||
parts = []
|
||||
with LOCK:
|
||||
for sent in split_sentences(req.text):
|
||||
parts.append(cleanup(_infer(sent), SR))
|
||||
a = np.concatenate(parts) if parts else np.zeros(0, np.float32)
|
||||
buf = io.BytesIO(); sf.write(buf, a, SR, format="WAV", subtype="PCM_16"); buf.seek(0)
|
||||
dur = a.size / SR; gen = time.time() - t0
|
||||
log.info("/tts %dZ audio=%.1fs gen=%.1fs rtf=%.2f", len(req.text), dur, gen, gen / max(dur, 0.01))
|
||||
return Response(buf.read(), media_type="audio/wav",
|
||||
headers={"X-Audio-Seconds": f"{dur:.2f}", "X-Gen-Seconds": f"{gen:.2f}"})
|
||||
|
||||
@app.post("/tts/stream")
|
||||
def tts_stream(req: Req):
|
||||
if "A" not in STATE: return JSONResponse({"error": "loading"}, status_code=503)
|
||||
sentences = split_sentences(req.text)
|
||||
def pcm():
|
||||
t0 = time.time(); total = 0; first = True
|
||||
with LOCK:
|
||||
for sent in sentences:
|
||||
a = cleanup(_infer(sent), SR); total += a.size
|
||||
if first: log.info("/tts/stream TTFB=%.2fs (%d Sätze)", time.time() - t0, len(sentences)); first = False
|
||||
yield _to_pcm16(a)
|
||||
log.info("/tts/stream %dZ audio=%.1fs gen=%.1fs", len(req.text), total / SR, time.time() - t0)
|
||||
return StreamingResponse(pcm(), media_type="application/octet-stream", headers={"X-Sample-Rate": str(SR)})
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""F5-TTS Qualitäts-/Tempo-Test (CPU): dt. Finetune + Lucy-Klon. Gleiche Sätze wie pocket -> A/B."""
|
||||
import os, time, soundfile as sf, numpy as np, torch
|
||||
# torchcodec/ffmpeg fehlt -> torchaudio.load/save auf soundfile umbiegen (wie bei OuteTTS-Patch)
|
||||
import torchaudio
|
||||
def _ta_load(path, *a, **k):
|
||||
data, sr = sf.read(str(path), dtype="float32", always_2d=True)
|
||||
return torch.from_numpy(data.T.copy()), sr
|
||||
def _ta_save(path, tensor, sr, *a, **k):
|
||||
arr = np.asarray(tensor.detach().cpu().numpy())
|
||||
sf.write(str(path), arr.T if arr.ndim == 2 else arr, sr)
|
||||
torchaudio.load = _ta_load
|
||||
torchaudio.save = _ta_save
|
||||
from f5_tts.api import F5TTS
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-f5"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_f5_test"; os.makedirs(OUT, exist_ok=True)
|
||||
ref = os.path.join(BASE, "lucy_ref.wav")
|
||||
ref_text = open(os.path.join(BASE, "lucy_ref.txt"), encoding="utf-8").read().strip()
|
||||
print("REF_TEXT:", ref_text[:80], flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
f5 = F5TTS(model="F5TTS_Base",
|
||||
ckpt_file=os.path.join(BASE, "model_f5tts_german.safetensors"),
|
||||
vocab_file=os.path.join(BASE, "vocab.txt"), device="cpu")
|
||||
print(f"Modell geladen in {time.time()-t0:.1f}s (Vocoder evtl. erst geladen)", flush=True)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time()
|
||||
wav, sr, _ = f5.infer(ref_file=ref, ref_text=ref_text, gen_text=text,
|
||||
nfe_step=32, target_rms=0.1, remove_silence=True)
|
||||
dt = time.time() - t0
|
||||
wav = np.asarray(wav, dtype=np.float32).reshape(-1)
|
||||
peak = float(np.max(np.abs(wav))) # Peak-Limiter gegen Clipping (F5 traf 1.0)
|
||||
if peak > 0.95: wav = wav * (0.95 / peak)
|
||||
sf.write(os.path.join(OUT, f"{name}.wav"), wav, sr)
|
||||
secs = len(wav) / sr
|
||||
print(f"[{name:6}] gen={dt:6.1f}s audio={secs:5.1f}s RTF={dt/max(secs,0.01):5.2f}", flush=True)
|
||||
print("F5_TEST_DONE", flush=True)
|
||||
@@ -0,0 +1 @@
|
||||
Hallo, schön, dass du da bist. Ich bin deine persönliche Assistentin und begleite dich durch deinen Tag.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo B2-Test: seriell vs. 3 Worker (laedt mehrere Modelle - kann ein paar Minuten dauern)...
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" bench_b2.py > bench_b2.log 2>&1
|
||||
type bench_b2.log
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Fertig. WAVs in: %USERPROFILE%\Desktop\lucy_samples (b2_serial / b2_parallel_3w)
|
||||
echo ============================================================
|
||||
pause
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo Erzeuge Lucy-Hoerproben (A1 safetensors-Cache + A2 Referenz-Cleaning)...
|
||||
echo Modell laedt offline aus dem lokalen Cache - das kann ein paar Minuten dauern.
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" make_samples.py > make_samples.log 2>&1
|
||||
type make_samples.log
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Fertig. Samples liegen in: %USERPROFILE%\Desktop\lucy_samples
|
||||
echo ============================================================
|
||||
pause
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo Lucy-Startklar-Check: bootet die Stimme + rendert finale Samples auf den Desktop...
|
||||
echo (Modell laedt offline aus dem Cache - ein paar Sekunden)
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" lucy_ready.py > lucy_ready.log 2>&1
|
||||
type lucy_ready.log
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Samples: %USERPROFILE%\Desktop\lucy_samples (lucy_final_*)
|
||||
echo ============================================================
|
||||
pause
|
||||
@@ -0,0 +1,12 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo B2-Sweep: WORKERS x THREADS finden (laedt viele Modelle - dauert ein paar Minuten)...
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" sweep_b2.py > sweep_b2.log 2>&1
|
||||
echo ============================================================
|
||||
type sweep_b2_result.json 2>nul
|
||||
echo.
|
||||
echo (Volllog: sweep_b2.log)
|
||||
echo ============================================================
|
||||
pause
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo TTFB-Test (seriell): kurzer-erster-Chunk an/aus...
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" ttfb_test.py > ttfb_test.log 2>&1
|
||||
type ttfb_test.log
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
cd /d "F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
echo Umlaut-Test: Logik-Check + Vorher/Nachher-Audio...
|
||||
echo.
|
||||
"ptts-venv\Scripts\python.exe" umlaut_test.py > umlaut_test.log 2>&1
|
||||
type umlaut_test.log
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Stimm-RÖNTGEN: liest Wörter + Wort-genaue Zeiten + PAUSEN + Prosodie (F0/Tempo) aus einer WAV.
|
||||
So sieht man beim Tunen objektiv, wo Pocket zu lange Pausen macht (Komma!), nuschelt, oder monoton wird.
|
||||
|
||||
Nutzung: python analyze.py <datei.wav>
|
||||
"""
|
||||
import sys, numpy as np, soundfile as sf, librosa
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
p = sys.argv[1]
|
||||
a, sr = sf.read(p)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
dur = len(a) / sr
|
||||
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
segs, _ = w.transcribe(p, language="de", beam_size=5, word_timestamps=True)
|
||||
words = [x for s in segs for x in (s.words or [])]
|
||||
txt = " ".join(x.word.strip() for x in words)
|
||||
|
||||
print(f"=== {p.split(chr(92))[-1]} ===")
|
||||
print(f"Dauer {dur:.2f}s | {len(words)} Wörter | Tempo {len(words)/max(dur,0.01):.1f} Wörter/s")
|
||||
print(f"TEXT: {txt}\n")
|
||||
|
||||
print("WORT-TIMING & PAUSE danach:")
|
||||
pauses = []
|
||||
for i, x in enumerate(words):
|
||||
gap = (words[i + 1].start - x.end) if i + 1 < len(words) else 0.0
|
||||
flag = " <=== LANG" if gap >= 0.30 else (" <- Pause" if gap >= 0.15 else "")
|
||||
if gap >= 0.15:
|
||||
pauses.append((x.word.strip(), gap))
|
||||
print(f" {x.start:5.2f}-{x.end:5.2f} {x.word.strip():16} Pause: {gap*1000:4.0f}ms{flag}")
|
||||
|
||||
# Rand-Stille
|
||||
env = np.abs(a) > 0.015
|
||||
lead = (np.argmax(env) / sr * 1000) if env.any() else 0
|
||||
tail = ((len(a) - 1 - np.argmax(env[::-1])) if env.any() else len(a))
|
||||
tail_ms = (len(a) - tail) / sr * 1000
|
||||
|
||||
# Prosodie: F0 (Tonhöhe) über stimmhafte Frames
|
||||
f0 = librosa.yin(a, fmin=80, fmax=400, sr=sr, frame_length=1024)
|
||||
f0v = f0[(f0 > 90) & (f0 < 380)]
|
||||
if f0v.size:
|
||||
med = float(np.median(f0v)); rng = float(np.percentile(f0v, 90) - np.percentile(f0v, 10))
|
||||
else:
|
||||
med = rng = 0.0
|
||||
|
||||
print(f"\nPAUSEN gesamt: {len(pauses)} (>=150ms) | längste: " +
|
||||
(", ".join(f'nach „{w_}\": {g*1000:.0f}ms' for w_, g in sorted(pauses, key=lambda t: -t[1])[:4]) or "keine"))
|
||||
print(f"RAND-STILLE: vorne {lead:.0f}ms, hinten {tail_ms:.0f}ms")
|
||||
print(f"TONHÖHE: median {med:.0f}Hz, Spanne {rng:.0f}Hz ({'monoton' if rng < 40 else 'lebendig' if rng > 90 else 'ok'})")
|
||||
@@ -0,0 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""bench_b2.py — verifiziert B2 (Worker-Pool) end-to-end mit den ECHTEN pocket_server-Funktionen.
|
||||
Misst die Wall-Clock einer langen, mehrsätzigen Antwort SERIELL (1 Modell) vs. 3 WORKER parallel,
|
||||
prüft die Reihenfolge (geordnete Ausgabe) und legt beide Ergebnis-WAVs auf den Desktop.
|
||||
"""
|
||||
import os, time
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pocket_tts import TTSModel
|
||||
import pocket_server as ps
|
||||
|
||||
DESK = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_samples")
|
||||
os.makedirs(DESK, exist_ok=True)
|
||||
|
||||
LONG = ("Guten Morgen, Commander. Das nächtliche Backup ist sauber durchgelaufen. "
|
||||
"Es gab keine Fehler in den Protokollen. Der Dienst läuft stabil weiter. "
|
||||
"Ich habe die Modelle vorgewärmt und die Engine antwortet zügig. "
|
||||
"Wenn du möchtest, starte ich jetzt den Tagesbericht und fasse die offenen Punkte zusammen.")
|
||||
|
||||
|
||||
def _serial():
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=ps.LSD, temp=ps.TEMP,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
ref = ps._prep_ref()
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ref_audio, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
ps.STATE.clear()
|
||||
ps.STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=ps._fingerprint(ref_audio, m.sample_rate))
|
||||
sents = ps._split_sentences(LONG)
|
||||
t0 = time.time()
|
||||
parts = list(ps._gen_sentences_ordered(sents)) # pool=None -> serieller Zweig (LOCK)
|
||||
dt = time.time() - t0
|
||||
a = np.concatenate(parts)
|
||||
sf.write(os.path.join(DESK, "b2_serial.wav"), a, m.sample_rate)
|
||||
ps.STATE.clear(); del m
|
||||
return dt, a.size / 24000, len(sents)
|
||||
|
||||
|
||||
def _parallel(nworkers):
|
||||
ps.STATE.clear()
|
||||
pool = ProcessPoolExecutor(max_workers=nworkers, initializer=ps._worker_init)
|
||||
list(pool.map(ps._warmup, range(nworkers))) # alle Worker vorab hochfahren
|
||||
ref = ps._prep_ref()
|
||||
ref_audio, _ = librosa.load(ref, sr=24000, mono=True)
|
||||
ps.STATE.update(sr=24000, ref_fp=ps._fingerprint(ref_audio, 24000), pool=pool)
|
||||
sents = ps._split_sentences(LONG)
|
||||
t0 = time.time()
|
||||
parts = list(ps._gen_sentences_ordered(sents)) # pool -> parallel, geordnet
|
||||
dt = time.time() - t0
|
||||
a = np.concatenate(parts)
|
||||
sf.write(os.path.join(DESK, f"b2_parallel_{nworkers}w.wav"), a, 24000)
|
||||
pool.shutdown(wait=True); ps.STATE.clear()
|
||||
return dt, a.size / 24000, len(sents)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Text: {len(LONG)} Zeichen")
|
||||
ds, audio_s, n = _serial()
|
||||
print(f"SERIELL : {ds:5.2f}s wall ({n} Saetze, {audio_s:4.1f}s Audio) -> b2_serial.wav")
|
||||
dp, _, _ = _parallel(3)
|
||||
print(f"3 WORKER: {dp:5.2f}s wall -> b2_parallel_3w.wav")
|
||||
if dp > 0:
|
||||
print(f"Speedup : {ds/dp:.2f}x (Audio gleich lang -> nur Generierzeit zaehlt)")
|
||||
print("B2_DONE")
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""bench_lucy.py — A3/B1: misst Lucys Stimme (pocket-tts) über temp/lsd.
|
||||
|
||||
Metriken je Konfig: RTF, gen-Zeit und ROHE Kollaps-Rate (EINE Generierung, OHNE best-of-N-Gate)
|
||||
— also genau das, was der Stimm-Wächter aktuell wegbügeln muss (= Latenz-Treiber). Niedrigere
|
||||
temp sollte die rohe Kollaps-Rate senken -> weniger Regenerationen -> niedrigere effektive Latenz.
|
||||
|
||||
Nutzung (im ptts-venv der lucy-tts-Maschine):
|
||||
python bench_lucy.py # Default-Sweep
|
||||
python bench_lucy.py --reps 5
|
||||
python bench_lucy.py --temps 0.7,0.8,0.9 --lsds 8,10,12
|
||||
python bench_lucy.py --whisper # + Verständlichkeits-Check (faster-whisper)
|
||||
|
||||
Reuse: Kollaps-Logik (F0 + MFCC-Fingerabdruck) und cleanup() kommen 1:1 aus pocket_server.py,
|
||||
damit der Benchmark exakt die Produktions-Kriterien misst.
|
||||
"""
|
||||
import os, time, argparse, statistics as st
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
import pocket_server as ps
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel": "Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die "
|
||||
"Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
|
||||
|
||||
def _voice_state(m):
|
||||
"""Voice-State laden: bevorzugt das gecachte safetensors (A1), sonst aus der Referenz klonen."""
|
||||
if os.path.exists(ps.VOICE_ST):
|
||||
try:
|
||||
return m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
except Exception as e:
|
||||
print(f" (safetensors-Load fehlgeschlagen, klone aus Referenz: {e})")
|
||||
return m.get_state_for_audio_prompt(ps._prep_ref())
|
||||
|
||||
|
||||
def _raw_gen(m, vs, ref_fp, text, sr):
|
||||
"""EINE rohe Generierung (kein Gate). -> (gen_s, audio_s, male, sim, audio)."""
|
||||
t0 = time.time()
|
||||
audio = m.generate_audio(vs, ps.LEAD + text, frames_after_eos=ps.FEOS)
|
||||
gen = time.time() - t0
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
f0 = ps._voiced_f0(a, sr)
|
||||
male = (f0 == f0) and f0 < ps.F0_FLOOR # NaN-sicher
|
||||
fp = ps._fingerprint(ps._crop_lead(a, sr), sr)
|
||||
sim = float(np.dot(ref_fp, fp)) if (ref_fp is not None and fp is not None) else 1.0
|
||||
return gen, a.size / sr, bool(male), sim, a
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--temps", default="0.7,0.8,0.9")
|
||||
ap.add_argument("--lsds", default="8,10")
|
||||
ap.add_argument("--reps", type=int, default=4)
|
||||
ap.add_argument("--whisper", action="store_true")
|
||||
args = ap.parse_args()
|
||||
temps = [float(x) for x in args.temps.split(",")]
|
||||
lsds = [int(x) for x in args.lsds.split(",")]
|
||||
|
||||
OUT = os.path.join(ps.BASE, "out_bench"); os.makedirs(OUT, exist_ok=True)
|
||||
ref = ps._prep_ref()
|
||||
print(f"LANG={ps.LANG} quantize={ps.QUANTIZE} noise_clamp={ps.NOISE_CLAMP} "
|
||||
f"F0_FLOOR={ps.F0_FLOOR} FP_FLOOR={ps.FP_FLOOR} reps={args.reps}")
|
||||
print(f"{'temp':>5} {'lsd':>4} | {'RTF':>5} {'gen/s':>6} | "
|
||||
f"{'collapse%':>9} {'sim_min':>7} {'sim_avg':>7}")
|
||||
rows = []
|
||||
for lsd in lsds:
|
||||
for temp in temps:
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=lsd, temp=temp,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
sr = m.sample_rate
|
||||
ref_audio, _ = librosa.load(ref, sr=sr, mono=True)
|
||||
ref_fp = ps._fingerprint(ref_audio, sr)
|
||||
vs = _voice_state(m)
|
||||
rtfs, gens, sims, collapses, n = [], [], [], 0, 0
|
||||
for name, text in SENT.items():
|
||||
for r in range(args.reps):
|
||||
gen, asec, male, sim, a = _raw_gen(m, vs, ref_fp, text, sr)
|
||||
rtfs.append(gen / max(asec, 0.01)); gens.append(gen); sims.append(sim)
|
||||
collapses += int(male or sim < ps.FP_FLOOR); n += 1
|
||||
if r == 0: # ein Sample je Satz zum Reinhören
|
||||
sf.write(os.path.join(OUT, f"t{temp}_l{lsd}_{name}.wav"), ps.cleanup(a, sr), sr)
|
||||
cr = 100.0 * collapses / max(n, 1)
|
||||
print(f"{temp:>5} {lsd:>4} | {st.mean(rtfs):>5.2f} {st.mean(gens):>6.2f} | "
|
||||
f"{cr:>8.1f}% {min(sims):>7.3f} {st.mean(sims):>7.3f}")
|
||||
rows.append((temp, lsd, st.mean(rtfs), cr, min(sims)))
|
||||
del m
|
||||
|
||||
best = sorted(rows, key=lambda x: (x[3], x[2]))[0] # min Kollaps, dann beste RTF
|
||||
print(f"\n>> Vorschlag: temp={best[0]} lsd={best[1]} "
|
||||
f"(collapse={best[3]:.1f}%, RTF={best[2]:.2f}) -> in pocket_server via "
|
||||
f"LUCY_TEMP / LUCY_LSD setzen.")
|
||||
|
||||
if args.whisper:
|
||||
print("\n=== Whisper-Verständlichkeit (faster-whisper small/int8) ===")
|
||||
from faster_whisper import WhisperModel
|
||||
import glob
|
||||
wm = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for p in sorted(glob.glob(os.path.join(OUT, "*.wav"))):
|
||||
segs, _ = wm.transcribe(p, language="de", beam_size=5)
|
||||
print(os.path.basename(p), "::", " ".join(s.text.strip() for s in segs))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Debug: warum greift der Onset-Crop auf dem 1s-Kopf des Streams nicht? + Roh-RMS für Gain-Kalibrierung."""
|
||||
import os, numpy as np, librosa, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
src,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
srt,_ = librosa.effects.trim(src, top_db=30); ref=os.path.join(BASE,"ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=0.9)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
LEAD = "Tja. "
|
||||
text = LEAD + "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu."
|
||||
|
||||
chunks = []
|
||||
for c in m.generate_audio_stream(vs, text, frames_after_eos=4):
|
||||
c = c.numpy() if hasattr(c,"numpy") else np.asarray(c)
|
||||
chunks.append(np.asarray(c,dtype=np.float32).reshape(-1))
|
||||
print(f"#chunks={len(chunks)} chunk_sizes_ms={[round(x.size/sr*1000) for x in chunks[:8]]}")
|
||||
full = np.concatenate(chunks)
|
||||
print(f"full dur={full.size/sr:.2f}s peak={np.abs(full).max():.3f} rms={np.sqrt(np.mean(full**2)):.3f}")
|
||||
|
||||
head = full[:int(1.0*sr)]
|
||||
for td in (45, 35, 25, 20):
|
||||
iv = librosa.effects.split(head, top_db=td)
|
||||
segs = [(round(s/sr,2), round(e/sr,2)) for s,e in iv]
|
||||
print(f"HEAD top_db={td}: {len(iv)} segs {segs}")
|
||||
# voller Onset-Crop (wie nicht-stream) zum Vergleich
|
||||
ivf = librosa.effects.split(full, top_db=35)
|
||||
print(f"FULL top_db=35: {len(ivf)} segs erste3={[(round(s/sr,2),round(e/sr,2)) for s,e in ivf[:3]]}")
|
||||
print("DBG_DONE")
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Diagnose: schneidet das Modell selbst vorne/hinten ab, oder mein Trim?
|
||||
Misst Stille-Anteil am Anfang/Ende des ROHEN Outputs (vor jedem Trim)."""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = os.path.join(BASE, "out_diag"); os.makedirs(OUT, exist_ok=True)
|
||||
y, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
yt, _ = librosa.effects.trim(y, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, yt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
|
||||
def lead_tail_silence(a, sr):
|
||||
"""Wie viel ms am Anfang/Ende liegen unter -40dB vom Peak (= 'Stille')?"""
|
||||
a = np.abs(np.asarray(a, dtype=np.float32).reshape(-1))
|
||||
if a.size == 0: return 0.0, 0.0, 0.0
|
||||
peak = a.max() + 1e-9
|
||||
thr = peak * (10 ** (-40/20)) # -40 dB
|
||||
above = np.where(a > thr)[0]
|
||||
if above.size == 0: return a.size/sr*1000, a.size/sr*1000, 0.0
|
||||
lead = above[0] / sr * 1000
|
||||
tail = (a.size - 1 - above[-1]) / sr * 1000
|
||||
return lead, tail, a.size/sr*1000
|
||||
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=4)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
sr = m.sample_rate
|
||||
for name, text in SENT.items():
|
||||
for feos in [None, 4, 8]:
|
||||
a = m.generate_audio(vs, text, frames_after_eos=feos)
|
||||
a = a.numpy() if hasattr(a, "numpy") else np.asarray(a)
|
||||
a = a.reshape(-1)
|
||||
lead, tail, total = lead_tail_silence(a, sr)
|
||||
sf.write(os.path.join(OUT, f"raw_{name}_feos{feos}.wav"), a, sr)
|
||||
print(f"[{name:5} feos={str(feos):4}] total={total:7.1f}ms lead_sil={lead:6.1f}ms tail_sil={tail:6.1f}ms peak={np.abs(a).max():.3f}", flush=True)
|
||||
print("DIAG_DONE", flush=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Stabilität gegen Stimm-Kollaps: temp 0.9 mit/ohne noise_clamp. F0-Verteilung + Whisper + RTF.
|
||||
Mehr Samples, um einen Kollaps (tiefe F0) zu provozieren und zu sehen, ob noise_clamp ihn dämpft."""
|
||||
import os, time, numpy as np, librosa, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
src,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
srt,_ = librosa.effects.trim(src, top_db=30); ref=os.path.join(BASE,"ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
LEAD="Tja. "
|
||||
TEXTS = ["Hallo Commander, ich höre dich.","Natürlich, das mache ich sofort für dich.",
|
||||
"Die Hitze ist heute wirklich heftig.","Klar, ich kümmere mich gleich darum.",
|
||||
"Guten Morgen, Commander, alles sauber.","Kein Problem, ich erledige das jetzt.",
|
||||
"Das Backup lief ohne Fehler durch.","Verstanden, Commander, ich bleibe dran."]
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
|
||||
def f0_of(a, sr):
|
||||
f0,_,_ = librosa.pyin(a, fmin=80, fmax=400, sr=sr, frame_length=1024)
|
||||
v=f0[~np.isnan(f0)]; return float(np.median(v)) if v.size else float("nan")
|
||||
|
||||
def run(temp, nc):
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=temp, noise_clamp=nc)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr=m.sample_rate
|
||||
print(f"=== temp={temp} noise_clamp={nc} ===", flush=True)
|
||||
f0s=[]; rtfs=[]
|
||||
for i in range(12):
|
||||
t=TEXTS[i%len(TEXTS)]
|
||||
t0=time.time(); a=m.generate_audio(vs, LEAD+t, frames_after_eos=4); dt=time.time()-t0
|
||||
a=a.numpy() if hasattr(a,"numpy") else np.asarray(a); a=np.asarray(a,dtype=np.float32).reshape(-1)
|
||||
f0=f0_of(a,sr); f0s.append(f0); rtfs.append(dt/max(a.size/sr,0.01))
|
||||
flag=" <<< MÄNNLICH/Kollaps?" if (f0==f0 and f0<150) else ""
|
||||
print(f" [{i:2}] F0={f0:6.1f}Hz{flag}", flush=True)
|
||||
arr=np.array([x for x in f0s if x==x])
|
||||
print(f" -> F0 median {np.median(arr):.0f} min {arr.min():.0f} max {arr.max():.0f} | RTF~{np.median(rtfs):.2f}", flush=True)
|
||||
|
||||
run(0.9, None)
|
||||
run(0.9, 3.0)
|
||||
print("NC_DONE")
|
||||
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Warum 'manchmal zu laut' + 'Stimme verändert sich am Anfang'? Stream-Kopf-Logik N× laufen lassen
|
||||
und Gain, Crop-Punkt, Kopf-Segmente, Whisper-Start, rms/peak protokollieren."""
|
||||
import os, numpy as np, librosa, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
src,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
srt,_ = librosa.effects.trim(src, top_db=30); ref=os.path.join(BASE,"ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
TARGET_RMS=0.09; LEAD="Tja. "
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=0.9)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr=m.sample_rate
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
TEXTS = ["Hallo Commander, ich höre dich.",
|
||||
"Natürlich, Commander. Das Backup ist sauber durchgelaufen."]
|
||||
|
||||
def run_once(text):
|
||||
chunks=[]
|
||||
for c in m.generate_audio_stream(vs, LEAD+text, frames_after_eos=4):
|
||||
c=c.numpy() if hasattr(c,"numpy") else np.asarray(c)
|
||||
chunks.append(np.asarray(c,dtype=np.float32).reshape(-1))
|
||||
full=np.concatenate(chunks)
|
||||
# Kopf adaptiv (wie Server): bis >=2 Segmente oder 2.5s
|
||||
head=[]; hl=0; head_arr=None
|
||||
for c in chunks:
|
||||
head.append(c); hl+=c.size
|
||||
if hl < int(0.5*sr): continue
|
||||
a=np.concatenate(head)
|
||||
if len(librosa.effects.split(a, top_db=35))>=2 or hl>=int(2.5*sr):
|
||||
head_arr=a; break
|
||||
if head_arr is None: head_arr=np.concatenate(head)
|
||||
iv=librosa.effects.split(head_arr, top_db=35)
|
||||
segs=[(round(s/sr,2),round(e/sr,2)) for s,e in iv]
|
||||
# Gain aus voiced (aktuelle Server-Logik)
|
||||
voiced=np.concatenate([head_arr[s:e] for s,e in iv]) if len(iv) else head_arr
|
||||
rms=float(np.sqrt(np.mean(voiced**2))) or 1e-9
|
||||
gain=TARGET_RMS/rms
|
||||
# Crop (aktuell): kurz vor echtem Wort
|
||||
if len(iv)>=2:
|
||||
cut=max(iv[0][1], iv[1][0]-int(0.06*sr)); cropped=full[cut:]
|
||||
else:
|
||||
cropped=full
|
||||
out=np.clip(cropped*gain,-0.95,0.95)
|
||||
sf.write(os.path.join(BASE,"out_diag_s.wav"), out, sr)
|
||||
seg,_=w.transcribe(os.path.join(BASE,"out_diag_s.wav"), language="de", beam_size=5)
|
||||
start=(" ".join(x.text for x in seg)).strip()[:30]
|
||||
return dict(segs=segs, voiced_rms=round(rms,3), gain=round(gain,2),
|
||||
out_rms=round(float(np.sqrt(np.mean(out**2))),3), out_peak=round(float(np.abs(out).max()),3),
|
||||
start=start)
|
||||
|
||||
for text in TEXTS:
|
||||
print(f"=== {text[:30]!r} ===")
|
||||
for i in range(4):
|
||||
print(f" run{i}:", run_once(text))
|
||||
print("DIAG2_DONE")
|
||||
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Test der ROBUSTEN Stream-Kopf-Logik: fester 1.8s-Kopf + Crop am ersten SUBSTANZIELLEN Wort-Segment
|
||||
(ignoriert Mini-Blips) + FIXER Gain. Ziel: Start immer echtes Wort, out_rms stabil ~0.09, kein Clip."""
|
||||
import os, numpy as np, librosa, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
src,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
srt,_ = librosa.effects.trim(src, top_db=30); ref=os.path.join(BASE,"ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
TARGET_RMS=0.09; LEAD="Tja. "
|
||||
BASE_GAIN = TARGET_RMS / 0.18 # ~0.5 (raw full-rms ~0.18 konsistent)
|
||||
GMIN, GMAX = 0.7*BASE_GAIN, 1.4*BASE_GAIN # Gain-Clamp -> kann NIE explodieren
|
||||
HEAD_FIXED = int(2.0*24000)
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=0.9)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr=m.sample_rate
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
TEXTS = ["Hallo Commander, ich höre dich.",
|
||||
"Natürlich, Commander. Das Backup ist sauber durchgelaufen."]
|
||||
|
||||
def crop_and_gain(a):
|
||||
"""Crop am ersten SUBSTANZIELLEN Wort-Segment nach dem Lead (>=0.2s, ignoriert Mini-Blips);
|
||||
Gain aus voiced-rms, GECLAMPT (kann nie explodieren)."""
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
voiced = np.concatenate([a[s:e] for s,e in iv]) if len(iv) else a
|
||||
rms = float(np.sqrt(np.mean(voiced**2))) or 1e-9
|
||||
gain = float(np.clip(TARGET_RMS/rms, GMIN, GMAX))
|
||||
cut = 0
|
||||
if len(iv) >= 2:
|
||||
for k in range(1, len(iv)):
|
||||
if (iv[k][1]-iv[k][0]) >= int(0.20*sr):
|
||||
cut = max(iv[k-1][1], iv[k][0]-int(0.06*sr)); break
|
||||
return a[cut:], round(cut/sr,2), round(gain,2)
|
||||
|
||||
def run_once(text):
|
||||
head=[]; hl=0; head_done=False; head_arr=None; rest=[]
|
||||
for c in m.generate_audio_stream(vs, LEAD+text, frames_after_eos=4):
|
||||
c=c.numpy() if hasattr(c,"numpy") else np.asarray(c); c=np.asarray(c,dtype=np.float32).reshape(-1)
|
||||
if not head_done:
|
||||
head.append(c); hl+=c.size
|
||||
if hl>=HEAD_FIXED: head_arr=np.concatenate(head); head_done=True
|
||||
else: rest.append(c)
|
||||
if head_arr is None: head_arr=np.concatenate(head)
|
||||
cropped_head, cutpt, gain = crop_and_gain(head_arr)
|
||||
full = np.concatenate([cropped_head]+rest)
|
||||
out=np.clip(full*gain,-0.95,0.95)
|
||||
sf.write(os.path.join(BASE,"out_diag_s3.wav"), out, sr)
|
||||
seg,_=w.transcribe(os.path.join(BASE,"out_diag_s3.wav"), language="de", beam_size=5)
|
||||
start=(" ".join(x.text for x in seg)).strip()[:32]
|
||||
return dict(cut=cutpt, gain=gain, out_rms=round(float(np.sqrt(np.mean(out**2))),3),
|
||||
out_peak=round(float(np.abs(out).max()),3), start=start)
|
||||
|
||||
print(f"gain-clamp=[{round(GMIN,2)},{round(GMAX,2)}] head={HEAD_FIXED/24000}s")
|
||||
for text in TEXTS:
|
||||
print(f"=== {text[:28]!r} ===")
|
||||
for i in range(5):
|
||||
print(f" run{i}:", run_once(text))
|
||||
print("DIAG3_DONE")
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verifiziert die 'Stimme wurde männlich'-Hypothese: misst F0 (Grundfrequenz) vieler Generierungen
|
||||
bei temp 0.9 vs 0.7. Weiblich (Saber-Klon) ~180-240Hz, männlich-Drift (Default 'juergen') ~100-140Hz.
|
||||
Sucht Ausreißer = Stimm-Identitäts-Kollaps."""
|
||||
import os, numpy as np, librosa, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
src,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
srt,_ = librosa.effects.trim(src, top_db=30); ref=os.path.join(BASE,"ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
LEAD="Tja. "
|
||||
TEXTS = [
|
||||
"Hallo Commander, ich höre dich.",
|
||||
"Natürlich, Commander, das mache ich sofort.",
|
||||
"Die Hitze in Hamburg ist heute wirklich heftig.",
|
||||
"Klar, ich kümmere mich gleich darum für dich.",
|
||||
"Guten Morgen, Commander, alles läuft sauber.",
|
||||
"Das Backup ist durchgelaufen, keine Fehler.",
|
||||
]
|
||||
|
||||
def median_f0(a, sr):
|
||||
f0,_,_ = librosa.pyin(a, fmin=80, fmax=400, sr=sr, frame_length=1024)
|
||||
v = f0[~np.isnan(f0)]
|
||||
return float(np.median(v)) if v.size else float("nan")
|
||||
|
||||
def run(temp):
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=temp)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr=m.sample_rate
|
||||
print(f"=== temp={temp} ===", flush=True)
|
||||
f0s=[]
|
||||
for i,t in enumerate(TEXTS):
|
||||
a = m.generate_audio(vs, LEAD+t, frames_after_eos=4)
|
||||
a = a.numpy() if hasattr(a,"numpy") else np.asarray(a)
|
||||
a = np.asarray(a,dtype=np.float32).reshape(-1)
|
||||
f0 = median_f0(a, sr); f0s.append(f0)
|
||||
flag = " <<< MÄNNLICH?" if (f0==f0 and f0<150) else ""
|
||||
print(f" [{i}] F0={f0:6.1f}Hz{flag} ({t[:30]})", flush=True)
|
||||
arr=np.array([x for x in f0s if x==x])
|
||||
print(f" -> median {np.median(arr):.0f}Hz, min {arr.min():.0f}, max {arr.max():.0f}", flush=True)
|
||||
|
||||
run(0.9)
|
||||
run(0.7)
|
||||
print("F0_DONE")
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ABSCHLUSS-TEST: beweist die Prompt-Wirkung. Dieselbe Lucy-Antwort im NEUEN Stil (kurze Sätze,
|
||||
Punkte) vs. ALTEM Stil (Komma-Kette). Rendert auf :8134 + Desktop, misst Pausen (Whisper)."""
|
||||
import os, io, json, urllib.request
|
||||
import numpy as np, soundfile as sf
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
SERVER = "http://127.0.0.1:8134/tts"
|
||||
OUT = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_test"); os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# Gleicher Inhalt, zwei Stile — so würde Lucy VORHER (Komma-Kette) und NACHHER (Prompt: kurze Sätze) antworten.
|
||||
PAIRS = [
|
||||
("A_backup",
|
||||
"Klar, Commander, das Backup ist heute Nacht durchgelaufen, es gab keine Fehler, und die Datenbank läuft stabil bei 92 Prozent.",
|
||||
"Klar, Commander. Das Backup lief heute Nacht durch. Keine Fehler. Die Datenbank ist stabil bei 92 Prozent."),
|
||||
("B_status",
|
||||
"Also, wenn ich das kurz zusammenfasse, die Verbindung war weg, dann lief das Update durch, danach musste ich neu starten, und jetzt sind alle Dienste wieder oben.",
|
||||
"Kurz zusammengefasst, Commander. Die Verbindung war weg. Das Update lief durch. Ich musste neu starten. Jetzt sind alle Dienste wieder oben."),
|
||||
]
|
||||
|
||||
def render(text):
|
||||
req = urllib.request.Request(SERVER, data=json.dumps({"text": text}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=180).read()
|
||||
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
|
||||
def measure(tag, text):
|
||||
wav = render(text); path = os.path.join(OUT, tag + ".wav"); open(path, "wb").write(wav)
|
||||
a, sr = sf.read(io.BytesIO(wav)); a = np.asarray(a, dtype=np.float32).reshape(-1); dur = len(a)/sr
|
||||
segs, _ = w.transcribe(path, language="de", beam_size=5, word_timestamps=True)
|
||||
words = [x for s in segs for x in (s.words or [])]
|
||||
gaps = [words[i+1].start - words[i].end for i in range(len(words)-1)]
|
||||
lang = [(words[i].word.strip(), gaps[i]) for i in range(len(gaps)) if gaps[i] >= 0.30]
|
||||
total_pause = sum(g for g in gaps if g >= 0.15)
|
||||
return dur, len(lang), total_pause, lang
|
||||
|
||||
for name, alt, neu in PAIRS:
|
||||
d_a, n_a, tp_a, l_a = measure(f"{name}_ALT_komma", alt)
|
||||
d_n, n_n, tp_n, l_n = measure(f"{name}_NEU_kurz", neu)
|
||||
print(f"\n### {name}")
|
||||
print(f" ALT (Komma-Kette): {d_a:4.1f}s | {n_a} LANG-Pausen | Summe Pausen {tp_a*1000:4.0f}ms")
|
||||
print(f" LANG: " + (", ".join(f'„{ww}\"={gg*1000:.0f}ms' for ww, gg in l_a) or "keine"))
|
||||
print(f" NEU (kurze Sätze): {d_n:4.1f}s | {n_n} LANG-Pausen | Summe Pausen {tp_n*1000:4.0f}ms")
|
||||
print(f" LANG: " + (", ".join(f'„{ww}\"={gg*1000:.0f}ms' for ww, gg in l_n) or "keine"))
|
||||
print(f" -> {d_a-d_n:+.1f}s Dauer, {tp_a*1000-tp_n*1000:+.0f}ms Pausen-Summe")
|
||||
print("\n=== ABSCHLUSS-TEST FERTIG === (WAVs im Desktop\\lucy_test)")
|
||||
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""lucy_ready.py — Startklar-Check + finale Samples in EINEM Lauf.
|
||||
Bootet den ECHTEN Server (FastAPI-TestClient triggert lifespan: Modell-Load, A1-Cache, STATE),
|
||||
prüft /health, und rendert die finalen Hörproben über die REALEN Endpoints /tts und /tts/stream
|
||||
(exakt das, was die Lucy-App auf :8130 aufruft) — inkl. Live-TTFB der Streaming-Antwort.
|
||||
Legt die Samples auf den Desktop, schreibt lucy_ready_report.json.
|
||||
"""
|
||||
import os, time, json
|
||||
import numpy as np, soundfile as sf
|
||||
from fastapi.testclient import TestClient
|
||||
import pocket_server as ps
|
||||
|
||||
DESK = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_samples")
|
||||
os.makedirs(DESK, exist_ok=True)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel": "Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die "
|
||||
"Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
LONG = ("Guten Morgen, Commander. "
|
||||
"Das nächtliche Backup ist sauber durchgelaufen und es gab keine Fehler. "
|
||||
"Der Dienst läuft stabil und die Engine antwortet zügig. "
|
||||
"Wenn du möchtest, fasse ich die offenen Punkte für heute zusammen.")
|
||||
|
||||
report = {}
|
||||
t0 = time.time()
|
||||
with TestClient(ps.app) as c: # <-- triggert lifespan (echter Boot)
|
||||
report["boot_s"] = round(time.time() - t0, 1)
|
||||
h = c.get("/health").json()
|
||||
report["health"] = h
|
||||
print(f"BOOT in {report['boot_s']}s health={h}")
|
||||
|
||||
for name, text in SENT.items(): # /tts (ganze Datei) -> Desktop
|
||||
t = time.time(); r = c.post("/tts", json={"text": text}); dt = time.time() - t
|
||||
assert r.status_code == 200, f"/tts {name} -> {r.status_code}"
|
||||
open(os.path.join(DESK, f"lucy_final_{name}.wav"), "wb").write(r.content)
|
||||
print(f"/tts {name:6}: {dt:4.2f}s audio={r.headers.get('X-Audio-Seconds')}s")
|
||||
|
||||
# /tts/stream: lange Antwort wie Lucy LIVE spricht -> TTFB messen + PCM zu WAV
|
||||
sr = int(h.get("sr") or 24000)
|
||||
t = time.time(); first = None; chunks = []
|
||||
with c.stream("POST", "/tts/stream", json={"text": LONG}) as s:
|
||||
sr = int(s.headers.get("X-Sample-Rate", sr))
|
||||
for ch in s.iter_bytes():
|
||||
if ch:
|
||||
if first is None:
|
||||
first = time.time() - t
|
||||
chunks.append(ch)
|
||||
a = np.frombuffer(b"".join(chunks), dtype="<i2").astype(np.float32) / 32767.0
|
||||
sf.write(os.path.join(DESK, "lucy_final_antwort.wav"), a, sr)
|
||||
total = time.time() - t
|
||||
report["stream_ttfb_s"] = round(first or 0, 2)
|
||||
report["stream_total_s"] = round(total, 2)
|
||||
report["stream_audio_s"] = round(len(a) / sr, 1)
|
||||
print(f"/tts/stream: TTFB={first:.2f}s total={total:.2f}s audio={len(a)/sr:.1f}s")
|
||||
|
||||
json.dump(report, open(os.path.join(ps.BASE, "lucy_ready_report.json"), "w"), indent=2)
|
||||
print("LUCY_READY" if h.get("status") == "ok" else "NOT_READY")
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""make_samples.py — erzeugt Lucy-Hörproben auf dem Desktop, um A1 (safetensors-Cache) und
|
||||
A2 (Referenz-Cleaning) hörbar zu testen. Nutzt 1:1 die Produktionslogik aus pocket_server.py
|
||||
(cleanup + Doppel-Wächter _gen_gated), läuft offline mit dem lokal gecachten german_24l-Modell.
|
||||
|
||||
Output: %USERPROFILE%\\Desktop\\lucy_samples\\
|
||||
lucy_clean_{kurz,mittel,lang}.wav (A2 AN: Highpass+Peak-Norm, Stimme aus safetensors-Cache)
|
||||
lucy_raw_{kurz,mittel,lang}.wav (A2 AUS: Roh-Referenz, direkt geklont)
|
||||
So hörst du den Cleaning-Unterschied direkt im A/B.
|
||||
"""
|
||||
import os, time
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
import pocket_server as ps # reuse: _prep_ref, cleanup, _gen_gated, _fingerprint, Konstanten
|
||||
|
||||
DESK = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_samples")
|
||||
os.makedirs(DESK, exist_ok=True)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel": "Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die "
|
||||
"Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
|
||||
|
||||
def build(ref_clean: bool, tag: str, use_cache: bool):
|
||||
ps.REF_CLEAN = ref_clean # A2-Schalter zur Laufzeit setzen
|
||||
ref = ps._prep_ref() # schreibt ref.wav (ggf. gecleant)
|
||||
print(f"\n[{tag}] lade Modell {ps.LANG} (lsd={ps.LSD} temp={ps.TEMP} nc={ps.NOISE_CLAMP}) ...")
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=ps.LSD, temp=ps.TEMP,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
sr = m.sample_rate
|
||||
print(f"[{tag}] Modell in {time.time()-t0:.1f}s (sr={sr})")
|
||||
if use_cache: # A1: einmal exportieren, dann aus Cache laden
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
try:
|
||||
ps.export_model_state(vs, ps.VOICE_ST)
|
||||
vs = m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
print(f"[{tag}] Voice-State exportiert+geladen <- {os.path.basename(ps.VOICE_ST)}")
|
||||
except Exception as e:
|
||||
print(f"[{tag}] Export/Cache fehlgeschlagen, nutze Live-Klon: {e}")
|
||||
else:
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ref_audio, _ = librosa.load(ref, sr=sr, mono=True)
|
||||
ps.STATE.update(m=m, vs=vs, sr=sr, ref_fp=ps._fingerprint(ref_audio, sr))
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time()
|
||||
a = ps.cleanup(ps._gen_gated(text), sr) # Produktions-Wächter + cleanup
|
||||
dt = time.time() - t0
|
||||
p = os.path.join(DESK, f"lucy_{tag}_{name}.wav")
|
||||
sf.write(p, a, sr)
|
||||
print(f"[{tag}] {name:6} gen={dt:5.2f}s audio={a.size/sr:4.1f}s -> {p}")
|
||||
ps.STATE.clear()
|
||||
del m
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== A2 AN (Highpass+Norm) + A1 safetensors-Cache ===")
|
||||
build(ref_clean=True, tag="clean", use_cache=True)
|
||||
print("\n=== A2 AUS (Roh-Referenz, direkt geklont) ===")
|
||||
build(ref_clean=False, tag="raw", use_cache=False)
|
||||
print(f"\nFERTIG. Samples liegen in: {DESK}")
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fix-Verifikation: rendert die Problem-/Regressionssätze auf :8134, misst Pausen + IST (Whisper)."""
|
||||
import os, io, json, urllib.request
|
||||
import numpy as np, soundfile as sf
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
SERVER = "http://127.0.0.1:8134/tts"
|
||||
OUT = os.path.join(os.environ["TEMP"], "lucy_fix"); os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
TESTS = [
|
||||
("01_kurz", "Schön, dass du da bist, Commander."),
|
||||
("07_lang", "Guten Morgen, Commander. Das nächtliche Backup ist um 3 Uhr durchgelaufen, es gab keine Fehler, und die Datenbank läuft mit 92 Prozent Auslastung völlig stabil."),
|
||||
("08_sehr_lang","Also, Commander, wenn ich das kurz zusammenfassen darf: heute früh war die Verbindung weg, dann lief das Update durch, danach musste ich zweimal neu starten, und jetzt sind endlich alle Dienste oben, das Gedächtnis ist warm, und wir können ganz entspannt weitermachen."),
|
||||
("09_akronyme", "Die CPU der RX 9070 XT und die GPU laufen mit ROCm, sagt der PC über das LAN. Es ist 21 Uhr UTC."),
|
||||
]
|
||||
|
||||
def render(text):
|
||||
req = urllib.request.Request(SERVER, data=json.dumps({"text": text}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=180).read()
|
||||
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for name, text in TESTS:
|
||||
wav = render(text)
|
||||
path = os.path.join(OUT, name + ".wav"); open(path, "wb").write(wav)
|
||||
a, sr = sf.read(io.BytesIO(wav)); a = np.asarray(a, dtype=np.float32).reshape(-1); dur = len(a)/sr
|
||||
segs, _ = w.transcribe(path, language="de", beam_size=5, word_timestamps=True)
|
||||
words = [x for s in segs for x in (s.words or [])]
|
||||
got = " ".join(x.word.strip() for x in words)
|
||||
pauses = [(words[i].word.strip(), words[i+1].start - words[i].end)
|
||||
for i in range(len(words)-1) if (words[i+1].start - words[i].end) >= 0.25]
|
||||
print(f"\n### {name} {dur:.1f}s {len(words)} Wörter")
|
||||
print(f"IST: {got}")
|
||||
if pauses:
|
||||
print("PAUSEN>=250ms: " + ", ".join(f'„{ww}\"={gg*1000:.0f}ms' for ww, gg in sorted(pauses, key=lambda t:-t[1])[:6]))
|
||||
else:
|
||||
print("PAUSEN>=250ms: KEINE")
|
||||
print("\n=== FERTIG ===")
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os, time, numpy as np, soundfile as sf, librosa, torch, torchaudio
|
||||
def _ta_load(p,*a,**k):
|
||||
x,sr=sf.read(p,dtype="float32"); return torch.from_numpy((x.T if x.ndim>1 else x[None,:]).copy()),sr
|
||||
def _ta_save(p,t,sample_rate=24000,*a,**k):
|
||||
sr=k.get("sample_rate",sample_rate); x=t.detach().cpu().numpy()
|
||||
sf.write(p,(x.T if x.ndim>1 else x.reshape(-1)),int(sr))
|
||||
torchaudio.load=_ta_load; torchaudio.save=_ta_save
|
||||
def _wl(f,sr=16000):
|
||||
y,_=librosa.load(f,sr=sr,mono=True); return y.astype(np.float32)
|
||||
try:
|
||||
import whisper.audio as _wa; _wa.load_audio=_wl
|
||||
import whisper.transcribe as _wt; _wt.load_audio=_wl
|
||||
except Exception as e: print("whisper-patch:", e, flush=True)
|
||||
import outetts
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
ref = os.path.join(BASE, "ref.wav")
|
||||
y,_ = librosa.load(os.path.join(BASE,"ref.mp3"), sr=24000, mono=True)
|
||||
yt,_ = librosa.effects.trim(y, top_db=30)
|
||||
sf.write(ref, yt[:int(14*24000)], 24000)
|
||||
|
||||
cfg = outetts.ModelConfig(
|
||||
model_path=os.path.join(BASE,"models","OuteTTS-1.0-1B-Q8_0.gguf"),
|
||||
tokenizer_path="OuteAI/Llama-OuteTTS-1.0-1B",
|
||||
interface_version=outetts.InterfaceVersion.V3,
|
||||
backend=outetts.Backend.LLAMACPP,
|
||||
)
|
||||
cfg.n_gpu_layers = 999
|
||||
t0=time.time(); iface=outetts.Interface(config=cfg); print(f"[iface] {time.time()-t0:.1f}s", flush=True)
|
||||
spk=iface.create_speaker(ref); print("[clone ok]", flush=True)
|
||||
|
||||
OUT=os.path.join(BASE,"out"); os.makedirs(OUT,exist_ok=True)
|
||||
SENT={
|
||||
"kurz":"Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang":"Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
for name,text in SENT.items():
|
||||
for run in ("cold","warm"):
|
||||
t0=time.time(); out=iface.generate(config=outetts.GenerationConfig(text=text, speaker=spk)); dt=time.time()-t0
|
||||
p=os.path.join(OUT,f"{name}.wav"); out.save(p)
|
||||
x,sr=sf.read(p); secs=len(x)/sr
|
||||
print(f"[{name:6} {run:4}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
print("PC_DONE", flush=True)
|
||||
@@ -0,0 +1,471 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Lucy-Stimme: Pocket TTS (Kyutai) als lokaler CPU-Dienst. Klont Lucys Saber-Stimme, real-time, kein GPU."""
|
||||
import os, io, re, time, threading, logging
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from scipy.signal import butter, sosfilt
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response, JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from contextlib import asynccontextmanager
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pocket_tts import TTSModel, export_model_state
|
||||
from text_norm import normalize_numbers, fix_acronyms # Zahlen-Normalisierung + Akronym-Fix für die Stimme
|
||||
|
||||
log = logging.getLogger("lucy-tts"); logging.basicConfig(level=logging.INFO)
|
||||
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||
REF_MP3 = os.path.join(BASE, "ref.mp3")
|
||||
LANG = os.environ.get("LUCY_LANG", "german_24l")
|
||||
# Vom User per Ohr getunt (2026-06-28): lsd 10 + noise_clamp 2.5 + ref18 (Sweep-Sieger: sauberes
|
||||
# 'Commander', Timbre + logische Betonung, wenig Rauschen). temp 0.9 (lebendig). Per Env umschaltbar.
|
||||
LSD = int(os.environ.get("LUCY_LSD", "16")) # 16 statt 10: glattere Prosodie/Komma-Übergänge (User-A/B 2026-07-01), RTF bleibt <1
|
||||
TEMP = float(os.environ.get("LUCY_TEMP", "0.9"))
|
||||
def _parse_nc(v): # noise_clamp: Zahl, oder None bei "none"/leer/0
|
||||
return None if v.strip().lower() in ("", "none", "0") else float(v)
|
||||
NOISE_CLAMP = _parse_nc(os.environ.get("LUCY_NOISE_CLAMP", "2.5")) # dämpft Artefakte+Rauschen+Kollaps; 3.0 = rauschärmer
|
||||
FEOS = int(os.environ.get("LUCY_FRAMES_AFTER_EOS", "4")) # kurze Sätze bekamen sonst 0ms Schwanz
|
||||
TARGET_RMS = float(os.environ.get("LUCY_TARGET_RMS", "0.09")) # User: 0.09 u. 0.06 beide gut -> 0.09 Default
|
||||
LEAD = os.environ.get("LUCY_LEAD", "Tja. ") # Wegwerf-Lead-Wort -> natürlicher Onset fürs echte 1. Wort
|
||||
QUANTIZE = os.environ.get("LUCY_QUANTIZE", "1") not in ("0", "false", "False") # int8: ~27% schneller, lt. Doku ohne Qualitätsverlust
|
||||
PAD_S = float(os.environ.get("LUCY_PAD_S", "0.025")) # Satz-Polster (klein = flüssigere Übergänge bei langen Antworten)
|
||||
TAIL_DB = int(os.environ.get("LUCY_TAIL_DB", "30")) # Tail-Trim (dB unter Peak): kleiner = mehr Nachlauf-Stille weg = kürzere Satz-Pausen (gemessen: 45 ließ 500-680ms stehen)
|
||||
MAX_GAP_S = float(os.environ.get("LUCY_MAX_GAP", "0.20")) # interne Sprech-Pausen deckeln: Pockets Komma-Pausen ziehen bis 580ms -> hart auf 200ms kappen (flüssiger)
|
||||
F0_FLOOR = float(os.environ.get("LUCY_F0_FLOOR", "160")) # Hz: drunter = männlicher Kollaps (Klon~220, male~100)
|
||||
FP_FLOOR = float(os.environ.get("LUCY_FP_FLOOR", "0.94")) # Fingerabdruck-Ähnlichkeit (ohne MFCC0): drunter = Stimm-Drift (gut 0.956-0.98, drift 0.928)
|
||||
MAX_TRIES = int(os.environ.get("LUCY_MAX_TRIES", "3")) # max. Versuche gegen Stimm-Anomalien
|
||||
FP_MIN_S = float(os.environ.get("LUCY_FP_MIN_S", "2.0")) # B3: FP-Drift-Gate erst ab so viel stimmhafter Dauer (kurze Audios = verrauschter Fingerabdruck = Fehlalarm). F0-Gate bleibt immer aktiv.
|
||||
# A1: Voice-State einmalig nach safetensors exportieren -> Start lädt kvcache statt neu zu klonen.
|
||||
VOICE_ST = os.environ.get("LUCY_VOICE_ST", os.path.join(BASE, "lucy_voice.safetensors"))
|
||||
FORCE_RECLONE = os.environ.get("LUCY_FORCE_RECLONE", "0") not in ("0", "false", "False")
|
||||
# A2: Referenz-Cleaning (pocket reproduziert die Sample-Qualität mit) — Highpass + Peak-Norm, abschaltbar.
|
||||
REF_CLEAN = os.environ.get("LUCY_REF_CLEAN", "1") not in ("0", "false", "False")
|
||||
REF_HPF_HZ = float(os.environ.get("LUCY_REF_HPF_HZ", "70")) # Rumpel/Netzbrumm raus
|
||||
REF_SECONDS = float(os.environ.get("LUCY_REF_SECONDS", "18")) # Klon-Referenzlänge
|
||||
# B2: parallele Satz-Worker. SWEEP-ERGEBNIS (9700X, german_24l, 30.06.): seriell hat die BESTE
|
||||
# TTFB (3.6s vs 6–8s Pool) UND RTF 0.74<1 (generiert schneller als Echtzeit -> Streaming spielt
|
||||
# lückenlos). Daher Default 0 = seriell für Lucys Live-Stimme. Der Pool (>=1) lohnt NUR für
|
||||
# Batch-/tts (ganze Datei am Stück): Durchsatz-Sweet-Spot 4w×2t (RTF 0.44) bzw. 3w×2t (RTF 0.50).
|
||||
# Threads pro Worker via LUCY_WORKER_THREADS.
|
||||
WORKERS = int(os.environ.get("LUCY_WORKERS", "0"))
|
||||
# TTFB-Opt „kurzer erster Chunk": erster Stream-Chunk bleibt kurz -> Lucy spricht früher.
|
||||
# MIT B3 (FP-Gate überspringt kurze Audios) GEMESSEN STARK: TTFB 1.31s statt 3.74s (30.06.),
|
||||
# Wall ~gleich, RTF<1 (lückenlos). Daher Default AN. (Ohne B3 wäre es schlechter -> beides gehört
|
||||
# zusammen.) Nur Stream-Pfad.
|
||||
FAST_FIRST = os.environ.get("LUCY_FAST_FIRST", "1") not in ("0", "false", "False")
|
||||
MIN_LEN = int(os.environ.get("LUCY_MIN_LEN", "55")) # Chunk-Bündelung: größer = weniger Übergänge = flüssiger
|
||||
# Laufzeit-Regler (POST /tune, OHNE Neustart) -> Prosodie live nach Ohr A/B-testen.
|
||||
# temp/lsd/noise_clamp sind Modell-Load-Params und NICHT hier drin (die brauchen einen Neustart).
|
||||
TUNE = {"min_len": MIN_LEN, "pad_s": PAD_S, "fast_first": FAST_FIRST}
|
||||
# Umlaut-Fix: LLM (Hermes/Qwen) gibt manchmal ASCII-Umlaute aus (ueber/schoen/maerz) -> pocket liest
|
||||
# „ue" wörtlich. Wir wandeln NUR bekannte Ganzwörter zurück (sicher: „neue/aktuell/Steuer" bleiben).
|
||||
UMLAUT_FIX = os.environ.get("LUCY_UMLAUT_FIX", "1") not in ("0", "false", "False")
|
||||
STATE, LOCK = {}, threading.Lock()
|
||||
|
||||
# Bekannte deutsche Umlaut-Wörter (korrekt geschrieben). Die ASCII-Schlüssel (ä->ae, ö->oe, ü->ue,
|
||||
# ß->ss) werden daraus AUTOMATISCH abgeleitet -> wenig Fehlerquelle. Erweiterbar via LUCY_UMLAUT_EXTRA.
|
||||
_UML_WORDS = (
|
||||
"über überall übrigens übernehmen überzeugt überprüfen für fürs fünf fünfzehn fünfzig müssen "
|
||||
"müsste müssten können könnt könnte könnten könig königin möchte möchten möchtest möglich "
|
||||
"möglichst möglichkeit würde würden würdest müde prüfen prüft prüfung zurück natürlich gemütlich "
|
||||
"grün grüne grüße grüßen drücken dürfen darüber gegenüber dafür wofür hierfür führen führt "
|
||||
"führung fühlen gefühl gefühle tür türen glück glücklich stück stücke brücke küche kühl "
|
||||
"kühlschrank früh früher frühstück frühling bücher büro bürger südlich süden schüler schützen "
|
||||
"wünschen wünsche übung übungen künstler künstlich rücken rückkehr brüder lücke müll gründe "
|
||||
"gründen begründung vergnügen "
|
||||
"schön schöne schöner schönste schönheit möbel höher höhe hören gehört gehören größe größer "
|
||||
"größte öffnen öffnet geöffnet öffentlich öl östlich löschen löffel lösung lösen börse dörfer "
|
||||
"wörter wörterbuch völlig völker störung stören zwölf böse höflich öfter vögel mögen "
|
||||
"ähnlich änderung ändern ärger ärgern ärztin äußern äußerst gespräch gespräche hängen länger "
|
||||
"länge mädchen männer märz nächste nächsten nähe näher qualität universität städte tätigkeit "
|
||||
"täglich träume träumen väter wäre wären während wärme zähne erklären erklärung verändern "
|
||||
"gefährlich geschäft geschäfte jährlich käse hände kälte plätze sätze wälder fähig fähigkeit"
|
||||
).split()
|
||||
|
||||
def _build_umlaut_map():
|
||||
words = list(_UML_WORDS)
|
||||
extra = os.environ.get("LUCY_UMLAUT_EXTRA", "")
|
||||
words += [w.strip() for w in extra.replace(",", " ").split() if w.strip()]
|
||||
# gängige Flexionsendungen mitnehmen -> deckt mögliche/möglichen/größeren/schönes... ab.
|
||||
# Bogus-Formen (z.B. „möchtee") sind harmlos: sie tauchen in echtem Text nie auf.
|
||||
endings = ("", "e", "en", "er", "es", "em", "n", "s")
|
||||
m = {}
|
||||
for w in words:
|
||||
base = w.lower()
|
||||
for suf in endings:
|
||||
wl = base + suf
|
||||
ascii_w = wl.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||
if ascii_w != wl: # nur echte Umlaut-Wörter
|
||||
m.setdefault(ascii_w, wl)
|
||||
return m
|
||||
|
||||
_UML_MAP = _build_umlaut_map()
|
||||
_UML_RX = re.compile(r"\b(" + "|".join(sorted((re.escape(k) for k in _UML_MAP), key=len, reverse=True))
|
||||
+ r")\b", re.IGNORECASE) if _UML_MAP else None
|
||||
|
||||
def _fix_umlauts(text: str) -> str:
|
||||
"""Wandelt NUR bekannte ASCII-Umlaut-Ganzwörter zurück (ueber->über), case-erhaltend."""
|
||||
if not UMLAUT_FIX or not _UML_RX:
|
||||
return text
|
||||
def _repl(mo):
|
||||
s = mo.group(0); u = _UML_MAP[s.lower()]
|
||||
return u[:1].upper() + u[1:] if s[:1].isupper() else u
|
||||
return _UML_RX.sub(_repl, text)
|
||||
|
||||
def _prep_ref():
|
||||
"""A2: Klon-Referenz aufbereiten. pocket-tts reproduziert die Sample-Qualität mit, daher optional
|
||||
Highpass (Rumpeln/Netzbrumm) + Peak-Normalisierung. Per LUCY_REF_CLEAN=0 abschaltbar (A/B per Ohr)."""
|
||||
ref = os.path.join(BASE, "ref.wav")
|
||||
y, _ = librosa.load(REF_MP3, sr=24000, mono=True)
|
||||
y, _ = librosa.effects.trim(y, top_db=30)
|
||||
if REF_CLEAN:
|
||||
sos = butter(4, REF_HPF_HZ, btype="highpass", fs=24000, output="sos")
|
||||
y = sosfilt(sos, y).astype(np.float32)
|
||||
# nach dem Highpass nochmal randstille trimmen, dann Peak-Norm gegen zu leise/zu laute Referenz
|
||||
y, _ = librosa.effects.trim(y, top_db=30)
|
||||
peak = float(np.max(np.abs(y))) or 1.0
|
||||
y = (y * (0.95 / peak)).astype(np.float32)
|
||||
sf.write(ref, y[: int(REF_SECONDS * 24000)], 24000)
|
||||
return ref
|
||||
|
||||
def _drop_tail_blip(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Entfernt das End-'taa': isolierter, sehr leiser + kurzer Schwanz-Blip (Modell-Halluzination,
|
||||
intermittierend bei temp 0.9). Verifiziert: trifft Blip (rms-ratio 0.15), schont echte Kurzwörter
|
||||
wie 'Fehler' (ratio 0.55). Bedingung ALLE: große Lücke + viel leiser als Sprache + kurz."""
|
||||
for _ in range(3): # bis zu 3 Blips hintereinander
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) < 2:
|
||||
break
|
||||
speech_rms = float(np.median([np.sqrt(np.mean(a[s:e] ** 2)) for s, e in iv[:-1]]))
|
||||
s, e = iv[-1]
|
||||
last_dur = (e - s) / sr
|
||||
last_rms = float(np.sqrt(np.mean(a[s:e] ** 2)))
|
||||
gap = (s - iv[-2][1]) / sr
|
||||
if gap > 0.35 and last_rms < 0.30 * speech_rms and last_dur < 0.35:
|
||||
a = a[: iv[-2][1]]
|
||||
else:
|
||||
break
|
||||
return a
|
||||
|
||||
def _crop_lead(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Schneidet das Wegwerf-Lead-Wort weg -> echtes 1. Wort behält seinen vollen, natürlichen Onset
|
||||
(pocket-tts startet sonst mid-Phonem = 'vorne abgeschnitten'). Schnitt KURZ VOR dem echten Wort
|
||||
(nicht direkt hinter dem Lead), damit auch die variable, teils lange Pause nach 'Tja.' verschwindet."""
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) >= 2:
|
||||
cut = max(iv[0][1], iv[1][0] - int(0.06 * sr)) # 60ms vor echtem Wort, aber hinter dem Lead
|
||||
a = a[cut:]
|
||||
return a
|
||||
|
||||
def _compress_gaps(a: np.ndarray, sr: int, max_gap: float = MAX_GAP_S, top_db: int = 30) -> np.ndarray:
|
||||
"""Deckelt INTERNE Sprech-Pausen (Pockets Komma-Pausen ziehen gemessen bis 580ms) auf max_gap.
|
||||
Findet stimmhafte Segmente, fügt sie mit gekappter Lücke wieder zusammen -> flüssiger, ohne die
|
||||
Sprache selbst anzutasten (nur Stille wird gekürzt). Onset-Vorlauf + Schwanz bleiben unberührt."""
|
||||
if a.size == 0 or max_gap <= 0:
|
||||
return a
|
||||
iv = librosa.effects.split(a, top_db=top_db)
|
||||
if len(iv) < 2:
|
||||
return a
|
||||
cap = int(max_gap * sr)
|
||||
out = [a[: iv[0][1]]] # Kopf inkl. natürlichem Onset-Vorlauf
|
||||
for k in range(1, len(iv)):
|
||||
g0 = iv[k - 1][1]
|
||||
keep = min(iv[k][0] - g0, cap)
|
||||
if keep > 0:
|
||||
out.append(a[g0 : g0 + keep]) # gekappte Pause (Wort-Ausklang bleibt erhalten)
|
||||
out.append(a[iv[k][0] : iv[k][1]]) # nächstes stimmhaftes Segment
|
||||
out.append(a[iv[-1][1] :]) # Schwanz (bereits getrimmt)
|
||||
return np.concatenate(out)
|
||||
|
||||
def cleanup(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""v4 (2026-06-28): Onset-Fix + RMS-Lautstärke + Blip-Killer.
|
||||
- _drop_tail_blip gegen End-'taa'
|
||||
- _crop_lead entfernt Wegwerf-Lead -> voller Onset vorne (kein Abschneiden mehr)
|
||||
- nur HINTEN trimmen (vorne unangetastet), RMS-Normalisierung auf TARGET_RMS (statt lautem Peak 0.95)
|
||||
- 8ms-Fades + 80ms-Atempause vorne+hinten."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
a = _drop_tail_blip(a, sr)
|
||||
a = _crop_lead(a, sr)
|
||||
# nur den Schwanz trimmen (vorderen Onset behalten)
|
||||
rev, _ = librosa.effects.trim(a[::-1], top_db=TAIL_DB)
|
||||
a = rev[::-1] if rev.size else a
|
||||
a = _compress_gaps(a, sr) # interne Komma-Pausen deckeln (größter Glättungs-Gewinn bei langen Sätzen)
|
||||
# RMS-Normalisierung auf Zielpegel (gegen 'zu laut') + Peak-Sicherheits-Clamp
|
||||
rms = float(np.sqrt(np.mean(a ** 2))) or 1e-9
|
||||
a = a * (TARGET_RMS / rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0.9: a = a * (0.9 / peak)
|
||||
fi = min(int(0.008 * sr), a.size // 2) # 8ms Fade gegen Klicks
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0.0, 1.0, fi, dtype=np.float32)
|
||||
a[-fi:] *= np.linspace(1.0, 0.0, fi, dtype=np.float32)
|
||||
pad = np.zeros(int(TUNE["pad_s"] * sr), dtype=np.float32) # Atempause/Anti-Klick (klein -> flüssiger Satz-Übergang)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
t0 = time.time()
|
||||
ref = _prep_ref() # ref.wav immer erzeugen -> Worker + Fingerabdruck
|
||||
need_export = FORCE_RECLONE or not os.path.exists(VOICE_ST) or \
|
||||
os.path.getmtime(VOICE_ST) < os.path.getmtime(REF_MP3)
|
||||
if WORKERS >= 1:
|
||||
# B2-Pool: safetensors einmalig erzeugen (kurz ein Modell), dann RAM freigeben; Worker
|
||||
# laden ihre eigene Instanz aus dem Cache. Hauptprozess hält danach KEIN Modell.
|
||||
log.info("Starte Worker-Pool (%d) — pocket-tts %s lsd=%d temp=%.2f nc=%s quantize=%s",
|
||||
WORKERS, LANG, LSD, TEMP, NOISE_CLAMP, QUANTIZE)
|
||||
if need_export:
|
||||
log.info("Erzeuge Voice-Cache %s ...", os.path.basename(VOICE_ST))
|
||||
mtmp = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
try:
|
||||
export_model_state(mtmp.get_state_for_audio_prompt(ref), VOICE_ST)
|
||||
except Exception as e:
|
||||
log.warning("Voice-Export fehlgeschlagen (Worker klonen selbst): %s", e)
|
||||
sr0 = mtmp.sample_rate
|
||||
del mtmp
|
||||
else:
|
||||
sr0 = 24000
|
||||
ref_audio, _ = librosa.load(ref, sr=sr0, mono=True)
|
||||
STATE.update(sr=sr0, ref_fp=_fingerprint(ref_audio, sr0))
|
||||
pool = ProcessPoolExecutor(max_workers=WORKERS, initializer=_worker_init)
|
||||
list(pool.map(_warmup, range(WORKERS))) # alle Worker vorab hochfahren
|
||||
STATE["pool"] = pool
|
||||
log.info("Worker-Pool bereit (%d Prozesse) in %.1fs", WORKERS, time.time() - t0)
|
||||
else:
|
||||
log.info("Seriell — pocket-tts %s lsd=%d temp=%.2f nc=%s quantize=%s",
|
||||
LANG, LSD, TEMP, NOISE_CLAMP, QUANTIZE)
|
||||
m = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
if need_export:
|
||||
log.info("Klone Stimme aus Referenz -> Export %s", os.path.basename(VOICE_ST))
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
try:
|
||||
export_model_state(vs, VOICE_ST); log.info("Voice-State exportiert")
|
||||
except Exception as e:
|
||||
log.warning("Voice-Export fehlgeschlagen (nutze Live-Klon): %s", e)
|
||||
else:
|
||||
log.info("Lade gecachten Voice-State <- %s", os.path.basename(VOICE_ST))
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(VOICE_ST)
|
||||
except Exception as e:
|
||||
log.warning("Cache-Load fehlgeschlagen, klone neu: %s", e)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ref_audio, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=_fingerprint(ref_audio, m.sample_rate))
|
||||
log.info("Lucy-Stimme bereit in %.1fs (sr=%d)", time.time() - t0, m.sample_rate)
|
||||
yield
|
||||
pool = STATE.get("pool")
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
STATE.clear()
|
||||
|
||||
app = FastAPI(title="Lucy TTS (Pocket)", lifespan=lifespan)
|
||||
|
||||
class Req(BaseModel):
|
||||
text: str
|
||||
|
||||
class PerfIn(BaseModel):
|
||||
msg: str = ""
|
||||
|
||||
class TuneIn(BaseModel):
|
||||
min_len: int | None = None # Chunk-Bündelung (größer = flüssiger, langsamere TTFB später)
|
||||
pad_s: float | None = None # Pause zwischen Stücken (Sekunden)
|
||||
fast_first: bool | None = None # erster Satz kurz halten (schnelle TTFB) an/aus
|
||||
|
||||
def _ready() -> bool:
|
||||
return STATE.get("pool") is not None or "m" in STATE
|
||||
|
||||
def _gen_sentences_ordered(sentences):
|
||||
"""Liefert je Satz fertiges float32-Audio IN REIHENFOLGE. Mit Worker-Pool laufen alle Sätze
|
||||
parallel (bis WORKERS gleichzeitig), werden aber in Eingabereihenfolge ausgegeben -> niedrige
|
||||
TTFB + korrekte Reihenfolge. Ohne Pool: seriell im Hauptprozess (wie bisher, unter LOCK)."""
|
||||
pool = STATE.get("pool")
|
||||
sr = STATE["sr"]
|
||||
if pool is not None:
|
||||
for fut in [pool.submit(_worker_gen, s) for s in sentences]:
|
||||
yield fut.result()
|
||||
else:
|
||||
with LOCK:
|
||||
for s in sentences:
|
||||
yield cleanup(_gen_gated_core(STATE, s), sr)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok" if _ready() else "loading", "engine": "pocket-tts", "lang": LANG,
|
||||
"sr": STATE.get("sr"), "workers": WORKERS, "device": "cpu"}
|
||||
|
||||
@app.post("/perf")
|
||||
def perf(body: PerfIn):
|
||||
"""Client-Perf-Zeilen in DIESES Terminal loggen (der Nutzer hat den TTS-Log eh offen) ->
|
||||
„Lucy denkt lange"-Diagnose ohne Browser-DevTools."""
|
||||
log.info("[lucy-perf] %s", body.msg)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/tune")
|
||||
def tune_get():
|
||||
"""Aktuelle Laufzeit-Regler. temp/lsd/nc stehen separat (Modell-Load, Neustart nötig)."""
|
||||
return {**TUNE, "note": "temp/lsd/noise_clamp brauchen Neustart (Env LUCY_TEMP/LSD/NOISE_CLAMP)"}
|
||||
|
||||
@app.post("/tune")
|
||||
def tune_set(body: TuneIn):
|
||||
"""Prosodie live ändern OHNE Neustart -> nächste Äußerung nutzt die neuen Werte."""
|
||||
if body.min_len is not None: TUNE["min_len"] = max(1, int(body.min_len))
|
||||
if body.pad_s is not None: TUNE["pad_s"] = max(0.0, float(body.pad_s))
|
||||
if body.fast_first is not None: TUNE["fast_first"] = bool(body.fast_first)
|
||||
log.info("[tune] %s", TUNE)
|
||||
return TUNE
|
||||
|
||||
@app.post("/tts")
|
||||
def tts(req: Req):
|
||||
if not _ready():
|
||||
return JSONResponse({"error": "loading"}, status_code=503)
|
||||
t0 = time.time(); sr = STATE["sr"]
|
||||
parts = list(_gen_sentences_ordered(_split_sentences(fix_acronyms(normalize_numbers(_fix_umlauts(req.text))), min_len=TUNE["min_len"])))
|
||||
a = np.concatenate(parts) if parts else np.zeros(0, np.float32)
|
||||
buf = io.BytesIO(); sf.write(buf, a, sr, format="WAV", subtype="PCM_16"); buf.seek(0)
|
||||
dur = a.size / sr; gen = time.time() - t0
|
||||
log.info("/tts %dZ audio=%.1fs gen=%.1fs rtf=%.2f", len(req.text), dur, gen, gen / max(dur, 0.01))
|
||||
return Response(buf.read(), media_type="audio/wav",
|
||||
headers={"X-Audio-Seconds": f"{dur:.2f}", "X-Gen-Seconds": f"{gen:.2f}"})
|
||||
|
||||
def _voiced_f0(a: np.ndarray, sr: int) -> float:
|
||||
"""Schnelle Grundfrequenz-Schätzung auf dem längsten stimmhaften Segment (max 0.6s) via yin.
|
||||
Für das Stimm-Kollaps-Gate: Klon ~220Hz, männlicher Default ~100Hz."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if not len(iv):
|
||||
return float("nan")
|
||||
s, e = max(iv, key=lambda x: x[1] - x[0])
|
||||
seg = a[s:min(e, s + int(0.6 * sr))]
|
||||
if seg.size < int(0.1 * sr):
|
||||
return float("nan")
|
||||
fv = librosa.yin(seg, fmin=80, fmax=400, sr=sr, frame_length=1024)
|
||||
return float(np.median(fv)) if fv.size else float("nan")
|
||||
|
||||
def _fingerprint(a: np.ndarray, sr: int) -> np.ndarray:
|
||||
"""Stimm-Fingerabdruck (MFCC mean+std über stimmhafte Frames, normiert). Für den Drift-Wächter:
|
||||
erkennt JEDEN Stimm-Wechsel (auch weiblich->andere weiblich, das der F0-Wächter durchließ)."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
iv = librosa.effects.split(a, top_db=30)
|
||||
if len(iv):
|
||||
a = np.concatenate([a[s:e] for s, e in iv])
|
||||
if a.size < int(0.2 * sr):
|
||||
return None
|
||||
m = librosa.feature.mfcc(y=a, sr=sr, n_mfcc=20)[1:] # MFCC0 (Energie) weglassen -> amplituden-invariant
|
||||
v = np.concatenate([m.mean(1), m.std(1)])
|
||||
return (v / (np.linalg.norm(v) + 1e-9)).astype(np.float32)
|
||||
|
||||
def _gen_gated_core(st: dict, text: str) -> np.ndarray:
|
||||
"""Einen Satz erzeugen mit DOPPEL-Wächter (best-of-N): männlicher Kollaps (F0<Floor) UND Stimm-Drift
|
||||
(Fingerabdruck-Ähnlichkeit zur Referenz < Floor) -> neu generieren; am Ende den ref-ähnlichsten,
|
||||
nicht-männlichen Kandidaten behalten. `st` = Zustand (STATE im Hauptprozess, _W im Worker)."""
|
||||
best_a, best_score = None, -2.0
|
||||
m, vs, sr, ref_fp = st["m"], st["vs"], st["sr"], st.get("ref_fp")
|
||||
for attempt in range(MAX_TRIES):
|
||||
audio = m.generate_audio(vs, LEAD + text, frames_after_eos=FEOS)
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
f0 = _voiced_f0(a, sr)
|
||||
male = f0 == f0 and f0 < F0_FLOOR
|
||||
cropped = _crop_lead(a, sr) # ohne Lead -> vergleichbar mit Referenz
|
||||
# B3: FP-Drift nur bei genug stimmhafter Dauer prüfen (kurze Audios -> Fingerabdruck unzuverlässig -> Fehlalarm)
|
||||
iv = librosa.effects.split(cropped, top_db=30)
|
||||
voiced_s = (sum(int(e - s) for s, e in iv) / sr) if len(iv) else 0.0
|
||||
fp = _fingerprint(cropped, sr) if voiced_s >= FP_MIN_S else None
|
||||
sim = float(np.dot(ref_fp, fp)) if (ref_fp is not None and fp is not None) else 1.0
|
||||
score = sim - (1.0 if male else 0.0) # männlich hart abstrafen
|
||||
if score > best_score:
|
||||
best_score, best_a = score, a
|
||||
if not male and sim >= FP_FLOOR: # gut genug -> stop
|
||||
break
|
||||
log.warning("Stimm-Anomalie (F0=%.0f male=%s sim=%.3f<%.2f) -> regeneriere (try %d)",
|
||||
f0, male, sim, FP_FLOOR, attempt + 1)
|
||||
return best_a
|
||||
|
||||
def _gen_gated(text: str) -> np.ndarray:
|
||||
"""Serieller Hauptprozess-Pfad (nutzt STATE). Wird auch von make_samples.py verwendet."""
|
||||
return _gen_gated_core(STATE, text)
|
||||
|
||||
# --- B2: persistenter Worker-Pool (je Prozess eigene Modellinstanz + Voice-State aus A1-Cache) -----
|
||||
_W: dict = {} # Per-Prozess-Zustand des Workers
|
||||
|
||||
def _worker_init():
|
||||
"""Einmal pro Worker-Prozess: Torch-Threads pinnen (gegen Oversubscription), Modell laden,
|
||||
Voice-State aus dem safetensors-Cache (A1) ziehen (Fallback: live klonen)."""
|
||||
try:
|
||||
import torch
|
||||
torch.set_num_threads(int(os.environ.get("LUCY_WORKER_THREADS", "2")))
|
||||
except Exception:
|
||||
pass
|
||||
m = TTSModel.load_model(language=LANG, lsd_decode_steps=LSD, temp=TEMP,
|
||||
noise_clamp=NOISE_CLAMP, quantize=QUANTIZE)
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(_prep_ref())
|
||||
ref_audio, _ = librosa.load(os.path.join(BASE, "ref.wav"), sr=m.sample_rate, mono=True)
|
||||
_W.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=_fingerprint(ref_audio, m.sample_rate))
|
||||
|
||||
def _worker_gen(text: str) -> np.ndarray:
|
||||
"""Im Worker: Satz mit Doppel-Wächter erzeugen + cleanup. Rückgabe = fertiges float32-PCM (picklebar)."""
|
||||
return np.asarray(cleanup(_gen_gated_core(_W, text), _W["sr"]), dtype=np.float32)
|
||||
|
||||
def _warmup(_):
|
||||
if "m" not in _W:
|
||||
_worker_init()
|
||||
return _W["sr"]
|
||||
|
||||
_SENT_RX = re.compile(r".+?(?:[.!?…]+(?:\s|$)|$)", re.S)
|
||||
|
||||
def _split_sentences(text: str, min_len: int = 55, keep_first_short: bool = False) -> list[str]:
|
||||
"""Text in Sätze zerlegen und sehr kurze Teile bündeln. Für satzweise Generierung +
|
||||
F0-Gate pro Satz -> ein Kollaps in der Mitte langer Antworten erreicht den Nutzer NIE.
|
||||
keep_first_short=True: der ERSTE Teil bleibt eigenständig (auch wenn kurz) -> schnellste TTFB
|
||||
im Stream (Lucy fängt früher an zu sprechen), Rest wird normal gebündelt."""
|
||||
parts = [m.group(0).strip() for m in _SENT_RX.finditer(text.strip())]
|
||||
out: list[str] = []
|
||||
for p in parts:
|
||||
if not p:
|
||||
continue
|
||||
if not out:
|
||||
out.append(p) # erster Teil
|
||||
elif keep_first_short and len(out) == 1:
|
||||
out.append(p) # zweiter Teil startet frisch -> Index 0 bleibt kurz
|
||||
elif len(out[-1]) < min_len:
|
||||
out[-1] = f"{out[-1]} {p}"
|
||||
else:
|
||||
out.append(p)
|
||||
return out or [text.strip()]
|
||||
|
||||
def _to_pcm16(a: np.ndarray, gain: float) -> bytes:
|
||||
"""float -> 16-bit-PCM (LE), mit Gain + Sicherheits-Clip."""
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1) * gain
|
||||
np.clip(a, -0.95, 0.95, out=a)
|
||||
return (a * 32767.0).astype("<i2").tobytes()
|
||||
|
||||
@app.post("/tts/stream")
|
||||
def tts_stream(req: Req):
|
||||
"""Streamt rohes PCM16-mono (sr via Header X-Sample-Rate) für niedrige Time-to-first-audio.
|
||||
SATZWEISE Generierung mit F0-Gate PRO Satz: jeder Satz wird voll erzeugt, auf Stimm-Kollaps
|
||||
(männlich/F0<Floor) geprüft und bei Bedarf neu generiert, BEVOR er emittiert wird. So erreicht
|
||||
kein kollabiertes Audio den Nutzer — auch nicht mitten in langen Antworten (war die 'gruselige'
|
||||
Schwäche, da pocket lange Texte intern chunkt und einzelne Chunks kippen können).
|
||||
Jeder Satz läuft durch die bewährte cleanup()-Pipeline (Lead-Crop, Tail-Blip, RMS-Norm, Pads).
|
||||
B2: Mit Worker-Pool laufen die Sätze PARALLEL, werden aber in Reihenfolge emittiert (TTFB =
|
||||
erster Satz, restliche rechnen schon nebenher)."""
|
||||
if not _ready():
|
||||
return JSONResponse({"error": "loading"}, status_code=503)
|
||||
sr = STATE["sr"]
|
||||
sentences = _split_sentences(normalize_numbers(_fix_umlauts(req.text)), min_len=TUNE["min_len"], keep_first_short=TUNE["fast_first"]) # +Umlaut +Zahlen, tunebar
|
||||
|
||||
def pcm_stream():
|
||||
t0 = time.time(); total = 0; first = True
|
||||
for a in _gen_sentences_ordered(sentences): # parallel (Pool) bzw. seriell, immer in Reihenfolge
|
||||
total += a.size
|
||||
if first:
|
||||
log.info("/tts/stream TTFB=%.2fs (%d Sätze, workers=%d)", time.time() - t0, len(sentences), WORKERS)
|
||||
first = False
|
||||
yield _to_pcm16(a, 1.0) # cleanup hat schon auf TARGET_RMS normalisiert
|
||||
log.info("/tts/stream %dZ audio=%.1fs gen=%.1fs", len(req.text), total / sr, time.time() - t0)
|
||||
|
||||
return StreamingResponse(pcm_stream(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": str(sr)})
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os, time, glob, numpy as np, soundfile as sf
|
||||
from pocket_tts import TTSModel
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = os.path.join(BASE, "out_preset"); os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l")
|
||||
print(f"[load] {time.time()-t0:.1f}s sr={m.sample_rate}", flush=True)
|
||||
|
||||
voice = None
|
||||
try:
|
||||
from pocket_tts.default_parameters import get_default_voice_for_language
|
||||
voice = get_default_voice_for_language(str(getattr(m, "origin", "german_24l")))
|
||||
except Exception as e:
|
||||
print("default-voice fehler:", e, flush=True)
|
||||
for cand in [voice, "anna", "vera", "juergen", "cosette"]:
|
||||
if not cand: continue
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(cand); voice = cand; break
|
||||
except Exception as e:
|
||||
print(f"voice {cand} nicht nutzbar: {str(e)[:60]}", flush=True)
|
||||
print(f"[voice] {voice}", flush=True)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
for name, text in SENT.items():
|
||||
for run in ("cold", "warm"):
|
||||
t0 = time.time()
|
||||
audio = m.generate_audio(vs, text)
|
||||
dt = time.time() - t0
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
secs = len(a) / m.sample_rate
|
||||
sf.write(os.path.join(OUT, f"{name}.wav"), a, m.sample_rate)
|
||||
print(f"[{name:6} {run:4}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
|
||||
print("=== Whisper ===", flush=True)
|
||||
from faster_whisper import WhisperModel
|
||||
wm = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for p in sorted(glob.glob(os.path.join(OUT, "*.wav"))):
|
||||
segs, _ = wm.transcribe(p, language="de", beam_size=5)
|
||||
print(os.path.basename(p), "::", " ".join(s.text.strip() for s in segs), flush=True)
|
||||
print("PRESET_DONE", flush=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = os.path.join(BASE, "out_ptts"); os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# Referenz ~18s wav fuer Klon
|
||||
y, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
yt, _ = librosa.effects.trim(y, top_db=30)
|
||||
ref = os.path.join(BASE, "ptts_ref.wav")
|
||||
sf.write(ref, yt[:int(18*24000)], 24000)
|
||||
print(f"[ref] {min(len(yt)/24000,18):.1f}s", flush=True)
|
||||
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l")
|
||||
print(f"[load] {time.time()-t0:.1f}s sr={m.sample_rate}", flush=True)
|
||||
t0 = time.time()
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
print(f"[clone] {time.time()-t0:.1f}s", flush=True)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
for name, text in SENT.items():
|
||||
for run in ("cold", "warm"):
|
||||
t0 = time.time()
|
||||
audio = m.generate_audio(vs, text)
|
||||
dt = time.time() - t0
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio, dtype=np.float32)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
secs = len(a) / m.sample_rate
|
||||
p = os.path.join(OUT, f"{name}.wav"); sf.write(p, a, m.sample_rate)
|
||||
print(f"[{name:6} {run:4}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
|
||||
print("=== Whisper ===", flush=True)
|
||||
from faster_whisper import WhisperModel
|
||||
wm = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
import glob
|
||||
for p in sorted(glob.glob(os.path.join(OUT, "*.wav"))):
|
||||
segs, _ = wm.transcribe(p, language="de", beam_size=5)
|
||||
print(os.path.basename(p), "::", " ".join(s.text.strip() for s in segs), flush=True)
|
||||
print("PTTS_DONE", flush=True)
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Rendert benannte Test-WAVs auf den Desktop (zum Anhören). LABEL steuert den Datei-Präfix.
|
||||
Server muss auf :8134 laufen. Nutzung: LUCY_LABEL=nachher python render_desktop.py"""
|
||||
import os, json, urllib.request
|
||||
|
||||
SERVER = "http://127.0.0.1:8134/tts"
|
||||
LABEL = os.environ.get("LUCY_LABEL", "nachher")
|
||||
OUT = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_test"); os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
TESTS = [
|
||||
("01_kurz", "Schön, dass du da bist, Commander."),
|
||||
("02_langer_satz","Also, Commander, wenn ich das kurz zusammenfassen darf: heute früh war die Verbindung weg, dann lief das Update durch, danach musste ich zweimal neu starten, und jetzt sind endlich alle Dienste oben, das Gedächtnis ist warm, und wir können ganz entspannt weitermachen."),
|
||||
("03_mittel", "Guten Morgen, Commander. Das nächtliche Backup ist um 3 Uhr durchgelaufen, es gab keine Fehler, und die Datenbank läuft mit 92 Prozent Auslastung völlig stabil."),
|
||||
("04_akronyme", "Die CPU der RX 9070 XT und die GPU laufen mit ROCm, sagt der PC über das LAN. Es ist 21 Uhr UTC."),
|
||||
("05_locker", "Klar, mach ich. Gib mir kurz zwei Sekunden, dann schau ich in den Logs nach, was da schiefgelaufen ist."),
|
||||
]
|
||||
|
||||
def render(text):
|
||||
req = urllib.request.Request(SERVER, data=json.dumps({"text": text}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=180).read()
|
||||
|
||||
for name, text in TESTS:
|
||||
wav = render(text)
|
||||
path = os.path.join(OUT, f"{name}_{LABEL}.wav")
|
||||
open(path, "wb").write(wav)
|
||||
print(f"OK {os.path.basename(path)} ({len(wav)//1024} KB)")
|
||||
print(f"\nFertig -> {OUT}")
|
||||
@@ -0,0 +1,20 @@
|
||||
import time
|
||||
from llama_cpp import Llama
|
||||
|
||||
MODEL = r"F:\Coding Stuff\mission-control-2\client\lucy-tts\models\OuteTTS-1.0-1B-Q8_0.gguf"
|
||||
|
||||
t0 = time.time()
|
||||
llm = Llama(model_path=MODEL, n_gpu_layers=999, n_ctx=8192, verbose=True)
|
||||
print(f"[load] {time.time()-t0:.1f}s", flush=True)
|
||||
|
||||
prompt = "Guten Tag, mein Name ist"
|
||||
# warmup
|
||||
llm(prompt, max_tokens=16, temperature=0.8)
|
||||
# messen
|
||||
for i in range(2):
|
||||
t0 = time.time()
|
||||
out = llm(prompt, max_tokens=300, temperature=0.8)
|
||||
dt = time.time() - t0
|
||||
n = out["usage"]["completion_tokens"]
|
||||
print(f"[run {i+1}] {n} tokens in {dt:.2f}s = {n/dt:.1f} tok/s", flush=True)
|
||||
print("SPEED_DONE", flush=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Deutscher TTS-Stresstest: rendert 10 Sätze (typische DE-Fallen, alle Längen), transkribiert per
|
||||
Whisper (SOLL vs IST) und misst Pausen/Prosodie. Server muss auf :8134 laufen (pocket_server /tts)."""
|
||||
import os, io, json, urllib.request
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
SERVER = "http://127.0.0.1:8134/tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_de_stress"; os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
TESTS = [
|
||||
("01_kurz_umlaut", "kurz", "Schön, dass du da bist, Commander."),
|
||||
("02_kurz_zahlen", "kurz", "Es ist 21 Uhr 40, und wir haben noch 16 GB frei."),
|
||||
("03_komposita", "mittel", "Die Geschwindigkeitsbegrenzung und die Aufmerksamkeitsspanne sind heute besonders wichtig."),
|
||||
("04_ss_sch_ch", "mittel", "Ich weiß nicht, ob die Straße noch frei ist, aber ich schaue gleich mal nach."),
|
||||
("05_umlaut_haeufung", "mittel", "Über fünf Türme, größere Bäume und schöne Flüsse führt die Röhre nach Süden."),
|
||||
("06_fremdwoerter", "mittel", "Das Backup-Skript läuft im Terminal, der Router ist online und der Server antwortet sofort."),
|
||||
("07_lang_gemischt", "lang", "Guten Morgen, Commander. Das nächtliche Backup ist um 3 Uhr durchgelaufen, es gab keine Fehler, und die Datenbank läuft mit 92 Prozent Auslastung völlig stabil."),
|
||||
("08_sehr_lang", "sehr lang", "Also, Commander, wenn ich das kurz zusammenfassen darf: heute früh war die Verbindung weg, dann lief das Update durch, danach musste ich zweimal neu starten, und jetzt sind endlich alle Dienste oben, das Gedächtnis ist warm, und wir können ganz entspannt weitermachen."),
|
||||
("09_akronyme", "mittel", "Die CPU der RX 9070 XT und die GPU laufen mit ROCm, sagt der PC über das LAN."),
|
||||
("10_zungenbrecher", "kurz", "Der Frühstücksfleischklößchenlieferant frühstückt fröhlich am frühen Freitag."),
|
||||
]
|
||||
|
||||
def render(text):
|
||||
req = urllib.request.Request(SERVER, data=json.dumps({"text": text}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return urllib.request.urlopen(req, timeout=180).read()
|
||||
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for name, length, text in TESTS:
|
||||
wav = render(text)
|
||||
path = os.path.join(OUT, name + ".wav")
|
||||
open(path, "wb").write(wav)
|
||||
a, sr = sf.read(io.BytesIO(wav)); a = np.asarray(a, dtype=np.float32).reshape(-1); dur = len(a) / sr
|
||||
segs, _ = w.transcribe(path, language="de", beam_size=5, word_timestamps=True)
|
||||
words = [x for s in segs for x in (s.words or [])]
|
||||
got = " ".join(x.word.strip() for x in words)
|
||||
pauses = [(words[i].word.strip(), words[i + 1].start - words[i].end)
|
||||
for i in range(len(words) - 1) if (words[i + 1].start - words[i].end) >= 0.25]
|
||||
f0 = librosa.yin(a, fmin=80, fmax=400, sr=sr, frame_length=1024); f0v = f0[(f0 > 90) & (f0 < 380)]
|
||||
med = np.median(f0v) if f0v.size else 0; rng = (np.percentile(f0v, 90) - np.percentile(f0v, 10)) if f0v.size else 0
|
||||
print(f"\n### {name} [{length}] {dur:.1f}s Tempo {len(words)/max(dur,.01):.1f}W/s F0 {med:.0f}Hz/Spanne {rng:.0f}")
|
||||
print(f"SOLL: {text}")
|
||||
print(f"IST : {got}")
|
||||
if pauses:
|
||||
print("PAUSEN: " + ", ".join(f'„{ww}"={gg*1000:.0f}ms' for ww, gg in sorted(pauses, key=lambda t: -t[1])[:6]))
|
||||
print("\n=== STRESSTEST FERTIG ===")
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""sweep_b2.py — findet den B2-Sweet-Spot auf DIESER Maschine (Ryzen 9700X, 8C/16T).
|
||||
Variiert WORKERS × THREADS-pro-Worker und misst je Konfig auf einer langen, mehrsätzigen Antwort:
|
||||
- wall : Gesamt-Wall-Clock (Generierung der ganzen Antwort)
|
||||
- ttfb : Time-to-first-audio (Reaktionszeit = erster Satz fertig)
|
||||
- audio : erzeugte Audiolänge (variiert leicht, da temp>0 stochastisch)
|
||||
- RTF : wall/audio (längen-normalisiert -> fairer Durchsatz-Vergleich; <1 = schneller als Echtzeit)
|
||||
Nutzt die ECHTEN pocket_server-Funktionen (_worker_init/_warmup/_gen_sentences_ordered).
|
||||
Schreibt sweep_b2_result.json + eine Tabelle und nennt am Ende den Sweet Spot.
|
||||
"""
|
||||
import os, time, json, statistics as st
|
||||
import numpy as np, librosa
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from pocket_tts import TTSModel
|
||||
import pocket_server as ps
|
||||
|
||||
BASE = ps.BASE
|
||||
LONG = ("Guten Morgen, Commander. "
|
||||
"Das nächtliche Backup ist sauber durchgelaufen und es gab keine Fehler. "
|
||||
"Der Dienst läuft stabil und die Engine antwortet zügig. "
|
||||
"Ich habe die Modelle vorgewärmt und die Latenz im Blick behalten. "
|
||||
"Die Protokolle zeigen keine Auffälligkeiten in den letzten Stunden. "
|
||||
"Wenn du möchtest, fasse ich die offenen Punkte für heute zusammen. "
|
||||
"Danach kümmere ich mich um die anstehenden Updates und melde mich wieder.")
|
||||
|
||||
# (workers, threads_pro_worker). Ziel: workers*threads ~ 6–8 (8 physische Kerne).
|
||||
CONFIGS = [("seriell", 0, 0), ("pool", 2, 4), ("pool", 2, 3),
|
||||
("pool", 3, 2), ("pool", 3, 3), ("pool", 4, 2)]
|
||||
REPS = 3
|
||||
|
||||
|
||||
def _measure(sents):
|
||||
"""Eine Durchführung: (wall, ttfb, audio_s) über den aktuell in STATE gesetzten Pfad."""
|
||||
t0 = time.time(); first = None; total = 0
|
||||
for i, a in enumerate(ps._gen_sentences_ordered(sents)):
|
||||
if i == 0:
|
||||
first = time.time() - t0
|
||||
total += a.size
|
||||
return time.time() - t0, first, total / 24000.0
|
||||
|
||||
|
||||
def run_serial(reps):
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=ps.LSD, temp=ps.TEMP,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
ref = ps._prep_ref()
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ra, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
ps.STATE.clear()
|
||||
ps.STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=ps._fingerprint(ra, m.sample_rate))
|
||||
sents = ps._split_sentences(LONG)
|
||||
rows = [_measure(sents) for _ in range(reps)]
|
||||
ps.STATE.clear(); del m
|
||||
return rows, len(sents)
|
||||
|
||||
|
||||
def run_pool(workers, threads, reps):
|
||||
os.environ["LUCY_WORKER_THREADS"] = str(threads) # vom Worker beim Spawn gelesen
|
||||
pool = ProcessPoolExecutor(max_workers=workers, initializer=ps._worker_init)
|
||||
list(pool.map(ps._warmup, range(workers)))
|
||||
ref = ps._prep_ref(); ra, _ = librosa.load(ref, sr=24000, mono=True)
|
||||
ps.STATE.clear()
|
||||
ps.STATE.update(sr=24000, ref_fp=ps._fingerprint(ra, 24000), pool=pool)
|
||||
sents = ps._split_sentences(LONG)
|
||||
rows = [_measure(sents) for _ in range(reps)]
|
||||
pool.shutdown(wait=True); ps.STATE.clear()
|
||||
return rows, len(sents)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = []
|
||||
print(f"Text: {len(LONG)} Zeichen, REPS={REPS}\n")
|
||||
print(f"{'config':>14} | {'wall':>6} {'ttfb':>6} {'audio':>6} {'RTF':>5}")
|
||||
print("-" * 48)
|
||||
for kind, w, t in CONFIGS:
|
||||
try:
|
||||
rows, nsent = (run_serial(REPS) if kind == "seriell" else run_pool(w, t, REPS))
|
||||
except Exception as e:
|
||||
print(f"{kind} w{w} t{t}: FEHLER {e}")
|
||||
continue
|
||||
wall = st.median([r[0] for r in rows])
|
||||
ttfb = st.median([r[1] for r in rows])
|
||||
audio = st.median([r[2] for r in rows])
|
||||
rtf = wall / max(audio, 0.01)
|
||||
label = "seriell(1×all)" if kind == "seriell" else f"{w}w×{t}t"
|
||||
print(f"{label:>14} | {wall:6.2f} {ttfb:6.2f} {audio:6.2f} {rtf:5.2f}")
|
||||
results.append(dict(label=label, kind=kind, workers=(1 if kind == "seriell" else w),
|
||||
threads=t, wall=wall, ttfb=ttfb, audio=audio, rtf=rtf, sents=nsent))
|
||||
|
||||
json.dump(results, open(os.path.join(BASE, "sweep_b2_result.json"), "w"), indent=2)
|
||||
|
||||
pool_rows = [r for r in results if r["kind"] == "pool"]
|
||||
if pool_rows:
|
||||
best_tput = min(pool_rows, key=lambda r: r["rtf"])
|
||||
best_ttfb = min(pool_rows, key=lambda r: r["ttfb"])
|
||||
# Sweet Spot: bester Durchsatz, aber TTFB nicht >20% über dem TTFB-Sieger (Reaktion zählt)
|
||||
cand = [r for r in pool_rows if r["ttfb"] <= best_ttfb["ttfb"] * 1.20]
|
||||
sweet = min(cand, key=lambda r: r["rtf"]) if cand else best_tput
|
||||
print("\n>> bester Durchsatz :", best_tput["label"], f"(RTF {best_tput['rtf']:.2f})")
|
||||
print(">> beste Reaktion :", best_ttfb["label"], f"(TTFB {best_ttfb['ttfb']:.2f}s)")
|
||||
print(">> SWEET SPOT :", sweet["label"],
|
||||
f"-> LUCY_WORKERS={sweet['workers']} LUCY_WORKER_THREADS={sweet['threads']}")
|
||||
print("SWEEP_DONE")
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Qualitäts-Sweep: noise_clamp (nie getestet, Hypothese: dämpft Artefakte+Kollaps) × lsd, temp 0.9.
|
||||
Produktions-äquivalente Verarbeitung (LEAD + cleanup v4 wie der Server) -> faires A/B gegen lucy_final."""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_quality_sweep"; os.makedirs(OUT, exist_ok=True)
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
LEAD, TARGET_RMS = "Tja. ", 0.09
|
||||
SENT = {"kurz":"Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang":"Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft."}
|
||||
|
||||
def crop_lead(a, sr):
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) >= 2:
|
||||
cut = max(iv[0][1], iv[1][0] - int(0.06*sr)); a = a[cut:]
|
||||
return a
|
||||
def cleanup(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
a = crop_lead(a, sr)
|
||||
rev,_ = librosa.effects.trim(a[::-1], top_db=45); a = rev[::-1] if rev.size else a
|
||||
rms = float(np.sqrt(np.mean(a**2))) or 1e-9; a = a*(TARGET_RMS/rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0.9: a = a*(0.9/peak)
|
||||
fi = min(int(0.008*sr), a.size//2)
|
||||
if fi>0: a[:fi]*=np.linspace(0.,1.,fi,dtype=np.float32); a[-fi:]*=np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
pad = np.zeros(int(0.08*sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
# (tag, lsd, noise_clamp)
|
||||
COMBOS = [
|
||||
("A_baseline_lsd6_ncNone", 6, None),
|
||||
("B_lsd6_nc2", 6, 2.0),
|
||||
("C_lsd6_nc3", 6, 3.0),
|
||||
("D_lsd10_nc3", 10, 3.0),
|
||||
]
|
||||
for tag, lsd, nc in COMBOS:
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=lsd, temp=0.9, noise_clamp=nc)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
print(f"== {tag} (load {time.time()-t0:.1f}s) ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time(); a = m.generate_audio(vs, LEAD+text, frames_after_eos=4); dt = time.time()-t0
|
||||
a = a.numpy() if hasattr(a,"numpy") else np.asarray(a)
|
||||
a = cleanup(a, sr); secs = a.size/sr
|
||||
sf.write(os.path.join(OUT, f"{tag}_{name}.wav"), a, sr)
|
||||
print(f" [{name:6}] gen={dt:4.1f}s audio={secs:4.1f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
print("SWEEP_DONE", flush=True)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sweep 2 (Feinschliff): lsd 10 (glättet Rauschen) × noise_clamp 2/2.5/3, temp 0.9.
|
||||
Ziel: sauberes 'Commander' (nicht erzwungen, = niedrigeres nc) + wenig Rauschen (= höheres lsd)."""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_sweep2"; os.makedirs(OUT, exist_ok=True)
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
LEAD, TARGET_RMS = "Tja. ", 0.09
|
||||
SENT = {"kurz":"Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang":"Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft."}
|
||||
|
||||
def crop_lead(a, sr):
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) >= 2: a = a[max(iv[0][1], iv[1][0]-int(0.06*sr)):]
|
||||
return a
|
||||
def cleanup(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
a = crop_lead(a, sr)
|
||||
rev,_ = librosa.effects.trim(a[::-1], top_db=45); a = rev[::-1] if rev.size else a
|
||||
rms = float(np.sqrt(np.mean(a**2))) or 1e-9; a = a*(TARGET_RMS/rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0.9: a = a*(0.9/peak)
|
||||
fi = min(int(0.008*sr), a.size//2)
|
||||
if fi>0: a[:fi]*=np.linspace(0.,1.,fi,dtype=np.float32); a[-fi:]*=np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
return np.concatenate([np.zeros(int(0.08*sr),dtype=np.float32), a, np.zeros(int(0.08*sr),dtype=np.float32)])
|
||||
|
||||
for nc in [2.0, 2.5, 3.0]:
|
||||
tag = f"lsd10_nc{str(nc).replace('.','p')}"
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=10, temp=0.9, noise_clamp=nc)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
print(f"== {tag} (load {time.time()-t0:.1f}s) ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time(); a = m.generate_audio(vs, LEAD+text, frames_after_eos=4); dt = time.time()-t0
|
||||
a = a.numpy() if hasattr(a,"numpy") else np.asarray(a)
|
||||
a = cleanup(a, sr); secs = a.size/sr
|
||||
sf.write(os.path.join(OUT, f"{tag}_{name}.wav"), a, sr)
|
||||
print(f" [{name:6}] gen={dt:4.1f}s audio={secs:4.1f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
print("SWEEP2_DONE", flush=True)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Prüft normalize_numbers: Einheiten/Uhrzeiten/Dezimalzahlen verbalisieren, Modellnummern schützen."""
|
||||
from text_norm import normalize_numbers as N
|
||||
|
||||
def check(name, text, must_have=(), must_not=()):
|
||||
out = N(text)
|
||||
ok = all(h in out for h in must_have) and all(x not in out for x in must_not)
|
||||
print(f"{'PASS' if ok else 'FAIL'} {name}: \"{text}\" -> \"{out}\"")
|
||||
return ok
|
||||
|
||||
allok = True
|
||||
allok &= check("Uhrzeit", "Es ist 18:01 Uhr.", must_have=["achtzehn Uhr", "eins"], must_not=[":", "18:"])
|
||||
allok &= check("Uhrzeit :00", "Um 18:00.", must_have=["achtzehn Uhr"], must_not=[":"])
|
||||
allok &= check("Uhrzeit HH:MM", "Treffen um 9:30 Uhr.", must_have=["neun Uhr", "dreißig"], must_not=[":"])
|
||||
allok &= check("Einheit GB verbalisiert", "Sie hat 16 GB Speicher.", must_have=["sechzehn GB"], must_not=[" 16 "])
|
||||
allok &= check("Einheit TB + GB", "8 TB und 32 GB RAM.", must_have=["acht TB", "zweiunddreißig GB", "RAM"])
|
||||
allok &= check("Dezimal verbalisiert", "Läuft mit 4.5 GHz.", must_have=["vier Komma", "fünf", "GHz"], must_not=["4.5"])
|
||||
allok &= check("Modellnummer geschützt", "Die RX 9070 XT ist schnell.", must_have=["9070", "XT"], must_not=["neuntausend"])
|
||||
allok &= check("Version geschützt", "Qwen3.6 nutzt Version 2.", must_have=["Qwen3.6", "zwei"], must_not=["Qwen3.sechs"])
|
||||
allok &= check("Modell H100 geschützt", "Das braucht eine H100.", must_have=["H100"])
|
||||
allok &= check("Zahl im Text", "Ich habe 5 Äpfel.", must_have=["fünf", "Äpfel"], must_not=[" 5 "])
|
||||
allok &= check("Prozent", "Das sind 50 Prozent.", must_have=["fünfzig Prozent"])
|
||||
allok &= check("Grad", "Es sind 25 Grad.", must_have=["fünfundzwanzig Grad"])
|
||||
allok &= check("Jahr", "Im Jahr 2026.", must_have=["zweitausend"], must_not=["2026"])
|
||||
allok &= check("Datum-Ordinal", "Am 3. Januar.", must_have=["Januar"], must_not=["3. Januar", " 3 "])
|
||||
allok &= check("große ID roh", "Fehler 123456.", must_have=["123456"])
|
||||
|
||||
print("\nALLE TESTS GRÜN" if allok else "\nES GAB FEHLER")
|
||||
raise SystemExit(0 if allok else 1)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Deutsche Zahlen-/Uhrzeit-Normalisierung für Lucys Stimme (num2words).
|
||||
|
||||
Eigenständig (nur re + num2words, KEIN torch/pocket_tts-Import), damit schnell testbar und von
|
||||
pocket_server importierbar.
|
||||
|
||||
Kernidee: Zahlen im Fließtext verbalisieren ("18:01" -> "achtzehn Uhr eins", "16 GB" -> "sechzehn
|
||||
GB", "4.5" -> "vier Komma fünf"), aber Modell-/Versionsnummern SCHÜTZEN ("RX 9070 XT", "Qwen3.6",
|
||||
"H100" bleiben roh — die soll man nicht als Kardinalzahl lesen). Abschaltbar via LUCY_NUM_NORM=0.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
try:
|
||||
from num2words import num2words as _n2w
|
||||
except Exception: # Lib fehlt -> No-op (Stimme läuft trotzdem)
|
||||
_n2w = None
|
||||
|
||||
# AUS per Default (2026-07-01, empirisch via Whisper-Rücktranskription verifiziert): Pocket german_24l
|
||||
# spricht ROHE Ziffern ("21 Uhr 40", "16 GB", "4.5 GHz", "9:30") sauber. Vorverbalisierte deutsche
|
||||
# Kompositzahlen ("einundzwanzig") ZERBRICHT das Modell zu Kauderwelsch ("ein Mund", "ein Aus-20").
|
||||
# Also NICHT verbalisieren. Mit LUCY_NUM_NORM=1 wieder anschaltbar (falls je ein Modell es braucht).
|
||||
NUM_NORM = os.environ.get("LUCY_NUM_NORM", "0") not in ("0", "false", "False")
|
||||
|
||||
_MONTHS = "Januar Februar März April Mai Juni Juli August September Oktober November Dezember".split()
|
||||
_MONTH_RX = "|".join(_MONTHS)
|
||||
# FULLY-uppercase 2+-Einheiten, neben denen Zahlen VERBALISIERT werden (sechzehn GB) — im Gegensatz
|
||||
# zu Modell-Akronymen (RX/XT/RTX), wo die Ziffern roh bleiben (RX 9070 XT). Gemischt-Case-Einheiten
|
||||
# (GHz/MHz/kg/km/Grad/Prozent) werden von der Akronym-Regel eh nicht erfasst -> dort wird ohnehin verbalisiert.
|
||||
_CAPS_UNITS = {"GB", "TB", "MB", "KB", "PB", "EB", "KW", "MW"}
|
||||
|
||||
|
||||
# Garble-Kandidaten (Whisper-verifiziert): Pocket german_24l kann diese Akronyme/Codes NICHT sauber
|
||||
# sprechen ("ROCm"->"AOC HM", "UTC"->Kauderwelsch) -> Ersatz durch gesprochene/buchstabierte Form.
|
||||
# Erweiterbar via LUCY_ACRONYM_EXTRA="ABC=A B C,XYZ=..." (neue Fälle findet transcribe.py).
|
||||
_ACRONYM_FIX = {
|
||||
"ROCm": "Rockem", "UTC": "U T C", "GMT": "G M T",
|
||||
"CEST": "C E S T", "CET": "C E T", "MESZ": "M E S Z", "MEZ": "M E Z",
|
||||
"NVMe": "N V M E", "PCIe": "P C I E",
|
||||
}
|
||||
|
||||
def _build_acr():
|
||||
m = dict(_ACRONYM_FIX)
|
||||
for pair in os.environ.get("LUCY_ACRONYM_EXTRA", "").split(","):
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
if k.strip():
|
||||
m[k.strip()] = v.strip()
|
||||
return m
|
||||
|
||||
_ACR_MAP = _build_acr()
|
||||
_ACR_RX = re.compile(r"\b(" + "|".join(re.escape(k) for k in sorted(_ACR_MAP, key=len, reverse=True)) + r")\b") if _ACR_MAP else None
|
||||
|
||||
ACR_ON = os.environ.get("LUCY_ACRONYM", "1") not in ("0", "false", "False") # A/B-Abschaltung
|
||||
|
||||
def fix_acronyms(text: str) -> str:
|
||||
"""Garble-anfällige Akronyme durch gesprochene Form ersetzen (ROCm -> Rockem, UTC -> U T C)."""
|
||||
return _ACR_RX.sub(lambda m: _ACR_MAP[m.group(0)], text) if (_ACR_RX and ACR_ON) else text
|
||||
|
||||
|
||||
def _card(v) -> str:
|
||||
try:
|
||||
return _n2w(int(v), lang="de")
|
||||
except Exception:
|
||||
return str(v)
|
||||
|
||||
|
||||
def normalize_numbers(text: str) -> str:
|
||||
if not NUM_NORM or _n2w is None:
|
||||
return text
|
||||
|
||||
masks: list[str] = []
|
||||
def _mask(s: str) -> str:
|
||||
masks.append(s)
|
||||
return f"\x00{len(masks) - 1}\x00"
|
||||
|
||||
# 1) Uhrzeiten: "18:01 Uhr"/"18:01" -> "18 Uhr 01", "18:00" -> "18 Uhr" (Ziffern verbalisiert Schritt 6)
|
||||
text = re.sub(r"\b(\d{1,2}):00(\s*Uhr)?\b", r"\1 Uhr", text)
|
||||
text = re.sub(r"\b(\d{1,2}):(\d{2})\s*Uhr\b", r"\1 Uhr \2", text)
|
||||
text = re.sub(r"\b(\d{1,2}):(\d{2})\b", r"\1 Uhr \2", text)
|
||||
|
||||
# 2) Datums-Ordinalzahlen: "3. Januar" -> "dritter Januar"
|
||||
def _ord(m):
|
||||
try:
|
||||
return f"{_n2w(int(m.group(1)), to='ordinal', lang='de')} {m.group(2)}"
|
||||
except Exception:
|
||||
return m.group(0)
|
||||
text = re.sub(rf"\b(\d{{1,2}})\.\s+({_MONTH_RX})\b", _ord, text)
|
||||
|
||||
# 3) Modell-/Versions-Token (Buchstabe UND Ziffer, evtl. mit Punkten: Qwen3.6, H100, v2.1, RTX4090)
|
||||
# -> ganzes Token maskieren (nie verbalisieren). Satz-Endpunkt/-Komma bleibt draußen.
|
||||
def _mask_model(m):
|
||||
tok = m.group(0); trail = ""
|
||||
while tok and tok[-1] in ".,":
|
||||
trail = tok[-1] + trail; tok = tok[:-1]
|
||||
if re.search(r"[A-Za-zÄÖÜäöü]", tok) and re.search(r"\d", tok):
|
||||
return _mask(tok) + trail
|
||||
return m.group(0)
|
||||
text = re.sub(r"[A-Za-zÄÖÜäöü0-9]+(?:[.,][A-Za-zÄÖÜäöü0-9]+)*", _mask_model, text)
|
||||
|
||||
# 4) Standalone-Dezimalzahl (nur Ziffern, kein Buchstabe): "4.5" -> "vier Komma fünf"
|
||||
# (Nachkommastellen ziffernweise, wie im Deutschen üblich).
|
||||
def _dec(m):
|
||||
return f"{_card(m.group(1))} Komma {' '.join(_card(d) for d in m.group(2))}"
|
||||
text = re.sub(r"(?<![\w\x00])(\d+)[.,](\d+)(?![\w\x00])", _dec, text)
|
||||
|
||||
# 5) Zahl neben ALL-CAPS-Token: Einheit (GB/TB..) -> verbalisieren; Modell-Akronym (RX/XT) -> roh maskieren.
|
||||
def _by_ctx(num, acr):
|
||||
return num if acr.upper() in _CAPS_UNITS else _mask(num)
|
||||
text = re.sub(r"\b([A-ZÄÖÜ]{2,})\s+(\d{1,6})\b", lambda m: f"{m.group(1)} {_by_ctx(m.group(2), m.group(1))}", text)
|
||||
text = re.sub(r"\b(\d{1,6})\s+([A-ZÄÖÜ]{2,})\b", lambda m: f"{_by_ctx(m.group(1), m.group(2))} {m.group(2)}", text)
|
||||
|
||||
# 6) verbleibende reine Ganzzahlen (0..9999) verbalisieren; größere roh lassen (IDs/Codes)
|
||||
def _c(m):
|
||||
v = int(m.group(0))
|
||||
return _n2w(v, lang="de") if v <= 9999 else m.group(0)
|
||||
text = re.sub(r"(?<![\w\x00])\d{1,4}(?![\w\x00])", _c, text)
|
||||
|
||||
# 7) Masken zurückholen
|
||||
text = re.sub(r"\x00(\d+)\x00", lambda m: masks[int(m.group(1))], text)
|
||||
return text
|
||||
@@ -0,0 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Whisper-Rücktranskription: WAS sagt die Stimme WIRKLICH? Liest die generierten Wörter, damit man
|
||||
beim Fine-Tunen SEHEN kann, wo Pocket Zahlen/Wörter vermurkst (statt nur zu hören/raten).
|
||||
|
||||
Nutzung: python transcribe.py [ordner-mit-wavs]
|
||||
Default-Ordner: C:\\Users\\TobisPC\\Desktop\\lucy_pocket_tune
|
||||
"""
|
||||
import os, sys, glob
|
||||
import soundfile as sf
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
DIR = sys.argv[1] if len(sys.argv) > 1 else r"C:\Users\TobisPC\Desktop\lucy_pocket_tune"
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for p in sorted(glob.glob(os.path.join(DIR, "*.wav"))):
|
||||
a, sr = sf.read(p)
|
||||
seg, _ = w.transcribe(p, language="de", beam_size=5)
|
||||
txt = " ".join(s.text for s in seg).strip()
|
||||
print(f"[{os.path.basename(p)}] ({len(a)/sr:.1f}s)")
|
||||
print(f" HÖRT WHISPER: {txt}\n")
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ttfb_test.py — misst die Stream-TTFB im SERIELLEN Pfad mit kurzem erstem Chunk an/aus.
|
||||
Ein Modell-Load, dann je Modus 3 Durchläufe -> Median. Belegt, ob keep_first_short Lucys
|
||||
Sprechbeginn beschleunigt, ohne den Rest zu verschlechtern.
|
||||
"""
|
||||
import time, statistics as st
|
||||
import numpy as np, librosa
|
||||
from pocket_tts import TTSModel
|
||||
import pocket_server as ps
|
||||
|
||||
LONG = ("Guten Morgen, Commander. "
|
||||
"Das nächtliche Backup ist sauber durchgelaufen und es gab keine Fehler. "
|
||||
"Der Dienst läuft stabil und die Engine antwortet zügig. "
|
||||
"Ich habe die Modelle vorgewärmt und die Latenz im Blick behalten. "
|
||||
"Die Protokolle zeigen keine Auffälligkeiten in den letzten Stunden. "
|
||||
"Wenn du möchtest, fasse ich die offenen Punkte für heute zusammen. "
|
||||
"Danach kümmere ich mich um die anstehenden Updates und melde mich wieder.")
|
||||
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=ps.LSD, temp=ps.TEMP,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
ref = ps._prep_ref()
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ra, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
ps.STATE.clear()
|
||||
ps.STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=ps._fingerprint(ra, m.sample_rate))
|
||||
|
||||
|
||||
def measure(keep_first):
|
||||
sents = ps._split_sentences(LONG, keep_first_short=keep_first)
|
||||
t0 = time.time(); first = None; total = 0
|
||||
for i, a in enumerate(ps._gen_sentences_ordered(sents)):
|
||||
if i == 0:
|
||||
first = time.time() - t0
|
||||
total += a.size
|
||||
return first, time.time() - t0, total / 24000.0, len(sents), sents[0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for keep in (False, True):
|
||||
ttfbs, walls = [], []
|
||||
n = s0 = None
|
||||
for _ in range(3):
|
||||
f, w, au, n, s0 = measure(keep)
|
||||
ttfbs.append(f); walls.append(w)
|
||||
print(f"keep_first_short={str(keep):5} | TTFB={st.median(ttfbs):4.2f}s wall={st.median(walls):5.2f}s "
|
||||
f"chunks={n} erster='{s0[:38]}'")
|
||||
print("TTFB_DONE")
|
||||
@@ -0,0 +1,79 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Fix: (1) 'vorne abgeschnitten' = Modell startet mid-Phonem -> Lead-Wort voranstellen,
|
||||
in flow generieren, dann in der STILLE-LÜCKE davor schneiden (voller Onset bleibt).
|
||||
(2) 'zu laut' = RMS-Normalisierung auf Zielpegel statt Peak 0.95."""
|
||||
import os, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_front_loud"; os.makedirs(OUT, exist_ok=True)
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
LEAD = "Tja. " # Wegwerf-Lead -> erzeugt natürlichen Onset fürs echte erste Wort
|
||||
|
||||
def drop_tail_blip(a, sr):
|
||||
for _ in range(3):
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) < 2: break
|
||||
srms = float(np.median([np.sqrt(np.mean(a[s:e]**2)) for s,e in iv[:-1]]))
|
||||
s,e = iv[-1]; dur=(e-s)/sr; rms=float(np.sqrt(np.mean(a[s:e]**2))); gap=(s-iv[-2][1])/sr
|
||||
if gap>0.35 and rms<0.30*srms and dur<0.35: a = a[:iv[-2][1]]
|
||||
else: break
|
||||
return a
|
||||
|
||||
def crop_lead(a, sr):
|
||||
"""Schneide in der Lücke NACH dem Lead-Wort -> echtes 1. Wort voll erhalten."""
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) >= 2:
|
||||
# Ende des 1. (Lead-)Segments + kleiner Sicherheitsabstand in die Lücke
|
||||
cut = iv[0][1] + int(0.03*sr)
|
||||
nxt = iv[1][0]
|
||||
cut = min(cut, max(iv[0][1], nxt - int(0.04*sr))) # mind. 40ms Stille vor echtem Wort lassen
|
||||
a = a[cut:]
|
||||
return a
|
||||
|
||||
def finalize(a, sr, target_rms, peak_cap=0.9, front_trim=False):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if front_trim:
|
||||
yt,_ = librosa.effects.trim(a, top_db=45); a = yt if yt.size else a
|
||||
else:
|
||||
# nur HINTEN trimmen (vorne unangetastet lassen)
|
||||
rev,_ = librosa.effects.trim(a[::-1], top_db=45); a = rev[::-1] if rev.size else a
|
||||
# RMS-Normalisierung auf Zielpegel
|
||||
rms = float(np.sqrt(np.mean(a**2))) or 1e-9
|
||||
a = a * (target_rms / rms)
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > peak_cap: a = a * (peak_cap / peak) # Sicherheits-Clamp
|
||||
fi = min(int(0.008*sr), a.size//2)
|
||||
if fi>0:
|
||||
a[:fi]*=np.linspace(0.,1.,fi,dtype=np.float32); a[-fi:]*=np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
pad = np.zeros(int(0.08*sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=0.9)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
|
||||
for name, text in SENT.items():
|
||||
# Onset-Fix-Roh: mit Lead generieren, Tail-Blip weg, Lead wegschneiden
|
||||
raw = m.generate_audio(vs, LEAD + text, frames_after_eos=4)
|
||||
raw = raw.numpy() if hasattr(raw,"numpy") else np.asarray(raw)
|
||||
raw = np.asarray(raw, dtype=np.float32).reshape(-1)
|
||||
raw = drop_tail_blip(raw, sr)
|
||||
cropped = crop_lead(raw, sr)
|
||||
for tr in [0.12, 0.09, 0.06]:
|
||||
out = finalize(cropped, sr, tr, front_trim=False)
|
||||
sf.write(os.path.join(OUT, f"{name}_onset_rms{int(tr*100):02d}.wav"), out, sr)
|
||||
# Whisper-Check: Output darf NICHT mit Lead beginnen
|
||||
sf.write(os.path.join(OUT, f"_chk_{name}.wav"), finalize(cropped, sr, 0.09), sr)
|
||||
seg,_ = w.transcribe(os.path.join(OUT, f"_chk_{name}.wav"), language="de", beam_size=5)
|
||||
txt = " ".join(s.text for s in seg).strip()
|
||||
print(f"[{name}] Whisper-Start: {txt[:55]!r}", flush=True)
|
||||
print("FRONT_LOUD_DONE", flush=True)
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os, time, glob, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = os.path.join(BASE, "out_tune"); os.makedirs(OUT, exist_ok=True)
|
||||
y, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
yt, _ = librosa.effects.trim(y, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, yt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
def light_trim(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
yt, _ = librosa.effects.trim(a, top_db=30)
|
||||
return yt if yt.size else a
|
||||
|
||||
for lsd in [2, 4, 8]:
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=lsd)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
print(f"== lsd={lsd} (load {time.time()-t0:.1f}s) ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time(); audio = m.generate_audio(vs, text); dt = time.time()-t0
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = light_trim(a, m.sample_rate)
|
||||
secs = a.size / m.sample_rate
|
||||
sf.write(os.path.join(OUT, f"lsd{lsd}_{name}.wav"), a, m.sample_rate)
|
||||
print(f" [{name:6}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
print("TUNE_DONE", flush=True)
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Sweep v2: lsd 4/5/6 + FIX gegen Vorne/Hinten-Abschneiden & Clipping.
|
||||
- peak-normalize auf 0.95 (killt PCM-16-Clipping/Distortion; roh war peak bis 1.56)
|
||||
- KEIN aggressiver Front-Trim mehr (Modell startet bei 0ms Stille -> Trim fraß 1. Phonem)
|
||||
- nur tiefe Stille sanft trimmen (top_db=45), dann feste 90ms-Pause vorne+hinten (Atem)
|
||||
- frames_after_eos=4 (kurze Sätze bekamen sonst 0ms Schwanz = abgeschnitten)
|
||||
"""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_lsd_v2"; os.makedirs(OUT, exist_ok=True)
|
||||
y, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
yt, _ = librosa.effects.trim(y, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, yt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
|
||||
def cleanup_v2(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
# 1) Peak-Normalisierung gegen Clipping/Distortion
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0: a = a * (0.95 / peak)
|
||||
# 2) nur TIEFE Stille sanft wegtrimmen (top_db=45 = wenig aggressiv, lässt weiche Onsets)
|
||||
yt, _ = librosa.effects.trim(a, top_db=45)
|
||||
a = yt if yt.size else a
|
||||
# 3) kurze 10ms-Fades an den echten Audiokanten (gegen Klicks)
|
||||
fi = min(int(0.01 * sr), a.size // 2)
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0.0, 1.0, fi, dtype=np.float32)
|
||||
a[-fi:] *= np.linspace(1.0, 0.0, fi, dtype=np.float32)
|
||||
# 4) feste 90ms Atem-Pause vorne+hinten (kein abruptes Einsetzen/Abschneiden)
|
||||
pad = np.zeros(int(0.09 * sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
for lsd in [4, 5, 6]:
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=lsd)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
sr = m.sample_rate
|
||||
print(f"== lsd={lsd} (load {time.time()-t0:.1f}s) ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time()
|
||||
audio = m.generate_audio(vs, text, frames_after_eos=4)
|
||||
dt = time.time() - t0
|
||||
a = audio.numpy() if hasattr(audio, "numpy") else np.asarray(audio)
|
||||
a = cleanup_v2(a, sr)
|
||||
secs = a.size / sr
|
||||
sf.write(os.path.join(OUT, f"lsd{lsd}_{name}.wav"), a, sr)
|
||||
print(f" [{name:6}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f} peak={np.abs(a).max():.3f}", flush=True)
|
||||
print("TUNE_V2_DONE", flush=True)
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Naturalness-Sweep gegen 'Roboter/abgehackt': temperature, lsd hoch, + 2 Referenz-Fenster.
|
||||
Wörter sind laut Whisper alle da -> Problem ist Prosodie/Timbre, nicht Content."""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_natural"; os.makedirs(OUT, exist_ok=True)
|
||||
|
||||
# Zwei Referenz-Fenster aus der EL-Saber-MP3
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30)
|
||||
ref18 = os.path.join(BASE, "ref.wav") # aktuell: erste 18s
|
||||
sf.write(ref18, srt[:int(18*24000)], 24000)
|
||||
refC = os.path.join(BASE, "ref_C.wav") # XTTS-Liebling: Sek 18..27 (9s)
|
||||
segC = src[int(18*24000):int(27*24000)]
|
||||
segCt, _ = librosa.effects.trim(segC, top_db=30)
|
||||
sf.write(refC, segCt, 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
}
|
||||
|
||||
def cleanup_v2(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0: a = a * (0.95 / peak)
|
||||
yt, _ = librosa.effects.trim(a, top_db=45); a = yt if yt.size else a
|
||||
fi = min(int(0.01*sr), a.size//2)
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0.,1.,fi,dtype=np.float32)
|
||||
a[-fi:] *= np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
pad = np.zeros(int(0.09*sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
# (tag, lsd, temp, ref)
|
||||
COMBOS = [
|
||||
("A_lsd6_t070_ref18", 6, 0.70, ref18),
|
||||
("B_lsd6_t090_ref18", 6, 0.90, ref18),
|
||||
("C_lsd6_t105_ref18", 6, 1.05, ref18),
|
||||
("D_lsd8_t090_ref18", 8, 0.90, ref18),
|
||||
("E_lsd12_t090_ref18",12, 0.90, ref18),
|
||||
("F_lsd6_t090_refC", 6, 0.90, refC),
|
||||
]
|
||||
|
||||
for tag, lsd, temp, ref in COMBOS:
|
||||
t0 = time.time()
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=lsd, temp=temp)
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
sr = m.sample_rate
|
||||
print(f"== {tag} (load {time.time()-t0:.1f}s) ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
t0 = time.time()
|
||||
audio = m.generate_audio(vs, text, frames_after_eos=4); dt = time.time()-t0
|
||||
a = audio.numpy() if hasattr(audio,"numpy") else np.asarray(audio)
|
||||
a = cleanup_v2(a, sr); secs = a.size/sr
|
||||
sf.write(os.path.join(OUT, f"{tag}_{name}.wav"), a, sr)
|
||||
print(f" [{name:6}] gen={dt:5.2f}s audio={secs:5.2f}s RTF={dt/max(secs,0.01):.2f}", flush=True)
|
||||
print("NATURAL_DONE", flush=True)
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tail-Blip-Killer gegen das End-'taa' (isolierter, leiser Hall nach großer Lücke).
|
||||
Verifiziert auf kurz/mittel/lang @ lsd6/t0.9: Blip weg, echte Endungen unangetastet."""
|
||||
import os, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_tailfix"; os.makedirs(OUT, exist_ok=True)
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
|
||||
def drop_tail_blip(a, sr):
|
||||
"""Entferne isolierten, leisen/kurzen Schwanz-Blip (Modell-Halluzination)."""
|
||||
for _ in range(3): # bis zu 3 Blips hintereinander
|
||||
iv = librosa.effects.split(a, top_db=35)
|
||||
if len(iv) < 2:
|
||||
break
|
||||
speech_rms = float(np.median([np.sqrt(np.mean(a[s:e]**2)) for s, e in iv[:-1]]))
|
||||
s, e = iv[-1]
|
||||
last_dur = (e - s) / sr
|
||||
last_rms = float(np.sqrt(np.mean(a[s:e]**2)))
|
||||
gap = (s - iv[-2][1]) / sr
|
||||
# isoliert (große Lücke) UND (kurz ODER deutlich leiser als Sprache) -> Halluzination
|
||||
if gap > 0.30 and (last_dur < 0.30 or last_rms < 0.45 * speech_rms):
|
||||
a = a[: iv[-2][1]]
|
||||
else:
|
||||
break
|
||||
return a
|
||||
|
||||
def cleanup_v3(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
a = drop_tail_blip(a, sr) # NEU: End-'taa' weg
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0: a = a * (0.95 / peak)
|
||||
yt, _ = librosa.effects.trim(a, top_db=45); a = yt if yt.size else a
|
||||
fi = min(int(0.01*sr), a.size//2)
|
||||
if fi > 0:
|
||||
a[:fi] *= np.linspace(0.,1.,fi,dtype=np.float32); a[-fi:] *= np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
pad = np.zeros(int(0.09*sr), dtype=np.float32)
|
||||
return np.concatenate([pad, a, pad])
|
||||
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=0.9)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
for name, text in SENT.items():
|
||||
raw = m.generate_audio(vs, text, frames_after_eos=4)
|
||||
raw = raw.numpy() if hasattr(raw,"numpy") else np.asarray(raw)
|
||||
raw = np.asarray(raw, dtype=np.float32).reshape(-1)
|
||||
fixed = cleanup_v3(raw, sr)
|
||||
sf.write(os.path.join(OUT, f"t090_{name}.wav"), fixed, sr)
|
||||
# Verifikations-Log: letzte Segmente vorher/nachher
|
||||
iv0 = librosa.effects.split(raw, top_db=35)
|
||||
print(f"[{name:6}] raw_dur={raw.size/sr:.2f}s -> fixed_dur={fixed.size/sr:.2f}s raw_segmente={len(iv0)}", flush=True)
|
||||
if len(iv0):
|
||||
s,e = iv0[-1]; print(f" raw letztes Segment: {s/sr:.2f}..{e/sr:.2f}s rms={np.sqrt(np.mean(raw[s:e]**2)):.3f}", flush=True)
|
||||
print("TAILFIX_DONE", flush=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Letzter Schliff: lsd6 + ref18, temp 0.70 vs 0.90 auf ALLEN Längen (inkl. lang)."""
|
||||
import os, time, numpy as np, soundfile as sf, librosa
|
||||
from pocket_tts import TTSModel
|
||||
|
||||
BASE = r"F:\Coding Stuff\mission-control-2\client\lucy-tts"
|
||||
OUT = r"C:\Users\TobisPC\Desktop\lucy_temp_final"; os.makedirs(OUT, exist_ok=True)
|
||||
src, _ = librosa.load(os.path.join(BASE, "ref.mp3"), sr=24000, mono=True)
|
||||
srt, _ = librosa.effects.trim(src, top_db=30); ref = os.path.join(BASE, "ref.wav")
|
||||
sf.write(ref, srt[:int(18*24000)], 24000)
|
||||
|
||||
SENT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
def cleanup_v2(a, sr):
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
if a.size == 0: return a
|
||||
peak = float(np.max(np.abs(a)))
|
||||
if peak > 0: a = a*(0.95/peak)
|
||||
yt,_ = librosa.effects.trim(a, top_db=45); a = yt if yt.size else a
|
||||
fi = min(int(0.01*sr), a.size//2)
|
||||
if fi>0:
|
||||
a[:fi]*=np.linspace(0.,1.,fi,dtype=np.float32); a[-fi:]*=np.linspace(1.,0.,fi,dtype=np.float32)
|
||||
pad = np.zeros(int(0.09*sr),dtype=np.float32)
|
||||
return np.concatenate([pad,a,pad])
|
||||
|
||||
for temp in [0.70, 0.90]:
|
||||
m = TTSModel.load_model(language="german_24l", lsd_decode_steps=6, temp=temp)
|
||||
vs = m.get_state_for_audio_prompt(ref); sr = m.sample_rate
|
||||
tag = f"t{int(temp*100):03d}"
|
||||
print(f"== temp={temp} ==", flush=True)
|
||||
for name, text in SENT.items():
|
||||
a = m.generate_audio(vs, text, frames_after_eos=4)
|
||||
a = a.numpy() if hasattr(a,"numpy") else np.asarray(a)
|
||||
a = cleanup_v2(a, sr)
|
||||
sf.write(os.path.join(OUT, f"{tag}_{name}.wav"), a, sr)
|
||||
print(f" {name}", flush=True)
|
||||
print("TEMP_FINAL_DONE", flush=True)
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""umlaut_test.py — prüft den Umlaut-Normalizer.
|
||||
A) Logik: bekannte ASCII-Umlaut-Wörter -> Umlaut, normale Wörter (neue/aktuell/Steuer) UNVERÄNDERT.
|
||||
B) Audio: erzeugt vorher (roh ASCII) vs nachher (mit Fix) auf den Desktop zum Reinhören.
|
||||
"""
|
||||
import os, time
|
||||
import numpy as np, soundfile as sf, librosa
|
||||
import pocket_server as ps
|
||||
|
||||
DESK = os.path.join(os.path.expanduser("~"), "Desktop", "lucy_samples")
|
||||
os.makedirs(DESK, exist_ok=True)
|
||||
|
||||
# --- A) Logik ---
|
||||
POS = {"ueber": "über", "Schoen": "Schön", "fuer": "für", "maerz": "März",
|
||||
"natuerlich": "natürlich", "Groesse": "Größe", "moeglich": "möglich", "muessen": "müssen"}
|
||||
NEG = ["neue", "aktuell", "Steuer", "Quelle", "Feuer", "treue", "Museum", "Dauer", "heute",
|
||||
"Frauen", "genau", "blaue"]
|
||||
ok = True
|
||||
for a, exp in POS.items():
|
||||
got = ps._fix_umlauts(a); good = got == exp; ok &= good
|
||||
print(f"[pos] {a!r:14}-> {got!r:14} erwartet {exp!r:12} {'OK' if good else 'FAIL'}")
|
||||
for w in NEG:
|
||||
got = ps._fix_umlauts(w); good = got == w; ok &= good
|
||||
print(f"[neg] {w!r:14}-> {got!r:14} {'OK (unverändert)' if good else 'FAIL: GEÄNDERT!'}")
|
||||
S = "Ueber fuenf moegliche Gespraeche, natuerlich auch die Groesse."
|
||||
print("SENT roh :", S)
|
||||
print("SENT fix :", ps._fix_umlauts(S))
|
||||
print("LOGIC_OK" if ok else "LOGIC_FAIL")
|
||||
|
||||
# --- B) Audio vorher/nachher ---
|
||||
from pocket_tts import TTSModel
|
||||
m = TTSModel.load_model(language=ps.LANG, lsd_decode_steps=ps.LSD, temp=ps.TEMP,
|
||||
noise_clamp=ps.NOISE_CLAMP, quantize=ps.QUANTIZE)
|
||||
ref = ps._prep_ref()
|
||||
try:
|
||||
vs = m.get_state_for_audio_prompt(ps.VOICE_ST)
|
||||
except Exception:
|
||||
vs = m.get_state_for_audio_prompt(ref)
|
||||
ra, _ = librosa.load(ref, sr=m.sample_rate, mono=True)
|
||||
ps.STATE.clear()
|
||||
ps.STATE.update(m=m, vs=vs, sr=m.sample_rate, ref_fp=ps._fingerprint(ra, m.sample_rate))
|
||||
|
||||
def gen(text):
|
||||
return ps.cleanup(ps._gen_gated(text), m.sample_rate)
|
||||
|
||||
sf.write(os.path.join(DESK, "umlaut_vorher.wav"), gen(S), m.sample_rate) # roh ASCII (falsch)
|
||||
sf.write(os.path.join(DESK, "umlaut_nachher.wav"), gen(ps._fix_umlauts(S)), m.sample_rate) # mit Fix
|
||||
print("Samples: umlaut_vorher.wav (roh) + umlaut_nachher.wav (fix) ->", DESK)
|
||||
print("UMLAUT_DONE")
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Verify: fehlen WÖRTER (Content-Cut) oder ist es nur gefühltes abruptes Einsetzen?
|
||||
Whisper-Rücktranskription der v2-Samples + Energie am 1./letzten Audio-Sample."""
|
||||
import os, numpy as np, soundfile as sf
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
DIR = r"C:\Users\TobisPC\Desktop\lucy_lsd_v2"
|
||||
EXPECT = {
|
||||
"kurz": "Hallo Commander, ich höre dich.",
|
||||
"mittel":"Guten Morgen, Commander. Das Backup ist sauber durchgelaufen und es gab keine Fehler.",
|
||||
"lang": "Natürlich kümmere ich mich darum, Commander. Ich starte den Dienst neu, prüfe die Protokolle und melde mich, sobald alles wieder läuft.",
|
||||
}
|
||||
w = WhisperModel("small", device="cpu", compute_type="int8")
|
||||
for lsd in [5, 6]:
|
||||
print(f"=== lsd {lsd} ===")
|
||||
for name in ["kurz", "mittel", "lang"]:
|
||||
p = os.path.join(DIR, f"lsd{lsd}_{name}.wav")
|
||||
a, sr = sf.read(p)
|
||||
a = np.asarray(a, dtype=np.float32).reshape(-1)
|
||||
# Energie der ersten/letzten 100ms NICHT-Pad (überspringe 90ms Pad)
|
||||
pad = int(0.09*sr)
|
||||
core = a[pad:-pad] if a.size > 2*pad else a
|
||||
seg, _ = w.transcribe(p, language="de", beam_size=5)
|
||||
txt = " ".join(s.text for s in seg).strip()
|
||||
print(f"[{name:6}] EXPECT: {EXPECT[name]}")
|
||||
print(f" GOT : {txt}")
|
||||
print(f" dur={a.size/sr:.2f}s core_start_amp={np.abs(core[:5]).max():.3f} core_end_amp={np.abs(core[-5:]).max():.3f}")
|
||||
print("VERIFY_DONE")
|
||||
Reference in New Issue
Block a user