jacquardSnapshot

← snapshot

3494 bytes
"use client";

/**
 * Choosing the least-bad browser voice.
 *
 * This only matters when cedar is unavailable, but it matters a lot then: the
 * old selection fell through to "the first English voice", and on macOS that
 * list contains Zarvox, Bad News, and Bubbles. Jackie could and did end up
 * speaking in a novelty voice.
 *
 * Ranking, best first:
 *   1. Anything the platform labels Premium, Enhanced, Neural, or Natural.
 *   2. Named modern voices known to be intelligible.
 *   3. Any remaining English voice that is not on the novelty list.
 *
 * Even the best of these is a dated formant synth. The honest fix is cedar —
 * see `lib/cedar.ts` — and the UI says so rather than quietly sounding bad.
 */

/**
 * macOS ships these as jokes. They are real `SpeechSynthesisVoice` entries
 * with `en-US` locales, so nothing but a list keeps them out.
 */
const NOVELTY = new Set([
  "albert",
  "bad news",
  "bahh",
  "bells",
  "boing",
  "bubbles",
  "cellos",
  "deranged",
  "eddy",
  "flo",
  "fred",
  "good news",
  "grandma",
  "grandpa",
  "hysterical",
  "jester",
  "junior",
  "kathy",
  "organ",
  "pipe organ",
  "princess",
  "ralph",
  "reed",
  "rocko",
  "sandy",
  "shelley",
  "superstar",
  "trinoids",
  "whisper",
  "wobble",
  "zarvox",
]);

/** Best-sounding first. Matched as a prefix, so locale suffixes still hit. */
const PREFERRED = [
  "Ava",
  "Allison",
  "Susan",
  "Serena",
  "Zoe",
  "Nicky",
  "Aaron",
  "Arthur",
  "Catherine",
  "Tom",
  "Evan",
  "Daniel",
  "Karen",
  "Moira",
  "Samantha",
  "Google UK English Female",
  "Google US English",
  "Microsoft Aria Online (Natural) - English (United States)",
];

/** Strips the platform's parenthetical locale so names compare cleanly. */
function bare(name: string): string {
  return name.replace(/\s*\(.*?\)\s*/g, "").trim().toLowerCase();
}

function isNovelty(voice: SpeechSynthesisVoice): boolean {
  return NOVELTY.has(bare(voice.name));
}

export interface VoiceChoice {
  voice: SpeechSynthesisVoice | null;
  /** True when the platform advertises a high-quality variant. */
  premium: boolean;
}

/** Picks the best English voice available, never a novelty one. */
export function pickVoice(voices: SpeechSynthesisVoice[]): VoiceChoice {
  const english = voices.filter(
    (v) => v.lang.toLowerCase().startsWith("en") && !isNovelty(v),
  );
  if (english.length === 0) return { voice: null, premium: false };

  // 1. Whatever the platform itself calls high quality.
  const premium = english.find((v) =>
    /premium|enhanced|neural|natural/i.test(v.name),
  );
  if (premium) return { voice: premium, premium: true };

  // 2. A known-good name.
  for (const want of PREFERRED) {
    const hit = english.find(
      (v) => bare(v.name) === want.toLowerCase() || v.name === want,
    );
    if (hit) return { voice: hit, premium: false };
  }

  // 3. Prefer a network voice over a local one — they are generally newer.
  const network = english.find((v) => !v.localService);
  return { voice: network ?? english[0] ?? null, premium: false };
}

/**
 * Splits text into utterance-sized pieces.
 *
 * A single long utterance gets one flat contour from these engines and starts
 * only once the whole string is queued. Sentence-at-a-time restores some
 * cadence — and the pause between them is where a person would breathe.
 */
export function sentences(text: string): string[] {
  return text
    .split(/(?<=[.!?])\s+/)
    .map((s) => s.trim())
    .filter(Boolean);
}