diff --git a/.github/workflows/macos-test.yml b/.github/workflows/macos-test.yml new file mode 100644 index 0000000..5dfa46c --- /dev/null +++ b/.github/workflows/macos-test.yml @@ -0,0 +1,53 @@ +name: macOS custom test +run-name: macOS custom test +on: + workflow_dispatch: + inputs: + samplesUrl: + description: 'samples zip URL (if any)' + vocabUrl: + description: 'vocab.txt URL (if any)' + progressUrl: + description: 'progress.txt URL (if any)' + L1: + description: 'first language' + default: 'en' + L2: + description: 'second language' + default: 'zh' + maxNew: + description: 'max new words' + default: 5 +jobs: + Test-Run: + runs-on: macos-latest + steps: + - name: Install sound tools + run: brew install sox lame + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up input + run: | + set -e + cd ${{ github.workspace }} + make gradint.py || true # missing python2 not an issue + mv hanzi-prompts/*.txt samples/prompts/ + if [ "${{ inputs.samplesUrl }}" ]; then curl -L "${{ inputs.samplesUrl }}" > samples.zip; cd samples ; unzip -o ../samples.zip ; cd .. ; fi + if [ "${{ inputs.vocabUrl }}" ] ; then curl -L "${{ inputs.vocabUrl }}" > vocab.txt ; fi + if [ "${{ inputs.progressUrl }}" ] ; then curl -L "${{ inputs.progressUrl }}" > progress.txt ; fi + echo firstLanguage = '"'${{ inputs.L1 }}'"' > settings.txt + echo secondLanguage = '"'${{ inputs.L2 }}'"' >> settings.txt + echo maxNewWords = ${{ inputs.maxNew }} >> advanced.txt + echo 'outputFile="lesson.mp3"' >> advanced.txt + - name: Make MP3 + run: | + set -e + cd ${{ github.workspace }} + python3 gradint.py + mkdir out + mv progress.txt lesson.mp3 out/ + - name: Upload output + uses: actions/upload-artifact@v4 + with: + name: output + path: ${{ github.workspace }}/out/ diff --git a/.gitignore b/.gitignore index b25c15b..b048b75 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *~ +__pycache__ +src/defaults.py diff --git a/INSTALL.txt b/INSTALL.txt deleted file mode 100644 index bad4f89..0000000 --- a/INSTALL.txt +++ /dev/null @@ -1,49 +0,0 @@ -Installing Gradint on GNU/Linux systems ---------------------------------------- - -Gradint does not need to be installed, it can -just run from the current directory. - -If you do want to make a system-wide installation -(for example if you want to make a package for a -GNU/Linux distribution), I suggest doing the -following as root: - -mkdir /usr/share/gradint -cp gradint.py /usr/share/gradint/ -cd samples/utils -for F in *.py *.sh; do - DestFile=/usr/bin/gradint-$(echo $F|sed -e 's/\..*//') - cp $F $DestFile - chmod +x $DestFile -done -cd ../.. ; rm -rf samples/utils -tar -zcf /usr/share/gradint/new-user.tgz \ - advanced.txt settings.txt vocab.txt samples -cat > /usr/bin/gradint < gradint.bgz rm -rf gradint @@ -231,15 +232,15 @@ publish: $(All_Versions) gradint.py cp samples/prompts/README.txt ~/homepage/public/gradint/prompts-readme.txt grep ^program_name < src/top.py|head -1|sed -e 's/.*radint v/v/' -e 's/ .*/./' > ~/homepage/public/gradint/latest-version.txt make clean - ~/homepage/update - ssh st0rage "cd eGuidedog/ssb22/gradint; screen -d -m /bin/bash -c 'sleep 60;. build-sync.sh'" + make -C ~/homepage gradint-build.7z: mkdir /tmp/gradint-build00 cp -r * /tmp/gradint-build00 rm -r /tmp/gradint-build00/LICENSE /tmp/gradint-build00/README.md /tmp/gradint-build00/charlearn mv /tmp/gradint-build00 gradint - cd gradint ; make clean ; rm -rf extras ; cd .. + make -C gradint clean + rm -rf gradint/extras 7za a gradint-build.7z gradint/ rm -rf gradint @@ -266,6 +267,7 @@ CD: $(Mac_Files) gradint.zip echo;echo;echo "Made CD directory. Can add gradint/samples, gradint/vocab.txt, gradint/espeak for Windows, gradint/espeak-.. for OSX, sox Win/Mac binaries, oggenc or whatever for Windows, etc." cleanup: - rm -f `find . -type f -name '*~' -o -name '*.pyc' -o -name DEADJOE` + find . -type f '(' -name '*~' -o -name '*.pyc' -o -name DEADJOE ')' -exec rm -vf '{}' ';' + rm -rvf __pycache__ # must be separate from find, as some find implementations exec before trying to descend and then error clean: cleanup - rm -f gradint.py $(All_Versions) src/defaults.py gradint-installer.command gradint.dmg + rm -rf gradint.py $(All_Versions) src/defaults.py gradint-installer.command gradint.dmg diff --git a/README-wrapper.md b/README-wrapper.md new file mode 100644 index 0000000..3596f09 --- /dev/null +++ b/README-wrapper.md @@ -0,0 +1,66 @@ +# Is gradint-wrapper.exe a virus? +From https://ssb22.user.srcf.net/gradint/gradint-wrapper.html +(also [mirrored on GitLab Pages](https://ssb22.gitlab.io/gradint/gradint-wrapper.html) just in case) + +The genuine gradint-wrapper.exe is **not** a virus. + +The Windows version of [Gradint](README.md) defaults to starting itself automatically once per day. This is because I’ve sometimes installed it for people who say they don’t know how to start programs (!) and/or want to be reminded about their daily vocabulary practice. + +(When the simpler commercial “DuoLingo” app came on the scene later, their owl picture became famous for posting reminders some people found annoying. I was doing it first.) + +Modern Windows laptops tend to hibernate rather than shut down. Therefore it’s no longer enough to put Gradint in the “Startup” folder—I also have to run a background process to make sure the “once per day” thing works. + +## Does gradint-wrapper.exe slow the computer down? +It shouldn’t! The background process wakes up once per hour and checks to see if the computer has hibernated overnight in the meantime. If the computer is being slow then it must have other problems, such as: + +1. Multiple anti-virus programs all scanning at once (an anti-virus program is no substitute for being careful and/or using a safer operating system, but if you must have one then consider if one is sufficient because the benefits of having more are rarely worth the cost in watching them “fight each other” over disk access), +2. Malware that is unknown to the anti-virus programs, and/or excessive amounts of “advertisement” software that was either pre-loaded by a shop or downloaded by a user who can’t tell the difference between advert-supported “free” and real free (try telling them to check for GPL, Apache or similar licenses, and/or verify the reputation of the publisher; don’t trust suggestions just because they seem to be from friends or the system), +3. Disk errors on very old hardware. + +Sometimes it’s easier to replace Windows with a good GNU/Linux installation as long as the hardware is functioning. + +## Why are there 3 or 4 instances of gradint-wrapper.exe? +There should normally be only one background instance, plus another if Gradint is currently open. When Gradint is launched from the desktop or start menu, it tries to stop the other instances and start its own, but on Vista and above this sometimes fails and multiple background processes can result. This is harmless as old ones should detect the situation next time they wake up (the code to do this has been improved in recent versions). It’s still occasionally possible for a user to launch two Gradint windows accidentally, but you should never see more than one automatically started. + +## Is it safe to Terminate gradint-wrapper.exe? +Yes, this is safe. But it will start again next time you reboot or run Gradint. + +## Is it safe to delete gradint-wrapper.exe? +That will break your Gradint installation. gradint-wrapper.exe is not just the background process: it is also the “wrapper” for loading the main part of Gradint on Windows (I use a 2-part loader to make the Windows version easier to update from GNU/Linux). + +## How do you stop Gradint from running every day? +If you upgrade to Gradint v0.9979+ you can: + +* set it at installation time (by answering No to the question “Do you want Gradint to start by itself and remind you to practise?” when first run); +* change it in the advanced settings (search for disable_once_per_day and set it to 1) + +Alternatively, go to Start menu > All programs > Startup, right-click on “Run gradint once per day” and delete it. Gradint will still start the background process when you run it manually (I set that in case it fails to find the startup folder); if you want to stop this, go to Start menu > All programs > Gradint and/or desktop > Gradint, right-click on Gradint, open in Notepad, delete `once_per_day=2` and save. + +## How do you uninstall Gradint? +Go to Start menu > All programs > Gradint > uninstall, or desktop > Gradint > uninstall. If it isn’t there, try re-downloading [the Gradint installer](https://ssb22.user.srcf.net/gradint/gradint.exe) and run it—it should replace the uninstall scripts which you can then use. + +## Why is Gradint not in “Add/Remove Programs”? +To get into the “Add/Remove Programs” list, a program must be installed system-wide. Gradint does not install itself system-wide; it installs itself in your user name’s home folder (unless you have an ancient version of Windows that doesn’t have them). This means you can install Gradint even when you don’t have permission to install system-wide programs (such as in a computer lab), but it also means Gradint cannot use the “Add/Remove Programs” list. + +## Why did some anti-virus labs flag Gradint as malicious in August 2020? +On 13th August 2020, some anti-virus labs I’d never heard of (AnyRun and VirusTotal, the latter citing Antiy-AVL, CrowdStrike Falcon, K7AntiVirus, Zillya, SecureAge APEX, Jiangmin and K7GW) incorrectly tagged the Gradint installer as a malicious trojan, and a company called Netcraft even sent a take-down notice to Cambridge University Information Services and the Student-Run Computing Facility hosting my website. + +After I contacted AnyRun support asking for an appeal against the “verdict: malicious activity” they had published, they confirmed their technicians decided it was a “false positive” and made that report private to the submitter, but they were unable to relay a message to the submitter that they had done so. + +I don’t know if this “detection” effort was anything to do with an incident that began the same day involving 200+ attempts from DigitalOcean-owned IP addresses to issue POST requests to gradint.exe (causing over a gigabyte of traffic), which I then blocked and reported to DigitalOcean. Since whoever it was continued to try (making another 700+ attempts over the next 5 days), we could just be looking at two separate issues that coincidentally started at about the same time. (My report to DigitalOcean was made after Cambridge University received the take-down notice, but before I had been told about it.) + +I don’t yet know what it is about the Gradint installer that these “detectors” objected to, but I suspect it’s because the Gradint installer unpacks copies of certain free and open source software components that Gradint uses, namely, Python (with its standard libraries), eSpeak, LAME, MadPlay, PTTS and SoX. It seems that the authors of these “detectors” regarded any attempt to unpack another executable as suspicious, especially if it’s being done from an installer that is “unsigned” because I have not paid Microsoft’s extortionate fee to be a “recognised” publisher. I’m glad to say that this was not the case with the “big” anti-virus programs (the ones I’d heard of), which did not flag Gradint as malicious on that day. + +I have asked Netcraft for an explanation of their take-down request and have not yet received any reply. + +## Why is the Gradint installer not signed? +As I said on the Gradint download page, I have not paid Microsoft to make me a “known publisher” (I consider it a bit extortionate of them to require this payment even for small hobby projects)—if you make sure to fetch the installer from my own page and via HTTPS, that should be ‘signature’ enough. If you’re being really cautious then you are welcome to download the source code, install Python and all required dependencies yourself and run it that way; I simply packaged up an installer as a convenience to those Windows users who prefer a “one-click” setup, and I don’t see why I should have to pay Microsoft not to issue a warning—that seems wrong. + +## Why did AnyRun say the Gradint installer uses Task Scheduler? +Some previous versions of Gradint used Task Scheduler for the “once per day” feature. The installer for the current version of Gradint contains one call to the Task Scheduler, but only to delete the task that those old versions left, if present. + +## Why did AnyRun report Bitcoin addresses in Gradint? +There are no Bitcoin addresses in Gradint. AnyRun’s detector must have found a false positive. + +## Why did AnyRun report Gradint using SearchProtocolHost.exe? +Again this appears to be a false positive. `SearchProtocolHost.exe` is a Microsoft component pre-installed on many versions of Windows that has frequently been known to misbehave, and it seems AnyRun’s detector misidentified it as being run by Gradint on that occasion. diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 0000000..f67b113 --- /dev/null +++ b/README-zh.md @@ -0,0 +1,112 @@ + +from https://ssb22.user.srcf.net/gradint/index-zh.html (also [mirrored on GitLab Pages](https://ssb22.gitlab.io/gradint/index-zh.html) just in case) + +简体字 | [繁體字](README-zhf.md) + +# Gradint自己学外语软件 + +![screenshot-zh-winCE.png](https://ssb22.user.srcf.net/gradint/screenshot-zh-winCE.png) + +跳到: 如何安装 | 学术引用 + +Gradint软件能帮你准备自学录音带练习外语词汇。 它能补充别的课程,帮你准备发表演讲,或者记录你所遇见的词汇。 + +**该软件的理论:** Gradint用皮姆斯勒(Pimsleur)1967年所出版的“分级间歇回想”法的变体。好像声音抽认卡, 采用一个特别顺序帮你记忆。 Pimsleur课程 ([听演示](https://ssb22.user.srcf.net/pims-lrnEn-demo.mp3)) 声称有专利的技巧, *Gradint并不违反*, 但1967年的“分级间歇回想”主意现在公有領域所以Gradint能用帮你学你自所选的词汇。 + +视窗操作系统: + +Animation (“memory balls” reinforcement) + +技术性的图表一课, 有许多分级间歇 (**grad** uated-**int** erval) 顺序。 短顺序回想旧词; 粉红顺序教新词; 簧显示顺序 能适应。 + +![diagram.jpg](https://ssb22.user.srcf.net/gradint/diagram.jpg) + +(If you have an SVG viewer, you can [download a clearer version](https://ssb22.user.srcf.net/gradint/diagram.svg)) + +**Gradint *只* 用声音, 所以你能集中发音。** (也意味你能一面梳洗一面听, 因为不需要盯着显示器看。) Gradint 或者做MP3(或其他声音文件格式)让你以后听, 或者直接播放而试试适应你的紧急打断。 所用的词语或者是真正录音或者是机器声音。 你可以随时把词语加到你的目录。 Gradint 能管理一万多词语的积攒。也能帮你排练诗歌等。 + +Gradint 是免费的自由开源软件。 The latest release is v3.11. + +## 如何安装 +1. 下载合适的译本。 + + 如果你学习中文, 请也下载 + + [Cheng Yali 的华语声音](https://ssb22.user.srcf.net/gradint/yali-voice.exe) ([听示范](https://ssb22.user.srcf.net/gradint/yali.mp3)) (也有[低音版](https://ssb22.user.srcf.net/gradint/yali-lower.exe)) 或 [黄冠能的粤语声音](https://ssb22.user.srcf.net/gradint/cameron-voice.exe), 比较大的下载但是没有比Gradint所包括的声音那么机器性。 视窗能打开; 别的作业系统把它放在Gradint 的卷宗。 + + 视窗操作系统 + + (不需要管理人员) 下载[视窗 (Windows) 本](https://ssb22.user.srcf.net/gradint/gradint.exe)而打开。 + * On Windows 7+ click the **small “More options” link** to reveal the “Run anyway” option (I haven’t paid Microsoft to make me a “known publisher”). + * There are incorrect reports that Gradint is a virus or a trojan. See my [discussion of Gradint’s supposedly-malicious behaviour](README-wrapper.md). + + 苹果机 (Mac) + + 下载 [苹果机本](https://ssb22.user.srcf.net/gradint/gradint.tbz), 打开包裹, 打开`Gradint`. (所有OS X版本10.0到10.14都该运转,但10.15可能需要安全许可) + + GNU/Linux等等 + + 包括每个孩子一台笔记型电脑(OLPC)、网络储存设备(NAS)和树莓派(Raspberry Pi), 下载 [GNU/Linux译本](https://ssb22.user.srcf.net/gradint/gradint.bgz), 做 `tar -jxf gradint.bgz` 然后 `gradint/gradint.py`. 推荐你也安装 `espeak` 和 `python-tk` 包裹。 + + Windows Mobile: + + [Windows Mobile](https://ssb22.user.srcf.net/wm/) 手机6.0或更旧 安装 [PythonCE](https://ssb22.user.srcf.net/wm/PythonCE.WM.CAB), 安装[gradint.cab](https://ssb22.user.srcf.net/gradint/gradintcab.zip) **然后在gradint文件夹打开Setup** 。 (这也安装eSpeak而一些读出剪贴板的脚本。要是您的手机有RAMdisk,Gradint运转的比较快。 + + S60: + + Nokia/Symbian S60 手机 安装 [PyS60](https://web.archive.org/web/20110727070040/https://garage.maemo.org/frs/download.php/5952/Python_1.9.4.sis) 和 [ScriptShell](https://web.archive.org/web/20110727070117/https://garage.maemo.org/frs/download.php/5910/PythonScriptShell_1.9.4_3rdEd.sis) (這些連接是S60第三版本的用途,其他版本請用搜索引擎找到合適的PyS60與ScriptShell),打開[gradint-S60.zip](https://ssb22.user.srcf.net/gradint/gradint-S60.zip)到手機的`data\python`或`python`文件夹, 打开Python然后run script `gradint.py`. + + Android: + + [安卓 (Android)](https://ssb22.user.srcf.net/setup/android.html) 手机 安装 QPython1.2.5的APK, 打开[gradint-android.zip](https://ssb22.user.srcf.net/gradint/gradint-android.zip) 到 `qpython/scripts` (或旧版本的`com.hipipal.qpyplus/scripts`), 然后你可以把QPython的”default program”变成`gradint.py` + + RISC OS: + + RISC OS 4 下载 [RISC OS Python 2.3](http://web.archive.org/web/20070315150725/python.acorn.de/Python-2.3-runtime-2003-08-03.zip), [gradint.zip](https://ssb22.user.srcf.net/gradint/gradint.zip) and PlayIt; shift-click to open `!gradint` or click to run. For RISC OS 5 on ARM7+ use Python 3.8, edit `!Run` to say `Python3`, and use MP3s not WAVs; install AMPlayer, and eSpeak if possible. + + 网站版本: + + 使用[Gradint网站版本](https://ssb22.user.srcf.net/gradint.cgi),能用任何浏览器。 为了设定自己的服务器,你可以用上面的Unix版本与这个[服务器脚本](https://ssb22.user.srcf.net/gradint/servertools.bgz) (存档也包括电子邮件服务的脚本)。 + +2. **告诉软件你要学什么语言。** 在大多数系统, Gradint 能显示一个视窗让你做这样。 或者你可以edit the file `settings.txt`. +3. **给软件词语和短语教。 **   你可以混合真正录音和机器声音。你可以随时把词语加到你的积攒。 在大多数系统, Gradint 能显示一个视窗让你做这样。 或者你可以: + + ![screenshot2.png](https://ssb22.user.srcf.net/gradint/screenshot2.png) + + * 把真正的录音文件放在`samples`文件夹和子目录 (请读英文的`samples`文件夹的`README.txt`) + * 把你要机器声音读出的词汇加到`vocab.txt`文件 (请读英文的[`vocab.txt`指南](vocab.txt)) +4. **如果可能, 准备提示声音 ** 比如 “请跟着说”, “在说一次”等。 英文和中文的机器声音课文已经提供了, 但如果你不想用机器声音或用别的语言, 请自己提供。 关于提供别的提示声音请看 [the file `README.txt` in the `prompts` subdirectory](samples/prompts/README.txt) of the `samples` directory. 我也有 [sampled English prompts](https://ssb22.user.srcf.net/gradint/en-prompts.zip). + +然后, 你每当想一课, 能打开这个软件。 + +如果你的电脑技巧很高, 你也能看[ `advanced.txt`](advanced.txt) (这是参考连接而已。为了做改版,请打开您所安装的Gradint的拷贝。) + +程序员: 源代码在所有下载里的`gradint.py`,或者欢迎下载[Gradint build environment](https://ssb22.user.srcf.net/gradint/gradint-build.7z)(看看里面的README.txt)。还有黄冠能所提供的SVN仓储: `svn co http://svn.code.sf.net/p/e-guidedog/code/ssb22` + +还有GitHub的仓储: `git clone https://github.com/ssb22/gradint.git` + +还有GitLab的仓储: `git clone https://gitlab.com/ssb22/gradint.git` + +还有Bitbucket的仓储: `git clone https://bitbucket.org/ssb22/gradint.git` + +## 学术引用 + +Silas S. Brown and Peter Robinson. Addressing Print Disabilities in Adult Foreign-language Acquisition. In: Proceedings of the 10th International Conference on Human-Computer Interaction (HCII 2003, Crete, Greece), Vol.4: Universal Access in HCI, pp 38-42. [PDF](https://ssb22.user.srcf.net/papers/hcii.pdf) + +Copyright and Trademarks: +All material © Silas S. Brown unless otherwise stated. +Android is a trademark of Google LLC. +ARM is a registered trademark of Advanced RISC Machines, Ltd or its subsidiaries. +GitHub is a trademark of GitHub Inc. +Linux is the registered trademark of Linus Torvalds in the U.S. and other countries. +Mac is a trademark of Apple Inc. +Microsoft is a registered trademark of Microsoft Corp. +MP3 is a trademark that was registered in Europe to Hypermedia GmbH Webcasting but I was unable to confirm its current holder. +Pimsleur is a registered trademark of Beverly Pimsleur exclusively licensed to Simon & Schuster. +Python is a trademark of the Python Software Foundation. +Raspberry Pi is a trademark of the Raspberry Pi Foundation. +RISC OS is a trademark of Pace Micro Technology Plc which might now have passed to RISC OS Ltd but I was unable to find definitive documentation. +Symbian was a trademark of the Symbian Foundation until its insolvency in 2022 and I was unable to find what happened to the trademark after that. +Unix is a trademark of The Open Group. +Windows is a registered trademark of Microsoft Corp. +Any other [trademarks](https://ssb22.user.srcf.net/trademarks.html) I mentioned without realising are trademarks of their respective holders. diff --git a/README-zhf.md b/README-zhf.md new file mode 100644 index 0000000..85f1409 --- /dev/null +++ b/README-zhf.md @@ -0,0 +1,112 @@ + +from https://ssb22.user.srcf.net/gradint/index-zhf.html (also [mirrored on GitLab Pages](https://ssb22.gitlab.io/gradint/index-zhf.html) just in case) + +[简体字](README-zh.md) | 繁體字 + +# Gradint自己學外語軟件 + +![screenshot-zh-winCE.png](https://ssb22.user.srcf.net/gradint/screenshot-zh-winCE.png) + +跳到: 如何安裝 | 學術引用 + +Gradint軟件能幫你準備自學錄音帶練習外語詞彙。 它能補充別的課程,幫你準備發表演講,或者記錄你所遇見的詞彙。 + +**該軟件的理論:** Gradint用皮姆斯勒(Pimsleur)1967年所出版的“分級間歇回想”法的變體。好像聲音抽認卡, 採用一個特別順序幫你記憶。 Pimsleur課程聲稱有專利的技巧, *Gradint並不違反*, 但1967年的“分級間歇回想”主意現在公有領域所以Gradint能用幫你學你自所選的詞彙。 + +視窗操作系統: + +Animation (“memory balls” reinforcement) + +技術性的圖表一課, 有許多分級間歇 (**grad** uated-**int** erval) 順序。 短順序回想舊詞; 粉紅順序教新詞; 簧顯示順序 能適應。 + +![diagram.jpg](https://ssb22.user.srcf.net/gradint/diagram.jpg) + +(If you have an SVG viewer, you can [download a clearer version](https://ssb22.user.srcf.net/gradint/diagram.svg)) + +**Gradint *只* 用聲音, 所以你能集中發音。** (也意味你能一面梳洗一面聽, 因為不需要盯著顯示器看。) Gradint 或者做MP3(或其他聲音文件格式)讓你以後聽, 或者直接播放而試試適應你的緊急打斷。 所用的詞語或者是真正錄音或者是機器聲音。 你可以隨時把詞語加到你的目錄。 Gradint 能管理一萬多詞語的積攢。也能幫你排練詩歌等。 + +Gradint 是免費的自由開源軟件。 The latest release is v3.11. + +## 如何安裝 +1. 下載合適的譯本。 + + 如果你學習中文, 請也下載 + + [Cheng Yali 的華語聲音](https://ssb22.user.srcf.net/gradint/yali-voice.exe) ([聽示範](https://ssb22.user.srcf.net/gradint/yali.mp3)) (也有[低音版](https://ssb22.user.srcf.net/gradint/yali-lower.exe)) 或 [黃冠能的粵語聲音](https://ssb22.user.srcf.net/gradint/cameron-voice.exe), 比較大的下載但是沒有比Gradint所包括的聲音那麼機器性。 視窗能打開; 別的作業系統把它放在Gradint 的卷宗。 + + 視窗操作系統 + + (不需要管理人員) 下載[視窗 (Windows) 本](https://ssb22.user.srcf.net/gradint/gradint.exe)而打開。 + * On Windows 7+ click the **small “More options” link** to reveal the “Run anyway” option (I haven’t paid Microsoft to make me a “known publisher”). + * There are incorrect reports that Gradint is a virus or a trojan. See my [discussion of Gradint’s supposedly-malicious behaviour](README-wrapper.md). + + 蘋果機 (Mac) + + 下載 [蘋果機本](https://ssb22.user.srcf.net/gradint/gradint.tbz), 打開包裹, 打開`Gradint`. (所有OS X版本10.0到10.14都該運轉,但10.15可能需要安全許可) + + GNU/Linux等等 + + 包括每個孩子一檯筆記型電腦(OLPC)、網路儲存設備(NAS)和樹莓派(Raspberry Pi), 下載 [GNU/Linux譯本](https://ssb22.user.srcf.net/gradint/gradint.bgz), 做 `tar -jxf gradint.bgz` 然後 `gradint/gradint.py`. 推薦你也安裝 `espeak` 和 `python-tk` 包裹。 + + Windows Mobile: + + [Windows Mobile](https://ssb22.user.srcf.net/wm/) 手機6.0或更舊 安裝 [PythonCE](https://ssb22.user.srcf.net/wm/PythonCE.WM.CAB), 安裝 [gradint.cab](https://ssb22.user.srcf.net/gradint/gradintcab.zip) **然後在gradint文件夾打開Setup** 。 (這也安裝eSpeak而一些讀出剪貼板的腳本。要是您的手機有RAMdisk,Gradint運轉的比較快。 + + S60: + + Nokia/Symbian S60 手機 安裝 [PyS60](https://web.archive.org/web/20110727070040/https://garage.maemo.org/frs/download.php/5952/Python_1.9.4.sis) 和 [ScriptShell](https://web.archive.org/web/20110727070117/https://garage.maemo.org/frs/download.php/5910/PythonScriptShell_1.9.4_3rdEd.sis) (这些连接是S60第三版本的用途,其他版本请用搜索引擎找到合适的PyS60与ScriptShell),打开[gradint-S60.zip](https://ssb22.user.srcf.net/gradint/gradint-S60.zip)到手机的`data\python`或`python`文件夾, 打開Python然後run script `gradint.py`. + + Android: + + [安卓 (Android)](https://ssb22.user.srcf.net/setup/android.html) 手機 安裝 QPython1.2.5的APK, 打開[gradint-android.zip](https://ssb22.user.srcf.net/gradint/gradint-android.zip) 到 `qpython/scripts` (或舊版本的`com.hipipal.qpyplus/scripts`), 然後你可以把QPython的”default program”變成`gradint.py` + + RISC OS: + + RISC OS 4 下載 [RISC OS Python 2.3](http://web.archive.org/web/20070315150725/python.acorn.de/Python-2.3-runtime-2003-08-03.zip), [gradint.zip](https://ssb22.user.srcf.net/gradint/gradint.zip) and PlayIt; shift-click to open `!gradint` or click to run. For RISC OS 5 on ARM7+ use Python 3.8, edit `!Run` to say `Python3`, and use MP3s not WAVs; install AMPlayer, and eSpeak if possible. + + 網站版本: + + 使用[Gradint網站版本](https://ssb22.user.srcf.net/gradint.cgi),能用任何瀏覽器。 為了設定自己的服務器,你可以用上面的Unix版本與這個[服務器腳本](https://ssb22.user.srcf.net/gradint/servertools.bgz) (存檔也包括電子郵件服務的腳本)。 + +2. **告訴軟件你要學什麼語言。** 在大多數系統, Gradint 能顯示一個視窗讓你做這樣。 或者你可以edit the file `settings.txt`. +3. **給軟件詞語和短語教。 **   你可以混合真正錄音和機器聲音。你可以隨時把詞語加到你的積攢。 在大多數系統, Gradint 能顯示一個視窗讓你做這樣。 或者你可以: + + ![screenshot2.png](https://ssb22.user.srcf.net/gradint/screenshot2.png) + + * 把真正的錄音文件放在`samples`文件夾和子目錄 (請讀英文的`samples`文件夾的`README.txt`) + * 把你要機器聲音讀出的詞彙加到`vocab.txt`文件 (請讀英文的[`vocab.txt`指南](vocab.txt)) +4. **如果可能, 準備提示聲音 ** 比如 “請跟著說”, “在說一次”等。 英文和中文的機器聲音課文已經提供了, 但如果你不想用機器聲音或用別的語言, 請自己提供。 關於提供別的提示聲音請看 [the file `README.txt` in the `prompts` subdirectory](samples/prompts/README.txt) of the `samples` directory. 我也有 [sampled English prompts](https://ssb22.user.srcf.net/gradint/en-prompts.zip). + +然後, 你每當想一課, 能打開這個軟件。 + +如果你的電腦技巧很高, 你也能看[ `advanced.txt`](advanced.txt) (這是參考連接而已。為了做改版,請打開您所安裝的Gradint的拷貝。) + +程序員: 源代碼在所有下載裡的`gradint.py`,或者歡迎下載[Gradint build environment](https://ssb22.user.srcf.net/gradint/gradint-build.7z)(看看裡面的README.txt)。還有黃冠能所提供的SVN倉儲: `svn co http://svn.code.sf.net/p/e-guidedog/code/ssb22` + +還有GitHub的倉儲: `git clone https://github.com/ssb22/gradint.git` + +還有GitLab的倉儲: `git clone https://gitlab.com/ssb22/gradint.git` + +還有Bitbucket的倉儲: `git clone https://bitbucket.org/ssb22/gradint.git` + +## 學術引用 + +Silas S. Brown and Peter Robinson. Addressing Print Disabilities in Adult Foreign-language Acquisition. In: Proceedings of the 10th International Conference on Human-Computer Interaction (HCII 2003, Crete, Greece), Vol.4: Universal Access in HCI, pp 38-42. [PDF](https://ssb22.user.srcf.net/papers/hcii.pdf) + +Copyright and Trademarks: +All material © Silas S. Brown unless otherwise stated. +Android is a trademark of Google LLC. +ARM is a registered trademark of Advanced RISC Machines, Ltd or its subsidiaries. +GitHub is a trademark of GitHub Inc. +Linux is the registered trademark of Linus Torvalds in the U.S. and other countries. +Mac is a trademark of Apple Inc. +Microsoft is a registered trademark of Microsoft Corp. +MP3 is a trademark that was registered in Europe to Hypermedia GmbH Webcasting but I was unable to confirm its current holder. +Pimsleur is a registered trademark of Beverly Pimsleur exclusively licensed to Simon & Schuster. +Python is a trademark of the Python Software Foundation. +Raspberry Pi is a trademark of the Raspberry Pi Foundation. +RISC OS is a trademark of Pace Micro Technology Plc which might now have passed to RISC OS Ltd but I was unable to find definitive documentation. +Symbian was a trademark of the Symbian Foundation until its insolvency in 2022 and I was unable to find what happened to the trademark after that. +Unix is a trademark of The Open Group. +Windows is a registered trademark of Microsoft Corp. +Any other [trademarks](https://ssb22.user.srcf.net/trademarks.html) I mentioned without realising are trademarks of their respective holders. diff --git a/README.md b/README.md index fb51a97..28ae407 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,63 @@ -# gradint -Graduated Interval Recall program from http://ssb22.user.srcf.net/gradint +# Graduated Interval Recall tool +from https://ssb22.user.srcf.net/gradint +(also [mirrored on GitLab Pages](https://ssb22.gitlab.io/gradint) just in case) | [Chinese version](README-zh.md) -(also mirrored at http://ssb22.gitlab.io/gradint just in case) +Gradint is a program that can be used to make your own self-study audio tapes for learning foreign-language vocabulary. You can use it to help with a course, to prepare for speaking assignments, or just to keep track of the vocabulary you come across. -Gradint is a program that can be used to make your own self-study audio tapes for learning foreign-language vocabulary. You can use it to help with a course, to prepare for speaking assignments, or just to keep track of the vocabulary you come across. +Gradint uses a variant of the “graduated-interval recall” method published by Pimsleur in 1967. It’s like audio flashcards that appear in a special pattern designed to help you remember. The Pimsleur accelerated language courses use several techniques (they say some are patented), and Gradint does not imitate all that, but this particular 1967 idea is now in the public domain so Gradint can use it to help you learn your own choice of vocabulary. -Gradint uses a variant of the “graduated-interval recall” method published by Pimsleur in 1967. It’s like audio flashcards that appear in a special pattern designed to help you remember. The Pimsleur accelerated language courses use several techniques (they say some are patented), and Gradint does not imitate all that, but this particular 1967 idea is now in the public domain so Gradint can use it to help you learn your own choice of vocabulary. - -Gradint gives only audio, so you concentrate on pronunciation. (And so you can listen during daily routines e.g. washing etc, since you don’t have to look or press buttons during a lesson.) Gradint can write its lessons to MP3 or similar files for you to hear later, or it can play them itself and try to adapt to emergency interruptions. The words it uses can be taken from real sound recordings or they can be synthesized by computer. You can add words to your collection at any time, and Gradint can manage collections of thousands of words (and supports batch entry). It can also help you rehearse longer texts such as poems. +Gradint gives only audio, so you concentrate on pronunciation. (And so you can listen during daily routines e.g. washing etc, since you don’t have to look or press buttons during a lesson.) Gradint can write its lessons to MP3 or similar files for you to hear later, or it can play them itself and try to adapt to emergency interruptions. The words it uses can be taken from real sound recordings or they can be synthesized by computer. You can add words to your collection at any time, and Gradint can manage collections of thousands of words (and supports batch entry). It can also help you rehearse longer texts such as poems. Gradint is Free/Libre and Open Source Software distributed under the GNU General Public License (GPL v3). -The Gradint home page (linked above) contains installers for various platforms and details of how to run them. This repository contains the Gradint build environment, including a `Makefile` that can create these installers by packaging up versions of the Python script for various environments (Windows, Mac, Linux, Windows Mobile, S60, Android, RISC OS) plus utilities for running Gradint on a server. Not all of Gradint's functions are available in all environments. - -If you are learning Chinese, you might also want Yali Cheng’s Mandarin voice or Cameron Wong’s Cantonese voice. Installers for these may also be found on the Gradint home page, and source in my separate Git repositories `yali-voice`, `yali-lower` and `cameron-voice`. They are larger downloads but less "robotic" than the eSpeak voice. - -Once you have installed Gradint, you must tell the program which language(s) you want to learn. On most systems, Gradint will show a GUI which lets you do this. You can also edit the file `settings.txt`. - -Then you can give the program some words and phrases to teach. This can be any combination of real recordings and computer-synthesized words, and you can always add more later. You can use the graphical interface (on supported systems), or you can place real recordings in the samples directory and its subdirectories (see the file `README.txt` in the `samples` directory), and add words that you want synthesized by computer to vocab.txt (see the instructions in `vocab.txt`). - -If possible, prepare some audio prompts such as “say again” and “do you remember how to say”. These can be real recordings or synthesized text. Some text for English and Chinese is already provided. For any other language you should ideally add your own; for details of how to do this, see the file README.txt in the prompts subdirectory of the samples directory. - -You should then be able to run the program every time you want a lesson. For more advanced things, see the settings in the file advanced.txt. - -Citation: Silas S. Brown and Peter Robinson. Addressing Print Disabilities in Adult Foreign-language Acquisition. In: Proceedings of the 10th International Conference on Human-Computer Interaction (HCII 2003, Crete, Greece), Vol.4: Universal Access in HCI, pp 38-42. +## Setup instructions +1. Download the appropriate version. + * **Windows:** download the [Windows installer](https://ssb22.user.srcf.net/gradint/gradint.exe) and run it. (You do not need Administrator rights.) + * On Windows 7+ click the small “More options” link to reveal the “Run anyway” option (I haven’t paid Microsoft to make me a “known publisher”). + * There are incorrect reports that Gradint is a virus or a trojan. See my [discussion of Gradint’s supposedly-malicious behaviour](README-wrapper.md). + * **Mac:** download the [Mac version](https://ssb22.user.srcf.net/gradint/gradint.tbz), unpack it, and open Gradint. Should work with versions of OS X from 10.0 through 10.14, but on 10.15 it might need permission to run from the Security settings. + * For **GNU/Linux and other Unix systems** (including OLPC laptops, NAS devices and the Raspberry Pi), download the [GNU/Linux version](https://ssb22.user.srcf.net/gradint/gradint.bgz), do `tar -jxf gradint.bgz` and run using `gradint/gradint.py` (compatible with both Python 2 and Python 3). Also install `espeak` and `python-tk` packages if possible. + * For **Windows Mobile** (6.0 or earlier) install [PythonCE](https://ssb22.user.srcf.net/wm/PythonCE.WM.CAB), install [gradint.cab](https://ssb22.user.srcf.net/gradint/gradintcab.zip) **and run Setup in the gradint folder.** (This will also install eSpeak, and some scripts to read the clipboard. It will run faster if you have a RAMdisk.) + * For **Nokia/Symbian S60 phones** (e.g. E63, N71, N86, N97, 6120), install [PyS60](https://web.archive.org/web/20110727070040/https://garage.maemo.org/frs/download.php/5952/Python_1.9.4.sis) and [ScriptShell](https://web.archive.org/web/20110727070117/https://garage.maemo.org/frs/download.php/5910/PythonScriptShell_1.9.4_3rdEd.sis) (those links are for 3rd edition phones; for other editions google it), unpack gradint-S60.zip into the phone’s `data\python` or `python` folder, open Python and run script `gradint.py`. + * For **Android** phones (including very old ones), install the old version 1.2.5 of QPython and disable Play Store updates on it (as version 3.0 is broken, especially on Android 4.x). Unpack [gradint-android.zip](https://ssb22.user.srcf.net/gradint/gradint-android.zip) into `qpython/scripts` (or `com.hipipal.qpyplus/scripts` on older versions), and optionally set QPython’s “default program” to `gradint.py` (or if you have SL4A+Python, use `/sdcard/sl4a/scripts`) + * **RISC OS:** For RISC OS 4, download [RISC OS Python 2.3](http://web.archive.org/web/20070315150725/python.acorn.de/Python-2.3-runtime-2003-08-03.zip) (via Internet Archive), [gradint.zip](https://ssb22.user.srcf.net/gradint/gradint.zip) and PlayIt; shift-click to open `!gradint` or click to run. For RISC OS 5 on ARM7+ use Python 3.8, edit `!Run` to say Python3, and use MP3s not WAVs; install AMPlayer, and eSpeak if possible. + * **Online:** You can use [Gradint Web edition](https://ssb22.user.srcf.net/gradint.cgi) with any browser. You can set up your own server with the Unix version and the [server scripts](server/README.txt) (also includes scripts for email-based service). + +**Additional downloads for Chinese:** [Yali Cheng’s Mandarin voice](https://ssb22.user.srcf.net/gradint/yali-voice.exe) ([hear a sample](https://ssb22.user.srcf.net/gradint/yali.mp3)); a [lower-pitch version of Yali’s voice](https://ssb22.user.srcf.net/gradint/yali-lower.exe); [Cameron Wong’s Cantonese voice](https://ssb22.user.srcf.net/gradint/cameron-voice.exe). These are larger downloads but less “robotic” than the voice that comes with Gradint. On Windows just open them; on other systems put them in the same folder as you put Gradint. Source is in my separate Git repositories `yali-voice`, `yali-lower` and `cameron-voice`. + +2. Tell the program which language you want to learn. On most systems, Gradint will show a GUI which lets you do this. A more technical way to do it is to edit the file settings.txt. +3. Give the program some words and phrases to teach. This can be any combination of real recordings and computer-synthesized words, and you can always add more later. You can use the graphical interface (on supported systems), or you can: + * place real recordings in the samples directory and its sub­directories (see [the file README.txt in the samples directory](samples/README.txt)) + * add words that you want synthesized by computer to vocab.txt (see [the instructions in vocab.txt](vocab.txt)) +4. If possible, prepare some audio prompts such as “say again” and “do you remember how to say”. These can be real recordings or synthesized text. Some text for English and Chinese is already provided, but if you won’t be using a speech synthesizer you can download [sampled English prompts](https://ssb22.user.srcf.net/gradint/en-prompts.zip). For any other language you should ideally add your own; for details of how to do this, see [the file README.txt in the prompts subdirectory](samples/prompts/README.txt) of the samples directory. + +You should then be able to run the program every time you want a lesson. + +You can do more advanced things if you are able to edit configuration files. For details see the file [advanced.txt](advanced.txt) (to make changes you will need to open the copy in your gradint installation). + +## Building + +This repository contains the Gradint build environment, including a `Makefile` that can create these installers by packaging up versions of the Python script for various environments (Windows, Mac, Linux, Windows Mobile, S60, Android, RISC OS) plus utilities for running Gradint on a server. Not all of Gradint's functions are available in all environments. + +A separate program [charlearn](charlearn/README.md) can help you learn to recognise foreign characters. If you are learning Chinese, [be careful of commercial computer voices](voice-mistakes.md). + +## Citation +Silas S. Brown and Peter Robinson. Addressing Print Disabilities in Adult Foreign-language Acquisition. In: Proceedings of the 10th International Conference on Human-Computer Interaction (HCII 2003, Crete, Greece), Vol.4: Universal Access in HCI, pp 38-42. + +## Copyright and Trademarks +All material © Silas S. Brown unless otherwise stated. +Android is a trademark of Google LLC. +ARM is a registered trademark of Advanced RISC Machines, Ltd or its subsidiaries. +GitHub is a trademark of GitHub Inc. +Linux is the registered trademark of Linus Torvalds in the U.S. and other countries. +Mac is a trademark of Apple Inc. +Microsoft is a registered trademark of Microsoft Corp. +MP3 is a trademark that was registered in Europe to Hypermedia GmbH Webcasting but I was unable to confirm its current holder. +Pimsleur is a registered trademark of Beverly Pimsleur exclusively licensed to Simon & Schuster. +Python is a trademark of the Python Software Foundation. +Raspberry Pi is a trademark of the Raspberry Pi Foundation. +RISC OS is a trademark of Pace Micro Technology Plc which might now have passed to RISC OS Ltd but I was unable to find definitive documentation. +Symbian was a trademark of the Symbian Foundation until its insolvency in 2022 and I was unable to find what happened to the trademark after that. +Unix is a trademark of The Open Group. +Windows is a registered trademark of Microsoft Corp. +Any other trademarks I mentioned without realising are trademarks of their respective holders. diff --git a/advanced.txt b/advanced.txt index 0dc41ae..8045674 100644 --- a/advanced.txt +++ b/advanced.txt @@ -47,7 +47,8 @@ otherLanguages = ["cant","ko","jp"] # able to tell the difference between cant_en.wav and an # ordinary English prompt and might use it wrongly. -possible_otherLanguages = ["cant","ko","jp","en","zh"] +possible_otherLanguages = ["cant","ko","jp","en","zh", + "zhy","zh-yue"] # You can also fill in otherFirstLanguages below # (using the same ["item","item"] format) to @@ -95,7 +96,7 @@ prefer_espeak = "en" # "zh" for Zhongwen (Mandarin). # - You can improve eSpeak's English by installing # Festival's dictionary and using lexconvert to convert -# it, see http://ssb22.user.srcf.net/gradint/lexconvert.html +# it, see https://ssb22.user.srcf.net/lexconvert/ # (this has already been done in the bundled version). # - eSpeak is not very natural-sounding, but it is very # clear and accurate in English and some other languages @@ -201,7 +202,7 @@ systemVoice = "en" # - Festival Lite on Windows (if all else fails) : # put flite.exe in the gradint folder # -# - Linux: install Festival, or flite if you want a US accent +# - GNU/Linux: install Festival, or flite for US accent # # - S60: the phone's built-in speech can be used # @@ -210,6 +211,55 @@ systemVoice = "en" # version of Jonathan Duddington's !Speak, or the even # older "Speech!" utility. These can be used only for # playing in real-time, not for generating files. +# +# - ChatterboxTTS: do: pip install chatterbox-tts +# (Python 3 required, and brings in lots of neural dependencies +# - likely too slow for real-time use) + +# OTHER MULTI-LANGUAGE SYNTHESISERS: +# +# Piper voices are experimentally supported on GNU/Linux. +# (Although Piper uses eSpeak, its Chinese voice may not read pinyin.) +# Setup: pip install piper-tts +# if you have an NVidia/CUDA GPU, also pip install onnxruntime-gpu +# Download voices: be in the gradint directory (or piper subdirectory) and do +# python3 -m piper.download_voices en_GB-cori-high zh_CN-huayan-medium +# (see https://rhasspy.github.io/piper-samples/ for voice names) +# Older setup: Download binaries and voices from +# https://github.com/rhasspy/piper/tree/4147f9629e88d3c1d4915a127f2d843f98347303 +# and put them in the gradint directory (or piper subdirectory) +# +# Coqui voices are experimentally supported on GNU/Linux. +# Setup: pip install coqui-tts[server,zh,ja,ko] +# Then download the voices you want, e.g.: +# from TTS.api import TTS;langs = {} +# for m in TTS().list_models(): langs.setdefault(m.split('/')[1].split('-')[0],[]).append(m) +# +# TTS(langs["zh"][0]) +# TTS('tts_models/en/jenny/jenny') +# (If any model crashes during download, be sure to delete the +# result from ~/.local/share/tts before running Gradint. For +# example vocoder_models--ja--kokoro--hifigan_v1 may crash. +# I did say support for these voices is experimental.) +# Gradint detects voices that have been downloaded +# (but prefer_espeak overrides this). The Chinese +# voice does NOT support pinyin. +# +# Gemini voices are experimentally supported on some platforms. +# Needs Gemini API key: they have a free-tier quota, but as of +# October 2025 it's just 3 queries a minute and 15 a day +# (10 a minute 100 a day if connected to a billing account) +# see https://ai.google.dev/gemini-api/docs/rate-limits +# so you might prefer to send a single comma-separated list of +# words to read and cut up the wav file it returns. +# And their language detection is automatic, which means it +# might not work for very short phrases and you might not be +# able to specify e.g. Mandarin vs Cantonese, so it's limited. +# Setup: pip install google-genai, put the API key into an +# environment variable called GEMINI_API_KEY +# (or set os.environ["GEMINI_API_KEY"] from here) +# and optionally set GEMINI_VOICES to a comma-separated list +# of voice names to use (randomly selected) # You can also set extra_speech to a list of # (language prefix, command), for example: @@ -350,7 +400,7 @@ lily_file = "C:\\Program Files\\NeoSpeech\\Lily16\\data-common\\userdict\\userdi # somewhere under C:\Program Files\VW\VT\Lily\M16-SAPI5\lib\ # but I don't know exactly) -# If you want to use SAPI under WINE in Linux +# If you want to use SAPI under WINE in GNU/Linux # then you can set ptts_program: ptts_program = None # (hint: run winecfg and set Windows version to Millenium (ME) @@ -759,7 +809,7 @@ gui_output_directory = "output" # in which case the first directory that EXISTS will be used # (or the last one on the list if all else fail). # Useful if the directory to your MP3 player only appears when -# it's plugged in for example. With Linux automounters you can +# it's plugged in for example. With GNU/Linux automounters # set "/media/*" as one of the directories, and it will expand to # whatever removable device is mounted IF there is only one. diff --git a/charlearn/README.md b/charlearn/README.md new file mode 100644 index 0000000..bd32cb3 --- /dev/null +++ b/charlearn/README.md @@ -0,0 +1,14 @@ +# charlearn +This program is meant to help you learn to recognise foreign characters, taking just a few minutes each day. It remembers which characters you have recently found difficult and what you confuse them with. It uses a simple HTML user interface, the appearance of which can be customised by user-supplied stylesheets or normal browser customisation. + +Note: in its present form, this program is not as effective with characters as [gradint](../README.md) is with audio. While I have a collection of thousands of words in gradint, charlearn only took me up to about 150 Chinese characters and then constant misidentifications virtually stalled any progress (but then I do have cortical visual impairment so your result may differ). It might be useful to get you started with 100 or so however. + +## Setup instructions +Make sure your machine has Python 2 (version 2.3+; download from www.python.org if necessary). Download [charlearn.py](charlearn.py) and [characters.txt](characters.txt) and save them both in the same place. Run charlearn.py with Python. + +That characters.txt contains frequently-used Chinese characters and their definitions from CEDICT. If you want to learn hiragana and katakana instead, download this [alternative characters.txt](jp/characters.txt). Alternatively you can make your own in the same format. (If you want to learn several things, put them in different directories and run charlearn.py separately. If you already know some characters, list them one per line in a file called known-chars.txt, using the same encoding as characters.txt and putting it in the same directory.) + +## Mobile phone version +A simpler flashcard program is available for running on mobile phones, using images to achieve large print. You will need a phone that can display short Web pages with some Javascript functionality, but it doesn’t have to be a “smartphone”—in 2008 I used it on a Nokia 6500s from Three, which was a Symbian S40 phone; similar “mid-range” handsets should also work (but not the very simple ones that don’t even have a browser). Download [charlearn.zip](https://ssb22.user.srcf.net/gradint/charlearn.zip) and put `flashcards.html` on the phone, together with the F folder which contains the pictures. Some pictures for hanzi-to-pinyin at 240x320 pixels are included, as is the program to generate them in case you need to do something different. Start the program on the phone by opening the flashcards.html file. + +Technical notes: You can also put this file and the `F` folder onto a Web server and browse the flashcards remotely, but that may incur a lot of bandwidth, so it would be better to transfer them onto the phone by cable etc. The Javascript does not do anything heavyweight like DOM, Cookies or Events; it uses only `document.write` and links back to itself, and you can save the state at any time using Bookmarks. diff --git a/charlearn/charlearn.py b/charlearn/charlearn.py index 45a37bf..ef340ac 100755 --- a/charlearn/charlearn.py +++ b/charlearn/charlearn.py @@ -256,7 +256,7 @@ def chooseChar(self): return self.thisSession.pop() def save(self): Pickler(open(dumpFile,"wb"),-1).dump(self) def countKnown(self): - charsSeen = sessnLen = charsSecure = newChars = 0 + charsSeen = sessnLen = newChars = 0 secure=[] ; insecure=[] self.chars.sort(key=byPriority) for c in self.chars: diff --git a/hanzi-prompts/begin_zh-yue.txt b/hanzi-prompts/begin_zh-yue.txt new file mode 100644 index 0000000..62cad49 --- /dev/null +++ b/hanzi-prompts/begin_zh-yue.txt @@ -0,0 +1 @@ +開頭 diff --git a/hanzi-prompts/end_zh-yue.txt b/hanzi-prompts/end_zh-yue.txt new file mode 100644 index 0000000..679afff --- /dev/null +++ b/hanzi-prompts/end_zh-yue.txt @@ -0,0 +1 @@ +今日個堂上完啦 diff --git a/hanzi-prompts/longpause_zh-yue.txt b/hanzi-prompts/longpause_zh-yue.txt new file mode 100644 index 0000000..18d9f6c --- /dev/null +++ b/hanzi-prompts/longpause_zh-yue.txt @@ -0,0 +1 @@ +而家我哋要等一陣,然後翻溫。喺第一課我哋仲未學習好多嘅詞語,所以停頓會比較長,但係喺未來嘅課程,我哋唔會有咁長嘅停頓 diff --git a/hanzi-prompts/meaningis_zh-yue.txt b/hanzi-prompts/meaningis_zh-yue.txt new file mode 100644 index 0000000..a4c75cb --- /dev/null +++ b/hanzi-prompts/meaningis_zh-yue.txt @@ -0,0 +1 @@ +意思係 diff --git a/hanzi-prompts/nowPleaseSay_zh-yue.txt b/hanzi-prompts/nowPleaseSay_zh-yue.txt new file mode 100644 index 0000000..92923db --- /dev/null +++ b/hanzi-prompts/nowPleaseSay_zh-yue.txt @@ -0,0 +1 @@ +而家請講 diff --git a/hanzi-prompts/pleaseSay_zh-yue.txt b/hanzi-prompts/pleaseSay_zh-yue.txt new file mode 100644 index 0000000..cce3b70 --- /dev/null +++ b/hanzi-prompts/pleaseSay_zh-yue.txt @@ -0,0 +1 @@ +請講 diff --git a/hanzi-prompts/repeatAfterMe_zh-yue.txt b/hanzi-prompts/repeatAfterMe_zh-yue.txt new file mode 100644 index 0000000..09aaa03 --- /dev/null +++ b/hanzi-prompts/repeatAfterMe_zh-yue.txt @@ -0,0 +1 @@ +請跟住講 diff --git a/hanzi-prompts/sayAgain_zh-yue.txt b/hanzi-prompts/sayAgain_zh-yue.txt new file mode 100644 index 0000000..13ca92f --- /dev/null +++ b/hanzi-prompts/sayAgain_zh-yue.txt @@ -0,0 +1 @@ +再講一次 diff --git a/hanzi-prompts/tryToSay_zh-yue.txt b/hanzi-prompts/tryToSay_zh-yue.txt new file mode 100644 index 0000000..d43c674 --- /dev/null +++ b/hanzi-prompts/tryToSay_zh-yue.txt @@ -0,0 +1 @@ +試吓講 diff --git a/hanzi-prompts/whatSay_zh-yue.txt b/hanzi-prompts/whatSay_zh-yue.txt new file mode 100644 index 0000000..aed1a57 --- /dev/null +++ b/hanzi-prompts/whatSay_zh-yue.txt @@ -0,0 +1 @@ +點講 diff --git a/hanzi-prompts/whatmean_zh-yue.txt b/hanzi-prompts/whatmean_zh-yue.txt new file mode 100644 index 0000000..0aaf415 --- /dev/null +++ b/hanzi-prompts/whatmean_zh-yue.txt @@ -0,0 +1 @@ +乜嘢意思? diff --git a/hanzi-prompts/whatmean_zh-yue_2.txt b/hanzi-prompts/whatmean_zh-yue_2.txt new file mode 100644 index 0000000..87e6d63 --- /dev/null +++ b/hanzi-prompts/whatmean_zh-yue_2.txt @@ -0,0 +1 @@ +係乜嘢意思? diff --git a/hanzi-prompts/whatmean_zh-yue_3.txt b/hanzi-prompts/whatmean_zh-yue_3.txt new file mode 100644 index 0000000..da79d2e --- /dev/null +++ b/hanzi-prompts/whatmean_zh-yue_3.txt @@ -0,0 +1 @@ +乜嘢意思呢? diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cd313a1 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +# Installing Gradint on GNU/Linux systems +# --------------------------------------- + +# Gradint does not need to be installed, it can +# just run from the current directory. + +# If you do want to make a system-wide installation +# (for example if you want to make a package for a +# GNU/Linux distribution), I suggest running as root +# the commands below. + +# For a distribution you might also have to write +# man pages and tidy up the help text etc. + +# Depends: python + a sound player (e.g. alsa-utils) +# Recommends: python-tk python-tksnack sox libsox-fmt-all madplay + +# --------------------------------------- + +set -e +PREFIX=/usr/local # or /usr +if which python >/dev/null 2>/dev/null; then PYTHON=python; else PYTHON=python3; fi + +mkdir -p "$PREFIX/share/gradint" +mv gradint.py "$PREFIX/share/gradint/" +cd samples/utils +for F in *.py *.sh; do + DestFile="$PREFIX/bin/gradint-$(echo $F|sed -e 's/\..*//')" + mv "$F" "$DestFile" + chmod +x "$DestFile" +done +cd ../.. ; rm -rf samples/utils +tar -zcf "$PREFIX/share/gradint/new-user.tgz" \ + advanced.txt settings.txt vocab.txt samples + +cat > "$PREFIX/bin/gradint" <<'EOF' +#!/bin/bash +if ! [ -e "$HOME/gradint" ]; then + echo -n "Unpacking new user Gradint configuration... " + mkdir "$HOME/gradint" + cd "$HOME/gradint" +EOF +echo " tar -zxf \"$PREFIX/share/gradint/new-user.tgz\"" >> "$PREFIX/bin/gradint" +cat >> "$PREFIX/bin/gradint" <<'EOF' + echo "done." +fi +cd "$HOME/gradint" +EOF +echo "$PYTHON \"$PREFIX/share/gradint/gradint.py\" "'$@' >> "$PREFIX/bin/gradint" +chmod +x "$PREFIX/bin/gradint" + +mkdir -p "$PREFIX/share/applications" +cat > "$PREFIX/share/applications/gradint.desktop" </dev/null|grep ^ProductVersion.*1[2-9]; then # macOS 12+ if test $(python3 -c 'import tkinter,sys;print(sys.version_info[:3]>=(3,10,1))' 2>/dev/null) = "True"; then exec python3 gradint.py; fi osascript -e "tell application (path to frontmost application as text) to display dialog \"macOS 12 bundled a broken version of the GUI libraries: please install Python 3 from python.org before running Gradint\" buttons {\"OK\"} with icon stop" diff --git a/samples/utils/autosplit.py b/samples/utils/autosplit.py old mode 100644 new mode 100755 diff --git a/samples/utils/cache-synth.py b/samples/utils/cache-synth.py old mode 100644 new mode 100755 diff --git a/samples/utils/cleanup-cache.py b/samples/utils/cleanup-cache.py old mode 100644 new mode 100755 diff --git a/samples/utils/diagram.py b/samples/utils/diagram.py old mode 100644 new mode 100755 diff --git a/samples/utils/list-synth.py b/samples/utils/list-synth.py old mode 100644 new mode 100755 diff --git a/samples/utils/list2cache.py b/samples/utils/list2cache.py old mode 100644 new mode 100755 diff --git a/samples/utils/manual-splitter.py b/samples/utils/manual-splitter.py old mode 100644 new mode 100755 diff --git a/samples/utils/player.py b/samples/utils/player.py old mode 100644 new mode 100755 index e09ed01..08af2d8 --- a/samples/utils/player.py +++ b/samples/utils/player.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # (should work in both Python 2 and Python 3) -# Simple sound-playing server v1.56 +# Simple sound-playing server v1.62 # Silas S. Brown - public domain - no warranty # connect to port 8124 (assumes behind firewall) @@ -13,11 +13,19 @@ import socket, select, os, sys, os.path, time, re for a in sys.argv[1:]: - if a.startswith("--rpi-bluetooth-setup"): # tested on Raspberry Pi 400 with Raspbian 11; also tested on Raspberry Pi Zero W with Raspbian 10 Lite (with the device already paired: needed to say "scan on", "discovery on", remove + pair in bluetoothctl). Send Eth=(bluetooth Ethernet addr) to start. Note that the setup command reboots the system. - os.system('if [ -e /etc/xdg/lxsession/LXDE-pi/autostart ]; then mkdir -p /home/pi/.config/lxsession/LXDE-pi && cp /etc/xdg/lxsession/LXDE-pi/autostart /home/pi/.config/lxsession/LXDE-pi/ && echo sudo ethtool --set-eee eth0 eee off >> /home/pi/.config/lxsession/LXDE-pi/autostart && echo python '+os.path.join(os.getcwd(),sys.argv[0])+' >> /home/pi/.config/lxsession/LXDE-pi/autostart; else (echo "[Unit]";echo "Descrption=Gradint player utility";echo "[Service]";echo "Type=oneshot";echo "ExecStart='+os.path.join(os.getcwd(),sys.argv[0])+'";echo "[Install]";echo "WantedBy=multi-user.target") > player.service && sudo mv player.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable player && chmod +x '+sys.argv[0]+' && awk '+"'"+'// {print} /^import / {print "os.system('+"'"+'"'+"'"+'"'+"'"+'pulseaudio --start'+"'"+'"'+"'"+'"'+"'"+')"}'+"'"+' < '+sys.argv[0]+' > .playerTMP && mv .playerTMP '+sys.argv[0]+'; fi && sudo "apt-get -y install sox mpg123 pulseaudio pulseaudio-module-bluetooth && usermod -G bluetooth -a pi && (echo load-module module-switch-on-connect;echo load-module module-bluetooth-policy;echo load-module module-bluetooth-discover) >> /etc/pulse/default.pa && (echo [General];echo FastConnectable = true) >> /etc/bluetooth/main.conf && reboot"') # (eee off: improves reliability of gigabit ethernet on RPi400) - elif a=="--aplay": use_aplay = True # aplay and madplay, for older embedded devices, NOT tested together with --rpi-bluetooth-* above + if a.startswith("--rpi-bluetooth-setup"): + # tested on Raspberry Pi 400, Raspberry Pi Zero W and Raspberry Pi 1 B+ with OS versions 10 through 13 + # Note that the setup command reboots the system. + # You can add extra arguments like --chime= and --aplay= (below) to be installed in the service. + # Once set up, send Eth=(bluetooth Ethernet addr) to start. + # You might need to pair via bluetoothctl power on / agent on / scan on / pair ... before 1st use. + os.system(r'if ! grep "$(cat ~/.ssh/*.pub)" ~/.ssh/authorized_keys; then cat ~/.ssh/*.pub >> ~/.ssh/authorized_keys;fi && (echo "[Unit]";echo "Description=Gradint player utility";echo "[Service]";echo "Type=oneshot";echo "ExecStart=bash -c \"while ! ssh localhost true; do sleep 1; done; ssh localhost '+os.path.join(os.getcwd(),sys.argv[0])+''.join(" "+x for x in sys.argv[1:] if not x==a)+r'\"";echo "WorkingDirectory='+os.getcwd()+'";echo User="$(whoami)";echo "[Install]";echo "WantedBy=multi-user.target") > player.service && sudo mv player.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable player && chmod +x '+sys.argv[0]+' && sudo bash -c "apt-get -y install sox mpg123 pulseaudio pulseaudio-module-bluetooth && usermod -G bluetooth -a $USER && (echo load-module module-switch-on-connect;echo load-module module-bluetooth-policy;echo load-module module-bluetooth-discover) >> /etc/pulse/default.pa && (echo [General];echo FastConnectable = true) >> /etc/bluetooth/main.conf && reboot"') # (eee off: improves reliability of gigabit ethernet on RPi400) + elif a.startswith("--aplay"): # aplay and madplay, for older embedded devices (can also be used with --rpi-bluetooth-setup in place, to do Eth=N over Bluetooth and Eth0 over local aplay, but this requires you set --aplay=hw:1,0 or whatever the local device is) + use_aplay = a[len("--aplay="):] + if use_aplay: use_aplay = " -D "+use_aplay + else: use_aplay = " " # non-"" elif a.startswith("--delegate="): delegate_to_check=a.split('=')[1] # will ping that IP and delegate all sound to it when it's up. E.g. if it has better amplification but it's not always switched on. - elif a.startswith("--chime="): chime_mp3=a.split('=')[1] # if clock bell desired, e.g. echo '$i-14vfff$c48o0l1b- @'|mwr2ly > chime.ly && lilypond chime.ly && timidity -Ow chime.midi && audacity chime.wav (amplify + trim) + mp3-encode (keep default 44100 sample rate so ~38 frames per sec). Not designed to work with --delegate. Pi1's 3.5mm o/p doesn't sound very good with this bell. + elif a.startswith("--chime="): chime_mp3=a.split('=')[1] # if clock bell desired, e.g. echo '$i-14vfff$c48o0l1b- @'|mwr2ly > chime.ly && lilypond chime.ly && timidity -Ow chime.midi && audacity chime.wav (amplify + trim) + mp3-encode (keep default 44100 sample rate so ~38 frames per sec). Always plays to local device. Pi1's 3.5mm o/p doesn't sound very good with this bell. else: assert 0, "unknown option "+a os.environ["PATH"] += ":/usr/local/bin" @@ -45,8 +53,8 @@ else: numChimes = h%12 if not 7<=h%24<=22: pass # silence the chime at night elif use_aplay: - if numChimes > 1: os.system("(madplay -Q -t 1 -o wav:- '"+chime_mp3+"'"+(";madplay -Q -t 1 -o raw:- '"+chime_mp3+"'")*(numChimes-2)+";madplay -Q -o raw:- '"+chime_mp3+"') | aplay -q") - else: os.system("madplay -Q -o wav:- '%s' | aplay -q" % chime_mp3) + if numChimes > 1: os.system("(madplay -Q -t 1 -o wav:- '"+chime_mp3+"'"+(";madplay -Q -t 1 -o raw:- '"+chime_mp3+"'")*(numChimes-2)+";madplay -Q -o raw:- '"+chime_mp3+"') | aplay -q"+use_aplay) + else: os.system("madplay -Q -o wav:- '%s' | aplay -q %s" % (chime_mp3,use_aplay)) elif numChimes > 1: os.system("(mpg123 -w - -n 38 --loop %d '%s' ; mpg123 -s '%s') 2>/dev/null | play -t wav --ignore-length - 2>/dev/null" % (numChimes-1,chime_mp3,chime_mp3)) else: os.system("mpg123 -q '%s'" % chime_mp3) if not select.select([s],[],[],1800-time.time()%1800)[0]: continue @@ -57,7 +65,7 @@ c.close() ; continue if delegate_to_check and not a==delegate_to_check and delegate_known_down < time.time()-60 and not os.system("ping -c 1 -w 0.5 '"+delegate_to_check+"' >/dev/null 2>/dev/null"): player = "nc -N '"+delegate_to_check+"' 8124" elif d=='RIFF': # WAV - if use_aplay: player = "aplay -q" + if use_aplay and not eth: player="aplay -q"+use_aplay else: player = "play - 2>/dev/null" elif d=='STOP': c.close() @@ -69,15 +77,15 @@ continue elif d=='QUIT': s.close() ; break - elif d=="Eth=": # Eth=ethernet address, to connect via Bluetooth, tested on Raspberry Pi 400 with Raspbian 11 + elif d=="Eth=": # Eth=ethernet address to connect via Bluetooth (see --rpi-bluetooth-setup above) eth = S(c.recv(17)) - assert re.match("^[A-Fa-f0-9:]*$",eth) - os.system("M=/dev/null;E="+eth+";if ! pacmd list-sinks | grep "+eth.replace(":","_")+" >$M; then while true; do bluetoothctl --timeout 1 disconnect | grep Missing >$M||sleep 5;T=5;while ! bluetoothctl --timeout $T connect $E | tee $M | egrep \"Connection successful|Device $E Connected: yes\"; do sleep 5; T=10;M=/dev/stderr;bluetoothctl --timeout 1 devices;echo Retrying $E; done ; Got=0; for Try in 1 2 3 4 5 6 7 8 9 a b c d e f g h i j k l m n o p q r s t u v w x y z; do if pacmd list-sinks | grep "+eth.replace(":","_")+" >/dev/null; then Got=1; break; fi; sleep 1; done; if [ $Got = 1 ] ; then break; fi; done; fi; pacmd set-default-sink bluez_sink."+eth.replace(":","_")+".a2dp_sink") # ; play /usr/share/scratch/Media/Sounds/Animal/Dog1.wav # (not really necessary if using 'close the socket' to signal we're ready) + assert re.match("^[A-Fa-f0-9:]+$",eth) + os.system("E="+eth+";if ! pacmd list-sinks | grep -i "+eth.replace(":","_")+" >/dev/null; then while true; do rfkill block bluetooth; rfkill unblock bluetooth; bluetoothctl --timeout 1 power on; bluetoothctl --timeout 1 disconnect | grep Missing >/dev/null||sleep 5;T=5;while ! bluetoothctl --timeout $T connect $E | egrep -i \"Connection successful|Device $E Connected: yes\"; do sleep 5; T=10;bluetoothctl --timeout 1 devices;echo Retrying $E; done ; Got=0; for Try in 1 2 3 4 5 6 7 8 9 a b c d e f g h i j k l m n o p q r s t u v w x y z; do if pacmd list-sinks | grep -i "+eth.replace(":","_")+" >/dev/null; then Got=1; break; fi; sleep 1; done; if [ $Got = 1 ] ; then break; fi; done; fi; pacmd set-default-sink bluez_sink."+eth.replace(":","_")+".a2dp_sink") # ; play /usr/share/scratch/Media/Sounds/Animal/Dog1.wav # (not really necessary if using 'close the socket' to signal we're ready) c.close() ; continue elif d=="Eth0": if eth: os.system("bluetoothctl --timeout 1 disconnect "+eth) c.close() ; continue - elif use_aplay: player = "madplay -Q -o wav:- - | aplay -q" # MP3 + elif use_aplay and not eth: player = "madplay -Q -o wav:- - | aplay -q"+use_aplay # MP3 else: player = "mpg123 - 2>/dev/null" # MP3 non-aplay if delegate_known_down < time.time()-60 and not player.startswith("nc -N "): delegate_known_down = time.time() player = os.popen(player,"w") diff --git a/samples/utils/recover-unavail.py b/samples/utils/recover-unavail.py old mode 100644 new mode 100755 diff --git a/samples/utils/synth-batchconvert-helper.py b/samples/utils/synth-batchconvert-helper.py old mode 100644 new mode 100755 diff --git a/samples/utils/trace.py b/samples/utils/trace.py old mode 100644 new mode 100755 diff --git a/samples/utils/transliterate.py b/samples/utils/transliterate.py old mode 100644 new mode 100755 diff --git a/server/cantonese.py b/server/cantonese.py old mode 100644 new mode 100755 index a5698c1..ecc4576 --- a/server/cantonese.py +++ b/server/cantonese.py @@ -5,7 +5,7 @@ # cantonese.py - Python functions for processing Cantonese transliterations # (uses eSpeak and Gradint for help with some of them) -# v1.42 (c) 2013-15,2017-23 Silas S. Brown. License: GPL +# v1.48 (c) 2013-15,2017-24 Silas S. Brown. License: GPL cache = {} # to avoid repeated eSpeak runs, # zi -> jyutping or (pinyin,) -> translit @@ -64,7 +64,7 @@ def hanzi_only(unitext): return u"".join(filter(lambda x:0x4e00<=ord(x)<0xa700 o def py2nums(pinyin): if not type(pinyin)==type(u""): pinyin = pinyin.decode('utf-8') - assert pinyin.strip(), "blank pinyin" # saves figuring out a findall TypeError + if not pinyin.strip(): return "" global pinyin_dryrun if pinyin_dryrun: pinyin_dryrun = list(pinyin_dryrun) @@ -91,7 +91,7 @@ def adjust_jyutping_for_pinyin(hanzi,jyutping,pinyin): i = 0 ; tones = re.finditer('[1-7]',jyutping) ; j2 = [] for h,p in zip(list(hanzi),pinyin): try: j = getNext(tones).end() - except StopIteration: return jyutping # one of the zin has no Cantonese reading, which we'll pick up later on "failed to fix" + except StopIteration: return jyutping # one of the hanzi has no Cantonese reading in our data: we'll warn "failed to fix" below j2.append(jyutping[i:j]) ; i = j if h in py2j and p.lower() in py2j[h]: j2[-1]=j2[-1][:re.search("[A-Za-z]*[1-7]$",j2[-1]).start()]+py2j[h][p.lower()] return "".join(j2)+jyutping[i:] @@ -100,8 +100,9 @@ def adjust_jyutping_for_pinyin(hanzi,jyutping,pinyin): u"\u4E3A\u70BA":{"wei2":"wai4","wei4":"wai6"}, u"\u4E50\u6A02":{"le4":"lok6","yue4":"ngok6"}, u"\u4EB2\u89AA":{"qin1":"can1","qing4":"can3"}, +u"\u4EC0":{"shen2":"sam6","shi2":"sap6"}, # unless zaap6 u"\u4F20\u50B3":{"chuan2":"cyun4","zhuan4":"zyun6"}, -u"\u4FBF":{"bian4":"pin4","pian2":"bin6"}, +u"\u4FBF":{"bian4":"bin6","pian2":"pin4"}, u"\u5047":{"jia3":"gaa2","jia4":"gaa3"}, u"\u5174\u8208":{"xing1":"hing1","xing4":"hing3"}, # u"\u5207":{"qie4":"cai3","qie1":"cit3"}, # WRONG (rm'd v1.17). It's cit3 in re4qie4. It just wasn't in yiqie4 (which zhy_list has as an exception anyway) @@ -153,10 +154,10 @@ def adjust_jyutping_for_pinyin(hanzi,jyutping,pinyin): def jyutping_to_lau(j): j = S(j).lower().replace("j","y").replace("z","j") for k,v in jlRep: j=j.replace(k,v) - return j.lower().replace("aa","a").replace("ohek","euk") + return j.lower().replace("ohek","euk") def jyutping_to_lau_java(jyutpingNo=2,lauNo=1): # for annogen.py 3.29+ --annotation-postprocess to ship Jyutping and generate Lau at runtime - return 'if(annotNo=='+str(jyutpingNo)+'||annotNo=='+str(lauNo)+'){m=Pattern.compile("(.*?)").matcher(r);sb=new StringBuffer();while(m.find()){String r2=(annotNo=='+str(jyutpingNo)+'?m.group(1).replaceAll("([1-7])(.)","$1­$2"):(m.group(1)+" ").toLowerCase().replace("j","y").replace("z","j")'+''.join('.replace("'+k+'","'+v+'")' for k,v in jlRep)+'.toLowerCase().replace("aa","a").replace("ohek","euk").replaceAll("([1-7])","$1-").replace("- "," ").replaceAll(" $","")),tmp=m.group(1).substring(0,1);if(annotNo=='+str(lauNo)+'&&tmp.equals(tmp.toUpperCase()))r2=r2.substring(0,1).toUpperCase()+r2.substring(1);m.appendReplacement(sb,""+r2+"");}m.appendTail(sb); r=sb.toString();}' # TODO: can probably go faster with mapping for some of this + return 'if(annotNo=='+str(jyutpingNo)+'||annotNo=='+str(lauNo)+'){m=Pattern.compile("(.*?)").matcher(r);sb=new StringBuffer();while(m.find()){String r2=(annotNo=='+str(jyutpingNo)+'?m.group(1).replaceAll("([1-7])(.)","$1­$2"):(m.group(1)+" ").toLowerCase().replace("j","y").replace("z","j")'+''.join('.replace("'+k+'","'+v+'")' for k,v in jlRep)+'.toLowerCase().replace("ohek","euk").replaceAll("([1-7])","$1-").replace("- "," ").replaceAll(" $","")),tmp=m.group(1).substring(0,1);if(annotNo=='+str(lauNo)+'&&tmp.equals(tmp.toUpperCase()))r2=r2.substring(0,1).toUpperCase()+r2.substring(1);m.appendReplacement(sb,""+r2+"");}m.appendTail(sb); r=sb.toString();}' # TODO: can probably go faster with mapping for some of this def incomplete_lau_to_jyutping(l): # incomplete: assumes Lau didn't do the "aa" -> "a" rule l = S(l).lower().replace("euk","ohek") @@ -236,7 +237,10 @@ def mysub(z,l): z = re.sub(re.escape(x)+r"(.)",r"\1"+y,z) return z if type(u"")==type(""): U=str # Python 3 - else: U=unicode # Python 2 + else: # Python 2 + def U(x): + try: return x.decode('utf-8') # might be an emoji pass-through + except: return x # already Unicode return unicodedata.normalize('NFC',mysub(U(jyutping_to_yale_TeX(j).replace(r"\i{}","i").replace(r"\I{}","I")),[(r"\`",u"\u0300"),(r"\'",u"\u0301"),(r"\=",u"\u0304")])).encode('utf-8') def superscript_digits_TeX(j): @@ -291,6 +295,9 @@ def songSubst(l): pinyin = pinyin.decode('utf-8') if pinyin and not (pinyin,) in cache: pinyin_dryrun.add(pinyin) + for w in pinyin.split(): + for h in w.split('-'): + pinyin_dryrun.add(h) dryrun_mode = False for l in lines: if '#' in l: l,pinyin = l.split('#') @@ -300,7 +307,7 @@ def songSubst(l): elif pinyin: jyutping = adjust_jyutping_for_pinyin(l,jyutping,pinyin) groupLens = [0] - for syl,space in re.findall('([A-Za-z]*[1-5])( *)',py2nums(pinyin)): + for syl,space in re.findall('([A-Za-z]*[1-5])( *)',' '.join('-'.join(py2nums(h) for h in w.split('-')) for w in pinyin.split())): # doing it this way so we're not relying on espeak transliterate_multiple to preserve spacing and hyphenation groupLens[-1] += 1 if space: groupLens.append(0) if not groupLens[-1]: groupLens=groupLens[:-1] diff --git a/server/email-lesson.sh b/server/email-lesson.sh index 8406ee7..17e0d95 100755 --- a/server/email-lesson.sh +++ b/server/email-lesson.sh @@ -3,9 +3,9 @@ # email-lesson.sh: a script that can help you to # automatically distribute daily Gradint lessons # to students using a web server with reminder -# emails. Version 1.15 +# emails. Version 1.16 -# (C) 2007-2010,2020-2022 Silas S. Brown, License: GPL +# (C) 2007-2010,2020-2022,2024 Silas S. Brown, License: GPL # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -39,7 +39,7 @@ elif which mutt >/dev/null 2>/dev/null; then DefaultMailProg="mutt -x" else DefaultMailProg="ssh example.org mail" fi -if test "a$1" == "a--run"; then +if [ "$1" == "--run" ]; then set -o pipefail # make sure errors in pipes are reported if ! [ -d email_lesson_users ]; then echo "Error: script does not seem to have been set up yet" @@ -61,14 +61,14 @@ if test "a$1" == "a--run"; then while true; do ssh -C $PUBLIC_HTML_EXTRA_SSH_OPTIONS -n -o ControlMaster=yes $ControlPath $(echo "$PUBLIC_HTML"|sed -e 's/:.*//') sleep 86400; sleep 10; done & MasterPid=$! else unset MasterPid fi - (while ! bash -c "$CAT_LOGS_COMMAND"; do echo "cat-logs failed, re-trying in 61 seconds" 1>&2;sleep 61; done) | grep '/user\.' > "$TMPDIR/._email_lesson_logs" + (while ! bash -c "$CAT_LOGS_COMMAND"; do echo "cat-logs failed, re-trying in 61 seconds" >&2;sleep 61; done) | grep '/user\.' > "$TMPDIR/._email_lesson_logs" # (note: sleeping odd numbers of seconds so we can tell where it is if it gets stuck in one of these loops) Users="$(echo user.*)" cd .. unset NeedRunMirror for U in $Users; do . email_lesson_users/config - if ! test "a$GLOBAL_GRADINT_OPTIONS" == a; then GLOBAL_GRADINT_OPTIONS="$GLOBAL_GRADINT_OPTIONS ;"; fi + if [ "$GLOBAL_GRADINT_OPTIONS" ]; then GLOBAL_GRADINT_OPTIONS="$GLOBAL_GRADINT_OPTIONS ;"; fi # set some (but not all!) variables to defaults in case not set in profile SUBJECT_LINE="$DEFAULT_SUBJECT_LINE" FORGOT_YESTERDAY="$DEFAULT_FORGOT_YESTERDAY" @@ -85,7 +85,7 @@ if test "a$1" == "a--run"; then mv "email_lesson_users/$U/profile.removeCR" "email_lesson_users/$U/profile" fi . "email_lesson_users/$U/profile" - if test "a$Use_M3U" == ayes; then FILE_TYPE_2=m3u + if [ "$Use_M3U" == yes ]; then FILE_TYPE_2=m3u else FILE_TYPE_2=$FILE_TYPE; fi if echo "$MailProg" | grep ssh >/dev/null; then # ssh discards a level of quoting, so we need to be more careful @@ -94,7 +94,7 @@ if test "a$1" == "a--run"; then Extra_Mailprog_Params2="\"$Extra_Mailprog_Params2\"" fi if [ -e "email_lesson_users/$U/lastdate" ]; then - if test "$(cat "email_lesson_users/$U/lastdate")" == "$(date +%Y%m%d)"; then + if [ "$(cat "email_lesson_users/$U/lastdate")" == "$(date +%Y%m%d)" ]; then # still on same day - do nothing with this user this time continue fi @@ -114,10 +114,10 @@ if test "a$1" == "a--run"; then fi else Did_Download=1; fi rm -f "email_lesson_users/$U/rollback" - if test $Did_Download == 0; then + if [ $Did_Download == 0 ]; then # send a reminder DaysOld="$(python -c "import os,time;print(int((time.time()-os.stat('email_lesson_users/$U/lastdate').st_mtime)/3600/24))")" - if test $DaysOld -lt 5 || test $(date +%u) == 1; then # (remind only on Mondays if not checked for 5 days, to avoid filling up inboxes when people are away and can't get to email) + if [ $DaysOld -lt 5 ] || [ $(date +%u) == 1 ]; then # (remind only on Mondays if not checked for 5 days, to avoid filling up inboxes when people are away and can't get to email) while ! $MailProg -s "$SUBJECT_LINE" "$STUDENT_EMAIL" "$Extra_Mailprog_Params1" "$Extra_Mailprog_Params2" </dev/null; then OUTDIR=$TMPDIR else OUTDIR=$PUBLIC_HTML; fi USER_GRADINT_OPTIONS="$GLOBAL_GRADINT_OPTIONS $GRADINT_OPTIONS samplesDirectory='email_lesson_users/$U/samples'; progressFile='email_lesson_users/$U/progress.txt'; pickledProgressFile='email_lesson_users/$U/progress.bin'; vocabFile='email_lesson_users/$U/vocab.txt';saveLesson='';loadLesson=0;progressFileBackup='email_lesson_users/$U/progress.bak';outputFile=" @@ -147,14 +147,14 @@ do echo "mail sending failed; retrying in 62 seconds"; sleep 62; done; fi tail -$NumLines "email_lesson_users/$U/podcasts-to-send" > "email_lesson_users/$U/podcasts-to-send2" mv "email_lesson_users/$U/podcasts-to-send" "email_lesson_users/$U/podcasts-to-send.old" mv "email_lesson_users/$U/podcasts-to-send2" "email_lesson_users/$U/podcasts-to-send" - if test $NumLines == 0; then + if [ $NumLines == 0 ]; then echo "$U" | $MailProg -s Warning:email-lesson-run-out-of-podcasts $ADMIN_EMAIL fi else rm -f "email_lesson_users/$U/podcasts-to-send.old" # won't be a rollback after this fi - if test "$ENCODE_ON_REMOTE_HOST" == 1; then + if [ "$ENCODE_ON_REMOTE_HOST" == 1 ]; then ToSleep=123 - while ! if test "a$Send_Podcast_Instead" == a; then + while ! if [ ! "$Send_Podcast_Instead" ]; then python gradint.py "$USER_GRADINT_OPTIONS '-.sh'" "$TMPDIR/__stderr" | ssh -C $PUBLIC_HTML_EXTRA_SSH_OPTIONS $ControlPath $(echo "$PUBLIC_HTML"|sed -e 's/:.*//') "mkdir -p $REMOTE_WORKING_DIR; cd $REMOTE_WORKING_DIR; cat > __gradint.sh;chmod +x __gradint.sh;PATH=$SOX_PATH ./__gradint.sh|$ENCODING_COMMAND $(echo $PUBLIC_HTML|sed -e 's/[^:]*://')/$U-$CurDate.$FILE_TYPE;rm -f __gradint.sh"; else cd "email_lesson_users/$U" ; cat "$Send_Podcast_Instead" | ssh -C $PUBLIC_HTML_EXTRA_SSH_OPTIONS $ControlPath $(echo "$PUBLIC_HTML"|sed -e 's/:.*//') "cat > $(echo $PUBLIC_HTML|sed -e 's/[^:]*://')/$U-$CurDate.$FILE_TYPE"; cd ../..; @@ -166,18 +166,18 @@ do echo "mail sending failed; retrying in 62 seconds"; sleep 62; done; fi sleep $ToSleep ; ToSleep=$[$ToSleep*1.5] # (increasing-time retries) done rm "$TMPDIR/__stderr" - if test "a$Use_M3U" == ayes; then + if [ "$Use_M3U" == yes ]; then while ! ssh -C $PUBLIC_HTML_EXTRA_SSH_OPTIONS $ControlPath $(echo "$PUBLIC_HTML"|sed -e 's/:.*//') "echo $OUTSIDE_LOCATION/$U-$CurDate.$FILE_TYPE > $(echo $PUBLIC_HTML|sed -e 's/[^:]*://')/$U-$CurDate.m3u"; do sleep 63; done fi else # not ENCODE_ON_REMOTE_HOST - if ! test "a$Send_Podcast_Instead" == a; then + if [ "$Send_Podcast_Instead" ]; then (cd "email_lesson_users/$U" ; cat "$Send_Podcast_Instead") > "$OUTDIR/$U-$CurDate.$FILE_TYPE" elif ! python gradint.py "$USER_GRADINT_OPTIONS '$OUTDIR/$U-$CurDate.$FILE_TYPE'" "$OUTDIR/$U-$CurDate.m3u" fi if echo "$PUBLIC_HTML" | grep : >/dev/null; then @@ -200,14 +200,14 @@ EOF do echo "mail sending failed; retrying in 65 seconds"; sleep 65; done echo "$CurDate" > "email_lesson_users/$U/lastdate" unset AdminNote - if test "a$Send_Podcast_Instead" == a; then - if test "$(zgrep -H -m 1 lessonsLeft "email_lesson_users/$U/progress.txt"|sed -e 's/.*=//')" == 0; then AdminNote="Note: $U has run out of new words"; fi + if [ "$Send_Podcast_Instead" == a ]; then + if [ "$(zgrep -H -m 1 lessonsLeft "email_lesson_users/$U/progress.txt"|sed -e 's/.*=//')" == 0 ]; then AdminNote="Note: $U has run out of new words"; fi elif ! [ -e "email_lesson_users/$U/podcasts-to-send" ]; then AdminNote="Note: $U has run out of podcasts"; fi - if ! test "a$AdminNote" == a; then + if [ "$AdminNote" ]; then while ! echo "$AdminNote"|$MailProg -s gradint-user-ran-out "$ADMIN_EMAIL"; do echo "Mail sending failed; retrying in 67 seconds"; sleep 67; done fi done # end of per-user loop - if test "a$NeedRunMirror" == "a1" && ! test "a$PUBLIC_HTML_MIRROR_COMMAND" == a; then + if [ "$NeedRunMirror" == "1" ] && [ "$PUBLIC_HTML_MIRROR_COMMAND" ]; then while ! $PUBLIC_HTML_MIRROR_COMMAND; do echo "PUBLIC_HTML_MIRROR_COMMAND failed; retrying in 79 seconds" echo As subject | $MailProg -s "PUBLIC_HTML_MIRROR_COMMAND failed, will retry" "$ADMIN_EMAIL" || true # ignore errors @@ -215,9 +215,9 @@ do echo "mail sending failed; retrying in 65 seconds"; sleep 65; done done fi rm -f "$TMPDIR/._email_lesson_logs" - if ! test a$MasterPid == a; then + if [ $MasterPid ] ; then kill $MasterPid - kill $(ps axwww|grep "$TMPDIR/__gradint_ctrl"|sed -e 's/^ *//' -e 's/ .*//') 2>/dev/null + kill $(pgrep -f "$TMPDIR/__gradint_ctrl") 2>/dev/null rm -f "$TMPDIR/__gradint_ctrl" # in case ssh doesn't fi rm -f "$Gradint_Dir/.email-lesson-running" @@ -227,7 +227,7 @@ fi echo "After setting up users, run this script daily with --run on the command line." echo "As --run was not specified, it will now go into setup mode." # Setup: -if test "a$EDITOR" == a; then +if ! [ "$EDITOR" ]; then echo "Error: No EDITOR environment variable set"; exit 1 fi if ! [ -e email_lesson_users/config ]; then @@ -286,7 +286,7 @@ while true; do echo "Type a user alias (or just press Enter) to add a new user, or Ctrl-C to quit" read Alias ID=$(mktemp -d user.$(python -c 'import random; print(random.random())')XXXXXX) # (newer versions of mktemp allow more than 6 X's so the python step isn't necessary, but just in case we want to make sure that it's hard to guess the ID) - if ! test "a$Alias" == a; then ln -s "$ID" "$Alias"; fi + if [ "$Alias" ]; then ln -s "$ID" "$Alias"; fi cd "$ID" || exit 1 cat > profile <') # (specify utf-8 here in case accept-charset is not recognised, e.g. some versions of IE6) + sys.stdout.write('Content-Type: text/html; charset=utf-8\n\n') # (specify utf-8 here in case accept-charset is not recognised, e.g. some versions of IE6) banner = S(getoutput(prog+" --help|head -3").strip()) - sys.stdout.write("This is espeak.cgi version "+version+", using eSpeak "+" ".join(banner.split()[1:])) + sys.stdout.write("This is espeak.cgi version "+version+', using eSpeak '+" ".join(banner.split()[1:])) if not loc: sys.stdout.write("
Warning: could not find a UTF-8 locale; espeak may malfunction on some languages") warnings=S(getoutput(prog+" -q -x .").strip()) # make sure any warnings about locales are output if warnings: sys.stdout.write("
"+warnings) - sys.stdout.write("
Text or SSML:
Language:
Language: Voice: Voice: Speed: Speed:
") + sys.stdout.write('") + sys.stdout.write('
') if fname: os.system("rm -rf \""+fname+"\"") # clean up temp dir diff --git a/server/gradint.cgi b/server/gradint.cgi index 9df0bc9..39e2a3b 100755 --- a/server/gradint.cgi +++ b/server/gradint.cgi @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # (either Python 2 or Python 3) -program_name = "gradint.cgi v1.32 (c) 2011,2015,2017-22 Silas S. Brown. GPL v3+" +program_name = "gradint.cgi v1.38 (c) 2011,2015,2017-25 Silas S. Brown. GPL v3+" # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -14,16 +14,23 @@ program_name = "gradint.cgi v1.32 (c) 2011,2015,2017-22 Silas S. Brown. GPL v3+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. +# If your Python is 3.13 or above (expected in Ubuntu 2.26 LTS) +# then you will need: pip install legacy-cgi + gradint_dir = "$HOME/gradint" # include samples/prompts path_add = "$HOME/gradint/bin" # include sox, lame, espeak, maybe oggenc lib_path_add = "$HOME/gradint/lib" espeak_data_path = "$HOME/gradint" -import os, os.path, sys, cgi, urllib, time +import os, os.path, sys, cgi, urllib, time, re # if this fails, see note above +import tempfile, getpass +myTmp = tempfile.gettempdir()+os.sep+getpass.getuser()+"-gradint-cgi" try: from commands import getoutput # Python 2 except: from subprocess import getoutput # Python 3 try: from urllib import quote,quote_plus,unquote # Python 2 except: from urllib.parse import quote,quote_plus,unquote # Python 3 +try: from importlib import reload # Python 3 +except: pass home = os.environ.get("HOME","") if not home: try: @@ -52,30 +59,40 @@ sys.stderr=open("/dev/null","w") ; sys.argv = [] gradint = None def reinit_gradint(): # if calling again, also redo setup_userID after global gradint,langFullName - if gradint: gradint = reload(gradint) + if gradint: + if sys.version_info[0]>2: gradint.map,gradint.filter,gradint.chr=gradint._map,gradint._filter,gradint.unichr # undo Python 3 workaround in preparation for it to be done again, because reload doesn't do this (at least not on all Python versions) + gradint = reload(gradint) else: import gradint gradint.waitOnMessage = lambda *args:False langFullName = {} for l in gradint.ESpeakSynth().describe_supported_languages().split(): abbr,name = gradint.S(l).split("=") - langFullName[abbr]=name + langFullName[abbr]=name.replace("_","-") # Try to work out probable default language: lang = os.environ.get("HTTP_ACCEPT_LANGUAGE","") if lang: for c in [',',';','-']: if c in lang: lang=lang[:lang.index(c)] if not lang in langFullName: lang="" + global noGTranslate if lang: gradint.firstLanguage = lang - if not lang=="en": gradint.secondLanguage="en" - elif " zh-" in os.environ.get("HTTP_USER_AGENT",""): gradint.firstLanguage,gradint.secondLanguage = "zh","en" # Chinese iPhone + if lang=="en": noGTranslate = True + else: + gradint.secondLanguage="en" # (most probable default) + noGTranslate = lang in gradint.GUI_translations # (unless perhaps any are incomplete) + elif " zh-" in os.environ.get("HTTP_USER_AGENT",""): # Chinese iPhone w/out Accept-Language + gradint.firstLanguage,gradint.secondLanguage = "zh","en" + noGTranslate = True # (don't know if it even pops up on that browser, but anyway) reinit_gradint() def main(): if "id" in query: # e.g. from redirectHomeKeepCookie - os.environ["HTTP_COOKIE"]="id="+query.getfirst("id") - print ('Set-Cookie: id=' + query.getfirst("id")+'; expires=Wed, 1 Dec 2036 23:59:59 GMT') + queryID = query.getfirst("id") + if not re.match("[A-Za-z0-9_.-]",queryID): return htmlOut("Bad query.  Bad, bad query.") # to avoid cluttering the disk if we're being given random queries by an attacker. IDs we generate are numeric only, but allow alphanumeric in case server admin wants to generate them. Don't allow =, parens, etc (likely random SQL query) + os.environ["HTTP_COOKIE"]="id="+queryID + print ('Set-Cookie: id=' + queryID+'; expires=Wed, 1 Dec 2036 23:59:59 GMT') # TODO: S2G if has_userID(): setup_userID() # always, even for justSynth, as it may include a voice selection (TODO consequently being called twice in many circumstances, could make this more efficient) filetype="" if "filetype" in query: filetype=query.getfirst("filetype") @@ -95,19 +112,19 @@ def main(): gradint.justSynthesize="0" if "l2w" in query and query.getfirst("l2w"): gradint.startBrowser=lambda *args:0 - if query.getfirst("l2")=="zh" and gradint.sanityCheck(query.getfirst("l2w"),"zh"): gradint.justSynthesize += "#en Pinyin needs tones. Please go back and add tone numbers." # speaking it because alert box might not work and we might be being called from HTML5 Audio stuff (TODO maybe duplicate sanityCheck in js, if so don't call HTML5 audio, then we can have an on-screen message here) + if query.getfirst("l2")=="zh" and gradint.generalCheck(query.getfirst("l2w"),"zh"): gradint.justSynthesize += "#en Pinyin needs tones. Please go back and add tone numbers." # speaking it because alert box might not work and we might be being called from HTML5 Audio stuff (TODO maybe duplicate generalCheck in js, if so don't call HTML5 audio, then we can have an on-screen message here) else: gradint.justSynthesize += "#"+query.getfirst("l2").replace("#","").replace('"','')+" "+query.getfirst("l2w").replace("#","").replace('"','') if "l1w" in query and query.getfirst("l1w"): gradint.justSynthesize += "#"+query.getfirst("l1").replace("#","").replace('"','')+" "+query.getfirst("l1w").replace("#","").replace('"','') - if gradint.justSynthesize=="0": return htmlOut('You must type a word in the box before pressing the Speak button.'+backLink) # TODO maybe add a Javascript test to the form also, IF can figure out if window.alert works + if gradint.justSynthesize=="0": return htmlOut(withLocalise('You must type a word in the box before pressing the Speak button.')+backLink) # TODO maybe add a Javascript test to the form also, IF can figure out if window.alert works serveAudio(stream = len(gradint.justSynthesize)>100, filetype=filetype) elif "add" in query: # add to vocab (l1,l2 the langs, l1w,l2w the words) if "l2w" in query and query.getfirst("l2w") and "l1w" in query and query.getfirst("l1w"): gradint.startBrowser=lambda *args:0 - if query.getfirst("l2")=="zh": scmsg=gradint.sanityCheck(query.getfirst("l2w"),"zh") - else: scmsg=None - if scmsg: htmlOut(gradint.B(scmsg)+gradint.B(backLink)) + if query.getfirst("l2")=="zh": gcmsg=gradint.generalCheck(query.getfirst("l2w"),"zh") + else: gcmsg=None + if gcmsg: htmlOut(gradint.B(gcmsg)+gradint.B(backLink)) else: addWord(query.getfirst("l1w"),query.getfirst("l2w"),query.getfirst("l1"),query.getfirst("l2")) - else: htmlOut('You must type words in both boxes before pressing the Add button.'+backLink) # TODO maybe add a Javascript test to the form also, IF can figure out a way to tell whether window.alert() works or not + else: htmlOut(withLocalise('You must type words in both boxes before pressing the Add button.')+backLink) # TODO maybe add a Javascript test to the form also, IF can figure out a way to tell whether window.alert() works or not elif "bulkadd" in query: # bulk adding, from authoring options dirID = setup_userID() def isOK(x): @@ -124,7 +141,7 @@ def main(): redirectHomeKeepCookie(dirID,"&dictionary=1") # '1' is special value for JS-only back link; don't try to link to referer as it might be a generated page elif "clang" in query: # change languages (l1,l2) dirID = setup_userID() - if (gradint.firstLanguage,gradint.secondLanguage) == (query.getfirst("l1"),query.getfirst("l2")) and not query.getfirst("clang")=="ignore-unchanged": return htmlOut('You must change the settings before pressing the Change Languages button.'+backLink) # (external scripts can set clang=ignore-unchanged) + if (gradint.firstLanguage,gradint.secondLanguage) == (query.getfirst("l1"),query.getfirst("l2")) and not query.getfirst("clang")=="ignore-unchanged": return htmlOut(withLocalise('You must change the settings before pressing the Change Languages button.')+backLink) # (external scripts can set clang=ignore-unchanged) gradint.updateSettingsFile(gradint.settingsFile,{"firstLanguage": query.getfirst("l1"),"secondLanguage":query.getfirst("l2")}) redirectHomeKeepCookie(dirID) elif "swaplang" in query: # swap languages @@ -142,12 +159,24 @@ def main(): try: v=open(gradint.vocabFile).read() except: v="" # (shouldn't get here unless they hack URLs) htmlOut('

|
',"Text edit your vocab list") - elif "lesson" in query: # make lesson + elif "lesson" in query: # make lesson ("Start lesson" button) setup_userID() gradint.maxNewWords = int(query.getfirst("new")) # (shouldn't need sensible-range check here if got a dropdown; if they really want to hack the URL then ok...) gradint.maxLenOfLesson = int(float(query.getfirst("mins"))*60) # TODO save those settings for next time also? serveAudio(stream = True, inURL = False, filetype=filetype) + elif "bigger" in query or "smaller" in query: + u = setup_userID() ; global zoom + if "bigger" in query: zoom = int(zoom*1.1) + else: zoom = int(zoom/1.1 + 0.5) + open(u+"-zoom.txt","w").write("%d\n" % zoom) + listVocab(True) + elif any("variant"+str(c) in query for c in range(max(len(gradint.GUI_translations[v]) for v in gradint.GUI_translations.keys() if v.startswith("@variants-")))): + for c in range(max(len(gradint.GUI_translations[v]) for v in gradint.GUI_translations.keys() if v.startswith("@variants-"))): #TODO duplicate code + if "variant"+str(c) in query: break + u = setup_userID() + gradint.updateSettingsFile(u+"-settings.txt",{"scriptVariants":{gradint.GUI_languages.get(gradint.firstLanguage,gradint.firstLanguage):c}}) + setup_userID() ; listVocab(True) elif "voNormal" in query: # voice option = normal setup_userID() gradint.voiceOption="" @@ -199,14 +228,16 @@ def allLinesHaveEquals(lines): for l in lines: if not '=' in l: return False return True +gradintUrl = os.environ.get("SCRIPT_URI","") # will be http:// or https:// as appropriate +if not gradintUrl and all(x in os.environ for x in ["REQUEST_SCHEME","SERVER_NAME","SCRIPT_NAME"]): gradintUrl = os.environ["REQUEST_SCHEME"]+"://"+os.environ["SERVER_NAME"]+os.environ["SCRIPT_NAME"] +if not gradintUrl: gradintUrl = "gradint.cgi" # guessing def authorWordList(lines,l1,l2): - gradintUrl = os.environ["SCRIPT_URI"] # will be http:// or https:// as appropriate r=[] ; count = 0 # could have target="gradint" in the following, but it may be in a background tab (target="_blank" not recommended as could accumulate many) r.append('
' % gradintUrl) for l in lines: l2w,l1w = l.split('=',1) - r.append('' % (count,l2w,l1w,U(justsynthLink(l2w.encode('utf-8'),l2)).replace('HREF="'+cginame+'?','HREF="'+gradintUrl+'?'),U(justsynthLink(l1w.encode('utf-8'),l1)).replace('HREF="'+cginame+'?','HREF="'+gradintUrl+'?'))) + r.append('' % (count,l2w,l1w,U(justsynthLink(l2w.encode('utf-8'),l2)).replace('HREF="'+cginame+'?','HREF="'+gradintUrl+'?'),U(justsynthLink(l1w.encode('utf-8'),l1)).replace('HREF="'+cginame+'?','HREF="'+gradintUrl+'?'))) count += 1 # could have target="gradint" in the following href, but see comment above r.append('
Click on each word for audio
%s%s
%s%s
to your personal list
' % (gradintUrl,l1,l2)) @@ -232,12 +263,16 @@ def justsynthLink(text,lang=""): # assumes written function h5a return ''+gradint.S(text)+'' # TODO if h5a's canPlayType etc works, cld o/p a lesson as a JS web page that does its own 'take out of event stream' and 'progress write-back'. wld need to code that HERE by inspecting the finished Lesson object, don't call play(). +zoom = 100 # in case browser device lacks a zoom UI, we'll provide one +noGTranslate = False def htmlOut(body_u8,title_extra="",links=1): + if noGTranslate: print ("Google: notranslate") print ("Content-type: text/html; charset=utf-8\n") if title_extra: title_extra=": "+title_extra print ('Gradint Web edition'+title_extra+'') print ('') - print ('') + print ('') + if not zoom==100: print('' % zoom) print ('') if type(body_u8)==type(u""): body_u8=body_u8.encode('utf-8') if hasattr(sys.stdout,'buffer'): # Python 3 @@ -247,9 +282,8 @@ def htmlOut(body_u8,title_extra="",links=1): else: print(body_u8) print ('
') if links: - print ('This is Gradint Web edition. If you need recorded words or additional functions, please download the full version of Gradint.') + print ('This is Gradint Web edition. If you need recorded words or additional functions, please download the full version of Gradint.') # TODO @ low-priority: Android 3 - if "iPhone" in os.environ.get("HTTP_USER_AGENT","") and gradint.secondLanguage=="zh": print ('

