Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 7efc97d0dde0b2a3e729ccf9
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/speech.ts
// The browser's own voices read the target language aloud, at normal and slow speed, and its speech
// recognition checks the learner's pronunciation where the browser has it. Where either is missing,
// the exercises that need it are swapped for others.
let voices: SpeechSynthesisVoice[] = [];
let loaded: Promise<void> | null = null;
function loadVoices(): Promise<void> {
if (typeof speechSynthesis === "undefined") return Promise.resolve();
loaded ??= new Promise((resolve) => {
const read = () => { voices = speechSynthesis.getVoices(); if (voices.length) resolve(); };
read();
speechSynthesis.addEventListener?.("voiceschanged", read);
setTimeout(resolve, 1500);
});
return loaded;
}
function voiceFor(code: string): SpeechSynthesisVoice | undefined {
const lang = code.slice(0, 2);
const matches = voices.filter((v) => v.lang.replace("_", "-").toLowerCase().startsWith(lang));
const score = (v: SpeechSynthesisVoice) =>
(v.lang.replace("_", "-").toLowerCase() === code.toLowerCase() ? 4 : 0) + (/premium|enhanced|natural|google/i.test(v.name) ? 2 : 0) + (v.localService ? 1 : 0);
return matches.sort((a, b) => score(b) - score(a))[0];
}
/** Whether this device can read `code` aloud. */
export async function canListen(code: string): Promise<boolean> {
await loadVoices();
return Boolean(voiceFor(code));
}
export function say(text: string, code: string, slow = false) {
if (typeof speechSynthesis === "undefined") return;
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text);
const v = voiceFor(code);
if (v) u.voice = v;
u.lang = v?.lang || code;
u.rate = slow ? 0.6 : 0.95;
speechSynthesis.speak(u);
}
type Recognition = {
lang: string; interimResults: boolean; maxAlternatives: number; continuous: boolean;
onresult: ((e: any) => void) | null; onerror: ((e: any) => void) | null; onend: (() => void) | null;
start(): void; stop(): void; abort(): void;
};
const Ctor = (): (new () => Recognition) | undefined =>
typeof window === "undefined" ? undefined : (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const MUTE_KEY = "lingo:no-speak-until";
/** Whether speaking exercises should appear: the browser can listen and the learner has not paused them. */
export function canSpeak(): boolean {
let until = 0;
try { until = Number(localStorage.getItem(MUTE_KEY) || 0); } catch { /* ignore */ }
return Boolean(Ctor()) && Date.now() > until;
}
/** "Can't speak now": no speaking exercises for the next hour. */
export function pauseSpeaking() {
try { localStorage.setItem(MUTE_KEY, String(Date.now() + 3600_000)); } catch { /* ignore */ }
}
/** Listens once. Resolves with what was heard ("" if nothing); rejects if the microphone is blocked. */
export function listen(code: string, onHeard: (text: string) => void): { stop: () => void; done: Promise<string> } {
const R = Ctor();
if (!R) return { stop: () => {}, done: Promise.reject(new Error("unsupported")) };
const rec = new R();
rec.lang = code;
rec.interimResults = true;
rec.maxAlternatives = 3;
rec.continuous = false;
let heard = "";
const done = new Promise<string>((resolve, reject) => {
rec.onresult = (e: any) => {
heard = Array.from(e.results as ArrayLike<any>).map((r: any) => r[0].transcript).join(" ").trim();
onHeard(heard);
};
rec.onerror = (e: any) => (e.error === "not-allowed" || e.error === "service-not-allowed" ? reject(new Error("blocked")) : resolve(heard));
rec.onend = () => resolve(heard);
});
rec.start();
return { stop: () => rec.stop(), done };
}
Change history
Point OpenApps links to openapps.free
Bind agent address in policy
Use OpenApps service URLs and add contribution strip
Bounded source sync
Bounded source sync
Bounded source sync