@rimelabs/sdk 0.1.0-alpha.12 → 0.1.0-alpha.14
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/CHANGELOG.md +14 -0
- package/README.md +183 -86
- package/dist/client.d.ts +2 -2
- package/dist/client.js +2 -1
- package/dist/grpc.d.ts +3 -3
- package/dist/grpc.js +54 -32
- package/dist/index.d.ts +2 -0
- package/dist/stt/client.d.ts +1 -2
- package/dist/stt/client.js +3 -7
- package/dist/tts/audio.d.ts +9 -0
- package/dist/tts/audio.js +42 -3
- package/dist/tts/buffer.d.ts +3 -0
- package/dist/tts/buffer.js +53 -0
- package/dist/tts/client.d.ts +14 -0
- package/dist/tts/client.js +36 -6
- package/dist/tts/lexicon.d.ts +7 -0
- package/dist/tts/lexicon.js +23 -0
- package/dist/tts/policy.d.ts +1 -1
- package/dist/tts/policy.js +2 -2
- package/dist/tts/stream.d.ts +14 -1
- package/dist/tts/stream.js +49 -22
- package/dist/tts/timestamps.d.ts +24 -0
- package/dist/tts/timestamps.js +46 -0
- package/dist/tts/transport.d.ts +7 -2
- package/dist/tts/transport.js +26 -9
- package/docs/stt.md +2 -2
- package/docs/tts.md +159 -14
- package/package.json +1 -2
- package/dist/tts/sentences.d.ts +0 -21
- package/dist/tts/sentences.js +0 -142
- package/vendor/LICENSE.blingfire +0 -21
- package/vendor/README.md +0 -11
- package/vendor/blingfire.cjs +0 -4
- package/vendor/blingfire.upstream.cjs +0 -1
- package/vendor/blingfire.wasm +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { RimeAudioFormatError } from "../errors.js";
|
|
2
|
+
export const maxBufferBytes = 64 * 1024 * 1024;
|
|
3
|
+
/** Validate mono PCM16 WAV and replace unknown streaming chunk lengths. */
|
|
4
|
+
export function finalizeWav(data, sampleRate, requestId) {
|
|
5
|
+
const invalid = () => new RimeAudioFormatError("Expected a complete mono PCM16 WAV at the requested sample rate", requestId);
|
|
6
|
+
if (data.length < 12 ||
|
|
7
|
+
data.toString("latin1", 0, 4) !== "RIFF" ||
|
|
8
|
+
data.toString("latin1", 8, 12) !== "WAVE")
|
|
9
|
+
throw invalid();
|
|
10
|
+
if (data.length - 8 > 0xffffffff)
|
|
11
|
+
throw new RimeAudioFormatError("Audio exceeds the WAV size limit", requestId);
|
|
12
|
+
if (![0xffffffff, data.length - 8].includes(data.readUInt32LE(4)))
|
|
13
|
+
throw invalid();
|
|
14
|
+
let offset = 12;
|
|
15
|
+
let foundFormat = false;
|
|
16
|
+
let dataSizeOffset;
|
|
17
|
+
let dataSize = 0;
|
|
18
|
+
while (offset < data.length) {
|
|
19
|
+
if (data.length - offset < 8)
|
|
20
|
+
throw invalid();
|
|
21
|
+
const name = data.toString("latin1", offset, offset + 4);
|
|
22
|
+
let size = data.readUInt32LE(offset + 4);
|
|
23
|
+
const start = offset + 8;
|
|
24
|
+
if (name === "data" && size === 0xffffffff)
|
|
25
|
+
size = data.length - start;
|
|
26
|
+
const end = start + size;
|
|
27
|
+
if (end > data.length)
|
|
28
|
+
throw invalid();
|
|
29
|
+
if (name === "fmt ") {
|
|
30
|
+
if (foundFormat ||
|
|
31
|
+
size < 16 ||
|
|
32
|
+
data.readUInt16LE(start) !== 1 ||
|
|
33
|
+
data.readUInt16LE(start + 2) !== 1 ||
|
|
34
|
+
data.readUInt32LE(start + 4) !== sampleRate ||
|
|
35
|
+
data.readUInt32LE(start + 8) !== sampleRate * 2 ||
|
|
36
|
+
data.readUInt16LE(start + 12) !== 2 ||
|
|
37
|
+
data.readUInt16LE(start + 14) !== 16)
|
|
38
|
+
throw invalid();
|
|
39
|
+
foundFormat = true;
|
|
40
|
+
}
|
|
41
|
+
else if (name === "data") {
|
|
42
|
+
if (!foundFormat || dataSizeOffset !== undefined || size % 2)
|
|
43
|
+
throw invalid();
|
|
44
|
+
dataSizeOffset = offset + 4;
|
|
45
|
+
dataSize = size;
|
|
46
|
+
}
|
|
47
|
+
offset = end + (size % 2);
|
|
48
|
+
}
|
|
49
|
+
if (offset !== data.length || dataSizeOffset === undefined)
|
|
50
|
+
throw invalid();
|
|
51
|
+
data.writeUInt32LE(data.length - 8, 4);
|
|
52
|
+
data.writeUInt32LE(dataSize, dataSizeOffset);
|
|
53
|
+
}
|
package/dist/tts/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AudioFormat } from "./audio.js";
|
|
2
|
+
import { type PronunciationEntry } from "./lexicon.js";
|
|
2
3
|
import { AudioStream, type TextSource } from "./stream.js";
|
|
3
4
|
import { Credentials } from "../auth.js";
|
|
4
5
|
import { policy } from "./policy.js";
|
|
@@ -6,6 +7,14 @@ export interface SynthesisOptions {
|
|
|
6
7
|
voice?: string | null;
|
|
7
8
|
language?: string;
|
|
8
9
|
audioFormat?: AudioFormat | null;
|
|
10
|
+
/** Requested output rate: 8000, 16000, or 24000 Hz. Defaults to 24000 for PCM/WAV/MP3. */
|
|
11
|
+
sampleRate?: number | null;
|
|
12
|
+
/** Duration multiplier from 0.4 to 2.5. Below 1 speeds speech up; above 1 slows it down. */
|
|
13
|
+
timeScaleFactor?: number | null;
|
|
14
|
+
/** Request final word timestamps. Supported only with model="mistv3". */
|
|
15
|
+
timestamps?: boolean;
|
|
16
|
+
/** Request-wide pronunciation overrides; requires a phoneme-capable Coda deployment. */
|
|
17
|
+
customLexicon?: readonly PronunciationEntry[];
|
|
9
18
|
timeout?: number | null;
|
|
10
19
|
}
|
|
11
20
|
export interface DiscoveryOptions {
|
|
@@ -32,6 +41,11 @@ export declare class TTS {
|
|
|
32
41
|
private prepare;
|
|
33
42
|
/** Synthesize complete text and return an audio stream. Do not await this call. */
|
|
34
43
|
synthesize(text: string, options?: SynthesisOptions): AudioStream;
|
|
44
|
+
/** Await complete audio in memory, finalizing WAV lengths. Never returns partial audio.
|
|
45
|
+
* The fixed output limit is 64 MiB (67,108,864 bytes); exceeding it raises
|
|
46
|
+
* RimeResourceLimitError. Use streaming synthesis for larger outputs or timestamps.
|
|
47
|
+
*/
|
|
48
|
+
synthesizeToBuffer(text: string, options?: SynthesisOptions): Promise<Buffer>;
|
|
35
49
|
/** Stream text chunks into synthesis and return an audio stream. Do not await this call. */
|
|
36
50
|
stream(text: TextSource, options?: SynthesisOptions): AudioStream;
|
|
37
51
|
private discover;
|
package/dist/tts/client.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { AudioFormat } from "./audio.js";
|
|
1
|
+
import { AudioFormat, resolveAudio } from "./audio.js";
|
|
2
|
+
import { maxBufferBytes, finalizeWav } from "./buffer.js";
|
|
3
|
+
import { snapshot } from "./lexicon.js";
|
|
2
4
|
import { AudioStream, constructionKey } from "./stream.js";
|
|
3
5
|
import { policy, timeout, nonempty } from "./policy.js";
|
|
4
6
|
import { abortable } from "../cancellation.js";
|
|
5
7
|
import { transport, connect, metadata, discover, } from "./transport.js";
|
|
6
|
-
import { RimeInputError,
|
|
8
|
+
import { RimeInputError, RimeResourceLimitError, RimeCancelledError, RimeTimeoutError, RimeUnavailableError, } from "../errors.js";
|
|
7
9
|
export class TTS {
|
|
8
10
|
credentials;
|
|
9
11
|
deployment;
|
|
@@ -49,6 +51,30 @@ export class TTS {
|
|
|
49
51
|
yield text;
|
|
50
52
|
})(), options);
|
|
51
53
|
}
|
|
54
|
+
/** Await complete audio in memory, finalizing WAV lengths. Never returns partial audio.
|
|
55
|
+
* The fixed output limit is 64 MiB (67,108,864 bytes); exceeding it raises
|
|
56
|
+
* RimeResourceLimitError. Use streaming synthesis for larger outputs or timestamps.
|
|
57
|
+
*/
|
|
58
|
+
async synthesizeToBuffer(text, options = {}) {
|
|
59
|
+
const audio = this.synthesize(text, options);
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let size = 0;
|
|
62
|
+
try {
|
|
63
|
+
for await (const chunk of audio) {
|
|
64
|
+
if (chunk.byteLength > maxBufferBytes - size)
|
|
65
|
+
throw new RimeResourceLimitError("Audio exceeds the 64 MiB buffer limit", audio.requestId);
|
|
66
|
+
chunks.push(chunk);
|
|
67
|
+
size += chunk.byteLength;
|
|
68
|
+
}
|
|
69
|
+
const data = Buffer.concat(chunks, size);
|
|
70
|
+
if (audio.format === AudioFormat.WAV)
|
|
71
|
+
finalizeWav(data, audio.sampleRate, audio.requestId);
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await audio.cancel();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
52
78
|
/** Stream text chunks into synthesis and return an audio stream. Do not await this call. */
|
|
53
79
|
stream(text, options = {}) {
|
|
54
80
|
this.checkOpen();
|
|
@@ -56,18 +82,22 @@ export class TTS {
|
|
|
56
82
|
!text ||
|
|
57
83
|
typeof text[Symbol.asyncIterator] !== "function")
|
|
58
84
|
throw new RimeInputError("text must be an async iterable of strings; use synthesize() for a string");
|
|
85
|
+
const lexicon = snapshot(options.customLexicon);
|
|
86
|
+
const timestamps = options.timestamps === undefined ? false : options.timestamps;
|
|
87
|
+
if (typeof timestamps !== "boolean")
|
|
88
|
+
throw new RimeInputError("timestamps must be a boolean");
|
|
89
|
+
if (timestamps && this.deployment.model !== "mistv3")
|
|
90
|
+
throw new RimeInputError("Word timestamps are supported only with model='mistv3'");
|
|
59
91
|
const voice = options.voice === undefined || options.voice === null
|
|
60
92
|
? this.deployment.defaultVoice
|
|
61
93
|
: nonempty(options.voice, "voice");
|
|
62
94
|
const language = nonempty(options.language ?? "en", "language");
|
|
63
|
-
const format = options.audioFormat
|
|
64
|
-
if (format !== AudioFormat.PCM_24000 && format !== AudioFormat.MULAW_8000)
|
|
65
|
-
throw new RimeAudioFormatError("Select a named AudioFormat profile");
|
|
95
|
+
const { format, sampleRate } = resolveAudio(options.audioFormat, options.sampleRate, options.timeScaleFactor);
|
|
66
96
|
const audio = new AudioStream(constructionKey, {
|
|
67
97
|
prepare: (s) => this.prepare(s),
|
|
68
98
|
checkOpen: () => this.checkOpen(),
|
|
69
99
|
forget: (s) => this.streams.delete(s),
|
|
70
|
-
}, text, voice, language, format, timeout(options.timeout, this.defaultTimeout));
|
|
100
|
+
}, text, voice, language, format, timeout(options.timeout, this.defaultTimeout), timestamps, lexicon, sampleRate, options.timeScaleFactor ?? undefined);
|
|
71
101
|
this.streams.add(audio);
|
|
72
102
|
return audio;
|
|
73
103
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A word or phrase and its space-separated X-SAMPA pronunciation. */
|
|
2
|
+
export interface PronunciationEntry {
|
|
3
|
+
readonly spelling: string;
|
|
4
|
+
readonly pronunciation: string;
|
|
5
|
+
}
|
|
6
|
+
/** Copy request options now; model and linguistic validation belong to the service. */
|
|
7
|
+
export declare function snapshot(entries?: readonly PronunciationEntry[]): readonly PronunciationEntry[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { RimeInputError } from "../errors.js";
|
|
2
|
+
// Unicode mode treats valid surrogate pairs as one code point outside this range.
|
|
3
|
+
const unpairedSurrogate = /[\uD800-\uDFFF]/u;
|
|
4
|
+
/** Copy request options now; model and linguistic validation belong to the service. */
|
|
5
|
+
export function snapshot(entries = []) {
|
|
6
|
+
if (!Array.isArray(entries))
|
|
7
|
+
throw new RimeInputError("customLexicon must be an array of pronunciation entries");
|
|
8
|
+
// Array.from also visits holes, which are invalid entries.
|
|
9
|
+
return Object.freeze(Array.from(entries, (entry) => {
|
|
10
|
+
if (!entry ||
|
|
11
|
+
typeof entry !== "object" ||
|
|
12
|
+
typeof entry.spelling !== "string" ||
|
|
13
|
+
typeof entry.pronunciation !== "string")
|
|
14
|
+
throw new RimeInputError("Lexicon entries must have string spelling and pronunciation fields");
|
|
15
|
+
if (unpairedSurrogate.test(entry.spelling) ||
|
|
16
|
+
unpairedSurrogate.test(entry.pronunciation))
|
|
17
|
+
throw new RimeInputError("Lexicon spelling and pronunciation must contain valid Unicode");
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
spelling: entry.spelling,
|
|
20
|
+
pronunciation: entry.pronunciation,
|
|
21
|
+
});
|
|
22
|
+
}));
|
|
23
|
+
}
|
package/dist/tts/policy.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { timeout } from "../validation.js";
|
|
2
2
|
export declare const policy: {
|
|
3
|
+
model: string;
|
|
3
4
|
target: string;
|
|
4
5
|
audience: string;
|
|
5
6
|
defaultVoice: string;
|
|
6
7
|
exchangeUrl: string;
|
|
7
|
-
sentenceBytes: number;
|
|
8
8
|
sourceChars: number;
|
|
9
9
|
outputBytes: number;
|
|
10
10
|
outputChunkBytes: number;
|
package/dist/tts/policy.js
CHANGED
|
@@ -3,11 +3,11 @@ export { timeout } from "../validation.js";
|
|
|
3
3
|
import { RimeInputError } from "../errors.js";
|
|
4
4
|
const codaHostname = "coda.api.rime.ai";
|
|
5
5
|
export const policy = {
|
|
6
|
+
model: "coda",
|
|
6
7
|
target: `${codaHostname}:443`,
|
|
7
8
|
audience: codaHostname,
|
|
8
9
|
defaultVoice: "clementine",
|
|
9
10
|
exchangeUrl: "https://themis.api.rime.ai/v1/token",
|
|
10
|
-
sentenceBytes: 65536,
|
|
11
11
|
sourceChars: 1024,
|
|
12
12
|
outputBytes: 96000,
|
|
13
13
|
outputChunkBytes: 9600,
|
|
@@ -20,7 +20,7 @@ export const policy = {
|
|
|
20
20
|
cleanupTimeout: 2,
|
|
21
21
|
};
|
|
22
22
|
export function resolve(model, endpoint) {
|
|
23
|
-
const deployment = { ...policy };
|
|
23
|
+
const deployment = { ...policy, model };
|
|
24
24
|
if (model === "mistv3") {
|
|
25
25
|
const hostname = "mist.api.rime.ai";
|
|
26
26
|
deployment.target = `${hostname}:443`;
|
package/dist/tts/stream.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { AudioFormat } from "./audio.js";
|
|
2
|
+
import type { TimestampResult } from "./timestamps.js";
|
|
3
|
+
import type { PronunciationEntry } from "./lexicon.js";
|
|
2
4
|
import { type PreparedConnection } from "./transport.js";
|
|
3
5
|
export interface StreamOwner {
|
|
4
6
|
prepare(signal: AbortSignal): Promise<PreparedConnection>;
|
|
@@ -14,6 +16,10 @@ export declare class AudioStream implements AsyncIterableIterator<Uint8Array> {
|
|
|
14
16
|
private language;
|
|
15
17
|
private formatValue;
|
|
16
18
|
private timeout;
|
|
19
|
+
private timestampsRequested;
|
|
20
|
+
private customLexicon;
|
|
21
|
+
private sampleRateValue;
|
|
22
|
+
private timeScaleFactor?;
|
|
17
23
|
private readonly controller;
|
|
18
24
|
private readonly queue;
|
|
19
25
|
private worker;
|
|
@@ -31,9 +37,16 @@ export declare class AudioStream implements AsyncIterableIterator<Uint8Array> {
|
|
|
31
37
|
private readonly operationId;
|
|
32
38
|
private bytesReceived;
|
|
33
39
|
private bytesDelivered;
|
|
34
|
-
constructor(key: symbol, owner: StreamOwner, source: TextSource | null, voice: string, language: string, formatValue: AudioFormat, timeout: number | null);
|
|
40
|
+
constructor(key: symbol, owner: StreamOwner, source: TextSource | null, voice: string, language: string, formatValue: AudioFormat, timeout: number | null, timestampsRequested?: boolean, customLexicon?: readonly PronunciationEntry[], sampleRateValue?: number, timeScaleFactor?: number | undefined);
|
|
35
41
|
get format(): AudioFormat;
|
|
42
|
+
/** Requested output rate. MP3 headers describe the encoded rate. */
|
|
43
|
+
get sampleRate(): number;
|
|
36
44
|
get requestId(): string | null;
|
|
45
|
+
/** Read requested timestamps after consuming all audio; never drains audio.
|
|
46
|
+
* Calling before iteration completes rejects with RimeInputError. Synthesis
|
|
47
|
+
* errors reject here too; alignment failures are returned in status.
|
|
48
|
+
*/
|
|
49
|
+
timestamps(): Promise<TimestampResult>;
|
|
37
50
|
private start;
|
|
38
51
|
private fail;
|
|
39
52
|
private produce;
|
package/dist/tts/stream.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { debuglog } from "node:util";
|
|
3
3
|
const log = debuglog("rime-sdk");
|
|
4
|
-
import { Converter } from "./audio.js";
|
|
4
|
+
import { AudioFormat, Converter, wireFormat } from "./audio.js";
|
|
5
5
|
import { policy } from "./policy.js";
|
|
6
6
|
import { abortable } from "../cancellation.js";
|
|
7
7
|
import { ByteQueue } from "./queue.js";
|
|
8
|
-
import { SentenceBuffer, ready } from "./sentences.js";
|
|
9
8
|
import { SynthesisCall } from "./transport.js";
|
|
10
9
|
import { RimeError, RimeInputError, RimeCancelledError, RimeTimeoutError, RimeStreamError, } from "../errors.js";
|
|
11
10
|
export const constructionKey = Symbol("private stream constructor");
|
|
@@ -16,6 +15,10 @@ export class AudioStream {
|
|
|
16
15
|
language;
|
|
17
16
|
formatValue;
|
|
18
17
|
timeout;
|
|
18
|
+
timestampsRequested;
|
|
19
|
+
customLexicon;
|
|
20
|
+
sampleRateValue;
|
|
21
|
+
timeScaleFactor;
|
|
19
22
|
controller = new AbortController();
|
|
20
23
|
queue = new ByteQueue(policy.outputBytes, policy.outputChunkBytes);
|
|
21
24
|
worker = null;
|
|
@@ -33,22 +36,43 @@ export class AudioStream {
|
|
|
33
36
|
operationId = randomUUID();
|
|
34
37
|
bytesReceived = 0;
|
|
35
38
|
bytesDelivered = 0;
|
|
36
|
-
constructor(key, owner, source, voice, language, formatValue, timeout) {
|
|
39
|
+
constructor(key, owner, source, voice, language, formatValue, timeout, timestampsRequested = false, customLexicon = [], sampleRateValue = 24000, timeScaleFactor) {
|
|
37
40
|
this.owner = owner;
|
|
38
41
|
this.source = source;
|
|
39
42
|
this.voice = voice;
|
|
40
43
|
this.language = language;
|
|
41
44
|
this.formatValue = formatValue;
|
|
42
45
|
this.timeout = timeout;
|
|
46
|
+
this.timestampsRequested = timestampsRequested;
|
|
47
|
+
this.customLexicon = customLexicon;
|
|
48
|
+
this.sampleRateValue = sampleRateValue;
|
|
49
|
+
this.timeScaleFactor = timeScaleFactor;
|
|
43
50
|
if (key !== constructionKey)
|
|
44
51
|
throw new TypeError("AudioStream is returned by client.tts.synthesize() or client.tts.stream()");
|
|
45
52
|
}
|
|
46
53
|
get format() {
|
|
47
54
|
return this.formatValue;
|
|
48
55
|
}
|
|
56
|
+
/** Requested output rate. MP3 headers describe the encoded rate. */
|
|
57
|
+
get sampleRate() {
|
|
58
|
+
return this.sampleRateValue;
|
|
59
|
+
}
|
|
49
60
|
get requestId() {
|
|
50
61
|
return this.call?.requestId ?? null;
|
|
51
62
|
}
|
|
63
|
+
/** Read requested timestamps after consuming all audio; never drains audio.
|
|
64
|
+
* Calling before iteration completes rejects with RimeInputError. Synthesis
|
|
65
|
+
* errors reject here too; alignment failures are returned in status.
|
|
66
|
+
*/
|
|
67
|
+
async timestamps() {
|
|
68
|
+
if (!this.timestampsRequested)
|
|
69
|
+
throw new RimeInputError("Enable timestamps: true when creating the stream");
|
|
70
|
+
if (this.failure)
|
|
71
|
+
throw this.failure;
|
|
72
|
+
if (!this.finished)
|
|
73
|
+
throw new RimeInputError("Consume all audio before reading timestamps");
|
|
74
|
+
return this.call.timestampResult();
|
|
75
|
+
}
|
|
52
76
|
start() {
|
|
53
77
|
if (this.failure)
|
|
54
78
|
throw this.failure;
|
|
@@ -96,7 +120,7 @@ export class AudioStream {
|
|
|
96
120
|
async produce() {
|
|
97
121
|
const source = this.source;
|
|
98
122
|
let iterator;
|
|
99
|
-
|
|
123
|
+
let highSurrogate = "";
|
|
100
124
|
let meaningful = false;
|
|
101
125
|
try {
|
|
102
126
|
try {
|
|
@@ -127,35 +151,39 @@ export class AudioStream {
|
|
|
127
151
|
}
|
|
128
152
|
if (result.done)
|
|
129
153
|
break;
|
|
130
|
-
|
|
131
|
-
if (typeof chunk !== "string")
|
|
154
|
+
if (typeof result.value !== "string")
|
|
132
155
|
throw new RimeInputError("The text source must yield strings");
|
|
156
|
+
let chunk = highSurrogate + result.value;
|
|
157
|
+
highSurrogate = "";
|
|
158
|
+
// A JavaScript source may split one Unicode character across yields.
|
|
159
|
+
if (/[\uD800-\uDBFF]$/.test(chunk)) {
|
|
160
|
+
highSurrogate = chunk.slice(-1);
|
|
161
|
+
chunk = chunk.slice(0, -1);
|
|
162
|
+
}
|
|
133
163
|
if (chunk.trim())
|
|
134
164
|
meaningful = true;
|
|
135
|
-
//
|
|
165
|
+
// Bound wire messages without splitting a Unicode character.
|
|
136
166
|
for (let offset = 0; offset < chunk.length;) {
|
|
137
167
|
let end = Math.min(chunk.length, offset + policy.sourceChars);
|
|
138
168
|
if (end < chunk.length && /[\uD800-\uDBFF]/.test(chunk[end - 1]))
|
|
139
169
|
end++;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
170
|
+
const part = chunk.slice(offset, end);
|
|
171
|
+
if (/[\uD800-\uDFFF]/u.test(part))
|
|
172
|
+
throw new RimeInputError("Text must contain valid Unicode");
|
|
173
|
+
if (!this.submitted)
|
|
174
|
+
this.progressAt = performance.now();
|
|
175
|
+
this.submitted = true;
|
|
176
|
+
await this.call.write(part);
|
|
177
|
+
this.progressAt = performance.now();
|
|
146
178
|
offset = end;
|
|
147
179
|
}
|
|
148
180
|
}
|
|
181
|
+
if (highSurrogate)
|
|
182
|
+
throw new RimeInputError("Text must contain valid Unicode");
|
|
149
183
|
if (!meaningful)
|
|
150
184
|
throw new RimeInputError("The text source contained no meaningful text");
|
|
151
|
-
for (const sentence of buffer.feed("", true)) {
|
|
152
|
-
if (!this.submitted)
|
|
153
|
-
this.progressAt = performance.now();
|
|
154
|
-
this.submitted = true;
|
|
155
|
-
await this.call.write(sentence);
|
|
156
|
-
}
|
|
157
|
-
this.call.finishInput();
|
|
158
185
|
this.inputDone = true;
|
|
186
|
+
this.call.finishInput();
|
|
159
187
|
}
|
|
160
188
|
catch (error) {
|
|
161
189
|
if (error instanceof RimeError || this.controller.signal.aborted)
|
|
@@ -196,10 +224,9 @@ export class AudioStream {
|
|
|
196
224
|
async run() {
|
|
197
225
|
let tasks = [];
|
|
198
226
|
try {
|
|
199
|
-
await abortable(ready, this.controller.signal);
|
|
200
227
|
const prepared = await this.owner.prepare(this.controller.signal);
|
|
201
228
|
this.call = new SynthesisCall(prepared, this.controller.signal);
|
|
202
|
-
await this.call.start(this.voice, this.language);
|
|
229
|
+
await this.call.start(this.voice, this.language, this.timestampsRequested, this.customLexicon, wireFormat(this.format), this.format === AudioFormat.MULAW_8000 ? 24000 : this.sampleRate, this.timeScaleFactor);
|
|
203
230
|
tasks = [this.produce(), this.read()];
|
|
204
231
|
await Promise.all(tasks);
|
|
205
232
|
this.queue.finish();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SynthesisResponseTrailer } from "@rimelabs/api";
|
|
2
|
+
/** A spoken, normalized word with start/end seconds from synthesis start. */
|
|
3
|
+
export interface WordTimestamp {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
}
|
|
8
|
+
/** Alignment's google.rpc.Code (0 means OK) and service explanation. */
|
|
9
|
+
export interface TimestampStatus {
|
|
10
|
+
readonly code: number;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
}
|
|
13
|
+
/** Final alignment status and words. Non-OK statuses carry no spans. */
|
|
14
|
+
export interface TimestampResult {
|
|
15
|
+
readonly status: TimestampStatus;
|
|
16
|
+
readonly spans: readonly WordTimestamp[];
|
|
17
|
+
}
|
|
18
|
+
export declare class TimestampTrailer {
|
|
19
|
+
seen: boolean;
|
|
20
|
+
invalid: boolean;
|
|
21
|
+
private value;
|
|
22
|
+
accept(trailer: SynthesisResponseTrailer): void;
|
|
23
|
+
result(requestId: string | null): TimestampResult;
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { RimeStreamError } from "../errors.js";
|
|
2
|
+
export class TimestampTrailer {
|
|
3
|
+
seen = false;
|
|
4
|
+
invalid = false;
|
|
5
|
+
value;
|
|
6
|
+
accept(trailer) {
|
|
7
|
+
if (this.seen)
|
|
8
|
+
this.invalid = true;
|
|
9
|
+
else {
|
|
10
|
+
this.seen = true;
|
|
11
|
+
this.value = trailer.timestamps;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
result(requestId) {
|
|
15
|
+
const malformed = () => new RimeStreamError("Invalid timestamp trailer", requestId);
|
|
16
|
+
if (this.invalid)
|
|
17
|
+
throw malformed();
|
|
18
|
+
const value = this.value;
|
|
19
|
+
if (!value)
|
|
20
|
+
throw new RimeStreamError("The service did not return requested timestamps", requestId);
|
|
21
|
+
if (!value.status || (value.status.code !== 0 && value.spans.length))
|
|
22
|
+
throw malformed();
|
|
23
|
+
const seconds = (duration) => {
|
|
24
|
+
if (!duration ||
|
|
25
|
+
duration.seconds < 0n ||
|
|
26
|
+
duration.seconds > 315576000000n ||
|
|
27
|
+
duration.nanos < 0 ||
|
|
28
|
+
duration.nanos >= 1000000000)
|
|
29
|
+
throw malformed();
|
|
30
|
+
return Number(duration.seconds) + duration.nanos / 1e9;
|
|
31
|
+
};
|
|
32
|
+
const spans = value.spans.map((span) => {
|
|
33
|
+
const start = seconds(span.start), end = seconds(span.end);
|
|
34
|
+
if (!span.text.trim() || end < start)
|
|
35
|
+
throw malformed();
|
|
36
|
+
return Object.freeze({ text: span.text, start, end });
|
|
37
|
+
});
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
status: Object.freeze({
|
|
40
|
+
code: value.status.code,
|
|
41
|
+
message: value.status.message,
|
|
42
|
+
}),
|
|
43
|
+
spans: Object.freeze(spans),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/tts/transport.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { metadata } from "../grpc.js";
|
|
2
2
|
export { rpcError } from "../grpc.js";
|
|
3
3
|
import * as grpc from "@grpc/grpc-js";
|
|
4
|
+
import type { PronunciationEntry } from "./lexicon.js";
|
|
4
5
|
export type Connection = grpc.Client;
|
|
5
6
|
export interface PreparedConnection {
|
|
6
7
|
client: Connection;
|
|
@@ -13,13 +14,17 @@ export declare const transport: {
|
|
|
13
14
|
export declare function connect(client: grpc.Client, signal: AbortSignal): Promise<void>;
|
|
14
15
|
export declare function discover(prepared: PreparedConnection, kind: DiscoveryKind, language: string | null | undefined, signal: AbortSignal): Promise<string[]>;
|
|
15
16
|
export declare class SynthesisCall {
|
|
17
|
+
private audioFormat;
|
|
18
|
+
private timestampsRequested;
|
|
19
|
+
private readonly timestampTrailer;
|
|
16
20
|
private readonly rpc;
|
|
17
21
|
constructor(prepared: PreparedConnection, signal: AbortSignal);
|
|
18
22
|
get requestId(): string | null;
|
|
19
23
|
get done(): boolean;
|
|
20
24
|
cancel(): void;
|
|
21
|
-
start(voice: string, language: string): Promise<void>;
|
|
22
|
-
|
|
25
|
+
start(voice: string, language: string, timestamps?: boolean, customLexicon?: readonly PronunciationEntry[], audioFormat?: string, sampleRate?: number, timeScaleFactor?: number): Promise<void>;
|
|
26
|
+
timestampResult(): import("./timestamps.js").TimestampResult;
|
|
27
|
+
write(text: string): Promise<void>;
|
|
23
28
|
finishInput(): void;
|
|
24
29
|
private checkFormat;
|
|
25
30
|
audio(): AsyncGenerator<Uint8Array>;
|
package/dist/tts/transport.js
CHANGED
|
@@ -8,11 +8,12 @@ import * as schema from "@rimelabs/api";
|
|
|
8
8
|
import { policy } from "./policy.js";
|
|
9
9
|
import { abortable } from "../cancellation.js";
|
|
10
10
|
import * as errors from "../errors.js";
|
|
11
|
+
import { TimestampTrailer } from "./timestamps.js";
|
|
11
12
|
export const transport = {
|
|
12
13
|
makeClient: (target) => new grpc.Client(target, grpc.credentials.createSsl(), {
|
|
13
14
|
"grpc.enable_retries": 0,
|
|
14
15
|
"grpc.max_receive_message_length": policy.receiveBytes,
|
|
15
|
-
"grpc.max_send_message_length":
|
|
16
|
+
"grpc.max_send_message_length": 131072,
|
|
16
17
|
}),
|
|
17
18
|
};
|
|
18
19
|
export function connect(client, signal) {
|
|
@@ -21,14 +22,17 @@ export function connect(client, signal) {
|
|
|
21
22
|
function openStream(client, auth) {
|
|
22
23
|
return client.makeBidiStreamRequest("/rime.TextToSpeech/SynthesizeStreaming", serializer(schema.StreamingSynthesisRequestSchema), deserializer(schema.SynthesisResponseStreamSchema), auth);
|
|
23
24
|
}
|
|
24
|
-
function header(voice, language) {
|
|
25
|
+
function header(voice, language, timestamps, customLexicon, audioFormat, samplingRate, timeScaleFactor) {
|
|
25
26
|
return create(schema.StreamingSynthesisRequestSchema, {
|
|
26
27
|
payload: {
|
|
27
28
|
case: "header",
|
|
28
29
|
value: create(schema.SynthesisRequestSchema, {
|
|
29
30
|
speaker: voice,
|
|
30
31
|
language,
|
|
31
|
-
|
|
32
|
+
splitStrategy: schema.SplitStrategy.ACCUMULATE_SENTENCE,
|
|
33
|
+
audioParameters: { audioFormat, samplingRate, timeScaleFactor },
|
|
34
|
+
timestamps: timestamps ? { enable: true } : undefined,
|
|
35
|
+
customLexicon: [...customLexicon],
|
|
32
36
|
}),
|
|
33
37
|
},
|
|
34
38
|
});
|
|
@@ -75,6 +79,9 @@ export async function discover(prepared, kind, language, signal) {
|
|
|
75
79
|
return response.languages;
|
|
76
80
|
}
|
|
77
81
|
export class SynthesisCall {
|
|
82
|
+
audioFormat = "audio/pcm";
|
|
83
|
+
timestampsRequested = false;
|
|
84
|
+
timestampTrailer = new TimestampTrailer();
|
|
78
85
|
rpc;
|
|
79
86
|
constructor(prepared, signal) {
|
|
80
87
|
this.rpc = new BidiCall(openStream(prepared.client, prepared.metadata), signal);
|
|
@@ -88,25 +95,35 @@ export class SynthesisCall {
|
|
|
88
95
|
cancel() {
|
|
89
96
|
this.rpc.cancel();
|
|
90
97
|
}
|
|
91
|
-
start(voice, language) {
|
|
92
|
-
|
|
98
|
+
start(voice, language, timestamps = false, customLexicon = [], audioFormat = "audio/pcm", sampleRate = 24000, timeScaleFactor) {
|
|
99
|
+
this.timestampsRequested = timestamps;
|
|
100
|
+
this.audioFormat = audioFormat;
|
|
101
|
+
const message = header(voice, language, timestamps, customLexicon, audioFormat, sampleRate, timeScaleFactor);
|
|
102
|
+
return this.rpc.write(message);
|
|
93
103
|
}
|
|
94
|
-
|
|
95
|
-
return this.
|
|
104
|
+
timestampResult() {
|
|
105
|
+
return this.timestampTrailer.result(this.requestId);
|
|
106
|
+
}
|
|
107
|
+
write(text) {
|
|
108
|
+
return this.rpc.write(textMessage(text));
|
|
96
109
|
}
|
|
97
110
|
finishInput() {
|
|
98
111
|
this.rpc.finishInput();
|
|
99
112
|
}
|
|
100
113
|
checkFormat(contentType) {
|
|
101
|
-
if (contentType !==
|
|
102
|
-
throw new errors.RimeAudioFormatError(
|
|
114
|
+
if (contentType !== this.audioFormat)
|
|
115
|
+
throw new errors.RimeAudioFormatError(`Expected ${this.audioFormat} from the service`, this.requestId);
|
|
103
116
|
}
|
|
104
117
|
async *audio() {
|
|
105
118
|
const headers = await this.rpc.headers();
|
|
106
119
|
const contentType = headers.get("x-rime-audio-content-type")[0];
|
|
107
120
|
for await (const response of this.rpc.responses()) {
|
|
121
|
+
if (response.payload.case === "trailer" && this.timestampsRequested)
|
|
122
|
+
this.timestampTrailer.accept(response.payload.value);
|
|
108
123
|
if (response.payload.case !== "audio")
|
|
109
124
|
continue;
|
|
125
|
+
if (this.timestampTrailer.seen)
|
|
126
|
+
this.timestampTrailer.invalid = true;
|
|
110
127
|
if (response.payload.value.length)
|
|
111
128
|
this.checkFormat(contentType);
|
|
112
129
|
yield response.payload.value;
|
package/docs/stt.md
CHANGED
|
@@ -52,8 +52,8 @@ split sample frames; the SDK carries incomplete bytes into the next chunk. A
|
|
|
52
52
|
truncated final frame raises `RimeAudioFormatError`, without committing the
|
|
53
53
|
utterance. Each gRPC audio payload is at most 64 KiB of 16 kHz mono PCM16.
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
Recognition connects to `stt.api.rime.ai:443` using TLS and the same API key as
|
|
56
|
+
the other features. No endpoint configuration is needed.
|
|
57
57
|
The existing `model`, `endpoint` and client `timeout` settings remain TTS settings.
|
|
58
58
|
There is no STT voice/model selector; the service selects its recognizer.
|
|
59
59
|
|