@segnavia/voice 0.3.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.
- package/dist/captions.d.ts +4 -0
- package/dist/captions.js +13 -0
- package/dist/elevenlabs-lexicon.d.ts +12 -0
- package/dist/elevenlabs-lexicon.js +40 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.js +14 -7
- package/dist/lexicon-provider.d.ts +5 -0
- package/dist/lexicon-provider.js +29 -0
- package/package.json +2 -2
package/dist/captions.js
ADDED
|
@@ -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
|
|
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:
|
|
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,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.
|
|
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.
|
|
29
|
+
"@segnavia/format": "0.4.0"
|
|
30
30
|
}
|
|
31
31
|
}
|