Interactive Practice

Talk with Katsu

Have a real back-and-forth in Japanese. Pick how you want to practice, chat with Katsu for as long as you like, then stop any time to get an assessment of how you did.

1. Choose a practice mode
2. Set the details
Scenario
Level
Level
Form
Level

Pick a mode above to see its options.

—
Show

Katsu stays in character, and adds a short correction note when you make a mistake. Change how Japanese is shown any time — it re-renders instantly.

Stop this conversation?

You can get an assessment of this session, or just pick a different practice mode.

📊 Session Assessment

'; } function downloadAssessment() { if (!state.lastAssessment) return; var html = buildReportHtml(state.lastAssessment, state.lastTurns || []); var today = new Date().toISOString().split('T')[0]; var blob = new Blob([html], { type: 'text/html' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'Conversation-Report-' + today + '.html'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function closeAssessment() { // Closing the report hands you back the menu you opened it from, so the // three choices are still in front of you: keep talking, pick a different // practice, or read the report again. It used to jump straight to the // setup screen, which threw the conversation away as the price of // dismissing a dialog — nothing to re-read, and no way back to the report. $('talk-assessment-modal').classList.remove('active'); $('talk-stop-modal').classList.add('active'); } function init() { $('talk-form').addEventListener('submit', submitInput); // The moment they start composing a reply, the scene stops moving without // them — even if they end up deleting it again. $('talk-input').addEventListener('input', disarmWait); $('talk-input').addEventListener('paste', disarmWait); paintWait(); var customInput = $('talk-custom-input'); customInput.addEventListener('input', validateStart); customInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !$('talk-start-btn').disabled) start(); }); $('talk-toggle-romaji').addEventListener('click', function () { if (display.script === 'romaji') return; // implied in rōmaji mode display.romaji = !display.romaji; persistDisplay(); buildDisplayControls(); rerenderAll(); }); $('talk-toggle-english').addEventListener('click', function () { display.english = !display.english; persistDisplay(); buildDisplayControls(); rerenderAll(); }); $('talk-toggle-fix').addEventListener('click', function () { display.fix = !display.fix; persistDisplay(); buildDisplayControls(); rerenderAll(); }); $('talk-toggle-speak').addEventListener('click', function () { setSpeechOn(!speechOn()); }); $('talk-setup-speak').addEventListener('click', function () { setSpeechOn(!speechOn()); }); wireRomajiInput(); // Same letters the Kana Drill uses for its two charts, so H and K mean // hiragana and katakana everywhere on the site. var infoOverlay = $('talk-info-overlay'); if (infoOverlay) { infoOverlay.addEventListener('click', function (e) { if (e.target === this) closeInfo(); }); } // Release either key — or leave the window — and the peek ends. function endPeek() { if (talkInfoPeeking) closeInfo(); } document.addEventListener('keyup', function (e) { var k = (e.key || '').toLowerCase(); if (k === 'i' || k === 'alt' || !e.altKey) endPeek(); }); window.addEventListener('blur', endPeek); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && infoOpen()) { closeInfo(); return; } if (!e.altKey || e.ctrlKey || e.metaKey) return; var key = (e.key || '').toLowerCase(); if (key === 'i') { e.preventDefault(); if (!e.repeat && !infoOpen()) openInfo(true); return; } if (infoOpen()) return; // the rest belong to the conversation if (key === 'w') { e.preventDefault(); skipAhead(); return; } var k = key; if (k !== 'h' && k !== 'k' && k !== 'l') return; e.preventDefault(); setInputMode(k === 'h' ? 'kana' : (k === 'k' ? 'katakana' : 'off')); var input = $('talk-input'); if (input && !input.disabled) input.focus(); if (typeof showToast === 'function') { showToast(tt('input_mode_toast_' + inputMode(), { kana: 'ひらがな — hiragana input', katakana: 'カタカナ — katakana input', off: 'Romaji input off' }[inputMode()]), 'success'); } }); buildDisplayControls(); // Voices load asynchronously in Chrome, so the button may only become // available a moment after the page settles. if (window.KA_Speech) { window.KA_Speech.onReady(function () { paintSpeechToggle(); rerenderAll(); }); } // Azure's voice list decides whether the picker appears at all. // Same collapsed panel as the Vocabulary page: the voice is a set-once // choice, so it stays out of the way until the caret is pressed. if (window.KA_Listen && window.KA_Listen.wirePanel) { // Open/close only — buildVoicePicker below fills the select and wires // Preview, because it also keeps the setup screen's copy in step. window.KA_Listen.wirePanel({ caret: 'talk-audio-caret', panel: 'talk-audio-panel' }); } buildVoicePicker(); var setupAudio = document.querySelector('#talk-setup-audio .talk-audio-controls'); if (setupAudio && window.KA_VoiceSpeed && !setupAudio.querySelector('.ka-speed')) { var sp = KA_VoiceSpeed.slider(); sp.style.flex = '1 1 220px'; setupAudio.appendChild(sp); } if (window.KA_Azure) { window.KA_Azure.listVoices().then(function () { paintSpeechToggle(); rerenderAll(); }); } // Never leave a voice talking to an empty room. window.addEventListener('pagehide', function () { if (window.KA_Voice) window.KA_Voice.stop(); }); // Keep the chips and the input helper in sync when the language changes. window.refreshTalkI18n = function () { buildDisplayControls(); paintRomajiToggle(); buildVoicePicker(); }; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } return { selectMode: selectMode, start: start, requestStop: requestStop, cancelStop: cancelStop, backToSetup: backToSetup, getAssessment: getAssessment, download: downloadAssessment, closeAssessment: closeAssessment, toggleRomaji: toggleRomajiInput, skipAhead: skipAhead, speak: speakJapanese, openInfo: openInfo, closeInfo: closeInfo }; })(); // ========================================================================= // TalkSpeech — speaking into the reply box, and being told how it sounded. // // How it fits together: // 1. /api/speech-token hands us a 10-minute Azure token. The subscription // key stays on the server; the token can only start a recognition. // 2. The Azure Speech SDK (~1 MB) is fetched ONLY when the microphone is // first pressed. Nobody who never speaks pays for that download. // 3. The browser streams the microphone straight to Azure, so partial // words land in the input as they are spoken. // 4. Pronunciation assessment rides along on the same recognition at no // extra cost — Azure bills it as ordinary speech-to-text. // // If any of that is unavailable the microphone simply never appears. Talk // has always worked by typing and continues to. // ========================================================================= var TalkSpeech = (function () { 'use strict'; var TOKEN_URL = '/api/speech-token'; // Pinned rather than @latest: a silently-updated SDK on a page nobody is // watching is how a working microphone breaks on a Tuesday. var SDK_URL = 'https://cdn.jsdelivr.net/npm/microsoft-cognitiveservices-speech-sdk@1.41.0/distrib/browser/microsoft.cognitiveservices.speech.sdk.bundle-min.js'; // Azure issues 10-minute tokens. Re-fetch at nine so a long session never // dies mid-sentence on an expiry we could have seen coming. var TOKEN_LIFE_MS = 9 * 60 * 1000; var sdk = null; // the loaded SDK namespace var sdkLoading = null; // in-flight load promise, so a double click loads once var token = null, tokenRegion = null, tokenAt = 0; var recognizer = null; var listening = false; var available = false; var baseText = ''; // what was already typed before we started var livePartial = false; // a partial result is currently on screen var lastAssessment = null; // kept so Hide can be undone function $(id) { return document.getElementById(id); } function t(key, fallback) { try { return (typeof window.t === 'function' && window.t(key)) || fallback; } catch (e) { return fallback; } } // ---- token ------------------------------------------------------------ function fetchToken() { if (token && Date.now() - tokenAt < TOKEN_LIFE_MS) return Promise.resolve(true); return fetch(TOKEN_URL, { method: 'GET' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (data) { if (!data || !data.success || !data.token) return false; token = data.token; tokenRegion = data.region; tokenAt = Date.now(); return true; }) .catch(function () { return false; }); } // ---- the SDK, loaded on demand --------------------------------------- function loadSdk() { if (sdk) return Promise.resolve(sdk); if (sdkLoading) return sdkLoading; sdkLoading = new Promise(function (resolve, reject) { var s = document.createElement('script'); s.src = SDK_URL; s.async = true; s.onload = function () { sdk = window.SpeechSDK || null; sdk ? resolve(sdk) : reject(new Error('SDK loaded but SpeechSDK is missing')); }; s.onerror = function () { reject(new Error('SDK failed to load')); }; document.head.appendChild(s); }); sdkLoading.catch(function () { sdkLoading = null; }); // let a retry try again return sdkLoading; } // ---- UI --------------------------------------------------------------- function setMic(state) { var mic = $('talk-mic'); if (!mic) return; mic.classList.toggle('listening', state === 'listening'); mic.classList.toggle('starting', state === 'starting'); mic.setAttribute('aria-pressed', state === 'listening' ? 'true' : 'false'); mic.disabled = state === 'starting'; mic.textContent = state === 'listening' ? '⏹' : '🎤'; mic.title = state === 'listening' ? t('talk_mic_stop', 'Stop listening') : t('talk_mic_start', 'Speak your reply (Alt+M)'); mic.setAttribute('aria-label', mic.title); } function note(msg) { var box = $('talk-pron'); if (!box) return; box.hidden = false; box.innerHTML = ''; var p = document.createElement('div'); p.className = 'talk-pron-note'; p.textContent = msg; box.appendChild(p); } // Fully gone: the score belonged to a sentence that has now been sent. function clearFeedback() { var box = $('talk-pron'); if (box) { box.hidden = true; box.innerHTML = ''; } lastAssessment = null; } // Hide, on the other hand, must be reversible. Destroying the score meant // dismissing it was permanent until you spoke again, which is a trap: the // button looks like "get this out of my way", not "throw this away". function collapseFeedback() { var box = $('talk-pron'); if (!box || !lastAssessment) return; box.innerHTML = ''; box.hidden = false; var row = document.createElement('div'); row.className = 'talk-pron-collapsed'; var lbl = document.createElement('span'); lbl.textContent = t('talk_pron_label', 'Pronunciation'); var num = document.createElement('b'); num.textContent = lastAssessment.overall; num.style.color = lastAssessment.overall >= 80 ? 'rgb(96, 138, 96)' : lastAssessment.overall >= 60 ? 'rgb(186, 141, 60)' : 'var(--accent)'; row.appendChild(lbl); row.appendChild(num); var bar = document.createElement('button'); bar.type = 'button'; bar.className = 'talk-pron-dismiss talk-pron-reopen'; bar.textContent = '\u25b4 ' + t('talk_pron_show', 'show'); bar.onclick = function () { showAssessment(lastAssessment.json); }; row.appendChild(bar); box.appendChild(row); } // Azure's 0-100 scores, in the three bands a learner can act on. function band(score) { if (score >= 80) return 'good'; if (score >= 60) return 'fair'; return 'poor'; } function showAssessment(json) { var box = $('talk-pron'); if (!box) return; var nb = json && json.NBest && json.NBest[0]; var pa = nb && nb.PronunciationAssessment; if (!pa) { clearFeedback(); return; } box.hidden = false; box.innerHTML = ''; var overall = Math.round(pa.PronScore != null ? pa.PronScore : pa.AccuracyScore || 0); lastAssessment = { json: json, overall: overall }; var head = document.createElement('div'); head.className = 'talk-pron-head'; var score = document.createElement('span'); score.className = 'talk-pron-score'; score.textContent = overall; score.style.color = overall >= 80 ? 'rgb(96, 138, 96)' : overall >= 60 ? 'rgb(186, 141, 60)' : 'var(--accent)'; head.appendChild(score); var label = document.createElement('span'); label.className = 'talk-pron-label'; label.textContent = t('talk_pron_label', 'Pronunciation'); head.appendChild(label); var sub = document.createElement('span'); sub.className = 'talk-pron-sub'; var bits = []; if (pa.AccuracyScore != null) bits.push(t('talk_pron_accuracy', 'accuracy') + ' ' + Math.round(pa.AccuracyScore)); if (pa.FluencyScore != null) bits.push(t('talk_pron_fluency', 'fluency') + ' ' + Math.round(pa.FluencyScore)); sub.textContent = bits.join(' · '); head.appendChild(sub); // The hide control lives on the header row, at the far end — same line as // the score, so it adds no height, and drawn as a chip so it is obviously // pressable. var dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.className = 'talk-pron-dismiss'; dismiss.textContent = '\u25be ' + t('talk_pron_hide', 'hide'); dismiss.onclick = collapseFeedback; head.appendChild(dismiss); box.appendChild(head); // Per-word. Japanese returns no phoneme breakdown — a documented gap, not // a bug here — so word level is as fine as this can honestly get. var words = (nb.Words || []).filter(function (w) { return w && w.Word && w.PronunciationAssessment; }); if (words.length) { var row = document.createElement('div'); row.className = 'talk-pron-words'; words.forEach(function (w) { var acc = w.PronunciationAssessment.AccuracyScore; var el = document.createElement('span'); el.className = 'talk-pron-word jp ' + band(acc == null ? 100 : acc); el.textContent = w.Word; if (acc != null) el.title = Math.round(acc) + '/100'; row.appendChild(el); }); box.appendChild(row); } } // ---- recognition ------------------------------------------------------ function stop() { listening = false; setMic('idle'); if (!recognizer) return; var r = recognizer; recognizer = null; try { r.stopContinuousRecognitionAsync(function () { try { r.close(); } catch (e) {} }, function () { try { r.close(); } catch (e) {} }); } catch (e) { try { r.close(); } catch (e2) {} } } function start() { var input = $('talk-input'); if (!input) return; setMic('starting'); // Deliberately NOT clearing the score here. Pressing the microphone to // say the next thing was wiping the previous score a second or two before // the new one arrived, which read as "it hides when I send". The old score // now stays put until a new one replaces it. fetchToken().then(function (haveToken) { if (!haveToken) throw new Error('no token'); return loadSdk(); }).then(function (S) { var cfg = S.SpeechConfig.fromAuthorizationToken(token, tokenRegion); cfg.speechRecognitionLanguage = 'ja-JP'; var audio = S.AudioConfig.fromDefaultMicrophoneInput(); recognizer = new S.SpeechRecognizer(cfg, audio); // Unscripted assessment: no reference text, because the learner is // answering Katsu, not reading a prompt. Word granularity — Japanese // returns nothing below it. enableMiscue off: with no script there is // nothing for inserted or skipped words to be measured against. var pron = new S.PronunciationAssessmentConfig( '', S.PronunciationAssessmentGradingSystem.HundredMark, S.PronunciationAssessmentGranularity.Word, false ); pron.applyTo(recognizer); baseText = input.value ? input.value.replace(/\s+$/, '') + ' ' : ''; // Partial results: what makes this feel live rather than laggy. The // romaji converter must not touch these — they are already kana. // The box can change under us between utterances — Send empties it, and // the learner may edit by hand. Trusting our own memory of what was // there is what pasted an already-sent sentence back in front of the // next one. So we re-read the box; the only subtlety is that while a // partial is on screen the box legitimately holds baseText + partial, // and adopting THAT as the new base is how the same words got counted // twice. function stillOurs() { return input.value.indexOf(baseText.replace(/\s+$/, '')) === 0; } function adoptBox() { var current = input.value.replace(/\s+$/, ''); baseText = current ? current + ' ' : ''; } recognizer.recognizing = function (_s, e) { if (!listening || !e.result || !e.result.text) return; // Check every time, partial in flight or not. Guarding this behind // !livePartial meant that clearing the box to redo a sentence, while // a partial was still on screen, was ignored — and the cleared text // came straight back on the next word. if (!stillOurs()) adoptBox(); livePartial = true; input.value = baseText + e.result.text; }; recognizer.recognized = function (_s, e) { if (!e.result) return; if (e.result.reason !== S.ResultReason.RecognizedSpeech) return; var said = e.result.text || ''; if (!said) return; // With a partial showing, the box is baseText + partial, so we only // check that our base is still the prefix; without one, whatever is // in the box is what the learner left there. if (livePartial) { if (!stillOurs()) baseText = ''; } else if (!stillOurs()) adoptBox(); livePartial = false; baseText = (baseText + said).replace(/\s+$/, '') + ' '; input.value = baseText.replace(/\s+$/, ''); try { var raw = e.result.properties.getProperty( S.PropertyId.SpeechServiceResponse_JsonResult); if (raw) showAssessment(JSON.parse(raw)); } catch (err) { /* transcription still worked; scoring is a bonus */ } }; recognizer.canceled = function (_s, e) { var why = (e && e.errorDetails) || ''; stop(); // A token that expired mid-session is worth one silent retry. if (/token|authenticat|1006/i.test(why) && Date.now() - tokenAt > 60000) { token = null; note(t('talk_mic_reconnect', 'Reconnecting…')); setTimeout(start, 300); return; } if (why) note(t('talk_mic_error', 'The microphone stopped unexpectedly. You can keep typing.')); }; recognizer.sessionStopped = function () { stop(); }; return new Promise(function (resolve, reject) { recognizer.startContinuousRecognitionAsync(resolve, reject); }); }).then(function () { listening = true; setMic('listening'); }).catch(function (err) { stop(); var msg = String((err && (err.message || err.name)) || err || ''); // Permission is the one failure worth explaining, because it is the // one the person can actually fix. if (/permission|NotAllowed|denied/i.test(msg)) { note(t('talk_mic_denied', 'Microphone access was blocked. Allow it in your browser’s address bar to speak your replies.')); } else if (/no token/i.test(msg)) { note(t('talk_mic_unavailable', 'Speech input is not available right now. You can still type.')); } else { note(t('talk_mic_error', 'The microphone stopped unexpectedly. You can keep typing.')); } }); } function toggle() { listening ? stop() : start(); } // ---- setup ------------------------------------------------------------ function init() { var mic = $('talk-mic'); if (!mic) return; // Three things must be true before the button is worth showing: the // browser can capture audio, the page is on a secure origin (getUserMedia // is refused otherwise), and the backend will actually issue a token. var canCapture = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); if (!canCapture || !window.isSecureContext) return; fetchToken().then(function (ok) { if (!ok) return; available = true; mic.style.display = ''; setMic('idle'); }); // Alt+M, matching the page's other Alt shortcuts. e.code so the layout // cannot change which physical key it is. document.addEventListener('keydown', function (e) { if (!e.altKey || e.ctrlKey || e.metaKey) return; var k = /^Key[A-Z]$/.test(e.code || '') ? e.code.slice(3).toLowerCase() : (e.key || '').toLowerCase(); if (k !== 'm' || !available) return; var micEl = $('talk-mic'); if (!micEl || micEl.offsetParent === null) return; e.preventDefault(); toggle(); }); // Never keep the microphone open on a page nobody is looking at. document.addEventListener('visibilitychange', function () { if (document.hidden && listening) stop(); }); window.addEventListener('pagehide', function () { if (listening) stop(); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } return { toggle: toggle, stop: stop, // Called by the page when the reply box is emptied by Send. Continuous // recognition survives a send, so without this the microphone would // keep building on text that is no longer on screen. // Sending clears the box, not the score. The score is the record of how // that sentence sounded, and it is most useful precisely after you have // sent it — it stays until the next thing you say replaces it, or you // hide it yourself. replySent: function () { baseText = ''; livePartial = false; }, isListening: function () { return listening; }, isAvailable: function () { return available; }, // Exposed for the tests, which stand in for the SDK and the token. _setSdk: function (s) { sdk = s; sdkLoading = Promise.resolve(s); }, _showAssessment: showAssessment }; })();