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