use-voice-control 0.1.0 → 0.1.2
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/README.md +1 -1
- package/dist/core/deepgram.d.ts +5 -0
- package/dist/core/kokoro.d.ts +5 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/dist/types/types.d.ts +17 -0
- package/package.json +7 -11
- package/speech/core/KokoroTTS.js +91 -0
- package/speech/core/deepgram.ts +56 -0
- package/speech/core/kokoro.js +93 -0
- package/speech/core/kokoro.ts +81 -0
- package/speech/docs/ARCHITECTURE.md +349 -0
- package/speech/docs/HUGGINGFACE_MIGRATION.md +172 -0
- package/speech/docs/INTEGRATION.md +276 -0
- package/speech/docs/MIGRATION.md +220 -0
- package/speech/docs/QUICKSTART.md +159 -0
- package/speech/docs/README.md +167 -0
- package/speech/index.ts +54 -0
- package/speech/legacy/conversation.js +150 -0
- package/speech/legacy/main.js +56 -0
- package/speech/legacy/stt.js +161 -0
- package/speech/legacy/tts.js +56 -0
- package/speech/legacy/worker.js +71 -0
- package/speech/types/types.ts +34 -0
- package/speech/ui/AudioPlayer.js +84 -0
- package/speech/ui/ui.js +109 -0
- package/speech/ui/voice-selector.js +77 -0
- package/speech/ui/voices.js +259 -0
- package/speech/utils/audio-utils.js +82 -0
- package/speech/utils/phonemize.js +198 -0
- package/speech/utils/semantic-split.js +107 -0
- package/speech/utils/sentence-detector.js +89 -0
- package/readme.md +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Speech Library
|
|
2
|
+
|
|
3
|
+
Unified text-to-speech API supporting Kokoro (default) and Deepgram providers.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Kokoro** (default): Fast, natural-sounding voices, runs on Node CPU
|
|
8
|
+
- **Deepgram**: Requires Cloudflare AI binding, returns MP3
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
### API Endpoint
|
|
13
|
+
|
|
14
|
+
**POST** `/api/agent/voice`
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"text": "Hello world",
|
|
19
|
+
"provider": "kokoro",
|
|
20
|
+
"voice": "af_heart"
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Request Body:**
|
|
25
|
+
- `text` (required): Text to convert to speech (max 5000 chars)
|
|
26
|
+
- `provider` (optional): `"kokoro"` (default) or `"deepgram"`
|
|
27
|
+
- `voice` (optional): Voice ID (see below)
|
|
28
|
+
|
|
29
|
+
**Response:** Audio file (WAV for Kokoro, MP3 for Deepgram)
|
|
30
|
+
|
|
31
|
+
### Direct Library Usage
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { generateSpeech } from "@/lib/speech";
|
|
35
|
+
|
|
36
|
+
// Kokoro (default)
|
|
37
|
+
const audio = await generateSpeech({
|
|
38
|
+
text: "Hello world",
|
|
39
|
+
voice: "af_heart"
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Deepgram
|
|
43
|
+
const audio = await generateSpeech({
|
|
44
|
+
text: "Hello world",
|
|
45
|
+
provider: "deepgram",
|
|
46
|
+
voice: "angus"
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Returns
|
|
50
|
+
{
|
|
51
|
+
audio: ArrayBuffer,
|
|
52
|
+
contentType: "audio/wav" | "audio/mpeg"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Voices
|
|
57
|
+
|
|
58
|
+
### Kokoro Voices (Default)
|
|
59
|
+
|
|
60
|
+
**Female:**
|
|
61
|
+
- `af_heart` - Default, warm and natural
|
|
62
|
+
- `af_alloy` - Clear and professional
|
|
63
|
+
- `af_aoede` - Soft and expressive
|
|
64
|
+
- `af_bella` - Bright and friendly
|
|
65
|
+
- `af_jessica` - Calm and steady
|
|
66
|
+
- `af_nicole` - Smooth and confident
|
|
67
|
+
- `af_river` - Cool and relaxed
|
|
68
|
+
- `af_sarah` - Warm and approachable
|
|
69
|
+
- `af_sky` - Light and airy
|
|
70
|
+
|
|
71
|
+
**Male:**
|
|
72
|
+
- `am_adam` - Strong and authoritative
|
|
73
|
+
- `am_echo` - Deep and resonant
|
|
74
|
+
- `am_fable` - Storytelling quality
|
|
75
|
+
- `am_fenrir` - Bold and commanding
|
|
76
|
+
- `am_liam` - Friendly and casual
|
|
77
|
+
- `am_michael` - Professional and clear
|
|
78
|
+
- `am_onyx` - Smooth and sophisticated
|
|
79
|
+
|
|
80
|
+
### Deepgram Aura Voices
|
|
81
|
+
|
|
82
|
+
- `angus` (default), `asteria`, `arcas`, `orion`, `orpheus`, `athena`
|
|
83
|
+
- `luna`, `zeus`, `perseus`, `helios`, `hera`, `stella`
|
|
84
|
+
|
|
85
|
+
## Architecture
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
lib/speech/
|
|
89
|
+
├── index.ts # Main API
|
|
90
|
+
├── types.ts # Shared types
|
|
91
|
+
├── kokoro.ts # Kokoro provider
|
|
92
|
+
└── deepgram.ts # Deepgram provider
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Model Loading
|
|
96
|
+
|
|
97
|
+
Kokoro model is lazy-loaded once per server instance:
|
|
98
|
+
- Model: `onnx-community/Kokoro-82M-v1.0-ONNX`
|
|
99
|
+
- Quantization: `q8` (8-bit)
|
|
100
|
+
- Device: `cpu`
|
|
101
|
+
- First request triggers download (~82MB)
|
|
102
|
+
- Subsequent requests reuse loaded model
|
|
103
|
+
|
|
104
|
+
### Rate Limiting
|
|
105
|
+
|
|
106
|
+
- Guests: 10 requests per day
|
|
107
|
+
- Authenticated users: Unlimited
|
|
108
|
+
- Enforced at API route level
|
|
109
|
+
|
|
110
|
+
## Performance
|
|
111
|
+
|
|
112
|
+
**Kokoro:**
|
|
113
|
+
- Fast CPU inference
|
|
114
|
+
- Natural prosody and intonation
|
|
115
|
+
- WAV output (higher quality)
|
|
116
|
+
- ~100-300ms generation time
|
|
117
|
+
|
|
118
|
+
**Deepgram:**
|
|
119
|
+
- Cloudflare Workers AI
|
|
120
|
+
- Lower latency in edge locations
|
|
121
|
+
- MP3 output (smaller size)
|
|
122
|
+
- Requires CF binding
|
|
123
|
+
|
|
124
|
+
## Error Handling
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
try {
|
|
128
|
+
const audio = await generateSpeech({ text: "..." });
|
|
129
|
+
} catch (error) {
|
|
130
|
+
// "Text is required"
|
|
131
|
+
// "Unknown TTS provider: ..."
|
|
132
|
+
// "Cloudflare AI binding not available"
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Migration from Old API
|
|
137
|
+
|
|
138
|
+
**Before:**
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"text": "Hello",
|
|
142
|
+
"speaker": "angus"
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**After (same behavior):**
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"text": "Hello",
|
|
150
|
+
"provider": "deepgram",
|
|
151
|
+
"voice": "angus"
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Now (Kokoro default):**
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"text": "Hello",
|
|
159
|
+
"voice": "af_heart"
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## References
|
|
164
|
+
|
|
165
|
+
- [kokoro-js npm](https://www.npmjs.com/package/kokoro-js)
|
|
166
|
+
- [Kokoro ONNX Model](https://huggingface.co/onnx-community/Kokoro-82M-v1.0-ONNX)
|
|
167
|
+
- [Deepgram Aura](https://developers.cloudflare.com/workers-ai/models/deepgram-aura/)
|
package/speech/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unified text-to-speech API supporting Kokoro (default) and Deepgram
|
|
3
|
+
*
|
|
4
|
+
* Kokoro: Faster, more natural, runs on Node CPU
|
|
5
|
+
* Deepgram: Requires Cloudflare AI binding, MP3 output
|
|
6
|
+
*/
|
|
7
|
+
import type { TTSOptions, TTSResult } from "./types/types";
|
|
8
|
+
import { generateKokoroSpeech } from "./core/kokoro";
|
|
9
|
+
import { generateDeepgramSpeech } from "./core/deepgram";
|
|
10
|
+
|
|
11
|
+
export * from "./types/types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate speech from text using the specified provider
|
|
15
|
+
*
|
|
16
|
+
* @param options - TTS configuration
|
|
17
|
+
* @returns Audio buffer and content type
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* // Use Kokoro (default)
|
|
22
|
+
* const audio = await generateSpeech({
|
|
23
|
+
* text: "Hello world",
|
|
24
|
+
* voice: "af_heart"
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* // Use Deepgram
|
|
28
|
+
* const audio = await generateSpeech({
|
|
29
|
+
* text: "Hello world",
|
|
30
|
+
* provider: "deepgram",
|
|
31
|
+
* voice: "angus"
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export async function generateSpeech(
|
|
36
|
+
options: TTSOptions
|
|
37
|
+
): Promise<TTSResult> {
|
|
38
|
+
const { text, provider = "kokoro", voice = "af_heart" } = options;
|
|
39
|
+
|
|
40
|
+
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
|
41
|
+
throw new Error("Text is required");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
switch (provider) {
|
|
45
|
+
case "kokoro":
|
|
46
|
+
return generateKokoroSpeech(text, voice);
|
|
47
|
+
|
|
48
|
+
case "deepgram":
|
|
49
|
+
return generateDeepgramSpeech(text, voice);
|
|
50
|
+
|
|
51
|
+
default:
|
|
52
|
+
throw new Error(`Unknown TTS provider: ${provider}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { SpeechToText } from "./stt.js";
|
|
2
|
+
import { textToSpeech, ttsModelReadyPromise } from "./tts.js";
|
|
3
|
+
import { processStreamingText } from "./sentence-detector.js";
|
|
4
|
+
import { displayConversation } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
const $ = document.querySelector.bind(document);
|
|
7
|
+
|
|
8
|
+
export class Conversation {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.modelsReady = false;
|
|
11
|
+
this.speechToText = new SpeechToText();
|
|
12
|
+
this.initModels();
|
|
13
|
+
this.conversationHistory = [
|
|
14
|
+
{
|
|
15
|
+
role: "system",
|
|
16
|
+
content: "placeholder"
|
|
17
|
+
}
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
async initModels() {
|
|
21
|
+
try {
|
|
22
|
+
await Promise.all([
|
|
23
|
+
this.speechToText.modelReadyPromise,
|
|
24
|
+
ttsModelReadyPromise
|
|
25
|
+
]);
|
|
26
|
+
this.modelsReady = true;
|
|
27
|
+
const toggleButton = document.getElementById('toggleRecording');
|
|
28
|
+
toggleButton.disabled = false;
|
|
29
|
+
toggleButton.textContent = 'Start Recording';
|
|
30
|
+
const recordingStatus = document.getElementById('recordingStatus');
|
|
31
|
+
recordingStatus.textContent = 'Models loaded. Click "Start Recording" to begin';
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('Error initializing models:', error);
|
|
34
|
+
const recordingStatus = document.getElementById('recordingStatus');
|
|
35
|
+
recordingStatus.textContent = 'Error loading models: ' + error.message;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
startRecording() {
|
|
40
|
+
if (this.modelsReady) {
|
|
41
|
+
this.speechToText.startRecording();
|
|
42
|
+
} else {
|
|
43
|
+
console.warn('Cannot start recording: models are not yet loaded');
|
|
44
|
+
const recordingStatus = document.getElementById('recordingStatus');
|
|
45
|
+
recordingStatus.textContent = 'Please wait for models to finish loading...';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async stopRecording() {
|
|
50
|
+
let text = await this.speechToText.stopRecording();
|
|
51
|
+
console.log('Transcription:', text)
|
|
52
|
+
$('#transcriptionStatus').textContent = text;
|
|
53
|
+
this.conversationHistory.push({
|
|
54
|
+
role: "user",
|
|
55
|
+
content: text
|
|
56
|
+
});
|
|
57
|
+
await this.sendConversationHistory();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async sendConversationHistory() {
|
|
61
|
+
let serverUrl = $('#serverUrl').value;
|
|
62
|
+
let system_prompt = $('#systemPrompt').value;
|
|
63
|
+
|
|
64
|
+
this.conversationHistory[0].content = system_prompt;
|
|
65
|
+
|
|
66
|
+
const response = await fetch(serverUrl, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
stream: true,
|
|
71
|
+
"messages": this.conversationHistory
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const decoder = new TextDecoder("utf-8");
|
|
76
|
+
const reader = response.body.getReader();
|
|
77
|
+
let accumulatedText = "";
|
|
78
|
+
const voiceSelect = document.getElementById('voiceSelect');
|
|
79
|
+
const voiceId = (voiceSelect && voiceSelect.value) ? voiceSelect.value : "af_heart";
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
while (true) {
|
|
83
|
+
const { value, done } = await reader.read();
|
|
84
|
+
if (done) break;
|
|
85
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
86
|
+
//console.log(chunk);
|
|
87
|
+
|
|
88
|
+
for (const line of chunk.split("\n")) {
|
|
89
|
+
if (line.startsWith("data: ")) {
|
|
90
|
+
const payload = line.slice(6).trim();
|
|
91
|
+
if (payload === "[DONE]") {
|
|
92
|
+
if (accumulatedText.trim().length > 0) {
|
|
93
|
+
textToSpeech(accumulatedText, voiceId);
|
|
94
|
+
this.conversationHistory.push({
|
|
95
|
+
"role": "assistant",
|
|
96
|
+
"content": accumulatedText
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log("\n[Stream complete]");
|
|
101
|
+
displayConversation(this.conversationHistory);
|
|
102
|
+
console.log("response", accumulatedText);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const json = JSON.parse(payload);
|
|
106
|
+
const content = json.choices[0].delta.content || "";
|
|
107
|
+
const result = processStreamingText(accumulatedText, content);
|
|
108
|
+
|
|
109
|
+
// If we have complete sentences, speak them
|
|
110
|
+
if (result.sentences.length > 0) {
|
|
111
|
+
result.sentences.forEach(sentence => {
|
|
112
|
+
textToSpeech(sentence, voiceId);
|
|
113
|
+
this.conversationHistory.push({
|
|
114
|
+
"role": "assistant",
|
|
115
|
+
"content": sentence
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
accumulatedText = result.remainder;
|
|
120
|
+
} else {
|
|
121
|
+
accumulatedText = result.remainder;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// This code will never be reached when streaming is enabled
|
|
127
|
+
// because of the return statement in the "[DONE]" handling block
|
|
128
|
+
console.timeEnd('LLM Processing');
|
|
129
|
+
|
|
130
|
+
// This section is for non-streaming mode
|
|
131
|
+
try {
|
|
132
|
+
const data = await response.json();
|
|
133
|
+
const response_text = data.choices[0].message.content;
|
|
134
|
+
this.conversationHistory.push({
|
|
135
|
+
"role": "assistant",
|
|
136
|
+
"content": response_text
|
|
137
|
+
});
|
|
138
|
+
console.log("response", response_text);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.log("Error parsing response as JSON, likely already processed as a stream.");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
// This was the original call, but now we're streaming per sentence
|
|
145
|
+
// textToSpeech(reponse, "af_heart");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AudioPlayer } from "./AudioPlayer.js";
|
|
2
|
+
|
|
3
|
+
const my_worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
|
4
|
+
|
|
5
|
+
let audioPlayer = new AudioPlayer();
|
|
6
|
+
|
|
7
|
+
// Create a promise that will resolve when the TTS model is ready
|
|
8
|
+
export const ttsModelReadyPromise = new Promise((resolve) => {
|
|
9
|
+
window.ttsModelReadyResolve = resolve;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const onMessageReceived = async (e) => {
|
|
13
|
+
switch (e.data.status) {
|
|
14
|
+
case "ready":
|
|
15
|
+
console.log("TTS model loaded successfully");
|
|
16
|
+
// Resolve the promise to indicate the TTS model is ready
|
|
17
|
+
window.ttsModelReadyResolve();
|
|
18
|
+
break;
|
|
19
|
+
|
|
20
|
+
case "device":
|
|
21
|
+
console.log(e.data);
|
|
22
|
+
break;
|
|
23
|
+
|
|
24
|
+
case "progress":
|
|
25
|
+
break;
|
|
26
|
+
|
|
27
|
+
case "stream":
|
|
28
|
+
console.log("audioPlayer.queueAudio", e.data);
|
|
29
|
+
audioPlayer.queueAudio(e.data.audio);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const onErrorReceived = (e) => { console.error("Worker error:", e); };
|
|
35
|
+
|
|
36
|
+
my_worker.addEventListener("message", onMessageReceived);
|
|
37
|
+
my_worker.addEventListener("error", onErrorReceived);
|
|
38
|
+
|
|
39
|
+
export function textToSpeech(text, voice) {
|
|
40
|
+
if (!text || !voice) {
|
|
41
|
+
console.error("Text and voice parameters are required for text-to-speech.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
text = text.replaceAll("*", "");
|
|
46
|
+
|
|
47
|
+
// Remove any special Markdown formatting for better speech
|
|
48
|
+
text = text.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
|
|
49
|
+
text = text.replace(/\*(.*?)\*/g, '$1'); // Italic
|
|
50
|
+
text = text.replace(/`(.*?)`/g, '$1'); // Code
|
|
51
|
+
text = text.replace(/~~(.*?)~~/g, '$1'); // Strikethrough
|
|
52
|
+
|
|
53
|
+
my_worker.postMessage({ type: "generate", text: text, voice: voice });
|
|
54
|
+
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { pipeline } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js";
|
|
2
|
+
|
|
3
|
+
import { convertAudioBufferToWav, resampleAudio, applyAudioGain } from "./audio-utils.js";
|
|
4
|
+
|
|
5
|
+
let mediaRecorder;
|
|
6
|
+
let audioChunks = [];
|
|
7
|
+
let mode;
|
|
8
|
+
let wav;
|
|
9
|
+
|
|
10
|
+
async function detectWebGPU() {
|
|
11
|
+
try {
|
|
12
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
13
|
+
return !!adapter;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SpeechToText {
|
|
20
|
+
|
|
21
|
+
constructor() {
|
|
22
|
+
// Create a promise that will resolve when the model is loaded
|
|
23
|
+
this.modelReadyPromise = new Promise((resolve, reject) => {
|
|
24
|
+
this._modelReadyResolve = resolve;
|
|
25
|
+
this._modelReadyReject = reject;
|
|
26
|
+
});
|
|
27
|
+
this.initialize();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async initialize() {
|
|
32
|
+
try {
|
|
33
|
+
const isWebGPUSupported = await detectWebGPU();
|
|
34
|
+
const device = isWebGPUSupported ? "webgpu" : "wasm";
|
|
35
|
+
const dtype = isWebGPUSupported ? "fp32" : "q8";
|
|
36
|
+
const options = {
|
|
37
|
+
device: device,
|
|
38
|
+
dtype: dtype,
|
|
39
|
+
quantized: !isWebGPUSupported,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
this.transcriber = await pipeline(
|
|
43
|
+
'automatic-speech-recognition',
|
|
44
|
+
'onnx-community/moonshine-base-ONNX', // 'onnx-community/whisper-large-v3-turbo',
|
|
45
|
+
options
|
|
46
|
+
);
|
|
47
|
+
console.log('Speech-to-text model loaded successfully');
|
|
48
|
+
// Resolve the promise to indicate the model is ready
|
|
49
|
+
this._modelReadyResolve();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Error loading speech-to-text model:', error);
|
|
52
|
+
this._modelReadyReject(error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
startRecording() {
|
|
58
|
+
navigator.mediaDevices.getUserMedia({ audio: true })
|
|
59
|
+
.then(stream => {
|
|
60
|
+
mode = "WAV";
|
|
61
|
+
const options = { mimeType: 'audio/wav' };
|
|
62
|
+
try {
|
|
63
|
+
mediaRecorder = new MediaRecorder(stream, options);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
//console.info('WAV format not supported, using default format.');
|
|
66
|
+
mediaRecorder = new MediaRecorder(stream);
|
|
67
|
+
mode = "OGG";
|
|
68
|
+
}
|
|
69
|
+
audioChunks = [];
|
|
70
|
+
mediaRecorder.ondataavailable = event => {
|
|
71
|
+
audioChunks.push(event.data);
|
|
72
|
+
};
|
|
73
|
+
mediaRecorder.start();
|
|
74
|
+
})
|
|
75
|
+
.catch(error => {
|
|
76
|
+
console.error('Error accessing microphone:', error);
|
|
77
|
+
recordingStatus.textContent = 'Error accessing microphone: ' + error.message;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async stopRecording() {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
|
86
|
+
mediaRecorder.stop();
|
|
87
|
+
mediaRecorder.onstop = async () => {
|
|
88
|
+
try {
|
|
89
|
+
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
|
90
|
+
// Create blob with WAV MIME type
|
|
91
|
+
let type = { type: 'audio/webm;codecs=opus' }
|
|
92
|
+
if (mode === "WAV") {
|
|
93
|
+
type = { type: 'audio/wav' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (mode === "WAV") {
|
|
97
|
+
console.log('WAV format is already selected.');
|
|
98
|
+
} else {
|
|
99
|
+
console.info('Converting audio to WAV format...');
|
|
100
|
+
const audioContext = new AudioContext();
|
|
101
|
+
let audioBlob = new Blob(audioChunks, { type: type });
|
|
102
|
+
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
103
|
+
//console.log("arrayBuffer", arrayBuffer)
|
|
104
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
105
|
+
//console.log('Audio buffer decoded:', audioBuffer);
|
|
106
|
+
//wav = audioBuffer.getChannelData(0); // Float32Array of first channel
|
|
107
|
+
//console.log(wav)
|
|
108
|
+
wav = convertAudioBufferToWav(audioBuffer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//wav = await resampleAudio(wav, 16000);
|
|
112
|
+
//const output = await transcriber(wav);
|
|
113
|
+
|
|
114
|
+
let wavBlob = new Blob([wav], { type: 'audio/wav' });
|
|
115
|
+
let wavBlobUrl = URL.createObjectURL(wavBlob);
|
|
116
|
+
|
|
117
|
+
const playbackStatus = document.getElementById('playbackStatus');
|
|
118
|
+
const audioPlayback = document.getElementById('audioPlayback');
|
|
119
|
+
audioPlayback.src = wavBlobUrl;
|
|
120
|
+
audioPlayback.style.display = 'block';
|
|
121
|
+
playbackStatus.textContent = 'Audio ready for playback:';
|
|
122
|
+
|
|
123
|
+
let output = await this.transcriber(wavBlobUrl);
|
|
124
|
+
if (output.text === undefined || output.text.length == 0) {
|
|
125
|
+
console.log('Trying transcription again 1...');
|
|
126
|
+
|
|
127
|
+
// Load audio and apply gain
|
|
128
|
+
wav = await convertAudioBufferToWav(await (async () => {
|
|
129
|
+
const audioContext = new AudioContext();
|
|
130
|
+
const arrayBuffer = await (await fetch(wavBlobUrl)).arrayBuffer();
|
|
131
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
132
|
+
// Use the applyAudioGain function to increase volume
|
|
133
|
+
return applyAudioGain(audioBuffer, 1.5);
|
|
134
|
+
})());
|
|
135
|
+
wavBlob = new Blob([wav], { type: 'audio/wav' });
|
|
136
|
+
wavBlobUrl = URL.createObjectURL(wavBlob);
|
|
137
|
+
audioPlayback.src = wavBlobUrl;
|
|
138
|
+
output = await this.transcriber(wavBlobUrl);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (output.text === undefined || output.text.length == 0) {
|
|
142
|
+
console.log('Trying transcription again 2...');
|
|
143
|
+
output = await this.transcriber(wavBlobUrl);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log('Transcription output 1:', output);
|
|
147
|
+
resolve(output.text);
|
|
148
|
+
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error('Error during transcription:', error);
|
|
151
|
+
reject(error);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
} else {
|
|
155
|
+
reject(new Error("MediaRecorder is not active"));
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AudioPlayer } from "./AudioPlayer.js";
|
|
2
|
+
|
|
3
|
+
const my_worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
|
4
|
+
|
|
5
|
+
let audioPlayer = new AudioPlayer();
|
|
6
|
+
|
|
7
|
+
// Create a promise that will resolve when the TTS model is ready
|
|
8
|
+
export const ttsModelReadyPromise = new Promise((resolve) => {
|
|
9
|
+
window.ttsModelReadyResolve = resolve;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const onMessageReceived = async (e) => {
|
|
13
|
+
switch (e.data.status) {
|
|
14
|
+
case "ready":
|
|
15
|
+
console.log("TTS model loaded successfully");
|
|
16
|
+
// Resolve the promise to indicate the TTS model is ready
|
|
17
|
+
window.ttsModelReadyResolve();
|
|
18
|
+
break;
|
|
19
|
+
|
|
20
|
+
case "device":
|
|
21
|
+
console.log(e.data);
|
|
22
|
+
break;
|
|
23
|
+
|
|
24
|
+
case "progress":
|
|
25
|
+
break;
|
|
26
|
+
|
|
27
|
+
case "stream":
|
|
28
|
+
console.log("audioPlayer.queueAudio", e.data);
|
|
29
|
+
audioPlayer.queueAudio(e.data.audio);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const onErrorReceived = (e) => { console.error("Worker error:", e); };
|
|
35
|
+
|
|
36
|
+
my_worker.addEventListener("message", onMessageReceived);
|
|
37
|
+
my_worker.addEventListener("error", onErrorReceived);
|
|
38
|
+
|
|
39
|
+
export function textToSpeech(text, voice) {
|
|
40
|
+
if (!text || !voice) {
|
|
41
|
+
console.error("Text and voice parameters are required for text-to-speech.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
text = text.replaceAll("*", "");
|
|
46
|
+
|
|
47
|
+
// Remove any special Markdown formatting for better speech
|
|
48
|
+
text = text.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
|
|
49
|
+
text = text.replace(/\*(.*?)\*/g, '$1'); // Italic
|
|
50
|
+
text = text.replace(/`(.*?)`/g, '$1'); // Code
|
|
51
|
+
text = text.replace(/~~(.*?)~~/g, '$1'); // Strikethrough
|
|
52
|
+
|
|
53
|
+
my_worker.postMessage({ type: "generate", text: text, voice: voice });
|
|
54
|
+
|
|
55
|
+
return text;
|
|
56
|
+
}
|