@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/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
+ }