Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions public/IMMERSIVE.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,21 @@ worker must control the page (reload once if prompted). Explicit data is served
cache-first as a snapshot; remove and redownload to refresh dictionaries. Browser
storage persistence is requested, but users or browsers can still clear data.
AI requires the configured server and speech depends on the available voice.

## Anki speech

New exports use language-specific `Graph Explorer · <locale> · TTS` note types
with Anki's native TTS on the Recognition answer. Locales are ja_JP, zh_CN,
zh_TW, and zh_HK; Anki chooses an installed system voice. The Speech field uses
the first supplied Japanese reading (or the text if unavailable), and original
characters for Chinese. Meaning and romanized Chinese readings are not spoken.
Native TTS needs a compatible Anki client and voice; this does not embed audio
files or use the browser's Google voice. Autoplay follows Anki's audio settings.

Existing Graph Explorer cards and customized note types are unchanged. To upgrade
old cards, export one new card in the desired language to create its note type,
then select the old notes for that language in Anki's browser and use Change Note
Type, mapping the five original fields by name. With Speech empty, the new
answer template speaks Text. Japanese Speech can be filled with a preferred kana
reading when needed. Because note types differ, re-exporting an old note may
create a duplicate; changing its note type is preferable.
2 changes: 1 addition & 1 deletion public/explorer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-title" content="Explorer">
<link rel="manifest" href="/explorer/manifest.json">
<link rel="stylesheet" href="/css/immersive.css?v=offline-dictionary">
<script defer src="/js/immersive-bundle.js?v=offline-dictionary"></script>
<script defer src="/js/immersive-bundle.js?v=anki-tts"></script>
<script>
// The explorer's own worker, separate from classic HanziGraph's; see /explorer/sw.js.
if ('serviceWorker' in navigator) addEventListener('load', () => navigator.serviceWorker.register('/explorer/sw.js'));
Expand Down
19 changes: 16 additions & 3 deletions public/js/modules/immersive-integrations.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { validateAnalysis } from './immersive-sentences.mjs';
export const noteModel = 'Graph Explorer';
export const languageFor = dataset => ({japanese:'ja-JP', cantonese:'zh-HK', traditional:'zh-TW', simplified:'zh-CN'})[dataset];
export function ankiModelFor(dataset) {
const language = languageFor(dataset);
if (!language) throw new Error('Unsupported Anki language.');
return `${noteModel} · ${language} · TTS`;
}
export function ankiTemplates(dataset) {
const language = languageFor(dataset)?.replace('-', '_');
if (!language) throw new Error('Unsupported Anki language.');
return [{Name:'Recognition', Front:'<div class="text">{{Text}}</div>',
Back:`{{FrontSide}}<hr id="answer"><div class="reading">{{Reading}}</div><div>{{Meaning}}</div><div class="audio">{{#Speech}}{{tts ${language}:Speech}}{{/Speech}}{{^Speech}}{{tts ${language}:Text}}{{/Speech}}</div>`}];
}
export function defaultPrompt(dataset) {
const language = dataset === 'japanese' ? 'Japanese' : dataset === 'cantonese' ? 'Cantonese using traditional characters' : dataset === 'traditional' ? 'Mandarin Chinese using traditional characters' : 'Mandarin Chinese using simplified characters';
const reading = dataset === 'japanese' ? 'hiragana readings (not romaji)' : dataset === 'cantonese' ? 'jyutping' : 'pinyin';
Expand Down Expand Up @@ -66,14 +77,16 @@ export async function ankiRequest(settings, action, params = {}, signal, fetcher
return data.result;
}
export function makeNote(settings, dataset, entry) {
return {deckName:settings.deck, modelName:noteModel, fields:{Text:escapeHtml(entry.text), Reading:escapeHtml(entry.reading), Meaning:escapeHtml(entry.english).replace(/\n/g, '<br>'), Source:escapeHtml(entry.source), Language:languageFor(dataset)}, tags:['graph-explorer', dataset, ...(entry.generated ? ['ai-generated'] : [])], options:{allowDuplicate:false, duplicateScope:'deck'}};
const speech = dataset === 'japanese' ? entry.reading?.split(' / ')[0]?.trim() || entry.text : entry.text;
return {deckName:settings.deck, modelName:ankiModelFor(dataset), fields:{Text:escapeHtml(entry.text), Reading:escapeHtml(entry.reading), Meaning:escapeHtml(entry.english).replace(/\n/g, '<br>'), Source:escapeHtml(entry.source), Language:languageFor(dataset), Speech:escapeHtml(speech)}, tags:['graph-explorer', dataset, ...(entry.generated ? ['ai-generated'] : [])], options:{allowDuplicate:false, duplicateScope:'deck'}};
}
export async function addToAnki(settings, dataset, entry, signal, fetcher) {
const noteModel = ankiModelFor(dataset);
const invoke = (action, params) => ankiRequest(settings, action, params, signal, fetcher);
const fields = ['Text','Reading','Meaning','Source','Language'];
const fields = ['Text','Reading','Meaning','Source','Language','Speech'];
const models = await invoke('modelNames');
if (!models.includes(noteModel)) {
await invoke('createModel', {modelName:noteModel, inOrderFields:fields, css:'.card { font-family: sans-serif; text-align: center; font-size: 22px; } .text { font-size: 32px; } .reading { font-size: 18px; margin: 12px; }', cardTemplates:[{Name:'Recognition', Front:'<div class="text">{{Text}}</div>', Back:'{{FrontSide}}<hr id="answer"><div class="reading">{{Reading}}</div><div>{{Meaning}}</div>'}]});
await invoke('createModel', {modelName:noteModel, inOrderFields:fields, css:'.card { font-family: sans-serif; text-align: center; font-size: 22px; } .text { font-size: 32px; } .reading { font-size: 18px; margin: 12px; }', cardTemplates:ankiTemplates(dataset)});
} else {
const existing = await invoke('modelFieldNames', {modelName:noteModel});
if (!fields.every(field => existing.includes(field))) throw new Error('The Graph Explorer note type has incompatible fields. Please rename it in Anki and try again.');
Expand Down
39 changes: 36 additions & 3 deletions scripts/tests/immersive-integrations.test.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { defaultPrompt, listModels, askAi, makeNote, addToAnki, ankiRequest } from '../../public/js/modules/immersive-integrations.mjs';
import { defaultPrompt, listModels, askAi, makeNote, addToAnki, ankiRequest, ankiModelFor, ankiTemplates } from '../../public/js/modules/immersive-integrations.mjs';
const settings = {aiEndpoint:'http://localhost:1234/v1/', aiModel:'local-model', ankiEndpoint:'http://localhost:8765', ankiKey:'test-key', deck:'JapaneseGraph'};
const response = value => ({ok:true, json:async () => value});

Expand Down Expand Up @@ -50,7 +50,7 @@ test('duplicate or incompatible Anki notes never overwrite existing content', as
const calls = [];
const fetcher = async (url, options) => {
const {action} = JSON.parse(options.body); calls.push(action);
return response({error:null, result:({modelNames:['Graph Explorer'], modelFieldNames:['Text','Reading','Meaning','Source','Language'], createDeck:2, canAddNotes:[false]})[action]});
return response({error:null, result:({modelNames:[ankiModelFor('japanese')], modelFieldNames:['Text','Reading','Meaning','Source','Language','Speech'], createDeck:2, canAddNotes:[false]})[action]});
};
await assert.rejects(addToAnki(settings, 'japanese', {text:'学校'}, undefined, fetcher), /already exists/);
assert.ok(!calls.includes('addNote'));
Expand All @@ -61,7 +61,7 @@ test('incompatible Anki model stops before creating a deck or adding notes', asy
const actions = [];
const fetcher = async (url, options) => {
const {action} = JSON.parse(options.body); actions.push(action);
return response({error:null, result:action === 'modelNames' ? ['Graph Explorer'] : ['Front','Back']});
return response({error:null, result:action === 'modelNames' ? [ankiModelFor('japanese')] : ['Front','Back']});
};
await assert.rejects(addToAnki(settings, 'japanese', {text:'学校'}, undefined, fetcher), /incompatible fields/);
assert.deepEqual(actions, ['modelNames','modelFieldNames']);
Expand Down Expand Up @@ -100,3 +100,36 @@ test('saved incomplete drafts cannot enable integrations and disabled drafts do
assert.equal(integrationProblem({aiEnabled:false, aiEndpoint:''}, 'ai'), '');
assert.equal(integrationProblem({...settings, aiEnabled:true}, 'ai'), '');
});


test('native TTS templates choose the target locale and speak only on the answer', () => {
for (const [dataset, locale] of Object.entries({japanese:'ja_JP',simplified:'zh_CN',traditional:'zh_TW',cantonese:'zh_HK'})) {
const [template] = ankiTemplates(dataset);
assert.ok(template.Back.includes('{{tts '+locale+':Speech}}'));
assert.ok(template.Back.includes('{{tts '+locale+':Text}}'));
assert.doesNotMatch(template.Front, /tts/);
assert.doesNotMatch(template.Back, /tts.*:Meaning/);
assert.equal(makeNote(settings,dataset,{text:'学校'}).modelName,ankiModelFor(dataset));
}
assert.equal(new Set(['japanese','simplified','traditional','cantonese'].map(ankiModelFor)).size,4);
assert.throws(() => ankiTemplates('unknown'));
});
test('Japanese TTS follows the first reading, Chinese speaks characters, and missing readings fall back', () => {
assert.equal(makeNote(settings,'japanese',{text:'丈夫',reading:'じょうぶ / ますらお'}).fields.Speech,'じょうぶ');
assert.equal(makeNote(settings,'japanese',{text:'電車が来た。',reading:''}).fields.Speech,'電車が来た。');
assert.equal(makeNote(settings,'simplified',{text:'学校',reading:'xue2 xiao4'}).fields.Speech,'学校');
assert.equal(makeNote(settings,'japanese',{text:'<script>',reading:'<kana>'}).fields.Speech,'&lt;kana&gt;');
});
test('legacy Graph Explorer models are not altered when creating an audio model', async () => {
const calls=[];
const fetcher=async (url, options)=>{
const body=JSON.parse(options.body);calls.push(body);
return response({error:null,result:({modelNames:['Graph Explorer'],createModel:1,createDeck:2,canAddNotes:[true],addNote:123})[body.action]});
};
await addToAnki(settings,'japanese',{text:'丈夫',reading:'じょうぶ'},undefined,fetcher);
const model=calls.find(call=>call.action==='createModel').params;
assert.equal(model.modelName,ankiModelFor('japanese'));
assert.ok(model.inOrderFields.includes('Speech'));
assert.deepEqual(model.cardTemplates,ankiTemplates('japanese'));
assert.ok(!calls.some(call=>call.action.startsWith('update')));
});
Loading