Spaces:
Running on Zero
Running on Zero
File size: 55,440 Bytes
419fe6e 472bb49 d9edf34 419fe6e aea8768 419fe6e aea8768 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e bfecf3d 419fe6e 472bb49 419fe6e bfecf3d 472bb49 bfecf3d 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c d9edf34 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 7c22a8c 33b7a36 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 | """Post-alignment segment subdivision using MFA word timestamps.
Two independent criteria (either or both can apply):
- max verses spanned per segment
- max words per segment
Segments that violate the active criteria are batch-submitted to MFA for
word-level timestamps, then cut at the best word boundary:
1. Verse pass first β split at verse boundaries.
2. Word pass β split at a waqf (stop-sign) if present in preferred order
preferred_stop > optional_stop > preferred_continue (phonemizer-canonical
labels). Tiebreak: word index closest to the middle. If no stop sign
exists at any recursion depth, fall back to an equal-word split.
Recurses until every leaf sub-segment satisfies the active criteria (no cap).
Pure module: no Gradio, no session state. The caller injects an ``mfa_caller``
closure that handles the actual MFA call + audio slicing.
"""
from __future__ import annotations
import copy
import math
import uuid
from dataclasses import replace
from typing import Callable, Optional
from qua_sdk.domain import SPECIAL_NAMES as ALL_SPECIAL_REFS
from config import AUTO_MERGE_GROUP_PREFIX
from src.core.quran_index import get_quran_index
from src.core.segment_types import SegmentInfo
from src.ui.segments import _parse_ref_verse_ranges
from src.ui.waqf import WAQF_MARK_BY_LABEL
# Waqf stop-sign priority for SPLITTING (phonemizer-canonical labels, highest
# first). A deliberate subset of the canonical waqf marks (see src/ui/waqf.py):
# compulsory_stop is never a desirable split point, so it is excluded here.
# Marks come from the shared WAQF_MARK_BY_LABEL map to avoid duplicating the
# Unicode literals. See quranic_phonemizer README Β§Stops (Waqf).
_SPLIT_PRIORITY_LABELS = ("preferred_stop", "optional_stop", "preferred_continue")
STOP_SIGN_PRIORITY = tuple(
(label, WAQF_MARK_BY_LABEL[label]) for label in _SPLIT_PRIORITY_LABELS
)
_MANUAL_SPLIT_SPECIAL_REFS = {"Basmala", "Isti'adha"}
_SPECIAL_TEXT_BY_REF = {
"Basmala": "Ψ¨ΩΨ³ΩΩ
Ω Ω±ΩΩΩΩΩΩ Ω±ΩΨ±ΩΩΨΩΩ
ΩΩ°ΩΩ Ω±ΩΨ±ΩΩΨΩΩΩ
",
"Isti'adha": "Ψ£ΩΨΉΩΩΨ°Ω Ψ¨ΩΩ±ΩΩΩΩΩΩ Ω
ΩΩΩ Ψ§ΩΨ΄ΩΩΩΩΨ·ΩΨ§ΩΩ Ψ§ΩΨ±ΩΩΨ¬ΩΩΩ
",
}
# ---------------------------------------------------------------------------
# Eligibility + counting helpers
# ---------------------------------------------------------------------------
def is_eligible(seg: SegmentInfo) -> bool:
"""Segment is a split candidate (also excluded from count totals when False)."""
if not seg.matched_ref:
return False
if seg.matched_ref in ALL_SPECIAL_REFS:
return False
if "+" in seg.matched_ref:
return False # compound special+verse (e.g. "Basmala+2:255:1-2:255:5")
if seg.has_missing_words or seg.has_repeated_words:
return False
if seg.error:
return False
return True
def verse_span(seg: SegmentInfo) -> int:
"""Number of distinct verses a segment spans (0 if unparseable/special)."""
if not is_eligible(seg):
return 0
ranges = _parse_ref_verse_ranges(seg.matched_ref)
return len(ranges)
def word_count_of(seg: SegmentInfo) -> int:
"""Number of Quran words the segment covers, using QuranIndex for ground truth."""
if not is_eligible(seg):
return 0
indices = get_quran_index().ref_to_indices(seg.matched_ref)
if not indices:
return 0
start_idx, end_idx = indices
return end_idx - start_idx + 1
def duration_of(seg: SegmentInfo) -> float:
"""Segment duration in seconds."""
return max(0.0, seg.end_time - seg.start_time)
def violates(seg: SegmentInfo, max_verses: Optional[int],
max_words: Optional[int],
max_duration: Optional[float] = None) -> tuple[bool, bool, bool]:
"""Return (violates_verse, violates_word, violates_duration)."""
if not is_eligible(seg):
return False, False, False
v_bad = bool(max_verses is not None and verse_span(seg) > max_verses)
w_bad = bool(max_words is not None and word_count_of(seg) > max_words)
d_bad = bool(max_duration is not None and duration_of(seg) > max_duration)
return v_bad, w_bad, d_bad
# ---------------------------------------------------------------------------
# Word-level helpers
# ---------------------------------------------------------------------------
def _segment_word_texts(seg: SegmentInfo) -> list[str]:
"""Return QPC Hafs text for each word in the segment, in order.
Uses QuranIndex (ground truth), not seg.matched_text, so waqf marks that
live as combining characters on the QPC string are visible.
"""
indices = get_quran_index().ref_to_indices(seg.matched_ref)
if not indices:
return []
start_idx, end_idx = indices
words = get_quran_index().words
return [words[i].text for i in range(start_idx, end_idx + 1)]
def _display_word_texts(text: str) -> list[str]:
"""Split display text into words, dropping verse markers."""
if not text:
return []
return text.replace(" \u06dd ", " ").split()
def _special_word_texts(seg: SegmentInfo) -> list[str]:
"""Return special-segment words in display order."""
if seg.matched_text:
words = _display_word_texts(seg.matched_text)
if words:
return words
return _display_word_texts(_SPECIAL_TEXT_BY_REF.get(seg.matched_ref, ""))
def manual_split_supported(seg: SegmentInfo) -> bool:
"""Segment supports manual split mode in the UI."""
if not seg.matched_ref or seg.has_repeated_words:
return False
if seg.matched_ref in ALL_SPECIAL_REFS:
return False
indices = get_quran_index().ref_to_indices(seg.matched_ref)
if not indices:
return False
return (indices[1] - indices[0] + 1) >= 2
def _manual_word_count(seg: SegmentInfo) -> int:
"""Return the word count used by manual split selection validation."""
indices = get_quran_index().ref_to_indices(seg.matched_ref)
if not indices:
return 0
return indices[1] - indices[0] + 1
def find_stop_split_idx(word_texts: list[str]) -> Optional[int]:
"""Return 0-based index of the word AFTER which to cut.
Walks the priority tuple; the first class with any hit wins. Within that
class, picks the hit whose index is closest to the middle of the segment.
The LAST word is never a valid cut point (would yield an empty right half)
so it's excluded from candidates.
"""
n = len(word_texts)
if n < 2:
return None
middle = (n - 1) / 2.0
for _label, mark in STOP_SIGN_PRIORITY:
hits = [i for i in range(n - 1) if mark in word_texts[i]]
if hits:
return min(hits, key=lambda i: abs(i - middle))
return None
# ---------------------------------------------------------------------------
# Ref arithmetic
# ---------------------------------------------------------------------------
def _make_ref_from_global(start_global_idx: int, end_global_idx: int) -> str:
"""Build a matched_ref string from two global word indices (inclusive)."""
words = get_quran_index().words
a = words[start_global_idx]
b = words[end_global_idx]
if start_global_idx == end_global_idx:
return f"{a.surah}:{a.ayah}:{a.word}"
return f"{a.surah}:{a.ayah}:{a.word}-{b.surah}:{b.ayah}:{b.word}"
def _matched_text_from_global(start_global_idx: int, end_global_idx: int) -> str:
"""Rebuild matched_text (display script) over a global word range."""
words = get_quran_index().words
return " ".join(words[i].display_text for i in range(start_global_idx, end_global_idx + 1))
# ---------------------------------------------------------------------------
# Split point β sub-segment construction
# ---------------------------------------------------------------------------
def _slice_mfa_words(mfa_words: list[dict], lo: int, hi: int, new_zero: float) -> list[dict]:
"""Return a deep-copied slice of mfa_words with times re-based to new_zero."""
return _shift_mfa_words(mfa_words[lo:hi + 1], -new_zero)
def _shift_mfa_words(mfa_words: list[dict], delta: float) -> list[dict]:
"""Return a deep-copied MFA words list with all times shifted by delta."""
out = []
for w in mfa_words:
nw = dict(w)
if isinstance(nw.get("start"), (int, float)):
nw["start"] = round(nw["start"] + delta, 4)
if isinstance(nw.get("end"), (int, float)):
nw["end"] = round(nw["end"] + delta, 4)
if "letters" in nw and isinstance(nw["letters"], list):
letters = []
for lt in nw["letters"]:
nlt = dict(lt)
if isinstance(nlt.get("start"), (int, float)):
nlt["start"] = round(nlt["start"] + delta, 4)
if isinstance(nlt.get("end"), (int, float)):
nlt["end"] = round(nlt["end"] + delta, 4)
letters.append(nlt)
nw["letters"] = letters
out.append(nw)
return out
def _merge_child_mfa_words(group: list[SegmentInfo]) -> Optional[list[dict]]:
"""Combine child-local MFA timestamps back into one merged local timeline."""
if not group or not all(seg.words for seg in group):
return None
merged_zero = group[0].start_time
out = []
for seg in group:
delta = seg.start_time - merged_zero
out.extend(_shift_mfa_words(seg.words or [], delta))
return out
def _build_child(parent: SegmentInfo,
start_global_idx: int, end_global_idx: int,
rel_start: float, rel_end: float,
mfa_word_lo: int, mfa_word_hi: int,
mfa_words: Optional[list],
group_id: str) -> SegmentInfo:
"""Build a sub-segment from parent, covering [start_global_idx..end_global_idx].
rel_start/rel_end are in parent-local seconds (0 == parent.start_time).
"""
abs_start = parent.start_time + rel_start
abs_end = parent.start_time + rel_end
new_ref = _make_ref_from_global(start_global_idx, end_global_idx)
new_text = _matched_text_from_global(start_global_idx, end_global_idx)
sliced_words = None
if mfa_words is not None and mfa_word_hi >= mfa_word_lo:
sliced_words = _slice_mfa_words(mfa_words, mfa_word_lo, mfa_word_hi, rel_start)
return replace(
parent,
start_time=abs_start,
end_time=abs_end,
matched_ref=new_ref,
matched_text=new_text,
transcribed_text="", # ASR transcript not sliceable
words=sliced_words,
wrap_word_ranges=None,
repeated_ranges=None,
repeated_text=None,
has_missing_words=False,
has_repeated_words=False,
error=None,
split_group_id=group_id,
# segment_number reassigned by caller at the end
segment_number=0,
)
# ---------------------------------------------------------------------------
# MFA word indexing
# ---------------------------------------------------------------------------
def _build_mfa_location_to_idx(mfa_words: list[dict]) -> dict[str, int]:
"""Map MFA location strings ('s:a:w') to their position in the words list."""
out = {}
for i, w in enumerate(mfa_words):
loc = w.get("location")
if loc and loc not in out:
out[loc] = i
return out
def _parent_segment_word_global_indices(seg: SegmentInfo) -> list[int]:
"""Global word indices the segment covers, in order."""
indices = get_quran_index().ref_to_indices(seg.matched_ref)
if not indices:
return []
return list(range(indices[0], indices[1] + 1))
def _mfa_rel_start(mfa_words: list[dict], idx: int, fallback: float) -> float:
v = mfa_words[idx].get("start") if 0 <= idx < len(mfa_words) else None
return float(v) if isinstance(v, (int, float)) else float(fallback)
def _mfa_rel_end(mfa_words: list[dict], idx: int, fallback: float) -> float:
v = mfa_words[idx].get("end") if 0 <= idx < len(mfa_words) else None
return float(v) if isinstance(v, (int, float)) else float(fallback)
# ---------------------------------------------------------------------------
# Verse pass
# ---------------------------------------------------------------------------
def _verse_cut_indices(seg: SegmentInfo, max_verses: int) -> list[int]:
"""0-based local-word indices after which to cut to limit verse span.
For a segment covering V verses with limit N<V, group verses into chunks
of <=N and compute cut indices at the last local-word of each non-final
chunk.
"""
ranges = _parse_ref_verse_ranges(seg.matched_ref)
if len(ranges) <= max_verses:
return []
per_verse_wc = [(wt - wf + 1) for (_s, _a, wf, wt) in ranges]
cut_after_local = []
cumulative = 0
v = 0
while v < len(ranges):
chunk_end = min(v + max_verses, len(ranges))
chunk_words = sum(per_verse_wc[v:chunk_end])
cumulative += chunk_words
if chunk_end < len(ranges):
cut_after_local.append(cumulative - 1)
v = chunk_end
return cut_after_local
def _split_by_indices(parent: SegmentInfo, mfa_words: list[dict],
cut_after_local: list[int],
group_id: str) -> list[SegmentInfo]:
"""Split parent at the given local-word indices.
cut_after_local: 0-based local-word indices after which to cut (not
including the final word). Returns new sub-segments in order. Uses MFA
word times for the actual boundary timestamps.
If mfa_words is unavailable/misaligned, returns [parent] unchanged.
"""
local_global = _parent_segment_word_global_indices(parent)
n_words = len(local_global)
if n_words == 0 or not cut_after_local:
return [parent]
# Map each parent-local word index -> its position in the MFA words list
# (MFA may have non-Quran prefix words like Basmala at 0:0:x β we only
# consider MFA entries whose location matches one of our global words.)
loc_to_mfa = _build_mfa_location_to_idx(mfa_words or [])
words = get_quran_index().words
local_to_mfa = []
for gi in local_global:
w = words[gi]
loc = f"{w.surah}:{w.ayah}:{w.word}"
local_to_mfa.append(loc_to_mfa.get(loc))
# If any required boundary word has no MFA entry, bail out β can't cut.
boundary_indices = set(cut_after_local) | {i + 1 for i in cut_after_local}
for bi in boundary_indices:
if 0 <= bi < n_words and local_to_mfa[bi] is None:
return [parent]
# Parent duration fallback if first/last words missing MFA timestamps.
parent_dur = max(0.0, parent.end_time - parent.start_time)
segments: list[SegmentInfo] = []
prev_local = 0
for cut in cut_after_local:
# Left sub-segment: local [prev_local .. cut]
lo_global = local_global[prev_local]
hi_global = local_global[cut]
mfa_lo = local_to_mfa[prev_local]
mfa_hi = local_to_mfa[cut]
rel_start = _mfa_rel_start(mfa_words, mfa_lo, 0.0) if prev_local > 0 else 0.0
rel_end = _mfa_rel_end(mfa_words, mfa_hi, parent_dur)
segments.append(_build_child(
parent, lo_global, hi_global, rel_start, rel_end,
mfa_lo, mfa_hi, mfa_words, group_id,
))
prev_local = cut + 1
# Final sub-segment
lo_global = local_global[prev_local]
hi_global = local_global[-1]
mfa_lo = local_to_mfa[prev_local]
mfa_hi = local_to_mfa[n_words - 1]
rel_start = _mfa_rel_start(mfa_words, mfa_lo, 0.0)
rel_end = parent_dur # Always extend last sub-segment to parent's end
segments.append(_build_child(
parent, lo_global, hi_global, rel_start, rel_end,
mfa_lo, mfa_hi, mfa_words, group_id,
))
return segments
# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------
def _batch_mfa(segments: list[SegmentInfo],
indices_to_call: list[int],
mfa_caller: Callable) -> dict[int, Optional[list]]:
"""Call MFA for the segments at the given indices, return {idx: words|None}."""
from src.ui.mfa_lookups import _build_mfa_ref
if not indices_to_call:
return {}
to_call = []
for i in indices_to_call:
seg = segments[i]
if seg.words: # already have MFA word timings from Animate-All or a prior pass
continue
mfa_ref = _build_mfa_ref(seg.to_json_dict())
if mfa_ref is None:
continue
to_call.append((i, mfa_ref))
if not to_call:
return {i: segments[i].words for i in indices_to_call}
# Extract refs + per-segment (start_time, end_time) β caller will slice audio
refs = [ref for _i, ref in to_call]
ranges = [(segments[i].start_time, segments[i].end_time) for i, _ref in to_call]
results = mfa_caller(refs, ranges) # list[words|None] aligned with to_call
out: dict[int, Optional[list]] = {i: segments[i].words for i in indices_to_call}
for (i, _ref), words in zip(to_call, results):
out[i] = words
return out
def split_segments(segments: list[SegmentInfo],
max_verses: Optional[int],
max_words: Optional[int],
mfa_caller: Callable,
max_duration: Optional[float] = None,
require_stop_sign: bool = False,
progress_cb: Optional[Callable[[int, int], None]] = None,
) -> tuple[list[SegmentInfo], dict]:
"""Subdivide segments that violate max_verses / max_words / max_duration.
Args:
segments: current SegmentInfo list (will not be mutated).
max_verses: int > 0, or None to disable the verse criterion.
max_words: int > 0, or None to disable the word criterion.
max_duration: float seconds > 0, or None to disable the duration criterion.
mfa_caller: callable(refs: list[str], ranges: list[(float, float)])
-> list[words|None]. Caller slices parent audio per range.
require_stop_sign: if True, skip the equal-word fallback in the word /
duration pass β segments with no waqf mark stay unsplit.
Does NOT affect the verse pass.
progress_cb: optional(completed_violators: int, total_violators: int).
Returns:
(new_segments, report) where report contains:
"split_groups": {group_id: [new_indices_in_new_list, ...]}
"failed": [original_idx, ...] β MFA failure, kept unsplit
"unchanged_original_indices": set(original_idx, ...)
"unchanged_new_indices": {original_idx: new_idx}
"violator_count": int
"""
n = len(segments)
report: dict = {
"split_groups": {},
"failed": [],
"unchanged_original_indices": set(),
"unchanged_new_indices": {},
"violator_count": 0,
}
if n == 0 or (max_verses is None and max_words is None and max_duration is None):
report["unchanged_original_indices"] = set(range(n))
report["unchanged_new_indices"] = {i: i for i in range(n)}
return list(segments), report
# -----------------------------------------------------------------
# Pass 1 β verse split (only on violators of the verse criterion)
# -----------------------------------------------------------------
working: list[SegmentInfo] = list(segments)
# Tracks which original indices have been replaced (and therefore should
# NOT be in unchanged_original_indices).
replaced_original: set[int] = set()
# Maps each element of `working` to its original index IF unchanged.
original_idx_of: dict[int, int] = {i: i for i in range(n)}
failed_original: set[int] = set()
def _mark_split_from_original(new_idx_in_working: int,
original_idx: Optional[int]):
original_idx_of.pop(new_idx_in_working, None)
if original_idx is not None:
replaced_original.add(original_idx)
if max_verses is not None:
violators = [i for i, s in enumerate(working)
if violates(s, max_verses, None, None)[0]]
report["violator_count"] = len(violators)
if violators:
mfa_out = _batch_mfa(working, violators, mfa_caller)
if progress_cb:
progress_cb(len(violators), len(violators))
new_working: list[SegmentInfo] = []
new_original_idx_of: dict[int, int] = {}
for i, seg in enumerate(working):
orig_i = original_idx_of.get(i)
if i in violators:
mfa_words = mfa_out.get(i)
if not mfa_words:
failed_original.add(orig_i if orig_i is not None else -1)
bad = replace(seg, error="split_failed")
new_working.append(bad)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
group_id = f"split-{uuid.uuid4().hex[:8]}"
cuts = _verse_cut_indices(seg, max_verses)
if not cuts:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
children = _split_by_indices(seg, mfa_words, cuts, group_id)
if len(children) <= 1:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
for child in children:
new_working.append(child)
_mark_split_from_original(len(new_working) - 1, orig_i)
if orig_i is not None:
replaced_original.add(orig_i)
else:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
working = new_working
original_idx_of = new_original_idx_of
# -----------------------------------------------------------------
# Pass 2 β word / duration split, recursing until every leaf satisfies
# both criteria. Each outer iteration batches one MFA call for all
# current violators. Same cut logic (stop-sign > equal-word) serves both.
# -----------------------------------------------------------------
if max_words is not None or max_duration is not None:
while True:
def _word_or_dur_violator(s):
_vv, w_bad, d_bad = violates(s, None, max_words, max_duration)
return w_bad or d_bad
violators = [i for i, s in enumerate(working)
if _word_or_dur_violator(s)]
if not violators:
break
mfa_out = _batch_mfa(working, violators, mfa_caller)
if progress_cb:
progress_cb(len(violators), len(violators))
new_working: list[SegmentInfo] = []
new_original_idx_of: dict[int, int] = {}
any_progress = False
for i, seg in enumerate(working):
orig_i = original_idx_of.get(i)
if i in violators:
mfa_words = mfa_out.get(i)
if not mfa_words:
# MFA failed β mark and stop trying to split this one.
failed_original.add(orig_i if orig_i is not None else -1)
bad = replace(seg, error="split_failed")
new_working.append(bad)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
# Verse boundary is highest priority β always cut there first
# regardless of whether pass 1 ran or what max_verses was set to.
verse_cuts = _verse_cut_indices(seg, 1)
if verse_cuts:
group_id = seg.split_group_id or f"split-{uuid.uuid4().hex[:8]}"
children = _split_by_indices(seg, mfa_words, verse_cuts, group_id)
if len(children) > 1:
for child in children:
new_working.append(child)
_mark_split_from_original(len(new_working) - 1, orig_i)
if orig_i is not None:
replaced_original.add(orig_i)
any_progress = True
continue
# _split_by_indices bailed (missing MFA boundary) β fall through
word_texts = _segment_word_texts(seg)
cut = find_stop_split_idx(word_texts)
if cut is None:
# No stop sign. If the caller requires a stop sign,
# leave this segment unsplit even though it violates.
if require_stop_sign:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
# Equal-word split fallback
if len(word_texts) < 2:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
cut = (len(word_texts) // 2) - 1
if cut < 0:
cut = 0
group_id = seg.split_group_id or f"split-{uuid.uuid4().hex[:8]}"
children = _split_by_indices(seg, mfa_words, [cut], group_id)
if len(children) <= 1:
# Could not cut (e.g. missing MFA for boundary) β stop.
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
continue
for child in children:
new_working.append(child)
_mark_split_from_original(len(new_working) - 1, orig_i)
if orig_i is not None:
replaced_original.add(orig_i)
any_progress = True
else:
new_working.append(seg)
if orig_i is not None:
new_original_idx_of[len(new_working) - 1] = orig_i
working = new_working
original_idx_of = new_original_idx_of
if not any_progress:
break # every violator refused to split β avoid infinite loop
# -----------------------------------------------------------------
# Finalize: renumber, build report.
# -----------------------------------------------------------------
for new_idx, seg in enumerate(working):
seg.segment_number = new_idx + 1
if seg.split_group_id:
report["split_groups"].setdefault(seg.split_group_id, []).append(new_idx)
report["failed"] = sorted(i for i in failed_original if i >= 0)
report["unchanged_original_indices"] = {
orig for new, orig in original_idx_of.items()
if orig not in replaced_original
}
report["unchanged_new_indices"] = dict(original_idx_of)
return working, report
def split_segment_manual(segments: list[SegmentInfo],
segment_idx: int,
cut_after_local: list[int],
mfa_caller: Callable) -> tuple[list[SegmentInfo], dict]:
"""Split a single user-selected segment at explicit local-word cuts."""
n = len(segments)
report: dict = {
"split_groups": {},
"failed": [],
"unchanged_original_indices": set(),
"unchanged_new_indices": {},
"violator_count": 1,
}
if n == 0:
raise ValueError("No segments available.")
if segment_idx < 0 or segment_idx >= n:
raise ValueError("Segment index out of range.")
parent = segments[segment_idx]
if not manual_split_supported(parent):
raise ValueError("This segment does not support manual splitting.")
try:
cuts = sorted({int(c) for c in cut_after_local})
except (TypeError, ValueError) as exc:
raise ValueError("Invalid manual split selection.") from exc
total_words = _manual_word_count(parent)
if total_words < 2:
raise ValueError("This segment is too short to split.")
if not cuts:
raise ValueError("Choose at least one split point.")
max_cut = total_words - 2
if any(c < 0 or c > max_cut for c in cuts):
raise ValueError("Split points are out of range.")
mfa_words = parent.words
if not mfa_words:
from src.ui.mfa_lookups import _build_mfa_ref
mfa_ref = _build_mfa_ref(parent.to_json_dict())
if not mfa_ref:
raise ValueError("This segment cannot be aligned for manual splitting.")
results = mfa_caller([mfa_ref], [(parent.start_time, parent.end_time)])
mfa_words = results[0] if results else None
if not mfa_words:
raise RuntimeError("MFA failed to return word boundaries for this segment.")
group_id = parent.split_group_id or f"split-{uuid.uuid4().hex[:8]}"
children = _split_by_indices(parent, mfa_words, cuts, group_id)
if len(children) <= 1:
raise RuntimeError("Could not split this segment at the selected boundaries.")
new_segments = list(segments[:segment_idx]) + children + list(segments[segment_idx + 1:])
for new_idx, seg in enumerate(new_segments):
seg.segment_number = new_idx + 1
report["split_groups"] = {group_id: list(range(segment_idx, segment_idx + len(children)))}
report["unchanged_original_indices"] = set(range(n)) - {segment_idx}
shift = len(children) - 1
unchanged_new_indices = {}
for old_idx in range(n):
if old_idx == segment_idx:
continue
new_idx = old_idx if old_idx < segment_idx else old_idx + shift
unchanged_new_indices[new_idx] = old_idx
report["unchanged_new_indices"] = unchanged_new_indices
return new_segments, report
def undo_split_group(segments: list[SegmentInfo],
segment_idx: int) -> tuple[list[SegmentInfo], dict]:
"""Merge a contiguous split-group run back into a single segment."""
n = len(segments)
if n == 0:
raise ValueError("No segments available.")
if segment_idx < 0 or segment_idx >= n:
raise ValueError("Split group index is out of range.")
seed = segments[segment_idx]
group_id = seed.split_group_id
if not group_id:
raise ValueError("This segment is not inside a split group.")
start_idx = segment_idx
while start_idx > 0 and segments[start_idx - 1].split_group_id == group_id:
start_idx -= 1
end_idx = segment_idx
while end_idx + 1 < n and segments[end_idx + 1].split_group_id == group_id:
end_idx += 1
if end_idx <= start_idx:
raise ValueError("This split group does not have multiple segments.")
group = segments[start_idx:end_idx + 1]
first = group[0]
last = group[-1]
if first.matched_ref in _MANUAL_SPLIT_SPECIAL_REFS:
merged_ref = first.matched_ref
merged_text = _SPECIAL_TEXT_BY_REF.get(merged_ref) or " ".join(
(seg.matched_text or "").strip() for seg in group if (seg.matched_text or "").strip()
)
else:
first_bounds = get_quran_index().ref_to_indices(first.matched_ref)
last_bounds = get_quran_index().ref_to_indices(last.matched_ref)
if not first_bounds or not last_bounds:
raise ValueError("Could not reconstruct the original split range.")
merged_ref = _make_ref_from_global(first_bounds[0], last_bounds[1])
merged_text = _matched_text_from_global(first_bounds[0], last_bounds[1])
merged = replace(
first,
start_time=first.start_time,
end_time=last.end_time,
matched_ref=merged_ref,
matched_text=merged_text,
transcribed_text="",
words=_merge_child_mfa_words(group),
wrap_word_ranges=None,
repeated_ranges=None,
repeated_text=None,
has_missing_words=False,
has_repeated_words=False,
error=None,
split_group_id=None,
segment_number=0,
)
new_segments = list(segments[:start_idx]) + [merged] + list(segments[end_idx + 1:])
for new_idx, seg in enumerate(new_segments):
seg.segment_number = new_idx + 1
unchanged_new_indices = {}
shift = end_idx - start_idx
for old_idx in range(n):
if start_idx <= old_idx <= end_idx:
continue
new_idx = old_idx if old_idx < start_idx else old_idx - shift
unchanged_new_indices[new_idx] = old_idx
report = {
"undo_group_id": group_id,
"undo_original_indices": list(range(start_idx, end_idx + 1)),
"unchanged_new_indices": unchanged_new_indices,
}
return new_segments, report
# ---------------------------------------------------------------------------
# Merge (inverse of split): fuse adjacent segments into one collapsed card
# ---------------------------------------------------------------------------
def _loc_of_global(global_idx: int) -> str:
"""Return the 's:a:w' location string for a global word index."""
w = get_quran_index().words[global_idx]
return f"{w.surah}:{w.ayah}:{w.word}"
def _ref_from_member_dict(d: dict) -> str:
"""Reconstruct a matched_ref from a serialized member dict (to_json_dict form)."""
if d.get("special_type"):
return d["special_type"]
if d.get("ref_to"):
return f"{d['ref_from']}-{d['ref_to']}"
return d.get("ref_from", "")
def can_merge_pair(seg_a: SegmentInfo, seg_b: SegmentInfo) -> bool:
"""Whether two adjacent segments may be merged (drives the merge chip).
Both must be ordinary Quran-ref segments (non-special, no compound '+',
parseable, not in a split group, no alignment error) AND the join between
them must be a *natural continuity*:
- overlap (the reciter went back into the first segment) β repetition; or
- contiguous, or a small 1-2 word gap, within the SAME surah.
A surah change, a re-anchor jump, or a gap > 2 words blocks the merge.
Merge-group members ARE allowed β that is what enables cascading.
"""
qi = get_quran_index()
for s in (seg_a, seg_b):
if not s.matched_ref:
return False
if s.matched_ref in ALL_SPECIAL_REFS:
return False
if "+" in s.matched_ref:
return False
if s.split_group_id:
return False
if s.error:
return False
if qi.ref_to_indices(s.matched_ref) is None:
return False
l0, l1 = qi.ref_to_indices(seg_a.matched_ref)
r0, _r1 = qi.ref_to_indices(seg_b.matched_ref)
words = qi.words
# Overlap: the second segment starts inside the first (reciter went back).
if l0 <= r0 <= l1:
return True
# Forward join must stay in the same surah (blocks surah change / re-anchor).
if words[l1].surah != words[r0].surah:
return False
# Contiguous (gap == 0) or a small natural gap (<= 2 words, auto-filled).
gap = r0 - l1 - 1
return 0 <= gap <= 2
def _merge_two(left: SegmentInfo, right: SegmentInfo, *,
members: list, group_id: str) -> SegmentInfo:
"""Build the single collapsed card that fuses ``left`` and ``right``.
Unions the Quran refs (min start .. max end), joins the audio span
[left.start_time, right.end_time], and concatenates the existing MFA word
timestamps. ``members`` is the flat recitation-order member list stamped on
the card for lossless undo; ``group_id`` is the stable merge id. Overlap
between the two ref ranges is detected and surfaced as repetition.
"""
qi = get_quran_index()
left_ref = qi.ref_to_indices(left.matched_ref)
right_ref = qi.ref_to_indices(right.matched_ref)
ranges = [r for r in (left_ref, right_ref) if r is not None]
if not ranges:
raise ValueError("Could not resolve the merged reference.")
start_gi = min(g0 for g0, _g1 in ranges)
end_gi = max(g1 for _g0, g1 in ranges)
covered = end_gi - start_gi + 1
total = sum(g1 - g0 + 1 for g0, g1 in ranges)
merged_ref = _make_ref_from_global(start_gi, end_gi)
merged_text = _matched_text_from_global(start_gi, end_gi)
# total > covered means member ranges overlap β the reciter repeated words.
# Show each member range line-separated (via repeated_ranges) rather than one
# contiguous block. total < covered means an auto-filled gap (single block).
if total > covered:
has_repeated = True
repeated_ranges = [[_loc_of_global(g0), _loc_of_global(g1)]
for g0, g1 in ranges]
repeated_text = [_matched_text_from_global(g0, g1) for g0, g1 in ranges]
else:
has_repeated = False
repeated_ranges = None
repeated_text = None
return replace(
left,
start_time=left.start_time,
end_time=right.end_time,
matched_ref=merged_ref,
matched_text=merged_text,
transcribed_text="",
match_score=min(left.match_score, right.match_score),
words=_merge_child_mfa_words([left, right]),
wrap_word_ranges=None,
repeated_ranges=repeated_ranges,
repeated_text=repeated_text,
# Provisional flag only. Merge is coverage-monotone for *gaining* β the
# union can't introduce a new gap β so when neither child was flagged
# the OR is False and the caller's surgical path can skip recompute. But
# a merge CAN clear a flag: a gap that sat between the two pieces is now
# covered. The OR can't see that, so callers that started from a flagged
# member must run recompute_missing_words() on the result to get the
# authoritative flag β see merge_segments_audio's fallback path.
has_missing_words=bool(left.has_missing_words or right.has_missing_words),
has_repeated_words=has_repeated,
error=None,
split_group_id=None,
merge_group_id=group_id,
merge_members=members,
# A merge result is never itself a partial-merge leftover. Clear it so the
# marker can't propagate when a grown partial-merge card is later cascaded.
partial_merge_leftover=None,
segment_number=0,
)
def merge_segments(segments: list[SegmentInfo],
left_idx: int) -> tuple[list[SegmentInfo], dict]:
"""Merge segments[left_idx] and segments[left_idx + 1] into one card.
Unions the Quran refs (min start .. max end), joins the audio span
[left.start_time, right.end_time] (already covers the removed silence), and
concatenates the existing MFA word timestamps β no MFA call is needed.
Cascades: if either side is already a merge card, its stored members are
absorbed so the result keeps one flat ``merge_members`` list.
"""
n = len(segments)
if n == 0:
raise ValueError("No segments available.")
if left_idx < 0 or left_idx + 1 >= n:
raise ValueError("Merge index is out of range.")
left = segments[left_idx]
right = segments[left_idx + 1]
if not can_merge_pair(left, right):
raise ValueError("These segments cannot be merged.")
# A partial-merge unit (grown card + its linked leftover) is locked: undo the
# partial merge before either piece can join another merge β same principle as
# split members. Keeps the partial structure simple and undo lossless.
if (is_partial_merge_member(left, segments)
or is_partial_merge_member(right, segments)):
raise ValueError("Undo the partial merge before merging these segments.")
# Flat member list in recitation order (absorb already-merged sides).
left_members = left.merge_members or [left.to_json_dict()]
right_members = right.merge_members or [right.to_json_dict()]
members = list(left_members) + list(right_members)
# Cascading onto a pipeline auto-merge makes the group user-owned: mint a
# fresh user id so the card's tag flips from "Auto-merged" to "Merged".
group_id = left.merge_group_id or right.merge_group_id
if not group_id or group_id.startswith(AUTO_MERGE_GROUP_PREFIX):
group_id = f"merge-{uuid.uuid4().hex[:8]}"
merged = _merge_two(left, right, members=members, group_id=group_id)
new_segments = list(segments[:left_idx]) + [merged] + list(segments[left_idx + 2:])
for new_idx, seg in enumerate(new_segments):
seg.segment_number = new_idx + 1
unchanged_new_indices = {}
for old_idx in range(n):
if old_idx in (left_idx, left_idx + 1):
continue
new_idx = old_idx if old_idx < left_idx else old_idx - 1
unchanged_new_indices[new_idx] = old_idx
report = {
"merge_group_id": group_id,
"merged_original_indices": [left_idx, left_idx + 1],
"unchanged_new_indices": unchanged_new_indices,
}
return new_segments, report
# ---------------------------------------------------------------------------
# Partial merge: move N boundary words of one segment into its neighbour,
# leaving a trimmed "leftover" remainder. Asymmetric counterpart of merge β
# instead of fusing two whole cards, it slides a few words across the boundary.
# ---------------------------------------------------------------------------
def can_partial_merge_pair(seg_a: SegmentInfo, seg_b: SegmentInfo, *,
source: SegmentInfo) -> bool:
"""Whether ``source`` may donate boundary words to its neighbour.
Inherits every rule of :func:`can_merge_pair` (overlap / <=2-word gap /
same surah / no specials / no compound '+' / no split group / no error /
parseable) by delegating to it with the pair in recitation order β overlap
is intentionally NOT blocked, ``_merge_two`` handles repetition. Adds one
requirement: the SOURCE must have >= 2 words, otherwise there is nothing to
move while leaving a non-empty leftover.
"""
if not can_merge_pair(seg_a, seg_b):
return False
idx = get_quran_index().ref_to_indices(source.matched_ref)
if idx is None:
return False
return (idx[1] - idx[0] + 1) >= 2
def is_partial_merge_member(seg: SegmentInfo, segments: list[SegmentInfo]) -> bool:
"""Whether ``seg`` is part of a partial-merge unit (grown card or its leftover).
The leftover stores the grown card's id in ``partial_merge_leftover``; the grown
card is the one whose ``merge_group_id`` some leftover points at. Such cards are
locked from further merges until the partial merge is undone.
"""
if seg.partial_merge_leftover:
return True
return bool(seg.merge_group_id) and any(
s.partial_merge_leftover == seg.merge_group_id for s in segments)
def _slice_segment_words(source: SegmentInfo, g_lo: int, g_hi: int,
word_lo: Optional[int], word_hi: Optional[int]) -> SegmentInfo:
"""Build a synthetic SegmentInfo over the source's global range [g_lo..g_hi].
``word_lo``/``word_hi`` index into ``source.words`` (the MFA word list) when
present; times come from those words, else fall back to a proportional slice
of the source's audio span. Used both for the moved slice and the leftover.
"""
new_ref = _make_ref_from_global(g_lo, g_hi)
new_text = _matched_text_from_global(g_lo, g_hi)
src_idx = get_quran_index().ref_to_indices(source.matched_ref)
s0, s1 = src_idx
n_src = s1 - s0 + 1
span = max(0.0, source.end_time - source.start_time)
sliced_words = None
if source.words:
sliced_words = [dict(w) for w in source.words[word_lo:word_hi + 1]]
# Timestamps: prefer MFA word boundaries, else proportional across the span.
if sliced_words:
starts = [w["start"] for w in sliced_words if isinstance(w.get("start"), (int, float))]
ends = [w["end"] for w in sliced_words if isinstance(w.get("end"), (int, float))]
abs_start = min(starts) if starts else source.start_time
abs_end = max(ends) if ends else source.end_time
else:
# Proportional fallback based on local word offsets within the source.
local_lo = g_lo - s0
local_hi = g_hi - s0
per = span / n_src if n_src else 0.0
abs_start = source.start_time + per * local_lo
abs_end = source.start_time + per * (local_hi + 1)
return replace(
source,
start_time=abs_start,
end_time=abs_end,
matched_ref=new_ref,
matched_text=new_text,
transcribed_text="",
words=sliced_words,
wrap_word_ranges=None,
repeated_ranges=None,
repeated_text=None,
has_missing_words=False,
has_repeated_words=False,
error=None,
split_group_id=None,
merge_group_id=None,
merge_members=None,
partial_merge_leftover=None,
segment_number=0,
)
def partial_merge(segments: list[SegmentInfo],
source_idx: int,
take_from: str,
count: int) -> tuple[list[SegmentInfo], dict]:
"""Move ``count`` boundary words of segments[source_idx] into a neighbour.
``take_from="head"``: the source's leading ``count`` words move into the
PREVIOUS segment (segments[source_idx - 1]); the source keeps its tail.
``take_from="tail"``: the trailing ``count`` words move into the NEXT
segment (segments[source_idx + 1]); the source keeps its head.
Produces two cards in reading order: a *grown* neighbour (a merge card whose
``merge_members`` are the two ORIGINAL pre-action segments, for lossless
undo) and a *leftover* (the trimmed source, sharing the grown card's
``merge_group_id`` and flagged ``partial_merge_leftover``). Per-word MFA data
is invalidated on both (``words=None``, ``transcribed_text=""``) so MFA
recomputes lazily over the new boundaries.
"""
n = len(segments)
if n == 0:
raise ValueError("No segments available.")
if source_idx < 0 or source_idx >= n:
raise ValueError("Partial merge index is out of range.")
if take_from not in ("head", "tail"):
raise ValueError("take_from must be 'head' or 'tail'.")
source = segments[source_idx]
src_idx = get_quran_index().ref_to_indices(source.matched_ref)
if src_idx is None:
raise ValueError("Could not resolve the source reference.")
g0, g1 = src_idx
src_word_count = g1 - g0 + 1
if not (1 <= count < src_word_count):
raise ValueError("Word count to move is out of range.")
if take_from == "head":
if source_idx - 1 < 0:
raise ValueError("No previous segment to merge into.")
neighbor_idx = source_idx - 1
else:
if source_idx + 1 >= n:
raise ValueError("No next segment to merge into.")
neighbor_idx = source_idx + 1
neighbor = segments[neighbor_idx]
# Pair in recitation order for the eligibility check (matches _merge_two order).
left, right = (neighbor, source) if take_from == "head" else (source, neighbor)
if not can_partial_merge_pair(left, right, source=source):
raise ValueError("These segments cannot be partially merged.")
# Lock: don't partial-merge into/out of an existing partial-merge unit; undo
# the prior partial merge first (mirrors the merge_segments guard).
if (is_partial_merge_member(source, segments)
or is_partial_merge_member(neighbor, segments)):
raise ValueError("Undo the partial merge before merging these segments.")
# The two ORIGINAL pre-action segments in recitation order, for lossless undo
# from the grown card: head β [neighbor(prev), source]; tail β [source, neighbor(next)].
if take_from == "head":
origin = [neighbor.to_json_dict(), source.to_json_dict()]
else:
origin = [source.to_json_dict(), neighbor.to_json_dict()]
new_id = f"merge-{uuid.uuid4().hex[:8]}"
if take_from == "head":
# Moved slice = leading `count` words; leftover = the tail.
slice_seg = _slice_segment_words(source, g0, g0 + count - 1, 0, count - 1)
leftover = _slice_segment_words(
source, g0 + count, g1, count, src_word_count - 1)
grown = _merge_two(neighbor, slice_seg, members=origin, group_id=new_id)
else:
# Moved slice = trailing `count` words; leftover = the head.
slice_seg = _slice_segment_words(
source, g1 - count + 1, g1, src_word_count - count, src_word_count - 1)
leftover = _slice_segment_words(
source, g0, g1 - count, 0, src_word_count - count - 1)
grown = _merge_two(slice_seg, neighbor, members=origin, group_id=new_id)
# Stamp the leftover: it renders as an ORDINARY card (no merge_group_id, or it
# would wrongly render as its own collapsed "Merged" card). It links to the
# grown card by storing the grown card's group id in partial_merge_leftover;
# undo is driven from the grown card, which finds and removes this sibling.
leftover.merge_group_id = None
leftover.merge_members = None
leftover.partial_merge_leftover = new_id
leftover.match_score = source.match_score
# Invalidate stale per-word MFA on BOTH cards β boundaries moved, so the old
# word timestamps no longer correspond. MFA recomputes lazily. Keep grown's
# repeated_* (computed by _merge_two from the union ranges).
grown.words = None
grown.transcribed_text = ""
leftover.words = None
leftover.transcribed_text = ""
if take_from == "head":
# Reading order: grown (was previous) then leftover (trimmed source).
replacement = [grown, leftover]
lo = neighbor_idx # == source_idx - 1
else:
# Reading order: leftover (trimmed source) then grown (was next).
replacement = [leftover, grown]
lo = source_idx
new_segments = list(segments[:lo]) + replacement + list(segments[lo + 2:])
for new_idx, seg in enumerate(new_segments):
seg.segment_number = new_idx + 1
# Both consumed slots collapse back to two new slots at [lo, lo + 1] β the
# index map is identity (one in, one out per slot), so other cards just shift
# by 0 (a partial merge keeps the segment count constant).
unchanged_new_indices = {}
for old_idx in range(n):
if old_idx in (lo, lo + 1):
continue
unchanged_new_indices[old_idx] = old_idx
report = {
"merge_group_id": new_id,
"affected_original_indices": [lo, lo + 1],
"unchanged_new_indices": unchanged_new_indices,
"partial_merge": True,
}
return new_segments, report
def undo_merge_group(segments: list[SegmentInfo],
segment_idx: int) -> tuple[list[SegmentInfo], dict]:
"""Restore the stored original members of a merge card.
Handles both kinds of merge:
* **Whole-card merge** β one collapsed card carrying ``merge_members``.
Restores the members in place of that single card.
* **Partial merge** β a *grown* card (``merge_group_id`` + ``merge_members``
= the two originals) plus an ordinary-looking *leftover* card that stores
the grown card's id in ``partial_merge_leftover`` (no ``merge_group_id``,
no members). Undo is driven from the grown card; both slots are removed
and the two original members reinserted. (Clicking the leftover is also
handled defensively.)
"""
n = len(segments)
if n == 0:
raise ValueError("No segments available.")
if segment_idx < 0 or segment_idx >= n:
raise ValueError("Merge group index is out of range.")
card = segments[segment_idx]
# The pairing id is the grown card's merge_group_id, which the leftover stores
# in partial_merge_leftover. Resolve it from whichever card was clicked.
group_id = card.merge_group_id or card.partial_merge_leftover
if not group_id:
raise ValueError("This segment is not a merge group.")
# Grown / collapsed card carries the members; a partial leftover links by id.
members_card_idx = segment_idx if card.merge_members else None
leftover_idx = segment_idx if card.partial_merge_leftover == group_id else None
for i, sib in enumerate(segments):
if i == segment_idx:
continue
if sib.merge_members and sib.merge_group_id == group_id and members_card_idx is None:
members_card_idx = i
if sib.partial_merge_leftover == group_id and leftover_idx is None:
leftover_idx = i
is_partial = leftover_idx is not None and members_card_idx is not None
if not is_partial:
# Whole-card merge: the clicked card must itself carry the members.
if not card.merge_members:
raise ValueError("This segment is not a merge group.")
members_card_idx = segment_idx
consumed = [segment_idx]
else:
consumed = sorted({members_card_idx, leftover_idx})
members = segments[members_card_idx].merge_members
if not members:
raise ValueError("This segment is not a merge group.")
restored = []
for i, m in enumerate(members):
seg = SegmentInfo.from_json_dict(m, index=i)
seg.merge_group_id = None
seg.merge_members = None
seg.partial_merge_leftover = None
restored.append(seg)
insert_at = consumed[0]
consumed_set = set(consumed)
tail = [j for j in range(insert_at, n) if j not in consumed_set]
new_segments = (list(segments[:insert_at]) + restored
+ [segments[j] for j in tail])
for new_idx, seg in enumerate(new_segments):
seg.segment_number = new_idx + 1
# Map every untouched old index to its position in new_segments by walking
# the same construction order (head untouched, restored block, then tail).
unchanged_new_indices = {old: old for old in range(insert_at) if old not in consumed_set}
base = insert_at + len(restored)
for offset, old_idx in enumerate(tail):
unchanged_new_indices[base + offset] = old_idx
report = {
"undo_merge_group_id": group_id,
"undo_original_index": insert_at,
"undo_original_indices": consumed,
"restored_count": len(restored),
"partial_merge": is_partial,
"unchanged_new_indices": unchanged_new_indices,
}
return new_segments, report
|