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:
Hitonabi
2026-07-02 10:29:33 +02:00
parent aff0105700
commit 09a1c98514
49 changed files with 8231 additions and 0 deletions
+26
View File
@@ -15,3 +15,29 @@ frontend/dist/avatar.vrm
# Env / local
*.env
.DS_Store
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
box_recon*
gemma_swap*
# Lucy-TTS/F5-Experimente: nur Code/Batch committen — venvs, Modelle, Audio, Logs bleiben lokal
client/lucy-tts/ptts-venv/
client/lucy-tts/venv/
client/lucy-tts/llamacpp-vulkan/
client/lucy-tts/llamacpp-vulkan.zip
client/lucy-tts/models/
client/lucy-tts/out_*/
client/lucy-tts/*.wav
client/lucy-tts/*.mp3
client/lucy-tts/*.log
client/lucy-tts/*.safetensors
client/lucy-tts/*.json
client/lucy-f5/.cache/
client/lucy-f5/F5-TTS-ONNX/
client/lucy-f5/onnx_de/
client/lucy-f5/onnx_f32/
client/lucy-f5/vocos-mel-24khz/
client/lucy-f5/*.onnx
client/lucy-f5/*.safetensors
client/lucy-f5/*.wav
client/lucy-f5/*.zip
+268
View File
@@ -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'})")
+298
View File
@@ -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}")
+196
View File
@@ -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)})
+44
View File
@@ -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)
+1
View File
@@ -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
+12
View File
@@ -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
+13
View File
@@ -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
+13
View File
@@ -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
+12
View File
@@ -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
+9
View File
@@ -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
+9
View File
@@ -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
+50
View File
@@ -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'})")
+67
View File
@@ -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")
+109
View File
@@ -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()
+32
View File
@@ -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")
+41
View File
@@ -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)
+40
View File
@@ -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")
+57
View File
@@ -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")
+59
View File
@@ -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")
+44
View File
@@ -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")
+47
View File
@@ -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)")
+60
View File
@@ -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")
+64
View File
@@ -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}")
+38
View File
@@ -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 ===")
+45
View File
@@ -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)
+471
View File
@@ -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 68s 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)})
+47
View File
@@ -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)
+45
View File
@@ -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)
+28
View File
@@ -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}")
+20
View File
@@ -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)
+47
View File
@@ -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 ===")
+105
View File
@@ -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 ~ 68 (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")
+53
View File
@@ -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)
+45
View File
@@ -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)
+28
View File
@@ -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)
+120
View File
@@ -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
+19
View File
@@ -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")
+50
View File
@@ -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")
+79
View File
@@ -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)
+33
View File
@@ -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)
+56
View File
@@ -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)
+61
View File
@@ -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)
+63
View File
@@ -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)
+40
View File
@@ -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)
+49
View File
@@ -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")
+28
View File
@@ -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")