first tests
This commit is contained in:
1 parent
50ef195c35
commit
203df3b7d6
12 files changed
+4228
No files matched your search
@@ -0,0 +1,560 @@
|
||||
"""Streaming web voice demo for LiquidAI/LFM3-Audio-2B.
|
||||
|
||||
One WebSocket per browser tab. The browser streams mic PCM (float32, native rate) while the
|
||||
talk button is held, and the server encodes and prefills it into the model's KV cache as it
|
||||
arrives (see session.py). On release only the last audio token and the assistant prefix remain,
|
||||
then generation streams text tokens (JSON) and decoded 24 kHz speech (binary float32 PCM) back
|
||||
frame by frame, together with debug events and timings. The conversation stays in the cache
|
||||
between turns.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
from transformers import AutoModelForMultimodalLM, AutoProcessor
|
||||
from transformers.generation.streamers import BaseStreamer
|
||||
|
||||
import tools
|
||||
from session import StreamingSession
|
||||
|
||||
MODEL_ID = os.environ.get("LFM_MODEL", "LiquidAI/LFM3-Audio-2B")
|
||||
IN_RATE = 16_000
|
||||
OUT_RATE = 24_000
|
||||
FRAME_SAMPLES = 1920 # one codec frame = 80 ms at 24 kHz
|
||||
CHUNK_FRAMES = int(os.environ.get("LFM_CHUNK_FRAMES", "1")) # frames per decoded audio chunk sent to the browser
|
||||
MAX_TOOL_ROUNDS = 4
|
||||
MIXED_START = "<|mixed_start|>"
|
||||
|
||||
MODES = {
|
||||
"chat": {"system": "Respond with interleaved text and audio.", "prefill": "<|mixed_start|>"},
|
||||
"asr": {"system": "Perform ASR.", "prefill": None},
|
||||
"tts": {"system": "Perform TTS in English using a female voice.", "prefill": "<|audio_start|>"},
|
||||
# no prefill: the model chooses between <tool_call> and <|mixed_start|>
|
||||
"tools": {"system": tools.SYSTEM_PROMPT, "prefill": None},
|
||||
}
|
||||
|
||||
|
||||
def pick_device() -> tuple[str, torch.dtype]:
|
||||
device = os.environ.get("LFM_DEVICE")
|
||||
if not device:
|
||||
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
|
||||
default_dtype = "float32" if device == "cpu" else "bfloat16"
|
||||
return device, getattr(torch, os.environ.get("LFM_DTYPE", default_dtype))
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self):
|
||||
self.device, self.dtype = pick_device()
|
||||
t0 = time.perf_counter()
|
||||
self.model = AutoModelForMultimodalLM.from_pretrained(
|
||||
MODEL_ID, dtype=self.dtype, device_map=self.device, trust_remote_code=True
|
||||
).eval()
|
||||
self.processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True)
|
||||
# the codec is ~87x realtime on one CPU thread; keeping it on CPU avoids contending with the LM
|
||||
self.codec_device = os.environ.get("LFM_CODEC_DEVICE", "cpu")
|
||||
self.codec = self.processor.audio_tokenizer.to(device=self.codec_device, dtype=torch.float32).eval()
|
||||
self.load_s = time.perf_counter() - t0
|
||||
self.tokenizer = self.processor.tokenizer
|
||||
self.lock = threading.Lock() # one generation at a time across all connections
|
||||
|
||||
def info(self) -> dict:
|
||||
return {
|
||||
"model": MODEL_ID,
|
||||
"device": self.device,
|
||||
"dtype": str(self.dtype).removeprefix("torch."),
|
||||
"codec_device": self.codec_device,
|
||||
"load_s": round(self.load_s, 2),
|
||||
"chunk_frames": CHUNK_FRAMES,
|
||||
"interleaved_n_text": self.model.config.interleaved_n_text,
|
||||
"interleaved_n_audio": self.model.config.interleaved_n_audio,
|
||||
"torch": torch.__version__,
|
||||
}
|
||||
|
||||
def build_inputs(self, conversation: list[dict], clips: list[np.ndarray], prefill: str | None, tool_defs=None):
|
||||
if prefill:
|
||||
conversation = conversation + [{"role": "assistant", "content": prefill}]
|
||||
prompt = self.processor.apply_chat_template(
|
||||
conversation,
|
||||
tokenize=False,
|
||||
continue_final_message=bool(prefill),
|
||||
add_generation_prompt=not prefill,
|
||||
tools=tool_defs,
|
||||
)
|
||||
enc = self.processor(
|
||||
text=prompt,
|
||||
audio=clips or None,
|
||||
sampling_rate=IN_RATE,
|
||||
add_special_tokens=False,
|
||||
return_tensors="pt",
|
||||
)
|
||||
keys = ("input_ids", "attention_mask", "input_features", "feature_attention_mask")
|
||||
inputs = {k: enc[k].to(self.model.device) for k in keys if k in enc}
|
||||
if "input_features" in inputs:
|
||||
inputs["input_features"] = inputs["input_features"].to(self.dtype)
|
||||
return prompt, inputs
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PlainTextStart(Exception):
|
||||
"""The reply opened with plain text where a tool call or speech marker was required."""
|
||||
|
||||
|
||||
class Emitter:
|
||||
"""Thread-safe bridge from the generation thread to the connection's asyncio send queue."""
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop, queue: asyncio.Queue, t0: float):
|
||||
self.loop, self.queue, self.t0 = loop, queue, t0
|
||||
|
||||
def ms(self) -> float:
|
||||
return round((time.perf_counter() - self.t0) * 1000, 1)
|
||||
|
||||
def send(self, item):
|
||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, item)
|
||||
|
||||
|
||||
class TextStream(BaseStreamer):
|
||||
def __init__(self, engine: Engine, emit: Emitter, cancel: threading.Event, stats: dict, first_allowed=None):
|
||||
self.tok, self.emit, self.cancel, self.stats = engine.tokenizer, emit, cancel, stats
|
||||
self.first_allowed = first_allowed # token ids the reply may open with; None allows anything
|
||||
self.special = set(self.tok.all_special_ids)
|
||||
self.ids: list[int] = []
|
||||
self.text = "" # everything generated, tool-call markup included
|
||||
self.visible = "" # what the chat bubble shows
|
||||
self.prompt_seen = False
|
||||
|
||||
def put(self, value):
|
||||
if self.cancel.is_set():
|
||||
raise Cancelled()
|
||||
if not self.prompt_seen: # generate() first hands the streamer the prompt
|
||||
self.prompt_seen = True
|
||||
return
|
||||
tid = int(value.reshape(-1)[-1])
|
||||
if self.first_allowed is not None and not self.ids and tid not in self.first_allowed:
|
||||
raise PlainTextStart()
|
||||
self.first_allowed = None
|
||||
t = self.emit.ms()
|
||||
self.stats.setdefault("first_text_ms", t)
|
||||
self.stats["text_tokens"] = self.stats.get("text_tokens", 0) + 1
|
||||
raw = self.tok.decode([tid], skip_special_tokens=False)
|
||||
delta = ""
|
||||
if tid not in self.special:
|
||||
self.ids.append(tid)
|
||||
full = self.tok.decode(self.ids, skip_special_tokens=True)
|
||||
if not full.endswith("�"): # wait for the rest of a split multi-byte char
|
||||
self.text = full
|
||||
visible = tools.visible_text(full)
|
||||
if visible.startswith(self.visible):
|
||||
delta, self.visible = visible[len(self.visible):], visible
|
||||
self.emit.send({"type": "token", "id": tid, "raw": raw, "delta": delta, "special": tid in self.special, "t_ms": t})
|
||||
|
||||
def end(self):
|
||||
pass
|
||||
|
||||
|
||||
class AudioStream(BaseStreamer):
|
||||
"""Collects speech frames and decodes them incrementally with the codec's streaming cache.
|
||||
|
||||
Decoding runs on its own thread so the codec never stalls the language model's sampling loop.
|
||||
"""
|
||||
|
||||
def __init__(self, engine: Engine, emit: Emitter, cancel: threading.Event, stats: dict):
|
||||
self.engine, self.emit, self.cancel, self.stats = engine, emit, cancel, stats
|
||||
# the cache class lives in the codec's remote-code module
|
||||
codec_module = sys.modules[type(engine.codec).__module__]
|
||||
self.cache = codec_module.LiquidCodecStreamingCache(engine.codec.config)
|
||||
self.frames = 0
|
||||
self.decode_s = 0.0
|
||||
self.max_backlog = 0
|
||||
self.collected: list[np.ndarray] = []
|
||||
self.inbox: queue.SimpleQueue = queue.SimpleQueue()
|
||||
self.worker = threading.Thread(target=self._decode_loop, daemon=True)
|
||||
self.worker.start()
|
||||
|
||||
def put(self, codes):
|
||||
if self.cancel.is_set():
|
||||
raise Cancelled()
|
||||
self.stats.setdefault("first_frame_ms", self.emit.ms())
|
||||
self.inbox.put(codes.detach().to("cpu"))
|
||||
self.frames += 1
|
||||
|
||||
def end(self):
|
||||
self.inbox.put(None)
|
||||
self.worker.join()
|
||||
self.stats["audio_frames"] = self.frames
|
||||
self.stats["codec_decode_ms"] = round(self.decode_s * 1000, 1)
|
||||
self.stats["max_decode_backlog"] = self.max_backlog
|
||||
|
||||
def _decode_loop(self):
|
||||
pending: list[torch.Tensor] = []
|
||||
done = False
|
||||
while not done:
|
||||
item = self.inbox.get()
|
||||
# drain whatever else is ready so a slow decode catches up in one bigger chunk
|
||||
items = [item]
|
||||
while not self.inbox.empty():
|
||||
items.append(self.inbox.get())
|
||||
done = items[-1] is None
|
||||
pending.extend(i for i in items if i is not None)
|
||||
self.max_backlog = max(self.max_backlog, len(pending))
|
||||
if done:
|
||||
if self.cache.started or pending:
|
||||
self._flush(pending, finalize=True)
|
||||
elif len(pending) >= CHUNK_FRAMES:
|
||||
self._flush(pending, finalize=False)
|
||||
pending = []
|
||||
|
||||
@torch.no_grad()
|
||||
def _flush(self, pending: list[torch.Tensor], finalize: bool):
|
||||
n = len(pending)
|
||||
codes = (
|
||||
torch.stack(pending, dim=-1)[None].to(self.engine.codec_device)
|
||||
if n
|
||||
else torch.zeros((1, self.engine.codec.config.n_q, 0), dtype=torch.long)
|
||||
)
|
||||
first_codes = pending[0].tolist() if n else None
|
||||
t = time.perf_counter()
|
||||
pcm = self.engine.codec.decode(codes, streaming_cache=self.cache, finalize=finalize).audio_values[0]
|
||||
self.decode_s += time.perf_counter() - t
|
||||
pcm = pcm.cpu().numpy().astype(np.float32)
|
||||
if not pcm.size:
|
||||
return
|
||||
self.collected.append(pcm)
|
||||
t_ms = self.emit.ms()
|
||||
self.stats.setdefault("first_audio_ms", t_ms)
|
||||
self.emit.send(
|
||||
{"type": "audio_chunk", "frames": n, "samples": int(pcm.size), "total_frames": self.frames,
|
||||
"codes": first_codes, "final": finalize, "t_ms": t_ms}
|
||||
)
|
||||
self.emit.send(pcm.tobytes())
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
engine: Engine | None = None
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def load():
|
||||
global engine
|
||||
engine = Engine()
|
||||
print("model ready:", json.dumps(engine.info()))
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC / "index.html")
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(ws: WebSocket):
|
||||
await ws.accept()
|
||||
loop = asyncio.get_running_loop()
|
||||
out: asyncio.Queue = asyncio.Queue()
|
||||
# every model call for this connection runs on one thread, in arrival order: prefill chunks, then generation
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
def run(fn, *args):
|
||||
return loop.run_in_executor(executor, fn, *args)
|
||||
|
||||
async def sender():
|
||||
while True:
|
||||
item = await out.get()
|
||||
if isinstance(item, (bytes, bytearray)):
|
||||
await ws.send_bytes(item)
|
||||
else:
|
||||
await ws.send_text(json.dumps(item))
|
||||
|
||||
send_task = asyncio.create_task(sender())
|
||||
await out.put({"type": "ready", **engine.info()})
|
||||
|
||||
session = StreamingSession(engine)
|
||||
settings = {"mode": "chat", "max_new_tokens": 1024, "audio_temperature": 1.0, "audio_top_k": 128,
|
||||
"system": "", "voice": "female", "keep_history": True, "force_speech": True}
|
||||
cancel = threading.Event()
|
||||
gen_task: asyncio.Task | None = None
|
||||
recording = False
|
||||
rec: list[np.ndarray] = []
|
||||
rec_rate = IN_RATE
|
||||
mic_inbox: queue.SimpleQueue = queue.SimpleQueue() # chunks waiting to be prefilled
|
||||
speech = {} # prefill stats for the utterance being recorded
|
||||
|
||||
def log(msg, **kw):
|
||||
out.put_nowait({"type": "log", "msg": msg, **kw})
|
||||
|
||||
def voice_mode():
|
||||
return settings["mode"] if settings["mode"] in ("chat", "asr", "tools") else "chat"
|
||||
|
||||
def system_text(mode):
|
||||
if settings["system"].strip():
|
||||
return settings["system"].strip()
|
||||
if mode == "tts":
|
||||
return f"Perform TTS in English using a {settings['voice']} voice."
|
||||
return MODES[mode]["system"]
|
||||
|
||||
# ---- jobs that run on the executor thread ----------------------------------------------------------------
|
||||
|
||||
def open_turn(mode):
|
||||
"""Close the previous turn and open a user turn in the cached context."""
|
||||
sys_txt = system_text(mode)
|
||||
block = engine.processor.apply_chat_template(
|
||||
[{"role": "system", "content": sys_txt}], tools=tools.TOOLS if mode == "tools" else None, tokenize=False
|
||||
)
|
||||
with engine.lock:
|
||||
t = time.perf_counter()
|
||||
info = session.begin_turn((mode, sys_txt), block, settings["keep_history"] and mode != "asr")
|
||||
info["open_ms"] = round((time.perf_counter() - t) * 1000, 1)
|
||||
if "context_reset" in info:
|
||||
emit_now({"type": "log", "msg": f"context reset ({info['context_reset']})"})
|
||||
speech.clear()
|
||||
speech.update(info, prefill_calls=0, prefill_busy_ms=0.0)
|
||||
|
||||
def feed_pending(final=False):
|
||||
chunks = []
|
||||
while not mic_inbox.empty():
|
||||
chunks.append(mic_inbox.get())
|
||||
if not chunks and not final:
|
||||
return
|
||||
pcm = np.concatenate(chunks) if chunks else np.zeros(0, np.float32)
|
||||
t = time.perf_counter()
|
||||
with engine.lock:
|
||||
session.feed_audio(pcm, rec_rate, final=final)
|
||||
ms = (time.perf_counter() - t) * 1000
|
||||
if final:
|
||||
speech["flush_ms"] = round(ms, 1)
|
||||
else:
|
||||
speech["prefill_calls"] += 1
|
||||
speech["prefill_busy_ms"] += ms
|
||||
|
||||
def emit_now(item):
|
||||
loop.call_soon_threadsafe(out.put_nowait, item)
|
||||
|
||||
def run_round(gen, prompt_text, prompt_tokens, mode, emit, round_no, first_allowed=None):
|
||||
stats: dict = {"prompt_tokens": prompt_tokens}
|
||||
emit.send({"type": "prompt", "text": prompt_text, "tokens": prompt_tokens, "round": round_no, "t_ms": emit.ms()})
|
||||
text_stream = TextStream(engine, emit, cancel, stats, first_allowed)
|
||||
audio_stream = AudioStream(engine, emit, cancel, stats) if mode != "asr" else None
|
||||
kwargs = dict(
|
||||
max_new_tokens=int(settings["max_new_tokens"]),
|
||||
audio_temperature=float(settings["audio_temperature"]),
|
||||
audio_top_k=int(settings["audio_top_k"]),
|
||||
streamer=text_stream,
|
||||
audio_streamer=audio_stream,
|
||||
)
|
||||
if mode == "asr":
|
||||
kwargs.update(generation_mode="text")
|
||||
stats["cancelled"] = False
|
||||
with engine.lock, torch.inference_mode():
|
||||
gen_t0 = time.perf_counter()
|
||||
try:
|
||||
gen(**kwargs)
|
||||
except Cancelled:
|
||||
stats["cancelled"] = True
|
||||
except PlainTextStart:
|
||||
stats["plain_start"] = True
|
||||
stats["gen_ms"] = round((time.perf_counter() - gen_t0) * 1000, 1)
|
||||
audio_s = sum(c.size for c in audio_stream.collected) / OUT_RATE if audio_stream else 0.0
|
||||
stats["audio_out_s"] = round(audio_s, 2)
|
||||
return text_stream, stats
|
||||
|
||||
def session_round(suffix, mode, emit, round_no, first_allowed=None):
|
||||
cached = session.length
|
||||
n = len(engine.tokenizer(suffix, add_special_tokens=False).input_ids)
|
||||
shown = f"[{cached} cached tokens]" + suffix
|
||||
return run_round(lambda **kw: session.generate(suffix, **kw), shown, cached + n, mode, emit, round_no, first_allowed)
|
||||
|
||||
def run_session_turn(user_text, mode, t0, user_meta):
|
||||
"""Generate the reply to the open user turn; in tools mode, run calls and continue until the model answers."""
|
||||
emit = Emitter(loop, out, t0)
|
||||
if user_text is None: # spoken: flush the encoder's last token
|
||||
feed_pending(final=True)
|
||||
open_suffix = (user_text or "") + "<|im_end|>\n<|im_start|>assistant\n"
|
||||
prefill = MODES[mode]["prefill"] or ""
|
||||
force_speech = mode == "tools" and settings["force_speech"]
|
||||
speech_markers = {engine.tokenizer.convert_tokens_to_ids(t) for t in ("<tool_call>", "<|mixed_start|>", "<|audio_start|>")}
|
||||
rounds: list[dict] = []
|
||||
suffix = open_suffix + prefill
|
||||
try:
|
||||
for round_no in range(MAX_TOOL_ROUNDS + 1):
|
||||
# the model sometimes answers in plain text only; force speech by prefilling the interleaved marker
|
||||
# after a tool result, and by restarting a first round that opens with plain text
|
||||
if force_speech and round_no:
|
||||
suffix += MIXED_START
|
||||
guard = speech_markers if force_speech and not round_no else None
|
||||
snap = session.snapshot()
|
||||
text_stream, r = session_round(suffix, mode, emit, round_no, guard)
|
||||
if r.get("plain_start"):
|
||||
emit.send({"type": "log", "msg": f"round {round_no} opened with plain text; restarting with speech"})
|
||||
rounds.append(r)
|
||||
session.restore(snap)
|
||||
text_stream, r = session_round(suffix + MIXED_START, mode, emit, round_no)
|
||||
rounds.append(r)
|
||||
if r["cancelled"]:
|
||||
break
|
||||
calls = tools.parse_tool_calls(text_stream.text) if mode == "tools" else []
|
||||
if not calls or round_no == MAX_TOOL_ROUNDS:
|
||||
break
|
||||
results = []
|
||||
for call in calls:
|
||||
fn = call["function"]
|
||||
emit.send({"type": "tool_call", "name": fn["name"], "arguments": fn["arguments"], "round": round_no,
|
||||
"t_ms": emit.ms()})
|
||||
t = time.perf_counter()
|
||||
result = tools.run_tool(fn["name"], fn["arguments"],
|
||||
lambda action, value: emit.send({"type": "ui", "action": action, "value": value}))
|
||||
results.append(result)
|
||||
emit.send({"type": "tool_result", "name": fn["name"], "result": result,
|
||||
"ms": round((time.perf_counter() - t) * 1000, 1), "t_ms": emit.ms()})
|
||||
suffix = (session.close_prefix() + "<|im_start|>tool\n<tool_response>\n" + json.dumps(results)
|
||||
+ "\n</tool_response><|im_end|>\n<|im_start|>assistant\n")
|
||||
except BaseException:
|
||||
session.restore(session.turn_snapshot)
|
||||
raise
|
||||
if rounds[-1]["cancelled"]:
|
||||
session.restore(session.turn_snapshot) # a cancelled turn leaves no trace in the context
|
||||
stats = summarize(rounds, mode, emit, user_meta)
|
||||
stats.update({k: (round(v, 1) if isinstance(v, float) else v) for k, v in speech.items()},
|
||||
context_tokens_after=session.length)
|
||||
return text_stream.visible, stats
|
||||
|
||||
def run_tts(text, t0):
|
||||
emit = Emitter(loop, out, t0)
|
||||
conv = [{"role": "system", "content": system_text("tts")}, {"role": "user", "content": text}]
|
||||
prompt, inputs = engine.build_inputs(conv, [], MODES["tts"]["prefill"])
|
||||
text_stream, r = run_round(lambda **kw: engine.model.generate(**inputs, return_dict_in_generate=True, **kw),
|
||||
prompt, int(inputs["input_ids"].shape[1]), "tts", emit, 0)
|
||||
return text_stream.visible, summarize([r], "tts", emit, {})
|
||||
|
||||
# ---- event-loop side -------------------------------------------------------------------------------------
|
||||
|
||||
async def generate(job, *args):
|
||||
cancel.clear()
|
||||
try:
|
||||
text, stats = await run(job, *args)
|
||||
await out.put({"type": "done", "text": text, "stats": stats})
|
||||
except Exception as e: # surface model errors in the UI instead of killing the socket
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
await out.put({"type": "error", "msg": f"{type(e).__name__}: {e}"})
|
||||
|
||||
try:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
if msg.get("bytes") is not None:
|
||||
if recording:
|
||||
chunk = np.frombuffer(msg["bytes"], dtype=np.float32)
|
||||
rec.append(chunk)
|
||||
mic_inbox.put(chunk)
|
||||
run(feed_pending) # queued behind open_turn; drains whatever has arrived by then
|
||||
continue
|
||||
data = json.loads(msg["text"])
|
||||
kind = data.get("type")
|
||||
busy = gen_task is not None and not gen_task.done()
|
||||
|
||||
if kind == "settings":
|
||||
settings.update({k: v for k, v in data.items() if k in settings})
|
||||
log("settings updated", settings=settings)
|
||||
elif kind == "start":
|
||||
if busy:
|
||||
log("busy: still generating, utterance ignored")
|
||||
continue
|
||||
recording, rec, rec_rate = True, [], int(data.get("rate", IN_RATE))
|
||||
run(open_turn, voice_mode())
|
||||
log(f"recording @ {rec_rate} Hz, prefilling while you speak")
|
||||
elif kind == "stop":
|
||||
if not recording:
|
||||
continue
|
||||
recording = False
|
||||
t0 = time.perf_counter()
|
||||
audio = np.concatenate(rec) if rec else np.zeros(0, np.float32)
|
||||
dur = audio.size / rec_rate
|
||||
rms = float(np.sqrt(np.mean(audio**2))) if audio.size else 0.0
|
||||
peak = float(np.abs(audio).max()) if audio.size else 0.0
|
||||
user_meta = {"user_audio_s": round(dur, 2), "user_rms": round(rms, 4), "user_peak": round(peak, 3)}
|
||||
await out.put({"type": "user_audio", **user_meta})
|
||||
if dur < 0.3:
|
||||
log("utterance too short, ignored")
|
||||
run(lambda: session.restore(session.turn_snapshot))
|
||||
continue
|
||||
gen_task = asyncio.create_task(generate(run_session_turn, None, voice_mode(), t0, user_meta))
|
||||
elif kind == "text":
|
||||
if busy or recording:
|
||||
log("busy")
|
||||
continue
|
||||
t0 = time.perf_counter()
|
||||
run(open_turn, voice_mode())
|
||||
gen_task = asyncio.create_task(generate(run_session_turn, data["text"], voice_mode(), t0, {}))
|
||||
elif kind == "tts":
|
||||
if busy or recording:
|
||||
log("busy")
|
||||
continue
|
||||
gen_task = asyncio.create_task(generate(run_tts, data["text"], time.perf_counter()))
|
||||
elif kind == "cancel":
|
||||
cancel.set()
|
||||
log("cancel requested")
|
||||
elif kind == "reset":
|
||||
run(session.reset)
|
||||
log("context cleared")
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
cancel.set()
|
||||
send_task.cancel()
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
def summarize(rounds: list[dict], mode: str, emit: Emitter, user_meta: dict) -> dict:
|
||||
gen_s = sum(r["gen_ms"] for r in rounds) / 1000
|
||||
audio_s = sum(r["audio_out_s"] for r in rounds)
|
||||
total = lambda key: sum(r.get(key, 0) for r in rounds) # noqa: E731
|
||||
first = lambda key: next((r[key] for r in rounds if key in r), None) # noqa: E731
|
||||
return {
|
||||
"mode": mode, **user_meta,
|
||||
"rounds": len(rounds),
|
||||
"speech_restarts": sum(1 for r in rounds if r.get("plain_start")),
|
||||
"prompt_tokens": rounds[-1]["prompt_tokens"],
|
||||
"first_text_ms": first("first_text_ms"),
|
||||
"first_frame_ms": first("first_frame_ms"),
|
||||
"first_audio_ms": first("first_audio_ms"),
|
||||
"text_tokens": total("text_tokens"),
|
||||
"audio_frames": total("audio_frames"),
|
||||
"codec_decode_ms": round(total("codec_decode_ms"), 1),
|
||||
"max_decode_backlog": max((r.get("max_decode_backlog", 0) for r in rounds), default=0),
|
||||
"cancelled": rounds[-1]["cancelled"],
|
||||
"gen_ms": round(gen_s * 1000, 1),
|
||||
"total_ms": emit.ms(),
|
||||
"audio_out_s": round(audio_s, 2),
|
||||
"rtf": round(gen_s / audio_s, 3) if audio_s else None,
|
||||
"text_tok_per_s": round(total("text_tokens") / gen_s, 1) if gen_s else None,
|
||||
"frames_per_s": round(total("audio_frames") / gen_s, 1) if gen_s else None,
|
||||
"per_round": rounds,
|
||||
}
|
||||
|
||||
|
||||
def collapse_placeholders(prompt: str) -> str:
|
||||
"""Show runs of audio placeholders as <|audio|>×N so the prompt stays readable."""
|
||||
|
||||
return re.sub(r"((<\|audio(?:_out)?\|>))\1+", lambda m: f"{m.group(2)}×{len(m.group(0)) // len(m.group(2))}", prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host=os.environ.get("HOST", "127.0.0.1"), port=int(os.environ.get("PORT", "8000")))
|
||||
Reference in new issue
Block a user