Source code for speaker_helper.cloning

"""
Voice-cloning defaults and sample resolution.

Module summary
--------------
Cloning a voice means "here is a recording of the voice I want, use it". This
module makes that easy and uniform across the library, CLI, and REST API by
turning a small, permissive *clone configuration* into a concrete list of
:class:`~speaker_helper.types.VoiceSample`.

A ready-to-use default ships with the project: ``assets/ref-malo.wav`` and its
transcript ``assets/ref-malo.txt`` (produced with ``vocal-helper``). So enabling
cloning with no further input clones that reference voice; any field can be
overridden.

The clone configuration is a plain mapping (mirroring ``settings.clone``) so it
travels unchanged through YAML, environment, CLI flags, and JSON request bodies:

* ``name`` — profile name for the clone (idempotency key). Default ``ref-malo``.
* ``audio`` — path to a reference recording. Default the bundled ref-malo file.
* ``reference_text`` — transcript of ``audio``. Default the bundled transcript.
* ``samples`` — an explicit list of ``{audio, reference_text}`` objects; when
  present it takes precedence over the single ``audio``/``reference_text`` pair
  (use it to clone from several recordings).

Usage example
-------------
>>> from speaker_helper.cloning import resolve_samples
>>> samples = resolve_samples({})          # the bundled ref-malo reference
>>> samples[0].reference_text[:11]
'Je m'appell'

Author
------
Warith HARCHAOUI — https://linkedin.com/in/warith-harchaoui
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

from speaker_helper.types import VoiceSample

# The bundled default reference voice. Assets live at the repository root (they
# are development/operational data, not importable code), so we resolve relative
# to the package's parent directory.
_REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CLONE_NAME = "ref-malo"
DEFAULT_CLONE_AUDIO = _REPO_ROOT / "assets" / "ref-malo.wav"
DEFAULT_CLONE_TEXT_FILE = _REPO_ROOT / "assets" / "ref-malo.txt"


[docs] def default_reference_text() -> str: """Return the bundled ref-malo transcript, or ``""`` if it is missing.""" # Best-effort read: a missing/unreadable transcript degrades to empty text # rather than breaking callers that only wanted a sensible default. try: return DEFAULT_CLONE_TEXT_FILE.read_text(encoding="utf-8").strip() except OSError: return ""
[docs] def clone_name(clone: dict[str, Any]) -> str: """Return the profile name for a clone configuration (default ``ref-malo``).""" # An absent or falsy name falls back to the bundled reference's name. return str(clone.get("name") or DEFAULT_CLONE_NAME)
[docs] def resolve_samples(clone: dict[str, Any]) -> list[VoiceSample]: """Turn a clone configuration mapping into concrete voice samples. Parameters ---------- clone : dict The clone configuration (see the module docstring). An empty mapping yields the single bundled ref-malo reference. Returns ------- list of VoiceSample One or more samples ready to upload to a cloning engine. Raises ------ ValueError If an explicit ``samples`` entry lacks an ``audio`` field, or if the resolved default reference audio does not exist on disk. Notes ----- A sample's ``reference_text`` falls back to the bundled default transcript only for the bundled default audio; a custom audio with no transcript keeps an empty string (the engine may reject it — supply the transcript). """ # Preferred path: an explicit multi-sample list wins over the single pair. explicit = clone.get("samples") if explicit: samples: list[VoiceSample] = [] # Each entry must carry audio; transcripts are optional at this stage. for i, item in enumerate(explicit): audio = item.get("audio") if not audio: raise ValueError(f"clone.samples[{i}] is missing 'audio'") samples.append( VoiceSample(audio=audio, reference_text=str(item.get("reference_text", ""))) ) return samples # Single-sample path: use the configured audio, or the bundled default. audio = clone.get("audio") or str(DEFAULT_CLONE_AUDIO) is_default_audio = str(audio) == str(DEFAULT_CLONE_AUDIO) # Only auto-fill the transcript for the bundled audio; a custom recording # with no transcript stays empty (the engine may then require one). reference_text = clone.get("reference_text") if reference_text is None: reference_text = default_reference_text() if is_default_audio else "" # Guard against a missing bundled asset so the failure is explicit. if is_default_audio and not DEFAULT_CLONE_AUDIO.is_file(): raise ValueError( f"default clone reference not found: {DEFAULT_CLONE_AUDIO}. " "Provide clone.audio explicitly or restore the bundled asset." ) return [VoiceSample(audio=str(audio), reference_text=str(reference_text))]
# Cloning engines cap reference-audio length (Voicebox: 30 s). We trim to a # little under that so a longer recording is still usable out of the box. DEFAULT_MAX_REFERENCE_SECONDS = 28.0 def _trim_to_bytes(wav_bytes: bytes, max_seconds: float) -> tuple[bytes, bool]: """Trim audio to ``max_seconds`` if longer, returning ``(bytes, trimmed)``. Uses ``audio-helper`` for duration and slicing (the ecosystem's audio path). Because ``audio-helper`` works on files, the in-memory payload is bridged through an ``os-helper`` temporary folder. """ import audio_helper as ah import os_helper as osh with osh.temporary_folder() as tmp: src = osh.join(tmp, "reference.wav") Path(src).write_bytes(wav_bytes) if ah.get_audio_duration(src) <= max_seconds: return wav_bytes, False clipped = ah.extract_audio_chunk( src, 0.0, max_seconds, output_audio_filename=osh.join(tmp, "clip.wav") ) return Path(clipped).read_bytes(), True
[docs] def prepare_samples( samples: list[VoiceSample], *, language: str = "fr", max_seconds: float = DEFAULT_MAX_REFERENCE_SECONDS, ) -> list[VoiceSample]: """Make samples engine-ready: trim over-long audio and fill transcripts. This is the canonical "add a voice to clone" path — supply only a recording and it just works: * **Trim.** Audio longer than ``max_seconds`` is trimmed to that length so it clears the engine's reference-length cap. Because trimming changes what is said, the transcript is then re-derived from the trimmed audio. * **Transcribe.** Any sample still lacking a transcript is transcribed with ``vocal-helper`` (cached in a ``.txt`` sidecar for file-backed samples). Parameters ---------- samples : list of VoiceSample Reference samples (audio as bytes or a file path). language : str ISO-639-1 language hint passed to ASR. max_seconds : float Maximum reference-audio length; longer audio is trimmed. Returns ------- list of VoiceSample Samples with in-memory audio (bytes) and a matching, non-empty ``reference_text``. Notes ----- ``soundfile`` and ``vocal-helper`` are imported lazily so importing this module stays cheap and ``vocal-helper`` is only required when a transcript must actually be derived. """ from speaker_helper.transcription import ensure_transcript, transcribe_bytes prepared: list[VoiceSample] = [] for s in samples: raw, trimmed = _trim_to_bytes(s.read_bytes(), max_seconds) if trimmed: # Trimming invalidates any supplied transcript, so re-transcribe the # trimmed segment to keep audio and text aligned. text = transcribe_bytes(raw, language=language) prepared.append(VoiceSample(audio=raw, reference_text=text)) continue if s.reference_text.strip(): prepared.append(VoiceSample(audio=raw, reference_text=s.reference_text)) continue # Short audio without a transcript: derive one (cached for file paths). text = ( ensure_transcript(s.audio, language=language) if isinstance(s.audio, str) else transcribe_bytes(raw, language=language) ) prepared.append(VoiceSample(audio=raw, reference_text=text)) return prepared