Signed-off-by: chickeyton <ngton2014@gmail.com> Signed-off-by: Gao Han <hgaoaf@connect.ust.hk> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Gao Han <hgaoaf@connect.ust.hk>
273 lines
12 KiB
Python
273 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Full-duplex use case: talk over the assistant (barge-in).
|
|
|
|
Scenario (see ``barge_in_client_flow.md`` for the diagram)
|
|
---------------------------------------------------------------
|
|
1. The user asks a question (``--question-wav``); the model answers in speech.
|
|
2. Mid-answer, the user starts talking again (``--interrupt-wav``) — a
|
|
follow-up or correction — while assistant audio is still streaming.
|
|
3. The model handles the overlap: depending on the session overlap policy and
|
|
speech length, the in-flight response is barged in (cancelled) or the
|
|
commit is deferred to the turn end, and a new response answers the
|
|
interruption. Either way the user never waits for the first answer.
|
|
|
|
The demo is model-neutral: it drives any duplex model through the public
|
|
:class:`vllm_omni.clients.duplex.DuplexClient` over
|
|
``/v1/realtime?duplex=1``. Model-specific session shape comes from the
|
|
per-model presets under ``vllm_omni.clients.<model>`` selected with
|
|
``--preset`` (default ``minicpmo_4_5``, whose model decides listen/speak on
|
|
its own — no VAD).
|
|
|
|
Run the server first (for MiniCPM-o 4.5: ``bash minicpmo/barge_in_serve.sh``, which
|
|
starts the ``vllm_omni/deploy/minicpmo_4_5.yaml`` profile with ``session_mode: duplex``),
|
|
then:
|
|
|
|
python barge_in_client.py \
|
|
--url ws://127.0.0.1:8099/v1/realtime \
|
|
--model openbmb/MiniCPM-o-4_5 \
|
|
--ref-audio /path/to/reference_voice.wav \
|
|
--question-wav question_16k.wav \
|
|
--interrupt-wav follow_up_16k.wav \
|
|
--output-dir ./duplex_out
|
|
|
|
Input WAVs must be mono 16 kHz PCM16; when a preset's session takes a
|
|
different capture format (the PersonaPlex preset streams 24 kHz float32),
|
|
the audio is converted before streaming. ``--ref-audio`` is required by the
|
|
MiniCPM-o preset (without it the session is rejected with
|
|
``ref_audio_required``). Outputs land in ``--output-dir`` as one WAV per
|
|
response — ``response_1_cancelled.wav`` for a barged-in answer,
|
|
``response_1_completed.wav`` for one that finished — plus ``summary.json``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from vllm_omni.clients.duplex import ( # noqa: E402
|
|
AudioFormat,
|
|
DuplexClient,
|
|
EventCollector,
|
|
SessionConfig,
|
|
acknowledge_collected_playback,
|
|
audio_data_url,
|
|
read_pcm16_wav,
|
|
wait_for_condition,
|
|
write_pcm16_wav,
|
|
)
|
|
|
|
_TRANSCRIPT_DELTA_EVENTS = {
|
|
"response.audio_transcript.delta",
|
|
"response.output_text.delta",
|
|
"response.text.delta",
|
|
}
|
|
|
|
# Input WAVs are mono 16 kHz PCM16; each preset's session may negotiate a
|
|
# different capture format (PersonaPlex takes 24 kHz float32) and a different
|
|
# model-unit-aligned chunk size (PersonaPlex frames are exactly 80 ms).
|
|
_PRESET_DEFAULT_CHUNK_MS = {"minicpmo_4_5": 100, "personaplex": 80, "none": 100}
|
|
|
|
|
|
def _convert_to_session_format(pcm16_16k: bytes, target: AudioFormat) -> bytes:
|
|
"""Convert mono 16 kHz PCM16 to the session's input format."""
|
|
if target.encoding == "pcm16" and target.sample_rate_hz == 16_000:
|
|
return pcm16_16k
|
|
import numpy as np
|
|
|
|
samples = np.frombuffer(pcm16_16k, dtype=np.int16).astype(np.float32) / 32768.0
|
|
if target.sample_rate_hz != 16_000 and len(samples) > 1:
|
|
target_len = round(len(samples) * target.sample_rate_hz / 16_000)
|
|
positions = np.linspace(0.0, len(samples) - 1, num=target_len)
|
|
samples = np.interp(positions, np.arange(len(samples)), samples).astype(np.float32)
|
|
if target.encoding == "pcm_f32le":
|
|
return samples.tobytes()
|
|
if target.encoding == "pcm16":
|
|
return (np.clip(samples, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes()
|
|
raise ValueError(f"Unsupported session input encoding: {target.encoding!r}")
|
|
|
|
|
|
def _session_config(preset: str, *, ref_audio_path: str | None) -> SessionConfig:
|
|
ref_audio = audio_data_url(Path(ref_audio_path).expanduser()) if ref_audio_path else None
|
|
if preset == "minicpmo_4_5":
|
|
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
|
|
|
|
return create_duplex_session_config(ref_audio=ref_audio)
|
|
if preset == "personaplex":
|
|
from vllm_omni.clients.personaplex import create_duplex_session_config
|
|
|
|
return create_duplex_session_config()
|
|
return SessionConfig(ref_audio=ref_audio)
|
|
|
|
|
|
def _fold_responses(collector: EventCollector) -> dict[str, dict[str, object]]:
|
|
"""Fold the event stream into per-response audio, text, and status."""
|
|
out: dict[str, dict[str, object]] = {}
|
|
for response_id in collector.response_ids:
|
|
out[response_id] = {
|
|
"audio": collector.audio_bytes(response_id),
|
|
"text": "",
|
|
"status": "in_progress",
|
|
"sample_rate_hz": collector.output_sample_rate_hz,
|
|
}
|
|
|
|
for event in collector.events:
|
|
etype = event.get("type")
|
|
response_id = collector.response_id(event)
|
|
item = out.get(response_id) if isinstance(response_id, str) else None
|
|
if etype in _TRANSCRIPT_DELTA_EVENTS and item is not None:
|
|
delta = event.get("delta")
|
|
if isinstance(delta, str):
|
|
item["text"] += delta
|
|
elif etype == "response.done" and item is not None:
|
|
response = event.get("response")
|
|
status = response.get("status") if isinstance(response, dict) else None
|
|
if item["status"] == "in_progress":
|
|
item["status"] = "cancelled" if status == "cancelled" else "completed"
|
|
elif etype == "audio.cancelled":
|
|
targets = [item] if item is not None else list(out.values())
|
|
for target in targets:
|
|
if target["status"] == "in_progress":
|
|
target["status"] = "cancelled"
|
|
return out
|
|
|
|
|
|
async def run(args: argparse.Namespace) -> int:
|
|
config = _session_config(args.preset, ref_audio_path=args.ref_audio)
|
|
question = _convert_to_session_format(read_pcm16_wav(Path(args.question_wav)), config.input_audio)
|
|
interrupt = _convert_to_session_format(read_pcm16_wav(Path(args.interrupt_wav)), config.input_audio)
|
|
chunk_ms = args.chunk_ms if args.chunk_ms is not None else _PRESET_DEFAULT_CHUNK_MS.get(args.preset, 100)
|
|
output_dir = Path(args.output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
collector = EventCollector()
|
|
client = DuplexClient(
|
|
args.url,
|
|
model=args.model,
|
|
config=config,
|
|
reconnect=None,
|
|
heartbeat_interval_s=None,
|
|
handshake_timeout_s=args.timeout_s,
|
|
)
|
|
# GREET: open the voice session (handshake happens on enter).
|
|
async with client:
|
|
consume_task = asyncio.create_task(collector.consume(client))
|
|
|
|
# ASK: stream the question at realtime pace, then commit the turn.
|
|
# The model decides on its own to answer (no VAD).
|
|
print("[1] streaming question ...", file=sys.stderr)
|
|
await client.stream_pcm(question, chunk_ms=chunk_ms)
|
|
await client.commit(create_response=False)
|
|
await wait_for_condition(
|
|
lambda: collector.count("response.created") > 0,
|
|
timeout_s=args.timeout_s,
|
|
label="first response.created",
|
|
)
|
|
print("[1] assistant is answering", file=sys.stderr)
|
|
|
|
# Let the answer play for a moment, as a listening user would.
|
|
await asyncio.sleep(args.listen_s)
|
|
|
|
# INTERRUPT: talk over the assistant while its audio still streams.
|
|
# The serving overlap policy decides: enough overlapped speech barges
|
|
# in (the first response is cancelled) or a short remark is deferred
|
|
# until the model yields the turn.
|
|
print("[2] interrupting mid-answer ...", file=sys.stderr)
|
|
ids_before_interrupt = set(collector.response_ids)
|
|
await client.stream_pcm(interrupt, chunk_ms=chunk_ms)
|
|
await client.commit(create_response=False)
|
|
|
|
# ANSWER AGAIN: wait for a response created *after* the interruption
|
|
# to finish. The first answer may still be streaming here; matching on
|
|
# any response.done would let its completion satisfy the wait and cut
|
|
# off the actual follow-up answer at close.
|
|
def follow_up_answer_done() -> bool:
|
|
return any(
|
|
event.get("type") == "response.done"
|
|
and collector.response_id(event) is not None
|
|
and collector.response_id(event) not in ids_before_interrupt
|
|
for event in collector.events
|
|
)
|
|
|
|
await wait_for_condition(
|
|
follow_up_answer_done,
|
|
timeout_s=args.timeout_s,
|
|
label="response.done for the response answering the interruption",
|
|
)
|
|
|
|
# HANG UP: report playback, close the session cleanly.
|
|
await acknowledge_collected_playback(client, collector)
|
|
await client.close(timeout_s=args.timeout_s)
|
|
try:
|
|
await asyncio.wait_for(consume_task, timeout=5.0)
|
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
|
consume_task.cancel()
|
|
|
|
# Report: one WAV per response; statuses show what the overlap did.
|
|
responses = _fold_responses(collector)
|
|
summary: list[dict[str, object]] = []
|
|
for index, (response_id, item) in enumerate(responses.items(), start=1):
|
|
audio = item["audio"]
|
|
assert isinstance(audio, bytes)
|
|
sample_rate_hz = int(item["sample_rate_hz"]) # type: ignore[arg-type]
|
|
wav_path = output_dir / f"response_{index}_{item['status']}.wav"
|
|
if audio:
|
|
write_pcm16_wav(wav_path, audio, sample_rate_hz=sample_rate_hz)
|
|
summary.append(
|
|
{
|
|
"response_id": response_id,
|
|
"status": item["status"],
|
|
"audio_s": round(len(audio) / (sample_rate_hz * 2), 2),
|
|
"text": item["text"],
|
|
"wav": wav_path.name if audio else None,
|
|
}
|
|
)
|
|
(output_dir / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
(output_dir / "events.jsonl").write_text(
|
|
"".join(json.dumps(event, ensure_ascii=False) + "\n" for event in collector.events),
|
|
encoding="utf-8",
|
|
)
|
|
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
cancelled = sum(1 for item in summary if item["status"] == "cancelled")
|
|
print(
|
|
f"\n{len(summary)} responses ({cancelled} interrupted); outputs in {output_dir}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--url", default="ws://127.0.0.1:8099/v1/realtime")
|
|
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
|
|
parser.add_argument(
|
|
"--preset",
|
|
choices=["minicpmo_4_5", "personaplex", "none"],
|
|
default="minicpmo_4_5",
|
|
help="per-model session preset from vllm_omni.clients.<model>",
|
|
)
|
|
parser.add_argument("--ref-audio", help="reference voice WAV (the MiniCPM-o preset requires it)")
|
|
parser.add_argument("--question-wav", required=True, help="mono 16 kHz PCM16 question")
|
|
parser.add_argument("--interrupt-wav", required=True, help="mono 16 kHz PCM16 follow-up spoken mid-answer")
|
|
parser.add_argument("--output-dir", default="./duplex_out")
|
|
parser.add_argument(
|
|
"--chunk-ms",
|
|
type=int,
|
|
default=None,
|
|
help="append chunk size; defaults to the preset's model-unit-aligned size (minicpmo_4_5: 100, personaplex: 80)",
|
|
)
|
|
parser.add_argument("--listen-s", type=float, default=2.0, help="how long to listen before interrupting")
|
|
parser.add_argument("--timeout-s", type=float, default=120.0)
|
|
return asyncio.run(run(parser.parse_args()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|