Spaces:
Runtime error
feat(dictation): word-level dictation scoring with tolerant normalization (#8)
Browse files## What & why
The ML core of M2 dictation: compares the learner's typed answer to the
reference and produces a word-level diagnosis. Pure and network-free,
reused
by M5 (pronunciation).
## Design
- Tolerance calibrated to what dictation tests — hearing/spelling the
right
word. Case, punctuation, spacing ignored; contractions ("don't" = "do
not")
and US/UK spelling ("colour" = "color") accepted as transcription
variants;
the word itself counts ("sheep" vs "ship") — the signal dictation must
catch.
- Word-aligned feedback via Levenshtein backtrace (jiwer): reference
shown with
substitutions/omissions/insertions marked, plus WER and an error
breakdown.
## Changes
- services/dictation.py: normalize_words, score_dictation →
DictationResult
(WER, S/D/I/H counts, per-word ops)
- main.py: render_dictation_feedback (marked reference + breakdown),
pure+tested
- Tests: +8 (82 total) — normalization, perfect-despite-variants,
phonetic
confusion caught, deletion/insertion alignment, empty
hypothesis/reference,
feedback rendering
## How to test
uv run pytest
## Out of scope
Listening tab wiring ASR → scoring → feedback (PR 4).
- pyproject.toml +1 -1
- src/tutor/app/main.py +28 -0
- src/tutor/services/dictation.py +156 -0
- tests/test_dictation.py +60 -0
- uv.lock +2 -2
|
@@ -10,6 +10,7 @@ dependencies = [
|
|
| 10 |
# Runtime deps only — keep the Docker image (HF Space, cpu-basic) lean.
|
| 11 |
"gradio>=6.17,<7",
|
| 12 |
"huggingface-hub>=1.18.0",
|
|
|
|
| 13 |
"numpy>=2.4.6",
|
| 14 |
"onnxruntime>=1.26.0",
|
| 15 |
"openai>=2.41,<3", # single OpenAI-compatible client: Gemini / Mistral / Ollama / OpenAI (ADR 0002)
|
|
@@ -41,7 +42,6 @@ train = [
|
|
| 41 |
]
|
| 42 |
asr = [
|
| 43 |
"faster-whisper>=1.0",
|
| 44 |
-
"jiwer>=3.0",
|
| 45 |
"soundfile>=0.12",
|
| 46 |
]
|
| 47 |
|
|
|
|
| 10 |
# Runtime deps only — keep the Docker image (HF Space, cpu-basic) lean.
|
| 11 |
"gradio>=6.17,<7",
|
| 12 |
"huggingface-hub>=1.18.0",
|
| 13 |
+
"jiwer>=4.0.0",
|
| 14 |
"numpy>=2.4.6",
|
| 15 |
"onnxruntime>=1.26.0",
|
| 16 |
"openai>=2.41,<3", # single OpenAI-compatible client: Gemini / Mistral / Ollama / OpenAI (ADR 0002)
|
|
|
|
| 42 |
]
|
| 43 |
asr = [
|
| 44 |
"faster-whisper>=1.0",
|
|
|
|
| 45 |
"soundfile>=0.12",
|
| 46 |
]
|
| 47 |
|
|
@@ -18,6 +18,7 @@ from tutor.ml.cefr.inference import create_cefr_classifier
|
|
| 18 |
from tutor.services.asr.base import ASRError
|
| 19 |
from tutor.services.asr.factory import create_asr_client
|
| 20 |
from tutor.services.cache import FileCache
|
|
|
|
| 21 |
from tutor.services.llm.base import ChatMessage, LLMError
|
| 22 |
from tutor.services.llm.factory import create_llm_client
|
| 23 |
from tutor.services.reading import (
|
|
@@ -89,6 +90,33 @@ def render_cloze_text(text: str, blanks: list[dict]) -> str:
|
|
| 89 |
return rendered
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def build_app(settings: Settings | None = None) -> gr.Blocks:
|
| 93 |
settings = settings or get_settings()
|
| 94 |
llm = create_llm_client(settings)
|
|
|
|
| 18 |
from tutor.services.asr.base import ASRError
|
| 19 |
from tutor.services.asr.factory import create_asr_client
|
| 20 |
from tutor.services.cache import FileCache
|
| 21 |
+
from tutor.services.dictation import DictationResult
|
| 22 |
from tutor.services.llm.base import ChatMessage, LLMError
|
| 23 |
from tutor.services.llm.factory import create_llm_client
|
| 24 |
from tutor.services.reading import (
|
|
|
|
| 90 |
return rendered
|
| 91 |
|
| 92 |
|
| 93 |
+
def render_dictation_feedback(result: DictationResult) -> str:
|
| 94 |
+
"""Reference sentence with errors marked, plus WER and an error breakdown."""
|
| 95 |
+
pieces: list[str] = []
|
| 96 |
+
for op in result.ops:
|
| 97 |
+
if op.op == "equal":
|
| 98 |
+
pieces.append(op.ref_word or "")
|
| 99 |
+
elif op.op == "substitute":
|
| 100 |
+
pieces.append(f"~~{op.hyp_word}~~ **{op.ref_word}**")
|
| 101 |
+
elif op.op == "delete":
|
| 102 |
+
pieces.append(f"**[missing: {op.ref_word}]**")
|
| 103 |
+
elif op.op == "insert":
|
| 104 |
+
pieces.append(f"~~{op.hyp_word}~~")
|
| 105 |
+
marked = " ".join(piece for piece in pieces if piece)
|
| 106 |
+
|
| 107 |
+
accuracy = round((1.0 - result.wer) * 100)
|
| 108 |
+
header = f"## {accuracy}% correct" + (" 🎉" if result.is_perfect else "")
|
| 109 |
+
breakdown = (
|
| 110 |
+
f"WER {result.wer:.0%} · {result.hits} correct, "
|
| 111 |
+
f"{result.substitutions} wrong, {result.deletions} missed, "
|
| 112 |
+
f"{result.insertions} extra"
|
| 113 |
+
)
|
| 114 |
+
if result.is_perfect:
|
| 115 |
+
return f"{header}\n\n{breakdown}"
|
| 116 |
+
legend = "_Marked below: **correct word**, ~~your version~~, **[missing: word]**._"
|
| 117 |
+
return f"{header}\n\n{breakdown}\n\n{legend}\n\n> {marked}"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
def build_app(settings: Settings | None = None) -> gr.Blocks:
|
| 121 |
settings = settings or get_settings()
|
| 122 |
llm = create_llm_client(settings)
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dictation scoring (M2) — pure, network-free, reused by M5 pronunciation.
|
| 2 |
+
|
| 3 |
+
Compares what the learner typed against the reference sentence and produces a
|
| 4 |
+
word-level diagnosis. Design choices (ADR 0004 spirit):
|
| 5 |
+
|
| 6 |
+
- Tolerance is calibrated to what dictation *tests*: hearing and spelling the
|
| 7 |
+
right word. Case, punctuation and spacing are ignored; contractions
|
| 8 |
+
("don't" = "do not") and US/UK spelling ("colour" = "color") are accepted
|
| 9 |
+
(legitimate transcription variants, not listening errors); but the word
|
| 10 |
+
itself counts ("ship" vs "sheep", "there" vs "their") — that is exactly what
|
| 11 |
+
dictation must catch.
|
| 12 |
+
- Feedback is word-aligned (Levenshtein backtrace via jiwer): the reference is
|
| 13 |
+
shown with substitutions, omissions and insertions marked, plus a WER and an
|
| 14 |
+
error breakdown. Aligning two sequences of different lengths is the core bit.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import re
|
| 18 |
+
from typing import Literal
|
| 19 |
+
|
| 20 |
+
from pydantic import BaseModel
|
| 21 |
+
|
| 22 |
+
# Minimal US/UK normalisation: fold common -our/-or, -ise/-ize, -re/-er, -ll-.
|
| 23 |
+
# Not exhaustive — a pragmatic set that covers the bulk of dictation cases.
|
| 24 |
+
_UK_US_SUFFIXES = (
|
| 25 |
+
("our", "or"), # colour -> color
|
| 26 |
+
("ise", "ize"), # realise -> realize
|
| 27 |
+
("isation", "ization"),
|
| 28 |
+
("yse", "yze"), # analyse -> analyze
|
| 29 |
+
("tre", "ter"), # centre -> center
|
| 30 |
+
)
|
| 31 |
+
_CONTRACTIONS = {
|
| 32 |
+
"don't": "do not",
|
| 33 |
+
"doesn't": "does not",
|
| 34 |
+
"didn't": "did not",
|
| 35 |
+
"won't": "will not",
|
| 36 |
+
"can't": "cannot",
|
| 37 |
+
"couldn't": "could not",
|
| 38 |
+
"wouldn't": "would not",
|
| 39 |
+
"shouldn't": "should not",
|
| 40 |
+
"isn't": "is not",
|
| 41 |
+
"aren't": "are not",
|
| 42 |
+
"wasn't": "was not",
|
| 43 |
+
"weren't": "were not",
|
| 44 |
+
"haven't": "have not",
|
| 45 |
+
"hasn't": "has not",
|
| 46 |
+
"hadn't": "had not",
|
| 47 |
+
"i'm": "i am",
|
| 48 |
+
"you're": "you are",
|
| 49 |
+
"we're": "we are",
|
| 50 |
+
"they're": "they are",
|
| 51 |
+
"it's": "it is",
|
| 52 |
+
"that's": "that is",
|
| 53 |
+
"i've": "i have",
|
| 54 |
+
"i'll": "i will",
|
| 55 |
+
"let's": "let us",
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
OpType = Literal["equal", "substitute", "delete", "insert"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class WordOp(BaseModel):
|
| 62 |
+
"""One alignment operation. ref_word/hyp_word are None where not applicable."""
|
| 63 |
+
|
| 64 |
+
op: OpType
|
| 65 |
+
ref_word: str | None = None
|
| 66 |
+
hyp_word: str | None = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class DictationResult(BaseModel):
|
| 70 |
+
wer: float
|
| 71 |
+
hits: int
|
| 72 |
+
substitutions: int
|
| 73 |
+
deletions: int
|
| 74 |
+
insertions: int
|
| 75 |
+
reference_words: list[str]
|
| 76 |
+
ops: list[WordOp]
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def is_perfect(self) -> bool:
|
| 80 |
+
return self.substitutions == 0 and self.deletions == 0 and self.insertions == 0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _expand_contractions(text: str) -> str:
|
| 84 |
+
return " ".join(_CONTRACTIONS.get(token, token) for token in text.split())
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _fold_spelling(word: str) -> str:
|
| 88 |
+
for uk, us in _UK_US_SUFFIXES:
|
| 89 |
+
if word.endswith(uk):
|
| 90 |
+
return word[: -len(uk)] + us
|
| 91 |
+
return word
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def normalize_words(text: str) -> list[str]:
|
| 95 |
+
"""Lowercase, drop punctuation, expand contractions, fold UK->US, split."""
|
| 96 |
+
text = text.lower()
|
| 97 |
+
text = re.sub(r"[^a-z0-9'\s]", " ", text) # keep the apostrophe for contractions
|
| 98 |
+
text = _expand_contractions(text)
|
| 99 |
+
text = re.sub(r"[^a-z0-9\s]", " ", text) # now drop stray apostrophes
|
| 100 |
+
return [_fold_spelling(word) for word in text.split()]
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def score_dictation(reference: str, hypothesis: str) -> DictationResult:
|
| 104 |
+
"""Word-level scoring with a tolerant normalisation. Pure, no I/O."""
|
| 105 |
+
import jiwer
|
| 106 |
+
|
| 107 |
+
ref_words = normalize_words(reference)
|
| 108 |
+
hyp_words = normalize_words(hypothesis)
|
| 109 |
+
|
| 110 |
+
# jiwer needs non-empty input; handle the degenerate cases explicitly.
|
| 111 |
+
if not ref_words:
|
| 112 |
+
msg = "reference is empty after normalisation"
|
| 113 |
+
raise ValueError(msg)
|
| 114 |
+
if not hyp_words:
|
| 115 |
+
return DictationResult(
|
| 116 |
+
wer=1.0,
|
| 117 |
+
hits=0,
|
| 118 |
+
substitutions=0,
|
| 119 |
+
deletions=len(ref_words),
|
| 120 |
+
insertions=0,
|
| 121 |
+
reference_words=ref_words,
|
| 122 |
+
ops=[WordOp(op="delete", ref_word=word) for word in ref_words],
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
output = jiwer.process_words([" ".join(ref_words)], [" ".join(hyp_words)])
|
| 126 |
+
ops: list[WordOp] = []
|
| 127 |
+
for chunk in output.alignments[0]:
|
| 128 |
+
if chunk.type == "equal":
|
| 129 |
+
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
|
| 130 |
+
word = ref_words[chunk.ref_start_idx + offset]
|
| 131 |
+
ops.append(WordOp(op="equal", ref_word=word, hyp_word=word))
|
| 132 |
+
elif chunk.type == "substitute":
|
| 133 |
+
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
|
| 134 |
+
ops.append(
|
| 135 |
+
WordOp(
|
| 136 |
+
op="substitute",
|
| 137 |
+
ref_word=ref_words[chunk.ref_start_idx + offset],
|
| 138 |
+
hyp_word=hyp_words[chunk.hyp_start_idx + offset],
|
| 139 |
+
)
|
| 140 |
+
)
|
| 141 |
+
elif chunk.type == "delete":
|
| 142 |
+
for offset in range(chunk.ref_end_idx - chunk.ref_start_idx):
|
| 143 |
+
ops.append(WordOp(op="delete", ref_word=ref_words[chunk.ref_start_idx + offset]))
|
| 144 |
+
elif chunk.type == "insert":
|
| 145 |
+
for offset in range(chunk.hyp_end_idx - chunk.hyp_start_idx):
|
| 146 |
+
ops.append(WordOp(op="insert", hyp_word=hyp_words[chunk.hyp_start_idx + offset]))
|
| 147 |
+
|
| 148 |
+
return DictationResult(
|
| 149 |
+
wer=output.wer,
|
| 150 |
+
hits=output.hits,
|
| 151 |
+
substitutions=output.substitutions,
|
| 152 |
+
deletions=output.deletions,
|
| 153 |
+
insertions=output.insertions,
|
| 154 |
+
reference_words=ref_words,
|
| 155 |
+
ops=ops,
|
| 156 |
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from tutor.services.dictation import normalize_words, score_dictation
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_normalize_folds_case_punctuation_contractions_spelling() -> None:
|
| 7 |
+
assert normalize_words("Hello, World!") == ["hello", "world"]
|
| 8 |
+
assert normalize_words("I don't know") == ["i", "do", "not", "know"]
|
| 9 |
+
assert normalize_words("The colour of the centre") == ["the", "color", "of", "the", "center"]
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_perfect_despite_legitimate_variants() -> None:
|
| 13 |
+
result = score_dictation("I don't like the colour.", "I do not like the color")
|
| 14 |
+
assert result.is_perfect
|
| 15 |
+
assert result.wer == 0.0
|
| 16 |
+
assert result.substitutions == result.deletions == result.insertions == 0
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_catches_phonetic_confusion() -> None:
|
| 20 |
+
result = score_dictation("I saw a sheep on the hill", "I saw a ship on the hill")
|
| 21 |
+
assert result.substitutions == 1
|
| 22 |
+
subs = [(op.ref_word, op.hyp_word) for op in result.ops if op.op == "substitute"]
|
| 23 |
+
assert subs == [("sheep", "ship")]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_deletion_and_alignment_order() -> None:
|
| 27 |
+
result = score_dictation("the quick brown fox jumps", "the quik brown fox")
|
| 28 |
+
assert result.substitutions == 1 # quick/quik
|
| 29 |
+
assert result.deletions == 1 # jumps missing
|
| 30 |
+
assert [op.op for op in result.ops] == ["equal", "substitute", "equal", "equal", "delete"]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_insertion_is_flagged() -> None:
|
| 34 |
+
result = score_dictation("the cat sat", "the big cat sat")
|
| 35 |
+
assert result.insertions == 1
|
| 36 |
+
inserted = [op.hyp_word for op in result.ops if op.op == "insert"]
|
| 37 |
+
assert inserted == ["big"]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_empty_hypothesis_is_all_deletions() -> None:
|
| 41 |
+
result = score_dictation("hello world", " ")
|
| 42 |
+
assert result.wer == 1.0
|
| 43 |
+
assert result.deletions == 2
|
| 44 |
+
assert all(op.op == "delete" for op in result.ops)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_empty_reference_raises() -> None:
|
| 48 |
+
with pytest.raises(ValueError, match="reference is empty"):
|
| 49 |
+
score_dictation("!!!", "anything")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_render_feedback_marks_errors() -> None:
|
| 53 |
+
from tutor.app.main import render_dictation_feedback
|
| 54 |
+
|
| 55 |
+
perfect = render_dictation_feedback(score_dictation("the cat sat", "the cat sat"))
|
| 56 |
+
assert "100% correct 🎉" in perfect
|
| 57 |
+
|
| 58 |
+
marked = render_dictation_feedback(score_dictation("I saw a sheep", "I saw a ship"))
|
| 59 |
+
assert "**sheep**" in marked # correct word in bold
|
| 60 |
+
assert "~~ship~~" in marked # learner's wrong version struck through
|
|
@@ -2587,6 +2587,7 @@ source = { editable = "." }
|
|
| 2587 |
dependencies = [
|
| 2588 |
{ name = "gradio" },
|
| 2589 |
{ name = "huggingface-hub" },
|
|
|
|
| 2590 |
{ name = "numpy" },
|
| 2591 |
{ name = "onnxruntime" },
|
| 2592 |
{ name = "openai" },
|
|
@@ -2598,7 +2599,6 @@ dependencies = [
|
|
| 2598 |
[package.dev-dependencies]
|
| 2599 |
asr = [
|
| 2600 |
{ name = "faster-whisper" },
|
| 2601 |
-
{ name = "jiwer" },
|
| 2602 |
{ name = "soundfile" },
|
| 2603 |
]
|
| 2604 |
data = [
|
|
@@ -2624,6 +2624,7 @@ train = [
|
|
| 2624 |
requires-dist = [
|
| 2625 |
{ name = "gradio", specifier = ">=6.17,<7" },
|
| 2626 |
{ name = "huggingface-hub", specifier = ">=1.18.0" },
|
|
|
|
| 2627 |
{ name = "numpy", specifier = ">=2.4.6" },
|
| 2628 |
{ name = "onnxruntime", specifier = ">=1.26.0" },
|
| 2629 |
{ name = "openai", specifier = ">=2.41,<3" },
|
|
@@ -2635,7 +2636,6 @@ requires-dist = [
|
|
| 2635 |
[package.metadata.requires-dev]
|
| 2636 |
asr = [
|
| 2637 |
{ name = "faster-whisper", specifier = ">=1.0" },
|
| 2638 |
-
{ name = "jiwer", specifier = ">=3.0" },
|
| 2639 |
{ name = "soundfile", specifier = ">=0.12" },
|
| 2640 |
]
|
| 2641 |
data = [
|
|
|
|
| 2587 |
dependencies = [
|
| 2588 |
{ name = "gradio" },
|
| 2589 |
{ name = "huggingface-hub" },
|
| 2590 |
+
{ name = "jiwer" },
|
| 2591 |
{ name = "numpy" },
|
| 2592 |
{ name = "onnxruntime" },
|
| 2593 |
{ name = "openai" },
|
|
|
|
| 2599 |
[package.dev-dependencies]
|
| 2600 |
asr = [
|
| 2601 |
{ name = "faster-whisper" },
|
|
|
|
| 2602 |
{ name = "soundfile" },
|
| 2603 |
]
|
| 2604 |
data = [
|
|
|
|
| 2624 |
requires-dist = [
|
| 2625 |
{ name = "gradio", specifier = ">=6.17,<7" },
|
| 2626 |
{ name = "huggingface-hub", specifier = ">=1.18.0" },
|
| 2627 |
+
{ name = "jiwer", specifier = ">=4.0.0" },
|
| 2628 |
{ name = "numpy", specifier = ">=2.4.6" },
|
| 2629 |
{ name = "onnxruntime", specifier = ">=1.26.0" },
|
| 2630 |
{ name = "openai", specifier = ">=2.41,<3" },
|
|
|
|
| 2636 |
[package.metadata.requires-dev]
|
| 2637 |
asr = [
|
| 2638 |
{ name = "faster-whisper", specifier = ">=1.0" },
|
|
|
|
| 2639 |
{ name = "soundfile", specifier = ">=0.12" },
|
| 2640 |
]
|
| 2641 |
data = [
|