You can also try the Open University Chinese Characters First Steps iPhone application.') print ('

'+program_name[:program_name.index("(")]+"using "+gradint.program_name[:gradint.program_name.index("(")]) print ("") backLink = ' Back' # TODO may want to add a random= to the non-js HREF @@ -258,36 +292,30 @@ def serveAudio(stream=0, filetype="mp3", inURL=1): # caller imports gradint (and sets justSynthesize or whatever) first if os.environ.get("HTTP_IF_MODIFIED_SINCE",""): print ("Status: 304 Not Modified\n\n") ; return + httpRange = re.match("bytes=([0-9]*)-([0-9]*)$",os.environ.get('HTTP_RANGE','')) # we MUST support Range: for some iOS players (Apple did not follow the HTTP standard of having a sensible fallback if servers respond with 200, and Apache will not do Range for us if we're CGI). Single Range should be sufficient. + if httpRange: httpRange = httpRange.groups() + if httpRange==('',''): httpRange = None # must spec one + if httpRange: + if not httpRange[0]: httpRange=[-int(httpRange[1]),None] + elif not httpRange[1]: httpRange=[int(httpRange[0]),None] + else: httpRange=[int(httpRange[0]),int(httpRange[1])+1] + print ("Status: 206 Partial Content") + stream = 0 if filetype=="mp3": print ("Content-type: audio/mpeg") else: print ("Content-type: audio/"+filetype) # ok for ogg, wav? if inURL: print ("Last-Modified: Sun, 06 Jul 2008 13:20:05 GMT") print ("Expires: Wed, 1 Dec 2036 23:59:59 GMT") # TODO: S2G + print ("Content-disposition: attachment; filename=gradint."+filetype) # helps with some browsers that can't really do streaming gradint.out_type = filetype + gradint.waitBeforeStart = 0 def mainOrSynth(): oldProgress = None ; rollback = False if not gradint.justSynthesize and 'h5a' in query: - # TODO: if os.environ.get('HTTP_RANGE','')=='bytes=0-1' then that'll be '\xff' for mp3 but would need to stop the web server from adding a Content-Length etc (flush stdout and wait indefinitely for server to terminate the cgi process??) - try: oldProgress = open(gradint.progressFile).read() + try: oldProgress = open(gradint.progressFile,'rb').read() except: pass rollback = True - if 'lesson' in query: random.seed(query.getfirst('lesson')) # so clients that re-GET same lesson from partway through can work - if os.environ.get('HTTP_X_PLAYBACK_SESSION_ID',''): # seen on iOS: assumes the stream is a live broadcast and reconnecting to it continues where it left off. TODO: cache the mp3 output? (but don't delay the initial response) Recalculating for now with sox trim: - if os.path.exists(gradint.progressFile+'-ts'): - trimTo = time.time() - os.stat(gradint.progressFile+'-ts').st_mtime - if trimTo < gradint.maxLenOfLesson: - cin,cout = os.popen2("sox "+(gradint.soundCollector.soxParams()+' - ')*2+" trim "+str(int(trimTo))) - gradint.soundCollector.o,copyTo = cin,gradint.soundCollector.o - def copyStream(a,b): - while True: - try: x = a.read(1024) - except EOFError: break - b.write(x) - b.close() - import thread ; thread.start_new(copyStream,(cout,copyTo)) - else: open(gradint.progressFile+'-ts','w') # previous one was abandoned, restart - else: open(gradint.progressFile+'-ts','w') # create 1st one - # end of if HTTP_X_PLAYBACK_SESSION_ID + if "lesson" in query: random.seed(query.getfirst("lesson")) # so clients that re-GET same lesson from partway through can work try: gradint.main() except SystemExit: if not gradint.justSynthesize: @@ -295,25 +323,44 @@ def serveAudio(stream=0, filetype="mp3", inURL=1): reinit_gradint() ; setup_userID() gradint.write_to_stdout,gradint.outputFile = o1,o2 gradint.setSoundCollector(gradint.SoundCollector()) - gradint.justSynthesize = "en Problem generating the lesson. Check we have prompts for those languages." ; gradint.main() ; oldProgress = None + gradint.justSynthesize = "en Problem generating the lesson. Check we have prompts for those languages." ; gradint.main() + if oldProgress: open(gradint.progressFile,'wb').write(oldProgress) + rollback = oldProgress = None if rollback: # roll back pending lFinish os.rename(gradint.progressFile,gradint.progressFile+'-new') - if oldProgress: open(gradint.progressFile,'w').write(oldProgress) + if oldProgress: open(gradint.progressFile,'wb').write(oldProgress) + # end of def mainOrSynth if stream: - print ("Content-disposition: attachment; filename=gradint.mp3\n") # helps with some browsers that can't really do streaming + print ("") sys.stdout.flush() gradint.write_to_stdout = 1 gradint.outputFile="-."+filetype ; gradint.setSoundCollector(gradint.SoundCollector()) mainOrSynth() else: - tempdir = getoutput("mktemp -d") gradint.write_to_stdout = 0 - gradint.outputFile=tempdir+"/serveThis."+filetype ; gradint.setSoundCollector(gradint.SoundCollector()) - gradint.waitBeforeStart = 0 - mainOrSynth() - print ("Content-Length: "+repr(os.stat(tempdir+"/serveThis."+filetype).st_size)+"\n") + tempdir = tempfile.mkdtemp() + fn,fn2 = tempdir+"/I."+filetype, tempdir+"/O."+filetype + if httpRange and "lesson" in query: # try to cache it + try: os.mkdir(myTmp) + except: pass # exist ok + for f in os.listdir(myTmp): + if os.stat(myTmp+os.sep+f).st_mtime < time.time()-4000: + os.remove(myTmp+os.sep+f) + fn = gradint.outputPrefix+str(int(query.getfirst("lesson")))+"."+filetype # (don't be tricked into clobbering paths with non-int lesson IDs) + if not os.path.exists(fn): + gradint.outputFile=fn + gradint.setSoundCollector(gradint.SoundCollector()) + mainOrSynth() + if httpRange: + total = os.stat(fn).st_size + open(fn2,"wb").write(open(fn,"rb").read()[httpRange[0]:httpRange[1]]) + if httpRange[0]<0: httpRange[0] += total + if not httpRange[1]: httpRange[1] = total + print("Content-Range: bytes %d-%d/%d" % (httpRange[0],httpRange[1]-1,total)) + else: fn2 = fn + print ("Content-Length: "+repr(os.stat(fn2).st_size)+"\n") sys.stdout.flush() - os.system("cat "+tempdir+"/serveThis."+filetype) + os.system("cat "+fn2) # components already validated so no quoting required os.system("rm -r "+tempdir) def addWord(l1w,l2w,l1,l2,out=True): @@ -322,7 +369,7 @@ def addWord(l1w,l2w,l1,l2,out=True): if not ((gradint.firstLanguage,gradint.secondLanguage) == (l2,l1) and "HTTP_REFERER" in os.environ and not cginame in os.environ["HTTP_REFERER"]): gradint.updateSettingsFile(gradint.settingsFile,{"firstLanguage": l1,"secondLanguage":l2}) gradint.firstLanguage,gradint.secondLanguage = l1,l2 if (l1w+"_"+l1,l2w+"_"+l2) in map(lambda x:x[1:],gradint.parseSynthVocab(gradint.vocabFile,forGUI=1)): - if out: htmlOut('This word is already in your list.'+backLink) + if out: htmlOut(withLocalise('This word is already in your list.')+backLink) return gradint.appendVocabFileInRightLanguages().write(gradint.B(l2w)+gradint.B("=")+gradint.B(l1w)+gradint.B("\n")) if not out: return @@ -332,7 +379,7 @@ def addWord(l1w,l2w,l1,l2,out=True): def redirectHomeKeepCookie(dirID,extra=""): dirID = gradint.S(dirID) # just in case - print ("Location: "+cginame+"?random="+str(random.random())+"&id="+dirID[dirID.rindex("/")+1:]+extra+"\n") + print ("Location: "+cginame+"?random="+str(random.random())[2:]+"&id="+dirID[dirID.rindex("/")+1:]+extra+"\n") def langSelect(name,curLang): curLang = gradint.espeak_language_aliases.get(curLang,curLang) @@ -355,10 +402,18 @@ for k,v in {"Swap":{"zh":u"交换","zh2":u"交換"}, "click for audio":{"zh":u"击某词就听声音","zh2":u"擊某詞就聽聲音"}, "Repeats":{"zh":u"重复计数","zh2":u"重複計數"}, "To edit this list on another computer, type":{"zh":u"要是想在其他的电脑或手机编辑这个词汇表,请在别的设备打","zh2":u"要是想在其他的電腦或手機編輯這個詞彙表,請在別的設備打"}, + "Please wait while the lesson starts to play":{"zh":u"稍等本课正开始播放","zh2":u"稍等本課正開始播放"}, + "Bigger":{"zh":u"大"},"Smaller":{"zh":u"小"}, + 'You must type a word in the box before pressing the Speak button.':{"zh":u"按‘发音’前,应该框里打字。","zh2":u"按‘發音’前,應該框裡打字。"}, + 'You must type words in both boxes before pressing the Add button.':{"zh":u"按‘添加’前,应该在两框里打字。","zh2":u"按‘添加’前,應該在兩框裡打字。"}, + 'You must change the settings before pressing the Change Languages button.':{"zh":u"按‘选择其他语言’前,应该转换语言设定。","zh2":u"按‘選擇其他語言’前,應該轉換語言設定。"}, + 'This word is already in your list.':{"zh":u"本词已经在您的词汇表。","zh2":u"本詞已經在您的詞彙表。"}, "Your word list is empty.":{"zh":u"词汇表没有词汇,加一些吧","zh2":u"詞彙表沒有詞彙,加一些吧"} }.items(): if not k in gradint.GUI_translations: gradint.GUI_translations[k]=v +def withLocalise(x): return x+" "+localise(x,1) + def h5a(): body = """

