Support user-provided speaker audio with a bundled reference encoder
Browse files- CITATION.cff +1 -1
- README.md +14 -1
- dude_tts/runtime.py +25 -5
- dude_tts/voice.py +57 -0
- infer.py +2 -2
- release.json +8 -2
- requirements.txt +1 -0
- speaker_encoder/config.json +4 -0
- speaker_encoder/model.safetensors +3 -0
CITATION.cff
CHANGED
|
@@ -5,6 +5,6 @@ title: "DuDE: Full-Duplex Conversation TTS"
|
|
| 5 |
authors:
|
| 6 |
- family-names: Chang
|
| 7 |
given-names: Cheng-Kuang
|
| 8 |
-
version: 1.
|
| 9 |
date-released: 2026-09-27
|
| 10 |
url: "https://huggingface.co/penguinfish1688/duplexdataengine"
|
|
|
|
| 5 |
authors:
|
| 6 |
- family-names: Chang
|
| 7 |
given-names: Cheng-Kuang
|
| 8 |
+
version: 1.1.0
|
| 9 |
date-released: 2026-09-27
|
| 10 |
url: "https://huggingface.co/penguinfish1688/duplexdataengine"
|
README.md
CHANGED
|
@@ -199,7 +199,20 @@ and duration are not supported.
|
|
| 199 |
|
| 200 |
## Voice templates
|
| 201 |
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
| Template | Reference audio |
|
| 205 |
| --- | --- |
|
|
|
|
| 199 |
|
| 200 |
## Voice templates
|
| 201 |
|
| 202 |
+
Use your own reference audio for either speaker by passing a file path to
|
| 203 |
+
`--voice-a` and `--voice-b`, or to `voice_a` and `voice_b` in Python:
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
python infer.py --model . --xml dialogue.xml \
|
| 207 |
+
--voice-a speaker_a.wav --voice-b speaker_b.wav --output dialogue.wav
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
Use a clear, single-speaker WAV or FLAC clip between 0.5 and 60 seconds long.
|
| 211 |
+
No transcript is needed. Audio is converted to mono and resampled automatically.
|
| 212 |
+
You can mix a custom reference with an included voice, such as
|
| 213 |
+
`--voice-a speaker_a.wav --voice-b voice_2`.
|
| 214 |
+
|
| 215 |
+
If you do not have reference audio, four example voices are included for convenience:
|
| 216 |
|
| 217 |
| Template | Reference audio |
|
| 218 |
| --- | --- |
|
dude_tts/runtime.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
from contextlib import nullcontext
|
| 3 |
from dataclasses import dataclass
|
| 4 |
import json
|
|
@@ -18,6 +18,7 @@ from .audio import normalize_for_listening
|
|
| 18 |
from .frontend import duplex_prefix
|
| 19 |
from .interleave import EVENTS
|
| 20 |
from .model import DualChannelTTS
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
@dataclass
|
|
@@ -43,7 +44,8 @@ class DuDE:
|
|
| 43 |
root = Path(model_id)
|
| 44 |
if not root.is_dir():
|
| 45 |
root = Path(snapshot_download(model_id, revision=revision,
|
| 46 |
-
allow_patterns=['*.json', '*.txt', '*.safetensors', 'speech_tokenizer/*',
|
|
|
|
| 47 |
return cls(root, device=device)
|
| 48 |
|
| 49 |
def __init__(self, root, *, device='cuda'):
|
|
@@ -57,6 +59,8 @@ class DuDE:
|
|
| 57 |
self.release = json.loads((self.root/'release.json').read_text())
|
| 58 |
self.events = json.loads((self.root/'event-token-map.json').read_text())
|
| 59 |
self.voices = json.loads((self.root/'voices/presets.json').read_text())
|
|
|
|
|
|
|
| 60 |
config = Qwen3TTSConfig.from_pretrained(self.root, local_files_only=True)
|
| 61 |
config.talker_config._attn_implementation = 'sdpa'
|
| 62 |
config.talker_config.code_predictor_config._attn_implementation = 'sdpa'
|
|
@@ -85,6 +89,24 @@ class DuDE:
|
|
| 85 |
def _event(self, event):
|
| 86 |
return [self.events[event]]
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def conditioning(self, xml, voice_a, voice_b):
|
| 89 |
if not isinstance(xml, str) or not xml.strip() or len(xml) > 30000:
|
| 90 |
raise ValueError('Enter a nonempty dialogue XML, up to 30,000 characters.')
|
|
@@ -92,9 +114,7 @@ class DuDE:
|
|
| 92 |
instruct_ids=[], language='English', speaker='Ryan')
|
| 93 |
channels = {}
|
| 94 |
for channel, voice in zip('AB', [voice_a, voice_b]):
|
| 95 |
-
|
| 96 |
-
raise ValueError(f'Unknown voice {voice!r}; choose from {list(self.voices)}')
|
| 97 |
-
channels[channel] = dict(header, voice_embedding=self.voices[voice]['embedding'])
|
| 98 |
record = dict(xml=xml, channels=channels)
|
| 99 |
conditioning = duplex_prefix(record, self.model.config, self._text, self._event)
|
| 100 |
if len(conditioning['text_ids']) > 12000:
|
|
|
|
| 1 |
+
"""XML + two reference voices -> normalized 24 kHz stereo audio."""
|
| 2 |
from contextlib import nullcontext
|
| 3 |
from dataclasses import dataclass
|
| 4 |
import json
|
|
|
|
| 18 |
from .frontend import duplex_prefix
|
| 19 |
from .interleave import EVENTS
|
| 20 |
from .model import DualChannelTTS
|
| 21 |
+
from .voice import ReferenceVoiceEncoder
|
| 22 |
|
| 23 |
|
| 24 |
@dataclass
|
|
|
|
| 44 |
root = Path(model_id)
|
| 45 |
if not root.is_dir():
|
| 46 |
root = Path(snapshot_download(model_id, revision=revision,
|
| 47 |
+
allow_patterns=['*.json', '*.txt', '*.safetensors', 'speech_tokenizer/*',
|
| 48 |
+
'speaker_encoder/*', 'voices/*']))
|
| 49 |
return cls(root, device=device)
|
| 50 |
|
| 51 |
def __init__(self, root, *, device='cuda'):
|
|
|
|
| 59 |
self.release = json.loads((self.root/'release.json').read_text())
|
| 60 |
self.events = json.loads((self.root/'event-token-map.json').read_text())
|
| 61 |
self.voices = json.loads((self.root/'voices/presets.json').read_text())
|
| 62 |
+
self._voice_encoder = None
|
| 63 |
+
self._voice_cache = {}
|
| 64 |
config = Qwen3TTSConfig.from_pretrained(self.root, local_files_only=True)
|
| 65 |
config.talker_config._attn_implementation = 'sdpa'
|
| 66 |
config.talker_config.code_predictor_config._attn_implementation = 'sdpa'
|
|
|
|
| 89 |
def _event(self, event):
|
| 90 |
return [self.events[event]]
|
| 91 |
|
| 92 |
+
def voice_embedding(self, reference):
|
| 93 |
+
"""Accept an included voice name or a path to a single-speaker recording."""
|
| 94 |
+
if isinstance(reference, str) and reference in self.voices:
|
| 95 |
+
return self.voices[reference]['embedding']
|
| 96 |
+
if not isinstance(reference, (str, Path)):
|
| 97 |
+
raise ValueError('A voice must be an audio file path or an included voice name.')
|
| 98 |
+
path = Path(reference).expanduser().resolve()
|
| 99 |
+
if not path.is_file():
|
| 100 |
+
raise ValueError(f'Reference audio {str(reference)!r} does not exist. '
|
| 101 |
+
f'Provide an audio file or choose from {list(self.voices)}.')
|
| 102 |
+
stat = path.stat()
|
| 103 |
+
key = (str(path), stat.st_mtime_ns, stat.st_size)
|
| 104 |
+
if key not in self._voice_cache:
|
| 105 |
+
if self._voice_encoder is None:
|
| 106 |
+
self._voice_encoder = ReferenceVoiceEncoder(self.root/'speaker_encoder', self.device)
|
| 107 |
+
self._voice_cache[key] = self._voice_encoder.encode(path)
|
| 108 |
+
return self._voice_cache[key]
|
| 109 |
+
|
| 110 |
def conditioning(self, xml, voice_a, voice_b):
|
| 111 |
if not isinstance(xml, str) or not xml.strip() or len(xml) > 30000:
|
| 112 |
raise ValueError('Enter a nonempty dialogue XML, up to 30,000 characters.')
|
|
|
|
| 114 |
instruct_ids=[], language='English', speaker='Ryan')
|
| 115 |
channels = {}
|
| 116 |
for channel, voice in zip('AB', [voice_a, voice_b]):
|
| 117 |
+
channels[channel] = dict(header, voice_embedding=self.voice_embedding(voice))
|
|
|
|
|
|
|
| 118 |
record = dict(xml=xml, channels=channels)
|
| 119 |
conditioning = duplex_prefix(record, self.model.config, self._text, self._event)
|
| 120 |
if len(conditioning['text_ids']) > 12000:
|
dude_tts/voice.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extract reference voices with the same frozen encoder used during training."""
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import soundfile as sf
|
| 7 |
+
import torch
|
| 8 |
+
from safetensors.torch import load_file
|
| 9 |
+
from scipy.signal import resample_poly
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def reference_audio(path):
|
| 13 |
+
"""Read a single-speaker clip, mix to mono, and resample to 24 kHz."""
|
| 14 |
+
path = Path(path).expanduser()
|
| 15 |
+
try:
|
| 16 |
+
info = sf.info(path)
|
| 17 |
+
if not .5 <= info.duration <= 60:
|
| 18 |
+
raise ValueError('Use a reference clip between 0.5 and 60 seconds long.')
|
| 19 |
+
wave, rate = sf.read(path, dtype='float32', always_2d=True)
|
| 20 |
+
except (sf.LibsndfileError, OSError) as error:
|
| 21 |
+
raise ValueError(f'Cannot read reference audio {path.name!r}; use a WAV or FLAC file.') from error
|
| 22 |
+
if not np.isfinite(wave).all():
|
| 23 |
+
raise ValueError('Reference audio contains nonfinite samples.')
|
| 24 |
+
wave = wave.mean(axis=1)
|
| 25 |
+
if not np.any(wave):
|
| 26 |
+
raise ValueError('Reference audio is silent; choose a clip containing speech.')
|
| 27 |
+
if rate != 24000:
|
| 28 |
+
wave = resample_poly(wave, 24000, rate)
|
| 29 |
+
return np.asarray(wave, dtype=np.float32)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ReferenceVoiceEncoder:
|
| 33 |
+
def __init__(self, directory, device):
|
| 34 |
+
from qwen_tts.core.models.configuration_qwen3_tts import Qwen3TTSSpeakerEncoderConfig
|
| 35 |
+
from qwen_tts.core.models.modeling_qwen3_tts import Qwen3TTSSpeakerEncoder
|
| 36 |
+
directory = Path(directory)
|
| 37 |
+
if not (directory/'model.safetensors').is_file():
|
| 38 |
+
raise ValueError('The speaker encoder is missing. Download the current complete model bundle.')
|
| 39 |
+
config = Qwen3TTSSpeakerEncoderConfig(**json.loads((directory/'config.json').read_text()))
|
| 40 |
+
self.device = torch.device(device)
|
| 41 |
+
self.dtype = torch.bfloat16 if self.device.type == 'cuda' else torch.float32
|
| 42 |
+
self.model = Qwen3TTSSpeakerEncoder(config)
|
| 43 |
+
self.model.load_state_dict(load_file(str(directory/'model.safetensors')), strict=True)
|
| 44 |
+
self.model.to(device=self.device, dtype=self.dtype).eval()
|
| 45 |
+
self.dimension = config.enc_dim
|
| 46 |
+
|
| 47 |
+
@torch.inference_mode()
|
| 48 |
+
def encode(self, path):
|
| 49 |
+
from qwen_tts.core.models.modeling_qwen3_tts import mel_spectrogram
|
| 50 |
+
wave = reference_audio(path)
|
| 51 |
+
mel = mel_spectrogram(torch.from_numpy(wave)[None], n_fft=1024,
|
| 52 |
+
num_mels=128, sampling_rate=24000, hop_size=256, win_size=1024,
|
| 53 |
+
fmin=0, fmax=12000).transpose(1, 2)
|
| 54 |
+
embedding = self.model(mel.to(self.device, dtype=self.dtype))[0].float().cpu()
|
| 55 |
+
if embedding.shape != (self.dimension,) or not torch.isfinite(embedding).all():
|
| 56 |
+
raise ValueError('Could not extract a valid speaker embedding from the reference clip.')
|
| 57 |
+
return embedding.tolist()
|
infer.py
CHANGED
|
@@ -8,8 +8,8 @@ if __name__ == '__main__':
|
|
| 8 |
p = argparse.ArgumentParser(description=__doc__)
|
| 9 |
p.add_argument('--model', default='penguinfish1688/duplexdataengine')
|
| 10 |
p.add_argument('--xml', required=True, help='Path to a dialogue XML file')
|
| 11 |
-
p.add_argument('--voice-a', default='voice_1')
|
| 12 |
-
p.add_argument('--voice-b', default='voice_2')
|
| 13 |
p.add_argument('--output', default='duplex.wav')
|
| 14 |
p.add_argument('--seed', type=int, default=20260922)
|
| 15 |
p.add_argument('--max-seconds', type=float, default=150.)
|
|
|
|
| 8 |
p = argparse.ArgumentParser(description=__doc__)
|
| 9 |
p.add_argument('--model', default='penguinfish1688/duplexdataengine')
|
| 10 |
p.add_argument('--xml', required=True, help='Path to a dialogue XML file')
|
| 11 |
+
p.add_argument('--voice-a', default='voice_1', help='Speaker A reference audio path or included voice name')
|
| 12 |
+
p.add_argument('--voice-b', default='voice_2', help='Speaker B reference audio path or included voice name')
|
| 13 |
p.add_argument('--output', default='duplex.wav')
|
| 14 |
p.add_argument('--seed', type=int, default=20260922)
|
| 15 |
p.add_argument('--max-seconds', type=float, default=150.)
|
release.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"format": "dude_duplex_v1",
|
| 3 |
-
"version": "1.
|
| 4 |
"author": "Cheng-Kuang Chang",
|
| 5 |
"base_model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
| 6 |
"base_revision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
|
|
@@ -46,5 +46,11 @@
|
|
| 46 |
"source_conversation": "V01_S0220_I00000126"
|
| 47 |
}
|
| 48 |
],
|
| 49 |
-
"license": "cc-by-nc-4.0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"format": "dude_duplex_v1",
|
| 3 |
+
"version": "1.1.0",
|
| 4 |
"author": "Cheng-Kuang Chang",
|
| 5 |
"base_model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
|
| 6 |
"base_revision": "0c0e3051f131929182e2c023b9537f8b1c68adfe",
|
|
|
|
| 46 |
"source_conversation": "V01_S0220_I00000126"
|
| 47 |
}
|
| 48 |
],
|
| 49 |
+
"license": "cc-by-nc-4.0",
|
| 50 |
+
"speaker_encoder": {
|
| 51 |
+
"source_model": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
|
| 52 |
+
"revision": "fd4b254389122332181a7c3db7f27e918eec64e3",
|
| 53 |
+
"weights": "speaker_encoder/model.safetensors",
|
| 54 |
+
"license": "apache-2.0"
|
| 55 |
+
}
|
| 56 |
}
|
requirements.txt
CHANGED
|
@@ -6,4 +6,5 @@ safetensors>=0.5
|
|
| 6 |
huggingface_hub>=0.34,<1
|
| 7 |
numpy>=1.26
|
| 8 |
soundfile>=0.12
|
|
|
|
| 9 |
qwen-tts @ git+https://github.com/QwenLM/Qwen3-TTS.git@022e286b98fbec7e1e916cb940cdf532cd9f488e
|
|
|
|
| 6 |
huggingface_hub>=0.34,<1
|
| 7 |
numpy>=1.26
|
| 8 |
soundfile>=0.12
|
| 9 |
+
scipy>=1.11
|
| 10 |
qwen-tts @ git+https://github.com/QwenLM/Qwen3-TTS.git@022e286b98fbec7e1e916cb940cdf532cd9f488e
|
speaker_encoder/config.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"enc_dim": 2048,
|
| 3 |
+
"sample_rate": 24000
|
| 4 |
+
}
|
speaker_encoder/model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:df60a638e7f4a29331c0af2bd2984ee5b992fee9d5923c776f7e4bdc3dedea48
|
| 3 |
+
size 24010000
|