Audio.svelte 8.17 KB
Newer Older
Timothy J. Baek's avatar
Timothy J. Baek committed
1
<script lang="ts">
2
	import { getAudioConfig, updateAudioConfig } from '$lib/apis/audio';
3
	import { createEventDispatcher, onMount, getContext } from 'svelte';
Jannik Streidl's avatar
Jannik Streidl committed
4
	import { toast } from 'svelte-sonner';
Timothy J. Baek's avatar
Timothy J. Baek committed
5
6
	const dispatch = createEventDispatcher();

7
8
	const i18n = getContext('i18n');

Timothy J. Baek's avatar
Timothy J. Baek committed
9
10
	export let saveSettings: Function;

Timothy J. Baek's avatar
Timothy J. Baek committed
11
	// Audio
Timothy J. Baek's avatar
Timothy J. Baek committed
12

13
14
15
	let OpenAIUrl = '';
	let OpenAIKey = '';

Timothy J. Baek's avatar
Timothy J. Baek committed
16
17
18
	let STTEngines = ['', 'openai'];
	let STTEngine = '';

Timothy J. Baek's avatar
Timothy J. Baek committed
19
	let conversationMode = false;
Timothy J. Baek's avatar
Timothy J. Baek committed
20
21
22
	let speechAutoSend = false;
	let responseAutoPlayback = false;

Timothy J. Baek's avatar
Timothy J. Baek committed
23
24
	let TTSEngines = ['', 'openai'];
	let TTSEngine = '';
Timothy J. Baek's avatar
Timothy J. Baek committed
25
26
27

	let voices = [];
	let speaker = '';
Timothy J. Baek's avatar
Timothy J. Baek committed
28

Timothy J. Baek's avatar
Timothy J. Baek committed
29
30
31
32
33
34
35
36
37
38
	const getOpenAIVoices = () => {
		voices = [
			{ name: 'alloy' },
			{ name: 'echo' },
			{ name: 'fable' },
			{ name: 'onyx' },
			{ name: 'nova' },
			{ name: 'shimmer' }
		];
	};
Timothy J. Baek's avatar
Timothy J. Baek committed
39

