@hanzo/rooms 0.1.3 → 0.1.5
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/dist/Chat.js +57 -53
- package/dist/Meet.js +91 -148
- package/dist/RightPane.js +21 -30
- package/dist/cast.d.ts +17 -14
- package/dist/cast.js +39 -49
- package/dist/lib/attach.d.ts +19 -14
- package/dist/lib/attach.js +12 -9
- package/dist/lib/limits.d.ts +9 -0
- package/dist/lib/limits.js +6 -0
- package/dist/pane.d.ts +31 -4
- package/dist/pane.js +59 -11
- package/dist/speech.d.ts +23 -1
- package/dist/speech.js +70 -79
- package/package.json +6 -6
package/dist/speech.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
// Agent Speech
|
|
3
|
+
// Agent Speech
|
|
4
4
|
//
|
|
5
5
|
// Every agent in the Hanzo ecosystem has an assigned voice (`voiceOf(name)` from
|
|
6
|
-
// `cast.tsx`)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// `cast.tsx`), and the platform reads it: `/v1/audio/speech` through
|
|
7
|
+
// @hanzo/voice's `speech()` transport and `mouth()`, in that voice. The
|
|
8
|
+
// browser's own speechSynthesis reads only when the platform refuses, in a
|
|
9
|
+
// voice of the same register and accent, and the refusal is reported rather
|
|
10
|
+
// than passed off as the platform's voice.
|
|
11
|
+
//
|
|
12
|
+
// - The Listen button on a message, the Meet speaker test and its briefing
|
|
9
13
|
// - Audio wave visualization during playback
|
|
10
14
|
// - Clean cancellation and state management
|
|
11
15
|
// - Text normalization (strips code fences, markdown syntax so speech sounds natural)
|
|
@@ -13,7 +17,10 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
|
|
13
17
|
import { Volume2, Square } from 'lucide-react';
|
|
14
18
|
import { View, XStack } from '@hanzo/gui';
|
|
15
19
|
import { Button } from '@hanzo/ui';
|
|
20
|
+
import { useIamToken } from '@hanzo/iam/react';
|
|
21
|
+
import { mouth, speech, REFUSED } from '@hanzo/voice';
|
|
16
22
|
import { GOOD } from './lib/mix.js';
|
|
23
|
+
import { base } from './lib/ai.js';
|
|
17
24
|
import { voiceOf, voiceProfileOf } from './cast.js';
|
|
18
25
|
/** Strips code blocks, links, headers, and markdown markup for natural audio speech. */
|
|
19
26
|
export function cleanForSpeech(raw) {
|
|
@@ -41,24 +48,41 @@ export function cleanForSpeech(raw) {
|
|
|
41
48
|
.replace(/\s+/g, ' ')
|
|
42
49
|
.trim();
|
|
43
50
|
}
|
|
44
|
-
/**
|
|
45
|
-
|
|
51
|
+
/** Browser voice names by register, for the stand-in. Word-bounded: "Female" holds "male". */
|
|
52
|
+
const REGISTER = {
|
|
53
|
+
female: /\b(female|samantha|victoria|karen|zira|serena|moira|tessa)\b/i,
|
|
54
|
+
male: /\b(male|daniel|alex|fred|david|arthur|oliver)\b/i,
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The browser voice that reads when the platform refuses: English, the cast
|
|
58
|
+
* voice's accent where this browser has it, its register where a name says so.
|
|
59
|
+
* Read per sentence, because the browser loads its voice list asynchronously.
|
|
60
|
+
*/
|
|
61
|
+
export function standIn({ gender, accent }, voices) {
|
|
62
|
+
const english = voices.filter((v) => v.lang.toLowerCase().startsWith('en'));
|
|
63
|
+
const local = english.filter((v) => v.lang.toLowerCase().replace('_', '-') === (accent === 'british' ? 'en-gb' : 'en-us'));
|
|
64
|
+
const pick = (list) => list.find((v) => REGISTER[gender].test(v.name));
|
|
65
|
+
return (pick(local) ?? pick(english) ?? local[0] ?? english[0] ?? voices[0])?.name;
|
|
66
|
+
}
|
|
67
|
+
/** The reply being read aloud on this page. One at a time: a new one stops the last. */
|
|
68
|
+
let active = null;
|
|
46
69
|
const activeListeners = new Set();
|
|
70
|
+
const notify = () => {
|
|
71
|
+
for (const listener of activeListeners)
|
|
72
|
+
listener();
|
|
73
|
+
};
|
|
47
74
|
export function stopAgentSpeech() {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
catch { }
|
|
53
|
-
}
|
|
54
|
-
activeUtterance = null;
|
|
55
|
-
for (const notify of activeListeners) {
|
|
56
|
-
notify();
|
|
57
|
-
}
|
|
75
|
+
const lips = active;
|
|
76
|
+
active = null;
|
|
77
|
+
lips?.hush();
|
|
78
|
+
notify();
|
|
58
79
|
}
|
|
59
80
|
/**
|
|
60
|
-
* Speak text in the assigned voice of an agent.
|
|
81
|
+
* Speak text in the assigned voice of an agent, through the platform.
|
|
61
82
|
* Returns a cancel function.
|
|
83
|
+
*
|
|
84
|
+
* Call it from the click that asks for it: the player is made inside that
|
|
85
|
+
* gesture, which is what lets Safari play the reply when it arrives.
|
|
62
86
|
*/
|
|
63
87
|
export function speakAgent(text, options = {}) {
|
|
64
88
|
if (typeof window === 'undefined')
|
|
@@ -67,77 +91,38 @@ export function speakAgent(text, options = {}) {
|
|
|
67
91
|
const clean = cleanForSpeech(text);
|
|
68
92
|
if (!clean)
|
|
69
93
|
return () => { };
|
|
70
|
-
if (!('speechSynthesis' in window)) {
|
|
71
|
-
options.onError?.(new Error('SpeechSynthesis not supported in this browser'));
|
|
72
|
-
return () => { };
|
|
73
|
-
}
|
|
74
94
|
const profile = voiceProfileOf(options.agent);
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const lang = v.lang.toLowerCase();
|
|
86
|
-
if (!lang.startsWith('en'))
|
|
87
|
-
return false;
|
|
88
|
-
if (isFemale) {
|
|
89
|
-
return name.includes('female') || name.includes('samantha') || name.includes('victoria') || name.includes('karen') || name.includes('zira');
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
return name.includes('male') || name.includes('daniel') || name.includes('alex') || name.includes('fred') || name.includes('david');
|
|
93
|
-
}
|
|
94
|
-
}) || voices.find((v) => v.lang.toLowerCase().startsWith('en')) || voices[0];
|
|
95
|
-
if (candidate) {
|
|
96
|
-
utterance.voice = candidate;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
catch { }
|
|
101
|
-
utterance.onstart = () => {
|
|
102
|
-
activeUtterance = utterance;
|
|
103
|
-
options.onStart?.();
|
|
104
|
-
};
|
|
105
|
-
utterance.onend = () => {
|
|
106
|
-
if (activeUtterance === utterance) {
|
|
107
|
-
activeUtterance = null;
|
|
108
|
-
}
|
|
95
|
+
const lips = mouth({
|
|
96
|
+
speech: speech({ ...base(), ...(options.token ? { token: options.token } : {}) }),
|
|
97
|
+
voice: () => standIn(profile, window.speechSynthesis?.getVoices() ?? []),
|
|
98
|
+
refused: options.onRefusal,
|
|
99
|
+
});
|
|
100
|
+
active = lips;
|
|
101
|
+
options.onStart?.();
|
|
102
|
+
lips.say(clean, profile.voice).then(() => {
|
|
103
|
+
if (active === lips)
|
|
104
|
+
active = null;
|
|
109
105
|
options.onEnd?.();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
utterance.onerror = (e) => {
|
|
115
|
-
if (activeUtterance === utterance) {
|
|
116
|
-
activeUtterance = null;
|
|
117
|
-
}
|
|
118
|
-
options.onError?.(e);
|
|
119
|
-
for (const notify of activeListeners) {
|
|
120
|
-
notify();
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
try {
|
|
124
|
-
window.speechSynthesis.speak(utterance);
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
106
|
+
notify();
|
|
107
|
+
}, (err) => {
|
|
108
|
+
if (active === lips)
|
|
109
|
+
active = null;
|
|
127
110
|
options.onError?.(err);
|
|
128
|
-
|
|
111
|
+
notify();
|
|
112
|
+
});
|
|
129
113
|
return () => {
|
|
130
|
-
if (
|
|
114
|
+
if (active === lips)
|
|
131
115
|
stopAgentSpeech();
|
|
132
|
-
}
|
|
133
116
|
};
|
|
134
117
|
}
|
|
135
118
|
/**
|
|
136
119
|
* Hook for managing agent speech playback in UI components.
|
|
137
120
|
*/
|
|
138
121
|
export function useAgentSpeech(defaultAgent) {
|
|
122
|
+
const { token } = useIamToken();
|
|
139
123
|
const [speaking, setSpeaking] = useState(false);
|
|
140
124
|
const [currentText, setCurrentText] = useState(null);
|
|
125
|
+
const [refusal, setRefusal] = useState(null);
|
|
141
126
|
const cancelRef = useRef(null);
|
|
142
127
|
useEffect(() => {
|
|
143
128
|
const listener = () => {
|
|
@@ -160,8 +145,10 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
160
145
|
stop();
|
|
161
146
|
setCurrentText(text);
|
|
162
147
|
setSpeaking(true);
|
|
148
|
+
setRefusal(null);
|
|
163
149
|
cancelRef.current = speakAgent(text, {
|
|
164
150
|
agent: agentName || defaultAgent,
|
|
151
|
+
token,
|
|
165
152
|
onStart: () => setSpeaking(true),
|
|
166
153
|
onEnd: () => {
|
|
167
154
|
setSpeaking(false);
|
|
@@ -171,8 +158,9 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
171
158
|
setSpeaking(false);
|
|
172
159
|
setCurrentText(null);
|
|
173
160
|
},
|
|
161
|
+
onRefusal: setRefusal,
|
|
174
162
|
});
|
|
175
|
-
}, [defaultAgent, stop]);
|
|
163
|
+
}, [defaultAgent, stop, token]);
|
|
176
164
|
const toggle = useCallback((text, agentName) => {
|
|
177
165
|
if (speaking && currentText === text) {
|
|
178
166
|
stop();
|
|
@@ -181,7 +169,7 @@ export function useAgentSpeech(defaultAgent) {
|
|
|
181
169
|
speak(text, agentName);
|
|
182
170
|
}
|
|
183
171
|
}, [speaking, currentText, speak, stop]);
|
|
184
|
-
return { speaking, currentText, speak, stop, toggle };
|
|
172
|
+
return { speaking, currentText, refusal, speak, stop, toggle };
|
|
185
173
|
}
|
|
186
174
|
/** The five bars' resting heights, as a share of the 12px the wave stands in. */
|
|
187
175
|
const BARS = [0.4, 0.9, 0.6, 1, 0.5];
|
|
@@ -219,11 +207,14 @@ export function AudioWave({ active = true, color = 'var(--state-success)' }) {
|
|
|
219
207
|
* Allows one-click audio playback in the agent's voice.
|
|
220
208
|
*/
|
|
221
209
|
export function SpeakButton({ text, agentName, compact = false, }) {
|
|
222
|
-
const { speaking, toggle } = useAgentSpeech(agentName);
|
|
210
|
+
const { speaking, refusal, toggle } = useAgentSpeech(agentName);
|
|
223
211
|
const voice = voiceOf(agentName);
|
|
212
|
+
// A browser voice standing in for a refused platform sounds like success;
|
|
213
|
+
// the label is the one place that says otherwise.
|
|
214
|
+
const stood = refusal ? ` ${REFUSED[refusal.covered ? 'covered' : 'lost']}` : '';
|
|
224
215
|
// An @hanzo/ui Button: ghost at rest, the quiet pressed ground while it
|
|
225
216
|
// speaks. The word for speaking is the one status here, so it alone takes
|
|
226
217
|
// the success ink (GOOD holds 4.5:1 on either theme); the icons follow the
|
|
227
218
|
// label through currentColor.
|
|
228
|
-
return (_jsx(Button, { variant: speaking ? 'secondary' : 'ghost', size: compact ? 'icon-sm' : 'sm', color: speaking ? GOOD : '$soft', onClick: () => toggle(text, agentName), "aria-label": speaking ? 'Stop speaking' : `Listen to ${agentName || 'agent'} (${voice})
|
|
219
|
+
return (_jsx(Button, { variant: speaking ? 'secondary' : 'ghost', size: compact ? 'icon-sm' : 'sm', color: speaking ? GOOD : '$soft', onClick: () => toggle(text, agentName), "aria-label": (speaking ? 'Stop speaking' : `Listen to ${agentName || 'agent'} (${voice})`) + stood, "aria-pressed": speaking, "data-refusal": refusal ? refusal.service : undefined, title: (speaking ? 'Stop speaking' : `Listen in ${agentName || 'agent'}'s voice (${voice})`) + stood, children: speaking ? (_jsxs(_Fragment, { children: [_jsx(AudioWave, { active: true, color: GOOD }), !compact && 'Speaking…', _jsx(Square, { size: 10, fill: "currentColor" })] })) : (_jsxs(_Fragment, { children: [_jsx(Volume2, { size: 13 }), !compact && 'Listen'] })) }));
|
|
229
220
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/rooms",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "The workspace rooms — Home, Chat, Inbox, Contacts, Meet, Cal, Drive, Board, Work, Bots — and the frame that holds them, once, for every Hanzo surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@hanzo/plans": ">=1.8.9",
|
|
57
57
|
"@hanzo/ui": ">=8.27.51",
|
|
58
58
|
"@hanzo/usage": ">=0.1.17",
|
|
59
|
-
"@hanzo/voice": ">=0.1.
|
|
59
|
+
"@hanzo/voice": ">=0.1.10",
|
|
60
60
|
"@hanzogui/shell": "^8.3.20",
|
|
61
61
|
"lucide-react": ">=1.42.0",
|
|
62
62
|
"react": ">=19.2.8",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"@hanzo/appearance": "0.2.6",
|
|
68
68
|
"@hanzo/build": "0.2.34",
|
|
69
69
|
"@hanzo/composer": "0.2.0",
|
|
70
|
+
"@hanzo/design": "0.5.33",
|
|
70
71
|
"@hanzo/event": "0.3.56",
|
|
71
72
|
"@hanzo/gui": "8.3.5",
|
|
72
73
|
"@hanzo/iam": "0.21.28",
|
|
@@ -74,9 +75,10 @@
|
|
|
74
75
|
"@hanzo/plans": "1.8.9",
|
|
75
76
|
"@hanzo/ui": "8.27.51",
|
|
76
77
|
"@hanzo/usage": "0.1.17",
|
|
77
|
-
"@hanzo/voice": "0.1.
|
|
78
|
+
"@hanzo/voice": "0.1.10",
|
|
78
79
|
"@hanzogui/config": "8.3.5",
|
|
79
80
|
"@hanzogui/core": "8.3.5",
|
|
81
|
+
"@hanzogui/lucide-icons-2": "8.3.5",
|
|
80
82
|
"@hanzogui/shell": "8.3.20",
|
|
81
83
|
"@hanzogui/web": "8.3.5",
|
|
82
84
|
"@types/node": "26.5.0",
|
|
@@ -87,9 +89,7 @@
|
|
|
87
89
|
"react-dom": "19.2.8",
|
|
88
90
|
"react-native-web": "0.21.2",
|
|
89
91
|
"typescript": "7.0.2",
|
|
90
|
-
"vitest": "4.1.11"
|
|
91
|
-
"@hanzo/design": "0.5.33",
|
|
92
|
-
"@hanzogui/lucide-icons-2": "8.3.5"
|
|
92
|
+
"vitest": "4.1.11"
|
|
93
93
|
},
|
|
94
94
|
"repository": {
|
|
95
95
|
"type": "git",
|