penguinfish1688 commited on
Commit
ac37044
·
verified ·
1 Parent(s): 1d5a2fc

Support user-provided speaker audio with a bundled reference encoder

Browse files
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.0.0
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
- Four example voices are included for convenience.
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- """Portable XML + two voice presets -> normalized 24 kHz stereo audio."""
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/*', 'voices/*']))
 
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
- if voice not in self.voices:
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.0.0",
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