spelling2.php 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. <?php
  2. /**
  3. * Spelling question generator.
  4. *
  5. * Changes from the original:
  6. * - the TTS request no longer goes through exec()/curl with text spliced into
  7. * a shell command. It is a normal HTTP POST with a json_encode()d body,
  8. * with a timeout, and the result is only kept if the server answered 200
  9. * with real audio (written to a temp file, then renamed). Previously a
  10. * failed request left a bad .mp3 that file_exists() trusted forever.
  11. * - the word/usage text goes to TTS as plain text (the old toHTML() turned
  12. * apostrophes and quotes into HTML entities)
  13. * - if audio can't be produced, the sentence is shown with the word blanked
  14. * out instead of an unplayable player
  15. * - <source> type is audio/mpeg (was audio/wav for an .mp3)
  16. * - audio path is absolute (no dependence on the working directory)
  17. * - user escaped in SQL; `e107.` database name replaced by MPREFIX
  18. * - falls back to any word when nothing exists at the user's level, and
  19. * shows a message instead of crashing when the word list is empty
  20. * - previous tkLastQuestion row removed before inserting the new one
  21. */
  22. class spelling2 {
  23. // Text-to-speech server (OpenAI-compatible /v1/audio/speech endpoint).
  24. const TTS_URL = 'http://192.168.1.252:8880/v1/audio/speech';
  25. const TTS_TIMEOUT = 20; // seconds
  26. private $user;
  27. /**
  28. * @param string $user User to supply the question for.
  29. */
  30. public function __construct($user) {
  31. $this->user = $user;
  32. }
  33. public function getSpellingWord() {
  34. $year = max(1, (int) ceil($this->difficulty()));
  35. $question = $this->pickWord($year);
  36. if ($question === null) {
  37. $question = $this->pickWord(13); // nothing at this level: fall back to any word
  38. }
  39. if ($question === null) {
  40. e107::getRender()->tablerender('Spelling', 'No spelling words are available yet.');
  41. return;
  42. }
  43. $questionHtml = $this->spellingHtml($question);
  44. $db = e107::getDb();
  45. $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
  46. $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
  47. e107::getRender()->tablerender('Spelling', $questionHtml);
  48. }
  49. /**
  50. * Pick a word at or below $year, preferring words this user has answered
  51. * least in the last month.
  52. *
  53. * @return array|null Row with `word` and `usage`, or null when none found.
  54. */
  55. private function pickWord($year) {
  56. $db = e107::getDb();
  57. $row = $db->retrieve(
  58. "SELECT `word`, `usage`, IFNULL(`cnt`, 0) AS Ncnt FROM
  59. (SELECT `word`, `usage` FROM `" . MPREFIX . "tk_wordList`
  60. WHERE `year` <= " . (int) $year . " AND `usage` != '') AS wList
  61. LEFT JOIN
  62. (SELECT `answer`, COUNT(`answer`) AS `cnt` FROM `" . MPREFIX . "tkReport`
  63. WHERE `answereddatestamp` > UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
  64. AND `username` = '" . $db->escape($this->user) . "'
  65. AND `subject` = 'spelling' GROUP BY `answer`) AS Ans
  66. ON wList.`word` = Ans.`answer`
  67. ORDER BY IFNULL(`cnt`, 0), RAND() LIMIT 1"
  68. );
  69. return (is_array($row) && !empty($row['word'])) ? $row : null;
  70. }
  71. /**
  72. * Build the spelling form. The expected answer is stored server-side;
  73. * the form only carries a token.
  74. *
  75. * @param array $data ['word' => .., 'usage' => ..]
  76. */
  77. private function spellingHtml($data) {
  78. $frm = e107::getForm();
  79. $token = TkAnswerStore::issue($this->user, ['word' => $data['word'], 'usage' => $data['usage']]);
  80. $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
  81. $fString .= $frm->label('<h4>Spelling</h4>') . "</br>\n";
  82. $fString .= $frm->label('Spell the word in the sentence.') . "\n";
  83. $fString .= '</br>';
  84. if ($this->ensureAudio($data)) {
  85. $fString .= "<audio controls><source src='audio/" . md5($data['word']) . ".mp3' type='audio/mpeg'>Your browser does not support the audio element.</audio>\n";
  86. } else {
  87. // No audio available: show the sentence with the word hidden.
  88. $blanked = preg_replace('/' . preg_quote($data['word'], '/') . '/iu', '_____', $data['usage']);
  89. $fString .= '<p><em>Audio is unavailable right now.</em></p><p>' . htmlspecialchars((string) $blanked) . "</p>\n";
  90. }
  91. $fString .= '</br></br>';
  92. $fString .= $frm->text('a1', '', 80, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
  93. $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
  94. $fString .= $frm->hidden('subject', 'spelling', ['id' => 'subject']);
  95. $fString .= '</br>' . "\n";
  96. $fString .= $frm->button('Answer', 'Check answer') . "\n";
  97. $fString .= $frm->close();
  98. $fString .= '</br></br>';
  99. return $fString;
  100. }
  101. /**
  102. * Make sure ../../audio/<md5(word)>.mp3 exists, generating it through the
  103. * TTS server if needed.
  104. *
  105. * @return bool True when a usable audio file is in place.
  106. */
  107. private function ensureAudio($data) {
  108. $dir = dirname(__DIR__, 2) . '/audio';
  109. $file = $dir . '/' . md5($data['word']) . '.mp3';
  110. if (is_file($file) && filesize($file) > 0) {
  111. return true;
  112. }
  113. if (!is_dir($dir) || !is_writable($dir)) {
  114. return false;
  115. }
  116. $voices = ['bf_emma', 'bf_isabella', 'bf_alice', 'bf_lily', 'bm_george', 'bm_lewis', 'bm_daniel', 'bm_fable'];
  117. $body = json_encode([
  118. 'model' => 'tts-1',
  119. 'input' => 'Spell the word, ' . $data['word'] . '. ' . $data['usage'] . '.',
  120. 'voice' => $voices[array_rand($voices)],
  121. ]);
  122. $context = stream_context_create(['http' => [
  123. 'method' => 'POST',
  124. 'header' => "Content-Type: application/json\r\n",
  125. 'content' => $body,
  126. 'timeout' => self::TTS_TIMEOUT,
  127. 'ignore_errors' => true,
  128. ]]);
  129. $audio = @file_get_contents(self::TTS_URL, false, $context);
  130. $status = isset($http_response_header[0]) ? $http_response_header[0] : '';
  131. if ($audio === false || strlen($audio) < 100 || !preg_match('#^HTTP/\S+\s+200\b#', $status)) {
  132. return false;
  133. }
  134. // Write to a temp file and rename so a half-written file is never served.
  135. $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp';
  136. if (file_put_contents($tmp, $audio) === false) {
  137. return false;
  138. }
  139. if (!rename($tmp, $file)) {
  140. @unlink($tmp);
  141. return false;
  142. }
  143. return true;
  144. }
  145. /**
  146. * Difficulty (word year level 1-13) from the last month of answers.
  147. * Capped while the user has few answers.
  148. *
  149. * @return float
  150. */
  151. private function difficulty() {
  152. $db = e107::getDb();
  153. $stats = $db->retrieve(
  154. "SELECT (SUM(correct)/COUNT(correct)*13) AS percent, COUNT(correct) AS count
  155. FROM `" . MPREFIX . "tkReport`
  156. WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
  157. AND `username` = '" . $db->escape($this->user) . "' AND `subject` = 'spelling'"
  158. );
  159. if (!is_array($stats)) {
  160. return 1;
  161. }
  162. $count = (int) $stats['count'];
  163. $percent = (float) $stats['percent'];
  164. if ($count >= 300) {
  165. return $percent;
  166. } elseif ($count >= 250) {
  167. return min($percent, 7);
  168. } elseif ($count >= 200) {
  169. return min($percent, 5);
  170. } elseif ($count >= 100) {
  171. return min($percent, 3);
  172. }
  173. return 1;
  174. }
  175. }