pocket-tts-onnx / pocket_tts_onnx.py
vlapky's picture
Duplicate from KevinAHM/pocket-tts-onnx
8c230d7
Raw
History Blame Contribute Delete
28.8 kB
"""
PocketTTS ONNX - bundle-aware ONNX inference for Pocket TTS.
"""
import json
import os
import queue
import threading
import time
import wave
from pathlib import Path
from typing import Generator, Optional, Union
import numpy as np
import onnxruntime as ort
import sentencepiece as spm
from huggingface_hub import hf_hub_download
from safetensors import safe_open
try:
import soundfile as sf
HAS_SOUNDFILE = True
except ImportError:
HAS_SOUNDFILE = False
try:
import scipy.signal
HAS_SCIPY = True
except ImportError:
HAS_SCIPY = False
class PocketTTSOnnx:
HF_REPO_ID = "kyutai/pocket-tts"
DEFAULT_LANGUAGE = "english_2026-04"
VALID_PRECISIONS = ("int8", "fp32")
TOKENS_PER_SECOND_ESTIMATE = 3.0
GEN_SECONDS_PADDING = 2.0
def __init__(
self,
models_dir: str = "onnx",
language: str = DEFAULT_LANGUAGE,
tokenizer_path: Optional[str] = None,
precision: str = "int8",
device: str = "auto",
temperature: float = 0.7,
lsd_steps: int = 1,
):
if precision not in self.VALID_PRECISIONS:
raise ValueError(f"precision must be one of {self.VALID_PRECISIONS}, got '{precision}'")
self.models_root = Path(models_dir)
self.language = self._normalize_language(language)
self.bundle_dir = self._resolve_bundle_dir(self.models_root, self.language)
self.metadata = self._load_metadata(self.bundle_dir)
self.precision = precision
self.temperature = temperature
self.lsd_steps = lsd_steps
self.providers = self._get_providers(device)
self.sample_rate = int(self.metadata["sample_rate"])
self.frame_rate = float(self.metadata["frame_rate"])
self.samples_per_frame = int(self.metadata["samples_per_frame"])
self.frame_duration = self.samples_per_frame / self.sample_rate
self.latent_dim = int(self.metadata["latent_dim"])
self.conditioning_dim = int(self.metadata["conditioning_dim"])
self.pad_with_spaces_for_short_inputs = bool(
self.metadata.get("pad_with_spaces_for_short_inputs", False)
)
self.remove_semicolons = bool(self.metadata.get("remove_semicolons", False))
self.model_recommended_frames_after_eos = self.metadata.get(
"model_recommended_frames_after_eos"
)
self.max_token_per_chunk = int(self.metadata.get("max_token_per_chunk", 50))
self.insert_bos_before_voice = bool(self.metadata.get("insert_bos_before_voice", False))
self.predefined_voices = tuple(self.metadata.get("predefined_voices", []))
tokenizer_file = tokenizer_path or str(self.bundle_dir / self.metadata["tokenizer_file"])
self.tokenizer = spm.SentencePieceProcessor()
self.tokenizer.Load(tokenizer_file)
self.bos_before_voice = None
bos_file = self.metadata.get("bos_before_voice_file")
if bos_file:
self.bos_before_voice = np.load(self.bundle_dir / bos_file).astype(np.float32)
self.flow_state_manifest = self.metadata["flow_lm_state_manifest"]
self.mimi_state_manifest = self.metadata["mimi_state_manifest"]
self._load_models()
self._precompute_flow_buffers()
self._voice_cache: dict[str, np.ndarray] = {}
self._voice_state_cache: dict[str, dict[str, np.ndarray]] = {}
@staticmethod
def _normalize_language(language: str) -> str:
if language == "english":
return "english_2026-04"
return language.replace("_2026_", "_2026-")
@staticmethod
def _resolve_bundle_dir(models_root: Path, language: str) -> Path:
candidate = models_root / language
if candidate.is_dir():
return candidate
if (models_root / "bundle.json").exists():
return models_root
raise FileNotFoundError(
f"Could not find ONNX bundle for '{language}' under {models_root}."
)
@staticmethod
def _load_metadata(bundle_dir: Path) -> dict:
metadata_path = bundle_dir / "bundle.json"
if not metadata_path.exists():
raise FileNotFoundError(f"Missing bundle metadata: {metadata_path}")
return json.loads(metadata_path.read_text())
def _get_providers(self, device: str) -> list[str]:
if device == "cpu":
return ["CPUExecutionProvider"]
if device == "cuda":
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
available = ort.get_available_providers()
if "CUDAExecutionProvider" in available:
return ["CUDAExecutionProvider", "CPUExecutionProvider"]
return ["CPUExecutionProvider"]
def _make_session_options(self) -> ort.SessionOptions:
opts = ort.SessionOptions()
opts.intra_op_num_threads = min(os.cpu_count() or 4, 4)
opts.inter_op_num_threads = 1
return opts
def _model_file(self, stem: str) -> str:
if self.precision == "int8":
quantized = self.bundle_dir / f"{stem}_int8.onnx"
if quantized.exists():
return quantized.name
fp32 = self.bundle_dir / f"{stem}.onnx"
if fp32.exists():
return fp32.name
raise FileNotFoundError(f"Missing ONNX file for {stem} in {self.bundle_dir}")
def _load_models(self):
opts = self._make_session_options()
self.mimi_encoder = ort.InferenceSession(
str(self.bundle_dir / "mimi_encoder.onnx"), sess_options=opts, providers=self.providers
)
self.text_conditioner = ort.InferenceSession(
str(self.bundle_dir / "text_conditioner.onnx"),
sess_options=opts,
providers=self.providers,
)
self.flow_lm_main = ort.InferenceSession(
str(self.bundle_dir / self._model_file("flow_lm_main")),
sess_options=opts,
providers=self.providers,
)
self.flow_lm_flow = ort.InferenceSession(
str(self.bundle_dir / self._model_file("flow_lm_flow")),
sess_options=opts,
providers=self.providers,
)
self.mimi_decoder = ort.InferenceSession(
str(self.bundle_dir / self._model_file("mimi_decoder")),
sess_options=opts,
providers=self.providers,
)
def _precompute_flow_buffers(self):
dt = 1.0 / self.lsd_steps
self._st_buffers = []
for j in range(self.lsd_steps):
s = j / self.lsd_steps
t = s + dt
self._st_buffers.append(
(
np.array([[s]], dtype=np.float32),
np.array([[t]], dtype=np.float32),
)
)
@staticmethod
def _numpy_dtype(dtype: str):
return {
"float32": np.float32,
"float16": np.float16,
"int64": np.int64,
"bool": np.bool_,
}[dtype]
def _make_filled_array(self, shape: list[int], dtype, fill: str) -> np.ndarray:
if fill == "nan":
return np.full(shape, np.nan, dtype=dtype)
if fill == "ones":
return np.ones(shape, dtype=dtype)
return np.zeros(shape, dtype=dtype)
def _init_state(self, manifest: list[dict]) -> dict[str, np.ndarray]:
state = {}
for entry in manifest:
dtype = self._numpy_dtype(entry["dtype"])
state[entry["input_name"]] = self._make_filled_array(
entry["shape"], dtype=dtype, fill=entry["fill"]
)
return state
@staticmethod
def _clone_state(state: dict[str, np.ndarray]) -> dict[str, np.ndarray]:
return {key: value.copy() for key, value in state.items()}
def _update_state_from_outputs(
self,
state: dict[str, np.ndarray],
result: list[np.ndarray],
manifest: list[dict],
output_offset: int,
):
for entry in manifest:
state[entry["input_name"]] = result[output_offset + entry["index"]]
def _load_audio(self, path: Union[str, Path]) -> np.ndarray:
path = Path(path)
if path.suffix.lower() == ".wav":
with wave.open(str(path), "rb") as wav_file:
sr = wav_file.getframerate()
raw_data = wav_file.readframes(-1)
audio = np.frombuffer(raw_data, dtype=np.int16).astype(np.float32) / 32768.0
else:
if not HAS_SOUNDFILE:
raise ImportError("soundfile required for non-wav voice cloning inputs.")
audio, sr = sf.read(str(path))
if len(audio.shape) > 1:
audio = audio.mean(axis=1)
audio = audio.astype(np.float32)
if sr != self.sample_rate:
if not HAS_SCIPY:
raise ImportError("scipy required for resampling.")
gcd = np.gcd(int(sr), int(self.sample_rate))
up = int(self.sample_rate // gcd)
down = int(sr // gcd)
audio = scipy.signal.resample_poly(audio, up, down, axis=-1).astype(np.float32)
return audio.reshape(1, 1, -1)
def encode_voice(self, audio_path: Union[str, Path]) -> np.ndarray:
audio = self._load_audio(audio_path)
embeddings = self.mimi_encoder.run(None, {"audio": audio})[0]
while embeddings.ndim > 3:
embeddings = embeddings.squeeze(0)
if embeddings.ndim < 3:
embeddings = embeddings[None]
return embeddings.astype(np.float32, copy=False)
def _prepare_voice_embeddings(self, embeddings: np.ndarray) -> np.ndarray:
embeddings = np.asarray(embeddings, dtype=np.float32)
while embeddings.ndim > 3:
embeddings = embeddings.squeeze(0)
if embeddings.ndim < 3:
embeddings = embeddings.reshape(1, -1, embeddings.shape[-1])
if self.insert_bos_before_voice and self.bos_before_voice is not None:
embeddings = np.concatenate([self.bos_before_voice, embeddings], axis=1)
return embeddings
def _hf_model_state(self, filename: str) -> dict[str, dict[str, np.ndarray]]:
cached = hf_hub_download(repo_id=self.HF_REPO_ID, filename=filename)
result: dict[str, dict[str, np.ndarray]] = {}
with safe_open(cached, framework="np") as handle:
for key in handle.keys():
module_name, tensor_key = key.split("/", 1)
result.setdefault(module_name, {})
result[module_name][tensor_key] = handle.get_tensor(key)
return result
@staticmethod
def _import_model_state_file(source: Union[str, Path]) -> dict[str, dict[str, np.ndarray]]:
result: dict[str, dict[str, np.ndarray]] = {}
with safe_open(str(source), framework="np") as handle:
for key in handle.keys():
module_name, tensor_key = key.split("/", 1)
result.setdefault(module_name, {})
result[module_name][tensor_key] = handle.get_tensor(key)
return result
@staticmethod
def _derive_step(module_state: dict[str, np.ndarray]) -> np.ndarray:
if "step" in module_state:
return np.asarray(module_state["step"], dtype=np.int64).reshape(1)
if "offset" in module_state and "end_offset" not in module_state:
return np.asarray(module_state["offset"], dtype=np.int64).reshape(1)
if "current_end" in module_state:
return np.array([module_state["current_end"].shape[0]], dtype=np.int64)
return np.array([0], dtype=np.int64)
def _adapt_state_tensor(self, source: np.ndarray, entry: dict) -> np.ndarray:
target_shape = tuple(entry["shape"])
target_dtype = self._numpy_dtype(entry["dtype"])
source = np.asarray(source, dtype=target_dtype)
if source.shape == target_shape:
return source.copy()
if source.size == np.prod(target_shape, dtype=np.int64):
return source.reshape(target_shape).copy()
target = self._make_filled_array(list(target_shape), target_dtype, entry["fill"])
if source.ndim != len(target_shape):
return target
slices = tuple(slice(0, min(src, dst)) for src, dst in zip(source.shape, target_shape))
if all(s.start == s.stop for s in slices):
return target
target[slices] = source[slices]
return target
def _state_from_model_state(
self, model_state: dict[str, dict[str, np.ndarray]], manifest: list[dict]
) -> dict[str, np.ndarray]:
state = self._init_state(manifest)
for entry in manifest:
module_state = model_state.get(entry["module"], {})
tensor = module_state.get(entry["key"])
if tensor is None and entry["key"] == "step":
tensor = self._derive_step(module_state)
if tensor is None:
continue
state[entry["input_name"]] = self._adapt_state_tensor(tensor, entry)
return state
def _condition_with_voice_embeddings(self, embeddings: np.ndarray) -> dict[str, np.ndarray]:
voice_embeddings = self._prepare_voice_embeddings(embeddings)
state = self._init_state(self.flow_state_manifest)
empty_seq = np.zeros((1, 0, self.latent_dim), dtype=np.float32)
result = self.flow_lm_main.run(
None,
{"sequence": empty_seq, "text_embeddings": voice_embeddings, **state},
)
self._update_state_from_outputs(state, result, self.flow_state_manifest, output_offset=2)
return state
def prepare_voice_state(self, voice: Union[str, Path, np.ndarray]) -> dict[str, np.ndarray]:
if isinstance(voice, np.ndarray):
return self._condition_with_voice_embeddings(voice)
voice_str = str(voice)
if voice_str in self._voice_state_cache:
return self._clone_state(self._voice_state_cache[voice_str])
if voice_str in self.predefined_voices:
filename = f"languages/{self.language}/embeddings/{voice_str}.safetensors"
model_state = self._hf_model_state(filename)
state = self._state_from_model_state(model_state, self.flow_state_manifest)
self._voice_state_cache[voice_str] = self._clone_state(state)
return state
voice_path = Path(voice_str)
if voice_path.exists() and voice_path.suffix == ".safetensors":
model_state = self._import_model_state_file(voice_path)
return self._state_from_model_state(model_state, self.flow_state_manifest)
if voice_str in self._voice_cache:
embeddings = self._voice_cache[voice_str]
elif voice_path.exists():
embeddings = self.encode_voice(voice_path)
self._voice_cache[voice_str] = embeddings
else:
raise ValueError(f"Voice '{voice}' not found.")
return self._condition_with_voice_embeddings(embeddings)
def _prepare_text_prompt(self, text: str) -> tuple[str, int]:
text = text.strip()
if not text:
raise ValueError("Text cannot be empty")
text = text.replace("\n", " ").replace("\r", " ").replace(" ", " ")
if self.remove_semicolons:
text = text.replace(";", ",")
number_of_words = len(text.split())
frames_after_eos_guess = 3 if number_of_words <= 4 else 1
if not text[0].isupper():
text = text[0].upper() + text[1:]
if text[-1].isalnum():
text = text + "."
if self.pad_with_spaces_for_short_inputs and len(text.split()) < 5:
text = " " * 8 + text
return text, frames_after_eos_guess
def _tokenize(self, text: str) -> np.ndarray:
prepared, _ = self._prepare_text_prompt(text)
token_ids = self.tokenizer.Encode(prepared)
return np.array(token_ids, dtype=np.int64).reshape(1, -1)
@staticmethod
def _find_boundary_indices(tokens: list[int], boundary_tokens: set[int]) -> list[int]:
indices = [0]
previous_was_boundary = False
for index, token in enumerate(tokens):
if token in boundary_tokens:
previous_was_boundary = True
else:
if previous_was_boundary:
indices.append(index)
previous_was_boundary = False
indices.append(len(tokens))
return indices
def _segments_from_boundaries(
self, tokens: list[int], boundary_indices: list[int]
) -> list[tuple[int, str]]:
segments = []
for i in range(len(boundary_indices) - 1):
start = boundary_indices[i]
end = boundary_indices[i + 1]
text = self.tokenizer.Decode(tokens[start:end])
segments.append((end - start, text))
return segments
def _split_into_best_sentences(self, text: str) -> list[str]:
prepared, _ = self._prepare_text_prompt(text)
prepared = prepared.strip()
tokens = self.tokenizer.Encode(prepared)
eos_tokens = set(self.tokenizer.Encode(".!...?")[1:])
boundaries = self._find_boundary_indices(tokens, eos_tokens)
segments = self._segments_from_boundaries(tokens, boundaries)
fallback_tokens = set(self.tokenizer.Encode(",;:")[1:])
refined_segments = []
for count, segment_text in segments:
if count <= self.max_token_per_chunk:
refined_segments.append((count, segment_text))
continue
sub_tokens = self.tokenizer.Encode(segment_text.strip())
sub_boundaries = self._find_boundary_indices(sub_tokens, fallback_tokens)
sub_segments = self._segments_from_boundaries(sub_tokens, sub_boundaries)
if len(sub_segments) > 1:
refined_segments.extend(sub_segments)
else:
refined_segments.append((count, segment_text))
chunks = []
current_chunk = ""
current_count = 0
for count, segment_text in refined_segments:
if not current_chunk:
current_chunk = segment_text
current_count = count
continue
if current_count + count > self.max_token_per_chunk:
chunks.append(current_chunk.strip())
current_chunk = segment_text
current_count = count
else:
current_chunk += " " + segment_text
current_count += count
if current_chunk:
chunks.append(current_chunk.strip())
return chunks
def _estimate_max_gen_len(self, token_count: int) -> int:
gen_len_sec = token_count / self.TOKENS_PER_SECOND_ESTIMATE + self.GEN_SECONDS_PADDING
return int(np.ceil(gen_len_sec * self.frame_rate))
def _run_flow_lm_chunk(
self,
initial_state: dict[str, np.ndarray],
text_ids: np.ndarray,
max_frames: Optional[int],
frames_after_eos: int,
) -> Generator[np.ndarray, None, None]:
state = self._clone_state(initial_state)
text_embeddings = self.text_conditioner.run(None, {"token_ids": text_ids})[0]
if text_embeddings.ndim == 2:
text_embeddings = text_embeddings[None]
empty_seq = np.zeros((1, 0, self.latent_dim), dtype=np.float32)
empty_text = np.zeros((1, 0, self.conditioning_dim), dtype=np.float32)
result = self.flow_lm_main.run(
None,
{"sequence": empty_seq, "text_embeddings": text_embeddings, **state},
)
self._update_state_from_outputs(state, result, self.flow_state_manifest, output_offset=2)
curr = np.full((1, 1, self.latent_dim), np.nan, dtype=np.float32)
eos_step = None
frame_limit = max_frames or self._estimate_max_gen_len(text_ids.shape[1])
dt = 1.0 / self.lsd_steps
for step in range(frame_limit):
result = self.flow_lm_main.run(
None,
{"sequence": curr, "text_embeddings": empty_text, **state},
)
conditioning = result[0]
eos_logit = result[1]
self._update_state_from_outputs(state, result, self.flow_state_manifest, output_offset=2)
if eos_logit[0][0] > -4.0 and eos_step is None:
eos_step = step
if eos_step is not None and step >= eos_step + frames_after_eos:
break
if self.temperature > 0:
std = np.sqrt(self.temperature)
x = np.random.normal(0.0, std, (1, self.latent_dim)).astype(np.float32)
else:
x = np.zeros((1, self.latent_dim), dtype=np.float32)
for s_arr, t_arr in self._st_buffers:
flow = self.flow_lm_flow.run(
None,
{"c": conditioning, "s": s_arr, "t": t_arr, "x": x},
)[0]
x = x + flow * dt
latent = x.reshape(1, 1, self.latent_dim)
yield latent
curr = latent
def generate_latents(
self,
text: str,
voice: Union[str, Path, np.ndarray],
max_frames: Optional[int] = None,
frames_after_eos: Optional[int] = None,
) -> np.ndarray:
base_state = self.prepare_voice_state(voice)
latent_chunks = []
for chunk in self._split_into_best_sentences(text):
_, guess = self._prepare_text_prompt(chunk)
effective_frames = (
frames_after_eos
if frames_after_eos is not None
else (self.model_recommended_frames_after_eos or (guess + 2))
)
text_ids = self._tokenize(chunk)
latent_chunks.extend(
self._run_flow_lm_chunk(base_state, text_ids, max_frames, effective_frames)
)
if not latent_chunks:
return np.zeros((1, 0, self.latent_dim), dtype=np.float32)
return np.concatenate(latent_chunks, axis=1)
def decode_latents(self, latents: np.ndarray, chunk_size: int = 15) -> np.ndarray:
state = self._init_state(self.mimi_state_manifest)
audio_chunks = []
for index in range(0, latents.shape[1], chunk_size):
chunk = latents[:, index : index + chunk_size, :]
result = self.mimi_decoder.run(None, {"latent": chunk, **state})
audio_chunks.append(result[0].reshape(-1))
self._update_state_from_outputs(state, result, self.mimi_state_manifest, output_offset=1)
if not audio_chunks:
return np.zeros((0,), dtype=np.float32)
return np.concatenate(audio_chunks)
def _decode_worker(self, latent_queue: queue.Queue, audio_chunks: list, decode_chunk_size: int = 12):
mimi_state = self._init_state(self.mimi_state_manifest)
buffered = []
decoded = 0
while True:
item = latent_queue.get()
if item is None:
break
buffered.append(item)
if len(buffered) - decoded >= decode_chunk_size:
chunk = np.concatenate(buffered[decoded : decoded + decode_chunk_size], axis=1)
result = self.mimi_decoder.run(None, {"latent": chunk, **mimi_state})
audio_chunks.append(result[0].reshape(-1))
self._update_state_from_outputs(
mimi_state, result, self.mimi_state_manifest, output_offset=1
)
decoded += decode_chunk_size
if decoded < len(buffered):
chunk = np.concatenate(buffered[decoded:], axis=1)
result = self.mimi_decoder.run(None, {"latent": chunk, **mimi_state})
audio_chunks.append(result[0].reshape(-1))
def generate(
self,
text: str,
voice: Union[str, Path, np.ndarray],
max_frames: Optional[int] = None,
frames_after_eos: Optional[int] = None,
) -> np.ndarray:
base_state = self.prepare_voice_state(voice)
full_audio = []
for chunk in self._split_into_best_sentences(text):
_, guess = self._prepare_text_prompt(chunk)
effective_frames = (
frames_after_eos
if frames_after_eos is not None
else (self.model_recommended_frames_after_eos or (guess + 2))
)
text_ids = self._tokenize(chunk)
latent_queue: queue.Queue = queue.Queue()
audio_chunks: list[np.ndarray] = []
decoder = threading.Thread(
target=self._decode_worker,
args=(latent_queue, audio_chunks),
daemon=True,
)
decoder.start()
for latent in self._run_flow_lm_chunk(base_state, text_ids, max_frames, effective_frames):
latent_queue.put(latent)
latent_queue.put(None)
decoder.join()
if audio_chunks:
full_audio.append(np.concatenate(audio_chunks))
if not full_audio:
return np.zeros((0,), dtype=np.float32)
return np.concatenate(full_audio)
def stream(
self,
text: str,
voice: Union[str, Path, np.ndarray],
max_frames: Optional[int] = None,
frames_after_eos: Optional[int] = None,
first_chunk_frames: int = 2,
target_buffer_sec: float = 0.2,
max_chunk_frames: int = 15,
) -> Generator[np.ndarray, None, None]:
base_state = self.prepare_voice_state(voice)
for chunk_text in self._split_into_best_sentences(text):
_, guess = self._prepare_text_prompt(chunk_text)
effective_frames = (
frames_after_eos
if frames_after_eos is not None
else (self.model_recommended_frames_after_eos or (guess + 2))
)
text_ids = self._tokenize(chunk_text)
mimi_state = self._init_state(self.mimi_state_manifest)
generated_latents = []
decoded_frames = 0
playback_start_time = None
start_time = time.time()
for latent in self._run_flow_lm_chunk(base_state, text_ids, max_frames, effective_frames):
generated_latents.append(latent)
pending = len(generated_latents) - decoded_frames
chunk_size = 0
if playback_start_time is None:
if pending >= first_chunk_frames:
chunk_size = first_chunk_frames
else:
elapsed = time.time() - start_time
audio_decoded_sec = decoded_frames * self.frame_duration
playback_elapsed = elapsed - playback_start_time
buffer_sec = audio_decoded_sec - playback_elapsed
if buffer_sec < target_buffer_sec and pending >= 1:
chunk_size = min(pending, 3)
elif pending >= max_chunk_frames:
chunk_size = max_chunk_frames
if chunk_size > 0:
latents_chunk = np.concatenate(
generated_latents[decoded_frames : decoded_frames + chunk_size], axis=1
)
result = self.mimi_decoder.run(None, {"latent": latents_chunk, **mimi_state})
self._update_state_from_outputs(
mimi_state, result, self.mimi_state_manifest, output_offset=1
)
decoded_frames += chunk_size
if playback_start_time is None:
playback_start_time = time.time() - start_time
yield result[0].reshape(-1)
if decoded_frames < len(generated_latents):
latents_chunk = np.concatenate(generated_latents[decoded_frames:], axis=1)
result = self.mimi_decoder.run(None, {"latent": latents_chunk, **mimi_state})
yield result[0].reshape(-1)
def save_audio(self, audio: np.ndarray, path: Union[str, Path]):
if not HAS_SOUNDFILE:
raise ImportError("soundfile required.")
sf.write(str(path), audio, self.sample_rate)
@property
def device(self) -> str:
if "CUDAExecutionProvider" in self.providers:
return "cuda"
return "cpu"
def __repr__(self) -> str:
return (
f"PocketTTSOnnx("
f"language={self.language!r}, "
f"device={self.device!r}, "
f"precision={self.precision!r}, "
f"temperature={self.temperature}, "
f"lsd_steps={self.lsd_steps}, "
f"sample_rate={self.sample_rate})"
)