ProCreations commited on
Commit
dc89d1c
·
verified ·
1 Parent(s): 81a5738

Upload folder using huggingface_hub

Browse files
.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
- "Audio is recorded from a quiet room. SPEAKER0 is a warm, friendly "
393
- "tutor in her thirties with a clear, gently enthusiastic voice."}]},
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
- const audioDone = !src || el >= dur;
406
- if (bakedIdx >= sched.length && audioDone && el >= dur) {
 
 
 
407
  live.clearRect(0, 0, S.liveCv.width, S.liveCv.height);
408
- resolve();
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