@segnavia/voice 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ import { Word } from "@segnavia/format";
2
+ //#region src/captions.d.ts
3
+ export declare function splitWords(text: string): string[];
4
+ //#endregion
@@ -0,0 +1,13 @@
1
+ //#region src/captions.ts
2
+ function splitWords(text) {
3
+ return text.split(/\s+/).filter(Boolean);
4
+ }
5
+ function remapCaptionWords(words, script) {
6
+ const scriptWords = splitWords(script);
7
+ return words.length === scriptWords.length ? words.map((word, index) => ({
8
+ ...word,
9
+ text: scriptWords[index] ?? word.text
10
+ })) : words;
11
+ }
12
+ //#endregion
13
+ export { remapCaptionWords, splitWords };
@@ -0,0 +1,12 @@
1
+ import { PronunciationDictionary } from "./index.js";
2
+ import { Lexicon } from "@segnavia/format";
3
+ //#region src/elevenlabs-lexicon.d.ts
4
+ export declare function publishElevenLabsLexicon(options: {
5
+ apiKey: () => string;
6
+ lexicon: Lexicon;
7
+ name: string;
8
+ description?: string;
9
+ dictionaryId?: string;
10
+ phonemes?: boolean;
11
+ }): Promise<PronunciationDictionary | null>;
12
+ //#endregion
@@ -0,0 +1,40 @@
1
+ //#region src/elevenlabs-lexicon.ts
2
+ async function publishElevenLabsLexicon(options) {
3
+ if (options.lexicon.entries.length === 0 && options.dictionaryId === void 0) return null;
4
+ const rules = options.lexicon.entries.map((entry) => options.phonemes === true && entry.ipa !== void 0 ? {
5
+ type: "phoneme",
6
+ string_to_replace: entry.term,
7
+ phoneme: entry.ipa,
8
+ alphabet: "ipa",
9
+ case_sensitive: true,
10
+ word_boundaries: true
11
+ } : {
12
+ type: "alias",
13
+ string_to_replace: entry.term,
14
+ alias: entry.say,
15
+ case_sensitive: true,
16
+ word_boundaries: true
17
+ });
18
+ const updating = options.dictionaryId !== void 0;
19
+ const path = updating ? `pronunciation-dictionaries/${encodeURIComponent(options.dictionaryId ?? "")}/set-rules` : "pronunciation-dictionaries/add-from-rules";
20
+ const response = await fetch(`https://api.elevenlabs.io/v1/${path}`, {
21
+ method: "POST",
22
+ headers: {
23
+ "xi-api-key": options.apiKey(),
24
+ "content-type": "application/json"
25
+ },
26
+ body: JSON.stringify(updating ? { rules } : {
27
+ name: options.name,
28
+ ...options.description === void 0 ? {} : { description: options.description },
29
+ rules
30
+ })
31
+ });
32
+ if (!response.ok) throw new Error(`ElevenLabs refused the pronunciation dictionary: ${response.status} ${await response.text()}`);
33
+ const body = await response.json();
34
+ return {
35
+ id: body.id,
36
+ versionId: body.version_id
37
+ };
38
+ }
39
+ //#endregion
40
+ export { publishElevenLabsLexicon };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { splitWords } from "./captions.js";
2
+ import { publishElevenLabsLexicon } from "./elevenlabs-lexicon.js";
3
+ import { lexiconProvider } from "./lexicon-provider.js";
1
4
  import { Word } from "@segnavia/format";
2
5
  //#region src/index.d.ts
