@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.
@@ -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
- connect(natsServerUrls: string[], user: IdentityUser, prefix?: string): Promise<void>;
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
  };
@@ -1,11 +1,13 @@
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>;
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
- "version": "0.0.1",
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",
@@ -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
- `/email-verification-request/${email}?returnUrl=${returnUrl}`,
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
- `/email-verification-complete/${email}/${otpCode}`,
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
- `/webauth-challenge-request?registration=${isRegistration ? 'true' : ''
42
- }&displayName=${displayName}`,
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
- `/webauth-challenge-complete?displayName=${displayName}&addAsAdditionalDevice=${addAsAdditionalDevice ? 'true' : ''
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 signOut() {
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 { VoicevoxTtsService } from './tts.service'
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
- voicevoxUrl: string
12
+ natsUrl: string
13
13
  }
14
14
 
15
15
  const defaultConfig: Config = {
16
16
  debug: false,
17
17
  authUrl: 'https://auth.jok.io',
18
- voicevoxUrl: 'https://voicevox.fly.dev',
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
- edgeTts: new EdgeTtsService(),
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
  }
@@ -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
- async connect(
26
- natsServerUrls: string[],
27
- user: IdentityUser,
28
- prefix?: string,
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: natsServerUrls,
44
+ servers: finalNatsServerUrls,
32
45
  })
33
46
 
34
- this.userId = user.userId
35
- this.sessionId = user.sessionId
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
- cb(decodedMessage as any, {
253
- subject,
254
- userId,
255
- sessionId,
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
  }
@@ -1,94 +1,153 @@
1
1
  import { playAudioFromBuffer } from './common/playAudioFromBuffer'
2
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
- )
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
- if (!audioQueryResponse.ok) {
26
- throw new Error(
27
- `Audio query failed: ${audioQueryResponse.statusText}`,
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
- 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
- )
17
+ ws.binaryType = 'arraybuffer'
45
18
 
46
- if (!synthesisResponse.body) {
47
- return null
48
- }
19
+ const SSML_text = getSSML(text, voice, options)
49
20
 
50
- const reader = synthesisResponse.body.getReader()
51
- const chunks = []
21
+ ws.addEventListener('open', () => {
22
+ const configMessage = buildTTSConfigMessage()
23
+ ws.send(configMessage)
52
24
 
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
- }
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
- // 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
- }
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
- return audioData.buffer
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(text: string, voice: string) {
76
- return this.getAudioBuffer(text, voice).then(x =>
77
- x ? playAudioFromBuffer(x) : null,
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 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
- })),
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
+ }