lingo-free.openapps.xyz

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.
Owned app · rev 8af89d2cc9f8
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.
← All files
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
8af89d2cc9 · 17 hours ago
Bind agent address in policy
0018350275 · 19 hours ago
Use OpenApps service URLs and add contribution strip
805365daf4 · 19 hours ago
Bounded source sync
3f2fb3b265 · 2 days ago
Bounded source sync
7dcb580324 · 2 days ago
Bounded source sync
dcd05c0944 · 2 days ago