Heterogeneity2025 commited on
Commit
f4b7b59
·
verified ·
1 Parent(s): 296b763

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +54 -6
  2. app.py +141 -0
  3. game_logic.py +357 -0
  4. requirements.txt +5 -0
  5. tts.py +59 -0
README.md CHANGED
@@ -1,10 +1,58 @@
1
  ---
2
- title: MathMai
3
- emoji: 🐨
4
- colorFrom: yellow
5
- colorTo: red
6
- sdk: docker
 
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Math Adventure
3
+ emoji: 🧮
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 6.17.3
8
+ app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # 🧮 Math Adventure
14
+
15
+ A talking, tap-to-play math game for an advanced 5–6 year old. Built with
16
+ [Gradio](https://gradio.app) (by Hugging Face). Each problem is **read aloud** by a
17
+ Hugging Face text-to-speech model (`facebook/mms-tts-eng`), so a child who can't yet
18
+ read fluently can still play. The difficulty **adapts automatically** — it gets harder
19
+ as the child answers correctly and eases off after a couple of misses.
20
+
21
+ ## What it covers
22
+ - **Addition & subtraction** — within 5 → 10 → 20 → 100, plus missing-number problems (`7 + ? = 15`).
23
+ - **Counting & comparing** — count objects, "which is bigger?", and missing numbers / skip-counting.
24
+ - **Simple multiplication** — equal groups, then ×2, ×5, ×10 and small times tables.
25
+ - **Shapes & patterns** — name shapes, complete ABAB / AABB patterns, count sides.
26
+
27
+ ## How it adapts
28
+ Held in game state: `level` (1–10), `score`, and streaks.
29
+ - **Level up** after **3 correct in a row**.
30
+ - **Level down** after **2 wrong in a row** (it never punishes — just eases off).
31
+
32
+ ## Run it locally (Windows)
33
+ ```powershell
34
+ py -m pip install -r requirements.txt
35
+ py app.py
36
+ ```
37
+ Then open <http://localhost:7860>. The first launch downloads the TTS model (~145 MB) once.
38
+
39
+ ## Run the logic tests
40
+ ```powershell
41
+ py -m pip install pytest
42
+ py -m pytest test_game_logic.py
43
+ ```
44
+
45
+ ## Deploy to Hugging Face Spaces (free)
46
+ 1. Create a free account at <https://huggingface.co>.
47
+ 2. **New Space** → name it → **SDK: Gradio** → Hardware: **CPU basic (free)**.
48
+ 3. Upload `app.py`, `game_logic.py`, `tts.py`, `requirements.txt`, and this `README.md`
49
+ (drag-and-drop in the Space's **Files** tab, or `git push` to the Space repo).
50
+ 4. The Space builds automatically and gives you a public URL — open it on a tablet and play.
51
+
52
+ ## Files
53
+ | File | Purpose |
54
+ |------|---------|
55
+ | `app.py` | Gradio UI, game loop, audio, rewards. |
56
+ | `game_logic.py` | Problem generation + adaptive difficulty (pure Python, unit-tested). |
57
+ | `tts.py` | Hugging Face text-to-speech, with a silent fallback if audio can't load. |
58
+ | `test_game_logic.py` | Tests for problem generation and the difficulty engine. |
app.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Math Adventure -- a talking, tap-to-play math game for an advanced 5-6 year old.
2
+
3
+ Built with Gradio (by Hugging Face). Problems are read aloud by a Hugging Face
4
+ text-to-speech model (see tts.py). Difficulty adapts as the player gets answers
5
+ right (see game_logic.py).
6
+
7
+ Run locally: py app.py -> http://localhost:7860
8
+ Deploy: upload this folder to a Hugging Face Space (SDK: Gradio).
9
+ """
10
+
11
+ import gradio as gr
12
+
13
+ import game_logic as gl
14
+ import tts
15
+
16
+ NUM_CHOICES = 4
17
+
18
+ CSS = """
19
+ #title {text-align:center; font-size:2.4rem; margin:0.2em 0;}
20
+ #scoreboard {text-align:center; font-size:1.4rem; font-weight:700;}
21
+ #problem {
22
+ text-align:center;
23
+ font-size:5rem;
24
+ line-height:1.3;
25
+ min-height:1.6em;
26
+ padding:0.3em 0.2em;
27
+ word-break:break-word;
28
+ }
29
+ #feedback {text-align:center; font-size:1.8rem; min-height:1.4em; font-weight:700;}
30
+ .answer-btn button {
31
+ font-size:2.6rem !important;
32
+ min-height:110px !important;
33
+ border-radius:22px !important;
34
+ }
35
+ #replay button {font-size:1.3rem !important;}
36
+ .gradio-container {max-width:760px !important; margin:auto !important;}
37
+ """
38
+
39
+
40
+ def _scoreboard(state):
41
+ stars = "⭐" * min(state["score"], 20)
42
+ return (
43
+ f"Level {state['level']} 🌟 • Score: {state['score']} • "
44
+ f"Streak: {state['streak']} 🔥\n\n{stars}"
45
+ )
46
+
47
+
48
+ def _button_updates(problem):
49
+ """Return a gr.update for each answer button, given the current problem."""
50
+ choices = problem["choices"]
51
+ updates = []
52
+ for i in range(NUM_CHOICES):
53
+ if i < len(choices):
54
+ updates.append(gr.update(value=choices[i], visible=True))
55
+ else:
56
+ updates.append(gr.update(visible=False))
57
+ return updates
58
+
59
+
60
+ def _render(state, problem, feedback, spoken=None):
61
+ """Bundle every UI output for one render pass."""
62
+ audio = tts.speak(spoken if spoken is not None else problem["spoken"])
63
+ return (
64
+ state,
65
+ problem,
66
+ gr.update(value=problem["display"]), # problem display
67
+ *_button_updates(problem), # the answer buttons
68
+ gr.update(value=_scoreboard(state)), # scoreboard
69
+ gr.update(value=feedback), # feedback line
70
+ audio, # autoplayed audio
71
+ )
72
+
73
+
74
+ def start_game():
75
+ state = gl.new_state()
76
+ problem = gl.generate_problem(state["level"])
77
+ return _render(state, problem, "Tap the right answer! 👇")
78
+
79
+
80
+ def answer(idx, state, problem):
81
+ # Guard against stale clicks on a hidden button.
82
+ if idx >= len(problem["choices"]):
83
+ return _render(state, problem, "")
84
+ chosen = problem["choices"][idx]
85
+ correct = chosen == problem["answer"]
86
+ state = gl.update_state(state, correct)
87
+ if correct:
88
+ feedback = "✅ Great job! 🎉"
89
+ spoken_prefix = "Great job!"
90
+ else:
91
+ feedback = f"❌ It was {problem['answer']}. You can do it — next one!"
92
+ spoken_prefix = "Good try!"
93
+ next_problem = gl.generate_problem(state["level"])
94
+ spoken = f"{spoken_prefix} {next_problem['spoken']}"
95
+ return _render(state, next_problem, feedback, spoken=spoken)
96
+
97
+
98
+ def replay(problem):
99
+ return tts.speak(problem["spoken"])
100
+
101
+
102
+ with gr.Blocks(title="Math Adventure") as demo:
103
+ gr.Markdown("# 🧮 Math Adventure", elem_id="title")
104
+
105
+ game_state = gr.State()
106
+ problem_state = gr.State()
107
+
108
+ scoreboard = gr.Markdown(elem_id="scoreboard")
109
+ problem_display = gr.Markdown(elem_id="problem")
110
+ feedback = gr.Markdown(elem_id="feedback")
111
+
112
+ with gr.Row():
113
+ btns = [gr.Button("", elem_classes="answer-btn") for _ in range(2)]
114
+ with gr.Row():
115
+ btns += [gr.Button("", elem_classes="answer-btn") for _ in range(2)]
116
+
117
+ with gr.Row():
118
+ replay_btn = gr.Button("🔊 Hear it again", elem_id="replay")
119
+
120
+ # autoplay reads each new problem aloud; hidden so it isn't a distraction.
121
+ audio = gr.Audio(autoplay=True, visible=False)
122
+
123
+ # Outputs updated on every render pass (order must match _render()).
124
+ render_outputs = [game_state, problem_state, problem_display,
125
+ *btns, scoreboard, feedback, audio]
126
+
127
+ for i, b in enumerate(btns):
128
+ b.click(
129
+ fn=lambda gs, ps, idx=i: answer(idx, gs, ps),
130
+ inputs=[game_state, problem_state],
131
+ outputs=render_outputs,
132
+ )
133
+
134
+ replay_btn.click(fn=replay, inputs=[problem_state], outputs=[audio])
135
+
136
+ demo.load(fn=start_game, inputs=None, outputs=render_outputs)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ tts.warm_up() # pre-load the TTS model so the first round speaks promptly
141
+ demo.launch(css=CSS, theme=gr.themes.Soft())
game_logic.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Math game logic: problem generation + adaptive difficulty.
2
+
3
+ No UI / model dependencies live here so it can be unit-tested in isolation.
4
+
5
+ A "problem" is a plain dict:
6
+ {
7
+ "skill": str, # which skill this exercises
8
+ "display": str, # big text/emoji shown on screen
9
+ "spoken": str, # plain-English text read aloud by TTS
10
+ "answer": str, # the correct choice (as a string)
11
+ "choices": list[str], # 4 shuffled multiple-choice options
12
+ }
13
+
14
+ Game state is a plain dict so it can live in a Gradio gr.State:
15
+ {"level": int, "score": int, "streak": int, "wrong_streak": int, "asked": int}
16
+ """
17
+
18
+ import random
19
+
20
+ MIN_LEVEL = 1
21
+ MAX_LEVEL = 10
22
+ LEVEL_UP_STREAK = 3 # correct-in-a-row needed to level up
23
+ LEVEL_DOWN_STREAK = 2 # wrong-in-a-row before easing down
24
+
25
+ SHAPES = ["circle", "square", "triangle", "star", "heart", "diamond"]
26
+ SHAPE_EMOJI = {
27
+ "circle": "⚪",
28
+ "square": "🟦",
29
+ "triangle": "🔺",
30
+ "star": "⭐",
31
+ "heart": "❤️",
32
+ "diamond": "🔷",
33
+ }
34
+ SHAPE_SIDES = {"triangle": 3, "square": 4, "circle": 0}
35
+
36
+ # Friendly emoji used to render "count the objects" problems.
37
+ OBJECT_EMOJI = ["🍎", "🍌", "🐶", "🐱", "🐟", "🌟", "🚗", "🎈", "🍓", "🦋"]
38
+
39
+
40
+ def new_state():
41
+ """Return a fresh game-state dict."""
42
+ return {"level": 1, "score": 0, "streak": 0, "wrong_streak": 0, "asked": 0}
43
+
44
+
45
+ # --------------------------------------------------------------------------
46
+ # Difficulty / skill selection
47
+ # --------------------------------------------------------------------------
48
+
49
+ def _skills_for_level(level):
50
+ """Which skills are unlocked at a given level (harder skills appear later)."""
51
+ skills = ["add_sub", "count_compare", "shapes_patterns"]
52
+ if level >= 4:
53
+ skills.append("multiply")
54
+ return skills
55
+
56
+
57
+ def update_state(state, correct):
58
+ """Apply the adaptive rule after an answer. Mutates and returns ``state``.
59
+
60
+ Level up after LEVEL_UP_STREAK correct in a row; level down after
61
+ LEVEL_DOWN_STREAK wrong in a row. Streaks reset on the opposite outcome.
62
+ """
63
+ state["asked"] += 1
64
+ if correct:
65
+ state["score"] += 1
66
+ state["streak"] += 1
67
+ state["wrong_streak"] = 0
68
+ if state["streak"] >= LEVEL_UP_STREAK and state["level"] < MAX_LEVEL:
69
+ state["level"] += 1
70
+ state["streak"] = 0
71
+ else:
72
+ state["wrong_streak"] += 1
73
+ state["streak"] = 0
74
+ if state["wrong_streak"] >= LEVEL_DOWN_STREAK and state["level"] > MIN_LEVEL:
75
+ state["level"] -= 1
76
+ state["wrong_streak"] = 0
77
+ return state
78
+
79
+
80
+ # --------------------------------------------------------------------------
81
+ # Helpers
82
+ # --------------------------------------------------------------------------
83
+
84
+ def _make_choices(answer, distractors, n=4):
85
+ """Build n unique shuffled string choices that always include ``answer``."""
86
+ answer_str = str(answer)
87
+ choices = [answer_str]
88
+ for d in distractors:
89
+ d = str(d)
90
+ if d not in choices:
91
+ choices.append(d)
92
+ if len(choices) >= n:
93
+ break
94
+ # Pad if we still came up short (e.g. tiny numbers near 0): step outward.
95
+ if len(choices) < n:
96
+ try:
97
+ base = int(float(answer))
98
+ k = 1
99
+ while len(choices) < n and k < 50:
100
+ cand = str(base + k)
101
+ if cand not in choices:
102
+ choices.append(cand)
103
+ k += 1
104
+ except (ValueError, TypeError):
105
+ pass
106
+ random.shuffle(choices)
107
+ return choices
108
+
109
+
110
+ def _numeric_distractors(answer, spread=3, allow_negative=False):
111
+ """Plausible near-miss numbers around ``answer``."""
112
+ candidates = set()
113
+ attempts = 0
114
+ while len(candidates) < 6 and attempts < 40:
115
+ attempts += 1
116
+ delta = random.randint(-spread, spread)
117
+ val = answer + delta
118
+ if val == answer:
119
+ continue
120
+ if val < 0 and not allow_negative:
121
+ continue
122
+ candidates.add(val)
123
+ return list(candidates)
124
+
125
+
126
+ # --------------------------------------------------------------------------
127
+ # Skill generators -- each returns a problem dict
128
+ # --------------------------------------------------------------------------
129
+
130
+ def _gen_add_sub(level):
131
+ if level <= 1:
132
+ a, b = random.randint(0, 5), random.randint(0, 5)
133
+ op = "+"
134
+ elif level == 2:
135
+ a, b = random.randint(0, 10), random.randint(0, 10)
136
+ op = "+"
137
+ elif level == 3:
138
+ a = random.randint(2, 10)
139
+ b = random.randint(0, a) # subtraction within 10, no negatives
140
+ op = "-"
141
+ elif level == 4:
142
+ op = random.choice(["+", "-"])
143
+ if op == "+":
144
+ a, b = random.randint(0, 20), random.randint(0, 20 - 0)
145
+ b = random.randint(0, 20 - a) if a <= 20 else 0
146
+ else:
147
+ a = random.randint(2, 20)
148
+ b = random.randint(0, a)
149
+ elif level == 5:
150
+ # missing addend: a + ? = total
151
+ a = random.randint(1, 15)
152
+ b = random.randint(1, 20 - a) if a < 20 else 1
153
+ total = a + b
154
+ return {
155
+ "skill": "add_sub",
156
+ "display": f"{a} + ? = {total}",
157
+ "spoken": f"{a} plus what makes {total}?",
158
+ "answer": str(b),
159
+ "choices": _make_choices(b, _numeric_distractors(b, 3)),
160
+ }
161
+ elif level <= 7:
162
+ op = random.choice(["+", "-"])
163
+ if op == "+":
164
+ a = random.randint(10, 50)
165
+ b = random.randint(1, 99 - a)
166
+ else:
167
+ a = random.randint(10, 99)
168
+ b = random.randint(1, a)
169
+ else: # level 8-10
170
+ op = random.choice(["+", "-"])
171
+ if op == "+":
172
+ a = random.randint(20, 80)
173
+ b = random.randint(10, 99 - a) if a < 99 else 10
174
+ else:
175
+ a = random.randint(30, 99)
176
+ b = random.randint(10, a)
177
+
178
+ answer = a + b if op == "+" else a - b
179
+ spread = 3 if answer < 20 else max(3, answer // 10)
180
+ return {
181
+ "skill": "add_sub",
182
+ "display": f"{a} {op} {b} = ?",
183
+ "spoken": f"What is {a} {'plus' if op == '+' else 'minus'} {b}?",
184
+ "answer": str(answer),
185
+ "choices": _make_choices(answer, _numeric_distractors(answer, spread)),
186
+ }
187
+
188
+
189
+ def _gen_count_compare(level):
190
+ if level <= 1:
191
+ n = random.randint(1, 5)
192
+ emoji = random.choice(OBJECT_EMOJI)
193
+ return {
194
+ "skill": "count_compare",
195
+ "display": emoji * n,
196
+ "spoken": "How many do you see?",
197
+ "answer": str(n),
198
+ "choices": _make_choices(n, _numeric_distractors(n, 2)),
199
+ }
200
+ if level == 2:
201
+ n = random.randint(3, 10)
202
+ emoji = random.choice(OBJECT_EMOJI)
203
+ return {
204
+ "skill": "count_compare",
205
+ "display": emoji * n,
206
+ "spoken": "How many do you see?",
207
+ "answer": str(n),
208
+ "choices": _make_choices(n, _numeric_distractors(n, 2)),
209
+ }
210
+ if level == 3:
211
+ a, b = random.sample(range(0, 11), 2)
212
+ bigger = max(a, b)
213
+ return {
214
+ "skill": "count_compare",
215
+ "display": f"{a} or {b}",
216
+ "spoken": f"Which number is bigger, {a} or {b}?",
217
+ "answer": str(bigger),
218
+ "choices": _make_choices(
219
+ bigger, [min(a, b)] + _numeric_distractors(bigger, 3)
220
+ ),
221
+ }
222
+ # level 4+: missing number in a sequence (skip-counting at higher levels)
223
+ if level <= 4:
224
+ step = 1
225
+ elif level <= 6:
226
+ step = random.choice([1, 2, 5])
227
+ else:
228
+ step = random.choice([2, 5, 10])
229
+ start = random.randint(0, 10) * step + random.randint(0, step)
230
+ seq = [start + step * i for i in range(4)]
231
+ hide = random.randint(1, 2) # hide an interior term
232
+ answer = seq[hide]
233
+ shown = [str(x) if i != hide else "?" for i, x in enumerate(seq)]
234
+ return {
235
+ "skill": "count_compare",
236
+ "display": " ".join(shown),
237
+ "spoken": "What number is missing?",
238
+ "answer": str(answer),
239
+ "choices": _make_choices(answer, _numeric_distractors(answer, max(2, step))),
240
+ }
241
+
242
+
243
+ def _gen_multiply(level):
244
+ if level <= 4:
245
+ groups = random.randint(2, 3)
246
+ per = random.randint(2, 3)
247
+ emoji = random.choice(OBJECT_EMOJI)
248
+ display = " ".join([emoji * per] * groups)
249
+ answer = groups * per
250
+ return {
251
+ "skill": "multiply",
252
+ "display": display,
253
+ "spoken": f"{groups} groups of {per}. How many in total?",
254
+ "answer": str(answer),
255
+ "choices": _make_choices(answer, _numeric_distractors(answer, 3)),
256
+ }
257
+ if level == 5:
258
+ table = random.choice([2, 10])
259
+ other = random.randint(1, 10)
260
+ elif level <= 7:
261
+ table = random.choice([2, 5, 10])
262
+ other = random.randint(1, 10)
263
+ else:
264
+ table = random.randint(2, 9)
265
+ other = random.randint(2, 9)
266
+ answer = table * other
267
+ return {
268
+ "skill": "multiply",
269
+ "display": f"{table} × {other} = ?",
270
+ "spoken": f"What is {table} times {other}?",
271
+ "answer": str(answer),
272
+ "choices": _make_choices(answer, _numeric_distractors(answer, max(3, table))),
273
+ }
274
+
275
+
276
+ def _gen_shapes_patterns(level):
277
+ if level <= 1:
278
+ shape = random.choice(SHAPES)
279
+ distract = random.sample([s for s in SHAPES if s != shape], 3)
280
+ return {
281
+ "skill": "shapes_patterns",
282
+ "display": SHAPE_EMOJI[shape],
283
+ "spoken": "What shape is this?",
284
+ "answer": shape,
285
+ "choices": _make_choices(shape, distract),
286
+ }
287
+ if level == 2:
288
+ target = random.choice(SHAPES)
289
+ return {
290
+ "skill": "shapes_patterns",
291
+ "display": "🔎",
292
+ "spoken": f"Which one is the {target}?",
293
+ "answer": SHAPE_EMOJI[target],
294
+ "choices": _make_choices(
295
+ SHAPE_EMOJI[target],
296
+ [SHAPE_EMOJI[s] for s in SHAPES if s != target],
297
+ ),
298
+ }
299
+ if level <= 4:
300
+ # ABAB pattern -> what comes next?
301
+ a, b = random.sample(SHAPES, 2)
302
+ ea, eb = SHAPE_EMOJI[a], SHAPE_EMOJI[b]
303
+ seq = [ea, eb, ea, eb]
304
+ nxt = ea # next after ...ea, eb, ea, eb is ea
305
+ return {
306
+ "skill": "shapes_patterns",
307
+ "display": " ".join(seq) + " ?",
308
+ "spoken": "What comes next in the pattern?",
309
+ "answer": nxt,
310
+ "choices": _make_choices(nxt, [eb] + [SHAPE_EMOJI[s] for s in SHAPES
311
+ if s not in (a, b)]),
312
+ }
313
+ if level <= 6:
314
+ # AABB pattern
315
+ a, b = random.sample(SHAPES, 2)
316
+ ea, eb = SHAPE_EMOJI[a], SHAPE_EMOJI[b]
317
+ seq = [ea, ea, eb, eb, ea, ea]
318
+ nxt = eb
319
+ return {
320
+ "skill": "shapes_patterns",
321
+ "display": " ".join(seq) + " ?",
322
+ "spoken": "What comes next in the pattern?",
323
+ "answer": nxt,
324
+ "choices": _make_choices(nxt, [ea] + [SHAPE_EMOJI[s] for s in SHAPES
325
+ if s not in (a, b)]),
326
+ }
327
+ # level 7+: count the sides of a shape
328
+ shape = random.choice([s for s in SHAPE_SIDES])
329
+ answer = SHAPE_SIDES[shape]
330
+ return {
331
+ "skill": "shapes_patterns",
332
+ "display": SHAPE_EMOJI[shape],
333
+ "spoken": f"How many sides does a {shape} have?",
334
+ "answer": str(answer),
335
+ "choices": _make_choices(answer, [0, 3, 4, 5, 6]),
336
+ }
337
+
338
+
339
+ _GENERATORS = {
340
+ "add_sub": _gen_add_sub,
341
+ "count_compare": _gen_count_compare,
342
+ "multiply": _gen_multiply,
343
+ "shapes_patterns": _gen_shapes_patterns,
344
+ }
345
+
346
+
347
+ def generate_problem(level, skill=None):
348
+ """Return a problem dict for ``level`` (1-10), optionally forcing a skill."""
349
+ level = max(MIN_LEVEL, min(MAX_LEVEL, int(level)))
350
+ if skill is None:
351
+ skill = random.choice(_skills_for_level(level))
352
+ problem = _GENERATORS[skill](level)
353
+ # Safety: guarantee the answer is among the (deduplicated) choices.
354
+ if problem["answer"] not in problem["choices"]:
355
+ problem["choices"][0] = problem["answer"]
356
+ random.shuffle(problem["choices"])
357
+ return problem
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio>=6.0
2
+ transformers>=4.44
3
+ torch>=2.2
4
+ numpy
5
+ scipy
tts.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text-to-speech using a Hugging Face model (facebook/mms-tts-eng).
2
+
3
+ The model is loaded lazily and cached so the first call pays the download/load
4
+ cost and later calls are fast. Every public function is wrapped so that if the
5
+ model (or torch) is unavailable the game keeps working silently instead of
6
+ crashing -- ``speak()`` simply returns ``None`` and the UI shows no audio.
7
+ """
8
+
9
+ MODEL_ID = "facebook/mms-tts-eng"
10
+
11
+ _model = None
12
+ _tokenizer = None
13
+ _load_failed = False
14
+
15
+
16
+ def _load():
17
+ """Load and cache the TTS model + tokenizer. Returns True on success."""
18
+ global _model, _tokenizer, _load_failed
19
+ if _model is not None:
20
+ return True
21
+ if _load_failed:
22
+ return False
23
+ try:
24
+ from transformers import VitsModel, AutoTokenizer
25
+
26
+ _model = VitsModel.from_pretrained(MODEL_ID)
27
+ _tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
28
+ _model.eval()
29
+ return True
30
+ except Exception as exc: # pragma: no cover - environment dependent
31
+ print(f"[tts] could not load {MODEL_ID}: {exc}. Audio disabled.")
32
+ _load_failed = True
33
+ return False
34
+
35
+
36
+ def warm_up():
37
+ """Pre-load the model at app startup (optional; speeds up the first round)."""
38
+ _load()
39
+
40
+
41
+ def speak(text):
42
+ """Synthesize ``text`` -> ``(sampling_rate, numpy_waveform)`` for gr.Audio.
43
+
44
+ Returns ``None`` if synthesis is unavailable, which gr.Audio renders as
45
+ "no audio" rather than erroring.
46
+ """
47
+ if not text or not _load():
48
+ return None
49
+ try:
50
+ import torch
51
+
52
+ inputs = _tokenizer(text, return_tensors="pt")
53
+ with torch.no_grad():
54
+ waveform = _model(**inputs).waveform
55
+ audio = waveform.squeeze().cpu().numpy()
56
+ return _model.config.sampling_rate, audio
57
+ except Exception as exc: # pragma: no cover - environment dependent
58
+ print(f"[tts] synthesis failed: {exc}")
59
+ return None