@jokio/sdk 0.0.1
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/.prettierrc +12 -0
- package/dist/auth.service.d.ts +19 -0
- package/dist/common/playAudioFromBuffer.d.ts +1 -0
- package/dist/crypto.service.d.ts +9 -0
- package/dist/edgeTts.service.d.ts +13 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.html +63 -0
- package/dist/jokio.sdk.es.js +8495 -0
- package/dist/jokio.sdk.umd.js +39 -0
- package/dist/nats.service.d.ts +36 -0
- package/dist/tts.service.d.ts +11 -0
- package/package.json +22 -0
- package/src/auth.service.ts +109 -0
- package/src/common/playAudioFromBuffer.ts +15 -0
- package/src/crypto.service.ts +100 -0
- package/src/edgeTts.service.ts +153 -0
- package/src/index.ts +37 -0
- package/src/nats.service.ts +325 -0
- package/src/tts.service.ts +94 -0
- package/test/index.html +63 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +23 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jokio/sdk",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"main": "dist/jokio.sdk.umd.js",
|
|
5
|
+
"module": "dist/jokio.sdk.es.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "npm run build && vite",
|
|
9
|
+
"build": "vite build && tsc"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"typescript": "5.8.3",
|
|
16
|
+
"vite": "6.3.5"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@simplewebauthn/browser": "13.1.0",
|
|
20
|
+
"nats.ws": "1.30.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startAuthentication,
|
|
3
|
+
startRegistration,
|
|
4
|
+
} from '@simplewebauthn/browser'
|
|
5
|
+
|
|
6
|
+
type Config = {
|
|
7
|
+
authUrl: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class AuthService {
|
|
11
|
+
constructor(private config: Config) { }
|
|
12
|
+
|
|
13
|
+
async requestEmailLogin(email: string, returnUrl: string) {
|
|
14
|
+
const res = await fetch(
|
|
15
|
+
this.config.authUrl +
|
|
16
|
+
`/email-verification-request/${email}?returnUrl=${returnUrl}`,
|
|
17
|
+
{ credentials: 'include' },
|
|
18
|
+
).then(processResponse)
|
|
19
|
+
|
|
20
|
+
return res as boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async completeEmailLogin(email: string, otpCode: string) {
|
|
24
|
+
const res = await fetch(
|
|
25
|
+
this.config.authUrl +
|
|
26
|
+
`/email-verification-complete/${email}/${otpCode}`,
|
|
27
|
+
{ credentials: 'include' },
|
|
28
|
+
).then(processResponse)
|
|
29
|
+
|
|
30
|
+
return res as IdentityUser
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async requestPasskeyLogin(
|
|
34
|
+
displayName: string = '',
|
|
35
|
+
isRegistration: boolean = false,
|
|
36
|
+
addAsAdditionalDevice = false,
|
|
37
|
+
) {
|
|
38
|
+
// 1. get challenge options
|
|
39
|
+
const opts = await fetch(
|
|
40
|
+
this.config.authUrl +
|
|
41
|
+
`/webauth-challenge-request?registration=${isRegistration ? 'true' : ''
|
|
42
|
+
}&displayName=${displayName}`,
|
|
43
|
+
{ credentials: 'include' },
|
|
44
|
+
).then(processResponse)
|
|
45
|
+
|
|
46
|
+
// 2. ask user to register
|
|
47
|
+
let attResp
|
|
48
|
+
try {
|
|
49
|
+
if (!opts.user?.id) {
|
|
50
|
+
attResp = await startAuthentication({ optionsJSON: opts })
|
|
51
|
+
} else {
|
|
52
|
+
attResp = await startRegistration({ optionsJSON: opts })
|
|
53
|
+
}
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
if (error.name === 'InvalidStateError') {
|
|
56
|
+
alert(
|
|
57
|
+
'Error: Authenticator was probably already registered by user',
|
|
58
|
+
)
|
|
59
|
+
} else {
|
|
60
|
+
alert(error.message)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
throw error
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 3. verify response
|
|
67
|
+
const res = await fetch(
|
|
68
|
+
this.config.authUrl +
|
|
69
|
+
`/webauth-challenge-complete?displayName=${displayName}&addAsAdditionalDevice=${addAsAdditionalDevice ? 'true' : ''
|
|
70
|
+
}`,
|
|
71
|
+
{
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: JSON.stringify(attResp),
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
credentials: 'include',
|
|
78
|
+
},
|
|
79
|
+
).then(processResponse)
|
|
80
|
+
|
|
81
|
+
return res as IdentityUser
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async signOut() {
|
|
85
|
+
// 1. get challenge options
|
|
86
|
+
const res = await fetch(this.config.authUrl + `/sign-out`, {
|
|
87
|
+
credentials: 'include',
|
|
88
|
+
}).then(processResponse)
|
|
89
|
+
|
|
90
|
+
return res as IdentityUser
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type IdentityUser = {
|
|
95
|
+
name: string
|
|
96
|
+
email: string
|
|
97
|
+
userId: string
|
|
98
|
+
sessionId: string
|
|
99
|
+
verified: boolean
|
|
100
|
+
}
|
|
101
|
+
const processResponse = async (x: Response) => {
|
|
102
|
+
const data = await x.json()
|
|
103
|
+
|
|
104
|
+
if (!x.ok) {
|
|
105
|
+
throw new Error(data.message)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return data
|
|
109
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function playAudioFromBuffer(buffer: ArrayBuffer) {
|
|
2
|
+
const blob = new Blob([buffer], { type: 'audio/mpeg' }) // or 'audio/wav' depending on your format
|
|
3
|
+
const url = URL.createObjectURL(blob)
|
|
4
|
+
|
|
5
|
+
const audio = new Audio()
|
|
6
|
+
audio.src = url
|
|
7
|
+
// audio.play()
|
|
8
|
+
|
|
9
|
+
// Optional: cleanup after playback
|
|
10
|
+
audio.onended = () => {
|
|
11
|
+
URL.revokeObjectURL(url)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return audio
|
|
15
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const uint2hex = (x: Uint8Array) =>
|
|
2
|
+
[...x].map(x => x.toString(16).padStart(2, '0')).join('')
|
|
3
|
+
|
|
4
|
+
const hex2uint = (hex: string) =>
|
|
5
|
+
new Uint8Array(
|
|
6
|
+
hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)),
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
const ab2b64 = (ab: any) =>
|
|
10
|
+
btoa(String.fromCharCode(...new Uint8Array(ab)))
|
|
11
|
+
|
|
12
|
+
const b642ab = (str: string) =>
|
|
13
|
+
Uint8Array.from(atob(str), c => c.charCodeAt(0)).buffer
|
|
14
|
+
|
|
15
|
+
const sha256 = async (buffer: ArrayBuffer) =>
|
|
16
|
+
await crypto.subtle.digest('SHA-256', buffer)
|
|
17
|
+
|
|
18
|
+
const textEncoder = new TextEncoder()
|
|
19
|
+
|
|
20
|
+
export class CryptoService {
|
|
21
|
+
async createSessionKeyPair() {
|
|
22
|
+
return await crypto.subtle.generateKey(
|
|
23
|
+
{
|
|
24
|
+
name: 'ECDH',
|
|
25
|
+
namedCurve: 'P-256',
|
|
26
|
+
},
|
|
27
|
+
false, // non-extractable private key
|
|
28
|
+
['deriveKey', 'deriveBits'],
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async exportPublicKey(publicKey: CryptoKey): Promise<string> {
|
|
33
|
+
const spki = await crypto.subtle.exportKey('spki', publicKey)
|
|
34
|
+
|
|
35
|
+
return ab2b64(spki)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async deriveAESKey(privateKey: CryptoKey, publicKeyString: string) {
|
|
39
|
+
const spki = b642ab(publicKeyString)
|
|
40
|
+
|
|
41
|
+
const publicKey = await crypto.subtle.importKey(
|
|
42
|
+
'spki',
|
|
43
|
+
spki,
|
|
44
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
45
|
+
false,
|
|
46
|
+
[],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Derive key directly
|
|
50
|
+
const aesKey = await crypto.subtle.deriveKey(
|
|
51
|
+
{
|
|
52
|
+
name: 'ECDH',
|
|
53
|
+
public: publicKey,
|
|
54
|
+
},
|
|
55
|
+
privateKey,
|
|
56
|
+
{ name: 'AES-GCM', length: 256 },
|
|
57
|
+
false,
|
|
58
|
+
['encrypt', 'decrypt'],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return aesKey
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async exportRawKey(key: CryptoKey) {
|
|
65
|
+
const raw = await crypto.subtle.exportKey('raw', key)
|
|
66
|
+
return uint2hex(new Uint8Array(raw))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async encrypt(text: string, key: CryptoKey) {
|
|
70
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
71
|
+
const encoded = new TextEncoder().encode(text)
|
|
72
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
73
|
+
{ name: 'AES-GCM', iv },
|
|
74
|
+
key,
|
|
75
|
+
encoded,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return `${uint2hex(new Uint8Array(ciphertext))}_${uint2hex(iv)}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async decrypt(encryptedText: string, key: CryptoKey) {
|
|
82
|
+
const parts = encryptedText.split('_')
|
|
83
|
+
|
|
84
|
+
const ciphertext = hex2uint(parts[0]).buffer
|
|
85
|
+
const iv = hex2uint(parts[1])
|
|
86
|
+
|
|
87
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
88
|
+
{ name: 'AES-GCM', iv },
|
|
89
|
+
key,
|
|
90
|
+
ciphertext,
|
|
91
|
+
)
|
|
92
|
+
return new TextDecoder().decode(decrypted)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async hashString(text: string) {
|
|
96
|
+
const hashBuffer = await sha256(textEncoder.encode(text).buffer)
|
|
97
|
+
|
|
98
|
+
return ab2b64(hashBuffer)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { playAudioFromBuffer } from './common/playAudioFromBuffer'
|
|
2
|
+
|
|
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
|
|
11
|
+
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const ws = new WebSocket(
|
|
14
|
+
`${Constants.WSS_URL}?trustedclienttoken=${Constants.TRUSTED_CLIENT_TOKEN}&ConnectionId=${reqId}`,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
ws.binaryType = 'arraybuffer'
|
|
18
|
+
|
|
19
|
+
const SSML_text = getSSML(text, voice, options)
|
|
20
|
+
|
|
21
|
+
ws.addEventListener('open', () => {
|
|
22
|
+
const configMessage = buildTTSConfigMessage()
|
|
23
|
+
ws.send(configMessage)
|
|
24
|
+
|
|
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
|
|
31
|
+
|
|
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
|
+
}
|
|
64
|
+
|
|
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
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
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),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getVoices(): Promise<any[]> {
|
|
97
|
+
const response = await fetch(
|
|
98
|
+
`${Constants.VOICES_URL}?trustedclienttoken=${Constants.TRUSTED_CLIENT_TOKEN}`,
|
|
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
|
|
151
|
+
}
|
|
152
|
+
return -1
|
|
153
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AuthService } from './auth.service'
|
|
2
|
+
import { CryptoService } from './crypto.service'
|
|
3
|
+
import { EdgeTtsService } from './edgeTts.service'
|
|
4
|
+
import { NatsService } from './nats.service'
|
|
5
|
+
import { VoicevoxTtsService } from './tts.service'
|
|
6
|
+
|
|
7
|
+
export { NatsService } from './nats.service'
|
|
8
|
+
|
|
9
|
+
type Config = {
|
|
10
|
+
debug: boolean
|
|
11
|
+
authUrl: string
|
|
12
|
+
voicevoxUrl: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultConfig: Config = {
|
|
16
|
+
debug: false,
|
|
17
|
+
authUrl: 'https://auth.jok.io',
|
|
18
|
+
voicevoxUrl: 'https://voicevox.fly.dev',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const jok = {
|
|
22
|
+
setup(config: Partial<Config>) {
|
|
23
|
+
if (config.authUrl) {
|
|
24
|
+
this.auth = new AuthService({ authUrl: config.authUrl })
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
auth: new AuthService(defaultConfig),
|
|
29
|
+
|
|
30
|
+
edgeTts: new EdgeTtsService(),
|
|
31
|
+
|
|
32
|
+
voicevoxTts: new VoicevoxTtsService(defaultConfig),
|
|
33
|
+
|
|
34
|
+
nats: new NatsService(),
|
|
35
|
+
|
|
36
|
+
crypto: new CryptoService(),
|
|
37
|
+
}
|