File size: 8,382 Bytes
49910a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
VoiceCloner — OpenVoice V2 tone-color converter.

Sits downstream of WaxalTTSEngine: takes the base VITS audio and reshapes it
to match a target speaker's tone color.

Usage:
    cloner = VoiceCloner()
    cloner.preload()                          # background thread

    # After WaxalTTS produces (audio_np, sr) …
    se = cloner.extract_se(audio_np, sr)      # extract SE from user's mic audio
    result = cloner.convert(audio_np, sr, se) # returns (cloned_audio, sr) or None

The OpenVoice V2 checkpoint is downloaded from myshell-ai/openvoice-v2 on
HuggingFace Hub at first use (cached in data/openvoice_v2/).

Falls back gracefully (returns None) if openvoice is not installed or the
checkpoint download fails — in that case the caller uses the raw VITS output.
"""
from __future__ import annotations

import logging
import os
import tempfile
import threading
from pathlib import Path
from typing import Optional

import numpy as np

logger = logging.getLogger(__name__)

OV_HF_REPO  = "myshell-ai/openvoice-v2"
OV_CKPT_DIR = Path("data/openvoice_v2")
HF_TOKEN    = os.environ.get("HF_TOKEN")


class VoiceCloner:
    """
    Thin wrapper around OpenVoice V2 ToneColorConverter.

    Thread-safety: convert() holds _lock so parallel calls are serialised;
    the lock is released while waiting for subprocess/IO.
    """

    def __init__(self) -> None:
        self._lock      = threading.Lock()
        self._converter = None
        self._src_se    = None   # cached base-TTS source SE (computed on first convert)
        self._ready     = False
        self._error: Optional[str] = None

    def preload(self) -> None:
        threading.Thread(target=self._load, daemon=True).start()

    def get_status(self) -> str:
        if self._ready:  return "ready"
        if self._error:  return f"error: {self._error}"
        return "loading…"

    # ── Loading ───────────────────────────────────────────────────────────────

    def _load(self) -> None:
        try:
            from openvoice.api import ToneColorConverter   # noqa: F401 — validate import
            OV_CKPT_DIR.mkdir(parents=True, exist_ok=True)

            # Download checkpoint from HF Hub once, then use local cache
            converter_cfg = OV_CKPT_DIR / "converter" / "config.json"
            if not converter_cfg.exists():
                logger.info("VoiceCloner: downloading OpenVoice V2 from HF Hub …")
                from huggingface_hub import snapshot_download
                snapshot_download(
                    repo_id=OV_HF_REPO,
                    local_dir=str(OV_CKPT_DIR),
                    token=HF_TOKEN,
                )

            # Find config — repo layout may vary
            cfg_path = self._find_converter_config()
            if cfg_path is None:
                raise FileNotFoundError(
                    f"converter/config.json not found under {OV_CKPT_DIR}"
                )

            from openvoice.api import ToneColorConverter
            logger.info("VoiceCloner: loading ToneColorConverter from %s …", cfg_path)
            converter = ToneColorConverter(str(cfg_path), device="cpu")
            ckpt = cfg_path.parent / "checkpoint.pth"
            converter.load_ckpt(str(ckpt))

            with self._lock:
                self._converter = converter
                self._ready     = True
            logger.info("VoiceCloner: OpenVoice V2 ready")

        except Exception as exc:
            self._error = str(exc)
            logger.warning(
                "VoiceCloner: load failed — voice cloning disabled: %s", exc
            )

    def _find_converter_config(self) -> Optional[Path]:
        """Probe known checkpoint layouts to locate converter/config.json."""
        candidates = [
            OV_CKPT_DIR / "converter" / "config.json",
            OV_CKPT_DIR / "checkpoints_v2" / "converter" / "config.json",
        ]
        for p in candidates:
            if p.exists():
                return p
        # Walk one level deep as fallback
        for p in OV_CKPT_DIR.rglob("config.json"):
            if p.parent.name == "converter":
                return p
        return None

    # ── SE extraction ─────────────────────────────────────────────────────────

    def extract_se(self, audio_np: np.ndarray, sr: int) -> Optional[np.ndarray]:
        """
        Extract OpenVoice V2 tone-color SE from raw float32 audio.
        Returns a numpy array (shape depends on OV model, typically (1, 256)),
        or None if not ready.
        """
        if not self._ready:
            return None
        try:
            import soundfile as sf
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                tmp = f.name
            sf.write(tmp, audio_np, sr)
            se = self._extract_se_from_file(tmp)
            Path(tmp).unlink(missing_ok=True)
            return se
        except Exception as exc:
            logger.debug("VoiceCloner.extract_se: %s", exc)
            return None

    def _extract_se_from_file(self, audio_path: str) -> Optional[np.ndarray]:
        try:
            from openvoice import se_extractor
            se, _ = se_extractor.get_se(
                audio_path,
                self._converter,
                target_dir=str(OV_CKPT_DIR / "tmp"),
                vad=False,
            )
            arr = se.cpu().numpy() if hasattr(se, "cpu") else np.array(se)
            return arr
        except Exception as exc:
            logger.debug("VoiceCloner._extract_se_from_file: %s", exc)
            return None

    # ── Voice conversion ──────────────────────────────────────────────────────

    def convert(
        self,
        audio_np: np.ndarray,
        sr: int,
        target_se: np.ndarray,
    ) -> Optional[tuple[np.ndarray, int]]:
        """
        Reshape audio to match the target speaker's tone color.

        Args:
            audio_np:  float32 audio from WaxalTTS (base voice).
            sr:        sample rate of audio_np.
            target_se: OpenVoice SE from SpeakerProfileManager (Individual or
                       Collective).

        Returns (cloned_audio_float32, sample_rate) or None if not ready.
        """
        if not self._ready:
            return None
        try:
            import soundfile as sf
            import torch

            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                src_path = f.name
            with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
                out_path = f.name

            sf.write(src_path, audio_np, sr)

            with self._lock:
                # Extract source SE on first call, then cache it for the session
                if self._src_se is None:
                    se = self._extract_se_from_file(src_path)
                    if se is not None:
                        self._src_se = se

                if self._src_se is None:
                    logger.warning("VoiceCloner: could not extract source SE")
                    return None

                src_se_t = torch.tensor(self._src_se)
                tgt_se_t = torch.tensor(target_se)

                # Ensure batch dim matches what the converter expects
                if src_se_t.dim() == 1:
                    src_se_t = src_se_t.unsqueeze(0)
                if tgt_se_t.dim() == 1:
                    tgt_se_t = tgt_se_t.unsqueeze(0)

                self._converter.convert(
                    audio_src_path=src_path,
                    src_se=src_se_t,
                    tgt_se=tgt_se_t,
                    output_path=out_path,
                    message="@MyShell",
                )

            audio_out, out_sr = sf.read(out_path, dtype="float32")
            Path(src_path).unlink(missing_ok=True)
            Path(out_path).unlink(missing_ok=True)
            return audio_out.astype(np.float32), out_sr

        except Exception as exc:
            logger.error("VoiceCloner.convert: %s", exc)
            return None