@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/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 & Voice Synthesis
3
+ // Agent Speech
4
4
  //
5
5
  // Every agent in the Hanzo ecosystem has an assigned voice (`voiceOf(name)` from
6
- // `cast.tsx`). This module provides speech synthesis for agents across Chat,
7
- // Talk channels, and Meet video calls:
8
- // - Direct browser SpeechSynthesis with agent-tailored pitch, rate, and voice matching
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
- /** Currently active speech synthesis utterance tracker. */
45
- let activeUtterance = null;
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
- if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
49
- try {
50
- window.speechSynthesis.cancel();
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 utterance = new SpeechSynthesisUtterance(clean);
76
- utterance.pitch = profile.pitch;
77
- utterance.rate = profile.rate;
78
- // Attempt to select best matching system voice based on gender / voice tone
79
- try {
80
- const voices = window.speechSynthesis.getVoices();
81
- if (voices.length > 0) {
82
- const isFemale = profile.gender === 'female';
83
- const candidate = voices.find((v) => {
84
- const name = v.name.toLowerCase();
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
- for (const notify of activeListeners) {
111
- notify();
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 (activeUtterance === utterance) {
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})`, "aria-pressed": speaking, title: speaking ? 'Stop speaking' : `Listen in ${agentName || 'agent'}'s voice (${voice})`, 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'] })) }));
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",
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.8",
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.8",
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",