'+localise("Your first language",1)+': '+langSelect('l1',firstLanguage)+' '+localise("second",1)+': '+langSelect('l2',secondLanguage)+' ' # onfocus..onblur updating onsubmit is needed for iOS "Go" button + body += (localise("Word in %s",1) % localise(secondLanguage))+':
'+(localise("Meaning in %s",1) % localise(firstLanguage))+':

'+localise("Your first language",1)+': '+langSelect('l1',firstLanguage)+' '+localise("second",1)+': '+langSelect('l2',secondLanguage)+' ' # onfocus..onblur updating onsubmit is needed for iOS "Go" button def htmlize(l,lang): if type(l)==type([]) or type(l)==type(()): return htmlize(l[-1],lang) l = gradint.B(l) @@ -403,7 +468,8 @@ def listVocab(hasList): # main screen def deleteLink(l1,l2): r = [] for l in [l2,l1]: - if type(l)==type([]) or type(l)==type(()) or not gradint.B("!synth:") in l: return "" # Web-GUI delete in poetry etc not yet supported + if type(l)==type([]) or type(l)==type(()) or not gradint.B("!synth:") in gradint.B(l): return "" # Web-GUI delete in poetry etc not yet supported + l = gradint.B(l) r.append(gradint.S(quote(l[l.index(gradint.B("!synth:"))+7:l.rfind(gradint.B("_"))]))) r.append(localise("Delete",2)) return ('') % tuple(r) @@ -412,16 +478,16 @@ def listVocab(hasList): # main screen # gradint.cache_maintenance_mode=1 # don't transliterate on scan -> NO, including this scans promptsDirectory! gradint.ESpeakSynth.update_translit_cache=lambda *args:0 # do it this way instead data = gradint.ProgressDatabase().data ; data.reverse() - if data: hasList = "

