Directory structure: └── learnercraft-ispeakerreact/ ├── README.md ├── CONTRIBUTING.md ├── eslint.config.js ├── forge.config.cjs ├── i18n.js ├── index.html ├── LICENSE ├── main.js ├── netlify.toml ├── package.json ├── postcss.config.js ├── preload.cjs ├── SECURITY.md ├── vite.config.js ├── .prettierrc ├── data/ │ └── splash.html ├── electron-main/ │ ├── createWindow.js │ ├── customFolderLocationOperation.js │ ├── expressServer.js │ ├── filePath.js │ ├── getFileAndFolder.js │ ├── isDeniedSystemFolder.js │ ├── logOperations.js │ ├── pronunciationCheckerIPC.js │ ├── pronunciationOperations.js │ ├── videoFileOperations.js │ └── zipOperation.js ├── public/ │ ├── _redirects │ ├── google5c7ff3958ee35135.html │ ├── images/ │ │ ├── homepage_screenshot.webp │ │ ├── ispeaker/ │ │ │ ├── exam_images/ │ │ │ │ └── thumb/ │ │ │ │ ├── Discussing-opinions_main-task_1.webp │ │ │ │ ├── Discussing-opinions_practice-task_1_1.webp │ │ │ │ ├── Discussing-opinions_practice-task_1_2.webp │ │ │ │ ├── Job-interview_main-task_1.webp │ │ │ │ ├── Negotiating_main-task_4.webp │ │ │ │ ├── Negotiating_main-task_5.webp │ │ │ │ ├── Negotiating_practice-task_1_1.webp │ │ │ │ ├── Negotiating_practice-task_1_2.webp │ │ │ │ ├── Negotiating_practice-task_1_3.webp │ │ │ │ ├── Negotiating_practice-task_1_4.webp │ │ │ │ └── Negotiating_practice-task_2_4.webp │ │ │ └── sound_images/ │ │ │ ├── sounds_american.webp │ │ │ └── sounds_british.webp │ │ └── screenshots/ │ │ ├── screenshot-00.webp │ │ ├── screenshot-01.webp │ │ ├── screenshot-02.webp │ │ ├── screenshot-03.webp │ │ ├── screenshot-04.webp │ │ └── screenshot-08.webp │ ├── json/ │ │ ├── conversation_list.json │ │ ├── ex_data.json │ │ ├── examspeaking_list.json │ │ ├── exercise_list.json │ │ ├── exercise_memory_match.json │ │ ├── json_file_hashes.json │ │ └── sounds_menu.json │ ├── media/ │ │ ├── conversation/ │ │ │ └── subtitles/ │ │ │ ├── gb/ │ │ │ │ ├── OALD9_GB_dialogues_1.srt │ │ │ │ ├── OALD9_GB_dialogues_10.srt │ │ │ │ ├── OALD9_GB_dialogues_11.srt │ │ │ │ ├── OALD9_GB_dialogues_12.srt │ │ │ │ ├── OALD9_GB_dialogues_13.srt │ │ │ │ ├── OALD9_GB_dialogues_14.srt │ │ │ │ ├── OALD9_GB_dialogues_15.srt │ │ │ │ ├── OALD9_GB_dialogues_16.srt │ │ │ │ ├── OALD9_GB_dialogues_17.srt │ │ │ │ ├── OALD9_GB_dialogues_18.srt │ │ │ │ ├── OALD9_GB_dialogues_19.srt │ │ │ │ ├── OALD9_GB_dialogues_2.srt │ │ │ │ ├── OALD9_GB_dialogues_20.srt │ │ │ │ ├── OALD9_GB_dialogues_21.srt │ │ │ │ ├── OALD9_GB_dialogues_22.srt │ │ │ │ ├── OALD9_GB_dialogues_23.srt │ │ │ │ ├── OALD9_GB_dialogues_24.srt │ │ │ │ ├── OALD9_GB_dialogues_25.srt │ │ │ │ ├── OALD9_GB_dialogues_26.srt │ │ │ │ ├── OALD9_GB_dialogues_27.srt │ │ │ │ ├── OALD9_GB_dialogues_28.srt │ │ │ │ ├── OALD9_GB_dialogues_29.srt │ │ │ │ ├── OALD9_GB_dialogues_3.srt │ │ │ │ ├── OALD9_GB_dialogues_30.srt │ │ │ │ ├── OALD9_GB_dialogues_31.srt │ │ │ │ ├── OALD9_GB_dialogues_32.srt │ │ │ │ ├── OALD9_GB_dialogues_33.srt │ │ │ │ ├── OALD9_GB_dialogues_34.srt │ │ │ │ ├── OALD9_GB_dialogues_35.srt │ │ │ │ ├── OALD9_GB_dialogues_36.srt │ │ │ │ ├── OALD9_GB_dialogues_37.srt │ │ │ │ ├── OALD9_GB_dialogues_38.srt │ │ │ │ ├── OALD9_GB_dialogues_39.srt │ │ │ │ ├── OALD9_GB_dialogues_4.srt │ │ │ │ ├── OALD9_GB_dialogues_5.srt │ │ │ │ ├── OALD9_GB_dialogues_6.srt │ │ │ │ ├── OALD9_GB_dialogues_7.srt │ │ │ │ ├── OALD9_GB_dialogues_8.srt │ │ │ │ └── OALD9_GB_dialogues_9.srt │ │ │ └── us/ │ │ │ ├── OALD9_US_dialogues_1.srt │ │ │ ├── OALD9_US_dialogues_10.srt │ │ │ ├── OALD9_US_dialogues_11.srt │ │ │ ├── OALD9_US_dialogues_12.srt │ │ │ ├── OALD9_US_dialogues_13.srt │ │ │ ├── OALD9_US_dialogues_14.srt │ │ │ ├── OALD9_US_dialogues_15.srt │ │ │ ├── OALD9_US_dialogues_16.srt │ │ │ ├── OALD9_US_dialogues_17.srt │ │ │ ├── OALD9_US_dialogues_18.srt │ │ │ ├── OALD9_US_dialogues_19.srt │ │ │ ├── OALD9_US_dialogues_2.srt │ │ │ ├── OALD9_US_dialogues_20.srt │ │ │ ├── OALD9_US_dialogues_21.srt │ │ │ ├── OALD9_US_dialogues_22.srt │ │ │ ├── OALD9_US_dialogues_23.srt │ │ │ ├── OALD9_US_dialogues_24.srt │ │ │ ├── OALD9_US_dialogues_25.srt │ │ │ ├── OALD9_US_dialogues_26.srt │ │ │ ├── OALD9_US_dialogues_27.srt │ │ │ ├── OALD9_US_dialogues_28.srt │ │ │ ├── OALD9_US_dialogues_29.srt │ │ │ ├── OALD9_US_dialogues_3.srt │ │ │ ├── OALD9_US_dialogues_30.srt │ │ │ ├── OALD9_US_dialogues_31.srt │ │ │ ├── OALD9_US_dialogues_32.srt │ │ │ ├── OALD9_US_dialogues_33.srt │ │ │ ├── OALD9_US_dialogues_34.srt │ │ │ ├── OALD9_US_dialogues_35.srt │ │ │ ├── OALD9_US_dialogues_36.srt │ │ │ ├── OALD9_US_dialogues_37.srt │ │ │ ├── OALD9_US_dialogues_38.srt │ │ │ ├── OALD9_US_dialogues_39.srt │ │ │ ├── OALD9_US_dialogues_4.srt │ │ │ ├── OALD9_US_dialogues_5.srt │ │ │ ├── OALD9_US_dialogues_6.srt │ │ │ ├── OALD9_US_dialogues_7.srt │ │ │ ├── OALD9_US_dialogues_8.srt │ │ │ └── OALD9_US_dialogues_9.srt │ │ └── exam/ │ │ └── subtitles/ │ │ ├── 1_talking_about_a_topic.srt │ │ ├── 2_discussing_opinions.srt │ │ ├── 3_negotiating.srt │ │ ├── 4_describing_a_picture.srt │ │ ├── 5_giving_personal_information.srt │ │ ├── 6_job_interview.srt │ │ └── 7_giving_a_presentation.srt │ └── styles/ │ └── style.css ├── scripts/ │ ├── gh_releases_body.py │ └── minify-dist-jsons.js ├── src/ │ ├── App.jsx │ ├── ErrorBoundary.jsx │ ├── ErrorBoundaryInner.jsx │ ├── index.jsx │ ├── components/ │ │ ├── Homepage.jsx │ │ ├── conversation_page/ │ │ │ ├── ConversationDetailPage.jsx │ │ │ ├── ConversationMenu.jsx │ │ │ ├── ListeningTab.jsx │ │ │ ├── PracticeTab.jsx │ │ │ ├── ReviewTab.jsx │ │ │ └── WatchAndStudyTab.jsx │ │ ├── download_page/ │ │ │ └── DownloadPage.jsx │ │ ├── exam_page/ │ │ │ ├── ExamDetailPage.jsx │ │ │ ├── ExamPage.jsx │ │ │ ├── ListeningTab.jsx │ │ │ ├── PracticeTab.jsx │ │ │ ├── ReviewTab.jsx │ │ │ └── WatchAndStudyTab.jsx │ │ ├── exercise_page/ │ │ │ ├── DictationQuiz.jsx │ │ │ ├── ExerciseDetailPage.jsx │ │ │ ├── ExercisePage.jsx │ │ │ ├── MatchUp.jsx │ │ │ ├── MemoryMatch.jsx │ │ │ ├── OddOneOut.jsx │ │ │ ├── Reordering.jsx │ │ │ ├── Snap.jsx │ │ │ ├── SortableWord.jsx │ │ │ ├── SortingExercise.jsx │ │ │ └── SoundAndSpelling.jsx │ │ ├── general/ │ │ │ ├── AccentDropdown.jsx │ │ │ ├── Footer.jsx │ │ │ ├── LoadingOverlay.jsx │ │ │ ├── LogoLightOrDark.jsx │ │ │ ├── NotFound.jsx │ │ │ ├── TopNavBar.jsx │ │ │ └── VersionUpdateDialog.jsx │ │ ├── setting_page/ │ │ │ ├── Appearance.jsx │ │ │ ├── AppInfo.jsx │ │ │ ├── ExerciseTimer.jsx │ │ │ ├── LanguageSwitcher.jsx │ │ │ ├── LogSettings.jsx │ │ │ ├── modelOptions.js │ │ │ ├── PronunciationCheckerDialogContent.jsx │ │ │ ├── PronunciationCheckerInfo.jsx │ │ │ ├── PronunciationSettings.jsx │ │ │ ├── pronunciationStepUtils.js │ │ │ ├── PronunciationUtils.js │ │ │ ├── ResetSettings.jsx │ │ │ ├── SavedRecordingLocationMenu.jsx │ │ │ ├── SaveFolderSettings.jsx │ │ │ ├── Settings.jsx │ │ │ ├── VideoDownloadMenu.jsx │ │ │ ├── VideoDownloadSubPage.jsx │ │ │ └── VideoDownloadTable.jsx │ │ ├── sound_page/ │ │ │ ├── ReviewCard.jsx │ │ │ ├── SoundList.jsx │ │ │ ├── SoundMain.jsx │ │ │ ├── SoundPracticeCard.jsx │ │ │ ├── TongueTwister.jsx │ │ │ ├── WatchVideoCard.jsx │ │ │ └── hooks/ │ │ │ ├── useSoundVideoDialog.jsx │ │ │ └── useSoundVideoDialogContext.jsx │ │ └── word_page/ │ │ ├── ipaUtils.js │ │ ├── Pagination.jsx │ │ ├── PronunciationChecker.jsx │ │ ├── RecordingWaveform.jsx │ │ ├── ReviewRecording.jsx │ │ ├── SpeechRecognitionTest.jsx │ │ ├── syllableParser.jsx │ │ ├── useWaveformTheme.jsx │ │ ├── WordDetails.jsx │ │ └── WordList.jsx │ ├── styles/ │ │ ├── index.css │ │ └── memory-card.css │ ├── ui/ │ │ └── Container.jsx │ └── utils/ │ ├── AccentLocalStorage.jsx │ ├── databaseOperations.jsx │ ├── ffmpegWavConverter.js │ ├── isElectron.jsx │ ├── isTouchDevice.jsx │ ├── levenshtein.js │ ├── openExternal.jsx │ ├── phonemeUtils.jsx │ ├── ShuffleArray.jsx │ ├── sonnerCustomToast.jsx │ ├── useCountdownTimer.jsx │ ├── useScrollTo.jsx │ └── ThemeContext/ │ ├── ThemeProvider.jsx │ ├── ThemeProviderContext.jsx │ ├── useAutoDetectTheme.jsx │ └── useTheme.jsx └── .github/ ├── ISSUE_TEMPLATE/ │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows/ ├── electron.yaml └── static.yml ================================================ FILE: README.md ================================================

iSpeakerReact homepage screenshot

Translation status

