feat: a reading voice in Settings, and a tutor that reads every figure
The voice picker was a dropdown in the quiz player, beside the question — the one control on that screen with nothing to do with answering it, and one a learner sets once and never touches. It is a setting now, on the user rather than in a Redis blob, with a play button beside each voice because a voice is worth hearing before it is chosen. Choosing nothing stays a real choice: it means whatever an administrator marked default, so a site that changes its default reaches everybody without a row being edited. The tutor reads figures from `question_media` rather than the two legacy path columns. Those agree exactly today, so nothing was being lost — the first question given a second figure in the editor would have been the one that broke it, silently and only for the tutor. The legacy columns remain as a fallback for anything not projected into that table yet. And the retrieval thresholds are written down in docs/retrieval-thresholds.md: the three answers, the sixteen queries they were measured against, why they are deliberately not the retrieval floor, and how to re-measure when the corpus grows. Worth keeping the headline in mind — "discuss love" scores 0.491, alongside "tell me a joke". A number in the 0.4s is noise, not a weak signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
parent
6203c3a92f
commit
3f57bda6aa
10 changed files with 303 additions and 15 deletions
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""A learner's own reading voice.
|
||||||
|
|
||||||
|
It was a dropdown in the quiz player, beside the question — the one control on
|
||||||
|
that screen with nothing to do with answering, and one a learner would set once
|
||||||
|
and never touch again. That is a setting.
|
||||||
|
|
||||||
|
Revision ID: e7f8091a2b3c
|
||||||
|
Revises: d6e7f8091a2b
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "e7f8091a2b3c"
|
||||||
|
down_revision = "d6e7f8091a2b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
if "tts_voice" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("users")}:
|
||||||
|
op.add_column("users", sa.Column("tts_voice", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("users", "tts_voice")
|
||||||
|
|
@ -20,6 +20,12 @@ class User(Base):
|
||||||
active_exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
|
active_exam_id = Column(Integer, ForeignKey("exams.id", ondelete="SET NULL"), nullable=True)
|
||||||
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
|
is_unthrottled = Column(Integer, default=0) # 1 = exempt from rate limits
|
||||||
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
|
reminders_disabled = Column(Boolean, default=False, nullable=False, server_default=sa_false())
|
||||||
|
# Which voice reads a question aloud. A setting, not a decision to retake at
|
||||||
|
# the top of every session — it used to be a dropdown in the player, beside
|
||||||
|
# the question, where it was the only control on screen that had nothing to
|
||||||
|
# do with answering. Null means whichever voice an administrator marked
|
||||||
|
# default, so a learner who never opens Settings still gets a working one.
|
||||||
|
tts_voice = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# passive_deletes leaves the child rows to the database, which already
|
# passive_deletes leaves the child rows to the database, which already
|
||||||
|
|
|
||||||
|
|
@ -278,8 +278,10 @@ def get_user_settings(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
|
import logging; logging.getLogger(__name__).warning(f"Failed to load user settings: {e}")
|
||||||
|
|
||||||
# Canonical opt-out preference comes from the DB
|
# Canonical preferences come from the DB. Redis holds the rest, and a cache
|
||||||
|
# is not where a choice somebody made once should live.
|
||||||
data["reminders_disabled"] = bool(current_user.reminders_disabled)
|
data["reminders_disabled"] = bool(current_user.reminders_disabled)
|
||||||
|
data["tts_voice"] = current_user.tts_voice
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -292,8 +294,15 @@ def save_user_settings(
|
||||||
"""Save user settings. reminders_disabled is persisted to Postgres;
|
"""Save user settings. reminders_disabled is persisted to Postgres;
|
||||||
other keys go to Redis."""
|
other keys go to Redis."""
|
||||||
# Persist opt-out preference to DB (canonical source for the scheduler)
|
# Persist opt-out preference to DB (canonical source for the scheduler)
|
||||||
|
changed = False
|
||||||
if "reminders_disabled" in settings_data:
|
if "reminders_disabled" in settings_data:
|
||||||
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
|
current_user.reminders_disabled = bool(settings_data.get("reminders_disabled"))
|
||||||
|
changed = True
|
||||||
|
if "tts_voice" in settings_data:
|
||||||
|
voice = (settings_data.get("tts_voice") or "").strip()
|
||||||
|
current_user.tts_voice = voice or None
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
db.add(current_user)
|
db.add(current_user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from app.models.ai_model_config import AIModelConfig
|
||||||
from app.models.attempt import QuizAttempt
|
from app.models.attempt import QuizAttempt
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.utils.quiz_access import require_question_access
|
from app.utils.quiz_access import require_question_access
|
||||||
from app.services import site_settings, vision_service
|
from app.services import question_figures, site_settings, vision_service
|
||||||
from app.services.quiz_builder import bank_question_predicate
|
from app.services.quiz_builder import bank_question_predicate
|
||||||
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
|
from app.utils.auth import check_rate_limit, get_current_user, require_moderator
|
||||||
|
|
||||||
|
|
@ -80,22 +80,44 @@ def _find_similar_questions(db: Session, question: Question, user: User, limit:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _figures(question: Question) -> list[vision_service.Image]:
|
#: What a figure's role is called when it is described to the tutor. The two
|
||||||
|
#: are not interchangeable: "the figure in the explanation" and "the figure in
|
||||||
|
#: the stem" mean different things to something that has been told the answer.
|
||||||
|
FIGURE_CAPTIONS = {
|
||||||
|
"stem": "the figure printed with the question stem",
|
||||||
|
"explanation": "the figure printed with the explanation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _figures(db: Session, question: Question) -> list[vision_service.Image]:
|
||||||
"""The pictures printed with this question, loaded from storage.
|
"""The pictures printed with this question, loaded from storage.
|
||||||
|
|
||||||
The learner is looking at them. Until now the tutor was not: it was handed
|
The learner is looking at them. Until now the tutor was not: it was handed
|
||||||
the stem, the options and the answer key as text and left to teach around a
|
the stem, the options and the answer key as text and left to teach around a
|
||||||
radiograph it had never seen, which produces confident sentences about a
|
radiograph it had never seen, which produces confident sentences about a
|
||||||
finding nobody described. Captions say which is which, because "the figure
|
finding nobody described.
|
||||||
in the explanation" and "the figure in the stem" mean different things to a
|
|
||||||
tutor that has been told the answer.
|
Read from `question_media`, which is where figures live — a question may
|
||||||
|
carry several, and the two legacy path columns can hold only one each.
|
||||||
|
They agree today, so nothing was being lost yet; the first question given a
|
||||||
|
second figure in the editor would have been the one that broke it, silently
|
||||||
|
and only for the tutor.
|
||||||
"""
|
"""
|
||||||
wanted = [
|
rows = question_figures.figures_for(db, question.id)
|
||||||
(question.image_path, "the figure printed with the question stem"),
|
images = [
|
||||||
(question.explanation_image_path, "the figure printed with the explanation"),
|
vision_service.load_image(row["path"],
|
||||||
|
FIGURE_CAPTIONS.get(row.get("role"), "a figure printed with this question"))
|
||||||
|
for row in rows if row.get("path")
|
||||||
]
|
]
|
||||||
loaded = [vision_service.load_image(path, caption) for path, caption in wanted if path]
|
if not images:
|
||||||
return [image for image in loaded if image]
|
# Nothing projected into `question_media` yet — a question written
|
||||||
|
# before that table existed, or one whose backfill has not run.
|
||||||
|
legacy = [
|
||||||
|
(question.image_path, FIGURE_CAPTIONS["stem"]),
|
||||||
|
(question.explanation_image_path, FIGURE_CAPTIONS["explanation"]),
|
||||||
|
]
|
||||||
|
images = [vision_service.load_image(path, caption) for path, caption in legacy if path]
|
||||||
|
return [image for image in images if image]
|
||||||
|
|
||||||
|
|
||||||
def _option_label(index: int) -> str:
|
def _option_label(index: int) -> str:
|
||||||
|
|
@ -300,7 +322,7 @@ async def chat(
|
||||||
# async endpoint, so it goes to a worker thread rather than stopping every
|
# async endpoint, so it goes to a worker thread rather than stopping every
|
||||||
# other request on this uvicorn worker.
|
# other request on this uvicorn worker.
|
||||||
handoff = None
|
handoff = None
|
||||||
figures = await run_in_threadpool(_figures, question)
|
figures = await run_in_threadpool(_figures, db, question)
|
||||||
if figures:
|
if figures:
|
||||||
try:
|
try:
|
||||||
parts, handoff = await run_in_threadpool(
|
parts, handoff = await run_in_threadpool(
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,16 @@ def text_to_speech(
|
||||||
|
|
||||||
text = request.text[:2000]
|
text = request.text[:2000]
|
||||||
|
|
||||||
if request.voice and request.voice.startswith("local-"):
|
# The player no longer asks, so the learner's own setting is what decides —
|
||||||
|
# falling through to whatever an administrator marked default when they have
|
||||||
|
# never chosen one. A caller may still name a voice, which is how a preview
|
||||||
|
# in Settings plays the one being considered rather than the one in force.
|
||||||
|
wanted = request.voice or current_user.tts_voice
|
||||||
|
if wanted and wanted.startswith("local-"):
|
||||||
config = db.query(AIModelConfig).filter(
|
config = db.query(AIModelConfig).filter(
|
||||||
AIModelConfig.task == "tts",
|
AIModelConfig.task == "tts",
|
||||||
AIModelConfig.is_active == True,
|
AIModelConfig.is_active == True,
|
||||||
AIModelConfig.model_id == request.voice,
|
AIModelConfig.model_id == wanted,
|
||||||
).first()
|
).first()
|
||||||
if not config:
|
if not config:
|
||||||
model_id, api_key = _default_tts_model(db)
|
model_id, api_key = _default_tts_model(db)
|
||||||
|
|
|
||||||
|
|
@ -206,7 +206,8 @@ def sources_block(sources: list[dict]) -> str:
|
||||||
# to come from the library, and a wrong claim costs them their trust in every
|
# to come from the library, and a wrong claim costs them their trust in every
|
||||||
# other answer.
|
# other answer.
|
||||||
#
|
#
|
||||||
# Worth re-measuring when the corpus changes size or subject. Anything else is
|
# Worth re-measuring when the corpus changes size or subject; the method and
|
||||||
|
# the full measurement are in docs/retrieval-thresholds.md. Anything else is
|
||||||
# tuning by feel against numbers nobody wrote down.
|
# tuning by feel against numbers nobody wrote down.
|
||||||
STRONG_MATCH = 0.55
|
STRONG_MATCH = 0.55
|
||||||
ADJACENT_MATCH = 0.50
|
ADJACENT_MATCH = 0.50
|
||||||
|
|
|
||||||
86
docs/retrieval-thresholds.md
Normal file
86
docs/retrieval-thresholds.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# What "close enough" means
|
||||||
|
|
||||||
|
AI Mode gives one of three answers, and which one is decided by a number rather
|
||||||
|
than by asking the model to work out its own situation. This is where that
|
||||||
|
number comes from, what it was measured against, and how to re-measure it.
|
||||||
|
|
||||||
|
## Why a number and not a longer prompt
|
||||||
|
|
||||||
|
Retrieval could not say "nothing". `hybrid_ids()` fuses a lexical ranker and a
|
||||||
|
semantic one by reciprocal rank, throws the distances away, and returns the
|
||||||
|
*union* — so the shortlist was never empty. A question about photosynthesis came
|
||||||
|
back with six paediatric sources and an instruction to answer only from them.
|
||||||
|
|
||||||
|
Adding scenarios to the prompt would have asked the model to classify, in prose,
|
||||||
|
a situation the data already knows. Classification-by-prose is the part that does
|
||||||
|
not work, and it is also the part that makes prompts long. So the branch lives in
|
||||||
|
code: `ai_mode_service.answer_mode()`.
|
||||||
|
|
||||||
|
## The three answers
|
||||||
|
|
||||||
|
| closeness | mode | what the learner gets |
|
||||||
|
|---|---|---|
|
||||||
|
| `>= STRONG_MATCH` | `sourced` | answered from the library, cited |
|
||||||
|
| `>= ADJACENT_MATCH` | `adjacent` | "nothing covers this directly; the closest is…", then an answer marking which parts came from where |
|
||||||
|
| below | `open` | one line saying the library does not cover it, then an answer from general knowledge, citing nothing |
|
||||||
|
| unmeasurable (`None`) | `sourced` | see below |
|
||||||
|
|
||||||
|
**Unmeasurable is not low.** No vector database, or a downed encoder, returns
|
||||||
|
`None`. Retrieval still found its rows by other means, so they are still cited;
|
||||||
|
dropping every citation because the ruler is missing is the worse failure.
|
||||||
|
|
||||||
|
## The numbers, and the measurement behind them
|
||||||
|
|
||||||
|
In `backend/app/services/ai_mode_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
STRONG_MATCH = 0.55
|
||||||
|
ADJACENT_MATCH = 0.50
|
||||||
|
```
|
||||||
|
|
||||||
|
Measured on 2026-09-12, against the corpus with article bodies embedded — eight
|
||||||
|
clearly on-topic questions and eight clearly off-topic, scored with
|
||||||
|
`search_service.top_similarity()` over the article and section corpora:
|
||||||
|
|
||||||
|
| | range | examples |
|
||||||
|
|---|---|---|
|
||||||
|
| off-topic | **0.339 – 0.499** | the French revolution 0.339 · how do I bake sourdough 0.410 · quantum entanglement 0.448 · javascript closures 0.452 · tell me a joke 0.455 · what is a mortgage 0.462 · discuss love 0.491 · photosynthesis 0.499 |
|
||||||
|
| on-topic | **0.586 – 0.740** | neonatal jaundice phototherapy 0.586 · what causes croup 0.594 · febrile seizure workup 0.638 · Kawasaki disease 0.660 · testicular torsion 0.713 · bronchiolitis in an infant 0.716 · iron deficiency in a toddler 0.733 · posterior urethral valves 0.740 |
|
||||||
|
|
||||||
|
The thresholds sit in the gap between those two bands.
|
||||||
|
|
||||||
|
Worth noticing that **"discuss love" scores 0.491, alongside "tell me a joke"**.
|
||||||
|
It feels adjacent to a paediatrics library — attachment, behaviour — and it is
|
||||||
|
not: 0.49 is where anything written in English lands against any corpus. That is
|
||||||
|
the reading to keep in mind. A number in the 0.4s is noise, not a weak signal.
|
||||||
|
|
||||||
|
## Why these are not `SEMANTIC_FLOOR`
|
||||||
|
|
||||||
|
`search_service.SEMANTIC_FLOOR` (0.45) decides what is worth putting in a list,
|
||||||
|
where a weak hit costs a reader one glance. These decide whether an answer
|
||||||
|
*claims to come from the library*, and a wrong claim there costs a learner their
|
||||||
|
trust in every other answer. Different jobs, different numbers, and tying them
|
||||||
|
together would mean one could not be tuned without moving the other.
|
||||||
|
|
||||||
|
## Re-measuring
|
||||||
|
|
||||||
|
Re-measure when the corpus changes size or subject — a much larger library
|
||||||
|
raises the floor for everything, because there is more for any query to be
|
||||||
|
vaguely near.
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose exec -T backend python -c "
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.services.search_service import top_similarity
|
||||||
|
db = SessionLocal()
|
||||||
|
for q in ['the French revolution', 'tell me a joke', 'discuss love',
|
||||||
|
'what causes croup', 'bronchiolitis in an infant']:
|
||||||
|
print(f'{top_similarity(db, q):.3f} {q}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick your own on- and off-topic sets, run both, and put the thresholds in the
|
||||||
|
gap. If there is no gap, the embeddings are wrong before the thresholds are —
|
||||||
|
that is what happened before the article bodies were indexed, when 98% of the
|
||||||
|
corpus was embedded on its title and summary alone and every distance was
|
||||||
|
measuring the wrong thing.
|
||||||
28
frontend/src/components/VoiceSetting.css
Normal file
28
frontend/src/components/VoiceSetting.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* A list rather than a dropdown: every voice is worth hearing before it is
|
||||||
|
chosen, and a play button inside a <select> is not a thing. */
|
||||||
|
.vset-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.vset-list li { display: flex; align-items: stretch; gap: 6px; }
|
||||||
|
|
||||||
|
.vset-pick {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 11px 14px; font: inherit; font-size: 0.9rem; text-align: left; cursor: pointer;
|
||||||
|
color: var(--text); background: var(--card-bg);
|
||||||
|
border: 1.5px solid var(--border); border-radius: 9px;
|
||||||
|
}
|
||||||
|
.vset-pick:hover { border-color: var(--text-subtle); }
|
||||||
|
.vset-pick.is-on { border-color: var(--primary); background: var(--option-sel-bg, #e5ecf8); font-weight: 600; }
|
||||||
|
.vset-pick small {
|
||||||
|
margin-left: auto; font-size: 0.72rem; font-weight: 600;
|
||||||
|
letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vset-hear {
|
||||||
|
flex: none; width: 42px; cursor: pointer;
|
||||||
|
font-size: 0.95rem; color: var(--text-muted);
|
||||||
|
background: var(--card-bg); border: 1.5px solid var(--border); border-radius: 9px;
|
||||||
|
}
|
||||||
|
.vset-hear:hover:not(:disabled) { color: var(--primary); border-color: var(--primary); }
|
||||||
|
.vset-hear:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
.vset-error { margin: 0 0 10px; font-size: 0.85rem; color: var(--wrong-fg); }
|
||||||
|
.vset-none { margin: 0; font-size: 0.88rem; color: var(--text-muted); }
|
||||||
98
frontend/src/components/VoiceSetting.jsx
Normal file
98
frontend/src/components/VoiceSetting.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import api from '../api/client'
|
||||||
|
import './VoiceSetting.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which voice reads a question aloud.
|
||||||
|
*
|
||||||
|
* It was a dropdown in the quiz player, sitting beside the question — the one
|
||||||
|
* control on that screen with nothing to do with answering it, and one a
|
||||||
|
* learner sets once and then never touches. That is a setting.
|
||||||
|
*
|
||||||
|
* Choosing nothing is a real choice and stays available: it means whichever
|
||||||
|
* voice an administrator marked default, so a learner who never opens this
|
||||||
|
* page still gets a working one, and a site that changes its default reaches
|
||||||
|
* them without anybody editing a row.
|
||||||
|
*/
|
||||||
|
export default function VoiceSetting() {
|
||||||
|
const [voices, setVoices] = useState([])
|
||||||
|
const [chosen, setChosen] = useState('')
|
||||||
|
const [state, setState] = useState('loading') // loading | ready | saving | failed
|
||||||
|
const [playing, setPlaying] = useState('')
|
||||||
|
const audio = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
Promise.all([
|
||||||
|
api.get('/tts/voices').catch(() => ({ data: [] })),
|
||||||
|
api.get('/auth/me/settings').catch(() => ({ data: {} })),
|
||||||
|
]).then(([list, settings]) => {
|
||||||
|
if (!live) return
|
||||||
|
setVoices(list.data || [])
|
||||||
|
setChosen(settings.data?.tts_voice || '')
|
||||||
|
setState('ready')
|
||||||
|
})
|
||||||
|
// Nothing should still be talking after this page closes.
|
||||||
|
return () => { live = false; audio.current?.pause() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const choose = async (value) => {
|
||||||
|
const previous = chosen
|
||||||
|
setChosen(value)
|
||||||
|
setState('saving')
|
||||||
|
try {
|
||||||
|
await api.put('/auth/me/settings', { tts_voice: value || null })
|
||||||
|
setState('ready')
|
||||||
|
} catch {
|
||||||
|
// Put it back rather than showing a choice that was not saved.
|
||||||
|
setChosen(previous)
|
||||||
|
setState('failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hear the one being considered, which is not necessarily the one in force. */
|
||||||
|
const preview = async (voice) => {
|
||||||
|
audio.current?.pause()
|
||||||
|
setPlaying(voice)
|
||||||
|
try {
|
||||||
|
const res = await api.post('/tts/speak',
|
||||||
|
{ text: 'A four-year-old is brought in with a barking cough.', voice: voice || null },
|
||||||
|
{ responseType: 'blob' })
|
||||||
|
const player = new Audio(URL.createObjectURL(res.data))
|
||||||
|
audio.current = player
|
||||||
|
player.onended = () => setPlaying('')
|
||||||
|
await player.play()
|
||||||
|
} catch { setPlaying('') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'loading') return <div className="loading"><div className="spinner" /></div>
|
||||||
|
if (!voices.length) {
|
||||||
|
return <p className="vset-none">No reading voices are configured for this site yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vset">
|
||||||
|
{state === 'failed' && (
|
||||||
|
<p className="vset-error" role="alert">That could not be saved. Try again.</p>
|
||||||
|
)}
|
||||||
|
<ul className="vset-list" role="radiogroup" aria-label="Reading voice">
|
||||||
|
{[{ id: '', name: 'Site default' }, ...voices].map(voice => (
|
||||||
|
<li key={voice.id || 'default'}>
|
||||||
|
<button type="button" role="radio" aria-checked={chosen === voice.id}
|
||||||
|
className={`vset-pick${chosen === voice.id ? ' is-on' : ''}`}
|
||||||
|
disabled={state === 'saving'}
|
||||||
|
onClick={() => choose(voice.id)}>
|
||||||
|
{voice.name}
|
||||||
|
{voice.is_default && voice.id && <small>site default</small>}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="vset-hear"
|
||||||
|
aria-label={`Hear ${voice.name}`} disabled={!!playing}
|
||||||
|
onClick={() => preview(voice.id)}>
|
||||||
|
{playing === voice.id ? '♪' : '▶'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import ExamAdmin from '../components/ExamAdmin'
|
||||||
import PeopleAdmin from '../components/PeopleAdmin'
|
import PeopleAdmin from '../components/PeopleAdmin'
|
||||||
import ModelsAdmin from '../components/ModelsAdmin'
|
import ModelsAdmin from '../components/ModelsAdmin'
|
||||||
import './SettingsPage.css'
|
import './SettingsPage.css'
|
||||||
|
import VoiceSetting from '../components/VoiceSetting'
|
||||||
|
|
||||||
function Section({ title, description, children }) {
|
function Section({ title, description, children }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -413,6 +414,13 @@ export default function SettingsPage() {
|
||||||
render: () => <StudySection /> },
|
render: () => <StudySection /> },
|
||||||
{ key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance',
|
{ key: 'appearance', group: 'You', icon: '🎨', label: 'Appearance',
|
||||||
render: () => <AppearanceSection /> },
|
render: () => <AppearanceSection /> },
|
||||||
|
{ key: 'voice', group: 'You', icon: '🔊', label: 'Reading voice',
|
||||||
|
render: () => (
|
||||||
|
<Section title="Reading voice"
|
||||||
|
description="Which voice reads a question aloud. Hear one before you choose it.">
|
||||||
|
<VoiceSetting />
|
||||||
|
</Section>
|
||||||
|
) },
|
||||||
{ key: 'data', group: 'You', icon: '🗄️', label: 'Your data',
|
{ key: 'data', group: 'You', icon: '🗄️', label: 'Your data',
|
||||||
render: () => <DataSection /> },
|
render: () => <DataSection /> },
|
||||||
...(isModerator ? [
|
...(isModerator ? [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue