Skip to content

Commit b4c2408

Browse files
authored
Merge pull request #343 from iceljc/features/realtime-chat
Features/realtime chat
2 parents 1e39e55 + acdf0eb commit b4c2408

File tree

10 files changed

+321
-28
lines changed

10 files changed

+321
-28
lines changed

src/lib/common/ProfileDropdown.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
>
4343
<img
4444
class="rounded-circle header-profile-user"
45-
src={`${buildUrl(PUBLIC_SERVICE_URL, user?.avatar)}?access_token=${$userStore?.token}`}
45+
src={`${user?.avatar && $userStore?.token ?
46+
`${buildUrl(PUBLIC_SERVICE_URL, user?.avatar)}?access_token=${$userStore?.token}` : ''}`}
4647
alt=""
4748
on:error={e => handleAvatarLoad(e)}
4849
/>

src/lib/common/audio-player/AudioPlayer.svelte

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@
242242
if (loop === "none") {
243243
if (order === "list") {
244244
if ($playList.playingIndex < audios.length - 1) {
245-
const promise = buildNextSongPromise(nextIdx);
245+
const promise = buildNextAudioPromise(nextIdx);
246246
promise.then(() => play());
247247
} else {
248248
$playList.playingIndex = ($playList.playingIndex + 1) % audios.length;
@@ -257,21 +257,21 @@
257257
} else {
258258
targetIdx = randomIdx;
259259
}
260-
const promise = buildNextSongPromise(targetIdx);
260+
const promise = buildNextAudioPromise(targetIdx);
261261
promise.then(() => play());
262262
}
263263
} else if (loop === "one") {
264264
player.currentTime = 0;
265265
} else if (loop === "all") {
266-
const promise = buildNextSongPromise(nextIdx);
266+
const promise = buildNextAudioPromise(nextIdx);
267267
promise.then(() => play());
268268
}
269269
};
270270
271271
/**
272272
* @param {number} idx
273273
*/
274-
function buildNextSongPromise(idx) {
274+
function buildNextAudioPromise(idx) {
275275
return new Promise((/** @type {any} */ resolve) => {
276276
$playList.playingIndex = idx;
277277
player.currentTime = 0;
@@ -287,8 +287,8 @@
287287
/**
288288
* @param {number} idx
289289
*/
290-
function switchSong(idx) {
291-
const promise = buildNextSongPromise(idx);
290+
function switchAudio(idx) {
291+
const promise = buildNextAudioPromise(idx);
292292
if (autoPlayNextOnClick) {
293293
promise.then(() => {
294294
play();
@@ -490,7 +490,7 @@
490490
{#each $audioList as song, idx}
491491
<!-- svelte-ignore a11y-click-events-have-key-events -->
492492
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
493-
<li on:click={() => switchSong(idx) }>
493+
<li on:click={() => switchAudio(idx) }>
494494
{#if idx === $playList.playingIndex}
495495
<span class="aplayer-list-cur" />
496496
{/if}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
2+
export const AudioRecordingWorklet = `
3+
class AudioProcessingWorklet extends AudioWorkletProcessor {
4+
5+
// send and clear buffer every 2048 samples,
6+
// which at 16khz is about 8 times a second,
7+
// or at 24khz is about 11 times a second
8+
buffer = new Int16Array(2048);
9+
10+
// current write index
11+
bufferWriteIndex = 0;
12+
13+
speaking = false;
14+
threshold = 0.1;
15+
16+
constructor() {
17+
super();
18+
}
19+
20+
/**
21+
* @param inputs Float32Array[][] [input#][channel#][sample#] so to access first inputs 1st channel inputs[0][0]
22+
* @param outputs Float32Array[][]
23+
*/
24+
process(inputs) {
25+
if (inputs[0].length) {
26+
const channel0 = inputs[0][0];
27+
this.speaking = channel0.some(sample => Math.abs(sample) >= this.threshold);
28+
this.processChunk(channel0);
29+
}
30+
return true;
31+
}
32+
33+
sendAndClearBuffer(){
34+
this.port.postMessage({
35+
event: "chunk",
36+
data: {
37+
speaking: this.speaking,
38+
int16arrayBuffer: this.buffer.slice(0, this.bufferWriteIndex).buffer,
39+
},
40+
});
41+
this.bufferWriteIndex = 0;
42+
}
43+
44+
processChunk(float32Array) {
45+
const l = float32Array.length;
46+
47+
for (let i = 0; i < l; i++) {
48+
// convert float32 -1 to 1 to int16 -32768 to 32767
49+
const int16Value = float32Array[i] * 32768;
50+
this.buffer[this.bufferWriteIndex++] = int16Value;
51+
if (this.bufferWriteIndex >= this.buffer.length) {
52+
this.sendAndClearBuffer();
53+
}
54+
}
55+
56+
if (this.bufferWriteIndex >= this.buffer.length) {
57+
this.sendAndClearBuffer();
58+
}
59+
}
60+
}
61+
`;

src/lib/scss/custom/pages/_agent.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100

101101
.agent-prompt-header {
102102
background-color: white;
103-
padding: 20px;
103+
padding: 15px;
104104
}
105105

106106
.agent-prompt-body {

src/lib/services/llm-realtime-service.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { endpoints } from '$lib/services/api-endpoints.js';
22
import { replaceUrl } from '$lib/helpers/http';
33
import axios from 'axios';
4-
import { json } from '@sveltejs/kit';
54

65
export const llmRealtime = {
76
/** @type {RTCPeerConnection} */
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { PUBLIC_SERVICE_URL } from "$env/static/public";
2+
import { AudioRecordingWorklet } from "$lib/helpers/realtime/pcmProcessor";
3+
4+
// @ts-ignore
5+
const AudioContext = window.AudioContext || window.webkitAudioContext;
6+
7+
const sampleRate = 24000;
8+
9+
/** @type {AudioContext} */
10+
let audioCtx = new AudioContext();
11+
12+
/** @type {any[]} */
13+
let audioQueue = [];
14+
15+
/** @type {boolean} */
16+
let isPlaying = false;
17+
18+
/** @type {WebSocket | null} */
19+
let socket = null;
20+
21+
/** @type {MediaStream | null} */
22+
let mediaStream = null;
23+
24+
/** @type {AudioWorkletNode | null} */
25+
let workletNode = null;
26+
27+
/** @type {MediaStreamAudioSourceNode | null} */
28+
let micSource = null;
29+
30+
export const realtimeChat = {
31+
32+
/**
33+
* @param {string} agentId
34+
* @param {string} conversationId
35+
*/
36+
start(agentId, conversationId) {
37+
reset();
38+
const wsUrl = buildWebsocketUrl();
39+
socket = new WebSocket(`${wsUrl}/chat/stream/${agentId}/${conversationId}`);
40+
41+
socket.onopen = async () => {
42+
console.log("WebSocket connected");
43+
44+
socket?.send(JSON.stringify({
45+
event: "start"
46+
}));
47+
48+
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
49+
audioCtx = new AudioContext({ sampleRate: sampleRate });
50+
51+
const workletName = "audio-recorder-worklet";
52+
const src = createWorkletFromSrc(workletName, AudioRecordingWorklet);
53+
await audioCtx.audioWorklet.addModule(src);
54+
55+
workletNode = new AudioWorkletNode(audioCtx, workletName);
56+
micSource = audioCtx.createMediaStreamSource(mediaStream);
57+
micSource.connect(workletNode);
58+
59+
workletNode.port.onmessage = event => {
60+
const arrayBuffer = event.data.data.int16arrayBuffer;
61+
if (arrayBuffer && socket?.readyState === WebSocket.OPEN) {
62+
if (event.data.data.speaking) {
63+
reset();
64+
}
65+
const arrayBufferString = arrayBufferToBase64(arrayBuffer);
66+
socket.send(JSON.stringify({
67+
event: 'media',
68+
body: {
69+
payload: arrayBufferString
70+
}
71+
}));
72+
}
73+
};
74+
};
75+
76+
socket.onmessage = (/** @type {MessageEvent} */ e) => {
77+
try {
78+
const json = JSON.parse(e.data);
79+
if (json.event === 'media' && !!json.media.payload) {
80+
const data = json.media.payload;
81+
enqueueAudioChunk(data);
82+
}
83+
} catch {
84+
// console.error('Error when parsing message');
85+
}
86+
};
87+
88+
socket.onclose = () => {
89+
console.log("Websocket closed");
90+
};
91+
92+
socket.onerror = (/** @type {Event} */ e) => {
93+
console.error('WebSocket error', e);
94+
};
95+
},
96+
97+
stop() {
98+
reset();
99+
100+
if (mediaStream) {
101+
mediaStream.getTracks().forEach(t => t.stop());
102+
mediaStream = null;
103+
}
104+
105+
if (workletNode) {
106+
micSource?.disconnect(workletNode);
107+
workletNode.port.close();
108+
workletNode.disconnect();
109+
micSource = null;
110+
workletNode = null;
111+
}
112+
113+
if (socket?.readyState === WebSocket.OPEN) {
114+
socket.send(JSON.stringify({
115+
event: 'disconnect'
116+
}));
117+
socket.close();
118+
socket = null;
119+
}
120+
}
121+
};
122+
123+
124+
function buildWebsocketUrl() {
125+
let url = '';
126+
const host = PUBLIC_SERVICE_URL.split('://');
127+
128+
if (PUBLIC_SERVICE_URL.startsWith('https')) {
129+
url = `wss:${host[1]}`;
130+
} else if (PUBLIC_SERVICE_URL.startsWith('http')) {
131+
url = `ws:${host[1]}`;
132+
}
133+
134+
return url;
135+
}
136+
137+
function reset() {
138+
isPlaying = false;
139+
audioQueue = [];
140+
}
141+
142+
/**
143+
* @param {string} base64Audio
144+
*/
145+
function enqueueAudioChunk(base64Audio) {
146+
const arrayBuffer = base64ToArrayBuffer(base64Audio);
147+
const float32Data = convert16BitPCMToFloat32(arrayBuffer);
148+
149+
const audioBuffer = audioCtx.createBuffer(1, float32Data.length, sampleRate);
150+
audioBuffer.getChannelData(0).set(float32Data);
151+
audioQueue.push(audioBuffer);
152+
153+
if (!isPlaying) {
154+
playNext();
155+
}
156+
}
157+
158+
function playNext() {
159+
if (audioQueue.length === 0) {
160+
isPlaying = false;
161+
return;
162+
}
163+
164+
isPlaying = true;
165+
const buffer = audioQueue.shift();
166+
167+
const source = audioCtx.createBufferSource();
168+
source.buffer = buffer;
169+
source.connect(audioCtx.destination);
170+
source.onended = () => {
171+
playNext();
172+
};
173+
source.start();
174+
}
175+
176+
177+
/**
178+
* @param {string} workletName
179+
* @param {string} workletSrc
180+
*/
181+
function createWorkletFromSrc(workletName, workletSrc) {
182+
const script = new Blob(
183+
[`registerProcessor("${workletName}", ${workletSrc})`],
184+
{
185+
type: "application/javascript",
186+
},
187+
);
188+
189+
return URL.createObjectURL(script);
190+
};
191+
192+
193+
/**
194+
* @param {ArrayBuffer} buffer
195+
*/
196+
function arrayBufferToBase64(buffer) {
197+
var binary = "";
198+
var bytes = new Uint8Array(buffer);
199+
var len = bytes.byteLength;
200+
for (var i = 0; i < len; i++) {
201+
binary += String.fromCharCode(bytes[i]);
202+
}
203+
return btoa(binary);
204+
};
205+
206+
/**
207+
* @param {string} base64
208+
*/
209+
function base64ToArrayBuffer(base64) {
210+
const binaryStr = atob(base64);
211+
const len = binaryStr.length;
212+
const bytes = new Uint8Array(len);
213+
214+
for (let i = 0; i < len; i++) {
215+
bytes[i] = binaryStr.charCodeAt(i);
216+
}
217+
return bytes.buffer;
218+
};
219+
220+
/**
221+
* @param {ArrayBuffer} buffer
222+
*/
223+
function convert16BitPCMToFloat32(buffer) {
224+
const chunk = new Uint8Array(buffer);
225+
const output = new Float32Array(chunk.length / 2);
226+
const dataView = new DataView(chunk.buffer);
227+
228+
for (let i = 0; i< chunk.length / 2; i++) {
229+
try {
230+
const int16 = dataView.getInt16(i * 2, true);
231+
output[i] = int16 / 32768;
232+
} catch (e) {
233+
console.error(e);
234+
}
235+
}
236+
return output;
237+
};

0 commit comments

Comments
 (0)