Skip to content

Commit 8e338ff

Browse files
committed
Significantly simplify and clean up typescript example, implements new EVIWebAudioPlayer
1 parent b656926 commit 8e338ff

File tree

11 files changed

+421
-496
lines changed

11 files changed

+421
-496
lines changed

evi/evi-typescript-quickstart/index.html

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,25 @@
77
<title>Empathic Voice Interface</title>
88
</head>
99
<body>
10-
<div id="app">
11-
<div id="btn-container">
12-
<button id="start-btn">Start</button>
13-
<button id="stop-btn" disabled="true">Stop</button>
14-
</div>
15-
<div id="heading-container">
16-
<h2>Empathic Voice Interface (EVI)</h2>
17-
<p>
18-
Welcome to our TypeScript sample implementation of the Empathic Voice Interface!
19-
Click the "Start" button and begin talking to interact with EVI.
20-
</p>
21-
</div>
22-
<div id="chat"></div>
23-
</div>
10+
<main id="app">
11+
<header id="heading-container">
12+
<div id="instructions-container">
13+
<h1>EVI TypeScript Quickstart</h2>
14+
<p id="instructions">
15+
Click <strong>Start</strong> to connect, grant mic access, then speak.
16+
Click <strong>Stop</strong> to end the session. </br>
17+
⚙️ Open your browser console to see socket logs and errors.
18+
</p>
19+
</div>
20+
<div id="btn-container">
21+
<button id="start-btn">Start</button>
22+
<button id="stop-btn" disabled="true">Stop</button>
23+
</div>
24+
</header>
25+
26+
<section id="chat"></section>
27+
</main>
28+
2429
<script type="module" src="/src/main.ts"></script>
2530
</body>
2631
</html>

evi/evi-typescript-quickstart/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"hume": "^0.11.0"
1313
},
1414
"devDependencies": {
15+
"@types/node": "^22.15.18",
1516
"typescript": "^5.2.2",
1617
"vite": "^5.1.4"
1718
},

evi/evi-typescript-quickstart/pnpm-lock.yaml