Currently, the app is available in English and Simplified Chinese. You can [help us translate into more languages](https://github.com/learnercraft/ispeakerreact/issues/18). # About iSpeakerReact **Introducing iSpeakerReact**: A modern reimagining, open-source alternative to Oxford iSpeaker Rewritten from the ground up with React + Vite, **iSpeakerReact** is a self-study interactive tool designed to help learners improve their speaking and listening skills. It is based on the original Oxford iSpeaker developed by Oxford University. While the official, modern, Angular-based Oxford iSpeaker has introduced significant design updates, it also streamlined or removed certain features that users found valuable (like the Exercises section). Therefore, we’ve decided to rebuild the app based on the older jQuery version, while taking inspiration from the newer Angular version to create a richer, more complete learning experience. > [!NOTE] > > 📢 The iSpeakerReact project has moved! > >Users should update their bookmarks to point to the new URL: > > 🔗 New URL: 👉 https://learnercraft.github.io/ispeakerreact/ > > Old URL (no longer maintained): https://yllst-testing-labs.github.io/ispeakerreact/ # Use the app Visit the webiste to use the tool online: Or download the offline portable version: For Windows, you can also download from the Microsoft Store if you want automatic updates:

Microsoft Store badge

The offline portable version (or "Slim edition" on the Microsoft Store) includes audio files for offline playback. You can manually download the video files in the Settings page for offline use. **Also check out other English-learning interactive tools:** - [iWriter](http://github.com/yell0wsuit/iwriter): help learners write effectively in English. - [Practical English Usage: Diagnostic Test](http://github.com/yell0wsuit/oxford-peu-diagnostics): help learners improve grammar and vocabulary skills. # Features - **Sounds** Learning to read phonetic transcriptions in the dictionary is a great way to ensure correct pronunciation. The Sounds section helps you practice pronouncing the International Phonetic Alphabet (IPA) used in English dictionaries. Begin with a video that demonstrates how each sound is pronounced, then use shorter clips to practice. You can also record* your pronunciation for later review and assessment. - **Words** Practice pronunciation with common words from the Oxford 3000™ and Oxford 5000™ lists. Each word is broken down into syllables with highlighted primary and secondary stress. Features include playback, recording, and reviewing your pronunciation with smiley ratings to track progress. - **Exercises** Interactive exercises that test how well you can listen and pronounce words. Choose from eight different exercise types: - Dictation: Test both your listening and spelling skills. - Match-up: Match sounds with the correct written words or phonetic transcriptions. - Reordering: Reorder words or sentences to match what you hear. - Sounds and Spelling: Listen and choose the correct vowel or diphthong spelling. - Sorting: Group words with the same sound, stress, or number of syllables. - Odd one out: Identify the word with a different stress pattern, rhythm, or number of syllables. - Snap!: Answer "Yes" or "No" to whether the phonetics match the written word or sound the same. - Memory match: A timed game where you find pairs of rhyming words or homophones. - **Conversations** Covering over 30 real-life, everyday topics, this section helps you learn how to use appropriate language for various situations. Watch videos to understand different contexts, highlight key phrases, explore additional expressions, and practice by writing and recording* your own conversations. - **Exams** Preparing for an English-speaking exam or a longer speaking task? The Exam section offers a wide range of tasks commonly encountered in evaluations like the Cambridge First, IELTS, TOEFL, and more. Watch real students complete these tasks, highlight key phrases from their responses, and expand your own repertoire of useful expressions. Boost your speaking skills by recording* yourself performing similar tasks, helping you enhance both your language proficiency and exam strategies. *In order to use the recording functionality, please ensure that you have a microphone device, and you have allowed the app to access it. # Browser compatibility Work on the latest version of the following browsers: - Chromium browsers (Microsoft Edge, Chrome, Opera, Brave, ...) - Mozilla Firefox - Safari (macOS, iOS, iPadOS) # Contributions Please see [CONTRIBUTING.MD](./CONTRIBUTING.md) for more information. # License This project is licensed under the Apache License 2.0. See the [LICENSE](./LICENSE) file for details. # Acknowledgements - [Oxford University Press](https://www.oxfordlearnersdictionaries.com/) for the original Oxford iSpeaker project. - [Weblate](https://weblate.org/) for providing the translation platform. - [i18next](https://www.i18next.com/) for the internationalization framework. - [React](https://react.dev/) and [Vite](https://vitejs.dev/) for the frontend framework and build tool. - [JS7z](https://github.com/GMH-Code/JS7z) for the 7-zip library used in the offline version. - [Tailwind CSS](https://tailwindcss.com/) & [daisyUI](https://daisyui.com/) for the UI design. - [React Icons](https://react-icons.github.io/react-icons/) for the icons. - [SVG Repo](https://www.svgrepo.com/) for the homepage icons. - [Logo.com](https://logo.com/) for the app logo. - [Electron](https://www.electronjs.org/) for the desktop app. - [dnd kit](https://dndkit.com/) for the drag-and-drop functionality. - [Sonner](https://sonner.emilkowal.ski/) for the toast notifications. - [Vidstack Player](https://vidstack.io/) for the custom video player used in the offline version. - [Wavesurfer.js](https://wavesurfer.xyz/) for the audio visualization. Special thanks to: - [Bootstrap](https://getbootstrap.com/) for the updated design in [version 2.0](https://github.com/learnercraft/ispeakerreact/pull/6). ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to iSpeakerReact Thank you for your interest in contributing! Please take a moment to review this guide before submitting issues, feature requests, translation contributions, or pull requests. > 📝 **Note:** This guide is not exhaustive. Project practices may evolve, and new situations may arise. When in doubt, feel free to ask questions or open an issue for clarification. ## 📬 Submitting issues, feature requests, or translations - To report bugs or request features, please [open an issue](https://github.com/learnercraft/ispeakerreact/issues/new/choose) and choose the appropriate category. - For translation contributions, please [refer to this issue](https://github.com/learnercraft/ispeakerreact/issues/18) for more information. ## 🔀 Submitting pull requests ### 📌 Project note - This project **does not use TypeScript** or any kind of static type checking yet. - In the meantime, **use** `PropTypes` to validate component props for basic type safety. ### ✅ What you should do - **Use a code editor** (e.g., Visual Studio Code) to write and format code efficiently. - **Format your code** before committing and pushing. You must use **Prettier with our configuration** to ensure consistent formatting. - **Test your code thoroughly** before pushing. Resolve any ESLint errors if possible. - An exception is allowed for variables defined in `vite.config.js` and available only at build time. If ESLint complains about these (e.g., the `__APP_VERSION__` variable), you can safely ignore the warning. - **Use clear, concise variable names** written in `camelCase`. Names should be self-explanatory and reflect their purpose. - **Use a clear, concise pull request title**. We recommend following [semantic commit message conventions](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716). Examples: - `fix: handle audio timeout error on older devices` - `feat: add pitch detection to feedback view` If the title doesn’t fully describe your changes, please provide a detailed description in the PR body. - Use **multiple small commits** with clear messages when possible. This improves readability and makes it easier to review specific changes. - Before submitting a **large pull request** or major change, open an issue first and select the appropriate category. After a review by our team, you can start your work. ### 🧪 Review process - All PRs are reviewed before merging. Please be responsive to feedback. When resolving comments, **make a new commit with**: `address feedback by @` ### 🤔 What you should NOT do - Submit pull requests that only include **cosmetic changes** like whitespace tweaks or code reformatting without any functional impact. These changes clutter diffs and make code reviews harder. [See this comment by the Rails team](https://github.com/rails/rails/pull/13771#issuecomment-32746700). - Submit a pull request with **one or several giant commit(s)**. This makes it difficult to review. - Use unclear, vague, or default commit messages like `Update file`, `fix`, or `misc changes`. - Modify configuration files (e.g., `.prettierrc`, `eslint.config.js`, etc.), or any files in the `.github` folder without prior discussion. ### 🚫 Prohibited actions - Add code or commits that: - Are **obscure** or **unclear** in intent - Are **malicious** or **unsafe** - **Executes scripts from external sources** associated with malicious, unsafe, or illegal behavior - Attempts to introduce **backdoors** or hidden functionality If we find any code that violates these rules, you will be blocked from further contributions and reported to GitHub for Terms of Use violations. - Use expletives or offensive language. This project is intended for everyone, and we strive to maintain a respectful environment for all contributors and users. --- Thank you again for helping us improve the project! ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js' import globals from 'globals' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' export default [ { ignores: ['dist'] }, { files: ['**/*.{js,jsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, sourceType: 'module', }, }, settings: { react: { version: '18.3' } }, plugins: { react, 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...js.configs.recommended.rules, ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, ...reactHooks.configs.recommended.rules, 'react/jsx-no-target-blank': 'off', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, ] ================================================ FILE: forge.config.cjs ================================================ const { FusesPlugin } = require("@electron-forge/plugin-fuses"); const { FuseV1Options, FuseVersion } = require("@electron/fuses"); const path = require("path"); module.exports = { packagerConfig: { asar: true, name: "iSpeakerReact", executableName: "ispeakerreact", appCopyright: "Licensed under the Apache License, Version 2.0. Video and audio materials © Oxford University Press. All rights reserved.", appBundleId: "page.learnercraft.ispeakerreact", appCategoryType: "public.app-category.education", win32metadata: { CompanyName: "LearnerCraft Labs", ProductName: "iSpeakerReact", }, prune: true, icon: path.join(__dirname, "dist", "appicon"), ignore: [ "^/\\.github$", // Ignore the .github directory "^/venv$", // Ignore the venv directory //"^/node_modules$", // Ignore the node_modules directory "^/\\.vscode$", // Ignore .vscode if exists "^/tests$", // Ignore tests directory if exists "^/scripts$", // Ignore scripts directory if exists "^/\\..*$", // Ignore any dotfiles (e.g., .gitignore, .eslintrc, etc.) "^/(README|SECURITY|CONTRIBUTING).md$", // Ignore README.md file "^/package-lock.json$", // Ignore package-lock.json file "^/public$", "^/src$", "^/netlify.toml$", ], }, rebuildConfig: {}, makers: [ { name: "@electron-forge/maker-zip", platforms: ["linux", "win32", "darwin"], }, /*{ name: "@electron-forge/maker-dmg", config: { options: { icon: path.join(__dirname, "dist", "appicon.icns"), }, }, },*/ { name: "@electron-forge/maker-deb", config: { options: { icon: path.join(__dirname, "dist", "appicon.png"), }, }, }, { name: "@electron-forge/maker-rpm", options: { icon: path.join(__dirname, "dist", "appicon.png"), }, }, /*{ name: "@electron-forge/maker-squirrel", config: (arch) => ({ setupIcon: path.join(__dirname, "dist", "appicon.ico"), iconUrl: path.join(__dirname, "dist", "appicon.ico"), setupExe: `iSpeakerReact-win32-${arch}-Setup.exe`, }), },*/ ], plugins: [ { name: "@electron-forge/plugin-auto-unpack-natives", config: {}, }, // Fuses are used to enable/disable various Electron functionality // at package time, before code signing the application new FusesPlugin({ version: FuseVersion.V1, [FuseV1Options.RunAsNode]: false, [FuseV1Options.EnableCookieEncryption]: true, [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, [FuseV1Options.EnableNodeCliInspectArguments]: false, [FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true, [FuseV1Options.OnlyLoadAppFromAsar]: true, }), ], }; ================================================ FILE: i18n.js ================================================ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import HttpApi from "i18next-http-backend"; i18n.use(HttpApi) // Load translations via HTTP (use with i18next-http-backend) .use(LanguageDetector) // Detect user language .use(initReactI18next) // Pass the i18n instance to react-i18next. .init({ lng: localStorage.getItem("ispeaker") ? JSON.parse(localStorage.getItem("ispeaker")).language : "en", fallbackLng: { "en-US": ["en"], "en-GB": ["en"], "zh-CN": ["zh"], default: ["en"], }, load: "languageOnly", debug: true, // Enable debug messages backend: { loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}.json`, // Translation files path }, interpolation: { escapeValue: false, // React already escapes values format: (value, format) => { if (format === "capitalize") { if (typeof value !== "string") return value; return value.charAt(0).toUpperCase() + value.slice(1); } return value; }, }, }) .then(() => { // Ensure the `lang` attribute on `html` is updated immediately const currentLang = i18n.language || "en"; document.documentElement.setAttribute("lang", currentLang); }); export default i18n; ================================================ FILE: index.html ================================================ iSpeakerReact
================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: main.js ================================================ /* global setImmediate */ // for eslint because setImmediate is node global import cors from "cors"; import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import applog from "electron-log"; import { Buffer } from "node:buffer"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { Conf } from "electron-conf/main"; import { createSplashWindow, createWindow } from "./electron-main/createWindow.js"; import { setCustomSaveFolderIPC } from "./electron-main/customFolderLocationOperation.js"; import { expressApp } from "./electron-main/expressServer.js"; import { getLogFolder, getSaveFolder, readUserSettings } from "./electron-main/filePath.js"; import { getCustomSaveFolderIPC, getFfmpegWasmPathIPC, getSaveFolderIPC, getVideoFileDataIPC, getVideoSaveFolderIPC, } from "./electron-main/getFileAndFolder.js"; import { getCurrentLogSettings, manageLogFiles, setCurrentLogSettings, } from "./electron-main/logOperations.js"; import { cancelProcess, checkPythonInstalled, downloadModel, installDependencies, killCurrentPythonProcess, resetGlobalCancel, setupPronunciationInstallStatusIPC, } from "./electron-main/pronunciationOperations.js"; import { checkDownloads, checkExtractedFolder } from "./electron-main/videoFileOperations.js"; import { verifyAndExtractIPC } from "./electron-main/zipOperation.js"; import { setupPronunciationCheckerIPC, setupGetRecordingBlobIPC, } from "./electron-main/pronunciationCheckerIPC.js"; const DEFAULT_PORT = 8998; let server; // Declare server at the top so it's in scope for all uses const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); let electronSquirrelStartup = false; try { electronSquirrelStartup = (await import("electron-squirrel-startup")).default; } catch (e) { console.log("Error importing electron-squirrel-startup:", e); applog.error("Error importing electron-squirrel-startup:", e); } if (electronSquirrelStartup) app.quit(); // Log operations manageLogFiles(); const conf = new Conf(); conf.registerRendererListener(); let mainWindow; // Allow requests from localhost:5173 (Vite's default development server) expressApp.use(cors({ origin: "http://localhost:5173" })); // Set up rate limiter: maximum of 2000 requests per 15 minutes /*const limiter = RateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 2000, });*/ // Set up the express server to serve video files expressApp.get("/video/:folderName/:fileName", async (req, res) => { const { folderName, fileName } = req.params; const documentsPath = await getSaveFolder(readUserSettings); const videoFolder = path.resolve(documentsPath, "video_files", folderName); const videoFilePath = path.resolve(videoFolder, fileName); if (!videoFilePath.startsWith(videoFolder)) { res.status(403).send("Access denied."); return; } try { await fsPromises.access(videoFilePath); const stat = await fsPromises.stat(videoFilePath); const fileSize = stat.size; const range = req.headers.range; if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const chunkSize = end - start + 1; const file = fs.createReadStream(videoFilePath, { start, end }); const head = { "Content-Range": `bytes ${start}-${end}/${fileSize}`, "Accept-Ranges": "bytes", "Content-Length": chunkSize, "Content-Type": "video/mp4", }; res.writeHead(206, head); file.pipe(res); } else { const head = { "Content-Length": fileSize, "Content-Type": "video/mp4", }; res.writeHead(200, head); fs.createReadStream(videoFilePath).pipe(res); } } catch { res.status(404).send("Video file not found."); return; } }); // IPC event from the renderer ipcMain.handle("open-external-link", async (event, url) => { await shell.openExternal(url); // Open the external link }); // Handle saving a recording ipcMain.handle("save-recording", async (event, key, arrayBuffer) => { const saveFolder = await getSaveFolder(readUserSettings); const recordingFolder = path.join(saveFolder, "saved_recordings"); const filePath = path.join(recordingFolder, `${key}.wav`); // Ensure the directory exists try { await fsPromises.access(recordingFolder); } catch { await fsPromises.mkdir(recordingFolder, { recursive: true }); } try { const buffer = Buffer.from(arrayBuffer); await fsPromises.writeFile(filePath, buffer); console.log("Recording saved to:", filePath); applog.log("Recording saved to:", filePath); return "Success"; } catch (error) { console.error("Error saving the recording to disk:", error); throw error; } }); // Handle checking if a recording exists ipcMain.handle("check-recording-exists", async (event, key) => { const saveFolder = await getSaveFolder(readUserSettings); const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`); try { await fsPromises.access(filePath); return true; } catch { return false; } }); // Handle playing a recording (this can be improved for streaming) ipcMain.handle("play-recording", async (event, key) => { const filePath = path.join( await getSaveFolder(readUserSettings), "saved_recordings", `${key}.wav` ); // Check if the file exists try { const data = await fsPromises.readFile(filePath); return data.buffer; // Return the ArrayBuffer to the renderer process } catch { console.error("File not found:", filePath); throw new Error("Recording file not found"); } }); /* Video file operations */ // Get video file data getVideoFileDataIPC(__dirname); // IPC event to get and open the video folder getVideoSaveFolderIPC(); // Check video file downloads checkDownloads(); // Check video file extracted folder checkExtractedFolder(); /* End video file operations */ // IPC event to get the current server port ipcMain.handle("get-port", () => { return server?.address()?.port || DEFAULT_PORT; }); ipcMain.handle("open-log-folder", async () => { // Open the folder in the file manager const logFolder = await getLogFolder(readUserSettings); await shell.openPath(logFolder); // Open the folder return logFolder; // Send the path back to the renderer }); ipcMain.handle("open-recording-folder", async () => { // Open the folder in the file manager const recordingFolder = await getSaveFolder(readUserSettings); const recordingFolderPath = path.join(recordingFolder, "saved_recordings"); try { await fsPromises.access(recordingFolderPath); } catch { await fsPromises.mkdir(recordingFolderPath, { recursive: true }); } await shell.openPath(recordingFolderPath); // Open the folder return recordingFolderPath; // Send the path back to the renderer }); // IPC event to verify and extract a zip file verifyAndExtractIPC(); // Listen for logging messages from the renderer process ipcMain.on("renderer-log", (event, logMessage) => { const { level, message } = logMessage; if (applog[level]) { applog[level](`Renderer log: ${message}`); } }); // Handle uncaught exceptions globally and quit the app process.on("uncaughtException", (error) => { console.error("An uncaught error occurred:", error); applog.error("An uncaught error occurred:", error); app.quit(); // Quit the app on an uncaught exception }); // Handle unhandled promise rejections globally and quit the app process.on("unhandledRejection", (reason, promise) => { console.error("Unhandled promise rejection at:", promise, "reason:", reason); applog.error("Unhandled promise rejection at:", promise, "reason:", reason); app.quit(); // Quit the app on an unhandled promise rejection }); app.on("renderer-process-crashed", (event, webContents, killed) => { applog.error("Renderer process crashed", { event, killed }); app.quit(); }); // Quit when all windows are closed, except on macOS. app.on("window-all-closed", () => { if (process.platform !== "darwin") { app.quit(); } }); // Recreate the window on macOS when the dock icon is clicked. app.on("activate", () => { if (mainWindow === null) { createWindow(__dirname, (srv) => { server = srv; }); } }); const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); process.exit(0); } else { app.whenReady() .then(() => { // 1. Show splash window immediately createSplashWindow(__dirname, ipcMain, conf); // 2. Start heavy work in parallel after splash is shown setImmediate(() => { // Create main window (can be shown after splash) createWindow(__dirname, (srv) => { server = srv; }); // Wait for log settings and manage logs in background ipcMain.once("update-log-settings", (event, settings) => { setCurrentLogSettings(settings); applog.info("Log settings received from renderer:", settings); manageLogFiles().then(() => { applog.info("Log files managed successfully."); }); }); }); }) .catch((error) => { // Catch any errors thrown in the app.whenReady() promise itself applog.error("Error in app.whenReady():", error); }); } getFfmpegWasmPathIPC(__dirname); /* Custom save folder operations */ // IPC: Get current save folder (resolved) getSaveFolderIPC(); // IPC: Get current custom save folder (raw, may be undefined) getCustomSaveFolderIPC(); // IPC: Set custom save folder setCustomSaveFolderIPC(); /* End custom save folder operations */ // IPC: Show open dialog for folder selection ipcMain.handle("show-open-dialog", async (event, options) => { const win = BrowserWindow.getFocusedWindow(); const result = await dialog.showOpenDialog(win, options); return result.filePaths; }); ipcMain.handle("get-log-settings", async () => { return getCurrentLogSettings(); }); // DEBUG: Trace undefined logs const origConsoleLog = console.log; console.log = (...args) => { if (args.length === 1 && args[0] === undefined) { origConsoleLog.call(console, "console.log(undefined) called! Stack trace:"); origConsoleLog.call(console, new Error().stack); } origConsoleLog.apply(console, args); }; /* Pronunciation checker operations */ ipcMain.handle("check-python-installed", async () => { try { const result = await checkPythonInstalled(); if (result.found) { applog.info("Python found:", result.version); } else { applog.error("Python not found. Stderr:", result.stderr); } return result; } catch (err) { applog.error("Error checking Python installation:", err); return { found: false, version: null, stderr: String(err) }; } }); installDependencies(); downloadModel(); cancelProcess(); // Setup pronunciation install status IPC setupPronunciationInstallStatusIPC(); // Before starting a new workflow, reset the global cancel flag ipcMain.handle("pronunciation-reset-cancel-flag", async () => { resetGlobalCancel(); }); /* End pronunciation checker operations */ ipcMain.handle("get-recording-path", async (_event, wordKey) => { const saveFolder = await getSaveFolder(readUserSettings); return path.join(saveFolder, "saved_recordings", `${wordKey}.wav`); }); setupPronunciationCheckerIPC(); setupGetRecordingBlobIPC(); app.on("before-quit", async () => { await killCurrentPythonProcess(); }); ================================================ FILE: netlify.toml ================================================ [build] publish = "dist" command = "npm run build" [context.deploy-preview] # For PR previews on Netlify [context.deploy-preview.environment] VITE_BASE = "/" ================================================ FILE: package.json ================================================ { "name": "ispeakerreact", "author": "LearnerCraft Labs", "description": "An English-learning interactive tool written in React, designed to help learners practice speaking and listening", "private": true, "version": "3.5.0", "type": "module", "main": "main.js", "license": "Apache-2.0", "scripts": { "dev": "vite", "build": "vite build && node scripts/minify-dist-jsons.js", "lint": "eslint .", "preview": "vite preview", "start": "concurrently \"cross-env NODE_ENV=development vite\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron .\"", "package": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package", "make": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge make", "vitebuildcli": "vite build --mode electron && node scripts/minify-dist-jsons.js", "makecli": "electron-forge make", "appx": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-x64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-x64 --assets ../public/images/icons/windows11 -m ./appxmanifest.xml", "appxarm": "vite build --mode electron && node scripts/minify-dist-jsons.js && electron-forge package --arch=arm64 && cd ./out && electron-windows-store --input-directory ./iSpeakerReact-win32-arm64 --output-directory ./ispeakerreact-appx --package-version ${npm_package_version}.0 --package-name ispeakerreact-arm64 --assets ../public/images/icons/windows11 -m ./appxmanifestARM.xml" }, "dependencies": { "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", "@fix-webm-duration/fix": "^1.0.1", "cors": "^2.8.5", "electron-conf": "^1.3.0", "electron-log": "^5.4.0", "electron-squirrel-startup": "^1.0.1", "express": "^5.2.1", "express-rate-limit": "^7.5.0", "fkill": "^9.0.0", "js7z-tools": "^2.4.1", "masonry-layout": "^4.2.2", "mime": "^4.0.7" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@electron-forge/cli": "^7.8.1", "@electron-forge/maker-deb": "^7.8.1", "@electron-forge/maker-dmg": "^7.8.1", "@electron-forge/maker-rpm": "^7.8.1", "@electron-forge/maker-squirrel": "^7.8.1", "@electron-forge/maker-zip": "^7.8.1", "@electron-forge/plugin-auto-unpack-natives": "^7.8.1", "@electron-forge/plugin-fuses": "^7.8.1", "@electron/fuses": "^1.8.0", "@eslint/js": "^9.27.0", "@tailwindcss/postcss": "^4.1.7", "@tailwindcss/typography": "^0.5.16", "@tailwindcss/vite": "^4.1.7", "@types/react": "^19.1.5", "@types/react-dom": "^19.1.5", "@vidstack/react": "^1.12.13", "@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react-swc": "^3.10.0", "@wavesurfer/react": "^1.0.11", "autoprefixer": "^10.4.21", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "daisyui": "^5.0.37", "dexie": "^4.0.11", "electron": "^36.9.5", "eslint": "^9.27.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^6.0.0-rc.1", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.1.0", "he": "^1.2.0", "i18next": "^25.2.0", "i18next-browser-languagedetector": "^8.1.0", "i18next-http-backend": "^3.0.2", "lodash": "^4.17.21", "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", "react": "^19.1.0", "react-dom": "^19.1.0", "react-flip-toolkit": "^7.2.4", "react-i18next": "^15.5.2", "react-icons": "^5.5.0", "react-loading-skeleton": "^3.5.0", "react-router-dom": "^7.6.0", "rollup-plugin-visualizer": "^6.0.0", "sonner": "^2.0.3", "tailwindcss": "^4.1.7", "vidstack": "^1.12.12", "vite": "^6.4.1", "vite-plugin-pwa": "^1.0.0", "wait-on": "^8.0.3", "wavesurfer.js": "^7.9.5" }, "optionalDependencies": { "appdmg": "latest" } } ================================================ FILE: postcss.config.js ================================================ export default { plugins: { '@tailwindcss/postcss': {}, }, } ================================================ FILE: preload.cjs ================================================ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("electron", { openExternal: (url) => ipcRenderer.invoke("open-external-link", url), saveRecording: (key, arrayBuffer) => ipcRenderer.invoke("save-recording", key, arrayBuffer), checkRecordingExists: (key) => ipcRenderer.invoke("check-recording-exists", key), playRecording: (key) => ipcRenderer.invoke("play-recording", key), ipcRenderer: { invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), send: (channel, ...args) => ipcRenderer.send(channel, ...args), on: (channel, func) => ipcRenderer.on(channel, func), removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel), removeListener: (channel, func) => ipcRenderer.removeListener(channel, func), }, getDirName: () => __dirname, isUwp: () => process.windowsStore, send: (channel, data) => { ipcRenderer.send(channel, data); }, log: (level, message) => { // Send log message to the main process ipcRenderer.send("renderer-log", { level, message }); }, getRecordingBlob: async (key) => { // Use IPC to ask the main process for the blob return await ipcRenderer.invoke("get-recording-blob", key); }, getFfmpegWasmPath: async () => { return await ipcRenderer.invoke("get-ffmpeg-wasm-path"); }, getFileAsBlobUrl: async (filePath, mimeType) => { const arrayBuffer = await ipcRenderer.invoke("read-file-buffer", filePath); const blob = new Blob([arrayBuffer], { type: mimeType }); return URL.createObjectURL(blob); }, }); ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions We are committed to supporting only the latest version of the app. All users are required to be on the latest version of *iSpeakerReact* to ensure they receive critical security patches, bug fixes, and feature improvements. Older versions are not supported and may contain unpatched vulnerabilities. The latest version of the software adheres to either the web version or the most recent release available on GitHub, with the web version being the preferred choice. Additionally, we will make effort to update the Microsoft Store and other app stores with the published version on GitHub. Users still using *iSpeaker: Pronunciation Tool* from the Microsoft Store should uninstall it from their system and switch to iSpeakerReact. ## Reporting a Vulnerability > [!WARNING] > **Do not open a public issue to report security vulnerabilities.** If you discover a security vulnerability, please report it privately by using GitHub's private disclosure feature: 🔒 [Report via GitHub Security Advisories](https://github.com/learnercraft/ispeakerreact/security/advisories) We will respond as soon as possible, typically within 48 hours. ================================================ FILE: vite.config.js ================================================ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react-swc"; import { visualizer } from "rollup-plugin-visualizer"; import { defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; import packageJson from "./package.json"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { const isElectron = mode === "electron"; const isDev = mode === "development"; const base = process.env.VITE_BASE || isElectron ? "./" : isDev ? "/" : "/ispeakerreact/"; return { base: base, build: { rollupOptions: { output: { ...(isElectron ? { inlineDynamicImports: true } // disables chunking for Electron : { manualChunks(id) { if (id.includes("node_modules")) { return id .toString() .split("node_modules/")[1] .split("/")[0] .toString(); } }, }), }, }, manifest: true, }, plugins: [ react(), visualizer(), tailwindcss(), !isElectron && VitePWA({ registerType: "autoUpdate", manifest: { name: "iSpeakerReact", short_name: "iSpeakerReact", theme_color: "#2a303c", background_color: "#2a303c", description: "An English-learning interactive tool written in React, designed to help learners practice speaking and listening.", lang: "en", icons: [ { src: `${base}images/icons/ios/192.png`, sizes: "192x192", type: "image/png", }, { src: `${base}images/icons/ios/512.png`, sizes: "512x512", type: "image/png", }, ], screenshots: [ { src: `${base}images/screenshots/screenshot-00.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-01.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-02.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-03.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-04.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-05.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-06.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, { src: `${base}images/screenshots/screenshot-07.webp`, sizes: "1920x1080", type: "image/webp", form_factor: "wide", }, ], id: "io.yllsttestinglabs.ispeakerreact", dir: "auto", orientation: "any", categories: ["education"], prefer_related_applications: false, }, workbox: { runtimeCaching: [ { // Files that need caching permanently urlPattern: /\.(?:woff2|ttf|jpg|jpeg|webp|svg|ico|png)$/, handler: "CacheFirst", options: { cacheName: "permanent-cache", expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year }, }, }, { // Cache other assets dynamically with versioning urlPattern: /\.(?:js|css|json|html)$/, handler: "CacheFirst", options: { cacheName: `dynamic-cache-v${packageJson.version}`, expiration: { maxEntries: 150, maxAgeSeconds: 60 * 60 * 24 * 7, // 1 week }, }, }, ], }, }), ], define: { __APP_VERSION__: JSON.stringify(packageJson.version), // Inject version }, optimizeDeps: { exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"], }, server: { fs: { // Allow serving files from one level up to the project root allow: [".."], }, }, }; }); ================================================ FILE: .prettierrc ================================================ { "trailingComma": "es5", "tabWidth": 4, "semi": true, "singleQuote": false, "useTabs": false, "printWidth": 100, "plugins": ["prettier-plugin-tailwindcss"] } ================================================ FILE: data/splash.html ================================================ iSpeakerReact
Splash Image
================================================ FILE: electron-main/createWindow.js ================================================ import { app, BrowserWindow, Menu, shell } from "electron"; import applog from "electron-log"; import path from "node:path"; import process from "node:process"; import { startExpressServer } from "./expressServer.js"; const isDev = process.env.NODE_ENV === "development"; let mainWindow; let splashWindow; const createSplashWindow = (rootDir, ipcMain, conf) => { splashWindow = new BrowserWindow({ width: 854, height: 413, frame: false, // Remove window controls transparent: true, // Make the window background transparent alwaysOnTop: true, icon: path.join(rootDir, "dist", "appicon.png"), webPreferences: { preload: path.join(rootDir, "preload.cjs"), nodeIntegration: false, contextIsolation: true, enableRemoteModule: false, devTools: isDev ? true : false, }, }); // For splash screen ipcMain.handle("get-conf", (event, key) => { return conf.get(key); }); // Load the splash screen HTML splashWindow.loadFile(path.join(rootDir, "data", "splash.html")); splashWindow.setTitle("Starting up..."); // Splash window should close when the main window is ready splashWindow.on("closed", () => { splashWindow = null; }); }; const createWindow = (rootDir, onServerReady) => { mainWindow = new BrowserWindow({ width: 1280, height: 720, show: false, webPreferences: { preload: path.join(rootDir, "preload.cjs"), nodeIntegration: false, contextIsolation: true, enableRemoteModule: false, devTools: isDev ? true : false, }, icon: path.join(rootDir, "dist", "appicon.png"), }); if (isDev) { mainWindow.loadURL("http://localhost:5173"); // Point to Vite dev server } else { mainWindow.loadFile(path.join(rootDir, "./dist/index.html")); // Load the built HTML file } // Show the main window only when it's ready mainWindow.once("ready-to-show", () => { setTimeout(() => { splashWindow.close(); mainWindow.maximize(); mainWindow.show(); // Start Express server in the background after main window is shown startExpressServer().then((srv) => { if (onServerReady) onServerReady(srv); }); }, 500); }); mainWindow.on("closed", () => { mainWindow = null; }); mainWindow.on("enter-full-screen", () => { mainWindow.setMenuBarVisibility(false); }); mainWindow.on("leave-full-screen", () => { mainWindow.setMenuBarVisibility(true); }); const menu = Menu.buildFromTemplate([ { label: "File", submenu: [{ role: "quit" }], }, { label: "Edit", submenu: [ { role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, ], }, { label: "View", submenu: [ { role: "reload" }, { role: "forceReload" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }, isDev ? { role: "toggleDevTools" } : null, ].filter(Boolean), }, { label: "Window", submenu: [{ role: "minimize" }, { role: "zoom" }], }, { label: "About", submenu: [ { label: "Project's GitHub page", click: () => { shell.openExternal("https://github.com/learnercraft/ispeakerreact"); }, }, ], }, ]); Menu.setApplicationMenu(menu); app.on("second-instance", () => { if (mainWindow) { if (mainWindow.isMinimized()) { mainWindow.restore(); } mainWindow.focus(); } }); applog.info(`App started. Version ${app.getVersion()}`); }; export { createSplashWindow, createWindow }; ================================================ FILE: electron-main/customFolderLocationOperation.js ================================================ import { ipcMain } from "electron"; import applog from "electron-log"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { getSaveFolder, settingsConf, getDataSubfolder, deleteEmptyDataSubfolder, } from "./filePath.js"; import isDeniedSystemFolder from "./isDeniedSystemFolder.js"; import { generateLogFileName } from "./logOperations.js"; // Helper: Recursively collect all files in a directory const getAllFiles = async (dir, base = dir) => { let files = []; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { files = files.concat(await getAllFiles(fullPath, base)); } else { files.push({ abs: fullPath, rel: path.relative(base, fullPath), }); } } return files; }; // Helper: Determine if folder contents should be moved const shouldMoveContents = (src, dest) => { return ( src && dest && src !== dest && fs.existsSync(src) && fs.existsSync(dest) && !src.startsWith(dest) && !dest.startsWith(src) ); }; // Helper: Delete pronunciation-venv in oldSaveFolder before moving const deletePronunciationVenv = async (oldSaveFolder, event) => { const oldVenvPath = path.join(oldSaveFolder, "pronunciation-venv"); if (fs.existsSync(oldVenvPath)) { event.sender.send("venv-delete-status", { status: "deleting", path: oldVenvPath }); try { await fsPromises.rm(oldVenvPath, { recursive: true, force: true }); console.log("Deleted old pronunciation-venv at:", oldVenvPath); applog.info("Deleted old pronunciation-venv at:", oldVenvPath); event.sender.send("venv-delete-status", { status: "deleted", path: oldVenvPath }); } catch (venvErr) { // Log but do not block move if venv doesn't exist or can't be deleted console.log("Could not delete old pronunciation-venv:", venvErr.message); applog.warn("Could not delete old pronunciation-venv:", venvErr.message); event.sender.send("venv-delete-status", { status: "error", path: oldVenvPath, error: venvErr.message, }); } } }; // Helper: Move all contents from one folder to another (copy then delete, robust for cross-device) const moveFolderContents = async (src, dest, event) => { // Recursively collect all files for accurate progress const files = await getAllFiles(src); const total = files.length; let moved = 0; // 1. Copy all files for (const file of files) { const srcPath = file.abs; const destPath = path.join(dest, file.rel); await fsPromises.mkdir(path.dirname(destPath), { recursive: true }); await fsPromises.copyFile(srcPath, destPath); moved++; if (event) event.sender.send("move-folder-progress", { moved, total, phase: "copy", name: file.rel, }); } // 2. Delete all originals (files only) moved = 0; for (const file of files) { const srcPath = file.abs; await fsPromises.rm(srcPath, { force: true }); moved++; if (event) event.sender.send("move-folder-progress", { moved, total, phase: "delete", name: file.rel, }); } // 3. Remove empty directories in src (track progress for dirs) // We'll collect all directories and send progress for each const collectDirs = async (dir) => { let dirs = [dir]; const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const fullPath = path.join(dir, entry.name); dirs = dirs.concat(await collectDirs(fullPath)); } } return dirs; }; const allDirs = await collectDirs(src); let dirDeleted = 0; for (const dirPath of allDirs.reverse()) { // delete from deepest try { await fsPromises.rmdir(dirPath); dirDeleted++; if (event) { event.sender.send("move-folder-progress", { moved: dirDeleted, total: allDirs.length, phase: "delete-dir", name: path.relative(src, dirPath) || ".", }); } } catch { // Intentionally ignore errors if directory is not empty or already removed } } if (event) event.sender.send("move-folder-progress", { moved: total, total, phase: "delete-done", name: null, }); }; // IPC: Set custom save folder with validation and move contents const setCustomSaveFolderIPC = () => { ipcMain.handle("set-custom-save-folder", async (event, folderPath) => { const oldSaveFolder = await getSaveFolder(); let newSaveFolder; let prevCustomFolder = null; if (!folderPath) { // Reset to default const userSettings = settingsConf.store || {}; if (userSettings.customSaveFolder) { prevCustomFolder = userSettings.customSaveFolder; } settingsConf.delete("customSaveFolder"); // Use getSaveFolder to get the default save folder newSaveFolder = await getSaveFolder(); applog.info("Reset to default save folder:", newSaveFolder); // Move contents back from previous custom folder's data subfolder if it exists let prevDataSubfolder = null; if (prevCustomFolder) { prevDataSubfolder = getDataSubfolder(prevCustomFolder); } // Ensure the destination exists before checking shouldMoveContents try { await fsPromises.mkdir(newSaveFolder, { recursive: true }); } catch (e) { console.log("Failed to create default save folder:", e); applog.error("Failed to create default save folder:", e); return { success: false, error: "folderChangeError", reason: e.message || "Unknown error", }; } applog.info("[DEBUG] prevDataSubfolder:", prevDataSubfolder); applog.info("[DEBUG] newSaveFolder:", newSaveFolder); const shouldMove = shouldMoveContents(prevDataSubfolder, newSaveFolder); applog.info("[DEBUG] shouldMoveContents:", shouldMove); if (shouldMove) { applog.info( "[DEBUG] Calling moveFolderContents with:", prevDataSubfolder, newSaveFolder ); try { await deletePronunciationVenv(prevDataSubfolder, event); await moveFolderContents(prevDataSubfolder, newSaveFolder, event); } catch (moveBackErr) { console.log("Failed to move contents back to default folder:", moveBackErr); applog.error("Failed to move contents back to default folder:", moveBackErr); return { success: false, error: "folderMoveError", reason: moveBackErr.message || "Unknown move error", }; } } } else { try { await fsPromises.access(folderPath); const stat = await fsPromises.stat(folderPath); if (!stat.isDirectory()) { console.log("Folder is not a directory:", folderPath); applog.error("Folder is not a directory:", folderPath); return { success: false, error: "folderChangeError", reason: "toast.folderNotDir", }; } if (isDeniedSystemFolder(folderPath)) { console.log("Folder is restricted:", folderPath); applog.error("Folder is restricted:", folderPath); return { success: false, error: "folderChangeError", reason: "toast.folderRestricted", }; } const testFile = path.join( folderPath, `.__ispeakerreact_test_${process.pid}_${Date.now()}` ); try { await fsPromises.writeFile(testFile, "test"); await fsPromises.unlink(testFile); } catch { console.log("Folder is not writable:", folderPath); applog.error("Folder is not writable:", folderPath); return { success: false, error: "folderChangeError", reason: "toast.folderNoWrite", }; } // All good, save settingsConf.set("customSaveFolder", folderPath); // Use the data subfolder for all operations newSaveFolder = getDataSubfolder(folderPath); // Ensure the data subfolder exists try { await fsPromises.mkdir(newSaveFolder, { recursive: true }); } catch (e) { console.log("Failed to create data subfolder:", e); applog.error("Failed to create data subfolder:", e); return { success: false, error: "folderChangeError", reason: e.message || "Unknown error", }; } console.log("New save folder:", newSaveFolder); applog.info("New save folder:", newSaveFolder); } catch (err) { console.log("Error setting custom save folder:", err); applog.error("Error setting custom save folder:", err); return { success: false, error: "folderChangeError", reason: err.message || "Unknown error", }; } } // Move contents if needed try { if (shouldMoveContents(oldSaveFolder, newSaveFolder)) { await deletePronunciationVenv(oldSaveFolder, event); await moveFolderContents(oldSaveFolder, newSaveFolder, event); } // Update log directory and file name to new save folder const currentLogFolder = path.join(newSaveFolder, "logs"); try { await fsPromises.mkdir(currentLogFolder, { recursive: true }); } catch (e) { console.log("Failed to create new log directory:", e); applog.warn("Failed to create new log directory:", e); } applog.transports.file.fileName = generateLogFileName(); applog.transports.file.resolvePathFn = () => path.join(currentLogFolder, applog.transports.file.fileName); applog.info("New log directory:", currentLogFolder); console.log("New log directory:", currentLogFolder); // Only delete the empty ispeakerreact_data subfolder, never the parent custom folder if (prevCustomFolder) { await deleteEmptyDataSubfolder(prevCustomFolder); } return { success: true, newPath: newSaveFolder }; } catch (moveErr) { console.log("Failed to move folder contents:", moveErr); applog.error("Failed to move folder contents:", moveErr); return { success: false, error: "folderMoveError", reason: moveErr.message || "Unknown move error", }; } }); }; export { setCustomSaveFolderIPC }; ================================================ FILE: electron-main/expressServer.js ================================================ import applog from "electron-log"; import express from "express"; import net from "net"; const DEFAULT_PORT = 8998; const MIN_PORT = 1024; // Minimum valid port number const MAX_PORT = 65535; // Maximum valid port number // Create Express server const expressApp = express(); // Function to generate a random port number within the range const getRandomPort = () => { return Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT; }; // Function to check if a port is available const checkPortAvailability = (port) => { return new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", (err) => { if (err.code === "EADDRINUSE" || err.code === "ECONNREFUSED") { resolve(false); // Port is in use applog.log("Port is in use. Error:", err.code); } else { reject(err); // Some other error occurred applog.log("Another error related to the Express server. Error:", err.code); } }); server.once("listening", () => { server.close(() => { resolve(true); // Port is available }); }); server.listen(port); }); }; // Function to start the Express server with the default port first, then randomize if necessary const startExpressServer = async () => { let port = DEFAULT_PORT; let isPortAvailable = await checkPortAvailability(port); if (!isPortAvailable) { applog.warn(`Default port ${DEFAULT_PORT} is in use. Trying a random port...`); do { port = getRandomPort(); isPortAvailable = await checkPortAvailability(port); } while (!isPortAvailable); } return expressApp.listen(port, () => { applog.info(`Express server is running on http://localhost:${port}`); }); }; export { expressApp, startExpressServer }; ================================================ FILE: electron-main/filePath.js ================================================ import { app, ipcMain } from "electron"; import { Conf } from "electron-conf/main"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; // Singleton instance for settings const settingsConf = new Conf({ name: "ispeakerreact_config" }); const readUserSettings = async () => { return settingsConf.store || {}; }; const getSaveFolder = async () => { // Try to get custom folder from user settings const userSettings = await readUserSettings(); let saveFolder; if (userSettings.customSaveFolder) { // For custom folder, use a subfolder 'ispeakerreact_data' const baseFolder = userSettings.customSaveFolder; // Ensure the base directory exists try { await fsPromises.access(baseFolder); } catch { await fsPromises.mkdir(baseFolder, { recursive: true }); } saveFolder = path.join(baseFolder, "ispeakerreact_data"); // Ensure the 'ispeakerreact_data' subfolder exists try { await fsPromises.access(saveFolder); } catch { await fsPromises.mkdir(saveFolder, { recursive: true }); } } else { // For default, just use Documents/iSpeakerReact const documentsPath = app.getPath("documents"); saveFolder = path.join(documentsPath, "iSpeakerReact"); // Ensure the directory exists try { await fsPromises.access(saveFolder); } catch { await fsPromises.mkdir(saveFolder, { recursive: true }); } } return saveFolder; }; const getLogFolder = async () => { const saveFolder = path.join(await getSaveFolder(), "logs"); // Ensure the directory exists try { await fsPromises.access(saveFolder); } catch { await fsPromises.mkdir(saveFolder, { recursive: true }); } return saveFolder; }; // Helper to get the data subfolder path const getDataSubfolder = (baseFolder) => { return path.join(baseFolder, "ispeakerreact_data"); }; // Synchronous version to get the log folder path const getLogFolderSync = () => { let saveFolder; const userSettings = settingsConf.store || {}; if (userSettings.customSaveFolder) { // For custom folder, use the data subfolder saveFolder = path.join(userSettings.customSaveFolder, "ispeakerreact_data"); } else { const documentsPath = app.getPath("documents"); saveFolder = path.join(documentsPath, "iSpeakerReact"); } return path.join(saveFolder, "logs"); }; // Helper to delete the empty ispeakerreact_data subfolder const deleteEmptyDataSubfolder = async (baseFolder) => { const dataFolder = path.join(baseFolder, "ispeakerreact_data"); try { const files = await fsPromises.readdir(dataFolder); if (files.length === 0) { await fsPromises.rmdir(dataFolder); return true; } } catch { // Folder does not exist or other error, ignore } return false; }; // Add IPC handlers for theme ipcMain.handle("get-theme", async () => { return settingsConf.get("ispeakerreact-ui-theme") || "auto"; }); ipcMain.handle("set-theme", async (event, newTheme) => { settingsConf.set("ispeakerreact-ui-theme", newTheme); return true; }); export { getDataSubfolder, getLogFolder, getLogFolderSync, getSaveFolder, readUserSettings, settingsConf, deleteEmptyDataSubfolder, }; ================================================ FILE: electron-main/getFileAndFolder.js ================================================ import { ipcMain, shell } from "electron"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getSaveFolder, readUserSettings } from "./filePath.js"; import fs from "node:fs"; const getVideoFileDataIPC = (rootDir) => { ipcMain.handle("get-video-file-data", async () => { const jsonPath = path.join(rootDir, "dist", "json", "videoFilesInfo.json"); try { const jsonData = await fsPromises.readFile(jsonPath, "utf-8"); // Asynchronously read the JSON file return JSON.parse(jsonData); // Parse the JSON string and return it } catch (error) { console.error("Error reading JSON file:", error); throw error; // Ensure that any error is propagated back to the renderer process } }); }; const getVideoSaveFolderIPC = () => { ipcMain.handle("get-video-save-folder", async () => { const saveFolder = await getSaveFolder(readUserSettings); const videoFolder = path.join(saveFolder, "video_files"); // Ensure the directory exists try { await fsPromises.access(videoFolder); } catch { await fsPromises.mkdir(videoFolder, { recursive: true }); } // Open the folder in the file manager shell.openPath(videoFolder); // Open the folder return videoFolder; // Send the path back to the renderer }); }; // IPC: Get current save folder (resolved) const getSaveFolderIPC = () => { ipcMain.handle("get-save-folder", async () => { return await getSaveFolder(readUserSettings); }); }; // IPC: Get current custom save folder (raw, may be undefined) const getCustomSaveFolderIPC = () => { ipcMain.handle("get-custom-save-folder", async () => { const userSettings = await readUserSettings(); return userSettings.customSaveFolder || null; }); }; // IPC: Get ffmpeg wasm absolute path const getFfmpegWasmPathIPC = (rootDir) => { ipcMain.handle("get-ffmpeg-wasm-path", async () => { // Adjust the path as needed if you move the file elsewhere return path.resolve(rootDir, "data", "ffmpeg"); }); }; // IPC: Read file as buffer for ffmpeg ipcMain.handle("read-file-buffer", async (event, filePath) => { return fs.readFileSync(filePath); // returns Buffer, serialized as Uint8Array }); export { getCustomSaveFolderIPC, getFfmpegWasmPathIPC, getSaveFolderIPC, getVideoFileDataIPC, getVideoSaveFolderIPC, }; ================================================ FILE: electron-main/isDeniedSystemFolder.js ================================================ import { app } from "electron"; import applog from "electron-log"; import path from "node:path"; import process from "node:process"; import fs from "node:fs"; const isDeniedSystemFolder = (folderPath) => { // Normalize and resolve the path to prevent path traversal attacks // This converts to absolute path and resolves ".." and "." segments let absoluteInput; try { // Resolve symlinks for robust security absoluteInput = fs.realpathSync.native(path.resolve(folderPath)); } catch (err) { console.warn("Error getting realpath:", err.message); applog.error("Error getting realpath:", err.message); } const platform = process.platform; const isCaseSensitive = platform !== "win32"; // Handle potential trailing slashes inconsistencies if (!absoluteInput.endsWith(path.sep) && absoluteInput !== path.sep) { absoluteInput += path.sep; } console.log("Checking path:", absoluteInput); applog.info("Checking path:", absoluteInput); // Define protected system locations based on platform let denyList = []; if (platform === "win32") { // Windows const systemDrive = process.env.SystemDrive || "C:"; const systemRoot = process.env.SystemRoot || path.join(systemDrive, "Windows"); denyList = [ // System directories systemDrive + path.sep, systemRoot, path.join(systemDrive, "Program Files"), path.join(systemDrive, "Program Files (x86)"), path.join(systemDrive, "Users"), path.join(systemRoot, "System32"), path.join(systemRoot, "SysWOW64"), // Additional sensitive locations path.join(systemDrive, "ProgramData"), path.join(systemDrive, "Recovery"), path.join(systemDrive, "Boot"), ]; } else if (platform === "darwin") { // macOS - using path.sep for consistency denyList = [ `${path.sep}System`, `${path.sep}Library`, `${path.sep}bin`, `${path.sep}sbin`, `${path.sep}usr`, `${path.sep}private`, `${path.sep}etc`, `${path.sep}var`, `${path.sep}Applications`, `${path.sep}Users${path.sep}Shared`, `${path.sep}Network`, `${path.sep}Volumes`, `${path.sep}cores`, ]; // Explicitly deny root directory denyList.push(path.sep); // Only deny other users' home directories, not the current user's const userHome = app.getPath("home"); denyList.push(path.join(path.sep, "Users")); // For subfolders denyList = denyList.filter((p) => p !== path.join(path.sep, "Users")); // Remove blanket /Users // Deny all /Users/* except current user const usersDir = path.join(path.sep, "Users"); try { const entries = fs.readdirSync(usersDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const userDir = path.join(usersDir, entry.name); if (userDir !== userHome) { denyList.push(userDir); } } } } catch (err) { console.warn("Error getting users directory:", err.message); applog.error("Error getting users directory:", err.message); } } else { // Linux and other Unix-like - using path.sep for consistency // We'll manually mark virtual filesystem paths that shouldn't be resolved const virtualPaths = [`${path.sep}proc`, `${path.sep}sys`, `${path.sep}dev`]; const standardPaths = [ `${path.sep}bin`, `${path.sep}boot`, `${path.sep}etc`, `${path.sep}home`, `${path.sep}lib`, `${path.sep}lib64`, `${path.sep}media`, `${path.sep}mnt`, `${path.sep}opt`, `${path.sep}root`, `${path.sep}run`, `${path.sep}sbin`, `${path.sep}srv`, `${path.sep}tmp`, `${path.sep}usr`, `${path.sep}var`, ]; denyList = [...virtualPaths, ...standardPaths]; // Explicitly deny root directory denyList.push(path.sep); // Only deny other users' home directories, not the current user's const userHome = app.getPath("home"); denyList.push(path.join(path.sep, "home")); // For subfolders denyList = denyList.filter((p) => p !== path.join(path.sep, "home")); // Remove blanket /home // Deny all /home/* except current user const homeDir = path.join(path.sep, "home"); try { const entries = fs.readdirSync(homeDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const userDir = path.join(homeDir, entry.name); if (userDir !== userHome) { denyList.push(userDir); } } } } catch (err) { console.warn("Error getting home directory:", err.message); applog.error("Error getting home directory:", err.message); } } // Add app-specific directories for all platforms try { const appPaths = [ app.getPath("userData"), app.getPath("exe"), app.getPath("appData"), app.getPath("temp"), app.getPath("logs"), app.getPath("crashDumps"), ]; denyList = [...denyList, ...appPaths]; } catch (err) { console.warn("Error getting app paths:", err.message); applog.error("Error getting app paths:", err.message); } // De-duplicate and filter out any undefined or empty values denyList = [...new Set(denyList)].filter(Boolean); console.log("Raw deny list:", denyList); applog.info("Raw deny list:", denyList); // Format all deny paths consistently for comparison const formattedDenyList = denyList.map((p) => { // Either use the path as-is (for virtual paths) or resolve it let formatted; try { formatted = fs.realpathSync.native(path.resolve(p)); } catch { formatted = path.resolve(p); } if (!isCaseSensitive) formatted = formatted.toLowerCase(); if (!formatted.endsWith(path.sep) && formatted !== path.sep) { formatted += path.sep; } return formatted; }); console.log("Formatted deny list:", formattedDenyList); applog.info("Formatted deny list:", formattedDenyList); if (!isCaseSensitive) absoluteInput = absoluteInput.toLowerCase(); // Always allow the current user's home directory (with or without trailing slash) const userHome = app.getPath("home"); let userHomeFormatted = userHome; if (!userHomeFormatted.endsWith(path.sep) && userHomeFormatted !== path.sep) { userHomeFormatted += path.sep; } let absoluteInputWithSep = absoluteInput; if (!absoluteInputWithSep.endsWith(path.sep) && absoluteInputWithSep !== path.sep) { absoluteInputWithSep += path.sep; } if (absoluteInput === userHome || absoluteInputWithSep === userHomeFormatted) { return false; } // Check if the path is a protected system folder or a subfolder of one return formattedDenyList.some((protectedPath) => { // Special case: only block root if it's exactly root if (protectedPath === path.sep) { return absoluteInput === protectedPath; } const isMatch = absoluteInput === protectedPath || absoluteInput.startsWith(protectedPath); if (isMatch) { console.log(`Match found: ${absoluteInput} matches ${protectedPath}`); applog.info(`Match found: ${absoluteInput} matches ${protectedPath}`); } return isMatch; }); }; export default isDeniedSystemFolder; ================================================ FILE: electron-main/logOperations.js ================================================ import { ipcMain } from "electron"; import applog from "electron-log"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getLogFolder, getLogFolderSync, readUserSettings, settingsConf } from "./filePath.js"; const defaultLogSettings = { numOfLogs: 10, keepForDays: 0, logLevel: "info", logFormat: "{h}:{i}:{s} {text}", maxLogSize: 5 * 1024 * 1024, }; let currentLogSettings = { ...defaultLogSettings }; const userLogSettings = settingsConf.get("logSettings"); if (userLogSettings) { currentLogSettings = { ...currentLogSettings, ...userLogSettings }; } const getCurrentLogSettings = () => { return currentLogSettings; }; const setCurrentLogSettings = (newSettings) => { currentLogSettings = { ...currentLogSettings, ...newSettings }; }; // Function to generate the log file name with date-time appended const generateLogFileName = () => { const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); return `ispeakerreact-log_${year}-${month}-${day}_${hours}-${minutes}-${seconds}.log`; }; // Configure electron-log to use the log directory applog.transports.file.fileName = generateLogFileName(); applog.transports.file.resolvePathFn = () => { const logFolder = getLogFolderSync(); return path.join(logFolder, applog.transports.file.fileName); }; applog.transports.file.maxSize = currentLogSettings.maxLogSize; applog.transports.console.level = currentLogSettings.logLevel; // Handle updated log settings from the renderer ipcMain.on("update-log-settings", async (event, newSettings) => { setCurrentLogSettings(newSettings); applog.info("Log settings updated:", currentLogSettings); // Save to user settings file settingsConf.set("logSettings", currentLogSettings); manageLogFiles(); }); // Function to check and manage log files based on the currentLogSettings const manageLogFiles = async () => { try { const { numOfLogs, keepForDays } = currentLogSettings; applog.info("Log settings:", currentLogSettings); // Get the current log folder dynamically const logFolder = await getLogFolder(readUserSettings); // Get all log files const logFiles = await fsPromises.readdir(logFolder); const logFilesResolved = []; for (const file of logFiles) { const filePath = path.join(logFolder, file); try { const stats = await fsPromises.stat(filePath); logFilesResolved.push({ path: filePath, birthtime: stats.birthtime, }); } catch (err) { if (err.code !== "ENOENT") { applog.error(`Error stating log file: ${filePath}`, err); } // If ENOENT, just skip this file } } // Sort log files by creation time (oldest first) logFilesResolved.sort((a, b) => a.birthtime - b.birthtime); // Remove logs if they exceed the specified limit (excluding 0 for unlimited) if (numOfLogs > 0 && logFilesResolved.length > numOfLogs) { const filesToDelete = logFilesResolved.slice(0, logFilesResolved.length - numOfLogs); for (const file of filesToDelete) { try { await fsPromises.unlink(file.path); applog.info(`Deleted log file: ${file.path}`); } catch (err) { if (err.code !== "ENOENT") { applog.error(`Error deleting log file: ${file.path}`, err); } } } } // Remove logs older than the specified days (excluding 0 for never) if (keepForDays > 0) { const now = new Date(); for (const file of logFilesResolved) { const ageInDays = (now - new Date(file.birthtime)) / (1000 * 60 * 60 * 24); if (ageInDays > keepForDays) { try { await fsPromises.unlink(file.path); applog.info(`Deleted old log file: ${file.path}`); } catch (err) { if (err.code !== "ENOENT") { applog.error(`Error deleting old log file: ${file.path}`, err); } } } } } } catch (error) { applog.error("Error managing log files:", error); } }; export { generateLogFileName, getCurrentLogSettings, manageLogFiles, setCurrentLogSettings }; ================================================ FILE: electron-main/pronunciationCheckerIPC.js ================================================ import { spawn } from "child_process"; import { ipcMain } from "electron"; import applog from "electron-log"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getSaveFolder, readUserSettings } from "./filePath.js"; import { getCurrentLogSettings } from "./logOperations.js"; import { getVenvPythonPath, ensureVenvExists } from "./pronunciationOperations.js"; const startProcess = (cmd, args) => { const proc = spawn(cmd, args, { shell: true }); return proc; }; // IPC handler to check pronunciation const setupPronunciationCheckerIPC = () => { ipcMain.handle("pronunciation-check", async (event, audioPath, modelName) => { const saveFolder = await getSaveFolder(readUserSettings); // If modelName is not provided, read from user settings if (!modelName) { const userSettings = await readUserSettings(); modelName = userSettings.modelName; } let modelDir; if (modelName) { const safeModelFolder = modelName.replace(/\//g, "_"); modelDir = path.join(saveFolder, "phoneme-model", safeModelFolder); console.log(`[PronunciationChecker] modelDir: ${safeModelFolder}`); } else { modelDir = path.join(saveFolder, "phoneme-model"); } const logSettings = getCurrentLogSettings(); // Configure electron-log with current settings applog.transports.file.maxSize = logSettings.maxLogSize; applog.transports.console.level = logSettings.logLevel; applog.info(`[PronunciationChecker] audioPath: ${audioPath}`); applog.info(`[PronunciationChecker] modelDir: ${modelDir}`); // Robust Python script for model inference const pyCode = ` import sys import json import torch import librosa from transformers.models.wav2vec2 import ( Wav2Vec2Processor, Wav2Vec2ForCTC, Wav2Vec2FeatureExtractor, Wav2Vec2CTCTokenizer, ) # Set console encoding to UTF-8 if sys.platform == "win32": import codecs sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict") sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict") MODEL_DIR = r"""${modelDir}""" AUDIO_PATH = r"""${audioPath}""" def log_json(obj): # Send a single JSON string to stdout print(json.dumps(obj, ensure_ascii=False)) def main(): try: log_json({"status": "progress", "message": "Loading processor..."}) feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(MODEL_DIR) tokenizer = Wav2Vec2CTCTokenizer.from_pretrained(MODEL_DIR) processor = Wav2Vec2Processor( feature_extractor=feature_extractor, tokenizer=tokenizer ) model = Wav2Vec2ForCTC.from_pretrained(MODEL_DIR) log_json({"status": "progress", "message": "Model loaded."}) log_json({"status": "progress", "message": f"Loading audio: {AUDIO_PATH}"}) speech_array, sampling_rate = librosa.load( AUDIO_PATH, sr=16000 ) # Ensure 16kHz for wav2vec2 log_json( { "status": "progress", "message": f"Audio loaded. Sample rate: {sampling_rate}", } ) log_json({"status": "progress", "message": "Preparing inputs for model..."}) inputs = processor( speech_array, sampling_rate=16000, return_tensors="pt", padding=True ) log_json( { "status": "progress", "message": f"Input tensor shape: {inputs['input_values'].shape}, dtype: {inputs['input_values'].dtype}", } ) log_json({"status": "progress", "message": "Running model..."}) with torch.no_grad(): logits = model(**inputs).logits log_json({"status": "progress", "message": "Model run complete."}) predicted_ids = torch.argmax(logits, dim=-1) transcription = processor.batch_decode(predicted_ids) if transcription == "": log_json( { "status": "success", "phonemes": None, "message": "No phonemes detected.", } ) else: log_json({"status": "success", "phonemes": transcription[0].strip()}) except Exception as e: import traceback tb = traceback.format_exc() log_json({"status": "error", "message": str(e), "traceback": tb}) sys.exit(1) if __name__ == "__main__": main() `; const tempPyPath = path.join(saveFolder, "pronunciation_checker_temp.py"); await fsPromises.writeFile(tempPyPath, pyCode, "utf-8"); applog.info(`[PronunciationChecker] tempPyPath: ${tempPyPath}`); let venvPython; try { await ensureVenvExists(); venvPython = await getVenvPythonPath(); } catch (err) { applog.error(`[PronunciationChecker] Failed to create or find venv: ${err.message}`); return { status: "error", message: `Failed to create or find venv: ${err.message}` }; } applog.info(`[PronunciationChecker] About to run: ${venvPython} -u ${tempPyPath}`); return new Promise((resolve) => { const py = startProcess(venvPython, ["-u", tempPyPath], (err) => { fsPromises.unlink(tempPyPath).catch(() => { applog.warn( "[PronunciationChecker] Failed to delete temp pronunciation checker file" ); }); if (err) { applog.error( `[PronunciationChecker] Python process exited with code: ${err.code}` ); } }); let lastJson = null; py.stdout.on("data", (data) => { const lines = data.toString().split(/\r?\n/); for (const line of lines) { if (line.trim().startsWith("{") && line.trim().endsWith("}")) { try { const jsonData = JSON.parse(line.trim()); lastJson = jsonData; // Log with appropriate level based on status const logMessage = { component: "PronunciationChecker", ...jsonData, }; switch (jsonData.status) { case "progress": applog.info(logMessage); break; case "success": applog.info(logMessage); break; case "error": applog.error(logMessage); break; default: applog.info(logMessage); } } catch { applog.error( `[PronunciationChecker] Failed to parse JSON: ${line.trim()}` ); } } } }); py.stderr.on("data", (data) => { const lines = data.toString().split(/\r?\n/); for (const line of lines) { if (line.trim()) { applog.error({ component: "PronunciationChecker", status: "error", message: line.trim(), }); } } }); py.on("close", (code) => { if (lastJson && lastJson.status === "error") { // Return the detailed error from Python resolve(lastJson); } else if (code === 0 && lastJson) { resolve(lastJson); } else { resolve({ status: "error", message: `Process exited with code ${code}`, }); } }); }); }); }; // IPC handler to get the recording blob for a given key const setupGetRecordingBlobIPC = () => { ipcMain.handle("get-recording-blob", async (_event, key) => { const saveFolder = await getSaveFolder(readUserSettings); const filePath = path.join(saveFolder, "saved_recordings", `${key}.wav`); try { const data = await fsPromises.readFile(filePath); return data.buffer; // ArrayBuffer for renderer } catch (err) { throw new Error(`Failed to read recording: ${err.message}`); } }); }; export { setupGetRecordingBlobIPC, setupPronunciationCheckerIPC }; ================================================ FILE: electron-main/pronunciationOperations.js ================================================ import { ipcMain } from "electron"; import { Conf } from "electron-conf/main"; import fkill from "fkill"; import { spawn } from "node:child_process"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { getSaveFolder, readUserSettings, settingsConf } from "./filePath.js"; let currentPythonProcess = null; let pendingCancel = false; let isGloballyCancelled = false; // Singleton instance for pronunciation install status const installConf = new Conf({ name: "pronunciation-install" }); const checkPythonInstalled = async () => { let log = ""; return new Promise((resolve) => { // Try python3 first const tryPython = (cmd, cb) => { const proc = spawn(cmd, ["--version"], { shell: true }); let stdout = ""; let stderr = ""; proc.stdout.on("data", (data) => { stdout += data.toString(); }); proc.stderr.on("data", (data) => { stderr += data.toString(); }); proc.on("close", (code) => { cb(code, stdout, stderr); }); }; tryPython("python3", (err, stdout, stderr) => { log += "> python3 --version\n" + stdout + stderr + "\n"; if (!err && stdout) { resolve({ found: true, version: stdout.trim(), stderr: stderr.trim(), log: log.trim(), }); } else { tryPython("python", (err2, stdout2, stderr2) => { log += "> python --version\n" + stdout2 + stderr2 + "\n"; if (!err2 && stdout2) { resolve({ found: true, version: stdout2.trim(), stderr: stderr2.trim(), log: log.trim(), }); } else { resolve({ found: false, version: null, stderr: (stderr2 || stderr || "").trim(), log: log.trim(), }); } }); } }); }); }; const startProcess = (cmd, args, onExit) => { const proc = spawn(cmd, args, { shell: true }); currentPythonProcess = proc; if (pendingCancel) { // Wait 0.5s, then kill the process setTimeout(() => { fkill(proc.pid, { force: true, tree: true }) .then(() => { console.log("[Cancel] Process killed after short delay due to pending cancel."); }) .catch((err) => { if (err.message && err.message.includes("Process doesn't exist")) { // Already dead, ignore } else { console.error("[Cancel] Error killing process after delay:", err); } }); proc._wasCancelledImmediately = true; // Mark for downstream logic }, 500); // 0.5 second delay pendingCancel = false; } proc.on("close", (code) => { onExit && onExit(code !== 0 ? { code } : null); }); return proc; }; const dependencies = [ "numpy", "torch", "transformers", "huggingface_hub[hf_xet]", "librosa", "protobuf", // for facebook models "phonemizer", // for facebook models ]; const resetGlobalCancel = () => { isGloballyCancelled = false; }; // --- VENV HELPERS --- const getVenvDir = async () => { const saveFolder = await getSaveFolder(readUserSettings); return path.join(saveFolder, "pronunciation-venv"); }; const getVenvPythonPath = async () => { const venvDir = await getVenvDir(); if (process.platform === "win32") { return path.join(venvDir, "Scripts", "python.exe"); } else { return path.join(venvDir, "bin", "python"); } }; const getVenvPipPath = async () => { const venvDir = await getVenvDir(); if (process.platform === "win32") { return path.join(venvDir, "Scripts", "pip.exe"); } else { return path.join(venvDir, "bin", "pip"); } }; const upgradeVenvPip = async () => { const venvPython = await getVenvPythonPath(); return new Promise((resolve, reject) => { const proc = spawn(venvPython, ["-m", "pip", "install", "--upgrade", "pip"], { shell: true, }); let output = ""; let error = ""; proc.stdout.on("data", (data) => { output += data.toString(); }); proc.stderr.on("data", (data) => { error += data.toString(); }); proc.on("close", (code) => { if (code === 0) { resolve(output); } else { reject(new Error(error || `pip upgrade failed with code ${code}`)); } }); }); }; const ensureVenvExists = async () => { const venvDir = await getVenvDir(); let venvPython = await getVenvPythonPath(); try { await fsPromises.access(venvPython); // Already exists return venvPython; } catch { // Create venv // Use system python to create venv let systemPython = "python"; // Try to use python3 if available try { await new Promise((resolve, reject) => { const proc = spawn("python3", ["--version"], { shell: true }); proc.on("close", (code) => { if (code === 0) resolve(); else reject(); }); }); systemPython = "python3"; } catch { // fallback to python } await new Promise((resolve, reject) => { const proc = spawn(systemPython, ["-m", "venv", venvDir], { shell: true }); proc.on("close", (code) => { if (code === 0) resolve(); else reject(new Error("Failed to create venv")); }); }); // Store venv path in settings settingsConf.set("pronunciationVenvPath", venvDir); return venvPython; } }; const installDependencies = () => { ipcMain.handle("pronunciation-install-deps", async (event) => { if (isGloballyCancelled) { return { deps: [{ name: "all", status: "cancelled" }], log: "" }; } let log = ""; event.sender.send("pronunciation-dep-progress", { name: "all", status: "pending" }); // Ensure venv exists let venvPip; try { await ensureVenvExists(); venvPip = await getVenvPipPath(); try { await upgradeVenvPip(); log += "Upgraded pip to latest version.\n"; } catch (err) { log += `Failed to upgrade pip: ${err.message}\n`; } } catch (err) { log += `Failed to create virtual environment: ${err.message}\n`; event.sender.send("pronunciation-dep-progress", { name: "all", status: "error", log: log.trim(), }); return { deps: [{ name: "all", status: "error" }], log }; } return new Promise((resolve) => { const pipArgs = ["install", ...dependencies, "-U"]; const pipProcess = startProcess(venvPip, pipArgs, (err) => { const status = err ? "error" : "success"; event.sender.send("pronunciation-dep-progress", { name: "all", status, log: log.trim(), }); resolve({ deps: [{ name: "all", status }], log }); }); pipProcess.stdout.on("data", (data) => { log += data.toString(); event.sender.send("pronunciation-dep-progress", { name: "all", status: "pending", log: log.trim(), }); }); pipProcess.stderr.on("data", (data) => { log += data.toString(); event.sender.send("pronunciation-dep-progress", { name: "all", status: "pending", log: log.trim(), }); }); }); }); }; // Extracted model download logic const downloadModelToDir = async (modelDir, modelName, onProgress) => { if (isGloballyCancelled) { if (onProgress) onProgress({ status: "cancelled", message: "Cancelled before start" }); return { status: "cancelled", message: "Cancelled before start" }; } // Ensure modelDir exists try { await fsPromises.access(modelDir); } catch { await fsPromises.mkdir(modelDir, { recursive: true }); } // Ensure venv exists and get venv Python path let venvPython; try { await ensureVenvExists(); venvPython = await getVenvPythonPath(); } catch (err) { if (onProgress) onProgress({ status: "error", message: `Failed to create virtual environment: ${err.message}`, }); return { status: "error", message: `Failed to create virtual environment: ${err.message}` }; } // Write Python code to temp file in save folder const pyCode = ` import os, sys, json try: from huggingface_hub import snapshot_download, list_repo_files except ImportError: print(json.dumps({"status": "error", "message": "huggingface_hub not installed"})) sys.exit(1) model_dir = r"""${modelDir}""" repo_id = "${modelName}" try: files = list_repo_files(repo_id) except Exception as e: print( json.dumps( {"status": "error", "message": f"Failed to list repo files: {str(e)}"} ) ) sys.exit(1) has_safetensors = any(f.endswith(".safetensors") for f in files) has_bin = any(f.endswith(".bin") for f in files) if has_safetensors and has_bin: print( json.dumps( { "status": "downloading", "message": "Both .safetensors and .bin found. Downloading all except .bin...", } ) ) try: snapshot_download( repo_id=repo_id, local_dir=model_dir, ignore_patterns=["*.bin"] ) print( json.dumps( {"status": "success", "message": "Downloaded all files except .bin"} ) ) except Exception as e: print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"})) sys.exit(1) elif has_safetensors: print( json.dumps( { "status": "downloading", "message": "Only .safetensors found. Downloading all files...", } ) ) try: snapshot_download(repo_id=repo_id, local_dir=model_dir) print( json.dumps( { "status": "success", "message": "Downloaded all files (including .safetensors)", } ) ) except Exception as e: print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"})) sys.exit(1) elif has_bin: print( json.dumps( { "status": "downloading", "message": "Only .bin found. Downloading all files...", } ) ) try: snapshot_download(repo_id=repo_id, local_dir=model_dir) print( json.dumps( { "status": "success", "message": "Downloaded all files (including .bin)", } ) ) except Exception as e: print(json.dumps({"status": "error", "message": f"Download failed: {str(e)}"})) sys.exit(1) else: print( json.dumps( { "status": "error", "message": "No model file (.safetensors or .bin) found in repo.", } ) ) sys.exit(1) `; const saveFolder = await getSaveFolder(readUserSettings); const tempPyPath = path.join(saveFolder, "download_model_temp.py"); await fsPromises.writeFile(tempPyPath, pyCode, "utf-8"); // Emit 'downloading' status before launching Python process if (onProgress) onProgress({ status: "downloading", message: "Preparing to download model..." }); return new Promise((resolve) => { const py = startProcess(venvPython, ["-u", tempPyPath], (err) => { currentPythonProcess = null; fsPromises.unlink(tempPyPath).catch(() => { console.log("Failed to delete temp file"); }); if (err) { if (onProgress) onProgress({ status: "error", message: `Model download process exited with code ${err.code}`, }); resolve({ status: "error", message: `Model download process exited with code ${err.code}`, }); } else { if (onProgress) onProgress({ status: "success", message: "Model download complete." }); resolve({ status: "success", message: "Model download complete." }); } }); // If process was cancelled immediately, resolve and do not proceed if (py._wasCancelledImmediately) { if (onProgress) onProgress({ status: "cancelled", message: "Process cancelled before start" }); resolve({ status: "cancelled", message: "Process cancelled before start" }); return; } let lastStatus = null; let hadError = false; py.stdout.on("data", (data) => { const str = data.toString(); // Always forward raw output to renderer for logging if (onProgress) onProgress({ status: "log", message: str }); // Try to parse JSON lines as before str.split(/\r?\n/).forEach((line) => { if (line.trim()) { try { const msg = JSON.parse(line); lastStatus = msg; if (msg.status === "error") { hadError = true; if (onProgress) onProgress(msg); } else { if (onProgress) onProgress(msg); } } catch { // Ignore parse errors for non-JSON lines } } }); }); py.stderr.on("data", (data) => { // Only log stderr, do not send error status unless process exits with error if (onProgress) onProgress({ status: "log", message: data.toString() }); }); py.on("exit", (code) => { currentPythonProcess = null; fsPromises.unlink(tempPyPath).catch(() => { console.log("Failed to delete temp file"); }); // If process exited with error and no JSON error was sent, send a generic error if (code !== 0 && !hadError) { if (onProgress) onProgress({ status: "error", message: `Model download process exited with code ${code}`, }); resolve({ status: "error", message: `Model download process exited with code ${code}`, }); } else { resolve(lastStatus); } }); }); }; const downloadModel = () => { ipcMain.handle("pronunciation-download-model", async (event, modelName) => { const saveFolder = await getSaveFolder(readUserSettings); // Replace / with _ for folder name const safeModelFolder = modelName.replace(/\//g, "_"); const modelDir = path.join(saveFolder, "phoneme-model", safeModelFolder); // Forward progress to renderer const finalStatus = await downloadModelToDir(modelDir, modelName, (msg) => { event.sender.send("pronunciation-model-progress", msg); }); // After successful download, update user settings with modelName console.log( `[PronunciationOperations] finalStatus: ${JSON.stringify(finalStatus, null, 2)}` ); if (finalStatus && (finalStatus.status === "success" || finalStatus.status === "found")) { settingsConf.set("modelName", modelName); console.log(`[PronunciationOperations] modelName updated to ${modelName}`); console.log(`[PronunciationOperations] settingsConf: ${settingsConf.get("modelName")}`); } // Return summary to renderer return finalStatus; }); }; const cancelProcess = () => { ipcMain.handle("pronunciation-cancel-process", async (event) => { isGloballyCancelled = true; console.log( "[Cancel] Cancellation requested. currentPythonProcess:", currentPythonProcess ? "exists" : "null" ); if (currentPythonProcess) { try { await fkill(currentPythonProcess.pid, { force: true, tree: true }); console.log("[Cancel] Python process tree killed with fkill."); } catch (err) { console.error("[Cancel] Error killing Python process tree:", err); } currentPythonProcess = null; } else { console.log("[Cancel] No Python process to kill. Setting pendingCancel flag."); pendingCancel = true; } // Update installConf with cancelled state const prevStatus = installConf.get("status", {}) || {}; installConf.set("status", { ...prevStatus, cancelled: true, timestamp: Date.now() }); event.sender.send("pronunciation-cancelled"); console.log("[Cancel] Pronunciation process cancelled (event sent to renderer)"); }); }; // Add a function to kill the current Python process (for app quit cleanup) const killCurrentPythonProcess = async () => { if (currentPythonProcess) { try { await fkill(currentPythonProcess.pid, { force: true, tree: true }); console.log("[Cleanup] Python process tree killed on app quit."); } catch (err) { console.error("[Cleanup] Error killing Python process tree on app quit:", err); } currentPythonProcess = null; } }; // The install status is now structured as: // { // python: { found, version }, // dependencies: [ { name, status, log }, ... ], // model: { status, message, log }, // stderr: , // log: , // timestamp: // } const setupPronunciationInstallStatusIPC = () => { ipcMain.handle("get-pronunciation-install-status", async () => { return installConf.get("status", null); }); ipcMain.handle("set-pronunciation-install-status", async (_event, status) => { // If the status is already in the new structure, save as is if ( status && status.python && status.dependencies && status.model && "stderr" in status && "log" in status ) { installConf.set("status", { ...status, timestamp: Date.now() }); } else { // If the status is in the old format, attempt to migrate const migrated = migrateOldStatusToStructured(status); installConf.set("status", migrated); } return true; }); }; // Helper to migrate old flat status to new structured format const migrateOldStatusToStructured = (status) => { if (!status) return null; // Try to extract python info const python = { found: status.found, version: status.version, }; // Dependencies: if array, use as is; if single dep, wrap in array let dependencies = status.deps; if (!Array.isArray(dependencies)) { dependencies = dependencies ? [dependencies] : []; } // Model info const model = { status: status.modelStatus || status.status, message: status.modelMessage || status.message, log: status.modelLog || "", }; return { python, dependencies, model, stderr: status.stderr || "", log: status.log || "", timestamp: Date.now(), }; }; export { cancelProcess, checkPythonInstalled, downloadModel, installDependencies, killCurrentPythonProcess, resetGlobalCancel, setupPronunciationInstallStatusIPC, ensureVenvExists, getVenvPythonPath, }; ================================================ FILE: electron-main/videoFileOperations.js ================================================ import { ipcMain } from "electron"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getSaveFolder, readUserSettings } from "./filePath.js"; const checkDownloads = () => { ipcMain.handle("check-downloads", async () => { const saveFolder = await getSaveFolder(readUserSettings); const videoFolder = path.join(saveFolder, "video_files"); // Ensure the directory exists try { await fsPromises.access(videoFolder); } catch { await fsPromises.mkdir(videoFolder, { recursive: true }); } const files = await fsPromises.readdir(videoFolder); // Return the list of zip files in the download folder const zipFiles = files.filter((file) => file.endsWith(".7z")); return zipFiles.length === 0 ? "no zip files downloaded" : zipFiles; }); }; // Check video extracted folder const checkExtractedFolder = () => { ipcMain.handle("check-extracted-folder", async (event, folderName, zipContents) => { const saveFolder = await getSaveFolder(readUserSettings); const extractedFolder = path.join(saveFolder, "video_files", folderName); // Check if extracted folder exists try { await fsPromises.access(extractedFolder); const extractedFiles = await fsPromises.readdir(extractedFolder); // Check if all expected files are present in the extracted folder const allFilesExtracted = zipContents[0].extractedFiles.every((file) => { return extractedFiles.includes(file.name); }); event.sender.send("progress-update", 0); return allFilesExtracted; // Return true if all files are extracted, else false } catch { return false; // Return false if the folder doesn't exist } }); }; export { checkDownloads, checkExtractedFolder }; ================================================ FILE: electron-main/zipOperation.js ================================================ import { ipcMain } from "electron"; import applog from "electron-log"; import JS7z from "js7z-tools"; import crypto from "node:crypto"; import fs from "node:fs"; import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { getSaveFolder, readUserSettings } from "./filePath.js"; // Function to calculate the SHA-256 hash of a file const calculateFileHash = (filePath) => { return new Promise((resolve, reject) => { const hash = crypto.createHash("sha256"); const stream = fs.createReadStream(filePath); stream.on("data", (data) => hash.update(data)); stream.on("end", () => resolve(hash.digest("hex"))); stream.on("error", (err) => reject(err)); }); }; const fileVerification = async (event, zipContents, extractedFolder) => { // Verify existing extracted files const totalFiles = zipContents[0].extractedFiles.length; let filesProcessed = 0; let fileErrors = []; for (const file of zipContents[0].extractedFiles) { const extractedFilePath = path.join(extractedFolder, file.name); try { await fsPromises.access(extractedFilePath); } catch { console.log(`File missing: ${file.name}`); applog.log(`File missing: ${file.name}`); fileErrors.push({ type: "missing", name: file.name, message: `File missing: ${file.name}. Make sure you do not rename or accidentally delete it.`, }); continue; } const extractedFileHash = await calculateFileHash(extractedFilePath); if (extractedFileHash !== file.hash) { console.log(`Hash mismatch for file: ${file.name}`); applog.log(`Hash mismatch for file: ${file.name}`); fileErrors.push({ type: "hash-mismatch", name: file.name, message: `Hash mismatch for file: ${file.name}. It seems like the file was either corrupted or tampered.`, }); continue; } filesProcessed++; const progressPercentage = Math.floor((filesProcessed / totalFiles) * 100); event.sender.send("progress-update", progressPercentage); // Send progress update applog.log("Verifying extracted file:", file.name); } if (fileErrors.length === 0) { // If no missing or corrupted files, finish the verification event.sender.send("verification-success", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.extractedSuccessMsg", param: extractedFolder, }); applog.log(`All extracted files are verified for "${extractedFolder}"`); return; } else { // Send all errors as an array event.sender.send("verification-errors", fileErrors); applog.log( `Verification found errors in extracted files for "${extractedFolder}"`, fileErrors ); return; } }; // Handle verify and extract process using JS7z const verifyAndExtractIPC = () => { ipcMain.on("verify-and-extract", async (event, zipFileData) => { const { zipFile, zipHash, zipContents } = zipFileData; const saveFolder = await getSaveFolder(readUserSettings); const videoFolder = path.join(saveFolder, "video_files"); const zipFilePath = path.join(videoFolder, zipFile); const extractedFolder = path.join(videoFolder, zipFile.replace(".7z", "")); // Step 0: Check if ZIP file exists first let zipExists = false; try { await fsPromises.access(zipFilePath); zipExists = true; } catch { zipExists = false; } if (zipExists) { // ZIP exists: proceed with hash verification and extraction console.log(`Starting verification for ${zipFile}`); applog.log(`Starting verification for ${zipFile}`); try { const js7z = await JS7z({ print: (text) => { console.log(`7-Zip output: ${text}`); applog.log(`7-Zip output: ${text}`); if (text.includes("%")) { const match = text.match(/\s+(\d+)%/); // Extract percentage from output if (match) { const percentage = parseInt(match[1], 10); event.sender.send("progress-update", percentage); } } }, printErr: (errText) => { console.error(`7-Zip error: ${errText}`); applog.error(`7-Zip error: ${errText}`); event.sender.send("verification-error", `7-Zip error: ${errText}`); }, onAbort: (reason) => { console.error(`7-Zip aborted: ${reason}`); applog.error(`7-Zip aborted: ${reason}`); event.sender.send("verification-error", `7-Zip aborted: ${reason}`); }, onExit: (exitCode) => { if (exitCode === 0) { console.log(`7-Zip exited successfully with code ${exitCode}`); applog.log(`7-Zip exited successfully with code ${exitCode}`); } else { console.error(`7-Zip exited with error code: ${exitCode}`); applog.error(`7-Zip exited with error code: ${exitCode}`); event.sender.send( "verification-error", `7-Zip exited with error code: ${exitCode}` ); } }, }); // Mount the local file system to the JS7z instance using NODEFS js7z.FS.mkdir("/mnt"); js7z.FS.mount(js7z.NODEFS, { root: videoFolder }, "/mnt"); const emZipFilePath = `/mnt/${zipFile}`; const emExtractedFolder = `/mnt/${zipFile.replace(".7z", "")}`; // Step 1: Verifying ZIP file hash event.sender.send( "progress-text", "settingPage.videoDownloadSettings.electronVerifyMessage.zipFileVerifying" ); const fileHash = await calculateFileHash(zipFilePath); if (fileHash !== zipHash) { applog.error( `Hash mismatch for ${zipFile}. It seems like the zip file was either corrupted or tampered.` ); event.sender.send("verification-error", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.zipHashMismatchMsg", param: zipFile, }); return; } event.sender.send("progress-text", "ZIP file verified"); // Step 2: Extracting ZIP file event.sender.send( "progress-text", "settingPage.videoDownloadSettings.electronVerifyMessage.zipExtractingMsg" ); js7z.callMain(["x", emZipFilePath, `-o${emExtractedFolder}`]); js7z.onExit = async (exitCode) => { if (exitCode !== 0) { applog.error(`Error extracting ${zipFile}`); event.sender.send("verification-error", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg", param: zipFile, }); return; } console.log(`Extraction successful for ${zipFile}`); applog.log(`Extraction successful for ${zipFile}`); // Step 3: Verifying extracted files event.sender.send( "progress-text", "settingPage.videoDownloadSettings.verifyinProgressMsg" ); await fileVerification(event, zipContents, extractedFolder); // Clean up the ZIP file after successful extraction and verification try { await fsPromises.unlink(zipFilePath); console.log(`Deleted ZIP file: ${zipFilePath}`); applog.log(`Extraction successful for ${zipFile}`); } catch (err) { console.error(`Failed to delete ZIP file: ${err.message}`); applog.error(`Failed to delete ZIP file: ${err.message}`); } event.sender.send("verification-success", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.zipSuccessMsg", param: zipFile, }); applog.log(`Successfully verified and extracted ${zipFile}`); }; } catch (err) { console.error(`Error processing ${zipFile}: ${err.message}`); event.sender.send("verification-error", { messageKey: "settingPage.videoDownloadSettings.electronVerifyMessage.zipErrorMsg", param: zipFile, errorMessage: err.message, }); } } else { // ZIP does not exist: check for extracted folder and verify its contents console.log( `ZIP file does not exist: ${zipFilePath}. It could be extracted before. Proceeding with extracted folder verification...` ); applog.log( `ZIP file does not exist: ${zipFilePath}. It could be extracted before. Proceeding with extracted folder verification...` ); try { await fsPromises.access(extractedFolder); console.log(`Extracted folder already exists: ${extractedFolder}`); applog.log(`Extracted folder already exists: ${extractedFolder}`); event.sender.send( "progress-text", "settingPage.videoDownloadSettings.verifyinProgressMsg" ); await fileVerification(event, zipContents, extractedFolder); } catch { console.log(`Extracted folder does not exist: ${extractedFolder}`); applog.log(`Extracted folder does not exist: ${extractedFolder}`); event.sender.send("verification-error", { messageKey: "settingPage.videoDownloadSettings.verifyFailedMessage", param: extractedFolder, }); } } }); }; export { fileVerification, verifyAndExtractIPC }; ================================================ FILE: public/_redirects ================================================ /* /index.html 200 ================================================ FILE: public/google5c7ff3958ee35135.html ================================================ google-site-verification: google5c7ff3958ee35135.html ================================================ FILE: public/images/homepage_screenshot.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_main-task_1.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_practice-task_1_1.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Discussing-opinions_practice-task_1_2.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Job-interview_main-task_1.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_main-task_4.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_main-task_5.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_1.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_2.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_3.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_1_4.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/exam_images/thumb/Negotiating_practice-task_2_4.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/sound_images/sounds_american.webp ================================================ [Binary file] ================================================ FILE: public/images/ispeaker/sound_images/sounds_british.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-00.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-01.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-02.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-03.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-04.webp ================================================ [Binary file] ================================================ FILE: public/images/screenshots/screenshot-08.webp ================================================ [Binary file] ================================================ FILE: public/json/conversation_list.json ================================================ { "conversationList": [ { "heading": "conversationPage.discussionAndOpinion.heading", "titles": [ { "title": "conversationPage.discussionAndOpinion.agreeingTitle", "info": "conversationPage.discussionAndOpinion.agreeingInfo", "id": "agreeing" }, { "title": "conversationPage.discussionAndOpinion.askingForOpinionsTitle", "info": "conversationPage.discussionAndOpinion.askingForOpinionsInfo", "id": "opinions" }, { "title": "conversationPage.discussionAndOpinion.concedingAPointTitle", "info": "conversationPage.discussionAndOpinion.concedingAPointInfo", "id": "conceding" }, { "title": "conversationPage.discussionAndOpinion.dealingWithQuestionsTitle", "info": "conversationPage.discussionAndOpinion.dealingWithQuestionsInfo", "id": "deal-questions" }, { "title": "conversationPage.discussionAndOpinion.disagreeingTitle", "info": "conversationPage.discussionAndOpinion.disagreeingInfo", "id": "disagreeing" }, { "title": "conversationPage.discussionAndOpinion.expressingPreferencesTitle", "info": "conversationPage.discussionAndOpinion.expressingPreferencesInfo", "id": "preferences" }, { "title": "conversationPage.discussionAndOpinion.givingReasonsTitle", "info": "conversationPage.discussionAndOpinion.givingReasonsInfo", "id": "reasons" }, { "title": "conversationPage.discussionAndOpinion.interruptingSomebodyTitle", "info": "conversationPage.discussionAndOpinion.interruptingSomebodyInfo", "id": "interrupting" } ] }, { "heading": "conversationPage.doubtGuessingAndCertainty.heading", "titles": [ { "title": "conversationPage.doubtGuessingAndCertainty.askingForClarificationTitle", "info": "conversationPage.doubtGuessingAndCertainty.askingForClarificationInfo", "id": "clarification" }, { "title": "conversationPage.doubtGuessingAndCertainty.correctingYourselfTitle", "info": "conversationPage.doubtGuessingAndCertainty.correctingYourselfInfo", "id": "correcting-yourself" }, { "title": "conversationPage.doubtGuessingAndCertainty.expressingCertaintyTitle", "info": "conversationPage.doubtGuessingAndCertainty.expressingCertaintyInfo", "id": "certainty" }, { "title": "conversationPage.doubtGuessingAndCertainty.expressingIgnoranceTitle", "info": "conversationPage.doubtGuessingAndCertainty.expressingIgnoranceInfo", "id": "ignorance" }, { "title": "conversationPage.doubtGuessingAndCertainty.expressingLikelihoodTitle", "info": "conversationPage.doubtGuessingAndCertainty.expressingLikelihoodInfo", "id": "likelihood" }, { "title": "conversationPage.doubtGuessingAndCertainty.speculatingTitle", "info": "conversationPage.doubtGuessingAndCertainty.speculatingInfo", "id": "speculating" } ] }, { "heading": "conversationPage.feelingsAndEmotions.heading", "titles": [ { "title": "conversationPage.feelingsAndEmotions.apologizingTitle", "info": "conversationPage.feelingsAndEmotions.apologizingInfo", "id": "apologizing" }, { "title": "conversationPage.feelingsAndEmotions.congratulatingSomebodyTitle", "info": "conversationPage.feelingsAndEmotions.congratulatingSomebodyInfo", "id": "congratulating" }, { "title": "conversationPage.feelingsAndEmotions.expressingSympathyTitle", "info": "conversationPage.feelingsAndEmotions.expressingSympathyInfo", "id": "sympathy" }, { "title": "conversationPage.feelingsAndEmotions.makingComplaintsTitle", "info": "conversationPage.feelingsAndEmotions.makingComplaintsInfo", "id": "complaints" }, { "title": "conversationPage.feelingsAndEmotions.thankingSomebodyTitle", "info": "conversationPage.feelingsAndEmotions.thankingSomebodyInfo", "id": "thanking" }, { "title": "conversationPage.feelingsAndEmotions.wishingSomebodyLuckTitle", "info": "conversationPage.feelingsAndEmotions.wishingSomebodyLuckInfo", "id": "wishing-luck" } ] }, { "heading": "conversationPage.openingsAndEndings.heading", "titles": [ { "title": "conversationPage.openingsAndEndings.endingConversationsTitle", "info": "conversationPage.openingsAndEndings.endingConversationsInfo", "id": "ending" }, { "title": "conversationPage.openingsAndEndings.introducingSomebodyTitle", "info": "conversationPage.openingsAndEndings.introducingSomebodyInfo", "id": "introducing" }, { "title": "conversationPage.openingsAndEndings.invitingSomebodyToSomethingTitle", "info": "conversationPage.openingsAndEndings.invitingSomebodyToSomethingInfo", "id": "inviting" }, { "title": "conversationPage.openingsAndEndings.leavingPhoneMessagesTitle", "info": "conversationPage.openingsAndEndings.leavingPhoneMessagesInfo", "id": "phonemsg" }, { "title": "conversationPage.openingsAndEndings.openingConversationsTitle", "info": "conversationPage.openingsAndEndings.openingConversationsInfo", "id": "opening" }, { "title": "conversationPage.openingsAndEndings.wrappingUpDiscussionsTitle", "info": "conversationPage.openingsAndEndings.wrappingUpDiscussionsInfo", "id": "wrapping-up" } ] }, { "heading": "conversationPage.permissionAndObligation.heading", "titles": [ { "title": "conversationPage.permissionAndObligation.askingAboutObligationTitle", "info": "conversationPage.permissionAndObligation.askingAboutObligationInfo", "id": "obligation" }, { "title": "conversationPage.permissionAndObligation.askingForPermissionTitle", "info": "conversationPage.permissionAndObligation.askingForPermissionInfo", "id": "permission" }, { "title": "conversationPage.permissionAndObligation.forbiddingSomethingTitle", "info": "conversationPage.permissionAndObligation.forbiddingSomethingInfo", "id": "forbidding" }, { "title": "conversationPage.permissionAndObligation.givingOrdersTitle", "info": "conversationPage.permissionAndObligation.givingOrdersInfo", "id": "orders" }, { "title": "conversationPage.permissionAndObligation.makingRequestsTitle", "info": "conversationPage.permissionAndObligation.makingRequestsInfo", "id": "requests" } ] }, { "heading": "conversationPage.suggestionsAndAdvice.heading", "titles": [ { "title": "conversationPage.suggestionsAndAdvice.askingForHelpTitle", "info": "conversationPage.suggestionsAndAdvice.askingForHelpInfo", "id": "asking-help" }, { "title": "conversationPage.suggestionsAndAdvice.askingForInformationTitle", "info": "conversationPage.suggestionsAndAdvice.askingForInformationInfo", "id": "information" }, { "title": "conversationPage.suggestionsAndAdvice.givingSomebodyAdviceTitle", "info": "conversationPage.suggestionsAndAdvice.givingSomebodyAdviceInfo", "id": "advice" }, { "title": "conversationPage.suggestionsAndAdvice.makingRecommendationsTitle", "info": "conversationPage.suggestionsAndAdvice.makingRecommendationsInfo", "id": "recommendations" }, { "title": "conversationPage.suggestionsAndAdvice.makingSuggestionsTitle", "info": "conversationPage.suggestionsAndAdvice.makingSuggestionsInfo", "id": "suggestions" }, { "title": "conversationPage.suggestionsAndAdvice.offeringHelpTitle", "info": "conversationPage.suggestionsAndAdvice.offeringHelpInfo", "id": "offer-help" }, { "title": "conversationPage.suggestionsAndAdvice.offeringSomebodySomethingTitle", "info": "conversationPage.suggestionsAndAdvice.offeringSomebodySomethingInfo", "id": "offer-somthing" }, { "title": "conversationPage.suggestionsAndAdvice.warningSomebodyOfDangerTitle", "info": "conversationPage.suggestionsAndAdvice.warningSomebodyOfDangerInfo", "id": "warning" } ] } ] } ================================================ FILE: public/json/ex_data.json ================================================ { "sounds_n_spelling": [ { "exercise": " / iː / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / iː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / iː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / əʊ / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / əʊ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / əʊ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / oʊ / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / oʊ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / oʊ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ə / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ə /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ə /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɔː / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɔː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɔː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɔː(r) / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɔː(r) /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɔː(r) /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɜː / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɜː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɜː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɜːr / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɜːr /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɜːr /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɪə(r)/ ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɪə(r) /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɪə(r) /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / ɪr / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɪr /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / ɪr /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / uː / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / uː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / uː /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / eɪ / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / eɪ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / eɪ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] }, { "exercise": " / aɪ / ", "info": "Listen and choose the correct spelling", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / aɪ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ], "a": [ { "h4": "American English", "left_p": [ "Listen to the word and choose the spelling that represents the sound / aɪ /.", "You can listen to the word as many times as you like.", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [] } ] } ], "snap": [ { "exercise": "Homophones", "info": "Do the two words sound the same or different?", "b_s": "yes", "a_s": "yes", "b": [ { "h4": "British English", "left_p": [ "Listen and write the word you hear. You can listen to the word as many times as you like.", "You must spell the word correctly!", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [ { "data": [ { "value": "creative", "index": "0" }, { "value": "assistance", "index": "1" } ], "feedbacks": [ { "value": "Yes", "index": "0", "answer": "false" }, { "value": "No", "index": "1", "answer": "true" }, { "correctAns": "No" } ] }, { "data": [ { "value": "creative", "index": "0" }, { "value": "assistance", "index": "1" } ], "feedbacks": [ { "value": "Yes", "index": "0", "answer": "false" }, { "value": "No", "index": "1", "answer": "true" }, { "correctAns": "No" } ] } ] } ], "a": [ { "h4": "American English", "left_p": [ "Listen and write the word you hear. You can listen to the word as many times as you like.", "You must spell the word correctly!", "When you want to stop, select \"Quit\" to see your score in this session." ], "act": [ { "data": [ { "value": "creative", "index": "0" }, { "value": "assistance", "index": "1" } ], "feedbacks": [ { "value": "Yes", "index": "0", "answer": "false" }, { "value": "No", "index": "1", "answer": "true" }, { "correctAns": "No" } ] }, { "data": [ { "value": "creative", "index": "0" }, { "value": "assistance", "index": "1" } ], "feedbacks": [ { "value": "Yes", "index": "0", "answer": "false" }, { "value": "No", "index": "1", "answer": "true" }, { "correctAns": "No" } ] } ] } ] }, { "exercise": "Reading phonetics", "info": "Does the phonetic transcription match the word?" }, { "exercise": "Random", "info": "A mixture of homophones and phonetics questions" } ] } ================================================ FILE: public/json/examspeaking_list.json ================================================ { "examList": [ { "heading": "examPage.groupTask.heading", "titles": [ { "title": "examPage.groupTask.discussingOpinions.title", "id": "discussing", "exam_popup": "examPage.groupTask.discussingOpinions.tooltip" }, { "title": "examPage.groupTask.negotiating.title", "id": "negotiating", "exam_popup": "examPage.groupTask.negotiating.tooltip" } ] }, { "heading": "examPage.individualTask.heading", "titles": [ { "title": "examPage.individualTask.describingPicture.title", "id": "describing", "exam_popup": "examPage.individualTask.describingPicture.tooltip" }, { "title": "examPage.individualTask.personalInformation.title", "id": "personal-info", "exam_popup": "examPage.individualTask.personalInformation.tooltip" }, { "title": "examPage.individualTask.topicTalking.title", "id": "talking-topic", "exam_popup": "examPage.individualTask.topicTalking.tooltip" } ] }, { "heading": "examPage.realLifeTask.heading", "titles": [ { "title": "examPage.realLifeTask.presentation.title", "id": "presentation", "exam_popup": "examPage.realLifeTask.presentation.tooltip" }, { "title": "examPage.realLifeTask.jobInterview.title", "id": "interview", "exam_popup": "examPage.realLifeTask.jobInterview.tooltip" } ] } ] } ================================================ FILE: public/json/exercise_list.json ================================================ { "exerciseList": [ { "heading": "exercise_page.dictationHeading", "file": "exercise_dictation.json", "titles": [ { "titleKey": "exercise_page.wordExercise", "infoKey": "exercise_page.exerciseInfo.dictationWord", "id": "words" }, { "titleKey": "exercise_page.missingWordExercise", "infoKey": "exercise_page.exerciseInfo.dictationMissingWord", "id": "missing-words" }, { "titleKey": "exercise_page.sentenceExercise", "infoKey": "exercise_page.exerciseInfo.dictationSentence", "id": "sentences" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.dictationRandom", "id": "random" } ] }, { "heading": "exercise_page.matchUpHeading", "file": "exercise_matchup.json", "titles": [ { "titleKey": "exercise_page.similarSoundExercise", "infoKey": "exercise_page.exerciseInfo.matchUpSimilarSound", "id": "similar-sounds" }, { "titleKey": "exercise_page.readingPhoneticsExercise", "infoKey": "exercise_page.exerciseInfo.matchUpReadingPhonetics", "id": "reading-phonetics" }, { "titleKey": "exercise_page.comprehensionExercise", "infoKey": "exercise_page.exerciseInfo.matchUpComprehension", "id": "comprehension" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.matchUpRandom", "id": "random" } ] }, { "heading": "exercise_page.reorderingHeading", "file": "exercise_reordering.json", "titles": [ { "titleKey": "exercise_page.wordExercise", "infoKey": "exercise_page.exerciseInfo.reorderingWord", "id": "words" }, { "titleKey": "exercise_page.sentenceExercise", "infoKey": "exercise_page.exerciseInfo.reorderingSentence", "id": "sentences" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.reorderingRandom", "id": "random" } ] }, { "heading": "exercise_page.soundSpellingHeading", "file": "exercise_sound_n_spelling.json", "infoKey": "exercise_page.exerciseInfo.soundSpellingInfo", "titles": [ { "title": "/ iː /", "id": "i-long" }, { "title": "/ əʊ /", "id": "ou-gb", "american": false }, { "title": "/ oʊ /", "id": "ou-us", "british": false }, { "title": "/ ə /", "id": "er" }, { "title": "/ ɔː /", "id": "o-gb", "american": false }, { "title": "/ ɔː(r) /", "id": "or-us", "british": false }, { "title": "/ ɜː /", "id": "er-gb", "american": false }, { "title": "/ ɜːr /", "id": "er-us", "british": false }, { "title": "/ ɪə(r) /", "id": "ear-gb", "american": false }, { "title": "/ ɪr /", "id": "ear-us", "british": false }, { "title": "/ uː /", "id": "u-long" }, { "title": "/ eɪ /", "id": "ay" }, { "title": "/ aɪ /", "id": "eye" }, { "titleKey": "exercise_page.randomExercise", "id": "random", "infoKey": "exercise_page.exerciseInfo.soundSpellingRandom" } ] }, { "heading": "exercise_page.sortingHeading", "file": "exercise_sorting.json", "titles": [ { "titleKey": "exercise_page.similarSoundExercise", "infoKey": "exercise_page.exerciseInfo.sortingSimilarSound", "id": "similar-sounds" }, { "titleKey": "exercise_page.wordStressExercise", "infoKey": "exercise_page.exerciseInfo.sortingWordStress", "id": "word-stress" }, { "titleKey": "exercise_page.syllablesExercise", "infoKey": "exercise_page.exerciseInfo.sortingSyllable", "id": "syllables" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.sortingRandom", "id": "random" } ] }, { "heading": "exercise_page.oddOneOutHeading", "file": "exercise_odd_one_out.json", "titles": [ { "titleKey": "exercise_page.wordStressExercise", "infoKey": "exercise_page.exerciseInfo.oddOneOutWordStress", "id": "word-stress" }, { "titleKey": "exercise_page.rhymingWordExercise", "infoKey": "exercise_page.exerciseInfo.oddOneOutRhymingWord", "id": "rhyming" }, { "titleKey": "exercise_page.syllablesExercise", "infoKey": "exercise_page.exerciseInfo.oddOneOutSyllable", "id": "syllables" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.oddOneOutRandom", "id": "random" } ] }, { "heading": "exercise_page.snapHeading", "file": "exercise_snap.json", "titles": [ { "titleKey": "exercise_page.readingPhoneticsExercise", "infoKey": "exercise_page.exerciseInfo.snapReadingPhonetics", "id": "reading-phonetics" }, { "titleKey": "exercise_page.homophonesExercise", "infoKey": "exercise_page.exerciseInfo.snapHomophones", "id": "homophones" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.snapRandom", "id": "random" } ] }, { "heading": "exercise_page.memoryMatchHeading", "file": "exercise_memory_match.json", "titles": [ { "titleKey": "exercise_page.rhymingWordExercise", "infoKey": "exercise_page.exerciseInfo.memoryMatchRhymingWord", "id": "rhyming" }, { "titleKey": "exercise_page.homophonesExercise", "infoKey": "exercise_page.exerciseInfo.memoryMatchHomophones", "id": "homophones" }, { "titleKey": "exercise_page.randomExercise", "infoKey": "exercise_page.exerciseInfo.memoryMatchRandom", "id": "random" } ] } ] } ================================================ FILE: public/json/exercise_memory_match.json ================================================ { "memory_match": [ { "exercise": "Rhyming words", "id": "rhyming", "info": "Find words that end in the same sound", "b_s": "yes", "a_s": "yes", "british": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.rhyming", "quiz": [ { "data": [ { "value": "1", "text": "river" }, { "value": "1", "text": "quiver" }, { "value": "2", "text": "bear" }, { "value": "2", "text": "care" }, { "value": "3", "text": "first" }, { "value": "3", "text": "worst" }, { "value": "4", "text": "blame" }, { "value": "4", "text": "flame" }, { "value": "5", "text": "crash" }, { "value": "5", "text": "splash" }, { "value": "6", "text": "spray" }, { "value": "6", "text": "tray" }, { "value": "7", "text": "peel" }, { "value": "7", "text": "squeal" }, { "value": "8", "text": "built" }, { "value": "8", "text": "quilt" } ] }, { "data": [ { "value": "1", "text": "ink" }, { "value": "1", "text": "pink" }, { "value": "2", "text": "crazy" }, { "value": "2", "text": "lazy" }, { "value": "3", "text": "mask" }, { "value": "3", "text": "flask" }, { "value": "4", "text": "floor" }, { "value": "4", "text": "poor" }, { "value": "5", "text": "metal" }, { "value": "5", "text": "kettle" }, { "value": "6", "text": "funny" }, { "value": "6", "text": "sunny" }, { "value": "7", "text": "swift" }, { "value": "7", "text": "gift" }, { "value": "8", "text": "able" }, { "value": "8", "text": "cable" } ] }, { "data": [ { "value": "1", "text": "trouble" }, { "value": "1", "text": "double" }, { "value": "2", "text": "goose" }, { "value": "2", "text": "moose" }, { "value": "3", "text": "easy" }, { "value": "3", "text": "queasy" }, { "value": "4", "text": "praising" }, { "value": "4", "text": "lazing" }, { "value": "5", "text": "scoring" }, { "value": "5", "text": "boring" }, { "value": "6", "text": "herd" }, { "value": "6", "text": "word" }, { "value": "7", "text": "earn" }, { "value": "7", "text": "learn" }, { "value": "8", "text": "thread" }, { "value": "8", "text": "bed" } ] }, { "data": [ { "value": "1", "text": "oaf" }, { "value": "1", "text": "loaf" }, { "value": "2", "text": "still" }, { "value": "2", "text": "spill" }, { "value": "3", "text": "folder" }, { "value": "3", "text": "boulder" }, { "value": "4", "text": "happy" }, { "value": "4", "text": "snappy" }, { "value": "5", "text": "writer" }, { "value": "5", "text": "lighter" }, { "value": "6", "text": "cookery" }, { "value": "6", "text": "rookery" }, { "value": "7", "text": "butter" }, { "value": "7", "text": "cutter" }, { "value": "8", "text": "liver" }, { "value": "8", "text": "sliver" } ] }, { "data": [ { "value": "1", "text": "crowned" }, { "value": "1", "text": "found" }, { "value": "2", "text": "friend" }, { "value": "2", "text": "end" }, { "value": "3", "text": "lose" }, { "value": "3", "text": "whose" }, { "value": "4", "text": "season" }, { "value": "4", "text": "reason" }, { "value": "5", "text": "station" }, { "value": "5", "text": "nation" }, { "value": "6", "text": "strings" }, { "value": "6", "text": "brings" }, { "value": "7", "text": "coffee" }, { "value": "7", "text": "toffee" }, { "value": "8", "text": "box" }, { "value": "8", "text": "docks" } ] }, { "data": [ { "value": "1", "text": "aiming" }, { "value": "1", "text": "blaming" }, { "value": "2", "text": "saved" }, { "value": "2", "text": "paved" }, { "value": "3", "text": "threat" }, { "value": "3", "text": "pet" }, { "value": "4", "text": "bite " }, { "value": "4", "text": "fright " }, { "value": "5", "text": "shower" }, { "value": "5", "text": "sour" }, { "value": "6", "text": "toil" }, { "value": "6", "text": "foil" }, { "value": "7", "text": "eye" }, { "value": "7", "text": "shy" }, { "value": "8", "text": "straight" }, { "value": "8", "text": "weight" } ] }, { "data": [ { "value": "1", "text": "onion" }, { "value": "1", "text": "bunion" }, { "value": "2", "text": "wireless" }, { "value": "2", "text": "tireless" }, { "value": "3", "text": "fully" }, { "value": "3", "text": "pulley" }, { "value": "4", "text": "lightning" }, { "value": "4", "text": "frightening" }, { "value": "5", "text": "ooze" }, { "value": "5", "text": "snooze" }, { "value": "6", "text": "dinner" }, { "value": "6", "text": "winner" }, { "value": "7", "text": "eggs" }, { "value": "7", "text": "legs" }, { "value": "8", "text": "great" }, { "value": "8", "text": "eight" } ] }, { "data": [ { "value": "1", "text": "power" }, { "value": "1", "text": "tower" }, { "value": "2", "text": "dancer" }, { "value": "2", "text": "answer" }, { "value": "3", "text": "raised" }, { "value": "3", "text": "dazed" }, { "value": "4", "text": "give" }, { "value": "4", "text": "sieve" }, { "value": "5", "text": "ache" }, { "value": "5", "text": "fake" }, { "value": "6", "text": "pair" }, { "value": "6", "text": "where" }, { "value": "7", "text": "around" }, { "value": "7", "text": "aground" }, { "value": "8", "text": "swept" }, { "value": "8", "text": "kept" } ] }, { "data": [ { "value": "1", "text": "tearful" }, { "value": "1", "text": "fearful" }, { "value": "2", "text": "choose" }, { "value": "2", "text": "bruise" }, { "value": "3", "text": "cattle" }, { "value": "3", "text": "battle" }, { "value": "4", "text": "widget" }, { "value": "4", "text": "fidget" }, { "value": "5", "text": "mash" }, { "value": "5", "text": "stash" }, { "value": "6", "text": "axed" }, { "value": "6", "text": "taxed" }, { "value": "7", "text": "easing" }, { "value": "7", "text": "squeezing" }, { "value": "8", "text": "closed" }, { "value": "8", "text": "dozed" } ] }, { "data": [ { "value": "1", "text": "rule" }, { "value": "1", "text": "fool" }, { "value": "2", "text": "frame" }, { "value": "2", "text": "tame" }, { "value": "3", "text": "leaf" }, { "value": "3", "text": "brief" }, { "value": "4", "text": "leaving" }, { "value": "4", "text": "thieving" }, { "value": "5", "text": "hurt" }, { "value": "5", "text": "shirt" }, { "value": "6", "text": "maybe" }, { "value": "6", "text": "baby" }, { "value": "7", "text": "frayed" }, { "value": "7", "text": "trade" }, { "value": "8", "text": "love" }, { "value": "8", "text": "glove" } ] } ] } ], "american": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.rhyming", "quiz": [ { "data": [ { "value": "1", "text": "river" }, { "value": "1", "text": "quiver" }, { "value": "2", "text": "bear" }, { "value": "2", "text": "care" }, { "value": "3", "text": "first" }, { "value": "3", "text": "worst" }, { "value": "4", "text": "blame" }, { "value": "4", "text": "flame" }, { "value": "5", "text": "crash" }, { "value": "5", "text": "splash" }, { "value": "6", "text": "spray" }, { "value": "6", "text": "tray" }, { "value": "7", "text": "peel" }, { "value": "7", "text": "squeal" }, { "value": "8", "text": "built" }, { "value": "8", "text": "quilt" } ] }, { "data": [ { "value": "1", "text": "ink" }, { "value": "1", "text": "pink" }, { "value": "2", "text": "crazy" }, { "value": "2", "text": "lazy" }, { "value": "3", "text": "mask" }, { "value": "3", "text": "flask" }, { "value": "4", "text": "floor" }, { "value": "4", "text": "poor" }, { "value": "5", "text": "metal" }, { "value": "5", "text": "kettle" }, { "value": "6", "text": "funny" }, { "value": "6", "text": "sunny" }, { "value": "7", "text": "swift" }, { "value": "7", "text": "gift" }, { "value": "8", "text": "able" }, { "value": "8", "text": "cable" } ] }, { "data": [ { "value": "1", "text": "trouble" }, { "value": "1", "text": "double" }, { "value": "2", "text": "goose" }, { "value": "2", "text": "moose" }, { "value": "3", "text": "easy" }, { "value": "3", "text": "queasy" }, { "value": "4", "text": "praising" }, { "value": "4", "text": "lazing" }, { "value": "5", "text": "scoring" }, { "value": "5", "text": "boring" }, { "value": "6", "text": "herd" }, { "value": "6", "text": "word" }, { "value": "7", "text": "earn" }, { "value": "7", "text": "learn" }, { "value": "8", "text": "thread" }, { "value": "8", "text": "bed" } ] }, { "data": [ { "value": "1", "text": "oaf" }, { "value": "1", "text": "loaf" }, { "value": "2", "text": "still" }, { "value": "2", "text": "spill" }, { "value": "3", "text": "folder" }, { "value": "3", "text": "boulder" }, { "value": "4", "text": "happy" }, { "value": "4", "text": "snappy" }, { "value": "5", "text": "writer" }, { "value": "5", "text": "lighter" }, { "value": "6", "text": "cookery" }, { "value": "6", "text": "rookery" }, { "value": "7", "text": "butter" }, { "value": "7", "text": "cutter" }, { "value": "8", "text": "liver" }, { "value": "8", "text": "sliver" } ] }, { "data": [ { "value": "1", "text": "crowned" }, { "value": "1", "text": "found" }, { "value": "2", "text": "friend" }, { "value": "2", "text": "end" }, { "value": "3", "text": "lose" }, { "value": "3", "text": "whose" }, { "value": "4", "text": "season" }, { "value": "4", "text": "reason" }, { "value": "5", "text": "station" }, { "value": "5", "text": "nation" }, { "value": "6", "text": "strings" }, { "value": "6", "text": "brings" }, { "value": "7", "text": "coffee" }, { "value": "7", "text": "toffee" }, { "value": "8", "text": "box" }, { "value": "8", "text": "docks" } ] }, { "data": [ { "value": "1", "text": "aiming" }, { "value": "1", "text": "blaming" }, { "value": "2", "text": "saved" }, { "value": "2", "text": "paved" }, { "value": "3", "text": "threat" }, { "value": "3", "text": "pet" }, { "value": "4", "text": "bite " }, { "value": "4", "text": "fright " }, { "value": "5", "text": "shower" }, { "value": "5", "text": "sour" }, { "value": "6", "text": "toil" }, { "value": "6", "text": "foil" }, { "value": "7", "text": "eye" }, { "value": "7", "text": "shy" }, { "value": "8", "text": "straight" }, { "value": "8", "text": "weight" } ] }, { "data": [ { "value": "1", "text": "onion" }, { "value": "1", "text": "bunion" }, { "value": "2", "text": "wireless" }, { "value": "2", "text": "tireless" }, { "value": "3", "text": "fully" }, { "value": "3", "text": "pulley" }, { "value": "4", "text": "lightning" }, { "value": "4", "text": "frightening" }, { "value": "5", "text": "ooze" }, { "value": "5", "text": "snooze" }, { "value": "6", "text": "dinner" }, { "value": "6", "text": "winner" }, { "value": "7", "text": "eggs" }, { "value": "7", "text": "legs" }, { "value": "8", "text": "great" }, { "value": "8", "text": "eight" } ] }, { "data": [ { "value": "1", "text": "power" }, { "value": "1", "text": "tower" }, { "value": "2", "text": "dancer" }, { "value": "2", "text": "answer" }, { "value": "3", "text": "raised" }, { "value": "3", "text": "dazed" }, { "value": "4", "text": "give" }, { "value": "4", "text": "sieve" }, { "value": "5", "text": "ache" }, { "value": "5", "text": "fake" }, { "value": "6", "text": "pair" }, { "value": "6", "text": "where" }, { "value": "7", "text": "around" }, { "value": "7", "text": "aground" }, { "value": "8", "text": "swept" }, { "value": "8", "text": "kept" } ] }, { "data": [ { "value": "1", "text": "tearful" }, { "value": "1", "text": "fearful" }, { "value": "2", "text": "choose" }, { "value": "2", "text": "bruise" }, { "value": "3", "text": "cattle" }, { "value": "3", "text": "battle" }, { "value": "4", "text": "widget" }, { "value": "4", "text": "fidget" }, { "value": "5", "text": "mash" }, { "value": "5", "text": "stash" }, { "value": "6", "text": "axed" }, { "value": "6", "text": "taxed" }, { "value": "7", "text": "easing" }, { "value": "7", "text": "squeezing" }, { "value": "8", "text": "closed" }, { "value": "8", "text": "dozed" } ] }, { "data": [ { "value": "1", "text": "rule" }, { "value": "1", "text": "fool" }, { "value": "2", "text": "frame" }, { "value": "2", "text": "tame" }, { "value": "3", "text": "leaf" }, { "value": "3", "text": "brief" }, { "value": "4", "text": "leaving" }, { "value": "4", "text": "thieving" }, { "value": "5", "text": "hurt" }, { "value": "5", "text": "shirt" }, { "value": "6", "text": "maybe" }, { "value": "6", "text": "baby" }, { "value": "7", "text": "frayed" }, { "value": "7", "text": "trade" }, { "value": "8", "text": "love" }, { "value": "8", "text": "glove" } ] } ] } ] }, { "exercise": "Homophones", "id": "homophones", "info": "Find words that sound the same but have different spellings", "b_s": "yes", "a_s": "yes", "british": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.homophones", "quiz": [ { "data": [ { "value": "1", "text": "bare" }, { "value": "1", "text": "bear" }, { "value": "2", "text": "bean" }, { "value": "2", "text": "been" }, { "value": "3", "text": "beech" }, { "value": "3", "text": "beach" }, { "value": "4", "text": "berry" }, { "value": "4", "text": "bury" }, { "value": "5", "text": "boar" }, { "value": "5", "text": "bore" }, { "value": "6", "text": "blue" }, { "value": "6", "text": "blew" }, { "value": "7", "text": "break" }, { "value": "7", "text": "brake" }, { "value": "8", "text": "buoy" }, { "value": "8", "text": "boy" } ] }, { "data": [ { "value": "1", "text": "pain" }, { "value": "1", "text": "pane" }, { "value": "2", "text": "please" }, { "value": "2", "text": "pleas" }, { "value": "3", "text": "pedal" }, { "value": "3", "text": "peddle" }, { "value": "4", "text": "past" }, { "value": "4", "text": "passed" }, { "value": "5", "text": "pore" }, { "value": "5", "text": "pour" }, { "value": "6", "text": "principle" }, { "value": "6", "text": "principal" }, { "value": "7", "text": "pair" }, { "value": "7", "text": "pear" }, { "value": "8", "text": "poll" }, { "value": "8", "text": "pole" } ] }, { "data": [ { "value": "1", "text": "tea" }, { "value": "1", "text": "tee" }, { "value": "2", "text": "tear" }, { "value": "2", "text": "tier" }, { "value": "3", "text": "taut" }, { "value": "3", "text": "taught" }, { "value": "4", "text": "toad" }, { "value": "4", "text": "towed" }, { "value": "5", "text": "too" }, { "value": "5", "text": "two" }, { "value": "6", "text": "tow" }, { "value": "6", "text": "toe" }, { "value": "7", "text": "tied" }, { "value": "7", "text": "tide" }, { "value": "8", "text": "time" }, { "value": "8", "text": "thyme" } ] }, { "data": [ { "value": "1", "text": "dear" }, { "value": "1", "text": "deer" }, { "value": "2", "text": "dough" }, { "value": "2", "text": "doe" }, { "value": "3", "text": "die" }, { "value": "3", "text": "dye" }, { "value": "4", "text": "due" }, { "value": "4", "text": "dew" }, { "value": "5", "text": "draft" }, { "value": "5", "text": "draught" }, { "value": "6", "text": "dual" }, { "value": "6", "text": "duel" }, { "value": "7", "text": "dyed" }, { "value": "7", "text": "died" }, { "value": "8", "text": "days" }, { "value": "8", "text": "daze" } ] }, { "data": [ { "value": "1", "text": "made" }, { "value": "1", "text": "maid" }, { "value": "2", "text": "mode" }, { "value": "2", "text": "mowed" }, { "value": "3", "text": "miner" }, { "value": "3", "text": "minor" }, { "value": "4", "text": "meat" }, { "value": "4", "text": "meet" }, { "value": "5", "text": "mind" }, { "value": "5", "text": "mined" }, { "value": "6", "text": "mussel" }, { "value": "6", "text": "muscle" }, { "value": "7", "text": "morning" }, { "value": "7", "text": "mourning" }, { "value": "8", "text": "mail" }, { "value": "8", "text": "male" } ] }, { "data": [ { "value": "1", "text": "see" }, { "value": "1", "text": "sea" }, { "value": "2", "text": "sowing" }, { "value": "2", "text": "sewing" }, { "value": "3", "text": "soul" }, { "value": "3", "text": "sole" }, { "value": "4", "text": "stair" }, { "value": "4", "text": "stare" }, { "value": "5", "text": "seam" }, { "value": "5", "text": "seem" }, { "value": "6", "text": "sun" }, { "value": "6", "text": "son" }, { "value": "7", "text": "stationary" }, { "value": "7", "text": "stationery" }, { "value": "8", "text": "sighs" }, { "value": "8", "text": "size" } ] }, { "data": [ { "value": "1", "text": "hall" }, { "value": "1", "text": "haul" }, { "value": "2", "text": "him" }, { "value": "2", "text": "hymn" }, { "value": "3", "text": "hare" }, { "value": "3", "text": "hair" }, { "value": "4", "text": "hi" }, { "value": "4", "text": "high" }, { "value": "5", "text": "higher" }, { "value": "5", "text": "hire" }, { "value": "6", "text": "hoarse" }, { "value": "6", "text": "horse" }, { "value": "7", "text": "heel" }, { "value": "7", "text": "heal" }, { "value": "8", "text": "hear" }, { "value": "8", "text": "here" } ] }, { "data": [ { "value": "1", "text": "feat" }, { "value": "1", "text": "feet" }, { "value": "2", "text": "flee" }, { "value": "2", "text": "flea" }, { "value": "3", "text": "fare" }, { "value": "3", "text": "fair" }, { "value": "4", "text": "for" }, { "value": "4", "text": "four" }, { "value": "5", "text": "fined" }, { "value": "5", "text": "find" }, { "value": "6", "text": "fir" }, { "value": "6", "text": "fur" }, { "value": "7", "text": "flower" }, { "value": "7", "text": "flour" }, { "value": "8", "text": "forth" }, { "value": "8", "text": "fourth" } ] }, { "data": [ { "value": "1", "text": "raise" }, { "value": "1", "text": "rays" }, { "value": "2", "text": "rain" }, { "value": "2", "text": "reign" }, { "value": "3", "text": "red" }, { "value": "3", "text": "read" }, { "value": "4", "text": "reeds" }, { "value": "4", "text": "reads" }, { "value": "5", "text": "rites" }, { "value": "5", "text": "rights" }, { "value": "6", "text": "rode" }, { "value": "6", "text": "road" }, { "value": "7", "text": "rose" }, { "value": "7", "text": "rows" }, { "value": "8", "text": "role" }, { "value": "8", "text": "roll" } ] }, { "data": [ { "value": "1", "text": "bye" }, { "value": "1", "text": "by" }, { "value": "2", "text": "board" }, { "value": "2", "text": "bored" }, { "value": "3", "text": "border" }, { "value": "3", "text": "boarder" }, { "value": "4", "text": "byte" }, { "value": "4", "text": "bite" }, { "value": "5", "text": "bee" }, { "value": "5", "text": "be" }, { "value": "6", "text": "banned" }, { "value": "6", "text": "band" }, { "value": "7", "text": "bread" }, { "value": "7", "text": "bred" }, { "value": "8", "text": "beat" }, { "value": "8", "text": "beet" } ] } ] } ], "american": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.homophones", "quiz": [ { "data": [ { "value": "1", "text": "bare" }, { "value": "1", "text": "bear" }, { "value": "2", "text": "bean" }, { "value": "2", "text": "been" }, { "value": "3", "text": "beech" }, { "value": "3", "text": "beach" }, { "value": "4", "text": "berry" }, { "value": "4", "text": "bury" }, { "value": "5", "text": "boar" }, { "value": "5", "text": "bore" }, { "value": "6", "text": "blue" }, { "value": "6", "text": "blew" }, { "value": "7", "text": "break" }, { "value": "7", "text": "brake" }, { "value": "8", "text": "buoy" }, { "value": "8", "text": "boy" } ] }, { "data": [ { "value": "1", "text": "pain" }, { "value": "1", "text": "pane" }, { "value": "2", "text": "please" }, { "value": "2", "text": "pleas" }, { "value": "3", "text": "pedal" }, { "value": "3", "text": "peddle" }, { "value": "4", "text": "past" }, { "value": "4", "text": "passed" }, { "value": "5", "text": "pore" }, { "value": "5", "text": "pour" }, { "value": "6", "text": "principle" }, { "value": "6", "text": "principal" }, { "value": "7", "text": "pair" }, { "value": "7", "text": "pear" }, { "value": "8", "text": "poll" }, { "value": "8", "text": "pole" } ] }, { "data": [ { "value": "1", "text": "tea" }, { "value": "1", "text": "tee" }, { "value": "2", "text": "tear" }, { "value": "2", "text": "tier" }, { "value": "3", "text": "taut" }, { "value": "3", "text": "taught" }, { "value": "4", "text": "toad" }, { "value": "4", "text": "towed" }, { "value": "5", "text": "too" }, { "value": "5", "text": "two" }, { "value": "6", "text": "tow" }, { "value": "6", "text": "toe" }, { "value": "7", "text": "tied" }, { "value": "7", "text": "tide" }, { "value": "8", "text": "time" }, { "value": "8", "text": "thyme" } ] }, { "data": [ { "value": "1", "text": "dear" }, { "value": "1", "text": "deer" }, { "value": "2", "text": "dough" }, { "value": "2", "text": "doe" }, { "value": "3", "text": "die" }, { "value": "3", "text": "dye" }, { "value": "4", "text": "due" }, { "value": "4", "text": "dew" }, { "value": "5", "text": "done" }, { "value": "5", "text": "dun" }, { "value": "6", "text": "dual" }, { "value": "6", "text": "duel" }, { "value": "7", "text": "dyed" }, { "value": "7", "text": "died" }, { "value": "8", "text": "days" }, { "value": "8", "text": "daze" } ] }, { "data": [ { "value": "1", "text": "made" }, { "value": "1", "text": "maid" }, { "value": "2", "text": "mode" }, { "value": "2", "text": "mowed" }, { "value": "3", "text": "miner" }, { "value": "3", "text": "minor" }, { "value": "4", "text": "meat" }, { "value": "4", "text": "meet" }, { "value": "5", "text": "mind" }, { "value": "5", "text": "mined" }, { "value": "6", "text": "mussel" }, { "value": "6", "text": "muscle" }, { "value": "7", "text": "morning" }, { "value": "7", "text": "mourning" }, { "value": "8", "text": "mail" }, { "value": "8", "text": "male" } ] }, { "data": [ { "value": "1", "text": "see" }, { "value": "1", "text": "sea" }, { "value": "2", "text": "sowing" }, { "value": "2", "text": "sewing" }, { "value": "3", "text": "soul" }, { "value": "3", "text": "sole" }, { "value": "4", "text": "stair" }, { "value": "4", "text": "stare" }, { "value": "5", "text": "seam" }, { "value": "5", "text": "seem" }, { "value": "6", "text": "sun" }, { "value": "6", "text": "son" }, { "value": "7", "text": "stationary" }, { "value": "7", "text": "stationery" }, { "value": "8", "text": "sighs" }, { "value": "8", "text": "size" } ] }, { "data": [ { "value": "1", "text": "hall" }, { "value": "1", "text": "haul" }, { "value": "2", "text": "him" }, { "value": "2", "text": "hymn" }, { "value": "3", "text": "hare" }, { "value": "3", "text": "hair" }, { "value": "4", "text": "hi" }, { "value": "4", "text": "high" }, { "value": "5", "text": "higher" }, { "value": "5", "text": "hire" }, { "value": "6", "text": "hoarse" }, { "value": "6", "text": "horse" }, { "value": "7", "text": "heel" }, { "value": "7", "text": "heal" }, { "value": "8", "text": "hear" }, { "value": "8", "text": "here" } ] }, { "data": [ { "value": "1", "text": "feat" }, { "value": "1", "text": "feet" }, { "value": "2", "text": "flee" }, { "value": "2", "text": "flea" }, { "value": "3", "text": "fare" }, { "value": "3", "text": "fair" }, { "value": "4", "text": "for" }, { "value": "4", "text": "four" }, { "value": "5", "text": "fined" }, { "value": "5", "text": "find" }, { "value": "6", "text": "fir" }, { "value": "6", "text": "fur" }, { "value": "7", "text": "flower" }, { "value": "7", "text": "flour" }, { "value": "8", "text": "forth" }, { "value": "8", "text": "fourth" } ] }, { "data": [ { "value": "1", "text": "raise" }, { "value": "1", "text": "raze" }, { "value": "2", "text": "rain" }, { "value": "2", "text": "reign" }, { "value": "3", "text": "red" }, { "value": "3", "text": "read" }, { "value": "4", "text": "reeds" }, { "value": "4", "text": "reads" }, { "value": "5", "text": "rites" }, { "value": "5", "text": "rights" }, { "value": "6", "text": "rode" }, { "value": "6", "text": "road" }, { "value": "7", "text": "rose" }, { "value": "7", "text": "rows" }, { "value": "8", "text": "role" }, { "value": "8", "text": "roll" } ] }, { "data": [ { "value": "1", "text": "bye" }, { "value": "1", "text": "by" }, { "value": "2", "text": "board" }, { "value": "2", "text": "bored" }, { "value": "3", "text": "beau" }, { "value": "3", "text": "bow" }, { "value": "4", "text": "byte" }, { "value": "4", "text": "bite" }, { "value": "5", "text": "bee" }, { "value": "5", "text": "be" }, { "value": "6", "text": "bough" }, { "value": "6", "text": "bow" }, { "value": "7", "text": "bread" }, { "value": "7", "text": "bred" }, { "value": "8", "text": "beat" }, { "value": "8", "text": "beet" } ] } ] } ] }, { "exercise": "Random", "id": "random", "info": "A mixture of homophone and rhyming matches", "b_s": "yes", "a_s": "yes", "british": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.random", "quiz": [] } ], "american": [ { "accent": "British and American English", "instructions": "exercise_page.exerciseInstruction.memory_match.random", "quiz": [] } ] } ] } ================================================ FILE: public/json/json_file_hashes.json ================================================ [{"file":"conversation_data.json","sha256":"f5b8a7a9e2bf4dbbdeaa1c2891c1022cdb1af3115d795b0abd3a7521c4829e43"},{"file":"conversation_list.json","sha256":"b3b2ee257a9ccb540455b8f58e35edc1d01a02709110a9053862053b979a57bf"},{"file":"examspeaking_data.json","sha256":"a0e748ad87544006535431a7ff6c6d7ad341f8835e5d6ce8b47ca0af9f6d91b8"},{"file":"examspeaking_list.json","sha256":"97d9dfd5a0e6553c6299a9031eb4a5474a009ba1d714f986f0fdff07fc48ba06"},{"file":"exercise_dictation.json","sha256":"735628b3d3ad2a8f2a01d71c50dc27bf3dd21917b35756c6793d354a0127b368"},{"file":"exercise_list.json","sha256":"da0e8b802bd9dde836c03296465245dff4ddbde881f32d8ecb90b5493f506b43"},{"file":"exercise_matchup.json","sha256":"d0e6af1e56078368709e5578b39757d9dc75160549abd075dcb4702b1cd639cf"},{"file":"exercise_memory_match.json","sha256":"90504c5bf90c62a4590b8f7d473784154d5ab0fcfa302a6c19d1ed712a8322c8"},{"file":"exercise_odd_one_out.json","sha256":"e4159a3cc95e70cc3a444c0811db86c6e8c8b00ba2e3e72be8ec5cfb7e75543f"},{"file":"exercise_reordering.json","sha256":"f33de698bf8056907b1454ea9601a12109b6f3e271b0719662051dc365178b17"},{"file":"exercise_snap.json","sha256":"b10ae5fcf21e982555e712c5a2643a87781689176431e1a1b7136eefdd7a22b4"},{"file":"exercise_sorting.json","sha256":"0ae2b8ccba66eeb97bbbcf40c0bafc9d2ed1cfb758fb0615ad570fad980a4563"},{"file":"exercise_sound_n_spelling.json","sha256":"0c7ad8f7abd7efc1a16b135df3cea3e930d4f5c7c40b26df62c28abbde73e37f"},{"file":"ex_data.json","sha256":"b098514cfc8bb3e3a2e1b3bde0a3af5aa9949d34beab87ee528875e64230e1c2"},{"file":"sounds_data.json","sha256":"978c0394c7f2e4696a4d3db01d4eb1ce8fcf0bcfb9ed462eb48a11276130443e"}] ================================================ FILE: public/json/sounds_menu.json ================================================ { "phonemes": { "consonants": [ { "phoneme": "/ p /", "word": "pen", "british": true, "american": true, "id": 1, "key": "pPen" }, { "phoneme": "/ b /", "word": "bad", "british": true, "american": true, "id": 2, "key": "bBad" }, { "phoneme": "/ t /", "word": "tea", "british": true, "american": true, "id": 3, "key": "tTea" }, { "phoneme": "/ d /", "word": "did", "british": true, "american": true, "id": 4, "key": "dDid" }, { "phoneme": "/ k /", "word": "cat", "british": true, "american": true, "id": 5, "key": "kCat" }, { "phoneme": "/ ɡ /", "word": "get", "british": true, "american": true, "id": 6, "key": "gGet" }, { "phoneme": "/ m /", "word": "man", "british": true, "american": true, "id": 7, "key": "mMan" }, { "phoneme": "/ n /", "word": "now", "british": true, "american": true, "id": 8, "key": "nNow" }, { "phoneme": "/ ŋ /", "word": "sing", "british": true, "american": true, "id": 9, "key": "ngSing" }, { "phoneme": "/ f /", "word": "fall", "british": true, "american": true, "id": 10, "key": "fFall" }, { "phoneme": "/ v /", "word": "van", "british": true, "american": true, "id": 11, "key": "vVan" }, { "phoneme": "/ θ /", "word": "thin", "british": true, "american": true, "id": 12, "key": "thThin" }, { "phoneme": "/ ð /", "word": "this", "british": true, "american": true, "id": 13, "key": "thThis" }, { "phoneme": "/ s /", "word": "see", "british": true, "american": true, "id": 14, "key": "sSee" }, { "phoneme": "/ z /", "word": "zoo", "british": true, "american": true, "id": 15, "key": "zZoo" }, { "phoneme": "/ ʃ /", "word": "shoe", "british": true, "american": true, "id": 16, "key": "shShoe" }, { "phoneme": "/ ʒ /", "word": "vision", "british": true, "american": true, "id": 17, "key": "sVision" }, { "phoneme": "/ tʃ /", "word": "chain", "british": true, "american": true, "id": 18, "key": "chChain" }, { "phoneme": "/ dʒ /", "word": "jam", "british": true, "american": true, "id": 19, "key": "jJam" }, { "phoneme": "/ l /", "word": "leg", "british": true, "american": true, "id": 20, "key": "lLeg" }, { "phoneme": "/ r /", "word": "red", "british": true, "american": true, "id": 21, "key": "rRed" }, { "phoneme": "/ h /", "word": "hat", "british": true, "american": true, "id": 22, "key": "hHat" }, { "phoneme": "/ w /", "word": "wet", "british": true, "american": true, "id": 25, "key": "wWet" }, { "phoneme": "/ j /", "word": "yes", "british": true, "american": true, "id": 24, "key": "yYes" }, { "phoneme": "/ x /", "word": "loch", "british": true, "american": true, "id": 23, "key": "chLoch" }, { "phoneme": "/ t̬ /", "word": "city", "british": false, "american": true, "id": 26, "key": "tCity" } ], "vowels": [ { "phoneme": "/ iː /", "word": "see", "british": true, "american": true, "id": 1, "key": "eeSee" }, { "phoneme": "/ i /", "word": "happy", "british": true, "american": true, "id": 2, "key": "yHappy" }, { "phoneme": "/ ɪ /", "word": "sit", "british": true, "american": true, "id": 3, "key": "iSit" }, { "phoneme": "/ e /", "word": "bed", "british": true, "american": true, "id": 4, "key": "eBed" }, { "phoneme": "/ æ /", "word": "cat", "british": true, "american": true, "id": 5, "key": "aCat" }, { "phoneme": "/ ə /", "word": "about", "british": true, "american": true, "id": 6, "key": "aAbout" }, { "phoneme": "/ ɜː /", "word": "fur", "british": true, "american": false, "id": 7, "key": "urFur" }, { "phoneme": "/ ɜːr /", "word": "fur", "british": false, "american": true, "id": 8, "key": "urFur_r" }, { "phoneme": "/ ʌ /", "word": "cup", "british": true, "american": true, "id": 9, "key": "uCup" }, { "phoneme": "/ uː /", "word": "too", "british": true, "american": true, "id": 10, "key": "ooToo" }, { "phoneme": "/ u /", "word": "actual", "british": true, "american": true, "id": 11, "key": "uActual" }, { "phoneme": "/ ʊ /", "word": "put", "british": true, "american": true, "id": 12, "key": "uPut" }, { "phoneme": "/ ɔː /", "word": "saw", "british": true, "american": true, "id": 13, "key": "awSaw" }, { "phoneme": "/ ɒ /", "word": "got", "british": true, "american": false, "id": 14, "key": "oGot" }, { "phoneme": "/ ɑː /", "word": "father", "british": true, "american": true, "id": 15, "key": "aFather" }, { "phoneme": "/ ɑ̃ /", "word": "en suite", "british": true, "american": true, "id": 16, "key": "aEnSuite" } ], "diphthongs": [ { "phoneme": "/ eɪ /", "word": "say", "british": true, "american": true, "id": 1, "key": "aySay" }, { "phoneme": "/ əʊ /", "word": "go", "british": true, "american": false, "id": 2, "key": "oGo" }, { "phoneme": "/ oʊ /", "word": "go", "british": false, "american": true, "id": 3, "key": "oGo_r" }, { "phoneme": "/ aɪ /", "word": "my", "british": true, "american": true, "id": 4, "key": "yMy" }, { "phoneme": "/ aʊ /", "word": "now", "british": true, "american": true, "id": 5, "key": "owNow" }, { "phoneme": "/ ɔɪ /", "word": "boy", "british": true, "american": true, "id": 6, "key": "oyBoy" }, { "phoneme": "/ ɪə /", "word": "near", "british": true, "american": false, "id": 7, "key": "earNear" }, { "phoneme": "/ ɪr /", "word": "near", "british": false, "american": true, "id": 8, "key": "earNear_r" }, { "phoneme": "/ eə /", "word": "hair", "british": true, "american": false, "id": 9, "key": "airHair" }, { "phoneme": "/ er /", "word": "hair", "british": false, "american": true, "id": 10, "key": "airHair_r" }, { "phoneme": "/ ʊə /", "word": "pure", "british": true, "american": false, "id": 11, "key": "urePure" }, { "phoneme": "/ ʊr /", "word": "pure", "british": false, "american": true, "id": 12, "key": "urePure_r" } ] } } ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_1.srt ================================================ 1 00:00:06,370 --> 00:00:08,235 Our website looks a bit dated, 2 00:00:08,240 --> 00:00:09,319 don't you think? 3 00:00:09,320 --> 00:00:11,479 Absolutely, we should get a web designer 4 00:00:11,480 --> 00:00:13,229 to take a look at it. 5 00:00:13,230 --> 00:00:14,465 I think you're right. 6 00:00:14,466 --> 00:00:17,287 Let's put it on the agenda for next week. 7 00:00:17,290 --> 00:00:19,785 I would certainly go along with that idea. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_10.srt ================================================ 1 00:00:07,820 --> 00:00:09,859 Hi Sarah! Congratulations on the promotion. 2 00:00:09,860 --> 00:00:11,559 I just heard this morning. 3 00:00:11,560 --> 00:00:12,875 Oh, thank you very much. 4 00:00:12,876 --> 00:00:15,610 When will you start in the new position? 5 00:00:15,611 --> 00:00:18,354 At the end of May, when Tony retires. 6 00:00:18,360 --> 00:00:19,719 Wow, that'll be great! ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_11.srt ================================================ 1 00:00:08,660 --> 00:00:10,374 Excuse me, can you tell me 2 00:00:10,375 --> 00:00:12,569 the way to Green Lane? 3 00:00:12,570 --> 00:00:16,137 Yes, go straight on and it's the first - 4 00:00:16,140 --> 00:00:20,626 or rather, the second on the... left 5 00:00:20,630 --> 00:00:22,030 - no, sorry, right! 6 00:00:22,031 --> 00:00:24,832 I'm terrible at left and right. 7 00:00:24,840 --> 00:00:26,707 Yeah, I'm the same. 8 00:00:26,708 --> 00:00:29,519 So, it's second on the right. Thanks! ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_12.srt ================================================ 1 00:00:06,840 --> 00:00:08,983 Excuse me, but can I just ask where 2 00:00:08,984 --> 00:00:10,659 we're going to publicize this? 3 00:00:10,660 --> 00:00:12,129 Well, that's something we probably 4 00:00:12,130 --> 00:00:14,345 should look into further. Yes, Jo? 5 00:00:14,346 --> 00:00:18,178 I was wondering if we have a launch date yet. 6 00:00:18,180 --> 00:00:19,952 Can I come back to that point later,  7 00:00:19,953 --> 00:00:21,706 when we deal with the project scheduling? ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_13.srt ================================================ 1 00:00:06,750 --> 00:00:08,576 I think we should sell the app 2 00:00:08,577 --> 00:00:10,979 at a low cost to maximize sales. 3 00:00:10,980 --> 00:00:13,697 I can't go along with that idea. 4 00:00:13,698 --> 00:00:15,805 If it's cheap, people will think it's poor quality. 5 00:00:15,810 --> 00:00:18,329 I take your point, but people won't 6 00:00:18,330 --> 00:00:20,836 pay much for an app these days. 7 00:00:20,840 --> 00:00:22,666 Well, actually, I'm not sure that's true. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_14.srt ================================================ 1 00:00:06,220 --> 00:00:10,746 Oh, is that the time? I'd better be off. 2 00:00:10,750 --> 00:00:12,195 It's been lovely talking to you. 3 00:00:12,200 --> 00:00:13,893 Yes, it was nice to meet you. 4 00:00:13,900 --> 00:00:14,859 Maybe see you later? ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_15.srt ================================================ 1 00:00:06,920 --> 00:00:09,749 Do you know what time it is in New York? 2 00:00:09,750 --> 00:00:12,305 I'm not at all sure to be honest. Why? 3 00:00:12,306 --> 00:00:15,953 I'm supposed to phone the New York office. 4 00:00:15,960 --> 00:00:17,324 Well, I think it's probably 5 00:00:17,325 --> 00:00:18,143 very early morning. 6 00:00:18,150 --> 00:00:20,333 I doubt they'll be at work right now. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_16.srt ================================================ 1 00:00:08,640 --> 00:00:11,303 Excuse me, is Alice out of the office today? 2 00:00:11,310 --> 00:00:13,399 Not to my knowledge, no. 3 00:00:13,400 --> 00:00:15,859 Do you know where she is? 4 00:00:15,860 --> 00:00:18,159 I have absolutely no idea, I'm afraid. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_17.srt ================================================ 1 00:00:07,690 --> 00:00:11,659 Are you likely to be in Oxford on the 15th? 2 00:00:11,660 --> 00:00:14,494 I'm probably going to be in London on that day. 3 00:00:14,495 --> 00:00:18,067 Will you be back by six? 4 00:00:18,070 --> 00:00:19,659 That's unlikely - there's bound to be 5 00:00:19,660 --> 00:00:21,577 a lot of traffic on the way home. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_18.srt ================================================ 1 00:00:06,610 --> 00:00:08,601 There's a Chinese restaurant next door 2 00:00:08,602 --> 00:00:11,037 or an Italian just across the street. 3 00:00:11,040 --> 00:00:13,426 I think I'd prefer the Italian myself. 4 00:00:13,430 --> 00:00:15,816 What about you? I don't really mind either way. 5 00:00:15,817 --> 00:00:17,859 OK, the Italian then. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_19.srt ================================================ 1 00:00:06,360 --> 00:00:08,991 I'm afraid I'm going to have to miss 2 00:00:08,992 --> 00:00:10,633 the conference. My father's not well. 3 00:00:10,634 --> 00:00:12,601 Oh, I'm sorry to hear that. 4 00:00:12,610 --> 00:00:15,561 I'm sorry, I know it's a bit short notice. 5 00:00:15,570 --> 00:00:17,549 No, please don't worry. Family comes first. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_2.srt ================================================ 1 00:00:08,520 --> 00:00:10,087 Oh, I do apologize. 2 00:00:10,088 --> 00:00:12,439 I should have been looking where 3 00:00:12,524 --> 00:00:14,461 I was going. Are you alright? 4 00:00:14,462 --> 00:00:16,181 Yes, don't worry about it. 5 00:00:16,182 --> 00:00:18,347 But you spilt some coffee. 6 00:00:18,350 --> 00:00:20,617 Can I get you another one? 7 00:00:20,620 --> 00:00:22,127 No, it's fine, really. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_20.srt ================================================ 1 00:00:05,700 --> 00:00:06,759 So unless they get somebody 2 00:00:06,760 --> 00:00:07,989 else in we're not gonna.. 3 00:00:07,990 --> 00:00:09,237 we're not gonna do it. 4 00:00:12,810 --> 00:00:15,589 Could I ask you not to talk out here, please? 5 00:00:15,590 --> 00:00:17,535 We're doing some recording in this room. 6 00:00:17,540 --> 00:00:20,039 Oh, I see. OK, we'll move away then. Thanks. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_21.srt ================================================ 1 00:00:07,740 --> 00:00:09,275 Hi, I'm here to see the 2 00:00:09,276 --> 00:00:10,299 head of Human Resources. 3 00:00:10,300 --> 00:00:12,084 Could you wait here for a moment, 4 00:00:12,090 --> 00:00:13,879 I'll just see if she's free... OK. 5 00:00:16,370 --> 00:00:17,479 Your visitor's here. 6 00:00:19,680 --> 00:00:21,077 OK, she can see you now. 7 00:00:21,080 --> 00:00:22,489 Would you go right in, please? ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_22.srt ================================================ 1 00:00:08,430 --> 00:00:11,735 Why don't you get a tablet? 2 00:00:11,740 --> 00:00:14,035 Well, first, I use the keyboard a lot, 3 00:00:14,040 --> 00:00:18,017 and second, you can't do as much on it. 4 00:00:18,020 --> 00:00:20,567 Hmm, maybe. But I like it just because 5 00:00:20,568 --> 00:00:23,569 it's so much easier to carry around. 6 00:00:23,570 --> 00:00:25,585 Well, it depends on your priorities, I guess. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_23.srt ================================================ 1 00:00:10,240 --> 00:00:12,414 I still haven't received my 2 00:00:12,415 --> 00:00:14,154 contract from Mr Spencer. 3 00:00:14,160 --> 00:00:15,509 Really? That's awful. 4 00:00:15,510 --> 00:00:18,859 If I were you, I'd remind him about it. 5 00:00:18,860 --> 00:00:20,409 You shouldn't do the work 6 00:00:20,410 --> 00:00:21,649 without a signed contract. 7 00:00:21,650 --> 00:00:22,579 No, you're right. 8 00:00:22,580 --> 00:00:24,439 Yes, I think you should make 9 00:00:24,440 --> 00:00:26,176 an appointment to see him 10 00:00:26,177 --> 00:00:27,536 as soon as possible. 11 00:00:27,540 --> 00:00:29,228 OK, I will. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_24.srt ================================================ 1 00:00:06,510 --> 00:00:08,345 If we don't finish everything today, 2 00:00:08,350 --> 00:00:10,295 we can work on it next week 3 00:00:10,296 --> 00:00:11,729 and supply it the week aft-- 4 00:00:11,730 --> 00:00:12,608 Sorry to interrupt, 5 00:00:12,609 --> 00:00:15,409 but I don't think we can do that. 6 00:00:15,410 --> 00:00:16,641 They're expecting it on Monday. 7 00:00:16,642 --> 00:00:17,551 Oh, of course... 8 00:00:17,552 --> 00:00:19,056 well, maybe we should get 9 00:00:19,057 --> 00:00:20,778 some extra help in that case? ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_25.srt ================================================ 1 00:00:07,890 --> 00:00:10,066 Hi, I haven't seen you for ages! 2 00:00:10,070 --> 00:00:11,947 Oh, hi, Sarah. This is Simon -  3 00:00:11,950 --> 00:00:14,126 I don't know if you've met? 4 00:00:14,130 --> 00:00:15,377 Simon - this is Sarah. 5 00:00:15,378 --> 00:00:17,561 No, I don't think we've met. Hi Simon. 6 00:00:17,562 --> 00:00:19,427 Hi Sarah, nice to meet you. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_26.srt ================================================ 1 00:00:06,990 --> 00:00:09,041 We're organizing a trip to 2 00:00:09,042 --> 00:00:10,409 Stratford on Wednesday afternoon. 3 00:00:10,410 --> 00:00:12,796 Would you be interested in coming along? 4 00:00:12,800 --> 00:00:15,562 I'd love to, but I don't think I can 5 00:00:15,563 --> 00:00:18,274 make it any sooner than two o'clock. 6 00:00:18,280 --> 00:00:20,325 Is that too late? I'm afraid so, 7 00:00:20,330 --> 00:00:22,375 we're all meeting at one. 8 00:00:22,380 --> 00:00:23,408 OK. Thanks anyway. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_27.srt ================================================ 1 00:00:06,570 --> 00:00:09,224 Hello, ABC print here. How can I help you? 2 00:00:09,230 --> 00:00:11,589 Yes, can I speak to Adam Talbot, please? 3 00:00:11,590 --> 00:00:14,539 I'm afraid he's not in. Can I take a message? 4 00:00:14,540 --> 00:00:16,899 Yes, er, can you ask him to 5 00:00:16,900 --> 00:00:18,374 call me back, please? It's Jo Walker. 6 00:00:18,375 --> 00:00:20,439 OK I'll let him know. Thank you. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_28.srt ================================================ 1 00:00:07,770 --> 00:00:10,454 I'd like to speak to the manager, please. 2 00:00:10,455 --> 00:00:12,464 I'd like to make a complaint. 3 00:00:12,470 --> 00:00:14,485 I'm afraid she's busy all day. 4 00:00:14,490 --> 00:00:16,499 Would you like to make an appointment 5 00:00:16,500 --> 00:00:17,504 for tomorrow? 6 00:00:17,510 --> 00:00:19,525 I'm sorry this isn't good enough. 7 00:00:19,530 --> 00:00:21,879 I'm only in town today. 8 00:00:21,880 --> 00:00:24,224 I'm sorry, but there's really nothing I can do. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_29.srt ================================================ 1 00:00:08,910 --> 00:00:10,549 Why don't you visit 2 00:00:10,550 --> 00:00:12,189 somewhere in central Europe? 3 00:00:12,190 --> 00:00:14,529 What would you recommend? 4 00:00:14,530 --> 00:00:16,538 Well, if you've never been there before, 5 00:00:16,540 --> 00:00:17,676 I'd recommend Prague. 6 00:00:17,677 --> 00:00:19,950 It's great for a long weekend 7 00:00:19,951 --> 00:00:22,225 at this time of year. Prague. 8 00:00:22,230 --> 00:00:23,987 Yeah, OK, I'll look into that. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_3.srt ================================================ 1 00:00:06,970 --> 00:00:09,219 Do I have to wear this visitor badge 2 00:00:09,220 --> 00:00:11,393 even here in the canteen? Yes, I think so. 3 00:00:11,394 --> 00:00:13,996 And am I supposed to hand it in 4 00:00:13,997 --> 00:00:16,329 to the security desk if I pop out for a coffee? 5 00:00:16,330 --> 00:00:18,929 No, just hand it in at the end of the day. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_30.srt ================================================ 1 00:00:06,370 --> 00:00:08,657 Can I have a glass of water, please? 2 00:00:08,660 --> 00:00:10,861 Yes, of course. Sorry, it's not very cold. 3 00:00:10,862 --> 00:00:12,919 That's alright. Thank you. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_31.srt ================================================ 1 00:00:06,200 --> 00:00:07,644 How about meeting up around eight 2 00:00:07,645 --> 00:00:09,819 in front of the restaurant? 3 00:00:09,820 --> 00:00:12,283 Yes, OK. Or we could meet a bit earlier 4 00:00:12,284 --> 00:00:14,244 and have a drink first if you like. 5 00:00:14,245 --> 00:00:16,573 Sure. Shall we say 6 00:00:16,574 --> 00:00:18,368 seven o'clock in the lobby? 7 00:00:18,370 --> 00:00:19,689 OK, see you there. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_32.srt ================================================ 1 00:00:09,830 --> 00:00:14,824 Oh no! Would you like me to help you with that? 2 00:00:14,830 --> 00:00:16,117 That's alright, thank you. 3 00:00:16,118 --> 00:00:18,432 I need to put everything in the 4 00:00:18,433 --> 00:00:20,369 right order. Thanks anyway. It's OK. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_33.srt ================================================ 1 00:00:06,740 --> 00:00:08,635 Can I get you a tea 2 00:00:08,636 --> 00:00:09,738 or a coffee before we begin? 3 00:00:09,740 --> 00:00:11,489 Yes, a coffee would be nice, thank you. 4 00:00:11,490 --> 00:00:14,065 How about something to eat? 5 00:00:14,070 --> 00:00:16,405 I might get a piece of cake myself. 6 00:00:16,410 --> 00:00:17,869 No, not for me, thanks. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_34.srt ================================================ 1 00:00:09,370 --> 00:00:11,357 Excuse me, do you mind if I join you? 2 00:00:11,358 --> 00:00:13,514 All the tables seem to be full. 3 00:00:13,515 --> 00:00:15,709 No, of course not! 4 00:00:15,710 --> 00:00:19,519 Here, let me move my bag out of the way. 5 00:00:19,520 --> 00:00:21,535 Are you here for the exhibition? 6 00:00:21,540 --> 00:00:23,555 Yes, and you? Yes, me too. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_35.srt ================================================ 1 00:00:07,240 --> 00:00:09,367 I'm going to an outdoor concert tonight. 2 00:00:11,770 --> 00:00:14,913 I wonder what will happen if it rains. 3 00:00:14,920 --> 00:00:18,177 I would imagine that they'll cancel. 4 00:00:18,180 --> 00:00:19,655 But then how will everybody know? 5 00:00:21,880 --> 00:00:24,049 I don't know. I would guess they'll 6 00:00:24,050 --> 00:00:25,739 post something on the website. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_36.srt ================================================ 1 00:00:06,350 --> 00:00:09,157 ... and then you just press Control S 2 00:00:09,158 --> 00:00:12,178 to save, and that's it. 3 00:00:12,180 --> 00:00:14,524 OK, I think I've got it now. 4 00:00:14,530 --> 00:00:15,873 Thank you very much. 5 00:00:15,874 --> 00:00:17,553 I do appreciate your help. 6 00:00:17,560 --> 00:00:19,569 I'm hopeless with some of this software. 7 00:00:19,570 --> 00:00:21,249 That's alright. No problem. 8 00:00:21,250 --> 00:00:22,924 If you need anything else, 9 00:00:22,930 --> 00:00:24,277 just let me know. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_37.srt ================================================ 1 00:00:06,210 --> 00:00:08,045 (...) 2 00:00:08,046 --> 00:00:09,890 Look out! Oh, sorry! 3 00:00:09,891 --> 00:00:11,425 I nearly spilled coffee everywhere! 4 00:00:11,430 --> 00:00:13,487 I know it's not a good place 5 00:00:13,488 --> 00:00:15,419 to stand and talk, is it? ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_38.srt ================================================ 1 00:00:07,820 --> 00:00:09,679 I've got my appraisal this afternoon. 2 00:00:09,680 --> 00:00:12,159 I'm dreading it. Oh, I hope it goes well. 3 00:00:12,160 --> 00:00:14,639 I'll keep my fingers crossed for you! Thanks. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_39.srt ================================================ 1 00:00:06,870 --> 00:00:09,214 Well, I'm afraid time is running out, 2 00:00:09,220 --> 00:00:12,659 so unless anyone has anything else to add... 3 00:00:12,660 --> 00:00:14,654 Sorry, did you want to say something, Jo? 4 00:00:14,660 --> 00:00:16,444 Yes, can I just remind everybody 5 00:00:16,445 --> 00:00:18,667 to send me your receipts for expenses? 6 00:00:18,670 --> 00:00:19,813 Thanks. OK, thanks Jo. 7 00:00:19,814 --> 00:00:21,529 We'll have to leave it there. 8 00:00:21,530 --> 00:00:22,959 Have a good trip home. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_4.srt ================================================ 1 00:00:08,180 --> 00:00:10,331 It says on the box, 'Take two tablets, 2 00:00:10,332 --> 00:00:12,667 in the morning and in the evening.' 3 00:00:12,670 --> 00:00:14,457 I'm not very clear about that. 4 00:00:14,460 --> 00:00:18,099 Do they mean to say for a day? 5 00:00:18,100 --> 00:00:19,769 Sorry, I don't quite follow... 6 00:00:19,770 --> 00:00:22,946 I mean is it two in the morning and 7 00:00:22,947 --> 00:00:27,667 two in the evening? Ah, I see. 8 00:00:27,670 --> 00:00:29,295 I'm not sure, to be honest. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_5.srt ================================================ 1 00:00:08,830 --> 00:00:11,005 Oh, excuse me. I wonder if you could 2 00:00:11,006 --> 00:00:12,776 possibly take a photo of us, please? 3 00:00:12,780 --> 00:00:15,137 Yes, of course. Where would you like to be? 4 00:00:15,140 --> 00:00:17,171 Just here I think. Do you know 5 00:00:17,172 --> 00:00:19,347 how to use this? Yes, I think so. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_6.srt ================================================ 1 00:00:07,540 --> 00:00:09,296 I wonder whether you can help me. 2 00:00:09,300 --> 00:00:11,490 I'm trying to find out if there's 3 00:00:11,491 --> 00:00:13,377 an artists' materials shop near here... 4 00:00:13,380 --> 00:00:17,417 Artists' materials? I really don't know. 5 00:00:17,420 --> 00:00:19,617 There's a stationers's in the High Street - 6 00:00:19,620 --> 00:00:20,875 they might sell something there, I suppose. 7 00:00:20,876 --> 00:00:21,817 OK, thanks. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_7.srt ================================================ 1 00:00:07,190 --> 00:00:08,904 I've got to go soon. 2 00:00:08,905 --> 00:00:10,276 I'm picking up Claire from the airport. 3 00:00:10,277 --> 00:00:12,158 Oh, I hate airports. 4 00:00:12,160 --> 00:00:13,305 Traveling by train's 5 00:00:13,306 --> 00:00:15,009 much nicer, isn't it? 6 00:00:15,010 --> 00:00:16,889 Depends on the train. 7 00:00:16,890 --> 00:00:20,634 Sometimes they're dirty and crowded, aren't they? 8 00:00:20,640 --> 00:00:22,928 I guess so. 9 00:00:22,930 --> 00:00:24,544 What do you think about 10 00:00:24,545 --> 00:00:26,159 those new high-speed trains? 11 00:00:26,160 --> 00:00:27,071 They're great, 12 00:00:27,072 --> 00:00:28,895 but they're terribly expensive. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_8.srt ================================================ 1 00:00:07,620 --> 00:00:09,579 Would it be alright if I used 2 00:00:09,580 --> 00:00:11,399 your computer to check my email? 3 00:00:11,400 --> 00:00:13,145 Yes, of course, go right ahead. 4 00:00:13,150 --> 00:00:15,605 Thanks. And would it be OK to print 5 00:00:15,606 --> 00:00:17,506 something out? Just a page or two... 6 00:00:17,510 --> 00:00:18,382 Yes, that's fine. 7 00:00:18,383 --> 00:00:20,419 It's only black and white, I'm afraid. ================================================ FILE: public/media/conversation/subtitles/gb/OALD9_GB_dialogues_9.srt ================================================ 1 00:00:06,500 --> 00:00:08,467 I don't think these clients will pay 2 00:00:08,468 --> 00:00:10,967 if the price is too high. 3 00:00:10,968 --> 00:00:12,557 Yes, but these clients are sponsored. 4 00:00:12,560 --> 00:00:14,159 They aren't paying for themselves. 5 00:00:14,160 --> 00:00:16,529 I see what you mean. 6 00:00:16,530 --> 00:00:18,287 I hadn't really appreciated that before. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_1.srt ================================================ 1 00:00:07,170 --> 00:00:09,647 Our website looks a little dated, 2 00:00:09,650 --> 00:00:11,929 don't you think? 3 00:00:11,930 --> 00:00:14,161 Absolutely, we should get a web designer 4 00:00:14,162 --> 00:00:16,548 to take a look at it. 5 00:00:16,550 --> 00:00:17,953 I think you're right. 6 00:00:17,954 --> 00:00:21,158 Let's put it on the agenda for next week. 7 00:00:21,160 --> 00:00:23,289 Definitely, that is a good idea. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_10.srt ================================================ 1 00:00:07,230 --> 00:00:09,185 Hi Carol! Congratulations on the promotion. 2 00:00:09,190 --> 00:00:10,829 I just heard this morning. 3 00:00:10,830 --> 00:00:12,041 Oh, thank you very much. 4 00:00:12,042 --> 00:00:14,624 When will you start in the new position? 5 00:00:14,625 --> 00:00:17,039 At the end of May, after Tony retires. 6 00:00:17,040 --> 00:00:19,214 Great. You will make a 7 00:00:19,215 --> 00:00:20,589 fantastic manager. Thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_11.srt ================================================ 1 00:00:07,430 --> 00:00:08,754 Excuse me, can you tell me 2 00:00:08,755 --> 00:00:10,618 how to get to Eighth Street? 3 00:00:10,620 --> 00:00:12,224 Oh sure. You go straight 4 00:00:12,225 --> 00:00:14,189 and it's the first on the - 5 00:00:14,190 --> 00:00:16,265 actually, the second on the left. - 6 00:00:16,270 --> 00:00:17,657 no, sorry, the right! 7 00:00:17,658 --> 00:00:19,739 I'm bad at left and right! 8 00:00:19,740 --> 00:00:22,091 Yeah, I'm the same. So it's the 9 00:00:22,092 --> 00:00:24,599 the second street on the right? Thanks! ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_12.srt ================================================ 1 00:00:06,870 --> 00:00:09,069 Excuse me, but can I just ask where 2 00:00:09,070 --> 00:00:10,737 we're going to publicize this? 3 00:00:10,740 --> 00:00:12,521 Well, that's something we probably need 4 00:00:12,522 --> 00:00:14,617 to look into before our next meeting. 5 00:00:14,620 --> 00:00:16,359 Yes, Jo? I was wondering if 6 00:00:16,360 --> 00:00:18,188 we have a launch date yet? 7 00:00:18,190 --> 00:00:19,529 I'll actually come back to 8 00:00:19,530 --> 00:00:21,271 that point later, when we deal 9 00:00:21,272 --> 00:00:22,659 with the project scheduling. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_13.srt ================================================ 1 00:00:06,530 --> 00:00:08,195 I think we should sell the app 2 00:00:08,196 --> 00:00:10,377 at a low cost to maximize sales. 3 00:00:10,380 --> 00:00:12,437 I don't know. If it's cheap, people 4 00:00:12,438 --> 00:00:14,169 will think it's poor quality. 5 00:00:14,170 --> 00:00:16,374 I see your point, but people won't 6 00:00:16,375 --> 00:00:18,599 pay much for an app these days. 7 00:00:18,600 --> 00:00:20,804 Well, actually, I'm not sure that's true. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_14.srt ================================================ 1 00:00:06,710 --> 00:00:08,164 Oh wow, look at the time. 2 00:00:08,165 --> 00:00:10,000 I've got to run. It has 3 00:00:10,001 --> 00:00:11,854 been so good talking to you. 4 00:00:11,860 --> 00:00:13,677 Yes, it was nice to meet you. 5 00:00:13,680 --> 00:00:16,099 See you later, I hope. Bye! Bye! ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_15.srt ================================================ 1 00:00:08,440 --> 00:00:11,193 Do you know what time it is in Australia? 2 00:00:11,200 --> 00:00:13,432 Uh, I'm not sure. Why? I'm supposed 3 00:00:13,433 --> 00:00:15,459 to call the Sydney office. 4 00:00:15,460 --> 00:00:17,944 Well, I think it's probably night time. 5 00:00:17,950 --> 00:00:20,427 I doubt they'll be at work now. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_16.srt ================================================ 1 00:00:11,550 --> 00:00:13,529 Excuse me, is Alice out of 2 00:00:13,530 --> 00:00:15,113 the office today? 3 00:00:15,120 --> 00:00:17,949 Not to my knowledge, no. 4 00:00:17,950 --> 00:00:20,355 Do you know where she is? 5 00:00:20,360 --> 00:00:22,969 I have absolutely no idea, sorry. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_17.srt ================================================ 1 00:00:07,010 --> 00:00:09,879 Do you think you'll be in DC on the 15th? 2 00:00:09,880 --> 00:00:11,314 Ah, I'm probably going to 3 00:00:11,315 --> 00:00:12,749 be in Baltimore that day, 4 00:00:12,750 --> 00:00:14,758 Will you be back by six? 5 00:00:14,760 --> 00:00:16,189 I wouldn't bet on it - 6 00:00:16,190 --> 00:00:17,881 There'll probably be a lot of 7 00:00:17,882 --> 00:00:19,638 traffic on the way home. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_18.srt ================================================ 1 00:00:06,260 --> 00:00:08,875 There's a Chinese restaurant next door, 2 00:00:08,876 --> 00:00:11,539 or Italian across the street. 3 00:00:11,540 --> 00:00:13,675 I think I'd prefer Italian myself. 4 00:00:13,680 --> 00:00:17,239 What about you? I don't really care either way. 5 00:00:17,240 --> 00:00:18,467 Let's do Italian then. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_19.srt ================================================ 1 00:00:08,320 --> 00:00:09,694 I'm afraid I'm going to have to 2 00:00:09,695 --> 00:00:10,794 miss the conference. 3 00:00:10,800 --> 00:00:13,637 My father's not doing well. 4 00:00:13,640 --> 00:00:15,855 Oh, I'm sorry to hear that. 5 00:00:15,856 --> 00:00:17,728 I hate to cancel at the last minute. 6 00:00:17,730 --> 00:00:19,289 No, I completely understand. 7 00:00:19,290 --> 00:00:20,829 Family comes first. Thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_2.srt ================================================ 1 00:00:05,940 --> 00:00:07,820 (I meant to ask you, are you going to 2 00:00:07,821 --> 00:00:09,811 the staff picnic on the weekend or are 3 00:00:09,812 --> 00:00:11,968 you going to give it a miss, do  you reckon?) 4 00:00:11,970 --> 00:00:14,033 Oh, I'm so sorry. 5 00:00:14,034 --> 00:00:15,918 I should have been looking where I was going. 6 00:00:15,920 --> 00:00:17,287 Are you alright? Yeah, 7 00:00:17,288 --> 00:00:18,655 don't worry about it. 8 00:00:18,660 --> 00:00:20,069 But you spilled some coffee. 9 00:00:20,070 --> 00:00:21,755 Can I get you another one? 10 00:00:21,760 --> 00:00:22,887 No, it's fine, really. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_20.srt ================================================ 1 00:00:05,730 --> 00:00:08,033 (... I told him to meet me at 7:00, 2 00:00:08,040 --> 00:00:09,587 but of course he was late. 3 00:00:09,590 --> 00:00:11,637 Like an hour late, he's always late!) 4 00:00:11,640 --> 00:00:12,929 What is his problem?) Excuse me, 5 00:00:12,930 --> 00:00:15,499 Could I ask you not to talk out here, please? 6 00:00:15,500 --> 00:00:17,298 We're doing some recording in this room. 7 00:00:17,300 --> 00:00:19,349 Oh sorry. We'll go down the hall then,. Thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_21.srt ================================================ 1 00:00:07,120 --> 00:00:08,985 Hi, I'm here to see the 2 00:00:08,986 --> 00:00:10,229 head of Human Resources. 3 00:00:10,230 --> 00:00:12,406 Could you wait here for a minute, 4 00:00:12,410 --> 00:00:14,269 I'll just see if she's free... 5 00:00:14,270 --> 00:00:17,145 Sure, thanks. 6 00:00:17,146 --> 00:00:19,449 OK, she can see you now. Go right on in. 7 00:00:19,450 --> 00:00:20,217 Great, thank you. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_22.srt ================================================ 1 00:00:07,080 --> 00:00:10,459 Why don't you get a tablet? 2 00:00:10,460 --> 00:00:12,379 Well, first, I use the keyboard a lot, 3 00:00:12,380 --> 00:00:15,547 and second, you can't do as much with it. 4 00:00:15,550 --> 00:00:18,048 Hmm, maybe. But I like it just because 5 00:00:18,049 --> 00:00:20,986 it's so much easier to carry around. 6 00:00:20,990 --> 00:00:22,879 Well, depends on your priorities, I guess. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_23.srt ================================================ 1 00:00:06,190 --> 00:00:08,175 Yeah, Monday's fine. OK, bye. 2 00:00:11,820 --> 00:00:13,689 I still haven't gotten my 3 00:00:13,690 --> 00:00:15,185 contract from Mr Spencer. 4 00:00:15,190 --> 00:00:16,395 Really? That's terrible. 5 00:00:16,396 --> 00:00:18,405 If I were you, 6 00:00:18,410 --> 00:00:19,669 I'd remind him about it. 7 00:00:19,670 --> 00:00:20,673 You shouldn't work without 8 00:00:20,674 --> 00:00:21,426 a signed contract. 9 00:00:21,430 --> 00:00:22,257 No, you're right. 10 00:00:22,258 --> 00:00:23,913 I think you should make an 11 00:00:23,914 --> 00:00:25,120 appointment to see him about it 12 00:00:25,121 --> 00:00:26,215 as soon as possible. 13 00:00:26,220 --> 00:00:26,978 OK, I will. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_24.srt ================================================ 1 00:00:06,770 --> 00:00:08,317 If we don't finish everything today, 2 00:00:08,320 --> 00:00:10,006 we can work on it next week 3 00:00:10,007 --> 00:00:11,669 and supply it the week aft- 4 00:00:11,670 --> 00:00:12,422 Sorry to interrupt, 5 00:00:12,423 --> 00:00:14,509 but I don't think we can do that. 6 00:00:14,510 --> 00:00:16,735 They're expecting it Monday. 7 00:00:16,740 --> 00:00:18,089 Oh, of course.... well, maybe we should get 8 00:00:18,090 --> 00:00:19,779 some extra help in that case. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_25.srt ================================================ 1 00:00:05,820 --> 00:00:07,947 (...) 2 00:00:07,948 --> 00:00:09,339 (...) Gwen, hi! 3 00:00:09,340 --> 00:00:10,909 It's been so long! 4 00:00:10,910 --> 00:00:12,469 Hi Carol,! This is Simon - 5 00:00:12,470 --> 00:00:14,965 I don't think you two have met? 6 00:00:14,970 --> 00:00:15,908 Simon, this is Carol. 7 00:00:15,909 --> 00:00:17,786 No, I don't think we've met. 8 00:00:17,790 --> 00:00:18,415 Hi Simon. 9 00:00:18,416 --> 00:00:20,293 Hi Carol, nice to meet you. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_26.srt ================================================ 1 00:00:05,920 --> 00:00:07,394 We're organizing a trip to 2 00:00:07,395 --> 00:00:08,574 Boston on Friday afternoon. 3 00:00:08,580 --> 00:00:10,637 Do you want to come with us? 4 00:00:10,640 --> 00:00:12,759 I'd love to, but I don't think 5 00:00:12,760 --> 00:00:14,767 I could make it before two o'clock. 6 00:00:14,770 --> 00:00:15,953 Is that too late? 7 00:00:15,954 --> 00:00:17,131 Oh, that's too bad - 8 00:00:17,132 --> 00:00:18,307 we're meeting at twelve. 9 00:00:18,310 --> 00:00:19,489 Oh, OK. Thanks anyway. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_27.srt ================================================ 1 00:00:16,200 --> 00:00:18,935 Hello, ABC Print here. How can I help you? 2 00:00:18,940 --> 00:00:21,363 Hi, can I please speak to Adam Talbot? 3 00:00:21,370 --> 00:00:24,096 I'm afraid he's not in. Can I take a message? 4 00:00:24,100 --> 00:00:27,139 Yes, can you ask him to call me back, please? 5 00:00:27,140 --> 00:00:29,267 It's Jo Walker. He has my number. 6 00:00:29,270 --> 00:00:31,099 OK, I'll let him know. Thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_28.srt ================================================ 1 00:00:08,400 --> 00:00:10,134 I'd like to talk to the manager, please. 2 00:00:10,135 --> 00:00:11,904 I'd like to make a complaint. 3 00:00:11,910 --> 00:00:13,835 I'm afraid she's out all day. 4 00:00:13,840 --> 00:00:15,765 Would you like to make an appointment 5 00:00:15,766 --> 00:00:16,728 for tomorrow? 6 00:00:16,730 --> 00:00:18,649 I'm sorry, that's not good enough. 7 00:00:18,650 --> 00:00:20,896 I'm leaving town this afternoon. 8 00:00:20,900 --> 00:00:23,027 I'm so sorry, can I take your number 9 00:00:23,028 --> 00:00:25,389 and ask her to call you in the morning? ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_29.srt ================================================ 1 00:00:06,780 --> 00:00:08,088 Why don't you go see one of 2 00:00:08,089 --> 00:00:09,389 the cities on the west coast? 3 00:00:09,390 --> 00:00:11,257 What would you recommend? 4 00:00:11,260 --> 00:00:12,806 Well, if you haven't been there before, 5 00:00:12,810 --> 00:00:14,649 my favorite is San Francisco. 6 00:00:14,650 --> 00:00:16,347 It's great for long weekend 7 00:00:16,348 --> 00:00:18,369 this time of year. San Francisco. 8 00:00:18,370 --> 00:00:21,879 OK, yeah, I'll look into that. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_3.srt ================================================ 1 00:00:06,660 --> 00:00:08,556 Do I have to wear this visitor 2 00:00:08,557 --> 00:00:10,437 badge even here in the cafeteria? 3 00:00:10,440 --> 00:00:13,787 Yes, I think so. And am I supposed 4 00:00:13,788 --> 00:00:15,699 to turn it in at the security desk 5 00:00:15,700 --> 00:00:17,471 if I run out for a sandwich? 6 00:00:17,472 --> 00:00:19,928 No, just turn it in at the end of the day. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_30.srt ================================================ 1 00:00:06,560 --> 00:00:08,375 Could I have a glass of water, please? 2 00:00:08,380 --> 00:00:12,031 Yes, of course. Um, sorry, it's not 3 00:00:12,032 --> 00:00:14,189 very cold. That's alright, thank you. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_31.srt ================================================ 1 00:00:06,000 --> 00:00:07,149 How about meeting up around eight 2 00:00:07,150 --> 00:00:08,879 in front of the restaurant? 3 00:00:08,880 --> 00:00:10,463 Yeah, OK. Or we could meet 4 00:00:10,464 --> 00:00:12,080 a little earlier and have 5 00:00:12,081 --> 00:00:13,592 a drink first if you want. 6 00:00:13,600 --> 00:00:15,687 Sure, should we say seven o'clock in the lobby? 7 00:00:15,690 --> 00:00:16,737 Yeah, see you there. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_32.srt ================================================ 1 00:00:09,910 --> 00:00:14,493 Oh no! Can I help you with that? 2 00:00:14,500 --> 00:00:16,707 No, that's alright. I've got to put 3 00:00:16,708 --> 00:00:18,649 everything in the right order. 4 00:00:18,650 --> 00:00:21,288 It's very kind of you to offer, though. 5 00:00:21,290 --> 00:00:22,417 You're welcome. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_33.srt ================================================ 1 00:00:06,390 --> 00:00:08,677 Can I get you a coffee or 2 00:00:08,678 --> 00:00:10,786 something else to drink before we start? 3 00:00:10,790 --> 00:00:12,667 Yes, a coffee would be nice, thank you. 4 00:00:12,670 --> 00:00:14,867 How about something to eat? 5 00:00:14,870 --> 00:00:16,747 I might get myself a muffin. 6 00:00:16,750 --> 00:00:18,639 Er no, not for me, thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_34.srt ================================================ 1 00:00:07,840 --> 00:00:09,959 Excuse me, do you mind if I join you? 2 00:00:09,960 --> 00:00:11,889 All the tables are full. 3 00:00:11,890 --> 00:00:13,329 No, of course not! Here,  4 00:00:13,330 --> 00:00:17,307 let me move my bag out of the way. 5 00:00:17,310 --> 00:00:19,229 Are you here for the exhibition? 6 00:00:19,230 --> 00:00:21,469 Yes, are you? Yes me too. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_35.srt ================================================ 1 00:00:12,520 --> 00:00:15,508 I'm going to an outdoor concert tonight. 2 00:00:15,510 --> 00:00:18,813 I wonder what'll happen if it rains. 3 00:00:18,820 --> 00:00:21,619 Hmm, they'll probably cancel it. 4 00:00:21,620 --> 00:00:25,495 But then how will everybody know? 5 00:00:25,500 --> 00:00:28,070 I don't know. I guess they'll 6 00:00:28,071 --> 00:00:29,605 put something on the website. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_36.srt ================================================ 1 00:00:06,910 --> 00:00:11,823 ...and then you just press Control S to save. 2 00:00:11,830 --> 00:00:14,489 OK, I think I've got it now. 3 00:00:14,490 --> 00:00:15,665 Thank you very much. 4 00:00:15,666 --> 00:00:17,135 I really appreciate your help - 5 00:00:17,140 --> 00:00:19,036 I get so lost with some of 6 00:00:19,037 --> 00:00:20,369 this software. That's alright. 7 00:00:20,370 --> 00:00:22,127 It's no problem. If you need 8 00:00:22,128 --> 00:00:23,899 anything else, just let me know. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_37.srt ================================================ 1 00:00:06,760 --> 00:00:10,189 See you later watch out. Oh sorry, 2 00:00:10,190 --> 00:00:14,579 I almost spilled coffee everywhere! 3 00:00:14,580 --> 00:00:15,839 That is not a good place 4 00:00:15,840 --> 00:00:17,099 to stand and chat, is it? ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_38.srt ================================================ 1 00:00:06,630 --> 00:00:08,137 I have my performance 2 00:00:08,138 --> 00:00:09,268 evaluation this afternoon. 3 00:00:09,270 --> 00:00:10,773 I am dreading it. 4 00:00:10,774 --> 00:00:12,653 Oh, I'm sure it'll be fine! 5 00:00:12,660 --> 00:00:14,167 I'll keep my fingers 6 00:00:14,168 --> 00:00:15,679 crossed for you. Thanks! ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_39.srt ================================================ 1 00:00:06,570 --> 00:00:09,173 Well, I'm afraid time is running out, 2 00:00:09,180 --> 00:00:13,299 so unless anyone else has anything to add... 3 00:00:13,300 --> 00:00:16,071 Sorry Jo, did you want to say something? 4 00:00:16,080 --> 00:00:18,004 Yes, can I just remind everybody to please 5 00:00:18,005 --> 00:00:20,099 send me your receipts for expenses? Thanks. 6 00:00:20,100 --> 00:00:21,843 OK, thanks Jo. 7 00:00:21,844 --> 00:00:23,869 Well, we'll have to leave it there. 8 00:00:23,870 --> 00:00:25,189 Have a good trip home! ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_4.srt ================================================ 1 00:00:07,180 --> 00:00:10,315 It says on the box, 'Take two tablets, 2 00:00:10,316 --> 00:00:13,716 in the morning and in the evening.' 3 00:00:13,720 --> 00:00:15,531 Well, that's confusing. 4 00:00:15,532 --> 00:00:20,109 Do they mean four a day? 5 00:00:20,110 --> 00:00:22,484 I'm sorry, what exactly are you asking? 6 00:00:22,490 --> 00:00:25,432 I mean, is it two in the morning and 7 00:00:25,433 --> 00:00:28,557 then two in the evening? Oh, I see, um... 8 00:00:33,680 --> 00:00:37,187 I don't know exactly. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_5.srt ================================================ 1 00:00:05,870 --> 00:00:07,029 (...) 2 00:00:07,030 --> 00:00:09,579 (...) Oh, excuse me. 3 00:00:09,580 --> 00:00:10,749 I wonder if you'd mind 4 00:00:10,750 --> 00:00:11,919 taking a picture of us, please? 5 00:00:11,920 --> 00:00:13,603 Sure, of course, 6 00:00:13,604 --> 00:00:16,129 Where do you want to be? 7 00:00:16,130 --> 00:00:18,054 Over here, I think. 8 00:00:18,055 --> 00:00:19,979 Do you know how to use this? 9 00:00:19,980 --> 00:00:21,447 Yeah, I think so. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_6.srt ================================================ 1 00:00:10,010 --> 00:00:11,989 Excuse me, I wonder if you can help me. 2 00:00:11,990 --> 00:00:13,795 I'm trying to find out if there's 3 00:00:13,796 --> 00:00:15,508 an art supplies store near here. 4 00:00:15,510 --> 00:00:18,689 Art supplies? I don't know. 5 00:00:18,690 --> 00:00:19,773 There's an office supplies store 6 00:00:19,774 --> 00:00:20,857 down Main Street - 7 00:00:20,860 --> 00:00:22,756 You might find what you're looking for. 8 00:00:22,760 --> 00:00:24,109 there, maybe. OK, thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_7.srt ================================================ 1 00:00:11,670 --> 00:00:12,719 Gotta go soon. 2 00:00:12,720 --> 00:00:14,819 Picking Sarah up from the airport. 3 00:00:14,820 --> 00:00:15,869 I hate airports. 4 00:00:15,870 --> 00:00:17,969 Trains are so much nicer, right? 5 00:00:17,970 --> 00:00:19,719 That depends on the train. Sometimes 6 00:00:19,720 --> 00:00:21,469 they're dirty and crowded, don't you think? 7 00:00:21,470 --> 00:00:24,487 I guess so. 8 00:00:24,490 --> 00:00:26,247 And what do you think about 9 00:00:26,248 --> 00:00:27,909 those new high-speed trains? 10 00:00:27,910 --> 00:00:28,531 Ah, they're great, 11 00:00:28,532 --> 00:00:29,775 but they're so expensive. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_8.srt ================================================ 1 00:00:08,290 --> 00:00:09,878 Would it be alright if I used 2 00:00:09,879 --> 00:00:11,219 your computer to check email? 3 00:00:11,220 --> 00:00:12,677 Yes, of course, go right ahead. 4 00:00:12,680 --> 00:00:14,743 Thanks. And would it be OK to print 5 00:00:14,744 --> 00:00:16,336 something out? Just a page or two... 6 00:00:16,340 --> 00:00:17,071 Yes, that's fine. 7 00:00:17,072 --> 00:00:18,535 It's only black and white though - 8 00:00:18,540 --> 00:00:20,599 I hope that's alright. Thanks. ================================================ FILE: public/media/conversation/subtitles/us/OALD9_US_dialogues_9.srt ================================================ 1 00:00:06,210 --> 00:00:08,549 I don't think these clients will commit 2 00:00:08,550 --> 00:00:11,088 if the price is too high. 3 00:00:11,090 --> 00:00:12,959 Actually, these clients are sponsored. 4 00:00:12,960 --> 00:00:15,959 They aren't paying for themselves. 5 00:00:15,960 --> 00:00:18,467 Oh, I see what you mean. 6 00:00:18,470 --> 00:00:19,477 I didn't know that. ================================================ FILE: public/media/exam/subtitles/1_talking_about_a_topic.srt ================================================ 1 00:00:06,590 --> 00:00:09,368 [EXAMINER] Please describe a place that you sometimes visit 2 00:00:09,369 --> 00:00:12,167 which is very important to you. 3 00:00:12,170 --> 00:00:14,871 You should say where it is, and 4 00:00:14,872 --> 00:00:17,617 how often and why you go there. 5 00:00:17,620 --> 00:00:20,489 [WOMAN] One place that is very important in 6 00:00:20,490 --> 00:00:23,867 my life is my grandmother's house. 7 00:00:23,870 --> 00:00:25,927 She lives in a small village 8 00:00:25,928 --> 00:00:28,318 in the south of the country. 9 00:00:28,320 --> 00:00:31,973 I go there every summer, and any time 10 00:00:31,974 --> 00:00:36,378 when I can get away for a few days. 11 00:00:36,380 --> 00:00:39,879 I go there to visit my grandmother, 12 00:00:39,880 --> 00:00:44,023 get away from the city and relax. 13 00:00:44,030 --> 00:00:47,263 The village is just a group of 14 00:00:47,264 --> 00:00:49,859 white houses on a hillside 15 00:00:49,860 --> 00:00:53,009 and a couple of shops, 16 00:00:53,010 --> 00:00:55,849 and it's really peaceful. 17 00:00:55,850 --> 00:00:57,574 There's no traffic, 18 00:00:57,575 --> 00:01:01,024 just the sound of goat bells, 19 00:01:01,030 --> 00:01:04,578 birds and insects. 20 00:01:04,580 --> 00:01:07,841 One reason why I like it is 21 00:01:07,842 --> 00:01:11,129 because it's a beautiful place. 22 00:01:11,130 --> 00:01:15,149 My grandmother has a lovely garden 23 00:01:15,150 --> 00:01:17,873 and we always sit outside in 24 00:01:17,874 --> 00:01:21,009 the shade of her olive trees, 25 00:01:21,010 --> 00:01:24,219 drinking sweet tea and chatting. 26 00:01:24,220 --> 00:01:26,847 It's so peaceful. 27 00:01:26,850 --> 00:01:28,960 But the main reason why this place is so 28 00:01:28,960 --> 00:01:33,716 important to me is because of my grandmother. 29 00:01:33,720 --> 00:01:37,429 She is so kind to me, and 30 00:01:37,430 --> 00:01:39,529 wonderful to talk to. 31 00:01:39,530 --> 00:01:42,369 Whenever I have a problem in my life, 32 00:01:42,370 --> 00:01:46,137 she has good advice for me. 33 00:01:46,140 --> 00:01:48,767 Also, she is a great cook 34 00:01:48,770 --> 00:01:52,159 and the meals she prepares are 35 00:01:52,160 --> 00:01:56,609 simple but so fresh and... 36 00:01:56,610 --> 00:01:59,697 rich? Er no, er... 37 00:01:59,698 --> 00:02:02,839 no, I mean tasty. 38 00:02:02,840 --> 00:02:05,579 And she's always giving me eh... 39 00:02:05,580 --> 00:02:08,319 What do you call them? 40 00:02:08,320 --> 00:02:12,596 - small plates of food during the day, 41 00:02:12,600 --> 00:02:15,360 so I always return home feeling 42 00:02:15,360 --> 00:02:19,319 calm and refreshed - and fat! 43 00:02:21,640 --> 00:02:24,229 I wouldn't like to live there, though. 44 00:02:24,230 --> 00:02:26,449 I prefer living in the city, 45 00:02:26,450 --> 00:02:30,879 definitely. I'm a city person. 46 00:02:30,880 --> 00:02:34,023 I couldn't live in a small 47 00:02:34,024 --> 00:02:36,119 village where everybody knows 48 00:02:36,217 --> 00:02:39,116 each other and nothing happens. 49 00:02:39,120 --> 00:02:44,357 I'd rather live in a busy, 50 00:02:44,360 --> 00:02:45,585 exciting place. 51 00:02:46,880 --> 00:02:49,600 But I really love visiting 52 00:02:49,600 --> 00:02:52,960 the village for the holidays. ================================================ FILE: public/media/exam/subtitles/2_discussing_opinions.srt ================================================ 1 00:00:07,550 --> 00:00:11,000 [LIU] Well, in my opinion, the best thing 2 00:00:11,000 --> 00:00:14,599 about cycling is the exercise. 3 00:00:14,600 --> 00:00:18,103 You can go from A to B and 4 00:00:18,104 --> 00:00:21,237 keep fit at the same time. 5 00:00:21,240 --> 00:00:22,920 What do you think? 6 00:00:22,920 --> 00:00:24,200 [VIKI] I agree. 7 00:00:24,200 --> 00:00:27,639 And it's quiet and clean. 8 00:00:27,640 --> 00:00:30,615 I mean, bikes don't create pollution 9 00:00:30,616 --> 00:00:33,440 like cars, and they don't use oil. 10 00:00:33,440 --> 00:00:36,834 [LIU] Hmm, yes, bikes are better for the environment. 11 00:00:36,840 --> 00:00:39,779 [VIKI] Yes, exactly. But on the other hand, 12 00:00:39,780 --> 00:00:42,983 there are bad points too. I mean, 13 00:00:42,984 --> 00:00:45,850 it can be quite dangerous, can't it? 14 00:00:45,851 --> 00:00:49,336 [LIU] Yes, that's true. Especially in 15 00:00:49,337 --> 00:00:52,909 the city with all the traffic. 16 00:00:55,080 --> 00:00:59,013 An another bad point is the weather - 17 00:00:59,020 --> 00:01:02,254 cycling isn't pleasant in 18 00:01:02,255 --> 00:01:03,560 rainy and cold conditions. 19 00:01:03,560 --> 00:01:05,120 [VIKI] Uh, uh. 20 00:01:05,120 --> 00:01:07,069 No. And it can be really 21 00:01:07,070 --> 00:01:09,548 tiring if you go up a hill. 22 00:01:09,550 --> 00:01:12,040 I don't cycle because I live in the 23 00:01:12,040 --> 00:01:16,536 mountains, and all the roads are really steep. 24 00:01:16,540 --> 00:01:19,824 If I lived in a flat place like Holland, 25 00:01:19,830 --> 00:01:22,349 I might cycle more. 26 00:01:22,350 --> 00:01:25,040 What about you? Do you cycle? 27 00:01:25,040 --> 00:01:29,320 [LIU] Sometimes, but only on holidays, 28 00:01:29,320 --> 00:01:31,800 not to go to work. 29 00:01:32,730 --> 00:01:35,159 I think the people in the 30 00:01:35,160 --> 00:01:37,329 photos are going to work. 31 00:01:39,430 --> 00:01:42,480 [VIKI] So, which place do you think looks 32 00:01:42,480 --> 00:01:45,800 more dangerous for the cyclists? 33 00:01:47,160 --> 00:01:54,379 [LIU] Well, I would say it's OK in the first picture, 34 00:01:54,380 --> 00:01:56,671 because there is a special part 35 00:01:56,672 --> 00:01:59,239 of the road for the cyclist, 36 00:01:59,240 --> 00:02:02,785 but not in the second picture - 37 00:02:02,790 --> 00:02:07,005 the cyclists are in the middle of the 38 00:02:07,006 --> 00:02:10,123 traffic, and it looks quite dangerous. 39 00:02:10,124 --> 00:02:13,590 [VIKI] Yes, and I think the man in the 40 00:02:13,591 --> 00:02:16,368 first picture is better prepared. 41 00:02:16,370 --> 00:02:20,249 He's wearing a bright colored coat 42 00:02:20,250 --> 00:02:25,080 and - what do you call it? A cycling hat? 43 00:02:25,080 --> 00:02:26,760 [LIU] A helmet, you mean? 44 00:02:26,760 --> 00:02:30,160 [VIKI] Helmet, exactly. The people in the 45 00:02:30,160 --> 00:02:32,000 second picture haven't got helmets. 46 00:02:32,000 --> 00:02:33,869 [LIU] Hmm. 47 00:02:33,970 --> 00:02:36,708 [VIKI] But there again, 48 00:02:36,710 --> 00:02:38,360 it could be more dangerous 49 00:02:38,360 --> 00:02:41,092 in the first picture, 50 00:02:41,100 --> 00:02:43,885 because the traffic is going much faster, 51 00:02:43,890 --> 00:02:45,520 isn't it? 52 00:02:45,520 --> 00:02:49,399 [LIU] Hmm, yes, I suppose so. 53 00:02:49,400 --> 00:02:54,049 But there is plenty of space. 54 00:02:54,050 --> 00:02:56,809 Hmm, on the whole, I think it's more dangerous 55 00:02:56,810 --> 00:02:58,600 in the second picture. 56 00:02:58,600 --> 00:03:00,419 [VIKI] Yeah. 57 00:03:00,420 --> 00:03:02,620 Yeah, I think you're probably right. ================================================ FILE: public/media/exam/subtitles/3_negotiating.srt ================================================ 1 00:00:06,320 --> 00:00:08,927 OK, so we have to choose one picture 2 00:00:08,928 --> 00:00:11,854 to put on the front of the brochure. 3 00:00:13,990 --> 00:00:15,009 Shall we get started, then? 4 00:00:17,650 --> 00:00:19,579 Er, let's see... 5 00:00:22,190 --> 00:00:25,318 It's really difficult. 6 00:00:25,320 --> 00:00:28,196 Personally, if I were choosing a holiday, 7 00:00:28,200 --> 00:00:31,511 I think I would pick up a brochure with 8 00:00:31,512 --> 00:00:35,208 a picture of the dancers on the front, 9 00:00:35,210 --> 00:00:38,086 but that's because I enjoy folk dancing. 10 00:00:38,090 --> 00:00:40,543 Maybe that would not be so 11 00:00:40,544 --> 00:00:42,619 good for most people. 12 00:00:42,620 --> 00:00:45,799 No, possibly not. To be honest, 13 00:00:45,800 --> 00:00:47,747 that one looks boring. 14 00:00:47,748 --> 00:00:51,563 If you ask me, we should choose one 15 00:00:51,564 --> 00:00:54,359 which most people would find attractive. 16 00:00:54,360 --> 00:00:58,335 How about the photo of the food? 17 00:00:58,340 --> 00:00:59,767 Everybody likes eating! 18 00:00:59,768 --> 00:01:02,619 That's true, but I don't think 19 00:01:02,620 --> 00:01:04,994 Scotland is famous for food. 20 00:01:05,000 --> 00:01:08,807 I think if people wanted a food holiday, 21 00:01:08,810 --> 00:01:11,189 they would choose Italy or 22 00:01:11,190 --> 00:01:13,569 France or somewhere like that. 23 00:01:13,570 --> 00:01:17,309 OK, that's a good point. 24 00:01:17,310 --> 00:01:21,395 It's the same with the picture of the beach - 25 00:01:21,400 --> 00:01:22,355 it's beautiful, 26 00:01:22,356 --> 00:01:25,701 but I've never heard of people going 27 00:01:25,702 --> 00:01:28,899 to Scotland for a beach holiday. 28 00:01:28,900 --> 00:01:30,679 From what I've heard, 29 00:01:30,680 --> 00:01:32,904 it's always raining in Britain. 30 00:01:32,910 --> 00:01:34,247 Yes, that's right, 31 00:01:34,248 --> 00:01:37,369 everybody says that the weather's terrible there. 32 00:01:39,860 --> 00:01:42,385 Well, ...we could use the picture 33 00:01:42,386 --> 00:01:44,069 of the mountain climbers. 34 00:01:44,070 --> 00:01:47,723 It would give people the idea of 35 00:01:47,724 --> 00:01:51,067 things you can do in Scotland. 36 00:01:51,070 --> 00:01:55,990 What that would be very popular? I doubt it. 37 00:01:55,991 --> 00:02:01,209 Maybe we should focus on history and culture. 38 00:02:01,210 --> 00:02:03,687 What's happening in the first picture, 39 00:02:03,690 --> 00:02:09,039 for example? I've no idea. 40 00:02:09,040 --> 00:02:13,687 It looks like a carnival or something. 41 00:02:13,690 --> 00:02:17,136 But I think I would opt for the castle. 42 00:02:17,140 --> 00:02:20,195 To me, that's a typical image of Scotland, 43 00:02:20,200 --> 00:02:22,663 and I can imagine it would look 44 00:02:22,664 --> 00:02:25,559 great on the cover of the brochure. 45 00:02:25,560 --> 00:02:27,095 What do you think? 46 00:02:27,096 --> 00:02:29,969 Well, as far as I'm concerned, 47 00:02:29,970 --> 00:02:35,345 the carnival or the castle would be fine. 48 00:02:35,350 --> 00:02:39,515 So, shall we use the castle then? 49 00:02:39,520 --> 00:02:44,507 Yes, OK. That's fine by me. 50 00:02:44,510 --> 00:02:47,827 Right, so I think we're agreed. 51 00:02:47,830 --> 00:02:49,726 We'll use the photo of the castle. ================================================ FILE: public/media/exam/subtitles/4_describing_a_picture.srt ================================================ 1 00:00:06,540 --> 00:00:08,131 Please describe this photograph. 2 00:00:08,132 --> 00:00:10,519 What do you think is happening 3 00:00:10,589 --> 00:00:12,737 and what is your opinion about it? 4 00:00:12,740 --> 00:00:16,639 This picture shows two young men 5 00:00:16,640 --> 00:00:19,589 chatting on a park bench. 6 00:00:19,590 --> 00:00:21,575 They are wearing casual clothes 7 00:00:21,576 --> 00:00:24,223 and training shoes. 8 00:00:24,230 --> 00:00:26,959 One of them has glasses. 9 00:00:26,960 --> 00:00:29,959 It is a cloudy day. 10 00:00:29,960 --> 00:00:31,144 In the background, 11 00:00:31,145 --> 00:00:33,514 we can see trees and grass. 12 00:00:35,710 --> 00:00:37,809 There are a lot of red and orange 13 00:00:37,810 --> 00:00:39,549 leaves on the ground, 14 00:00:39,550 --> 00:00:43,799 so I would say it's autumn in a cold country, 15 00:00:43,800 --> 00:00:46,067 perhaps in the north of Europe, 16 00:00:46,070 --> 00:00:49,659 or maybe in North America. 17 00:00:49,660 --> 00:00:52,431 There is a pram to the left of the bench 18 00:00:52,432 --> 00:00:55,048 and another one to the right. 19 00:00:55,050 --> 00:00:58,262 I would guess that they're babies in the prams, 20 00:00:58,270 --> 00:01:01,575 although we can't actually see them. 21 00:01:01,580 --> 00:01:03,689 And I suppose that the 22 00:01:03,690 --> 00:01:05,799 young men are the fathers. 23 00:01:05,800 --> 00:01:08,859 The picture is slightly unusual, 24 00:01:08,860 --> 00:01:11,265 because we normally expect to see 25 00:01:11,266 --> 00:01:12,869 mothers looking after babies. 26 00:01:12,870 --> 00:01:14,627 In most parts of the world, 27 00:01:14,630 --> 00:01:16,399 childcare is traditionally the mother's job. 28 00:01:18,510 --> 00:01:21,819 But I think the idea for this photo is good, 29 00:01:21,820 --> 00:01:23,479 and because in my opinion, 30 00:01:23,480 --> 00:01:25,704 both parents should have equal 31 00:01:25,705 --> 00:01:27,484 responsibility for the children. 32 00:01:27,490 --> 00:01:28,665 The picture 33 00:01:28,666 --> 00:01:32,781 also makes us think about another question. 34 00:01:32,790 --> 00:01:35,218 It is the middle of the day. 35 00:01:35,220 --> 00:01:37,835 Why aren't the men at work? 36 00:01:37,840 --> 00:01:40,503 It could be the weekend, of course, 37 00:01:40,504 --> 00:01:43,663 or perhaps a public holiday. 38 00:01:43,670 --> 00:01:47,039 But there is another possibility. 39 00:01:47,040 --> 00:01:49,373 Perhaps the men look after their 40 00:01:49,374 --> 00:01:51,317 homes and the children full time, 41 00:01:51,318 --> 00:01:53,529 and their wives go to work. 42 00:01:53,530 --> 00:01:55,819 We normally talk about housewives, 43 00:01:55,820 --> 00:02:00,418 but in this case maybe they are house husbands. 44 00:02:00,420 --> 00:02:02,079 I know this is unusual, 45 00:02:02,080 --> 00:02:03,249 but why not? 46 00:02:03,250 --> 00:02:04,809 I love that idea. ================================================ FILE: public/media/exam/subtitles/5_giving_personal_information.srt ================================================ 1 00:00:06,600 --> 00:00:08,213 Hello, I'm Martin. 2 00:00:08,214 --> 00:00:10,365 What's your name please? 3 00:00:10,370 --> 00:00:13,549 I'm Cristina. And where are you 4 00:00:13,550 --> 00:00:16,055 from, Cristina? I'm from Spain. 5 00:00:16,056 --> 00:00:19,119 Oh, really? Whereabouts in Spain? 6 00:00:19,120 --> 00:00:22,809 From a town called Iznalloz. 7 00:00:22,810 --> 00:00:25,485 It's near the city of Granada. 8 00:00:25,490 --> 00:00:28,191 Oh right. And can you tell me a 9 00:00:28,192 --> 00:00:30,417 little bit about what you do? 10 00:00:30,420 --> 00:00:35,855 Yes, I am a student. I am 11 00:00:35,856 --> 00:00:39,407 studying economics at university. 12 00:00:39,410 --> 00:00:42,355 I also have a part-time 13 00:00:42,356 --> 00:00:44,319 job in a hairdresser's. 14 00:00:44,320 --> 00:00:46,909 And where do you live? 15 00:00:46,910 --> 00:00:50,077 Actually, I live in two places. 16 00:00:50,080 --> 00:00:52,963 During the week, when there are classes, 17 00:00:52,970 --> 00:00:56,336 I live with an aunt in Granada, 18 00:00:56,340 --> 00:00:58,907 near to the university, 19 00:00:58,910 --> 00:01:02,373 and at the weekends I usually go home 20 00:01:02,374 --> 00:01:06,166 to my parents' place in Iznalloz. 21 00:01:06,170 --> 00:01:09,967 But next year I hope to rent a flat 22 00:01:09,968 --> 00:01:12,598 together with a friend of mine. 23 00:01:12,600 --> 00:01:16,666 OK. And how long have you been 24 00:01:16,667 --> 00:01:19,898 studying English. Oh for a long time. 25 00:01:19,900 --> 00:01:22,819 I have been studying English 26 00:01:22,820 --> 00:01:25,155 at school since primary, 27 00:01:25,160 --> 00:01:28,834 and I have also done evening classes 28 00:01:28,840 --> 00:01:31,167 at a language school. 29 00:01:31,170 --> 00:01:35,189 Nowadays, I try to practise by 30 00:01:35,190 --> 00:01:38,739 making friends on social media 31 00:01:38,740 --> 00:01:40,871 and chatting in English. 32 00:01:40,872 --> 00:01:44,069 How important do you think English 33 00:01:44,169 --> 00:01:48,469 will be in your career? Very important. 34 00:01:48,470 --> 00:01:52,039 When I graduate, I hope to apply for a job 35 00:01:52,040 --> 00:01:55,527 in a multinational company. 36 00:01:55,530 --> 00:01:58,799 I want to work abroad, 37 00:01:58,800 --> 00:02:03,629 in Britain or maybe another country, 38 00:02:03,630 --> 00:02:05,109 you know. 39 00:02:05,110 --> 00:02:10,285 I would really love to see new places, 40 00:02:10,290 --> 00:02:13,049 meet new people, see the world. 41 00:02:13,050 --> 00:02:16,919 I've never been outside Spain before, 42 00:02:16,920 --> 00:02:18,497 it's too expensive, 43 00:02:18,498 --> 00:02:21,653 But I've always wanted to travel. ================================================ FILE: public/media/exam/subtitles/6_job_interview.srt ================================================ 1 00:00:06,860 --> 00:00:08,395 Good morning, Mr Mason. 2 00:00:08,396 --> 00:00:10,699 Take a seat, please. Thank you. 3 00:00:12,740 --> 00:00:14,706 Now, I can see from your CV 4 00:00:14,707 --> 00:00:16,317 that you graduated from Bristol 5 00:00:16,318 --> 00:00:18,423 University just over a year ago. 6 00:00:18,430 --> 00:00:19,290 Yes, that's right. 7 00:00:19,291 --> 00:00:21,711 So, can you tell me a little bit 8 00:00:21,712 --> 00:00:24,112 about what you've been doing since then? 9 00:00:24,120 --> 00:00:25,191 Yes, first of all, 10 00:00:25,192 --> 00:00:27,944 I got a summer job as a group leader 11 00:00:27,945 --> 00:00:30,436 at a children's summer camp in Canada. 12 00:00:30,440 --> 00:00:31,699 That's something that I'd 13 00:00:31,700 --> 00:00:32,959 previously done in France, 14 00:00:32,960 --> 00:00:36,067 and I've always enjoyed working with kids. 15 00:00:36,070 --> 00:00:37,381 When that was over, 16 00:00:37,382 --> 00:00:39,733 I came back to London and got 17 00:00:39,734 --> 00:00:41,875 a job as a studio assistant in 18 00:00:41,876 --> 00:00:44,099 a local community TV station. 19 00:00:44,100 --> 00:00:46,049 What were your duties there? 20 00:00:46,050 --> 00:00:48,257 Well, basically I did everything, you know, 21 00:00:48,258 --> 00:00:50,611 from taking calls to responding 22 00:00:50,612 --> 00:00:53,047 to emails and maintaining the website, 23 00:00:53,050 --> 00:00:55,709 right the way through to interviewing and 24 00:00:55,710 --> 00:00:58,488 helping out with the filming and editing. 25 00:00:58,490 --> 00:01:01,721 It's a small company so I got a 26 00:01:01,722 --> 00:01:05,466 chance to try a little bit of everything. 27 00:01:05,470 --> 00:01:08,109 What did you enjoy most? 28 00:01:08,110 --> 00:01:11,182 I guess the interviewing. I think I'm 29 00:01:11,183 --> 00:01:13,909 quite outgoing, so I love meeting 30 00:01:13,910 --> 00:01:16,561 people and finding out about them. 31 00:01:16,570 --> 00:01:20,665 I see, right. So what about the future? 32 00:01:20,670 --> 00:01:25,655 Where do you see yourself in five years' time? 33 00:01:25,660 --> 00:01:29,268 I'd like to work for a larger TV network, 34 00:01:29,270 --> 00:01:31,274 perhaps as an interviewer for 35 00:01:31,275 --> 00:01:32,477 a regular programme, 36 00:01:32,480 --> 00:01:34,494 either interviewing guests in the 37 00:01:34,495 --> 00:01:37,063 studio or going out and about, 38 00:01:37,064 --> 00:01:39,478 interviewing members of the public. 39 00:01:39,480 --> 00:01:41,838 And what made you decide to apply 40 00:01:41,839 --> 00:01:43,559 for a job with us? 41 00:01:43,560 --> 00:01:46,079 I think this is a well-known company 42 00:01:46,080 --> 00:01:48,317 that's got a very good reputation, 43 00:01:48,320 --> 00:01:50,425 so I felt it would be the ideal place 44 00:01:50,426 --> 00:01:52,836 to develop my skills, and hopefully 45 00:01:52,837 --> 00:01:54,524 contribute something with my 46 00:01:54,525 --> 00:01:56,479 enthusiasm and willingness to learn. 47 00:01:56,480 --> 00:01:59,199 OK, well, thank you for coming in today. 48 00:01:59,200 --> 00:02:02,013 Obviously, we have a few more candidates 49 00:02:02,014 --> 00:02:04,854 to consider, so we will be making a 50 00:02:04,855 --> 00:02:07,365 selection in a week or so, and we'll 51 00:02:07,366 --> 00:02:10,184 let you know one way or the other. 52 00:02:10,185 --> 00:02:10,969 OK, thanks. ================================================ FILE: public/media/exam/subtitles/7_giving_a_presentation.srt ================================================ 1 00:00:05,790 --> 00:00:08,190 So I'm here today to present you 2 00:00:08,191 --> 00:00:10,347 with the results of that survey. 3 00:00:10,350 --> 00:00:13,415 Now to do this, I've divided my 4 00:00:13,416 --> 00:00:15,279 presentation into three parts. 5 00:00:15,280 --> 00:00:17,264 Firstly, I'll talk about the 6 00:00:17,265 --> 00:00:18,852 background to the research, 7 00:00:18,860 --> 00:00:21,091 then we'll look at how we 8 00:00:21,092 --> 00:00:23,239 approach the research and thirdly 9 00:00:23,240 --> 00:00:25,224 I'll summarize the key findings 10 00:00:25,225 --> 00:00:26,415 of the research. 11 00:00:26,420 --> 00:00:29,749 So let's start with a brief 12 00:00:29,750 --> 00:00:31,969 description of the background. 13 00:00:31,970 --> 00:00:35,147 So once we selected the 90,000 customers 14 00:00:35,148 --> 00:00:37,618 across your company's three regions, 15 00:00:37,620 --> 00:00:40,997 we then emailed them the survey. 16 00:00:41,000 --> 00:00:43,519 We allowed four weeks for them to 17 00:00:43,520 --> 00:00:46,209 respond to the surveys and after that 18 00:00:46,210 --> 00:00:49,171 period we received a 4% response rate. 19 00:00:49,172 --> 00:00:52,297 Now this is quite good for 20 00:00:52,298 --> 00:00:54,468 this type of survey. 21 00:00:54,470 --> 00:00:57,103 We then analyzed those responses and 22 00:00:57,104 --> 00:01:00,138 compiled them in a report for you. 23 00:01:00,140 --> 00:01:01,854 Let's look at those responses 24 00:01:01,855 --> 00:01:03,569 in a bit more detail. 25 00:01:03,570 --> 00:01:08,273 Now, of that 4%, this blue section 26 00:01:08,280 --> 00:01:10,909 shows that 57% of those 27 00:01:10,910 --> 00:01:13,539 responses came from the region 28 00:01:13,540 --> 00:01:16,234 with the difficulties. That's significant, 29 00:01:16,235 --> 00:01:20,177 as you are more likely to receive 30 00:01:20,178 --> 00:01:23,627 responses from customers who are either 31 00:01:23,628 --> 00:01:26,639 very satisfied or very unsatisfied. 32 00:01:26,640 --> 00:01:26,947 Anyway, 33 00:01:26,948 --> 00:01:29,411 this leads me on to the third and 34 00:01:29,412 --> 00:01:31,398 main part of my presentation: 35 00:01:31,400 --> 00:01:35,107 the actual results of the survey. 36 00:01:35,110 --> 00:01:36,839 Now in front of you, 37 00:01:36,840 --> 00:01:39,247 there's a copy of the survey's findings. 38 00:01:44,580 --> 00:01:46,919 I'm going to summarize the key 39 00:01:46,920 --> 00:01:49,789 findings for each of these questions. 40 00:01:49,790 --> 00:01:52,387 So let's begin with question one, 41 00:01:52,390 --> 00:01:55,532 which asks your customers how likely they 42 00:01:55,533 --> 00:01:58,897 are to recommend your company to a friend. 43 00:01:58,900 --> 00:02:02,724 As you can see, we have used a scale, 44 00:02:02,725 --> 00:02:06,246 with zero being not at all likely 45 00:02:06,247 --> 00:02:09,069 and ten being extremely likely. 46 00:02:09,070 --> 00:02:11,861 According to this chart, 47 00:02:11,862 --> 00:02:14,653 the overall response indicates 48 00:02:14,654 --> 00:02:17,947 that customers are fairly likely 49 00:02:17,950 --> 00:02:19,937 to recommend your company. 50 00:02:21,950 --> 00:02:25,159 However, this chart represents an average 51 00:02:25,160 --> 00:02:28,019 response across the three regions. 52 00:02:28,020 --> 00:02:31,439 If we break these responses down 53 00:02:31,440 --> 00:02:35,225 into the three regions like this, 54 00:02:35,230 --> 00:02:38,459 it illustrates the difference in 55 00:02:38,460 --> 00:02:41,689 customer satisfaction region by region. 56 00:02:41,690 --> 00:02:45,913 Now remember, red and yellow are the regions 57 00:02:45,914 --> 00:02:48,908 with increasing numbers of customers. 58 00:02:48,910 --> 00:02:52,809 Consequently, the responses are quite high, 59 00:02:52,810 --> 00:02:56,609 even as high as ten, 60 00:02:56,610 --> 00:02:58,967 but the lower scores in the 61 00:02:58,968 --> 00:03:01,429 previous chart were caused by the 62 00:03:01,430 --> 00:03:03,459 decline in these blue responses here. 63 00:03:03,460 --> 00:03:05,745 They go down to the 64 00:03:05,746 --> 00:03:07,889 zero level in some cases. ================================================ FILE: public/styles/style.css ================================================ @font-face { font-family: Inter; src: url("../fonts/InterVariable.woff2"); font-style: normal; font-weight: 300 900; font-display: swap; } @font-face { font-family: Inter; src: url("../fonts/InterVariable-Italic.woff2"); font-style: italic; font-weight: 300 900; font-display: swap; } @font-face { font-family: "Noto Sans SC"; src: url("../fonts/NotoSansSC-VariableFont_wght.woff2"); font-style: normal; font-weight: 300 900; font-display: swap; } html, body { font-family: "Inter", system-ui, -apple-system, Roboto, "Segoe UI", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; font-variation-settings: "opsz" 15; font-feature-settings: "cv02", "cv03", "cv04", "cv05", "cv08", "cv10", "cv11"; } /* Specific font for Chinese text */ :lang(zh) { font-family: "Noto Sans SC", system-ui, sans-serif; } ================================================ FILE: scripts/gh_releases_body.py ================================================ import requests import os import json import sys # Get environment variables with fallbacks repo = "learnercraft/ispeakerreact" # release_id = os.environ.get("RELEASE_ID") # Define the GitHub API URL and headers # if release_id: # url = f"https://api.github.com/repos/{repo}/releases/{release_id}" # else: # Fallback to latest release if no specific release ID is provided url = f"https://api.github.com/repos/{repo}/releases/latest" token = os.environ.get("GITHUB_TOKEN") headers = { "User-Agent": "release-updater-action", "Accept": "application/vnd.github.v3+json", } # Add authorization header if token is available if token: headers["Authorization"] = f"token {token}" # Fetch the release data try: response = requests.get(url, headers=headers) response.raise_for_status() # Raise exception for 4XX/5XX responses release_data = response.json() # Debug: Print the keys in the response #print(f"DEBUG: Response keys: {list(release_data.keys())}", file=sys.stderr) except requests.exceptions.RequestException as e: print(f"ERROR: Failed to fetch release data: {e}", file=sys.stderr) print(f"Response status code: {response.status_code}", file=sys.stderr) print(f"Response content: {response.text}", file=sys.stderr) sys.exit(1) # Extract necessary details try: tag = release_data.get("tag_name") if not tag: print(f"ERROR: No tag_name in response data", file=sys.stderr) sys.exit(1) name = release_data.get("name", tag) assets = release_data.get("assets", []) if not assets: print("WARNING: No assets found in the release", file=sys.stderr) # For the changelog URL, handle possible tag format variations base_tag = tag.split("-")[0] if "-" in tag else tag changelog_url = f"https://github.com/{repo}/compare/{base_tag}...{tag}" except KeyError as e: print(f"ERROR: Missing required field in response data: {e}", file=sys.stderr) print(f"Full response: {json.dumps(release_data, indent=2)}", file=sys.stderr) sys.exit(1) # Organize assets by platform windows_downloads = [] macos_downloads = [] linux_downloads = [] for asset in assets: asset_name = asset["name"] download_url = asset["browser_download_url"] # Windows if asset_name.startswith("iSpeakerReact-win32-x64") and asset_name.endswith(".zip"): windows_downloads.append(f"- [Windows x64 ZIP]({download_url})") elif asset_name.startswith("iSpeakerReact-win32-arm64") and asset_name.endswith(".zip"): windows_downloads.append(f"- [Windows ARM64 ZIP]({download_url})") # macOS elif asset_name.startswith("iSpeakerReact-darwin-x64") and asset_name.endswith(".zip"): macos_downloads.append(f"- [macOS Intel (x64) ZIP]({download_url})") elif asset_name.startswith("iSpeakerReact-darwin-arm64") and asset_name.endswith(".zip"): macos_downloads.append(f"- [macOS Apple Silicon (arm64) ZIP]({download_url})") # Linux elif asset_name.startswith("iSpeakerReact-linux-x64") and asset_name.endswith(".zip"): linux_downloads.append(f"- [Linux x64 ZIP]({download_url})") elif asset_name.endswith(".deb"): linux_downloads.append(f"- [DEB package]({download_url})") elif asset_name.endswith(".rpm"): linux_downloads.append(f"- [RPM package]({download_url})") # Build markdown markdown = f"""## Download ### Windows {chr(10).join(windows_downloads)} This app requires Windows 10 or later. ### macOS {chr(10).join(macos_downloads)} ### Linux {chr(10).join(linux_downloads)} There is no AppImage version for this app yet. ## App updates /* Please add some here manually */ **Full Changelog**: {changelog_url} """ print(markdown.strip()) ================================================ FILE: scripts/minify-dist-jsons.js ================================================ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const minifyJsonFiles = (dir) => { if (!fs.existsSync(dir)) return; fs.readdirSync(dir, { withFileTypes: true }).forEach((entry) => { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { minifyJsonFiles(fullPath); } else if (entry.isFile() && entry.name.endsWith(".json")) { const content = fs.readFileSync(fullPath, "utf8"); try { const minified = JSON.stringify(JSON.parse(content)); fs.writeFileSync(fullPath, minified, "utf8"); console.log(`Minified: ${fullPath}`); } catch (e) { console.error(`Failed to minify: ${fullPath}`, e); } } }); }; // Minify dist/json minifyJsonFiles(path.join(__dirname, "../dist/json")); // Minify dist/locales minifyJsonFiles(path.join(__dirname, "../dist/locales")); // Minify data/ //minifyJsonFiles(path.join(__dirname, "../data")); ================================================ FILE: src/App.jsx ================================================ import { Suspense, lazy } from "react"; import { BrowserRouter, HashRouter, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; import LoadingOverlay from "./components/general/LoadingOverlay"; import NotFound from "./components/general/NotFound"; import Homepage from "./components/Homepage"; import ErrorBoundary from "./ErrorBoundary"; import isElectron from "./utils/isElectron"; import ThemeProvider from "./utils/ThemeContext/ThemeProvider"; import { useTheme } from "./utils/ThemeContext/useTheme"; import VersionUpdateDialog from "./components/general/VersionUpdateDialog"; import { useState, useEffect } from "react"; const SoundList = lazy(() => import("./components/sound_page/SoundList")); const WordList = lazy(() => import("./components/word_page/WordList")); const ConversationMenu = lazy(() => import("./components/conversation_page/ConversationMenu")); const ExamPage = lazy(() => import("./components/exam_page/ExamPage")); const ExercisePage = lazy(() => import("./components/exercise_page/ExercisePage")); const SettingsPage = lazy(() => import("./components/setting_page/Settings")); const DownloadPage = lazy(() => import("./components/download_page/DownloadPage")); const RouterComponent = isElectron() ? HashRouter : BrowserRouter; const PROD_BASE_URL = "https://learnercraft.github.io/ispeakerreact"; const isProdWeb = import.meta.env.PROD && !isElectron() && window.location.href.startsWith(PROD_BASE_URL); // Ensure baseUrl does not add unnecessary slashes const baseUrl = isElectron() ? "" : (() => { switch (import.meta.env.BASE_URL) { case "/": case "./": return ""; // Use no basename for "/" or "./" default: return import.meta.env.BASE_URL; } })(); // Clear web cache if a newer version is found const AppContent = () => { const [showUpdateDialog, setShowUpdateDialog] = useState(false); const { theme } = useTheme(); const toastTheme = theme === "dark" || (theme === "auto" && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light"; useEffect(() => { if (isProdWeb) { setShowUpdateDialog(true); } else { console.warn("Dev mode or Electron version detected. Version check skipped."); } }, []); return ( <> window.location.reload()} /> } /> }> } /> }> } /> }> } /> }> } /> }> } /> }> } /> }> } /> } /> ); }; const App = () => ( ); export default App; ================================================ FILE: src/ErrorBoundary.jsx ================================================ import { useTranslation } from "react-i18next"; import ErrorBoundaryInner from "./ErrorBoundaryInner"; const ErrorBoundary = (props) => { const { t } = useTranslation(); return ; }; export default ErrorBoundary; ================================================ FILE: src/ErrorBoundaryInner.jsx ================================================ import PropTypes from "prop-types"; import React from "react"; import { FaGithub } from "react-icons/fa"; import { FiRefreshCw } from "react-icons/fi"; import { HiOutlineClipboardCopy } from "react-icons/hi"; import { Toaster } from "sonner"; import Container from "./ui/Container"; import openExternal from "./utils/openExternal"; import { sonnerSuccessToast } from "./utils/sonnerCustomToast"; export default class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null, errorInfo: null }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { console.error("React Error Boundary caught an error:", error, errorInfo); this.setState({ errorInfo }); } handleCopy = () => { const { error, errorInfo } = this.state; const fullError = `Error log:\n \`\`\` App version: v${__APP_VERSION__}\n${error?.toString()}\n\nStack Trace:\n${errorInfo?.componentStack} \`\`\` `; navigator.clipboard.writeText(fullError).then(() => { sonnerSuccessToast(this.props.t("toast.appCrashCopySuccess")); }); }; handleRefresh = () => location.reload(); render() { const { hasError, error, errorInfo } = this.state; const { t } = this.props; if (hasError) { return (

🚨 {t("appCrash.appCrashedTitle")}

{t("appCrash.appCrashedDesc")}

App version: v{__APP_VERSION__} {"\n"} {error?.toString()} {"\n"} {errorInfo?.componentStack}
); } return this.props.children; } } ErrorBoundary.propTypes = { t: PropTypes.func.isRequired, children: PropTypes.node.isRequired, }; ================================================ FILE: src/index.jsx ================================================ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "../i18n"; import App from "./App"; import "./styles/index.css"; const root = createRoot(document.getElementById("root")); root.render( ); ================================================ FILE: src/components/Homepage.jsx ================================================ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Container from "../ui/Container"; import isElectron from "../utils/isElectron"; import Footer from "./general/Footer"; import LogoLightOrDark from "./general/LogoLightOrDark"; import TopNavBar from "./general/TopNavBar"; const Homepage = () => { const { t } = useTranslation(); const navigate = useNavigate(); const handleNavigate = (path) => { navigate(path); }; useEffect(() => { if (isElectron()) { document.title = `iSpeakerReact v${__APP_VERSION__}`; } else { document.title = `${t("navigation.home")} | iSpeakerReact v${__APP_VERSION__}`; } }, [t]); const cardsInfo = [ { title: `${t("navigation.sounds")}`, description: `${t("homepage.soundDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/sound_menu_icon.svg`, path: "sounds", }, { title: `${t("navigation.words")}`, description: `${t("homepage.wordDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/word_menu_icon.svg`, path: "words", }, { title: `${t("navigation.exercises")}`, description: `${t("homepage.exerciseDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/exercise_menu_icon.svg`, path: "exercises", }, { title: `${t("navigation.conversations")}`, description: `${t("homepage.conversationDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/conversation_menu_icon.svg`, path: "conversations", }, { title: `${t("navigation.exams")}`, description: `${t("homepage.examDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/exam_menu_icon.svg`, path: "exams", }, { title: `${t("navigation.settings")}`, description: `${t("homepage.settingDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/settings_menu_icon.svg`, path: "settings", }, { title: `${t("navigation.download")}`, description: `${t("homepage.downloadDescription")}`, icon: `${import.meta.env.BASE_URL}images/ispeaker/menu/download_menu_icon.svg`, path: "download", hideForElectron: true, }, ]; useEffect(() => { window.scrollTo(0, 0); }, []); return ( <>

iSpeakerReact

v{__APP_VERSION__}

{cardsInfo .filter((card) => !(isElectron() && card.hideForElectron)) .map((card, idx) => (
{`${card.title}

{card.title}

{card.description}

))}