marcos commited on
Commit ·
df80962
1
Parent(s): e629c2a
Omni: official low-latency first chunk (<500ms) + sdpa + plan doc
Browse filess2s now synthesizes the first N audio frames of the answer with the official
talker (greedy -> deterministic prefix of the full generation, smooth seam)
and emits it as the first chunk; full answer follows. Adds --first-chunk-frames
and --tts-attn-implementation (sdpa). serve.sh uses a 2-token bridge prefix +
3-frame first chunk.
Measured on RTX 3090 (warm): first_audio_chunk_ms 5931 -> ~459ms, first chunk
intelligible. Held-out accuracy 30/30 unaffected (full TTS path unchanged).
Adds CLAUDE.md documenting both systems + the omni integration plan.
- CLAUDE.md +55 -0
- deploy/omni_serve.sh +2 -0
- scripts/qwen35_qwen3tts_voiceclone_smoke.py +8 -5
- scripts/speech_to_speech_server.py +19 -2
CLAUDE.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# gemma1b-tts-integration
|
| 2 |
+
|
| 3 |
+
Speech-to-speech PT-BR omni + tutor de pronúncia. Dois sistemas no mesmo repo.
|
| 4 |
+
|
| 5 |
+
## GPU / pod
|
| 6 |
+
|
| 7 |
+
- Pod vast.ai: `root@83.10.127.251 -p 41506` — **RTX 3090 24GB**.
|
| 8 |
+
- Venvs: `/workspace/qwen3-tts-venv` (Qwen3.5 LLM, whisper, espeak-cv-ft, Qwen3-TTS), `/workspace/qwen3-codec-venv` (codec 12Hz).
|
| 9 |
+
- Repo no pod: `/workspace/gemma1b-tts-integration`. Sync por `rsync -e 'ssh -p 41506'` para os subdirs corretos.
|
| 10 |
+
- Servers persistidos por `deploy/omni_serve.sh start|stop|status|restart` + cron `@reboot` (vast não tem systemd ativo).
|
| 11 |
+
|
| 12 |
+
## Sistema 1 — Omni speech-to-speech
|
| 13 |
+
|
| 14 |
+
Pipeline (3 modelos): `áudio → Wav2Vec2 + projector → Qwen3.5-0.8B → texto → Qwen3-TTS → áudio`.
|
| 15 |
+
|
| 16 |
+
- Bridge (porta 8787): Wav2Vec2 + projector semântico → resposta. `scripts/wav2vec2_qwen_omni_answer_server.py`.
|
| 17 |
+
- S2S (porta 8788): ponte + Qwen3-TTS oficial (voice clone pf_dora). `scripts/speech_to_speech_server.py`.
|
| 18 |
+
- Endpoints: `/v1/speech-to-speech.wav`, `.first.wav`, `.stream.wav`.
|
| 19 |
+
- Lado **entrada é omni de verdade** (embeddings de áudio no LLM, sem ASR-texto). Lado **saída é encadeado** (texto→TTS), não fundido.
|
| 20 |
+
|
| 21 |
+
### Objetivo atual (em andamento)
|
| 22 |
+
|
| 23 |
+
Integrar os 3 modelos como **um modelo só** e provar:
|
| 24 |
+
1. **100 perguntas de áudio respondidas com sentido correto.**
|
| 25 |
+
2. **Latência < 500 ms no primeiro chunk de áudio** (warm).
|
| 26 |
+
|
| 27 |
+
Plano:
|
| 28 |
+
- Baseline honesto: accuracy held-out (30) vs train (100, overfit) + `first_audio_chunk_ms` atual. Tool: `scripts/omni_asr_gate.py`.
|
| 29 |
+
- Latência: carregar Qwen3-TTS com `attn_implementation="sdpa"` (zero-install; modelo suporta sdpa/flash) + first-chunk oficial = gerar primeiros N frames (greedy `tts_temperature=0` ⇒ prefixo exato da geração completa ⇒ emenda suave) e emitir cedo.
|
| 30 |
+
- Validar 100 perguntas: sentido correto + 1º chunk < 500 ms inteligível (gate whisper).
|
| 31 |
+
|
| 32 |
+
Estado conhecido: projector treinado em 130 (held-out 26/30); TTS oficial 29/30 mas ~3 s full; audio_head rápido (491 ms) porém ininteligível (2/30) — descartado. flash-attn não instalado; sdpa é o caminho.
|
| 33 |
+
|
| 34 |
+
### Dados / checkpoints
|
| 35 |
+
|
| 36 |
+
- Eval: `docs/qwen35_ptbr_expanded100_eval.jsonl` (100, = treino), `docs/qwen35_ptbr_general_eval.jsonl` (30, held-out). Áudio em `job_output/omni-train-130/{train,eval}_questions/`.
|
| 37 |
+
- QA manifests: `job_output/omni-train-130/{train,eval}_qa.jsonl` (com `required`/`forbidden`).
|
| 38 |
+
- Projector: `job_output/omni-train-130/projector_answer/`.
|
| 39 |
+
|
| 40 |
+
## Sistema 2 — Tutor de pronúncia (completo)
|
| 41 |
+
|
| 42 |
+
Dois ramos determinísticos → LLM redige. `scripts/pronunciation_tutor.py`. Doc: `docs/gop_logit_bplus.html`.
|
| 43 |
+
|
| 44 |
+
- **Segmental (B+ GOP-logit):** `wav2vec2-xlsr-53-espeak-cv-ft` → logits → `speech_bridge_gemma/ctc_gop.py` (GOP por fonema, cap top-K). Limiar calibrado por `scripts/calibrate_gop.py` (synth pt-br vs voz francesa = interferência L1; 34 fonemas). Thresholds: `docs/gop_thresholds.json`.
|
| 45 |
+
- **Suprasegmental (prosódia):** `speech_bridge_gemma/prosody.py` — F0/energia/duração vs áudio nativo → entonação (corr/range), tônica (pico de proeminência), ritmo (rate, nPVI). Sem treino.
|
| 46 |
+
- Fusão no payload/draft do feedback: `speech_bridge_gemma/qwen_pronunciation_feedback.py`.
|
| 47 |
+
- Validado na 3090: francófono → 4 erros de fonema + entonação monótona + ritmo silábico; nativo → prosódia limpa.
|
| 48 |
+
|
| 49 |
+
Não integrado ao omni (pipelines separados; encoders de áudio diferentes).
|
| 50 |
+
|
| 51 |
+
## Convenções
|
| 52 |
+
|
| 53 |
+
- Código sem comentários, autoexplicativo, solução mais simples (ver `~/.claude/CLAUDE.md`).
|
| 54 |
+
- Git: commit local; push para `origin` e `hf` (Hugging Face) só quando pedido. HF rejeita >10MB sem LFS — usar worktree sobre `hf/main` + LFS para `*.pt`/`*.safetensors`.
|
| 55 |
+
- Latência: 5xx/cache, medir sempre **warm** (cold carrega modelo ~30 s).
|
deploy/omni_serve.sh
CHANGED
|
@@ -36,9 +36,11 @@ start_s2s() {
|
|
| 36 |
--host 127.0.0.1 --port "$S2S_PORT" \
|
| 37 |
--answer-backend bridge_http \
|
| 38 |
--bridge-http-url "http://127.0.0.1:$BRIDGE_PORT/answer-audio" \
|
|
|
|
| 39 |
--tts-backend official --tts-model "$TTS_MODEL" --tts-mode voice_clone \
|
| 40 |
--ref-audio "$REF_AUDIO" --ref-text "$REF_TEXT" \
|
| 41 |
--tts-dtype bfloat16 --tts-text-normalization ptbr_safe --max-answer-tokens 48 \
|
|
|
|
| 42 |
> "$RUN_DIR/s2s.log" 2>&1 < /dev/null &
|
| 43 |
echo $! > "$s2s_pid"
|
| 44 |
echo "s2s started pid=$(cat "$s2s_pid") port=$S2S_PORT"
|
|
|
|
| 36 |
--host 127.0.0.1 --port "$S2S_PORT" \
|
| 37 |
--answer-backend bridge_http \
|
| 38 |
--bridge-http-url "http://127.0.0.1:$BRIDGE_PORT/answer-audio" \
|
| 39 |
+
--bridge-http-first-url "http://127.0.0.1:$BRIDGE_PORT/answer-audio?max_answer_tokens=2" \
|
| 40 |
--tts-backend official --tts-model "$TTS_MODEL" --tts-mode voice_clone \
|
| 41 |
--ref-audio "$REF_AUDIO" --ref-text "$REF_TEXT" \
|
| 42 |
--tts-dtype bfloat16 --tts-text-normalization ptbr_safe --max-answer-tokens 48 \
|
| 43 |
+
--first-chunk-frames 3 --tts-attn-implementation sdpa \
|
| 44 |
> "$RUN_DIR/s2s.log" 2>&1 < /dev/null &
|
| 45 |
echo $! > "$s2s_pid"
|
| 46 |
echo "s2s started pid=$(cat "$s2s_pid") port=$S2S_PORT"
|
scripts/qwen35_qwen3tts_voiceclone_smoke.py
CHANGED
|
@@ -228,11 +228,14 @@ def load_tts(args: argparse.Namespace, device: str):
|
|
| 228 |
Qwen3TTSModel, _ = import_qwen_tts()
|
| 229 |
|
| 230 |
dtype = resolve_dtype(args.tts_dtype, device)
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
|
| 238 |
def build_voice_prompt(model, args: argparse.Namespace):
|
|
|
|
| 228 |
Qwen3TTSModel, _ = import_qwen_tts()
|
| 229 |
|
| 230 |
dtype = resolve_dtype(args.tts_dtype, device)
|
| 231 |
+
base = {"device_map": device if device != "cuda" else "cuda:0", "dtype": dtype}
|
| 232 |
+
attn = getattr(args, "tts_attn_implementation", "sdpa")
|
| 233 |
+
if attn:
|
| 234 |
+
try:
|
| 235 |
+
return Qwen3TTSModel.from_pretrained(args.tts_model, attn_implementation=attn, **base)
|
| 236 |
+
except (TypeError, ValueError):
|
| 237 |
+
pass
|
| 238 |
+
return Qwen3TTSModel.from_pretrained(args.tts_model, **base)
|
| 239 |
|
| 240 |
|
| 241 |
def build_voice_prompt(model, args: argparse.Namespace):
|
scripts/speech_to_speech_server.py
CHANGED
|
@@ -215,9 +215,15 @@ class SpeechToSpeechRuntime:
|
|
| 215 |
wavs = [wav]
|
| 216 |
else:
|
| 217 |
tts_start = time.perf_counter()
|
| 218 |
-
|
| 219 |
-
|
|
|
|
| 220 |
timings["first_audio_chunk_ms"] = round((time.perf_counter() - start) * 1000.0, 3)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
wav_path = out_dir / f"{uuid.uuid4().hex}.wav"
|
| 222 |
self.write_wav(wav_path, wavs[0], sample_rate)
|
| 223 |
audio_bytes = wav_path.read_bytes()
|
|
@@ -253,6 +259,15 @@ class SpeechToSpeechRuntime:
|
|
| 253 |
"llm_error": self.llm_error,
|
| 254 |
}
|
| 255 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
def synthesize_audio_head(
|
| 257 |
self,
|
| 258 |
spoken: str,
|
|
@@ -650,6 +665,8 @@ def parse_args() -> argparse.Namespace:
|
|
| 650 |
parser.add_argument("--bridge-http-first-url", default="")
|
| 651 |
parser.add_argument("--bridge-http-timeout", type=float, default=120.0)
|
| 652 |
parser.add_argument("--tts-backend", choices=["official", "audio_head"], default="official")
|
|
|
|
|
|
|
| 653 |
parser.add_argument("--tts-model", default="")
|
| 654 |
parser.add_argument("--tts-dtype", choices=["auto", "float32", "float16", "bfloat16"], default="bfloat16")
|
| 655 |
parser.add_argument("--tts-mode", choices=["voice_clone", "custom_voice"], default="custom_voice")
|
|
|
|
| 215 |
wavs = [wav]
|
| 216 |
else:
|
| 217 |
tts_start = time.perf_counter()
|
| 218 |
+
first_frames = max(1, int(self.args.first_chunk_frames))
|
| 219 |
+
first_audio_wav, first_audio_sample_rate = self.synthesize_official(spoken, first_frames)
|
| 220 |
+
first_audio_kind = "qwen3_official_first_chunk"
|
| 221 |
timings["first_audio_chunk_ms"] = round((time.perf_counter() - start) * 1000.0, 3)
|
| 222 |
+
if first_chunk_only:
|
| 223 |
+
wavs, sample_rate = [first_audio_wav], first_audio_sample_rate
|
| 224 |
+
else:
|
| 225 |
+
wavs, sample_rate = synthesize_batch(self.tts, [spoken], self.voice_prompt, self.args)
|
| 226 |
+
timings["tts_ms"] = round((time.perf_counter() - tts_start) * 1000.0, 3)
|
| 227 |
wav_path = out_dir / f"{uuid.uuid4().hex}.wav"
|
| 228 |
self.write_wav(wav_path, wavs[0], sample_rate)
|
| 229 |
audio_bytes = wav_path.read_bytes()
|
|
|
|
| 259 |
"llm_error": self.llm_error,
|
| 260 |
}
|
| 261 |
|
| 262 |
+
def synthesize_official(self, spoken: str, frames: int) -> tuple[Any, int]:
|
| 263 |
+
saved = self.args.max_audio_tokens
|
| 264 |
+
self.args.max_audio_tokens = frames
|
| 265 |
+
try:
|
| 266 |
+
wavs, sample_rate = synthesize_batch(self.tts, [spoken], self.voice_prompt, self.args)
|
| 267 |
+
finally:
|
| 268 |
+
self.args.max_audio_tokens = saved
|
| 269 |
+
return wavs[0], sample_rate
|
| 270 |
+
|
| 271 |
def synthesize_audio_head(
|
| 272 |
self,
|
| 273 |
spoken: str,
|
|
|
|
| 665 |
parser.add_argument("--bridge-http-first-url", default="")
|
| 666 |
parser.add_argument("--bridge-http-timeout", type=float, default=120.0)
|
| 667 |
parser.add_argument("--tts-backend", choices=["official", "audio_head"], default="official")
|
| 668 |
+
parser.add_argument("--first-chunk-frames", type=int, default=8)
|
| 669 |
+
parser.add_argument("--tts-attn-implementation", default="sdpa")
|
| 670 |
parser.add_argument("--tts-model", default="")
|
| 671 |
parser.add_argument("--tts-dtype", choices=["auto", "float32", "float16", "bfloat16"], default="bfloat16")
|
| 672 |
parser.add_argument("--tts-mode", choices=["voice_clone", "custom_voice"], default="custom_voice")
|