| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- <?php
- /**
- * Spelling question generator.
- *
- * Changes from the original:
- * - the TTS request no longer goes through exec()/curl with text spliced into
- * a shell command. It is a normal HTTP POST with a json_encode()d body,
- * with a timeout, and the result is only kept if the server answered 200
- * with real audio (written to a temp file, then renamed). Previously a
- * failed request left a bad .mp3 that file_exists() trusted forever.
- * - the word/usage text goes to TTS as plain text (the old toHTML() turned
- * apostrophes and quotes into HTML entities)
- * - if audio can't be produced, the sentence is shown with the word blanked
- * out instead of an unplayable player
- * - <source> type is audio/mpeg (was audio/wav for an .mp3)
- * - audio path is absolute (no dependence on the working directory)
- * - user escaped in SQL; `e107.` database name replaced by MPREFIX
- * - falls back to any word when nothing exists at the user's level, and
- * shows a message instead of crashing when the word list is empty
- * - previous tkLastQuestion row removed before inserting the new one
- */
- class spelling2 {
- // Text-to-speech server (OpenAI-compatible /v1/audio/speech endpoint).
- const TTS_URL = 'http://192.168.1.252:8880/v1/audio/speech';
- const TTS_TIMEOUT = 20; // seconds
- private $user;
- /**
- * @param string $user User to supply the question for.
- */
- public function __construct($user) {
- $this->user = $user;
- }
- public function getSpellingWord() {
- $year = max(1, (int) ceil($this->difficulty()));
- $question = $this->pickWord($year);
- if ($question === null) {
- $question = $this->pickWord(13); // nothing at this level: fall back to any word
- }
- if ($question === null) {
- e107::getRender()->tablerender('Spelling', 'No spelling words are available yet.');
- return;
- }
- $questionHtml = $this->spellingHtml($question);
- $db = e107::getDb();
- $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
- $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
- e107::getRender()->tablerender('Spelling', $questionHtml);
- }
- /**
- * Pick a word at or below $year, preferring words this user has answered
- * least in the last month.
- *
- * @return array|null Row with `word` and `usage`, or null when none found.
- */
- private function pickWord($year) {
- $db = e107::getDb();
- $row = $db->retrieve(
- "SELECT `word`, `usage`, IFNULL(`cnt`, 0) AS Ncnt FROM
- (SELECT `word`, `usage` FROM `" . MPREFIX . "tk_wordList`
- WHERE `year` <= " . (int) $year . " AND `usage` != '') AS wList
- LEFT JOIN
- (SELECT `answer`, COUNT(`answer`) AS `cnt` FROM `" . MPREFIX . "tkReport`
- WHERE `answereddatestamp` > UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
- AND `username` = '" . $db->escape($this->user) . "'
- AND `subject` = 'spelling' GROUP BY `answer`) AS Ans
- ON wList.`word` = Ans.`answer`
- ORDER BY IFNULL(`cnt`, 0), RAND() LIMIT 1"
- );
- return (is_array($row) && !empty($row['word'])) ? $row : null;
- }
- /**
- * Build the spelling form. The expected answer is stored server-side;
- * the form only carries a token.
- *
- * @param array $data ['word' => .., 'usage' => ..]
- */
- private function spellingHtml($data) {
- $frm = e107::getForm();
- $token = TkAnswerStore::issue($this->user, ['word' => $data['word'], 'usage' => $data['usage']]);
- $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
- $fString .= $frm->label('<h4>Spelling</h4>') . "</br>\n";
- $fString .= $frm->label('Spell the word in the sentence.') . "\n";
- $fString .= '</br>';
- if ($this->ensureAudio($data)) {
- $fString .= "<audio controls><source src='audio/" . md5($data['word']) . ".mp3' type='audio/mpeg'>Your browser does not support the audio element.</audio>\n";
- } else {
- // No audio available: show the sentence with the word hidden.
- $blanked = preg_replace('/' . preg_quote($data['word'], '/') . '/iu', '_____', $data['usage']);
- $fString .= '<p><em>Audio is unavailable right now.</em></p><p>' . htmlspecialchars((string) $blanked) . "</p>\n";
- }
- $fString .= '</br></br>';
- $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
- $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
- $fString .= $frm->hidden('subject', 'spelling', ['id' => 'subject']);
- $fString .= '</br>' . "\n";
- $fString .= $frm->button('Answer', 'Check answer') . "\n";
- $fString .= $frm->close();
- $fString .= '</br></br>';
- return $fString;
- }
- /**
- * Make sure ../../audio/<md5(word)>.mp3 exists, generating it through the
- * TTS server if needed.
- *
- * @return bool True when a usable audio file is in place.
- */
- private function ensureAudio($data) {
- $dir = dirname(__DIR__, 2) . '/audio';
- $file = $dir . '/' . md5($data['word']) . '.mp3';
- if (is_file($file) && filesize($file) > 0) {
- return true;
- }
- if (!is_dir($dir) || !is_writable($dir)) {
- return false;
- }
- $voices = ['bf_emma', 'bf_isabella', 'bf_alice', 'bf_lily', 'bm_george', 'bm_lewis', 'bm_daniel', 'bm_fable'];
- $body = json_encode([
- 'model' => 'tts-1',
- 'input' => 'Spell the word, ' . $data['word'] . '. ' . $data['usage'] . '.',
- 'voice' => $voices[array_rand($voices)],
- ]);
- $context = stream_context_create(['http' => [
- 'method' => 'POST',
- 'header' => "Content-Type: application/json\r\n",
- 'content' => $body,
- 'timeout' => self::TTS_TIMEOUT,
- 'ignore_errors' => true,
- ]]);
- $audio = @file_get_contents(self::TTS_URL, false, $context);
- $status = isset($http_response_header[0]) ? $http_response_header[0] : '';
- if ($audio === false || strlen($audio) < 100 || !preg_match('#^HTTP/\S+\s+200\b#', $status)) {
- return false;
- }
- // Write to a temp file and rename so a half-written file is never served.
- $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
- if (file_put_contents($tmp, $audio) === false) {
- return false;
- }
- if (!rename($tmp, $file)) {
- @unlink($tmp);
- return false;
- }
- return true;
- }
- /**
- * Difficulty (word year level 1-13) from the last month of answers.
- * Capped while the user has few answers.
- *
- * @return float
- */
- private function difficulty() {
- $db = e107::getDb();
- $stats = $db->retrieve(
- "SELECT (SUM(correct)/COUNT(correct)*13) AS percent, COUNT(correct) AS count
- FROM `" . MPREFIX . "tkReport`
- WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
- AND `username` = '" . $db->escape($this->user) . "' AND `subject` = 'spelling'"
- );
- if (!is_array($stats)) {
- return 1;
- }
- $count = (int) $stats['count'];
- $percent = (float) $stats['percent'];
- if ($count >= 300) {
- return $percent;
- } elseif ($count >= 250) {
- return min($percent, 7);
- } elseif ($count >= 200) {
- return min($percent, 5);
- } elseif ($count >= 100) {
- return min($percent, 3);
- }
- return 1;
- }
- }
|