Lines changed: 18 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { convertBlobToBase64, ensureSingleValidAudioTrack, getAudioStream } from "hume";
2+
import type { MimeType } from "hume";
3+
import type { ChatSocket } from "hume/api/resources/empathicVoice/resources/chat";
4+
5+
/**
6+
* Begins capturing microphone audio and streams it into the given EVI ChatSocket.
7+
*
8+
* This function:
9+
* 1. Prompts the user for microphone access and obtains a single valid audio track.
10+
* 2. Creates a MediaRecorder using the specified MIME type.
11+
* 3. Slices the audio into blobs at the given interval, converts each blob to a base64 string,
12+
* and sends it over the provided WebSocket-like ChatSocket via `socket.sendAudioInput`.
13+
* 4. Logs any recorder errors to the console.
14+
*
15+
* @param socket - The Hume EVI ChatSocket to which encoded audio frames will be sent.
16+
* @param mimeType - The audio MIME type to use for the MediaRecorder (e.g., WEBM, OGG).
17+
* @param timeSliceMs - How often (in milliseconds) to emit audio blobs. Defaults to 80ms.
18+
*
19+
* @returns A MediaRecorder instance controlling the ongoing microphone capture.
20+
* Call `.stop()` on it to end streaming.
21+
*
22+
* @throws {DOMException} If the user denies microphone access or if no audio track is available.
23+
* @throws {Error} If MediaRecorder cannot be constructed with the given MIME type.
24+
*/
25+
export async function startAudioCapture(
26+
socket: ChatSocket,
27+
mimeType: MimeType,
28+
timeSliceMs = 80
29+
): Promise<MediaRecorder> {
30+
const micAudioStream = await getAudioStream();
31+
ensureSingleValidAudioTrack(micAudioStream);
32+
33+
const recorder = new MediaRecorder(micAudioStream, { mimeType });
34+
recorder.ondataavailable = async (e: BlobEvent) => {
35+
if (e.data.size > 0 && socket.readyState === WebSocket.OPEN) {
36+
const data = await convertBlobToBase64(e.data);
37+
socket.sendAudioInput({ data });
38+
}
39+
};
40+
recorder.onerror = (e) => console.error("MediaRecorder error:", e);
41+
recorder.start(timeSliceMs);
42+
43+
return recorder;
44+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { HumeClient } from "hume";
2+
import type { ChatSocket } from "hume/api/resources/empathicVoice/resources/chat";
3+
4+
let client: HumeClient | null = null;
5+
6+
function getClient(apiKey: string): HumeClient {
7+
if (!client) {
8+
client = new HumeClient({ apiKey });
9+
}
10+
return client;
11+
}
12+
13+
/**
14+
* Initializes and opens an Empathic Voice Interface (EVI) ChatSocket.
15+
*
16+
* This function ensures a singleton HumeClient is created using the provided API key,
17+
* then connects to the EVI WebSocket endpoint (optionally with a specific config ID),
18+
* and registers your event handlers for the socket's lifecycle events.
19+
*
20+
* @param apiKey Your Hume API key. Must be a non-empty string.
21+
* @param handlers Callback handlers for socket events:
22+
* - open: Invoked when the connection is successfully established.
23+
* - message: Invoked for each incoming SubscribeEvent.
24+
* - error: Invoked on transport or protocol errors.
25+
* - close: Invoked when the socket is closed.
26+
* @param configId (Optional) EVI configuration ID to apply; if omitted, default EVI configuration is used.
27+
*
28+
* @returns The connected ChatSocket instance, ready for sending and receiving audio/text messages.
29+
*
30+
* @throws {Error} If `apiKey` is falsy or an empty string.
31+
*/
32+
export function connectEVI(
33+
apiKey: string,
34+
handlers: ChatSocket.EventHandlers,
35+
configId?: string
36+
): ChatSocket {
37+
if (!apiKey) {
38+
throw new Error("VITE_HUME_API_KEY is not set.");
39+
}
40+
41+
const client = getClient(apiKey);
42+
const socket = client.empathicVoice.chat.connect({ configId });
43+
44+
socket.on("open", handlers.open);
45+
socket.on("message", handlers.message);
46+
socket.on("error", handlers.error);
47+
socket.on("close", handlers.close);
48+
49+
return socket;
50+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { AssistantMessage, UserMessage } from "hume/api/resources/empathicVoice";
2+
3+
/**
4+
* Extracts and returns the top three emotion scores from a prosody analysis.
5+
*
6+
* This function pulls the `scores` object out of the `message.models.prosody`
7+
* (if available), converts it into an array of `[emotion, numericScore]` entries,
8+
* sorts that array in descending order by score, and then returns the top three
9+
* as objects with the emotion name and a stringified score (two decimal places).
10+
*
11+
* @param message A `UserMessage` or `AssistantMessage` containing `models.prosody.scores`
12+
* where keys are emotion labels and values are numeric scores.
13+
* @returns An array of up to three `{ emotion, score }` objects, sorted by highest score first.
14+
* The `score` property is formatted as a string with exactly two decimal places.
15+
*/
16+
function extractTopThreeEmotions(
17+
message: UserMessage | AssistantMessage
18+
): { emotion: string; score: string }[] {
19+
const scores = message.models.prosody?.scores;
20+
const scoresArray = Object.entries(scores || {});
21+
22+
scoresArray.sort((a, b) => b[1] - a[1]);
23+
24+
const topThreeEmotions = scoresArray.slice(0, 3).map(([emotion, score]) => ({
25+
emotion,
26+
score: Number(score).toFixed(2),
27+
}));
28+
29+
return topThreeEmotions;
30+
}
31+
32+
/**
33+
* Renders a chat bubble for the given message into the specified container and scrolls the
34+
* container to show the newest entry.
35+
*
36+
* @param container The chat container that holds chat messages.
37+
* @param msg A UserMessage or AssistantMessage, including text content and prosody scores.
38+
*/
39+
export function appendChat(
40+
container: HTMLElement | null,
41+
msg: UserMessage | AssistantMessage
42+
): void {
43+
if (!container) return;
44+
45+
const { role, content } = msg.message;
46+
const timestamp = new Date().toLocaleTimeString();
47+
48+
const card = document.createElement("div");
49+
card.className = `chat-card ${role}`;
50+
51+
card.innerHTML = `
52+
<div class="role">${role[0].toUpperCase() + role.slice(1)}</div>
53+
<div class="timestamp"><strong>${timestamp}</strong></div>
54+
<div class="content">${content}</div>
55+
`;
56+
57+
const scoresEl = document.createElement("div");
58+
scoresEl.className = "scores";
59+
60+
const topEmotions = extractTopThreeEmotions(msg);
61+
topEmotions.forEach(({ emotion, score }) => {
62+
const item = document.createElement("div");
63+
item.className = "score-item";
64+
item.innerHTML = `${emotion}: <strong>${score}</strong>`;
65+
scoresEl.appendChild(item);
66+
});
67+
68+
card.appendChild(scoresEl);
69+
container.appendChild(card);
70+
container.scrollTop = container.scrollHeight;
71+
}

0 commit comments

Comments
 (0)