"+"".join(["%s" % (num,gradint.secondLanguage,htmlize(dest,gradint.secondLanguage),gradint.firstLanguage,htmlize(src,gradint.firstLanguage),deleteLink(src,dest)) for num,src,dest in data])+"
"+localise("Your word list",1)+" ("+localise("click for audio",1)+")
"+localise("Repeats",1)+""+localise(gradint.secondLanguage,1)+""+localise(gradint.firstLanguage,1)+"
%d%s%s
" + if data: hasList = "

"+"".join(["%s" % (num,gradint.secondLanguage,htmlize(dest,gradint.secondLanguage),gradint.firstLanguage,htmlize(src,gradint.firstLanguage),deleteLink(src,dest)) for num,src,dest in data])+"
"+localise("Your word list",1)+" ("+localise("click for audio",1)+")
"+localise("Repeats",1)+""+localise(gradint.secondLanguage,1)+""+localise(gradint.firstLanguage,1)+"
%d%s%s
" else: hasList="" else: hasList="" - if hasList: body += '

'+numSelect('new',range(2,10),gradint.maxNewWords)+' '+localise("new words in")+' '+numSelect('mins',[15,20,25,30],int(gradint.maxLenOfLesson/60))+' '+localise('mins')+"""
""" + if hasList: body += '

'+numSelect('new',range(2,10),gradint.maxNewWords)+' '+localise("new words in")+' '+numSelect('mins',[15,20,25,30],int(gradint.maxLenOfLesson/60))+' '+localise('mins')+"""
""" # when lesson ended, refresh with lFinish which saves progress (interrupts before then cancel it), but don't save progress if somehow got the ended event in 1st minute as that could be a browser issue if "dictionary" in query: if query.getfirst("dictionary")=="1": body += '' # apparently it is -1, not -2; the redirect doesn't count as one (TODO are there any JS browsers that do count it as 2?) else: body += '

'+localise("Back to dictionary",1)+'' # TODO check for cross-site scripting if hasList: - if "SCRIPT_URI" in os.environ: hasList += "

"+localise("To edit this list on another computer, type",1)+" "+os.environ["SCRIPT_URI"]+"?id="+getCookieId()+"" - else: hasList="

"+localise("Your word list is empty.",1) + if "://" in gradintUrl: hasList += "

"+localise("To edit this list on another computer, type",1)+" "+gradintUrl.replace(".",".").replace("/","/")+"?id="+re.sub("([0-9]{4})(?!$)",r"\1",getCookieId())+"" # span needed for iOS at least + else: hasList="

"+localise("Your word list is empty.",1) body += hasList htmlOut(body+'') @@ -454,14 +520,17 @@ def setup_userID(): open(dirName+'/'+userID+'-settings.txt','w') # TODO this could still be a race condition (but should be OK under normal circumstances) need_write = 1 print ('Set-Cookie: id=' + userID+'; expires=Wed, 1 Dec 2036 23:59:59 GMT') # TODO: S2G - userID = dirName+'/'+userID + userID0, userID = userID, dirName+os.sep+userID # already validated gradint.progressFileBackup=gradint.pickledProgressFile=None gradint.vocabFile = userID+"-vocab.txt" gradint.progressFile = userID+"-progress.txt" gradint.settingsFile = userID+"-settings.txt" + gradint.outputPrefix = myTmp+os.sep+userID0+"-" if need_write: gradint.updateSettingsFile(gradint.settingsFile,{'firstLanguage':gradint.firstLanguage,'secondLanguage':gradint.secondLanguage}) else: gradint.readSettings(gradint.settingsFile) gradint.auto_advancedPrompt=1 # prompt in L2 if we don't have L1 prompts on the server, what else can we do... + if os.path.exists(userID+"-zoom.txt"): + global zoom ; zoom = int(open(userID+"-zoom.txt").read().strip()) return userID try: main() diff --git a/server/lesson-table.py b/server/lesson-table.py old mode 100644 new mode 100755 index 9b3eb7d..4d0c287 --- a/server/lesson-table.py +++ b/server/lesson-table.py @@ -5,7 +5,7 @@ # for summarizing it to a teacher or native speaker. # Reads from progressFile and progressFileBackup. -# Version 1.06 (c) 2011, 2020-21 Silas S. Brown. License: GPL +# Version 1.07 (c) 2011, 2020-21, 2025 Silas S. Brown. License: GPL # Example use: # export samples_url=http://example.org/path/to/samples/ # or omit @@ -43,7 +43,7 @@ count += 1 del newProg,opd changes.sort() -print ('Gradint lesson report

Gradint lesson report

') +print ('Gradint lesson report

Gradint lesson report

') if gradint.unix and gradint.got_program("zgrep"): print (os.popen("zgrep '^# collection=' \"%s\"" % gradint.progressFile).read()[2:].rstrip()) print ('') # (have Question/Answer order rather than Word/Meaning, because if it's L2-only poetry then the question is the previous line, which is not exactly "meaning") @@ -97,5 +97,5 @@ def link(l): return l.replace('&','&').replace('<','<') if samples_url: return ''+wrappable(l)+'' return wrappable(l).replace('&','&').replace('<','<') -for b4,pos,today,l1,l2 in changes: print ('' % (b4,today,link(l1),link(l2))) +for b4,pos,today,l1,l2 in changes: print ('' % (b4,today,link(l1),link(l2))) print ('
Repeats beforeRepeats todayQuestionAnswer
%d%d%s%s
%d%d%s%s
') diff --git a/server/safety-check-progressfile.py b/server/safety-check-progressfile.py old mode 100644 new mode 100755 diff --git a/server/vocab2html.py b/server/vocab2html.py old mode 100644 new mode 100755 diff --git a/src/filescan.py b/src/filescan.py index 0a7237b..8f12f55 100644 --- a/src/filescan.py +++ b/src/filescan.py @@ -103,7 +103,7 @@ def getLsDic(directory): except: return {} # (can run without a 'samples' directory at all if just doing synth) if checkIn("settings"+dottxt,ls): # Sort out the o/p from import_recordings (and legacy record-with-HDogg.bat if anyone's still using that) - oddLanguage,evenLanguage = exec_in_a_func(wspstrip(u8strip(read(directory+os.sep+"settings"+dottxt).replace("\r\n","\n")))) + oddLanguage,evenLanguage = exec_in_a_func(wspstrip(u8strip(read(directory+os.sep+"settings"+dottxt).replace(B("\r\n"),B("\n"))))) if oddLanguage==evenLanguage: oddLanguage,evenLanguage="_"+oddLanguage,"-meaning_"+evenLanguage # if user sets languages the same, assume they want -meaning prompts else: oddLanguage,evenLanguage="_"+oddLanguage,"_"+evenLanguage for f in ls: diff --git a/src/frontend.py b/src/frontend.py index 3d4699b..3356828 100644 --- a/src/frontend.py +++ b/src/frontend.py @@ -150,7 +150,7 @@ def clearScreen(): warnings_printed = [] return if winsound or mingw32: os.system("cls") - else: os.system("clear 1>&2") # (1>&2 in case using stdout for something else) + else: os.system("clear >&2") # (>&2 in case using stdout for something else) return True cancelledFiles = [] @@ -1240,6 +1240,8 @@ def synchronizeListbox(listbox,masterList): elif not olpc and got_program("gnome-open"): textEditorCommand=explorerCommand="gnome-open" elif got_program("nautilus"): explorerCommand="nautilus" + elif got_program("pcmanfm"): explorerCommand="pcmanfm" # LXDE, LXQt + elif got_program("pcmanfm-qt"): explorerCommand="pcmanfm-qt" # might not work as well as pcmanfm on 24.04 elif got_program("rox"): # rox is available - try using that to open directories # (better not use it for editor as it might not be configured) @@ -1295,10 +1297,10 @@ def openDirectory(dir,inGuiThread=0): if inGuiThread: tkMessageBox.showinfo(app.master.title(),msg) else: waitOnMessage(msg) -def sanityCheck(text,language,pauseOnError=0): # text is utf-8; returns error message if any +def generalCheck(text,language,pauseOnError=0): # text is utf-8; returns error message if any if not text: return # always OK empty strings if pauseOnError: - ret = sanityCheck(text,language) + ret = generalCheck(text,language) if ret: waitOnMessage(ret) return ret if language=="zh": @@ -1329,7 +1331,7 @@ def s60_addVocab(): result = appuifw.multi_query(label1,label2) # unfortunately multi_query can't take default items (and sometimes no T9!), but Form is too awkward (can't see T9 mode + requires 2-button save via Options) and non-multi query would be even more modal if not result: return # cancelled l2,l1 = result # guaranteed to both be populated - while sanityCheck(l2.encode('utf-8'),secondLanguage,1): + while generalCheck(l2.encode('utf-8'),secondLanguage,1): l2=appuifw.query(label1,"text",u"") if not l2: return # cancelled # TODO detect duplicates like Tk GUI does? @@ -1371,7 +1373,7 @@ def s60_viewVocab(): oldL1,oldL2 = l1,l2 if action==2: first=1 - while first or (l2 and sanityCheck(l2.encode('utf-8'),secondLanguage,1)): + while first or (l2 and generalCheck(l2.encode('utf-8'),secondLanguage,1)): first=0 ; l2=appuifw.query(ensure_unicode(secondLanguage),"text",l2) if not l2: continue elif action==3: @@ -1386,7 +1388,7 @@ def s60_viewVocab(): def android_addVocab(): while True: l2 = None - while not l2 or sanityCheck(l2.encode('utf-8'),secondLanguage,1): + while not l2 or generalCheck(l2.encode('utf-8'),secondLanguage,1): l2 = android.dialogGetInput("Add word","Word in %s" % localise(secondLanguage)).result if not l2: return # cancelled l1 = android.dialogGetInput("Add word","Meaning in %s" % localise(firstLanguage)).result @@ -1482,9 +1484,9 @@ def downloadLAME(): fi if grep downloads.sourceforge lame.tar.gz 2>/dev/null; then Link="$(cat lame.tar.gz|grep downloads.sourceforge|head -1)" - echo "Got HTML: $Link" 1>&2 + echo "Got HTML: $Link" >&2 Link="$(echo "$Link"|sed -e 's/.*http/http/' -e 's,.*/projects,http://sourceforge.net/projects,' -e 's/".*//')" - echo "Following link to $Link" 1>&2 + echo "Following link to $Link" >&2 if ! $Curl "$Link" > lame.tar.gz; then rm -f lame.tar.gz; exit 1 fi @@ -1585,7 +1587,7 @@ def gui_event_loop(): if not text1 and not text2: app.todo.alert=u"Before pressing the "+localise("Speak")+u" button, you need to type the text you want to hear into the box." else: if text1.startswith(B('#')): msg="" # see below - else: msg=sanityCheck(text1,secondLanguage) + else: msg=generalCheck(text1,secondLanguage) if msg: app.todo.alert=ensure_unicode(msg) else: app.set_watch_cursor = 1 ; app.toRestore = [] @@ -1706,7 +1708,7 @@ def scanDirs(): app.todo.alert=msg+" "+localise("Repeat count is 0, so we cannot reduce it for extra revision.") elif menu_response=="add": text1 = asUnicode(app.Text1.get()).encode('utf-8') ; text2 = asUnicode(app.Text2.get()).encode('utf-8') - msg=sanityCheck(text1,secondLanguage) + msg=generalCheck(text1,secondLanguage) if msg: app.todo.alert=ensure_unicode(msg) else: o=appendVocabFileInRightLanguages() @@ -1933,8 +1935,8 @@ def rest_of_main(): exitStatus = 1 if appuifw: raw_input() # so traceback stays visible # It is not guaranteed that __del__() methods are called for objects that still exist when the interpreter exits. So: - global viable_synths,getsynth_cache,theMp3FileCache - del viable_synths,getsynth_cache,theMp3FileCache + global viable_synths,getsynth_cache,theMp3FileCache,globalEspeakSynth + del viable_synths,getsynth_cache,theMp3FileCache,globalEspeakSynth if app: app.todo.exit_ASAP=1 while app: time.sleep(0.2) diff --git a/src/lessonplan.py b/src/lessonplan.py index 1d64301..b0565eb 100644 --- a/src/lessonplan.py +++ b/src/lessonplan.py @@ -102,17 +102,30 @@ def _load_from_text(self,fromString=0): self.promptsData[k[:-len(dotwav)]]=self.promptsData[k] del self.promptsData[k] self._py3_fix() + def _saved_by_py3(self): + # NB the Windows version of Gradint is still Python 2.3 so generator expressions (new in 2.4) would be a syntax error even though this code is never reached in that version, so: + for l in [self.data,self.unavail]: + for i in l: + for j in i[1:]: + if type(j)==str: j=[j] + for k in j: + for c in k: + if ord(c) > 255: return True # must have been written by the Python 3 version def _py3_fix(self): if not type("")==type(u""): return - # we're Python 3, and we might have just loaded data from Python 2 + # we're Python 3, and we might have just loaded data from Python 2. Might have to encode as Latin-1 then decode as UTF-8. But don't do this if file was in fact saved by Python 3. + if self._saved_by_py3(): return for l in [self.data,self.unavail]: for i in range(len(l)): for j in [1,2]: if type(l[i][j])==str: l[i]=l[i][:j]+(S2(LB(l[i][j])),)+l[i][j+1:] elif type(l[i][j])==list: l[i]=l[i][:j]+(map(lambda x:S2(LB(x)),l[i][j]),)+l[i][j+1:] + def _py3_fix_on_save(self): + if type("")==type(u"") and not self._saved_by_py3(): self.unavail.append((1,u"\u2014","[Py3]")) # ensure there's at least one, to prevent a py3_fix redo def save(self,partial=0): if need_say_where_put_progress: show_info("Saving "+cond(partial,"partial ","")+"progress to "+progressFile+"... ") else: show_info("Saving "+cond(partial,"partial ","")+"progress... ") + self._py3_fix_on_save() global progressFileBackup # Remove 0-repeated items (helps editing by hand) data = [] # don't use self.data - may want to make another lesson after saving @@ -159,6 +172,7 @@ def save(self,partial=0): if not app and not appuifw and not android: show_info("done\n") def save_binary(self,data): # save a pickled version if possible (no error if not) if not (pickledProgressFile and pickle): return + self._py3_fix_on_save() try: if compress_progress_file: if paranoid_file_management: fn=os.tempnam() diff --git a/src/makeevent.py b/src/makeevent.py index ea13e5d..5886f6a 100644 --- a/src/makeevent.py +++ b/src/makeevent.py @@ -179,7 +179,7 @@ def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=Non if len(t2)<100 or not filter(lambda x:x,l) or scl_disable_recursion: l=None # don't mix partials and synth for different parts of a short phrase, it's too confusing (TODO make the 100 configurable?) elif type(get_synth_if_possible(lang,0))==EkhoSynth: l=None # some faulty versions of Ekho are more likely to segfault if called on fragments (e.g. if the fragment ends with some English), so don't do this with Ekho (unless can confirm it's at least ekho_4.5-2ubuntu10.04 .. not all versions of ekho can report their version no.) else: # longer text and SOME can be synth'd from partials: go through it more carefully - t2=fix_compatibility(ensure_unicode(text2.replace(chr(0),"")).replace(u"\u3002",".").replace(u"\u3001",",")).encode('utf-8') + t2=fix_compatibility(ensure_unicode(text2).replace(unichr(0),"").replace(u"\u3002",".").replace(u"\u3001",",")).encode('utf-8') for t in ".!?:;,": t2=t2.replace(B(t),B(t)+chr(0)) l=[] scl_disable_recursion = 1 @@ -227,7 +227,10 @@ def stripPuncEtc(text): elif not winsound: # ok if mingw32, appuifw etc (unzip_and_delete will warn) for d in [os.getcwd()+cwd_addSep,".."+os.sep,samplesDirectory+os.sep]: f=d+zipToCheck+".exe" - if fileExists(f): unzip_and_delete(f,ignore_fail=1) # ignore the error exit status from unzip, which will be because of extra bytes at the beginning + if fileExists(f): + unzip_and_delete(f,ignore_fail=1) # ignore the error exit status from unzip, which will be because of extra bytes at the beginning + try: os.unlink("setup.bat") + except: pass # Filename / Unicode translation - need some safety across filesystems. synthCache(+utils) could be done this way also rather than having TRANS.TBL (however I'm not sure it would save that much code) non_normal_filenames = {} ; using_unicode_filenames=0 @@ -362,8 +365,8 @@ def toDict(l): # make the list of filenames into a dict of short-key -> [(long-k except IOError: pass # ignore write errors as it's only a cache except OSError: pass if partials_raw_mode: - (wtype,wrate,wchannels,wframes,wbits) = sndhdr.what(partialsDirectory+os.sep+"header"+dotwav) - partials_raw_0bytes = int(betweenPhrasePause*wrate)*wchannels*(wbits/8) + (wtype,wrate,wchannels,wframes,wbits) = swhat(partialsDirectory+os.sep+"header"+dotwav) + partials_raw_0bytes = int(betweenPhrasePause*wrate)*wchannels*int(wbits/8) else: synth_partials_voices,partials_raw_mode = {},None if checkIn("cant",synth_partials_voices): synth_partials_voices["zhy"]=synth_partials_voices["zh-yue"]=synth_partials_voices["cant"] @@ -445,7 +448,6 @@ def optimise_partial_playing(ce): # ce is a CompositeEvent of SampleEvents. See if we can change it to a ShellEvent that plays all partial-samples in a single command - this helps with continuity on some low-end platforms. if soundCollector and not saveLesson: return ce # no point doing this optimisation if won't ever play in real time fileType = soundFileType(ce.eventList[0].file) - hasPauses = 0 for e in ce.eventList[1:]: if not soundFileType(e.file)==fileType: return ce # must be all the same type for this optimisation s = None @@ -476,7 +478,8 @@ def optimise_partial_playing(ce): return s else: return ce # can't figure out an optimisation in these circumstances def simplified_header(fname): - h=sndhdr.what(fname) + # called by optimise_partial_playing(_list) + h=swhat(fname) # ignore num frames i.e. h[3], just compare formats if h: return h[:3]+h[4:] def optimise_partial_playing_list(ceList): diff --git a/src/play.py b/src/play.py index 5e730f4..14bbe05 100644 --- a/src/play.py +++ b/src/play.py @@ -148,8 +148,7 @@ def sox_check(): if macsound: if not gotSox and not os.system("mv sox-14.4.2 sox && rm sox.README"): gotSox,soxMp3 = sox_check() # see if that one works instead (NB must use os.system here: our system() has not yet been defined) if not gotSox and got_program("sox"): - if macsound: xtra=". (If you're on 10.8 Mountain Lion, try downloading a more recent sox binary from sox.sourceforge.net and putting it inside Gradint.app, but that will break compatibility with older PowerPC Macs.)" # TODO: ship TWO binaries? but we don't want the default gradint to get too big. See sox.README for more notes. - elif cygwin: xtra="" + if macsound or cygwin: xtra="" else: xtra=". Ubuntu users please install libsox-fmt-all." show_warning("SoX found but can't handle WAV, so you won't be able to write lessons to files for later"+xtra) else: gotSox = got_program("sox") @@ -167,7 +166,7 @@ def sox_check(): # (need a warning here, because if using 'aplay' then sox o/p is 2>/dev/null (see below) so a missing sox won't be obvious) if sox_formats.find("alsa")>=0 and isDirectory("/dev/snd"): sox_type=sox_type.replace("ossdsp","alsa") - oss_sound_device = "hw:0,0" + oss_sound_device = " " # older versions could take "hw:0,0" but just leave at -t alsa now? if not oss_sound_device: dsps_to_check = [] if sox_formats.find("ossdsp")>=0: @@ -240,6 +239,7 @@ def system(cmd): try: r=os.popen(cmd) except: return os.system(cmd) # too many file descriptors open or something r.read() ; return r.close() +signal=0 if unix: # Unix: make sure "kill" on gradint's pid includes the players: try: @@ -251,7 +251,6 @@ def siggrp(sigNo,*args): raise KeyboardInterrupt # clean up, rm tempfiles etc signal.signal(signal.SIGTERM,siggrp) except: pass -else: signal=0 # Event(len) gives a pause of that length # SampleEvent extends this to actually play something: @@ -313,7 +312,7 @@ def play(self): # returns a non-{False,0,None} value on error # we can handle MP3 on WinCE by opening in Media Player. Too bad it ignores requests to run minimized. fname = self.file if not B(fname[0])==B("\\"): fname=os.getcwd()+cwd_addSep+fname # must be full path - r=not ctypes.cdll.coredll.ShellExecuteEx(ctypes.byref(ShellExecuteInfo(60,File=ensure_unicode(fname)))) + ctypes.cdll.coredll.ShellExecuteEx(ctypes.byref(ShellExecuteInfo(60,File=ensure_unicode(fname)))) time.sleep(self.length) # exactLen may not be enough elif (winsound and not (self.length>10 and wavPlayer)) or winCEsound: # (don't use winsound for long files if another player is available - it has been known to stop prematurely) if fileType=="mp3": file=theMp3FileCache.decode_mp3_to_tmpfile(self.file) @@ -439,18 +438,26 @@ def lengthOfSound(file): if B(file).lower().endswith(B(dotmp3)): return rough_guess_mp3_length(file) else: return pcmlen(file) +if type("")==type(u""): # Python 3 + import wave + def swhat(file): + if file.lower().endswith(os.extsep+"wav"): + o = wave.open(file,'rb') + return "wav",o.getframerate(),o.getnchannels(),o.getnframes(),8*o.getsampwidth() + else: # fallback non-WAV + import sndhdr # before Python 3.13 + return sndhdr.what(file) +else: # Python 2 + import sndhdr + swhat = sndhdr.what def pcmlen(file): - header = sndhdr.what(file) - if not header: - # some Python 3 installations seem less able to run sndhdr - if gotSox: return len(readB(os.popen("sox \""+file+"\" -t raw "+sox_8bit+" "+sox_signed+" -c 1 -r 8000 - ",popenRB)))/8000.0 - else: raise IOError("sndhdr can't analyse file '%s'" % (file,)) + header = swhat(file) (wtype,wrate,wchannels,wframes,wbits) = header if android: if wrate==6144: # might be a .3gp from android_recordFile d = open(file).read() if 'mdat' in d: return (len(d)-d.index('mdat'))/1500.0 # this assumes the bitrate is roughly the same as in my tests, TODO figure it out properly - divisor = wrate*wchannels*wbits/8 # do NOT optimise with (wbits>>3), because wbits could be 4 + divisor = wrate*wchannels*int(wbits/8) # do NOT optimise with (wbits>>3), because wbits could be 4 if not divisor: raise IOError("Cannot parse sample format of '%s': %s" % (file,repr(header))) return (filelen(file) - 44.0) / divisor # 44 is a typical header length, and .0 to convert to floating-point @@ -599,7 +606,7 @@ def beepCmd(soxParams,fname): class ShSoundCollector(object): def __init__(self): self.file2command = {} - self.commands = ["C() { echo -n $1% completed $'\r' 1>&2;}"] + self.commands = ["C() { echo -n $1% completed $'\r' >&2;}"] self.seconds = self.lastProgress = 0 if write_to_stdout: self.o=sys.stdout else: self.o = open(outputFile,"wb") @@ -633,10 +640,13 @@ def addFile(self,file,length): fileType=soundFileType(file) self.seconds += length if not checkIn(file,self.file2command): - if fileType=="mp3": fileData,fileType = decode_mp3(file),"wav" # because remote sox may not be able to do it + if fileType=="mp3": handle,fileData,fileType = None,decode_mp3(file),"wav" # because remote sox may not be able to do it elif compress_SH and unix: handle=os.popen("cat \""+S(file)+"\" | sox -t "+fileType+" - -t "+fileType+" "+sox_8bit+" - 2>/dev/null",popenRB) # 8-bit if possible (but don't change sample rate, as we might not have floating point) else: handle = open(file,"rb") - offset, length = self.bytesWritten, outfile_writeFile(self.o,handle,file) + if handle: offset, length = self.bytesWritten, outfile_writeFile(self.o,handle,file) + else: + outfile_writeBytes(self.o,fileData) + length = len(fileData) self.bytesWritten += length # dd is more efficient when copying large chunks - try to align to 1k first_few_bytes = min(length,(1024-(offset%1024))%1024) @@ -656,7 +666,7 @@ def addFile(self,file,length): def finished(self): if outputFile_appendSilence: self.addSilence(outputFile_appendSilence,False) outfile_writeBytes(self.o,"\n") # so "tail" has a start of a line - self.commands.append("C 100;echo 1>&2;exit") + self.commands.append("C 100;echo >&2;exit") for c in self.commands: outfile_writeBytes(self.o,c+"\n") outfile_writeBytes(self.o,"tail -%d \"$S\" | bash\n" % (len(self.commands)+1)) if not write_to_stdout: diff --git a/src/recording.py b/src/recording.py index d1e1470..4342c0e 100644 --- a/src/recording.py +++ b/src/recording.py @@ -555,7 +555,6 @@ def doRecord(self,filename,row,languageNo,needToUpdatePlayButton=False): if app.scanrow.get()=="2": # "stop" focusButton(self.coords2buttons[(row,3+3*languageNo)]) else: - moved = 0 if app.scanrow.get()=="1": # move along 1st while languageNo+1/dev/null').readlines(): - if not '#' in l: continue - name,lang=l[:l.index('#')].rsplit(None,1) - voiceAttrs.append({'VoiceName':name,'VoiceLanguage':lang.replace('_','-')}) + for l in readB(os.popen('say -v "?" /dev/null',popenRB)).split(B("\n")): + if not B('#') in l: continue + name,lang=l[:l.index(B('#'))].rsplit(None,1) + voiceAttrs.append({'VoiceName':S(name),'VoiceLanguage':S(lang).replace('_','-')}) if not voiceAttrs: return {"en":""} # maybe we're on ancient OS X: don't use a -v parameter at all for vocAttrib in voiceAttrs: if not checkIn('VoiceName',vocAttrib): continue @@ -113,7 +113,7 @@ def scanVoices(self): if not lang: continue # TODO: output VoiceName in a warning? else: lang = vocAttrib['VoiceLanguage'] if '-' in lang: lang=lang[:lang.index("-")] - d.setdefault(lang,[]).append(vocAttrib['VoiceName'].encode('utf-8')) + d.setdefault(lang,[]).append(B(vocAttrib['VoiceName'])) found=0 ; d2=d.copy() class BreakOut(Exception): pass # First, check for voice matches in same language beginning @@ -659,7 +659,7 @@ def transliterate_multiple(self,lang,textList,forPartials=1,keepIndexList=0): if int0: if int0 > thisgroup_max_priority: thisgroup_max_priority = int0 - if lWords[-1]=="[_^_]": thisgroup_enWord_priority = int0 # so far it looks like this is going to be an English word + if lWords[-1]==B("[_^_]"): thisgroup_enWord_priority = int0 # so far it looks like this is going to be an English word else: # a split between the groups if thisgroup_enWord_priority == thisgroup_max_priority: # the choice with the highest priority was the one containing the [_^_] to put the word into English en_words[r[-1]]=1 @@ -672,6 +672,7 @@ def transliterate_multiple(self,lang,textList,forPartials=1,keepIndexList=0): foundLetter=0 if l.startswith(B("Translate ")): toAppend=l[l.index(B("'"))+1:-1].replace(LB("\xc3\xbc"),B("v")) + if toAppend==LB("\xc2\xa0"): continue # stray no-break space (don't let this interfere with being able to do partials) if not (checkIn(toAppend,en_words) and r and toAppend==r[-1]): # TODO what about partial English words? e.g. try "kao3 testing" - translate 'testing' results in a translate of 'test' also (which assumes it's already in en mode), resulting in a spurious word "test" added to the text box; not sure how to pick this up without parsing the original text and comparing with the Replace rules that occurred r.append(toAppend) @@ -864,7 +865,7 @@ def fix_compatibility(utext): # convert 'compatibility full-width' characters to if 0xff01<=ord(c)<=0xff5e: r.append(unichr(ord(c)-0xfee0)) elif 0x2010 <= ord(c) <= 0x2015: r.append("-") elif c==unichr(0x201a): r.append(",") # sometimes used as comma (incorrectly) - elif 0x2018 <= ord(c) <= 0x201f: r.append('"') + elif 0x2018 <= ord(c) <= 0x201f or 0x3008 <= ord(c) <= 0x301b: r.append('"') elif c==unichr(0xff61): r.append(".") else: r.append(c) return u"".join(r) @@ -931,19 +932,144 @@ def guess_length(self,lang,text): return quickGuess(len(text),12) # TODO need a if oss_sound_device: def play(self,lang,text): if not self.theProcess: self.startProcess() - self.theProcess.write("(Parameter.set 'Audio_Command \"play --device=%s \$FILE vol %.1f\")\n(tts_text \"%s\" nil)\n" % (oss_sound_device,5*soundVolume,text)) # (tts_text text nil) can be better than (SayText text) because it splits into multiple utterances if necessary + self.theProcess.write("(Parameter.set 'Audio_Command \"play --device=%s \\$FILE vol %.1f\")\n(tts_text \"%s\" nil)\n" % (oss_sound_device,5*soundVolume,text)) # (tts_text text nil) can be better than (SayText text) because it splits into multiple utterances if necessary self.theProcess.flush() # else send it via a file, because we haven't got code to give it to play to the other devices directly def makefile(self,lang,text): if not self.theProcess: self.startProcess() fname = os.tempnam()+dotwav - self.theProcess.write("(Parameter.set 'Audio_Command \"sox \$FILE %s vol 5\")\n(SayText \"%s\")\n" % (fname,text)) + self.theProcess.write("(Parameter.set 'Audio_Command \"sox \\$FILE %s vol 5\")\n(SayText \"%s\")\n" % (fname,text)) self.theProcess.flush() return fname def finish_makefile(self): if self.theProcess: self.theProcess.close() self.theProcess = None +class ChatterboxSynth(Synth): + def __init__(self): + Synth.__init__(self) ; self.model = None + def works_on_this_platform(self): + try: + import importlib + return importlib.util.find_spec('chatterbox') + except: return 0 + def supports_language(self,lang): return lang=="en" + def guess_length(self,lang,text): return quickGuess(len(text),12) # need better estimate + def makefile(self,lang,text): + if not self.model: + import torch;from chatterbox.tts import ChatterboxTTS + self.model = ChatterboxTTS.from_pretrained(device=cond(torch.cuda.is_available(),"cuda","cpu")) # cuda can run out of GPU RAM if reading long texts; may need cpu (slower) for that. But cuda is probably OK for the short words / phrases Gradint is likely to use, and anyway cpu + long texts can result in some sections of the text missing. + text = ensure_unicode(text) + fname = os.tempnam()+dotwav + import torchaudio + torchaudio.save(fname,self.model.generate(text),self.model.sr) # (generate can also take an audio_prompt_path wav for voice cloning; Coqui below does this only on selected models) + return fname + +class CoquiSynth(Synth): + def __init__(self): + Synth.__init__(self) + self.synths = {} + def works_on_this_platform(self): + if not unix: return 0 # I'm unable to test elsewhere + self.base = os.environ.get("HOME","")+"/.local/share/tts" + return isDirectory(self.base) # Voices require large downloads the first time they are used, so we'll use only already-downloaded voices + def supports_language(self,lang): + for a in os.listdir(self.base): # don't use any() with a generator func because we need to be Python 2.3 compatible + if a.startswith("tts_models--"+lang+"-"): return True # TODO: might not want to use all downloaded models, or might not want to use for all input types (e.g. zh does not support pinyin) + def guess_length(self,lang,text): return quickGuess(len(text),cond(lang in ["zh"],6,12)) # need better estimate + def makefile(self,lang,text): + text = ensure_unicode(text) + if lang=="zh": text += u"\u3002" # otherwise that model can glitch and repeat the last word of the phrase + if not lang in self.synths: + import torch;from TTS.api import TTS # shouldn't fault if models are downloaded to ~/.local/share/tts (unless uninstalled and not cleaned up...) + # We can assume Python 3 by this point, but must still use syntax compatible with Python 2 + for a in sorted(os.listdir(self.base)): + if a.startswith("tts_models--"+lang+"-"): + self.synths[lang]=TTS(a.replace("--","/")).to(cond(torch.cuda.is_available(),"cuda","cpu")) + break + fname = os.tempnam()+dotwav + self.synths[lang].tts_to_file(text,file_path=fname) + return fname + +class PiperSynth(Synth): + def __init__(self): + Synth.__init__(self) + self.lCache = {} + def works_on_this_platform(self): + if not unix: return 0 # I can't test on other platforms + for self.program in ["piper/piper","./piper"]: + if fileExists(self.program): return True + self.program = None + try: + import piper, wave + return True + except: pass + def supports_language(self,lang): + if lang in self.lCache: return self.lCache[lang] + for d in [".","piper"]: + foundSubdir = False + for f in os.listdir(d): + if f=="piper": foundSubdir=True + if (f.startswith(lang+"_") or f.startswith(lang+"-")) and f.endswith('.onnx'): + self.lCache[lang] = d+"/"+f + return self.lCache[lang] + if not foundSubdir: break + def guess_length(self,lang,text): return quickGuess(len(text),cond(lang in ["zh"],6,12)) # need better estimate + def transliterate(self,lang,text,forPartials=0): + # Piper TTS models are controlled by eSpeak phonemes, so we should be able to get eSpeak to do this + es = ESpeakSynth() + if not es.works_on_this_platform() or not es.supports_language(lang): return text + return es.transliterate(lang,text,forPartials) + def can_transliterate(self,lang): + es = ESpeakSynth() + return es.works_on_this_platform() and es.supports_language(lang) + def makefile(self,lang,text): + fname = os.tempnam()+dotwav + if self.program: + f=os.popen(self.program+' --model "'+self.supports_language(lang)+'" --output_file "'+fname+'"',popenWB) + f.write(text+"\n") ; f.close() + else: # no same-directory binary -> use Python API + import piper,wave + L = self.supports_language(lang) + if type(L)==str: + try: + import torch,onnxruntime + uc=torch.cuda.is_available() + except: uc=False + L=piper.PiperVoice.load(L,use_cuda=uc) + self.lCache[lang]=L + with wave.open(fname,"wb") as w: L.synthesize_wav(text,w) + return fname + +class GeminiSynth(Synth): + def __init__(self): + Synth.__init__(self) + self.lCache = {} + def works_on_this_platform(self): + if os.environ.get("GEMINI_API_KEY"): + try: + global genai, wave + from google import genai + import wave # assume have full library if got genai + return True + except: pass + def supports_language(self,lang): return True # if they set up Gemini we just "hope" its auto-detect works (see advanced.txt) + def guess_length(self,lang,text): return quickGuess(len(text),cond(lang in ["zh"],6,12)) # need better estimate + def makefile(self,lang,text): + for Try in [2,1,0]: + try: frames=genai.Client().models.generate_content(model="gemini-2.5-flash-preview-tts",contents=ensure_unicode(text),config=genai.types.GenerateContentConfig(response_modalities=["AUDIO"],speech_config=genai.types.SpeechConfig(voice_config=genai.types.VoiceConfig(prebuilt_voice_config=genai.types.PrebuiltVoiceConfig(voice_name=random.choice(os.environ.get('GEMINI_VOICES','Aoede,Kore,Laomedeia,Sulafat').split(','))))))).candidates[0].content.parts[0].inline_data.data + except: + if Try: + sys.stderr.write("Gemini fetch error: waiting 1 minute before retry\n"), time.sleep(60) + continue + raise + break + fname = os.tempnam()+dotwav + w=wave.open(fname,'wb') + w.setnchannels(1),w.setsampwidth(2),w.setframerate(24000) + w.writeframes(frames) + w.close() ; return fname + class GeneralSynth(Synth): def __init__(self): Synth.__init__(self) def supports_language(self,lang): @@ -983,6 +1109,7 @@ def makefile(self,lang,text): return fname all_synth_classes = [GeneralSynth,GeneralFileSynth] # at the beginning so user can override +all_synth_classes += [GeminiSynth,CoquiSynth,PiperSynth,ChatterboxSynth] # override espeak if present (especially PiperSynth) for s in synth_priorities.split(): # synth_priorities no longer in advanced.txt (see system.py above) but we can still support it if s.lower()=="ekho": all_synth_classes.append(EkhoSynth) elif s.lower()=="espeak": all_synth_classes.append(ESpeakSynth) @@ -1162,7 +1289,7 @@ def abspath_from_start(p): # for just_synthesize to check for paths relative to os.chdir(d) return r -def just_synthesize(callSanityCheck=0,lastLang_override=None): +def just_synthesize(callGeneralCheck=0,lastLang_override=None): # Handle the justSynthesize setting (see advanced.txt) global startAnnouncement,endAnnouncement,logFile,synth_partials_cache synth_partials_cache = {} # to stop 'memory leak' when running from the GUI @@ -1197,7 +1324,7 @@ def checkCanSynth(fname): r = repr(l[0]) if r[:1]=="b": r=r[1:] show_warning("Assuming that %s is a word to synthesize in language '%s'" % (r,lastLanguage)) - if callSanityCheck and sanityCheck(l[0],lastLanguage,1): return + if callGeneralCheck and generalCheck(l[0],lastLanguage,1): return event = checkCanSynth("!synth:"+S(l[0])+"_"+S(lastLanguage)) if not event: continue # couldn't synth called_synth = 1 @@ -1217,10 +1344,10 @@ def checkCanSynth(fname): lastLanguage=lang ; continue # otherwise, user might have omitted lang by mistake show_warning("Assuming %s was meant to be synthesized in language '%s'" % (cond(B('#') in B(justSynthesize) or len(repr(line))<10,"that '"+repr(line)+"'","this line"),lastLanguage)) - if callSanityCheck and sanityCheck(line,lastLanguage,1): return + if callGeneralCheck and generalCheck(line,lastLanguage,1): return event = checkCanSynth("!synth:"+S(line)+"_"+S(lastLanguage)) else: - if callSanityCheck and sanityCheck(text,lang,1): return + if callGeneralCheck and generalCheck(text,lang,1): return event = checkCanSynth(fname) lastLanguage = lang if not event: continue diff --git a/src/system.py b/src/system.py index ec983c2..131262b 100644 --- a/src/system.py +++ b/src/system.py @@ -63,7 +63,7 @@ class ShellExecuteInfo(ctypes.Structure): _fields_ = [("cbSize",wintypes.DWORD), try: ctypes.cdll.commdlg except: WMstandard = True -if macsound and __name__=="__main__": os.system("clear 1>&2") # so warnings etc start with a clear terminal (1>&2 just in case using stdout for something else) +if macsound and __name__=="__main__": os.system("clear >&2") # so warnings etc start with a clear terminal (>&2 just in case using stdout for something else) if riscos_sound: sys.stderr.write("Loading Gradint...\n") # in case it takes a while try: import androidhelper as android @@ -224,7 +224,7 @@ def wspstrip(s): # directory should be OK by now if sys.platform.find("ymbian")>-1: sys.path.insert(0,os.getcwd()+os.sep+"lib") -import time,sched,sndhdr,random,math,pprint,codecs +import time,sched,random,math,pprint,codecs def exc_info(inGradint=True): import sys # in case it's been gc'd @@ -237,7 +237,7 @@ def exc_info(inGradint=True): if tbObj and hasattr(tbObj,"tb_lineno"): w += (" at line "+str(tbObj.tb_lineno)) if inGradint: if tbObj and hasattr(tbObj,"tb_frame") and hasattr(tbObj.tb_frame,"f_code") and hasattr(tbObj.tb_frame.f_code,"co_filename") and not tbObj.tb_frame.f_code.co_filename.find("gradint"+extsep+"py")>=0: w += (" in "+tbObj.tb_frame.f_code.co_filename) - else: w += (" in "+program_name[:program_name.index("(c)")]) + else: w += (" in "+program_name[:program_name.index(" (c)")]) w += " on Python "+sys.version.split()[0]+"\n" del tbObj return w @@ -261,6 +261,7 @@ def readSettings(f): GUI_translations_old.update(GUI_translations) ; GUI_translations = GUI_translations_old # in case more have been added since advanced.txt last update def cond(a,b,c): + # Python 2.4 can inline "b if a else c" but Python 2.3 can't if a: return b else: return c @@ -428,7 +429,7 @@ def progressFileOK(): # put temp files in the current directory, EXCEPT if the current directory contains non-ASCII characters then check C:\TEMP and C:\ first (just in case the non-ASCII characters create problems for command lines etc; gradint *should* be able to cope but it's not possible to test in advance on *everybody's* localised system so best be on the safe side). TODO check for quotes etc in pathnames too. def isAscii(): for c in os.getcwd(): - if c<' ' or c>chr(127): return False + if not 32<=ord(c)<=127: return False return True tmpPrefix = None if winCEsound or not isAscii(): diff --git a/src/top.py b/src/top.py index 3f88721..1677a3b 100644 --- a/src/top.py +++ b/src/top.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # (Python 2 or Python 3, but more fully tested on 2) -program_name = "gradint v3.075 (c) 2002-23 Silas S. Brown. GPL v3+." +program_name = "gradint v3.11 (c) 2002-25 Silas S. Brown. GPL v3+." # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -32,7 +32,9 @@ def sort(l,c): l.sort(key=cmp_to_key(c)) def chr(x): return unichr(x).encode('latin1') from subprocess import getoutput popenRB,popenWB = "r","w" - def unicode(b,enc): return b.decode(enc) + def unicode(b,enc): + if type(b)==str: return b + return b.decode(enc) else: # Python 2 def sort(l,c): l.sort(c) popenRB,popenWB = "rb","wb" @@ -43,11 +45,11 @@ def sort(l,c): l.sort(c) try: True except: exec("True = 1 ; False = 0") def readB(f,m=None): - if hasattr(f,"buffer"): f=f.buffer # Python 3 non-"b" file + if hasattr(f,"buffer"): f0,f=f,f.buffer # Python 3 non-"b" file if m: return f.read(m) else: return f.read() # no "None" in Python 2 def writeB(f,b): - if hasattr(f,"buffer"): f=f.buffer # Python 3 non-"b" file + if hasattr(f,"buffer"): f0,f=f,f.buffer # Python 3 non-"b" file f.write(b) def B(x): if type(x)==bytes: return x diff --git a/thindown.py b/thindown.py old mode 100644 new mode 100755 index 1c0c92a..c7f79b2 --- a/thindown.py +++ b/thindown.py @@ -1,3 +1,9 @@ +#!/usr/bin/env python +# (works on either Python 2 or Python 3) + +# program to "thin down" the gradint .py for low memory environments +# by taking out some of the code that's unused on that platform + # This file is part of the source code of Gradint # (c) Silas S. Brown. # This program is free software; you can redistribute it and/or modify @@ -8,11 +14,6 @@ # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. -#!/usr/bin/env python -# (works on either Python 2 or Python 3) - -# program to "thin down" the gradint .py for low memory environments -# by taking out some of the code that's unused on that platform import sys, re @@ -215,22 +216,87 @@ "def android_changeLang():", ] +unix_only = [ +"class CoquiSynth(Synth):", +"class PiperSynth(Synth):", +] + android_or_S60 = [ "def droidOrS60RecWord(recFunc,inputFunc):", ] if "s60" in sys.argv: # S60 version version = "S60" - to_omit = tk_only + desktop_only + winCE_only + not_S60_or_android + android_only + riscos_only + mac_only + to_omit = tk_only + desktop_only + winCE_only + not_S60_or_android + android_only + riscos_only + mac_only + unix_only elif "android" in sys.argv: # Android version version = "Android" - to_omit = tk_only + desktop_only + winCE_only + S60_only + not_S60_or_android + not_android + riscos_only + mac_only + to_omit = tk_only + desktop_only + winCE_only + S60_only + not_S60_or_android + not_android + riscos_only + mac_only + unix_only elif "wince" in sys.argv: # Windows Mobile version version = "WinCE" - to_omit = desktop_only + S60_only + android_only + android_or_S60 + not_winCE + riscos_only + mac_only + to_omit = desktop_only + S60_only + android_only + android_or_S60 + not_winCE + riscos_only + mac_only + unix_only elif "core" in sys.argv: # experimental "core code only" for 'minimal embedded porting' starting point (no UI, no synth, limited file I/O; you'll probably have to load up the event data yourself) version = "core" - to_omit = tk_only + not_S60_or_android + not_android + riscos_only + mac_only + desktop_only + winCE_only + S60_only + android_only + android_or_S60 + ["def main():","def rest_of_main():",'if __name__=="__main__":',"def transliterates_differently(text,lang):","def primitive_synthloop():","def appendVocabFileInRightLanguages():",'def delOrReplace(L2toDel,L1toDel,newL2,newL1,action="delete"):',"def sanityCheck(text,language,pauseOnError=0):","def localise(s):","def singular(number,s):","def readText(l):","def asUnicode(x):","def updateSettingsFile(fname,newVals):","def clearScreen():","def startBrowser(url):",'def getYN(msg,defaultIfEof="n"):',"def waitOnMessage(msg):","def interrupt_instructions():","def parseSynthVocab(fname,forGUI=0):","def scanSamples_inner(directory,retVal,doLimit):","def getLsDic(directory):","def check_has_variants(directory,ls):","def exec_in_a_func(x):","def scanSamples(directory=None):","def synth_from_partials(text,lang,voice=None,isStart=1):","def partials_langname(lang):","if partialsDirectory and isDirectory(partialsDirectory):",'for zipToCheck in ["yali-voice","yali-lower","cameron-voice"]:','def stripPuncEtc(text):','def can_be_synthesized(fname,dirBase=None,lang=None):','def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=None):','def textof(fname):','if synthCache and transTbl in synthCache_contents:','if synthCache:','class Partials_Synth(Synth):','def abspath_from_start(p):','class SynthEvent(Event):','def pinyin_uColon_to_V(pinyin):','def synth_event(language,text,is_prompt=0):','def get_synth_if_possible(language,warn=1,to_transliterate=False):','if wavPlayer_override or (unix and not macsound and not (oss_sound_device=="/dev/sound/dsp" or oss_sound_device=="/dev/dsp")):','def fix_compatibility(utext):','def read_chinese_number(num):','def preprocess_chinese_numbers(utext,isCant=0):','def intor0(v):','def fix_pinyin(pinyin,en_words):','def fix_commas(text):','def shell_escape(text):','class SimpleZhTransliterator(object):','def sort_out_pinyin_3rd_tones(pinyin):','def ensure_unicode(text):','def unzip_and_delete(f,specificFiles="",ignore_fail=0):','class Synth(object):','def quickGuess(letters,lettersPerSec):',"def changeToDirOf(file,winsound_also=0):",'if app or appuifw or android:','def subst_some_synth_for_synthcache(events):','def decide_subst_synth(cache_fname):','if winsound or winCEsound or mingw32 or riscos_sound or not hasattr(os,"tempnam") or android:','if len(sys.argv)>1:','def readSettings(f):','def exc_info(inGradint=True):','if not fileExists(configFiles[0]):','def u8strip(d):',] + to_omit = tk_only + not_S60_or_android + not_android + riscos_only + mac_only + desktop_only + winCE_only + S60_only + android_only + android_or_S60 + unix_only + [ +"def main():", +"def rest_of_main():", +'if __name__=="__main__":', +"def transliterates_differently(text,lang):", +"def primitive_synthloop():", +"def appendVocabFileInRightLanguages():", +'def delOrReplace(L2toDel,L1toDel,newL2,newL1,action="delete"):', +"def generalCheck(text,language,pauseOnError=0):", +"def localise(s):", +"def singular(number,s):", +"def readText(l):", +"def asUnicode(x):", +"def updateSettingsFile(fname,newVals):", +"def clearScreen():", +"def startBrowser(url):",'def getYN(msg,defaultIfEof="n"):',"def waitOnMessage(msg):", +"def interrupt_instructions():", +"def parseSynthVocab(fname,forGUI=0):", +"def scanSamples_inner(directory,retVal,doLimit):", +"def getLsDic(directory):", +"def check_has_variants(directory,ls):", +"def exec_in_a_func(x):", +"def scanSamples(directory=None):", +"def synth_from_partials(text,lang,voice=None,isStart=1):", +"def partials_langname(lang):", +"if partialsDirectory and isDirectory(partialsDirectory):", +'for zipToCheck in ["yali-voice","yali-lower","cameron-voice"]:', +'def stripPuncEtc(text):', +'def can_be_synthesized(fname,dirBase=None,lang=None):', +'def synthcache_lookup(fname,dirBase=None,printErrors=0,justQueryCache=0,lang=None):', +'def textof(fname):', +'if synthCache and transTbl in synthCache_contents:', +'if synthCache:', +'class Partials_Synth(Synth):', +'def abspath_from_start(p):', +'class SynthEvent(Event):', +'def pinyin_uColon_to_V(pinyin):', +'def synth_event(language,text,is_prompt=0):', +'def get_synth_if_possible(language,warn=1,to_transliterate=False):', +'if wavPlayer_override or (unix and not macsound and not (oss_sound_device=="/dev/sound/dsp" or oss_sound_device=="/dev/dsp")):', +'def fix_compatibility(utext):', +'def read_chinese_number(num):', +'def preprocess_chinese_numbers(utext,isCant=0):', +'def intor0(v):', +'def fix_pinyin(pinyin,en_words):', +'def fix_commas(text):', +'def shell_escape(text):', +'class SimpleZhTransliterator(object):', +'def sort_out_pinyin_3rd_tones(pinyin):', +'def ensure_unicode(text):', +'def unzip_and_delete(f,specificFiles="",ignore_fail=0):', +'class Synth(object):', +'def quickGuess(letters,lettersPerSec):',"def changeToDirOf(file,winsound_also=0):",'if app or appuifw or android:', +'def subst_some_synth_for_synthcache(events):', +'def decide_subst_synth(cache_fname):', +'if winsound or winCEsound or mingw32 or riscos_sound or not hasattr(os,"tempnam") or android:', +'if len(sys.argv)>1:', +'def readSettings(f):', +'def exc_info(inGradint=True):', +'if not fileExists(configFiles[0]):', +'def u8strip(d):'] else: assert 0, "Unrecognised version on command line" revertToIndent = lastIndentLevel = indentLevel = -1 diff --git a/voice-mistakes.md b/voice-mistakes.md new file mode 100644 index 0000000..431cef2 --- /dev/null +++ b/voice-mistakes.md @@ -0,0 +1,81 @@ +# Chinese mistakes in commercial speech synthesizers +From https://ssb22.user.srcf.net/gradint/mistakes.html +(also [mirrored on GitLab Pages](https://ssb22.gitlab.io/gradint/mistakes.html) just in case) + +Commercial unit-selection voices may sound pleasant, but they do make mistakes. If you use one for language learning, be sure that it is not your only source. For example [Gradint](README.md) has a function to alternate between different synthesizers on different repeats (it also has a syllable-based voice which should at least be predictable). + +To demonstrate the trouble with unit-selection voices for language learning, below are some example Chinese mistakes that I found, usually after just a few minutes of experimenting with each voice. + +## Google Translate +(2011-05, using SVOX Yun which is also used by Android) +* 继续学院: The 学 is only half-pronounced. It seems like they had a recording of a whole 学 but some program played only half of it. You can’t really hear the ‘-ue’ of the ‘xue’. +* 糖尿病: ‘n’ of 尿 unclear +* 深省: Google correctly says this is “shēn xǐng”, but its voice incorrectly says “shēn shěng” (the voice must be using a smaller dictionary than the transcriber) +* 绝: somewhat unclear when spoken in isolation +## Beijing Infoquick SinoVoice +(2011-05; online trial no longer available) +* 用出来: The main word 用 could be clearer; at least 来 (and possibly 出) should be neutral tone (轻声) but isn’t +## iFlyTek InterPhonic / Bider SpeechPlus +(free trial no longer available) +* bao3zheng4, bian4ming2, fou3ren4, jia3ru2, mei3zhou1, mu4du3, many others (via CSSML pinyin markup): Incorrect syllables spoken (I’d have thought pinyin gives better control but it doesn’t) +## Neospeech Hui +(2011-05, rebranded as ReadSpeaker Mandarin Female in 2019 then back to Hui end-2024) +* 糖尿病: ‘n’ of 尿 unclear +* 奉公守法: first syllable unclear +## ScanSoft (Nuance) MeiLing +(also used by Nokia) +* 深省: 省 spoken as shěng instead of xǐng; no way to add a dictionary entry to override it +* 地, 行 and many other ambiguous hanzi: Engine often gets the wrong reading (e.g. dì instead of de in many adverbs, xíng instead of háng in 十四行诗), no way to override (except sometimes by writing wrong hanzi) +* 邮编: 编 pitch too low for the context +* 切合实际,对: 际 in 切合实际 by itself is correctly pronounced jì, but when followed by ”,对” the 际 seems to pronounced more like jiè (although not so when the hanzi after the comma is different, or when there is no pause before the 对) +* 絶 (variant of 絕/绝), 説 (variant of 說/说) and others: completely skipped, with no indication that there is a missing character in the text +* 用户界面: 界 sounds too much like 3rd tone instead of 4th tone +* 齁声: Pitch falls from B to E-flat. Some drop in pitch of tone 1 at the end of a phrase is acceptable, but an augmented fifth? (Compare 中东, 拼车, 伸开, etc) +* 人文学: Faults on 文 (but not in 人文 by itself). Sounds better if incorrectly written as 人闻学. +* 撞击: 击 sounds like a truncated neutral tone instead of tone 1 +* 电脑及资讯科技: something like half a 个 is inserted before the 及 +* 劫难: sounds more like jián’àn than jiénàn (it must be a coded exception to 难’s usual nán pronunciation but it seems the syllable boundary is wrong) +* 没有论文登出就垮台: 文 truncated +* 耳闻: ěr sounds like èr +## Microsoft Lili +(couldn’t test but heard a demo) +* 才: spoken as an unclear cǎi instead of cái (the old “MS Simplified Chinese” voice actually gets this one right but gets 央行 wrong) +## Neospeech Lily +(no longer sold separately but used by NextSpeak and ImTranslator 2011-05 without the lexicon access) +* 糖尿病: ‘n’ of 尿 very unclear +* yong4chu5lai5, zhuan3lai2zhuan3qu4 (via pinyin lexicon): Incorrectly read as yòngchūlai, zhuǎilái... but OK if input as hanzi 用出来, 转来转去 +* chan3chu2 or 铲除: says chù instead of chú +* shan4yong4 or 善用: shèn instead of shàn in pinyin; “n”s clipped in hanzi +* li4bi4 or 利弊: sounds like bībì +* you2bian1 or 邮编: biān pitch too low for the context +* jia1de5fu1: spoken as jiādìfū (maybe it’s being treated as 加的夫, which might be right but a pinyin override shouldn’t try to guess what the pinyin should have been; what if it came from 家的夫?) +## Loquendo Lisheng +(2011; interactive demo no longer available) +* mu4du3, mu4du4.: both words seem to end in dù (the du3 sounds OK if it’s the last thing in the sentence) +## Apple Ting-ting +(in OS 10.7, retested in 11 and 12) +* 乐: always spoken as yuè even in words like 快乐 and 乐意 when it should be lè (fixed before macOS 11.4; these and other dictionary mistakes—pó instead of fán in 繁体字, etc—are forgivable because the voice can work reasonably well from pinyin) +* mu4du3, mu4du4: Both “du” sounds seem incomplete +* yue4du2: dú fails to rise in pitch +* zhi1 di4: dì sounds too neutral (“fa2 zhi1 di4” is worse as this zhī is high by comparison) +* jing4qi2li3: q sounds like x in this context +* ming2 que4: què glitches in mid-syllable (it’s OK when said in isolation) +* jing1juan4: juan sounds like a garbled jue (can also sound like jue in contexts e.g. jing1juan4ming2) +* chang3kai1: chǎng sounds like a tone 1 higher than the kāi; if doubled to 敞开敞开, the second chǎng is better but is almost a full third tone instead of a half +* kou3 zheng1guo1: guo sounds almost like gua (zheng1guo1 by itself is better except the pitch falls nearly a major sixth) +* cheng2qiang2 tan1ta1: q becomes like x + pitch drop at end +* qu3dai4: tones not clear +* ying3 pian4: n dropped (better in context) + +Apple’s Ting-ting was supplied by Nuance (it says so in the PCMWave file) and it sounds like Loquendo Lisheng with different prosody, although Lion’s mid-2011 release was 2 months before Nuance finished taking over Loquendo. (Pre-releases reportedly used MeiLing instead of Ting-ting.) Baidu’s 2017 voice sounds identical to Ting-ting. I can probably claim some minor input to these voices, because in 2008 Loquendo lent me copies of Lisheng and Lingling so I could raise bug reports, which they fixed, but time was limited so we couldn’t catch everything and they didn’t release the voice to consumers. I don’t know what has happened to it since then. (Ting-ting’s PCMWave file also contains the string “SCANSOFT” which merged with Nuance in 2005, but it additionally has English rewrite rules that are provably unused by the engine, so perhaps they just tried to merge the codebases.) + +## Copyright and Trademarks +All material © Silas S. Brown unless otherwise stated. +Android is a trademark of Google LLC. +Apple is a trademark of Apple Inc. +Baidu is a trademark of Baidu Online Network Technology (Beijing) Co. Ltd. +Google is a trademark of Google LLC. +Loquendo is a trademark of Loquendo S.p.A. +Microsoft is a registered trademark of Microsoft Corp. +ScanSoft and Nuance are trademarks of Nuance Communications, Inc. +Any other trademarks I mentioned without realising are trademarks of their respective holders.