Download data.py from clr/toh26: direct link, hf CLI and curl.
- Browser
- Download file 17.8 kB
-
https://huggingface.co/spaces/clr/toh26/resolve/main/data.py
- Command line
-
hf download hf://spaces/clr/toh26/data.py
-
curl -L -o data.py https://huggingface.co/spaces/clr/toh26/resolve/main/data.py
17.8 kB
| import base64 | |
| import io | |
| import itertools | |
| import json | |
| import os | |
| import random | |
| import unicodedata | |
| import uuid | |
| import gradio as gr | |
| from collections import defaultdict | |
| import soundfile as sf | |
| import numpy as np | |
| import librosa | |
| import librosa.display | |
| from matplotlib.figure import Figure | |
| from matplotlib.ticker import FuncFormatter | |
| import matplotlib.patheffects as pe | |
| # this builds exercises specifically to the structure of the original book | |
| JSON_REL = os.path.join("skynjun_shsh", "skynjun_shsh_AlignDict__HF-MANUAL.json") | |
| PAD_S = 0.5 # padding added to each side of an item's start/end - best to have a lot | |
| LENGDIR = ["langt", "stutt"] | |
| LENGD_SEL = [("löng", "langt"), ("stutt", "stutt")] # is this grammer idk at all..... | |
| # nicer: hint printed either side of textbox which user fills in -- formatting maybe weird tho | |
| # txtbox: backup plan - prefill the textbox with the hint e.g. b__la, user edits underscores | |
| HINT_STYLE = "nicer" | |
| # for nicer hints - | |
| # assuming 3.2em is good for abt 2 underscores & 9em around 5-10 chars, maybe adjust | |
| BASE_CSS = """ | |
| .gradio-container { max-width: 1250px !important; } | |
| .ex-row { gap: 6px !important; align-items: center; border-bottom: 1px solid var(--border-color-primary); padding: 2px 0; flex-wrap: nowrap !important; } | |
| .ex-row .block { padding: 2px !important; } | |
| .ex-num p { font-size: 1.2em; margin: 0; text-align: center; } | |
| .play-btn { min-width: 44px !important; max-width: 52px; height: 40px; padding: 0 !important; font-size: 1.2em; } | |
| .radio-row { gap: 12px !important; flex-wrap: nowrap !important; } | |
| .fb p { margin: 0; line-height: 1.35; } | |
| .hint-wrap { display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; | |
| align-items: center; justify-content: flex-start !important; gap: 0 !important; | |
| width: max-content !important; max-width: 100%; min-width: 0 !important; padding: 0 !important; } | |
| .hint-wrap::before, .hint-wrap::after { font-size: 1.3em; white-space: nowrap; flex: 0 0 auto; | |
| margin: 0 !important; padding: 0 !important; } | |
| .hint-wrap > .hint-box { flex: 0 0 auto !important; width: 3.2em !important; min-width: 0 !important; | |
| margin: 0 2px !important; padding: 0 !important; } | |
| .hint-box input, .hint-box textarea { text-align: center; } | |
| .plain-box { flex: 0 0 auto !important; width: 9em !important; min-width: 0 !important; } | |
| .hint-box input, .plain-box input, .hint-box textarea, .plain-box textarea { | |
| padding: 4px 6px !important; min-height: 0 !important; height: 2em !important; line-height: 1.2 !important; } | |
| .chapter-head h2 { font-size: 2em; margin: 0.2em 0 0.4em 0; } | |
| """ | |
| # without manual correction 18 tokens fail this | |
| # (more are aligned to wrong/bad audio but not caught here) | |
| def has_audio(d: dict) -> bool: | |
| return d.get("start") is not None and d.get("end") is not None | |
| def load_book(folder: str) -> list[dict]: | |
| with open(os.path.join(folder, JSON_REL), encoding="utf-8") as f: | |
| return json.load(f)["bók"] | |
| def kafli_choices(book): | |
| return [(f"{k['n_kaf']}. kafli", k["n_kaf"]) for k in book] | |
| def aefing_choices(book, n_kaf): | |
| kaf = next(k for k in book if k["n_kaf"] == n_kaf) | |
| return [(f"{e['n_æf']}. {e['hljóðskynjun']}", e["n_æf"]) for e in kaf["kafli"]] | |
| def chapter_header(book, n_kaf, n_aef) -> str: | |
| kaf = next(k for k in book if k["n_kaf"] == n_kaf) | |
| aef = next(e for e in kaf["kafli"] if e["n_æf"] == n_aef) | |
| # key name guessed: accepts either spelling | |
| lys = kaf.get("lýsing") or kaf.get("lysing") or "" | |
| txt = f"## **{n_kaf}. kafli**" | |
| if lys: | |
| txt += f"\n\n### {lys}" | |
| return txt + f"\n\n## {aef['hljóðskynjun']}" | |
| def sound_choices(book): | |
| seen = {} | |
| for k in book: | |
| for e in k["kafli"]: | |
| sides = e["hljóðskynjun"].split(" — ") | |
| for x in e["æfingar"]: | |
| if len(sides) == len(x["hljóð"]): | |
| for sound, side in zip(x["hljóð"], sides): | |
| seen.setdefault(sound, side.split(" [")[0].strip()) | |
| return [(f"{orth} [{s}]", s) for s, orth in seen.items()] | |
| def build_pool(book) -> dict: | |
| pool = defaultdict(list) | |
| for k in book: | |
| for e in k["kafli"]: | |
| for x in e["æfingar"]: | |
| for d in x["dæmi"]: | |
| if has_audio(d): | |
| pool[(d["svar"], d["lengd"])].append(d) | |
| return pool | |
| # make the user pick settings that generate a reasonable number of exercises | |
| def validate_selection(sounds, lengdir) -> str | None: | |
| ns, nl = len(sounds or []), len(lengdir or []) | |
| if ns == 0 or nl == 0: | |
| return "Veldu að minnsta kosti 1 hljóð ásamt lengd(um)!" | |
| if nl == 2 and ns > 3: | |
| return f"Þú valdir of mörg hljóð! ({ns})" | |
| if nl == 1 and not 2 <= ns <= 4: | |
| return f"Þú valdir of mörg hljóð! ({ns})" | |
| return None | |
| # if the user picked 3 sounds and both lengths, only generate 2 of each type | |
| # so they get 12 exercises instead of 18 | |
| # but otherwise generate 3 of each type | |
| def items_per_condition(n_sounds: int, n_lengdir: int) -> int: | |
| return 2 if (n_sounds, n_lengdir) == (3, 2) else 3 | |
| # generates randomly - | |
| # user can get a new exercise even for the same settings | |
| def sample_exercises(pool, folder, sounds, lengdir, rng=random): | |
| k = items_per_condition(len(sounds), len(lengdir)) # usually 3 unless thats too many | |
| used, chosen, warnings = set(), [], [] | |
| for sound, lengd in itertools.product(sounds, lengdir): | |
| cands = list(pool.get((sound, lengd), [])) | |
| rng.shuffle(cands) | |
| got = 0 | |
| for d in cands: | |
| w = _norm(d["orð"]) | |
| if w not in used: # ensure unique word set, not just unique tokens | |
| used.add(w); chosen.append(d); got += 1 | |
| if got == k: | |
| break | |
| if got < k: # in case missing datas | |
| warnings.append(f"fann aðeins {got} / {k} orð fyrir [{sound}] * {lengd}") | |
| rng.shuffle(chosen) | |
| exercises = [make_exercise(d, folder) for d in chosen] | |
| for ex in exercises: | |
| ex["sounds"] = list(sounds) | |
| return exercises, warnings | |
| def make_exercises_for_chapter(book, folder, n_kaf, n_aef, lengd) -> list[dict]: | |
| kaf = next(k for k in book if k["n_kaf"] == n_kaf) | |
| aef = next(e for e in kaf["kafli"] if e["n_æf"] == n_aef) | |
| ex = next(x for x in aef["æfingar"] if x["lengd"] == lengd) | |
| return [make_exercise(d, folder) for d in ex["dæmi"] if has_audio(d)] | |
| def clip_audio(folder: str, item: dict): | |
| path = os.path.join(folder, item["audio_path"]) | |
| sr = sf.info(path).samplerate | |
| start = max(0, int(round((item["start"] - PAD_S) * sr))) | |
| stop = int(round((item["end"] + PAD_S) * sr)) | |
| data, sr = sf.read(path, start=start, stop=stop, dtype="int16") | |
| return sr, data | |
| SPEC_FMAX = 5000 | |
| SPEC_CMAP = "viridis" | |
| SPEC_TOP_DB = 50 | |
| TRIM_DB = 30 # silence threshold | |
| SPEC_PEAK_PCT = 99.5 # bc my computer likes to record clicks even when i dont click anything | |
| def _fmt_tick(v, _pos=None) -> str: | |
| return f"{round(v, 2):g}" # for tiny graphs | |
| def _spec_html(y, sr, num) -> str: | |
| y = np.asarray(y, dtype=np.float32) | |
| if y.ndim > 1: | |
| y = y.mean(axis=1) | |
| n_fft = 2 ** int(round(np.log2(0.02 * sr))) | |
| if y.size < n_fft or not np.any(y): | |
| return "" | |
| hop = n_fft // 4 | |
| mag = np.abs(librosa.stft(y, n_fft=n_fft, hop_length=hop)) | |
| S = librosa.amplitude_to_db(mag, ref=np.percentile(mag, SPEC_PEAK_PCT), top_db=None) | |
| fig = Figure(figsize=(3.0, 1.35)) # Figure (not pyplot) => no global state, thread-safe | |
| ax = fig.subplots() | |
| librosa.display.specshow(S, sr=sr, hop_length=hop, x_axis="time", y_axis="hz", ax=ax, | |
| cmap=SPEC_CMAP, vmin=-SPEC_TOP_DB, vmax=0) | |
| fmax = min(SPEC_FMAX, sr / 2) | |
| ax.set_ylim(0, fmax) | |
| ax.set_yticks(np.arange(0, fmax + 1, 2000)) | |
| ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _p: f"{v / 1000:g}")) | |
| ax.xaxis.set_major_formatter(FuncFormatter(_fmt_tick)) | |
| ax.set_xlabel("") | |
| ax.set_ylabel("kHz", fontsize=7, labelpad=1) | |
| ax.tick_params(labelsize=6, length=2, pad=1) | |
| ax.text(0.04, 0.94, str(num), transform=ax.transAxes, color="white", fontsize=13, | |
| fontweight="bold", ha="left", va="top", | |
| path_effects=[pe.withStroke(linewidth=2, foreground="black")]) | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=120, bbox_inches="tight", pad_inches=0.02) | |
| b64 = base64.b64encode(buf.getvalue()).decode() | |
| return f'<img src="data:image/png;base64,{b64}" style="display:block;max-width:100%;height:auto;">' | |
| def sample_spec_html(clip, num) -> str: | |
| sr, arr = clip | |
| return _spec_html(arr.astype(np.float32) / 32768.0, sr, num) | |
| def user_spec_html(path, num) -> str: | |
| if not path: | |
| return "" | |
| try: | |
| y, sr = librosa.load(path, sr=None, mono=True) | |
| y, _ = librosa.effects.trim(y, top_db=TRIM_DB) | |
| return _spec_html(y, sr, num) | |
| except Exception as e: | |
| print("spectrogram problem:", e) | |
| return "" | |
| # hopefully, | |
| # this makes playable audio without a whole gradio audio component for it | |
| # bc those are huge and cant be reduced enough in size. | |
| # ¡¡ not entirely sure will work on all browsers/devices !! | |
| def audio_data_uri(clip) -> str: | |
| sr, arr = clip | |
| buf = io.BytesIO() | |
| sf.write(buf, arr, sr, format="WAV", subtype="PCM_16") | |
| return "data:audio/wav;base64," + base64.b64encode(buf.getvalue()).decode() | |
| def make_exercise(d: dict, folder: str) -> dict: | |
| pre, _, post = d["spurning"].partition("__") | |
| clip = clip_audio(folder, d) | |
| return dict( | |
| prefix=pre, suffix=post, | |
| word=d["orð"], answer=d["svar"], lengd=d["lengd"], | |
| sounds=list(d["original_target_sounds"]), | |
| audio_uri=audio_data_uri(clip), | |
| clip=clip, | |
| ) | |
| def _norm(s: str) -> str: | |
| return unicodedata.normalize("NFC", (s or "")).strip().casefold() | |
| # check the user's typed word with and without hint format | |
| def word_ok(item: dict, typed: str, hint: bool) -> bool: | |
| target = _norm(item["word"]) | |
| if _norm(typed) == target: | |
| return True | |
| return hint and _norm(item["prefix"] + (typed or "").strip() + item["suffix"]) == target | |
| # to point out non-answers as non-answers rather than incorrect -- | |
| def _word_blank(item: dict, typed: str, hint: bool) -> bool: | |
| t = (typed or "").strip() | |
| if not t: | |
| return True | |
| return hint and HINT_STYLE != "nicer" and t == (item["prefix"] + "__" + item["suffix"]).strip() | |
| def _cls(s: str) -> str: | |
| return s.encode("utf-8").hex() | |
| def hint_css(book) -> str: | |
| pres, sufs = set(), set() | |
| for k in book: | |
| for e in k["kafli"]: | |
| for x in e["æfingar"]: | |
| for d in x["dæmi"]: | |
| p, _, q = d["spurning"].partition("__") | |
| if p: pres.add(p) | |
| if q: sufs.add(q) | |
| esc = lambda t: t.replace("\\", "\\\\").replace('"', '\\"') | |
| rules = [f'.hint-wrap.pre-{_cls(p)}::before {{ content: "{esc(p)}"; }}' for p in pres] | |
| rules += [f'.hint-wrap.suf-{_cls(q)}::after {{ content: "{esc(q)}"; }}' for q in sufs] | |
| return "\n".join(rules) | |
| # to listen without gradio audio component, hopefully it works on most devices... | |
| def _play_js(uri: str) -> str: | |
| key = uuid.uuid4().hex | |
| return f"""() => {{ | |
| const w = window; w.__clips = w.__clips || {{}}; | |
| for (const [k, a] of Object.entries(w.__clips)) if (k !== '{key}') a.pause(); | |
| let a = w.__clips['{key}']; | |
| if (!a) {{ a = new Audio('{uri}'); w.__clips['{key}'] = a; }} | |
| if (a.paused) {{ a.currentTime = 0; a.play(); }} else {{ a.pause(); }} | |
| }}""" | |
| def new_exercise_set(items, hint, ask_lengd=False, ask_sound=True, header=None, spectro=False) -> dict: | |
| return {"items": items, "hint": bool(hint), "ask_lengd": ask_lengd, | |
| "ask_sound": ask_sound, "gen": uuid.uuid4().hex, "header": header, | |
| "spectro": bool(spectro)} | |
| # to refresh exercises, destroy all current widgets then make new ones | |
| # (otherwise if the user recorded any audio that tends to be sticky) | |
| def wire_generate(btn, state, inputs, make, extra_outputs=(), guard=None): | |
| def blank(*args): | |
| return gr.skip() if (guard and guard(*args)) else None | |
| btn.click(blank, inputs, state).then(make, inputs, [state, *extra_outputs]) | |
| def exercise_panel() -> gr.State: | |
| """Create the exercise area (call inside a tab). Returns the State that drives it.""" | |
| state = gr.State(None) | |
| def show(st): | |
| if not st: | |
| gr.Markdown("Veldu hljóð og smelltu á **Byrja**") | |
| return | |
| items, use_hint = st["items"], st["hint"] | |
| ask_lengd, ask_sound, gen = st["ask_lengd"], st["ask_sound"], st["gen"] | |
| spectro = st.get("spectro", False) | |
| k = lambda name, i: f"{gen}-{name}-{i}" # unique per set => fresh, empty widgets | |
| texts, sound_radios, lengd_radios, fbs = [], [], [], [] | |
| if st.get("header"): | |
| gr.Markdown(st["header"], elem_classes="chapter-head") | |
| for i, it in enumerate(items, 1): | |
| with gr.Row(elem_classes="ex-row", equal_height=True): | |
| gr.Markdown(f"**{i}**", elem_classes="ex-num", scale=0, min_width=30) | |
| with gr.Column(scale=1 if spectro else 0, min_width=230 if spectro else 44): | |
| play = gr.Button("🔊", size="sm", scale=0, min_width=44, elem_classes="play-btn") | |
| play.click(None, None, None, js=_play_js(it["audio_uri"])) | |
| if spectro: | |
| gr.HTML(sample_spec_html(it["clip"], i), key=k("sspec", i)) | |
| with gr.Column(scale=2, min_width=230 if spectro else 100): | |
| rec = gr.Audio(sources=["microphone"], type="filepath", show_label=False, | |
| container=False, min_width=100, key=k("rec", i), | |
| waveform_options=gr.WaveformOptions(show_recording_waveform=False)) | |
| if spectro: | |
| uspec = gr.HTML("", key=k("uspec", i)) | |
| rec.change(lambda p, n=i: user_spec_html(p, n), rec, uspec) | |
| with gr.Column(scale=0, min_width=150): | |
| texts.append(_word_box(it, use_hint, k("txt", i))) | |
| if ask_sound or ask_lengd: | |
| with gr.Column(scale=2, min_width=220): | |
| with gr.Row(elem_classes="radio-row"): | |
| if ask_sound: | |
| sound_radios.append(gr.Radio( | |
| [(f"[{s}]", s) for s in it["sounds"]], show_label=False, | |
| container=False, min_width=0, key=k("snd", i))) | |
| if ask_lengd: | |
| lengd_radios.append(gr.Radio( | |
| LENGDIR, show_label=False, container=False, | |
| min_width=0, key=k("len", i))) | |
| fbs.append(gr.Markdown("", elem_classes="fb", scale=2, min_width=150, key=k("fb", i))) | |
| score_btn = gr.Button("Athuga svör", variant="primary", key=f"{gen}-score") | |
| summary = gr.Markdown("", key=f"{gen}-summary") | |
| def score(*vals): | |
| n = len(items) | |
| typed = vals[:n] | |
| rest = vals[n:] | |
| sel = rest[:n] if ask_sound else [None] * n | |
| rest = rest[n:] if ask_sound else rest | |
| lens = rest[:n] if ask_lengd else [None] * n | |
| msgs, n_s, n_l, n_w = [], 0, 0, 0 | |
| for it, s, w, l in zip(items, sel, typed, lens): | |
| s_ok, l_ok = s == it["answer"], l == it["lengd"] | |
| w_ok = word_ok(it, w, use_hint) | |
| n_s += s_ok; n_w += w_ok; n_l += l_ok | |
| def line(label, ok, blank, answer): | |
| if ok: return f"✅ {label}" | |
| if blank: return f"🟣 {label}: Ekkert svar" | |
| return f"❌ {label} → **{answer}**" | |
| lines = [line("orð", w_ok, _word_blank(it, w, use_hint), it["word"])] | |
| if ask_sound: | |
| lines.append(line("hljóð", s_ok, not s, f"[{it['answer']}]")) | |
| if ask_lengd: | |
| lines.append(line("lengd", l_ok, not l, it["lengd"])) | |
| msgs.append(" \n".join(lines)) | |
| parts = ([f"Hljóð: {n_s}/{n}"] if ask_sound else []) \ | |
| + ([f"Lengdir: {n_l}/{n}"] if ask_lengd else []) + [f"Orð: {n_w}/{n}"] | |
| perfect = n_w == n and (n_s == n or not ask_sound) and (n_l == n or not ask_lengd) | |
| total = "**" + " · ".join(parts) + "**" + (" 🎉" if perfect else "") | |
| return [gr.update(value=m) for m in msgs] + [total] | |
| score_btn.click(score, texts + sound_radios + lengd_radios, fbs + [summary]) | |
| return state | |
| # hopefully the css will work eventually... but.... | |
| def _word_box(it, use_hint, key): | |
| if use_hint and HINT_STYLE == "nicer": | |
| classes = ["hint-wrap"] | |
| if it["prefix"]: classes.append(f"pre-{_cls(it['prefix'])}") | |
| if it["suffix"]: classes.append(f"suf-{_cls(it['suffix'])}") | |
| with gr.Column(elem_classes=classes, min_width=0): | |
| return gr.Textbox(show_label=False, container=False, placeholder="__", key=key, | |
| lines=1, max_lines=1, elem_classes="hint-box") | |
| if use_hint: # fill in the blank | |
| return gr.Textbox(it["prefix"] + "__" + it["suffix"], show_label=False, container=False, key=key, | |
| lines=1, max_lines=1, elem_classes="plain-box") | |
| return gr.Textbox(show_label=False, container=False, placeholder="Orð", key=key, | |
| lines=1, max_lines=1, elem_classes="plain-box") | |