3
6
  export interface Speech {
@@ -12,7 +15,10 @@ export interface VoiceProvider {
12
15
  id: string;
13
16
  synthesize(text: string, voice?: string): Promise<Speech>;
14
17
  }
15
- export declare function splitWords(text: string): string[];
18
+ export interface PronunciationDictionary {
19
+ id: string;
20
+ versionId?: string;
21
+ }
16
22
  export declare function wordsFromCharacters(characters: string[], startsSec: number[], endsSec: number[]): Word[];
17
23
  export declare function pacedProvider(options?: {
18
24
  wordsPerMinute?: number;
@@ -21,6 +27,7 @@ export declare function elevenLabsProvider(options: {
21
27
  apiKey: () => string;
22
28
  voice: string;
23
29
  model?: string;
30
+ pronunciationDictionaries?: readonly PronunciationDictionary[];
24
31
  }): VoiceProvider;
25
32
  export declare function alignedProvider(options: {
26
33
  id: string;
@@ -40,4 +47,5 @@ export declare function narrate(provider: VoiceProvider, text: string, options:
40
47
  cacheDirectory: string;
41
48
  voice?: string;
42
49
  }): Promise<Narrated>;
43
- //#endregion
50
+ //#endregion
51
+ export { lexiconProvider, publishElevenLabsLexicon, splitWords };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
+ import { remapCaptionWords, splitWords } from "./captions.js";
2
+ import { publishElevenLabsLexicon } from "./elevenlabs-lexicon.js";
3
+ import { lexiconProvider } from "./lexicon-provider.js";
1
4
  import { createHash } from "node:crypto";
2
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
6
  import path from "node:path";
4
7
  //#region src/index.ts
5
- function splitWords(text) {
6
- return text.split(/\s+/).filter(Boolean);
7
- }
8
8
  function wordsFromCharacters(characters, startsSec, endsSec) {
9
9
  const words = [];
10
10
  let current = null;
@@ -54,8 +54,13 @@ function pacedProvider(options = {}) {
54
54
  };
55
55
  }
56
56
  function elevenLabsProvider(options) {
57
+ const locators = (options.pronunciationDictionaries ?? []).map(({ id, versionId }) => ({
58
+ pronunciation_dictionary_id: id,
59
+ ...versionId === void 0 ? {} : { version_id: versionId }
60
+ }));
61
+ if (locators.length > 3) throw new Error(`ElevenLabs accepts at most 3 pronunciation dictionaries; received ${locators.length}.`);
57
62
  return {
58
- id: `elevenlabs-${options.model ?? "eleven_multilingual_v2"}-${options.voice}`,
63
+ id: `${`elevenlabs-${options.model ?? "eleven_multilingual_v2"}-${options.voice}`}${locators.length > 0 ? `-${JSON.stringify(locators)}` : ""}`,
59
64
  async synthesize(text, voice = options.voice) {
60
65
  const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voice}/with-timestamps`, {
61
66
  method: "POST",
@@ -65,14 +70,16 @@ function elevenLabsProvider(options) {
65
70
  },
66
71
  body: JSON.stringify({
67
72
  text,
68
- model_id: options.model ?? "eleven_multilingual_v2"
73
+ model_id: options.model ?? "eleven_multilingual_v2",
74
+ ...locators.length > 0 ? { pronunciation_dictionary_locators: locators } : {}
69
75
  })
70
76
  });
71
77
  if (!response.ok) throw new Error(`ElevenLabs refused the synthesis: ${response.status} ${await response.text()}`);
72
78
  const body = await response.json();
73
79
  const { characters, character_start_times_seconds: starts, character_end_times_seconds: ends } = body.alignment;
80
+ const words = wordsFromCharacters(characters, starts, ends);
74
81
  return {
75
- words: wordsFromCharacters(characters, starts, ends),
82
+ words: remapCaptionWords(words, text),
76
83
  durationMs: Math.round((ends.at(-1) ?? 0) * 1e3) + 300,
77
84
  audio: {
78
85
  bytes: Buffer.from(body.audio_base64, "base64"),
@@ -125,4 +132,4 @@ async function narrate(provider, text, options) {
125
132
  return narrated;
126
133
  }
127
134
  //#endregion
128
- export { alignedProvider, elevenLabsProvider, narrate, pacedProvider, splitWords, wordsFromCharacters };
135
+ export { alignedProvider, elevenLabsProvider, lexiconProvider, narrate, pacedProvider, publishElevenLabsLexicon, splitWords, wordsFromCharacters };
@@ -0,0 +1,5 @@
1
+ import { VoiceProvider } from "./index.js";
2
+ import { Lexicon } from "@segnavia/format";
3
+ //#region src/lexicon-provider.d.ts
4
+ export declare function lexiconProvider(inner: VoiceProvider, lexicon: Lexicon): VoiceProvider;
5
+ //#endregion
@@ -0,0 +1,29 @@
1
+ import { remapCaptionWords } from "./captions.js";
2
+ import { lexiconVersion } from "@segnavia/format";
3
+ //#region src/lexicon-provider.ts
4
+ const WORD_CHARACTER = "[\\p{L}\\p{N}\\p{M}_]";
5
+ function escapeRegExp(value) {
6
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ }
8
+ function respell(text, lexicon) {
9
+ if (lexicon.entries.length === 0) return text;
10
+ const replacements = new Map(lexicon.entries.map((entry) => [entry.term, entry.say]));
11
+ const alternatives = [...replacements.keys()].sort((left, right) => right.length - left.length).map(escapeRegExp).join("|");
12
+ const terms = new RegExp(`(?<!${WORD_CHARACTER})(?:${alternatives})(?!${WORD_CHARACTER})`, "gu");
13
+ return text.replace(terms, (term) => replacements.get(term) ?? term);
14
+ }
15
+ function lexiconProvider(inner, lexicon) {
16
+ const fixedLexicon = { entries: lexicon.entries.map((entry) => ({ ...entry })) };
17
+ return {
18
+ id: `${inner.id}-lexicon-${lexiconVersion(fixedLexicon)}`,
19
+ async synthesize(text, voice) {
20
+ const speech = await inner.synthesize(respell(text, fixedLexicon), voice);
21
+ return {
22
+ ...speech,
23
+ words: remapCaptionWords(speech.words, text)
24
+ };
25
+ }
26
+ };
27
+ }
28
+ //#endregion
29
+ export { lexiconProvider };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segnavia/voice",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,6 +26,6 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@segnavia/format": "0.2.0"
29
+ "@segnavia/format": "0.4.0"
30
30
  }
31
31
  }