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('

Spelling

') . "
\n"; $fString .= $frm->label('Spell the word in the sentence.') . "\n"; $fString .= '
'; if ($this->ensureAudio($data)) { $fString .= "\n"; } else { // No audio available: show the sentence with the word hidden. $blanked = preg_replace('/' . preg_quote($data['word'], '/') . '/iu', '_____', $data['usage']); $fString .= '

Audio is unavailable right now.

' . htmlspecialchars((string) $blanked) . "

\n"; } $fString .= '

'; $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 .= '
' . "\n"; $fString .= $frm->button('Answer', 'Check answer') . "\n"; $fString .= $frm->close(); $fString .= '

'; return $fString; } /** * Make sure ../../audio/.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; } }