Timothy J. Baek's avatar
Timothy J. Baek committed
40
	const getWebAPIVoices = () => {
Timothy J. Baek's avatar
Timothy J. Baek committed
41
		const getVoicesLoop = setInterval(async () => {
Timothy J. Baek's avatar
Timothy J. Baek committed
42
			voices = await speechSynthesis.getVoices();
Timothy J. Baek's avatar
Timothy J. Baek committed
43
44

			// do your loop
Timothy J. Baek's avatar
Timothy J. Baek committed
45
			if (voices.length > 0) {
Timothy J. Baek's avatar
Timothy J. Baek committed
46
47
48
				clearInterval(getVoicesLoop);
			}
		}, 100);
Timothy J. Baek's avatar
Timothy J. Baek committed
49
50
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
51
52
	const toggleConversationMode = async () => {
		conversationMode = !conversationMode;
Timothy J. Baek's avatar
Timothy J. Baek committed
53
54
55
56
57
58
59
60
61
62
63

		if (conversationMode) {
			responseAutoPlayback = true;
			speechAutoSend = true;
		}

		saveSettings({
			conversationMode: conversationMode,
			responseAutoPlayback: responseAutoPlayback,
			speechAutoSend: speechAutoSend
		});
Timothy J. Baek's avatar
Timothy J. Baek committed
64
65
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
66
67
68
69
70
71
72
73
74
75
	const toggleResponseAutoPlayback = async () => {
		responseAutoPlayback = !responseAutoPlayback;
		saveSettings({ responseAutoPlayback: responseAutoPlayback });
	};

	const toggleSpeechAutoSend = async () => {
		speechAutoSend = !speechAutoSend;
		saveSettings({ speechAutoSend: speechAutoSend });
	};

76
77
78
79
80
81
82
83
84
85
86
87
	const updateConfigHandler = async () => {
		const res = await updateAudioConfig(localStorage.token, {
			url: OpenAIUrl,
			key: OpenAIKey
		});

		if (res) {
			OpenAIUrl = res.OPENAI_API_BASE_URL;
			OpenAIKey = res.OPENAI_API_KEY;
		}
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
88
89
90
	onMount(async () => {
		let settings = JSON.parse(localStorage.getItem('settings') ?? '{}');

Timothy J. Baek's avatar
Timothy J. Baek committed
91
		conversationMode = settings.conversationMode ?? false;
Timothy J. Baek's avatar
Timothy J. Baek committed
92
93
94
		speechAutoSend = settings.speechAutoSend ?? false;
		responseAutoPlayback = settings.responseAutoPlayback ?? false;

Timothy J. Baek's avatar
Timothy J. Baek committed
95
96
97
		STTEngine = settings?.audio?.STTEngine ?? '';
		TTSEngine = settings?.audio?.TTSEngine ?? '';
		speaker = settings?.audio?.speaker ?? '';
Timothy J. Baek's avatar
Timothy J. Baek committed
98

Timothy J. Baek's avatar
Timothy J. Baek committed
99
		if (TTSEngine === 'openai') {
Timothy J. Baek's avatar
Timothy J. Baek committed
100
101
102
103
			getOpenAIVoices();
		} else {
			getWebAPIVoices();
		}
104
105
106
107
108
109
110

		const res = await getAudioConfig(localStorage.token);

		if (res) {
			OpenAIUrl = res.OPENAI_API_BASE_URL;
			OpenAIKey = res.OPENAI_API_KEY;
		}
Timothy J. Baek's avatar
Timothy J. Baek committed
111
112
113
114
115
	});
</script>

<form
	class="flex flex-col h-full justify-between space-y-3 text-sm"
116
117
	on:submit|preventDefault={async () => {
		await updateConfigHandler();
Timothy J. Baek's avatar
Timothy J. Baek committed
118
		saveSettings({
Timothy J. Baek's avatar
Timothy J. Baek committed
119
			audio: {
Timothy J. Baek's avatar
Timothy J. Baek committed
120
121
				STTEngine: STTEngine !== '' ? STTEngine : undefined,
				TTSEngine: TTSEngine !== '' ? TTSEngine : undefined,
Timothy J. Baek's avatar
Timothy J. Baek committed
122
123
				speaker: speaker !== '' ? speaker : undefined
			}
Timothy J. Baek's avatar
Timothy J. Baek committed
124
125
126
127
		});
		dispatch('save');
	}}
>
128
	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]">
Timothy J. Baek's avatar
Timothy J. Baek committed
129
		<div>
130
			<div class=" mb-1 text-sm font-medium">{$i18n.t('STT Settings')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
131
132

			<div class=" py-0.5 flex w-full justify-between">
133
				<div class=" self-center text-xs font-medium">{$i18n.t('Speech-to-Text Engine')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
134
135
				<div class="flex items-center relative">
					<select
Jannik Streidl's avatar
Jannik Streidl committed
136
						class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
Timothy J. Baek's avatar
Timothy J. Baek committed
137
						bind:value={STTEngine}
Timothy J. Baek's avatar
Timothy J. Baek committed
138
139
						placeholder="Select a mode"
						on:change={(e) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
140
141
							if (e.target.value !== '') {
								navigator.mediaDevices.getUserMedia({ audio: true }).catch(function (err) {
Ased Mammad's avatar
Ased Mammad committed
142
143
144
145
146
									toast.error(
										$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
											error: err
										})
									);
Timothy J. Baek's avatar
Timothy J. Baek committed
147
148
									STTEngine = '';
								});
Timothy J. Baek's avatar
Timothy J. Baek committed
149
150
151
							}
						}}
					>
152
153
						<option value="">{$i18n.t('Default (Web API)')}</option>
						<option value="whisper-local">{$i18n.t('Whisper (Local)')}</option>
Timothy J. Baek's avatar
Timothy J. Baek committed
154
155
156
157
158
					</select>
				</div>
			</div>

			<div class=" py-0.5 flex w-full justify-between">
159
				<div class=" self-center text-xs font-medium">{$i18n.t('Conversation Mode')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
160
161
162
163
164
165
166
167
168

				<button
					class="p-1 px-3 text-xs flex rounded transition"
					on:click={() => {
						toggleConversationMode();
					}}
					type="button"
				>
					{#if conversationMode === true}
169
						<span class="ml-2 self-center">{$i18n.t('On')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
170
					{:else}
171
						<span class="ml-2 self-center">{$i18n.t('Off')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
172
173
174
175
176
					{/if}
				</button>
			</div>

			<div class=" py-0.5 flex w-full justify-between">
177
178
179
				<div class=" self-center text-xs font-medium">
					{$i18n.t('Auto-send input after 3 sec.')}
				</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
180
181
182
183
184
185
186
187
188

				<button
					class="p-1 px-3 text-xs flex rounded transition"
					on:click={() => {
						toggleSpeechAutoSend();
					}}
					type="button"
				>
					{#if speechAutoSend === true}
189
						<span class="ml-2 self-center">{$i18n.t('On')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
190
					{:else}
191
						<span class="ml-2 self-center">{$i18n.t('Off')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
192
193
194
					{/if}
				</button>
			</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
195
196
197
		</div>

		<div>
198
			<div class=" mb-1 text-sm font-medium">{$i18n.t('TTS Settings')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
199
200

			<div class=" py-0.5 flex w-full justify-between">
201
				<div class=" self-center text-xs font-medium">{$i18n.t('Text-to-Speech Engine')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
202
203
				<div class="flex items-center relative">
					<select
Jannik Streidl's avatar
Jannik Streidl committed
204
						class=" dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
Timothy J. Baek's avatar
Timothy J. Baek committed
205
206
207
208
209
210
211
212
213
214
215
216
						bind:value={TTSEngine}
						placeholder="Select a mode"
						on:change={(e) => {
							if (e.target.value === 'openai') {
								getOpenAIVoices();
								speaker = 'alloy';
							} else {
								getWebAPIVoices();
								speaker = '';
							}
						}}
					>
217
218
						<option value="">{$i18n.t('Default (Web API)')}</option>
						<option value="openai">{$i18n.t('Open AI')}</option>
Timothy J. Baek's avatar
Timothy J. Baek committed
219
220
221
					</select>
				</div>
			</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
222

223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
			{#if TTSEngine === 'openai'}
				<div class="mt-1 flex gap-2 mb-1">
					<input
						class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
						placeholder={$i18n.t('API Base URL')}
						bind:value={OpenAIUrl}
						required
					/>

					<input
						class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
						placeholder={$i18n.t('API Key')}
						bind:value={OpenAIKey}
						required
					/>
				</div>
			{/if}

Timothy J. Baek's avatar
Timothy J. Baek committed
241
			<div class=" py-0.5 flex w-full justify-between">
242
				<div class=" self-center text-xs font-medium">{$i18n.t('Auto-playback response')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
243
244
245
246
247

				<button
					class="p-1 px-3 text-xs flex rounded transition"
					on:click={() => {
						toggleResponseAutoPlayback();
Timothy J. Baek's avatar
Timothy J. Baek committed
248
					}}
Timothy J. Baek's avatar
Timothy J. Baek committed
249
					type="button"
Timothy J. Baek's avatar
Timothy J. Baek committed
250
				>
Timothy J. Baek's avatar
Timothy J. Baek committed
251
					{#if responseAutoPlayback === true}
252
						<span class="ml-2 self-center">{$i18n.t('On')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
253
					{:else}
254
						<span class="ml-2 self-center">{$i18n.t('Off')}</span>
Timothy J. Baek's avatar
Timothy J. Baek committed
255
256
					{/if}
				</button>
Timothy J. Baek's avatar
Timothy J. Baek committed
257
258
259
260
261
			</div>
		</div>

		<hr class=" dark:border-gray-700" />

Timothy J. Baek's avatar
Timothy J. Baek committed
262
		{#if TTSEngine === ''}
Timothy J. Baek's avatar
Timothy J. Baek committed
263
			<div>
264
				<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Voice')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
265
266
267
268
				<div class="flex w-full">
					<div class="flex-1">
						<select
							class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
Timothy J. Baek's avatar
Timothy J. Baek committed
269
							bind:value={speaker}
Timothy J. Baek's avatar
Timothy J. Baek committed
270
271
							placeholder="Select a voice"
						>
Ased Mammad's avatar
Ased Mammad committed
272
							<option value="" selected>{$i18n.t('Default')}</option>
Timothy J. Baek's avatar
Timothy J. Baek committed
273
							{#each voices.filter((v) => v.localService === true) as voice}
Timothy J. Baek's avatar
Timothy J. Baek committed
274
275
276
277
278
279
280
								<option value={voice.name} class="bg-gray-100 dark:bg-gray-700">{voice.name}</option
								>
							{/each}
						</select>
					</div>
				</div>
			</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
281
		{:else if TTSEngine === 'openai'}
Timothy J. Baek's avatar
Timothy J. Baek committed
282
			<div>
283
				<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Voice')}</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
284
285
				<div class="flex w-full">
					<div class="flex-1">
286
287
288
						<input
							list="voice-list"
							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
Timothy J. Baek's avatar
Timothy J. Baek committed
289
290
							bind:value={speaker}
							placeholder="Select a voice"
291
292
293
						/>

						<datalist id="voice-list">
Timothy J. Baek's avatar
Timothy J. Baek committed
294
							{#each voices as voice}
295
								<option value={voice.name} />
Timothy J. Baek's avatar
Timothy J. Baek committed
296
							{/each}
297
						</datalist>
Timothy J. Baek's avatar
Timothy J. Baek committed
298
299
300
					</div>
				</div>
			</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
301
		{/if}
Timothy J. Baek's avatar
Timothy J. Baek committed
302
303
304
305
	</div>

	<div class="flex justify-end pt-3 text-sm font-medium">
		<button
Timothy J. Baek's avatar
Timothy J. Baek committed
306
			class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
Timothy J. Baek's avatar
Timothy J. Baek committed
307
308
			type="submit"
		>
Jannik Streidl's avatar
Jannik Streidl committed
309
			{$i18n.t('Save')}
Timothy J. Baek's avatar
Timothy J. Baek committed
310
311
312
		</button>
	</div>
</form>