Spaces:
Running on Zero
Running on Zero
Upload folder using huggingface_hub
Browse files- .gitattributes +1 -0
- engine.py +88 -3
- static/board.js +9 -3
- static/voice/belinda.txt +1 -0
- static/voice/belinda.wav +3 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
static/voice/belinda.wav filter=lfs diff=lfs merge=lfs -text
|
engine.py
CHANGED
|
@@ -336,6 +336,80 @@ def parse_lesson_json(text):
|
|
| 336 |
return None
|
| 337 |
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
def sanitize_step(step, step_index=0):
|
| 340 |
if not isinstance(step, dict):
|
| 341 |
return None
|
|
@@ -384,13 +458,21 @@ def transcribe(audio_path):
|
|
| 384 |
_SPOKEN_CLEAN = re.compile(r"[*_#`<>\[\]{}|\\~^]")
|
| 385 |
|
| 386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
_TTS_SYSTEM = [
|
| 388 |
{"role": "system",
|
| 389 |
"content": [{"type": "text", "text": "Generate audio following instruction."}]},
|
| 390 |
{"role": "scene",
|
| 391 |
-
"content": [{"type": "text", "text":
|
| 392 |
-
|
| 393 |
-
|
| 394 |
]
|
| 395 |
|
| 396 |
|
|
@@ -608,12 +690,14 @@ def run_turn(audio_path, typed_text, board_snapshot, history, profile,
|
|
| 608 |
parser = LessonStreamParser()
|
| 609 |
n_steps = 0
|
| 610 |
full_text = ""
|
|
|
|
| 611 |
for chunk in streamer:
|
| 612 |
full_text += chunk
|
| 613 |
for raw_step in parser.feed(chunk):
|
| 614 |
step = sanitize_step(raw_step, n_steps)
|
| 615 |
if not step:
|
| 616 |
continue
|
|
|
|
| 617 |
audio_b64, dur = (None, None)
|
| 618 |
if voice_on and step["say"]:
|
| 619 |
audio_b64, dur = synthesize(step["say"])
|
|
@@ -635,6 +719,7 @@ def run_turn(audio_path, typed_text, board_snapshot, history, profile,
|
|
| 635 |
step = sanitize_step(raw_step, n_steps)
|
| 636 |
if not step:
|
| 637 |
continue
|
|
|
|
| 638 |
audio_b64, dur = (None, None)
|
| 639 |
if voice_on and step["say"]:
|
| 640 |
audio_b64, dur = synthesize(step["say"])
|
|
|
|
| 336 |
return None
|
| 337 |
|
| 338 |
|
| 339 |
+
# ---- board layout hygiene: the model's spatial reasoning is approximate, ----
|
| 340 |
+
# ---- so clamp out-of-bounds coordinates and nudge overlapping text. ----
|
| 341 |
+
|
| 342 |
+
_TEXT_H = {"s": 4.0, "m": 5.5, "l": 7.0, "xl": 9.0}
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def _clamp_pt(pt, lo_x=2, hi_x=98, lo_y=3, hi_y=72):
|
| 346 |
+
try:
|
| 347 |
+
return [min(max(float(pt[0]), lo_x), hi_x), min(max(float(pt[1]), lo_y), hi_y)]
|
| 348 |
+
except Exception:
|
| 349 |
+
return [50, 38]
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _op_bbox(op):
|
| 353 |
+
"""Rough bounding box (x1, y1, x2, y2) for collision checks; None = skip."""
|
| 354 |
+
kind = op.get("op")
|
| 355 |
+
if kind == "title":
|
| 356 |
+
return (22, 2, 78, 14)
|
| 357 |
+
if kind in ("text", "note"):
|
| 358 |
+
x, y = op.get("at", [10, 20])
|
| 359 |
+
size = op.get("size", "m" if kind == "text" else "s")
|
| 360 |
+
w = min(len(str(op.get("text", ""))) * {"s": 1.5, "m": 2.0, "l": 2.7, "xl": 3.6}.get(size, 2.0), 52)
|
| 361 |
+
lines = max(1, int(w // 52) + 1)
|
| 362 |
+
h = _TEXT_H.get(size, 5.5) * lines
|
| 363 |
+
if op.get("align") == "center":
|
| 364 |
+
x -= w / 2
|
| 365 |
+
return (x, y - h / 2, x + w, y + h / 2)
|
| 366 |
+
if kind in ("box", "ellipse"):
|
| 367 |
+
x, y = op.get("at", [10, 20])
|
| 368 |
+
return (x, y, x + float(op.get("w", 20) or 20), y + float(op.get("h", 10) or 10))
|
| 369 |
+
return None
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def _overlaps(a, b):
|
| 373 |
+
return a[0] < b[2] - 1 and a[2] > b[0] + 1 and a[1] < b[3] - 1 and a[3] > b[1] + 1
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def layout_pass(board, placed):
|
| 377 |
+
"""Clamp coordinates into the visible board; move colliding text down/over."""
|
| 378 |
+
out = []
|
| 379 |
+
for op in board:
|
| 380 |
+
op = dict(op)
|
| 381 |
+
kind = op.get("op")
|
| 382 |
+
try:
|
| 383 |
+
if "at" in op:
|
| 384 |
+
op["at"] = _clamp_pt(op["at"])
|
| 385 |
+
if "from" in op:
|
| 386 |
+
op["from"] = _clamp_pt(op["from"])
|
| 387 |
+
if "to" in op:
|
| 388 |
+
op["to"] = _clamp_pt(op["to"])
|
| 389 |
+
if "points" in op and isinstance(op["points"], list):
|
| 390 |
+
op["points"] = [_clamp_pt(p) for p in op["points"][:12]]
|
| 391 |
+
if kind in ("box", "ellipse", "axes"):
|
| 392 |
+
x, y = op.get("at", [10, 20])
|
| 393 |
+
op["w"] = min(float(op.get("w", 20) or 20), 96 - x)
|
| 394 |
+
op["h"] = min(float(op.get("h", 10) or 10), 71 - y)
|
| 395 |
+
if kind in ("text", "note"): # nudge colliding labels into free space
|
| 396 |
+
bbox = _op_bbox(op)
|
| 397 |
+
for _ in range(8):
|
| 398 |
+
if bbox is None or not any(_overlaps(bbox, p) for p in placed):
|
| 399 |
+
break
|
| 400 |
+
op["at"] = [op["at"][0], op["at"][1] + 5]
|
| 401 |
+
if op["at"][1] > 71:
|
| 402 |
+
op["at"] = [min(op["at"][0] + 18, 96), 8]
|
| 403 |
+
bbox = _op_bbox(op)
|
| 404 |
+
bbox = _op_bbox(op)
|
| 405 |
+
if bbox:
|
| 406 |
+
placed.append(bbox)
|
| 407 |
+
except Exception:
|
| 408 |
+
pass
|
| 409 |
+
out.append(op)
|
| 410 |
+
return out
|
| 411 |
+
|
| 412 |
+
|
| 413 |
def sanitize_step(step, step_index=0):
|
| 414 |
if not isinstance(step, dict):
|
| 415 |
return None
|
|
|
|
| 458 |
_SPOKEN_CLEAN = re.compile(r"[*_#`<>\[\]{}|\\~^]")
|
| 459 |
|
| 460 |
|
| 461 |
+
# Zero-shot voice cloning: a fixed reference clip + exact transcript pins
|
| 462 |
+
# Tutori's voice — without it, Higgs samples a fresh "smart voice" per step
|
| 463 |
+
# and the voice drifts mid-lesson. Swap the wav+txt pair to change the voice.
|
| 464 |
+
_VOICE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "voice")
|
| 465 |
+
_VOICE_REF_WAV = os.path.join(_VOICE_DIR, "belinda.wav")
|
| 466 |
+
with open(os.path.join(_VOICE_DIR, "belinda.txt")) as _f:
|
| 467 |
+
_VOICE_REF_TEXT = _f.read().strip()
|
| 468 |
+
|
| 469 |
_TTS_SYSTEM = [
|
| 470 |
{"role": "system",
|
| 471 |
"content": [{"type": "text", "text": "Generate audio following instruction."}]},
|
| 472 |
{"role": "scene",
|
| 473 |
+
"content": [{"type": "text", "text": "Audio is recorded from a quiet room."}]},
|
| 474 |
+
{"role": "user", "content": [{"type": "text", "text": _VOICE_REF_TEXT}]},
|
| 475 |
+
{"role": "assistant", "content": [{"type": "audio", "path": _VOICE_REF_WAV}]},
|
| 476 |
]
|
| 477 |
|
| 478 |
|
|
|
|
| 690 |
parser = LessonStreamParser()
|
| 691 |
n_steps = 0
|
| 692 |
full_text = ""
|
| 693 |
+
placed = [] # bboxes drawn so far this turn, for collision nudging
|
| 694 |
for chunk in streamer:
|
| 695 |
full_text += chunk
|
| 696 |
for raw_step in parser.feed(chunk):
|
| 697 |
step = sanitize_step(raw_step, n_steps)
|
| 698 |
if not step:
|
| 699 |
continue
|
| 700 |
+
step["board"] = layout_pass(step["board"], placed)
|
| 701 |
audio_b64, dur = (None, None)
|
| 702 |
if voice_on and step["say"]:
|
| 703 |
audio_b64, dur = synthesize(step["say"])
|
|
|
|
| 719 |
step = sanitize_step(raw_step, n_steps)
|
| 720 |
if not step:
|
| 721 |
continue
|
| 722 |
+
step["board"] = layout_pass(step["board"], placed)
|
| 723 |
audio_b64, dur = (None, None)
|
| 724 |
if voice_on and step["say"]:
|
| 725 |
audio_b64, dur = synthesize(step["say"])
|
static/board.js
CHANGED
|
@@ -355,6 +355,7 @@
|
|
| 355 |
const ops = (step.board || []).map(o => ({ op: o, seed: (S.opSeedCounter++ * 2654435761) >>> 0 }));
|
| 356 |
let dur = step.dur || Math.max(2.2, String(step.say || "").split(/\s+/).length * 0.36);
|
| 357 |
let src = null;
|
|
|
|
| 358 |
|
| 359 |
if (step.audio && S.voiceOn) {
|
| 360 |
try {
|
|
@@ -366,6 +367,8 @@
|
|
| 366 |
src = actx().createBufferSource();
|
| 367 |
src.buffer = buf;
|
| 368 |
src.connect(actx().destination);
|
|
|
|
|
|
|
| 369 |
}
|
| 370 |
} catch (e) { src = null; }
|
| 371 |
}
|
|
@@ -402,10 +405,13 @@
|
|
| 402 |
renderOp(live, it, (el - it.start) / (it.end - it.start));
|
| 403 |
}
|
| 404 |
}
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
| 407 |
live.clearRect(0, 0, S.liveCv.width, S.liveCv.height);
|
| 408 |
-
|
| 409 |
} else if (S.stopFlag) {
|
| 410 |
try { src && src.stop(); } catch (e) {}
|
| 411 |
// bake the remainder instantly so the board ends complete
|
|
|
|
| 355 |
const ops = (step.board || []).map(o => ({ op: o, seed: (S.opSeedCounter++ * 2654435761) >>> 0 }));
|
| 356 |
let dur = step.dur || Math.max(2.2, String(step.say || "").split(/\s+/).length * 0.36);
|
| 357 |
let src = null;
|
| 358 |
+
let audioEnded = true; // true while there is no source; set false when one starts
|
| 359 |
|
| 360 |
if (step.audio && S.voiceOn) {
|
| 361 |
try {
|
|
|
|
| 367 |
src = actx().createBufferSource();
|
| 368 |
src.buffer = buf;
|
| 369 |
src.connect(actx().destination);
|
| 370 |
+
audioEnded = false;
|
| 371 |
+
src.onended = () => { audioEnded = true; }; // the ONLY reliable end signal
|
| 372 |
}
|
| 373 |
} catch (e) { src = null; }
|
| 374 |
}
|
|
|
|
| 405 |
renderOp(live, it, (el - it.start) / (it.end - it.start));
|
| 406 |
}
|
| 407 |
}
|
| 408 |
+
// wait for the audio's real `ended` event — clock estimates start the
|
| 409 |
+
// next clip while this one's tail is still audible (voices overlap).
|
| 410 |
+
// el > dur + 2 is only a safety net if `ended` never fires.
|
| 411 |
+
const audioDone = audioEnded || el > dur + 2.0;
|
| 412 |
+
if (bakedIdx >= sched.length && audioDone) {
|
| 413 |
live.clearRect(0, 0, S.liveCv.width, S.liveCv.height);
|
| 414 |
+
setTimeout(resolve, 140); // natural breath between steps
|
| 415 |
} else if (S.stopFlag) {
|
| 416 |
try { src && src.stop(); } catch (e) {}
|
| 417 |
// bake the remainder instantly so the board ends complete
|
static/voice/belinda.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Twas the night before my birthday. Hooray! It's almost here! It may not be a holiday, but it's the best day of the year.
|
static/voice/belinda.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f20b6e7eb2f515c78e418bd5b6d0b92dbebb850b732fe74376da9944ccbfc4df
|
| 3 |
+
size 492114
|