@jokio/sdk 0.0.1 → 0.0.3
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/README.md +10 -0
- package/dist/auth.service.d.ts +3 -1
- package/dist/index.d.ts +4 -5
- package/dist/index.html +51 -8
- package/dist/jokio.sdk.es.js +1125 -1094
- package/dist/jokio.sdk.umd.js +24 -24
- package/dist/nats.service.d.ts +13 -4
- package/dist/tts.service.d.ts +11 -9
- package/dist/voicevoxTts.d.ts +11 -0
- package/package.json +3 -1
- package/src/auth.service.ts +37 -9
- package/src/index.ts +10 -8
- package/src/nats.service.ts +59 -38
- package/src/tts.service.ts +134 -75
- package/src/voicevoxTts.ts +94 -0
- package/test/index.html +51 -8
- package/dist/edgeTts.service.d.ts +0 -13
- package/src/edgeTts.service.ts +0 -153
package/dist/nats.service.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
type Config = {
|
|
2
|
+
natsUrl: string;
|
|
3
|
+
};
|
|
1
4
|
export declare class NatsService<TApi> {
|
|
5
|
+
private config;
|
|
2
6
|
onStatusChange?: (isConnected: boolean) => void;
|
|
3
7
|
private nc?;
|
|
4
8
|
private sessionId;
|
|
@@ -6,7 +10,12 @@ export declare class NatsService<TApi> {
|
|
|
6
10
|
private globalPrefix;
|
|
7
11
|
private resolveConnected;
|
|
8
12
|
isConnected: Promise<unknown>;
|
|
9
|
-
|
|
13
|
+
constructor(config: Config);
|
|
14
|
+
connect(opts: {
|
|
15
|
+
natsWsServerUrls?: string[];
|
|
16
|
+
user?: IdentityUser;
|
|
17
|
+
prefix?: string;
|
|
18
|
+
}): Promise<void>;
|
|
10
19
|
disconnect(): Promise<void>;
|
|
11
20
|
publish<TSubject extends keyof TApi & string>(subject: TSubject, data: InferReturnType<TApi, TSubject>): Promise<void>;
|
|
12
21
|
call<TSubject extends keyof TApi & string>(subject: TSubject, data: InferProps<TApi, TSubject>, opts?: {
|
|
@@ -17,6 +26,9 @@ export declare class NatsService<TApi> {
|
|
|
17
26
|
userId: string;
|
|
18
27
|
sessionId: string;
|
|
19
28
|
}) => void): Promise<() => void>;
|
|
29
|
+
/**
|
|
30
|
+
* Reqiores permission to publish response in `_INBOX.>`. If you need to just listen events, use `on`.
|
|
31
|
+
*/
|
|
20
32
|
handler<TSubject extends keyof TApi & string>(subject: TSubject, cb: (data: InferProps<TApi, TSubject>, ctx: {
|
|
21
33
|
subject: string;
|
|
22
34
|
userId: string;
|
|
@@ -24,10 +36,7 @@ export declare class NatsService<TApi> {
|
|
|
24
36
|
}) => Promise<InferReturnType<TApi, TSubject>>): Promise<() => void>;
|
|
25
37
|
private buildSubject;
|
|
26
38
|
}
|
|
27
|
-
export declare const nats: NatsService<unknown>;
|
|
28
39
|
type IdentityUser = {
|
|
29
|
-
jwt: string;
|
|
30
|
-
seed: string;
|
|
31
40
|
userId: string;
|
|
32
41
|
sessionId: string;
|
|
33
42
|
};
|
package/dist/tts.service.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
export declare class EdgeTtsService {
|
|
2
|
+
getAudioBuffer(text: string, voice: string, options?: {
|
|
3
|
+
pitch?: string;
|
|
4
|
+
rate?: string;
|
|
5
|
+
volume?: string;
|
|
6
|
+
}): Promise<ArrayBuffer>;
|
|
7
|
+
getAudio(text: string, voice: string, options?: {
|
|
8
|
+
pitch?: string;
|
|
9
|
+
rate?: string;
|
|
10
|
+
volume?: string;
|
|
11
|
+
}): Promise<HTMLAudioElement>;
|
|
9
12
|
getVoices(): Promise<any[]>;
|
|
10
13
|
}
|
|
11
|
-
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Config = {
|
|
2
|
+
voicevoxUrl: string;
|
|
3
|
+
};
|
|
4
|
+
export declare class VoicevoxTtsService {
|
|
5
|
+
private config;
|
|
6
|
+
constructor(config: Config);
|
|
7
|
+
getAudioBuffer(text: string, voice: string): Promise<ArrayBuffer | null>;
|
|
8
|
+
getAudio(text: string, voice: string): Promise<HTMLAudioElement | null>;
|
|
9
|
+
getVoices(): Promise<any[]>;
|
|
10
|
+
}
|
|
11
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jokio/sdk",
|
|
3
|
-
"
|
|
3
|
+
"description": "pure js sdk for building decentralised localfirst web apps. Provides tts ai model integrations, realtime p2p communication & crypto encryptions.",
|
|
4
|
+
"version": "0.0.3",
|
|
5
|
+
"license": "MIT",
|
|
4
6
|
"main": "dist/jokio.sdk.umd.js",
|
|
5
7
|
"module": "dist/jokio.sdk.es.js",
|
|
6
8
|
"types": "dist/index.d.ts",
|
package/src/auth.service.ts
CHANGED
|
@@ -7,13 +7,15 @@ type Config = {
|
|
|
7
7
|
authUrl: string
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
const USER_STORAGE_KEY = 'LAST_AUTH_DATA'
|
|
11
|
+
|
|
10
12
|
export class AuthService {
|
|
11
|
-
constructor(private config: Config) {
|
|
13
|
+
constructor(private config: Config) {}
|
|
12
14
|
|
|
13
15
|
async requestEmailLogin(email: string, returnUrl: string) {
|
|
14
16
|
const res = await fetch(
|
|
15
17
|
this.config.authUrl +
|
|
16
|
-
|
|
18
|
+
`/email-verification-request/${email}?returnUrl=${returnUrl}`,
|
|
17
19
|
{ credentials: 'include' },
|
|
18
20
|
).then(processResponse)
|
|
19
21
|
|
|
@@ -23,10 +25,12 @@ export class AuthService {
|
|
|
23
25
|
async completeEmailLogin(email: string, otpCode: string) {
|
|
24
26
|
const res = await fetch(
|
|
25
27
|
this.config.authUrl +
|
|
26
|
-
|
|
28
|
+
`/email-verification-complete/${email}/${otpCode}`,
|
|
27
29
|
{ credentials: 'include' },
|
|
28
30
|
).then(processResponse)
|
|
29
31
|
|
|
32
|
+
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res))
|
|
33
|
+
|
|
30
34
|
return res as IdentityUser
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -38,8 +42,9 @@ export class AuthService {
|
|
|
38
42
|
// 1. get challenge options
|
|
39
43
|
const opts = await fetch(
|
|
40
44
|
this.config.authUrl +
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
`/webauth-challenge-request?registration=${
|
|
46
|
+
isRegistration ? 'true' : ''
|
|
47
|
+
}&displayName=${displayName}`,
|
|
43
48
|
{ credentials: 'include' },
|
|
44
49
|
).then(processResponse)
|
|
45
50
|
|
|
@@ -66,8 +71,9 @@ export class AuthService {
|
|
|
66
71
|
// 3. verify response
|
|
67
72
|
const res = await fetch(
|
|
68
73
|
this.config.authUrl +
|
|
69
|
-
|
|
70
|
-
|
|
74
|
+
`/webauth-challenge-complete?displayName=${displayName}&addAsAdditionalDevice=${
|
|
75
|
+
addAsAdditionalDevice ? 'true' : ''
|
|
76
|
+
}`,
|
|
71
77
|
{
|
|
72
78
|
method: 'POST',
|
|
73
79
|
body: JSON.stringify(attResp),
|
|
@@ -78,17 +84,39 @@ export class AuthService {
|
|
|
78
84
|
},
|
|
79
85
|
).then(processResponse)
|
|
80
86
|
|
|
87
|
+
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res))
|
|
88
|
+
|
|
81
89
|
return res as IdentityUser
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
async
|
|
85
|
-
// 1. get challenge options
|
|
92
|
+
async guestSignIn() {
|
|
86
93
|
const res = await fetch(this.config.authUrl + `/sign-out`, {
|
|
87
94
|
credentials: 'include',
|
|
88
95
|
}).then(processResponse)
|
|
89
96
|
|
|
97
|
+
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(res))
|
|
98
|
+
|
|
90
99
|
return res as IdentityUser
|
|
91
100
|
}
|
|
101
|
+
|
|
102
|
+
async clearLoginData() {
|
|
103
|
+
localStorage.removeItem(USER_STORAGE_KEY)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getLastLoginData() {
|
|
107
|
+
const s = localStorage.getItem(USER_STORAGE_KEY)
|
|
108
|
+
if (!s) {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const res = JSON.parse(s)
|
|
114
|
+
|
|
115
|
+
return res || null
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
92
120
|
}
|
|
93
121
|
|
|
94
122
|
export type IdentityUser = {
|
package/src/index.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { AuthService } from './auth.service'
|
|
2
2
|
import { CryptoService } from './crypto.service'
|
|
3
|
-
import { EdgeTtsService } from './edgeTts.service'
|
|
4
3
|
import { NatsService } from './nats.service'
|
|
5
|
-
import {
|
|
4
|
+
import { EdgeTtsService } from './tts.service'
|
|
6
5
|
|
|
7
6
|
export { NatsService } from './nats.service'
|
|
7
|
+
export { VoicevoxTtsService } from './voicevoxTts'
|
|
8
8
|
|
|
9
9
|
type Config = {
|
|
10
10
|
debug: boolean
|
|
11
11
|
authUrl: string
|
|
12
|
-
|
|
12
|
+
natsUrl: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const defaultConfig: Config = {
|
|
16
16
|
debug: false,
|
|
17
17
|
authUrl: 'https://auth.jok.io',
|
|
18
|
-
|
|
18
|
+
natsUrl: 'https://natsx.jok.io',
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const jok = {
|
|
@@ -23,15 +23,17 @@ export const jok = {
|
|
|
23
23
|
if (config.authUrl) {
|
|
24
24
|
this.auth = new AuthService({ authUrl: config.authUrl })
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
if (config.natsUrl) {
|
|
28
|
+
this.nats = new NatsService({ natsUrl: config.natsUrl })
|
|
29
|
+
}
|
|
26
30
|
},
|
|
27
31
|
|
|
28
32
|
auth: new AuthService(defaultConfig),
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
voicevoxTts: new VoicevoxTtsService(defaultConfig),
|
|
34
|
+
tts: new EdgeTtsService(),
|
|
33
35
|
|
|
34
|
-
nats: new NatsService(),
|
|
36
|
+
nats: new NatsService(defaultConfig),
|
|
35
37
|
|
|
36
38
|
crypto: new CryptoService(),
|
|
37
39
|
}
|
package/src/nats.service.ts
CHANGED
|
@@ -5,6 +5,11 @@ import {
|
|
|
5
5
|
JSONCodec,
|
|
6
6
|
type NatsConnection,
|
|
7
7
|
} from 'nats.ws'
|
|
8
|
+
import { AuthService } from './auth.service'
|
|
9
|
+
|
|
10
|
+
type Config = {
|
|
11
|
+
natsUrl: string
|
|
12
|
+
}
|
|
8
13
|
|
|
9
14
|
const jsonCodec = JSONCodec()
|
|
10
15
|
|
|
@@ -22,17 +27,34 @@ export class NatsService<TApi> {
|
|
|
22
27
|
resolve => (this.resolveConnected = resolve),
|
|
23
28
|
)
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
constructor(private config: Config) {}
|
|
31
|
+
|
|
32
|
+
async connect(opts: {
|
|
33
|
+
natsWsServerUrls?: string[]
|
|
34
|
+
user?: IdentityUser
|
|
35
|
+
prefix?: string
|
|
36
|
+
}) {
|
|
37
|
+
const { natsWsServerUrls, user, prefix } = opts
|
|
38
|
+
|
|
39
|
+
const finalNatsServerUrls = natsWsServerUrls ?? [
|
|
40
|
+
this.config.natsUrl,
|
|
41
|
+
]
|
|
42
|
+
|
|
30
43
|
this.nc = await connect({
|
|
31
|
-
servers:
|
|
44
|
+
servers: finalNatsServerUrls,
|
|
32
45
|
})
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
const userInfo =
|
|
48
|
+
user ?? new AuthService({ authUrl: '' }).getLastLoginData()
|
|
49
|
+
|
|
50
|
+
if (!userInfo) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Please authenticate first by calling `auth` apis',
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.userId = userInfo.userId
|
|
57
|
+
this.sessionId = userInfo.sessionId
|
|
36
58
|
|
|
37
59
|
this.globalPrefix = prefix ?? ''
|
|
38
60
|
|
|
@@ -197,6 +219,9 @@ export class NatsService<TApi> {
|
|
|
197
219
|
return () => subscription.unsubscribe()
|
|
198
220
|
}
|
|
199
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Reqiores permission to publish response in `_INBOX.>`. If you need to just listen events, use `on`.
|
|
224
|
+
*/
|
|
200
225
|
async handler<TSubject extends keyof TApi & string>(
|
|
201
226
|
subject: TSubject,
|
|
202
227
|
cb: (
|
|
@@ -249,33 +274,33 @@ export class NatsService<TApi> {
|
|
|
249
274
|
|
|
250
275
|
const decodedMessage = jsonCodec.decode(message.data)
|
|
251
276
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.then(res => {
|
|
258
|
-
if (message.reply) {
|
|
259
|
-
message.respond(
|
|
260
|
-
jsonCodec.encode({
|
|
261
|
-
ok: true,
|
|
262
|
-
data: res,
|
|
263
|
-
}),
|
|
264
|
-
)
|
|
265
|
-
}
|
|
266
|
-
})
|
|
267
|
-
.catch(err => {
|
|
268
|
-
console.error('[nats] handle', err)
|
|
269
|
-
|
|
270
|
-
if (message.reply) {
|
|
271
|
-
message.respond(
|
|
272
|
-
jsonCodec.encode({
|
|
273
|
-
ok: false,
|
|
274
|
-
message: err.message,
|
|
275
|
-
}),
|
|
276
|
-
)
|
|
277
|
-
}
|
|
277
|
+
try {
|
|
278
|
+
const res = await cb(decodedMessage as any, {
|
|
279
|
+
subject,
|
|
280
|
+
userId,
|
|
281
|
+
sessionId,
|
|
278
282
|
})
|
|
283
|
+
|
|
284
|
+
if (message.reply) {
|
|
285
|
+
message.respond(
|
|
286
|
+
jsonCodec.encode({
|
|
287
|
+
ok: true,
|
|
288
|
+
data: res,
|
|
289
|
+
}),
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
} catch (err: any) {
|
|
293
|
+
console.error('[nats] handle', err)
|
|
294
|
+
|
|
295
|
+
if (message.reply) {
|
|
296
|
+
message.respond(
|
|
297
|
+
jsonCodec.encode({
|
|
298
|
+
ok: false,
|
|
299
|
+
message: err.message,
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
279
304
|
} catch (err: any) {
|
|
280
305
|
console.error('[nats] handle', err)
|
|
281
306
|
|
|
@@ -301,11 +326,7 @@ export class NatsService<TApi> {
|
|
|
301
326
|
}
|
|
302
327
|
}
|
|
303
328
|
|
|
304
|
-
export const nats = new NatsService()
|
|
305
|
-
|
|
306
329
|
type IdentityUser = {
|
|
307
|
-
jwt: string
|
|
308
|
-
seed: string
|
|
309
330
|
userId: string
|
|
310
331
|
sessionId: string
|
|
311
332
|
}
|
package/src/tts.service.ts
CHANGED
|
@@ -1,94 +1,153 @@
|
|
|
1
1
|
import { playAudioFromBuffer } from './common/playAudioFromBuffer'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const audioQueryResponse = await fetch(
|
|
12
|
-
`${
|
|
13
|
-
this.config.voicevoxUrl
|
|
14
|
-
}/audio_query?text=${encodeURIComponent(text)}&speaker=${
|
|
15
|
-
voice || 3
|
|
16
|
-
}`,
|
|
17
|
-
{
|
|
18
|
-
method: 'POST',
|
|
19
|
-
headers: {
|
|
20
|
-
Accept: 'application/json',
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
)
|
|
3
|
+
export class EdgeTtsService {
|
|
4
|
+
async getAudioBuffer(
|
|
5
|
+
text: string,
|
|
6
|
+
voice: string,
|
|
7
|
+
options?: { pitch?: string; rate?: string; volume?: string },
|
|
8
|
+
): Promise<ArrayBuffer> {
|
|
9
|
+
const audioChunks: Uint8Array[] = []
|
|
10
|
+
const reqId = crypto.randomUUID() // Supported in most modern browsers
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const ws = new WebSocket(
|
|
14
|
+
`${Constants.WSS_URL}?trustedclienttoken=${Constants.TRUSTED_CLIENT_TOKEN}&ConnectionId=${reqId}`,
|
|
28
15
|
)
|
|
29
|
-
}
|
|
30
16
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Step 2: Synthesize the speech
|
|
34
|
-
const synthesisResponse = await fetch(
|
|
35
|
-
`${this.config.voicevoxUrl}/synthesis?speaker=${voice}`,
|
|
36
|
-
{
|
|
37
|
-
method: 'POST',
|
|
38
|
-
headers: {
|
|
39
|
-
Accept: 'audio/wav',
|
|
40
|
-
'Content-Type': 'application/json',
|
|
41
|
-
},
|
|
42
|
-
body: JSON.stringify(audioQuery),
|
|
43
|
-
},
|
|
44
|
-
)
|
|
17
|
+
ws.binaryType = 'arraybuffer'
|
|
45
18
|
|
|
46
|
-
|
|
47
|
-
return null
|
|
48
|
-
}
|
|
19
|
+
const SSML_text = getSSML(text, voice, options)
|
|
49
20
|
|
|
50
|
-
|
|
51
|
-
|
|
21
|
+
ws.addEventListener('open', () => {
|
|
22
|
+
const configMessage = buildTTSConfigMessage()
|
|
23
|
+
ws.send(configMessage)
|
|
52
24
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
25
|
+
const speechMessage =
|
|
26
|
+
`X-RequestId:${reqId}\r\n` +
|
|
27
|
+
`Content-Type:application/ssml+xml\r\n` +
|
|
28
|
+
`X-Timestamp:${new Date().toISOString()}Z\r\n` +
|
|
29
|
+
`Path:ssml\r\n\r\n` +
|
|
30
|
+
SSML_text
|
|
59
31
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
32
|
+
ws.send(speechMessage)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
ws.addEventListener('message', event => {
|
|
36
|
+
const data = event.data
|
|
37
|
+
|
|
38
|
+
if (data instanceof ArrayBuffer) {
|
|
39
|
+
const buffer = new Uint8Array(data)
|
|
40
|
+
const needle = new TextEncoder().encode('Path:audio\r\n')
|
|
41
|
+
|
|
42
|
+
const index = findSubarray(buffer, needle)
|
|
43
|
+
if (index !== -1) {
|
|
44
|
+
const audioData = buffer.subarray(index + needle.length)
|
|
45
|
+
audioChunks.push(audioData)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const textData = new TextDecoder().decode(buffer)
|
|
49
|
+
if (textData.includes('Path:turn.end')) {
|
|
50
|
+
ws.close()
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
if (data.includes('Path:turn.end')) {
|
|
54
|
+
ws.close()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
ws.addEventListener('close', () => {
|
|
60
|
+
if (!audioChunks.length) {
|
|
61
|
+
reject('No audio data available to save.')
|
|
62
|
+
return
|
|
63
|
+
}
|
|
71
64
|
|
|
72
|
-
|
|
65
|
+
// Combine all Uint8Arrays into one ArrayBuffer
|
|
66
|
+
const totalLength = audioChunks.reduce(
|
|
67
|
+
(acc, chunk) => acc + chunk.length,
|
|
68
|
+
0,
|
|
69
|
+
)
|
|
70
|
+
const finalBuffer = new Uint8Array(totalLength)
|
|
71
|
+
let offset = 0
|
|
72
|
+
for (const chunk of audioChunks) {
|
|
73
|
+
finalBuffer.set(chunk, offset)
|
|
74
|
+
offset += chunk.length
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
resolve(finalBuffer.buffer) // ArrayBuffer
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
ws.addEventListener('error', err => {
|
|
81
|
+
reject(err)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
async getAudio(
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
async getAudio(
|
|
87
|
+
text: string,
|
|
88
|
+
voice: string,
|
|
89
|
+
options?: { pitch?: string; rate?: string; volume?: string },
|
|
90
|
+
) {
|
|
91
|
+
return this.getAudioBuffer(text, voice, options).then(x =>
|
|
92
|
+
playAudioFromBuffer(x),
|
|
78
93
|
)
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
async getVoices(): Promise<any[]> {
|
|
82
|
-
const
|
|
83
|
-
`${
|
|
84
|
-
).then(x => x.json())
|
|
85
|
-
|
|
86
|
-
return speakers.flatMap((x: any) =>
|
|
87
|
-
x.styles.map((y: any) => ({
|
|
88
|
-
id: y.id.toString(),
|
|
89
|
-
locale: 'ja-JP',
|
|
90
|
-
description: `${x.name} - ${y.name}`,
|
|
91
|
-
})),
|
|
97
|
+
const response = await fetch(
|
|
98
|
+
`${Constants.VOICES_URL}?trustedclienttoken=${Constants.TRUSTED_CLIENT_TOKEN}`,
|
|
92
99
|
)
|
|
100
|
+
const data = await response.json()
|
|
101
|
+
return data.map((voice: any) => ({
|
|
102
|
+
id: voice.ShortName,
|
|
103
|
+
locale: voice.Locale,
|
|
104
|
+
description: voice.FriendlyName,
|
|
105
|
+
}))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const Constants = {
|
|
110
|
+
TRUSTED_CLIENT_TOKEN: '6A5AA1D4EAFF4E9FB37E23D68491D6F4',
|
|
111
|
+
WSS_URL:
|
|
112
|
+
'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1',
|
|
113
|
+
VOICES_URL:
|
|
114
|
+
'https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list',
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const buildTTSConfigMessage = (): string => {
|
|
118
|
+
return (
|
|
119
|
+
`X-Timestamp:${new Date().toISOString()}Z\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
|
|
120
|
+
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":true},"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}`
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const getSSML = (
|
|
125
|
+
text: string,
|
|
126
|
+
voice: string,
|
|
127
|
+
options: any = {},
|
|
128
|
+
): string => {
|
|
129
|
+
options.pitch = options.pitch?.replace('hz', 'Hz')
|
|
130
|
+
const pitch = options.pitch || '0Hz' // this.validatePitch(options.pitch || '0Hz');
|
|
131
|
+
const rate = options.rate || '0%' // this.validateRate(options.rate || '0%');
|
|
132
|
+
const volume = options.volume || '0%' // this.validateVolume(options.volume || '0%');
|
|
133
|
+
|
|
134
|
+
return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'><prosody pitch='${pitch}' rate='${rate}' volume='${volume}'>${text}</prosody></voice></speak>`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Helper: Finds a subarray inside another Uint8Array
|
|
138
|
+
function findSubarray(
|
|
139
|
+
haystack: Uint8Array,
|
|
140
|
+
needle: Uint8Array,
|
|
141
|
+
): number {
|
|
142
|
+
for (let i = 0; i <= haystack.length - needle.length; i++) {
|
|
143
|
+
let match = true
|
|
144
|
+
for (let j = 0; j < needle.length; j++) {
|
|
145
|
+
if (haystack[i + j] !== needle[j]) {
|
|
146
|
+
match = false
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (match) return i
|
|
93
151
|
}
|
|
152
|
+
return -1
|
|
94
153
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { playAudioFromBuffer } from './common/playAudioFromBuffer'
|
|
2
|
+
|
|
3
|
+
type Config = {
|
|
4
|
+
voicevoxUrl: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class VoicevoxTtsService {
|
|
8
|
+
constructor(private config: Config) {}
|
|
9
|
+
|
|
10
|
+
async getAudioBuffer(text: string, voice: string) {
|
|
11
|
+
const audioQueryResponse = await fetch(
|
|
12
|
+
`${
|
|
13
|
+
this.config.voicevoxUrl
|
|
14
|
+
}/audio_query?text=${encodeURIComponent(text)}&speaker=${
|
|
15
|
+
voice || 3
|
|
16
|
+
}`,
|
|
17
|
+
{
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: {
|
|
20
|
+
Accept: 'application/json',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if (!audioQueryResponse.ok) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Audio query failed: ${audioQueryResponse.statusText}`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const audioQuery = await audioQueryResponse.json()
|
|
32
|
+
|
|
33
|
+
// Step 2: Synthesize the speech
|
|
34
|
+
const synthesisResponse = await fetch(
|
|
35
|
+
`${this.config.voicevoxUrl}/synthesis?speaker=${voice}`,
|
|
36
|
+
{
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
Accept: 'audio/wav',
|
|
40
|
+
'Content-Type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(audioQuery),
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if (!synthesisResponse.body) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const reader = synthesisResponse.body.getReader()
|
|
51
|
+
const chunks = []
|
|
52
|
+
|
|
53
|
+
// Step 1: Read all the chunks
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read()
|
|
56
|
+
if (done) break
|
|
57
|
+
chunks.push(value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Step 2: Concatenate into one Uint8Array
|
|
61
|
+
const totalLength = chunks.reduce(
|
|
62
|
+
(acc, chunk) => acc + chunk.length,
|
|
63
|
+
0,
|
|
64
|
+
)
|
|
65
|
+
const audioData = new Uint8Array(totalLength)
|
|
66
|
+
let offset = 0
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
audioData.set(chunk, offset)
|
|
69
|
+
offset += chunk.length
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return audioData.buffer
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async getAudio(text: string, voice: string) {
|
|
76
|
+
return this.getAudioBuffer(text, voice).then(x =>
|
|
77
|
+
x ? playAudioFromBuffer(x) : null,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getVoices(): Promise<any[]> {
|
|
82
|
+
const speakers = await fetch(
|
|
83
|
+
`${this.config.voicevoxUrl}/speakers`,
|
|
84
|
+
).then(x => x.json())
|
|
85
|
+
|
|
86
|
+
return speakers.flatMap((x: any) =>
|
|
87
|
+
x.styles.map((y: any) => ({
|
|
88
|
+
id: y.id.toString(),
|
|
89
|
+
locale: 'ja-JP',
|
|
90
|
+
description: `${x.name} - ${y.name}`,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|