Add VoiceClonePrompt.save()/load() for cross-session voice reuse#223
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces serialization support for VoiceClonePrompt by adding save and load methods, allowing cloned voices to be reused across sessions. It also updates the documentation and exports VoiceClonePrompt in the package root. The review feedback suggests improving the robustness and usability of these methods by supporting os.PathLike and torch.device types, and adding defensive validation checks on the loaded data structure.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ref_text: str | ||
| ref_rms: float | ||
|
|
||
| def save(self, path: str) -> None: |
There was a problem hiding this comment.
To improve usability and compatibility with standard Python path handling (such as pathlib.Path), consider updating the path parameter type hint to accept Union[str, os.PathLike] instead of just str.
| def save(self, path: str) -> None: | |
| def save(self, path: Union[str, os.PathLike]) -> None: |
| def load(cls, path: str, map_location: str = "cpu") -> "VoiceClonePrompt": | ||
| """Load a prompt saved with :meth:`save`. | ||
|
|
||
| The returned prompt can be passed directly to | ||
| :meth:`OmniVoice.generate`; the audio tokens are moved to the model | ||
| device automatically during generation, so no manual ``.to(device)`` | ||
| is needed. | ||
|
|
||
| Args: | ||
| path: File path previously written by :meth:`save`. | ||
| map_location: Device to load the audio tokens onto. | ||
| Returns: | ||
| The restored :class:`VoiceClonePrompt`. | ||
| """ | ||
| data = torch.load(path, map_location=map_location, weights_only=True) | ||
| version = data.get("format_version") | ||
| if version != _VOICE_CLONE_PROMPT_FORMAT_VERSION: | ||
| raise ValueError(f"Unsupported VoiceClonePrompt format version: {version}") | ||
| return cls( | ||
| ref_audio_tokens=data["ref_audio_tokens"], | ||
| ref_text=data["ref_text"], | ||
| ref_rms=data["ref_rms"], | ||
| ) |
There was a problem hiding this comment.
To make the load method more robust and flexible, we should:
- Update the
pathparameter type hint to acceptUnion[str, os.PathLike]to supportpathlib.Pathobjects. - Update
map_locationto acceptOptional[Union[str, torch.device]]to allow loading directly to a specific PyTorch device object. - Add defensive checks to ensure the loaded data is a dictionary and contains all the required keys, preventing unexpected
AttributeErroror unhandledKeyErrorwhen loading invalid or corrupted files.
@classmethod
def load(
cls,
path: Union[str, os.PathLike],
map_location: Optional[Union[str, torch.device]] = "cpu",
) -> "VoiceClonePrompt":
"""Load a prompt saved with :meth:`save`.
The returned prompt can be passed directly to
:meth:`OmniVoice.generate`; the audio tokens are moved to the model
device automatically during generation, so no manual ``.to(device)``
is needed.
Args:
path: File path previously written by :meth:`save`.
map_location: Device to load the audio tokens onto.
Returns:
The restored :class:`VoiceClonePrompt`.
"""
data = torch.load(path, map_location=map_location, weights_only=True)
if not isinstance(data, dict):
raise ValueError(
f"Invalid VoiceClonePrompt file: expected a dictionary, got {type(data).__name__}"
)
version = data.get("format_version")
if version != _VOICE_CLONE_PROMPT_FORMAT_VERSION:
raise ValueError(f"Unsupported VoiceClonePrompt format version: {version}")
for key in ("ref_audio_tokens", "ref_text", "ref_rms"):
if key not in data:
raise ValueError(f"Invalid VoiceClonePrompt file: missing required key '{key}'")
return cls(
ref_audio_tokens=data["ref_audio_tokens"],
ref_text=data["ref_text"],
ref_rms=data["ref_rms"],
)|
Thanks! Merged |
This adds
VoiceClonePrompt.save(path)andVoiceClonePrompt.load(path)so a cloned voice can be persisted and reused across sessions, plus a short README example. Refs #54, #169, #44.Motivation
In #169 you pointed out that
create_voice_clone_prompt()can be reused acrossgenerate()calls — that works within one session, but the prompt cannot be persisted, so every new session pays the full cost again: audio loading, silence trimming, tokenization, and (whenref_textis omitted) loading Whisper and running ASR. For the batch use case in #169 (1k+ files over multiple runs) this is significant, and because auto-transcription can vary slightly between runs, recomputing also means the prompt is not reproducible. #54's ask for saved voices needs an on-disk format as its first building block.Why an official format instead of "just
torch.savethe object"With torch >= 2.6 (
weights_only=Truebecame thetorch.loaddefault; this repo pins torch 2.8), pickling the dataclass directly produces a file that fails to load by default:So the DIY route pushes users toward
weights_only=False, which is exactly the footgun the torch default is trying to prevent. This PR instead saves a version-tagged plain dict (tensor moved to CPU), which loads cleanly underweights_only=Trueand is portable across devices (CUDA/MPS/CPU).Design notes
generate()behavior._prepare_inference_inputsalready does.to(self.device)when the prompt is consumed.format_versionfield allows the format to evolve without breaking old files.VoiceClonePromptis now exported fromomnivoiceso the README example works with a plain import.Verification (Apple Silicon, MPS, torch 2.8.0)
Session 1 — create, save, round-trip check, generate:
Session 2 — fresh process:
The saved prompt is byte-identical across sessions, and the second session never loads the ASR model at all.
pre-commit run --all-filespasses.